/*
 * SPDX-FileCopyrightText: 2026 Alexander Geppert
 * All rights reserved.
 *
 * Oral Defense Practice — styles.
 *
 * Extracted from index.html (which keeps only markup). Sections mirror
 * the UI: tokens, app frame, header, conversation pane, bubbles,
 * verification card, screens (selection / upload), footer.
 */

    /* ---- Tokens ---- */
    :root {
      --bg: #0f1115;
      --panel: #1a1d24;
      --border: #2a2e38;
      --text: #e6e8ee;
      --muted: #8b90a0;
      --accent: #5b9dff;
      --accent-soft: #2a4a82;
      --danger: #ff5d6c;
      --ok: #3ecf8e;
      --amber: #fac775;

      /* Verification vocabulary, named separately from the UI palette
         above because these three carry a JUDGEMENT and the others
         carry a system state.
         NOT PASS is deliberately NOT --danger. Red here is the same red
         as a failed request, a dead connection and a delete button: it
         reads as alarm, and an alarm is the wrong register for "a
         rubric was not met on this attempt". Amber says "not yet"
         without it.
         That pushes the panel-FAILURE state off amber and onto grey,
         which is more accurate than what it replaced — a panel that
         crashed is a technical fault and says nothing at all about the
         student, so it should not wear a judgement colour. */
      --verdict-pass: #3ecf8e;
      --verdict-notpass: #f0b866;
      --verdict-none: #8b90a0;

      --bubble-assistant: #1a1d24;
      --bubble-user: #2a4a82;
      --bubble-radius: var(--radius-lg);

      /* ---- Surfaces and states that used to be written inline ----
         Every colour in this file now comes from a token, so a second
         theme is a value swap rather than an audit of 1,400 lines. */
      --raised: #232732;          /* button hover, tile hover */
      --accent-hover: #4a8cf0;
      --shadow: rgba(0,0,0,0.35);

      /* TEXT ON A FILLED SURFACE, one token per surface. White on
         everything is exactly what failed: white on --accent measured
         2.72:1, under the 4.5:1 floor, on the most important button in
         the app. Dark text on that same blue is 6.94:1. The LIGHT theme
         needs the opposite value (white on its darker accent is
         5.74:1, dark text only 3.22:1), which is precisely what a token
         is for. */
      --on-accent: #0f1115;
      --on-bubble-user: #ffffff;  /* on --bubble-user: 8.75:1 */
      /* Text on a filled --accent-soft: the app bar's avatar. Its own
         token even though it currently equals --on-bubble-user, because
         the two name different surfaces and a light theme that moved
         one would silently move the other. */
      --on-accent-soft: #ffffff;  /* on --accent-soft: 8.75:1 */
      --on-viewas: #ffffff;

      /* THE VIEW-AS GOLD IS TWO TOKENS, not one, and the split is
         forced by contrast rather than taste: darkening the gold far
         enough that white text on the chip passes (5.07:1) drops the
         same colour to 3.73:1 as LABEL TEXT on the dark page. One
         value cannot do both jobs. */
      --viewas: #b58900;          /* dashed border + label: 5.89:1 on --bg */
      --viewas-fill: #8a6a00;     /* chip background: white on it, 5.07:1 */

      /* Alpha derivations, written out rather than color-mix()'d: the
         literal values keep dark rendering identical, and light gets
         alphas CHOSEN — a .07 wash that reads on #0f1115 is invisible
         on #f6f7f9. "glow" = box-shadow halo, "edge" = border,
         "wash" = fill. */
      --ok-glow: rgba(62,207,142,0.15);
      --ok-edge: rgba(62,207,142,0.5);
      --accent-glow: rgba(91,157,255,0.15);
      --accent-edge: rgba(91,157,255,0.5);
      --amber-glow: rgba(250,199,117,0.18);
      --amber-edge: rgba(250,199,117,0.5);
      --danger-wash: rgba(255,93,108,0.12);
      --danger-edge: rgba(255,93,108,0.4);
      --pass-edge: rgba(62,207,142,0.5);
      --pass-wash: rgba(62,207,142,0.08);
      --notpass-edge: rgba(240,184,102,0.45);
      --notpass-wash: rgba(240,184,102,0.07);
      --none-edge: rgba(139,144,160,0.45);
      --none-wash: rgba(139,144,160,0.07);

      /* ---- THE SCALE (I.1) ----------------------------------------
         The colour system above was built early and carefully. The
         SPACING and TYPE systems were never built at all: this file
         used fifteen distinct spacings between 2px and 24px and
         FOURTEEN font sizes between 9.5px and 20px, each chosen where
         it was written. No screenshot shows that — it is why the app
         looked amateurish in a way no single screen explained.

         Every LMS this was measured against (Moodle's Boost, Canvas's
         Instructure UI, Open edX's Paragon, Sakai's Morpheus) is built
         on a step scale. The steps below are ours; nothing is copied.

         THE RULE WHEN A VALUE DOES NOT FIT: the value moves, not the
         scale. A sixteenth spacing is how the first fifteen happened.

         Spacing: 2/4/8/12/16/24/32/48/64. The 2px half-step is real — badge
         and chip insets live there and rounding them to 4 doubles
         them. */
      --space-0: 2px;
      --space-1: 4px;
      --space-2: 8px;
      --space-3: 12px;
      --space-4: 16px;
      --space-5: 24px;
      --space-6: 32px;
      --space-7: 48px;
      --space-8: 64px;

      /* Type: 11/12/13/15/18/20.
         Three steps sit in 11-13 because this is a DENSE DATA UI and
         they do three different jobs — micro-label, meta line, body.
         Collapsing them further would cost information density on the
         results and roster screens, which is the opposite of the goal. */
      --text-2xs: 11px;      /* caps micro-labels: LISTEN FOR, TESTS   */
      --text-xs: 12px;       /* meta lines, captions, counts           */
      --text-sm: 13px;       /* body: the default for almost everything */
      --text-md: 15px;       /* emphasis inside a card                 */
      --text-lg: 18px;       /* screen and card headings               */
      --text-xl: 20px;       /* the one page title                     */

      /* The cap on a two-column list of NetIDs, and on the toolbar that
         controls it. ONE NUMBER: written twice, the toolbar drifts off
         the list it sits above and nothing but a screenshot says so. */
      --list-narrow: 440px;
      /* The Users screen's rows carry grant controls as well as a name,
         so they get their own width — and their toolbar reads the same
         token, for the reason above. */
      --list-wide: 560px;

      /* M.3: the width of an editing FORM, which is a different job
         from a list and wants a different number.
         440px was inherited from the lists and was never chosen for
         this: it forces every field — a course code, a term, a session
         length in minutes — into its own full-width row, so the two
         forms ran well past a screen height while two thirds of each
         line sat empty. At 680 a pair of short fields fits side by side
         with room to read both labels, and a description textarea is
         still inside a comfortable measure rather than sprawling.
         ONE NUMBER, for --list-narrow's stated reason: the Settings
         screen IS the course form, and its page header has to line up
         with it. Written twice, they drift and only a screenshot
         says so — which is exactly the bug check_course_rail
         measures for. */
      --form-wide: 680px;

      /* Radius: four steps and a pill, replacing nine ad-hoc values. */
      --radius-xs: 4px;
      --radius-sm: 6px;
      --radius-md: 8px;
      --radius-lg: 12px;
      --radius-pill: 999px;

      /* Native <select>, file and number inputs, and scrollbars follow
         the theme. Without this they render LIGHT on the dark page —
         visible today on the view-as select — and a light theme would
         get the inverse. */
      color-scheme: light dark;
    }

    /* ---- The light theme, and how a choice overrides the system ----
       THREE STATES, TWO PALETTES, ONE OVERRIDE. Dark is :root above.
       Light is the block below. The app bar's control writes
       data-theme="light" or "dark" on <html>, or removes it for
       "system".
       The media query is deliberately narrowed to
       :root:not([data-theme="dark"]) rather than bare :root. That one
       clause is what makes an explicit DARK choice work on a machine
       set to light: choosing dark switches this block off and reveals
       :root, so dark never needs a block of its own. Only LIGHT is
       written twice — once for the system, once for the choice — and
       test_the_two_light_palettes_are_the_same_palette asserts the two
       are token-for-token identical, because a light theme that drifts
       depending on how you asked for it is the exact bug a token
       system exists to prevent.
       Only tokens live here. If a rule below ever needs a colour this
       block cannot reach, the rule is wrong, not the block. */
    @media (prefers-color-scheme: light) {
      :root:not([data-theme="dark"]) {
        --bg: #f6f7f9;
        --panel: #ffffff;
        --border: #dfe3ea;
        --text: #10131a;
        --muted: #5c6474;
        --accent: #2563c4;
        --accent-soft: #dce8fb;
        --danger: #b3243a;
        --ok: #12855a;
        --amber: #8a5a12;

        /* The judgement vocabulary keeps its reasoning and its hues;
           only the lightness moves, because #3ecf8e on white is 1.9:1.
           NOT PASS stays amber, never red (see above). */
        --verdict-pass: #12855a;
        --verdict-notpass: #8a5a12;
        --verdict-none: #5c6474;

        --bubble-assistant: #ffffff;
        --bubble-user: #dce8fb;
        --bubble-radius: var(--radius-lg);

        --raised: #eef1f6;
        --accent-hover: #1d4f9e;
        --shadow: rgba(16,19,26,0.14);

        --on-accent: #ffffff;       /* on --accent: 5.74:1 */
        --on-bubble-user: #12386f;  /* white would vanish on the pale fill */
        --on-accent-soft: #12386f;  /* same reason, on the avatar */
        --on-viewas: #ffffff;

        --viewas: #7a5c00;          /* 5.83:1 on --bg */
        --viewas-fill: #7a5c00;     /* white on it, 6.25:1 */

        /* Heavier alphas: a wash tuned for a near-black ground does not
           register on a near-white one. */
        --ok-glow: rgba(18,133,90,0.20);
        --ok-edge: rgba(18,133,90,0.55);
        --accent-glow: rgba(37,99,196,0.20);
        --accent-edge: rgba(37,99,196,0.55);
        --amber-glow: rgba(138,90,18,0.20);
        --amber-edge: rgba(138,90,18,0.55);
        --danger-wash: rgba(179,36,58,0.09);
        --danger-edge: rgba(179,36,58,0.40);
        --pass-edge: rgba(18,133,90,0.45);
        --pass-wash: rgba(18,133,90,0.08);
        --notpass-edge: rgba(138,90,18,0.45);
        --notpass-wash: rgba(138,90,18,0.08);
        --none-edge: rgba(92,100,116,0.45);
        --none-wash: rgba(92,100,116,0.07);
      }
    }

    /* The same palette, chosen rather than inherited. */
    :root[data-theme="light"] {
      --bg: #f6f7f9;
      --panel: #ffffff;
      --border: #dfe3ea;
      --text: #10131a;
      --muted: #5c6474;
      --accent: #2563c4;
      --accent-soft: #dce8fb;
      --danger: #b3243a;
      --ok: #12855a;
      --amber: #8a5a12;

      /* The judgement vocabulary keeps its reasoning and its hues;
         only the lightness moves, because #3ecf8e on white is 1.9:1.
         NOT PASS stays amber, never red (see above). */
      --verdict-pass: #12855a;
      --verdict-notpass: #8a5a12;
      --verdict-none: #5c6474;

      --bubble-assistant: #ffffff;
      --bubble-user: #dce8fb;
      --bubble-radius: var(--radius-lg);

      --raised: #eef1f6;
      --accent-hover: #1d4f9e;
      --shadow: rgba(16,19,26,0.14);

      --on-accent: #ffffff;       /* on --accent: 5.74:1 */
      --on-bubble-user: #12386f;  /* white would vanish on the pale fill */
      --on-accent-soft: #12386f;  /* same reason, on the avatar */
      --on-viewas: #ffffff;

      --viewas: #7a5c00;          /* 5.83:1 on --bg */
      --viewas-fill: #7a5c00;     /* white on it, 6.25:1 */

      /* Heavier alphas: a wash tuned for a near-black ground does not
         register on a near-white one. */
      --ok-glow: rgba(18,133,90,0.20);
      --ok-edge: rgba(18,133,90,0.55);
      --accent-glow: rgba(37,99,196,0.20);
      --accent-edge: rgba(37,99,196,0.55);
      --amber-glow: rgba(138,90,18,0.20);
      --amber-edge: rgba(138,90,18,0.55);
      --danger-wash: rgba(179,36,58,0.09);
      --danger-edge: rgba(179,36,58,0.40);
      --pass-edge: rgba(18,133,90,0.45);
      --pass-wash: rgba(18,133,90,0.08);
      --notpass-edge: rgba(138,90,18,0.45);
      --notpass-wash: rgba(138,90,18,0.08);
      --none-edge: rgba(92,100,116,0.45);
      --none-wash: rgba(92,100,116,0.07);
    }
    * { box-sizing: border-box; }
    html, body { height: 100%; }
    body {
      margin: 0;
      font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
      background: var(--bg);
      color: var(--text);
      display: flex;
      flex-direction: column;
      align-items: stretch;
      height: 100vh;
      /* dvh where supported: on a phone the collapsing address bar
         otherwise leaves the last row under it (the same reason
         .menu-screen already carried both). */
      height: 100dvh;
      overflow: hidden;
    }

    /* ---- The app bar ----
       Persistent across every screen, which is the whole reason it
       exists. Everything in it answers a question the screens could
       not: what application is this, where am I in it, who am I signed
       in as, and how do I leave. */
    .app-bar {
      flex: 0 0 auto;
      display: flex;
      align-items: center;
      gap: var(--space-3);
      height: 52px;
      padding: 0 max(var(--space-4), env(safe-area-inset-left))
               0 max(var(--space-4), env(safe-area-inset-right));
      border-bottom: 1px solid var(--border);
      background: var(--panel);
    }
    .app-bar-spacer { flex: 1 1 auto; min-width: 0; }

    .brand {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      flex: 0 0 auto;
      font: inherit;
      font-size: var(--text-sm);
      font-weight: 650;
      color: var(--text);
      background: none;
      border: none;
      padding: var(--space-1) var(--space-0);
      cursor: pointer;
      border-radius: var(--radius-md);
    }
    .brand-mark {
      width: 22px;
      height: 22px;
      flex: 0 0 22px;
      border-radius: var(--radius-sm);
      display: grid;
      place-items: center;
      background: var(--accent);
      color: var(--on-accent);
    }
    .brand:hover .brand-name { color: var(--accent); }
    .brand:focus-visible,
    .theme-option:focus-visible,
    .profile-btn:focus-visible,
    .crumb-link:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }

    /* ---- Breadcrumb ----
       These crumbs ARE the back navigation: five screens each carried
       their own back button, each hardcoding its own parent. */
    .crumbs {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      min-width: 0;
      font-size: var(--text-sm);
      color: var(--muted);
      overflow: hidden;
    }
    .crumb-sep { flex: 0 0 auto; opacity: 0.5; }
    .crumb-link {
      font: inherit;
      color: var(--muted);
      background: none;
      border: none;
      padding: var(--space-0) var(--space-1);
      margin: 0;
      cursor: pointer;
      border-radius: var(--radius-sm);
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .crumb-link:hover { color: var(--text); }
    /* The last crumb is where you already are, so it is text, not a
       control that would do nothing. */
    .crumb-current {
      color: var(--text);
      font-weight: 550;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      min-width: 0;
    }

    /* ---- Theme control ---- */
    .theme-control {
      display: flex;
      flex: 0 0 auto;
      gap: var(--space-0);
      padding: var(--space-0);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--bg);
    }
    .theme-option {
      display: grid;
      place-items: center;
      width: 26px;
      height: 24px;
      padding: 0;
      border: none;
      border-radius: var(--radius-sm);
      background: none;
      color: var(--muted);
      cursor: pointer;
    }
    .theme-option:hover { color: var(--text); }
    .theme-option[aria-checked="true"] {
      background: var(--raised);
      color: var(--accent);
    }
    /* Visible to a screen reader, not to the eye: the buttons carry
       icons, and an icon with no name is a button with no name. */
    .visually-hidden {
      position: absolute;
      width: 1px;
      height: 1px;
      margin: -1px;
      padding: 0;
      overflow: hidden;
      clip: rect(0 0 0 0);
      clip-path: inset(50%);
      white-space: nowrap;
      border: 0;
    }

    /* ---- The main landmark, and the link that reaches it (I.2) ---- */
    /* A TRANSPARENT WRAPPER. The screens were flex children of <body>
       (`flex: 1 1 auto; min-height: 0`), and body is a 100dvh flex
       column with overflow hidden. Wrapping them in a landmark without
       passing that through would have collapsed every scrolling region
       in the app — the conversation pane, the results list, the roster
       — into a zero-height box. It adds a name for assistive
       technology and nothing else. */
    #main {
      flex: 1 1 auto;
      min-height: 0;
      display: flex;
      flex-direction: column;
      align-items: stretch;
    }
    /* Focused programmatically by the skip link and by showScreen. The
       ring belongs on the heading it moves to, not on a full-page box,
       so the landmark itself shows nothing. */
    #main:focus { outline: none; }

    /* ---- I.3: the course shell ---- */

    /* THE ROW. #main stays a column so the demonstration banner keeps
       the full width above; everything below it is rail-beside-content. */
    #shell {
      flex: 1 1 auto;
      min-height: 0;
      display: flex;
      flex-direction: row;
      align-items: stretch;
    }

    /* min-width: 0 IS INSURANCE, AND THE HONEST NOTE IS THAT NOTHING
       IN THE APP EXERCISES IT TODAY. A flex item's default min-width is
       `auto` — "never shrink below my content" — so a child that cannot
       shrink would push this column out and carry the rail off the left
       edge of the viewport: present in the DOM, correct in every
       assertion, unreachable. Measured at 1440px and 1000px on the
       results, question-sheet and assignment screens with the line
       REMOVED: the rail does not move. Every content column in this app
       wraps.

       It stays because I.4 adds .data-table, and a table is the classic
       thing that will not shrink. It is written down as insurance
       rather than as a load-bearing line because a claim no probe can
       falsify is a claim this file should not be making (9.55, 9.58).

       min-height: 0 is the same rule one axis over and DOES bite today:
       without it the screens inside stop scrolling and grow the page. */
    #shellBody {
      flex: 1 1 auto;
      min-width: 0;
      min-height: 0;
      display: flex;
      flex-direction: column;
      align-items: stretch;
    }

    /* The rail itself. flex: 0 0 — it is a fixed column, not a share
       of the space: a rail that grew with the window would put its
       six short labels in the middle of a wide empty box. */
    #courseRail {
      flex: 0 0 200px;
      min-height: 0;
      overflow-y: auto;
      display: flex;
      flex-direction: column;
      gap: var(--space-0);
      padding: var(--space-5) var(--space-3);
      border-right: 1px solid var(--border);
      background: var(--bg);
    }
    /* The course, named once at the top of its own nav. Not a link:
       "Assignments" directly below IS the course's front page, and two
       controls doing one thing is the confusion the rail removes. */
    .rail-course {
      padding: 0 var(--space-2) var(--space-4);
    }
    .rail-course-code {
      display: block;
      font-size: var(--text-md);
      font-weight: 600;
      color: var(--text);
    }
    .rail-course-meta {
      display: block;
      font-size: var(--text-xs);
      color: var(--muted);
      margin-top: var(--space-0);
    }
    .rail-link {
      display: block;
      width: 100%;
      text-align: left;
      font: inherit;
      font-size: var(--text-sm);
      color: var(--muted);
      background: none;
      border: none;
      border-radius: var(--radius-sm);
      padding: var(--space-2) var(--space-2);
      cursor: pointer;
    }
    .rail-link:hover { background: var(--raised); color: var(--text); }
    .rail-link:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: -2px;
    }
    /* WHERE YOU ARE, said with more than colour: the weight and the
       bar carry it for anyone who cannot separate the two greys, and
       aria-current says it to a screen reader. */
    .rail-link[aria-current="page"] {
      color: var(--text);
      font-weight: 600;
      background: var(--raised);
      box-shadow: inset 2px 0 0 var(--accent);
    }

    /* The scope control at the top of each tool page: which assignment
       this screen is about. Hidden entirely when nothing qualifies —
       an empty picker states no fact. */
    .assignment-picker {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      margin: 0 0 var(--space-5);
    }
    .assignment-picker label {
      font-size: var(--text-sm);
      color: var(--muted);
    }
    .assignment-picker select {
      font: inherit;
      font-size: var(--text-sm);
      color: var(--text);
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
      padding: var(--space-2) var(--space-3);
      max-width: 100%;
    }
    /* One assignment is not a choice, and the control says so by
       looking like a statement rather than an invitation. The text
       stays full-strength: it is still the answer to "which one". */
    .assignment-picker select:disabled {
      opacity: 1;
      color: var(--text);
      background: none;
      border-color: transparent;
      padding-left: 0;
    }

    /* BELOW 960px THE RAIL GOES ON TOP, and 960 is where the arithmetic
       says it must: .menu-content is 760px of readable column and the
       rail is 200px, so anything narrower starts eating the text the
       course is actually about. The app's other breakpoint is 560px
       (the phone rules) and is the wrong one here — it would leave the
       content squeezed for 400px of widths. */
    @media (max-width: 960px) {
      #shell { flex-direction: column; }
      #courseRail {
        flex: 0 0 auto;
        flex-direction: row;
        align-items: center;
        gap: var(--space-1);
        overflow-x: auto;
        overflow-y: hidden;
        padding: var(--space-2) var(--space-3);
        border-right: none;
        border-bottom: 1px solid var(--border);
      }
      /* The course name is already in the breadcrumb, and a horizontal
         rail has no room to repeat it. */
      .rail-course { display: none; }
      .rail-link {
        width: auto;
        flex: 0 0 auto;
        white-space: nowrap;
      }
      .rail-link[aria-current="page"] {
        box-shadow: inset 0 -2px 0 var(--accent);
      }
    }

    /* OFF-SCREEN UNTIL FOCUSED, not display:none — a hidden element is
       not focusable, so `display: none` would make the skip link
       unreachable by the only input method it exists for. */
    .skip-link {
      position: absolute;
      left: var(--space-2);
      top: calc(-1 * var(--space-8));
      z-index: 100;
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-md);
      background: var(--accent);
      color: var(--on-accent);
      font-size: var(--text-sm);
      font-weight: 600;
      text-decoration: none;
      transition: top 0.15s;
    }
    .skip-link:focus {
      top: var(--space-2);
      outline: 2px solid var(--text);
      outline-offset: 2px;
    }

    /* The heading a screen change moves focus to — the same element
       focusScreen aims at, selected the same way, so a screen cannot
       gain focus without gaining the ring that says so. The ring is
       what a sighted keyboard user has instead of the announcement;
       :focus-visible so a mouse click on a tile does not draw one on
       the next screen's title. */
    [data-screen-title]:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: var(--space-1);
      border-radius: var(--radius-xs);
    }

    /* ---- Profile menu ---- */
    .profile { position: relative; flex: 0 0 auto; }
    .profile-btn {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      font: inherit;
      font-size: var(--text-sm);
      color: var(--muted);
      background: none;
      border: 1px solid transparent;
      border-radius: var(--radius-pill);
      padding: var(--space-0) var(--space-3) var(--space-0) var(--space-0);
      cursor: pointer;
      max-width: 190px;
    }
    .profile-btn:hover { border-color: var(--border); color: var(--text); }
    .profile-btn[aria-expanded="true"] {
      border-color: var(--border);
      background: var(--raised);
      color: var(--text);
    }
    .avatar {
      width: 26px;
      height: 26px;
      flex: 0 0 26px;
      border-radius: 50%;
      display: grid;
      place-items: center;
      background: var(--accent-soft);
      color: var(--on-accent-soft);
      font-size: var(--text-2xs);
      font-weight: 650;
      letter-spacing: 0.02em;
    }
    .profile-name {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .profile-menu {
      position: absolute;
      top: calc(100% + 7px);
      right: 0;
      z-index: 20;
      min-width: 208px;
      padding: var(--space-2);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      background: var(--panel);
      box-shadow: 0 10px 28px var(--shadow);
      text-align: left;
    }
    .profile-menu-head {
      padding: var(--space-2) var(--space-3) var(--space-3);
    }
    /* The divider hangs on the ITEM, not on the head. Under a provider
       with no session to end there is no sign-out and therefore no
       item, and a head-owned border drew a line under an empty box. */
    .profile-menu-head + .profile-menu-item {
      border-top: 1px solid var(--border);
      margin-top: var(--space-2);
      padding-top: var(--space-2);
    }
    .profile-menu-name {
      font-size: var(--text-sm);
      font-weight: 600;
      color: var(--text);
      word-break: break-word;
    }
    .profile-menu-role {
      font-size: var(--text-2xs);
      color: var(--muted);
      margin-top: var(--space-0);
    }
    .profile-menu-item {
      display: block;
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-sm);
      font-size: var(--text-sm);
      color: var(--text);
      text-decoration: none;
      cursor: pointer;
    }
    .profile-menu-item:hover { background: var(--raised); }

    /* ---- Native controls, in the app's palette ----
       batch 2's color-scheme made these follow the THEME; it could not
       make them match the PALETTE. Measured off the shipped dark
       screens, the select rendered #3b3b3b and the file-input button
       #6b6b6b, against a --panel of #1a1d24 — correct for the theme,
       and visibly not part of this application. Appearance:none hands
       the box back to the stylesheet; the arrow is drawn from a token
       so it follows both themes. */
    select {
      appearance: none;
      -webkit-appearance: none;
      font: inherit;
      font-size: var(--text-sm);
      color: var(--text);
      background-color: var(--raised);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      padding: var(--space-2) var(--space-5) var(--space-2) var(--space-3);
      cursor: pointer;
      /* The disclosure arrow, inline so it needs no asset and no
         second colour source. */
      background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                        linear-gradient(135deg, currentColor 50%, transparent 50%);
      background-position: calc(100% - 15px) calc(50% + 1px),
                           calc(100% - 10px) calc(50% + 1px);
      background-size: 5px 5px, 5px 5px;
      background-repeat: no-repeat;
    }
    select:hover { border-color: var(--accent-edge); }
    select:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
    }
    /* The dropdown list itself is drawn by the OS; give its rows the
       panel colour so an open menu is not a white rectangle. */
    select option {
      background: var(--panel);
      color: var(--text);
    }

    /* File inputs: the BUTTON is the part the browser draws, and it is
       the part that looked foreign. */
    input[type="file"] {
      font: inherit;
      font-size: var(--text-sm);
      color: var(--muted);
      max-width: 100%;
    }
    input[type="file"]::file-selector-button {
      font: inherit;
      font-size: var(--text-sm);
      color: var(--text);
      background: var(--raised);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      padding: var(--space-2) var(--space-3);
      margin-right: var(--space-3);
      cursor: pointer;
    }
    input[type="file"]::file-selector-button:hover {
      border-color: var(--accent-edge);
    }

    /* ---- App frame: fixed-height column that owns all scrolling internally ---- */
    .app {
      width: 100%;
      max-width: 760px;
      margin: 0 auto;
      /* Was height:100vh, from when this screen WAS the page. The app
         bar is a sibling above it now, so a hard viewport height would
         push the footer off by exactly the bar's height. */
      flex: 1 1 auto;
      min-height: 0;
      display: flex;
      flex-direction: column;
      padding: var(--space-5) var(--space-5) 0;
    }

    /* ---- Header (title + controls + status) ---- */
    .header { flex: 0 0 auto; padding-bottom: var(--space-3); border-bottom: 1px solid var(--border); }
    /* GLOBAL, despite the neighbourhood: the session header has no
       <h1> at all (its title is a div with role="heading"), so this
       rule's only subjects are the seven .page-header headings. It
       lives here for history, not for scope. */
    h1 { font-size: var(--text-lg); font-weight: 600; margin: 0 0 var(--space-0); }
    .subtitle { color: var(--muted); font-size: var(--text-xs); margin-bottom: var(--space-3); }
    .controls {
      display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap;
    }
    .controls-spacer { flex: 1 1 auto; }

    /* D.4's readout. MINUTES and a bar, never seconds: a ticking
       stopwatch in the corner of an exam is pressure, a minute figure
       is orientation. The bar carries the continuous part, so nothing
       has to count down for progress to be legible. */
    .session-clock {
      display: flex; align-items: center; gap: var(--space-2);
      font-size: var(--text-xs); color: var(--muted); white-space: nowrap;
    }
    .session-clock[hidden] { display: none; }
    .clock-bar {
      width: 84px; height: 4px; border-radius: var(--radius-xs);
      background: var(--raised); overflow: hidden;
    }
    .clock-bar i {
      display: block; height: 100%; width: 0;
      background: var(--accent); opacity: .8;
      transition: width 1s linear;
    }
    /* The end-of-defense action reads as consequential without reading
       as an error: --danger is the palette's alarm colour and an
       ordinary ending is not an alarm (the D.7 reasoning about registers
       applies to controls too). */
    button.primary.danger-action {
      background: var(--danger-wash); border-color: var(--danger-edge);
      color: var(--danger);
    }
    button.primary.danger-action:hover:not(:disabled) {
      background: var(--danger-edge);
    }
    button {
      font-size: var(--text-sm);
      font-weight: 500;
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-md);
      border: 1px solid var(--border);
      background: var(--panel);
      color: var(--text);
      cursor: pointer;
      transition: background 0.15s, opacity 0.15s;
    }
    button:hover:not(:disabled) { background: var(--raised); }
    button:disabled { opacity: 0.4; cursor: not-allowed; }
    button.primary { background: var(--accent); border-color: var(--accent);
                     color: var(--on-accent); }
    button.primary:hover:not(:disabled) { background: var(--accent-hover); }

    .status {
      display: flex; align-items: center; gap: var(--space-2);
      font-size: var(--text-xs); color: var(--muted);
      margin-left: auto;
    }
    .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
    .dot.listening   { background: var(--ok);    box-shadow: 0 0 0 4px var(--ok-glow); }
    .dot.connecting  { background: var(--accent); box-shadow: 0 0 0 4px var(--accent-glow); }
    .dot.thinking    { background: var(--accent); box-shadow: 0 0 0 4px var(--accent-glow); }
    .dot.waiting     { background: var(--amber); box-shadow: 0 0 0 4px var(--amber-glow); }
    .dot.error       { background: var(--danger); }

    /* ---- Conversation pane: scrolling area ----
       The container itself scrolls. Generous bottom padding keeps the newest
       bubble visually positioned in the lower-middle of the screen rather
       than glued to the bottom (the classic iMessage / WhatsApp feel). */
    .conversation-wrap {
      flex: 1 1 auto;
      position: relative;
      overflow: hidden;
    }
    .conversation {
      height: 100%;
      overflow-y: auto;
      padding: var(--space-5) var(--space-1) 30vh;     /* 30vh bottom space, per design */
      scroll-behavior: smooth;
    }
    .conversation::-webkit-scrollbar { width: 10px; }
    .conversation::-webkit-scrollbar-track { background: transparent; }
    .conversation::-webkit-scrollbar-thumb {
      background: var(--border); border-radius: var(--radius-md);
    }

    /* ---- Bubbles ---- */
    .turn { margin-bottom: var(--space-3); max-width: 78%; }
    .turn.assistant { margin-right: auto; }
    .turn.user      { margin-left: auto; }
    .turn.draft     { margin-left: auto; } /* mirrors user side */

    .bubble {
      padding: var(--space-3) var(--space-4);
      border-radius: var(--bubble-radius);
      font-size: var(--text-md);
      line-height: 1.5;
      white-space: pre-wrap;
      word-break: break-word;
    }
    .turn.assistant .bubble {
      background: var(--bubble-assistant);
      border: 1px solid var(--border);
      border-bottom-left-radius: 4px;  /* tail */
    }
    .turn.user .bubble {
      background: var(--bubble-user);
      color: var(--on-bubble-user);
      border-bottom-right-radius: 4px; /* tail */
    }
    .turn.draft .bubble {
      background: transparent;
      border: 1px dashed var(--border);
      color: var(--muted);
      font-style: italic;
      border-bottom-right-radius: 4px;
    }

    /* Caption under assistant bubbles for the model + timing. */
    .caption {
      font-size: var(--text-2xs);
      color: var(--muted);
      margin: var(--space-1) var(--space-1) 0;
      font-variant-numeric: tabular-nums;
    }
    .caption .model-name { color: var(--accent); }
    .caption .timing { color: var(--muted); }
    .caption .intervention-tag {
      display: inline-block;
      margin-left: var(--space-2);
      padding: 1px var(--space-2);
      border: 1px solid var(--amber);
      border-radius: var(--radius-xs);
      color: var(--amber);
      font-size: var(--text-2xs);
      letter-spacing: 0.04em;
      text-transform: uppercase;
    }

    /* Blinking cursor while streaming. */
    .cursor {
      display: inline-block;
      width: 7px;
      height: 15px;
      background: var(--accent);
      margin-left: var(--space-0);
      vertical-align: text-bottom;
      animation: blink 1s steps(2) infinite;
    }
    @keyframes blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }

    /* Interrupted reply: ellipsis is appended in the text itself
       (see markAssistantInterrupted); the visible cue is the
       "interrupted" tag in the caption. No edge fade. */
    .interrupted-tag {
      display: inline-block;
      margin-left: var(--space-2);
      color: var(--muted);
      font-size: var(--text-2xs);
      letter-spacing: 0.04em;
      text-transform: uppercase;
    }

    /* Soft bubble entry: each .turn fades + slides into place over a
       short window. New chunks appended to an already-rendered bubble
       don't re-animate (the element keeps its post-animation state). */
    @keyframes turn-in {
      from { opacity: 0; transform: translateY(6px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    .turn {
      animation: turn-in 220ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
    }
    /* Respect users who prefer reduced motion. */
    @media (prefers-reduced-motion: reduce) {
      .turn { animation: none; }
      .conversation { scroll-behavior: auto; }
    }

    /* "Jump to latest" pill, shown when the user has scrolled up. */
    .jump-latest {
      position: absolute;
      right: 14px;
      bottom: 18px;
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-pill);
      background: var(--accent);
      color: var(--on-accent);
      font-size: var(--text-xs);
      font-weight: 500;
      border: none;
      cursor: pointer;
      opacity: 0;
      pointer-events: none;
      transform: translateY(8px);
      transition: opacity 0.18s, transform 0.18s;
      box-shadow: 0 4px 14px var(--shadow);
    }
    .jump-latest.visible {
      opacity: 1;
      pointer-events: auto;
      transform: translateY(0);
    }

    /* ---- Footer (transcript file path, errors) ---- */
    .footer {
      flex: 0 0 auto;
      padding: var(--space-2) var(--space-1) var(--space-3);
      border-top: 1px solid var(--border);
    }
    /* Live input level, drawn from the PCM already being sent to the
       STT rather than a second analyser: what moves here is exactly
       what the recogniser receives. Decorative only — aria-hidden, and
       the status text carries the same state for a screen reader. */
    .mic-level {
      display: flex; align-items: flex-end; gap: var(--space-0);
      height: 18px; margin-bottom: var(--space-2);
    }
    .mic-level[hidden] { display: none; }
    .mic-level i {
      width: 3px; height: 2px; border-radius: 2px;
      background: var(--ok); opacity: .85;
      transition: height .09s linear;
    }
    .transcript-path {
      font-size: var(--text-2xs);
      color: var(--muted);
      word-break: break-all;
    }
    .transcript-path code {
      color: var(--text);
      background: var(--panel);
      padding: var(--space-0) var(--space-2);
      border-radius: var(--radius-xs);
    }
    .error-banner {
      display: none;
      margin-top: var(--space-2);
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-md);
      background: var(--danger-wash);
      border: 1px solid var(--danger-edge);
      color: var(--danger);
      font-size: var(--text-xs);
    }

    /* Empty-state when no turns yet. */
    .empty-state {
      color: var(--muted);
      font-size: var(--text-md);
      text-align: center;
      padding: var(--space-8) var(--space-5);
    }

    /* ---- Screen switching ----
       Three sibling screens: assignment selection, submission upload,
       and the session itself. Exactly one is visible at a time. */
    [hidden] { display: none !important; }

    /* Full viewport width, so the header's left edge is the SCREEN's left
       edge. The old 760px cap applied to the whole screen, which pushed
       the back control toward the middle on any laptop display. The cap
       now lives on .menu-content, where it belongs: long lines of text
       are hard to read, but a navigation bar is not text. */
    .menu-screen {
      width: 100%;
      max-width: none;
      /* Was min-height:100vh, for the same reason .app was: each screen
         used to be the whole page. They share the frame with the app
         bar now, and min-height:0 is what lets overflow-y actually
         scroll inside a flex child rather than growing it. */
      flex: 1 1 auto;
      min-height: 0;
      overflow-y: auto;
      display: flex;
      flex-direction: column;
      align-items: stretch;
      padding: 0;
      /* Was centred. The app bar anchors its brand and crumbs to the
         left edge, and a centred column under it reads as two
         different applications stacked. */
      text-align: left;
    }

    /* The readable column. Centred, and vertically centred in whatever
       space the header leaves. */
    .menu-content {
      flex: 1 1 auto;
      width: 100%;
      max-width: 760px;
      margin: 0 auto;
      padding: var(--space-5) var(--space-5) var(--space-7);
      display: flex;
      flex-direction: column;
      align-items: stretch;
      /* NOT centred. Vertical centring makes the content's position a
         function of how much content there is: one course sat in the
         middle of an empty page, a full roster started at the top, and
         the heading moved between two screens of the same app. The
         phone block has always overridden this to flex-start — that
         override was the bug report. */
      justify-content: flex-start;
      gap: 0;
      box-sizing: border-box;
    }
    .menu-screen h1 { font-size: var(--text-xl); font-weight: 600; margin: 0 0 var(--space-1); }
    .menu-subtitle { color: var(--muted); font-size: var(--text-sm); margin: 0 0 var(--space-5); }

    /* ---- Assignment tiles ---- */
    .assignment-grid {
      display: flex;
      flex-wrap: wrap;
      gap: var(--space-3);
      justify-content: flex-start;
    }
    /* SIZED FOR THE ACTION BAR, not just for the content.
       The bar is absolutely positioned in the top-right corner, so it
       does not participate in layout and the tile has to make room for
       it deliberately. Two ways it failed to:

       HORIZONTALLY. At 132px with three 26px buttons the bar started
       40px from the left edge — two thirds of the tile — and on a
       320px-wide phone, where the buttons grow to 32px for touch, it
       covered 77% of the tile. The rule now is that a tile is at least
       twice the width of its action bar, so at most half of it is ever
       covered. test_a_tile_is_wide_enough_for_its_action_bar computes
       both sides from this stylesheet rather than trusting this
       comment, and it budgets for THREE buttons even though two are
       drawn today, so adding a third does not silently re-break it.

       VERTICALLY. The bar occupies the top 32px (6px offset + 26px
       button) while the content is centred over the whole height, so
       the icon sat underneath it. The top padding reserves that strip,
       which fixes it independently of width and of button count. */
    .assignment-tile {
      /* WIDER AND LEFT-ALIGNED, because the tile now says something.
         190px of centred text was right for a glyph and a title; a
         description, a length and an attempt line read as a paragraph
         with ragged both edges at that width.
         STILL SQUARE, because that was asked for directly: the minimum
         grows with the width rather than being abandoned. Content sits
         from the top and the attempt line is pushed to the bottom by
         margin-top: auto, so the square reads as deliberate rather than
         as a tile that ran out of things to say. */
      width: 236px;
      min-height: 236px;
      display: flex;
      flex-direction: column;
      align-items: stretch;
      justify-content: flex-start;
      text-align: left;
      gap: var(--space-1);
      padding: var(--space-6) var(--space-3) var(--space-3);
      border-radius: var(--radius-lg);
      border: 1px solid var(--border);
      background: var(--panel);
      color: var(--text);
      cursor: pointer;
      transition: background 0.15s, border-color 0.15s, transform 0.15s;
    }
    /* *** A TILE'S CONTENT IS NEVER COMPRESSED; THE TILE GROWS. *** (I.4)
       The tile is a flex column, and a flex item's default is
       `flex: 0 1 auto` — shrinkable. On a phone the tile's height comes
       from aspect-ratio rather than from content, so the children were
       squeezed into whatever was left: measured at 390px, .tile-meta
       asked for 15px and got 9.1, .tile-blurb asked for 96 and got
       28.2, each with overflow: hidden, so both were cut THROUGH THE
       MIDDLE OF A LINE of text.

       That is the defect E.66 recorded as "the tiles clip their own
       titles", and the title's line clamp was hiding half of it. It is
       also why the phone block's claim that content can "push a tile
       taller than square" was not true: a box cannot be pushed taller
       by children that shrink instead.

       WHICH FIX IS DOING THE WORK, said plainly: hiding the phone
       tile's description (see the phone block) is what makes today's
       tile fit, and on its own it would be enough — with the
       description gone the remaining content is ~139px inside a 177px
       square, so there is no squeeze left to prevent. THIS rule is
       insurance against the CLASS: a box that cannot grow must not cut
       text through the middle of a line, whatever the next line added
       to a tile turns out to be. It is written down as insurance
       because a browser probe of it passes either way (9.55 — the
       probe missed, the mutation was fine, and the claim needed
       narrowing rather than the guard). */
    .assignment-tile > *, .course-tile > * { flex-shrink: 0; }

    .assignment-tile:hover {
      background: var(--raised);
      border-color: var(--accent);
      transform: translateY(-2px);
    }
    .assignment-tile:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }
    .tile-icon { color: var(--accent); display: flex; }
    /* *** THE TITLE IS NEVER TRUNCATED; THE DESCRIPTION IS. *** (I.4)
       This clamped to two lines so a long title could not stretch the
       tile. On a phone the assignment grid is two ~180px columns, and
       two lines of 180px is not enough for a real assignment name:
       "HW6 — Neural network from scratch" rendered as
       "HW6 — Neural network from scr…" inside a tile that is
       min-height: 236px and half empty below it. A tile whose title
       cannot be read is not identifying anything, which is the tile's
       only job; an uneven row of tiles is a much smaller price, and
       the square's own slack absorbs most of it.
       .tile-blurb keeps ITS clamp — the description is the part that
       can be cut without losing which assignment this is. */
    .tile-label {
      font-size: var(--text-xs);
      font-weight: 500;
      line-height: 1.3;
    }
    @media (prefers-reduced-motion: reduce) {
      .assignment-tile:hover { transform: none; }
    }

    /* ---- End-of-defense note + verification card ---- */
    .system-note {
      text-align: center;
      color: var(--muted);
      font-size: var(--text-xs);
      margin: var(--space-4) 0;
    }
    .verification-card {
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      padding: var(--space-4) var(--space-4);
      margin: var(--space-2) 0 var(--space-4);
      animation: turn-in 220ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
    }
    @media (prefers-reduced-motion: reduce) {
      .verification-card { animation: none; }
    }
    .verification-card h3 {
      margin: 0 0 var(--space-3);
      font-size: var(--text-xs);
      font-weight: 600;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      color: var(--muted);
    }
    .verdict-banner {
      font-size: var(--text-md);
      font-weight: 600;
      padding: var(--space-3) var(--space-3);
      border-radius: var(--radius-lg);
      margin-bottom: var(--space-3);
      border: 1px solid var(--border);
      color: var(--muted);
    }
    .verdict-banner.pending .spinner-dots::after {
      content: "\u2026";
    }
    .verdict-banner.pass {
      color: var(--verdict-pass);
      border-color: var(--pass-edge);
      background: var(--pass-wash);
    }
    .verdict-banner.not-pass {
      color: var(--verdict-notpass);
      border-color: var(--notpass-edge);
      background: var(--notpass-wash);
    }
    .verdict-banner.error {
      color: var(--verdict-none);
      border-color: var(--none-edge);
      background: var(--none-wash);
    }
    .verdict-sub {
      display: block;
      margin-top: var(--space-0);
      font-size: var(--text-xs);
      font-weight: 400;
      color: var(--muted);
    }
    /* E.39. The criterion rows describe the defense; they did not
       decide it. This line says so, and it is the reason a student
       cannot read the chips below as the verdict's arithmetic. */
    .criteria-heading {
      font-size: var(--text-xs);
      color: var(--muted);
      line-height: 1.45;
      padding-bottom: var(--space-2);
    }
    .panelist-row {
      padding: var(--space-2) var(--space-0);
      border-top: 1px solid var(--border);
      font-size: var(--text-sm);
    }
    .panelist-head {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      font-variant-numeric: tabular-nums;
    }
    .panelist-name { font-weight: 500; }
    /* ---- I.4: THE BADGE, and it is one component now ----

       "This thing is in this state" was drawn four different ways.
       .status-chip and .panelist-chip were the SAME EIGHT DECLARATIONS
       written out twice, six hundred lines apart; .result-verdict was a
       third near-copy with a different border treatment; .tile-badge a
       fourth with different letter-spacing. Nothing asserted any of
       them — zero references across the guards and the browser checks —
       which is why four of them could exist at all.

       THE TONES NAME THE MEANING, NOT THE SCREEN. A badge on the
       results screen and a badge on the submissions screen that mean
       the same thing have to look the same, and the only way to keep
       that true is for the class to say what it means. */
    .badge {
      display: inline-block;
      padding: 1px var(--space-2);
      border-radius: var(--radius-pill);
      font-size: var(--text-2xs);
      letter-spacing: 0.04em;
      text-transform: uppercase;
      border: 1px solid var(--border);
      color: var(--muted);
      white-space: nowrap;
    }
    /* The three judgement tones. Named for the judgement, so the
       criterion chips and the verdict cannot drift apart. */
    .badge-pass    { color: var(--verdict-pass);
                     border-color: var(--pass-edge); }
    .badge-notpass { color: var(--verdict-notpass);
                     border-color: var(--notpass-edge); }
    .badge-none    { color: var(--verdict-none);
                     border-color: var(--none-edge); }
    /* The three system tones: present and expected, present and not yet
       acted on, accepted but worth a second look. */
    .badge-ok      { color: var(--ok);     border-color: var(--ok-edge); }
    .badge-info    { color: var(--accent); border-color: var(--accent-edge); }
    .badge-warn    { color: var(--amber);  border-color: var(--amber-edge); }
    /* Provisional: a dashed edge, the convention the unmatched chip and
       the pending member row each invented separately. */
    .badge-quiet   { border-style: dashed; }

    /* THE VERDICT IS THE SAME BADGE, LOUDER. It is the sentence a
       student reads first, so it keeps the size and the full-strength
       border it always had — as a modifier, not as a second component
       that can be restyled without this one noticing. */
    .badge-strong {
      font-size: var(--text-xs);
      padding: var(--space-0) var(--space-3);
      letter-spacing: normal;
      text-transform: none;
    }
    .badge-strong.badge-pass    { border-color: var(--verdict-pass); }
    .badge-strong.badge-notpass { border-color: var(--verdict-notpass); }
    .panelist-latency { color: var(--muted); font-size: var(--text-2xs); margin-left: auto; }
    .panelist-note {
      color: var(--muted);
      font-size: var(--text-xs);
      line-height: 1.45;
      margin-top: var(--space-1);
      white-space: pre-wrap;
    }
    /* The readings behind the banner's tally: what actually decided,
       kept compact because the number, not the paragraphs, is what a
       student needs in order to check it. */
    .reading-strip {
      display: flex;
      flex-wrap: wrap;
      gap: var(--space-2) var(--space-3);
      margin-top: var(--space-3);
      padding-top: var(--space-2);
      border-top: 1px solid var(--border);
      font-size: var(--text-xs);
      color: var(--muted);
      font-variant-numeric: tabular-nums;
    }
    .reading-item {
      display: inline-flex;
      align-items: center;
      gap: var(--space-2);
    }
    /* The row layout pushes the latency to the far right with
       margin-left:auto; inside the strip the items sit side by side
       and that would shove every latency to the window edge. */
    .reading-item .panelist-latency { margin-left: 0; }
    .verification-path {
      margin-top: var(--space-3);
      font-size: var(--text-2xs);
      color: var(--muted);
      word-break: break-all;
    }

    /* ---- Upload screen ---- */
    /* NOT --list-narrow, which happens to be the same number. This is
       a card on a screen that has no list on it; reading the roster's
       token here would mean a change to the roster width silently
       moved this card. The M.3 lesson (--form-width was 440 only
       because it had been copied from the lists) in the other
       direction. */
    .upload-card {
      width: 100%;
      max-width: 440px;
      text-align: left;
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      padding: var(--space-5) var(--space-5) var(--space-4);
    }
    .upload-card h2 { font-size: var(--text-md); font-weight: 600; margin: 0 0 var(--space-2); }
    .upload-description {
      color: var(--muted);
      font-size: var(--text-sm);
      line-height: 1.5;
      margin: 0 0 var(--space-4);
      white-space: pre-wrap;
    }
    .upload-dropzone {
      border: 1px dashed var(--border);
      border-radius: var(--radius-lg);
      padding: var(--space-5) var(--space-4);
      text-align: center;
      color: var(--muted);
      font-size: var(--text-sm);
      line-height: 1.5;
      margin-bottom: var(--space-4);
    }
    /* .soon went with the "Coming soon" badge it existed for (K.1).
       The student's path no longer promises a feature anywhere: the
       one place it did was submission upload on a course assignment,
       which is not a missing feature but a deliberate refusal. */
    .upload-dropzone-note {
      margin: 0;
      white-space: pre-line;
    }
    .freeform-attach {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: var(--space-3);
      margin-top: var(--space-4);
    }
    .freeform-status {
      margin: 0;
      color: var(--text);
      font-weight: 600;
    }
    .upload-actions {
      display: flex;
      justify-content: space-between;
      gap: var(--space-3);
    }
    /* View-as selector: only rendered for principals holding the
       impersonate capability, and deliberately plain so it never reads
       as part of the student-facing UI. */
    /* Says out loud that everything on screen was invented. Wears the
       view-as vocabulary rather than a colour of its own: both mean
       "what you are looking at is not what it appears to be", and a
       third warning colour would dilute both. */
    /* A FULL-WIDTH STRIP, not a chip in the bar. Inside the bar it
       competed with the crumbs and the view-as chip and lost — 129px on
       a laptop, EIGHTEEN on a phone. A warning nobody can read is not a
       warning, and one that costs the navigation its labels is an
       expensive one. Out here it has no neighbours to be squeezed by,
       fits its whole sentence at every width, and reads more like the
       page-level statement it is.
       Wears the view-as vocabulary rather than a colour of its own:
       both mean "what you are looking at is not what it appears to
       be", and a third warning colour would dilute both. */
    .showcase-banner {
      flex: 0 0 auto;
      padding: var(--space-2) max(var(--space-5), env(safe-area-inset-left));
      border-bottom: 1px solid var(--viewas);
      background: var(--amber-glow);
      color: var(--viewas);
      font-size: var(--text-xs);
      line-height: 1.4;
    }
    .showcase-banner[hidden] { display: none; }

    /* H.4's replay notice. Deliberately the SAME amber as the showcase
       banner and the view-as bar: those three say the same kind of
       thing — what you are looking at is not what it appears to be —
       and a fourth colour would make the newest one read as decoration.
       Inside the session header rather than above it, because it must
       travel with the screen it is about. */
    .replay-notice {
      margin-top: var(--space-2);
      padding: var(--space-2) var(--space-3);
      border: 1px solid var(--viewas);
      border-radius: var(--radius-md);
      background: var(--amber-glow);
      color: var(--viewas);
      font-size: var(--text-xs);
      line-height: 1.4;
    }
    .replay-notice[hidden] { display: none; }

    /* The count of demo accounts the granting screen is not listing. */
    .users-hidden-note {
      font-size: var(--text-xs);
      line-height: 1.45;
      color: var(--muted);
      border: 1px dashed var(--border);
      border-radius: var(--radius-md);
      padding: var(--space-2) var(--space-3);
      margin-bottom: var(--space-2);
    }

    .view-as-bar {
      display: flex;
      align-items: center;
      flex: 0 0 auto;
      gap: var(--space-2);
      /* No margin: it sits in the app bar's flex row now, not in the
         middle of the courses screen where it separated the subtitle
         from the tiles. */
      padding: var(--space-1) var(--space-2);
      border: 1px dashed var(--viewas);
      border-radius: var(--radius-pill);
      font-size: var(--text-xs);
      color: var(--viewas);
      white-space: nowrap;
    }
    .view-as-bar[hidden] { display: none; }

    /* Shown during a session that is being impersonated, so the state is
       never invisible while it is in force. */
    .view-as-active {
      /* inline-BLOCK, and the margin is not decoration.
         This is inserted between two block siblings (the subtitle above,
         the controls row below). As `display: inline` — the default for
         the <span> showViewAsBanner creates — its vertical padding does
         not participate in layout at all, so the chip overflowed its
         line box and the Start button painted over its lower edge.
         Measured in a browser at 3px of overlap: enough to clip the
         banner and look like a rendering fault, not enough to look
         deliberate. The margin then keeps the gap once the padding
         counts. */
      display: inline-block;
      margin: 0 0 var(--space-3);
      background: var(--viewas-fill);
      color: var(--on-viewas);
      padding: var(--space-1) var(--space-3);
      border-radius: var(--radius-xs);
      font-size: 0.85rem;
      font-weight: 600;
      line-height: 1.4;
    }
    .view-as-active[hidden] { display: none; }

    /* ---- I.4: the page header ----

       TITLE LEFT, ACTIONS RIGHT, ONE BLOCK, INSIDE THE CONTENT COLUMN.
       Every LMS in the Track I survey puts a page's title, its
       description and its actions together; this app had the title in
       the content column and the actions in a strip ABOVE it, each with
       its own 760px cap and its own padding. They lined up by
       coincidence and stopped lining up the moment I.3 put a rail
       beside the column — the buttons stayed anchored to the old cap
       while the title moved with the column.

       The block is a plain row: the title takes the space, the actions
       take what they need. No spacer, no min-width — the arrangement
       that needed those had a back button on the left, and the
       breadcrumb replaced that two tracks ago. */
    .page-header {
      display: flex;
      align-items: baseline;
      justify-content: space-between;
      gap: var(--space-4);
      flex-wrap: wrap;
      margin: 0 0 var(--space-1);
    }
    .page-header h1, .page-header h2 { margin: 0; }
    .page-header-actions {
      display: flex;
      gap: var(--space-2);
      flex: 0 0 auto;
      margin-left: auto;
    }
    /* ALWAYS PRESENT, EVEN WHEN EMPTY: a component whose shape depends
       on whether it has buttons is two components, and the guard that
       checks every tool screen has one would have to know which. This
       keeps an empty one out of the layout instead. */
    .page-header-actions:empty { display: none; }
    .page-header-actions button {
      font-size: var(--text-xs);
      padding: var(--space-2) var(--space-3);
    }
    /* The title wraps before the actions do: an assignment title is
       long and a button label is not. */
    .page-header > :not(.page-header-actions) { min-width: 0; }
    /* An icon-led back control reads as navigation rather than as an
       action, which is what distinguishes it from the buttons opposite. */
    .back-btn::before {
      content: "\2190";
      margin-right: var(--space-2);
    }

    .menu-actions {
      display: flex;
      gap: var(--space-3);
      justify-content: center;
      margin-top: var(--space-5);
    }

    /* ---- Course tiles ---- */
    /* Courses carry more than assignments do — a title, a code/section/
       term line, and sometimes a badge — so forcing them into the square
       assignment tile crushed the text. A wider tile with the icon beside
       the text rather than above it gives each line a full measure and
       lets the type hierarchy actually read. */
    /* Centred flex rather than a grid.
       `repeat(auto-fill, ...)` kept empty columns alive, so a single
       course sat in column one with an empty column beside it — the tile
       hugged the left of the grid box rather than centring. auto-fit
       would collapse the track but then stretch one tile across the full
       width, which looks worse. A wrapping flex row centres any number
       of tiles, including one. */
    .course-grid {
      display: flex;
      flex-wrap: wrap;
      justify-content: flex-start;
      align-items: stretch;
      gap: var(--space-3);
      width: 100%;
      max-width: 620px;
      margin: 0;
    }
    /* Icon above the text, everything centred — the same language as the
       assignment tiles, so the two screens read as one app. The tile is
       wider and taller than a square so the extra lines a course carries
       have room; cramming them into 132px is what looked smushed. */
    .course-tile {
      width: 190px;
      min-height: 190px;   /* square minimum; see .assignment-tile */
      box-sizing: border-box;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: var(--space-2);
      /* Top padding reserves the action strip; see .assignment-tile. */
      padding: 38px var(--space-3) var(--space-4);
      border-radius: var(--radius-lg);
      border: 1px solid var(--border);
      background: var(--panel);
      color: var(--text);
      cursor: pointer;
      text-align: center;
      transition: background 0.15s, border-color 0.15s, transform 0.15s;
    }
    .course-tile:hover {
      background: var(--raised);
      border-color: var(--accent);
      transform: translateY(-2px);
    }
    .course-tile:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 2px;
    }
    .course-tile .tile-icon { flex: 0 0 auto; }
    .course-body {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: var(--space-1);
      width: 100%;
      min-width: 0;   /* lets long titles clamp instead of overflowing */
    }
    .course-name {
      font-size: var(--text-sm);
      font-weight: 600;
      line-height: 1.35;
      width: 100%;
      /* Two lines then ellipsis: a long course title should wrap rather
         than truncate at the first word, but must not stretch the tile. */
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }

    /* Course code, section, and term. The disambiguator when two
       instructors each run their own CS540 — see DESIGN_courses.md. */
    .tile-meta {
      font-size: var(--text-2xs);
      color: var(--muted);
      line-height: 1.4;
      width: 100%;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }

    /* Marks a tile the viewer can edit, so an instructor can tell at a
       glance which courses are theirs to change. Deliberately quiet: it
       is a hint, not a call to action. */
    /* The course tile's "teaching"/"admin" mark is `badge badge-info`
       now; only its placement in the tile's column is still its own. */
    .course-body > .badge {
      align-self: center;
      margin-top: var(--space-0);
      line-height: 1.4;
    }

    /* ---- Tile actions ---- */
    /* Edit and delete are SIBLINGS of the tile button, not children of
       it. A button inside a button is invalid, and the previous
       click-handling <span> inside the tile also pulled the icon and
       label off centre because it participated in the tile's flex
       column. The wrapper keeps the tile a clean square with its content
       genuinely centred, and overlays the controls on top. */
    /* The wrapper is what the grid lays out now that actions are
       siblings of the tile. It has to carry the tile's footprint, or the
       tile inside sizes to its content and the row comes out ragged. */
    .tile-wrap {
      position: relative;
      display: inline-flex;
      /* Neither grow nor shrink, and no content-based floor. The wrap is
         what a grid or flex row lays out, so its automatic minimum is
         what pins a tile open at its longest label. */
      flex: 0 0 auto;
      min-width: 0;
    }
    .tile-wrap > .course-tile,
    .tile-wrap > .assignment-tile { width: 100%; }
    .course-grid > .tile-wrap,
    .course-grid > .course-tile { width: 190px; }
    /* The assignment grid needs the same, and did not have it. The
       WRAPPER is what the grid lays out, and `.tile-wrap > .tile
       { width: 100% }` above means the tile takes its size FROM the
       wrapper — so with no width here the wrapper sized to the tile's
       min-content, which is its longest label. Measured in a real
       browser: four assignments rendered 273, 105, 96 and 190px wide.
       The course grid was already immune because of the rule above;
       the guard was scoped to courses too, so nothing caught it. */
    .assignment-grid > .tile-wrap,
    .assignment-grid > .assignment-tile { width: 236px; }
    /* Always visible, not revealed on hover.
       Hover-to-reveal hides the fact that the feature exists at all: a
       control nobody can see is a control nobody knows they have, and
       there is no hover on touch anyway. Kept visually quiet instead —
       muted until pointed at — so it does not compete with the tile. */
    .tile-actions {
      position: absolute;
      top: 6px;
      right: 6px;
      display: flex;
      gap: var(--space-1);
      opacity: 0.55;
      transition: opacity 0.15s;
    }
    .tile-wrap:hover .tile-actions,
    .tile-wrap:focus-within .tile-actions { opacity: 1; }
    /* AND WHILE ITS MENU IS OPEN, said as its own rule rather than left
       to the clause above. Focus does land inside the menu, so
       :focus-within already raises it — but that makes a solid menu
       depend on where focus happens to be, and an open menu is the
       foreground of the screen whatever the pointer is doing. */
    .tile-wrap.menu-open .tile-actions { opacity: 1; }
    /* WHERE THERE IS NO HOVER, THERE IS NO WAY TO RAISE THEM. The strip
       is not hidden — it sits at 0.55 — so a touch user could always
       reach it; what they could never get is the other 0.45. A muted
       glyph on --bg at 55% is a poor target to aim a thumb at, and it
       is the only state a phone or tablet ever shows.
       (hover: none) is the query that actually asks the question,
       rather than inferring "touch" from a width that a narrow desktop
       window also matches. */
    @media (hover: none) {
      .tile-actions { opacity: 1; }
    }
    .tile-action {
      width: 26px;
      height: 26px;
      padding: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: var(--text-xs);
      line-height: 1;
      border-radius: var(--radius-sm);
      border: 1px solid var(--border);
      background: var(--bg);
      color: var(--muted);
      cursor: pointer;
    }
    .tile-action:hover { color: var(--text); border-color: var(--accent); }
    .tile-action.danger:hover {
      color: var(--danger);
      border-color: var(--danger);
    }
    .tile-action:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
    }

    /* The one control left in the corner. U+22EE is a tall, thin glyph
       and at the strip's 12px it reads as a smudge, so it gets the
       card-emphasis step instead. */
    .tile-menu-btn { font-size: var(--text-md); }

    /* ---- A menu somewhere other than a tile corner (K.2b) ----
       .tile-actions is the positioned container a tile's menu drops
       from. The page header has no such thing, so this is the smallest
       one that works: relative, inline, and nothing else. The menu's
       own rules below do the rest, which is the point of reusing them
       — padding, shadow, the up-flip and the item styling are written
       once and this borrows all of it. */
    .menu-host { position: relative; display: inline-flex; }
    /* Same reason as .tile-wrap.menu-open: an open menu is the
       foreground of the screen, and the page header sits above content
       that would otherwise paint over it. */
    .menu-host.menu-open { z-index: 30; }

    /* ---- The overflow menu (J.2b) ----
       INSIDE .tile-actions, not inside .tile-wrap, and that is what
       makes it hold on a phone without a second set of numbers:
       .tile-actions is already absolutely positioned, so it is the
       containing block, and `top: 100%` tracks the trigger's own height
       and offset. When the buttons grow to 32px for touch and the strip
       moves to 8px, the menu follows them without being told. */
    .tile-menu {
      position: absolute;
      top: calc(100% + var(--space-1));
      right: 0;
      min-width: 184px;
      padding: var(--space-1);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      background: var(--panel);
      box-shadow: 0 10px 28px var(--shadow);
      text-align: left;
    }
    /* Set by openTileMenuFor from a MEASUREMENT, and only when the drop
       would fall past the bottom of the viewport. Not a media query and
       not a rule about the last row: which row is last depends on how
       many assignments a course has and how wide the window is. */
    .tile-menu.tile-menu-up {
      top: auto;
      bottom: calc(100% + var(--space-1));
    }
    /* ABOVE ITS NEIGHBOURS, and only while open. The tiles are siblings
       in source order, so without this every menu but the last row's
       last tile is painted UNDER the tile to its right — open, correct,
       and half-covered. */
    .tile-wrap.menu-open { z-index: 30; }
    .tile-menu-item {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      width: 100%;
      padding: var(--space-2) var(--space-3);
      border: 0;
      border-radius: var(--radius-sm);
      background: none;
      font: inherit;
      font-size: var(--text-sm);
      color: var(--text);
      text-align: left;
      /* M.5: 32px, which is the density Fluent 2 and Polaris both ship
         for a desktop menu item (Carbon's md is 40, Material's is 48 —
         those are touch densities, and the phone rule below adopts one).
         MIN-HEIGHT RATHER THAN MORE PADDING, for the reason M.3 widened
         .inline-form with a width token: the spacing scale is FULL at
         nine steps and test_every_rhythm_value_is_on_the_scale watches
         padding/margin/gap, not min-height. The height was 30px, which
         is nobody's number — it was whatever 8px of padding around a
         13px line happened to come to. */
      min-height: 32px;
      /* The labels are short and naming them is the whole point; a
         wrapped "Question sheet" would be two lines of menu. The menu
         sizes to them instead. */
      white-space: nowrap;
      cursor: pointer;
    }
    .tile-menu-item:hover { background: var(--raised); }
    /* INSET, not outset. The item fills the menu's padding box, so an
       outward ring is drawn underneath the menu's own border. */
    .tile-menu-item:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: -2px;
    }
    /* Destructive items read as destructive BEFORE they are pointed at.
       On the old strip this was a hover-only colour on an unlabelled
       glyph, which is the least warning a delete has ever carried. */
    .tile-menu-item.danger { color: var(--danger); }
    .tile-menu-item.danger:hover { background: var(--danger-wash); }
    .tile-menu-glyph {
      /* Fixed width so the labels start on one line down the menu
         rather than stepping in and out with each glyph's advance. */
      flex: 0 0 16px;
      text-align: center;
      font-size: var(--text-xs);
      color: var(--muted);
    }
    .tile-menu-item.danger .tile-menu-glyph,
    .tile-menu-item:hover .tile-menu-glyph { color: inherit; }

    /* ---- The caret that says a menu follows (M.5) ----
       *** THE DISCLOSURE AFFORDANCE IS REQUIRED, NOT DECORATION. ***
       Fluent 2 defines a MenuButton as precisely "a button with a
       chevron icon after the button contents", and Nielsen Norman's
       finding is the reason it matters: a unique affordance with no
       visual signifier goes unnoticed even by veteran users. This
       shipped as a bare U+25BE inside the label, inheriting whatever
       the button's font-size was, which is not a caret — it is a
       character that happens to point down.
       THE TWO NUMBERS ARE FLUENT'S: a 12px chevron, 4px after the
       label. Both are existing scale steps (--text-xs, --space-1)
       rather than new ones.
       ONLY ON A BUTTON THAT ACTUALLY OPENS A MENU. #newCourseBtn opens
       a FORM, so it gets the primary fill and no caret: a chevron on a
       control with no menu behind it is a door advertised and not
       opened (E.31), one layer down. */
    .menu-caret {
      display: inline-block;
      margin-left: var(--space-1);
      font-size: var(--text-xs);
      line-height: 1;
      transition: transform 0.15s;
    }
    /* Carbon is the only one of the five systems that publishes a
       VISUAL for "this menu is open" — the chevron turns over, on a
       fast transition. aria-expanded is the fact and a screen reader
       has it; this is the half somebody looking at the screen has. */
    .menu-host.menu-open .menu-caret { transform: rotate(180deg); }

    @media (max-width: 560px) {
      /* *** A MOUSE AND A THUMB ARE NOT THE SAME TARGET. *** 32px
         clears WCAG 2.2 Target Size (Minimum, AA, 24px) but falls short
         of Target Size (Enhanced, AAA, 44px) and of what every platform
         ships for touch — Material 48dp, Apple HIG 44pt, USWDS 48px.
         44 is the standard's own number, taken rather than rounded up,
         because a two-item menu at 48 is most of a phone's header. */
      .tile-menu-item { min-height: 44px; }
    }

    /* Assignment tiles keep the square form; the label gets room now that
       nothing else sits in the flex column with it. */
    .assignment-tile { position: relative; box-sizing: border-box; }
    /* The icon sat above centred text; left-aligned it needs to sit on
       its own line without stretching. */
    .assignment-tile .tile-icon { align-self: flex-start; margin-bottom: var(--space-0); }
    .assignment-tile .tile-label {
      padding: 0;
      font-size: var(--text-sm);
      font-weight: 600;
    }
    .assignment-tile .tile-meta { white-space: normal; }

    /* K.2b: WHY THIS TILE CANNOT BE STARTED, in the server's own
       words. Warm rather than red: for an instructor it is a to-do
       (attach the reading), and for a student it is simply not their
       move. --danger is for destruction, and a reading nobody has
       uploaded yet is not a failure. --amber is the token this app
       already uses for "attention, not alarm" (it is what NOT PASS
       wears, for the same reason). */
    .tile-blocked {
      font-size: var(--text-2xs);
      line-height: 1.45;
      color: var(--amber);
      display: -webkit-box;
      -webkit-line-clamp: 3;
      -webkit-box-orient: vertical;
      overflow: hidden;
      min-width: 0;
      overflow-wrap: anywhere;
    }

    /* The assignment's own description, one clamped paragraph. Clamped
       rather than truncated at a character count: three lines is a
       layout fact the browser can measure and a JS slice cannot. */
    .tile-blurb {
      font-size: var(--text-2xs);
      line-height: 1.45;
      color: var(--muted);
      display: -webkit-box;
      -webkit-line-clamp: 3;
      -webkit-box-orient: vertical;
      overflow: hidden;
      min-width: 0;
      overflow-wrap: anywhere;
    }

    /* "Defended twice · 3 days ago". Deliberately NOT in the verdict
       vocabulary: this says a thing HAPPENED, not how it went, and
       borrowing --verdict-pass here would turn a neutral fact into a
       grade on a course screen.
       THE DOT IS --muted, AND THAT IS THE WHOLE POINT. It was --ok
       first, which is a system-state colour rather than a judgement
       one — but --ok and --verdict-pass are the same green, so on
       screen a green dot beside "Defended" reads as "passed". The
       token vocabulary was right and the pixels still lied. */
    .tile-done {
      margin-top: auto;
      padding-top: var(--space-2);
      font-size: var(--text-2xs);
      line-height: 1.4;
      color: var(--muted);
      display: flex;
      align-items: center;
      gap: var(--space-2);
    }
    .tile-done::before {
      content: "";
      width: 6px;
      height: 6px;
      flex: 0 0 6px;
      border-radius: 50%;
      background: var(--muted);
      opacity: 0.7;
    }
    /* D.8: nothing left. Set from attempts_left, which the SERVER
       resolves, so this never contradicts the Begin button. */
    .tile-done-spent { color: var(--danger); }
    .tile-done-spent::before { background: var(--danger); opacity: 1; }

    /* WHY min-width: 0 AND flex: 0 0 auto, on both tile kinds.
       A flex item's automatic minimum size is its MIN-CONTENT width, not
       the width you gave it, and a tile is itself a flex container whose
       min-content comes from its longest unbreakable label. Measured in
       a real browser: an assignment titled "HW6: Neural Networks and
       Backpropagation" rendered 273px wide against a declared 190px, and
       its neighbours shrank to 105px and 96px absorbing the overflow —
       four tiles that should have been identical squares came out at
       four different sizes. min-width: 0 lets the declared width hold;
       overflow-wrap lets a long word break rather than demand room. */
    .assignment-tile, .course-tile { flex: 0 0 auto; min-width: 0; }
    .tile-label, .course-name { min-width: 0; overflow-wrap: anywhere; }

    /* ---- Responsive ---- */
    /* Phones: tiles go full-bleed in a two-up grid rather than a
       wrapping flex row, so a row of two never leaves an orphan
       stretched across the width. */
    @media (max-width: 560px) {
      /* ---- The app bar on a phone ----
         The bar is the one element on every screen, so it is also the
         one with the least room to spare. What goes: the brand's words
         (the mark carries it), the crumb TRAIL (only the parent link
         and where you are survive, which is what a back affordance
         was), the profile NAME (the avatar is the control), and the
         view-as label. What stays at full size: the tap targets. */
      .app-bar {
        height: 48px;
        gap: var(--space-2);
        padding: 0 max(var(--space-3), env(safe-area-inset-left))
                 0 max(var(--space-3), env(safe-area-inset-right));
      }
      .brand-name { display: none; }
      /* *** THE CRUMBS MUST NOT BE THE THING THAT COLLAPSES. ***
         Every other item in the bar is flex: 0 0 auto, and .crumbs
         carries min-width: 0 so a long course title can ellipsis — put
         those together in 390px and the crumbs are squeezed to ZERO,
         which on the roster screen meant no visible way back at all.
         The back buttons were removed in this same batch, so that was
         a dead end, not a cosmetic one. The crumbs get a floor and the
         view-as chip does the shrinking instead. */
      .crumbs {
        font-size: var(--text-xs);
        gap: var(--space-1);
        flex: 1 1 auto;
        min-width: 88px;
      }
      /* NEVER DROPPED on a phone: half the audience is holding one,
         and the strip has room to wrap to two lines rather than
         disappear. */
      .showcase-banner { padding: var(--space-2) var(--space-3); font-size: var(--text-xs); }
      .view-as-bar { min-width: 0; overflow: hidden; }
      .view-as-bar select {
        max-width: 72px;
        text-overflow: ellipsis;
        padding-right: var(--space-5);
      }
      /* Everything before the parent link is noise on 390px: the trail
         cannot be read anyway, and the one crumb worth tapping is the
         way out of here. */
      .crumb-drop { display: none; }
      .profile-name { display: none; }
      .profile-btn { padding: var(--space-0); max-width: none; }
      .view-as-bar { padding: var(--space-0) var(--space-2); font-size: var(--text-2xs); }
      .view-as-bar label { display: none; }
      /* Measured, not guessed: at 390px the bar's children came to
         428px and pushed the avatar off the right edge entirely.
         These numbers are what bring it to 374. */
      .theme-option { width: 26px; height: 28px; }
      .profile-menu { min-width: 190px; }

      .menu-content { padding: var(--space-1) var(--space-3) var(--space-6); justify-content: flex-start; }
      .menu-screen h1 { font-size: var(--text-lg); }
      .menu-subtitle { margin-bottom: var(--space-4); }
      /* On a phone the title and its actions stack rather than
         competing for one line: "New assignment" and a course title do
         not both fit in 390px, and a wrapped button row under the title
         is the arrangement people already know. */
      .page-header { gap: var(--space-2); }
      .page-header-actions { margin-left: 0; }

      /* Two up, like the assignment grid. At full width a SQUARE tile
         would be ~332px tall on a 360px phone, which is most of the
         viewport for one course. */
      .course-grid {
        display: grid;
        /* minmax(0, ...) not 1fr: a grid column's automatic minimum is
           its item's MIN-CONTENT, so a long course title pinned the
           column at 228px and the tile could not shrink to fit a
           320px phone. Measured, not guessed. */
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: var(--space-3);
      }
      .course-grid > .tile-wrap,
      .course-grid > .course-tile { width: 100%; }
      .course-grid > .tile-wrap { display: flex; }
      .course-grid .course-tile { width: 100%; }

      .assignment-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: var(--space-3);
        width: 100%;
      }
      /* Same specificity as the desktop `.assignment-grid > .tile`
         rules, or those win inside this media query and a 190px tile
         overflows a 161px column. */
      .tile-wrap { display: flex; }
      .assignment-grid > .tile-wrap,
      .assignment-grid > .assignment-tile { width: 100%; }
      /* Buttons grow to 32px for touch here, so the strip they need is
         taller and the reserved padding grows with them. */
      /* The grid makes tiles fluid here, so a px minimum cannot track
         the width. aspect-ratio does, and height:auto lets content
         still push a tile taller than square when it needs to — which
         matters more now that a tile carries a description. 
         THAT LAST CLAUSE ONLY BECAME TRUE IN I.4: it needed the tile's
         children to stop shrinking, or the box took its aspect-derived
         height and squeezed the text into it. See the flex-shrink rule
         beside .assignment-tile. */
      .assignment-tile, .course-tile { aspect-ratio: 1 / 1; height: auto; }
      .assignment-tile { width: 100%; min-height: 0;
                         padding: 42px var(--space-3) var(--space-3); }
      /* *** THE DESCRIPTION GOES; THE TILE STAYS SQUARE AND UNCUT. ***
         (I.4) aspect-ratio does not floor a tile's height, it FIXES it:
         a grid item with an aspect ratio takes that height and the
         content has to fit. Measured at 390px with the children no
         longer shrinking: the tile is 177px and its content wants 199,
         so the description hung 13px past the bottom border.

         Something had to give, and the description is the right thing.
         At two-up on a phone a tile is ~161px wide; three clamped lines
         of a description there is a fragment of a sentence, while the
         title, the length, the criteria count and the attempt line are
         what a student is scanning for. The description is a tap away
         on the screen the tile opens. The square was asked for
         directly and it is kept. */
      .tile-blurb { display: none; }
      /* *** K.2b PUT A SECOND TEXT BLOCK ON THE TILE AND THIS BLOCK
         PREDATES IT. *** The paragraph above resolved one overflow by
         dropping the description, which was the right thing to give:
         the title, the length and the attempt line are what a student
         scans for, and the description is a tap away. THE REFUSAL IS
         NOT. It is the sentence saying this tile cannot be started at
         all, in the words the mint would refuse with, and a student
         who cannot see it on a phone presses Begin and learns from a
         409 instead. So the square gives here, and only here: a tile
         carrying a refusal grows to hold it.

         Measured at 390px: the reading check's refusal hung 45px past
         the bottom border. No showcase assignment was blocked until
         E.143's content, which is why this went a whole batch without
         a complaint — the same reason check_components' own exemption
         list still said the description was the only line that may
         give. */
      .assignment-tile.tile-has-blocked { aspect-ratio: auto; }
      /* min-height: 0, or the desktop 190px square minimum wins over
         aspect-ratio here and a 141px-wide tile renders 190 tall. */
      .course-tile { padding: 46px var(--space-3) var(--space-4); min-height: 0; }

      /* ---- The SESSION screen on a phone ----
         This block covered the menu screens only; the screen a student
         actually spends twenty minutes on had no phone rules at all,
         which is the gap batch 3 was for. Students defend on laptops
         AND phones, so this is the primary surface for half of them. */
      .header .subtitle { font-size: var(--text-xs); }
      .controls { gap: var(--space-2); row-gap: var(--space-2); }
      /* The clock takes its own line rather than being squeezed to a
         sliver; the bar then has room to actually read as a bar. */
      .session-clock { order: 3; width: 100%; }
      .clock-bar { flex: 1 1 auto; width: auto; }
      /* Touch targets: 8px vertical padding is a 30px button, under the
         44px the platform guidelines ask for on a control someone
         reaches for while talking. */
      .controls button { padding: var(--space-3) var(--space-4); }
      .conversation { padding: var(--space-3) var(--space-0) 24vh; }
      .bubble { max-width: 92%; font-size: var(--text-md); }
      .turn { margin-bottom: var(--space-3); }
      .verification-card { padding: var(--space-3) var(--space-3); }
      .footer { padding: var(--space-2) var(--space-0) var(--space-3); }
      .mic-level { height: 14px; }

      .invite-row { flex-wrap: wrap; }
      .invite-row input { flex: 1 1 100%; }
      .upload-row { flex-wrap: wrap; }
      .upload-row input[type="file"] { flex: 1 1 100%; }

      /* Touch targets. 26px is fine for a mouse and too small for a
         thumb, so the controls grow rather than the tile shrinking. */
      .tile-action { width: 32px; height: 32px; font-size: var(--text-md); }
      .tile-actions { gap: var(--space-2); top: 8px; right: 8px; }

      /* ---- Results on a phone ----
         The head is who / verdict / tally / attempts / date on one
         line, which is right at 1280px and impossible at 390. It wraps
         here, and the TALLY goes: it is detail, available on expand,
         and squeezing it onto a phone only truncated it to "3 pa…".
         The verdict and the date are what triage actually reads. */
      .result-student-head {
        flex-wrap: wrap;
        row-gap: var(--space-2);
        padding: var(--space-3) var(--space-3);
      }
      .result-tally { display: none; }
      .result-student-head .result-when {
        margin-left: auto;
        font-size: var(--text-2xs);
      }
      .result-student-body { padding: var(--space-0) var(--space-3) var(--space-3); }
      .results-summary { gap: var(--space-2); }
      .results-chip { padding: var(--space-1) var(--space-3) var(--space-1) var(--space-2); font-size: var(--text-xs); }
      .results-of { margin-left: 0; width: 100%; }
      .results-search { flex-wrap: wrap; }
      .results-search input { flex: 1 1 100%; }
    }

    /* Wide displays: a slightly roomier reading column, and enough width
       for four tiles per row. The course row stays CENTRED — widening it
       further would only push a small number of tiles apart rather than
       fitting more, which is what made a single course look
       left-aligned in the first place. */
    @media (min-width: 900px) {
      .menu-content { max-width: 860px; }
      .course-grid { max-width: 820px; }
    }

    @media (prefers-reduced-motion: reduce) {
      .course-tile:hover { transform: none; }
    }

    /* ---- Pending invitations ---- */
    /* Sits above the grid because an invited user with no courses would
       otherwise see an empty page with their invitation nowhere in
       reach. */
    /* NOT --list-narrow: this sits above the course GRID, not above a
       .data-list, so there is nothing here for it to line up with. */
    .invitation-list {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
      margin-bottom: var(--space-5);
      width: 100%;
      max-width: 440px;
      align-self: center;
    }
    .invitation {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: var(--space-3);
      padding: var(--space-3) var(--space-3);
      border: 1px solid var(--accent-soft);
      border-radius: var(--radius-lg);
      background: var(--panel);
      text-align: left;
    }
    .invitation-text { font-size: var(--text-sm); }
    .invitation-text .muted { color: var(--muted); font-size: var(--text-xs); }

    /* The question-sheet chips sit under the summary sentence rather
       than beside it: that sentence is long ("N of M students have a
       question sheet prepared", and longer still on the demonstration),
       and a chip row sharing its line would wrap unpredictably as the
       numbers change \u2014 the jitter J.3a's count line was moved to avoid. */
    .script-chips {
      display: flex;
      flex-wrap: wrap;
      gap: var(--space-2);
      margin-top: var(--space-2);
    }

    /* ---- J.3b: the loading state ----
       Placeholders shaped like the rows they stand in for, so a list
       that is FILLING cannot be read as a list that is EMPTY. That
       confusion is the defect: this app draws a considered empty state
       on five screens, and a "Loading..." line would have looked like
       one of them.

       --raised rather than a second grey: it is already the app's
       "something is here but inert" tone (button hover, tile hover),
       and a skeleton is exactly that. */
    .skeleton {
      border-radius: var(--radius-md);
      background: var(--raised);
      /* The sweep is the only thing saying this is in progress rather
         than broken. Two stops of the panel colour over the raised
         one, moving left to right. */
      background-image: linear-gradient(90deg,
        transparent 0%, var(--panel) 50%, transparent 100%);
      background-size: 200% 100%;
      background-repeat: no-repeat;
      animation: skeleton-sweep 1.2s ease-in-out infinite;
    }
    .skeleton-row {
      height: 46px;         /* a .data-row: two lines of text plus its padding */
      margin-bottom: var(--space-2);
    }
    .skeleton-tile {
      /* The grids lay out .tile-wrap, so a placeholder has to carry a
         tile's footprint or the row comes out ragged as it resolves. */
      width: 190px;
      height: 190px;
      flex: 0 0 auto;
    }
    @keyframes skeleton-sweep {
      from { background-position: 200% 0; }
      to   { background-position: -200% 0; }
    }
    /* A SWEEPING GRADIENT IS EXACTLY THE KIND OF MOTION THIS QUERY
       EXISTS FOR, and the stylesheet already keeps three of these
       blocks. Still visible, just still: the placeholder shape is what
       carries the meaning, and the animation only says "in progress". */
    @media (prefers-reduced-motion: reduce) {
      .skeleton { animation: none; background-image: none; }
    }

    /* ---- J.3a: the list toolbar ----
       Search, an optional order control, and a live count, over any
       .data-list. Shared by the course roster and the admin Users
       screen because both grow with the class and both had none of it.

       align-items: center and not baseline: the count is a different
       type size from the controls and a baseline would leave it
       sitting low against a 34px input. */
    /* THE TOOLBAR SITS OVER ITS LIST, and the first draft did not: the
       search box ran the full content column while the rows below were
       capped at --list-narrow and centred, so the controls started
       185px to the left of the list they controlled. Measured at
       1440px; no assertion in this repo would have noticed, because
       both elements were individually correct. */
    .list-toolbar-narrow {
      max-width: var(--list-narrow);
      margin-left: auto;
      margin-right: auto;
    }
    .list-toolbar {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: var(--space-2);
      margin-bottom: var(--space-3);
      /* width: 100% IS LOAD-BEARING, the same as it is on .data-list.
         .menu-content is a flex column, so a child with only a
         max-width sizes to its CONTENT — measured at 414.391px against
         a 440px list, centred, and therefore 13px adrift on each side.
         Declaring the width makes max-width the cap it was meant to
         be rather than a ceiling nothing reaches. */
      width: 100%;
    }
    /* The search box takes the slack; the order control and the count
       take what they need. flex-basis is a floor, not a width: at
       390px the row wraps and this becomes the full line. */
    .list-toolbar input[type="search"] {
      flex: 1 1 200px;
      min-width: 0;
    }
    .list-toolbar select { flex: 0 0 auto; }
    /* *** ITS OWN LINE, ALWAYS, and that is a measurement rather than a
       preference. *** A wrapping flex container assigns items to lines
       by their flex-BASIS, before any shrinking — so whether the count
       fitted beside the controls depended on its own text: "362 people"
       (59px) sat on the row and "21 of 364 people" (89px) pushed
       itself off it. The search box then grew into the space the count
       had vacated, 211px -> 278px, ON THE FIRST KEYSTROKE. A control
       that resizes as you type into it is the kind of thing no
       assertion notices and everybody feels.
       flex-basis: 100% takes the decision away from the text. */
    .list-count {
      flex: 1 0 100%;
      text-align: right;
      font-size: var(--text-xs);
      color: var(--muted);
      white-space: nowrap;
    }

    /* ---- A destructive button, at rest ----
       Remove and Revoke sat beside a person's name looking exactly
       like every other button in the app. J.2b gave the tile menu's
       delete item a resting red; this is the same decision for an
       ordinary button, and the two are written next to each other so
       they cannot drift apart.
       BORDER AND TEXT, NOT A FILLED RED BUTTON. A solid red control is
       the loudest thing on a roster screen and would pull the eye to
       the one action nobody should take by accident. */
    button.danger {
      color: var(--danger);
      border-color: var(--danger-edge);
    }
    button.danger:hover {
      background: var(--danger-wash);
      border-color: var(--danger);
    }
    /* *** THIS BLOCK HAS TO SIT AFTER THE RULES IT OVERRIDES, and the
       first draft did not. *** A media query adds no specificity, so
       between two rules of equal weight the LATER one wins whatever
       the viewport is. The phone override went into the 560px block up
       at .invite-row, which is earlier in the file than .list-toolbar
       below it — so the desktop `flex: 1 1 200px` beat it at every
       width and the toolbar simply did not wrap. The stylesheet already
       keeps a 560px block per section for exactly this reason; this is
       that block for this component. */
    @media (max-width: 560px) {
      /* The search box takes the whole line too, so the order control
         is not squeezed into the remainder of a 390px row. The count
         already has its own line at every width. */
      .list-toolbar input[type="search"] { flex: 1 1 100%; }
    }

    /* ---- Roster ---- */
    /* ---- I.4: the data list ----

       ONE COMPONENT, NAMED FOR WHAT IT IS. This rendered the roster,
       the users screen, submissions, results AND question sheets while
       being called .data-list, and two of its five users had to be
       patched by id to undo its width cap:
           #resultsList, #scriptList { max-width: none; margin-left: 0; }
       A component patched at a call site is a component with a missing
       modifier. The cap is .data-list-narrow now, and the screens that
       want it ask for it.

       NOT A <table>, deliberately. These rows are cards — a name, a
       badge, a date — not tabular data with columns that line up, and a
       real table cannot wrap at 390px. Recorded here so the next reader
       knows it was decided rather than skipped. */
    .data-list {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
      width: 100%;
      margin: 0 0 var(--space-5);
    }
    /* The roster and the users screen: two short columns of NetIDs,
       which read badly at full width. */
    .data-list-narrow {
      max-width: var(--list-narrow);
      margin-left: auto;
      margin-right: auto;
    }
    .data-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: var(--space-3);
      padding: var(--space-2) var(--space-3);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--panel);
      font-size: var(--text-sm);
      text-align: left;
    }
    .data-row .muted { color: var(--muted); font-size: var(--text-xs); }
    /* Provisional, the same dashed convention .badge-quiet carries. */
    .data-row-quiet { border-style: dashed; }

    /* The pre-session notice: quiet, but present before every Begin. */
    .session-notice {
      color: var(--muted);
      font-size: var(--text-xs);
      line-height: 1.5;
      margin: var(--space-3) 0 0;
    }

    /* D.8: why Begin is dead. Not .session-notice's muted grey — this
       one is the answer to a question the student is about to ask, and
       a notice they read past is a support message later. */
    .upload-limit-note {
      color: var(--danger);
      font-size: var(--text-sm);
      line-height: 1.5;
      margin: var(--space-3) 0 0;
    }
    .upload-limit-note[hidden] { display: none; }

    /* F.2: session results (instructor/TA). Rows reuse .data-row's
       box; the column layout is theirs because each row carries two
       text blocks under its header line. */
    .results-search { display: flex; gap: var(--space-2); margin: 0 0 var(--space-3); }
    .results-search input {
      flex: 1;
      min-width: 0;
      background: var(--panel);
      color: var(--text);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      padding: var(--space-2) var(--space-3);
      font-size: var(--text-sm);
    }
    /* ---- Results at roster scale ---- */
    /* THIS BLOCK USED TO BE A PATCH, and I.4 turned it into the absence
       of one. .data-list was capped at 440px and centred for everybody,
       which is right for the Members screen — a column of names — and
       wrong for results: a table of outcomes with a verdict, a tally, a
       count and a date on every line, which at 440px wrap onto three
       rows each while the list sits adrift of the heading above it.
       #scriptList is the same shape, which H.5 made visible: with one
       sheet per submission every row carries four fields, the netid
       wraps onto two lines and the note runs off the card. (9.54: found
       by looking at the page, not by any assertion about it.)
       Both screens simply do not ask for .data-list-narrow now. The two
       id selectors that used to undo the cap are gone, and with them
       the question of which call site wins. */
    .results-truncated {
      font-size: var(--text-xs);
      color: var(--amber);
      border: 1px solid var(--amber-edge);
      background: var(--amber-glow);
      border-radius: var(--radius-md);
      padding: var(--space-2) var(--space-3);
      margin-bottom: var(--space-3);
    }

    /* The summary bar: a count and its filter are the same control,
       because "14 passed" and "show me the 14" are the same thought. */
    .results-summary {
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: var(--space-2);
      margin: 0 0 var(--space-3);
    }
    .results-chip {
      display: flex;
      align-items: baseline;
      gap: var(--space-2);
      font: inherit;
      font-size: var(--text-xs);
      color: var(--muted);
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: var(--radius-pill);
      padding: var(--space-1) var(--space-3) var(--space-1) var(--space-3);
      cursor: pointer;
    }
    .results-chip:hover { border-color: var(--accent-edge); }
    .results-chip.on {
      border-color: var(--accent);
      color: var(--text);
      background: var(--raised);
    }
    .results-chip-n {
      font-size: var(--text-md);
      font-weight: 650;
      color: var(--text);
      font-variant-numeric: tabular-nums;
    }
    /* N.4: what an assignment cost, inside its own chip. QUIETER THAN
       THE COUNT, which is the chip's subject — the money is a second
       fact about the same row and reading as loud as the first would
       make every chip look like two chips. */
    .results-chip-cost {
      font-size: var(--text-xs);
      color: var(--muted);
      font-variant-numeric: tabular-nums;
    }
    .results-chip.on .results-chip-cost { color: var(--text); }
    /* Each count wears its own verdict colour, so the bar reads as the
       same vocabulary as the chips further down the page. */
    .results-chip.chip-pass .results-chip-n { color: var(--verdict-pass); }
    .results-chip.chip-not-pass .results-chip-n {
      color: var(--verdict-notpass);
    }
    .results-chip.chip-neutral .results-chip-n { color: var(--verdict-none); }
    .results-of {
      font-size: var(--text-xs);
      color: var(--muted);
      margin-left: auto;
    }

    /* One card per student. The head is the triage line; the body opens
       on demand, because seventy attempts with two text blocks each is
       a document rather than a screen. */
    .result-student {
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      background: var(--panel);
      margin-bottom: var(--space-2);
      overflow: hidden;
    }
    .result-student.open { border-color: var(--accent-edge); }
    .result-student-head {
      display: flex;
      align-items: center;
      gap: var(--space-3);
      width: 100%;
      font: inherit;
      font-size: var(--text-sm);
      color: var(--text);
      background: none;
      border: none;
      text-align: left;
      padding: var(--space-3) var(--space-4);
      cursor: pointer;
    }
    .result-student-head:hover { background: var(--raised); }
    /* One line. The tally and attempt count give way before the date
       wraps to a row of its own, which is what made a card look like
       two cards. */
    .result-student-head > .muted {
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .result-student-head .result-when { flex: 0 0 auto; }
    .result-student-head:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: -2px;
    }
    .result-chevron {
      flex: 0 0 auto;
      color: var(--muted);
      font-size: var(--text-md);
      line-height: 1;
      transition: transform 0.15s;
    }
    .result-student.open .result-chevron { transform: rotate(90deg); }
    .result-who { font-size: var(--text-sm); }
    .result-student-head .result-when { margin-left: auto; }
    .result-student-body {
      border-top: 1px solid var(--border);
      padding: var(--space-1) var(--space-4) var(--space-3);
    }
    .result-student-body .result-row { border: none; padding: var(--space-3) 0 0; }
    .result-student-body .result-row + .result-row {
      border-top: 1px solid var(--border);
    }

    /* Nobody's verdict, so nothing from the judgement vocabulary: not
       having defended yet is a fact about the calendar. */
    .result-undefended {
      border: 1px dashed var(--border);
      border-radius: var(--radius-lg);
      padding: var(--space-3) var(--space-4);
      margin-bottom: var(--space-2);
    }
    .result-undefended-head {
      font-size: var(--text-sm);
      color: var(--muted);
      margin-bottom: var(--space-2);
    }
    .result-undefended-list {
      display: flex;
      flex-wrap: wrap;
      gap: var(--space-2);
    }
    .result-undefended-name {
      font-size: var(--text-xs);
      color: var(--text);
      background: var(--raised);
      border: 1px solid var(--border);
      border-radius: var(--radius-pill);
      padding: var(--space-0) var(--space-3);
    }

    .result-row {
      flex-direction: column;
      align-items: stretch;
      gap: var(--space-3);
    }
    .result-head {
      display: flex;
      align-items: center;
      gap: var(--space-3);
      flex-wrap: wrap;
    }
    .result-head .result-when { margin-left: auto; }
    /* The verdict chip wears the verification vocabulary colours, not
       the UI palette — same argument as the session card: NOT PASS is
       "not yet", never alarm-red, and a not-assessed row carries no
       judgement colour at all. */
    /* The verdict is `badge badge-strong badge-<tone>` now. Its one
       irreplaceable property — never wrapping INSIDE the badge, after
       "Not pass · ended early" broke across three lines on a phone and
       made one card twice the height of its neighbours — moved onto
       .badge, where every badge gets it. */
    .result-block {
      border-top: 1px solid var(--border);
      padding-top: var(--space-2);
    }
    .result-block-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: var(--space-3);
      color: var(--muted);
      font-size: var(--text-xs);
      margin-bottom: var(--space-2);
    }
    /* pre, so the model's numbering and line breaks survive exactly as
       the copy button would paste them — the screen shows what the
       clipboard gets. Wrapped, because review questions are sentences,
       not code. */
    .result-text {
      margin: 0;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      font-family: inherit;
      font-size: var(--text-sm);
      line-height: 1.5;
    }

    /* H.1b: a question item, rendered rather than printed.
       DISTINGUISHED BY ROLE, NOT BY VALENCE, and that is a decision
       rather than a palette. Green for "Listen for" and red for "Thin
       answer" was drawn and rejected: it reads as pass/fail at a
       glance, which would put back through CSS exactly the verdict
       these prompts are forbidden from stating (E.54). The question
       carries the weight; the supporting lines are one muted register
       below it and differ from each other only by their label.
       THE LABEL COLUMN IS FIXED-WIDTH ON PURPOSE. It is what lets a
       TA drop their eye to "THIN ANSWER" without reading the line
       above it — the difference between scanning and reading, in a
       room, while somebody is talking. */
    /* ONE DEFINITION OF THE LABEL COLUMN'S WIDTH, because three
       rules depend on it agreeing: the label's own min-width, the gap
       after it, and the hanging indent that lines a wrapped note up
       under its text. Three literals would drift the moment a label
       is renamed and the column has to widen. */
    .qa-list { --qa-lab-min: 88px; --qa-lab-gap: 10px; }
    .qa-item { margin: 0 0 var(--space-3); }
    .qa-item:last-child { margin-bottom: 0; }

    /* HANGING INDENTS, on the question and on every note. A question
       that wrapped used to put its second line under the NUMBER, and
       a note its second line under the LABEL, so a two-line item read
       as two elements — the opposite of what rendering these was for.
       Found by reading the screenshot; no assertion in the suite sees
       where a line wraps. */
    .qa-q {
      font-size: var(--text-sm);
      line-height: 1.45;
      color: var(--text);
      overflow-wrap: anywhere;
      padding-left: var(--space-5);
      text-indent: -24px;
    }
    .qa-n {
      display: inline-block;
      min-width: 24px;
      color: var(--muted);
      /* text-indent IS INHERITED and this inline-block re-applies the
         question's negative indent to its own first line, painting the
         number a full indent LEFT of its box — far enough off the card
         to vanish at phone width. Every inline-block inside a
         hanging-indented line needs this; .qa-lab is the other one. */
      text-indent: 0;
    }

    .qa-notes {
      margin: var(--space-1) 0 0 var(--space-4);
      padding-left: var(--space-3);
      border-left: 2px solid var(--border);
    }
    .qa-row {
      font-size: var(--text-xs);
      line-height: 1.5;
      color: var(--muted);
      margin: var(--space-0) 0;
      overflow-wrap: anywhere;
      padding-left: calc(var(--qa-lab-min) + var(--qa-lab-gap));
      text-indent: calc(-1 * (var(--qa-lab-min) + var(--qa-lab-gap)));
    }
    /* inline-block with a min-width rather than a grid: the label and
       its text have to stay one selectable run so a TA copying by hand
       out of the page gets "Listen for  the condition number...", not
       two fragments. */
    .qa-lab {
      display: inline-block;
      /* CONTENT-BOX, against the page's global border-box, because the
         hanging indent on .qa-row is min-width + gap and has to equal
         this box's OUTER width. Under border-box the gap folds inside
         the min-width, the box comes out 88px instead of 98px, and a
         wrapped note lands 10px right of the line above it — measured
         in the browser, invisible in the CSS. */
      box-sizing: content-box;
      /* Same inherited text-indent reset as .qa-n, and here it is
         worth saying what it looked like: the label's glyphs painted
         a full column-width left of the label's own box, OUTSIDE the
         rail — while getBoundingClientRect still reported the box in
         the right place. The measurement agreed with the CSS and both
         were wrong about the page; only the pixels showed it. */
      text-indent: 0;
      /* min-width ALIGNS the column; padding-right GUARANTEES the gap.
         With min-width alone the longest label ("THIN ANSWER") filled
         the column exactly and its text ran straight into it, while
         the shorter two looked fine — a defect only visible by
         rendering it, which is why the batch's verification reads the
         screenshot rather than trusting the assertions. Padding holds
         whatever the labels are renamed to. */
      min-width: var(--qa-lab-min);
      padding-right: var(--qa-lab-gap);
      font-weight: 600;
      font-size: var(--text-2xs);
      letter-spacing: 0.07em;
      text-transform: uppercase;
      color: var(--muted);
      opacity: 0.85;
    }

    /* Narrow screens: the fixed label column costs more than it buys
       once the text has only a few words per line, so the label goes
       on its own line and the indent shrinks. */
    @media (max-width: 560px) {
      .qa-notes { margin-left: var(--space-2); padding-left: var(--space-2); }
      .qa-lab { display: block; min-width: 0; margin-bottom: 1px; }
      /* The label is on its own line here, so there is no column to
         hang under — the indent would only eat the width the narrow
         screen does not have. */
      .qa-row { padding-left: 0; text-indent: 0; }
    }

    /* G.1b. Sits under the lookup row and is revealed only once a
       lookup has run, so the cost note and the "what is already here"
       line are always read before the button can be pressed. */
    /* G.1d: the class list. One row per student, their sheets nested,
       and Prepare beside the row's own count of what already exists. */
    .script-summary {
      font-size: var(--text-xs);
      color: var(--muted);
    }
    /* Sits above the search row, with the summary: both are facts about
       the class as a whole rather than about any one student. NOT
       muted, unlike the summary directly above it — this one says what
       pressing Prepare will spend, and the markup carried a `muted`
       class for a while that styled nothing at all (there is no bare
       .muted rule in this file, only component-scoped ones), which is
       a caption's appearance claimed without a caption's colour. */
    .script-coverage {
      font-size: var(--text-xs);
      margin: 0 0 var(--space-3);
    }

    /* THE HEADER AND THE BUTTON ARE SIBLINGS. The header is itself a
       <button> (it toggles the row open), so the Prepare control cannot
       live inside it — a button in a button is invalid and unreachable
       by keyboard. This row is what puts them side by side instead. */
    .script-student-headrow {
      display: flex;
      align-items: center;
      gap: var(--space-2);
    }
    .script-student-headrow > .result-student-head { flex: 1 1 auto; }

    .script-row-prepare {
      flex: 0 0 auto;
      font-size: var(--text-xs);
      padding: var(--space-2) var(--space-3);
      white-space: nowrap;
    }
    .script-row-note {
      flex: 0 0 auto;
      font-size: var(--text-xs);
      padding-right: var(--space-1);
      white-space: nowrap;
    }

    /* The count is the fact the old screen made you run a lookup to
       see, so it is not muted into the furniture — and "No sheet yet"
       is the actionable state, which is why it is the one that gets the
       attention colour rather than a warning one. Nothing here is a
       verdict about a student: it is a fact about a TA's own progress. */
    .script-count {
      font-size: var(--text-xs);
      color: var(--muted);
      white-space: nowrap;
    }
    .script-count-none {
      color: var(--accent);
      font-weight: 600;
    }

    @media (max-width: 560px) {
      /* The button drops under the header rather than squeezing the
         NetID off the line. */
      .script-student-headrow { flex-wrap: wrap; }
      .script-student-headrow > .result-student-head { flex: 1 1 100%; }
      .script-row-prepare { margin: 0 0 var(--space-2) var(--space-5); }
      .script-row-note { margin: 0 0 var(--space-2) var(--space-5); }
    }

    /* A CONTROL OVER THE NARROW LIST, so it takes the list's cap and
       not a copy of the number. Same rule .list-toolbar-wide states
       below: the control follows whichever cap its list uses. */
    .invite-row {
      display: flex;
      gap: var(--space-2);
      justify-content: center;
      width: 100%;
      max-width: var(--list-narrow);
      margin: 0 auto;
    }
    .invite-row input,
    .invite-row select {
      font-size: var(--text-sm);
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-md);
      border: 1px solid var(--border);
      background: var(--panel);
      color: var(--text);
    }
    .invite-row input { flex: 1 1 auto; min-width: 0; }
    .invite-row input:focus-visible,
    .invite-row select:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
    }

    /* ---- Users screen (admin) ---- */
    .users-list { max-width: var(--list-wide); }
    /* THE SAME MISTAKE ONE SCREEN OVER, and the inverse of the roster's:
       #userList opts out of --list-narrow, so a toolbar capped at 440px
       sat 60px narrower than the 560px rows beneath it. The toolbar
       follows whichever cap its list uses, never a number of its own. */
    .list-toolbar-wide {
      max-width: var(--list-wide);
      margin-left: auto;
      margin-right: auto;
    }
    .user-grant-controls {
      display: flex;
      gap: var(--space-2);
      align-items: center;
      flex-wrap: wrap;
      justify-content: flex-end;
    }
    .user-grant-controls select,
    .user-grant-controls input {
      font-size: var(--text-xs);
      padding: var(--space-1) var(--space-2);
      border-radius: var(--radius-md);
      border: 1px solid var(--border);
      background: var(--panel);
      color: var(--text);
    }
    .user-grant-controls input { width: 54px; }

    /* ---- Submissions screen (instructor) ----
       Same 440px column as the roster for the same reason the materials
       list copies it: same kind of place, reached the same way. The
       match table is the exception — it earns the full content width
       because a filename, a chip and a dropdown per row do not fit in
       440px, and truncated filenames are the one thing this screen
       must never show: the filename IS the evidence for the match. */
    .section-heading {
      font-size: var(--text-sm);
      letter-spacing: 0.06em;
      text-transform: uppercase;
      color: var(--muted);
      margin: var(--space-5) 0 var(--space-2);
      text-align: center;
    }
    /* NOT --list-wide, which is the Users screen's ROW width and has
       nothing to do with this screen. These are control rows above a
       match table that deliberately takes the full content width. */
    .single-upload-row, .bulk-inputs {
      display: flex;
      gap: var(--space-2);
      align-items: center;
      justify-content: center;
      flex-wrap: wrap;
      width: 100%;
      max-width: 560px;
      margin: 0 auto;
    }
    .single-upload-row select,
    .single-upload-row input[type="text"] {
      font-size: var(--text-sm);
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-md);
      border: 1px solid var(--border);
      background: var(--panel);
      color: var(--text);
    }
    .bulk-label {
      font-size: var(--text-xs);
      color: var(--muted);
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
      text-align: left;
    }
    .match-summary {
      margin: var(--space-4) auto var(--space-2);
      font-size: var(--text-sm);
      color: var(--muted);
      text-align: center;
    }
    .match-rows {
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
      width: 100%;
      max-width: 720px;
      margin: 0 auto var(--space-3);
      text-align: left;
    }
    .match-row {
      display: flex;
      align-items: center;
      gap: var(--space-3);
      padding: var(--space-2) var(--space-3);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--panel);
      font-size: var(--text-sm);
    }
    .match-row .match-name {
      flex: 1 1 auto;
      min-width: 0;
      overflow-wrap: anywhere;
    }
    .match-row .muted { color: var(--muted); font-size: var(--text-xs); }
    .match-row select {
      font-size: var(--text-sm);
      padding: var(--space-1) var(--space-2);
      border-radius: var(--radius-md);
      border: 1px solid var(--border);
      background: var(--panel);
      color: var(--text);
      max-width: 180px;
    }
    /* Submission state is `badge badge-ok` / `badge-info` /
       `badge-warn` / `badge-quiet` now — see statusBadge in app.js for
       which state takes which tone, and the badge block above for what
       each tone means. Off-roster is the typo flag and takes warn: it
       is accepted, it just wants a second look. */
    .bulk-outcome {
      width: 100%;
      max-width: 720px;
      margin: var(--space-3) auto 0;
      font-size: var(--text-sm);
      text-align: left;
    }
    .bulk-outcome .outcome-error { color: var(--amber); }

    /* ---- Assignment materials (screen 1c) ----
       Same measurements as .data-list / .data-row on purpose: the
       two screens are the same kind of thing reached the same way, and
       a different width would read as a different kind of place.
       *** THE 440px CAP CAME OFF IN M.3. *** Every one of these
       components capped itself at 440 independently, which was right
       while a separate materials SCREEN existed beside the form. Batch
       4 deleted that screen ("files live in the assignment form, not a
       screen of their own") and nothing noticed: seven copies of one
       number, six of them now describing a place that does not exist.
       They inherit the form's width instead, so --form-wide is the one
       number and widening the form widens what is inside it. */
    .material-list {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
      width: 100%;
      margin: 0 0 var(--space-4);
    }
    .material-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: var(--space-3);
      padding: var(--space-2) var(--space-3);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--panel);
      font-size: var(--text-sm);
      text-align: left;
    }
    .material-row .muted { color: var(--muted); font-size: var(--text-xs); }

    /* D.3: the rubric editor (form) and the student-facing criteria
       list (upload screen). Editor rows mirror .material-row's frame
       but stack label over text, because a criterion is a paragraph
       rather than a filename. */
    .rubric-row {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
      padding: var(--space-3) var(--space-3);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--panel);
      text-align: left;
    }
    .rubric-row input[type="text"] {
      font-size: var(--text-sm);
      font-weight: 600;
    }
    .rubric-row textarea { font-size: var(--text-sm); }
    .rubric-row .rubric-row-foot {
      display: flex;
      justify-content: flex-end;
    }
    .rubric-row-default {
      opacity: 0.75;
    }
    .rubric-row-default .rubric-default-label {
      font-size: var(--text-sm);
      font-weight: 600;
    }
    .rubric-row-default .rubric-default-text {
      color: var(--muted);
      font-size: var(--text-xs);
      line-height: 1.45;
      white-space: pre-wrap;
    }
    .checkbox-row {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      width: 100%;
      margin: 0 0 var(--space-4);
      font-size: var(--text-sm);
      color: var(--muted);
    }
    .checkbox-row input { width: auto; margin: 0; }

    /* H.2: one defense's conversation, under the attempt it belongs to.
       Indented from the result blocks above it because it is EVIDENCE
       for them rather than another of them — the verdict and the
       feedback are what the system said, this is what was said to
       produce them. */
    .transcript-block {
      margin: var(--space-2) 0 var(--space-1);
      padding: var(--space-3) var(--space-3);
      border-left: 3px solid var(--border);
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
      font-size: var(--text-sm);
      line-height: 1.5;
      max-height: 460px;
      overflow-y: auto;
    }
    /* Hanging indent, so a wrapped answer stays under its own text and
       not under the speaker's name. text-indent is INHERITED and an
       inline-block re-applies it (9.54), so .transcript-who resets it
       — the bug that shipped twice in E.54 and was measured wrong
       twice before anybody looked at the pixels. */
    .transcript-turn {
      padding-left: 84px;
      text-indent: -84px;
    }
    .transcript-who {
      display: inline-block;
      box-sizing: content-box;
      width: 72px;
      text-indent: 0;
      font-size: var(--text-2xs);
      letter-spacing: 0.04em;
      text-transform: uppercase;
      color: var(--muted);
      vertical-align: baseline;
    }
    .transcript-empty { margin: var(--space-2) 0 var(--space-1); font-size: var(--text-sm); }

    @media (max-width: 560px) {
      /* At phone width 84px of indent leaves too little for the words,
         so the speaker goes on its own line instead. */
      .transcript-turn { padding-left: 0; text-indent: 0; }
      .transcript-who { display: block; width: auto; margin-bottom: var(--space-0); }
    }

    /* H.1c: the question-coverage control, under the rubric it
       multiplies. Same 440px column as every other field in this form
       so the label rail stays straight. */
    .coverage-row {
      width: 100%;
      margin: 0 0 var(--space-4);
    }
    .coverage-controls {
      display: flex;
      flex-wrap: wrap;
      gap: var(--space-2);
      align-items: center;
    }
    /* THE WRAP AND THE min-width ARE BOTH LOAD-BEARING AT 390px. A
       <select>'s automatic minimum size is its WIDEST OPTION, not its
       shortest, so "General, about the whole submission" refuses to
       shrink and pushes the number box off the right edge of the phone
       — which is what it did until this was measured. min-width: 0
       lets it shrink; the basis plus wrap sends the number box to its
       own line rather than letting the select ellipsise its own text. */
    .coverage-controls select {
      flex: 1 1 220px;
      min-width: 0;
      margin: 0;
    }
    /* Wide enough for two digits and the spinner, no wider: the count
       is a small number and a full-width box would imply otherwise. */
    .coverage-controls input { flex: 0 0 110px; margin: 0; }

    /* E.4: the negotiated-capture diagnostic under the session
       controls. Deliberately small and muted — it is evidence for
       debugging, not interface. */
    .mic-diagnostic {
      margin-top: var(--space-2);
      font-size: var(--text-2xs);
      color: var(--muted);
      text-align: left;
      overflow-wrap: anywhere;
    }

    /* D.2: the formative note under the verdict. Visually quieter than
       the banner — coaching, not judgement. */
    .feedback-block {
      margin-top: var(--space-3);
      padding: var(--space-3) var(--space-3);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      text-align: left;
    }
    .feedback-heading {
      font-size: var(--text-xs);
      font-weight: 600;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      color: var(--muted);
      margin-bottom: var(--space-2);
    }
    .feedback-text {
      font-size: var(--text-sm);
      line-height: 1.55;
      margin: 0;
      white-space: pre-wrap;
    }

    /* C6: the late correction. Shares the feedback block's shape so it
       reads as part of the same card, and is SET APART from it by the
       accent rule on the leading edge — the note coaches and says
       nothing about content; this one says what was wrong. A student
       must be able to see at a glance that they are two different
       kinds of thing. */
    .correction-block {
      margin-top: var(--space-3);
      padding: var(--space-3) var(--space-3);
      border: 1px solid var(--border);
      border-left: 3px solid var(--accent);
      border-radius: var(--radius-lg);
      text-align: left;
    }
    .correction-heading {
      font-size: var(--text-xs);
      font-weight: 600;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      color: var(--muted);
      margin-bottom: var(--space-2);
    }
    /* Why it is arriving only now. Quieter than the correction itself:
       it is context, not the thing being said. */
    .correction-why {
      font-size: var(--text-xs);
      line-height: 1.5;
      color: var(--muted);
      margin: 0 0 var(--space-2);
    }
    .correction-text {
      font-size: var(--text-sm);
      line-height: 1.55;
      margin: 0;
      white-space: pre-wrap;
    }

    .upload-rubric {
      margin: 0 0 var(--space-4);
      padding: var(--space-3) var(--space-3);
      border: 1px solid var(--border);
      border-radius: var(--radius-lg);
      text-align: left;
    }
    .upload-rubric h3 {
      font-size: var(--text-sm);
      font-weight: 600;
      margin: 0 0 var(--space-2);
    }
    .upload-rubric .rubric-item { margin: 0 0 var(--space-3); }
    .upload-rubric .rubric-item:last-child { margin-bottom: 0; }
    .upload-rubric .rubric-item-label {
      font-size: var(--text-sm);
      font-weight: 600;
      margin: 0 0 var(--space-0);
    }
    .upload-rubric .rubric-item-text {
      color: var(--muted);
      font-size: var(--text-xs);
      line-height: 1.5;
      margin: 0;
      white-space: pre-wrap;
    }

    .materials-note {
      width: 100%;
      margin: 0 0 var(--space-4);
      padding: var(--space-3) var(--space-3);
      border: 1px dashed var(--border);
      border-radius: var(--radius-md);
      color: var(--muted);
      font-size: var(--text-sm);
      text-align: left;
    }

    /* The budget is a token cost paid per turn, not a disk quota, so it
       is shown filling up rather than only reported once it is blown. */
    .budget {
      width: 100%;
      margin: 0 0 var(--space-4);
    }
    .budget-track {
      height: 6px;
      border-radius: var(--radius-xs);
      background: var(--border);
      overflow: hidden;
    }
    .budget-fill {
      height: 100%;
      width: 0;
      background: var(--accent);
      transition: width 120ms ease-out;
    }
    .budget-full { background: var(--danger); }
    .budget-label {
      margin-top: var(--space-2);
      color: var(--muted);
      font-size: var(--text-xs);
      text-align: left;
    }

    .upload-row {
      display: flex;
      gap: var(--space-2);
      /* LEFT, NOT CENTRED (M.3). Centring was how a 440px control
         stayed under a 440px field; inside a wider form it floats the
         Add-file button away from the label rail every other control
         lines up against. */
      justify-content: flex-start;
      align-items: center;
      width: 100%;
      margin: 0;
    }
    .upload-row input[type="file"] {
      flex: 1 1 auto;
      min-width: 0;
      font-size: var(--text-sm);
      color: var(--text);
    }
    .upload-row input[type="file"]:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
    }
    /* The file input takes the slack; the button keeps its own size.
       Without this it is squeezed and "Add file" wraps onto two lines —
       seen in a browser, invisible to a source check. */
    .upload-row button { flex: 0 0 auto; white-space: nowrap; }

    /* .empty-state is 60px of vertical padding, which is right for an
       empty screen and absurd for an empty list inside a form. */
    .material-list .empty-state { padding: var(--space-3) var(--space-3); font-size: var(--text-sm); }

    /* Transcript review is defined but ungranted pending the IRB data
       policy (B.1-B.3). The control is shown disabled rather than hidden
       so the gap stays visible and does not get quietly designed away. */
    .pending-feature {
      opacity: 0.45;
      cursor: not-allowed;
    }

    /* ---- Inline forms ---- */
    /* Replaces the browser prompt() chain used for course and assignment
       creation. prompt() is serial and unforgiving: you cannot see the
       fields you have already filled, cannot go back, cannot tell which
       are optional, and a mis-typed second field means starting over.
       It also cannot be styled, so it looked like a browser artifact
       rather than part of the app. */
    .inline-form {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
      width: 100%;
      max-width: var(--form-wide);
      margin: 0 auto var(--space-5);
      padding: var(--space-4);
      text-align: left;
      background: var(--panel);
      border: 1px solid var(--accent-soft);
      border-radius: var(--radius-lg);
      box-sizing: border-box;
    }
    .inline-form[hidden] { display: none; }

    /* J.2: THE SETTINGS SCREEN IS THE FORM, so its title has to belong
       to the form. Every other screen's page header sits over content
       that fills the readable column, and left-aligning it against a
       full-width grid is right. This screen holds ONE centred card, and
       a heading pinned to the column's left edge floated three hundred
       pixels away from the thing it names — found by looking at the
       page (9.54), not by any assertion about it.
       BOTH READ --form-wide (M.3) so the two cannot drift when the
       form's width changes; they did not, and widening the form is
       exactly when they would have. Below that width both are full
       width, so this changes nothing on a phone. */
    #settingsScreen .page-header {
      /* width: 100% IS LOAD-BEARING, and measuring is the only way to
         find out. .menu-content is a flex column, and an item with an
         AUTO CROSS-AXIS MARGIN does not stretch — the auto margins
         absorb the free space instead and the item shrink-wraps. With
         max-width and auto margins alone this header collapsed to the
         width of the word "Settings" (78.9px) and centred that, which
         looks deliberate and is not. */
      width: 100%;
      max-width: var(--form-wide);
      margin-left: auto;
      margin-right: auto;
    }
    .inline-form h2 {
      font-size: var(--text-md);
      font-weight: 600;
      margin: 0 0 var(--space-2);
    }
    .inline-form label {
      font-size: var(--text-xs);
      color: var(--muted);
      margin-top: var(--space-2);
    }
    /* K.2b: the kind of the assignment being edited, when it is not
       the default. A quiet line under the heading rather than a field,
       because there is nothing here to change. */
    .form-kind {
      margin: 0 0 var(--space-3);
      font-size: var(--text-2xs);
      color: var(--muted);
    }
    .inline-form .optional {
      font-size: var(--text-2xs);
      opacity: 0.7;
      font-style: italic;
    }
    .inline-form input,
    .inline-form textarea,
    /* E.145: the sheet row's notes field. Joined here rather than given
       its own copy of these declarations — it is the same control. */
    .script-notes-input {
      font: inherit;
      font-size: var(--text-sm);
      padding: var(--space-2) var(--space-3);
      border-radius: var(--radius-md);
      border: 1px solid var(--border);
      background: var(--bg);
      color: var(--text);
      width: 100%;
      box-sizing: border-box;
    }
    .inline-form textarea,
    .script-notes-input { resize: vertical; min-height: 62px; }
    .inline-form input:focus-visible,
    .inline-form textarea:focus-visible,
    .script-notes-input:focus-visible {
      outline: 2px solid var(--accent);
      outline-offset: 1px;
      border-color: var(--accent);
    }
    /* Errors sit with the form rather than in the page-level banner, so
       the message is next to the field the person needs to change. */
    /* M.3: A SECTION INSIDE A FORM.
       The owner's words about these screens were "very bad", and the
       two things they named were wasted space and no grouping. Width
       (--form-wide) answers the first; this answers the second.
       A RULE AND A HEADING, NOT A BOX. A bordered card per section
       would nest a panel inside a panel and add two more edges to a
       screen that already has the form's own. The rule is the lightest
       mark that says "a new thing starts here", and the heading says
       what it is. The first section has no rule: a line above the first
       item separates it from nothing.
       THE HEADINGS ARE NOT <h3>. Every one of them would sit under the
       form's <h2>, which is right semantically — but these are
       GROUPING LABELS for controls, and <legend> inside <fieldset> is
       what that means in HTML. Left as a <p> with a class rather than
       a fieldset because fieldset carries layout behaviour (it is not a
       flex container, and its min-width is auto) that would fight the
       form's flex column for nothing an instructor can perceive.
       Recorded so the next person does not think it was an oversight. */
    .form-section {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
    }
    /* THE RULE GOES ON THE JOIN, NOT ON THE SECTION. The obvious
       spelling is a top border on every section and :first-of-type to
       cancel it — but :first-of-type means "first DIV among its
       siblings", not "first section", so it silently stops cancelling
       the day any other div is added above. The adjacent-sibling form
       says what is actually meant: a separator exists BETWEEN two
       sections and nowhere else. */
    .form-section + .form-section {
      /* --space-4 AND NOT --space-5, which is what the first draft used
         and what MEASURING corrected: three joins at 24+24 cost 144px
         of a form whose whole problem is height, and 16+16 reads as the
         same separation. */
      margin-top: var(--space-4);
      padding-top: var(--space-4);
      border-top: 1px solid var(--border);
    }
    .form-section[hidden] { display: none; }
    /* *** ONE SPACING MECHANISM INSIDE A FORM, NOT TWO. *** Each of
       these carries its own margin-bottom from when it sat on a screen
       of its own; inside a flex column that already has a gap, the two
       add up and every note pays 24px where the rest of the form pays
       8. Found by measuring, not by reading. */
    .form-section > .material-list,
    .form-section > .materials-note,
    .form-section > .budget,
    .form-section > .coverage-row,
    .form-section > .checkbox-row { margin-bottom: 0; }
    /* The label for the group, in the same register as the micro-labels
       the rest of the app uses for exactly this job (LISTEN FOR, TESTS).
       Caps and letter-spacing carry the hierarchy so the SIZE does not
       have to — a larger heading here would compete with the form's
       own title. */
    /* The default rubric, behind a disclosure: reference material in the
       one branch where nobody is editing it. The summary carries the
       count, which is the part that has to stay visible. */
    .rubric-disclosure > summary {
      cursor: pointer;
      font-size: var(--text-xs);
      color: var(--muted);
      padding: var(--space-2) 0;
    }
    .rubric-disclosure[open] > summary { margin-bottom: var(--space-2); }
    /* E.144: a student's EARLIER question sheets, behind the same
       disclosure the default rubric uses. Deliberately the same
       control rather than a second kind of "show more": a TA who has
       met one on the assignment form has met this one. The count lives
       in the summary for that pattern's own reason — it is the part
       that has to stay readable while the rows are hidden. */
    .script-version-disclosure > summary {
      cursor: pointer;
      font-size: var(--text-xs);
      color: var(--muted);
      padding: var(--space-2) 0;
    }
    .script-version-disclosure[open] > summary {
      margin-bottom: var(--space-2);
    }
    /* THE HANDOUT, SHOWN AS THE TEXT IT IS (E.144). Monospace, and
       that is not decoration: the ruled answer lines are drawn with
       underscores and the grade block is a column of "____ / 2", so a
       proportional face turns the document a TA is about to hand over
       into a ragged one on screen while the file stays straight. The
       one place in this stylesheet that asks for a fixed pitch, so the
       stack is written here rather than made a token nothing else
       would use. */
    .script-handout {
      font-family: ui-monospace, SFMono-Regular, Menlo, Consolas,
                   "Liberation Mono", monospace;
      font-size: var(--text-xs);
    }
    /* E.145: the TA's own notes on a sheet. The label carries the
       heading in the same micro-label register the rest of the app uses
       for this job (LISTEN FOR, TESTS), and wraps the control so the
       two read as one thing — the limits row's rule. */
    .script-notes {
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
      margin-top: var(--space-3);
    }
    .script-notes-label {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
    }
    .script-notes-title {
      font-size: var(--text-2xs);
      font-weight: 600;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--muted);
    }
    .script-notes-state {
      font-size: var(--text-2xs);
      min-height: 1em;      /* reserves the line, so "Saved" appearing
                               does not shift the row under the cursor */
    }

    /* E.148: the instruction this assignment's sheets are written
       under, edited on the screen where its output is read. Borrows
       .script-notes' whole register deliberately — the micro-label, the
       stacked label, the reserved state line — because it is the same
       kind of control one level up (the assignment's, not the sheet's)
       and two spellings of one thing would be two things to learn. */
    .script-guidance {
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
      margin: 0 0 var(--space-3);
    }
    .script-guidance-label {
      display: flex;
      flex-direction: column;
      gap: var(--space-2);
    }
    .script-guidance-title {
      font-size: var(--text-2xs);
      font-weight: 600;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--muted);
    }
    #scriptGuidanceInput { resize: vertical; min-height: 56px; }
    .script-guidance-note {
      margin: 0;
      font-size: var(--text-2xs);
      /* THE COLOUR, STATED HERE AND NOT BORROWED FROM `muted`.
         There is no bare .muted rule in this file, only
         component-scoped ones — .script-coverage's comment
         records the batch that learned it. */
      color: var(--muted);
    }
    .script-guidance-state {
      font-size: var(--text-2xs);
      color: var(--muted);
      min-height: 1em;      /* the reserved line, .script-notes-state's
                               reason: "Saved" must not push the roster
                               down under the cursor */
    }

    /* E.148: what a sheet was prepared under, stated on the sheet. A
       QUIET BLOCK and not a control: it is a fact about this artefact,
       so it reads like the "Prepared <date>" line rather than like
       something to act on. */
    .script-provenance {
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
      margin-top: var(--space-3);
      padding-left: var(--space-3);
      border-left: 2px solid var(--border);
    }
    .script-provenance-title {
      font-size: var(--text-2xs);
      font-weight: 600;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--muted);
    }
    .script-provenance-text {
      margin: 0;
      font-size: var(--text-xs);
      white-space: pre-wrap;   /* an instruction written as a list keeps
                                  its lines, as the handout block does */
    }
    .script-provenance-stale {
      margin: 0;
      font-size: var(--text-2xs);
      color: var(--muted);
    }
    .form-section-title {
      margin: 0;
      font-size: var(--text-2xs);
      font-weight: 600;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: var(--muted);
    }
    /* The Files heading carries an "optional"/"required" tag that app.js
       rewrites per kind. Uppercased it reads as a second shout beside
       the heading rather than the aside it is. */
    .form-section-title .optional {
      text-transform: none;
      letter-spacing: 0;
    }
    /* A field's own label already has margin-top for rhythm; directly
       under a section heading that doubles the gap the heading just
       made. */
    .form-section-title + label { margin-top: 0; }
    .inline-form-error {
      font-size: var(--text-xs);
      color: var(--danger);
      margin-top: var(--space-2);
      line-height: 1.4;
    }
    .inline-form-error[hidden] { display: none; }
    .inline-form-row {
      display: flex;
      gap: var(--space-3);
    }
    .inline-form-row > div { flex: 1 1 0; min-width: 0; }
    .inline-form-actions {
      display: flex;
      justify-content: flex-end;
      gap: var(--space-2);
      margin-top: var(--space-3);
    }

    @media (max-width: 560px) {
      /* Stacked, because two side-by-side inputs on a phone leave
         neither wide enough to read what you typed. */
      .inline-form-row { flex-direction: column; gap: var(--space-2); }
      .inline-form-actions button { flex: 1 1 0; }
    }
    /* N.3b: THE USAGE DASHBOARD. Three views out of one screen —
       whole course, a set of assignments, a set of students — so the
       parts that change are the band at the top and the rows at the
       bottom, and nothing is a second component that has to be kept in
       step with a first.

       NO NEW TOKENS. The scale is FULL at 9 spacing / 6 type / 5
       radius steps and test_the_scale_has_steps_rather_than_a_value_
       per_use caps it there; every value below is already on it. The
       chips are .results-chip verbatim rather than a lookalike, for
       the reason the shared list helpers exist: two chip components
       drift, and a filter chip is a filter chip. */
    .usage-totals {
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      gap: var(--space-5);
      padding: var(--space-4);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--panel);
    }
    .usage-cell { display: flex; flex-direction: column; gap: var(--space-0); }
    /* The NUMBER first and larger: this band is read by somebody
       scanning for one figure, and a label-first cell makes them read
       five words to find it. */
    .usage-cell-value { font-size: var(--text-lg); }
    .usage-cell-label { color: var(--muted); font-size: var(--text-xs); }
    /* WHAT THESE NUMBERS ARE ABOUT, on its own line and always
       present. A band that silently means something different
       depending on what is selected above it is the failure this one
       sentence prevents. */
    .usage-scope {
      flex-basis: 100%;
      margin: 0;
      color: var(--muted);
      font-size: var(--text-xs);
    }
    /* ---- N.5: the trend chart, shared by Usage and Limits ----------
       COLOURS COME FROM TOKENS, NOT FROM THE SCRIPT. The chart is
       built with createElementNS and carries no style attribute — the
       policy sets no style-src, so an inline style= is refused — and
       putting a hex value in app.js would make the chart the third
       place in this app that knows what the accent colour is, and the
       only one that could not follow a theme. */
    .trend-head {
      display: flex;
      align-items: baseline;
      gap: var(--space-3);
      flex-wrap: wrap;
    }
    .trend-head .form-section-title { margin-bottom: 0; }
    .trend-head select { font-size: var(--text-xs); }
    .trend-scope { margin-top: 0; }
    .trend-host { margin: var(--space-2) 0 var(--space-4); }
    /* A block, so the caption below it is not baseline-aligned against
       an inline image box, and so the measured width is the container's
       rather than the text run's. */
    .trend { display: block; max-width: 100%; }
    .trend-bar { fill: var(--accent); }
    /* The running total reads as a line over the bars rather than as a
       second series beside them, so it is the text colour at a weight
       the bars are not. */
    .trend-line {
      stroke: var(--text);
      stroke-width: 1.5;
      stroke-linejoin: round;
      opacity: 0.55;
    }
    .trend-axis { stroke: var(--border); stroke-width: 1; }
    .trend-tick {
      fill: var(--muted);
      font-size: var(--text-xs);
      font-variant-numeric: tabular-nums;
    }
    /* The left number belongs to the bars and the right to the line,
       so each hugs its own edge and neither drifts into the plot. */
    .trend-left { text-anchor: end; }
    .trend-right { text-anchor: start; }
    .trend-from { text-anchor: start; }
    .trend-to { text-anchor: end; }
    .trend-caption {
      margin: var(--space-1) 0 0;
      font-size: var(--text-xs);
      color: var(--muted);
      line-height: 1.5;
    }

    /* N.4: the provenance of the money. Outside the band, because it is
       about the RATES rather than about the numbers, and because it
       wraps to several lines where .usage-scope is one. Its own top
       margin rather than the band's bottom one: the band is a flex row
       and this sits under the whole screen's first block. */
    .usage-pricing {
      margin: var(--space-2) 0 0;
      font-size: var(--text-xs);
      line-height: 1.5;
      /* Its own colour rather than a `muted` class in the markup:
         there is no bare .muted rule in this stylesheet, only
         component-scoped ones, and a class the page carries that no
         rule can match is an intention the page does not honour. A
         guard says so by name. */
      color: var(--muted);
    }
    /* THE ROW A SET OF .results-chip SITS IN, and it is named for what
       it is rather than for the first screen that needed one. N.3b
       called it .usage-chips; N.3d wanted the same row on the Limits
       screen, and the choice was a second identical rule or an honest
       name. A second rule is how two chip rows come to have different
       gaps, which is the drift the chips themselves already avoid by
       being .results-chip verbatim on all three screens. */
    .chip-row {
      display: flex;
      flex-wrap: wrap;
      gap: var(--space-2);
      margin-bottom: var(--space-3);
    }
    .usage-row { gap: var(--space-3); }
    /* The checkbox and the NetID are one target: a 13px checkbox is
       under every pointer-size floor published, and the label makes
       the whole name clickable without adding a control. */
    .usage-pick {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      min-width: 0;
    }
    .usage-who { overflow: hidden; text-overflow: ellipsis; }
    /* AFTER the name and before the badge, so a column of rows reads
       as a column of numbers. */
    .usage-numbers { margin-left: auto; white-space: nowrap; }

    @media (max-width: 560px) {
      /* The band wraps to two rows rather than scrolling sideways,
         and the gap closes so five cells still fit in the width. */
      .usage-totals { gap: var(--space-3); padding: var(--space-3); }
      .usage-cell-value { font-size: var(--text-md); }
      /* The numbers go under the name: three figures and a NetID do
         not fit on one 390px line, and truncating the NetID would
         hide the one thing the row is identified by. */
      .usage-row { flex-wrap: wrap; }
      .usage-numbers { margin-left: 0; font-size: var(--text-2xs); }
    }

    /* ---- N.3d: the limits screen ----
       WHO CANNOT DEFEND, AND WHY. Structurally the usage screen's
       shape — a band, a chip row, a toolbar, a list — because it is
       read the same way and by the same person. Its own class names
       rather than the .usage-* ones because the two components do
       diverge: usage's band sums spend into five cells, and this one
       counts refusals into however many cells the server has gates.
       The chips and the badges ARE shared (.results-chip,
       .badge/.badge-warn/.badge-quiet), for the reason the shared list
       helpers exist: two chip components drift.

       NO NEW TOKENS. The scale is full at 9 spacing / 6 type / 5
       radius and test_the_scale_has_steps_rather_than_a_value_per_use
       caps it there; every value below is already on it. */
    .limits-band {
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      gap: var(--space-5);
      padding: var(--space-4);
      margin-bottom: var(--space-4);
      border: 1px solid var(--border);
      border-radius: var(--radius-md);
      background: var(--panel);
    }
    /* Number first and larger, like the usage band: this is read by
       somebody scanning for one figure. */
    .limits-stat { display: flex; flex-direction: column; gap: var(--space-0); }
    .limits-stat-value { font-size: var(--text-lg); }
    .limits-stat-label { color: var(--muted); font-size: var(--text-xs); }
    /* WHAT THESE NUMBERS ARE ABOUT, always present. It carries the
       roster sentence too: the payload lists only students a gate has
       met, and a band that did not say so would read as a census of
       the whole class. */
    .limits-scope {
      flex-basis: 100%;
      margin: 0;
      color: var(--muted);
      font-size: var(--text-xs);
    }
    /* The toggle reads as one control with its label, the way the
       usage row's checkbox and NetID do. */
    .limits-only {
      display: flex;
      align-items: center;
      gap: var(--space-2);
      font-size: var(--text-xs);
      color: var(--muted);
      white-space: nowrap;
    }
    /* A ROW IS A BLOCK, NOT A LINE. One student can be standing under
       three gates on four assignments, so .data-row's single centred
       line is overridden into a column here. */
    .limits-row {
      flex-direction: column;
      align-items: stretch;
      justify-content: flex-start;
      gap: var(--space-2);
    }
    .limits-head {
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: var(--space-2);
    }
    .limits-who { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
    .limits-standings {
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
    }
    /* One assignment's rungs, indented under its title so a student
       standing on two assignments does not read as one long list. */
    .limits-group {
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      gap: var(--space-2);
    }
    .limits-asn {
      flex-basis: 100%;
      color: var(--muted);
      font-size: var(--text-2xs);
      letter-spacing: 0.04em;
      text-transform: uppercase;
    }
    /* *** ONE RUNG PER LINE, AND THE FIRST DRAFT PUT THEM SIDE BY
       SIDE. *** Two standings on one line read as one sentence:
       "Attempts · 3 of 3 · needs an instructor  Spacing · 336 of 12
       hours" is two gates saying opposite things, and the screenshot
       is the only place that showed it. Every rung already separates
       its own parts with a middot, so there was no separator left to
       tell them apart with. */
    /* A ROW OF ITS OWN, AND IT CAN HOLD A CONTROL (N.3e). The rung
       carries its words, then a Release button, then — when that button
       is pressed — the panel, which takes the full width underneath
       rather than squeezing the sentence it belongs to. */
    .limits-standing {
      flex-basis: 100%;
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      gap: var(--space-2);
      color: var(--muted);
      font-size: var(--text-xs);
    }
    /* THE ONE THAT IS ACTUALLY REFUSING, in the amber its badge wears.
       Not colour alone: the line already carries the gate's name and
       the sentence saying whether waiting fixes it, so a reader who
       cannot see the tone loses nothing. */
    .limits-standing.blocked { color: var(--amber); }
    .limits-released { border-color: var(--accent-edge); color: var(--accent); }

    /* ---- N.3e: the controls, and the record they leave ----
       Small and quiet, because the screen is read far more often than
       it is acted on: one blocked student in a course of 350 is the
       shape this list has, and a row of loud buttons beside 349 people
       who are fine would be the wrong emphasis. */
    .limits-release-btn {
      padding: var(--space-0) var(--space-2);
      font-size: var(--text-2xs);
      border-radius: var(--radius-sm);
    }
    /* THE PANEL TAKES THE WHOLE LINE UNDER ITS RUNG. Inline would push
       the sentence the instructor is acting on off the edge, and the
       one thing they must still be able to read while typing a reason
       is which gate they are releasing. */
    .limits-panel {
      flex-basis: 100%;
      display: flex;
      flex-wrap: wrap;
      align-items: flex-end;
      gap: var(--space-3);
      margin-top: var(--space-1);
      padding: var(--space-3);
      border: 1px solid var(--accent-edge);
      border-radius: var(--radius-sm);
      background: var(--raised);
    }
    .limits-panel-field {
      display: flex;
      flex-direction: column;
      gap: var(--space-1);
      font-size: var(--text-2xs);
      color: var(--muted);
    }
    /* The reason grows and the number does not: one is a sentence and
       one is a digit, and a 200px box for "2" reads as though more were
       expected. */
    .limits-panel-field:last-of-type { flex: 1 1 var(--list-narrow); }
    .limits-panel-actions { display: flex; gap: var(--space-2); }
    /* ONE LINE OF THE RECORD. Quiet by default, because it is context
       rather than the answer: the rungs above it say who is blocked,
       and this says who already did something about it. */
    .limits-trail {
      flex-basis: 100%;
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      gap: var(--space-2);
      color: var(--accent);
      font-size: var(--text-2xs);
    }
    /* WITHDRAWN, AND STILL THERE — the dashed convention .badge-quiet
       and the pending invitation row already use for "provisional or
       past". Greyed rather than struck through: a struck line reads as
       a mistake, and this is a decision somebody made and then
       unmade. */
    .limits-trail.revoked {
      color: var(--muted);
      border-top: 1px dashed var(--border);
      padding-top: var(--space-1);
    }

    @media (max-width: 560px) {
      /* The band wraps rather than scrolling sideways, and the gap
         closes so four cells still fit across 390px. */
      .limits-band { gap: var(--space-3); padding: var(--space-3); }
      .limits-stat-value { font-size: var(--text-md); }
      /* The rungs already stack at every width; on a phone they also
         shrink, because "Attempts · 3 of 3 · needs an instructor" is
         a 40-character line inside a 358px column. */
      .limits-standing { font-size: var(--text-2xs); }
      /* The head wraps rather than squeezing the NetID: a truncated
         NetID takes away the one thing the row is identified by. */
      .limits-head { row-gap: var(--space-1); }
      /* The panel's fields stack: a number box and a sentence box do
         not share a 358px line, and the reason is the one that must be
         wide enough to read back what was typed. */
      .limits-panel { flex-direction: column; align-items: stretch; }
      .limits-panel-field:last-of-type { flex: 1 1 auto; }
    }
