/* ============================================================================
   Govnix Satellite design system — the ONE canonical stylesheet
   ----------------------------------------------------------------------------
   This is the single source of all visual definition, and the only stylesheet
   any page loads: the specimen sheet in kitchen-sink.html and the screens in
   screens/ alike. There is deliberately no second file.

   The reason is not tidiness. A page that loads two stylesheets can use a class
   that one of them never defines, and that does not fail — it silently renders
   at the browser default. Review cannot see it, because there is nothing to
   compare against: the specimen and the screen simply disagree, quietly. Every
   class this project uses is defined here, and validate_html.py checks that
   every page loads this file and nothing else.

   It has two layers, in this order:

     1. TOKENS AND BASE COMPONENTS — palette, type, spacing, shape, and the
        controls: buttons, fields, tables, pills, dialogs.
     2. THE APPLICATION LAYER — the page shell and the composites assembled from
        those bases, further down under its own banner.

   It is used three ways:

     1. As the real stylesheet for the eventual application.
     2. Rendered by design/kitchen-sink.html into a screenshot, which is passed
        to the mockup generator as a reference image — so the mockups are
        conditioned on what a browser actually draws, not on prose.
     3. Serialised into the generation prompt as a compact token block.

   Two rules carry the whole language, and they say the same thing twice:

     ROUNDED MEANS INTERACTIVE.  Structure is square; controls are round.
     SHADOWED MEANS INTERACTIVE. Raised = click it. Recessed = type into it.
                                 Nothing else has a shadow.

   THE TWO PLANES. Govnix Satellite is drawn as a lit instrument panel hanging in
   the dark: the FRAME — the header, the navigation rail, the tab bar, and the
   dark bars inside a page — is deep space, and everything you work ON is a light
   panel standing on it. Nothing is half-lit. A surface is either `--brand-space`
   (or its deeper and raised steps) with `--brand-on-dark*` written on it, or
   `--brand-surface` with `--brand-ink*`. The one accent that crosses between them
   is the azure, which has a bright step for the dark plane and a deep one for the
   light, because a single value cannot carry a word on both.
   ========================================================================= */

:root {
  /* --- How every tint and shade below is made --------------------------- *
   *
   * A colour family is one hue at three depths, and the two outer depths are MIXES of the base
   * rather than values chosen by eye:
   *
   *   the tint   color-mix(in srgb, <base> 20%, #ffffff)   less saturated, light enough to carry ink
   *   the ink    color-mix(in srgb, <base> 40%, #000000)   more saturated, dark enough to read
   *
   * The base is the only hand-picked value in a family. Everything else follows from it, so a
   * hue can be changed in one place and its tint and ink stay in step with it. Mixing the ink
   * toward BLACK rather than toward a grey is what keeps it saturated: a grey mix would wash the
   * hue out exactly where the small type needs it most.
   *
   * These are not arbitrary percentages, and they are unchanged from the palette this system grew
   * out of: a tint is exactly 20% of its base over white and an ink exactly 40% of its base over
   * black, which is what `--brand-azure-tint`/`--brand-azure-ink` and
   * `--brand-violet-tint`/`--brand-violet-ink` both compute to, to the byte.
   *
   * THE FORMULA IS A FACT ABOUT THE LIGHT PLANE, and that is the whole reason it survives the move
   * to a dark frame. Mixing a tint toward white and an ink toward black only makes sense where the
   * thing being tinted is a LIGHT panel. The dark plane therefore does not use it: a colour written
   * on the frame is named directly, as `--brand-on-dark*`, because "20% of azure over white" would
   * be a pale chip on a navy ground and there is no arithmetic that turns a light-plane tint into a
   * dark-plane one. Two planes, two ways of naming a colour, and no rule pretending to cover both.
   * -------------------------------------------------------------------- */

  /* --- Brand: AZURE ----------------------------------------------------- *
   * The one hue that carries a word: a heading, a link, the selected navigation item, the filled
   * action. It is a blue, and the reason is the frame rather than the fashion — the accent has to
   * be legible standing on deep space, and a hue that is already dark has nowhere to go.
   *
   * IT HAS TWO STEPS, and this is the only family in the file that does. `--brand-azure` is the
   * accent on a LIGHT panel; `--brand-azure-bright` is the same accent on the DARK frame. Neither
   * is a tint of the other and they are not interchangeable: #2563eb on the navy header measures
   * 1.9:1 and is unreadable, and #6cb8ff on white measures 1.8:1 and is worse. The system used to
   * get away with one value because the frame was near-black and the accent was azure, and an
   * azure happens to sit at a lightness that works on both. Blue does not, so the plane decides.
   * ---------------------------------------------------------------------- */
  --brand-azure: #2563eb;           /* the accent on the light plane: headings, links, selected nav */
  --brand-azure-bright: #6cb8ff;    /* the same accent on the dark frame; 7.91:1 on `--brand-space` */
  --brand-azure-deep: #1d4ed8;      /* action fill; white text sits on this at 6.70:1 */
  --brand-azure-deep-hover: #1a44c2;   /* white on this is 7.93:1 */
  --brand-azure-deep-active: #15379c;  /* and 10.20:1 here */
  --brand-azure-tint: color-mix(in srgb, var(--brand-azure) 20%, #ffffff);      /* --bs-primary-bg-subtle */
  --brand-azure-tint-deep: #b3c8f8; /* the pressed state, one step deeper than the hover tint */
  --brand-azure-ink: color-mix(in srgb, var(--brand-azure) 40%, #000000);  /* --bs-primary-text-emphasis */

  /* --- Secondary: VIOLET ------------------------------------------------- *
   * The second hue, and the one that says "a different kind of thing" — a category chip, a filter
   * set, a term scope. It is deliberately further from the accent than the secondary it
   * replaced ever was, because violet and azure sit on opposite sides of blue: a chip in one can never be
   * mistaken for a control in the other, which is the failure a two-hue system is most prone to.
   * ---------------------------------------------------------------------- */
  --brand-violet: #7c3aed;          /* --bs-secondary */
  --brand-violet-tint: color-mix(in srgb, var(--brand-violet) 20%, #ffffff);
  --brand-violet-ink: color-mix(in srgb, var(--brand-violet) 40%, #000000);

  /* --- The mark's orange: SOLAR ------------------------------------------ *
   * ONE LETTER'S COLOUR, and it is not an accent. An accent in this system is a token a CONTROL
   * wears — a button, a link, a selected item — and this is worn by nothing: it is the "o" of
   * Govnix and the "o" alone. Keeping it out of the accent group is what stops a later reader
   * reaching for it as a second brand colour and putting it on a button, which is exactly the
   * mistake the status group's own comment warns about from the other direction.
   *
   * It is the ORANGE THE KIT WAS DRAWN FROM — `#f15a22`, taken unchanged from the production
   * stylesheet this design system grew out of — and it is the only colour here that is carried
   * over from that palette. It comes back because it is the one hue that is unmistakably NOT part
   * of this one: the frame is blue, the panels are blue, the second word is blue, and a single
   * orange letter in the middle of the mark is a signature rather than a second accent.
   *
   * ONE STEP, NOT TWO, and that is the difference between it and the azure. The azure needs a step
   * per plane because it carries WORDS and a word has to clear 4.5:1; this carries a SHAPE, and a
   * shape has to clear 3:1. Measured, it is 4.95:1 on the frame and 3.37:1 on a white panel, so one
   * value clears the threshold that applies on both — and it is a logotype besides, which is the
   * case WCAG exempts. A second step would have been a second decision for no gain. */
  --brand-orange: #f15a22;          /* the "o" of Govnix; 4.95:1 on the frame, 3.37:1 on white */

  /* --- The frame: the void ---------------------------------------------- *
   * Three depths, and they are depths rather than shades: each is a surface at a different height
   * above the page, and each has exactly one job.
   *
   * `--brand-space-deep` is the NAVIGATION, the furthest thing from the reader and the darkest.
   * `--brand-space` is the HEADER and every dark bar that lives inside a light page, one step up
   * from the rail so that the two do not merge into one L-shaped mass across the top-left of the
   * window. `--brand-space-raised` is a CONTROL standing on either of them — the header's class
   * selector, the account disc — which is why it is light enough to read as raised without any
   * shadow doing the work; on a dark ground a navy drop shadow is simply not there.
   *
   * The rail keeps its own colour and is seamless against the column it sits in; see the
   * application layer for why the column and the rail are two adjacent opaque boxes.
   * ---------------------------------------------------------------------- */
  --brand-space: #121b40;           /* the header, and the dark bars inside a light page */
  --brand-space-deep: #05091a;      /* the navigation rail and the tab bar */
  --brand-space-raised: #283876;    /* a control standing on the frame; 1.53:1 above the header */
  --brand-space-edge: #4555a0;      /* the hairline between a frame control and the frame */

  /* --- Neutrals --------------------------------------------------------- */
  --brand-ink: var(--brand-space);  /* --bs-body-color: ink AND the header surface */
  /* The ink IS the header's colour — one colour, referenced rather than repeated. The comment
     above has always said so ("ink AND the nav surface"), but it was written twice as two literals,
     and two literals that happen to match are two colours waiting to drift: a change to one of them
     would have made every word on the page a slightly different dark from the bar it sits under,
     which is the kind of difference a reader notices without being able to name. Measured before
     that change was made: body text, hero text and the rail surface all computed to
     `rgb(34, 34, 34)`, and the two tokens were identical — so it changed nothing on screen and
     removed the possibility. The same holds here at `#121b40`: it is a near-black with the frame's
     own blue in it, 16.71:1 on a light panel, and the words on a page and the header above them are
     still one decision. */
  --brand-ink-muted: #5b688c;       /* 5.52:1 on `--brand-surface`, 5.05:1 on `surface-alt` */
  --brand-ink-faint: #65709b;       /* 4.83:1 on `--brand-surface`; placeholders and faint marks */
  --brand-surface: #ffffff;
  --brand-surface-alt: #f2f5fa;     /* --bs-secondary-bg: a cool near-white for headers and stripes */
  --brand-border: #dbe2ee;
  --brand-border-strong: #c2cbdc;

  /* --- On the dark frame ------------------------------------------------ *
   * The mirror of the ink group above, and the reason it is a separate group rather than a tint of
   * it: these are read against `--brand-space`, where a dark ink is invisible. Every value here
   * clears 4.5:1 on the DEEPEST frame step, so one value serves the header, the rail and the tab
   * bar without a per-surface variant.
   * ---------------------------------------------------------------------- */
  --brand-on-dark: #eaf0ff;         /* starlight: the words and marks on the frame */
  --brand-on-dark-muted: #9fb0d8;   /* 7.70:1 on `--brand-space` */
  --brand-on-dark-faint: #7f8cbb;   /* 5.07:1 on `--brand-space`; the breadcrumb mark, and rules */

  /* --- Status: a semantic axis, NOT the accent colours ------------------ *
   *
   * These are states, and a state is not an accent. An accent says "this is the brand"; a status
   * says "this is what happened". They may look alike; they must not be the same decision, which is
   * why these are their own tokens even where a value would coincide.
   *
   * `--brand-warning` and `--brand-info` used to hold the SAME value, deliberately, as two tokens
   * free to diverge later. They have now diverged, and the reason is the new secondary: amber was
   * doing double duty as both the warning and the informational colour, and in a system whose
   * second hue is a violet there is no longer any amber accent for the pair to hide behind. An
   * amber "info" beside an amber "warning" is two names for one state, so info is a teal — the one
   * place in the palette that says "this is a note, not a problem".
   *
   * Four states, three depths each, all derived from the base by the mix above. The `-ink` on its
   * own `-tint` is what a status pill renders, and every pair clears 4.5:1 — checked, not assumed,
   * because the two pills that used hand-picked values were the two that failed it.
   * -------------------------------------------------------------------- */
  --brand-success: #198754;
  --brand-success-tint: color-mix(in srgb, var(--brand-success) 20%, #ffffff);
  --brand-success-ink: color-mix(in srgb, var(--brand-success) 40%, #000000);

  --brand-warning: #fcb511;
  --brand-warning-tint: color-mix(in srgb, var(--brand-warning) 20%, #ffffff);
  --brand-warning-ink: color-mix(in srgb, var(--brand-warning) 40%, #000000);

  --brand-danger: #e32b2b;
  --brand-danger-tint: color-mix(in srgb, var(--brand-danger) 20%, #ffffff);
  --brand-danger-ink: color-mix(in srgb, var(--brand-danger) 40%, #000000);

  --brand-info: #0e7490;
  --brand-info-tint: color-mix(in srgb, var(--brand-info) 20%, #ffffff);
  --brand-info-ink: color-mix(in srgb, var(--brand-info) 40%, #000000);

  /* --- Icons ------------------------------------------------------------ */
  /* Inline SVG as a data URI, never a glyph: a glyph depends on the reader's fonts and this
     does not.

     A control that changes what it means while it is on screen swaps the whole image rather than
     rotating one, so the two states are drawn deliberately instead of being one state seen from an
     angle. `--icon-close` used to be here for the navigation trigger, which became a cross while
     the drawer was open — and it has been REMOVED, because that behaviour was removed: the trigger
     is a link to `#nav`, the fragment that opens the drawer, so it cannot close it and a close mark
     was a promise it could not keep. Nothing in the kit swaps a mark now, and the token had no
     other user; it was the only icon token in this file referenced by nothing at all. If a control
     ever does change meaning, the drawing belongs here again. */

  /* The settings cog: a ring, a hole, and eight SHORT, SQUARE-ENDED teeth.

     Two earlier attempts are worth not repeating. Round-capped radial spokes made a ship's wheel —
     a wheel is a ring with thin spokes through it, and a gear is a ring with stubby teeth standing
     on it, and the difference is entirely in the ends. Tracing the gear as one continuous outline
     path worked at 120px and turned to a soft scalloped badge at 20px, because the tooth flanks
     and the root arcs blur into each other.

     So the teeth are separate strokes with butt caps, wider than they are long. The drawing carries
     no colour of its own — it is a MASK, and takes `currentColor` from the control it sits in —
     which is what lets one cog serve the header's frame and a light panel alike. */
  --icon-settings: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M10.00 3.80L10.00 1.00M14.38 5.62L16.36 3.64M16.20 10.00L19.00 10.00M14.38 14.38L16.36 16.36M10.00 16.20L10.00 19.00M5.62 14.38L3.64 16.36M3.80 10.00L1.00 10.00M5.62 5.62L3.64 3.64' fill='none' stroke='%23222222' stroke-width='2.8'/%3E%3Ccircle cx='10' cy='10' r='6.2' fill='none' stroke='%23222222' stroke-width='1.6'/%3E%3Ccircle cx='10' cy='10' r='2.5' fill='none' stroke='%23222222' stroke-width='1.6'/%3E%3C/svg%3E");

  /* --- Navigation icons ------------------------------------------------- *
   *
   * One per destination in the main menu. Drawn on a 20x20 grid at a 1.7 stroke, in the
   * navigation's own starlight rather than in the ink: these sit on the dark rail, and the two
   * icons above are the only ones in the system that do not.
   *
   * A destination gets its icon from its `data-icon` attribute, so the markup says what a menu
   * item IS and the stylesheet owns how it is drawn. `validate_html.py` fails the build if an
   * item names an icon that does not exist, which is the failure that would otherwise be
   * invisible: a missing background image leaves the box the size it was and simply draws
   * nothing.
   *
   * They are drawn as OUTLINES with no fill, so they sit at the same weight as the labels and
   * do not read as filled buttons. `--icon-*` above are solid strokes on a light surface; these
   * are lighter strokes on a dark one, which is why they cannot share a colour.
   * -------------------------------------------------------------------- */
  /* --- The subject marks: one drawing per subject in the class vocabulary (§7.3) -------------
     A class states its SUBJECT, and the subject is what a class is about — so the mark that
     illustrates a class is the subject's rather than a second copy of the surface's. They are
     drawn as MASKS like every other icon in the kit, which is what lets one drawing be the hero's
     illustration on a class page and a small mark beside a class name without a copy per ground
     and per size. The vocabulary is the plan's own and is closed: a subject the plan does not have
     is a subject no class can hold, and `other` is the mark for the classes that name none. */

  --icon-assignments: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M11.5 2.5H5.5A1.5 1.5 0 0 0 4 4v12a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 16 16V7z' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M11.5 2.5V7H16' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M7 10.5h6M7 13.5h4' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-roster: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='6.8' cy='7' r='2.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M2.2 17.5a4.6 4.6 0 0 1 9.2 0' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3Ccircle cx='15' cy='7.6' r='2' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M14.7 12.8c1.6.6 2.7 2.1 2.7 4' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-marks: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M3.5 17.5V11.5M8 17.5V6M12.5 17.5V9M17 17.5V3.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M2 17.5h16' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-announcements: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M4.5 3.5h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H8.5l-4 3.5V13.5a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2z' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* The class's materials. A folder, because Content holds things you open and read — pages,
     files, links, embedded media — and the mark for "the things a class is given" is a
     container rather than a document. It has to be distinguishable at 20px from the assignment
     sheet (a page with a folded corner) and from the archive box (a lidded crate), which is why
     its tab is a corner rather than a lid. */
  --icon-content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M2.5 6.2a2 2 0 0 1 2-2h2.9l1.8 2.2h6.3a2 2 0 0 1 2 2v7.4a2 2 0 0 1-2 2H4.5a2 2 0 0 1-2-2z' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* The class's online assessments. A clipboard with a check, which says "a form you answer and
     that is marked for you" — the thing that distinguishes a quiz from an assignment, where the
     student submits work and other students review it. The clipboard is what makes it a form
     rather than a sheet: a quiz is filled in place, not handed over. */
  --icon-quizzes: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M6.6 4.2H5.2a1.6 1.6 0 0 0-1.6 1.6v10.4a1.6 1.6 0 0 0 1.6 1.6h9.6a1.6 1.6 0 0 0 1.6-1.6V5.8a1.6 1.6 0 0 0-1.6-1.6h-1.4' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3Crect x='7.2' y='2.6' width='5.6' height='3.4' rx='1' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M7 11.6l2.1 2.1 3.9-3.9' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  --icon-archive: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='2.5' y='3.5' width='15' height='3.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M4 7v9.5a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M8 10.5h4' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* The dashboard. Panels of UNEQUAL size — one tall, two stacked — because that is what a
     dashboard is: a screen assembled from parts of different shapes. The kit already has a
     four-square grid (`--icon-pages`) and it is the wrong mark here: four equal squares say "a
     set of like things", which is a list, and a dashboard is not a list. It has to be
     distinguishable from it at 20px, so the left panel runs the full height and the two on the
     right are wider than they are tall. */
  --icon-dashboard: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='2.6' y='3' width='6.2' height='14' rx='1.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Crect x='11.2' y='3' width='6.2' height='6' rx='1.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Crect x='11.2' y='11' width='6.2' height='6' rx='1.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3C/svg%3E");
  /* The date field's picker button. Same 20x20 grid, same 1.7 stroke and round caps as every
     other icon, so it sits beside them as one set rather than as a browser's drawing. */
  /* The plus on a button that creates something. Drawn on the same 20x20 grid with the same round
     caps as every other icon, but a heavier stroke (2.6 against 1.7) and reaching almost the whole
     box: a plus is two lines, and at the kit's normal weight and span a 24px one read as a thin
     cross the size of the letters beside it rather than as an addition. Measured after the change:
     22px of ink, twice the button's own capital height. */
  --icon-plus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M10 2v16M2 10h16' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-calendar: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='2.5' y='4.5' width='15' height='13' rx='2' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Cpath d='M2.5 8.5h15' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Cpath d='M6.5 2.5v4M13.5 2.5v4' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");

  /* Home — the institution. It is NO LONGER the mark on the control that opens the navigation:
     that control wears `--icon-menu` below. The drawing is kept rather than deleted because these
     notes are the record of how a schoolhouse was arrived at, and they are worth reading beside the
     note that replaced them — the argument for it was sound, and the circumstances changed.

     A schoolhouse: a broad low building under a roof with a flat ridge, a cupola standing on the
     ridge, and a door. What this control means is the institution the reader is standing in, so
     the mark is the building they came in through rather than a dwelling.

     **The flat ridge is what separates the cupola from the roof.** The first attempt gave the
     building a plain gable and grew the tower out of the apex, and the two slopes ran straight up
     into the tower's sides: there was no line where one ended and the other began, so the
     silhouette read as one peaked shape with a spire on it. A ridge — a short horizontal between
     the two slopes, wider than the cupola — puts a floor under the cupola and leaves the roof
     visible on either side of it. Two paths that meet, rather than one shape.

     **The cupola is wide and shallow, and that is a decision with evidence.** A narrow belfry with
     a pointed cap reads as a church steeple — on a broad building, that is textbook church
     iconography and it was the first thing anyone saw. Giving it a flat top instead made it a
     chimney. A wide, shallow, hipped cap is the one version that says schoolhouse rather than
     either: it is too broad to be a spire and too shaped to be a flue. At 4.2 wide the space
     inside it also stays open at 20px, where the narrow belfry closed up into a solid point.

     **The building is deliberately wider than it is tall** — 15.2 against 13.9 including the
     cupola — because that is what a schoolhouse looks like and a cottage does not. The ridge is
     set low enough to leave the cupola room; its sides start exactly ON the ridge at y 8.6, so
     the two paths meet rather than cross.

     Three paths and twelve segments, which is about the ceiling for this box. An earlier version
     drew a chair at a desk and then an isometric desk; both were more drawing than 20px can carry.
     A trigger is pressed without being studied and this one sits in a row of small controls beside
     the class selector, so the drawing has to survive a glance.

     Stroke, grid and caps are the rest of the set's — 20x20, 1.7, round. */
  --icon-home: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M2.4 11.6L6.2 8.6H13.8L17.6 11.6V16.2H2.4Z' fill='none' stroke='%23222222' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M7.9 8.6V5.4H7.1L10 4.1L12.9 5.4H12.1V8.6' fill='none' stroke='%23222222' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M8.2 16.2V12.4H11.8V16.2' fill='none' stroke='%23222222' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E");

  /* The navigation trigger — three bars.

     IT REPLACED THE SCHOOLHOUSE ABOVE, and the argument it replaces is worth keeping in view
     because it was a good argument and only one of its two halves stopped being true. It read:
     *"A hamburger says 'there is a menu here', which the reader learns the first time they press
     it and never needs again. A schoolhouse says which top level they are standing in."* That is
     right about a trigger that stands ALONE in the header of a four-item navigation — the mark is
     the only thing there that can say where you are. It is not right here: this trigger stands
     beside the ORGANIZATION SELECTOR, which already names the institution in words, so the
     schoolhouse answered a question the control next to it had answered — and what it opens is a
     ten-item drawer, which is the one thing three bars are for. The operator made the call; the
     reasoning is written down so the next reader does not re-derive it and quietly revert it.

     Three bars of equal length and equal spacing, on the set's 20x20 grid at its 1.7 stroke with
     round caps. The span is 14 of the 20 units: a menu mark reads as a menu by the LENGTH of its
     bars as much as by their number, and at 10 units the same three bars read as a minus sign
     stacked twice. */
  --icon-menu: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M3 5.5h14M3 10h14M3 14.5h14' fill='none' stroke='%23222222' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");

  /* The application's own mark — the "o" of "Govnix", drawn as a disc. It is the only token here
     that is not a drawing of a control's job.

     The wordmark spells the disc with `.brand-o`: a 0.51em circle painted `--brand-orange` with the
     letter clipped out of it. This is the same circle, as a MASK, so it can be painted in a button
     on the dark frame — a mask takes the colour of the thing it draws in, and a data URI with
     `#f15a22` baked into it could not.

     IT IS THE NAVIGATION TRIGGER'S MARK, and that is the whole point of it: on every band below
     the rail the wordmark is NOT drawn and the trigger stands where the wordmark stood, so the
     button takes the wordmark's own mark. The header stops spending two hundred pixels on a name
     the disc already carries and spends them on the breadcrumb instead.

     Read `--icon-menu` above for the argument that was made for three bars at that control and
     then withdrawn. The short of it: the operator asked for the disc, because the wordmark's place
     in the header is the button's place, and a mark that says WHOSE header this is beats a mark
     that says the one thing the reader learns by pressing it.

     A true circle on the set's 20x20 grid, at r 6.5 — a 13px disc, which is the height of the
     hamburger's own ink (three bars at 1.7 with a round cap each end). The button's weight in the
     row is therefore unchanged by the swap. It is drawn FILLED where almost everything else in
     this set is outlined, because the wordmark's disc is filled and this is that disc. */
  --icon-brand: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='10' r='6.5'/%3E%3C/svg%3E");

  /* The document marking controls — the tool palette, the ink weights, and the
     actions that belong to the marking bar. Same 20x20 grid, same 1.7 stroke and
     the same round caps and joins as everything above, so a pen and an archive
     box sit at one weight.

     These are MASKS, not pictures, and that is the point: a marking tool is drawn
     on the light tool palette AND on the dark marking bar, and it inverts again
     when it is the active tool. A drawing with its colour baked in would need
     three of each; a mask takes the colour of the control it is in, so there is
     one canonical drawing per tool. See `[data-icon]::before` for the box. */
  --icon-pen: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M13.6 3.2l3.2 3.2-8.6 8.6-4.4 1.2 1.2-4.4z' fill='none' stroke='%23000' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M11.8 5l3.2 3.2' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-highlighter: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5 10.4l5.4-5.4 4 4-5.4 5.4z' fill='none' stroke='%23000' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M3.5 16.6h13' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-eraser: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M7.6 16.4l-3.9-3.9a1.4 1.4 0 0 1 0-2l6.7-6.7a1.4 1.4 0 0 1 2 0l3.9 3.9a1.4 1.4 0 0 1 0 2l-6.7 6.7a1.4 1.4 0 0 1-2 0z' fill='none' stroke='%23000' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M7 6.9l6.1 6.1' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-note: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M3.5 3.5h13v9.5l-4 4h-9z' fill='none' stroke='%23000' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M16.5 13h-4v4' fill='none' stroke='%23000' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E");
  --icon-pin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M10 17.6s5.4-5.1 5.4-8.9a5.4 5.4 0 0 0-10.8 0c0 3.8 5.4 8.9 5.4 8.9z' fill='none' stroke='%23000' stroke-width='1.7' stroke-linejoin='round'/%3E%3Ccircle cx='10' cy='8.6' r='2' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3C/svg%3E");
  --icon-undo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M6.6 7.4H12a4.4 4.4 0 0 1 0 8.8H8' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M9.2 4.4l-3 3 3 3' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  --icon-ink-thin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M4.5 15.5L15.5 4.5' fill='none' stroke='%23000' stroke-width='1.2' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-ink-medium: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M4.5 15.5L15.5 4.5' fill='none' stroke='%23000' stroke-width='2.8' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-ink-thick: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M4.5 15.5L15.5 4.5' fill='none' stroke='%23000' stroke-width='4.8' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-zoom-out: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='8.8' cy='8.8' r='5.6' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Cpath d='M12.9 12.9l4.3 4.3' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M6.3 8.8h5' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-zoom-in: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='8.8' cy='8.8' r='5.6' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Cpath d='M12.9 12.9l4.3 4.3' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M6.3 8.8h5M8.8 6.3v5' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  --icon-finish: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M4 10.6l4.2 4.2L16.4 5.4' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  --icon-previous: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M12.4 4.2L6.6 10l5.8 5.8' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  --icon-next: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M7.6 4.2L13.4 10l-5.8 5.8' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* The play triangle, drawn as a FILLED path rather than a stroked outline like the arrows: a
     play control is a solid mark in every player the reader has used, and an outlined triangle at
     20px reads as a warning sign. */
  --icon-play: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M6.6 4.4l9 5.6-9 5.6z' fill='%23000'/%3E%3C/svg%3E");
  --icon-fit-width: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M3.4 10h13.2' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M6.8 6.6L3.4 10l3.4 3.4M13.2 6.6L16.6 10l-3.4 3.4' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  --icon-pages: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='3' y='3' width='6' height='6' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='11' y='3' width='6' height='6' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='3' y='11' width='6' height='6' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='11' y='11' width='6' height='6' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3C/svg%3E");

  /* The two halves of the thumbnail sheet's view switch. They are a pair, and they are drawn as
     one: the SAME proportions at two arrangements, so the switch reads as one choice rather than
     as two unrelated marks.

     Six small tiles in three columns, against four large squares in two. That is not decoration.
     `--icon-pages` is four squares, and a grid view drawn as four squares would be that mark
     twice — a reader who has learned it in the viewer's toolbar would read the sheet's grid view
     as "pages". Three columns of smaller tiles also says what a thumbnail sheet IS: more items
     than a 2x2 can show, each one too small to read and there to be recognised.

     The list is three rows, each a square and a line. Not three full-width bars: three bars is
     the hamburger, which means "a menu opens" everywhere it is used, and a view switch that
     looks like a menu button is a control nobody presses. The leading square is what makes the
     row read as an ITEM rather than as a rule. */
  --icon-grid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='2.6' y='2.6' width='3.6' height='6.4' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='8.2' y='2.6' width='3.6' height='6.4' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='13.8' y='2.6' width='3.6' height='6.4' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='2.6' y='11' width='3.6' height='6.4' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='8.2' y='11' width='3.6' height='6.4' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='13.8' y='11' width='3.6' height='6.4' rx='1' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3C/svg%3E");
  --icon-list: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='3' y='3.4' width='3' height='3' rx='0.8' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='8.4' y='3.4' width='8.6' height='3' rx='1.5' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='3' y='8.5' width='3' height='3' rx='0.8' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='8.4' y='8.5' width='8.6' height='3' rx='1.5' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='3' y='13.6' width='3' height='3' rx='0.8' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3Crect x='8.4' y='13.6' width='8.6' height='3' rx='1.5' fill='none' stroke='%23000' stroke-width='1.7'/%3E%3C/svg%3E");
  --icon-grip: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='7' cy='4.4' r='1.8' fill='%23000'/%3E%3Ccircle cx='13' cy='4.4' r='1.8' fill='%23000'/%3E%3Ccircle cx='7' cy='10' r='1.8' fill='%23000'/%3E%3Ccircle cx='13' cy='10' r='1.8' fill='%23000'/%3E%3Ccircle cx='7' cy='15.6' r='1.8' fill='%23000'/%3E%3Ccircle cx='13' cy='15.6' r='1.8' fill='%23000'/%3E%3C/svg%3E");
  --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5.5 5.5l9 9M14.5 5.5l-9 9' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* The disclosure mark — the same down chevron the kit's selects draw, as a MASK rather than as
     the `▾` character the rail used to carry. A glyph depends on the reader's fonts, which is the
     one thing the icon set exists to avoid: on a machine whose fallback face has no U+25BE the
     chevron keeps its box and shows nothing, and the rail then looks like a section that does not
     expand rather than like a mark that failed to draw. It points DOWN and it does not rotate:
     the submenu it belongs to is drawn whenever the mark is, because there is no script to toggle
     one and a chevron pointing at a collapsed list that cannot be opened is a promise the control
     cannot keep. */


  --icon-disclosure: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6.5l4 4 4-4' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  --icon-redo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E<path d='M13.4 7.4H8a4.4 4.4 0 0 0 0 8.8h4' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/><path d='M10.8 4.4l3 3-3 3' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/>%3C/svg%3E");
  --icon-bold: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E<path d='M7 4.6h3.1a2.5 2.5 0 0 1 0 5H7z' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/><path d='M7 9.6h3.6a2.6 2.6 0 0 1 0 5.2H7z' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/>%3C/svg%3E");
  --icon-italic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E<path d='M9.6 4.8h4.2M6.2 15.2h4.2M11.8 4.8l-3.6 10.4' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/>%3C/svg%3E");
  --icon-underline: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E<path d='M6.8 4v5.6a3.2 3.2 0 0 0 6.4 0V4' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/><path d='M5.2 16.6h9.6' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/>%3C/svg%3E");
  --icon-list-bulleted: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E<circle cx='5' cy='5.6' r='1.3' fill='%23000'/><circle cx='5' cy='10' r='1.3' fill='%23000'/><circle cx='5' cy='14.4' r='1.3' fill='%23000'/><path d='M9.2 5.6h6.2M9.2 10h6.2M9.2 14.4h6.2' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/>%3C/svg%3E");
  --icon-list-numbered: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E<path d='M5.6 4.4v2.4M5.6 8.8v2.4M5.6 13.2v2.4' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/><path d='M9.2 5.6h6.2M9.2 10h6.2M9.2 14.4h6.2' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/>%3C/svg%3E");
  --icon-link: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E<path d='M8.2 7.6l1.8-1.8a3.4 3.4 0 0 1 4.8 4.8l-1.8 1.8' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/><path d='M11.8 12.4l-1.8 1.8a3.4 3.4 0 0 1-4.8-4.8l1.8-1.8' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/><path d='M8.6 11.4l2.8-2.8' fill='none' stroke='%23000' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/>%3C/svg%3E");

  /* --- Radii: structure is SQUARE, controls are ROUND ------------------- */
  --radius-structure: 0;            /* nav, header, cards, panels, tables, modals */
  --radius-control: 6px;            /* fields, selects */
  --radius-pill: 999px;             /* buttons, chips, tags, toggles, status pills */

  /* --- The veil: what dims the page behind a layer that has taken over ----------------
     One value for every veil, because there are now two of them — the dialog's backdrop and the
     navigation drawer's — and they say the same thing: *this is behind, and it is not what you are
     working on*. Two values would drift, and a reader who has learned one shade would read the
     other as a different state. */
  /* A LIGHT veil, not a dark background. The page behind stays clearly visible — readable as a
     document, just dimmed — because the reader needs the context. A scrim dark enough to hide the
     page would defeat the point of showing it. It is the frame's own colour rather than a neutral
     black, so a dimmed page still looks like part of this application. */
  --veil: rgba(5, 9, 26, 0.45);

  /* --- Shadows: only controls carry one, and the meaning is the shape --- */
  /* Click it: raised, soft, offset down and to the right (light from upper left). The shadow is
     tinted with the ink rather than neutral black — a neutral grey shadow under a navy control
     reads as dirt, and a shadow is a colour like any other. */
  --shadow-control: 2px 2px 5px rgba(11, 17, 40, 0.22);
  --shadow-control-hover: 3px 3px 7px rgba(11, 17, 40, 0.28);
  --shadow-control-active: 1px 1px 2px rgba(11, 17, 40, 0.28);
  /* THE FRAME NEEDS ITS OWN THREE, and they are the one set in the file that is pure black. On
     `--brand-space` an ink-tinted shadow is the same colour as the thing it falls on and draws
     nothing, so a control standing on the frame would lose the raised state that says it can be
     pressed. Black at a higher opacity is the only shadow that exists against a near-black ground.
     They are used ONLY on the frame; nothing inside a light panel may wear them, and the pair is
     kept whole for the same reason the light one is: a control that lifts must collapse. */
  --shadow-control-dark: 2px 2px 6px rgba(0, 0, 0, 0.55);
  --shadow-control-dark-hover: 3px 3px 8px rgba(0, 0, 0, 0.62);
  --shadow-control-dark-active: 1px 1px 3px rgba(0, 0, 0, 0.62);
  /* The three are ONE press, not three independent values, and they are used as such: a control
     that is raised at rest must collapse to `--shadow-control-active` under `:active`. A button
     that lifts as the pointer arrives and then stays lifted while it is held down has answered the
     pointer and ignored the finger, and on a touch screen there is no hover to arrive — the press
     is the only feedback there is. `validate_html.py` enforces the pairing (check_pressed_states):
     raised at rest plus a hover lift obliges an `:active`. */
  /* Type into it: recessed, soft, strongest along the top inner edge. */
  --shadow-input-inset: inset 0 2px 3px rgba(11, 17, 40, 0.14);
  --shadow-input-focus: inset 0 2px 3px rgba(11, 17, 40, 0.10), 0 0 0 3px rgba(37, 99, 235, 0.35);
  /* The recessed edges of a SCROLLING region — the dialog's body, where the head and the foot are
     layers standing above the content. Named as two separate bands because they are placed at the
     two edges of one box, and the dialog's body is the only thing that wears them today. See
     `.dialog-body` for why the shadow has to be `inset` on the scroll container itself. */
  --shadow-scroll-edge-top: inset 0 6px 6px -6px rgba(11, 17, 40, 0.20);
  --shadow-scroll-edge-bottom: inset 0 -6px 6px -6px rgba(11, 17, 40, 0.20);

  /* --- Type ------------------------------------------------------------- */
  /* The mono face, and it is a TOKEN rather than a stack written where it is needed: a page that
     teaches R shows its R, and one place in the kit shows code. The stack is the platform's own
     mono first so a machine that has no Roboto Mono still draws code in a face that is not the
     body's — the failure this prevents is code that reads as prose. */
  --font-mono: "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "DejaVu Sans Mono",
    monospace;
  /* THE HEADING FACE IS A ROUNDED SANS, and the fallback stack changed with it. It used to be a
     slab serif with `Georgia` and `Times New Roman` behind it, because a serif falls back to a
     serif: a machine without the file still drew a heading in the right KIND of face. Nunito is a
     rounded sans, so the stack behind it is now sans faces, and the nearest one to its soft
     terminals is the platform's own UI face. A serif fallback here would be worse than no fallback
     at all — it would draw the heading in a face that contradicts the mark it is set in. */
  --font-heading: "Nunito", "Segoe UI", system-ui, -apple-system, Helvetica, Arial, sans-serif;
  --font-body: "Roboto", -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;

  --text-h1: 2rem;
  --text-h2: 1.5rem;
  --text-h3: 1.125rem;
  --text-h4: 1rem;
  --text-h5: 0.875rem;
  --text-h6: 0.8125rem;
  --text-body: 1rem;
  --text-small: 0.875rem;
  --text-micro: 0.75rem;
  /* The subject mark is the kit's only icon that is an illustration rather than a control label, so
     it is the only one whose size is not the 20px icon box. 64px beside a class name; the hero of a
     class page takes the larger step. */
  --size-row-mark: 64px;
  /* The two steps the PUBLIC SITE adds, and the only sizes the application does not use.
     The application's scale is an adaptation: `--text-h1` is 2rem because a working interface
     has a header, a class scope and a table already competing for the top of the page. A landing
     page has none of those, so its h1 is the College's own marketing h1 — 4.25rem, taken from
     their production CSS (§1) rather than chosen here. `--text-lede` is the paragraph under it,
     which is the one place on a marketing page where text is set larger than body to be read
     rather than to be scanned. */
  --text-mega: 4.25rem;
  --text-lede: 1.125rem;
  /* A reading measure for prose that is not a control, a table or a field. The application's text
     sits in panels of a known width; the public site's sits in whatever the window gives it, so the
     measure has to be declared rather than inherited. */
  --width-reading: 62ch;
  /* THE PUBLIC SITE'S OWN WIDTH — the one number the whole site is laid out on, and it is a TOKEN
     because more than one thing has to agree with it: the content column is this wide, and the
     header's contents are inset to line up with that column rather than with the window. Written
     twice, the two would drift the moment either changed, and a header whose wordmark sits 24px
     from the window's edge over a column that starts at 160 is the drift in its most visible form. */
  --width-page: 1120px;

  /* --- Space ------------------------------------------------------------ */
  /* The box or circle of a checkbox or radio. A choice's callout is indented by this plus the
     gap, so that it lines up with the choice's WORDS; deriving the indent keeps the two in step. */
  --size-mark: 18px;
  /* The furniture of an EDITABLE ROW — `.option-row`, and the `.option-head` above it. A row is a
     grip, a label field, a value field and a remove control, and the head has to land its labels
     over those columns, so both are built from these three. A head that repeated the numbers would
     keep its labels on one line and simply stop pointing at their columns, which is a defect no
     screenshot review would call one. */
  --size-grip: 22px;
  --size-icon-btn: 30px;
  --width-option-value: 88px;
  /* The WHO ACTS column, on the stage list. It is wider than the value column because its options
     are names rather than one word — "Allocated reviewers" is the longest phrase the stage's actor
     vocabulary has, and a select that truncated it to "Allocated rev…" would be a closed set the
     reader cannot read. The three widths are what the head and the row are both built from, so the
     labels land over their fields at every width. */
  --width-option-actor: 168px;
  /* THE GOES TO column, beside it. A stage's destination is a name too — a marker group's name, or a
     participant's — and it is the column the actor must not be read as, which is why the two sit
     side by side and are built from two tokens rather than one shared width. They are equal today,
     and that is a coincidence of the vocabulary rather than a relationship between the columns: a
     group may be called anything, and the destination is the one that will need the room first. */
  --width-option-dest: 168px;
  /* THE STATION MAP's own numbers. `--size-route-line` is the height of a station's head line, and
     the node is centred on it — a mark beside a line of text and a mark ON it are different marks,
     and only the second reads as a station. The line's own ends are the same number again. It is
     declared rather than read off the chip, because the chip's height is the chip's business and the
     head's line is the station's. */
  --size-route-node: 16px;
  --size-route-line: 28px;
  --size-route-gutter: 36px;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
}

/* ---------------------------------------------------------------------------
   Base
   ------------------------------------------------------------------------ */

body {
  margin: 0;
  background: var(--brand-surface);
  color: var(--brand-ink);
  font-family: var(--font-body);
  font-size: var(--text-body);
  line-height: 1.5;
}

/* Heading hierarchy — the two levels never mix.
   TOP LEVEL: the only place azure carries words. Either azure on white, or white on a solid
   deep azure field. Deep azure, not vivid, because white needs 7.66:1 and vivid azure only
   gives 3.06:1 against white text.
   LOWER LEVELS: section, panel and card titles are the ink, never azure. */
h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-heading);
  font-weight: 700;
  color: var(--brand-ink);
  margin: 0 0 var(--space-3);
  line-height: 1.2;
}

/* Six levels, descending. h1 is the application or page name in the top header bar, and it is
   the ONLY azure text anywhere on a screen. h2 to h6 are the ink. */
h1 { font-size: var(--text-h1); color: var(--brand-azure); }
h2 { font-size: var(--text-h2); }
h3 { font-size: var(--text-h3); }
h4 { font-size: var(--text-h4); }
h5 { font-size: var(--text-h5); }
h6 { font-size: var(--text-h6); }

/* The other permitted top-level treatment: white on a solid deep azure field. */
.brand-title {
  display: inline-block;
  background: var(--brand-azure-deep);
  color: #ffffff;
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: var(--text-h1);
  line-height: 1.2;
  padding: var(--space-3) var(--space-5);
  border-radius: var(--radius-structure);
}

a { color: var(--brand-azure-deep); }

/* ---------------------------------------------------------------------------
   A CONTROL IS NOT A LINK, whatever element it is built from.

   An action that navigates is correctly an <a>: it is keyboard-reachable, it can be opened in a
   new tab, and it is announced as a link. But it is a BUTTON to the eye, and the browser's own
   link affordance — the underline — is painted on top of the button's. The result is two buttons
   sitting side by side, one of them underlined because of the element it happened to be written
   with, which reads as a different kind of thing when it is the same kind of thing. It is the
   element showing through the design.

   So the control's affordance wins. Every class in the control vocabulary withdraws the underline
   wherever it is used, so `<button class="btn">` and `<a class="btn">` are indistinguishable.

   This is stated ONCE, as a vocabulary, rather than patched onto whichever class was first written
   as an anchor — that is how the defect arrived, and patching one class at a time guarantees the
   next one recurs. `validate_html.py` refuses a control class used on an anchor that does not
   withdraw the underline, so the list cannot silently rot.

   `.row-link` is deliberately NOT here: a clickable table row really is a link, and it underlines
   on hover on purpose. The rule is about controls, not about anchors.

   The ROW CONTROLS are here for a reason worth stating, because they are the ones that got away: a
   row that chooses a destination (`.feature-option`), a row in a menu (`.menu-item`) and a row in a
   running list (`.record-row`) are pressable — each draws its own hover ground — but every screen
   writes them as `<a>`, and an anchor wears no chrome, states no border, and gets its pointer cursor
   from the browser rather than from the class. So the class never had to say any of it, and the
   FIRST application to write one as the `<button>` it is (the organization row changes the session;
   it navigates nowhere, so a link would misdescribe it) got the platform's border on three sides of
   a row whose rule states only `border-top` — the hairline it was written for. */
.btn,
.frame-select,
.nav-toggle,
.icon-btn,
.account-btn,
.tool,
.tab,
.chip-action,
.chip-remove,
.search-submit,
.mark-option,
.mode-exit,
.add-row,
.feature-option,
.menu-item,
.record-row,
.subnav-item,
.nav-item,
.nav-veil,
.dialog-veil,
.filter-switch,
.field-toggle-label {
  text-decoration: none;

  /* AND THE REST OF THE ELEMENT'S OWN CHROME, for the same reason and with harder evidence.

     A `<button>` does not start as a blank box. It is born with `background: buttonface` (measured:
     `rgb(239, 239, 239)`), a two-pixel `rgb(0, 0, 0)` border, the platform's own font at its own
     size, and `appearance: auto`, which on some platforms re-imposes the native widget over all of
     it. An `<a>` is born with none of that. So a class that styles a control well enough for the
     element it was written on renders as the BROWSER'S control on any other element — and because
     every screen in this kit is static, the first thing an action was ever written as is an anchor.
     A real application writes an action as a `<button>`, correctly, and gets a grey rounded
     rectangle with a black outline sitting inside the design: the element showing through again,
     exactly the defect this block was written for the first time.

     Measured in the application before this rule: `.account-btn`, `.nav-item`, `.nav-veil` and
     `.dialog-veil` all computed to `background: rgb(239, 239, 239)` with a `2px rgb(0, 0, 0)`
     border. The tab row's items came out as four bordered boxes in the middle of a tab row, the
     drawer's destinations as grey slabs, and the dialog's own veil as a filled card with an
     outline — while every check in this file passed, because the kit's own screens never put those
     classes on anything but an anchor.

     So the vocabulary is stated twice: the affordance it withdraws from the anchor, and the chrome
     it withdraws from the control. Both are declarations a component's own rule then overrides
     (`.btn` states its own border, padding, font and shadow further down, and is unaffected), and
     this block is deliberately EARLY so that it is only ever a floor. `appearance: none` matters
     most of the four on the platforms that draw a native widget over a styled button. */
  appearance: none;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
}

/* ---------------------------------------------------------------------------
   Structure — SQUARE corners, FLAT, separated by hairlines
   ------------------------------------------------------------------------ */

.section,
.card,
.panel,
.inspector,
.header-bar,
.table-wrap,
.thumb-sheet,
.board-col,
.modal,
.nav-rail,
.tab-bar {
  border-radius: var(--radius-structure);   /* exactly zero */
  box-shadow: none;                          /* structure never casts a shadow */
}

.card,
.panel,
.inspector,
.table-wrap,
.thumb-sheet,
.board-col {
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
}

/* ---------------------------------------------------------------------------
   A HEADING OVER A BLOCK WEARS THE BAND. One rule, three components.
   ---------------------------------------------------------------------------
   `.table th`, `.running-head` and `.card-header` all do the same job: they name a
   block and sit over what it holds. The band — the tinted ground closed by the 2px
   strong rule — is how this kit marks that. It is a STRUCTURAL signal, not a
   typographic one: it says where a block begins, and it says it the same way whether
   the words over it are a small uppercase column label or a display heading.

   The rule was arrived at twice and applied once. `.running-head` states it in full
   ("the block's head wears the same dress as a table's head, because it is the same
   thing") and `.table th` has carried it from the start; the card's header was left
   with a bare hairline and no ground, so a card whose body is a fact list — a
   definition table in everything but markup — rendered with the one heading on the
   page that was not marked as a heading. Read as a table, it looked like a table
   whose header had lost its background.

   So the card header takes the band and the strong closing rule. What it does NOT
   take is the table header's size: a column label is micro, uppercase and medium, and
   a card header is a display heading, because the two name blocks of different rank.
   The band is shared; the type is the heading's own.
   ------------------------------------------------------------------------ */

.card-header,
.panel-header {
  background: var(--brand-surface-alt);
  border-bottom: 2px solid var(--brand-border-strong);
  padding: var(--space-4) var(--space-5);
  font-family: var(--font-heading);
  font-weight: 700;
}

.card-body,
.panel-body { padding: var(--space-5); }

/* Header bar: the frame's top edge, square and flat. This is the specimen shell's header and the
   application's `.app-header` is the same surface, so the two wear the same dress — a specimen that
   showed a light header would be documenting a shell the application no longer has. */
.header-bar {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  background: var(--brand-space);
  border-bottom: 1px solid var(--brand-space-edge);
  color: var(--brand-on-dark);
  padding: var(--space-3) var(--space-5);
}

/* ---------------------------------------------------------------------------
   The frame path — class scope, then feature.
   ---------------------------------------------------------------------------
   The top of the containment chain has TWO axes, not one (see PROJECT_PLAN
   §19.1): the CLASS you are working in, and the FEATURE within it. Both are
   chosen once and held while you work, so both sit in the frame rather than in
   the menu, and the two are the same control because they are the same kind of
   choice — pick one of a short list, and everything below resolves within it.

   The mark between them is a breadcrumb, and it is doing real work: it says the
   second resolves INSIDE the first, so "Assignments" reads as this class's
   assignments rather than as a destination of its own. Without it the two
   selectors read as two unrelated menus sitting next to each other.
   ------------------------------------------------------------------------ */

.frame-path {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
}

.frame-select {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-pill);
  border: 1px solid var(--brand-border-strong);
  background: var(--brand-surface);
  color: var(--brand-ink);
  font-family: var(--font-body);
  font-size: var(--text-small);
  font-weight: 500;
  white-space: nowrap;
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

.frame-select:hover { box-shadow: var(--shadow-control-hover); }
.frame-select:active { box-shadow: var(--shadow-control-active); }

/* The label truncates before the chevron does: the chevron is what says the control is a
   choice, so it is the last thing that should be lost to a narrow header. */
.frame-select .frame-select-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.frame-select .select-meta { color: var(--brand-ink-muted); font-weight: 400; }

.frame-select::after {
  content: "";
  width: 10px;
  height: 6px;
  flex: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%235A5A5A' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
}

/* The class selector carries a BENTO GRID instead of a chevron, because it does not open a
   dropdown of nearby values — it opens the list of classes you belong to. A chevron says "more
   like this"; a grid says "somewhere else entirely", which is what changing class is. */
.frame-select--grid::after {
  width: 14px;
  height: 14px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect x='1.4' y='1.4' width='5.6' height='5.6' rx='1.5' fill='%235A5A5A'/%3E%3Crect x='9' y='1.4' width='5.6' height='5.6' rx='1.5' fill='%235A5A5A'/%3E%3Crect x='1.4' y='9' width='5.6' height='5.6' rx='1.5' fill='%235A5A5A'/%3E%3Crect x='9' y='9' width='5.6' height='5.6' rx='1.5' fill='%235A5A5A'/%3E%3C/svg%3E");
  background-size: 14px 14px;
}

/* The breadcrumb mark. Drawn from borders rather than typed as a glyph, for the same reason
   every other icon here is: a glyph depends on the reader's fonts, and this does not. */
.frame-sep {
  flex: none;
  width: 8px;
  height: 8px;
  border-right: 1.5px solid var(--brand-ink-faint);
  border-bottom: 1.5px solid var(--brand-ink-faint);
  transform: rotate(-45deg);
  margin: 0 var(--space-1);
}

/* ---------------------------------------------------------------------------
   Navigation — the ONLY dark region, square, flat, with an azure indicator
   ------------------------------------------------------------------------ */

.nav-rail {
  background: var(--brand-space-deep);
  color: var(--brand-on-dark);
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-3);
}

.nav-rail .nav-item,
.tab-bar .nav-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  color: var(--brand-on-dark);
  font-size: var(--text-small);
  text-decoration: none;
  border-radius: var(--radius-structure);
}

/* The selected item is marked by the azure BAR alone. Its label stays the same starlight as
   every other item, just slightly bolder — the header name is the only azure text. */
.nav-rail .nav-item[aria-current="page"],
.tab-bar .nav-item[aria-current="page"] {
  color: var(--brand-on-dark);
  font-weight: 600;
}

/* Selected indicator: to the LEFT of the item in a vertical rail.
   It is drawn on `::after`, NOT `::before`. An item's `::before` is its ICON — one drawing per
   pseudo-element — and this rule used to take that box over: the `background` shorthand wiped the
   icon image, so the selected item was the only one in the rail with a blank where its icon should
   be. The indicator is a second mark, so it gets the second box, which is also what `.app-nav`
   already does. */
.nav-rail .nav-item[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 4px;
  height: 70%;
  background: var(--brand-azure-bright);
  border-radius: var(--radius-structure);
}

.tab-bar {
  background: var(--brand-space-deep);
  color: var(--brand-on-dark);
  display: flex;
  align-items: stretch;
}

.tab-bar .nav-item {
  flex: 1;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-1);
  padding: var(--space-3) var(--space-2);
  text-align: center;
}

/* Selected indicator: BENEATH the item in a horizontal tab bar. */
.tab-bar .nav-item[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 4px;
  background: var(--brand-azure-bright);
  border-radius: var(--radius-structure);
}

/* HOVER: the selected item's bar, before it is earned.

   The current item is marked by ONE SOLID AZURE BAR — down the LEFT edge in a vertical rail,
   along the BOTTOM edge in a horizontal bar. On hover the same mark appears in the same place, in
   the same accent, but BROKEN INTO TWO DOTS along the bar's own axis: a colon where the bar runs
   vertically, an umlaut where it runs horizontally. The hover is therefore a PREVIEW of the
   selection rather than a second, unrelated decoration — you are shown the bar you are about to
   get, and the shape of the mark tells you which arrangement you are in.

   The dots are the selected mark REDUCED, and never the same as it: one bar means "you are here",
   two dots mean "you could be". A hovered item can therefore never be mistaken for the current
   one, which is exactly the failure mode of a hover state that copies the selected treatment.
   Two dots rather than a dimmed bar is also the more honest signal, because opacity reads as
   "disabled" on a dark ground while a broken mark reads as "provisional".

   The current item is excluded: it already carries the full bar, and a second mark beside it would
   look like a mistake rather than an emphasis. */
/* Both arrangements draw the dots on `::after` — the item's second box. `::before` is the icon
   and only the icon, so a hovered item keeps the mark it was chosen for. */
.nav-rail .nav-item:hover:not([aria-current="page"])::after,
.tab-bar .nav-item:hover:not([aria-current="page"])::after {
  content: "";
  position: absolute;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--brand-azure-bright);
}

/* A vertical rail: the bar runs down the left edge, so the two dots STACK — a colon. The pair
   straddles the item's centre line, so the mark grows from the middle outwards as the bar does. */
.nav-rail .nav-item:hover:not([aria-current="page"])::after {
  left: 0;
  top: calc(50% - 7px);
  box-shadow: 0 10px 0 var(--brand-azure-bright);
}

/* A horizontal bar: the bar runs along the bottom edge, so the two dots sit SIDE BY SIDE — an
   umlaut, centred on the item. */
.tab-bar .nav-item:hover:not([aria-current="page"])::after {
  bottom: 0;
  left: calc(50% - 7px);
  box-shadow: 10px 0 0 var(--brand-azure);
}

/* ---------------------------------------------------------------------------
   Buttons — ROUNDED and RAISED: the clearest "you can click this"
   ------------------------------------------------------------------------ */

.btn {
  /* A button is ONE control, whether it is written as a `<button>` or as an `<a>`. The display has
     to be stated: `<button>` is `inline-block` by itself and an `<a>` is `inline`, so a link styled
     as a button sat on a line box and measured **42px** against the same control's **40px** — the
     line box's leading, added to a control that is not a line of text. It went unnoticed because a
     dialog footer is a flex container and blockifies its children, so only a link-button in normal
     flow was affected. */
  display: inline-block;
  font-family: var(--font-body);
  font-size: var(--text-small);
  font-weight: 500;
  line-height: 1;
  padding: var(--space-3) var(--space-5);
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  cursor: pointer;
  box-shadow: var(--shadow-control);
  transition: box-shadow 0.12s ease, background-color 0.12s ease;
}

/* A button that carries an icon is a row of two things, and its icon is larger than its label.

   `.btn` is not a flex container, so the `[data-icon]::before` box would be an inline
   pseudo-element whose `width` and `height` apply to nothing — the mask would draw at zero size.
   Naming the attribute here makes it a flex item, which is what gives the size effect and what
   supplies the gap between the mark and the word.

   20px, the kit's own icon size. The mark was 24px for a while, on the argument that "on this
   button the mark is half the message" — and measured, it was not: a 24px plus draws **22px of
   ink** against a **13px** label, so the symbol was 1.7× the word it introduces and read as the
   loudest thing in the button. At 20px the ink is **18.5px** against the same 13px: unmistakably
   a plus, and no longer competing with its label.

   The vertical alignment is measured too, because the mark's BOX can be centred while the drawing
   inside it is not — and because the LABEL's box is not the thing the eye uses either. The label's
   ink runs from a capital to the descender of the "g", so its ink centre sits **1.25px below** the
   button's centre while its **cap-height** centre sits **0.25px** above it. A mark beside a word
   aligns to the cap centre, which is what the eye reads as the word; measured against that, the
   plus is **0.25px low** — level, to within a quarter of a pixel. Aligning it to the ink centre
   instead would drop it a pixel and a quarter and make it read as sagging. */
.btn[data-icon] {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}
.btn[data-icon]::before {
  width: 20px;
  height: 20px;
  mask-size: 20px 20px;
  /* The mark does not enlarge the button, and this is where that is enforced.

     A 20px icon in a 14px line box sets the flex container's content height, so a button with an
     icon would stand taller than one without — measured, 46px against 40px. The fix used to be a
     reduced `padding-block` on the button, and that is a value that has to be re-derived every
     time either size moves: it was written when the mark was 24px and gave 42px, and when the mark
     was reduced to 20px nobody re-measured, so the button silently became **38px** — 2px SHORTER
     than every other control in the kit. Measured on the rendered buttons: "Create assignment"
     196.1×38 against "Save changes" 136.8×40 in the same dialog footer.

     A symmetric negative margin removes exactly the difference between the mark and the line box,
     so the two agree and the button is the height of its own label again. `align-items: center`
     keeps the mark centred because the margins are equal, and the mark still DRAWS at 20px — it
     overhangs into the button's padding, which is 12px and has room. The value is derived from
     `1lh` rather than written down, so it follows the font instead of drifting behind it. */
  margin-block: calc((20px - 1lh) / -2);
}

.btn:hover { box-shadow: var(--shadow-control-hover); }
.btn:active { box-shadow: var(--shadow-control-active); }

.btn-primary {
  background: var(--brand-azure-deep);
  color: #ffffff;
}

.btn-primary:hover { background: var(--brand-azure-deep-hover); }
.btn-primary:active { background: var(--brand-azure-deep-active); }

.btn-secondary {
  background: var(--brand-surface);
  color: var(--brand-azure-deep);
  border-color: var(--brand-azure-deep);
}

.btn-quiet {
  background: var(--brand-surface-alt);
  color: var(--brand-ink);
  border-color: var(--brand-border);
}

.btn-danger {
  background: var(--brand-danger);
  color: #ffffff;
}

/* The compact size, for a button that lives inside a bar or a table footer rather than
   standing on its own. Same shape, same shadow, same affordance — only the box shrinks. */
.btn-sm {
  font-size: var(--text-micro);
  padding: var(--space-2) var(--space-4);
}

/* ---------------------------------------------------------------------------
   Form fields — ROUNDED but RECESSED: inset, never a drop shadow
   ------------------------------------------------------------------------ */

.field { display: flex; flex-direction: column; gap: var(--space-2); }

/* A field's name, and TWO HOSTS FOR ONE RULE — a `<label>` and a `<span class="field-label">`.

   The span exists because of the rich text editor, and it is not a style choice. `for` associates a
   label with a LABELABLE element, and a `contenteditable` surface is not one: a `<label for>` on an
   editor is an attribute nothing reads, which is the thing this kit refuses to leave lying around.
   So the editor's name is a span, and the surface points at it with `aria-labelledby` — which is
   the association that actually works.

   Listing both here rather than writing the span its own copy is the same call the table foot and
   the thumbnail foot got: two rules that happen to match today are two rules to keep in step, and
   the day one of them moves is the day a form has two kinds of label. */
.field > label,
.field > .field-label {
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
}

/* You type into these, so they are recessed. */
.input,
.textarea {
  font-family: var(--font-body);
  font-size: var(--text-small);
  color: var(--brand-ink);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-control);
  padding: var(--space-3) var(--space-4);
  box-shadow: var(--shadow-input-inset);
  /* A field is the one control that is routinely told `width: 100%` — to fill a cell, a column or
     a form — and a content-box field that is 100% wide is 100% PLUS its own padding and border,
     so it hangs 26px past whatever it was supposed to fill. Measured: a 327px column drew a 353px
     field. There is no global reset in this stylesheet, so the field family states it itself. */
  box-sizing: border-box;
}

.input::placeholder,
.textarea::placeholder { color: var(--brand-ink-faint); }

/* ---------------------------------------------------------------------------
   The date field — a native date/time control, dressed
   ---------------------------------------------------------------------------
   A date is a date, not a string that looks like one. `<input type="date">` and its
   `datetime-local` sibling are real form controls: they carry a machine-readable value, they
   validate, they submit as a date, and they offer the platform's own picker with no script — and
   this kit has no script. A text input holding "14 Mar" is none of those things, and a
   DEMONSTRATION of one teaches every later drawing of that column to use the wrong control.

   The field's box, border and recess are already `.input`'s; only the picker button is the
   browser's, so only that needs dressing. It becomes a mask in `currentColor` like every other
   icon in the kit, which means it follows the field's ink and needs no second copy for a disabled
   or focused field.
   ------------------------------------------------------------------------ */
.input[type="date"],
.input[type="time"],
.input[type="datetime-local"],
.input[type="month"],
.input[type="week"] {
  /* The value is data, so it lines up like the other figures in the column it sits in. */
  font-variant-numeric: tabular-nums;
  /* The platform draws the value in ITS format — `dd/mm/yyyy, --:--` — at a width it will not go
     below, and `.input` sets no width, so in a column sized by its heading the field was squeezed
     to 137px and the deadline read `14/03/2026, 17:1`. `width: 100%` lets the field fill a column
     wider than it needs; the minimum below is what stops the column squeezing it.

     The minimum has to be a LENGTH. Measured: `min-width: max-content`, `fit-content` and the
     `-webkit-` keywords are all ignored as a `min-width` on an `<input>` in this Chromium, so the
     platform's own intrinsic width is not reachable from CSS. 160px is what `dd/mm/yyyy` draws at
     plus its picker, 208px the same for `dd/mm/yyyy, --:--`; a length on the field does widen the
     table column it sits in, which is the case that was broken.
     `verify_field_fit.py` measures every one of these, both ways — the value has to fit the field,
     and the field has to fit its column — and fails if either is short. */
  width: 100%;
}

.input[type="date"],
.input[type="month"],
.input[type="week"] { min-width: 160px; }

.input[type="datetime-local"],
.input[type="time"] { min-width: 208px; }

.input[type="date"]::-webkit-calendar-picker-indicator,
.input[type="time"]::-webkit-calendar-picker-indicator,
.input[type="datetime-local"]::-webkit-calendar-picker-indicator,
.input[type="month"]::-webkit-calendar-picker-indicator,
.input[type="week"]::-webkit-calendar-picker-indicator {
  width: 20px;
  height: 20px;
  margin-left: var(--space-2);
  /* `currentColor`, not a colour: the button is part of the field and follows its ink. The UA
     dims its own drawing, so the opacity has to be reset or the mask inherits that dimming. */
  background-color: currentColor;
  mask-image: var(--icon-calendar);
  mask-repeat: no-repeat;
  mask-position: center;
  mask-size: 20px 20px;
  opacity: 1;
  cursor: pointer;
}

.input[type="date"]:disabled::-webkit-calendar-picker-indicator,
.input[type="time"]:disabled::-webkit-calendar-picker-indicator,
.input[type="datetime-local"]:disabled::-webkit-calendar-picker-indicator,
.input[type="month"]:disabled::-webkit-calendar-picker-indicator,
.input[type="week"]:disabled::-webkit-calendar-picker-indicator { cursor: not-allowed; }

/* A field with a picker needs room for it. The right padding is the field's own plus the button. */
.input[type="date"],
.input[type="time"],
.input[type="datetime-local"],
.input[type="month"],
.input[type="week"] {
  padding-right: var(--space-2);
  /* And it needs a height that the picker button cannot change.

     A field's height is the tallest thing inside it. In a text field that is the line box; in a
     date field it is the platform's picker button, which is taller than the text beside it — so
     every date and time field stood **50px** against a text field's **42px**, in the same form at
     the same size with the same padding. The button cannot be resized: it is a shadow element, and
     `1lh` on it resolves against the platform's own font rather than the field's.

     So the FIELD states its height, from its own parts — its two paddings, its border, and one
     line box — which is the same sum a text field arrives at by itself. `1lh` here is the field's
     own line box, because this is a real element and not a shadow one. Change the font size or the
     leading and both fields move together. */
  height: calc(2 * var(--space-3) + 2px + 1lh);
}

.input:focus,
.textarea:focus {
  outline: none;
  border-color: var(--brand-azure);
  box-shadow: var(--shadow-input-focus);
}

.textarea { min-height: 88px; resize: vertical; }

/* ---------------------------------------------------------------------------
   The rich text editor — a field that holds FORMATTED text
   ---------------------------------------------------------------------------
   A long-form field is not a textarea with a toolbar bolted on. It is the same
   thing a textarea is — somewhere to type — one step further along, and the two
   are the same FAMILY: both are fields, so both are recessed, both take the
   control radius, and both draw the focus ring for `:focus` rather than
   `:focus-visible`, because clicking one puts a caret in it.

   WHAT MAKES IT RICH IS THE CONTENT, NOT THE CHROME. The surface's typography is
   the DOCUMENT VIEWER's — `.page-title` and `.page-text`, the classes the reader
   sees a published page in — so what an author writes looks like what the class
   will read. A separate "editor type scale" would be a second answer to "how does
   a page look", and the two would drift the first time either moved.

   THE BAR IS CHROME INSIDE THE FIELD, so it wears the chrome ground — the same
   alt surface a card's head and a table's head wear — under a hairline. That is
   what stops the toolbar reading as a row of controls floating above a box.

   THE TOOLS ARE `.tool`, unchanged. A compact pressable control in a bar is one
   component in this kit and it already exists: the marking bar's palette. Drawing
   a second pill for the same job is how two toolbars in one application stop
   looking like each other, and the active state — filled nightfall, never azure —
   is already the kit's answer for a tool that is ON.

   WHY THE TOOLS ARE ICON-ONLY HERE, WHEN THE MARKING BAR'S ARE LABELLED. Ten
   labelled tools measured 571px on the marking sheet, which is why that bar wraps
   to two lines at 810px. An editor's toolbar holds more controls than a pen
   palette does, and every one of them is a toggle the reader sets and forgets, so
   the words would be read once and then only take room. The marks carry the
   meaning and each control is named for assistive technology instead — the same
   trade the zoom pair makes.
   ------------------------------------------------------------------------ */

.editor {
  display: flex;
  flex-direction: column;
  /* A field, so it is told `width: 100%` as often as `.input` is, and a content-box
     field that is 100% wide hangs past what it was meant to fill. The field family
     states this itself — see `.textarea`. */
  box-sizing: border-box;
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-control);
  background: var(--brand-surface);
  box-shadow: var(--shadow-input-inset);
  /* The bar's corners are the frame's, so the frame clips rather than the bar
     carrying a radius of its own that has to be kept in step with this one. */
  overflow: hidden;
}

.editor-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--brand-surface-alt);
  border-bottom: 1px solid var(--brand-border);
}

/* A divider between two GROUPS of tools. Not a gap: the bar's own gap separates
   every control from every other one, so it cannot also say "these four belong
   together and those three are a different kind of thing". The rule does that,
   and it is the same rule a table uses between its rows. */
.editor-sep {
  flex: none;
  align-self: stretch;
  width: 1px;
  margin: var(--space-1) var(--space-1);
  background: var(--brand-border-strong);
}

/* The writing surface. It grows, so a short editor is still a usable box and a
   long one is not scrolled inside a fixed height the author cannot see out of. */
.editor-surface {
  flex: 1;
  min-height: 148px;
  padding: var(--space-4) var(--space-5);
  font-family: var(--font-body);
  font-size: 13px;
  line-height: 1.75;
  color: var(--brand-ink);
  /* You type here, so the pointer says so. `.textarea` gets this from the platform. */
  cursor: text;
}

/* The caret is the state, so there is no ring on the surface itself: the ring goes
   on the FRAME, which is what the reader sees as the field. Drawn for `:focus`,
   not `:focus-visible` — clicking a field is a real way to focus it, and the ring
   is then the caret's own state rather than a keyboard hint. */
.editor:focus-within {
  border-color: var(--brand-azure);
  box-shadow: var(--shadow-input-focus);
}
.editor-surface:focus { outline: none; }

/* The placeholder, with no script and no second element: `:empty` is true of a
   surface that holds nothing, and the words come from the markup. The surface must
   therefore be written with NOTHING inside it — not even the whitespace an
   indented line would carry, which would make it non-empty and silently drop the
   hint. */
.editor-surface:empty::before {
  content: attr(data-placeholder);
  color: var(--brand-ink-faint);
}

/* The content's own rhythm. A surface is a column of blocks, and the FIRST and
   LAST of them sit against the surface's padding rather than adding a margin on
   top of it — otherwise a one-paragraph page is 16px lower than it should be and
   the field looks bottom-heavy. */
.editor-surface > :first-child { margin-top: 0; }
.editor-surface > :last-child { margin-bottom: 0; }
.editor-surface ul,
.editor-surface ol { margin: 0 0 var(--space-4); padding-left: var(--space-5); }
.editor-surface li { margin-bottom: var(--space-1); }

/* A quotation is marked by a bar rather than by italic text or quote marks: it is
   a structural fact about the block, and the bar survives being copied out of the
   editor and into a rendered page, where the quotes would not. */
.editor-surface blockquote {
  margin: 0 0 var(--space-4);
  padding-left: var(--space-4);
  border-left: 3px solid var(--brand-azure);
  color: var(--brand-ink-muted);
}

/* READ-ONLY IS DERIVED, NOT DECLARED. A published page is read rather than
   written, and the browser already knows that: it is a `contenteditable="false"`
   surface. So the dress follows the ATTRIBUTE through `:has()` and there is no
   class for a script to keep in step with the state the browser is enforcing —
   the same reasoning as the table's checked row.

   And what changes is the RECESS. The inset shadow is this kit's word for "type
   into it"; on a surface that cannot be typed into it is a lie, so the read-only
   editor is flat and bordered like the structure it now is. The toolbar is gone
   with it: there is nothing for it to act on. */
.editor:has(.editor-surface[contenteditable="false"]) {
  box-shadow: none;
  background: var(--brand-surface);
}
.editor:has(.editor-surface[contenteditable="false"]) .editor-surface {
  cursor: default;
}

/* Disabled: the whole control is unavailable, so it is muted across the frame —
   the same treatment a disabled field gets, and the same reason, which is that a
   control which cannot be used must not look like one that can. */
.editor[aria-disabled="true"] {
  background: var(--brand-surface-alt);
  border-color: var(--brand-border);
  box-shadow: none;
}
.editor[aria-disabled="true"] .editor-bar {
  background: var(--brand-surface-alt);
  border-bottom-color: var(--brand-border);
}
.editor[aria-disabled="true"] .editor-surface {
  color: var(--brand-ink-faint);
  cursor: not-allowed;
}

/* ---------------------------------------------------------------------------
   Chip field — a field that holds its values as chips
   ---------------------------------------------------------------------------
   NOT a multi-select, and the name matters. A multi-select is the `select
   multiple` convention: one control that lists every option in a column so the
   whole set is on screen. This is the opposite trade — a field you type into,
   which scales to a set far too large to list and expects you to know what you
   are looking for. Both are fields holding a SET of values, so both are recessed
   and take the field's dress; they are siblings, not versions of each other. See
   `.multiselect` below for the other one.
   Choosing several values is a different act from choosing one, so it is a
   different control rather than a `<select>` with `multiple` on it. The values
   stay visible as chips, which is the whole point: the reader can see what is
   chosen without opening anything, and take one back without hunting for it in a
   list that may have scrolled.

   It is a FIELD, so it is recessed — you type into it to narrow the list and
   press Enter to add. A chip inside it is a value, not a control, so the chip is
   flat; the only thing in the field that is pressed rather than typed into is the
   remove button on each chip.

   Disabled, the values STAY. This is the same rule that keeps the tick in a
   disabled checkbox: disabling a control withdraws its affordance, it does not
   delete its content. Only the remove buttons go.
   ------------------------------------------------------------------------ */

.chip-field {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-control);
  box-shadow: var(--shadow-input-inset);
  cursor: text;
}

.chip-field:focus-within {
  border-color: var(--brand-azure);
  box-shadow: var(--shadow-input-focus);
}

.chip-field > input {
  flex: 1 1 8ch;
  min-width: 0;
  padding: var(--space-2) 0;
  border: 0;
  background: transparent;
  font-family: var(--font-body);
  font-size: var(--text-small);
  color: var(--brand-ink);
}

.chip-field > input:focus { outline: none; }
.chip-field > input::placeholder { color: var(--brand-ink-faint); }

/* Room for the remove control inside the chip. */
.chip-field .chip { padding-right: var(--space-1); }

.chip-remove {
  width: 16px;
  height: 16px;
  flex: none;
  padding: 0;
  border: 0;
  border-radius: var(--radius-pill);
  background-color: transparent;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 4l8 8M12 4l-8 8' fill='none' stroke='%2360240E' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 10px 10px;
  cursor: pointer;
}

.chip-remove:hover { background-color: rgba(96, 36, 14, 0.16); }

.chip-field:has(input:disabled),
.chip-field.is-disabled {
  background-color: var(--brand-surface-alt);
  border-color: var(--brand-border);
  box-shadow: none;
  cursor: not-allowed;
}

.chip-field:has(input:disabled) .chip-remove,
.chip-field.is-disabled .chip-remove { display: none; }

/* ---------------------------------------------------------------------------
   Multi-select — the browser's own `select multiple`, dressed by the kit
   ---------------------------------------------------------------------------
   This is the `select multiple` convention: the control is a BOX, and the box
   lists every option in a column. Nothing is typed and nothing has to be known in
   advance, because the whole set is on screen — which is the one thing this
   control exists to guarantee. Four rows is the HTML default and a longer list
   scrolls INSIDE the box, which is what keeps it a field rather than a list of
   everything the application happens to contain.

   It is the chip field's sibling, not a version of it. The trade is what differs:
   the chip field scales to a set too large to enumerate and needs you to know what
   you are looking for; the multi-select makes a small set readable at a glance and
   needs nothing known in advance.

   **It is a real `<select multiple>`.** It was a `role="listbox"` built from nested
   divs, and it looked right — the rows, the ground, the azure bar, the heavier
   label — while being a list box the browser did not know about. A div listbox has
   to reimplement keyboard navigation, type-ahead, the platform's own selection
   behaviour, form submission and the accessibility tree, and it reimplements them
   worse: none of it was there, so the control could only be operated with a mouse.
   The native element is the interface; the kit's job is the dress.

   **What can be dressed is less, and that is the trade.** An `<option>` has no
   pseudo-elements and no reliable font-weight, so the azure bar and the heavier
   label are gone — they cannot be drawn on a native option at all. What remains is
   the option's GROUND and INK, which is enough to carry the state and is what the
   kit now uses. The browser paints its own highlight over the ground while the
   control has focus, which is the platform's indication that it has the keyboard,
   and it should not be fought.

   There is NO chevron, and here that is the platform's business too: a
   multi-select's list is never closed, so there is nothing to disclose. */
.multiselect {
  display: block;
  /* The same content-box trap `.add-row` had, found by the same sweep. It is a FIELD told
     `width: 100%`, and it carries a padding and a border of its own, so without this its box is
     100% PLUS those — 28px wider than the column it is put in. It is not in a dialog today, which
     is the only reason it was not already showing a scrollbar somewhere. */
  box-sizing: border-box;
  width: 100%;
  /* Four rows plus the box's own padding, so the field is the same height in every
     state. `max-height` keeps a longer list inside the field. */
  max-height: 148px;
  padding: var(--space-1);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-control);
  box-shadow: var(--shadow-input-inset);
  font-family: var(--font-body);
  font-size: var(--text-small);
  color: var(--brand-ink);
}

/* A native option's rows are sized by the platform; padding them makes four rows taller than the
   four-row box and the control grows a scrollbar it does not need. */
/* A chosen row is the brand's ground and ink. `background-color` and `color` are the two
   properties a native option actually honours, so the state is carried by the pair rather than by
   a mark: the ground alone is 1.07:1 against the field's white, which is nothing in greyscale. */
.multiselect option:checked {
  background-color: var(--brand-azure-tint);
  color: var(--brand-azure-ink);
}

/* An unavailable option stays IN the list, and is muted rather than hidden: a value that cannot be
   taken is itself information. */
.multiselect option:disabled { color: var(--brand-ink-faint); }

.multiselect:focus-visible {
  outline: 2px solid var(--brand-azure);
  outline-offset: 1px;
}

/* Disabled, the control is flat and muted — and every value STAYS chosen, because the selection is
   the data and disabling the control does not discard it. */
.multiselect:disabled {
  background-color: var(--brand-surface-alt);
  border-color: var(--brand-border);
  box-shadow: none;
  color: var(--brand-ink-faint);
  cursor: not-allowed;
}

/* A search field carries its own submit control.
   ---------------------------------------------------------------------------
   The magnifier sits INSIDE the field rather than beside it, because it acts on
   what has been typed rather than on the page around it.

   It is also the one control that does NOT get the drop shadow, and that is
   deliberate. The shadow rule says a raised control is one you press and a
   recessed field is one you type into; the field is already recessed, which marks
   the whole surface — button included — as the input. A raised square inside a
   recessed one reads as an object sitting in a hole, not as two controls. And
   nothing is lost by dropping the shadow here: the magnifier at the right of a
   search field is the one control whose meaning is carried entirely by its
   position and its shape, so a hover tint and a focus ring are enough to confirm
   it. Everywhere else, elevation still means "you can press this". */
.search {
  position: relative;
  display: flex;
  align-items: center;
}

/* Room for the button, so a long query never runs underneath it. */
.search .input { width: 100%; padding-right: 42px; }

/* A native search field draws its own clear button at the right edge, which would sit on top
   of the magnifier. One control per corner. */
.search .input::-webkit-search-cancel-button,
.search .input::-webkit-search-decoration { appearance: none; }

.search-submit {
  position: absolute;
  right: 4px;
  width: 30px;
  height: 30px;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: var(--radius-control);
  background-color: transparent;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='7' cy='7' r='4.7' fill='none' stroke='%235A5A5A' stroke-width='1.7'/%3E%3Cpath d='M10.6 10.6L14 14' fill='none' stroke='%235A5A5A' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 15px 15px;
  cursor: pointer;
}

.search-submit:hover { background-color: var(--brand-surface-alt); }

.search-submit:focus-visible {
  outline: 2px solid var(--brand-azure);
  outline-offset: 1px;
}

/* A select is CLICKED, not typed into — you press it to open a menu. So it is built like a
   button: pill shaped, raised by the same drop shadow, and carrying a chevron. */
.select {
  appearance: none;
  box-sizing: border-box;
  font-family: var(--font-body);
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
  background-color: var(--brand-surface);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6.5l4 4 4-4' fill='none' stroke='%23222222' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--space-4) center;
  background-size: 12px 12px;
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-pill);
  padding: var(--space-3) var(--space-7) var(--space-3) var(--space-5);
  box-shadow: var(--shadow-control);
  cursor: pointer;
}

.select:hover { box-shadow: var(--shadow-control-hover); }

.select:focus {
  outline: none;
  border-color: var(--brand-azure);
  box-shadow: var(--shadow-control), 0 0 0 3px rgba(241, 90, 34, 0.35);
}

/* AFTER `:focus`, and that is not tidiness — it is the only place the rule works. Pressing a select
   focuses it, so both states match at once, both are two classes deep, and source order decides.
   Written before `:focus` this rule loses to it and the press is answered by the rest shadow plus
   the ring, which is the state the reader was already in. It keeps the ring for the same reason:
   the property is one value, so a collapse that dropped the ring would blink it off under the
   finger. Measured by `verify_pressed_states.py`, which is how the ordering was found. */
.select:active { box-shadow: var(--shadow-control-active), 0 0 0 3px rgba(241, 90, 34, 0.35); }

/* The compact size, for a select that lives inside a bar or a table footer rather than standing
   on its own — the same relationship `.btn-sm` has to `.btn`. It takes the BUTTON's measure in
   every respect: the same height, the same fill, the same pill radius. The chevron is what says
   it opens a menu rather than doing something.

   The base `.select` is a FIELD, and matches `.input` instead — taller box, square-ish radius,
   white fill. That is the right dress for a form, where a white field inside a border reads as
   somewhere to type. It is the wrong dress in a control strip: white-on-white leaves the select
   with nothing but its hairline, so it reads as text with a chevron beside a row of grey pills
   rather than as one of them.

   `line-height: 1` is the other half of that, and it is the same declaration `.btn` carries.
   Without it the select's content box is the font's `normal` line box — about a sixth taller
   than the font — so it stands 2px proud of the button beside it.

   The width is the widest option, and browsers size a select that way, so the pair does not
   shuffle sideways as the reader changes their mind. */
.select-sm {
  font-size: var(--text-micro);
  line-height: 1;
  background-color: var(--brand-surface-alt);
  padding: var(--space-2) var(--space-6) var(--space-2) var(--space-4);
  background-position: right var(--space-3) center;
  background-size: 11px 11px;
}

/* A field's own hint. The margin is zeroed so the spacing comes from the container's gap: as a
   `<p>` this would otherwise add a paragraph margin on top of the gap and space the hint twice. */
.field-hint { margin: 0; font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* A note about a GROUP of fields rather than about one of them.

   The timezone a deadline is read in belongs to the set of date-time fields, not to any single
   one: a form with two deadlines side by side would otherwise say the same thing twice, and the
   same deadline moved to another row would have to take its note with it. It is the same quiet
   line as a field hint — that is what it is, one level up — but it sits directly in the section
   rather than inside a `.field`, so it carries its own spacing.

   `--space-2` is the gap INSIDE a field, which is what makes the note read as the last line of the
   row above it rather than as the first line of whatever comes next; `--space-4` is the gap between
   rows and would detach it. */
.form-note { margin: var(--space-2) 0 0; font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* ---------------------------------------------------------------------------
   Selection controls — rounded and raised
   ------------------------------------------------------------------------ */

.check,
.radio {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-small);
}

/* Styled on the ELEMENT, not on a wrapper, so a checkbox is the same checkbox wherever it
   appears — beside a label in a form, alone in a table's select column, anywhere. Scoping this
   to `.check input` meant the table's select column, which has no room for a label and so no
   wrapper, fell back to the browser's own control and looked like a different application.
   The `.check` and `.radio` wrappers now only lay out the label beside the control.
   `.toggle` is excluded because a switch is a different control, not a checkbox in costume. */
input[type="checkbox"]:not(.toggle),
input[type="radio"] {
  appearance: none;
  box-sizing: border-box;
  width: var(--size-mark);
  height: var(--size-mark);
  margin: 0;
  flex: none;
  vertical-align: middle;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  box-shadow: var(--shadow-control);
  cursor: pointer;
}

input[type="checkbox"]:not(.toggle) { border-radius: 4px; }
input[type="radio"] { border-radius: var(--radius-pill); }

/* The select-all control, and the one control in the kit with THREE states.
   ------------------------------------------------------------------------
   A table's header cell can mean three different things, and they are not degrees of one another:
   nothing is selected, everything on this page is selected, or everything on every page is. A
   checkbox holds two of those and cannot hold the third — which is why this is built on the two
   states a native checkbox DOES have, `:checked` and `:indeterminate`, rather than on a scripted
   button:

     unchecked          nothing selected       → the empty box
     :indeterminate     this page selected     → deep azure fill, ONE tick
     :checked           every page selected    → deep azure fill, TWO ticks

   `:indeterminate` is the browser's own mixed state, and it is the right one for the middle: one
   page out of every page IS a partial selection. It also maps to `aria-checked="mixed"` for assistive
   technology by itself, which a `role="checkbox"` button would have to be told. A native checkbox
   therefore needs NO script for the two states it can hold, and one property assignment —
   `input.indeterminate = true` — for the third.

   It is an `<input type="checkbox">` and not a button, and that is a correction. The first version
   was a `role="checkbox"` button carrying `aria-checked`, which reads well and does nothing: with no
   script attached, pressing it did not change it, so the control was inert where the row checkboxes
   beside it toggled. A design that cannot be operated is not a design, and a control that looks
   pressable and is not teaches the reader to distrust the whole table.

   The three states are told apart by the MARK, not by the fill: `indeterminate` and `checked` are
   both the deep azure fill, and a reader who cannot see the tick can still tell "nothing" from
   "something" by the fill alone. Colour is never the only carrier, and here it carries the coarser
   distinction while the tick carries the finer one.

   The two ticks are drawn side by side, which is what a double tick means everywhere it is used,
   and they are rendered at 14px rather than the single tick's 12px. Size alone was not enough: the
   first pair overlapped horizontally (the first tick reaching x 8.0, the second starting at 7.3) and
   merged into one squiggle — measured on the rendered control, 2 paths but a single unbroken ink
   run. The pair now leaves a 3.2-unit gap, about 2.8px at the rendered size.

   The select-all is excluded from the generic checkbox rule (`:not(.select-all)`), and that
   exclusion is load-bearing. Both selectors weigh the same — `input` + `[type="checkbox"]` + a
   class + a state — so whichever came LAST would win, and the generic rule sits below this one.
   Without the exclusion the row checkbox's single tick silently overrode the two-tick mark, and
   the third state rendered identically to the second: measured on the rendered control, both
   carried the same 142 pixels of ink. A plain row checkbox keeps its single tick. */
input[type="checkbox"].select-all {
  appearance: none;
  box-sizing: border-box;
  display: block;
  width: var(--size-mark);
  height: var(--size-mark);
  margin: 0;
  padding: 0;
  flex: none;
  border-radius: 4px;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  box-shadow: var(--shadow-control);
  cursor: pointer;
}
input[type="checkbox"].select-all:indeterminate,
input[type="checkbox"].select-all:checked {
  background-color: var(--brand-azure-deep);
  border-color: var(--brand-azure-deep);
  background-repeat: no-repeat;
  background-position: center;
}
input[type="checkbox"].select-all:indeterminate {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.2 8.4l3.1 3.1L12.9 4.9' fill='none' stroke='%23ffffff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-size: 12px 12px;
}
input[type="checkbox"].select-all:checked {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M0.6 8.6l1.9 1.9 3.4-4.9' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M9.1 8.6l1.9 1.9 3.4-4.9' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-size: 14px 14px;
}
input[type="checkbox"].select-all:hover { box-shadow: var(--shadow-control-hover); }
input[type="checkbox"].select-all:focus-visible {
  outline: 2px solid var(--brand-azure);
  outline-offset: 2px;
}

/* A checked box shows an actual tick, not just a filled square — a solid azure block
   reads as "selected" only if the reader already knows it is a checkbox. */
input[type="checkbox"]:not(.toggle):not(.select-all):checked {
  background-color: var(--brand-azure-deep);
  border-color: var(--brand-azure-deep);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.2 8.4l3.1 3.1L12.9 4.9' fill='none' stroke='%23ffffff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 12px 12px;
}

/* A checked radio is a white centre inside an azure ring. */
input[type="radio"]:checked {
  background: var(--brand-surface);
  border: 5px solid var(--brand-azure-deep);
}

.toggle {
  appearance: none;
  width: 44px;
  height: 24px;
  border-radius: var(--radius-pill);
  background: var(--brand-border-strong);
  border: 0;
  /* `margin: 0` is a reset the shared checkbox rule carries and this control does not get, because
     it is deliberately excluded from that rule — a switch is a different control, not a checkbox in
     costume. So the UA's own checkbox margin, measured at `4px 3px 3px 4px`, stayed on it, and it
     only became visible when the switch was turned around to sit at the END of its row: the 3px
     right margin then held the switch 3px off the row's right edge while the row above ends flush,
     so the switch read as misaligned. At the start of the row it was a 4px inset nobody had anything
     to compare against. Every other restyled control in the sheet already resets it. `.field-toggle`
     still sets `margin-top` for its own vertical centring. */
  margin: 0;
  position: relative;
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

.toggle::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-pill);
  background: var(--brand-surface);
}

.toggle:checked { background: var(--brand-azure-deep); }
.toggle:checked::after { left: 23px; }

/* ---------------------------------------------------------------------------
   Form layout — how a set of fields is arranged on a surface
   ---------------------------------------------------------------------------
   A long form is not one undifferentiated column of fields. It is grouped into
   named sections, because a reader scanning a form looks for the section that
   holds the thing they came to change. The rule between sections is what makes
   the grouping visible; the first section carries none, because there is nothing
   above it to separate from.
   ------------------------------------------------------------------------ */

.form-section {
  padding-top: var(--space-4);
  margin-top: var(--space-4);
  border-top: 1px solid var(--brand-border);
}

.form-section:first-child { padding-top: 0; margin-top: 0; border-top: 0; }

.form-section > h3 {
  margin: 0 0 var(--space-3);
  font-size: var(--text-micro);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--brand-ink-muted);
}

/* Fields sit one to a row until there is room for two, and then they pair up. A field that
   should never share a row spans the lot. */
.form-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
.form-grid > .span-all { grid-column: 1 / -1; }

@media (min-width: 600px) {
  .form-grid--2 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}

/* A switch row reads as one line: the label, then the control, with the help text under the
   label. It is not a field, so it does not get the field's stacked shape. */
/* A TOGGLE ROW READS LEFT TO RIGHT: the words first, the switch last.

   A switch is the one control that does not sit UNDER its label. Every other field in a form is
   label-above-control, because you read what is being asked and then answer it; a switch is
   answered by its position, and a column of switches all landing on the same right edge is what
   lets a reader scan a list of settings and see at a glance which are on. Putting the switch first
   puts the answer before the question, and it breaks the column: every switch then starts at a
   different x, wherever its label happens to end.

   The text therefore leads and takes the remaining width, which is what pushes the switch to the
   far edge of the row. `order` does the reversing rather than the markup, so the component is
   correct however it is written — a row authored with the control first (as all six in the
   assignment dialog are) and a row authored with the text first both render the same way. The
   markup keeps the control first, which is the conventional order for a form control.

   The row's words are a real `<label for="...">`, not a span beside the switch. A span looks
   identical and reads identically to a sighted reader, and clicking it does NOTHING — which is the
   one thing a label has to do. Every other choice in this system already gets it right by wrapping
   the input (`label.check`, `label.radio`, `label.filter-switch`), so the switch row was the odd
   one out: the label of the control the reader is most likely to click was the only label on the
   page that was inert.

   It is a `<label for>` rather than a wrapping `<label>` because a toggle row may carry a REVEAL,
   and a reveal holds further controls. Wrapped, a click on any of them would also flip the switch
   that revealed them. `for` names the switch and nothing else, so the help text stays inert too —
   it describes the setting rather than answering it. */
.field-toggle {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
}

.field-toggle > .field-toggle-text { order: -1; flex: 1; min-width: 0; }

/* The switch is 24px tall and the label's line is 21px, so top-aligning them leaves the switch
   hanging 7px below its label and reading as level with the helper text rather than with the thing
   it answers. Raising it by half the difference centres it on the label's line, which is the line
   the switch is answering. The number is the two heights' difference, not a preference: change
   either and it changes with them. */
.field-toggle > .toggle { margin-top: -1.5px; }
/* The label answers the switch, so it carries the pointer the switch itself already has. Without
   it the row's words look like every other form label, which is not a control. */
.field-toggle .field-toggle-label { font-size: var(--text-small); font-weight: 500; cursor: pointer; }

/* A label that claims the pointer has to answer it, and this one answers twice — which is the
   point, because the two ends of the row are far apart. The words underline where the pointer
   actually is, and the switch raises at the far edge, which is where the change will appear. The
   row is one control and it says so at both ends.

   The underline is the vocabulary this system already uses for a text target, so a reader who has
   learned it elsewhere reads it here. It arrives ON HOVER here rather than at rest, and that is the
   answer for this KIND of list rather than for links in general: a settings list is a list of
   RECORDS, and underlining every label in it says "link" once per row beside the settings it
   describes. A list of DESTINATIONS — the classes you belong to, the class picker, a breadcrumb —
   underlines at rest, because the reader is choosing between places and the underline is what says
   so before they point at anything. `.record-row-code` is the other half of this rule;
   `check_link_affordance` holds both. */
.field-toggle-label:hover { text-decoration: underline; text-underline-offset: 3px; }
.field-toggle:has(.field-toggle-label:hover) .toggle:not(:disabled) {
  box-shadow: var(--shadow-control-hover);
}
.field-toggle .field-toggle-help {
  margin: var(--space-1) 0 0;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* A LIST OF SETTINGS — a series of switches read as one group. The gap matches `.choice-list`,
   because the two list kinds sit in the same forms and must not read as different densities. */
.toggle-list { display: flex; flex-direction: column; gap: var(--space-3); }

/* A setting that only applies once its switch is on — the same idea as `.choice-reveal`, but with
   no indent: a setting's label already starts at the row's left edge, so there is no mark to clear.
   The question is asked at the moment it becomes relevant, and not before. */
.field-toggle-reveal { display: none; }
.field-toggle:has(.toggle:checked) .field-toggle-reveal { display: block; }

/* A setting that cannot be changed is muted across the whole row — label and callout — so the row
   reads as unavailable rather than merely having a grey switch. The `.check` and `.radio` rows
   already do this; without it a locked setting looked fully available and simply would not move. */
.field-toggle:has(.toggle:disabled) .field-toggle-label {
  color: var(--brand-ink-faint);
  /* A disabled switch does not move, and a label that points at it does nothing either. */
  cursor: default;
}
/* ...and it must not promise otherwise under the pointer: a row that underlines while its switch is
   locked reads as available and then simply will not move. The switch's own lift is already
   withheld by `:not(:disabled)` above. */
.field-toggle:has(.toggle:disabled) .field-toggle-label:hover { text-decoration: none; }
.field-toggle:has(.toggle:disabled) .field-toggle-help { color: var(--brand-ink-faint); }

/* ---------------------------------------------------------------------------
   THE RECORD — an edit that is a PAGE rather than a dialog
   ---------------------------------------------------------------------------
   A dialog is a bounded surface: capped at the viewport, its body scrolling inside it, sitting
   over a page the reader can no longer see. That is the right shape for a question that is
   ANSWERED and the wrong one for a record that is WORKED ON (§5.30) — an assignment's eight
   sections, a rubric's criteria list, a workflow's stage list and its route.

   So an edit is often two steps. The DIALOG captures what is required to REACH the editor — the
   object's name, or an immutable property that decides what the editor will offer — and the
   RECORD is the editor itself, on a page of its own. The dialog is the gate, not a smaller
   version of the page.

   It is a `.tabpanel` like any other view of a surface: the fragment names it, the surface's own
   content is the default while the URL names none, and the way back is a link to the list's own
   fragment. Nothing about it is modal, so the hero that says which surface this is stays on
   screen and the reader keeps their bearings while they work — which is the whole argument for a
   page over a dialog.
   ------------------------------------------------------------------------ */

/* The record is a COLUMN, and it is capped. A form stretched to the full width of a 1440px window
   is a form whose label and field are a foot apart, and the cap is the same measure the widest
   dialog in the kit used — so nothing that fitted in the dialog has to reflow to fit the page. */
.record {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
  max-width: 780px;
}

/* The way back, the title and the lede. The way back comes FIRST, above the title, because it
   answers "where am I" before the reader has read what they are looking at. It is a LINK rather
   than a button: it is a destination, and a control that commits nothing should not wear the
   dress of one that does. */
.record-head { display: flex; flex-direction: column; gap: var(--space-2); }

.record-back {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-micro);
  font-weight: 500;
  color: var(--brand-ink-muted);
  text-decoration: none;
}

.record-back:hover { color: var(--brand-ink); text-decoration: underline; text-underline-offset: 3px; }

/* The way back is a LINE of micro type, so it does not wear the 20px icon box the buttons use —
   a 20px mark beside a 12px word is a mark with a caption. Two classes deep so it beats the base
   box on source order, the same collision `.tabs--segmented > .tab[data-icon]` documents. */
.record-back[data-icon]::before { width: 14px; height: 14px; mask-size: 14px 14px; }

.record-title {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-h2);
  line-height: 1.2;
  color: var(--brand-ink);
}

/* ONE TREATMENT FOR THE LINE UNDER A HEADING, in both the places that have one: a record's head
   and a part's band. Two components stating the same thing twice is two things to keep in step —
   and this is the treatment of "the sentence that says what this is for", which is one job. */
.record-lede,
.running-lede {
  margin: 0;
  font-size: var(--text-small);
  color: var(--brand-ink-muted);
  max-width: 62ch;
}

.record-body { display: flex; flex-direction: column; gap: var(--space-4); }

/* The control strip, read right to left exactly as a dialog's footer is: the way out on the left
   edge, the action that commits in the far corner. It is stated on the quiet button rather than
   as `space-between` so that a record whose strip carries a note keeps the note with the action
   it belongs to instead of dropping it in the middle as a third button. */
.record-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-3);
  padding-top: var(--space-4);
  border-top: 1px solid var(--brand-border);
}

.record-actions .btn-quiet { margin-right: auto; }

/* A record that is wider than a form — one carrying a stage list or a criteria list — may say so,
   and takes the full width of the surface it sits in. */
.record--wide { max-width: none; }

/* ---------------------------------------------------------------------------
   A LIST OF CHOICES — checkboxes or radios offered as a set, where each one may
   carry an adornment: a callout that qualifies it, or a field it reveals.
   ------------------------------------------------------------------------ */

/* The list is a column, and the gap is what says the items are alternatives to one another
   rather than one long paragraph of labels. */
.choice-list { display: flex; flex-direction: column; gap: var(--space-3); }

/* One choice and everything that belongs to it. The adornments are inside this box, so a callout
   can never drift onto the neighbouring option. */
.choice { display: flex; flex-direction: column; gap: var(--space-1); }

/* The callout belongs to the choice's WORDS, not to its mark, so it is indented to line up with
   the text it qualifies. Aligned to the mark it reads as a note about the whole list; aligned to
   the words it reads as a note about the one option it belongs to — which is what it is. The
   indent is the mark plus the gap, derived from the token rather than repeated, so changing the
   size of the box moves the callout with it. */
.choice-help {
  margin: 0;
  padding-left: calc(var(--size-mark) + var(--space-2));
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* A field that only applies once its choice is made — the file-type selector behind "authors
   upload a file". It is hidden until then and indented to the choice's words, so when it appears
   it is visibly INSIDE the option that produced it rather than sitting beside it as a sibling
   question. Nothing else would say which of two adjacent fields the new control answers. */
.choice-reveal {
  display: none;
  padding-left: calc(var(--size-mark) + var(--space-2));
}

.choice:has(input:checked) .choice-reveal { display: block; }

/* A choice that cannot be made says why, and its callout carries the reason rather than being
   dropped — an option that is merely greyed out leaves the reader guessing what would enable it. */
.choice:has(input:disabled) .choice-help { color: var(--brand-ink-faint); }

/* ---------------------------------------------------------------------------
   Chips, tags and status pills — rounded; clickable ones are raised
   ------------------------------------------------------------------------ */

.chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-micro);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  background: var(--brand-azure-tint);
  color: var(--brand-azure-ink);
  border: 1px solid transparent;
}

.chip-violet { background: var(--brand-violet-tint); color: var(--brand-violet-ink); }

/* The status chips. Same three triads as the status pills, because a chip and a pill answer the
   same question — what state is this? — and only their shape differs. A chip is a label you can
   also press; a pill reports. */
.chip-success { background: var(--brand-success-tint); color: var(--brand-success-ink); }
.chip-warning { background: var(--brand-warning-tint); color: var(--brand-warning-ink); }
.chip-danger { background: var(--brand-danger-tint); color: var(--brand-danger-ink); }
.chip-info { background: var(--brand-info-tint); color: var(--brand-info-ink); }

.chip-action {
  background: var(--brand-surface);
  color: var(--brand-ink);
  border-color: var(--brand-border-strong);
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

.chip-action:hover { box-shadow: var(--shadow-control-hover); }
.chip-action:active { box-shadow: var(--shadow-control-active); }

.chip-action[aria-pressed="true"] {
  background: var(--brand-azure-deep);
  color: #ffffff;
  border-color: var(--brand-azure-deep);
}

/* The term bar — the scope ABOVE the class list.
   ---------------------------------------------------------------------------
   Govnix Satellite's classes are per-term: the same course runs in Fall, in Spring and in
   Summer, with different people, different assignments and different marks. So a
   class is not identified by its code — "BIOL 1102" names three different classes
   in one year — and a list of them without a term is a list of near-duplicates
   that the reader has to tell apart by a suffix at the end of every row.

   The bar sits between the dialog's head and its body, as a `flex: none` sibling of
   both. It is not part of the head, which holds the title and the explanation and
   no controls; and it is not inside the body either, because the body's first child
   has to stay the first class option, so that the list's own top rule is suppressed
   by `:first-child` instead of doubling against the bar's bottom rule.

   Chips rather than a dropdown: there are two or three terms in play, not twenty.
   Every one is then visible and one tap away, where a select would hide two choices
   to save a single row.
   ------------------------------------------------------------------------ */
.term-row {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  padding: var(--space-3) var(--space-5);
  border-bottom: 1px solid var(--brand-border);
}

/* "Term", not "semester" — though the application's own instructor-facing text says semester
   (`editCourses.php`: "the name of the course, the year and the semester"). The institution
   this kit was drawn for runs three terms a year including a summer session, and a summer session
   is not a semester. The narrower
   word would be wrong for a third of the year. */
.term-label {
  flex: none;
  margin-right: var(--space-1);
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-muted);
}

/* The class list — choosing which class you are working in.
   ---------------------------------------------------------------------------
   A class is a scope, not a page (PROJECT_PLAN §19.1): you choose one and
   everything below resolves inside it. This is that choice, and it is the
   fallback path rather than the normal one — the header selector carries the
   current class, and this is what you open when you want a different one.

   The current row is marked the way every current item in this application is
   marked: an azure bar, and a heavier label. Not an azure label — azure text
   appears once, on the application name. It also takes the settled-selection
   grey, so the row you are in is quiet and the rows you are not in are the ones
   that light up under the pointer.

   The marking is the ATTRIBUTE, not one of its values. `aria-current` has a
   value vocabulary of its own and it is the application's to choose: a row that
   navigates somewhere says `page`, and a row that is simply the member you are
   standing on — an organization you are reading, with nowhere to go — says
   `true`. Both are the same fact to this rule, so it reads `[aria-current]`
   and leaves the value to whoever knows best what the row does.
   ------------------------------------------------------------------------ */

.feature-option {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-5);
  color: var(--brand-ink);
  text-decoration: none;
  border-top: 1px solid var(--brand-border);
  /* A ROW FILLS ITS ROW WHATEVER CHANGES WHEN IT IS PRESSED — the same rule as "a control is not
     a link", applied to geometry rather than to chrome. Every screen in this kit writes a picker
     row as an `<a>`, and an anchor in normal flow fills its container. An application writes the
     row that changes the session's organization as a `<button>`, because it navigates nowhere — and
     a button's `width: auto` resolves SHRINK-TO-FIT however it is displayed, `display: flex`
     included, because that is the layout the HTML rendering rules give the element. MEASURED: the
     organization picker's only row came out 269px wide and left-aligned inside a 559px dialog,
     with the UA's centred `text-align` inherited into the name column, while the feature picker's
     rows beside it filled the dialog — same class, same dialog, different element. State the two
     facts a button does not bring with it and the row stops depending on which element built it. */
  width: 100%;
  box-sizing: border-box;
  text-align: left;
  /* AND IT SAYS IT IS A CONTROL, in the one place the kit's own guard looks. `validate_html.py`
     derives the control vocabulary from this declaration — it is how the kit says a thing is
     pressable — so a class that relies on the browser's default pointer for anchors is INVISIBLE to
     the check that exists to catch exactly this fault. That is how a row whose only stated border
     was `border-top` shipped: no check could see it, because it never said it was a control.
     Declaring it is not bookkeeping — it is the statement that makes the guard work. */
  cursor: pointer;
}

.feature-option:first-child { border-top: 0; }
.feature-option:hover { background: var(--brand-azure-tint); }

.feature-option[aria-current] { background: var(--brand-surface-alt); }
.feature-option[aria-current]:hover { background: var(--brand-surface-alt); }

.feature-option[aria-current]::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 4px;
  height: 70%;
  background: var(--brand-azure);
}

.feature-option-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
/* The picker is the same list asking the same question, so it wears the same answer. */
.feature-option-code { font-weight: 500; text-decoration: underline; text-underline-offset: 3px; }
.feature-option[aria-current] .feature-option-code { font-weight: 600; }
.feature-option-name { font-size: var(--text-micro); color: var(--brand-ink-muted); }
.feature-option-role {
  flex: none;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* A label for a row of filters.
   ---------------------------------------------------------------------------
   The chips say WHICH filter is applied; this says that the row is filtering at
   all, so it is not mistaken for a set of category tabs. It is a MARKER, not a
   control: no border, no shadow, no hover, and `aria-hidden`, because there is
   nothing to press — the chips beside it are the controls. Giving it a raised
   box would promise a filter panel that does not exist.

   ONE mark for the ROW, never one per set. What it says is a fact about the row
   — that this row filters at all — and there is one of those however many sets
   the row holds. A mark inside each set said it twice, and said something else
   as well: two marks at the head of two runs of chips is the two-rows reading
   coming back at a smaller scale, which is the reading merging the rows existed
   to remove. The sets are told apart by their labels, their own chip tints and
   the wider gap between them, and none of those is the mark's job.

   It is a MASK rather than a picture so that its colour is a token. It takes the
   ROW's ink, so it takes the muted grey: it cannot wear one set's family without
   claiming to belong to that set, and a mark that belongs to no set is exactly
   what it is. It used to read `var(--set-ink, …)` and pick up whichever set it
   happened to sit inside.
   ------------------------------------------------------------------------ */

.filter-mark {
  flex: none;
  width: 16px;
  height: 16px;
  background-color: var(--brand-ink-muted);
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M2.5 3.5h11l-4 5v4.2l-3 1.3V8.5l-4-5z' fill='none' stroke='%23000' stroke-width='1.5' stroke-linejoin='round'/%3E%3C/svg%3E");
  mask-repeat: no-repeat;
  mask-position: center;
  mask-size: 16px 16px;
}

/* A row of filters can hold more than one SET of them, and the sets are colour coded.
   ---------------------------------------------------------------------------
   The calendar filters by class and by kind; the gradebook by class and by state. Each is a
   different dimension of the same question, so they belong on ONE row: two rows spend a line of the
   page saying "there is more than one filter here", which the colour says for nothing. What the
   reader then needs is to see where one set ends and the next begins.

   THREE things answer it, and the mark is not one of them — it belongs to the ROW and there is one
   of it. The code is a FAMILY per set — its ink on the LABEL, its tint on the chips at rest — plus
   the wider gap between sets than within one. **The pressed chip does not change family.**
   "Selected" is one thing, and a second colour meaning "which set" at the same moment would make
   one control carry two meanings at once; the set is still legible while a chip is pressed, from
   the label and the chips beside it that are still at rest.

   The label is text as well as colour, because colour alone is not a code for a reader who cannot
   see it — and "Class" is a shorter thing to read than a hue is to decode.
   ------------------------------------------------------------------------ */

.filter-set {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}

.filter-set-label {
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--set-ink);
}

/* The tint belongs to a chip that is NOT selected. Written as `:not()` rather than left to source
   order, because `.chip-action[aria-pressed="true"]` and `.filter-set .chip-action` weigh the same
   — (0,2,0) — so which one won would depend on where this rule happened to sit in the file. */
.filter-set .chip-action:not([aria-pressed="true"]) { background: var(--set-tint); }

.filter-set--class { --set-ink: var(--brand-azure-ink); --set-tint: var(--brand-azure-tint); }

/* Kind and State are the same set seen on two screens: what sort of item this is, and where it has
   got to. One family between them, so a reader who learns the violet on the calendar reads it again
   on the gradebook. */
.filter-set--kind,
.filter-set--state { --set-ink: var(--brand-violet-ink); --set-tint: var(--brand-violet-tint); }

.status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  /* A pill is a LABEL, and a label that wraps is two labels stacked. It is also the one element in
     a table cell that can grow the ROW: measured with the status column squeezed, "In progress"
     broke onto two lines and the pill went 17px → 35px, taking its row from 20px → 38px with it —
     so one long status re-spaced every row in the table. The pill does not wrap; the cell takes
     the width instead. */
  white-space: nowrap;
  font-size: var(--text-micro);
  font-weight: 500;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  background: var(--brand-surface-alt);
  color: var(--brand-ink-muted);
}

.status::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: var(--radius-pill);
  background: currentColor;
}

.status-on { background: var(--brand-success-tint); color: var(--brand-success-ink); }
.status-off { background: var(--brand-danger-tint); color: var(--brand-danger-ink); }
.status-due { background: var(--brand-warning-tint); color: var(--brand-warning-ink); }

/* ---------------------------------------------------------------------------
   PERMISSIONS — who can reach this, and what may they do with it
   ---------------------------------------------------------------------------
   Reach was drawn differently in every place it appeared. A workflow's was a
   `<select>`; a rubric's was two checkboxes — "This class only" AND "Every class
   I teach", which cannot both be true; and a list reported it as bare grey text
   in a third set of words. Three drawings for one question, so the reader had to
   learn each one and translate between them.

   This is ONE component, and it is the only place a reach is set or read. Its
   frame is fixed and only its CONTENTS change:

     the LEVELS      a closed set of mutually exclusive scopes, chosen with a
                     radio. Used for record sharing (§10.4, §11.4) and for a
                     declared policy, such as a quiz's result visibility (§8.6);
     the ROLES       a set of audiences that are NOT exclusive — content offered
                     to markers and withheld from students (§7.4), an
                     announcement's roles — so the same frame holds checkboxes;
     the SUMMARY     the chosen level read back in a list, in the control's own
                     words, so a table and the form that set it agree;
     the WINDOW      when the reach applies, which is the kit's own date fields
                     rather than a second date control;
     the EXCEPTIONS  a named person or class that departs from the level, added
                     with the kit's own chip field and listed here.

   TWO RULES CARRY IT:

   1. A level is ONE choice, so it is a RADIO. "Only my classes" and "Everyone"
      cannot both hold; drawn as checkboxes the form offers a state the server
      must refuse, and the reader who ticks both has been told they chose
      something they did not. A set that genuinely IS not exclusive is the roles
      form below, and it is a different form for that reason.

   2. The whole card is the TARGET, not the mark. An 18px box is far under the
      44px minimum on its own, and the note ("editing a shared rubric copies
      it") qualifies the option it belongs to — so the label wraps the mark, the
      name and the note together, and a click anywhere in the option chooses it.

   The frame is STRUCTURE — square, flat, no shadow — because nothing in it is a
   control until the levels inside it are.
   ------------------------------------------------------------------------ */

.perm {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  margin: 0;
  padding: 0;
  border: 0;
  /* A flex item's floor is its content unless it is told otherwise, and a long level name would
     then push the frame wider than the column it was given. */
  min-inline-size: 0;
}

.perm-legend {
  margin: 0;
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
}

/* The note belongs to the QUESTION — what the answer means, or what it costs — so it sits above
   the levels and is not indented to any one of them. A note about ONE option lives inside that
   option, as `.perm-level-note`. */
.perm-note {
  margin: 0;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* The levels are a COLUMN, and the gap is what says they are alternatives to one another rather
   than a set of facts. */
.perm-levels {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
}

/* One level, and everything that belongs to it. The label wraps all of it, so the option is what
   the reader aims at rather than the mark. */
.perm-level {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  background: var(--brand-surface);
  cursor: pointer;
}

.perm-level:hover { border-color: var(--brand-border-strong); }

/* The chosen level is marked by a FILL and by the radio's own mark, never by colour alone. The
   fill is the quiet alt ground rather than a tint of the accent: a reach is not a status, and a
   column of haze cards would read as a column of errors. */
.perm-level:has(input:checked) {
  background: var(--brand-surface-alt);
  border-color: var(--brand-border-strong);
}

/* A level that cannot be chosen KEEPS its note rather than losing it, and says why there — a
   greyed option with no reason leaves the reader guessing what would enable it. */
.perm-level:has(input:disabled) { cursor: not-allowed; }
.perm-level:has(input:disabled):hover { border-color: var(--brand-border); }
.perm-level:has(input:disabled) .perm-level-name,
.perm-level:has(input:disabled) .perm-level-note,
.perm-level:has(input:disabled) .perm-level-tag { color: var(--brand-ink-faint); }

/* The mark is the global radio; it is only aligned to the first line of the name rather than to
   the top of a two-line option. */
.perm-level > input[type="radio"],
.perm-level > input[type="checkbox"] { margin-top: 1px; }

.perm-level-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }

.perm-level-name {
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
}

.perm-level-note {
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* A CONSEQUENCE of the level, as distinct from its meaning: what already attaches it, or how many
   authors an edit would affect (§11.2's blast radius). It is a tag rather than a sentence because
   it is read down a column of options while the reader is deciding. */
.perm-level-tag {
  flex: none;
  align-self: flex-start;
  margin-top: 1px;
  font-size: var(--text-micro);
  color: var(--brand-ink-faint);
  white-space: nowrap;
}

/* THE READ-BACK. A list reports the level in the SAME WORDS the control offers, so the reader
   never has to learn a translation between a form and a table. `data-perm-level` carries the
   level's canonical key, so the words can be re-written without changing the meaning and one page
   can render another's level from a value rather than from prose.

   Only the widest level carries the accent, and that is a real boundary rather than decoration:
   `none` and `colleagues` both stay inside the institution, and `everyone` is the one that leaves
   it. A tag that coloured all three would say the same thing three times. */
/* `.cap-tag` is the fifth member: ONE CAPABILITY a role may exercise, read back in the matrix's
   narrow form. The pill shape is shared because the job is shared — a tag is one value read back —
   and each meaning keeps its own name, so a class in the markup says which kind of read-back it is.
   It is deliberately NOT `.perm-tag`: that one carries a `data-perm-level` and is checked against
   the control that set it, and a capability is not a level. */
.perm-tag,
.policy-tag,
.unit-tag,
.ai-tag,
.cap-tag {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  white-space: nowrap;
  font-size: var(--text-micro);
  font-weight: 500;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  background: var(--brand-surface-alt);
  color: var(--brand-ink-muted);
}

.perm-tag::before,
.policy-tag::before,
.unit-tag::before,
.ai-tag::before,
.cap-tag::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: var(--radius-pill);
  background: currentColor;
}

/* THE MACHINE MARK — the same frame, and the one read-back that carries an accent for a reason the
   others do not.
   A mark, a comment or a summary a machine made has to be distinguishable from one a person made,
   wherever it is read — in the author's feedback, in a marker's view, in a moderation list and in an
   export (§12.9). It is not a preference and it is not a policy: a person marked by a machine MUST
   be told so, and the only way a label survives every one of those places is if it is a component
   rather than a sentence somebody remembered to write.

   It is FILLED, and filled in nightfall, which no other tag is. The unit tag's comment argues that
   colouring one of three ordinary answers would be decoration pretending to be a boundary; here
   there IS a boundary — a machine's mark is not a person's, and the reader's judgement of it is
   different — so the accent is the point rather than an ornament. Nightfall rather than azure
   because azure is the kit's word for "you are here" and "this is primary", and a machine mark is
   neither: it is a fact about provenance. */
.ai-tag {
  background: var(--brand-space);
  color: var(--brand-on-dark);
}

/* THE MARKING UNIT — the same frame again, and the one read-back that carries NO accent.
   A rubric's criteria are worth something, and that something is stated in one of three units:
   the criteria are equally weighted, or each is a percentage of the rubric, or each is a number of
   points. The unit is the rubric's own, not a criterion's, because the criteria have to TOTAL
   something — and a total is only readable if every term in it is in the same unit.

   The other two tags accent a level that leaves the institution or a policy that bites. No unit
   does either: all three are ordinary ways to state what a criterion is worth, and a colour on one
   of them would be decoration pretending to be a boundary. So the frame is shared and nothing is
   added — which is itself the statement that these are three answers to one question, not a
   ranking. */
.unit-tag {
  font-variant-numeric: tabular-nums;
}

.perm-tag[data-perm-level="everyone"] {
  background: var(--brand-warning-tint);
  color: var(--brand-warning-ink);
}

/* AUDIENCE BY ROLE — the same frame, a different answer set.
   Content, announcements and quiz windows are not shared BETWEEN institutions; they are published
   to the PEOPLE in one class, and who that is arrives as a set of roles. The set is not a choice,
   so these are checkboxes — and they wear the pill shape rather than the level card, because four
   roles drawn as four cards would cost four times the height to say what four words say. */
.perm-roles {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
}

.perm-role {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-pill);
  background: var(--brand-surface);
  font-size: var(--text-small);
  color: var(--brand-ink);
  cursor: pointer;
}

.perm-role:hover { border-color: var(--brand-border-strong); }
.perm-role:has(input:checked) { background: var(--brand-surface-alt); border-color: var(--brand-border-strong); }
.perm-role:has(input:disabled) { cursor: not-allowed; color: var(--brand-ink-faint); }
.perm-role:has(input:disabled):hover { border-color: var(--brand-border); }

/* EXCEPTIONS — a named person or class that departs from the level above.
   This is a LIST, not a form: the reader checks what is already there before adding another, and
   each row is one departure with the control that takes it back. The thing that ADDS one is the
   kit's chip field, because an open set of identifiers is exactly what a chip field is for — the
   same control "visible reviewers" already uses, rather than a second way to name a person. */
.perm-exceptions {
  display: flex;
  flex-direction: column;
  margin: 0;
  padding: 0;
  list-style: none;
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  overflow: hidden;
}

.perm-exception {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  background: var(--brand-surface);
}

.perm-exception + .perm-exception { border-top: 1px solid var(--brand-border); }

.perm-exception-who {
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
}

.perm-exception-what {
  flex: 1;
  min-width: 0;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* THE COMPACT FORM, for a dialog column or a form grid where the three levels are being CONFIRMED
   rather than explained. The NAME stays — it is the level's identity — and the per-level notes go,
   because a reader choosing which record to attach is not re-reading what sharing means. The
   question's own note stays: it is the consequence that applies to whichever level is chosen. */
.perm--compact .perm-levels { gap: var(--space-1); }
.perm--compact .perm-level { padding: var(--space-2) var(--space-3); }
.perm--compact .perm-level-note { display: none; }

/* ---------------------------------------------------------------------------
   MARKER GROUPS — who marks, grouped, with the policy each group marks under
   ---------------------------------------------------------------------------
   A class does not have one set of markers. It has the students who review each
   other's drafts, a group of paid markers hired to mark for the instructor, and
   the instructor working with a TA — and the three differ in who they are, how
   much each may mark, and what they are shown. Held as one list called
   "markers", every policy that distinguishes them is unsayable, and the same
   three groups have to be re-declared on every assignment, which is how a policy
   about paid markers comes to differ between two assignments without anyone
   deciding that it should.

   So a marker group is a RECORD — a name, a member set and a policy set — and
   these are the parts it is drawn from:

     the CARD        one group: its name, its policies read back, its members and
                     where it is used. It is STRUCTURE, so square and flat, like
                     the roster it partitions;
     the MEMBERS     the people in it, each as a pill, because a group is read as
                     "who is in it" far more often than it is counted;
     the POLICY TAG  the group's policies in the POLICY FRAME'S OWN WORDS, so the
                     editor and the list cannot drift apart — the rule the
                     permissions component is built on (§5.19);
     the EMPTY CARD  a group with no members, which is INERT rather than an
                     error: it is offered as a destination, allocates nobody, and
                     says so, because an empty group that looked like a staffed
                     one is a stage nobody would notice had no marker.

   The policies themselves are NOT re-invented here. A policy is one choice from a
   closed set, which is what `.perm[data-perm="policy"]` already is, so the editor
   uses that frame and only the READ-BACK is new: `.policy-tag`, which is
   `.perm-tag`'s shape carrying a marker policy's key rather than a sharing level.
   ------------------------------------------------------------------------ */

.mgroup-list,
.machine-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.mgroup,
.machine {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-4);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  background: var(--brand-surface);
}

/* A group with no members is drawn as an OPEN frame rather than a filled one. It is not the same
   kind of thing: it is a destination that allocates nobody, and a card identical to a staffed
   group's is a stage nobody would notice had no marker in it. */
.mgroup--empty {
  border-style: dashed;
  background: var(--brand-surface-alt);
}

.mgroup-head,
.machine-head {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2) var(--space-3);
  flex-wrap: wrap;
}

.mgroup-title,
.machine-title {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1 1 200px;
  min-width: 0;
}

.mgroup-name,
.machine-name {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-h5);
  font-weight: 700;
  color: var(--brand-ink);
}

.mgroup-note,
.machine-note {
  margin: 0;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* The count is the ONE fact about a group that is not its name, and it is read before the members
   are: a reader scanning the list wants to know whether a group is staffed. */
.mgroup-count,
.machine-count {
  flex: none;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
  white-space: nowrap;
}

.mgroup-policies,
.machine-policies {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}

/* THE MACHINE'S CARD — a marker group's frame with a different record in it (§12.9).
   It shares the frame deliberately: an AI marker is a class-owned record a stage names as its
   destination, which is what a marker group is, and drawing it as anything else would say the two
   are different kinds of thing. What is different is the RECORD — no members, because a machine
   holds no class role and cannot be in a group, and the rubric, model and prompt it marks with
   instead — so those three facts get a definition list and the member list has no counterpart.
   The workload policy is absent for the same reason: a machine marks every paper in its stage, and
   offering "one paper per assignment" would offer a setting that cannot mean anything. */
.machine-facts {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  margin: 0;
  padding: var(--space-3);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  background: var(--brand-surface-alt);
  font-size: var(--text-micro);
}

.machine-fact {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
}

.machine-facts dt {
  flex: none;
  min-width: 96px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-faint);
}

.machine-facts dd {
  margin: 0;
  color: var(--brand-ink);
}

.member-list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* "and 128 more", for the one group whose membership is the class. A group of everybody cannot list
   everybody, and a card that silently stopped at six would read as a group of six.

   It WRAPS, and that is not tidiness. Written `white-space: nowrap` — which is what a short summary
   wants — the first sentence put in one took the page with it: measured at a 500px viewport the item
   was **620px** wide, so the page scrolled sideways by 170px and every dialog on the surface
   reported as overflowing. A `nowrap` is a promise that the text is short, and nothing in the
   markup keeps that promise. */
.member-more {
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

.mgroup-foot,
.machine-foot {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--brand-border);
}

/* Where the group is USED — the destination read-back. It is the half of the record that makes a
   group worth having, so it sits in the foot where a reader looks after the members rather than
   being left to the workflow that names it. */
.mgroup-used,
.machine-used {
  flex: 1;
  min-width: 0;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* One person, as a group lists them. The avatar is the roster's own, at the size a pill can
   afford, and the role is a word rather than a colour: a group's members may hold any class role,
   because a group is a destination rather than a role — the peer group is students, the marking
   group is markers, and the instructor-and-TA group is neither. */
.member {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3) var(--space-1) var(--space-1);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-pill);
  background: var(--brand-surface-alt);
  font-size: var(--text-small);
  color: var(--brand-ink);
}

.member .avatar {
  width: 22px;
  height: 22px;
  font-size: 10px;
}

.member-role {
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* The one policy level that reaches BEYOND the group is marked, for the reason the widest sharing
   level is marked: a group applied to every review task changes what happens in assignments the
   reader is not looking at, and a group used only where a workflow names it changes nothing until
   something names it. */
.policy-tag[data-policy="applies"][data-policy-level="default"] {
  background: var(--brand-warning-tint);
  color: var(--brand-warning-ink);
}

/* ---------------------------------------------------------------------------
   Tabs — rounded, raised; the active one is filled
   ------------------------------------------------------------------------ */

/* THE TAB ROW WRAPS, and it is the only fix the system's scroll rule allows. It was a plain flex
   row with no wrap, and four padded labels do not fit a phone's content column: measured, the row
   reached 424px in a 390px window on `credential.html` and 403px on `system.html`, so the PAGE grew
   a horizontal scrollbar and the reader was given a second scroll plane — the thing §"only one
   scrollbar may be live at a time" exists to forbid.

   A sideways-scrolling tab row is the other answer and it is the worse one: the reader is handed a
   scrollbar to reach a destination they can already see is there, and the row then hides how many
   views the page has. Wrapping costs a second line at the narrowest band, keeps every tab visible at
   once, in order, and asks for no gesture. It changes nothing at any width where the row already
   fits, because it only wraps when it would otherwise overflow. */
.tabs { display: flex; gap: var(--space-2); flex-wrap: wrap; }

/* THE VIEW SWITCHER THAT IS NOT DRAWN — the row a `.tabbed` still needs when its two views each
   carry their own way in and their own way back (§5.30).

   A record editor is a view of its surface: the surface's own action opens it and the record's
   own way back closes it, and those two controls are where the reader is already looking. A tab
   row above the hero saying the same pair of words would be the third copy of one answer. But the
   stylesheet relates a switcher to its panels BY POSITION, and `check_tabs` refuses a `.tabbed`
   with no switcher at all — because that is how a view stops being reachable and how the current
   one stops being marked. So the row exists, keeps its two links live, and stays out of the
   layout: taken out with `.sr-only`'s technique rather than `display: none`, so it is still in the
   accessibility tree and a reader who cannot see the page is still told there are two views.

   The tab dress's own state rules do not reach it — a row clipped to a pixel cannot show a fill —
   and they do not need to: the record's `data-icon` way back is the visible state. */
.tabs--views {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.tab {
  /* The tab's own two colours, named. A count inside it inverts THESE values rather than
     guessing at colours it cannot see — see `.count`. The declarations below read the
     properties, so a new state cannot change the background and leave the badge behind. */
  --control-fill: var(--brand-surface);
  --control-ink: var(--brand-ink-muted);
  font-family: var(--font-body);
  font-size: var(--text-small);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-pill);
  border: 1px solid var(--brand-border);
  background: var(--control-fill);
  color: var(--control-ink);
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

.tab:hover { box-shadow: var(--shadow-control-hover); }
.tab:active { box-shadow: var(--shadow-control-active); }

/* A tab can be a link as well as a button, and the two mark "this is the one you are on"
   differently: a button tab is `aria-selected`, because a tab is a state of one widget, and a
   link tab is `aria-current`, because three views are three destinations and the reader may
   arrive at any of them directly. The same dress applies to both — a reader does not care
   which element the current one happens to be. */
.tab[aria-selected="true"],
.tab[aria-current="true"],
.tab[aria-current="page"] {
  --control-fill: var(--brand-space);
  --control-ink: var(--brand-on-dark);
  background: var(--control-fill);
  color: var(--control-ink);
  border-color: var(--brand-space);
}

/* ---------------------------------------------------------------------------
   Tabs that switch a PANEL, on a page that carries no script
   ---------------------------------------------------------------------------
   The dress above is one component in three host elements, and WHICH ONE a page
   uses is decided by where the state lives. A `<button aria-selected>` is a state
   of one widget and needs a script to change it. A `<label>` bound to a radio
   needs nothing, and so does a LINK: the fragment does the switching. A view of a
   page takes the link form here, for the reason the dialogs do — the fragment is
   what a reader can arrive at, what the Back button undoes, and what the renderer
   and the guards use to reach a state at all. A view that could not be reached by
   URL is a view nothing can photograph.

   THE TWO HALVES ARE RELATED BY POSITION, and that is the whole convention:

     * the TABS come first and the PANELS after them, in the SAME ORDER, so the
       second tab belongs to the second panel;
     * CSS cannot ask "which tab points at the panel that is targeted" — `:target`
       matches the element whose id is the fragment, and a tab carries the fragment
       in its `href`, not in an id — so the current tab is marked by matching the
       panel's POSITION. Writing the tab's own id into the stylesheet would put a
       page's names in the design system, which is the mistake the hero's scene
       paths already taught;
     * the FIRST panel is the default, shown while the URL names none, which is
       every arrival from the navigation;
     * and there are FOUR positions, because four is the most a tab row can hold
       and still be read at a phone width without becoming two rows of controls.

   `check_tabs` holds all four, and `verify_tabs.py` measures the third: that
   exactly one panel is laid out, at every width, whichever tab is current.
   ------------------------------------------------------------------------ */

.tabbed {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

/* CLOSED by default, so a page whose tabs are wired to nothing shows one panel rather than all of
   them. A page that drew both is a page whose tabs do nothing — the failure the header's cog was
   removed for, one level down. */
.tabpanel { display: none; }

/* The panel the URL names. */
.tabpanel:target { display: block; }

/* And the DEFAULT, while the URL names none. `:not(:has(...))` is what makes "while nothing is
   targeted" expressible: without it the default would be drawn UNDER whichever panel was targeted,
   and the page would show two views at once.

   The second `:not` is the same question asked of a DIALOG. A dialog is opened by the fragment
   too, and a dialog opened from inside a panel takes the fragment with it — so the panel stops
   being the target, and without this the surface the dialog belongs to closes behind it and the
   reader comes back to the first tab. The dialog would then be a modal over a page they were not
   looking at, and the way out would put them somewhere they had not been. */
.tabbed:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target))
  > .tabpanel:nth-of-type(1) { display: block; }

/* The panel that OWNS the open dialog, and it is shown instead of the default. Written against
   the relationship rather than an id — the panel that holds the anchor is the panel the dialog
   came from — so a new dialog needs markup and no new CSS. */
.tabbed:has(> .tabpanel .dialog-open:target) > .tabpanel:has(.dialog-open:target) { display: block; }

/* And the same question asked of a NESTED panel, which is a state one level deeper: a record editor
   reached from inside a sub-feature — the roster's role capabilities are a page under its Roles
   part. The fragment names the inner panel, so the OUTER switcher has nothing targeted in it and
   would fall back to its first panel: the reader would arrive at the capability page with the Users
   list behind it, and the sub-menu would say they were somewhere they are not.

   One `:has()` per compound and no nesting, for the reason the rule above records: `:has()` inside
   `:has()` is not parsed, and an invalid selector takes the whole list with it. */
.tabbed:has(> .tabpanel .tabpanel:target) > .tabpanel:has(.tabpanel:target) { display: block; }

/* A panel is a scroll TARGET, so the browser scrolls it into view — and the tab row that was just
   clicked sits directly above it. Without a margin the row is scrolled off the top of the screen and
   the reader loses the control they were using: the one thing a tab must not do. The margin is the
   row's own height plus the gap, so the row and the panel's first line both stay on screen. */
.tabpanel { scroll-margin-top: calc(var(--space-6) + var(--space-5)); }

/* A SUB-MENU ROW IS TALLER THAN A TAB ROW, and the margin is the row that was actually pressed plus
   the two spaces under it — the sub-menu's own `margin-bottom` and the switcher's gap.

   A sub-menu item measures 45px (its own padding and one line of small type), so the tab row's 56px
   is 29px short of what a sub-menu needs. Measured on the assignments surface: pressing "Rubrics"
   scrolled the panel to the top of the window and left the row that was pressed 29px ABOVE it, so
   the reader lost the control they had just used — which is the one thing a tab row must not do,
   and it is the failure `verify_tabs.py` exists to catch. The roster escaped it only because its
   page happened to fit the window at the width the guard measures. */
.tabbed > .subnav ~ .tabpanel {
  scroll-margin-top: calc(2 * var(--space-3) + 1.5 * var(--text-small) + var(--space-5) + var(--space-4));
}

/* A WHOLE-PAGE panel is reached from the navigation rather than from a tab row, so the thing above
   it is the HEADER and not a row of tabs: the margin is the header's height plus the region's own
   top padding, which is the whole of the distance between the top of the document and the panel.

   The difference is not cosmetic. With the tab row's own 56px the browser scrolls 40px past the
   panel to reach it — `scroll-margin-top` is space LEFT above the target, and the panel's offset is
   the header plus the padding — and the brand bar comes back in the capture cut in half, showing
   the bottom slivers of its own controls. Measured on the rendered board: the header's top sat at
   y **-40** with the record editor open, on a page whose whole arrival is this one scroll.

   The fallback is for the specimen document, which carries the switcher but no application shell
   and therefore no header. Without it the custom property would be undefined there, the whole
   declaration would be invalid at computed-value time, and the panel would silently lose the
   margin the rule above gives it — including on the sub-menu specimen, where the row above the
   panel is what `verify_tabs.py` measures. */
.tabbed > .tabs--views ~ .tabpanel { scroll-margin-top: calc(var(--header-h, 32px) + var(--space-6)); }

.tabbed:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target)) > .tabs > .tab:nth-of-type(1),
.tabbed:has(> .tabpanel:nth-of-type(1):target) > .tabs > .tab:nth-of-type(1),
.tabbed:has(> .tabpanel:nth-of-type(2):target) > .tabs > .tab:nth-of-type(2),
.tabbed:has(> .tabpanel:nth-of-type(3):target) > .tabs > .tab:nth-of-type(3),
.tabbed:has(> .tabpanel:nth-of-type(4):target) > .tabs > .tab:nth-of-type(4),
/* The tab of the panel that owns the open dialog, so the row still says where the reader is while
   the dialog is up.

   NO NESTED `:has()`. `:has()` inside another `:has()` is not a selector the engine will parse, and
   an invalid selector does not lose only itself — it takes the WHOLE selector list with it. This
   rule was written with the dialog case nested, so the browser dropped it entire and NO tab has
   ever been marked current: the specimen rendered a row of three identical pills and looked
   plausible enough to pass. `validate_html.py` greps the stylesheet's TEXT, found every selector it
   was looking for, and could not see it. The dialog case therefore reaches the dialog THROUGH the
   panel — one `:has()` per position, whose argument is `> .tabpanel:nth-of-type(N) .dialog-open:target`. */

.tabbed:has(> .tabpanel:nth-of-type(1) .dialog-open:target) > .tabs > .tab:nth-of-type(1),
.tabbed:has(> .tabpanel:nth-of-type(2) .dialog-open:target) > .tabs > .tab:nth-of-type(2),
.tabbed:has(> .tabpanel:nth-of-type(3) .dialog-open:target) > .tabs > .tab:nth-of-type(3),
.tabbed:has(> .tabpanel:nth-of-type(4) .dialog-open:target) > .tabs > .tab:nth-of-type(4) {
  --control-fill: var(--brand-space);
  --control-ink: var(--brand-on-dark);
  background: var(--control-fill);
  color: var(--control-ink);
  border-color: var(--brand-space);
}

/* ---------------------------------------------------------------------------
   THE SAME SWITCHER IN A SUB-MENU, AND THE SAME SWITCHER IN A SEGMENTED
   CONTROL — one mechanism, three dresses.

   Which dress a switcher wears is decided by what its items ARE, not by what it
   does, because the mechanism is identical in all three: a row of links, a
   fragment per view, the panel that fragment names drawn and the rest hidden,
   and the current one marked by POSITION because CSS cannot ask which item
   points at the panel the URL names.

     * `.tabs > .tab` — a raised PILL row. A view of one thing, where the items
       are worth their own control: two or three views of a class, each of which
       a reader is choosing between deliberately.
     * `.subnav` — a second LEVEL of navigation. Flat text, a hairline, and the
       azure bar under the current item — the same bar the navigation uses,
       because both answer "where am I". This is the dress a surface's
       sub-features wear: they are destinations, not views, and a destination
       dressed as a button says "press me and something changes".
     * `.tabs--segmented` — the pill row's own items in the COMPACT dress: one
       bordered pill cut into halves, the active half filled. For a switch with
       two positions that governs how a list is DRAWN rather than where it goes.
       Two pills for one binary choice is two controls where the reader is making
       one.

   The sub-menu's current marking is the bar rather than a fill, so it needs its
   own rules; the segmented dress needs none, because it is the tab row's own
   items and the position rules above already mark them.
   ------------------------------------------------------------------------ */

.tabbed:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target)):not(:has(> .subnav > .subnav-item[aria-current])) > .subnav > .subnav-item:nth-of-type(1),
.tabbed:has(> .tabpanel:nth-of-type(1):target) > .subnav > .subnav-item:nth-of-type(1),
.tabbed:has(> .tabpanel:nth-of-type(1) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(1),
.tabbed:has(> .tabpanel:nth-of-type(2):target) > .subnav > .subnav-item:nth-of-type(2),
.tabbed:has(> .tabpanel:nth-of-type(2) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(2),
.tabbed:has(> .tabpanel:nth-of-type(3):target) > .subnav > .subnav-item:nth-of-type(3),
.tabbed:has(> .tabpanel:nth-of-type(3) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(3),
.tabbed:has(> .tabpanel:nth-of-type(4):target) > .subnav > .subnav-item:nth-of-type(4),
.tabbed:has(> .tabpanel:nth-of-type(4) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(4),
/* And a part is current while a page INSIDE it is being read, for the same reason a dialog's is. */
.tabbed:has(> .tabpanel:nth-of-type(1) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(1),
.tabbed:has(> .tabpanel:nth-of-type(2) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(2),
.tabbed:has(> .tabpanel:nth-of-type(3) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(3),
.tabbed:has(> .tabpanel:nth-of-type(4) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(4),
/* OR THE ITEM THAT SAYS IT IS CURRENT. `:target` is how a STATIC screen selects a view, and the
   rule above falls back to the first item while no panel is targeted — which is right for a screen
   with no fragment and wrong for an application, where no panel ever is targeted and the reader's
   choice is state. An application marks the item instead, with `aria-current`, and this member is
   what makes that marking draw the same bar and the same ink. It is the same restatement the
   dialog's `data-open` is, one component over — except that the fallback had to be guarded as
   well, or BOTH the first item and the chosen one are drawn current. */
.tabbed > .subnav > .subnav-item[aria-current] {
  color: var(--brand-ink);
  font-weight: 600;
}

/* A TAB IS PRESSABLE, AND IT SAYS SO — AND IT ANSWERS.
   ---------------------------------------------------------------------------
   The hand is DECLARED rather than left to the element, and the tab row is the case that proves
   why. On every screen here a tab is an `<a>`, and an anchor is handed the pointer cursor by the
   BROWSER — so the class never had to declare it. An application writes the tab as the `<button>`
   it is, because the reader's choice is STATE and not `:target` (the same reason `[aria-current]`
   exists in the rule above), and measured in the application the row computed `cursor: default`
   while the very same class computed `pointer` in the kit: the platform's arrow over a control that
   hovers, clicks, and carries the bar. Reported.

   And the claim OBLIGES the answer, which is the kit's own rule (`check_pointer_controls_hover`): a
   control that says "press me" and answers nothing is lying, and a hover state is the one thing no
   screenshot in this pipeline can show. The answer here is the kit's hover GROUND for a row —
   `--brand-azure-tint`, the one `.feature-option`, `.menu-item` and `.record-row` wear. It cannot
   be mistaken for the current marking, which is the INK and the 3px bar over NO ground at all: a
   settled selection wears `--brand-surface-alt`, so a tinted tab reads as a tab under the pointer
   and never as a second current one. The rail's hover mark is deliberately absent from this item
   for the same reason it excludes its own current item — a broken bar beside a full bar looks like
   a mistake — and a ground has no such interaction, so the current tab is not excluded here.

   These two rules are stated AFTER the selector group above and never inside it: that group is one
   rule whose members are joined by commas and whose last member is the `[aria-current]` one, so a
   rule inserted before its closing brace becomes a FOURTEENth member of it — which is what happened
   first, and the kit's own `check_pointer_controls_hover` reported all thirteen position selectors
   as claiming `cursor: pointer` and answering nothing. */
.tabbed > .subnav > .subnav-item {
  cursor: pointer;
}

.tabbed > .subnav > .subnav-item:hover {
  background: var(--brand-azure-tint);
}

/* The bar, on the same list of positions — one bar under the item that belongs to the panel the
   URL names, and under the first item while it names none. */
.tabbed:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target)):not(:has(> .subnav > .subnav-item[aria-current])) > .subnav > .subnav-item:nth-of-type(1)::after,
.tabbed:has(> .tabpanel:nth-of-type(1):target) > .subnav > .subnav-item:nth-of-type(1)::after,
.tabbed:has(> .tabpanel:nth-of-type(1) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(1)::after,
.tabbed:has(> .tabpanel:nth-of-type(2):target) > .subnav > .subnav-item:nth-of-type(2)::after,
.tabbed:has(> .tabpanel:nth-of-type(2) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(2)::after,
.tabbed:has(> .tabpanel:nth-of-type(3):target) > .subnav > .subnav-item:nth-of-type(3)::after,
.tabbed:has(> .tabpanel:nth-of-type(3) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(3)::after,
.tabbed:has(> .tabpanel:nth-of-type(4):target) > .subnav > .subnav-item:nth-of-type(4)::after,
.tabbed:has(> .tabpanel:nth-of-type(1) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(1)::after,
.tabbed:has(> .tabpanel:nth-of-type(2) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(2)::after,
.tabbed:has(> .tabpanel:nth-of-type(3) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(3)::after,
.tabbed:has(> .tabpanel:nth-of-type(4) .tabpanel:target) > .subnav > .subnav-item:nth-of-type(4)::after,
.tabbed:has(> .tabpanel:nth-of-type(4) .dialog-open:target) > .subnav > .subnav-item:nth-of-type(4)::after,
/* The same member as the ink rule: the bar under the item the application marked current. */
.tabbed > .subnav > .subnav-item[aria-current]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: var(--brand-azure);
}

/* THE COMPACT DRESS. One bordered pill cut into halves, so the halves carry no control furniture
   of their own: no border, no radius, no shadow, and no gap — the divider is the only line, and
   the container clips the ends to the pill. */
.tabs--segmented {
  display: inline-flex;
  align-items: stretch;
  gap: 0;
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-pill);
  background: var(--brand-surface);
  box-shadow: var(--shadow-control);
  overflow: hidden;
}

.tabs--segmented > .tab {
  /* A real target. An icon is drawn by an absolutely positioned `::before`, so it contributes
     NOTHING to the box: a half with only an icon measured 16px tall — the icon's own height around
     a zero-width content box — and a 16px control is one a reader has to aim at. `--size-icon-btn`
     is the kit's size for a compact control, the same size `.icon-btn` and `.segmented-item` are. */
  min-width: var(--size-icon-btn);
  /* 32px, which is the floor `verify_tabs.py` measures a pressable control against — a tab at 30px
     is a tab a reader has to aim at. The WIDTH is the icon button's 30px, because a half is an
     icon; the HEIGHT is the control floor, because a half is pressed. */
  min-height: 32px;
  /* `border-box`, because the kit's controls are content-box: a 30px minimum measured 46px of
     border box — 30 of content plus 16 of padding — which is a taller control than the one the
     token names. The floor is stated in the box that is measured. */
  box-sizing: border-box;
  /* AND A FLEX BOX, because a half's whole content is its icon and an icon is a `::before`. A
     pseudo-element is an INLINE box unless something blockifies it, and an inline box ignores the
     width and height the icon rule gives it — so the mark computed to 16x16 and DREW NOTHING, while
     the control kept its size and looked like a button with an empty label. Measured on the roster:
     both halves 30x32, `::before` width `16px`, `::before` display `inline`. As a flex item the
     pseudo is blockified and its own size applies. */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-2) var(--space-3);
  border: 0;
  border-radius: 0;
  box-shadow: none;
  font-size: var(--text-micro);
}

.tabs--segmented > .tab + .tab { border-left: 1px solid var(--brand-border); }

.tabs--segmented > .tab:hover {
  background: var(--brand-surface-alt);
  box-shadow: none;
}

/* The half is a 16px mark in a control built to be small, and named with the attribute so it is
   two classes deep: a bare `.tab[data-icon]::before` has the same specificity as
   `[data-icon]::before` and would lose on source order — the collision `.segmented-item[data-icon]`
   documents, one dress over. */
.tabs--segmented > .tab[data-icon]::before { width: 16px; height: 16px; mask-size: 16px 16px; }

/* ---------------------------------------------------------------------------
   The calendar's scale opens on the month, which is not its first item
   ---------------------------------------------------------------------------
   A `.tabbed` defaults to its FIRST panel, and that is the right rule: it means
   a switcher needs no state to have a state, and the order of the items IS the
   order of the panels. The calendar is the one place where the natural order of
   the ITEMS and the natural default disagree. The scale is read day, week,
   month, year — but a reader arriving at "Calendar" is asking about the term,
   not about one day, and the page's own hero counts deadlines in September.

   So the calendar names its default, and the generic rule stands down for this
   one component. BOTH halves are restated, because a default that is shown but
   not MARKED is a switcher reading "Day" over a page showing a month — the
   failure `verify_tabs.py` measures by comparing the filled half with the panel
   that is actually laid out.

   The third `:not()` is not decoration: the generic rule carries three of them, and a rule with
   two loses to it however late it is written — `:not()` and `:has()` take the specificity of what
   is inside them, so the generic rule is ~(0,10,0) against ~(0,7,0) here. Measured without it: BOTH
   `#cal-day` and `#cal-month` laid out, which `verify_tabs.py` reports as "two is a page whose tabs
   do nothing". */
.cal-scale:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target))
  > .tabpanel:nth-of-type(1) { display: none; }
.cal-scale:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target))
  > #cal-month { display: block; }
.cal-scale:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target))
  > .tabs > .tab:nth-of-type(1) {
  --control-fill: var(--brand-surface);
  --control-ink: var(--brand-ink-muted);
  background: var(--control-fill);
  color: var(--control-ink);
  border-color: var(--brand-border);
}
.cal-scale:not(:has(> .tabpanel:target)):not(:has(> .tabpanel .dialog-open:target)):not(:has(> .tabpanel .tabpanel:target))
  > .tabs > #tab-cal-month {
  --control-fill: var(--brand-space);
  --control-ink: var(--brand-on-dark);
  background: var(--control-fill);
  color: var(--control-ink);
  border-color: var(--brand-space);
}

/* ---------------------------------------------------------------------------
   A switcher that shares its row with the list's own controls
   ---------------------------------------------------------------------------
   A view switch belongs WITH the controls it governs: the filters, the search
   and the choice of how to draw the result are one row of one thing, and the
   switch is the last of the three.

   It cannot simply be moved into the control row, because a `.tabs` row is the
   `.tabbed`'s OWN child — the stylesheet relates a row to its panels by
   POSITION, and `verify_tabs.py` reads the row as a direct child — so the two
   are put on one line by their shared parent instead: the control row in the
   first column, the switcher in the second, and every panel across both.

   Below the wide band it is a column again, with the switcher on its own line at
   the trailing edge. The chips wrap at a phone width, and a switch floating in
   the middle of four wrapped lines of filters is a control that has lost the row
   it belongs to.
   ------------------------------------------------------------------------ */

.tabbed--controls > .tabs { align-self: flex-end; }

@media (min-width: 600px) {
  .tabbed--controls {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
  }
  .tabbed--controls > .tabpanel { grid-column: 1 / -1; }

  /* AND ON THE CALENDAR THE SWITCH SITS ON THE BAR'S CENTRE, NOT ON THE FLOOR OF THE ROW.

     `align-self: flex-end` is the right rule for the ROSTER, which is what it was written for: the
     filters wrap to two or three lines at a portrait width, and a switch pinned to the bottom of
     them is a switch beside the last line of the thing it governs. The calendar's bar is ONE 32px
     line at every width — three step controls and a period between them — so there is no bottom
     line for the switch to belong to, and flex-end was measured putting the pill 14px below the
     month it names: the segmented control's centre at y=524 against the bar's at y=510.

     Two rules, and the second is the one that is easy to miss. `align-items: center` is already on
     the grid, and it does NOT reach the pill, because `align-self` on the item beats it. And the
     row was 48px tall, not the bar's 32, because `.cal-bar` carries a `--space-4` bottom margin
     that the GRID counts as part of the item — so centring alone would have moved the pill to the
     middle of a box 16px taller than the bar, 8px low. That margin is the row gap restated: the
     `.tabbed` already spaces its rows by `--space-4`, and it is what the phone band's stacked
     column is spaced by, which is why it is stood down here rather than deleted.

     Measured after: the row is 36px — the pill's own height, the taller of the two — and both
     controls centre on y=512. */
  .tabbed--controls.cal-scale > .cal-bar { margin-bottom: 0; }
  .tabbed--controls.cal-scale > .tabs { align-self: center; }
}

/* ---------------------------------------------------------------------------
   The capability matrix — which roles may do what
   ---------------------------------------------------------------------------
   §3.2 is a table of capabilities against roles, and the roles are the columns
   because the capabilities are the longer list and the one that grows. The cell
   is a mark rather than a word: ten rows of "yes" and "no" is a table nobody
   reads down, and the two marks have to be distinguishable WITHOUT COLOUR
   (§20.4) — a tick and a dash differ in shape, and the tick is drawn from the
   icon set rather than typed, because a `✓` character depends on the reader's
   fonts, which is the one thing the icon set exists to avoid.

   The mark carries its meaning for assistive technology in words, because a
   masked pseudo-element announces nothing: "may" and "may not" are in the markup
   and not only in the picture.
   ------------------------------------------------------------------------ */

.cap-matrix th { text-align: center; }
.cap-matrix th:first-child { text-align: left; }

.cap-role-name {
  display: block;
  font-size: var(--text-micro);
  letter-spacing: 0.04em;
}

.cap-role-count {
  display: block;
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  color: var(--brand-ink-muted);
}

/* A class-defined role's way into its editor sits on the ROLE'S OWN ROW — see `.running-enter`,
   the slot the kit already gives a record for its one action — and the matrix's heads stay a clean
   repeating unit: a role's name over the number of people who hold it.

   The head was where this control first went, because the column is the answer the editor will
   rewrite and the reader is already looking at the set they want to change. Two reviews of the
   rendered board said no. The heads are read DOWN as a set, so a button in one of them stops that
   column being a peer of the five beside it; and it put an action in a band that is scaffolding,
   where a reader scans for a name and not for something to press. A row that names the role is
   where the intent to change it actually forms.
   ------------------------------------------------------------------------ */

.cap-cell { text-align: center; }

/* "may". The tick is a MASK, so it takes the cell's own ink and cannot fail to resolve the way a
   character can — and it is 14px inside a 20px line, which is the size the rest of the kit's marks
   are drawn at. */
.cap-yes::before {
  content: "";
  display: inline-block;
  width: 14px;
  height: 14px;
  vertical-align: -2px;
  background: currentColor;
  mask-image: var(--icon-finish);
  mask-size: 14px 14px;
  mask-repeat: no-repeat;
}

.cap-yes { color: var(--brand-success-ink); }

/* THE MATRIX'S NARROW FORM — a per-role LIST, which is the kit's own rule for a 2-D grid.

   `DESIGN_SYSTEM.md` §7.5 says it in one line: "There is no honest narrow layout for a 2-D grid.
   Below landscape it becomes a per-reviewer list where each reviewer is a row and their allocations
   are chips." The capability matrix is the same shape of problem — ten capabilities against six
   roles — so it becomes the same shape of answer: **each role is a row and the capabilities it may
   exercise are chips.**

   IT REPLACES A HORIZONTAL SCROLLBAR, and that is the whole reason it exists. Seven columns do not
   fit a phone's content column, and the wrap's answer was `overflow-x: auto`: a scroll container
   inside a scrolling page. Two planes at once is the thing the kit forbids — the reader drags the
   page sideways to read a column while the page itself scrolls up and down — and no amount of
   `contain: layout` makes that one plane. A form that fits cannot scroll, so the form changes.

   The reader at this width could not compare two columns anyway: six role columns in 324px are 46px
   apart, and the comparison the grid exists for is already unavailable. What is left is the question
   a phone reader actually asks, *what may this role do*, and the list answers it directly.

   The two forms are the same facts and `check_cap_matrix_forms` holds them together: the chips under
   each role must be exactly the capabilities that role's column ticks. */
.cap-roles { display: none; }

.cap-role { display: flex; flex-direction: column; gap: var(--space-2); }

/* The role, named like a column head — the same uppercase micro label the matrix's `th` carries, so
   the two forms name a role in one voice. The count is the number of people holding it, and it is
   the column head's own count. */
.cap-role-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  font-size: var(--text-micro);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-muted);
}
.cap-role-count { font-weight: 500; }

.cap-role-set { display: flex; flex-wrap: wrap; gap: var(--space-2); }

/* A CAPABILITY IS A NAME, SO THE TAG MAY WRAP HERE. Every other tag in the family is `nowrap`,
   because a tag in a row of tags is one VALUE and breaking it breaks the row's rhythm — but this one
   is the whole label of a thing the role may do, and the longest of the ten is 52 characters, which
   does not fit a phone's content column. Wrapped, it is a two-line pill; unwrapped, it would be a
   pill wider than the phone and the row would overflow sideways, which is the fault this form
   replaced. */
.cap-role-set .cap-tag { white-space: normal; }

/* Below the phone band the grid becomes the list, and the grid goes. Both are hidden from the other
   form's width rather than one being a fallback the other draws over. */
@media (max-width: 599px) {
  .table-wrap:has(.cap-matrix) { display: none; }
  .cap-roles { display: flex; flex-direction: column; gap: var(--space-4); }
}

/* "may not". A dash, not an empty cell: an empty cell is a value nobody wrote, and the difference
   between "may not" and "not asked" is the difference between a rule and a gap. */
.cap-no::before {
  content: "";
  display: inline-block;
  width: 10px;
  height: 2px;
  vertical-align: 3px;
  background: var(--brand-border-strong);
}

.cap-no { color: var(--brand-ink-faint); }

/* The count disc, and the two things a count can mean.

   A number in a control is not text: it is a tally, and it reads as one only when it is given a
   shape of its own. "To mark 12" as running text asks the reader to find where the label stops
   and the number starts; a disc says it in one glance, and says it again on every control that
   carries one, in the same place.

   The disc is the CONTROL'S OWN COLOURS INVERTED — its ink as the fill, its fill as the ink —
   rather than a fixed pair, because that is the only way one rule sits on a white tab and a
   nightfall one and is legible on both. It is also why the control names its colours in
   `--control-fill` and `--control-ink`: the badge inverts values it is TOLD, instead of reaching
   for colours it cannot see.

   `currentColor` is not the answer here, and the measurement is what proved it: it resolves to
   the element's OWN colour, not its parent's. Setting the number to the control's fill made
   `currentColor` that same fill, so the disc and the number came out identical — 1.00:1, a
   white circle with a white number on a white tab, which renders perfectly and shows nothing.
   A count on a control that names neither property falls back to a mid grey disc, which is
   visible on any ground; `validate_html.py` requires both on any control that carries one. */
.count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* `border-box`, so the number the token names is the number the disc measures. The kit's controls
     are content-box, so `min-width: 18px` with 4px of side padding drew a 26px disc for a single
     digit — an oval where the design says disc, and a minimum that lied by the width of its own
     padding. Stated in the box that is measured, one digit is a circle and three digits grow it into
     a pill, which is what a count should do. */
  box-sizing: border-box;
  min-width: 18px;
  height: 18px;
  padding: 0 var(--space-1);
  margin-left: var(--space-2);
  border-radius: var(--radius-pill);
  font-size: var(--text-micro);
  font-weight: 500;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  background: var(--control-ink, var(--brand-ink-muted));
  color: var(--control-fill, var(--brand-surface));
  vertical-align: middle;
}

/* A count that is a NOTIFICATION — work that has arrived and has not been looked at — rather
   than a total. The disc stops inverting and takes the action azure, because a tally and a
   thing waiting for you are not the same statement and must not look the same. It is the same
   azure the accent rule already sanctions for a filled badge with white text, at 7.66:1. */
.count-new {
  background: var(--brand-azure-deep);
  color: #ffffff;
}

/* On a control that is ITSELF dark, the azure disc is 1.8:1 against its own fill and stops
   being a disc at all. So the notification inverts there too, and keeps its meaning in the ink
   instead: white disc, action-azure number. A badge has to be the loudest thing in the control
   it sits on, and the action azure is not louder than nightfall. */
.tab[aria-selected="true"] .count-new {
  background: var(--brand-surface);
  color: var(--brand-azure-deep);
}

/* ---------------------------------------------------------------------------
   Sub-navigation — a second level of navigation inside a page.
   It uses the SAME azure bar as the main navigation, because both answer the
   question "where am I". The bar sits BENEATH a horizontal item, or to the
   LEFT of a vertical one, exactly as in the navigation rail and tab bar.
   A submenu is navigation, not a control, so it is square and flat.
   ------------------------------------------------------------------------ */

.subnav {
  display: flex;
  /* EVERY SIDEWAYS SCROLL IN THIS CONSOLE WAS THIS ONE COMPONENT, and it is now gone.

     The row was `overflow-x: auto` on the argument that "the views are one line and stay one line"
     and that a wrapped row "reads as two rows of navigation". The first half was true and the second
     was the wrong trade: a scroll container hands the reader a second scroll plane to reach a
     destination they can already see, which is the defect this kit refuses everywhere else — and
     measured across the five record screens (credential, person, role, system, system-group) at
     320/390/480, the row was hiding up to **234px** of itself behind that scrollbar. A sweep of every
     element on every screen found no other horizontal scroll container anywhere in the console, so
     this was the last one.

     It WRAPS, which is what the kit already does with the row one level up: `.tabs` is
     `flex-wrap: wrap` for exactly this reason, and a tab row and a sub-menu are the same shape of
     thing — a row of destinations inside a page. The wrap is tighter than the columns
     (`--space-2` against `--space-5`) so the two lines still read as ONE navigation rather than as
     two stacked rows, which was the objection the scroll was answering.

     The item stays `nowrap`, and that is what makes the wrap work: a phrase is one thing, so
     "Authorized users" moves to the next line whole rather than breaking in the middle. */
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  /* One definition and one class, and this is the only one: the component used to be declared a
     second time further down the sheet, in `.subnav a` form, and the LATER rule won — so the
     documented rule was dead for every property the two shared and the scroll container was
     declared where nobody looking at this component would find it. A duplicate is not a second
     opinion, it is the first one silently withdrawn. */
  margin-bottom: var(--space-5);
  /* THE HAIRLINE IS A BACKGROUND STRIPE, NOT A BORDER — and that is what lets the current item's
     azure bar cover it. The bar is drawn at the item's own bottom edge, which is the nav's CONTENT
     box; a border sits OUTSIDE that box, so a border-bottom always left a thin grey line under the
     thick azure one and the row read as a doubled rule. Two other ways out were tried and
     measured: `bottom: -1px` on the bar pushes it past the content box, and a box-shadow on the bar
     paints the right pixel but a scroll container CLIPS it, which measured as no change at all. A
     background is painted behind the items, so the bar sits on top of it and the line is simply not
     there under the bar. */
  background: linear-gradient(to top, var(--brand-border) 0 1px, transparent 1px);
  border-radius: var(--radius-structure);
}

.subnav-item {
  /* The item's own two colours, named, so a COUNT inside it inverts the right pair — see
     `.count`. An item is flat: it has no fill and no border, so the pair it names is the page's
     own two colours, the surface it sits on as its fill and the muted ink as its ink. The disc
     is then the same disc a tab draws, which is the point — a tally is a tally wherever it sits,
     and the reader learns the disc once. */
  --control-fill: var(--brand-surface);
  --control-ink: var(--brand-ink-muted);
  position: relative;
  padding: var(--space-3) 0;
  font-size: var(--text-small);
  color: var(--brand-ink-muted);
  text-decoration: none;
  white-space: nowrap;
  border-radius: var(--radius-structure);
}

.subnav-item[aria-current="page"] {
  color: var(--brand-ink);
  font-weight: 600;
}

/* The bar sits ON the item's bottom edge, not 1px below it.

   The row used to be a scroll container, and `bottom: -1px` pushed the bar 1px past the nav's content
   box — 1px of vertical overflow in a box that scrolled, which is 1px of scrollbar: measured, the
   sub-menu rendered a vertical scrollbar down its right edge and lost 15px of its own width to it.
   The row wraps rather than scrolling now, so that specific trap is gone with the scroll container,
   and `bottom: 0` is kept: the bar belongs inside the item's own box whatever the row does. */
.subnav-item[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: var(--brand-azure);
}

/* Vertical submenu inside the dark rail: children are indented, and the
   selected one carries the azure bar on its left like any other item. */
/* The disclosure chevron is a real element, not a pseudo-element. A rail item already spends
   `::before` on its icon and `::after` on its state mark, and a parent CAN be the current page —
   which needs all three at once. The chevron is the one that is always there rather than a state,
   so it is the one that gets its own box.

   It is a MASK, drawn from `--icon-disclosure`. It used to be the `▾` character, which is the one
   thing the icon set exists to avoid: a glyph depends on the reader's fonts, so on a machine whose
   fallback face has no U+25BE the mark keeps its box and shows nothing — and a section that looks
   like it does not expand is a section nobody opens. */
.nav-rail .nav-item.is-parent .nav-chevron,
.app-nav .nav-item.is-parent .nav-chevron {
  margin-left: auto;
  color: var(--brand-on-dark-muted);
}

/* The icon box is 20px and a rail row is 44px, so a chevron at 20px reads as a second icon beside
   the section's own. Two classes deep, so it beats `[data-icon]::before` on source order — the
   same collision `.segmented-item[data-icon]` documents. */
.nav-rail .nav-chevron[data-icon]::before,
.app-nav .nav-chevron[data-icon]::before { width: 12px; height: 12px; mask-size: 12px 12px; }

/* THE SECTION'S OWN SUBMENU, AND IT EXISTS IN THE RAIL ALONE.
   ---------------------------------------------------------------------------
   The application has two lists and they answer different questions: the four
   SECTIONS (which part of the app am I in) and a class's nine SURFACES (which
   page of this class am I on). The rail is the one arrangement with room to show
   the second list inside the first, so when a section is the current page its
   own list is drawn under it — for Classes that is the whole of the class's
   surfaces, in the feature menu's order, because they are the same nine in the
   same order.

   It is drawn ONLY at the rail's width, and that is not a collapse — below
   1100px the submenu is not in the page at all. The middle band's navigation is
   a DRAWER and the phone's is a TAB BAR, and a tab bar holds four destinations
   and not nine: a bar of nine items is a bar of items nobody can hit. The
   surfaces stay reachable there through the header's feature menu, which is
   where they are chosen from anyway.

   The disclosure is the SELECTION, not a control. There is no script on these
   pages, so a chevron that opened and closed a list would be a control that does
   nothing; instead a section that is current shows its children, and the chevron
   — which is drawn only where the children are — points down because the list is
   open. A section that is not current has no chevron and no children, so nothing
   promises a disclosure that cannot happen. */
.nav-sub { display: none; }

@media (min-width: 1100px) {
  .nav-sub {
    display: flex; flex-direction: column; gap: var(--space-1);
    margin: 0; padding: 0; list-style: none;
  }
  /* The item that holds a submenu becomes a column, so the children stack UNDER it rather than
     sitting beside it. The `<li>` is a row by default because the tab bar lays its items out
     across the screen; a `<li>` with two children in a row puts the submenu beside its parent. */
  .app-nav .nav-list > li:has(.nav-sub) { flex-direction: column; }
}

.nav-rail .nav-item.is-child,
.app-nav .nav-item.is-child {
  padding-left: var(--space-7);
  font-size: var(--text-micro);
}

/* ---------------------------------------------------------------------------
   Menus — an open dropdown.
   The panel is structure, so it is square, flat and separated by a hairline
   border rather than a shadow. The items inside it are flat rows and do NOT
   each carry a shadow: the open panel already announces that everything in it
   is actionable, and a shadow per row would be noise.
   ------------------------------------------------------------------------ */

.menu {
  display: inline-block;
  min-width: 220px;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-structure);
  box-shadow: none;
  padding: var(--space-1) 0;
}

/* A MENU ITEM IS A ROW, AND IN THE FEATURE MENU IT CARRIES THE FEATURE'S OWN DRAWING.

   The header's second selector is the complete list of a class's surfaces, and a list of eight names
   is a list of eight words: "Content", "Roster", "Syllabus" — each one a label the reader has to
   have learned. The callout tiles already answer this, by showing the surface's own illustration
   beside its name, and the dropdown is the same list in another place — so it shows the same
   drawings, from the same files, at the size a menu row can hold.

   A ROW rather than the block it was, because an icon beside a name is one line and `display: block`
   would put the drawing above the word. The gap is what separates them; the item's own padding is
   unchanged, so the menu does not grow taller for the pictures.

   `.menu-item` is every menu's row — the kit has one row class, and the feature menu, the select menus
   and the row-action menus are all built from it. The feature menu is the one whose rows carry a
   drawing, because it lists the class's SURFACES and a surface has an illustration; a row-action menu
   has no picture to show, and its rows are the same box with text in it. `display: flex` on a row with
   one text child changes nothing about how that text wraps. */
.menu-item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-small);
  color: var(--brand-ink);
  text-decoration: none;
  border-radius: var(--radius-structure);
  /* Pressable, and it says so — see `.feature-option` for why the declaration is load-bearing. */
  cursor: pointer;
}

/* 24px, and the number is measured against the two things around it. The row is 20px of type inside
   8px of padding, so a larger drawing would set the row's height and make the menu taller than the
   list of names it replaces; and the smallest drawing the kit draws anywhere else is the see-also
   tile's 32px, which is a tile with two lines of its own rather than a row in a menu. `object-fit`
   because the stickers are one canvas but not one shape — a wide drawing letterboxed inside a 24px
   box is still 24px of row.

   THE FRAME SELECTOR'S COLLAPSED FORM IS THE SECOND HOST. On a phone the feature selector draws
   this drawing instead of its words, so the control and the menu it opens cannot disagree about
   which surface you are in — and the two are ONE rule for the reason `.table-foot` and
   `.thumb-foot` are: they are the same artefact at the same size, and two rules that match today
   are two rules to keep in step. */
.menu-item-art,
.frame-select-art { flex: none; width: 24px; height: 24px; object-fit: contain; }

.menu-item:hover,
.menu-item[aria-current="true"] { background: var(--brand-surface-alt); }

.menu-item[aria-disabled="true"] { color: var(--brand-ink-faint); }

.menu-sep { height: 1px; background: var(--brand-border); margin: var(--space-1) 0; }

/* ---------------------------------------------------------------------------
   The feature dropdown — a menu anchored under the control that opens it.
   ---------------------------------------------------------------------------
   The header's second selector picks the FEATURE, and it is the complete list of them rather than
   the short one the navigation carries. The navigation is a bottom tab bar on a phone, so it holds
   the few destinations a reader moves between constantly and cannot hold seven; the dropdown is
   where the rest of the class's surfaces live. Two lists, two jobs.

   It is opened with `:target` and a link, the same way the class picker is, and for the same
   reason: a fragment navigation needs no script, works with the Back button, and is reachable from
   the keyboard. The anchor is a zero-sized `.anchor`, so the trigger can stay where it is in the
   row and the panel can be its later sibling — the `~` combinator only looks at siblings.

   What this cannot do, and the real implementation must: set `aria-expanded`, move focus into the
   menu, close on Escape, and restore focus to the trigger. Those are behaviours, not appearances.
   ------------------------------------------------------------------------ */

.dropdown { position: relative; display: inline-flex; align-items: center; }

/* Closed is the resting state, and it has to be stated: `.menu` is `inline-block` by itself, so an
   unopened dropdown would otherwise be a permanent panel sitting in the header. */
.dropdown-menu { display: none; }

.dropdown-open:target ~ .dropdown-menu {
  display: block;
  position: absolute;
  top: calc(100% + var(--space-2));
  /* Aligned to the TRIGGER'S RIGHT EDGE, so the panel grows back across the page rather than off
     it. `left: 0` is the tidier convention and it is what this was first written as — and
     measured, it put the panel's right edge at 510.8px in a 390px viewport: 120px of the menu
     off-screen, at exactly the widths where a reader has least room. Anchoring to the right keeps
     the panel inside wherever the trigger is, because the panel can only grow towards the middle
     of the page, and it still lines the panel up with the control it belongs to. */
  right: 0;
  z-index: 40;
  min-width: 220px;
  /* A backstop for a viewport narrower than the panel. */
  max-width: calc(100vw - 2 * var(--space-3));
}

/* The veil closes the menu on a click anywhere else. It is TRANSPARENT rather than dimmed, unlike
   the navigation's: dimming the page is right for a drawer, which takes the reader somewhere, and
   wrong for a menu, which is a short list of places they are already looking at. */
.dropdown-open:target ~ .dropdown-veil { display: block; }
.dropdown-veil { display: none; position: fixed; inset: 0; z-index: 35; }

/* ---------------------------------------------------------------------------
   Data display — square, flat containers; rows are not clickable so no shadow
   ------------------------------------------------------------------------ */

/* ---------------------------------------------------------------------------
   Data tables — the most-used component in the application, so the treatment is
   specified completely rather than left to be reinvented per screen.
   The container is structure: square, flat, hairline-bordered. Everything inside
   is typography and rules, with no shadows except on a row that is clickable.
   ------------------------------------------------------------------------ */

.table-wrap {
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  /* A TABLE WIDER THAN THE COLUMN IT IS IN SCROLLS INSIDE ITS OWN BOX, AND MUST NOT MAKE THE PAGE
     SCROLL. Two declarations, and both are load-bearing:

       * `overflow-x: auto` is what makes the columns that do not fit REACHABLE. It was `hidden`,
         which clipped them: the reader was shown a table with its right-hand roles missing and no
         way to get to them, which is worse than a scrollbar.
       * `contain: layout` is what stops the clipped table reaching the PAGE. Measured on the
         capability matrix at 500px: the wrap was 434px wide with a 523px table inside it, and
         `document.documentElement.scrollWidth` was **526** against a 500px viewport — the page
         scrolled sideways to reach columns that were not drawn at all. `overflow: hidden` and
         `overflow: clip` both still counted it; `contain: layout` does not, because it stops the
         element's contents contributing to its own min-content size, which is the path the
         too-wide table was taking out of the panel and into the page.

     The `hidden` was there to clip the table's corners to the wrap's radius. `auto` does that too
     — a scroll container clips — and only adds a scrollbar when the table genuinely does not fit.

   AND THE WRAP IS ALSO A QUERY CONTAINER, which is what lets the table ask how much room it has
   instead of the page guessing from the window. `container-type: inline-size` implies the `contain:
   layout` above it and a little more (style, and the inline-size containment the query needs), so
   the two are consistent rather than doubled — and the queried size is the wrap's CONTENT box,
   which is exactly the width the table is laid out in, because the wrap has no padding. See the
   container query below the phone band: the table becomes cards when its column can no longer hold
   the widest table on the console, in any band, at any window size. */
  overflow-x: auto;
  contain: layout;
  container-type: inline-size;
}

.table { width: 100%; border-collapse: collapse; font-size: var(--text-small); }

/* Header: a small uppercase label in medium weight, closed by a heavier rule than
   the row rules, so the header reads as a distinct band. Never bold display type —
   the header is a label, not a heading. */
.table th {
  text-align: left;
  font-family: var(--font-body);
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-muted);
  background: var(--brand-surface-alt);
  padding: var(--space-3) var(--space-4);
  border-bottom: 2px solid var(--brand-border-strong);
  white-space: nowrap;
}

.table td {
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--brand-border);
  vertical-align: middle;
}

.table tbody tr:last-child td { border-bottom: 0; }

/* Hover and selection. Both are fills, not colours: azure is reserved, and a tinted row
   would compete with the status pills inside it.

   They are deliberately the OTHER way round from what a first guess suggests. The
   TRANSIENT state is the warm one: a pointer passing over a row is momentary, and haze
   is the livelier signal. The PERSISTENT state is the quiet one: a checked row stays
   checked while you scroll and read, and a wash of haze down a column of ticks would
   shout for the whole time it is on screen. Grey is the resting colour, so grey is what
   a settled selection settles to.

   **The settled state is driven by the CHECKBOX, not by an attribute kept in step with it.**
   It was `tr[aria-selected="true"]`, which meant the row's ground was a second copy of a state
   the checkbox already held — and a copy that only a script could keep true. A reader ticking a
   box got no repaint until something ran; a reader whose script had failed got a row that was
   checked and looked unchecked, or the reverse. `:has(:checked)` makes the checkbox the single
   source and the browser do the repainting, with no script in the path at all.

   The `aria-selected` attribute went with it. It is not valid on a plain table's `row` — the role
   supports it only inside a `grid` or a `treegrid` — and an attribute nothing reads and nothing
   keeps in step is worse than absent, because the next person maintains it.

   This rule beats both hover rules on SPECIFICITY, so a checked row keeps its grey while the
   pointer passes over it and while it is pressed: `:has()` takes the specificity of its most
   specific argument, so this is (0,3,3) against the row-link hover's (0,3,2). It used to be the
   other way round, which is why a duplicate of this rule sat far below, after those. That
   duplicate is gone; the ordering is no longer what holds it. */
.table tbody tr:hover { background: var(--brand-azure-tint); }
.table tbody tr:has(input[type="checkbox"]:checked) { background: var(--brand-surface-alt); }

/* The first cell often names the row. It carries more weight than the rest. */
.table .cell-strong { font-weight: 500; color: var(--brand-ink); }
.table .cell-muted { color: var(--brand-ink-muted); }

/* Numbers align right and use tabular figures, so digits line up column-wise.
   Left-aligned numbers are the single most common table defect. */
.table .num { text-align: right; font-variant-numeric: tabular-nums; }

/* ...and a field INSIDE a numeric column is numeric too.

   `text-align` on the cell positions the field in the column; it says nothing about the text
   inside the field, which is left-aligned by default. So an editable "Weight" column had its
   numbers against the left edge of each box while the heading above them and the total beneath
   them sat against the right edge — the column's figures did not line up with the column.

   The tabular figures have to be named again rather than inherited. A form control does not
   inherit `font` from its surroundings: the UA stylesheet sets the `font` SHORTHAND on it, which
   resets `font-variant-numeric` to `normal` along with everything else. Measured: the cell
   carried `tabular-nums` and the input inside it reported `normal`.

   Stated as a rule about the COLUMN rather than about one control, because that is what it is:
   any single-line field in a `.num` cell is holding a number. A textarea is not included — a
   numeric column does not hold a paragraph. */
.table .num .input,
.table .num .select { text-align: right; font-variant-numeric: tabular-nums; }

/* A column whose content is a MARK rather than text — the narrow select column, or a column
   that answers yes/no, like "Excluded". The mark is centred on the column.

   Left-aligned it sits in the wrong place in both, and for the same reason: a cell's content is
   laid out from its left padding, so a mark reads as belonging to the column's leading edge
   whenever the column is wider than the mark. Measured, the select column's box was 34px wide
   for an 18px mark with 16px of padding on one side and none on the other — **8px right of
   centre** — and "Excluded" is 100px wide because its heading is, leaving the mark **29px left
   of centre**. Neither is a defect you can see in a screenshot at a glance; both are obvious
   once measured. */
.table th:has(> input[type="checkbox"]:only-child):not(.select-col),
.table td:has(> input[type="checkbox"]:only-child):not(.select-col) {
  text-align: center;
  /* Padding EQUAL ON ALL FOUR SIDES, not just left and right. The gutter is what the mark is
     centred within, and a cell padded 12px top and 8px side does not have one gutter — it has two,
     and the mark sits in the larger one. Measured before: the side gaps were 8px and the vertical
     ones 12.8 and 13.0, so the "equal all the way around" the column is for was not there.

     8px is the gutter because it is the tightest side: the mark is 18px and the column is 34px,
     and widening the sides to match a 12px vertical would widen every checkbox column by 8px. */
  padding: var(--space-2);
}

/* The ROW-SELECTION column is the exception, and the exception is measured rather than chosen.
   A row is 51.5px tall because of the columns beside it, and the mark is 18px, so the vertical
   gutter is (51.5 − 18) / 2 ≈ 16.5px whatever the cell's own padding says — the cell stretches and
   the mark centres inside it. The cell's padding only governs the HORIZONTAL gutter, so at 8px a
   side the mark sat 8px from the left and 16.5px from the top and bottom: the margins were not
   equal, and the mark read as crowded against the column's leading edge with room to spare above
   and below it.

   The left padding is therefore the vertical gutter, and the right is ZERO — the mark is flush to
   the column's trailing edge, and the 16px of the next column's own padding supplies the room to
   the right of it. That keeps the column exactly 34px wide (16 + 18 + 0), so the mark moves 8px
   right inside a column that does not change size and no other column shifts.

   The vertical padding stays at 8px on purpose. It does not affect the gutter — the cell stretches
   and the mark centres — but it DOES set the cell's minimum height, and 16px top and bottom would
   have forced the 43px header row out to 50px. */
.table .select-col {
  text-align: left;
  padding: var(--space-2) 0 var(--space-2) var(--space-4);
}

/* The select column is the mark plus a gutter on the LEFT and none on the right, and that asymmetry
   is measured rather than chosen — `DESIGN_SYSTEM.md` §5.5 has the working. In short: a row is
   51.5px tall, so the vertical gutter is about 16.5px whatever the padding says, and the left
   padding is 16px so that top, left and bottom agree. The right is 0 because the NEXT column's own
   16px padding is what supplies the room to the right of the mark, and it keeps the column 34px so
   that no other column moves. `verify_mark_columns.py` asserts all three, and every alternative
   costs one of them.

   What that reasoning missed is the next column's HOVER FILL, which spans its whole box and so
   consumes the very padding it was relying on. That is fixed on the fill rather than here — see
   `th[aria-sort]:hover` — because moving the gutter into this column is what the guard above
   refuses. */
.table .select-col { width: 1%; }

/* The mark is a BLOCK with auto side margins, and that is what makes the vertical centring exact.

   As an inline-block it sat on the cell's BASELINE, so the descender space below the baseline
   pushed it 2.1px high of centre — the classic half-x-height error. `vertical-align: middle` does
   not fix it either: it aligns the box's middle with baseline-plus-half-the-x-height, which is a
   different line than the line box's centre. A block has no baseline to sit on, so the cell's own
   `vertical-align: middle` centres the one thing in the cell, and it lands on the centre line.
   Measured after the change: the mark's centre is within 0.5px of the cell's, the residue being
   the cell's 1px bottom border and the row's sub-pixel height.

   The `margin` also does the horizontal centring, so a mark in a cell whose column is wider than
   the mark — an "Excluded" answer column, say — is centred rather than left against the padding. */
.table :is(td, th):has(> :is(input[type="checkbox"], .select-all):only-child)
  > :is(input[type="checkbox"], .select-all) {
  display: block;
  margin: 0 auto;
}

.table .select-col :is(input, .select-all) { margin: 0; }

/* Long text truncates rather than wrapping and destroying the row rhythm. */
.table .truncate { max-width: 22ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Sortable headers: a faint indicator when unsorted, a solid one when sorted, so the
   column in play is unmistakable. */
/* A sortable header is a control — you click it, and it reorders everything below. So its hover
   INVERTS rather than tints: nightfall ground, starlight label, starlight arrows. Haze would say
   "a row is under the pointer", and a header is not a row — it is the table's chrome, and
   nightfall is the ground this system already gives its chrome, the rail and the nav being the
   other two. A tint would also be too quiet for what this control does: reordering the whole
   table is a bigger act than pointing at one row. */
.table th[aria-sort] { cursor: pointer; }
.table th[aria-sort]:hover {
  /* The fill stops short of the cell's left edge, because this cell begins exactly where the mark
     in the column beside it ends — measured, the checkbox's right edge and this heading's left edge
     are both at x 291, a gap of 0. A fill that spans the whole box therefore puts a dark block hard
     against the mark with no margin between them, and paints over the mark's `--shadow-control`,
     which reaches 2px + 5px = 7px past it.

     The fill is shortened rather than the cell being widened, and that is the point: the select
     column's own geometry is load-bearing — left gutter 16 to match the row's ~16.5px vertical
     gutter, right gutter 0, column 34px — and `verify_mark_columns.py` refuses every alternative.
     What the column's reasoning missed is that the next column's padding is only free space until
     that column is hovered, at which point its fill consumes it.

     The fill is an IMAGE whose width is the cell less `--space-2`, aligned right, so the cell's own
     tint shows in the strip it leaves and the head still reads as one band. An inset `box-shadow`
     would not do: `inset 8px 0 0 0` paints an 8px STRIP along the left edge rather than a fill inset
     from it — measured, nightfall at 291–299 and tint at 299 on, the exact opposite of the intent.

     `--space-2` is the first gutter that clears the shadow's 7px reach, and `validate_html.py`
     reads the reach from the shadow token rather than trusting this number. */
  background-image: linear-gradient(var(--brand-space), var(--brand-space));
  background-size: calc(100% - var(--space-2)) 100%;
  background-position: right center;
  background-repeat: no-repeat;
  color: var(--brand-on-dark);
}

/* Sort indicators are drawn shapes, not glyphs. A glyph escape depends on the font having the
   character, and a doubled backslash silently renders the escape as literal text — both of
   which have already happened once in this project. */
.table th[aria-sort]::after {
  content: "";
  display: inline-block;
  width: 8px;
  height: 12px;
  margin-left: var(--space-2);
  vertical-align: -1px;
  background-repeat: no-repeat;
  background-position: center;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3E%3Cpath d='M4 0l3.5 4h-7z' fill='%23C4CCD3'/%3E%3Cpath d='M4 12L0.5 8h7z' fill='%23C4CCD3'/%3E%3C/svg%3E");
}
.table th[aria-sort="ascending"]::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3E%3Cpath d='M4 2l3.5 4h-7z' fill='%23222222'/%3E%3C/svg%3E");
}
.table th[aria-sort="descending"]::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3E%3Cpath d='M4 10L0.5 6h7z' fill='%23222222'/%3E%3C/svg%3E");
}

/* Every arrow has to be redrawn for the inverted ground, and the two SORTED states are the ones
   that make it mandatory: an ink arrow on nightfall is not a faint arrow, it is no arrow at all —
   the only indication of which column is in play would disappear at the moment the reader points
   at it, which is precisely when they are deciding whether to reverse it.

   These three rules come after the unsorted hover rule on purpose. `.table th[aria-sort]:hover`
   and `.table th[aria-sort="ascending"]:hover` have identical specificity — a presence selector
   and a value selector both count once — so only source order decides, and with the pair the
   other way round a hovered ascending column would show the unsorted two-arrow mark. */
.table th[aria-sort]:hover::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3E%3Cpath d='M4 0l3.5 4h-7z' fill='%23F4F1EF'/%3E%3Cpath d='M4 12L0.5 8h7z' fill='%23F4F1EF'/%3E%3C/svg%3E");
}
.table th[aria-sort="ascending"]:hover::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3E%3Cpath d='M4 2l3.5 4h-7z' fill='%23F4F1EF'/%3E%3C/svg%3E");
}
.table th[aria-sort="descending"]:hover::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3E%3Cpath d='M4 10L0.5 6h7z' fill='%23F4F1EF'/%3E%3C/svg%3E");
}

/* A footer row carries column totals or averages: closed by a heavier rule and set
   in medium weight, so it reads as a summary rather than another data row. */
.table tfoot td {
  border-top: 2px solid var(--brand-border-strong);
  border-bottom: 0;
  font-weight: 500;
  color: var(--brand-ink);
  background: var(--brand-surface-alt);
}

/* A total under a column of FIELDS lines up with the numbers INSIDE the fields, not with the
   cell edge. A field insets its own text — by its border and its right padding — so the total
   has to be inset by the cell's padding PLUS the field's, or it lands one field-inset to the
   right of the column it is totalling.

   The HEADING over such a column takes the same inset, for the same reason: a numeric column's
   heading sits over its figures, and leaving it at the cell's padding alone put it 5px to the
   right of them — so the heading, the figures and the total were three different right edges.

   Measured in the data display before the rule: the Weight fields' numbers ended 21px from the
   cell edge, the total 12px (a 9px drift) and the heading 16px. The arithmetic is the cell's own
   `--space-3` plus the field's right padding and 1px border — and the field's padding is now
   `--space-4`, the same as every other field in the kit, because this table's fields were shrunk to
   `--space-1`/`--space-2` and 12px type while every other field in the application is 14px with
   `--space-3`/`--space-4`. Measured, that made a field in a cell **24-34px** against **42-50px**
   everywhere else, which is not a dense table but a different control. `verify_field_fit.py` checks
   every table, so a variant that is not stated here fails rather than drifting. */
.grid-table th.num,
.grid-table tfoot .num { padding-right: calc(var(--space-3) + var(--space-4) + 1px); }

/* A compact variant for dense screens such as the gradebook. */
.table--compact td,
.table--compact th { padding: var(--space-2) var(--space-3); }

/* Below the table: the actions on the left, the count beside them, the pager on the right.

   The actions that act on the selected rows live HERE, in the table's own footer, rather
   than in a separate bar floating above the table. The footer is already the strip that
   reports on the table as a whole — how many rows there are, which page you are on — so it
   is where the reader already looks for table-wide controls. A bar above the table pushed
   the first row down, detached itself from the thing it acted on, and had to appear and
   disappear as the selection changed; anchored in the footer the controls stay put and the
   count beside them says what they will act on.

   `.thumb-foot` is the same strip under a thumbnail sheet, and the two are listed together
   rather than written twice: the reader is being asked the same question — what do you want done
   with what you have ticked — so it is answered by the same strip, in the same order, with the
   same proximity rule. Two rules that happen to match today are two rules that will drift. */
.table-foot,
.thumb-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3) var(--space-4);
  padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--brand-border);
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* The controls first, then the label that says what they will act on. The separator belongs to
   the selected-count clause, so it is drawn by CSS and can never precede it — the clause and its
   mark arrive together or not at all. It used to be that the clause itself was dropped when the
   table became cards, because cards had no ticks to count; the ticks are back, so the count of
   them is back with them. */

/* A bulk action is a PAIR — the select names the action, the button commits it — and the pair,
   not either half, is what has to survive the wrap. It is bound by proximity rather than by
   weight: the gap inside the pair is a third of the gap between controls, so the two read as one
   control, while every button in the strip stays equally quiet. Weighting the button instead
   would put a second loudest-thing in the table foot, competing with the page's own primary
   action, and the strip's whole convention is that the consequence is carried by the
   confirmation that follows rather than by the trigger.

   The gap cannot go tighter than this. Each control casts a 2px-offset, 5px-blur shadow, and at
   4px the select's shadow had not faded before the button's border began: the space between them
   was a grey smudge rather than clean card, which reads as a seam in one broken control. At 8px
   the tail has cleared.

   The button exists because a select commits nothing on its own. An action that fires the moment
   the value changes has already happened by the time the reader has read what they chose, and
   choosing differently cannot undo it — so the choice and the commit are two steps. */
.bulk-action { display: inline-flex; align-items: center; gap: var(--space-2); }

/* The distance from the pair to the count label is deliberately WIDE — three times the gap inside
   the pair — because the only thing separating "these are two kinds of thing" from "these are one
   thing" is the ratio between the two gaps. At a 2:1 ratio it read as noise: a reader saw buttons
   in a row and no pair at all. The foot's own column gap is `--space-4`, so the pair carries the
   difference itself.

   This used to be carried by a `.foot-actions` wrapper holding the pair and the Export button
   beside it. Export is now an entry in the select, which left the wrapper holding one child — and
   a wrapper with one child is a gap that separates nothing. */
.table-foot > .bulk-action,
.thumb-foot > .bulk-action { margin-right: var(--space-2); }
.foot-status { font-weight: 500; color: var(--brand-ink-muted); }
.foot-status .foot-selected { color: var(--brand-ink); }
/* The separator is glued to the count with a NON-BREAKING space, so the mark cannot begin a line.
   A plain leading space is a break opportunity — the one place the line may break is inside this
   declaration's own content — and the mark then LEADS the line it should have ended: "· Showing
   1–6 of 6". With the glue the break falls after the mark instead, and the count and the total
   still reflow onto two lines when they must. The same rule, for the same reason, as the hero's
   summary line, which is where it was found. */
.foot-status .foot-selected::after { content: "\00A0· "; }

/* A partition inside a table body.
   ---------------------------------------------------------------------------
   The Dashboard's class list is partitioned by headings inside one framed block
   (§5.14), and a table that groups needs the same thing in a row: the group's
   name over the rows it holds, so the reader can see where one class's marks stop
   and the next begins without comparing a column.

   It is a `<th>` spanning the table, and that is what makes it a heading for the
   rows beneath rather than a row among them. It already wears the header's dress —
   `.table th` gives any `th` the tinted ground and the 2px strong rule — so all
   this adds is the weight and the ink: a header ONE LEVEL DOWN reads as a
   stronger label, not a fainter one. Nothing is needed to stop the row's hover
   fill reaching it, because the `th`'s own ground is opaque and spans every
   column; the haze is painted underneath it and never seen.

   The heading WRAPS, and that is the one place it departs from the header it is a
   level below. A column label is two words and fits any column it heads, so `.table th`
   holds it on one line. A group heading is a SENTENCE — "BIOL 1102 · Ecology &
   Conservation · Spring 2026" — and a sentence told not to wrap is a sentence that gets
   cut: measured at 390px it needed 344px in a 312px cell, and the wrapper's
   `overflow: hidden` took the tail off the class's own name. */
.table tbody tr.table-group th {
  padding: var(--space-2) var(--space-4);
  font-weight: 700;
  color: var(--brand-ink);
  white-space: normal;
}

/* A mark, and the scale it is out of.
   ---------------------------------------------------------------------------
   A mark is meaningless without its denominator: "16.5" is a strong pass out of
   20 and a disaster out of 100. The two are ONE reading, so the figure is the
   heavy half and the scale is set beside it in the small muted tone. Both are
   tabular, so a column of marks lines up on its digits and on its slashes. */
/* The value and its scale are each given a BOX, and that is what makes the column line up on the
   values rather than on the denominators. Right-aligning the pair as one run — which is what the
   cell does by itself — aligns the scales, which are the qualifier, and leaves the numbers ragged:
   measured, "16.5 / 20" started 12px left of "27 / 40" because "/ 100" is wider than "/ 20". A
   numeric column that does not line up on its numbers is the one thing `.num` exists to prevent. */
.mark {
  display: inline-block;
  min-width: 3.5ch;
  text-align: right;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.mark-scale {
  display: inline-block;
  min-width: 4.5ch;
  text-align: left;
  font-size: var(--text-micro);
  font-weight: 400;
  color: var(--brand-ink-muted);
  font-variant-numeric: tabular-nums;
}

/* The second line of a table cell.
   ---------------------------------------------------------------------------
   A cell that carries two facts about one thing — an instrument and the rubric
   version its mark was made against — is ONE cell, not two. The second line
   qualifies the first, and a column of its own would put the two on different
   rows of the reader's attention, which is the opposite of qualifying. It is set
   back to 400 because the cell it sits in is `.cell-strong`, and a qualifier that
   inherits the weight of the thing it qualifies reads as a second name. */
.cell-note {
  display: block;
  font-weight: 400;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* A PERSON, DRAWN WITH THEIR PICTURE, EVERYWHERE THEY ARE A ROW IN SOMETHING.
   ---------------------------------------------------------------------------
   The People surface's own list, an authorized-users table, a role's holders — and the same disc in
   the header's person selector and in the dialog that switches between people. ONE component for all
   of them, because a face drawn four ways is four different people to a reader scanning for one, and
   because "which of these is Alice" is the question every one of these lists is asked.

   The disc is `.avatar` — the one the account button has always worn at the top right — so the
   picture of a person is the same picture everywhere it appears, and the two lines beside it keep
   the shape the cell already had: the name, then the handle. A cell that gains a face must not
   become a different kind of cell.

   The disc is `aria-hidden` and the name is not, because the name IS the identity: a screen reader
   that announced "AS, Alice Smith" would be reading out a spelling before the person. */
.person-cell {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-width: 0;
}
.person-cell-main {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* A mark, at the size a dialog can afford.
   ---------------------------------------------------------------------------
   It is the answer the reader opened the dialog for, so it is the largest thing
   in the body — and it stands BESIDE the class's aggregate, because a mark alone
   says nothing about whether it is good. 27 of 40 is a different fact against a
   class mean of 26 than against one of 34, and a dialog that showed only the
   first would leave the reader to supply the second from memory. */
.mark-figure {
  display: flex;
  align-items: baseline;
  gap: var(--space-4);
  flex-wrap: wrap;
  padding-bottom: var(--space-4);
}
.mark-figure-value {
  font-family: var(--font-heading);
  font-size: var(--text-h2);
  font-weight: 700;
  color: var(--brand-ink);
}
/* The scale grows with the figure it belongs to: the small muted tone it wears in a table row is
   right beside 12px type and unreadable beside 32px. */
.mark-figure-value .mark-scale { font-size: var(--text-small); }
.mark-figure-note { font-size: var(--text-small); color: var(--brand-ink-muted); }

/* A division inside a dialog body.
   ---------------------------------------------------------------------------
   The dialog's title is the record's name; this separates the QUESTIONS asked of
   it — what the mark was, and where it came from — and it is set in the system's
   label tone so it reads as a heading over the rows beneath it rather than as a
   second title. No rule of its own: `.summary-row` already opens with one, and two
   hairlines a few pixels apart is one line too many. */
.dialog-section {
  margin: var(--space-5) 0 0;
  font-family: var(--font-heading);
  font-size: var(--text-micro);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-muted);
}

.pager { display: inline-flex; align-items: center; gap: var(--space-2); margin-left: auto; }

/* The rows-per-page control LEADS the pager, because a page number means nothing until the reader
   knows what a page holds: "3" is a different place at 10 rows a page and at 100. The two are one
   control in two halves — the size is the unit, the numbers are the position in it.

   It is a bare select and NOT a pair, which is the one place this differs from the bulk action
   beside it. The pair exists because a bulk action changes real data and cannot be undone by
   choosing differently; the confirmation is what stands between the choice and the consequence.
   Changing how many rows are shown changes nothing but the view, the reader sees the result
   immediately, and setting it back is one click — so it commits on change. An Apply button here
   would be ceremony, and worse, it would be a SECOND button in a strip whose whole convention is
   that it carries one.

   The gap inside the control is the pager's own, and the margin after it is wider, so the label and
   its select read as one thing and the page numbers as another — the same proximity rule the
   bulk-action pair uses. Measured on the rendered strip: **8px** from the label to the select and
   **24px** from the select to the first page number, the same 3:1 the pair uses, so the strip has one
   proximity rule rather than two.

   **The select and the page buttons are the same 30px, and nothing states that.** `.pager button`
   declares `height: 30px`; `.select-sm` *computes* the same 30px from its padding, font size,
   `line-height` and borders. Change any of those — the font scale, the spacing scale, the border
   width — and the two part company silently: the strip looks slightly wrong, which reads as a table
   nobody finished rather than as a defect. `verify_foot_controls.py` measures every control in every
   pager at three widths and requires one height, one centre line and the minimum target size. Both
   were 30.0px, centred to 0.0px, when it was written. */

.page-size {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-right: var(--space-4);
  white-space: nowrap;
}

.pager button {
  min-width: 30px;
  height: 30px;
  border-radius: var(--radius-control);
  border: 1px solid var(--brand-border-strong);
  background: var(--brand-surface);
  color: var(--brand-ink);
  font-family: var(--font-body);
  font-size: var(--text-micro);
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

.pager button:hover { box-shadow: var(--shadow-control-hover); }
.pager button:active { box-shadow: var(--shadow-control-active); }

.pager button[aria-current="true"] {
  background: var(--brand-space);
  color: var(--brand-on-dark);
  border-color: var(--brand-space);
}

/* The empty state holds the collection's shape rather than collapsing it, so the page does
   not jump when data arrives. It is one component on two hosts — a table and a thumbnail sheet
   — and they are listed together for the reason the foot is: the message is the same message in
   the same place, and two rules that match today would not match after the next edit to one. */
.table-empty,
.thumb-empty {
  padding: var(--space-7) var(--space-5);
  text-align: center;
  color: var(--brand-ink-muted);
  font-size: var(--text-small);
}

.table-empty strong,
.thumb-empty strong {
  display: block;
  margin-bottom: var(--space-2);
  font-family: var(--font-heading);
  color: var(--brand-ink);
}

/* A row that IS clickable earns the shadow. */
/* ---------------------------------------------------------------------------
   A row that leads somewhere.
   Clicking a row is navigation, so it is done with a real link rather than a click
   handler: the first cell carries an anchor, and a stretched pseudo-element makes the
   whole row its target. That gives keyboard access, a focus ring, middle-click and
   "open in new tab" for free, and it needs no JavaScript at all.
   The affordance is a pointer cursor, a stronger hover fill, a pressed state, a focus
   ring and a trailing chevron — NOT a drop shadow. The shadow rule is scoped to
   discrete controls; on a full-width row inside a table it reads as a floating bar
   rather than as a row.
   ------------------------------------------------------------------------ */

.table tbody tr:has(.row-link) { position: relative; cursor: pointer; }
.table tbody tr:has(.row-link):hover { background: var(--brand-azure-tint); }
.table tbody tr:has(.row-link):active { background: var(--brand-azure-tint-deep); }

/* A checked row keeps its grey while the pointer passes over it, and while it is pressed.
   Selection is the settled state; a hover and a press are momentary ones and must not overwrite
   it. That is now held by the SELECTOR rather than by where this rule sits — see the rule in the
   table section — so there is nothing to duplicate here. */
.table tbody tr:has(.row-link:focus-visible) {
  outline: 2px solid var(--brand-azure);
  outline-offset: -2px;
}

.table .row-link { color: inherit; text-decoration: none; }

/* Stretch the link across the row, so the whole row is the target while the markup stays
   a link inside a cell — which is what keeps it valid and keyboard-reachable. */
.table .row-link::after { content: ""; position: absolute; inset: 0; }

/* ...but the overlay covers the WHOLE row, including the checkbox cell, so it would swallow
   the click and tick the box by navigating instead. Anything interactive inside the row is
   therefore lifted above the overlay. The checkbox column is the case that matters today; a
   button or a second link in a cell would be the same bug, so they are covered too. The
   `.row-link` itself is deliberately NOT lifted — raising the anchor would raise its own
   overlay with it and put the checkbox back underneath. */
.table tbody tr:has(.row-link) .select-col,
.table tbody tr:has(.row-link) td > :is(button, input, select, label) {
  position: relative;
  z-index: 1;
}
.table tbody tr:has(.row-link) .select-col { cursor: default; }

.table tbody tr:has(.row-link) .row-link:hover { text-decoration: underline; }

/* A trailing chevron says the row leads somewhere. It is the ONLY cue that survives into a
   static view — a hover fill needs a pointer to be seen — so it is drawn clearly in the ink
   rather than faintly, and the link text colours on hover to confirm it. */
.table tbody tr:has(.row-link) td:last-child { padding-right: var(--space-7); }
.table tbody tr:has(.row-link) td:last-child::after {
  content: "";
  position: absolute;
  right: var(--space-4);
  top: 50%;
  width: 8px;
  height: 8px;
  margin-top: -5px;
  border-right: 2px solid var(--brand-ink-muted);
  border-bottom: 2px solid var(--brand-ink-muted);
  transform: rotate(-45deg);
}

/* The name in the row confirms the affordance on hover, the way any link does. */
.table tbody tr:has(.row-link):hover .row-link { color: var(--brand-azure-deep); }

.metric {
  font-family: var(--font-heading);
  font-size: 1.75rem;
  font-weight: 700;
  color: var(--brand-ink);
}

.metric-label {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--brand-ink-muted);
}

/* ---------------------------------------------------------------------------
   Alerts — square, flat, with a coloured left rule
   ------------------------------------------------------------------------ */

.alert {
  border-radius: var(--radius-structure);
  border: 1px solid var(--brand-border);
  border-left: 4px solid var(--brand-azure);
  background: var(--brand-surface);
  padding: var(--space-4) var(--space-5);
  font-size: var(--text-small);
  box-shadow: none;
}

.alert-success { border-left-color: var(--brand-success); }
.alert-warning { border-left-color: var(--brand-warning); }
.alert-danger { border-left-color: var(--brand-danger); }
.alert-info { border-left-color: var(--brand-info); }

/* ---------------------------------------------------------------------------
   Disabled — flat, muted, and NEVER shadowed.
   A shadow means "you can use this", so a control that cannot be used must not
   have one. Disabling a control removes its affordance; it does not dim one.
   ------------------------------------------------------------------------ */

.btn:disabled,
.input:disabled,
.textarea:disabled,
.select:disabled,
.chip-action:disabled,
.tab:disabled {
  /* `background-color`, never the `background` shorthand: the shorthand also resets
     `background-image`, which silently stripped the dropdown's chevron — leaving a disabled
     select that looked exactly like a disabled text field, with nothing to say it was a
     choice at all. */
  background-color: var(--brand-surface-alt);
  color: var(--brand-ink-faint);
  border-color: var(--brand-border);
  box-shadow: none;
  cursor: not-allowed;
}

/* Text a screen reader reads and the page does not draw.

   Needed wherever a VISUAL distinction carries meaning that no text does — the read/unread
   state of a count is exactly that: a disc changes colour, and colour alone is not a message.
   The element stays in the accessibility tree (so it joins the control's name) and is taken
   out of the layout, rather than being `display: none`, which would remove it from both. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* A disabled control withdraws its affordance; it does not lose its identity. The same rule
   that keeps the tick in a disabled checkbox keeps the chevron in a disabled select: the shape
   says what the control is, and the missing shadow says you cannot use it. The chevron is
   muted rather than removed, because a dropdown that has stopped being a dropdown is a
   different control, and the reader would have to guess which. */
.select:disabled {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6.5l4 4 4-4' fill='none' stroke='%237A7A7A' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

.input:disabled::placeholder,
.textarea:disabled::placeholder { color: var(--brand-ink-faint); }

/* Four distinct states, and they must not be confused for one another:
     unchecked            white box, raised
     checked              azure fill, white tick, raised
     unchecked + disabled flat grey box, no shadow
     checked   + disabled grey fill, GREY TICK, no shadow
   The tick survives into the disabled state on purpose: a grey box on its own reads as
   "unset", which is the opposite of what it means. Note `background-color`, never the
   `background` shorthand — the shorthand would wipe the tick image. */
input[type="checkbox"]:not(.toggle):disabled,
input[type="radio"]:disabled {
  background-color: var(--brand-surface-alt);
  border-color: var(--brand-border);
  box-shadow: none;
  cursor: not-allowed;
}

input[type="checkbox"]:not(.toggle):not(.select-all):checked:disabled {
  background-color: var(--brand-border-strong);
  border-color: var(--brand-border-strong);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.2 8.4l3.1 3.1L12.9 4.9' fill='none' stroke='%235a5a5a' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

/* The selected-but-disabled radio keeps its ring and its centre, both muted — the direct
   analogue of the azure state rather than a different idea. */
input[type="radio"]:checked:disabled {
  background-color: var(--brand-surface);
  border: 5px solid var(--brand-border-strong);
}

.check:has(input:disabled),
.radio:has(input:disabled),
.check.is-disabled,
.radio.is-disabled {
  color: var(--brand-ink-faint);
  cursor: not-allowed;
}

.toggle:disabled {
  background: var(--brand-border);
  box-shadow: none;
  cursor: not-allowed;
}

.toggle:checked:disabled { background: var(--brand-border-strong); }

/* A disabled navigation item stays legible but is clearly not reachable. */
.nav-item[aria-disabled="true"] {
  color: var(--brand-on-dark-muted);
  cursor: not-allowed;
  pointer-events: none;
}

/* ---------------------------------------------------------------------------
   Marking — the view-and-mark surface.
   The page is structure, so it is square, flat and bordered. The tools are
   controls, so they are rounded and raised, and the ACTIVE tool is filled
   nightfall exactly like an active tab. Reviewer ink is the ink and the
   highlighter is translucent amber, so neither competes with the brand azure.
   ------------------------------------------------------------------------ */

.mark-split { display: flex; gap: var(--space-4); align-items: stretch; }

.page {
  position: relative;
  background: #ffffff;
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  box-shadow: none;
  padding: var(--space-6);
}

.page-title { font-family: var(--font-heading); font-size: var(--text-h4); margin: 0 0 var(--space-3); }
.page-text { font-size: 13px; line-height: 1.75; color: var(--brand-ink); margin: 0 0 var(--space-4); }

/* ---------------------------------------------------------------------------
   Marking mode — a full-screen modality.
   Reading and annotating a document cannot share the screen with the application
   frame on a tablet: the header, navigation and sub-menu take exactly the room
   the document needs. So marking mode SUPPRESSES the frame and replaces it with
   one slim dark bar, and the document fills the viewport.
   The bar is nightfall, the same dark surface as the navigation, so dark still
   means chrome and light still means content. The active tool is filled deep
   azure with white text — the palette's rule for a filled surface with white
   text, and the same treatment as a chosen rubric option.
   ------------------------------------------------------------------------ */

.marking-mode {
  display: flex;
  flex-direction: column;
  height: 100%;
  background: var(--brand-surface-alt);
}

.mode-bar {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  flex: none;
  /* The bar wraps its CLUSTERS rather than crushing them. Without this the two toolbars, which
     are flex items and shrink by default, are squeezed until their own tools wrap inside them —
     producing a ragged stack of buttons at the frame edge instead of a row. A cluster keeps its
     natural width and the bar takes another line when it must, so the bar reads as composed at
     any width and still sits on one line at the full-screen width it is designed for. */
  flex-wrap: wrap;
  padding: var(--space-2) var(--space-4);
  background: var(--brand-space);
  color: var(--brand-on-dark);
}

.mode-bar .toolbar {
  /* The cluster keeps its natural width so the BAR takes another line instead of the cluster being
     crushed — which is the point of the rule. But "natural width" has to be bounded by the bar it
     is in, or a cluster wider than the viewport pushes the whole DOCUMENT sideways and the reader
     is given a horizontal scrollbar to reach a control that is already the full width of the
     screen. Measured: the seven tool pills came to 633px inside a 390px viewport, and the page
     scrolled 149px sideways. `max-width` is what lets the cluster's own `flex-wrap` do its job —
     the pills take a second line INSIDE the cluster, which is the same answer the bar gives one
     level up, rather than a second answer that contradicts it. */
  flex: none;
  max-width: 100%;
  min-width: 0;
}

.mode-bar .doc-title { color: var(--brand-on-dark); }
.mode-bar .doc-pager { color: var(--brand-on-dark-muted); }

/* Tools sit on the dark bar: outlined when idle, filled deep azure when active. */
.mode-bar .tool {
  background: transparent;
  color: var(--brand-on-dark);
  border-color: rgba(244, 241, 239, 0.35);
  box-shadow: none;
}

.mode-bar .tool[aria-pressed="true"] {
  background: var(--brand-azure-deep);
  color: #ffffff;
  border-color: var(--brand-azure-deep);
}

/* Leaving the mode is a plain text control, not a competing button. */
.mode-exit {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-pill);
  border: 1px solid rgba(244, 241, 239, 0.35);
  background: transparent;
  color: var(--brand-on-dark);
  font-family: var(--font-body);
  font-size: var(--text-micro);
  cursor: pointer;
}

/* The exit sits on the dark bar, so it has no shadow to deepen. It brightens its own outline
   instead — the same acknowledgement, drawn for a surface that is already dark. */
.mode-exit:hover {
  background: rgba(244, 241, 239, 0.12);
  border-color: var(--brand-on-dark);
}

.marking-stage { display: flex; flex: 1; min-height: 0; gap: 0; }

/* Marking mode carries the document and the ink and NOTHING ELSE. The rubric is
   answered afterwards, in assessment mode. */

/* ---------------------------------------------------------------------------
   The ink layer — the surface a stylus writes on
   ---------------------------------------------------------------------------
   `.marking-mode` above describes the FRAME: one slim nightfall bar and the
   document filling the viewport beneath it. This is the document's own half of
   that, and it is a layer rather than a picture.

   The ink is a `<canvas>` laid over the page, and it is deliberately NOT the
   page. The document is the thing being read; the ink is what the reader put on
   it. Keeping them separate is what lets the page reflow and the ink be drawn
   back onto it at the new size, lets one stroke be undone without touching a
   word, and lets the same page be shown with no ink at all.

   The page itself is `.page`, which the kit already defines as structure —
   square, flat, bordered — and which already carries `position: relative`, so
   the overlays cover it without a wrapper element invented for the purpose.
   ------------------------------------------------------------------------ */

/* The drawing surface. `inset: 0` covers the page's padding box, so a stroke at
   the very edge of the sheet lands on the sheet and not beside it. */
.ink-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* `pan-y` and NOT `none`, and the difference is a finger. `touch-action` is
     about touchscreen manipulation, so it does not apply to a stylus at all —
     the pen draws whatever this says. A finger does obey it, so `pan-y` keeps
     the page scrollable under a thumb while the pen stays free. `none` would
     freeze the page for every reader who has no stylus, which is most of them. */
  touch-action: pan-y;
  cursor: crosshair;
  z-index: 2;
}

/* The pointer says what the active tool will do before it does it: a pen draws,
   an eraser rubs out, a note is typed and a pin is dropped. */
.ink-layer[data-tool="eraser"] { cursor: cell; }
.ink-layer[data-tool="note"] { cursor: text; }
.ink-layer[data-tool="pin"] { cursor: copy; }

/* The two marks the reader PLACES rather than draws. Both are DOM elements and
   not canvas pixels, because both are things the application has to read back —
   a note is a string and a pin is a record — and pixels are not. The overlay
   lets the pointer through everywhere except where a note actually is. */
.ink-notes { position: absolute; inset: 0; z-index: 3; pointer-events: none; }

.ink-note-box {
  position: absolute;
  pointer-events: auto;
  box-sizing: border-box;
  width: 190px;
  padding: var(--space-2) var(--space-3);
  background: var(--brand-surface);
  border: 1px solid var(--brand-azure-deep);
  border-radius: var(--radius-control);
  box-shadow: var(--shadow-control);
  font-family: var(--font-body);
  font-size: 12px;
  font-style: italic;
  line-height: 1.5;
  color: var(--brand-azure-deep);
  resize: none;
  overflow: hidden;
}
.ink-note-box:focus { outline: none; box-shadow: var(--shadow-input-focus); }

/* A placed pin. `.pin` is already the kit's numbered annotation disc, so the
   only thing added here is where it sits: centred on the point that was
   pressed, which the reader aimed at. */
.ink-pin {
  position: absolute;
  pointer-events: auto;
  transform: translate(-50%, -50%);
}

/* The eraser's own ring, drawn where the eraser is. It is a read-out of the
   tool's reach, so it follows the pointer and is shown only while erasing. */
.ink-eraser-ring {
  position: absolute;
  z-index: 4;
  width: 28px;
  height: 28px;
  margin: -14px 0 0 -14px;
  border: 1.5px solid var(--brand-ink);
  border-radius: var(--radius-pill);
  background: rgba(244, 241, 239, 0.55);
  pointer-events: none;
  display: none;
}
.ink-page[data-erasing="true"] .ink-eraser-ring { display: block; }

/* The marking stage, laid out for a tablet. The stage the kit already defines
   carries no padding, because the kitchen sink sets it inside a fixed frame
   with a `.doc-stage` of its own inside it; this variant is the full-viewport
   arrangement, where the page list is the rail down the left edge. */
.ink-stage {
  gap: var(--space-4);
  padding: var(--space-5);
  align-items: flex-start;
  overflow: auto;
  background: var(--brand-surface-alt);
}

/* A modality takes the VIEWPORT. `.marking-mode` states `height: 100%`, which
   is the height of a containing block — and a document that does not state one
   has none, so the mode would sit at its content's height with the sheet ending
   in the middle of the screen. The base rule is left alone because the kitchen
   sink shows the same component inside a fixed frame of 520px, where a viewport
   minimum would force it taller than the frame it is a specimen in. */
.marking-mode--page { min-height: 100vh; }

/* The page list, as the reader's own navigation. `.doc-thumb` is the kit's
   thumbnail; what is added is that here it is a control rather than a marker,
   because these pages are reached by pressing one. */
.ink-page-list .doc-thumb { cursor: pointer; }
.ink-page-list .doc-thumb:hover { border-color: var(--brand-ink-muted); }
.ink-page-list .doc-thumb:focus-visible { outline: 3px solid var(--brand-azure); outline-offset: 2px; }

/* The sheet, and the zoom. `zoom` rather than a transform because a transform
   does not change layout: the page would grow past its column and the ink would
   be drawn at one size while being displayed at another. With `zoom` the sheet
   re-lays-out at the new size, the canvas re-measures from its own box, and the
   strokes — which are fractions of the page — land where they belong.

   The ink scales WITH the page, and that is a decision rather than a default: a
   stroke drawn at the medium nib is 3.7px against the text at 100%, and 7.4px
   against the same text at 200%. A mark that kept its screen size while the
   words doubled would get thinner relative to the document every time the
   reader zoomed in to read it. */
.ink-page { --zoom: 1; zoom: var(--zoom); }

/* ---------------------------------------------------------------------------
   The pen probe — what the stylus reported, while it reported it
   ---------------------------------------------------------------------------
   THIS IS NOT PART OF MARKING MODE. §5.1 is explicit that marking mode carries
   the document, the ink and one slim bar and nothing else, and a panel of live
   figures beside the page would be a second thing to read while reading.

   It is what the PROTOTYPE adds, and for the one question a prototype exists to
   answer: whether a stylus's pressure, tilt and speed actually arrive on the
   page as ink of different weights. That is a claim about hardware, and a claim
   about hardware cannot be made by a still image of the result — so the figures
   are drawn beside the stroke they produced, and the toggle in the bar hides
   the whole panel, leaving the marking mode the design system describes.

   The visualisation is CSS and not a second canvas. The meter's width and each
   trace bar's height are `calc()` over a custom property the script sets on the
   element, so the drawing is a stylesheet's job here as everywhere else.
   ------------------------------------------------------------------------ */

.ink-probe {
  /* The probe's own properties, defined here because the instrument is the only
     thing that writes them and this is the element that owns them. */
  --pressure: 0;
  flex: none;
  width: 248px;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-4);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  font-size: var(--text-micro);
  color: var(--brand-ink);
}
.ink-probe[hidden] { display: none; }

.probe-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
}
.probe-title {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-small);
  font-weight: 700;
  color: var(--brand-ink);
}
.probe-note { margin: 0; line-height: 1.5; color: var(--brand-ink-muted); }

.probe-rows { display: flex; flex-direction: column; gap: var(--space-2); }
.probe-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
}
.probe-key { color: var(--brand-ink-muted); }
.probe-value {
  font-variant-numeric: tabular-nums;
  font-weight: 500;
  color: var(--brand-ink);
  text-align: right;
}

/* The pressure meter. Its fill is the custom property the script sets, so the
   bar is drawn by the stylesheet rather than by a second drawing surface. */
.probe-meter {
  height: 8px;
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-pill);
  background: var(--brand-surface-alt);
  overflow: hidden;
}
.probe-meter-fill {
  height: 100%;
  width: calc(var(--pressure) * 100%);
  background: var(--brand-azure-deep);
}

/* The trace: the last second of pressure, one bar per sample, newest last. It
   is what makes the mapping legible at a glance — the pen going lightly down
   and hard along the stroke is a shape here, and a shape is what a reader
   recognises without reading a number. */
.probe-trace {
  --trace: 0;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 46px;
  padding: 2px;
  background: var(--brand-surface-alt);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
}
.probe-trace-bar {
  flex: 1;
  min-height: 1px;
  height: calc(var(--trace) * 100%);
  background: var(--brand-space);
  border-radius: 1px;
}

.probe-foot {
  margin: 0;
  padding-top: var(--space-3);
  border-top: 1px solid var(--brand-border);
  line-height: 1.5;
  color: var(--brand-ink-muted);
}

/* The phone and portrait bands. There is no room beside the page for a rail panel,
   so the probe moves UNDER the document and the page list becomes the strip the kit
   already has a class for — the two arrangements §5.0 describes, chosen by width as
   everywhere else in this kit.

   The page list is named by BOTH of its classes, and that is not decoration: `.page-list--rail`
   sets `flex-direction: column` and is declared LATER in this file than the ink layer is, so at
   equal specificity the rail wins and the list stays a column — measured, twelve thumbnails
   stacked down the left edge of a 390px screen, 700px of a 844px viewport spent on navigation
   and the document pushed off the bottom of it. Two classes settle it by specificity rather
   than by moving this block to the end of the file, where the next component added would
   reintroduce the same problem. */
@media (max-width: 1099px) {
  .ink-stage { flex-direction: column; align-items: stretch; padding: var(--space-4); }
  .ink-probe {
    width: auto;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: var(--space-4);
  }
  .ink-probe .probe-head,
  .ink-probe .probe-note,
  .ink-probe .probe-meter,
  .ink-probe .probe-foot,
  .ink-probe .check { flex: 1 1 100%; }
  .ink-probe .probe-rows { flex: 1 1 220px; }
  .ink-probe .probe-trace { flex: 1 1 220px; }
  .page-list--rail.ink-page-list {
    flex-direction: row;
    overflow-x: auto;
    padding-bottom: var(--space-2);
  }
}

@media (max-width: 599px) {
  .ink-stage { padding: var(--space-3); }
  .ink-page { padding: var(--space-4); }
}

/* ---------------------------------------------------------------------------
   Assessment mode — the rubric, as a modal questionnaire.
   Annotating and scoring are different tasks: one is reading and reacting in
   place, the other is judging against fixed criteria. So assessment is its own
   modality, entered at the end of marking, and it takes the whole screen it
   needs while leaving the document visible behind it for context.
   The modal is a single surface, not a window inside a window. It is structure,
   so it is square and flat; its controls are rounded and raised.
   ------------------------------------------------------------------------ */

/* THE DIALOG FRAME — one frame for every modal surface in the application.
   ---------------------------------------------------------------------------
   A modal is a surface raised over the page, and there is exactly one way to
   raise one. The frame is defined once here and everything modal is built from
   it: the assessment questionnaire, and the assignment form. Two modal frames
   that drifted apart would be two designs.

   The frame is STRUCTURE, so it is square and flat and casts no shadow. Its
   controls are controls: rounded and raised. That split is the whole system in
   miniature.
   ------------------------------------------------------------------------ */

.dialog-backdrop {
  position: relative;
  background: var(--veil);
  padding: var(--space-6);
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The real thing, covering the viewport. The plain `.dialog-backdrop` is the inline form,
   used by the kitchen sink to show a dialog as a specimen without taking over the sheet. */
.dialog-backdrop--overlay {
  position: fixed;
  inset: 0;
  z-index: 40;
  /* NOT a scroll container. It spans the viewport, so anything it can scroll it scrolls at the
     right edge of the screen — a second scrollbar beside the page's own — and because the dialog
     is its centred child, scrolling the shaded area moves the dialog with it. There is nothing
     here to scroll: the dialog is capped at the backdrop's height and scrolls internally. */
  overflow: hidden;
}

.dialog {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 560px;
  /* `border-box`, so `max-height: 100%` means the whole panel rather than its content. The
     stylesheet has no global border-box reset, so without this the dialog is its allowance PLUS
     its two 1px borders — 2px too tall for the backdrop's content box. That 2px is enough to make
     the fixed backdrop overflow, which gives it a scrollbar down the right edge of the viewport
     and lets the shaded area scroll the dialog up and down. */
  box-sizing: border-box;
  max-height: 100%;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-structure);
  box-shadow: none;
  overflow: hidden;
}

.dialog--wide { max-width: 720px; }

.dialog-head {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  flex: none;
  padding: var(--space-4) var(--space-5) var(--space-3);
  border-bottom: 1px solid var(--brand-border);
}

.dialog-heading { flex: 1; min-width: 0; }

.dialog-step {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--brand-ink-muted);
}

.dialog-title {
  font-family: var(--font-heading);
  font-size: var(--text-h4);
  margin: var(--space-1) 0 0;
}

.dialog-sub {
  margin: var(--space-1) 0 0;
  font-size: var(--text-small);
  color: var(--brand-ink-muted);
}

/* The close control is a control: rounded, raised, and a real link so it is keyboard
   reachable and needs no script. */
.dialog-body {
  flex: 1;
  padding: var(--space-5);
  overflow: auto;
  /* The body is RECESSED, and the shadow at each edge is what says the head and the foot are
     layers standing above it rather than borders drawn across one surface.

     The shadow is `inset` on the SCROLL CONTAINER, which is the whole mechanism: an inset
     box-shadow is painted in the element's own border box and does NOT travel with its content, so
     the band stays pinned under the head and above the foot while the rows slide beneath it. Put
     the same shadow on a child instead — a `::before` inside the scroller, or a wrapper around the
     content — and it scrolls away with the first flick, which is worse than having none: the
     reader loses the edge exactly when they need it.

     It is shown whether or not there is anything to scroll, and that is deliberate. The layering is
     true either way — the head and the foot are separate surfaces, not a hairline drawn across one
     — so the shadow states a fact rather than predicting a scrollbar. Hiding it below the fold
     would need script, and would make the recessed edge appear and disappear as content changed
     height, which reads as a rendering fault rather than as a state.

     Two soft bands rather than a line: `-6px` of spread cancels most of the `6px` blur, so what is
     left is a few pixels that fade out, which is what a surface passing under another looks like.
     A hairline would be a third border on a panel that already has two. */
  box-shadow: var(--shadow-scroll-edge-top), var(--shadow-scroll-edge-bottom);
}

.dialog-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-3);
  flex: none;
  padding: var(--space-4) var(--space-5);
  border-top: 1px solid var(--brand-border);
  background: var(--brand-surface);
}

/* The footer is the dialog's control strip, and it is read right to left: the action that
   finishes the job sits in the far corner, where the eye already goes for the end of a form,
   and the quiet way out sits beside it rather than being pushed into a corner of its own.
   The dialog has no close mark in its heading — a way out at the top AND a way out at the
   bottom is two answers to one question, and the heading's job is to say what the dialog is. */

/* A note that LEADS the strip — progress, a count, a warning — pushes the actions to the far
   end. It has to come first in the markup for this to work: an auto margin on the last child
   absorbs the free space *after* it, which packs the actions to the left instead. */
/* The way out is LEFT-ALIGNED and the action that commits is right-aligned. The free space is
   absorbed after the way-out button, so it sits on the footer's left edge and everything else is
   pushed to the right — measured on all eight dialog footers in the kit, the quiet button's left
   edge is flush with the footer's padding box (0.0px) and the primary's right edge is flush too.

   Stated on the quiet button rather than as `justify-content: space-between`, because a footer may
   hold three things and space-between would spread them to three positions — which puts a status
   note in the middle of the footer where it reads as a third action. Absorbing the space in ONE
   place keeps the note with the group it belongs to: a footer of `Back | 3 marks of 5 entered |
   Next criterion` renders as the way out on the left and the note with the action on the right,
   which is where a progress figure is useful.

   The note has no auto margin of its own. Two auto margins split the free space between them and
   put both items off their edges — neither left nor right, and both looking accidental. */
.dialog-foot .btn-quiet { margin-right: auto; }

/* ---------------------------------------------------------------------------
   A dialog takes the whole viewport when there is no room to spare.
   ---------------------------------------------------------------------------
   The margin around the dialog is the backdrop's padding — 32px on every side, 64px across and
   64px down. On a wide screen that is breathing room. On a small one it is the most expensive
   thing on the page: at 390px it costs a sixth of the width, and on a short window it costs a
   sixth of the height, both spent on nothing the user can use.

   So below the phone band, or on a window too short to spare it, the margin goes and the dialog
   fills the viewport. Nothing else has to change for that to work: the dialog is a column of
   `flex: none` head, `flex: 1` body and `flex: none` foot, so stretching it puts the heading at
   the top, the footer at the bottom, and the scrolling in between — which is the shape a small
   screen wants anyway.

   The border goes with the margin. A hairline drawn hard against the edge of the screen is not a
   frame, it is a stray pixel row.

   The width threshold is the phone band rather than the arithmetic 624px the dialog's own
   max-width plus margin would give. Between 600 and 623 the margin costs the dialog a little of
   its width and nothing else; aligning the rule to the band the rest of the system switches on
   keeps one breakpoint instead of two numbers that mean almost the same thing.
   ------------------------------------------------------------------------ */
@media (max-width: 599px), (max-height: 560px) {
  .dialog-backdrop { padding: 0; align-items: stretch; }
  .dialog { max-width: none; max-height: none; border: 0; }
}

/* ---------------------------------------------------------------------------
   Confirmation — a dialog that asks before something is done
   ---------------------------------------------------------------------------
   A confirmation exists to make a consequence visible BEFORE it happens, so the
   consequence IS the content: what is about to change, and what will be lost.
   Everything else is decoration.

   The confirm button carries the weight of the action — danger when something
   is destroyed, primary when nothing is — and the SAFE action is always the
   quiet one, sitting first, so the place a hurried pointer lands is the place
   that does no harm.
   ------------------------------------------------------------------------ */

.confirm-list {
  margin: var(--space-4) 0 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--brand-border);
}

.confirm-list li {
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--brand-border);
  font-size: var(--text-small);
  color: var(--brand-ink);
}

.confirm-list li:last-child { border-bottom: 0; }

.confirm-note {
  margin: var(--space-4) 0 0;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* ---------------------------------------------------------------------------
   The account's own panel
   ---------------------------------------------------------------------------
   A profile is READ, not filled in, so it is a page of facts rather than a form:
   who you are, and what Govnix Satellite knows about the account. The facts use the same
   labelled row the assessment summary uses — a quiet key and an emphasised value
   in a hairline row — because a confirmed answer and a recorded fact have the
   same anatomy, and a second set of three declarations for the same shape is how
   a design system grows two of everything.

   The identity block is the one thing here that is not a fact row: it answers
   "who am I", and it gets the picture at a size a panel can afford.
   ------------------------------------------------------------------------ */
.profile-id {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding-bottom: var(--space-4);
  border-bottom: 1px solid var(--brand-border);
}

.profile-id-name {
  display: block;
  font-size: var(--text-body);
  font-weight: 500;
  color: var(--brand-ink);
}

.profile-id-mail { display: block; font-size: var(--text-small); color: var(--brand-ink-muted); }

/* The rows sit in their own container so the first one is a `:first-child` and loses its top
   rule — otherwise the identity block's bottom rule and the first row's top rule stack into a
   2px line, which reads as a mistake rather than as a divider. */
.profile-facts { margin-top: var(--space-2); }

/* A class the account can reach, and the role it holds there. The role is PER CLASS and can
   differ between them — the same account is an instructor in one class and a marker in another —
   so it is written beside each class rather than once at the top, where it would be a
   half-truth. */
.profile-class {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
}

.profile-class + .profile-class { margin-top: var(--space-1); }
.profile-role { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* Position and extent, so the marker knows how far there is to go. */
.assessment-progress {
  display: flex;
  gap: 4px;
  margin-top: var(--space-3);
}

.assessment-progress span {
  height: 4px;
  flex: 1;
  background: var(--brand-border);
  border-radius: var(--radius-structure);
}

.assessment-progress span[data-done="true"] { background: var(--brand-azure); }

/* The summary screen, where every answer is confirmed before it is recorded. It is not one of
   the rubric's criteria, so it offers no Back — see the rule beside `.dialog-step`. */
.summary-row {
  display: flex;
  align-items: baseline;
  gap: var(--space-4);
  padding: var(--space-3) 0;
  border-top: 1px solid var(--brand-border);
  font-size: var(--text-small);
}

.summary-row:first-child { border-top: 0; }
.summary-key { flex: none; width: 44%; color: var(--brand-ink-muted); }
.summary-value { font-weight: 500; }

/* The document viewer.
   Everything under review is a PDF: any other submitted format is normalised to PDF
   before it reaches the interface, so the viewer only ever renders one kind of thing.
   The viewer frame is structure (square, flat, bordered); its controls are controls. */
.doc-viewer {
  display: flex;
  flex-direction: column;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  box-shadow: none;
  overflow: hidden;
}

.doc-toolbar {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--brand-border);
  background: var(--brand-surface);
}

.doc-title { font-size: var(--text-small); font-weight: 500; color: var(--brand-ink); }
.doc-pager { font-size: var(--text-micro); color: var(--brand-ink-muted); white-space: nowrap; }

/* The viewer is TWO independent components, not one layout.
   ---------------------------------------------------------------------------
   `page-view` is the surface being read and annotated. `page-list` is navigation
   between pages. They are separated because they have different space needs:
   the page wants every pixel it can get in order to stay legible and large enough
   to mark up by pen, while the page list is a navigational aid that is consulted
   occasionally and can collapse to nothing when it is not wanted.
   Keeping them as one component would have hard-coded the strip beside the page
   and made every alternative arrangement a special case.
   ------------------------------------------------------------------------ */

.doc-stage {
  background: var(--brand-surface-alt);
  padding: var(--space-5);
  display: flex;
  gap: var(--space-4);
  align-items: flex-start;
}

/* The page surface. Fills whatever the page list leaves it. */
.page-view { flex: 1; min-width: 0; }

/* THE STAGE HOLDS THE DOCUMENT, WHATEVER THE DOCUMENT IS.

   The viewer frame above is one component — a toolbar, a stage and an optional list of pages — and
   it was built for a PDF being marked. A class posts more than PDFs, and the three other things it
   posts are the same shape of problem: something to LOOK AT, with a toolbar that says what it is and
   a control that moves through it. So they are not three more viewers. They are three more things
   the STAGE can hold:

     * `.page` — a document page, the original case, and the one marking mode annotates;
     * `.slide` — a deck's slide, 16:9, because that is the shape a deck is authored in;
     * `.player` — a recording, 16:9 for the same reason, with its own controls.

   One frame, three contents. A second viewer component would be a second toolbar, a second page
   list, a second set of paddings — and the four would stop looking like one application the first
   time any of them moved. */

/* A SLIDE. The ratio is the point rather than a nicety: a slide IS a 16:9 surface, and a slide
   drawn at whatever height its text happens to take is a slide nobody would recognise as one. The
   sheet is white on the stage's alt ground, exactly as `.page` is, because both are "a page of a
   document" and the reader is looking at the same kind of thing. */
.slide {
  /* `border-box`, so the RATIO is the box the reader sees. The kit states this per component
     rather than globally, and a content-box slide is a 16:9 CONTENT box with padding added on
     top of it — measured, that is 684x449 against the 684x385 the ratio asks for. */
  box-sizing: border-box;
  aspect-ratio: 16 / 9;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  background: #ffffff;
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  padding: var(--space-6);
  overflow: hidden;
}
.slide-title { font-family: var(--font-heading); font-size: var(--text-h3); margin: 0; }
.slide-list { margin: 0; padding-left: var(--space-5); font-size: var(--text-small); }
.slide-list li { margin-bottom: var(--space-2); }
/* The deck's own furniture — which slide this is, and the course it belongs to — sits at the
   slide's floor rather than under the content, so a slide with two lines and a slide with eight
   put it in the same place. */
.slide-foot {
  margin-top: auto;
  display: flex;
  justify-content: space-between;
  gap: var(--space-4);
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}

/* A RECORDING. Nightfall, because a player is a dark surface in every application the reader has
   ever used and this one is not the place to be original — and because the poster photograph sits
   on it rather than beside it. The controls are at the FOOT over the picture, which is where a
   player puts them, and the scrubber is drawn rather than real: the page carries no script, so the
   bar shows the position and the control that changes it is the application's business. */
.player {
  /* `border-box`, so the RATIO is the box the reader sees. The kit states this per component
     rather than globally, and a content-box slide is a 16:9 CONTENT box with padding added on
     top of it — measured, that is 684x449 against the 684x385 the ratio asks for. */
  box-sizing: border-box;
  aspect-ratio: 16 / 9;
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  background: var(--brand-space);
  border-radius: var(--radius-structure);
  overflow: hidden;
}
.player-poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.45;
}
.player-play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* The play control is a BUTTON the size of a target rather than a glyph: it is the one control the
   reader wants from a recording, and 44px is the floor the kit holds a touch target to. */
.player-play-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  border: 0;
  border-radius: var(--radius-pill);
  background: var(--brand-azure);
  color: #ffffff;
  /* Raised, and it is the only control on the player, so it carries the kit's control shadow the
     way `.btn-primary` does — a flat disc on a dark ground reads as a label rather than as the one
     thing to press. */
  box-shadow: var(--shadow-control);
  cursor: pointer;
}
/* AND IT ANSWERS, on hover and on press. The press is the one that matters here: a recording is
   most often started on a touch screen, where there is no hover to confirm anything, so the disc
   deepens and its shadow collapses under the finger. */
.player-play-btn:hover { background: var(--brand-azure-deep); box-shadow: var(--shadow-control-hover); }
.player-play-btn:active {
  background: var(--brand-azure-deep-active);
  box-shadow: var(--shadow-control-active);
}
/* Only the SIZE, which is the rule `[data-icon]` already carries: the button's `data-icon="play"`
   supplies the drawing through the sheet's own mapping, and a rule that set `mask-image` here as
   well would be a second source for one mark — the check that caught it says it plainly, that one
   pseudo-element holds one drawing. */
.player-play-btn::before { width: 20px; height: 20px; mask-size: 20px 20px; }
.player-bar {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  background: rgba(0, 0, 0, 0.55);
}
.player-scrub {
  flex: 1;
  height: 4px;
  border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, 0.28);
}
.player-scrub span { display: block; height: 100%; border-radius: var(--radius-pill); background: var(--brand-azure); }
.player-time {
  font-size: var(--text-micro);
  color: var(--brand-on-dark);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* A TRANSCRIPT, under a recording, and it is the reason a recording is readable at all: it is what a
   reader searches, what they read where sound is not available, and what the captions are made
   from. One line per cue, with the time in a column of its own so the times line up and the words
   start at one x — the same argument the key column of a facts card makes. */
.transcript { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: var(--space-3); }
.transcript-line { display: flex; gap: var(--space-4); font-size: var(--text-small); }
.transcript-time {
  flex: none;
  width: 6ch;
  color: var(--brand-ink-muted);
  font-variant-numeric: tabular-nums;
}

/* A CODE BLOCK, in a page that teaches one. The ground is the alt surface and the face is the mono
   token the kit already uses for an identifier — a page about R that shows its R in the body face
   is a page whose code cannot be copied by eye, and one whose code cannot be distinguished from its
   prose. */
.code-block {
  margin: 0 0 var(--space-4);
  padding: var(--space-4);
  background: var(--brand-surface-alt);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  font-family: var(--font-mono);
  font-size: var(--text-micro);
  line-height: 1.7;
  overflow-x: auto;
  white-space: pre;
}
/* `overflow-x: auto` above is a backstop for a line longer than the column, and it is the ONE
   place in the kit where a horizontal scroll container is tolerated — because it is inside a
   document the reader is reading rather than a frame they are navigating, and because the
   alternative for a long line of code is wrapping it, which changes what the code says. Every
   specimen here is written to fit, so the bar never appears; `verify_scrollbars.py` reads it at
   every band and would report it if one did. */

/* A FIGURE in a page: the picture, and the line that says what it is. The caption is BELOW and
   muted, because a caption above the picture is read before the thing it describes. */
.figure { margin: 0 0 var(--space-4); }
.figure img { display: block; width: 100%; height: auto; border-radius: var(--radius-structure); }
.figure-caption { margin-top: var(--space-2); font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* Page navigation. Collapsible: with `hidden`, or by dropping the class that
   selects a variant, the page view takes the whole frame. */
.page-list {
  flex: none;
  display: flex;
  gap: var(--space-2);
}

/* Beside the page — the default where there is width. */
.page-list--rail { flex-direction: column; }

/* Above or below the page — where there is height but not width. */
.page-list--strip {
  flex-direction: row;
  overflow-x: auto;
  padding-bottom: var(--space-2);
}

/* Overlaid on the page — where there is neither. */
.page-list--sheet {
  position: absolute;
  left: var(--space-4);
  top: var(--space-4);
  bottom: var(--space-4);
  flex-direction: column;
  overflow-y: auto;
  padding: var(--space-3);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-structure);
  box-shadow: none;
}

.page-list[hidden] { display: none; }

.doc-thumb {
  width: 54px;
  height: 72px;
  flex: none;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding-bottom: 3px;
  font-size: 10px;
  color: var(--brand-ink-muted);
}

.doc-thumb[aria-current="page"] {
  border-color: var(--brand-ink);
  color: var(--brand-ink);
  font-weight: 600;
}

/* Reviewer's ink and highlight. */
.highlight { background: rgba(252, 181, 17, 0.45); }
.ink-underline { border-bottom: 2px solid var(--brand-ink); }
.ink-note {
  font-family: var(--font-body);
  font-size: 12px;
  color: var(--brand-azure-deep);
  font-style: italic;
}

/* A numbered annotation pin. A filled deep azure disc carrying a white
   numeral is the palette's own rule for a filled surface with white text. */
.pin {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  border-radius: var(--radius-pill);
  background: var(--brand-azure-deep);
  color: #ffffff;
  font-size: 11px;
  font-weight: 600;
  vertical-align: middle;
}

/* Tool palette — controls, so rounded and raised; the active tool is filled. */
.toolbar { display: inline-flex; gap: var(--space-2); flex-wrap: wrap; }

.tool {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-pill);
  border: 1px solid var(--brand-border);
  background: var(--brand-surface);
  color: var(--brand-ink);
  font-family: var(--font-body);
  font-size: var(--text-micro);
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

/* A tool button is compact and its icon sits BESIDE a word, so it is drawn at 16px rather than
   the 20px a nav item uses where the icon carries the meaning alone. At 20px the six palette
   tools are 571px wide, which no longer fits beside the ink-weight group at 810px — the row
   wrapped, and the extra stacked line cost the marking sheet 93px of its frame budget. */
/* `[data-icon]::before` sets 20px and has the same specificity as a bare `.tool::before`, so the
   later rule would win and this override would silently do nothing. Naming the attribute makes it
   two classes deep and order-independent. */
.tool[data-icon]::before { width: 16px; height: 16px; mask-size: 16px 16px; }

.tool:hover { box-shadow: var(--shadow-control-hover); }
.tool:active { box-shadow: var(--shadow-control-active); }

.tool[aria-pressed="true"] {
  background: var(--brand-space);
  color: var(--brand-on-dark);
  border-color: var(--brand-space);
}

/* ---------------------------------------------------------------------------
   The segmented pair — two controls that are one choice
   ---------------------------------------------------------------------------
   Zoom out and zoom in are not two destinations. They are one adjustment with two directions, and
   a reader reaches for them as a pair: whichever they press, they are doing the same thing to the
   same page. Drawn as two separate tool buttons they took 190px of the marking bar for two words
   and two icons, and the bar had to find room for them beside the pen palette and the way out.

   Joined, they are ONE object with two halves: a single outline, one rounded shape, and the
   division drawn inside it rather than between two borders. The icons carry the meaning — an
   icon-only control is the compact form this kit already uses for the navigation trigger and the
   account — so each half is labelled for assistive technology instead of on screen. That is where
   the space comes from: the pair is about 80px where the two words were 190px.

   The container owns the border, the radius and the shadow, and `overflow: hidden` cuts the halves
   to the pill's own curve. The halves carry no border of their own except the single hairline that
   divides them, so the join has ONE line and not two — two borders meeting is the tell of a
   button group pretending to be a control.
   ------------------------------------------------------------------------ */

.segmented {
  display: inline-flex;
  flex: none;
  align-items: stretch;
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-pill);
  background: var(--brand-surface);
  box-shadow: var(--shadow-control);
  overflow: hidden;
}

.segmented-item {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The same floor, for the same reason — see `.tabs--segmented > .tab`, which is this control's
     other implementation. */
  min-width: var(--size-icon-btn);
  min-height: 32px;
  box-sizing: border-box;
  padding: var(--space-2) var(--space-3);
  border: 0;
  background: transparent;
  color: var(--brand-ink);
  font-family: var(--font-body);
  font-size: var(--text-micro);
  cursor: pointer;
}

.segmented-item + .segmented-item { border-left: 1px solid var(--brand-border); }

.segmented-item:hover { background: var(--brand-surface-alt); }

.segmented-item[aria-pressed="true"] {
  background: var(--brand-space);
  color: var(--brand-on-dark);
}

/* 16px, the tool bar's own size, and named with the attribute so it is two classes deep: a bare
   `.segmented-item::before` has the same specificity as `[data-icon]::before` and would lose on
   source order, drawing the mark at 20px in a control built to be small. */
.segmented-item[data-icon]::before { width: 16px; height: 16px; mask-size: 16px 16px; }

/* On the marking bar the pair wears the bar's own dress: no fill of its own, an outline in the
   bar's hairline, and the same deep azure for the active half that every other tool uses. */
.mode-bar .segmented {
  background: transparent;
  border-color: rgba(244, 241, 239, 0.35);
  box-shadow: none;
}

.mode-bar .segmented-item { color: var(--brand-on-dark); }

.mode-bar .segmented-item + .segmented-item { border-left-color: rgba(244, 241, 239, 0.35); }

.mode-bar .segmented-item:hover { background: rgba(244, 241, 239, 0.12); }

.mode-bar .segmented-item[aria-pressed="true"] {
  background: var(--brand-azure-deep);
  color: #ffffff;
}

/* Rubric marking controls: one row per criterion. */
.mark-item { border-top: 1px solid var(--brand-border); padding: var(--space-4) 0; }
.mark-item:first-child { border-top: 0; }
.mark-label { font-family: var(--font-heading); font-size: var(--text-small); margin: 0 0 var(--space-3); }
.mark-options { display: flex; gap: var(--space-2); flex-wrap: wrap; }

.mark-option {
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-pill);
  border: 1px solid var(--brand-border-strong);
  background: var(--brand-surface);
  color: var(--brand-ink);
  font-family: var(--font-body);
  font-size: var(--text-small);
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

.mark-option:hover { box-shadow: var(--shadow-control-hover); }
.mark-option:active { box-shadow: var(--shadow-control-active); }

.mark-option[aria-pressed="true"] {
  background: var(--brand-azure-deep);
  color: #ffffff;
  border-color: var(--brand-azure-deep);
}

/* ---------------------------------------------------------------------------
   A GRADIENT OF RESPONSE OPTIONS ESCALATES IN COLOUR.

   When a row of buttons is a SCALE rather than a set of alternatives — weak to
   outstanding, never to always, strongly disagree to strongly agree — the row
   must climb one ramp together, so the direction of the scale is visible before
   a single label is read. Four identical pills labelled "Weak / Adequate /
   Strong / Outstanding" ask the reader to read all four words and remember their
   order; a row that visibly warms left to right answers "which way is up?" at a
   glance, and answers it for the eye that is skimming.

   The ramp is ONE hue changing in lightness, not a rainbow. A red-to-green
   scale collapses under the most common colour blindness and disappears in
   greyscale; a single hue darkening survives both, so the order still reads on
   a photocopied rubric.

   Two signals, kept separate: the RAMP says where an option sits on the scale,
   the DEEP-AZURE FILL says which one you chose. The chosen option is excluded
   from the ramp, so choosing "Weak" does not make it look like "Outstanding" —
   the fill is the same deep azure wherever it lands.

   The scale declares its own length with data-steps, so a three-, four-, five-
   or six-point scale all climb the same way without any per-option styling.
   The attribute is the whole mechanism: any future Likert or response row gets
   the escalation by declaring how many steps it has.
--------------------------------------------------------------------------- */
[data-steps] > * { --scale-t: 0; }

[data-steps="2"] > :nth-child(1) { --scale-t: 0; }
[data-steps="2"] > :nth-child(2) { --scale-t: 1; }

[data-steps="3"] > :nth-child(1) { --scale-t: 0; }
[data-steps="3"] > :nth-child(2) { --scale-t: .5; }
[data-steps="3"] > :nth-child(3) { --scale-t: 1; }

[data-steps="4"] > :nth-child(1) { --scale-t: 0; }
[data-steps="4"] > :nth-child(2) { --scale-t: .3333; }
[data-steps="4"] > :nth-child(3) { --scale-t: .6667; }
[data-steps="4"] > :nth-child(4) { --scale-t: 1; }

[data-steps="5"] > :nth-child(1) { --scale-t: 0; }
[data-steps="5"] > :nth-child(2) { --scale-t: .25; }
[data-steps="5"] > :nth-child(3) { --scale-t: .5; }
[data-steps="5"] > :nth-child(4) { --scale-t: .75; }
[data-steps="5"] > :nth-child(5) { --scale-t: 1; }

[data-steps="6"] > :nth-child(1) { --scale-t: 0; }
[data-steps="6"] > :nth-child(2) { --scale-t: .2; }
[data-steps="6"] > :nth-child(3) { --scale-t: .4; }
[data-steps="6"] > :nth-child(4) { --scale-t: .6; }
[data-steps="6"] > :nth-child(5) { --scale-t: .8; }
[data-steps="6"] > :nth-child(6) { --scale-t: 1; }

/* The paint. The chosen option is excluded — its fill carries the selection, and the
   ramp must not compete with it. A disabled option is excluded too, because a
   disabled control withdraws its affordance rather than keeping its emphasis. */
[data-steps] > :not([aria-pressed="true"]):not(:disabled) {
  background: color-mix(in srgb,
    var(--brand-azure-tint-deep) calc(var(--scale-t) * 100%), var(--brand-surface));
  border-color: color-mix(in srgb,
    var(--brand-azure-tint-deep) calc(var(--scale-t) * 100%), var(--brand-border-strong));
}

/* A read-only comment already left by another reviewer. */
.comment-locked {
  background: var(--brand-surface-alt);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-small);
  color: var(--brand-ink-muted);
}

.comment-author { font-weight: 600; color: var(--brand-ink); }

/* ============================================================================
   THE APPLICATION LAYER
   ----------------------------------------------------------------------------
   The page shell, and the composite components built from the base components
   above. Nothing here invents a new visual language: every composite is
   assembled from bases, which is what keeps the screens looking like one
   system.

   Responsive strategy — three bands, matching DEVICE_TARGETS.md:
     phone     < 600px    bottom tab bar, single column, compact
     portrait  600-1099   bottom tab bar, single column, roomier
     landscape >= 1100    left rail, wide content, roomy
   The ONLY structural change between bands is the navigation and the column
   count. Everything else is spacing and type scale, which is what makes the
   three widths one design rather than three.

   A media query answers the VIEWPORT, not the element. A specimen sheet is a
   fixed-width document, so the phone and portrait queries DO fire on it — which
   is correct, since the sheet is rendered at the band width it depicts. It is
   also why a band that re-creates the shell from its own classes still has to
   restate the shell rules that band needs.
   ========================================================================= */
/* ---------------------------------------------------------------------------
   The shell
   ------------------------------------------------------------------------ */

.app { display: flex; flex-direction: column; min-height: 100vh; background: var(--brand-surface); }

/* THE HEADER'S OWN HEIGHT, named because one thing has to subtract it.

   The landscape rail is `height: 100vh` so it fills the window — but it sits BESIDE the main
   region and BELOW the header, so `100vh` made the document `100vh + 64px` on every screen: the
   page carried 64px of scroll it could not use, and any fragment — a dialog, a tab panel, a record
   editor — scrolled the page by that much and took the header off the top of the capture. The rail
   has to be the window MINUS the header, and the header's height is a fact about its contents
   rather than a number to guess at, so it is measured and named here and `verify_shell.py` fails if
   the drawn header stops agreeing with it. */
.app { --header-h: 64px; }

/* THE HEADER IS ON THE FRAME, not on the page. It is the top edge of the instrument panel rather
   than a strip of the document, and the change is structural rather than decorative: the shell is
   dark, the content is light, and the header is where those two meet. So it takes `--brand-space`
   and the frame's own hairline, and it sets `color` once for everything inside it — the breadcrumb,
   the account name, the frame path — rather than letting each of them inherit the page's ink and
   then be corrected one at a time.

   The dark header is why `--brand-azure-bright` exists. The wordmark and the header's own marks are
   read against this ground, and the accent that carries a heading on a white panel measures 1.9:1
   here. See the token block. */
.app-header {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  flex: none;
  padding: var(--space-3) var(--space-4);
  background: var(--brand-space);
  border-bottom: 1px solid var(--brand-space-edge);
  color: var(--brand-on-dark);
}

.app-header .app-name,
.header-bar .app-name { margin: 0; flex: none; font-size: 1.375rem; color: var(--brand-azure-bright); }

/* THE WORDMARK IS TWO WORDS IN TWO COLOURS AND A MARK, and the three are one decision.

   `Govnix` is the house and `Satellite` is the product, so the two halves are the two halves of the
   name rather than a product with a qualifier beside it, and they share the heading face because
   they are one name. The colouring is what says so without a second element: the house in the
   plane's own neutral — the frame's starlight in the header, the ink on a light panel — and the
   product in the accent, so the mark reads as a company and the thing it makes rather than as two
   words that happen to be adjacent.

   The HOUSE IS THE NEUTRAL ONE, and that is the reverse of how the two were coloured when the
   product was called Learning Path. A company's name is written in ink, and the accent belongs to
   the product — but the arrangement also gives the "o" somewhere to sit: an orange letter inside a
   word of ink is a signature, and the same letter inside a word of azure would have been one accent
   competing with another.

   IT REPLACED A BILINGUAL SWAP. The mark used to be the platform's hən̓q̓əmin̓əm̓ name with its
   English translation one hover away, both stacked in one grid cell so that the hover could not
   change the heading's width. That mechanism is gone with the name it existed for: Govnix Satellite
   has one name in one language, and a hover that revealed the same words in the same language would
   be a control that promises a second name and has none. What is left is the two-colour split, which
   was the part of the mark that carried meaning in both languages.

   The colour is set on the WORDS and not on the heading, so that the same markup is one mark here
   and one mark in the footer, where the two halves sit on a light panel and therefore take the other
   plane's pair. `.app-name` still carries the accent as a fallback, so a name left unsplit degrades
   to a single-colour wordmark rather than to nothing. */
/* THE PAIR TAKES ITS PLANE FROM ITS CONTAINER. The base rule is the LIGHT plane, because that is
   where the mark stands in the footer and in the specimen sheet's drawer head; the frame overrides
   it below, because a starlight on white and an ink on navy are the two ways to make half a
   wordmark disappear.

   THE HOUSE IS NEUTRAL AND THE PRODUCT IS THE ACCENT, which is the reverse of how the two words
   were coloured before. `Govnix` is the name of a company and companies are named in ink; `Satellite`
   is the product, and the product is what the accent is for. It also gives the "o" somewhere to sit:
   an orange letter inside a word of ink is a mark, and the same letter inside a word of azure would
   have been one accent competing with another.

   `.app-name` still carries the accent as a fallback, so a name left unsplit degrades to a
   single-colour wordmark rather than to nothing. */
.app-name-first { color: var(--brand-ink); }
.app-name-second { color: var(--brand-azure); }

/* THE HOUSE'S NAME IS ONE WORD, AND IT MAY NOT BREAK IN THE MIDDLE OF ITSELF.

   The disc is an INLINE-BLOCK, and an atomic inline is a soft wrap opportunity on both of its
   sides. So `Govnix`, written as `G`, then the disc, then `vnix`, has two places to break that no
   reader would put in a wordmark — and nothing showed it while every host had room. The
   application's frame only draws the name from 1100px up, and the public header had a 552px row
   to draw 50px of type in.

   Then the public header's row went tight and the name, a flex item, shrank to its own
   MIN-CONTENT — which with a break opportunity inside the word is no longer the word, it is the
   longest FRAGMENT of it. Measured at a 600px window: the name came out **37px wide and 48px
   tall**, which is two lines, "Go" over "vnix", for a word that is 50px on one line.

   `nowrap` is on the WORD and not on the whole mark: the application's wordmark is "Govnix
   Satellite", and the space between the two words is a real wrap opportunity that a narrow frame
   should keep. It is also what puts the flex item's automatic minimum size back — with no break
   opportunity inside it, an item will not shrink below its own word, so a tight row gives up space
   AROUND the name rather than THROUGH it. */
.app-name-first { white-space: nowrap; }

/* The frame's own pair: the house is the frame's starlight rather than the page's ink, and the
   product takes the accent's bright step. See the token block for why one azure cannot serve both. */
.app-header .app-name-first,
.header-bar .app-name-first { color: var(--brand-on-dark); }
.app-header .app-name-second,
.header-bar .app-name-second { color: var(--brand-azure-bright); }

/* THE "o" OF GOVNIX IS A SOLID ORANGE DISC, and the disc is the whole of the mark.

   It is the only letterform in the kit that is drawn rather than set, and it is the only place the
   orange appears.

   The mark was drawn five times before it was settled: as a spiral galaxy, as a wireframe globe, as
   a keyhole, as the letter itself in green, and at last as the same letter FILLED and in the orange
   this kit was drawn from. The three drawings were legible and all three were wrong for the same
   reason — the wordmark is two words in two colours, and a picture dropped into the middle of one of
   them is a third thing competing with both. The letter in green was right in kind and too quiet: an
   "o" is a ring, and a ring of green beside a word of ink reads as a word that has been coloured in
   rather than as a mark. FILLED, the same letter becomes a shape — the roundest thing the alphabet
   has — and a solid disc is the one form that reads instantly at 11px and at 88px alike.

   IT IS STILL THE LETTER, NOT A PICTURE OF ONE. The "o" is in the markup and inside a span, so the
   word is still a word: it selects, it copies, it is announced, it reflows, and it needs no label
   to be read aloud. The span paints the disc and the glyph is made transparent, because a glyph
   drawn in the same colour as the disc behind it would spill its own advance — 0.576em against the
   disc's 0.51em — over the "v" beside it.

   The box is the face's own x-height and the "o"'s own advance, both measured from Nunito rather
   than guessed, and the small side margins are the "o"'s own side bearings: a disc has none of its
   own, and without them the mark would crowd its neighbours in a word that has not changed.

   `overflow: hidden` IS WHAT PUTS IT ON THE BASELINE, and it is the one declaration here that is
   not doing what it appears to do. An inline-block's baseline is normally the baseline of the last
   line box inside it — so this span, which still contains the letter, took its baseline from that
   letter's own descent and the disc floated about a fifth of an em above the words beside it. CSS
   2.1 gives an inline-block whose `overflow` is anything but `visible` the BOTTOM MARGIN EDGE as
   its baseline instead, which is the disc's own foot. So the disc now sits on the same baseline as
   the "v", the "n", the "i" and the "x", and it does so by the rule rather than by a magic offset
   that would have to be retuned the moment the face or the size changed. It also clips the glyph,
   which is the second thing it is for.

   ONE ORANGE, BOTH PLANES, unlike the azure beside it. The azure needs a step per plane because it
   carries WORDS and a word has to clear 4.5:1; this carries a SHAPE, and a shape has to clear 3:1 —
   so one value clears the threshold that applies on both. See the token block. */
.brand-o {
  display: inline-block;
  width: 0.51em;
  height: 0.51em;
  margin: 0 0.035em;
  border-radius: 50%;
  background: var(--brand-orange);
  color: transparent;
  overflow: hidden;
}

/* The frame path takes the space between the app name and the user block, and gives it up
   first when the header is tight — the selectors shrink and truncate rather than pushing the
   user block off the edge. */
.app-header .frame-path,
.header-bar .frame-path { flex: 1 1 auto; }

.app-header .header-right,
.header-bar .header-right {
  margin-left: auto;
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  font-size: var(--text-micro);
  color: var(--brand-on-dark-muted);
}

/* THE HEADER'S CONTROLS STAND ON THE FRAME, and every one of them is the same substitution: the
   raised fill and the hairline that a control uses on a light panel are a light panel's answer, and
   on the frame they would be a white box in a dark bar. `--brand-space-raised` is the frame's own
   raised step, and `--shadow-control-dark` is the frame's own shadow — the light plane's shadow is
   tinted with the ink and is invisible against the ink's own colour.

   The account disc and the frame selector are named here rather than in their own blocks because
   neither of them is only ever on the frame: `.avatar` is also the panel's picture and
   `.frame-select` is also the drawer's, and only the header's instances are standing on the dark. */
.app-header .avatar,
.header-bar .avatar {
  background: var(--brand-space-raised);
  border-color: var(--brand-space-edge);
  color: var(--brand-on-dark);
}

.app-header .frame-select,
.header-bar .frame-select {
  background: var(--brand-space-raised);
  border-color: var(--brand-space-edge);
  color: var(--brand-on-dark);
  box-shadow: var(--shadow-control-dark);
}

.app-header .frame-select:hover,
.header-bar .frame-select:hover { box-shadow: var(--shadow-control-dark-hover); }
.app-header .frame-select:active,
.header-bar .frame-select:active { box-shadow: var(--shadow-control-dark-active); }
.app-header .frame-select .select-meta,
.header-bar .frame-select .select-meta { color: var(--brand-on-dark-muted); }

/* The frame path's toggle, on the same raised step as the selector beside it. */
.app-header .icon-btn,
.header-bar .icon-btn {
  background: var(--brand-space-raised);
  border-color: var(--brand-space-edge);
  color: var(--brand-on-dark);
  box-shadow: var(--shadow-control-dark);
}

.app-header .icon-btn:hover,
.header-bar .icon-btn:hover { box-shadow: var(--shadow-control-dark-hover); }
.app-header .icon-btn:active,
.header-bar .icon-btn:active { box-shadow: var(--shadow-control-dark-active); }

/* The frame's own chevron and grid, redrawn for the dark ground — the same reason the table's
   sort arrows are redrawn for theirs. The light-plane mark is `#5A5A5A` baked into the data URI,
   and a baked colour is the one thing a `currentColor` mask cannot rescue. */
.app-header .frame-select::after,
.header-bar .frame-select::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%239FB0D8' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

.app-header .frame-select--grid::after,
.header-bar .frame-select--grid::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect x='1.4' y='1.4' width='5.6' height='5.6' rx='1.5' fill='%239FB0D8'/%3E%3Crect x='9' y='1.4' width='5.6' height='5.6' rx='1.5' fill='%239FB0D8'/%3E%3Crect x='1.4' y='9' width='5.6' height='5.6' rx='1.5' fill='%239FB0D8'/%3E%3Crect x='9' y='9' width='5.6' height='5.6' rx='1.5' fill='%239FB0D8'/%3E%3C/svg%3E");
}

/* The breadcrumb mark between the two halves of the frame path, in the frame's faint. */
.app-header .frame-sep,
.header-bar .frame-sep { border-color: var(--brand-on-dark-faint); }

.avatar {
  width: 30px; height: 30px; flex: none;
  border-radius: var(--radius-pill);
  background: var(--brand-surface-alt);
  border: 1px solid var(--brand-border);
  display: flex; align-items: center; justify-content: center;
  font-size: var(--text-micro); font-weight: 500; color: var(--brand-ink);
}

/* The same picture, at the size a panel can afford. It sits AFTER `.avatar` because both are one
   class deep and only source order separates them. */
.avatar-lg {
  width: 48px; height: 48px;
  font-size: var(--text-small);
}

/* THE PICTURE IN THE CIRCLE — and the initials that stand in when there is no picture.
   ---------------------------------------------------------------------------
   A person is drawn with the same die-cut sticker the surfaces wear, INSET inside the disc. The
   inset is the point: a sticker bled to the edge of a 30px circle loses the die-cut edge that makes
   it a sticker, and at that size the edge is most of what identifies it. The disc's own ground then
   reads as a ring around the drawing — the light plane's haze in a table, the header's raised navy
   in the frame, which is the dress the account button has always worn.

   THE ONE MARK TOO SMALL FOR THE RING IS THE CRUMB that opens the list of people, and it wears the
   sticker bare for that reason: see `.app-header .frame-path > .frame-select > .avatar`.

   It is ONE FILE. The drawing inside the circle is the same file that person's own page draws large
   as its hero art, so a reader meets ONE drawing of a person in the list, in the header, in the
   dialog that switches between them and on the page itself — the rule the kit already states for a
   surface and the tiles that point at it.

   AND THE DISC IS NOT ONLY FOR PEOPLE. An organization is an entity with a drawing of its own, and
   its picker's rows are the same rows this dialog draws, so they wear the same disc and the same
   file the header's crumb shows. The class name is historical, the way `.running` is: what the disc
   means is "the picture of the ONE THING this row is about".

   The initials stay, as the FALLBACK. A person with no drawing on file gets their initials rather
   than an empty circle, on the same reasoning `.thumb-face` gives for a roster imported from records
   that carry no picture: a placeholder too faint to read has failed at the one job it has, which is
   to say who this is. */
.avatar > img { width: 84%; height: 84%; object-fit: contain; }

/* The name and the picture are ONE control, not two labels beside a picture.

   Two spans would be two things to click, neither of them announced as a link, and no way for a
   pointer to tell that the pair is a single destination. Wrapped, the whole cluster is one target
   with one focus ring and one name.

   The padding is a hover TARGET rather than spacing, so the negative margin lets the target extend
   past the cluster without pushing the picture off the header's own right edge.

   **The two halves carry their own affordance, and neither of them is a pill.** The name is
   underlined, the way a breadcrumb link and a row link are, and the disc is raised with the same
   `--shadow-control` that a button, a select and a chip wear.

   **There is no hover fill, and there used to be.** The cluster gained `surface/alt` under the
   pointer, which drew a stadium around the name and the disc — and that oval read as the control
   TURNING INTO A BUTTON at the moment it was pointed at, beside two affordances that are already
   saying what it is. It was there for grouping, and it is the weakest of the three ways to say the
   pair is one thing: the underline and the disc are the other two, they are visible at rest, and
   they do not arrive after the reader has already decided. Measured before the change, the hover
   state was the only thing that drew a box: at rest the cluster's background was
   `rgba(0, 0, 0, 0)` and on hover it was `rgb(242, 245, 247)`.

   The disc keeps its own hover answer — the shadow deepens from `--shadow-control` to
   `--shadow-control-hover` — because the disc is the part a reader recognises the control by, and
   the one part that should look pressable.

   The `border-radius` stays although nothing paints a box any more: it is what shapes the focus
   ring, which the UA draws around the cluster for a keyboard reader. */
.account-btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-2);
  margin: calc(var(--space-1) * -1) calc(var(--space-2) * -1);
  border-radius: var(--radius-pill);
  cursor: pointer;
  color: inherit;
}

.account-btn .user-name { text-decoration: underline; }

/* THE READER'S OWN NAME IS NOT A QUALIFIER.

   `.header-right` carries `--brand-on-dark-muted`, and for most of what lives in that corner that
   is right: a crumb's count and the words beside a name are CONTEXT, and the quiet step is what
   makes them recede — it is the one part of the path that gets spent first when the header is tight.
   The reader's own name is not context. It is the identity of the session, the one word in that
   corner that answers "who am I signed in as", and it was inheriting the quiet step along with the
   qualifiers.

   Measured, that was `#9fb0d8` at 12px on `--brand-space`: **7.70:1**. That passes AA, and it is
   still the wrong answer — a muted step does not fail a threshold, it RECEDES by design, and the
   operator's report was that the corner is not legible. So this is not a contrast repair; it is the
   ink of the plane.

   Starlight, `--brand-on-dark`, which is what every other word standing on the frame wears.
   Measured after: **14.65:1**. The underline and the disc are untouched — the name was already a
   link and still is, and this is its colour only. */
.app-header .account-btn .user-name,
.header-bar .account-btn .user-name { color: var(--brand-on-dark); }

/* NO `.account-btn:hover { background: … }`. See the comment above: the hover fill drew a stadium
   around the cluster and made the control read as a button at the moment it was pointed at. */

/* The disc keeps its own colour on hover now that there is no fill behind it to dissolve into.
   It used to flip to `surface` so it would not vanish into the `surface/alt` pill; with the pill
   gone, changing it would be a change nobody asked for. Only the shadow answers the pointer.

   The disc is scoped to the button. The account panel's larger picture is read rather than pressed,
   and a shadow there would promise a click that does nothing.

   THE DISC WAS TRIED WITHOUT ITS RING, and the ring came back. The picture was worn bare — no
   ground, no hairline, the drawing edge to edge and about a fifth larger — on the argument that a
   frame spends width a small mark cannot spare. That is the right trade for the CRUMB and the
   wrong one here, and the difference is what the ring is doing. The crumb's drawing stands inside
   a raised navy PILL that is already the control, so the ring was only ever a second frame around
   a picture, and all three crumbs are the same small thing in a row. This picture stands on
   NOTHING: the cluster has no fill — the hover fill was removed above, deliberately — so the
   disc's own ground IS the button, and it is the only thing in the header's right corner that says
   "pressable" without the reader stopping to read the name. Bare, the drawing floated on the frame
   at the same weight as the words beside it, and the corner stopped looking like a control.

   ONE THING MEASURED WHILE IT WAS OFF, and deliberately left alone: this disc wears
   `--shadow-control`, the LIGHT plane's ink-tinted shadow, while standing on `--brand-space`.
   Every other control in the frame wears `--shadow-control-dark` — measured, the selector beside
   it is `rgba(0, 0, 0, 0.55) 2px 2px 6px` against this disc's `rgba(11, 17, 40, 0.22) 2px 2px
   5px`, and 22% ink on a near-black ground draws almost nothing, so the raise this comment
   promises is barely there. Changing it would change how the header looks at rest, which is the
   operator's call rather than a repair to make while reverting something else. */
.account-btn .avatar { box-shadow: var(--shadow-control); }
.account-btn:hover .avatar { box-shadow: var(--shadow-control-hover); }
.account-btn:active .avatar { box-shadow: var(--shadow-control-active); }

.app-body { display: flex; flex: 1; min-height: 0; }

.app-nav {
  flex: none;
  background: var(--brand-space-deep);
  color: var(--brand-on-dark);
}

.app-main {
  /* The content region's own padding, named so that a band can bleed to its edges by negating
     it. The phone band changes the padding, and a hard-coded negative margin would then bleed
     the wrong distance — so the bleed is derived from the padding rather than guessed. */
  --main-pad: var(--space-5);
  flex: 1; min-width: 0; padding: var(--main-pad);
  display: flex; flex-direction: column;
  /* A query container, so that a row inside can ask how wide IT is. The filter row needs that:
     whether its two halves fit side by side depends on the content width, which changes with the
     padding, the band and the navigation rail — three things a viewport media query cannot see.

     AND A QUERY CONTAINER IS ALSO A CONTAINING BLOCK FOR ITS FIXED CHILDREN, which is why
     `.dialog-scope` is a SIBLING of this element and never a descendant — see `.app-body` above.
     `container-type: inline-size` implies `contain: layout`, `contain: layout` makes the element a
     containing block for `position: fixed` descendants, and every dialog is a fixed backdrop with
     `inset: 0`. Put one inside `main` and the overlay covers the CONTENT COLUMN instead of the
     window: the header and the rail stay undimmed and pressable behind a modal dialog, and the
     panel is clipped by whatever `main` clips. Measured in the application, which had exactly that
     for as long as the port existed — the page reported no overflow throughout, because a fixed
     element resolves to a box, just the wrong one. `check_dialog_scope_outside_main` holds the
     screens to the structure that makes it work. */
  container-type: inline-size;
}

/* ---------------------------------------------------------------------------
   The footer — the signature, and nothing else
   ---------------------------------------------------------------------------
   One line at the foot of every page: the house's name, and a link to its site.
   It used to be a four-line lockup and a paragraph of somebody else's prose, and
   all of it except the name described something that is no longer true of this
   product. A footer is the one part of a page that makes a claim about WHO IS
   SPEAKING, so a line that has stopped being true is worse here than anywhere
   else.
   ------------------------------------------------------------------------ */

.app-footer {
  margin-top: auto;
  padding-top: var(--space-4);
  border-top: 1px solid var(--brand-border);
  /* The signature is one line and it is centred, and both of those are facts about the FOOTER
     rather than about the line: the wrapper element that existed to stack three lines and centre
     them went with the two lines it was stacking. */
  text-align: center;
}

/* THE SIGNATURE — the house's name, and a link.

   `Govnix` and not the product's name: the product is already at the top of every screen, and a
   footer that repeated it would be saying one thing twice in the two places furthest apart. It is
   a LINK, because a signature that is not a destination is a statement, and a reader who wants to
   know who made the thing has nowhere to go.

   `--brand-azure` here and the frame's own bright step in the header: the same pair the wordmark
   wears, for the same reason — the footer is on the light plane and the header is not.

   IT IS ONE WORD AND IT DOES NOT BREAK, for the reason `.app-name-first` gives: the signature is
   the same three pieces — a `G`, the disc, `vnix` — and the disc is an inline-block, which is a
   soft wrap opportunity. It is stated here as well because this is the copy of the mark that
   stands ALONE, with no `.app-name-first` around it: the public header's name and both footers. */
.footer-signature-name {
  font-family: var(--font-heading);
  font-size: var(--text-body);
  color: var(--brand-ink);
  text-decoration: none;
  white-space: nowrap;
}

/* THE SECOND HALF OF THE PUBLIC SITE'S MARK, in the ink — the same two-tone the application's
   wordmark wears, and for the same reason: the mark is ONE name, and colouring the two words
   differently says so without a second element to keep in step.

   It is a SPAN INSIDE THE ANCHOR rather than a second anchor, because `Govnix.Satellite` is one
   destination: two links side by side would be two things to click for one place, and the second
   would not be announced as part of the first.

   It is no longer used by the footer, which now carries the name alone — the site's header is the
   only place the two-word mark is still drawn on a light panel. */
.footer-signature-software { color: var(--brand-azure); }

.footer-signature-name:hover { text-decoration: underline; }

/* ---------------------------------------------------------------------------
   Navigation — the same component in THREE arrangements.
   The items and their order are identical in all three; only the container moves.

     phone     < 600px     a bar across the bottom, always there
     middle    600-1099px  a drawer, off the left edge until asked for
     landscape >= 1100px   a rail down the left, always there

   The middle band used to carry the bottom bar as well, and that was wrong. A
   bar across the bottom is a phone arrangement: it costs a row of height on a
   screen that is short, and it puts the destinations at the far end from the
   content on a screen that is wide. In between there is neither the room for a
   permanent rail — 224px out of 810 leaves the content cramped — nor a reason
   for a bar. So the navigation steps out of the way until it is asked for.
   ------------------------------------------------------------------------ */

.app-nav .nav-list { display: flex; flex-direction: row; margin: 0; padding: 0; list-style: none; }

/* The flex children of a `<ul>` are its `<li>` elements, not the anchors inside them, so the
   sizing has to be said once for the wrapper and once for the control. Without the wrapper the
   anchors keep their content width and a four-item bar occupies only as much of the screen as its
   labels happen to need — and because the bar's BACKGROUND still spans the width, it reads as a
   bar that fills the screen while the items sit bunched at the left edge. */
.app-nav .nav-list > li { display: flex; flex: 1; min-width: 0; }

.app-nav .nav-item {
  position: relative;
  flex: 1;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-1);
  padding: var(--space-3) var(--space-2);
  font-size: var(--text-micro);
  color: var(--brand-on-dark);
  text-decoration: none;
  text-align: center;
}

/* The icon box, and the mapping from a name to a drawing.

   Keyed on the ATTRIBUTE rather than on a container, so an icon is defined once however many
   places draw it — the navigation, the header's settings button, the marking toolbar, and
   whatever wants one next. `data-icon` therefore means one thing everywhere: this element is
   labelled by an icon.

   An item with no `data-icon` gets no box at all, rather than an empty one — a reserved 20px gap
   with nothing in it would indent the label against its neighbours for no visible reason.

   The drawing is a MASK, not a picture, so the icon is painted in `currentColor` and follows the
   control it sits in. That is what makes the marking tools possible at all: a pen is drawn on the
   light tool palette, on the dark marking bar, and again inverted when it is the active tool. A
   picture would need a copy of the drawing per ground and per state; a mask needs one, and the
   colour comes from the control — which already knows it, because it is the same colour as the
   label beside it. The token still names a colour in its SVG, and it is ignored: a mask reads
   the image's ALPHA, so only the shape matters. */
[data-icon]::before {
  content: "";
  flex: none;
  width: 20px; height: 20px;
  background-color: currentColor;
  mask-repeat: no-repeat;
  mask-position: center;
  mask-size: 20px 20px;
}

[data-icon="assignments"]::before { mask-image: var(--icon-assignments); }

/* THE SUBJECT MARK — the class's subject as a drawing, and the one icon in the kit that is an
   ILLUSTRATION rather than a label.

   Every other icon in this kit marks a CONTROL or a destination: a cog opens settings, a calendar
   opens a picker, and the drawing is the label of a thing you press. This one is not pressed and
   labels nothing — it says what the class is ABOUT, on the page that is the class's own, and the
   subject is the only thing about a class that a picture can carry. It is therefore drawn larger
   than any other icon in the kit and is the only one whose size is not the icon box's 20px.

   It is a MASK like the rest, so one drawing serves the hero of a class page and a mark beside a
   class name without a copy per size, and it takes `currentColor` — which on the hero is the INK,
   not the brand azure. The hero's own `h1` is the one piece of azure text on the page, and a
   subject mark in the same azure would be a second one competing with the name of the surface it
   sits under. The mark is drawn, and the colour is left to the thing it is drawn in.

   A class that names no subject, or one the vocabulary does not have, gets `other`: a subject
   vocabulary that is closed is not the same thing as a page that refuses to draw. */
.row-mark {
  /* THE CLASS'S SUBJECT, DRAWN — and drawn by the same tool as every hero illustration.
     ---------------------------------------------------------------------------
     It was twelve line-art masks in this sheet: a `--subject-*` token per subject, a `::before`
     painting the filled body and a `::after` painting the linework over it. The operator asked
     three times for the department illustrations to stop reading as a different family from the
     hero stickers, and the answer is not a better mask — it is the same generator, the same
     palette and the same die-cut cut-out the thirteen heroes wear. So the marks are STICKERS now,
     and this is the whole component: an `<img>` at the mark's size, drawing the file for its
     subject. The tokens and the two pseudo-elements went with the drawings; a copy of a drawing
     that no longer exists is exactly the drift this kit keeps having to remove.

     The file is the shared 1024x1024 sticker canvas (`normalize_stickers.py`), so the mark is
     square wherever it is drawn and `contain` is only what keeps a file that is not square from
     stretching. Size comes from `--size-row-mark`, which the hero overrides with its own
     column — the mark is 20px in a table cell, 40px in a class row and 208px in a hero. */
  display: block;
  flex: none;
  width: var(--size-row-mark);
  height: var(--size-row-mark);
  object-fit: contain;
}

.row-mark--lg { --size-row-mark: 96px; }

[data-icon="dashboard"]::before { mask-image: var(--icon-dashboard); }
[data-icon="roster"]::before { mask-image: var(--icon-roster); }
[data-icon="marks"]::before { mask-image: var(--icon-marks); }
[data-icon="announcements"]::before { mask-image: var(--icon-announcements); }
[data-icon="content"]::before { mask-image: var(--icon-content); }
[data-icon="quizzes"]::before { mask-image: var(--icon-quizzes); }
[data-icon="archive"]::before { mask-image: var(--icon-archive); }
[data-icon="settings"]::before { mask-image: var(--icon-settings); }
/* The class calendar's button. The glyph is the date field's own picker drawing rather than a
   second calendar: one shape means one thing, and a control that opens a calendar should wear
   the mark a reader has already learned from every date field in the kit. The token is painted
   black because it was drawn for the picker button, but a mask only reads its ALPHA, so the
   button's `currentColor` is what shows. */
[data-icon="calendar"]::before { mask-image: var(--icon-calendar); }
[data-icon="home"]::before { mask-image: var(--icon-home); }
/* The navigation trigger's own mark — three bars. See `--icon-menu`. */
[data-icon="menu"]::before { mask-image: var(--icon-menu); }

/* The trigger's mark in the Satellite shell — the application's own disc, which is the mark the
   wordmark wears where the wordmark is drawn. See `--icon-brand`. */
[data-icon="brand"]::before { mask-image: var(--icon-brand); }
[data-icon="plus"]::before { mask-image: var(--icon-plus); }

[data-icon="pen"]::before { mask-image: var(--icon-pen); }
[data-icon="highlighter"]::before { mask-image: var(--icon-highlighter); }
[data-icon="eraser"]::before { mask-image: var(--icon-eraser); }
[data-icon="note"]::before { mask-image: var(--icon-note); }
[data-icon="pin"]::before { mask-image: var(--icon-pin); }
[data-icon="undo"]::before { mask-image: var(--icon-undo); }
[data-icon="ink-thin"]::before { mask-image: var(--icon-ink-thin); }
[data-icon="ink-medium"]::before { mask-image: var(--icon-ink-medium); }
[data-icon="ink-thick"]::before { mask-image: var(--icon-ink-thick); }
[data-icon="zoom-out"]::before { mask-image: var(--icon-zoom-out); }
[data-icon="zoom-in"]::before { mask-image: var(--icon-zoom-in); }
[data-icon="finish"]::before { mask-image: var(--icon-finish); }
[data-icon="previous"]::before { mask-image: var(--icon-previous); }
[data-icon="next"]::before { mask-image: var(--icon-next); }
[data-icon="fit-width"]::before { mask-image: var(--icon-fit-width); }
[data-icon="play"]::before { mask-image: var(--icon-play); }
[data-icon="pages"]::before { mask-image: var(--icon-pages); }
[data-icon="grid"]::before { mask-image: var(--icon-grid); }
[data-icon="list"]::before { mask-image: var(--icon-list); }
[data-icon="grip"]::before { mask-image: var(--icon-grip); }
[data-icon="close"]::before { mask-image: var(--icon-close); }
[data-icon="disclosure"]::before { mask-image: var(--icon-disclosure); }
[data-icon="redo"]::before { mask-image: var(--icon-redo); }
[data-icon="bold"]::before { mask-image: var(--icon-bold); }
[data-icon="italic"]::before { mask-image: var(--icon-italic); }
[data-icon="underline"]::before { mask-image: var(--icon-underline); }
[data-icon="list-bulleted"]::before { mask-image: var(--icon-list-bulleted); }
[data-icon="list-numbered"]::before { mask-image: var(--icon-list-numbered); }
[data-icon="link"]::before { mask-image: var(--icon-link); }

/* The selected item is marked by the azure bar ALONE. Its label stays the same
   starlight as every other item, because azure text is reserved for the app name. */
.app-nav .nav-item[aria-current="page"] { font-weight: 600; }

.app-nav .nav-item[aria-current="page"]::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 3px;
  background: var(--brand-azure-bright);
}

/* Hover: the selected mark reduced to two dots, so the item you are about to open is shown the
   same indicator in its provisional form. See `design-system.css` for why the mark is broken into
   dots rather than dimmed. The app nav is ONE component in two arrangements, so the dots change
   axis with the bar: side by side along the bottom, stacked down the left. */
.app-nav .nav-item:hover:not([aria-current="page"])::after {
  content: "";
  position: absolute;
  width: 4px; height: 4px;
  border-radius: 50%;
  background: var(--brand-azure-bright);
  bottom: 0;
  left: calc(50% - 7px);
  box-shadow: 10px 0 0 var(--brand-azure-bright);
}

.app-nav .nav-item[aria-disabled="true"] { color: var(--brand-on-dark-muted); }

/* From 600px up the navigation is VERTICAL, whether it ends up as a rail or as a drawer, so
   the items are styled once. Only the container differs. */
@media (min-width: 600px) {
  .app-nav .nav-list { flex-direction: column; padding: var(--space-3); gap: var(--space-1); }
  .app-nav .nav-list > li { flex: none; }
  .app-nav .nav-item {
    /* The anchor fills its `<li>`, so the whole band is the target.

       `flex: none` here left the anchor at its CONTENT width — measured 113px for "Content" and
       166px for "Announcements" inside a 200px band — and the rest of the band was dead: hovering
       the row lit nothing and clicking it did nothing. A menu whose items can only be hit where
       the words happen to end is a menu with invisible holes in it.

       The `<li>` is a ROW flex container, so this property sizes the anchor horizontally; it has
       nothing to do with the item's height, which comes from the `<li>` stretching across the
       column. The horizontal bar had this right all along — its `.nav-item` is `flex: 1` — which
       is exactly why the fault survived a review of the phone band and showed up only in the
       rail. */
    flex: 1;
    min-width: 0;
    flex-direction: row;
    justify-content: flex-start;
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    font-size: var(--text-small);
    text-align: left;
  }
  /* The bar moves from beneath the item to its left, and the label bolds. */
  .app-nav .nav-item[aria-current="page"]::after {
    top: 50%; bottom: auto; left: 0; right: auto;
    width: 4px; height: 70%;
    transform: translateY(-50%);
  }

  /* The hover dots stack where the bar turns vertical: a colon down the left edge. */
  .app-nav .nav-item:hover:not([aria-current="page"])::after {
    bottom: auto; left: 0;
    top: calc(50% - 7px);
    box-shadow: 0 10px 0 var(--brand-azure-bright);
  }
}

@media (min-width: 1100px) {
  /* THE COLUMN IS PAINTED BY THE SHELL, NOT BY THE RAIL, and that is the whole of this rule.

     The rail is `position: sticky; top: 0`, so it spends most of a long page pinned to the top of
     the window while the content scrolls under it. Its own box therefore cannot be trusted to
     cover the left edge: it is a box in a scrolling document, and every height it could be given
     is wrong for one of its two jobs.

       * `height: 100vh` — right on screen, wrong in the document. The rail begins BELOW the
         header, so a full-window box makes the page `100vh + 64px` tall: 64px of scroll that
         nothing is in, and — because a fragment scrolls the page to reach its target — a header
         that goes off the top whenever a dialog or a record editor is opened by fragment.
       * `height: calc(100vh - var(--header-h))` — right in the document, wrong on screen. Once
         the rail is pinned to `top: 0` it stops a header's height short of the bottom of the
         window, and the page's ground shows down the left edge under the menu.

     So the rail is given the height that is right for the document, and the ground is taken off
     it and given to `.app-body` — the box that actually spans the full height of the document,
     from under the header to the last line of the footer. `.app-main` is then painted back over
     the rest of it, so the two-tone column is drawn by two adjacent opaque boxes rather than by a
     gradient or a pseudo-element that could be layered wrong. The rail keeps its own nightfall
     background and is seamless against the column it sits in.

     This is why the fix is structural rather than a negative margin: a negative margin does not
     reduce the scrollable overflow of a positioned box, so `100vh` on a sticky rail adds a
     header's worth of scroll no matter what is done to its margins.

     `verify_shell.py` measures all of it: no phantom scroll, the left edge nightfall at the bottom
     of the window at rest, and the left edge still nightfall at the bottom of the window once the
     page has been scrolled to its end. */
  .app-body { background: var(--brand-space-deep); }
  .app-main { background: var(--brand-surface); }

  .app-nav {
    width: 224px; position: sticky; top: 0; align-self: flex-start;
    height: calc(100vh - var(--header-h)); overflow-y: auto;
  }
}

/* Below 1100px the navigation is a bottom tab bar, and a tab bar is FIXED to the viewport:
   it stays put while the page scrolls beneath it. In normal flow it would sit at the end of
   the document instead, which is not what a phone does and not what the content padding
   below assumes. The main region is padded to clear it, so nothing is ever hidden behind it. */
@media (max-width: 599px) {
  .app-nav {
    position: fixed;
    left: 0; right: 0; bottom: 0;
    z-index: 10;
    border-top: 1px solid var(--brand-border);
  }
  .app-main { --main-pad: var(--space-4); padding: var(--main-pad);
              padding-bottom: calc(60px + var(--space-5)); }
}

/* The drawer, for the band in between. Closed is the resting state: it sits off the left edge
   and is reached from the header. Nothing is padded to clear it, because when it is closed
   there is nothing to clear — and when it is open it covers the content rather than pushing
   it, which is what makes it a drawer rather than a column. */
@media (min-width: 600px) and (max-width: 1099px) {
  .app-nav {
    position: fixed;
    top: 0; left: 0; bottom: 0;
    width: 260px;
    z-index: 31;
    transform: translateX(-100%);
    transition: transform 160ms ease-out;
    overflow-y: auto;
  }

  /* Opened by the fragment, the same mechanism the dialogs use: the trigger is a real link, so
     it is keyboard reachable, and the browser's Back button closes the drawer. */
  .nav-anchor:target ~ .app-body .app-nav { transform: translateX(0); }

  /* Clicking the dimmed page closes it. The veil sits UNDER the drawer, or a click on a
     destination would close the drawer instead of following the link. */
  .nav-anchor:target ~ .app-body .nav-veil { display: block; }
  .nav-veil { position: fixed; inset: 0; z-index: 30; }

  /* The trigger does NOT change mark while the drawer is open, and the reason is that it cannot
     close the drawer. It is a link to `#nav`, which is the fragment that OPENS the drawer, so
     pressing it while the drawer is open re-navigates to the fragment already set and changes
     nothing — measured, the navigation sits at `matrix(1, 0, 0, 1, -260, 0)` before the press and
     at the same matrix after it. The drawer is closed by the veil, by any destination, or by the
     Back button, all of which are real navigations.

     It used to become a cross, on the reasoning that one control should both open and close and
     the reader should see which state they are in. The second half is true and the first half was
     not: a close mark is a promise that pressing it closes, and this control cannot keep it. A
     control that looks like it does something and does not is worse than one that stays quiet —
     the reader presses it, nothing happens, and they stop trusting the header. */
}

@media (prefers-reduced-motion: reduce) {
  .app-nav { transition: none; }
}

/* The navigation trigger. It reveals the drawer, and every band below the rail has one — the
   Satellite navigation is a drawer from a phone up to the desktop breakpoint, so on those bands
   this control is the only way into the ten destinations.

   IT WEARS THE APPLICATION'S OWN DISC, `--icon-brand`, AND IT STANDS IN THE WORDMARK'S PLACE. The
   wordmark is not drawn on these bands at all (see the rule in the Satellite block below), so the
   header's first control IS its brand: one control doing both jobs, where there used to be a
   wordmark that did nothing and a hamburger beside it. Three bars said "a menu opens here", which
   is the one thing the reader learns by pressing it and never needs again; the disc says whose
   header this is, which is what the wordmark was for and what the drawer cannot say while it is
   closed. The operator made the call — the note inside `--icon-menu` records the argument that was
   made for the bars and why the room ran out.

   The DRESS is `.icon-btn`'s, and it carries that class for it: it stands in the header beside
   the settings cog and the class selector, and a control in that row that is drawn differently
   from its neighbours reads as a different kind of thing. Only the BEHAVIOUR is here. The
   visibility rules are therefore written with both classes, because `.icon-btn` sets a display of
   its own and a single-class rule here would lose to it on source order — the trigger would show
   at every width, including the two where it does nothing. */
.icon-btn.nav-toggle { display: none; }

/* The disc is the one mark in this set NOT painted in the ink. `[data-icon]::before` paints
   `currentColor`, and the header's `.icon-btn` has already set that to the frame's on-dark ink —
   correct for a cog, wrong for the brand, which is `--brand-orange` in every plane and on every
   ground. Naming the colour one class deeper than the rule that set it is the whole of the fix. */
.icon-btn.nav-toggle::before { background-color: var(--brand-orange); }

@media (min-width: 600px) and (max-width: 1099px) {
  .icon-btn.nav-toggle { display: flex; }
}

/* The chevron that descends from the trigger to the class selector, and it is hidden and shown by
   the SAME band as the trigger. It says the class selector is inside home, so with no home on
   screen there is nothing for it to descend from — and an orphan chevron at the head of the path
   reads as a level that was left out rather than as a level that is not shown. The two are one
   control's worth of markup and they move together. */
.nav-sep { display: none; }

@media (min-width: 600px) and (max-width: 1099px) {
  .nav-sep { display: block; }
}

/* The application name is the second way in. It is a link at every width, and where the drawer
   does not exist the fragment resolves to the top of the page and nothing moves — the `:target`
   rules above are scoped to the band that has a drawer. Making it a link only in that band is not
   expressible in markup without scripting, and a second route to the navigation is worth more
   than the fragment it costs. */
.app-name-link { color: inherit; text-decoration: none; }

.nav-veil { display: none; background: var(--veil); }

/* ---------------------------------------------------------------------------
   Page furniture
   ------------------------------------------------------------------------ */

.page-head {
  display: flex;
  align-items: flex-start;
  gap: var(--space-4);
  flex-wrap: wrap;
  margin-bottom: var(--space-4);
}

/* ---------------------------------------------------------------------------
   The hero — the page's head, its figures and its primary action as ONE band.
   ---------------------------------------------------------------------------
   A feature screen used to open with three stacked strips: a heading, a row of figures, and a
   filter bar. Each was correct and the stack was not — three thin bands of the same weight say
   "these are three separate things", when in fact they are one thing: what this page is, what is
   in it, and what you can do about it.

   The band is FULL BLEED to the edges of the content region, so it reads as the top of the page
   rather than as a card floating on it. It carries two images:

     the BACKGROUND   one shared Vancouver coastal scene, set as a CSS background so it costs no
                      markup and every feature can share it
     the ILLUSTRATION one per feature, a die-cut sticker with a transparent background, so it sits
                      ON the scene rather than in a rectangle of its own

   The transparency is the whole reason the sticker is cut out: a rectangular image would land on
   the scenery as a pasted-on box, and the band's one job is to look like one picture.
   ------------------------------------------------------------------------ */
.hero {
  position: relative;
  /* THE BAND IS ITS OWN LAYER, and the scrim's place in it is fixed by the band rather than by the
     band's own layout. It is what lets `.hero::before` be given a negative `z-index`, which is what
     keeps the scrim behind the words however the band re-arranges itself — see the scrim's comment
     for the fault that made this necessary. `isolation` rather than `z-index: 0` because the band is
     not competing with anything: it is saying that nothing outside it may be layered inside it. */
  isolation: isolate;
  display: flex;
  align-items: center;
  gap: var(--space-5);
  min-height: 208px;
  margin: calc(-1 * var(--main-pad)) calc(-1 * var(--main-pad)) var(--space-5);
  padding: var(--space-6) var(--space-5);
  overflow: hidden;
  border-bottom: 1px solid var(--brand-space-edge);
  background-color: var(--brand-space-deep);
  /* The DEFAULT scene, and every feature overrides it: each screen sets `--hero-image` on its own
     hero to a view of its own, so the band still says "this is one place" without every page
     saying it with the same photograph. This one is kept because the token has to be defined for a
     hero that overrides nothing — the specimen bands, and any page added before its scene exists —
     and a hero with no scene at all would render as a flat fill. The CHOICE of scene is data about
     the page, so it lives in the page: a table of fourteen image paths in this file would be the
     application's data in the design system, which is the thing this file is not for.

     THE BAND IS ON THE FRAME, not on a panel, and that is what changed when the scenes did. It
     used to be a pale photograph under a white scrim carrying the page's ink; it is now a dark
     scene under a dark scrim carrying the frame's starlight. The alternative — keeping a light
     band and lighting every space scene to match — was rejected on the artwork rather than on
     taste: deep space photographed brightly is a nebula poster, and the band is the one place in
     the application where the subject is a PLACE rather than a control. */
  --hero-image: url("assets/hero/orbit-scene.webp");
  background-image: var(--hero-image);
  background-size: cover;
  /* Framed above the true centre: the brightest detail of a space scene sits in its upper half,
     and a band cropped to the middle would put the words across the emptiest part of the field
     with the scene's own interest cut off above them. */
  background-position: center 38%;
  background-repeat: no-repeat;
}

/* The scrim. The scene is a real photograph of deep space — stars, a nebula, the limb of a planet
   — and the words sit on top, so this is what keeps the heading legible.

   So it is set by measurement rather than by taste, exactly as the light version was. Under the
   words, unscrimmed, the BRIGHTEST background pixel across the fourteen scenes is a star at
   luminance 0.847 — and a star is the hard case, because it is small, it is the brightest thing in
   the frame, and it can land on a letter. Starlight `--brand-on-dark` needs the ground under it to
   be no brighter than 0.149 to clear 4.5:1, so the scrim is what buys that headroom: at 0.72 over
   the text's area a full-white star lands at 0.051 and the heading clears at 8.8:1. It fades to
   nothing over the illustration, so the right of the band still looks like a place.

   The gradient runs to the RIGHT and is heaviest on the LEFT, because that is where the words are:
   a scrim chosen to flatten the whole band would erase the scene it is protecting the words from,
   which is the mistake the light version's own comment warns about.

   IT SITS AT A NEGATIVE `z-index`, AND THAT IS A FIX RATHER THAN A FLOURISH. The rule used to be
   the other way round — the scrim at `z-index: auto` and every child lifted over it by
   `.hero > * { position: relative }` — and that rule has a hole in it: `>` matches CHILDREN, and
   the band does not always keep its children. At 1299px and below a band carrying the class's info
   cards gives `.hero-content` `display: contents` so that the words, the cards and the contents can
   be ordered against each other as siblings; the wrapper then generates no box, `.hero-words`
   becomes a GRANDCHILD, and the rule that was supposed to lift the words over the scrim stops
   matching them. The words stayed `position: static`, the absolutely-positioned scrim painted over
   them, and the band dimmed its own heading exactly where the layout had changed — the opposite of
   what a scrim is for, and invisible in the wide-band screenshots the band was reviewed at.

   A negative `z-index` cannot be defeated that way. Within a stacking context, negative-z-index
   descendants paint above the context's own background and below ALL of its in-flow content —
   whatever that content is, however deeply it is nested, and whether or not any of it is
   positioned. So the scrim is behind the words by construction, and a future re-arrangement of the
   band cannot put it in front of them again. It is what `.hero`'s `isolation` is for: without a
   stacking context on the band, a negative-z-index pseudo-element would escape past the band's own
   background and be painted behind the scene it is meant to sit on. */
.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(100deg,
      rgba(5, 9, 26, 0.78) 0%,
      rgba(5, 9, 26, 0.72) 44%,
      rgba(5, 9, 26, 0.26) 68%,
      rgba(5, 9, 26, 0.00) 100%);
}

/* NOTHING IS LIFTED OVER THE SCRIM, because nothing has to be: it is behind everything by
   construction. See `.hero::before`. */

.hero-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-4); }

/* The words are one group and the action is the other, which is what the markup now says. The
   grouping is what lets the band re-arrange itself at a narrow width without a breakpoint: the
   words keep their own min-content width — the longest word in the heading — and the action is
   pushed to a line of its own only when it genuinely cannot fit beside them.

   AND THE WORDS MUST BE WILLING TO BE SQUEEZED, which is the clause the min-content rule above
   cannot supply on its own. A title is data: a credential is named by its id, a machine by its
   hostname, and some of those are ONE UNBREAKABLE WORD forty characters long. A flex item's
   automatic minimum size is its min-content width, so such a heading props the words column open
   wider than the band, the band clips it (`.hero` cannot scroll), and the title the page is
   introduced by is cut off mid-serial. `min-width: 0` lets the column be narrower than that one
   word; `overflow-wrap: break-word` on the title then breaks the word rather than spilling it.
   Ordinary titles are unaffected: they still hold their longest word (min-content of a title with
   spaces is its longest word, which `break-word` does not change), so the action still drops to
   its own line exactly as before. */
.hero-words { display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; }

/* The heading is one of TWO colours and never a third: the frame's starlight, or the page's ink.
   Which of the two is a fact about the background behind it — a dark scene carries starlight, a
   pale panel carries ink — and the band answers that question with its own scrim rather than by
   fading the type. This one is sized so the starlight works (see `.hero::before`), which is why it
   is `--brand-on-dark` and not a mid-tone: a grey heading over a photograph reads as a heading that
   is still loading, and it is the reason the scrim exists at all. */
.hero-title {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-h1);
  color: var(--brand-on-dark);
  /* A title is data and may be one unbreakable word — see `.hero-words`. */
  overflow-wrap: break-word;
}

.hero-sub { margin: 0; font-size: var(--text-small); color: var(--brand-on-dark); }

/* One figure and its label are ONE unit, and a unit does not break.
   ------------------------------------------------------------------------
   The narrow form of the hero's summary is a line of small text, and as the band narrows the line
   reflows. Left as running text, the reflow breaks wherever the spaces happen to fall — measured,
   it split "17 reviews" from "outstanding" and "16.8 mean" from "mark", so the reader met a number
   with no label and a label with no number, and had to carry one across the line break to pair
   them up. A number and what it counts are one fact; the line may break between facts and never
   inside one.

   So each pair is its own inline element and does not wrap. The line still reflows between pairs,
   which is the whole point — it is the pairs that stay whole.

   The separator belongs to the pair it FOLLOWS, not the pair it introduces, and that is the whole
   of the fix below. It used to be drawn inside the following pair — so that the mark would travel
   with the pair it introduces — and that is precisely what put a bare "·" at the head of a line:
   whatever begins a line begins it, and a mark glued to the front of a pair is at the front of the
   pair when the pair moves. Measured on `dashboard.html` at 390px, the fourth pair began a line at
   the band's left edge with the mark leading it.

   The break opportunity is not in the separator's own content, which is why no amount of tuning
   that content fixes it: it is the whitespace BETWEEN the pairs, in the parent, where a rule about
   a pair cannot reach it. Glued to what PRECEDES it instead, the mark can only ever END a line —
   the break falls at the inter-pair space, which now sits after the mark.

   The glue is a non-breaking space rather than the pair's own `nowrap`, deliberately. `nowrap`
   would suppress a break inside the separator today, so a plain space would render correctly — and
   it would go on rendering correctly until somebody removed `nowrap` for an unrelated reason, at
   which point the mark would lead a line again, silently. The non-breaking space does not depend on
   a second declaration staying where it is. */
.hero-sub .stat { white-space: nowrap; }
.hero-sub .stat:not(:last-child)::after { content: "\00A0·"; }

/* Text over a photograph is ALL full ink, not the muted tone the same words carry on a plain page.
   This is arithmetic, not taste: muted `#5a5a5a` clears 4.5:1 only where the background is lighter
   than luminance 0.635, and lifting the scene that far is exactly what erases it. The ink needs
   0.247 — comfortably satisfied everywhere the words can land. So the hierarchy is carried by
   size, weight and case, and the picture survives. */
/* THE FIGURES ARE ON THE BAND, so they take the band's pair. `.metric` and `.metric-label` are
   drawn on the hero itself rather than on a panel — `.summary-figure` is the only thing that ever
   holds them, and it appears nowhere but inside a hero (checked, not assumed) — so both would be
   the page's ink on the frame's own ground and both would be invisible. The label was already
   restated here; the figure was not, and the guard caught it: the landscape band reported 1.08:1
   against a scene at luminance 0.008, which is a heading that is not dim, it is absent. */
.hero .metric { color: var(--brand-on-dark); }
.hero .metric-label { color: var(--brand-on-dark); }

.hero-stats { display: flex; align-items: flex-end; gap: var(--space-5); flex-wrap: wrap; }
.hero-action { margin-left: auto; }

/* A hero can carry TWO actions, and Grades does: the plan requires its marks to be both
   **exportable** by the reader and **printable** (§19.8, §19.9), and they are different routes to
   the same records — one hands over a file, the other hands over paper. The group takes the right
   edge and the quiet one comes first, so the primary is the last thing before the illustration,
   which is where the eye lands. The two sit one gap apart and are not merged into a segmented
   pair: a segmented control is ONE adjustment in two halves, and exporting and printing are not
   two halves of anything. */
.hero-actions {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}

/* The hero carries TWO summaries of the same thing, and exactly one of them is shown.

   The figures are the wide-screen form: four numbers large enough to read at a glance, which is
   what a band this size is for. The sub-heading is the narrow form: the same facts as one line of
   small text, which is all a short band can hold.

   They carry the SAME FACTS, and that is a rule rather than a coincidence. Two summaries of one
   page that say different things are two answers to one question, and the reader has to work out
   which question is being answered — which is what the wide band did while it showed
   "6 assignments · 128 students" beside four figures counting something else entirely.

   So the switch is a switch, and hiding one summary hides no information, because the other is
   carrying it. The primary action is not a summary and stays at every width: hiding it would not
   save space, it would remove the action.

   A HERO THAT CARRIES INFOBOXES CARRIES NEITHER, and it is the band's room that decides. The two
   figures and the action are the wide band's use for a second column; where the second column holds
   the class's own cards — the teacher and the room — the figures and the action are the two things
   the band cannot afford, and the line of small text becomes the page's only summary. So the hide
   is scoped to a hero that HAS figures rather than to a width: `.hero:has(.summary-figure)` is every
   other page, whose figures are still there, and the one page with cards is left with the line at
   every width instead of with nothing at 1100px and up. `validate_html.py` holds the other half —
   a hero with `.hero-cards` has no figures and no action to begin with. */
@media (min-width: 1100px) {
  .hero:has(.summary-figure) .hero-sub { display: none; }
}
@media (max-width: 1099px) {
  .hero .summary-figure { display: none; }
}

/* No shadow, deliberately. The design system's rule is that a shadow means "you can press this",
   and the illustration is not pressable — a raised sticker would be a lie about what it is. The
   white die-cut border is what separates it from the scene instead. */
/* EVERY STICKER IS ONE SIZE, because every sticker FILE is one size: `normalize_stickers.py` puts
   each drawing on the same 1024x1024 canvas at the same 88% inset, so the box here is square and
   the drawing inside it is the same share of it whatever the illustration. It used to be
   `height: auto`, which is the file's aspect ratio times the width — measured, 138px for the widest
   sticker against 246px for the tallest — so a hero's layout depended on which drawing was in it
   and every correction was a rule about ONE illustration. `object-fit: contain` is the belt to the
   canvas's braces: a file that is not square is letterboxed rather than stretched. */
.hero-art { flex: none; width: 208px; height: 208px; object-fit: contain; }

/* THE HERO'S SUBJECT STICKER IS A STICKER LIKE ANY OTHER. It used to need three overrides — it
   did not dangle, it was not 208px wide, and it took the brand azure because a mask in
   `currentColor` over a photograph was invisible — and all three were rules about ONE illustration,
   which is what the shared canvas and the shared tool remove. A subject is now the same drawing,
   from the same set, at the same size, in the same box. */

/* The illustration hangs out of the band, and the action stands clear of it.
   ------------------------------------------------------------------------
   The die-cut sticker was pinned inside the band like a second column of content, which left the
   primary action floating in a pocket of scenery between the last figure and the picture —
   measured, a 24px gap to the illustration and no relationship to anything.

   So the picture now crosses the band's bottom edge. The layout makes the room for it rather than
   the sticker covering what follows: the band's own bottom margin grows by the depth of the
   dangle, so the running block below clears the illustration's lowest pixel by the page's
   ordinary gap (measured: 55px of dangle, 25px of clearance).

   The action stands BESIDE the picture, not on it. An earlier version pulled the illustration
   left by 64px so the two overlapped by 40×40px, and it was rejected on sight. The overlap bought
   nothing the row's own gap does not give: a control that a picture partly covers reads as
   something to be moved aside before it can be used, and the picture is scenery while the button
   is the one thing the band exists to offer. It also needed a `z-index` on the action and a
   probe that sampled inside the overlapping strip rather than at the button's centre — two pieces
   of machinery that existed only to make the overlap safe. Both are gone with it.

   Wide screens only. Below the figures' breakpoint the band is a short strip with a 148px or 84px
   illustration, and there is no room to dangle. */
@media (min-width: 1100px) {
  .hero {
    /* Overflow has to open, or the band clips the very thing that makes the picture a sticker. */
    overflow: visible;
    /* The dangle plus one ordinary gap. It used to be the dangle plus TWO — 24 of space below the
       band and 56 of room for the picture — which came to **80px**, and measured against the
       sub-menu that follows it on the assignments and roster surfaces that read as a hole: the
       picture hangs into that space on the RIGHT of the band, so the left nine tenths of it was
       blank. A vision probe put the gap at "roughly double a comfortable section rhythm" and asked
       for 40-48px. It is 48 now, with 24px of clearance under the picture — which is what the
       dangle needs and no more. */
    margin-bottom: calc(var(--space-5) + 24px);
  }
  .hero-art {
    align-self: flex-end;
    /* The band's padding is inside its box, so the dangle is the margin minus the padding:
       80 - 32 = **48px** past the band, matched exactly by the band's own bottom margin.

       **The dangle is sized to the WIDEST sticker, not to the box.** Every sticker is now the same
       1024x1024 canvas (`normalize_stickers.py`), so a wide drawing is letterboxed inside it: its
       own bottom edge sits above the box's, by **44px** at the size the hero draws it for the widest
       drawing and **13px** for the squarest. The drawing that crosses the band's edge LEAST is
       therefore the widest one, and the dangle has to clear that. At 24px only the square drawings
       crossed at all, which a vision probe read as the picture sitting inside the band; at 48px the
       widest drawing crosses by 4px and the squarest by 35px. */
    margin-bottom: calc(-1 * (48px + var(--space-6)));
  }
}

@media (max-width: 1099px) {
  .hero { min-height: 0; padding: var(--space-5); }
  /* Square at every band, because the canvas is square: a width with no height would let the file's
     own ratio decide the box again, and the box is what the band is laid out around. */
  .hero-art { width: 148px; height: 148px; }
  /* With the figures gone the row that held them holds only the button, so the band kept a 40px
     row, a 16px gap and its own padding to show one control — measured at 1099, 810 and 599px,
     the button sat 81px below the sub-heading in every case, stranded at the left of an empty
     line, which is the whitespace this fold exists to reclaim.

     So the row COLLAPSES rather than emptying. `display: contents` removes `.hero-stats`'s own
     box — its height, its 24px gap and its wrap — and lets the button join the title band as a
     direct item of `.hero-content`, which becomes a row: the words in the first item, the button
     in the second.

     No breakpoint decides where the button goes, because there is no width at which the answer
     is known in advance — it depends on the heading's own text and the button's own label. The
     words are `flex: 1 1 auto` with their automatic minimum left alone, so they cannot shrink
     past their longest word; `flex-wrap: wrap` then drops the button to a line of its own exactly
     when it stops fitting. Measured: side by side down to 500px, wrapped below that — and the
     band is 126px instead of 171px while they share a line.

     This fold is only correct while the figures are hidden, and the figures are hidden by the
     rule directly below. If they ever come back at this width, the button goes back with them. */
  .hero-content { flex-direction: row; flex-wrap: wrap; align-items: center; }
  .hero-stats { display: contents; }
  .hero-words { flex: 1 1 auto; }
  .hero-action { flex: 0 0 auto; margin-left: 0; }
  /* THE CONTENTS IS NOT A CONTROL BESIDE THE WORDS, so it does not join the row above — it takes a
     line of its own under them, the way the cards take one under the content at 1299. A grid left
     as a row item is squeezed to its own min-content: measured, the Overview's strip became **121px
     wide and 877px tall**, seven tiles in a single column, and the band 1302px. The floor that
     decides how many tiles go across needs a width to divide, and a row item that can shrink past
     its floor has none. `flex: 1 0 100%` with the row's own `wrap` gives it the full line without a
     breakpoint of its own — the same trick the cards' wrapper uses on the band.

     Both densities, because both bands carry one: the Overview's strip and the dashboard's sections. */
  .hero-content > .callouts { flex: 1 0 100%; }
}

@media (max-width: 599px) {
  .hero { padding: var(--space-5) var(--space-4); gap: var(--space-4); }
  .hero-title { font-size: var(--text-h2); }
  .hero-art { width: 84px; height: 84px; }
  /* A narrow band cannot afford a long diagonal fade — the words span nearly all of it, so the
     scrim is more even, and the scene stays visible behind them. It is still heavy enough to hold
     starlight over a star: at 0.70 over the whole band a full-white star lands at 0.057. */
  .hero::before {
    background: linear-gradient(100deg,
      rgba(5, 9, 26, 0.78) 0%, rgba(5, 9, 26, 0.70) 100%);
  }
}

/* ---------------------------------------------------------------------------
   The hero's two cards — who teaches the class, and where it meets
   ---------------------------------------------------------------------------
   A hero's picture slot holds a die-cut sticker of the FEATURE — a clipboard for Assignments, a
   calendar for Calendar. Overview is the one page whose subject is the class rather than the
   feature, and it held the class's SUBJECT sticker instead: biology, in the same slot.

   The subject is a fact the page already gives in words, in the class facts card a few hundred
   pixels below. So the picture was a second copy of something the reader had been told, and the
   slot was spending a hero's most valuable position on it. What a reader arriving at a class wants
   and cannot get anywhere else is WHO TEACHES IT, HOW TO REACH THEM, and WHERE AND WHEN IT MEETS —
   so the slot holds two cards: the teacher, and the room.

   They are ONE component in two dressings — `.hero-card` plus a modifier — because they are the
   same thing twice: a picture on a tinted panel, a name, a title, and a short keyed list. Two
   components would be two sets of padding to keep in step, and the two cards sit side by side.

   THE PICTURE SITS ON A TINTED PANEL, and that is the one thing here that is not a choice. A
   die-cut sticker's edge is a WHITE skirt; a white skirt on a white card is no skirt at all, and
   measured on the card without the panel the cut-out edge disappears and the picture reads as a
   rectangle of artwork — the single thing the sticker set exists to avoid. `--brand-azure-tint` is the
   palette's softest ground and the tone the kit already gives a surface that holds something.

   THE TEACHER'S CARD KEEPS `.hero-art`, because it IS this hero's illustration: `check_hero_art`
   asks every page with a hero for one, and this is the one. The building is
   the building is `.hero-card-art` only — a picture INSIDE a card rather than the band's own
   illustration — because the band's rules for a sticker (208px, pushed to the band's floor, pulled
   down over its edge by a negative margin) would land on a 67px drawing in a panel.
   ------------------------------------------------------------------------ */

/* The slot, as one thing. `flex: none` because the band is a row: the words take everything the
   cards do not, and a wrapper that could grow would take their room. */
.hero-cards { display: flex; flex-wrap: wrap; gap: var(--space-3); flex: none; }

.hero-card {
  /* `border-box`, and it is the exception in a sheet whose controls are content-box on purpose.
     The reason for content-box is that a control's stated measure is the measure a guard reads —
     `.tabs--segmented > .tab` is 30px and `verify_tabs.py` measures 30px — and a card is not a
     control: its width is the slot it sits in, padding and border included. */
  box-sizing: border-box;
  flex: 0 0 248px;
  display: flex;
  flex-direction: column;
  /* The picture, the name and the facts, in that order and packed from the top, with the slack at
     the foot. NOT `space-between`: the two cards stand side by side and are stretched to the taller
     of them, and a card that pushed its facts to its own floor would put them level with the other
     card's facts only when the two happened to have the same number of rows. Packed from the top,
     the two heads are on one line and the two lists are on one line. The room card's button is what
     absorbs the slack instead — `margin-top: auto` — so it still closes its card.
     `min-height` is the 208px the sticker this slot used to hold occupied, so the band's shape
     survives a card whose own content is shorter than that. */
  min-height: 208px;
  gap: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  background: var(--brand-surface);
  color: var(--brand-ink);
}

/* The picture and the name, side by side. Stacked they made a card 281px tall — measured — and the
   band grew with it: 73px of hero spent on a portrait that has the room to sit beside two lines of
   type. */
.hero-card-head { display: flex; align-items: center; gap: var(--space-3); }

.hero-card-portrait {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  /* A stated size rather than the sticker's own, so the panel is the same whatever the drawing:
     `normalize_stickers.py` puts every sticker on one square canvas, and a panel sized by its
     contents would still change if the file's ratio ever did. */
  width: 96px;
  height: 84px;
  background: var(--brand-azure-tint);
}

/* THE PICTURES BACK AT THEIR OWN SIZE, and 1/0.88 of the drawing they hold.
   `normalize_stickers.py` fits every sticker's longest side to 88% of the 1024 canvas, so a box
   sized to the DRAWING has to be sized 1/0.88 of it: 76px of box is a 67px drawing. Sized against
   the file's own ratio before normalising, the same 76px box drew 76 and would have drawn 67 after
   — the trap the shared canvas exists to remove, arriving from the other side.
   TWO classes deep, and that is not decoration: the teacher's portrait also carries `.hero-art`,
   because it IS this hero's illustration, and the band's rules for a sticker — 208px, `align-self:
   flex-end`, a negative margin pulling it over the band's edge — are one class deep. Written at
   equal depth the two would be decided by whichever rule came last in this file, which is the
   collision the kit has been bitten by before; written two deep, the card's rule wins on
   specificity and the order of the file stops mattering. */
.hero-card .hero-card-art {
  width: auto;
  height: 76px;
  align-self: center;
  margin-bottom: 0;
}

.hero-card-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

.hero-card-name {
  font-family: var(--font-heading);
  font-size: var(--text-body);
  font-weight: 700;
  color: var(--brand-ink);
}

.hero-card-role { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* The facts, keyed. A hairline above them rather than a heading, for the reason the class facts
   card gives: the row above already says what this card is about, and a third line of type to say
   "Contact" spends a line of a narrow card saying what three keys say for nothing. */
.hero-card-facts {
  margin: var(--space-2) 0 0;
  padding: var(--space-2) 0 0;
  border-top: 1px solid var(--brand-border);
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.hero-card-facts li { display: flex; gap: var(--space-2); font-size: var(--text-micro); }

/* The key column is `flex: none` and WIDTHED IN `ch`, so the values start on one line down the card
   and the column is as wide as its own longest key rather than as wide as the widest value can push
   it. "Drop-in" is the longest of the six. */
.hero-card-key { flex: none; width: 7ch; color: var(--brand-ink-muted); }
.hero-card-value { font-weight: 500; }

/* THE LOCATION CARD'S LIST IS ORGANISED BY PLACE, AND EACH PLACE IS PROFILED.

   A class rarely meets in one place: the lecture is in one building and room and the lab is in
   another, and that is the ordinary arrangement rather than the exception. So each `<li>` is a
   PLACE — its own drawing of its own building, its name with the room and building in it, and the
   meetings that happen there — and the list is a list of profiled places.

   `flex-direction: row` on the item, against the column the teacher's facts use, because a place is
   a picture beside its own details. The two pictures are the two buildings, so the card distinguishes
   the arrangement by sight and not only by reading two room codes.

   The place's drawing is 48px in a 64x56 panel — smaller than the teacher's 76px in a 96x84 panel,
   because a place is a line in a list rather than the card's subject. The panel keeps the same tint
   for the same reason the teacher's does: a die-cut sticker's edge is a white skirt, and a white
   skirt on a white card is no skirt at all.

   The day-keyed form is what this replaces, and the reason is worth keeping. Days as rows with the
   place as a suffix reads as a timetable with a note on it: the second place arrives as an exception
   to the first, and a class whose lab is in another BUILDING has to squeeze the building into a
   suffix after a time. Measured on the row form, "14:30–17:20 · C118, Building C" fitted — but it
   was still the second place wearing the first place's clothes. Places as blocks make the two
   peers, and a third place is a third block with nothing new to invent. */
.hero-classroom .hero-card-facts li { flex-direction: row; align-items: center; gap: var(--space-2); }

/* The place's panel, and THREE classes deep for the drawing's size. `.hero-card .hero-card-art` is
   two deep and sets 76px; at equal depth the two rules would be decided by whichever came last in
   this file, which is the collision this sheet keeps being bitten by.

   THE SAME BLOCK IS A PERSON. `.hero-card-person*` is the teacher's card's second block — the lab
   instructor — and it is the place block's dress on purpose: the operator asked for her to be
   "similar in size and style to the building and room information from the other infobox". What a
   place and a person have in common is that both are a PICTURE BESIDE ITS OWN DETAILS, and that is
   what these rules are; the class name says which kind of thing the block is about, so neither has
   to borrow the other's word for it. */
.hero-card-place-figure,
.hero-card-person-figure {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  height: 56px;
  background: var(--brand-azure-tint);
}
.hero-card .hero-card-place-figure .hero-card-art,
.hero-card .hero-card-person-figure .hero-card-art { height: 48px; }

.hero-card-place-body,
.hero-card-person-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

/* The place, named, and the meetings under it in the muted tone the key column uses — the place is
   the thing being looked for and the times are its detail. */
.hero-card-place-name,
.hero-card-person-name { font-weight: 600; }
.hero-card-when { color: var(--brand-ink-muted); }

/* THE HOURS ARE STATED LIKE THE MEETINGS, because they are the same kind of fact: a day and a range
   of clock time, read against a place or against a person. In the location card the meetings stack
   under the place they happen in; in the teacher's card the drop-in hours stack under the key that
   says what they are. One per line, in the same muted tone, in the same column every other value in
   the card starts in — the key is `flex: none` and width'd, so the stacked hours do not move it.

   The form this replaces is one line of text: `Tue 13:00–15:00 · Thu 10:00–12:00`. It is not wrong,
   and the middle dot is doing real work — but it is a SENTENCE, and the card beside it states its
   times as a list. Two cards that answer the same question in two shapes make the reader work out
   that they are the same question. */
.hero-card-times { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

/* Places are separated by a hairline, so a card with two of them reads as two things rather than as
   one list that changed subject. The first has the list's own rule above it already. */
.hero-card-place + .hero-card-place {
  margin-top: var(--space-2);
  padding-top: var(--space-2);
  border-top: 1px solid var(--brand-border);
}

/* The lab instructor's block, which is the LAST item of the teacher's facts and is separated from
   them for the reason two places are separated from each other: it is a second thing, and the facts
   above it are the first. It takes the same hairline.

   `align-items: center` is what the location card states on its own items, and it is needed here
   rather than inherited: `.hero-card-facts li` is a row with the default `stretch`, and a 56px
   panel stretched down the side of a five-line body is a panel that has stopped being a picture
   beside its details and become a bar beside them. */
.hero-card-person {
  align-items: center;
  margin-top: var(--space-2);
  padding-top: var(--space-2);
  border-top: 1px solid var(--brand-border);
}

/* UNDERLINED AT REST, and the ink left alone.

   A bare `a` is azure-deep with no underline, which is the kit's rule for a link inside running
   text — the sentence around it says what the link is for. An address in a fact row has no sentence
   around it: it is the value of "Email", sitting in the same column as "A214B" and the hours, and
   nothing about the row says one of the three is pressable. So the address says so itself, with the
   underline the kit's other at-rest links wear (`.account-btn .user-name`, `.feature-option-code`)
   and the same 3px offset, and it keeps the row's own ink so the three values read as one column of
   values rather than two of them plus a link.

   The underline is the whole affordance, at rest and under the pointer — the kit does not change an
   at-rest underline on hover, because a link that only LOOKS like a link once the pointer is
   already on it has said it too late. No `cursor: pointer` either: the browser gives a link one,
   and a class that claims one has to declare a state of its own to go with it. */
.hero-card-link { color: inherit; text-decoration: underline; text-underline-offset: 3px; }

/* The room card's way out, and it is a CONTROL rather than a link in a fact row: it leaves the
   application for the college's own campus map, so it wears the button dress the kit gives every
   other control that goes somewhere. `stretch` takes the card's width, which is what makes the two
   cards' feet agree whether or not one of them ends in a button — and it is `align-self` rather
   than `width: 100%`, because a full-width control has to be `border-box` and this one does not
   need to be.
   `margin-top: auto` puts it at the card's floor: the cards are packed from the top so that the
   facts of the two line up, which leaves the slack to be taken by the last thing in the card. */
.hero-classroom-map { margin-top: auto; align-self: stretch; text-align: center; }

/* BELOW THE BAND'S WIDE FORM THE CARDS LEAVE THE CONTENT'S ROW AND TAKE A LINE OF THEIR OWN.
   At 1300px and up the slot is the band's second column and the two cards fit in it with the words
   still beside them. Below that they do not, and a card cannot simply be squeezed: its facts wrap
   to one word a line. So the wrapper takes a full line under the words and the cards share it —
   508px of them, which is still one line at a 599px band and two below about 520px, where the
   wrapper's own `wrap` splits them without a breakpoint of its own.

   The wrap is on the BAND and scoped to a band that holds cards, because `.hero-content` is
   `flex: 1` and a band that wrapped in general would drop another page's sticker onto a line of its
   own the moment its words were a word longer. */
@media (max-width: 1299px) {
  .hero:has(> .hero-cards) { flex-wrap: wrap; }
  .hero-cards { flex: 1 0 100%; }
  /* `1 1 248px` rather than `0 0`: on a full-width line the two cards SHARE it instead of leaving a
     third of it empty. */
  .hero-card { flex: 1 1 248px; }

  /* AND THE CONTENTS GOES LAST ONCE THE BAND STACKS.

     Three things are now in this band and only two of them are in one container: the words and the
     contents are both inside `.hero-content`, and the cards are the band's own second child. So the
     order the reader gets when the band wraps is the DOM's — words, contents, cards — and the one
     the band wants is words, CARDS, contents: the cards are who teaches the class and where it
     meets, which is what a reader came for, and the contents is the page's table of contents, which
     is what they read next. `order` cannot express that while the two live in different flex
     containers, because `order` only ever sorts within one.

     `display: contents` puts all three in one. It takes `.hero-content`'s own box away — its height,
     its 16px gap, its `flex: 1` — and its children become the band's items, where `order` works and
     where the band's own 24px gap replaces the content's 16px. The cost is that gap, which is 8px
     between the words and what follows them; the benefit is that the band's stacking order is a
     decision rather than a consequence of which element somebody had to nest.

     Scoped to a band that HOLDS CARDS, because a band with no cards has nothing to reorder and
     giving `.hero-content` `display: contents` there would take a box away from a layout that is
     using it. */
  .hero:has(> .hero-cards) .hero-content { display: contents; }
  .hero:has(> .hero-cards) .hero-words { flex: 1 0 100%; order: 1; }
  .hero:has(> .hero-cards) .hero-cards { order: 2; }
  .hero:has(> .hero-cards) .callouts { flex: 1 0 100%; order: 3; }
}

.page-head h1 { margin: 0; font-size: var(--text-h2); color: var(--brand-ink); }
.page-head .page-sub { margin: var(--space-1) 0 0; font-size: var(--text-small); color: var(--brand-ink-muted); }
/* The page's primary action sits at the far right of the summary strip rather than in a band
   of its own above it. The figures say what the page holds and the button is what you do about
   them, so they belong on one line — and it is one band fewer between the heading and the
   content. `align-self` keeps the button centred against the figures instead of stretching to
   their height. */
.summary-action { margin-left: auto; align-self: center; }

.breadcrumbs {
  display: flex; align-items: center; gap: var(--space-2);
  margin-bottom: var(--space-3);
  font-size: var(--text-micro); color: var(--brand-ink-muted);
}
.breadcrumbs a { color: var(--brand-ink-muted); text-decoration: none; }
.breadcrumbs a:hover { text-decoration: underline; }
.breadcrumbs .sep { color: var(--brand-border-strong); }


.stack { display: flex; flex-direction: column; gap: var(--space-4); }
.row-inline { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }

/* ---------------------------------------------------------------------------
   THE CALLOUT — one surface of the class, as a tile you can go to.

   Overview is a SUMMARY (§7.5): its job is to say what the class holds and where
   each part of it is. The navigation says the same nine names, but it says them
   as a list of words inside a menu; a callout says them as a TILE, carrying the
   surface's own illustration and one figure QUOTED FROM THAT SURFACE — so a
   reader who has never opened the class can see what is in it without opening
   nine pages, and the figure is the surface's own number rather than a second
   copy of it that can go stale.

   TWO DENSITIES, ONE COMPONENT, and the difference is what a tile has to say:

     * `.callouts--strip` is the class's own TABLE OF CONTENTS, and on the Overview
       it lives IN THE HERO'S LEFT COLUMN, under the words — art, name, figure, and
       nothing else. It auto-fits, because how many surfaces a class has is the
       class's business and not the layout's.
     * `.callouts--sections` is the app's own sections, under the dashboard's hero
       — the same three things plus a NOTE saying what the destination is FOR,
       which is the one thing a reader choosing between places needs and the
       navigation cannot say.

   They are one component because a tile is one thing: a drawing, a name and a
   figure in a box that is a link. The density changes the FLOOR and whether there
   is a note, and nothing else. A third density — an annotated directory with
   explicit columns, at the foot of the Overview — was built and then removed with
   its only user: the page lists its surfaces ONCE, because the same set twice on
   one page is the duplication the page's own rule forbids.

   A `<nav>` of links rather than a `<ul>`, for the reason `.subnav` is: this is a
   set of DESTINATIONS, the whole tile is the target, and a list would put a
   wrapper between the grid and the thing that is clicked.
   ------------------------------------------------------------------------ */

.callouts {
  --callout-min: 110px;
  display: grid;
  /* AUTO-FIT, NOT AUTO-FILL — and the difference is a hole in the row. `auto-fill` creates as many
     tracks as the container allows and leaves the unused ones EMPTY, so the tiles stop short of the
     right edge and the row reads as a block that ran out rather than as a row: measured on the
     Overview at 1440px with eight tiles, the tiles ended 129px short of the column they sit in.
     `auto-fit` collapses the tracks nothing lands in, so the tiles share the width they have. */
  grid-template-columns: repeat(auto-fit, minmax(var(--callout-min), 1fr));
  gap: var(--space-3);
  /* THE BREAK BELOW IS ON THE BASE, and there is no margin above it. A grid sits directly under
     the hero that introduces it, so the space above it is the HERO's own gap — adding one here
     would double it, which measured as 128px of clear air under the dashboard's hero against the
     80px the hero asks for. The break below is the component's own for the reason it always was:
     `main` is a block container with NO gap, so a grid without one sits flush against whatever
     follows it, and measured before this rule the Overview's tiles and the body below them were at
     the same pixel. The page's section rhythm is 32px; a row of bordered boxes takes one step
     wider, 48px, because it reads as a division rather than as ordinary spacing. */
  margin-bottom: var(--space-7);
}

/* 110px is measured rather than chosen: the floor decides the COUNT and the container decides the
   width. On a content column of about 1150px — the strip under a hero, or a page that carries it
   without one — 110px puts all SEVEN of the Overview's tiles on one line. At a 390px phone the same
   floor gives three across rather than two, which is the difference between a three-row contents
   and a five-row one.

   IN THE HERO THE CONTAINER IS THE BAND'S LEFT COLUMN, and it is about 590px at 1440 because the
   class's two cards take the rest: the same floor gives four across and two rows there. The floor
   is doing its job — it is a floor, and the count is the container's business. */
.callouts--strip { --callout-min: 110px; }

/* IN THE BAND, A GRID OF TILES IS NOT A BLOCK WITH A BREAK UNDER IT. `margin-bottom` on the grid is
   the space between a row of tiles and the body below it, and inside the hero there is no body below
   it — the band's own padding is that space, and a grid margin in there is space the band pays for
   twice. It measured 48px of dead air inside the band's floor.

   This is both densities, because both moved into the band: the Overview's `.callouts--strip` and
   the dashboard's `.callouts--sections`. */
.hero .callouts { margin-bottom: 0; }

/* THE APP'S OWN SECTIONS, which is the same tile about a different set. The dashboard's grid holds
   the navigation's four MINUS the page itself, so it is three items against the overview's nine —
   and three items in a fixed two-column grid leaves the last one alone in a row of its own, which
   is a ragged row that says nothing. So this one AUTO-FITS with a floor wide enough for a note:
   three tracks where there is room, one below 700px, and no empty track either way. The overview's
   directory keeps its explicit columns, because nine items in three columns is a rectangle. */
.callouts--sections {
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--space-4);
}

/* SEE ALSO — the same tile again, smaller, for the two or three places a reader of THIS page is
   most likely to want next. It is not a second navigation: the navigation lists everything, and a
   see-also block is a SHORTLIST, which is why it holds two or three and never more. A block that
   listed nine would be the feature menu again, in a worse place.

   It is smaller than the dashboard's grid on purpose. The dashboard's tiles introduce the
   application to someone arriving; these are a footnote to a page they are already reading, so
   the drawing is 32px rather than 48 and the tile is one line of words rather than three. The
   difference is size and not shape: a see-also tile is the same tile, and a reader who has learned
   it on the dashboard should recognise it here.

   No note. A note says what a destination is FOR, which is what the dashboard's tiles are for —
   a reader who is already inside a class does not need to be told what Rubrics is; they need to be
   told that it is the next thing. */
.see-also { margin-top: var(--space-6); }

.see-also-title {
  margin: 0 0 var(--space-3);
  font-size: var(--text-micro);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--brand-ink-muted);
}

/* A SHORTLIST IS SMALL TILES AT THE LEFT, NOT TWO BANNERS ACROSS THE PAGE. `auto-fit` with `1fr`
   stretches whatever it is given across the whole column, which is right for a page's own contents —
   nine tiles sharing the width — and wrong here: measured with two tiles in a 1150px column, each
   came out 580px wide with its drawing and its two words bunched into the left sixth and the rest
   empty. So the track has a CEILING as well as a floor: the tiles keep their size, the row keeps
   its left alignment, and the space they do not need is simply not used. 200px is measured against the widest
   content the set contains — a 32px drawing, the gap, and "2 awaiting feedback", which is 154px
   before the tile's own padding — so the longest tile is snug and the shortest is not stretched
   past the point where it reads as a container. */
/* A SHORTLIST IS TILES THE SIZE OF THEIR OWN WORDS, and it is the one density that is not a grid.
   A grid gives every tile the same width, which is right when the tiles hold a NOTE — the
   dashboard's are three lines each and fill the track. A see-also tile holds a name and a figure,
   so a fixed track leaves most of it empty: measured at 200px, the narrowest tile had 84px of blank
   space inside it and the row read as a banner that had been stretched. `flex` sizes each tile to
   its content instead, so the row is a row of tiles rather than a row of boxes, and `wrap` gives
   the phone its one-per-line without a media query. */
.callouts--see-also {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: var(--space-3);
}

.callouts--see-also .callout { flex: 0 1 auto; }

.callouts--see-also .callout { padding: var(--space-2) var(--space-3); }
.callouts--see-also .callout-art { width: 32px; height: 32px; }
/* The words sit against the drawing rather than centring on it: a tile one line tall has no
   vertical slack to centre in, and a name that floats mid-tile beside a 32px drawing reads as
   misaligned. */
.callouts--see-also .callout-body { justify-content: center; }


/* A TILE IS A LINK, AND IT IS RAISED LIKE ONE.

   `SHADOWED MEANS INTERACTIVE` is one of the two rules the whole language rests on, and the tile
   was the one pressable thing on the page wearing the dress of a surface: a border and a fill, no
   shadow, which is what `.card` and `.panel` wear and what the reader has been taught to read as
   "a box of content". Measured against the panels around it, a callout was indistinguishable from
   structure — so the one row on the page whose whole purpose is to be gone into read as the one
   row that was not. It takes the same `--shadow-control` a button, a select and an account disc
   take, and the same deepening on hover.

   The radius stays zero, and that is not an oversight. `ROUNDED MEANS INTERACTIVE` is about the
   SMALL controls — a pill for a button, a 6px corner for a field — and a tile is a surface the
   size of a card: a pill-shaped tile is a shape nothing else in the kit is, and the shadow is
   already carrying the claim. */
.callout {
  --callout-name-size: var(--text-small);
  display: flex;
  gap: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  background: var(--brand-surface);
  color: var(--brand-ink);
  text-decoration: none;
  box-shadow: var(--shadow-control);
}

/* The strip tile is a COLUMN: 110px will not hold a drawing and a word beside it. */
.callouts--strip .callout {
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-2);
  text-align: center;
}

/* The annotated tile is a ROW, and its words start at the top rather than centring against the
   drawing — a note is two lines and a centred block beside a drawing reads as neither. */
.callouts--sections .callout { align-items: flex-start; }

/* The drawing. `object-fit` because the stickers are not one shape — a ruler and
   a bulletin board are not the same proportions — and a stretched sticker is a
   drawing that has been pulled out of shape to fit a box. */
/* THE SIZE IS ON THE BASE, and that is a fix rather than tidiness: with the size stated only per
   density, a third density had no size at all, and a sticker with no width and height renders at
   its NATURAL size — measured, 1755 x 1263px inside a 347px tile, which pushed the words to zero
   width and wrapped the note to one character a line. A base size means a new density gets a sane
   drawing and has to state only what is different. */
.callout-art { flex: none; object-fit: contain; width: 48px; height: 48px; }
/* 40px rather than 32: the nine stickers share one palette and one outline weight, so at 32px
   two of them — Content's books-and-folder and Syllabus's sealed bundle — collapse to the same
   warm blob, and a reader glancing at the row can swap one for the other. The drawing is the whole
   reason the tile exists, so it is sized to be recognised rather than to be small. */
.callouts--strip .callout-art { width: 40px; height: 40px; }

.callout-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.callouts--strip .callout-body { align-items: center; }

/* UNDERLINED AT REST, because the name is the destination and nothing else in the tile says so.

   The tile had two hover answers — the ground lifted and the name went azure — and no RESTING
   one. A hover affordance arrives after the reader has already decided, which is the failure the
   class list was fixed for: a title in a list of places has to say it is a place before anybody
   points at it. The shadow says "pressable"; the underline says WHICH WORD is the place, and on a
   tile that also carries a drawing and a figure that is the half the shadow cannot say.

   It is the same underline the kit's other at-rest links wear — `.account-btn .user-name`,
   `.feature-option-code`, `.hero-card-link` — and it stays under the pointer, where the name
   also takes the action azure. */
.callout-name {
  font-weight: 600;
  font-size: var(--callout-name-size);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* A surface name is ONE WORD, and a flex item will not wrap one: its min-content width is the
   whole word, so a name wider than its tile overflows the padding box instead of wrapping. At a
   390px phone the strip's tiles are 111px and "Announcements" is 103px of type — measured, it ran
   to within 4px of the tile's edge, and a surface named a letter or two longer would have run
   THROUGH it. So the narrow band tightens the tile's gutter and drops the name a step, which buys
   the longest of the nine about 8px of clearance on each side. The alternative — two columns, so
   the tiles are 173px — was measured and rejected: it makes the contents five rows and 583px tall
   under the hero, which pushes the class's own content off the first screen of a phone. */
@media (max-width: 599px) {
  .callouts--strip .callout { --callout-name-size: var(--text-micro); padding: var(--space-2) var(--space-1); }
}

/* A TILE'S OWN TEXT IS THE DARKEST INK, and no tile is allowed a grey "for contrast".
   ---------------------------------------------------------------------------
   A tile carries its own ground — `--brand-surface`, opaque — so the hero's photograph never
   touches anything inside it, and the only contrast that matters is the one between the tile's ink
   and the tile's own white. That is 15.9:1 for `--brand-ink` and 7.0:1 for `--brand-ink-muted`, so
   both pass and neither is the constraint: the greys here were a contrast habit carried in from
   the words that sit ON the photograph, where they are load-bearing.

   The rule is worth stating because the two cases look alike and are not. Out in the band the
   muted tone needs a background lighter than luminance 0.635, which is what the scrim is sized for
   and what `.hero .metric-label` above is about. Inside a tile it constrains nothing at all, and a
   grey that constrains nothing is just a lighter word — which is the thing the operator reported:
   the tile's own text reading as though it had been faded to pass a test it was never subject to.

   So the tile's figure and note take the ink. The NAME always did — it inherits the tile's own
   `color`, which is `--brand-ink` — and that is the half that was reported as grey; it was not, and
   the two greys beside it are what made it read that way. */
.callout-figure {
  font-size: var(--text-micro);
  color: var(--brand-ink);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.callout-note { font-size: var(--text-micro); color: var(--brand-ink); }

/* The whole tile is the target, and it confirms the affordance on hover the way
   every other link in the kit does: the ground lifts, the border firms, the shadow
   deepens, and the name takes the action azure. A tile with nothing to go to is
   not a target and answers none of it — see `.callout--static` below, which is the
   only tile these rules do not reach. */
.callout:not(.callout--static):hover {
  background: var(--brand-surface-alt);
  border-color: var(--brand-border-strong);
  box-shadow: var(--shadow-control-hover);
}
.callout:not(.callout--static):hover .callout-name { color: var(--brand-azure-deep); }

/* AND IT GOES DOWN WHEN IT IS PRESSED.

   The tile is raised at rest and LIFTS on hover, so the one thing it never said was that the press
   had landed. On a touch screen there is no hover at all to confirm anything, and a tap on a raised
   tile that changes nothing under the finger reads as a tap that missed — which is the failure the
   operator reported: "the feature and see also tiles don't have a clicked state to indicate that the
   button is being pressed".

   Two signals, because the kit already uses both for a press and a tile is large enough to carry
   them at once. The shadow COLLAPSES to the same `--shadow-control-active` a `.btn` takes, which is
   the surface going back down rather than being lifted; and the ground takes `--brand-azure-tint-deep`,
   which `:root` documents as "the pressed state" and which the table's rows and the sheet's thumb
   tiles already take. The name keeps the action azure the hover gave it, restated on `:active`
   rather than inherited, because on a touch device the hover may never have run.

   The border stays firm. It is what the hover set, and a press does not undo a lift — the press is
   the moment the lift ends, not a third state of its own.

   This is ONE rule for all three densities, and that is the point of the component: the Overview's
   contents, the dashboard's sections and every see-also shortlist are the same tile, so a reader who
   has learned that a tile goes down under the finger has learned it everywhere. */
.callout:not(.callout--static):active {
  background: var(--brand-azure-tint-deep);
  border-color: var(--brand-border-strong);
  box-shadow: var(--shadow-control-active);
}
.callout:not(.callout--static):active .callout-name { color: var(--brand-azure-deep); }

/* ---------------------------------------------------------------------------
   A TILE THAT IS NOT A DESTINATION

   The component's whole reason is a set of places, and a page sometimes wants to
   QUOTE the same surfaces rather than offer them. The public site's section on
   signing in does: it shows the three screens it is made of — the console's own
   drawings, names and figures, in the console's own tile — and that page has no
   links into `screens/` at all, so there is nowhere for those three to go.

   What is left is the dress, and the dress is where the claim lives. This is the
   rule that put the shadow on the tile, read the other way round: SHADOWED MEANS
   INTERACTIVE, so a tile that cannot be pressed does not cast one — at rest, under
   the pointer, or under the finger. A tile drawn as a raised chip that answers
   neither is worse than no affordance at all: it teaches the reader that this
   kit's shadows mean nothing, which costs every control on every page.

   The name loses its underline for the same reason. The underline is the other
   half of the tile's claim — the shadow says the TILE is pressable and only the
   underline says WHICH WORD in it is the way in — and a quotation has no way in.

   A MODIFIER and not a second component, because art, name and figure in a box is
   one shape whichever it is: the densities above still choose the layout, so a
   quoted tile is the tile the page would have had if it were a way in, minus the
   two marks that said so. */
.callout--static { box-shadow: none; }
.callout--static .callout-name { text-decoration: none; }

/* ---------------------------------------------------------------------------
   The filter row — a row until it cannot be one, then two full-width lines.
   ---------------------------------------------------------------------------
   Above the breakpoint the four chips keep their natural size and the search takes whatever is
   left, which is what `margin-left: auto` was doing. Below it the two halves stop being a row:
   the chips stretch to fill the line edge to edge, and the search takes a line of its own at full
   width. A search field that wraps and then sits in a 238px box at the right of an otherwise
   empty line is the shape that prompted this.

   CSS cannot ask "did this line wrap?", so the breakpoint is measured rather than guessed. The
   mark is 16px and the gap after it 12px, the chips and their gaps come to 345px, the search needs
   238px, and the two halves need 12px between them: 595px including the mark's 28px, which is
   inside the 345. `verify_filter_wrap.py` loads the page either side of that number and asserts the
   layout actually changes there, so a chip label that grows moves the real breakpoint into view
   instead of silently leaving this one behind.

   The chips GROW rather than being made equal. Four equal segments would be the tidier
   segmented-control look, but `min-width: auto` refuses to let a flex item shrink below its own
   words, so at a phone width "Needs setup" would either overflow the row or wrap inside its own
   pill. Growing proportionally fills the width and cannot break.
   ------------------------------------------------------------------------ */
.filter-row .chip { flex: 0 1 auto; }

@container (max-width: 594px) {
  /* Stretched across the whole line, the four chips fill it edge to edge — and a stretched pill
     centres its label. A chip is an `inline-flex` box, so its label is a flex item and sits at
     `flex-start` by default: once the chip is wide and the label is not, the words bunch against
     the left edge and the pill reads as a left-aligned block with dead space beside it rather than
     as a label in a shape. Above the breakpoint the chip is sized to its own words, so the centring
     is already true by construction and it changes nothing there — it is stated below the breakpoint
     because below it is where the box and the label stop agreeing. */
  .filter-row .chip { flex: 1 1 auto; justify-content: center; }
  /* The mark STAYS. It used to be dropped here, on the reasoning that chips filling the line say
     "these are filters" by themselves — and measured, keeping it costs nothing: at every width from
     599px down to 340px the four chips still fit on one line, the row is still 78px tall, and the
     only difference is that they start 28px in from the edge, which is the mark and the row's gap.
     Against that, dropping it took away the one element that says the row filters at all — the
     chips alone read as category tabs — and it did so exactly where the reader has least else to
     go on. */
  .filter-row .search { flex: 1 1 100%; margin-left: 0; }
}
.summary-strip { display: flex; gap: var(--space-5); flex-wrap: wrap; }

/* ---------------------------------------------------------------------------
   A titled list of items, each with one route
   ---------------------------------------------------------------------------
   The table below is the RECORD: every assignment, whatever stage it is at, with
   its figures, for checking, comparing and auditing. This is the ROUTE: only the
   items that can be acted on right now, each with the one control that enters it,
   for acting.

   They are separate components because they answer different questions. Folding
   the routes into the table would put a button in every row — including rows for
   assignments that are inactive, finished, or not yet set up — and a control that
   is right three times in six teaches the reader to stop looking at it.

   The component is GENERAL, and `.running` is only the name of its first use: the
   assignments screen lists the open phases, and the Dashboard lists what is due
   next across every class. Same block, same row, same one-route-per-item rule.

   On the assignments screen it sits ABOVE the filter row, not below it. Those
   filters belong to the table ("All / Running / Complete / Needs setup"), and a
   block sitting between a filter and the thing it filters reads as though the
   filter applies to it too.
   ------------------------------------------------------------------------ */
.running {
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  margin-bottom: var(--space-4);
}

/* The block's head wears the same dress as a table's head, because it is the same thing: a label
   over a set of rows. Measured, the two were not the same thing at all — a table's `th` carries
   `--brand-surface-alt` and a 2px `--brand-border-strong` rule under it, and this head carried no
   ground and a 1px hairline. So the two components read as two constructions of the same idea, and
   the heading of a list was the one place on the page where a heading was not marked as one.

   The partition heading INSIDE the block (`.record-group-head`) keeps the 1px rule, deliberately:
   it is a level below this one, and the two rules are what say so. */
.running-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  background: var(--brand-surface-alt);
  border-bottom: 2px solid var(--brand-border-strong);
}

/* A PART'S OWN MARK. A surface heads itself with a hero; a part of a surface heads itself with a
   band, and the band carries the sticker the part wore when it was a surface of its own — so a
   reader who arrived by the tile that shows that drawing meets the same drawing on arrival. It is
   a MARK rather than an illustration at this size: 32px, on the head's own axis, and drawn from
   the same file the hero used. See `DESIGN_SYSTEM.md` §5.30. */
.running-art {
  width: 32px;
  height: 32px;
  object-fit: contain;
  flex: none;
  align-self: center;
}

.running-title {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-small);
  font-weight: 700;
  color: var(--brand-ink);
}

.running-count { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* A block's BODY, where its content is not a list of `.running-item`s. The head carries the block's
   ground and its rule, so the body is what the head is a label over — and without it the first
   thing inside the block sits flush against the rule. */
.running-body { padding: var(--space-4); }

.running-list { margin: 0; padding: 0; list-style: none; }

.running-item {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--brand-border);
}

.running-item:last-child { border-bottom: 0; }
.running-main { flex: 1; min-width: 0; }

.running-name {
  display: block;
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
}

/* The phase is a LABEL, not a status. A status is a judgement about how something is going —
   success, warning, danger — and a phase is not one: Submission is not better or worse than
   Review, it is simply the stage that happens to be open. So it takes the system's label
   treatment rather than a pill, and any judgement in the row is carried by the row's own
   words. The separator is drawn by CSS so it disappears with the label — and it is glued to the
   label with a NON-BREAKING space so that it cannot begin a line. A plain leading space is a break
   opportunity, and the mark then leads the line it should have ended: "· 84 of 128 submitted". The
   break belongs after the mark, which is where it now is — the same rule, for the same reason, as
   the hero's summary line and the table foot's count, where it was found. */
.running-phase {
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink);
}

.running-phase::after { content: "\00A0· "; color: var(--brand-ink-faint); }
.running-meta { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* One route per item, and it is the button. The name is deliberately NOT a second link: the
   table already opens the assignment, and a row here that opened both the assignment and its
   process would be two destinations in one line, with the reader left to guess which half they
   were pointing at. The route is what this block is for; the record is what the table is for. */
.running-enter { flex: none; }

/* The empty state holds the block's shape rather than collapsing it, so the page does not jump
   when a phase opens. "Nothing is running" is information: it is the difference between a class
   that is between phases and a class whose setup is wrong. */
.running-empty {
  padding: var(--space-5) var(--space-4);
  font-size: var(--text-small);
  color: var(--brand-ink-muted);
}

/* The route stacks: the name and its facts take the full width, then the button goes under them,
   also full width. A part-width button beside two lines of text is a small target for the one
   control this block exists to offer.

   This block has to come AFTER the rules above, not with the other phone rules earlier in the
   file: every selector here is one class deep, so source order is the only thing that decides,
   and a phone rule written above its component is silently overridden by it. */
@media (max-width: 599px) {
  .running-item { flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
  .running-main { flex: 1 1 100%; }
  .running-enter { flex: 1 1 100%; text-align: center; }
}

/* ---------------------------------------------------------------------------
   Composite components — the twelve the feature briefs declared as missing.
   Each is assembled from base components, never from raw CSS.
   ------------------------------------------------------------------------ */

/* The bulk action bar that used to sit here, above the table, is gone. Its actions now live in
   the table's own footer, beside the count and the pager — see `.bulk-action` above. It was
   never a composite of its own: the footer already reports on the table as a whole, so the
   controls that act on the table belong in it. */

/* 1. Drag handle — a grip at the left of a row you can reorder.
   The grip is a MASK like every other mark in the kit, not a character. It was drawn as a box with
   a `font-size` and no drawing, so the first screen to use it would have had to supply a glyph —
   and a glyph depends on the reader's fonts, which is the one thing the icon set exists to avoid:
   on a machine whose fallback face has no grip character the control keeps its box and shows
   nothing, and the row then looks like a row nobody can move. */
.drag-handle {
  flex: none;
  /* `border-box`, so `--size-grip` is the width the grip actually draws. Without it this span is
     22px PLUS its two 1px borders — 24px — and the column head above the row reserves 22, so every
     label in the head sits 2px left of the field it names. Measured on the workflow editor: the
     "Stage" label at x 92.5 over an input at x 94.5, with the "Kind" label correct at 606.5 because
     its field's box-sizing comes from the field family. A control's own token has to describe the
     control. */
  box-sizing: border-box;
  width: var(--size-grip); height: 28px;
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--radius-control);
  border: 1px solid var(--brand-border);
  background: var(--brand-surface-alt);
  color: var(--brand-ink-faint);
  cursor: grab;
}

/* Two classes deep, so it is order-independent: a bare `.drag-handle::before` weighs the same as
   `[data-icon]::before` and would lose on source order. */
.drag-handle[data-icon]::before { width: 14px; height: 14px; mask-size: 14px 14px; }

/* 2. Nested option row — a label field, a value field and a remove control on one line, and a
      THIRD and FOURTH field where the row has them (the stage list's actor, and its destination).

   The row WRAPS, and that is not a convenience: at a phone width a grip, a description, a kind, an
   actor and a destination do not fit on one line, and a row that could not wrap would take its
   overflow out of the description — the only field on it whose content is a sentence. Wrapping
   costs a line; a squeezed description costs the row its meaning.

   THE FOURTH COLUMN WAS PAID FOR OUT OF THE DESCRIPTION, AND THE PRICE IS RECORDED. Measured in the
   workflow editor at its widest — a 670px dialog — the row is ONE line of 42px and the description
   gets **154px** of it, where the same row with three columns gave it 330. The row does not wrap
   there, and that is deliberate rather than an oversight: the actor and the destination are read
   TOGETHER — the peer reviewers submit to the paid markers — and a wrap puts the destination on a
   line of its own, away from the column it is compared with. Below the dialog's width the row wraps
   anyway and the description takes a full line again, which is what the flexible basis below is for.
   Raising that basis to force a wrap at 670px was measured too: the description goes to 353px and
   the row to 80px, at the cost of separating the two columns the row exists to compare.

   The wrap is safe because the HEAD wraps with it. Both are flex rows built from the same four
   widths and the same flexible basis, so the same items land on the same lines at every width —
   which is the whole reason the two are defined from one set of numbers rather than each from its
   own. Measured after the fourth column was added: every label still lands on its field's left edge
   to within 0.00px, at 810px and at a true 390px. */
.option-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
/* `min-width: 0` is not a tidy-up. A flex item's automatic minimum is its own min-content size, and
   for a text field that is the UA's default width for `size` — around 200px, well over the 140px
   basis. The field therefore refused to shrink to its basis, the line-break arithmetic was done
   with the wrong number, and the row wrapped at a DIFFERENT place from the head: measured at 390px,
   the head held all four of its cells on one line while the row put its value field on the next, so
   the "Weight" label sat 201px from the field it names. The head did not move; the row did. */
/* The description is the field that gives up its width, so it is the field that has to say it did.
   Measured in the workflow editor at its widest, the four-column row leaves the description **154px**
   — enough for a stage's name, which is what identifies it, and not for the gloss after the em dash.
   A value cut off with no mark reads as a field that is broken; the ellipsis is what makes it read as
   a value with more to it, and the whole of it is one click away. Same position as the head's labels,
   which are truncated for the same reason: a clipped word is a thing the reader can see is wrong. */
.option-row .input { flex: 1 1 140px; min-width: 0; text-overflow: ellipsis; }
.option-row .input--value { flex: none; width: var(--width-option-value); }
.option-row .select { flex: none; width: var(--width-option-actor); }
/* After the rule above, and it has to be: the two selectors weigh the same, so source order is what
   lets the destination take its own width rather than the actor's. */
.option-row .select--dest { width: var(--width-option-dest); }
/* And the kind takes the VALUE's width rather than the actor's, for the same reason: the kind is a
   closed vocabulary of twelve short words, it sits under the head's `Kind` label, and a control
   given the actor's 168px would stop pointing at its own column. Also after the base rule. */
.option-row .select--kind { width: var(--width-option-value); }

/* 2b. The head above a list of editable rows — what each column is FOR.
   ------------------------------------------------------------------------
   A row of bare fields says nothing about which field is which, and the stage list is where that
   bites hardest: its two fields hold a stage's description and its KIND, and "Brief — the work is
   posted and read" beside "Brief" is two texts with no way to tell which one the platform reads as
   the kind. Nothing is broken — every field is present, labelled for assistive technology and
   working — and the reader still cannot fill it in.

   The head is a TABLE's answer, because it is the same question a table's `th` answers: micro,
   uppercase, muted column labels over the columns they name.

   It carries NO ground and NO rule under it, unlike a table's head, and that is deliberate. This
   head sits inside a form section, directly beneath a section heading and above a list of fields;
   a banded head there would read as a table that had been dropped into the middle of a form, and
   the section heading above it is already the band. It is a level below that heading, so it takes
   the fainter ink.

   The two end cells are SPACERS, not labels: the grip and the remove control are unlabelled, so the
   head reserves their width and nothing else. They are sized from the same tokens those two
   controls are, which is what makes the labels land over their fields.

   A label and its field are built from the SAME declaration, not from two that happen to agree:
   `.option-head-label` and `.option-row .input` share a flexible basis, and
   `--value`/`--actor`/`--dest` name the same three widths in both. That is what makes the two wrap
   together as well as align, and it is the only reason a third and a fourth column could be added
   at all. */
.option-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-faint);
}

.option-head-grip { flex: none; width: var(--size-grip); }
.option-head-end { flex: none; width: var(--size-icon-btn); }
/* The label is given the same floor as the field it names, so the two lines break together — and a
   label too long for its column is TRUNCATED rather than allowed to overlap the next one, because a
   clipped word is a thing the reader can see is wrong. */
.option-head-label {
  flex: 1 1 140px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.option-head-label--value { flex: none; width: var(--width-option-value); }
.option-head-label--actor { flex: none; width: var(--width-option-actor); }
.option-head-label--dest { flex: none; width: var(--width-option-dest); }

/* The list that holds a head and its rows. The head is CLOSER to its first row than the rows are to
   one another, and that is the whole of the arrangement: a head belongs to the list it names, so a
   gap as large as the row spacing would read as one more row of the form rather than as the label
   over a column. The gap is therefore the tighter one, and the rows re-space themselves. */
.option-list { display: flex; flex-direction: column; gap: var(--space-2); }
.option-row + .option-row { margin-top: var(--space-2); }

/* ---------------------------------------------------------------------------
   The station map — a process read as a ROUTE
   ---------------------------------------------------------------------------
   A workflow is a path, and a path is the one thing a table cannot draw. The stage list says what
   each step IS — its kind, who acts, where it goes — and says nothing about the route: where it
   starts, that each step follows the one before it, and that it ends. Read as a column of rows,
   three stages and three unrelated records look the same, and a reader has to assemble the process
   from the order of the rows.

   So the same facts are read a second way, as a ROUTE: a line with a station on it per stage, each
   station's facts stated in words, and the end marked. It is a station map in the sense a transit
   map is — the line is the sequence and the stations are the stops — and it is NOT a diagram to be
   decoded: every station carries its facts as text, the order is a real list, and the line is
   `aria-hidden` decoration drawn over it. That is the difference §11.2 insists on between a route
   and a diagram editor: nothing here is dragged, connected or positioned, and the map cannot say
   anything the list above it does not.

   THE LINE AND THE GAP ARE ONE NUMBER. A segment runs from its own station's node to the NEXT
   station's node — which is past the station's own box by exactly the gap the list declares — so a
   line drawn from one number and a gap set from another leaves the route broken at every station.
   Both read `--space-4`, and the last station draws no segment, because a route has nothing after
   its end.

   THE NODE IS CENTRED ON THE HEAD'S LINE, and both are built from `--size-route-line` for the same
   reason: a mark beside a line of text and a mark ON it are different marks, and only the second
   reads as a station. The line's own ends are the same number again, so the segment starts and stops
   at the centres of the two nodes it joins.
   ------------------------------------------------------------------------ */

.route {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The gutter is where the line and the nodes live and the station's content is indented past it, so
   the line runs beside the text rather than through it. */
.route-station {
  position: relative;
  padding-left: var(--size-route-gutter);
  min-height: var(--size-route-line);
}

.route-station:not(:last-child)::before {
  content: "";
  position: absolute;
  left: calc(var(--size-route-node) / 2 - 1px);
  top: calc(var(--size-route-line) / 2);
  bottom: calc(-1 * var(--space-4) - var(--size-route-line) / 2);
  width: 2px;
  background: var(--brand-border);
}

.route-node {
  position: absolute;
  left: 0;
  top: calc((var(--size-route-line) - var(--size-route-node)) / 2);
  width: var(--size-route-node);
  height: var(--size-route-node);
  /* `border-box`, or the token is a lie: the node's 2px ring is drawn OUTSIDE the 16px it names, so
     it renders 20px and its centre sits 2px below the head's — measured. The grip taught this
     lesson already; a mark whose drawn size is not the number it is built from misaligns every
     calculation that uses the number. */
  box-sizing: border-box;
  border: 2px solid var(--brand-border);
  border-radius: var(--radius-pill);
  background: var(--brand-surface);
}

/* The end of the route is the one station a reader does not pass THROUGH: a hollow node is a stop on
   the way and a filled one is where the route stops. */
.route-station--end .route-node {
  border-color: var(--brand-space);
  background: var(--brand-space);
}

.route-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin: 0;
  min-height: var(--size-route-line);
  font-weight: 500;
}

/* The number is the stage list's own row number, so the two views of one process are joined by the
   one thing both carry — and a reader who finds a step in the map can find its row. */
.route-step {
  flex: none;
  min-width: 1.4em;
  font-size: var(--text-micro);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--brand-ink-faint);
}

.route-name { min-width: 0; }

/* The facts. Each label is the stage list's OWN column head and each value is the words its control
   shows, so the map says nothing the editor cannot and a reader learns no second vocabulary. The
   pairs are wrapped in their own boxes because a label at the end of a line with its value on the
   next is a pair that has come apart. */
.route-facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-4);
  margin: var(--space-1) 0 0;
  font-size: var(--text-small);
}

.route-fact { display: flex; align-items: baseline; gap: var(--space-2); }

.route-facts dt {
  flex: none;
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-faint);
}

.route-facts dd { margin: 0; color: var(--brand-ink-muted); }
.icon-btn:hover { box-shadow: var(--shadow-control-hover); }
.icon-btn:active { box-shadow: var(--shadow-control-active); }

.icon-btn {
  flex: none;
  width: var(--size-icon-btn); height: var(--size-icon-btn);
  display: flex; align-items: center; justify-content: center;
  border-radius: var(--radius-control);
  border: 1px solid var(--brand-border-strong);
  background: var(--brand-surface);
  /* The ink, not the muted ink, even though `color` was `ink-muted` here before. The cog used to
     be a picture with `#222222` baked into its SVG, so this property was ignored and the drawing
     came out the ink colour. A mask obeys `currentColor` instead, so the colour has to be named — and it
     is named as the colour the drawing was actually painted in. Left as `ink-muted` the cog would
     have silently lightened from #222222 to #5A5A5A. */
  color: var(--brand-ink);
  cursor: pointer;
  box-shadow: var(--shadow-control);
}

/* 3. Add-row button — a quiet button at full width, beneath a list.
   A FLEX row, which it was not. `[data-icon]::before` gives the mark a width and a height, and
   neither applies to a non-replaced INLINE box — so a plus on this control would have drawn
   nothing at all, and the control would have kept its dashed slot and lost its mark. Making the
   row a flex container is what lets it carry one, and it centres the label as a side effect,
   which is what a full-width slot's label should be.

   `box-sizing: border-box` is LOAD-BEARING and it was missing. This is a control that is told
   `width: 100%`, and a content-box box that is 100% wide is 100% PLUS its own padding and border
   — measured, 670px of column drew a **696px** slot, 26px too wide. In a dialog that 26px landed
   on the body's own padding and pushed 2px past it, so the dialog grew a horizontal scrollbar:
   the reader got a scrollbar to reach a slot that was already the full width of the column, and
   the control's dashed edge was drawn outside the panel it sits in. Exactly the defect `.textarea`
   documents for the field family, and the field family states this for itself for the same
   reason — there is no global reset in this stylesheet. */
.add-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  box-sizing: border-box;
  width: 100%;
  padding: var(--space-3);
  border-radius: var(--radius-control);
  border: 1px dashed var(--brand-border-strong);
  background: transparent;
  color: var(--brand-ink-muted);
  font-family: var(--font-body);
  font-size: var(--text-small);
  cursor: pointer;
}
/* THE SLOT IS SEPARATED FROM THE LIST BY MORE THAN THE LIST'S OWN ROW PITCH, and it needs saying
   because it was separated by NOTHING.

   `.stack` carries `gap: var(--space-4)`, and a gap applies BETWEEN a container's own children — so
   the rows are 16px apart and the add-row, which is a SIBLING of the list rather than a child of
   it, began with no space at all above it. Measured, the slot's top edge sat on the last row's
   bottom edge: the one control that belongs to the LIST was the one control that looked like part
   of a ROW, which is the exact reading the full-width slot exists to avoid.

   So it is one step further than the pitch it follows: rows sit `--space-4` apart and the slot sits
   `--space-5` from the last of them. That is the same proximity rule the bulk-action pair uses, one
   scale step up — the ratio between two gaps is the only thing that says "these are one thing" or
   "these are two", and at zero there is no ratio to read.

   The base margin is what separates the slot from anything else it follows — a note, a hint, a
   second slot — and it is cancelled when the slot is first, so an empty list does not begin with a
   space it cannot account for. */
.add-row { margin-top: var(--space-3); }
.add-row:first-child { margin-top: 0; }
.stack + .add-row { margin-top: var(--space-5); }

.add-row:hover { background: var(--brand-surface-alt); color: var(--brand-ink); }

/* 4. Conditional field group — a card shown or hidden by another control's value. */
.conditional-group {
  border: 1px solid var(--brand-border);
  border-left: 3px solid var(--brand-azure);
  border-radius: var(--radius-structure);
  padding: var(--space-4);
  background: var(--brand-surface);
}
.conditional-group > .group-label {
  font-size: var(--text-micro);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--brand-ink-muted);
  margin-bottom: var(--space-3);
}

/* 5. Dense editable grid — the table with inputs in its cells and a totals footer. */
.grid-table td { padding: var(--space-2) var(--space-3); }

/* An excluded row is dimmed, struck through and tinted — and the control that excludes it is the
   row's own checkbox, so the appearance is driven by the CHECKBOX and not by a class a script has
   to add. It was `tr.excluded`, which meant the reader's own tick changed nothing until something
   ran, and changed nothing at all if the script had failed. There is no `.excluded` class any more:
   it was a second source of truth for a state the checkbox already holds, and the two could
   disagree — a row that was struck through while its box was clear, or the reverse. */
.grid-table tbody tr:has(input[type="checkbox"]:checked) {
  color: var(--brand-ink-faint);
  background: var(--brand-surface-alt);
}
.grid-table tbody tr:has(input[type="checkbox"]:checked) .input { text-decoration: line-through; }

/* 6 & 7. File drop zone and attached file row. */
.dropzone {
  border: 1px dashed var(--brand-border-strong);
  border-radius: var(--radius-control);
  padding: var(--space-5);
  text-align: center;
  color: var(--brand-ink-muted);
  font-size: var(--text-small);
  background: var(--brand-surface);
}
.attached-file {
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  font-size: var(--text-small);
}
.attached-file .file-size { margin-left: auto; color: var(--brand-ink-muted); font-size: var(--text-micro); }

/* 8. Threaded reply — the read-only comment box with a reply indented beneath it. */
.thread { display: flex; flex-direction: column; gap: var(--space-2); }
.thread .reply {
  margin-left: var(--space-5);
  padding-left: var(--space-4);
  border-left: 2px solid var(--brand-border);
}

/* 9 & 10. Page view and page list, as independent components. */
.doc-stage { display: flex; gap: var(--space-4); align-items: flex-start; background: var(--brand-surface-alt); padding: var(--space-4); }
.page-view { flex: 1; min-width: 0; }
.page-list { flex: none; display: flex; gap: var(--space-2); flex-direction: column; }
.page-list--strip { flex-direction: row; overflow-x: auto; }
.page-list--collapsed { display: none; }
.doc-thumb {
  width: 44px; height: 58px; flex: none;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  display: flex; align-items: flex-end; justify-content: center;
  padding-bottom: 2px; font-size: 10px; color: var(--brand-ink-muted);
}
.doc-thumb[aria-current="page"] { border-color: var(--brand-ink); color: var(--brand-ink); font-weight: 600; }

/* 11. Wizard step — a head, a body and a foot with Back and Next. */
.wizard-step {
  border: 1px solid var(--brand-border-strong);
  border-radius: var(--radius-structure);
  background: var(--brand-surface);
  overflow: hidden;
}
.wizard-step > .step-head { padding: var(--space-4) var(--space-5) var(--space-3); border-bottom: 1px solid var(--brand-border); }
.wizard-step > .step-body { padding: var(--space-5); }
.wizard-step > .step-foot {
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-4) var(--space-5);
  border-top: 1px solid var(--brand-border);
}
.step-label { font-size: var(--text-micro); text-transform: uppercase; letter-spacing: 0.06em; color: var(--brand-ink-muted); }
.step-title { font-family: var(--font-heading); font-size: var(--text-h4); margin: var(--space-1) 0 0; }
.progress-segments { display: flex; gap: 4px; margin-top: var(--space-3); }
.progress-segments span { flex: 1; height: 4px; background: var(--brand-border); border-radius: var(--radius-structure); }
.progress-segments span[data-done="true"] { background: var(--brand-azure); }

/* 12. CSS-only dialog — a modal opened and closed by the URL fragment.
   ---------------------------------------------------------------------------
   A dialog has to open and close, and there is no script. `:target` is the one
   mechanism that does it with real controls: the trigger is an `<a href="#id">`,
   the close is an `<a href="#top">`, and the browser's own fragment handling is
   the state. That means the trigger is keyboard reachable, the browser's Back
   button closes the dialog, and the URL says what is open — none of which a
   checkbox-and-label hack provides.

   The catch is that `:target` matches exactly ONE element, so a dialog with two
   entry points cannot be targeted twice. New and edit are the same interface, so
   they are the same dialog, and the dialog is reached through a pair of empty
   anchor elements placed as its previous siblings. Which one is targeted decides
   the mode: only the mode-specific words change, never the fields.

   A page with MORE THAN ONE dialog must wrap each one, with its anchors, in a
   `.dialog-scope`. The `~` combinator only looks at siblings, so without the
   wrapper every anchor would open every dialog on the page at once. The wrapper
   holds nothing but fixed and zero-sized elements, so it takes up no space.

   What this cannot do, and the real implementation must: trap focus, close on
   Escape, and restore focus to the trigger on close. Those are behaviours, not
   appearances, and they belong to the application rather than to the design.
   ------------------------------------------------------------------------ */

/* A structural hook, and deliberately without declarations.

   It exists so the `~` combinator cannot reach past one dialog's anchors into the next dialog's:
   on a page with two dialogs an unscoped anchor opens both. It holds only fixed, zero-sized
   elements, so it occupies no space and needs no styling.

   It is declared here anyway so that "every class this project uses is defined in this file" stays
   literally true instead of acquiring a list of exceptions. An exception list is how the original
   two-stylesheet arrangement went wrong: the rule was true of each file and false of the page. */
.dialog-scope { }

/* The anchor is a scroll target and nothing else, so it is fixed and zero-sized: a target in
   normal flow would make the browser jump the page when the dialog opened. */
.anchor {
  position: fixed;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
}

/* Closed is the resting state. */
.dialog-backdrop--overlay { display: none; }

/* ONE SCROLLABLE PANEL AT A TIME.

   While a dialog is open, the page behind it must not scroll. The dialog covers the viewport and
   is the only thing the user can act on, so a second scrollbar on the page behind is not merely
   redundant — it is a scrollbar that does nothing useful and implies the background is still live.
   Worse, on a trackpad or a touch screen a gesture aimed at the dialog's own content can land on
   the page behind and scroll it out of position, so the dialog closes onto a different place from
   where it opened.

   The dialog is already capped at the viewport's height (`.dialog` is `max-height: 100%`) and its
   body scrolls internally (`.dialog-body` is `overflow: auto`), so suppressing the page's
   scrollbar leaves exactly one scrollable panel: the one the user is looking at. The page keeps
   its scroll POSITION, so closing the dialog returns the user to where they were.

   `:has()` is what makes this expressible: the dialog is opened by `:target` on an anchor that
   sits ABOVE the backdrop, so no sibling combinator can reach the root from it.

   The scrollbar this removes is NOT free, and the first version of this rule treated it as if it
   were. `overflow: hidden` stops the element being a scroll container, so nothing reserves the
   scrollbar's width and the page — which is fluid — reflows 15px wider the moment the dialog
   opens. `html { scrollbar-gutter: stable }` reserves that width permanently and the reflow goes
   away entirely: measured on the assignments screen at 1440px, the table holds at 1137px and the
   header at 1425px with the dialog open, against 1152 and 1440 without the rule.

   That measurement is worth recording, because an earlier one concluded the opposite and the
   conclusion was wrong. `scrollbar-gutter: stable` was judged not to reserve anything, on the
   evidence of `documentElement.clientWidth`, which reports the viewport width INCLUDING the
   reserved gutter and so reads 1440 either way. The gutter is real; the instrument was not. The
   body's width and the widths of real elements are what show it. */
body:has(.dialog-open:target) { overflow: hidden; }

/* ...and the width that scrollbar was taking is reserved, so the page does not move.

   `overflow: hidden` on the body propagates to the viewport, which stops the viewport being a
   scroll container — so the scrollbar goes and nothing reserves its 15px. A fluid page then
   reflows every column 15px wider the instant a dialog opens, and back again when it closes.

   `scrollbar-gutter: stable` reserves that width whether the scrollbar is there or not. It has to
   be on the ROOT, because the gutter belongs to the viewport: an `overflow` that propagates from
   `body` takes the viewport's scroll container with it, and the property does nothing anywhere
   else.

   It is scoped to the open state rather than left on permanently, and that matters more than it
   looks. A reserved gutter is an EMPTY strip when no scrollbar is drawn, and the specimen sheets
   and screen renders are captured with scrollbars suppressed — so a permanent gutter would take
   15px off every sheet and every screen render, silently. Reserved only while a dialog is open, a
   closed page is untouched and an open one is exactly as wide as it was.

   Measured on the assignments screen at 1440px: the table holds at 1137px and the header at
   1425px with the dialog open, against 1152 and 1440 without this rule. */
html:has(.dialog-open:target) {
  scrollbar-gutter: stable;
  /* The reserved gutter shows the CANVAS, which is the root element's background — and a fixed
     overlay cannot reach it, because a fixed element is sized to the initial containing block,
     which excludes the gutter. `100vw` excludes it too once the gutter is reserved, so
     `100vw - 100%` is 0 and the gutter's width is not reachable from CSS at all; a border painted
     past the edge is clipped by the viewport before it gets there. So the canvas is painted to
     match the dimmed page instead. It is the veil's own colour over the page's surface, written
     as the mix rather than as a hex, so the two cannot drift apart. */
  background: color-mix(in srgb, #222222 28%, var(--brand-surface));
}

/* Opened by whichever `.dialog-open` anchor is the target. Written against the class rather
   than a particular id, so a new dialog needs markup and no new CSS. The plain `.anchor`
   is a scroll target only — it closes rather than opens, which is why the two are separate
   classes and not one. */
.dialog-open:target ~ .dialog-backdrop--overlay { display: flex; }

/* The veil is the DIM, and it is nothing else: it is not a target, and clicking it does not
   dismiss the dialog. A modal dialog is a state the reader entered deliberately, and the way out
   is a control that says so — the footer's quiet Cancel — rather than a click on the page around
   it. The shade was an `<a href="#top">` for as long as it was also the way out; dismissing by
   clicking away is how a half-filled form is lost, and a form with a rubric's criteria or a
   workflow's stages in it is exactly the thing a reader must not lose to a stray click.

   It is a `<span aria-hidden="true">` now, and `pointer-events: none` makes that a fact rather
   than a convention: the shade cannot be clicked at all, so nothing can be hung on it later by
   accident. It still sits UNDER the panel — `position: absolute` with the dialog lifted above it —
   so a click inside the form reaches the form.

   The browser's Back button still closes a dialog, because the fragment is the state and that is
   the platform's own affordance rather than one of ours. It is not a substitute for Cancel: it
   leaves the dialog and the page it was opened from together. */
.dialog-veil { position: absolute; inset: 0; pointer-events: none; }
.dialog-backdrop--overlay .dialog { position: relative; z-index: 1; }

/* One dialog, two entry points, and only the mode-specific parts differ. `revert` is the right
   value here rather than `block` or `inline`: it restores whatever the element's own display
   would have been, so the same rule works on a heading, a paragraph, a span inside a button
   and a whole form body without naming any of them. */
.dialog-mode-edit { display: none; }
.dialog-open[data-mode="edit"]:target ~ .dialog-backdrop--overlay .dialog-mode-new { display: none; }
.dialog-open[data-mode="edit"]:target ~ .dialog-backdrop--overlay .dialog-mode-edit { display: revert; }
/* And the mode's own control, restated as `inline-block` — which is what `revert` gives a
   `<button>` and NOT what it gives an `<a>`, whose own display is `inline`. The role editor's two
   mode controls are links now, because the step after this one is a page rather than the rest of
   the dialog, and a link left at `inline` sits on a line box: the same control measured **42px**
   against its sibling's **40px**, which is the fault `.btn` states its own display to prevent.
   Both element types are named, because both are used and only one is the default. */
.dialog-open[data-mode="edit"]:target ~ .dialog-backdrop--overlay button.dialog-mode-edit,
.dialog-open[data-mode="edit"]:target ~ .dialog-backdrop--overlay a.dialog-mode-edit { display: inline-block; }

/* ---------------------------------------------------------------------------
   Responsive: what changes with width, and nothing more.
   Columns and density only — no component changes between bands.
   ------------------------------------------------------------------------ */

.grid-2 { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
@media (min-width: 600px) { .grid-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }

.split { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
@media (min-width: 1100px) { .split { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); } }

@media (min-width: 1100px) {
  .app-main { padding: var(--space-6); }
  .summary-strip { gap: var(--space-6); }
}

/* THE PHONE BAND NO LONGER HAS ANYTHING TO SAY ABOUT A TABLE. A table's form is decided by the
   width of the column it is standing in, which is the container query below; what is left here is
   the frame's own header giving up the words a phone has no room for. */
@media (max-width: 599px) {
  /* The header keeps both selectors, because the frame path is how you know where you are —
     but it gives up the secondary text, which is what the truncation would have eaten anyway.
     A class is identified by its code and a user by their initials; the descriptors are for
     when there is room to read them. */
  .app-header { gap: var(--space-2); padding: var(--space-2) var(--space-3); }
  .app-header .app-name { font-size: 1.125rem; }
  .app-header .select-meta { display: none; }
  .app-header .header-right .user-name { display: none; }
  .app-header .frame-select { padding: var(--space-2) var(--space-3); font-size: var(--text-micro); }
}

/* ---------------------------------------------------------------------------
   THE TABLE BECOMES CARDS WHEN ITS OWN COLUMN CANNOT HOLD IT
   ---------------------------------------------------------------------------
   Same fields, same order, one card per row. This is not a phone rule and never
   was one: a table does not scroll sideways on a phone, it scrolls sideways
   wherever it is standing in less room than it needs — and in this frame the
   WORST place to stand is the RAIL band, not the phone. Measured on the People
   screen: a 1100px window gives the table a **781px** column, because the rail
   takes 254 and the frame's padding 64, while a 1099px window gives it **1051**.
   A viewport media query cannot see that difference; a container query can.

   So the container is the table's OWN WRAPPER, and the number is the width the
   widest table on the console needs: measured with `width: min-content`, the
   Credentials list needs **911px**, then Systems 866, People 861, Organizations
   825, Downloads 769, and down to Role and System group at 470. 910px is that
   threshold, so the table form appears exactly when the column can hold the
   widest of them and not one pixel before.

   Every stacked table is inside a `.table-wrap` — 17 of them across 14 screens,
   and the wrapper is already the box that owns the sideways scroll — so the
   query is the width the table actually has, in any band, at any window size.
   The alternative was two viewport queries (one for the drawer band, one for the
   rail) whose numbers would have had to be re-derived by hand the day the rail
   changed width. `.table-wrap` carries `container-type: inline-size` below.

   The consequence is worth stating: a table with four columns, which needs 470px,
   becomes cards as soon as its column is under 910 — earlier than it strictly had
   to. That is the price of one threshold for a set of tables of different widths,
   and it is the right side to err on: a card is the same fields in the same order,
   and a sideways scrollbar is not.
   ------------------------------------------------------------------------ */
@container (max-width: 910px) {
  /* THE HEAD SURVIVES AS A BAND, WITH ITS OWN WORDS ON IT.

     The stack hid the whole header row, which is right for the column names — the card carries them
     itself, in `data-label` — and wrong for the one control in it that is not a column name. The
     select-all acts on the SET rather than on a column, and hiding the head took it with everything
     else; the operator's report was that a card list had lost the ability to select everything at
     once, which is the one gesture that makes twelve cards workable in bulk.

     The first attempt put the control back in an otherwise empty band, and that was wrong in a way
     worth recording: it read as a ROW THAT HAD LOST ITS OTHER SIX CELLS — a lone checkbox over
     nothing, which the operator reported as "isolated at the top of the table with an incomplete
     row". A control returned without its context is not the control back.

     So the band is the table's HEAD, in the head's own dress: the alt ground and the 2px rule that
     closes every header row in this kit, with the mark column's own cell at its left. The words are
     the control's own, and they arrive in `data-label` on the header cell — the same mechanism the
     cells below use to carry their column's name into the card, so the words are in the markup where
     a screen reader and a translator can reach them. They are the SHORT form ("Select all"): the
     accessible name beside them already says what the set is ("Select all people"), and a visible
     label repeating the whole sentence would be the same words twice on one line.

     The cell is a GRID with the mark column as its first track, which is what puts the tick and the
     words on the same x as the cards below: the card's tick is centred in a `--space-7` column and
     its first field starts where that column ends, and this band is built from the same two numbers
     rather than from an inset that happens to look close. */
  /* THE BAND EXISTS ONLY WHERE THERE IS SOMETHING TO SELECT, and the test is the MARK rather than
     the class. `select-col` is the kit's name for the narrow column the row's control stands in,
     and the row's control is not always a mark: `credential.html` (Authorized users) and
     `system-group.html` both put a REMOVE button in their trailing `select-col`. Unscoped, those
     two tables grew a 48px band with no tick and no words in it — the head's ground and its 2px
     rule with an empty grid over them: a context returned with no control, which is the same
     defect as a control returned with no context, seen from the other side. */
  .table-stack:has(thead th.select-col > input[type="checkbox"]) thead,
  .table-stack:has(thead th.select-col > input[type="checkbox"]) thead tr {
    display: block;
    background: var(--brand-surface-alt);
    border-bottom: 2px solid var(--brand-border-strong);
  }
  .table-stack thead th { display: none; }
  .table-stack thead th.select-col:has(> input[type="checkbox"]) {
    display: grid;
    grid-template-columns: var(--space-7) minmax(0, 1fr);
    align-items: center;
    height: 44px;
    /* `width: auto` because the table's own select column is `width: 1%` — it is the narrowest
       column in the table, which is what keeps a mark column 34px wide — and that 1% survives into
       the band, where the cell is no longer a table cell: measured, the grid was 2.8px wide with its
       fixed 48px mark track overflowing it and the words track collapsed to 0, so the label was
       there, correct and invisible. A block-level grid fills its parent unless it is told not to. */
    width: auto;
    padding: 0;
    background: none;
    border: 0;
  }
  .table-stack thead th.select-col > input { justify-self: center; }
  .table-stack thead th.select-col::after {
    content: attr(data-label);
    font-size: var(--text-micro);
    font-weight: 500;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--brand-ink-muted);
  }

  .table-stack tbody tr {
    display: grid;
    /* HOW MANY PAIRS SHARE A LINE IS THE LINE'S OWN BUSINESS. A pair is a label with its value, and
       once a pair is one line (below) its width is a fact about the words in it rather than about
       the layout: `CONTROL PLANE [Off]` is 156px and `PERSON [Alice Smith]` wants more. A fixed
       1fr/auto pair of tracks assumed the cell was no wider than its label, which stopped being true
       the moment the value joined the label — measured, the row came to 319px of pairs inside 240px
       of card at 320. So the tracks AUTO-FIT with a floor: two across where two fit, one where they
       do not, and no empty track either way. The floor is `min(170px, 100%)` for the reason §7.6's
       other floors are — a floor wider than its container is content wider than the page. */
    grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr));
    gap: var(--space-1) var(--space-3);
    padding: var(--space-3) var(--space-4);
    border-bottom: 1px solid var(--brand-border);
  }

  /* THE TRAILING CHEVRON IS THE CARD'S, NOT THE LAST CELL'S. In the table the last cell reserves
     `--space-7` of padding so that its words stop short of the chevron drawn into that cell's own
     padding. In the card the chevron is centred on the ROW — `top: 50%` of a cell the grid has
     stretched to the whole card — while the cells' contents sit at the TOP of it, so the two are
     almost never beside each other. The reserve stayed, and it was being paid by the last FIELD's
     pair out of a track the pair needed: measured at 480px, `STATUS [Suspended]` needs 162px and
     had 120, so the label came out on the line above its own pill — the same fault as the block
     label, arrived at from the other end.

     So the clearance is stated on the row, where the chevron is: the card's content stops 48px
     short of its right edge, which is the space the chevron occupies. The chevron itself does not
     move — it is placed against the containing block's padding edge, so it was never being pushed
     by the padding it was being given. */
  .table-stack tbody tr:has(.row-link) { padding-right: var(--space-7); }
  .table-stack tbody tr:has(.row-link) td:last-child { padding-right: 0; }

  /* THE LABEL IS READ WITH ITS VALUE, NOT ABOVE IT, and this is the fix for what the operator
     reported twice over: on the People card the label "Systems" sat one line above its own figure
     "6", and "Control plane" one line above its own pill — so a label and the thing it names read as
     two unrelated lines, and six fields of a person came out as twelve.

     The label was `display: block`, which is what put the value on the line below it. It is now a
     ROW — `display: flex; align-items: baseline`, which is the kit's own label/value pair and the
     same two declarations `.summary-row` opens with, on the record pages' "At a glance" — so the two
     are one line by construction rather than by luck.

     The label itself does not wrap: `flex: none; white-space: nowrap`. A label that broke inside
     itself ("REMOTE" over "LOGIN") would separate itself from its value just as surely as the block
     did, and the pair exists to be read as one thing. `flex: none` is the other half: the label is
     sized to its words and the VALUE takes the slack, so a long value wraps under itself rather than
     pushing its label to a second line. */
  .table-stack td {
    border: 0; padding: 0;
    display: flex;
    /* `wrap` is what makes the pair a promise the layout can keep: the label can never end up on a
       line of its own, because if a value does not fit beside it the VALUE drops below — the pair
       breaks after the label's words rather than inside them. Measured, the widest pair on the
       console is `CONTROL PLANE [Off]` at 162px, which is what the grid's floor is sized against. */
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--space-2);
  }
  /* `text-align: right` went with the columns. It right-aligned the figures against a column of
     other figures, and a card has no columns: the figure now stands beside its own label, so it
     takes the same position every other value takes. */

  /* THE COLUMN'S NAME TRAVELS WITH ITS VALUE. The stack HIDES the header row — that is what turns a
     row into a card — so without this the card is a row of BARE VALUES and the reader cannot tell
     which one is the role and which is the state. Measured on the specimen before this rule existed:
     the card read "A. Whitfield | Student | Active", three values and nothing naming any of them,
     which is worse than the sideways scroll it replaced — the scroll costs a gesture, this costs the
     column names. The label is the header's OWN WORDS, carried by the cell rather than copied into
     it, so a header that is re-worded re-words every card beneath it, and a cell that has no label
     (the selection column, which is hidden here anyway) simply draws none. */
  .table-stack td[data-label]::before {
    content: attr(data-label);
    flex: none;
    white-space: nowrap;
    font-size: var(--text-micro);
    font-weight: 500;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--brand-ink-muted);
  }

  /* A GROUP HEADING IS A BAND, NOT A RECORD. The stack turns every `tr` into a grid of labelled
     fields, which is right for a record and wrong for a heading. Left as a grid, the group row kept
     that grid's own 12px/16px padding, so its grey heading sat inside a white gutter on all four
     sides of it — the only band in the table that did not reach the frame, and the more conspicuous
     for being the only one. Measured at 390px: the wrapper is 358px wide and the heading was 312px
     of it, 16px of white down each side and 12px above and below.

     It keeps its full width instead, so the band runs border to border exactly as the column header
     above it does — and it is the more important of the two to get right, because the stack HIDES
     the column header, which leaves this heading as the only thing naming the rows beneath it. */
  .table-stack tbody tr.table-group { display: block; padding: 0; border-bottom: 0; }
  .table-stack tbody tr.table-group th { display: block; }

  /* THE CARD KEEPS ITS TICK, AND THE FOOT KEEPS ITS ACTIONS.

     The stack used to drop both: the checkbox column was hidden on the reasoning that a card has no
     column to tick in, and the bulk-action pair went with it because buttons that act on a selection
     act on nothing when there is none. That left the operator with a card that could be READ and not
     ACTED ON — the one thing a list of records is for, and the thing a phone needs most, because a
     phone is where a reader works down a list ticking things. So the tick comes back, and the strip
     that acts on it comes back with it. The two are one decision and `check_media_blocks` holds them
     together in both directions: whichever half a later edit hides, the other has to go with it.

     THE MARK GETS A COLUMN, THE WAY IT HAS ONE IN THE TABLE. The tick is absolutely positioned in a
     `--space-7` band down the card's left edge and the card's content starts where that band ends, so
     every field of every card begins at the same x — the card's own mark column, mirroring the
     chevron's band on the right, which the row already reserves for the same reason. It is the corner
     the kit already puts a tick in: `input.thumb-pick` sits at the top-left of a thumbnail tile and
     is lifted above the stretched link for the same reason too — without that lift the overlay would
     swallow the tap and ticking a card would open it.

     The band is 48px rather than the table's 34, because a mark that is a TAP TARGET on a touch
     screen needs a finger's worth of box around it rather than the 18px mark alone. The mark is
     centred in it — measured at (24, 22) from the card's top-left, against a first line of text whose
     own centre is 22.5, so the tick and the words it belongs to read as one line rather than as a
     mark that happened to land near it.

     The selection it makes is a FILL, and that rule is the table's own — `.table tbody
     tr:has(input[type="checkbox"]:checked)` — driven by the checkbox rather than by a class kept in
     step with it, so a card that is ticked says so by the same one mechanism every other selectable
     thing in the kit uses. */
  .table-stack tbody tr { position: relative; }

  /* The mark gets its column on a row that HAS a mark, and on no other row. Measured on the two
     tables whose `select-col` is an ACTION column: this rule's `padding-left` gave every card a
     `--space-7` band down its left edge with nothing in it, so all four of their fields started
     48px in to make room for a mark that is not there. */
  .table-stack tbody tr:has(> td.select-col > input[type="checkbox"]) { padding-left: var(--space-7); }

  /* THE TICK IS A 44px TARGET CENTRED ON THE CARD'S FIRST LINE, and that line is where the field's
     own LABEL is.

     It has moved twice, and both positions are worth recording, because they are the two things a
     reader can take the mark to belong to. It was a band at `top: 0`, centre y**22** — the centre of
     the first line, which is right for a card whose label and value share that line and wrong for
     one whose value is a BLOCK: on `credential.html`'s Authorized users the pair runs y12..95, so the
     mark sat 32px above its own field's centre. It was then a grid item centred in the field's area,
     centre y**54** on the same card — which centres on the VALUE and leaves the LABEL, the half that
     names the field, 33px above it. The operator's two reports, in order: "the first label and value
     in the card cell (i.e. Person & Name) should be optically vertically centred with the checkbox",
     then "can you optically vertically centre the 'Person' label when it appears next to the checkbox
     in the card format".

     Both hold at once ONLY where the label and the value share a line, which is most cards and is not
     the ones whose value is a person — avatar, name and note is 202px of content in a 177px track, so
     the pair must wrap. Where they cannot both hold, the LABEL is what the reader is looking for
     beside the mark: it is the field's name, and it is what the label of every other field does. So
     the target is anchored to the FIRST LINE and the value's own lines are below it.

     The negative `margin-top` is the row's own padding. `align-self: start` puts the target's top at
     the first line's top (y12), and a 44px target around an 18px line needs 12px above it — exactly
     the padding the row already has. The mark's centre lands at y22 against the label's line at
     y12..30, whose own centre is 21. `position: relative` and not absolute, because a static grid item
     cannot carry the `z-index` that lifts the mark above the row's stretched `.row-link`, and without
     that lift the overlay swallows the tap. */
  .table-wrap:has(.table-stack) tbody tr > td.select-col:has(> input[type="checkbox"]) {
    display: flex;
    align-items: center;
    justify-content: center;
    grid-row: 1;
    grid-column: 1;
    align-self: start;
    justify-self: start;
    position: relative;
    z-index: 1;
    width: var(--space-7);
    height: 44px;
    margin-top: calc(-1 * var(--space-3));
    margin-left: calc(-1 * var(--space-7));
    padding: 0;
  }

  /* And the field BESIDE the mark shares that area, so the mark has the field's height to centre in
     while the other fields keep the order they had (auto-placement skips the occupied first cell and
     starts at the second, exactly as it did when the tick was out of flow). */
  .table-stack tbody tr:has(> td.select-col > input[type="checkbox"]) > td.select-col + td {
    grid-row: 1;
    grid-column: 1;
  }

  /* AND A `select-col` THAT HOLDS AN ACTION IS AN ORDINARY CELL, read with its own label like
     every other field of the card — which is what the table says too: the column is headed
     "Remove" and the button in it says "Remove".

     `padding: 0` is load-bearing and beats `.table .select-col`'s own `8px 0 8px 16px` on
     specificity (0,3,3 against 0,2,0), because that left padding would inset the button 16px from
     every field above it. What it must NOT do is stay `position: absolute`: the row's trailing
     chevron is drawn by `td:last-child::after`, so a positioned cell becomes the chevron's
     containing block and drags the mark into the corner with it — MEASURED at 900px before this
     was scoped: the cell was `0,0 48x44`, its 77px button came to x = **−14** (14px outside the
     card entirely) and the chevron went to the same corner, which the operator reported as "the
     Remove button and arrow for each card has shifted to the top left corner of the card cell". */
  .table-wrap:has(.table-stack) tbody tr > td.select-col { padding: 0; }

  /* A `tfoot` is a column of TOTALS, and a card has no columns to total. */
  .table-stack tfoot { display: none; }

  /* AND THE BULK-ACTION PAIR HAS TO SURVIVE A PHONE, which it did not: the strip it lives in was
     hidden here, so nothing had ever measured it at this width. A bulk action is a select sized to
     its widest option — deliberately, so the pair does not shuffle as the reader changes their mind
     — plus the button that commits it, and measured on the People screen that pair is **280px**
     inside a 320px phone's 277px card: **19px of the row hung outside the wrapper**, which is a
     sideways scroll inside the strip that must never have one.

     `min-width: 0` on the pair and on its select is the whole fix, and it is two declarations rather
     than one because a flex item's automatic minimum is its own content: without the first the pair
     refuses to be narrower than its widest option, and without the second the select refuses inside
     a pair that has already given way. The SELECT gives up its width and not the button — the button
     is what the reader commits with and has to stay whole, while a select narrower than its longest
     option clips that option, which is a clipped word in a menu they are about to open rather than a
     control that cannot be pressed. Nothing stretches: the pair is its natural width wherever there
     is room for it, and it is the shrink that is new. */
  .table-wrap:has(.table-stack) .bulk-action { min-width: 0; }
  .table-wrap:has(.table-stack) .bulk-action > .select { min-width: 0; }
}

/* ===========================================================================
   CALENDAR — one list of days, three densities
   ===========================================================================
   The brief asks for a month grid, a week view and an agenda. The week became a panel of
   its own, and what the width arranges is the month — a grid of columns, a row per day,
   a date heading over its rows. The obvious build for that is three blocks of markup with
   two of them hidden, which is three copies of every deadline on the page: three links for
   a screen reader to read out, three tab stops per item, and three places for one deadline
   to drift out of agreement with itself. A screen whose widths disagree about what is due
   is worse than one with a single width.

   So there is ONE list of days here and the densities are three arrangements of it,
   which is exactly what a media query expresses — the same move the table already
   makes when it stacks into labelled rows on a phone.

     month   >=1100px   7 columns, five weeks, the numeral in the cell's corner
     week    600-1099   7 columns, the current week only, each item showing its time
     agenda  <600px     one column, a written date per day, full-width rows

   The parts that only one density needs carry their own class and are off by default:
   the time, the kind label, the state pill, the chevron and the day's written date.
   Everything else is one element serving all three, so the densities cannot disagree.
   ------------------------------------------------------------------------ */

/* The bar shares its row with the scale switch at 600px and up, and the margin below is what spaces
   it from the switch in the stacked phone band. On that row it is stood down — see
   `.tabbed--controls.cal-scale` — because the `.tabbed`'s own `--space-4` gap is already there and
   a margin the grid counts as part of the item is what pulled the switch off the bar's centre. */
.cal-bar {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-bottom: var(--space-4);
}

/* The month's own navigation, then the button that returns to now. Two weights, because they are
   two different things: stepping is a movement made repeatedly, and Today is a destination gone
   to once. */
.cal-nav { display: inline-flex; align-items: center; gap: var(--space-2); }
/* The calendar's three views are a `.subnav` on their own row beneath the bar, so there is no
   `margin-left: auto` here any more: that right-aligned them inside the bar, and a sub-menu whose
   hairline stopped two thirds of the way across the row read as a rule that ran out rather than as
   the edge of a navigation. It is left at the row's start, which is where every other sub-menu in
   the sheet sits and where the azure bar under the current item has a hairline to sit on. */
.cal-views { margin-bottom: var(--space-4); }

/* The views and the switch, on ONE line.
   ---------------------------------------------------------------------------
   "Outstanding only" is a VIEW of the calendar, not a filter on its contents: it does not narrow
   which classes or which kinds of work are shown, it narrows which of the things already shown are
   worth the reader's attention. So it belongs on the views' row, and it takes that row's right edge
   — the views are read left to right, and the switch is the one control there that is not a
   destination.

   The row owns the margin and the views give theirs up, because two stacked elements each carrying
   their own bottom margin is how a row ends up with a gap that belongs to neither of them.

   It WRAPS, which is what lets the switch drop to a line of its own at the widths where
   `.filter-switch-end` already does that — the rule is unchanged and still says what it said; only
   the row it acts in has moved. */
.cal-views-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-3) var(--space-4);
  margin-bottom: var(--space-4);
}

.cal-views-row .cal-views { margin-bottom: 0; }

/* The period, between the two step controls. It is not a control — the two arrows either side
   are — so it carries no fill and no shadow: it is the label those two controls act on. */
.cal-period {
  min-width: 12ch;
  text-align: center;
  font-family: var(--font-heading);
  font-size: var(--text-small);
  font-weight: 700;
  color: var(--brand-ink);
}

/* The sheet the grid is drawn on.

   The hairlines between cells are the GRID'S OWN BACKGROUND showing through a 1px gap, not a
   border on every cell. A border per cell needs `:nth-child(7n)` arithmetic to strip the trailing
   edge, and that arithmetic silently breaks the moment a density hides cells — which the phone
   agenda does, because `:nth-child` counts DOM position and not visible position. A gap cannot
   break: it is one line of CSS that is right for 35 cells, for 7, and for 1. */
.cal {
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  overflow: hidden;
}

.cal-weekdays {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  background: var(--brand-surface-alt);
  border-bottom: 1px solid var(--brand-border);
}

.cal-weekday {
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-muted);
}

.cal-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 1px;
  background: var(--brand-border);
}

.cal-day {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  min-height: 116px;
  padding: var(--space-2);
  background: var(--brand-surface);
}

/* Days outside the month keep the grid's shape — a calendar that closes up around the first of
   the month is not a calendar — but they are muted, because nothing in them is the reader's. */
.cal-day--out { background: var(--brand-surface-alt); }

.cal-day-num {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  font-size: var(--text-micro);
  font-weight: 500;
  color: var(--brand-ink-muted);
}

/* Today. The brief asks for a light tint AND a numeral filled deep azure with white text, and
   both are needed: the tint is the faintest thing on the page and the filled numeral is the part
   that is actually read. White on `--brand-azure-deep` is 7.66:1 — the same figure every other
   white-on-azure label in the system carries. */
.cal-day--today { background: var(--brand-azure-tint); }
.cal-day--today .cal-day-num {
  background: var(--brand-azure-deep);
  color: #ffffff;
  border-radius: var(--radius-pill);
}

/* The day written out, for the agenda's date heading. It is in the markup at every width and shown
   at one — the same arrangement the hero uses for its two summaries. */
.cal-day-date { display: none; }

.cal-events { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; }

/* An item on the calendar.
   ---------------------------------------------------------------------------
   Tinted by KIND — submission, review, feedback — from three families the system
   already has. The tint is a reinforcement and never the message: the kind is
   also a word in the agenda row, and the item is a link at every density, so the
   reader who cannot tell violet from haze loses nothing.

   The chip carries a hairline in its OWN ink at low alpha rather than a fixed
   grey. It has to be delineated on three different grounds — the white of an
   ordinary cell, the haze of today, and the grey of a day outside the month —
   and no single fixed hairline is right on all three. Derived from `currentColor`
   it is right on all three by construction, which is also what keeps a haze
   submission chip legible on a haze today cell.
   ------------------------------------------------------------------------ */
.event {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-2);
  padding: var(--space-1) var(--space-2);
  border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
  border-radius: var(--radius-control);
  font-size: var(--text-micro);
  line-height: 1.35;
  text-decoration: none;
  min-width: 0;
}

.event--submission { background: var(--brand-azure-tint);        color: var(--brand-azure-ink); }
.event--review     { background: var(--brand-violet-tint);    color: var(--brand-violet-ink); }
.event--feedback   { background: var(--brand-success-tint); color: var(--brand-success-ink); }

.event:hover { box-shadow: var(--shadow-control-hover); }

/* The class is the scan affordance: a reader looking for one class' work reads down the left of
   the stack, so the class short name is the heavy half and the assignment name the light one.

   The class never gives up its width and the assignment name always does. A class short name is a
   code — "BIOL 1102" truncated to "BIOL 1" identifies nothing — where an assignment name is prose,
   and "Field rep…" still says which assignment it is. */
.event-class { flex: none; font-weight: 600; white-space: nowrap; }
.event-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Off by default; each density turns on what it has room for. The state pill is the exception: a
   judgement about an item is worth its width at every density, and the brief asks for the overdue
   pill on the month chip as well as on the agenda row. */
.event-time,
.event-kind,
.event-chevron { display: none; }

/* A pill inside a chip is the small one, and it takes the family's SOLID fill rather than its
   tint: the chip is 12px type in a grid cell, and the status pill's own padding is sized for a
   table cell.

   The solid fill is not decoration, it is the whole reason the pill can be seen. A pill is a shape
   ON a chip, so its ground has to separate from the chip's ground — and the chip's ground is
   already a tint, which is the one thing a tint cannot do. Measured: `--brand-azure-tint` is
   (252, 222, 211) and `--brand-danger-tint` is (249, 213, 213) — **10/255 apart**, and their inks
   20/255 apart. So an overdue pill on a submission chip was the same pale pink in the same dark
   brown, on the one chip that most needed to say something. The azure and red families are
   neighbours by construction and no choice of kind tints fixes that.

   The fills and their inks are the ones the system already states, with the ratios it already
   measured: white on `--brand-danger` is 4.52:1, white on `--brand-success` 4.53:1, and
   `--brand-warning-ink` on `--brand-warning` is 4.63:1 — amber cannot carry white. It never shrinks,
   because a truncated pill says nothing and the brief's whole point is that the state is named. */
.event .status { flex: none; padding: 0 var(--space-2); border: 0; }
.event .status-on  { background: var(--brand-success); color: #ffffff; }
.event .status-off { background: var(--brand-danger);  color: #ffffff; }
.event .status-due { background: var(--brand-warning); color: var(--brand-warning-ink); }

/* The trailing chevron, drawn once for every row that leads somewhere. It is the only cue that
   survives into a static view — a hover fill needs a pointer to be seen — so it is drawn in the
   row's own ink rather than faintly. */
.event-chevron,
.row-chevron {
  flex: none;
  width: 8px;
  height: 8px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(-45deg);
}

/* The outstanding-only switch. A SWITCH and not a chip: a chip is one choice among several, and
   this is a state that stays on while the chips beside it are changed. */
.filter-switch {
  display: inline-flex;
  /* The words lead and the switch lands at the far end, which is what every other switch row in
     the system does — `.field-toggle` reverses its text with `order`, and this row has no text
     wrapper to order, so the container is turned around instead. Authored control-first and
     reversed here for the same reason `.field-toggle` is: the control comes first in the markup
     because that is the conventional order for a form control, and where it LANDS is a layout
     decision. Left unreversed it drew the answer before the question, and the switch of the one
     row that is a filter read as a different component from every switch in a settings list. */
  flex-direction: row-reverse;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-small);
  color: var(--brand-ink);
  cursor: pointer;
}
/* The label is part of the control, so the whole label has to answer the pointer — the switch
   itself is 44px wide and the words beside it are a larger target than that. The focus ring goes
   on the label rather than on the switch, because the switch's own ring would sit inside a target
   that is wider than it. */
.filter-switch:hover .toggle { box-shadow: var(--shadow-control-hover); }
/* The ring follows the KEYBOARD, not the pointer. It used to be `:focus-within`, and a switch takes
   no typing, so the ring appeared every time the row was clicked and meant nothing. Measured in
   Chromium 153 with a trusted mouse click: the switch comes back `:focus` true and `:focus-visible`
   FALSE, while the same switch reached by 24 Tab presses comes back `:focus-visible` true — so this
   one selector draws the ring for the keyboard and not for the pointer, and keeps it for the
   keyboard, which deleting the ring would have thrown away. Every other ring in the sheet already
   asks `:focus-visible`; this was the one that did not. */
.filter-switch:has(.toggle:focus-visible) {
  outline: 2px solid var(--brand-azure);
  outline-offset: var(--space-1);
}

/* The switch sits at the end of the filter row, away from the chips it is not one of. It is a
   CLASS and not an inline style, because the phone band has to undo it: a switch pushed to the far
   right of a line it shares with four stretched chips is stranded at the edge of the screen with a
   gap in front of it, so below the breakpoint it takes a line of its own and starts at the mark. */
.filter-switch-end { margin-left: auto; }
@media (max-width: 599px) {
  .filter-switch-end { flex: 1 1 100%; margin-left: 0; }
}

/* The grid is the MONTH's, at every width above the agenda, and the month's own cells are taller
   below 1100px so the times have room to show under the name.

   IT USED TO BE THE WEEK'S, and the rule that made it one has been removed rather than kept. Two
   densities for the same grid cannot both be right: the grid lives in the MONTH panel, so a week
   density shows a week under a marked "Month" tab — and the exemption that was written to prevent
   that (`#cal-month .cal-grid > … { display: flex }`) is scoped by an ID, so it also outranked the
   phone agenda's "a day with nothing in it is not drawn" and put nineteen empty date headings on a
   390px page. Measured, the week density was not even the better read: the columns are the same
   seven either way (108px at 810px against 163px at 1440px), so the same 12px names wrapped to the
   same three lines, and the density's whole gain was one row instead of five. The WEEK is a panel
   of its own now — a written list, one line per day — which is what a week wants at this width. */
@media (max-width: 1099px) {
  .cal-day { min-height: 168px; }

  .event {
    flex-direction: column;
    align-items: flex-start;
    gap: 1px;
  }
  .event-time { display: inline; font-variant-numeric: tabular-nums; opacity: 0.85; }
  .event-name { white-space: normal; }
}

/* The agenda: one column, a written date per day, each item a full-width row.

   Days with nothing in them are dropped here, and days outside the month with them. An agenda is
   a list of what is coming, and a date heading with nothing under it is a line the reader has to
   read in order to learn nothing. The month grid keeps those days, because there the shape of the
   month IS the information — which is the whole reason the two densities exist. */
@media (max-width: 599px) {
  .cal-weekdays { display: none; }
  .cal-grid { grid-template-columns: minmax(0, 1fr); }
  .cal-grid > .cal-day:not(:has(.event)) { display: none; }
  .cal-day { min-height: 0; padding: 0; gap: 0; }

  .cal-day-num { display: none; }
  .cal-day-date {
    display: block;
    padding: var(--space-2) var(--space-4);
    background: var(--brand-surface-alt);
    font-size: var(--text-micro);
    font-weight: 500;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--brand-ink-muted);
  }
  .cal-events { gap: 0; }

  /* The row is TWO lines, not one. At 390px a time, a kind label, a class code, an assignment name,
     a pill and a chevron do not fit across — and squeezing them onto one line does not shrink the
     name, it wraps it a character at a time: measured, "Problem set 3" drew as "P s 3" in a column
     four characters wide. So the facts of the item take the first line and the name takes the
     second, full width, which is what the name needs to be read at all.

     `order` does the reordering rather than the markup, because the DOM order is the one the month
     chip needs and the three densities must stay one list of days. */
  .event {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-1) var(--space-3);
    padding: var(--space-3) var(--space-4);
    border: 0;
    border-bottom: 1px solid var(--brand-border);
    border-radius: 0;
    font-size: var(--text-small);
  }
  .event-time { order: 1; display: inline; font-variant-numeric: tabular-nums; opacity: 1; }
  .event-kind {
    order: 2;
    display: inline;
    font-weight: 500;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }
  .event-class { order: 3; }
  .event .status { order: 4; }
  .event-chevron { order: 5; display: block; margin-left: auto; }
  .event-name { order: 6; flex: 1 1 100%; white-space: normal; }
}

/* ===========================================================================
   DASHBOARD — the classes, partitioned
   ===========================================================================
   The plan asks for the class list to be partitioned into active, test, disused and
   archived groups (§19.3), and the reason is the one thing a flat list cannot do:
   a class that finished two terms ago and a class running now are not the same
   kind of thing, and a list that puts them in one column makes the reader check
   the term on every row to find out which they are looking at.

   The partitions are headings INSIDE one framed block rather than four framed
   blocks. Four frames would read as four independent lists and would spend four
   borders and four gaps to say what one heading says.
   ------------------------------------------------------------------------ */

.record-groups { margin: 0; padding: 0; list-style: none; }

.record-group-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-4);
  background: var(--brand-surface-alt);
  border-bottom: 1px solid var(--brand-border);
  font-size: var(--text-micro);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--brand-ink-muted);
}

/* The count belongs to the heading, not to the list, because it is the heading that says how much
   is in the group — and a partition whose size is not stated makes the reader count the rows to
   find out whether they are looking at all of it. */
.record-group-count { font-weight: 400; color: var(--brand-ink-faint); }

.record-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--brand-border);
  color: var(--brand-ink);
  text-decoration: none;
  /* Pressable, and it says so — see `.feature-option` for why the declaration is load-bearing. */
  cursor: pointer;
}

/* Which row is last is a fact about the LIST, so the list's own element is the one that has to be
   asked. Written as `.record-row:last-child` first, which matches EVERY class row: the link is the
   only child of its `<li>`, so each one is a last child and the rule that was meant to drop the
   final separator dropped all of them. Measured, all six rows had `border-bottom: 0px none` and the
   class list read as one undivided block. */
.running-list > li:last-child > .record-row { border-bottom: 0; }
/* The class's subject, first in the row.
   ---------------------------------------------------------------------------
   It comes before the code because it says what the class is ABOUT before the code says which class
   it is, and in a list of six codes that is the difference between scanning and reading. It is
   smaller than the mark beside a class name on a class's own page — a list row is not a heading —
   and it is the one mark in the kit that is NOT `aria-hidden`: in a list there is nowhere else for
   the subject to be said, so it carries the subject as its accessible name and the row announces
   "Biology" before it announces the code. */
.record-row .row-mark { --size-row-mark: 40px; }

/* AND THE SAME MARK, AT THE SAME SIZE, IN THE CLASS PICKER. The picker is a list of the classes you
   belong to, so it is the same question the classes list asks and it gets the same answer: the
   drawing first, then the code, then the name. A reader who has learned that a leaf means Biology
   in the list meets it again in the dialog that chooses between them. */
.feature-option .row-mark { --size-row-mark: 40px; }

.record-row:hover { background: var(--brand-azure-tint); }
.record-row:focus-visible { outline: 2px solid var(--brand-azure); outline-offset: -2px; }

.record-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
/* The title of a class is UNDERLINED AT REST, and this is the one kind of list where that is right.
   ---------------------------------------------------------------------------
   The system's rule for a link's affordance turns on what the list IS, and there are two answers:

     * a list of DESTINATIONS — the classes you belong to, the class picker, a breadcrumb — is a set
       of places the reader is choosing between. The underline is the affordance, it is visible
       before anyone points at anything, and a reader scanning six codes is scanning six ways out;
     * a list of RECORDS — a table of submissions, a settings list — is a set of facts the reader is
       reading. The ROW carries the affordance there (a pointer, a hover fill, a chevron) and its
       inner link underlines under the pointer, because underlining every label in a dense list
       competes with the data and says "link" six times where the reader is reading numbers.

   So `nothing is underlined at rest` was never the rule; it was the answer for the second case,
   written down as though it were the answer for both. `.account-btn .user-name` has been underlined
   at rest since it was built, and it is the first case: a name that is a destination.

   The underline is on the TITLE and the whole row stays the target — the row is still what the
   reader clicks, and the underline says which words name the place. */
.record-row-code {
  font-weight: 500;
  text-decoration: underline;
  text-underline-offset: 3px;
}

.record-row-name { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* The class's phase tally. Three figures and three words, in the order the work happens, so the
   row answers "where is this class up to" without opening it. */
.record-steps { flex: none; display: flex; gap: var(--space-3); }
.record-step {
  display: inline-flex;
  align-items: baseline;
  gap: var(--space-1);
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
}
.record-step-n { font-weight: 600; color: var(--brand-ink); font-variant-numeric: tabular-nums; }

.record-row-role { flex: none; font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* On a phone the row is two lines: the class takes the full width, and the tally and the role
   share the line beneath it. A tally squeezed beside a long class name wraps into a column of
   single words, which is not a tally any more. */
@media (max-width: 599px) {
  .record-row { flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
  .record-row-main { flex: 1 1 100%; }
  .record-steps { flex: 1 1 auto; }
  .record-row-role { margin-left: auto; }
}

/* ===========================================================================
   THUMBNAIL SHEET — a collection read as pictures
   ===========================================================================
   A table answers "what is the state of each item": one row per item, and a
   column for every fact about it. A thumbnail sheet answers a different
   question — "what does each item look like" — with one tile per item, and the
   picture is the content rather than an illustration of it.

   Two screens need it, and they are ONE component. The class roster as a grid
   of people, and an assignment's submissions as a grid of title pages: the
   thing an instructor actually recognises, because a submission is remembered
   by its first page and not by its file name. The tile's SHAPE is the only
   thing that differs between the two; its anatomy, its states and its controls
   are the same.

   WHAT IT SHARES WITH A TABLE is the whole of the selection — a tick on every
   tile, a select-all over the set, a count, and the same bulk-action pair in
   the same kind of foot. That is not a coincidence to be factored away. The
   reader is being asked the same question, so it is answered with the same
   control, and a second way to answer it would be a second thing to learn.

   WHAT IT NEEDS AND A TABLE DOES NOT is the VIEW controls. A table's columns
   are fixed by its data; a tile has a size the reader chooses, and the same
   set can be read as pictures or as rows. The switch between the two is a
   CONTROL and not a navigation — it changes how the current set is drawn, it
   leads nowhere and it cannot be linked to — so it wears the segmented pair's
   dress, the one this kit gives an adjustment with two directions. The row form
   is deliberately NOT drawn again here: it is the table, already defined, which
   is why the switch has nothing of its own to show for it.

   The tile is STRUCTURE, not a control: square, hairline-bordered and flat,
   exactly like a clickable table row. Twelve tiles each carrying a control's
   raised shadow would be twelve shadows in a field of pictures, and the
   pictures are what the reader came for. The one affordance it earns is the
   one a clickable row earns — a fill under the pointer, a pointer cursor, a
   focus ring — and NOT a chevron, because the picture is already the thing that
   says "there is more to see here".

   IT NEEDS NO PHONE RULE, and that is worth saying because the table does. A
   table has to be rebuilt as cards at the narrowest width, which is why its
   checkbox column and its bulk actions have to be dropped together; a sheet of
   tiles is already a wrapping grid, so the narrowest width simply holds fewer
   tiles and every control on it still has something to act on.
   ------------------------------------------------------------------------ */

/* The frame is structure — `.thumb-sheet` is listed with `.card`, `.panel` and
   `.table-wrap` in the base layer, so its corners, its border and its lack of a
   shadow are stated once. All this adds is the clipping that lets the head and
   the foot sit flush inside it. */
.thumb-sheet { overflow: hidden; }

/* The head is the label over the set, so it wears the table head's dress — the
   alt ground under a 2px strong rule — for the reason §5.7 gives: a heading
   over a set of things is the same thing wherever it appears, and the one place
   a heading is NOT marked as one is the place a reader stops trusting it. */
.thumb-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3) var(--space-4);
  padding: var(--space-3) var(--space-4);
  background: var(--brand-surface-alt);
  border-bottom: 2px solid var(--brand-border-strong);
}

.thumb-title {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-small);
  font-weight: 700;
  color: var(--brand-ink);
}

.thumb-count { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* The view controls stand at the far end of the head, away from the label: they
   act on the whole sheet, and the left end is where the sheet says what it
   holds. Pushed rather than ordered, so the head keeps one reading direction
   whichever of the two ends is longer. */
.thumb-view {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-left: auto;
}

/* A labelled select in a strip — the same control as the pager's rows-per-page, and chosen for the
   same reason: it changes nothing but the view, the reader sees the result immediately, and setting
   it back is one click. So it commits on change, and an Apply beside it would be ceremony in a
   strip that already carries one commit control.

   THREE HOSTS, ONE RULE, and they are named separately rather than sharing one name because the
   strips they sit in are different components: a thumbnail sheet's head, an editor's toolbar, and
   a board card's foot. Listing all three here is what keeps them the same control — a second rule
   that happened to match today would be a second rule to keep in step, which is the mistake the
   table foot and the thumbnail foot were already merged to avoid.

   The board's host is the one that COMMITS rather than views: it sets the card's status, so it is
   the only one of the three whose change is written down. It wears the same dress anyway, because
   what the dress says is "a labelled select in a small space", and a control that looked different
   for being important would be a second vocabulary for the same shape. */
.thumb-control,
.editor-control,
.board-status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
  white-space: nowrap;
}

/* The tiles. `auto-fill` rather than a column count, because the tile's size is
   the reader's choice and the number that fit is a consequence of it — a fixed
   count would make the size control a lie at every width but one.

   The floor is stated rather than left to `auto`, which is the whole point of
   the `minmax`: a tile must never squeeze below the size at which its picture
   stops being recognisable, because recognition is the only reason a sheet of
   this kind exists. Below that floor the sheet shows fewer tiles per row, and
   it is the row that gives way rather than the picture. The floor is a token
   because the size control changes it. */
.thumb-grid {
  --thumb-min: 168px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--thumb-min), 1fr));
  gap: var(--space-4);
  padding: var(--space-4);
}

.thumb-grid--sm { --thumb-min: 118px; }
.thumb-grid--lg { --thumb-min: 232px; }

.thumb-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  /* The mat. A selection in this system is a FILL — see the table's rows — and
     a fill under a picture that bleeds to the tile's edge is a fill nobody can
     see. This strip of ground around the picture is where the state shows, and
     that is why it is here rather than being decoration. */
  padding: var(--space-2);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  cursor: pointer;
}

/* Hover is the warm transient fill and a settled selection is the quiet grey —
   the same way round as the table's rows, and for the same reason: a pointer
   passing over is momentary, while a tick stays ticked for as long as the
   reader is working down the sheet. A wash of haze down a column of ticks
   would shout for the whole time it is on screen. */
.thumb-tile:hover { background: var(--brand-azure-tint); }
.thumb-tile:active { background: var(--brand-azure-tint-deep); }

/* Driven by the CHECKBOX and not by a class kept in step with it — the same
   rule the table's rows follow, and it matters more here: a sheet is where a
   reader ticks a dozen boxes in a row, so a state that only a script could
   repaint would leave the sheet disagreeing with itself while they work.

   It beats both hover rules on specificity, so a ticked tile keeps its grey
   while the pointer crosses it and while it is pressed. */
.thumb-tile:has(.thumb-pick:checked) { background: var(--brand-surface-alt); }

.thumb-tile:has(.thumb-link:focus-visible) {
  outline: 2px solid var(--brand-azure);
  outline-offset: -2px;
}

/* The picture's frame. Recessed rather than raised: it is a window the picture
   is seen through, not a control. Its ground shows wherever the picture does
   not fill it, which is what makes a page read as a page on a desk rather than
   as a filled rectangle. */
.thumb-shot {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 3 / 4;
  overflow: hidden;
  background: var(--brand-surface-alt);
  border: 1px solid var(--brand-border);
}

/* The two shapes a sheet of this kind holds. A page is taller than it is wide
   and a person is square, and that is the whole of the difference between the
   roster and the submissions: the same tile, the same controls, the same
   states. */
.thumb-shot--page { aspect-ratio: 3 / 4; }
.thumb-shot--face { aspect-ratio: 1 / 1; }

/* A thumbnail is for RECOGNISING something, so nothing is cropped: a title page
   drawn `cover` loses the one part that identifies it, which is the top of it.
   The picture gives way to the frame rather than the frame to the picture. */
.thumb-shot img { width: 100%; height: 100%; object-fit: contain; }

/* A face is the one case where the subject is centred by construction, so
   filling the frame loses nothing and a portrait that fills it reads better
   than one floating in a letterbox. */
.thumb-shot--face img { object-fit: cover; }

/* The stand-in for a page whose image has not been produced yet. It is a real
   state — the render is made when the file arrives — and it is worth drawing:
   an empty grey rectangle says "broken", where a sheet with the proportions of
   a page says "not yet, and it will look like this". */
.thumb-page {
  position: relative;
  width: 76%;
  height: 88%;
  overflow: hidden;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
}

/* The lines stand in for the page's text, and they are DRAWN rather than
   written, so the placeholder needs no content of its own: the application
   replaces the whole element with the rendered page, and until then this is
   what carries the shape. */
.thumb-page::before {
  content: "";
  position: absolute;
  left: 16%;
  right: 16%;
  top: 20%;
  height: 46%;
  background-image: repeating-linear-gradient(
    to bottom, var(--brand-border) 0 2px, transparent 2px 9px);
}

/* The stand-in for a student with no picture on file, which is the common case
   in a roster imported from the institution's own records. Initials, in the
   palette's documented pair — the deep-azure ink on haze, 11.96:1 — because a
   placeholder too faint to read is a placeholder that has failed at the one job
   it has, which is to say who this is. */
.thumb-face {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 58%;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-pill);
  background: var(--brand-azure-tint);
  color: var(--brand-azure-ink);
  font-family: var(--font-heading);
  font-size: var(--text-h4);
  font-weight: 700;
}

/* The tick sits ON the picture, and that is the one place it can go. The
   caption may be one word or three and may be absent altogether, so a tick
   anchored to it would move up and down the sheet from tile to tile; the
   picture is the only region every tile is guaranteed to have.

   It is lifted above the stretched link below, or the overlay would swallow the
   click and ticking a tile would open it instead. The table's checkbox column
   is lifted for exactly the same reason. */
input[type="checkbox"].thumb-pick {
  position: absolute;
  z-index: 1;
  top: var(--space-2);
  left: var(--space-2);
  margin: 0;
}

.thumb-cap {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: 0 var(--space-1) var(--space-1);
  min-width: 0;
}

/* Both lines of the caption truncate, and the meta line is why that matters. A tile's caption
   is two lines whatever the data says: without this, one long file name wraps to three and the
   tile grows, so a grid row's height is set by its wordiest member and the sheet's rows stop
   lining up — a ragged grid is the one thing a sheet of pictures cannot afford, because the
   alignment IS the layout. The full value is one click away in the thing the tile opens. */
.thumb-name {
  overflow: hidden;
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.thumb-meta {
  overflow: hidden;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A pill in a caption hugs its own words. The caption is a flex column, so
   without this every status would be stretched to the tile's width and would
   read as a band across the tile rather than as a label on it. The board card is
   the same host in the same shape — a flex column of lines with a pill as one of
   them — so it is listed here rather than given a second rule that would have to
   be kept in step with this one. */
.thumb-cap .status,
.board-card > .status { align-self: flex-start; }

/* The tile leads somewhere, so it is a REAL link stretched over the whole tile
   — keyboard-reachable, openable in a new tab, and needing no script — exactly
   like a clickable table row. The mark is the hover fill, the pointer cursor
   and the focus ring; there is no chevron, because the picture is already the
   affordance and a chevron in a caption would be a second one. */
.thumb-link { color: inherit; text-decoration: none; }
.thumb-link::after { content: ""; position: absolute; inset: 0; }
.thumb-tile:hover .thumb-link { color: var(--brand-azure-deep); }

/* ---------------------------------------------------------------------------
   A TILE THAT IS A SET RATHER THAN A PICTURE.
   ---------------------------------------------------------------------------
   A gallery is a collection OF pictures, and the tile is the third thing a sheet of this kind
   holds: `.thumb-shot--face` is square and a person, `.thumb-shot--page` is taller than it is
   wide and a document, and `.thumb-shot--gallery` is wider than it is tall and a STACK.

   **The stack has the shape of what is IN it**, which is what makes a set of photographs and a set
   of pages one component rather than two: `.thumb-shot--gallery` is landscape because a photograph
   is, and the same stack wearing `.thumb-shot--page` is portrait because a page is. The pair is
   composed rather than duplicated, for the reason `.thumb-shot--face` and `.thumb-shot--page` are
   one class on the plain tile: the two facts are independent — that the picture is a SET, and that
   what is in it is a page — and a fourth name would be a fourth thing to keep in step.

   Only the picture region is new. The frame, the head, the foot, the tick, the bulk-action pair,
   the count and the pager are the thumbnail sheet's, unchanged, and they are the thumbnail
   sheet's because they belong to the SHEET and not to what the sheet happens to hold — which is
   what makes a roster, a set of submissions and a set of galleries one component rather than
   three that resemble each other.

   Three decisions, and each one answers a way of drawing a set that is worse:

   **It is a STACK and not a mosaic.** Four crops of four images is the other obvious drawing, and
   it lies twice over: it says the set is those four, when the set may be fifty, and it says the
   four are equal, when the cover is the one the application chose to represent them all. A stack
   says the only thing that is true of every gallery — there is a cover, and there is more behind
   it — and the disc says how much more.

   **The depth is TWO sheets and it does not change.** It is not a count and it is not a
   proportion; it is the difference between one and more than one, and a gallery of fifty and a
   gallery of five wear the same two edges. A stack that grew with the count would make the
   reader measure the picture to learn a number the disc is already printing.

   **A cover FILLS its frame.** The plain sheet is `contain` because a cropped title page loses the
   top of itself, which is the only part that identifies it. A photograph is not a document: it is
   composed to be looked at whole, it is the reason the frame is landscape, and a cover letterboxed
   inside its own stack reads as an image that failed to load. The PORTRAIT stack goes back to
   `contain`, because its members are pages again and a page is exactly the thing that must not be
   cropped — the shape and the fitting are one decision, and the shape is the members'.
   ------------------------------------------------------------------------ */

/* Landscape, because a photograph is. This is the shape that makes the third variant a variant
   and not a second component: the tile's anatomy is the same at every shape. */
.thumb-shot--gallery { aspect-ratio: 3 / 2; }

/* A stack of PAGES — a document read as a set. The same tile, the same head, the same count and the
   same two edges; the members are taller than they are wide, so the frame and the pile are too.
   Written as the composition of the two classes rather than as a fourth name, so it cannot drift
   from the shape it is: `.thumb-shot--gallery` says the picture is a SET and `.thumb-shot--page`
   says what is in it. */
.thumb-shot--gallery.thumb-shot--page { aspect-ratio: 3 / 4; }

/* The pile is the frame's shape, and the step is restated for it: a percentage in `translate()` is
   a share of the width on the x axis and of the HEIGHT on the y, so one pair of numbers cannot be
   the same distance on both axes at two different shapes. A 3:4 pile is a third taller than it is
   wide, so 12% of the width is 9% of the height — see the landscape pair below. */
.thumb-shot--gallery.thumb-shot--page .thumb-stack {
  aspect-ratio: 3 / 4;
  --stack-step-x: 12%;
  --stack-step-y: -9%;
}

/* THE ONE PLACE THIS FRAME DIFFERS FROM THE OTHER TWO, and it is the ground rather than the frame.

   The frame's ground is the backing a picture is seen on, and `.thumb-shot`'s own is the pale
   cool grey the roster and the submissions want: a single picture letterboxed inside it should
   recede. A PILE is the opposite case. What the reader has to see is the EDGE of each sheet, and
   the sheets are white — so a white sheet on a near-white ground is a set of hairlines on a field
   the colour of hairlines, which is what "a decorative border drawn twice" looks like. Measured
   against the pale ground, the stack read as a nested frame; against this one the sheets are white
   on grey and the pile has a silhouette.

   It is stated on the variant and not on `.thumb-shot`, because it is a property of the pile and
   not of the frame. The hairline is stepped up with it, from `--brand-border` to
   `--brand-border-strong`: the same value as the ground would be a frame with no edge on it, and
   the edge is what makes the grey read as a mat the pile sits ON rather than as a field the pile
   is part of. */
.thumb-shot--gallery {
  background: var(--brand-border);
  border-color: var(--brand-border-strong);
}

/* The pile. Every sheet is placed in ONE grid cell and separated by a transform, so the depth is
   drawn by the layout rather than by absolute offsets that would have to be kept in step with the
   tile's size — the size is the reader's choice here, and an offset written in pixels would be
   right at one setting and wrong at the other two.

   THE STEP IS TWO TOKENS, and they are a pair rather than a number because the step has to be the
   same DISTANCE on both axes and a percentage is not: a share of the width on the x axis and of the
   height on the y, and the pile is not square. The landscape pair is here and the portrait pair is
   on the portrait stack, because the shape decides the numbers — a shape that changed the aspect
   without changing these would lean, stepping half again as far sideways as upward. */
.thumb-stack {
  --stack-step-x: 12%;
  --stack-step-y: -18%;
  display: grid;
  /* Stated rather than left to `auto`: the track is the frame's whole content box, and `1fr`'s
     own minimum would let a long cover image push the pile past the frame it is seen through. */
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: minmax(0, 1fr);
  width: 70%;
  aspect-ratio: 3 / 2;
}

.thumb-cover,
.thumb-under { grid-area: 1 / 1; }

/* A SET OF ONE has nothing behind the cover, and so no pile to make room for. The margin around
   the pile is the pile's — it is the ground the stepped edges are read against — so with no edges
   there is nothing for it to hold, and the picture takes the frame instead.

   This is not tidying. Measured: the same margin with nothing in it reads as a stack whose sheets
   are MISSING — an unfinished or broken tile — rather than as a gallery of one, which is what it
   is. A picture that fills its frame is a complete thing; a picture floating in the space a pile
   would have used is a picture with a hole beside it. It is derived from the markup rather than
   declared, because "there are no sheets behind the cover" is exactly what `.thumb-under`'s
   absence already says, and a class would be a second copy of that fact. */
.thumb-stack:not(:has(.thumb-under)) { width: 100%; }

/* A sheet behind the cover. It is the cover's own surface and the cover's own hairline and
   nothing else — not a shadow, which structure never casts in this kit, and not a second
   photograph, because a second photograph at a quarter size says the gallery's second image is
   its cover. The reader counts the EDGES, and the edges are the whole of what it says. */
.thumb-under {
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
}

/* Two of them, stepped evenly up and to the right, so the pile reads as a depth rather than as one
   sheet that happens to be crooked. The second is exactly half the first, so the two edges are
   evenly spaced rather than merely different.

   THE STEP IS BOUNDED BY THE FRAME, which clips what leaves it. At 70% of a 3:2 frame the pile has
   10% of the frame's width of ground above it and the outermost sheet is stepped 8.4%, so the
   sliver each sheet shows is about 8px on a 204px frame and the pile keeps its own margin. A
   smaller step and the sheets read as a border drawn twice rather than as a pile; a larger one and
   the outermost sheet is cut off by the frame it is seen through. The portrait stack's pair is
   stated with its shape, above. */
.thumb-under:nth-child(1) { transform: translate(var(--stack-step-x), var(--stack-step-y)); }
.thumb-under:nth-child(2) {
  transform: translate(calc(var(--stack-step-x) / 2), calc(var(--stack-step-y) / 2));
}

/* The cover is the front sheet, and it clips its image. It is written LAST in the markup, and
   that is what puts it in front: the three sheets share one grid cell, so document order IS the
   stacking order and no `z-index` has to be kept in step with it. */
.thumb-cover {
  position: relative;
  overflow: hidden;
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
}

/* A cover fills its frame — see the note above. `.thumb-shot img` sets `contain` for the sheet's
   other two shapes; this is the one variant that overrides it, and the portrait stack puts it back,
   because its members are pages again. */
.thumb-cover img { object-fit: cover; }

.thumb-shot--gallery.thumb-shot--page .thumb-cover img { object-fit: contain; }

/* The drawn stand-in for a page FILLS the cover, because here the cover IS the page. `.thumb-page`
   is drawn for the document viewer, where it sits in a recess with the frame's own ground around it;
   inside a cover that is already a page, its own ground and its own hairline would be a page drawn
   inside a page, and its 76%-by-88% box would leave a margin of nothing at all. Only the box
   changes — the drawn lines are the same rule, and they are what makes the cover say "a page"
   rather than "a blank sheet", which is the one thing a stack of white sheets cannot say.

   Filled with `inset` rather than with `width: 100%`, so the box is the cover's own padding box
   exactly: a percentage would be the width of the containing block plus whatever the page brought
   with it, which is the defect `check_full_width_controls` exists to catch — and the page's own
   hairline has to go, or the cover wears two. The width and height are put back to `auto` with it,
   because the stand-in's own 76%-by-88% box is still declared and an over-constrained absolutely
   positioned box keeps its width and throws the inset away — measured, the page came out 106px
   wide inside a 141px cover, a page drawn inside a page after all. */
.thumb-cover .thumb-page {
  position: absolute;
  inset: 0;
  width: auto;
  height: auto;
  border: 0;
}

/* How many pictures are in the set, and it sits on the COVER rather than in the caption for the
   reason the tick does: the picture is the one region every tile is guaranteed to have, and the
   caption may be one word or three. It is the kit's own count disc, so the number inverts its
   host — a nightfall disc carrying the surface's white — and the pair is measured for legibility
   like every other count in the kit rather than eyeballed.

   The disc counts; the caption NAMES what it counted ("38 images"). A badge that carried the
   word as well would be the caption written a second time, on a tile that has room for neither. */
.thumb-badge {
  --control-fill: var(--brand-surface);
  --control-ink: var(--brand-ink);
  position: absolute;
  right: var(--space-2);
  bottom: var(--space-2);
  /* Above the stretched link below, so the disc is not a hole in the tile's own target. */
  z-index: 1;
}

/* The disc carries a left margin, because everywhere else in the kit it is drawn BESIDE a label.
   Here it is the whole of what the badge holds, so the margin would push the number off the
   corner it is anchored to. */
.thumb-badge .count { margin-left: 0; }

/* ---------------------------------------------------------------------------
   THE BOARD — work read as STATUSES, and a card that says which one it is in.
   ---------------------------------------------------------------------------
   A table answers "what is the state of each item" one item per row; a board answers the same
   question with the STATES as the first thing the reader sees, because the question a board is
   opened with is not "what is the state of this one" but "how much is sitting in each". So the
   columns are the statuses, a card is one piece of work, and the reader reads down a column to
   learn what is in it and across the board to learn where the work has piled up.

   **The board never scrolls sideways.** That is not a preference; it is the rule the scrollbar
   guard enforces on every surface (§5.29, `verify_scrollbars.py`). A board that scrolls across
   while the page scrolls down leaves the reader managing two axes to read one screen, and the kit's
   answer is the same one the table's capability matrix got: the collection changes FORM rather than
   growing a bar. So the board states a COLUMN FLOOR — `minmax(var(--board-col), 1fr)` under
   `auto-fill` — and below the width of that floor the columns wrap onto the next line. A board at
   the phone band is therefore a stack of statuses, read one after another, which is what a board
   on a phone is.

   `auto-fill` and not `auto-fit`, for the reason the thumbnail grid uses it: the columns are a
   fixed size and the number that fit is a consequence of the window. `auto-fit` would collapse the
   tracks a wide screen did not fill and STRETCH three columns across 1440px, so a card would be
   twice the width of the thing it holds — a board whose columns grow with the window is a board
   whose cards stop being scannable at the width a board is most useful.

   THE CARD CARRIES ITS OWN STATUS, and that is the whole of what makes this an interface rather
   than a report. The kit has no drag: a card cannot be picked up and dropped, and a board that
   cannot be changed is a picture of work. So the status is a FIELD of the card, edited where it is
   read — a labelled select in the kit's own dress — and the column head names the same status in
   the same words. That is deliberate and it is the route's rule (§5.16b's neighbour, §11.9): two
   readings of one fact may state it twice, and they may not invent two vocabularies for it. A card
   whose select says "In review" while it sits in the Awaiting reviewers column is a board with two
   answers and no way to tell which is true, so `validate_html.py` reads the head against the cards
   and requires them to agree.
   ------------------------------------------------------------------------ */

.board {
  /* The floor, not a count. A token because it is the one number that decides how many statuses a
     given window shows, and the number a board is authored against. */
  --board-col: 200px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--board-col), 1fr));
  gap: var(--space-4);
  /* Each column is as tall as its own cards. Stretched columns would put every foot — and so every
     "add" slot — on one line at the bottom of the tallest column, which is a row of controls that
     belongs to no column in particular. */
  align-items: start;
  /* THE WIDTH IS STATED, and it is load-bearing rather than tidy. `auto-fill` repeats as many tracks
     as the width allows, so a grid laid out this way has a height that depends on its width — and a
     flex item is asked for its height BEFORE it is given its width. The two together are a cycle,
     and CSS resolves it the blunt way: with no definite size in the inline axis, `auto-fill`
     repeats **once**, so the board reported the height of three columns STACKED and the row it sits
     in was laid out 1564px tall around three columns that were drawn side by side and 625px high.
     Measured, and it renders perfectly: the extra 939px is white space under the columns.
     `width: 100%` breaks the cycle by resolving the inline size first, so the track count is known
     before the height is asked for. A board nested in a block never hits this — which is why the
     thumbnail grid does not carry the same line. */
  width: 100%;
}

/* The column is the frame `.card` and `.thumb-sheet` are, and it is listed with them in the base
   layer rather than restating its corners, its border and its lack of a shadow here. All this adds
   is the clipping that lets the head sit flush inside it. */
.board-col {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* The head names the status over the cards that are in it, so it wears the band — the alt ground
   under a 2px strong rule — for the reason §5.7 gives: a heading over a set of things is the same
   thing wherever it appears. */
.board-head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  background: var(--brand-surface-alt);
  border-bottom: 2px solid var(--brand-border-strong);
}

.board-name {
  min-width: 0;
  font-family: var(--font-heading);
  font-size: var(--text-small);
  font-weight: 700;
  color: var(--brand-ink);
}

/* The count stands at the far end of the head, away from the name, so the two heads of two columns
   read as one row of numbers however long the statuses are. It is tabular so a column of counts
   lines up with itself as the work moves. */
.board-count {
  flex: none;
  margin-left: auto;
  font-size: var(--text-micro);
  font-variant-numeric: tabular-nums;
  color: var(--brand-ink-muted);
}

/* The pitch between two cards is the card's own padding, so the gap that separates two cards and
   the space that insets what is inside one are the same number. At the list pitch — `--space-2` —
   the cards read as rows of one block rather than as separate things, which is what they are. */
.board-body {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-3);
}

/* The add slot sits in the column's own foot rather than inside its body, so it is in the same
   place whether the column holds nine cards or none — the one control a column always has. */
.board-foot { padding: 0 var(--space-3) var(--space-3); }

/* The card is structure: square, flat and hairline-bordered, exactly like a clickable row and for
   the same reason. Twenty cards each carrying a control's raised shadow would be twenty shadows in
   a field of work, and the work is what the reader came for. */
.board-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  cursor: pointer;
}

/* Hover is the warm transient fill, as it is on a row and on a tile. There is no settled grey here:
   a card is not selectable, because nothing on a board acts on a selection — the card's own status
   control is the action, and a tick beside it would be a second way to say "this one". */
.board-card:hover { background: var(--brand-azure-tint); }
.board-card:active { background: var(--brand-azure-tint-deep); }

.board-card:has(.board-link:focus-visible) {
  outline: 2px solid var(--brand-azure);
  outline-offset: -2px;
}

/* The card leads somewhere, so it is a REAL link stretched over the whole card — keyboard-reachable,
   openable in a new tab, and needing no script — exactly like a clickable table row. */
.board-link { color: inherit; text-decoration: none; }
.board-link::after { content: ""; position: absolute; inset: 0; }
.board-card:hover .board-link { color: var(--brand-azure-deep); }

/* Both lines truncate, and the meta line is why it matters: a card's caption is two lines whatever
   the data says, so one long file name would grow its card and set the height of its column. */
.board-title {
  overflow: hidden;
  font-size: var(--text-small);
  font-weight: 500;
  color: var(--brand-ink);
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The meta line holds the file name and nothing else, so the name has the whole width to truncate
   into. The state pill does NOT share this line: measured, a pill beside a truncating file name
   leaves the name a third of the card and the pair reads as cramped — the pill pushes against the
   words it is not about. It goes on its own line, which is where the thumbnail sheet's caption puts
   it too. */
.board-meta {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
}

.board-file {
  overflow: hidden;
  min-width: 0;
  font-size: var(--text-micro);
  color: var(--brand-ink-muted);
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The facts a card carries, as key-and-value pairs in the hero card's own shape: a label in the
   faint ink over a value in the ink, wrapping rather than squeezing. Two facts on one line at a
   column's width would each be half a fact. */
.board-facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-1) var(--space-3);
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: var(--text-micro);
}

.board-fact { display: flex; gap: var(--space-2); }

.board-fact-key { color: var(--brand-ink-faint); }

.board-fact-value { font-weight: 500; color: var(--brand-ink); }

/* The status control is lifted above the stretched link, or the overlay swallows the click and
   choosing a status opens the card instead. The thumbnail sheet's tick is lifted for exactly the
   same reason. Its own dress is the shared labelled-select rule; all this adds is the lift. */
.board-status { position: relative; z-index: 1; }


/* ===========================================================================
   THE PUBLIC SITE — the components the pre-launch site is built from
   ---------------------------------------------------------------------------
   These are the first components in this kit that are not part of the application. They live in
   the same stylesheet because the public site is the same institution's work and must not drift
   from it: the type, the palette, the radii, the control heights and the wordmark are the
   application's, and a marketing page built from a second set of them would be a second brand.
   What is genuinely new is small — a page that is READ rather than worked in needs a measure, a
   hero with room in it, and a section frame — and everything else here is the application's own
   components used in public: `.card`, `.btn`, `.route`, `.chip`, `.app-footer`.
   =========================================================================== */

/* 1. The public header.
   ---------------------------------------------------------------------------
   It is NOT the application frame: there is no class scope, no feature menu, no account cluster
   and no rail, because none of those mean anything to a reader who has not signed in. It keeps the
   frame's HEIGHT and its hairline, which is what makes the two read as one family — a visitor who
   signs in should recognise the page they came from. */
.public-header {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  min-height: 56px;
  padding: 0 var(--space-5);
  border-bottom: 1px solid var(--brand-border);
  background: var(--brand-surface);
}

/* The name is the mark, so it carries the wordmark's own two-colour split and is a link at every
   width — the same rule the application's header follows. */
.public-header .footer-signature-name { font-family: var(--font-heading); font-size: var(--text-h4); }

.public-nav {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  margin-left: auto;
  flex-wrap: wrap;
}

/* The links are ROWS, not words. A header link sized to its own label is a 21px target in a 56px
   bar — measured — and the reader aiming at "Mission" is aiming at a word rather than at the band
   it sits in. `min-height` matches the kit's own control height, so the public header and the
   application's controls agree about what a target is. */
.public-nav a {
  display: inline-flex;
  align-items: center;
  min-height: 40px;
  padding: 0 var(--space-2);
  font-size: var(--text-small);
  color: var(--brand-ink);
  text-decoration: none;
}

/* A quiet hover rather than the link colour: the public header's links are a menu, not prose, and
   five azure words in a row would break the rule that exactly one piece of azure text appears on
   a screen. */
.public-nav a:hover { color: var(--brand-azure); text-decoration: underline; }

/* THE ACTION IN THE NAV IS NOT A NAV LINK, and it was drawing like one.

   `.public-nav a` is a class and an element and it states a `color`, which is ONE STEP MORE
   SPECIFIC than `.btn-primary`'s own `color: #ffffff` — so the header's primary action drew the
   page's ink on the azure fill and the fill behind it did nothing to make the label readable:
   measured, **2.49:1**, where the button's own pairing is **6.70:1**. Its hover was worse than its
   resting state: the azure that the nav's links turn on hover is the very fill this control is
   already standing on, so the label vanished into the button.

   Both states are therefore restated here, one class deeper than the rule that was winning. This
   is what the rule is for — the `font-size` below is the same correction for the same reason — and
   the point is general: a rule about the nav's LINKS is not a rule about the button that sits
   among them, and a control that carries its own palette cannot inherit one from the row it is
   laid out in. */
.public-header > .btn { font-size: var(--text-small); }

.public-header > .btn-primary,
.public-header > .btn-primary:hover { color: #ffffff; text-decoration: none; }

/* 2. The hero.
   ---------------------------------------------------------------------------
   The application's `.hero` is a band inside a working page: a title, a line of facts and one
   action, sized to leave room for the table underneath. A landing page's hero has nothing
   underneath it competing, so it takes the marketing step — and it is the ONLY place
   `--text-mega` is used, which is what keeps the application's scale intact beside it. */
.brand-hero {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-4);
  padding: var(--space-6) var(--space-5);
  border-bottom: 1px solid var(--brand-border);
  background: var(--brand-surface-alt);
}

.brand-hero-eyebrow {
  margin: 0;
  font-size: var(--text-micro);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--brand-ink-muted);
}

.brand-hero-title {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-mega);
  line-height: 1.05;
  color: var(--brand-ink);
}

.brand-hero-lede {
  margin: 0;
  max-width: var(--width-reading);
  font-size: var(--text-lede);
  line-height: 1.6;
  color: var(--brand-ink);
}

.brand-hero-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }

/* THE LANDING PAGE'S HERO CARRIES A SCENE, and the mechanism is the application's own: the same
   `--hero-image`, the same `cover` crop, the same scrim by weight. What is new is only what a
   public page needs and a working one does not — room.

   IT IS THE SAME PHOTOGRAPH AS THE APPLICATION'S BANDS, and that is the point rather than a
   shortcut. There is one shared scene behind this kit's heroes and the landing page is the same
   place seen from outside it: a visitor's first picture of the product should be the picture the
   product's own pages carry, not a second photograph the site invented. The CHOICE of it stays in
   the page — `--hero-image` is set there, exactly as each screen sets it on its own band — and the
   fallback written here is the same file the application falls back to.

   EVERYTHING ON IT TAKES THE STARLIGHT, because the ground is a photograph and not a panel: the
   eyebrow, the heading, the lede, the figures, and the wordmark's two halves. The wordmark half is
   the one substitution read off the application's own header — a name that is ink on a pale panel
   is starlight on the frame, and this band is the frame's own kind of ground.

   THE SCENE IS PAINTED ONCE for this band and the section bands below it, because they are the
   same mechanism rather than two that resemble each other: the same `--hero-image` the page names,
   the same `cover` crop, the same frame at 38%, the same scrim weight for weight. Only the SHAPE
   is stated apart — this band is the page's opening statement and the other is a heading with a
   picture beside it — and a second copy of the painting is exactly how two bands that must agree
   come to disagree. */
.brand-hero--scene {
  position: relative;
  isolation: isolate;
  justify-content: center;
  padding-block: var(--space-7);
  border-bottom: 1px solid var(--brand-space-edge);
  --hero-image: url("assets/hero/orbit-scene.webp");
}

/* The scrim, weight for weight `.hero::before`'s, and for the same arithmetic: starlight needs the
   ground under it to be no brighter than luminance 0.149 to clear 4.5:1, and an unscrimmed star is
   the brightest thing in any of these scenes at 0.847. It is heaviest on the LEFT because that is
   where the words are, and it fades to nothing over the right, so the band still looks like a
   place. The negative `z-index` is not a flourish either — it is what keeps the scrim behind the
   words whatever the band's layout does to its children, and it is why every band wearing it also
   declares `isolation`. */
.brand-hero--scene,
.section-band {
  background-color: var(--brand-space-deep);
  background-image: var(--hero-image);
  background-size: cover;
  /* Framed above the true centre, for the reason the application's band gives: the brightest
     detail of a space scene sits in its upper half, and a band cropped to the middle puts the
     words across the emptiest part of the field. */
  background-position: center 38%;
  background-repeat: no-repeat;
}

.brand-hero--scene::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(100deg,
      rgba(5, 9, 26, 0.78) 0%,
      rgba(5, 9, 26, 0.72) 44%,
      rgba(5, 9, 26, 0.26) 68%,
      rgba(5, 9, 26, 0.00) 100%);
}

.brand-hero--scene .brand-hero-eyebrow { color: var(--brand-on-dark-muted); }

/* Text over a photograph is ALL full ink, which is the arithmetic `.hero .metric` already states:
   the muted tone a label would carry on a plain page clears 4.5:1 only where the background is
   lighter than luminance 0.635, and lifting a scene that far is exactly what erases it. */
.brand-hero--scene .brand-hero-title,
.brand-hero--scene .brand-hero-lede,
.brand-hero--scene .stat-figure-value,
.brand-hero--scene .stat-figure-label { color: var(--brand-on-dark); }

.brand-hero--scene .app-name-first { color: var(--brand-on-dark); }
.brand-hero--scene .app-name-second { color: var(--brand-azure-bright); }

/* 2b. A BAND THAT HEADS A SECTION — the application's own band, on a page that is a sequence of
   them.
   ---------------------------------------------------------------------------
   The landing page was eleven white sections of the same height behind one another, separated by
   a hairline and nothing else: measured, eleven of its twelve bands had no background of their own
   at all, so the page had one picture at the top and seven thousand pixels of the same plane under
   it. This kit has ten scenes and eleven stickers, drawn for exactly this, and the page used one
   scene and ten stickers.

   So a section that is ABOUT one place opens with that place: the same construction as the
   application's own hero band — the scene behind, the scrim over it, and the sticker of the thing
   the section is about standing on the right, beside the words in starlight. It is not a new
   component; it is the band this file already has, given the one thing a page of sections needs
   and a page of one subject does not, which is to be repeated.

   THE WORDS ARE THE SECTION'S OWN — `.site-section-title` and `.site-section-lede` — and only
   their colour is overridden, for the reason `.cta-band` gives: a second pair of element rules
   would be a second section frame, and the band would stop reading as a section at all.

   The band ESCAPES THE SECTION'S PADDING so its edges line up with the hero's above it. A band
   inset by the section's own gutter would be a dark rectangle floating in a white column — a card
   without a radius, which this kit reads as something to click.

   There is no dangle here, and that is deliberate. The application's band hangs its picture over
   its own bottom edge because a running block below can afford 48px of clearance; a page of eleven
   sections cannot spend that eleven times, and the picture is not what the section is FOR. */
.section-band {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: center;
  gap: var(--space-6);
  min-height: 208px;
  margin: calc(-1 * var(--section-pad-block)) calc(-1 * var(--section-pad-inline)) 0;
  padding: var(--space-6) var(--space-5);
  border-bottom: 1px solid var(--brand-space-edge);
}

/* The words take the room the picture does not, and cannot be squeezed past their own longest
   word — the same rule `.hero-content` follows on the application's band. */
.section-band .site-section-head { flex: 1; min-width: 0; gap: var(--space-3); }

.section-band .site-section-head .site-section-title { color: var(--brand-on-dark); }

/* THE LEDE IS FULL STARLIGHT TOO, and this is arithmetic rather than taste — the same arithmetic
   the landing hero's own comment states. `--brand-ink-muted` clears 4.5:1 only where the ground is
   lighter than luminance 0.635, and a scene is dark by construction; `--brand-on-dark-muted` is no
   better here, which is the part worth writing down, because it LOOKS like the answer. Measured,
   it is luminance 0.434, so it needs the ground under it at 0.058 or below — a 0.94 scrim, which
   is no longer a band with a photograph behind it. The application's own bands never put it over a
   scene for that reason: `.hero-sub` and `.hero .metric-label` are `--brand-on-dark`.

   So nothing over a scene in this kit is a muted tone. A paragraph carries no less weight than a
   heading here; it is just longer.

   THE HEAD IS NAMED IN BOTH SELECTORS, and that is what keeps them here rather than a line further
   down: the dark page's own rule for a section's words is `.public-site .site-section-lede`, which
   is a step less specific than these and would otherwise repaint this lede in the muted tone the
   paragraph above is about. Naming `.site-section-head` is not decoration — it is the difference
   between a rule that wins on its merits and one that wins because of where it sits in the file. */
.section-band .site-section-head .site-section-lede { color: var(--brand-on-dark); }

/* THE BAND'S OWN SCRIM, AND IT IS NOT THE HERO'S. Two differences, both measured.

   It is HEAVIER, and it holds that weight FURTHER RIGHT. A hero's words are a heading and a line of
   figures and stop early; a section's lede is a paragraph at the reading measure — measured, its box
   is 552px of a 950px band, so the text itself reaches 61% of the width. Measured across the four
   scenes this page uses, the brightest pixel in the left 58% — where those words sit — is **0.943**
   on the dashboard scene and 0.885 on the people scene, so a star is genuinely behind this text and
   not a theoretical case; starlight `--brand-on-dark` is luminance 0.870 and needs the ground under
   it at 0.155 or below to clear 4.5:1, and 0.86 composites a 0.943 star to 0.136. The 0.86 holds to
   63% for that reason, and it is enough for a star of 1.0 — which is the point of spending 0.86
   rather than the 0.84 the scenes in hand would allow. Measured after, over the four bands: the
   worst ground under the words is luminance 0.009, and the worst contrast **15.6:1**.

   And it is HORIZONTAL where the hero's is at 100deg. A tilted gradient measures its stops along a
   line that runs down-and-right, so on a band of this height the same x carries less scrim at the
   bottom than at the top — the lede's last line would sit on the lightest part of the scrim while
   the heading above it sat on the heaviest. This band's words run down the left as a column, so the
   scrim runs straight across and the alpha depends on x alone.

   ITS STOPS ARE IN PIXELS, NOT PERCENTAGES, and that is the part that had to be measured twice. The
   thing the heavy zone has to cover is a BOX OF FIXED WIDTH — the lede at the reading measure — so
   a percentage of the band covers it at one width and not at another. Measured: at a 950px band the
   words reach 61% of the width, and the same words in a 723px band reach 72%, because the head is
   what gets squeezed when the band narrows and the lede keeps its own measure until it cannot. At
   the 82% stop the wider layout wanted, the 723px band put the lede's right edge under a 0.61
   scrim. 600px clears the measure plus the band's own padding at every width the row is used at,
   and the fade then runs to full scene by 780px.

   The scene is visible from there to the band's right edge, which is the trade the hero states: the
   band has to look like a place. */
.section-band::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(90deg,
      rgba(5, 9, 26, 0.88) 0,
      rgba(5, 9, 26, 0.86) 600px,
      rgba(5, 9, 26, 0.28) 780px,
      rgba(5, 9, 26, 0.00) 100%);
}

/* The picture is the application's own `.hero-art` — the same drawing, in the same square box, at
   the same three sizes the kit's breakpoints already give it. It gives back only the DANGLE: see
   the band's comment for why a page of sections does not spend it. */
.section-band .hero-art { align-self: center; margin-bottom: 0; }

/* THE SECTION'S HEAD, AND THE HALF OF IT THAT WAS EMPTY.
   ---------------------------------------------------------------------------
   Every section's head was a column — the title, then the lede under it — so the words ran down
   the left of a 1120px measure and the right half of the head was blank in all eleven sections.
   That is the whitespace: not the padding, which is 32px, but the column never being asked to use
   the width it was given.

   The two are a ROW where there is room for one and a column where there is not, and the row is
   `auto-fit` for the reason `.trio` gives — the head declares how narrow a column may be and the
   grid decides how many there are, so a phone stacks them without a breakpoint anywhere. The head
   inside a `.section-band` keeps the column: the band's own row is the words against the picture,
   and a second row inside the first would be two answers to one question. */
.site-section > .site-section-head {
  display: grid;
  /* `min(300px, 100%)` rather than a bare 300px, and it is the difference between a rule that works
     on a phone and one that does not: `minmax(300px, 1fr)` states a FLOOR, and a floor wider than
     its container is content 22px wider than the page — measured at 320px, the head was 300px
     inside a 278px box. `min()` is the floor and the container, whichever is smaller, which is
     what a floor was always meant to be. */
  grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
  gap: var(--space-5);
  align-items: start;
}

/* IN THE COMPACT BAND THE BAND STACKS, and its scrim goes flat.
   ---------------------------------------------------------------------------
   A band's words beside a 148px picture leave 472px at 700 and 258px at 600, which a paragraph
   reads badly in and a phone reads not at all. So below the kit's own 699px band the picture drops
   under the words rather than beside them, and the words take the whole width.

   THAT IS ALSO WHY THE SCRIM IS RESTATED HERE. The rule above holds 0.86 only as far as 63% of the
   width, because that is where the words stop when there is a picture to the right of them; with
   the words across the full width, 63% is halfway through the lede and the rest of it stands on a
   scrim fading to nothing. A narrow band cannot afford a diagonal fade at all — the same
   conclusion `.hero::before` reaches at 599px, and for the same reason: there is nowhere on the
   band that is not under the words. */
@media (max-width: 699px) {
  .section-band {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-4);
  }

  .section-band .hero-art { align-self: flex-start; }

  .section-band::before {
    background: linear-gradient(90deg,
      rgba(5, 9, 26, 0.88) 0%,
      rgba(5, 9, 26, 0.86) 100%);
  }
}

/* 3. A section, and the note that says what goes in it.
   ---------------------------------------------------------------------------
   A marketing page is a sequence of sections and every one of them answers the same two questions
   in the same order — what is this, and what does it say — so the frame is a component rather than
   a habit. The NOTE is the part that matters while the content is still being written: it is where
   the block's job is stated, in the document, next to the block. */
.site-section {
  /* THE PADDING IS NAMED, because the band inside this section has to escape it and a band that
     states the escape itself is a copy of a number that changes at the phone band — where it did,
     silently: the band's `-24px` margins were wider than the `16px` of padding they were meant to
     cancel and the band hung 8px out of the page on each side, which is a horizontal scrollbar
     waiting for a longer word. Custom properties inherit, so the band reads these off its own
     section wherever it is and the two cannot disagree. */
  --section-pad-block: var(--space-6);
  --section-pad-inline: var(--space-5);
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  padding: var(--section-pad-block) var(--section-pad-inline);
  border-bottom: 1px solid var(--brand-border);
}

.site-section-head { display: flex; flex-direction: column; gap: var(--space-2); }

.site-section-title {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-h2);
  color: var(--brand-ink);
}

.site-section-lede {
  margin: 0;
  max-width: var(--width-reading);
  font-size: var(--text-body);
  line-height: 1.6;
  color: var(--brand-ink-muted);
}

.site-section-note {
  margin: 0;
  max-width: var(--width-reading);
  padding: var(--space-3) var(--space-4);
  border-left: 3px solid var(--brand-azure);
  background: var(--brand-surface-alt);
  font-size: var(--text-small);
  color: var(--brand-ink-muted);
}

/* 4. The marks, shown large.
   ---------------------------------------------------------------------------
   The two wordmarks are the application's own — the header's `.app-name` and the footer's
   `.footer-signature-name` — displayed at a size a brand guide needs and a header does not. These
   modifiers change SIZE and nothing else, deliberately: a brand guide that restated the two-colour
   split would be a second place for the mark's most important rule to live, and the second place is
   the one that drifts. */
.brand-mark { font-family: var(--font-heading); }
.brand-mark--xl { font-size: var(--text-mega); line-height: 1.05; }
.brand-mark--lg { font-size: var(--text-h1); line-height: 1.1; }
.brand-mark--sm { font-size: var(--text-h4); }

/* 5. The swatch grid.
   ---------------------------------------------------------------------------
   A colour is a value, a name and a JOB, and the third is the one a reader needs: the same azure is
   an accent in one place and a fill in another, and a swatch that showed only the hex would let
   someone use the accent behind white text at 3.68:1. The job is the column that says which of the
   two a given token is. */
.swatch-grid {
  display: grid;
  /* Three across at the guide's own measure, not four. The palette is nine colours, and a
     four-across grid leaves the ninth alone on a row of its own — which reads as a colour that was
     added afterwards rather than as the end of a set. */
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: var(--space-3);
}

.swatch {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  padding: var(--space-3);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  background: var(--brand-surface);
}

.swatch-chip {
  flex: none;
  width: 40px;
  height: 40px;
  border-radius: var(--radius-control);
  border: 1px solid var(--brand-border);
}

.swatch-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.swatch-name { font-size: var(--text-small); font-weight: 700; color: var(--brand-ink); }
.swatch-value { font-size: var(--text-micro); font-weight: 700; color: var(--brand-ink); }
.swatch-use { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* 6. The figure band.
   ---------------------------------------------------------------------------
   Figures on a marketing page are claims, so each one is a number and the thing it counts, and the
   pair does not wrap — the same rule the hero's own summary line follows, for the same reason. */
.stat-band { display: flex; flex-wrap: wrap; gap: var(--space-5); }

/* THE LABEL WRAPS WHEN IT HAS TO, and the VALUE does not.
   ---------------------------------------------------------------------------
   The rule this replaces was `white-space: nowrap` on the whole figure, and it was written for a
   figure that is a NUMBER AND A NOUN — "12 people" — where a wrap between the two would be two
   lines doing one line's work. The public site's figures are CLAIMS and carry a phrase: "card to
   carry: it signs you in and unlocks administrator rights".

   Measured at 320px, that figure is **358px wide inside a 310px column**, so the hero — and the
   page with it — grew a horizontal scrollbar. The pair still never wraps: the figure is one flex
   item in a wrapping band, so the band breaks BETWEEN figures and never inside one, which is what
   the rule was for. What is now allowed is the one thing the rule had no business forbidding, which
   is a label long enough to be a sentence wrapping like a sentence. Nothing changes above a phone:
   a figure is laid out at its own width unless it cannot fit, so "12 people" still reads as one
   line at every width there is room for it. */
.stat-figure { display: flex; flex-direction: column; gap: 2px; }
.stat-figure-value { font-family: var(--font-heading); font-size: var(--text-h2); color: var(--brand-ink); white-space: nowrap; }
.stat-figure-label { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* 7. A trio of cards.
   ---------------------------------------------------------------------------
   Three is the largest number of things a reader holds at once, so the pattern that repeats on
   every marketing page is exactly three: three problems, three capabilities, three audiences. The
   cards are the application's `.card`, which is the point — a marketing claim and a product
   capability are the same object in this kit. */
.trio {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--space-4);
}

.trio .card { display: flex; flex-direction: column; }
.trio .card h3 { margin: 0; font-size: var(--text-h4); }
.trio .card p { margin: 0; font-size: var(--text-small); color: var(--brand-ink-muted); }

/* 8. A quotation.
   ---------------------------------------------------------------------------
   A person's words about the product, set apart from ours. The bar is the same 3px azure the
   section note uses, at the same edge, because both say "this is a remark beside the content
   rather than the content". */
.pullquote {
  margin: 0;
  max-width: var(--width-reading);
  padding: var(--space-4);
  border-left: 3px solid var(--brand-azure);
  background: var(--brand-surface-alt);
}

.pullquote-text {
  margin: 0;
  font-family: var(--font-heading);
  font-size: var(--text-h4);
  line-height: 1.5;
  color: var(--brand-ink);
}

.pullquote-source { margin: var(--space-2) 0 0; font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* 9. Questions.
   ---------------------------------------------------------------------------
   `<details>` rather than a scripted accordion: this kit has no scripting at all, and a disclosure
   widget the browser already implements is a better answer than the first exception. The marker is
   replaced with the kit's own chevron so it matches the application's rows. */
.faq { display: flex; flex-direction: column; gap: var(--space-2); max-width: var(--width-reading); }

.faq-item {
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  background: var(--brand-surface);
  padding: var(--space-3) var(--space-4);
}

/* A `<summary>` is a control the browser draws, and its clickable box is its OWN box: the item's
   padding is not part of it. Sized to its text it is a 24px target — measured — so it takes the
   same vertical padding as a button, and the whole row of the question is what the reader presses. */
.faq-item summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 40px;
  padding: var(--space-2) 0;
  cursor: pointer;
  font-size: var(--text-body);
  font-weight: 700;
  color: var(--brand-ink);
}

/* The disclosure mark is the kit's OWN chevron, and it has to be drawn rather than left to the
   browser. `::marker` is rendered only while the summary is `display: list-item`, and the summary
   is a flex row — so the triangle was suppressed by the layout and the marker rule that used to
   sit here was colouring a drawing that never appeared. The four closed questions then had no
   affordance at all: they read as static cards, and the only thing separating the open one from
   them was that it was taller. The mark is the same `--icon-disclosure` chevron the rail's
   sections wear, at the kit's small icon size, in the summary's own ink — so it takes the colour
   of the row it belongs to, including its hover state. */
.faq-item summary[data-icon]::before { width: 14px; height: 14px; mask-size: 14px 14px; }
.faq-item p { margin: var(--space-3) 0 0; font-size: var(--text-small); color: var(--brand-ink-muted); }

/* 10. The closing action.
   ---------------------------------------------------------------------------
   The last thing on the page, and it is the only band that is a single action rather than a
   statement. It takes the deep azure as a FIELD — the `.brand-title` treatment the design system
   already permits for a top-level statement — so the page ends on the one surface where the
   action cannot be missed, and the action itself is the white button on it. */
.cta-band {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-3);
  padding: var(--space-6) var(--space-5);
  background: var(--brand-azure-deep);
  color: var(--brand-on-dark);
}

/* The band is a SECTION on a dark field, so it uses the section's own title and lede and overrides
   only their colour. A second pair of element rules would be a second section frame — and the
   guide's structure check reads the section's frame by its classes, so the band would read as a
   section with no title at all. */
.cta-band .site-section-title,
.cta-band .site-section-lede { color: var(--brand-on-dark); }

/* The band is a FIELD of the deep azure, and `.btn-primary` is that same azure — so the one
   action the band exists to offer was drawn in the colour of the ground it stood on and read as a
   label rather than as a button. The band therefore INVERTS the primary: the white fill with the
   band's own azure for the label, which is 7.66:1 — the same pairing the button wears everywhere
   else, seen the other way round, so it is the kit's own contrast and not a new one. Only the
   primary needs stating; the quiet button's fill is already `--brand-surface-alt` and stands off
   the field on its own. */
.cta-band .btn-primary {
  background: var(--brand-surface);
  color: var(--brand-azure-deep);
}

.cta-band .btn-primary:hover { background: var(--brand-surface-alt); }

/* 11. An outline — a page described as its blocks.
   ---------------------------------------------------------------------------
   This is the part of the document that is a PLAN rather than a specimen: the landing page and the
   explainer site, written down in the order a reader meets them, so that content can be written
   against a structure rather than the structure being discovered from the content. Each item names
   the block, says what it must say, and points at the component it is built from. */
.outline {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: block;
}

.outline-item {
  display: flex;
  gap: var(--space-3);
  align-items: baseline;
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  background: var(--brand-surface);
}

.outline-item::before {
  counter-increment: block;
  content: counter(block);
  flex: none;
  width: 22px;
  font-family: var(--font-heading);
  font-size: var(--text-small);
  color: var(--brand-azure);
}

.outline-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.outline-name { font-size: var(--text-small); font-weight: 700; color: var(--brand-ink); }
.outline-says { font-size: var(--text-micro); color: var(--brand-ink-muted); }
.outline-built { font-size: var(--text-micro); color: var(--brand-ink-faint); }

/* 12. A gallery of the artwork.
   ---------------------------------------------------------------------------
   The pre-launch site SHOWS the product rather than describing it, and what it has to show is THREE
   kinds of picture that are not interchangeable: a **sticker**, which is a die-cut drawing of what a
   feature does; a **scene**, which is the place that feature's band is photographed against; and a
   **screenshot**, which is the screen itself. All three are shown as a framed picture with its name
   under it, because a picture in a marketing page is a claim and a claim without a label is a
   picture nobody can check.

   THE SCREENSHOT IS THE ONE THAT WAS MISSING, and the section that most needed it is the one that
   lists the console's ten screens: it was showing the ten DRAWINGS of them, which says what each
   screen is for and nothing about what it looks like. A drawing of a screen is a claim about the
   product; a screenshot is the product.

   The frame is load-bearing for the sticker: it is cut out, so without a surface behind it the
   drawing floats on the page and its own silhouette is the only thing saying where it ends. The
   tile's surface is that backing, and it is the same `--brand-surface-alt` the hero band uses, so
   the drawing is seen on the ground it is actually drawn on. */
.art-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: var(--space-3);
}

/* A scene is a landscape photograph, so it takes a wider track: at a sticker's 150px a coastal
   view is a stripe, and the one thing a scene is for — that it is a place — is what a stripe
   loses. The track is set here rather than per page, because the two kinds of picture are a
   property of the artwork and not of where it is shown. */
.art-grid--wide { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }

/* A SCREENSHOT TAKES TWO ACROSS, AND THE COUNT IS FIXED RATHER THAN `auto-fill`.
   ---------------------------------------------------------------------------
   The reason is legibility, not layout: a crop is 950px wide, and a track at three across is 349,
   which reduces the interface inside it by nearly three times — measured, a table is a texture at
   that size. Two across the measure is 552, a reduction of about half, and that is the point at
   which a table's rows, a status pill and a face still read as themselves.

   It is stated as TWO TRACKS rather than as a minimum width because no single minimum does it: the
   count has to be two at a 1120px container (552 a tile) AND two at a 542px one (263 a tile), and
   the minimum that gives two at the first gives four at the second — `auto-fill` divides by a
   constant and what this needs is half. A fixed pair says what the rule is for.

   Below the kit's own phone band it is one, because 211px is not a picture of anything. */
.art-grid--screens { grid-template-columns: repeat(2, minmax(0, 1fr)); }

@media (max-width: 599px) {
  .art-grid--screens { grid-template-columns: minmax(0, 1fr); }
}

.art-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  padding: var(--space-3);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-control);
  background: var(--brand-surface-alt);
  text-align: center;
}

/* The drawings are different shapes — one is tall, one is wide, one is nearly square — so they are
   fitted into a band of ONE height rather than scaled to a common width. Twelve tiles whose
   drawings end at twelve different heights read as twelve pictures that happen to be near each
   other; a single band makes them a set, which is what a gallery is for. `object-fit: contain`
   letterboxes each drawing inside the band and never crops it. */
.art-tile-art { display: block; width: 100%; height: 104px; object-fit: contain; }

/* A scene is a photograph rather than a cut-out, so it is mounted rather than floated: it fills
   its tile edge to edge and the caption sits under it. The padding that frames a sticker is a
   mount around a picture, and a mount inside a photograph is a border drawn on the picture. All
   eleven scenes are the same 3:2 frame, so the ratio is stated once here and the row is even. */
/* TWO KINDS OF PICTURE, AND ONLY ONE OF THEM IS CROPPED.
   ---------------------------------------------------------------------------
   A scene is a landscape photograph of a place and shows the whole of itself. A SCREENSHOT is not
   shown whole, and that is the point: the operator's report on the first version — which was the
   whole screen from its top — was that it was overwhelming and repetitive "with all the UI chrome
   included". That is exactly right, and the reason is structural: ten screenshots of ten screens
   are ten pictures of the same header and the same hero band, because those are the parts a kit
   gives every screen. What differs between them is everything UNDER the chrome.

   So the crop starts below it, at the top of the screen's own working area, and runs as far as 3:2
   of the window's width. Measured off the pixels rather than off the DOM, because the two
   disagreed: on all ten screens the header and the hero band are one dark run ending at **y=380**
   of a 960-wide render, and the working area starts at 388. Every crop begins there — except the
   Dashboard's, which is the one screen whose own band IS its subject: the title, the figures and
   the ten destinations as tiles.

   THE CROP IS 3:2 AND EVERY ONE OF THEM IS, which is what makes the gallery a gallery. The crops were
   first cut to the end of whatever the screen had — 584 to 633 of a 950px window — so ten pictures of
   five different heights sat in a two-across grid and did not tile: the operator's report was that
   they "don't tile correctly in their current form". A screen whose working area is shorter than the
   ratio is completed with WHITE — the application's content plane, which is what the page continues
   as — and it has to be a FILL rather than a repeated row. Repeating the page's own last row is the
   obvious way to extend a screenshot, and on the roles screen that row happened to hold a footer mark
   and a link: stretched 49px it became a set of vertical streaks down the bottom of the tile, and the
   operator's report was that the screenshot "looks smeared". Measured after: the pads are 255 with a
   standard deviation of 0.2, and the longest run of repeated rows in any of the ten is a blank one.
   One whose working area is longer (the systems list runs to 1598) simply stops at the ratio, because
   the top of a table is what a table's first rows say.

   `cover` is stated rather than assumed: the crop and the box agree exactly and they are only equal
   while every capture keeps the ratio it was taken at. A re-take that did not would start silently
   cropping the bottom of a screen instead of admitting it.

   The tile's surface is still the drawing's backing as much as it was for a sticker, because the
   caption under it is the raised panel the gallery has always used — a screenshot is not a cut-out
   and does not need a ground, but the FRAME is what makes ten pictures sit on the page as one set.

   TO RE-TAKE ONE, and this is the part that cost the most time: render the screen at **960 CSS px
   wide**, capture FULL PAGE, and cut from **y=388** down to 3:2 of that width (the Dashboard from
   y=63: its own band is its subject).A wider viewport is what fails — the pane these pages are
   reviewed in paints about 1072 CSS px wide and no more, so a page laid out at 1440 comes back with
   its content in the top-left of the frame and flat white over the rest of it. Vertical stitching is
   fine (a full-page capture of a 1867px page is one image); it is the horizontal side that has to
   fit. And the boundary at 388 was read off the PIXELS, not the DOM — the two disagreed by 128px. */
.art-tile--scene { padding: 0; background: var(--brand-surface); }

.art-tile--screen { padding: 0; background: var(--brand-surface); }

.art-tile--scene .art-tile-art { height: auto; aspect-ratio: 3 / 2; object-fit: cover; }

.art-tile--screen .art-tile-art { height: auto; aspect-ratio: 3 / 2; object-fit: cover; }

.art-tile--scene .art-tile-caption,
.art-tile--screen .art-tile-caption { padding: 0 var(--space-3) var(--space-3); }

/* THE SCREENSHOT'S CAPTION IS A ROW, AND THE SURFACE'S OWN DRAWING LEADS IT. The screenshot says
   what the screen looks like; the sticker says which screen it is, in the same drawing the console
   itself uses for that destination — so the gallery is still the set of eleven stickers it was, and
   it is now the ten screens as well. It is the row-with-a-mark the application's own dialogs use
   (`.feature-option` with its `.avatar`), which is the rule the kit states for a person's drawing
   arriving beside a person's name, applied to a screen's.

   The caption turns from a column into a row and its text turns from centred to left, because the
   two grids are different pictures: a sticker's name belongs under a drawing of nothing in
   particular, and a screen's name belongs beside its own mark, on the same left edge as the
   screenshot above it. */
.art-tile--screen .art-tile-caption {
  flex-direction: row;
  align-items: center;
  gap: var(--space-2);
  padding-top: var(--space-3);
  text-align: left;
}

.art-tile-mark { flex: none; width: 32px; height: 32px; object-fit: contain; }
.art-tile-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

/* AND THE CAPTIONS ARE ONE HEIGHT TOO. Equal pictures are not enough: a note that wraps to two lines
   in one tile and fits on one line in the next leaves the frames ragged by 16px, which is the same
   fault the pictures had. `2lh` is the note's own two lines — the unit the kit already uses for a
   line box it has to measure against (the frame path's derived padding) — so the floor moves with
   the face and the size rather than being a number that has to be kept. */
.art-tile--screen .art-tile-note { min-height: 2lh; }

.art-tile-caption {
  display: flex;
  flex-direction: column;
  gap: 2px;
  /* Full width and centred, so the two grids' captions are placed the same way: a caption
     that shrank to its own text would centre a one-line name and left-align a two-line
     pair, and the scene grid and the illustration grid would then look like two components.
     `border-box`, because the scene's caption carries the tile's own inset as padding and
     this sheet has no global reset — a content-box 100% would hang past the tile. */
  width: 100%;
  box-sizing: border-box;
  min-width: 0;
  text-align: center;
}
.art-tile-name { font-size: var(--text-micro); font-weight: 700; color: var(--brand-ink); }
.art-tile-note { font-size: var(--text-micro); color: var(--brand-ink-muted); }

/* The public site at the phone band, which is the kit's own phone band.
   ---------------------------------------------------------------------------
   The marketing step is a DESKTOP headline: at 4.25rem on a 390px screen the name takes most of the
   viewport and the page opens with a wall rather than a statement. So the hero steps down to the
   application's own `--text-h1` — the size the product itself gives a page title, and the one a
   phone can hold — and the section padding steps down with it, because 48px of side padding on a
   390px screen is a quarter of the width spent on nothing. */
@media (max-width: 599px) {
  .brand-hero, .cta-band { padding: var(--space-5) var(--space-4); }
  /* The two other components keep the same padding; the SECTION states it as its own two names,
     because its band is reading them. See `.site-section`. */
  .site-section { --section-pad-block: var(--space-5); --section-pad-inline: var(--space-4); }
  .brand-hero-title, .brand-mark--xl { font-size: var(--text-h1); }
  .public-header { padding: 0 var(--space-4); }
  .stat-band { gap: var(--space-4); }
}

/* A TILE'S FIGURE WRAPS ON THE PUBLIC SITE, and this is a layout fix rather than a preference.
   ---------------------------------------------------------------------------
   `.callout-figure` is `white-space: nowrap`, which is right where it came from: the dashboard's
   tiles carry a short figure — "5 need attention" — and a figure that wraps between "5" and "need"
   is two lines where one would do. The public site's tiles carry a PHRASE instead — "who signed in,
   and who was turned away" — and a nowrap phrase is a tile that cannot be narrower than the phrase,
   which is a tile whose grid track cannot shrink.

   Measured at 768: the three tiles wanted **775px inside a 710px box** and the page itself grew a
   31px horizontal scrollbar — the second scroll plane the kit forbids, arriving through a tile
   rather than through a table. Nothing was wrong with the grid or the breakpoints: `minmax(220px,
   1fr)` was doing exactly what it says, and the tracks could not shrink because their contents
   would not. Allowing the figure to wrap lets the track do its job, and a short figure still
   refuses to wrap on its own — there is nothing to break.

   Scoped to the public site so the dashboard keeps the behaviour it was written for. */
.public-site .callout-figure { white-space: normal; }

/* ===========================================================================
   THE PUBLIC SITE ON THE FRAME — the landing page wears the product's own dark
   ---------------------------------------------------------------------------
   The landing page was the frame's opposite: a pale plane under a dark hero, which is the one
   combination this kit uses nowhere else. Every screen of the application is the frame at its
   edges and a light panel in the middle — decision 9, "charcoal navigation, light everywhere
   else" — and a marketing page has no middle to keep pale. It is not a record that anybody reads
   a row of, and the thing it is selling is a dark console. Measured on the page as it was, eleven
   of its twelve bands were that same pale plane and the only dark on it was the hero, 9% of the
   page, which read as a photograph pasted onto the top of a document.

   So this class is the page saying WHICH PLANE IT IS, and the answer is the frame. Everything
   below is one mapping from the light plane's tokens onto the frame's, and there is no new colour
   in it:

     the page      `--brand-space`          the frame's own ground
     a band        `--brand-space-deep`     the plane the rail sits on, behind a scene
     a panel       `--brand-space-raised`   a surface standing ON the frame
     a hairline    `--brand-space-edge`     and there is exactly one, as on the frame
     the words     `--brand-on-dark`, `--brand-on-dark-muted`
     an accent     `--brand-azure-bright`   which the token file defines for the frame, at 7.91:1

   `--brand-space-raised` IS EXTENDED HERE, and that is the one new decision rather than a mapping.
   The token file defines it as "a control standing on the frame", and a card, a tile, an FAQ row
   and a quotation are not controls. But they are things standing on the frame, which is what the
   step is for, and the alternative — panels the colour of the page, held by a 2.44:1 hairline — is
   a dark page with a picture frame drawn round everything and no surface in it. The step is 1.53:1
   above the page, which is the same step the header's own controls take.

   NOTHING HERE CHANGES A SIZE, A SPACE OR A SHAPE. A dark page that also re-laid itself out could
   not be compared with the light one, and every measurement above this line would have to be taken
   again.

   The application is untouched: every selector is scoped, including the two components the site
   shares with it — `.card`, which the console uses for its own panels, and `.callout`, which the
   dashboard's tiles are. */
.public-site {
  background: var(--brand-space-deep);
  color: var(--brand-on-dark);
}

/* THE CONTENT COLUMN IS A PLANE OF ITS OWN, which is the answer to "the content does not stand out
   from the background". It was the same colour as the page it sat on, so a 1120px column centred in
   a wider window read as the page having margins rather than as a column standing on a ground —
   nothing to see on either side of it and nothing to see within it either.

   So the page is the DEEP plane and the column is the frame, which is the arrangement the token
   file already describes: `--brand-space` is "the header, and the dark bars inside a light page"
   and `--brand-space-deep` is "the navigation rail and the tab bar". The column is one step of
   1.18:1 above the page, and that step is small on purpose — a page divided into two dark tones has
   to stay one place.

   THE STEP IS THE WHOLE OF THE BOUNDARY, and it was drawn as well at first: a hairline down each
   side, `--brand-space-edge`, which is the token file's own description of what it is for. It was
   rejected on sight, and the reason is worth keeping, because a line down each side of a page looks
   like a safe way to say "this is the column". It is not: a hairline is this kit's mark for a
   CONTROL's edge — the frame around a pressable thing — so two of them down the length of the page
   drew the content as one very tall control, and it turned a 1.18:1 division into a drawn box. A
   change of plane is the quieter and the more accurate signal, and it is how the application's own
   rail and frame meet: two tones, and no line between them.

   It is `> main` and not a class, so that the column exists for any public page in this family
   without one being written into each, and so that the guide — which is a light specimen document
   and not the site — is untouched by any of it. */
.public-site > main {
  max-width: var(--width-page);
  margin: 0 auto;
  background: var(--brand-space);
}

/* The header, and the two links in it. The wordmark keeps its orange disc: `--brand-orange` is
   already read on the frame by the application's own header. */
.public-site .public-header {
  background: var(--brand-space);
  border-bottom-color: var(--brand-space-edge);
  /* THE HEADER'S CONTENTS ARE NARROWER THAN THE COLUMN, and this is the whole of the work. The
     header is a BAR and spans the window — that is what a bar does, and its hairline should reach
     both edges — while its contents are inset by the column's gutter PLUS THE SECTIONS' OWN
     PADDING, so the wordmark's left edge is the left edge of the section titles and the nav's right
     edge is the right edge of the copy. The header's contents are then narrower than the column by
     exactly that padding, which is what makes them read as sitting inside it rather than on it.

     The inset is DERIVED rather than chosen, and every term is the one the column itself uses: the
     gutter is what `margin: auto` leaves the column, floored at zero for the bands where there is
     none, and the padding is the one the sections beside it were given in the same band. Written
     as a number it would be a fourth thing to keep in step, and the alignment it exists to produce
     would be the first casualty of any change to either.

     `max()` around the gutter rather than a bare take is the part that makes one formula serve
     every width: below 1120px the gutter is negative and the header falls back to the band's own
     padding, which is exactly where the sections' text starts. */
  padding-inline: calc(max(0px, (100% - var(--width-page)) / 2) + var(--space-5));
}
.public-site .public-nav a { color: var(--brand-on-dark); }
.public-site .public-nav a:hover { color: var(--brand-azure-bright); }
.public-site .footer-signature-name { color: var(--brand-on-dark); }

/* THE SECTIONS' WORDS. It is `.site-section` and not the head, because a section's lede is not
   always inside one: three of the page's paragraphs are loose children of their section, under a
   head that has already had its say — "Ten screens hold all of it, and they are the console's
   own" — and a rule that required the head left those three in the light plane's ink. The audit
   that catches this is a contrast walk over every text node; the near-miss was 3.03:1. */
.public-site .site-section { border-bottom-color: var(--brand-space-edge); }
.public-site .site-section .site-section-title { color: var(--brand-on-dark); }
.public-site .site-section .site-section-lede { color: var(--brand-on-dark-muted); }

/* THE PANELS — the cards, the tiles, the rows, the quotation. This is where the page stops being
   white, and it is one rule rather than six because the six were already one colour. */
.public-site .trio .card,
.public-site .faq-item,
.public-site .outline-item,
.public-site .art-tile,
.public-site .pullquote {
  background: var(--brand-space-raised);
  border-color: var(--brand-space-edge);
}

.public-site .trio .card h3,
.public-site .faq-item summary,
.public-site .outline-name,
.public-site .art-tile-name,
.public-site .pullquote-text { color: var(--brand-on-dark); }

.public-site .trio .card p,
.public-site .faq-item p,
.public-site .outline-says,
.public-site .outline-built,
.public-site .art-tile-note,
.public-site .pullquote-source { color: var(--brand-on-dark-muted); }

/* `--brand-azure` is the LIGHT plane's accent — on `--brand-space-raised` it is 2.1:1, which is a
   bar nobody sees and a number nobody reads. The token file keeps the frame's own. */
.public-site .pullquote { border-left-color: var(--brand-azure-bright); }
.public-site .outline-item::before { color: var(--brand-azure-bright); }

/* THE TILES THAT POINT AT THE THREE CARD SCREENS. The hover dress is restated rather than left to
   inherit, because the base one paints the LIGHT plane's `--brand-surface-alt` and its ink: the
   ground would have gone pale on hover, which is the one thing a dark page must not do.
   The name stays starlight on hover — the accent does not survive the firmed ground (3.25:1),
   and the ground lifting from `raised` to `edge` is the feedback. */
.public-site .callout {
  background: var(--brand-space-raised);
  border-color: var(--brand-space-edge);
  color: var(--brand-on-dark);
  box-shadow: var(--shadow-control-dark);
}
/* A QUOTED tile keeps the panels' dress exactly — raised ground, edge border, no shadow — which is
   what the rule above gives it apart from the shadow. That one has to be taken off BY NAME: the
   rule above states the dark shadow for every tile on this plane, and a modifier one class lighter
   than `.public-site .callout` cannot outrank it. */
.public-site .callout--static { box-shadow: none; }
.public-site .callout:not(.callout--static):hover {
  background: var(--brand-space-edge);
  border-color: var(--brand-azure-bright);
  box-shadow: var(--shadow-control-dark-hover);
}
.public-site .callout:not(.callout--static):active { box-shadow: var(--shadow-control-dark-active); }
.public-site .callout:not(.callout--static):hover .callout-name { color: var(--brand-on-dark); }
.public-site .callout-figure { color: var(--brand-on-dark-muted); }

/* THE CONTROLS. `.btn` is one component across the whole kit, and its shadow is a fact about the
   SURFACE it stands on rather than about the button — which is why the token file keeps a second
   set and says why ("an ink-tinted shadow on `--brand-space` is the same colour as the thing it
   falls on and draws nothing"). The three stay one press. */
.public-site .btn { box-shadow: var(--shadow-control-dark); }
.public-site .btn:hover { box-shadow: var(--shadow-control-dark-hover); }
.public-site .btn:active { box-shadow: var(--shadow-control-dark-active); }

/* An outlined button on the frame: the frame's hairline, and starlight for its label. Nothing
   fills it, so the outline is what says it is a control — which is what secondary means once the
   primary beside it is the only filled thing in the row. */
.public-site .btn-secondary {
  background: transparent;
  color: var(--brand-on-dark);
  border-color: var(--brand-space-edge);
}

/* And the quiet one, which on the frame is the application's own quiet control rather than a pale
   chip. `--brand-surface-alt` is the LIGHT plane's quiet fill; on a dark page it is the loudest
   thing in the row, and it puts a second primary beside the primary. This is the dress of
   `.app-header .icon-btn`, which is the frame's own answer to the same question: a surface
   standing on the frame, closed by the frame's hairline. */
.public-site .btn-quiet {
  background: var(--brand-space-raised);
  color: var(--brand-on-dark);
  border-color: var(--brand-space-edge);
}

/* EXCEPT IN THE CLOSING BAND, where the pale chip is right and always was: that band is a FIELD of
   the deep azure rather than the frame, and the quiet fill stands off it. Its own comment says so,
   and this rule is that comment kept true. */
.cta-band .btn-quiet {
  background: var(--brand-surface-alt);
  color: var(--brand-ink);
  border-color: var(--brand-border);
}

/* The footer's hairline, which is the page's last edge — AND ITS FOOT, which this page has to state
   for itself. In the application the signature's bottom gap comes from `.app-main`'s padding,
   because the footer is the last child of a padded column. This page's footer is not: it stands
   outside `main`, on the bare body, so there is no padding under it to inherit and the name landed
   flush against the bottom edge of the page — the one line on the site that touched its own edge.

   It takes the SECTION RHYTHM rather than the application's 24px, because the gap it is standing
   in is a section's: `--space-6` is what a section's own foot gets at this band, so the signature
   lands instead of stopping. */
.public-site .app-footer {
  border-top-color: var(--brand-space-edge);
  padding-bottom: var(--space-6);
}

/* THE HEADER'S INSET AT THE PHONE BAND, and it has to be written here rather than beside the rule
   it corrects. `--space-5` in that rule is this desktop band's 24px and this band's padding is 16 —
   the same 16 the sections beside it take — so the header needs the second formula. It is stated at
   the END of the block because a media query does not raise a rule's specificity: the same claim
   written earlier in the file loses to the plain rule it is there to correct, which is exactly what
   happened first, and it lost silently — the header stayed 8px wide of the content on a phone and
   nowhere else. */
@media (max-width: 599px) {
  /*
   TWO ROWS, AND THE TWO ROWS ARE THE HEADER'S TWO JOBS: who this is and what to do about it, then
   where to go. It was one row that wrapped, and measured at 360px that came to **154px of header
   in four ragged rows of links**, with the page's only action somewhere in the middle of them and
   the rows breaking wherever the words happened to run out rather than anywhere that means
   anything.

   So the bar is explicit here. The name and the action take the first row; the five places take the
   second as a two-across grid, over a hairline that says the menu is a menu rather than more words
   in the bar. The action is placed on the first row by `grid-area` rather than by being first in the
   markup, because the markup order is the one the desktop bar needs — the action last, at the
   right-hand end of the row of links — and this band needs the other one. The reading order is
   unchanged and stays the desktop order: a keyboard reader meets the five places, then the action.

   The five links keep their 40px rows, which is the kit's control height and the height the public
   header already sets for them ("the links are ROWS, not words"); two columns of three rows is
   then about 128px of menu, which is what a five-item menu costs at this width. */
  .public-header {
    padding-inline: calc(max(0px, (100% - var(--width-page)) / 2) + var(--space-4));
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
    row-gap: var(--space-2);
    padding-block: var(--space-2);
  }

  .public-header > .footer-signature-name { grid-area: 1 / 1 / 2 / 2; }
  .public-header > .btn { grid-area: 1 / 2 / 2 / 3; }

  .public-nav {
    grid-area: 2 / 1 / 3 / 3;
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 0;
    margin-left: 0;
    border-top: 1px solid var(--brand-border);
    padding-top: var(--space-1);
  }

  /* Flush with the column, so the first item of the menu starts on the same edge as everything
     else on the page. The 8px the base gives each link is what the grid's own column does here. */
  .public-nav a { padding: 0; }

  /* The menu's own hairline, on the plane the page it is on is made of. The header is the ONE
     public component the site and its guide do not dress alike — the site wears the frame and the
     guide is a light specimen document — so the two arrangements above are shared and only this
     colour is not. */
  .public-site .public-nav { border-top-color: var(--brand-space-edge); }
}

/* ===========================================================================
   SATELLITE — the navigation for TEN destinations, and the marks it needs
   ---------------------------------------------------------------------------
   The kit's navigation was drawn for FOUR destinations and arranges itself three
   ways (see the navigation block): a fixed bottom tab bar on a phone, a drawer in
   the middle band, and a rail on a desktop.

   The control plane this language is being applied to has TEN primary
   destinations — Dashboard, Activity, Access, People, Roles, Credentials,
   Systems, System Groups, Networks and Downloads. A bottom bar of ten is a bar
   of items nobody can hit: at 390px each lands under 40px wide, with its label
   wrapped or clipped beside its own icon.

   So the Satellite navigation keeps the kit's RAIL on a desktop and the kit's
   DRAWER at every width below it, and drops the bottom bar. Nothing else changes:
   the same `<li>`/`.nav-item` markup, the same order, the same azure bar, the
   same 20px icon box, the same `aria-current="page"` mark. `.app-nav--satellite`
   is the whole of the difference, and it is a CONTAINER rule — the items are the
   kit's own.

   The two destinations the kit already draws (`dashboard`, `roster`, `home`,
   `calendar`, `settings`) are reused rather than redrawn: a control-plane Person
   is a person, and People wears the roster mark the class roster taught. The rest
   are added below.
   ========================================================================= */

:root {
  /* ACTIVITY — a pulse. The audit log is a timeline of events, and a trace that
     rises and falls is what a timeline looks like rather than a bar chart (which
     is a set of quantities) or a list (which is what the page already is). */
  --icon-activity: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M1.8 11h3.2l2.2-6 3.2 9.6 2.2-5.6h5.6' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* ACCESS — a key. The Access view answers "who signed in, and who elevated"; a
     key is the one shape that says "how a person gets in" without meaning a
     lock (which would read as security settings rather than as entry events). */
  --icon-access: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='7.2' cy='9.4' r='3.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M10.5 9.4H18' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M15 9.4v3M17.3 9.4v2.1' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* PEOPLE — the roster's two figures, given its own token because "People" is the
     control plane's own surface. One drawing, two names: the roster mark the class
     kit learned, worn on the control plane's People item. */
  --icon-people: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='6.8' cy='7' r='2.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M2.2 17.5a4.6 4.6 0 0 1 9.2 0' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3Ccircle cx='15' cy='7.6' r='2' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M14.7 12.8c1.6.6 2.7 2.1 2.7 4' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* ROLES — a shield with a tick. A role is a grant of permission over a set of
     systems, so the mark is the one thing that says "what is allowed here", not a
     padlock (which says "locked") and not a key (which the Access item owns). */
  --icon-roles: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M10 2.2l6.2 2.4v5c0 3.7-2.5 6.4-6.2 8.1-3.7-1.7-6.2-4.4-6.2-8.1v-5z' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3Cpath d='M7.4 9.9l1.9 1.9 3.4-3.8' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* CREDENTIALS — a card. A credential is a physical token (a smartcard, a
     YubiKey) or a software key; a card with a chip is the one drawing that says
     "a thing you hold and present" without committing to a particular form. */
  --icon-credentials: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='2.2' y='4.8' width='15.6' height='10.4' rx='2' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M2.2 8.4h15.6' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Crect x='4.8' y='10.7' width='3.6' height='2.6' rx='0.7' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M11.2 6.6h4.2' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* SYSTEMS — a server tower. A managed endpoint or server, drawn as the thing it
     most often is: a stack of bays. Distinguishable at 20px from the credential
     card (wide, one line) and from System Groups below (a cylinder). */
  --icon-systems: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='5.2' y='2.4' width='9.6' height='15.2' rx='1.8' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M7.5 6.2h5M7.5 10h5M7.5 13.8h5' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* SYSTEM GROUPS — a database. A named collection of systems is a SET, and the
     set's mark is a store: a cylinder says "a collection of things of one kind"
     where the tower beside it says "one machine". */
  --icon-groups: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cellipse cx='10' cy='5.2' rx='5.8' ry='2.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M4.2 5.2v9.6c0 1.4 2.6 2.5 5.8 2.5s5.8-1.1 5.8-2.5V5.2' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M4.2 10c0 1.4 2.6 2.5 5.8 2.5s5.8-1.1 5.8-2.5' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3C/svg%3E");
  /* NETWORK — three nodes. The WiFi profile a tenant bakes into its hardware
     ISOs is a connection, and a small graph is the shape that means "how these
     things reach each other" rather than a globe (artwork) or a plug (a socket). */
  --icon-network: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Ccircle cx='10' cy='4.4' r='2.1' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Ccircle cx='4.6' cy='15' r='2.1' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Ccircle cx='15.4' cy='15' r='2.1' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M10 6.5l-3.9 6.4M10 6.5l3.9 6.4M6.7 15h6.6' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* DOWNLOADS — an arrow into a tray. A tenant-customised ISO is a file you take
     away, and the tray is what stops the arrow reading as "upload" or as "expand". */
  --icon-downloads: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M10 2.6v9.4' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M6.2 8.4L10 12.2l3.8-3.8' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M3.4 13.6v2a1.6 1.6 0 0 0 1.6 1.6h10a1.6 1.6 0 0 0 1.6-1.6v-2' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E");
  /* ORGANIZATION — a building. The organization scope selector names the tenant a
     Govnix administrator is standing in; a building is the institution, and it is
     the kit's own `home` drawing, mounted on the frame like the other header marks. */
  --icon-org: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='3.4' y='3.2' width='13.2' height='13.6' rx='1.4' fill='none' stroke='%23f4f1ef' stroke-width='1.7'/%3E%3Cpath d='M6.6 6.4h2M11.4 6.4h2M6.6 9.6h2M11.4 9.6h2M6.6 12.8h2M11.4 12.8h2' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linecap='round'/%3E%3Cpath d='M8.6 16.8v-2.6h2.8v2.6' fill='none' stroke='%23f4f1ef' stroke-width='1.7' stroke-linejoin='round'/%3E%3C/svg%3E");
}

[data-icon="activity"]::before { mask-image: var(--icon-activity); }
[data-icon="access"]::before { mask-image: var(--icon-access); }
[data-icon="people"]::before { mask-image: var(--icon-people); }
[data-icon="roles"]::before { mask-image: var(--icon-roles); }
[data-icon="credentials"]::before { mask-image: var(--icon-credentials); }
[data-icon="systems"]::before { mask-image: var(--icon-systems); }
[data-icon="groups"]::before { mask-image: var(--icon-groups); }
[data-icon="network"]::before { mask-image: var(--icon-network); }
[data-icon="downloads"]::before { mask-image: var(--icon-downloads); }
[data-icon="org"]::before { mask-image: var(--icon-org); }

/* The drawer at every width below the rail. This is the middle band's rule, opened up to the
   phone band as well — see the banner above for why the bottom bar is not used here.

   THE BANDS DO NOT MEET AT A VIEWPORT THAT IS NOT A WHOLE NUMBER OF PIXELS. The kit's bands are
   `≤ 599` and `≥ 600` (and `≤ 1099` / `≥ 1100`), and a width that falls between two of them
   matches NEITHER, so every rule in the band's block is skipped at once. MEASURED at 1099.4px:
   `.app-nav` came back as the kit's base navigation — `position: static`, 183px wide, in the
   flow, with the main region pushed to left 183 — because THIS block is what makes it a drawer
   and the rail's block does not begin until 1100. A 1.5-DPR window at 899 device pixels is
   exactly such a width (599.33), which is how the sweep found it.

   So the condition below ends at 1099.98 rather than 1099: it abuts the rail at 1100 instead of
   stopping a whole pixel short, and the phone band reaches back over the boundary with the
   `≤ 599.98` on its own block, so no width below the rail is left without one. The 0.02px the
   two share is decided by source order — this block first, the phone block's later — so the
   narrow dress still wins, and no whole-number width changes hands. (The last 0.02px before
   1100 is the one pair still open; closing it means the same change in the kit's own band
   declarations, which are the kit's and are left alone here. It is not reachable at 1.25 or
   1.5 device pixel ratios: no device width divides to 1099.99.) */
@media (max-width: 1099.98px) {
  .app-nav--satellite {
    position: fixed;
    top: 0; left: 0; bottom: 0;
    width: 272px;
    z-index: 31;
    transform: translateX(-100%);
    transition: transform 160ms ease-out;
    overflow-y: auto;
  }
  .nav-anchor:target ~ .app-body .app-nav--satellite { transform: translateX(0); }
  .nav-anchor:target ~ .app-body .nav-veil { display: block; }
  .nav-veil { position: fixed; inset: 0; z-index: 30; }

  /* The list is vertical in this band too, stated HERE rather than left to the kit's own 600px
     block. The phone block below covers `≤ 599.98`, so without these two lines the 0.02px
     between 599.98 and 600 would fall to the kit's base list — the horizontal row of a four-item
     bar — inside a 272px drawer. */
  .app-nav--satellite .nav-list { flex-direction: column; padding: var(--space-3); gap: var(--space-1); }
  .app-nav--satellite .nav-list > li { flex: none; }

  /* The main region is NOT padded to clear a bar, because there is no bar: the drawer overlays
     the content when it is open and is absent when it is closed. The rule is stated with a
     sibling selector rather than by width, so it beats the phone band's own `padding-bottom`
     (which exists to clear the four-item bar and would leave 84px of dead space under this nav). */
  .app-nav--satellite + .app-main { padding-bottom: var(--main-pad); }

  /* The trigger and its chevron belong to every band that has a drawer, which for this nav is
     every band below the rail. The base rule hides them and the middle band re-shows them; this
     opens that up to the phone as well. */
  .icon-btn.nav-toggle { display: flex; }
  .nav-sep { display: block; }

  /* NO COUNT IS SHOWN IN THE DRAWER BANDS, at any level. `select-meta` is the quiet qualifier
     beside a name — "1 organization", "12 people", "Person" — and it is the one part of the path
     that is context rather than identity, so it is the first thing spent when the path is short.
     The kit already spends it at 599px; this continues that through the drawer bands, where the
     path carries a trigger and two extra chevrons the rail's does not.

     MEASURED on a person page: the three counts add 246px of content, and the path grows by 62px
     between 450px and 600px. At 600px the three names and the three counts want 557px in a 439px
     path — 96px more than the levels' names alone want, which is 96px of a name spent on a count.
     With the counts gone the organization's name truncates by 30; with them it truncates by 126,
     and the level above the last has nothing left to abbreviate at all.

     The rail band keeps every count: at 1100px and up the path is 933px for 575px of content. */
  .app-header .frame-path > .frame-select:not(:last-of-type) .select-meta { display: none; }

  /* THE WORDMARK IS NOT DRAWN WHERE THE TRIGGER IS, because the two are the same mark now: the
     trigger wears the wordmark's disc and stands in the wordmark's slot, so drawing both would be
     one mark twice, side by side, with one of them silent. The room it gives back is the point of
     the swap — `Govnix Satellite` is a little over two hundred pixels, and the header needs them
     for the path.

     `display: none` rather than a visually-hidden span, and the difference matters because this
     element is a LINK: a visually-hidden link is still focusable, so a keyboard reader would tab
     to a focus ring around nothing at the head of the header — a worse control than no control.
     Nothing is lost by taking it out. It is in the browser's own title bar, the disc still says
     whose header this is, the drawer names the application when it opens, and the page keeps its
     OWN `<h1>` — the hero's — so the document is left with one heading instead of two. */
  .app-header .app-name { display: none; }
}

/* The phone band lays the list out as a ROW, because the kit's four-item navigation is a bottom
   bar there. This navigation is a drawer at every width below the rail, so the phone band takes
   the vertical arrangement the middle band already has — stated in its own block rather than
   nested inside the one above, because a `@media` inside a `@media` is not honoured: the browser
   closed the outer block at the inner rule and the drawer's own `position: fixed` leaked to every
   width, which took the rail off the desktop entirely.

   `≤ 599.98` rather than `≤ 599` so that this block meets the drawer's block above instead of
   leaving the 599–600 hole described there — the dress below is for a viewport under 600, and
   599.98 is the last value under it that the drawer's block does not already cover. */
@media (max-width: 599.98px) {
  .app-nav--satellite .nav-list { flex-direction: column; padding: var(--space-3); gap: var(--space-1); }
  .app-nav--satellite .nav-list > li { flex: none; }
  .app-nav--satellite .nav-item {
    flex: 1; min-width: 0;
    flex-direction: row; justify-content: flex-start;
    gap: var(--space-3);
    padding: var(--space-3) var(--space-4);
    font-size: var(--text-small);
    text-align: left;
  }
  .app-nav--satellite .nav-item[aria-current="page"]::after {
    top: 50%; bottom: auto; left: 0; right: auto;
    width: 4px; height: 70%;
    transform: translateY(-50%);
  }
  .app-nav--satellite .nav-item:hover:not([aria-current="page"])::after {
    bottom: auto; left: 0;
    top: calc(50% - 7px);
    box-shadow: 0 10px 0 var(--brand-azure-bright);
  }
}

@media (min-width: 1100px) {
  .app-nav--satellite { width: 244px; }
}

@media (prefers-reduced-motion: reduce) {
  .app-nav--satellite { transition: none; }
}

/* ===========================================================================
   THE SIGN-IN PAGE
   ---------------------------------------------------------------------------
   The control plane's authentication boundary (§5.4): a single card, centred,
   carrying the wordmark, the organization's sign-in domain, and a username and
   password. It exists BEFORE the shell, so it has no navigation, no hero and no
   scope selector — there is no session yet to scope anything to.

   It is a new component rather than a composition of the shell's parts because
   nothing else in the kit is centred, and centring by inline style would put a
   layout decision on a page. The card is structure (square, hairline) and the
   card is raised, because the whole page is one control group standing on the
   frame's own ground — see the body rule below.
   ========================================================================= */

body:has(.signin) { background: var(--brand-space-deep); }

.signin {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-6) var(--space-4);
}

.signin-card {
  width: 100%;
  max-width: 424px;
  /* `width: 100%` PLUS this card's own padding, unless the box model is told otherwise. Under
     `content-box` the card is 100% of its parent and then 24px wider than that on each side, so it
     overflows the window it is centred in — measured on `screens/login.html` at 390px: the card's
     right edge landed at 429px and the PAGE grew a horizontal scrollbar, which is a second scroll
     plane on the one page that has nothing else on it. The kit adds `border-box` per component
     rather than resetting it globally, so it has to be said here too. */
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  padding: var(--space-6);
  background: var(--brand-surface);
  border: 1px solid var(--brand-border);
  border-radius: var(--radius-structure);
  box-shadow: var(--shadow-control);
}

.signin-brand { display: flex; align-items: center; gap: var(--space-3); }
.signin-brand .app-name { margin: 0; font-size: 1.375rem; }

.signin-head { display: flex; flex-direction: column; gap: var(--space-1); }
.signin-title { margin: 0; font-family: var(--font-heading); font-size: var(--text-h3); color: var(--brand-ink); }
.signin-sub { margin: 0; font-size: var(--text-small); color: var(--brand-ink-muted); max-width: 46ch; }

.signin-actions { display: flex; justify-content: flex-end; margin-top: var(--space-2); }

/* The signature under the card, on the frame's own ground — the one place the footer's mark is
   drawn in the frame's pair rather than the panel's. */
.signin-foot {
  margin-top: var(--space-4);
  text-align: center;
  font-size: var(--text-micro);
  color: var(--brand-on-dark-muted);
}
.signin-foot a { color: var(--brand-on-dark); text-decoration: none; }
.signin-foot a:hover { text-decoration: underline; }

/* The organization selector inside the card is a FIELD rather than a frame control: it stands on a
   white panel, so it takes the light plane's dress, not the header's. */
.signin-card .select { width: 100%; }

/* ===========================================================================
   THE FEATURE SELECTOR'S PILL IS SHORTER THAN ITS OWN DRAWING — on purpose
   ---------------------------------------------------------------------------
   The sticker is 24px and the row's line box is 20.1px, so a pill carrying the
   drawing at the row's own `--space-2` block padding stands 41.1px where the two
   beside it stand 38.1px, and the kit's rule is that every control in a row shares
   ONE scale. The difference is split between the drawing's two sides, and the sum
   is DERIVED rather than chosen: `2 * --space-2 + 2px + 1lh` is what a sibling
   `.frame-select` measures, and the same sum with 24px in place of the line box
   leaves the remainder to be halved.

   `1lh` is the pill's own line box, so a change to the face or the size moves the
   sibling and this control together — the same device the date field uses for its
   height. Measured after the rule: 38.1px in every band, against the 38.1px of the
   two selectors beside it, and against the 35.1px the compact band's tighter
   padding gives all three.

   IT USED TO NAME ONE CLASS, and the rule was never about that class — it is about
   CARRYING A MARK. Two more crumbs acquired one and both broke the row: the
   ORGANIZATION crumb took the solar-system drawing when the building glyph was
   retired and stood 41.3px, and the RECORD crumb — the one that opens the list of
   people — took the person's avatar and stood **48.7px** against its neighbours'
   38.3px. It was the tallest control in the header, and it was the control the
   reader had just been asked to look at.

   So the rule names the MARK now, whichever crumb wears it, and the header's avatar
   is a 24px mark like the drawings beside it rather than the 30px disc it is in a
   table — a face is a mark in a crumb and a portrait in a row, and the sum above
   only holds if the mark is the size the sum was derived for. Measured after: all
   three at 38.3px, in every band. The account button's avatar is deliberately NOT
   in this rule: it sits in `.header-right`, not in `.frame-path`, and it is not a
   crumb. */
.app-header .frame-path > .frame-select:has(> .frame-select-art),
.app-header .frame-path > .frame-select:has(> .avatar) {
  padding-block: calc((2 * var(--space-2) + 1lh - 24px) / 2);
}
/* A MARK THAT IS TOO SMALL TO BE INSET IS NOT INSET. The disc is a frame for a picture, and a frame
   spends its width on itself: at the crumb's 24px the ground and the hairline leave a 20px drawing
   swimming inside a ring, which is the smallest the mark ever gets and the place it can least
   afford to be smaller. So the crumb wears the sticker BARE — the same 24px drawing, edge to edge,
   exactly as `.frame-select-art` beside it does. The result is also the more consistent one: all
   three crumbs then carry the same KIND of mark rather than two drawings and one disc.

   The disc is still right in a list, where the mark sits in a dense row of text and the ground is
   what separates a face from the name beside it, and at `.avatar-lg`, where there is room for both.
   The threshold is therefore not a number to tune — it is whether the drawing still reads at the
   size the disc would leave it. */
.app-header .frame-path > .frame-select > .avatar {
  width: 24px;
  height: 24px;
  background: none;
  border: 0;
}
.app-header .frame-path > .frame-select > .avatar > img { width: 100%; height: 100%; }

/* ===========================================================================
   THE PATH GIVES UP ITS ROOM FROM THE LEFT
   ---------------------------------------------------------------------------
   The path is a descent — organization › feature › record — and its levels are
   not worth the same. The last one is where the reader IS: it is the page under
   this header, and the hero's `h1` says the same words a few lines below. The
   ones above it are the directions they came by. So when the three do not fit,
   the last level keeps its text whole and the levels above take the whole of the
   loss.

   FLEXBOX SAYS THAT WITH SHRINK WEIGHTS, not with `flex: 0 0 auto`, and the
   difference is what happens when there is nothing left to give. Negative space
   is handed out in proportion to (shrink x basis), so 999 against 1 gives the
   levels above very nearly all of it; and because each of those stops at its own
   `min-width: 0` the leftover — the case where even an empty path above cannot
   buy the last level the room it needs — falls to the last level, which then
   truncates like the others. A last level pinned with `flex: 0 0 auto` would
   instead push the header wider than the viewport, and a header that scrolls
   sideways is worse than a truncated name.

   THE WEIGHTS ARE SCOPED TO THE BANDS THAT CARRY WORDS, which is everything from
   the compact band's 700px up — and that is not tidiness. Below 700 the two
   levels above the last are MARKS, and a mark is not a form that can be
   abbreviated: left in the same rule they shrank like text, and the
   organization's mark measured 17px against its own 20px box on a role page at
   600px. That is the defect the feature's sticker already had a `flex: none`
   for, and the way out is the two states rather than a third weight.

   The cut is at the LABEL's end, which is the right order of loss: a level being
   abbreviated loses its `select-meta` count before it loses its name (and in the
   drawer bands it has already lost it — see the rule below).

   MEASURED, at 390px on a person page, before this rule: the three labels drew
   29 of 88, 1 of 37 and 17 of 61 pixels of the text they hold — three ellipses,
   and the LAST of them the smallest, which is the whole complaint. With it, on
   the same page, the record's label is drawn whole at 390px, 600px, 700px and
   everything above; on a role — the longest name in the set — it is whole from
   600px up, and at 390px it is the one control that truncates, because the two
   levels above it are already their marks.
   ========================================================================= */
@media (min-width: 700px) {
  .app-header .frame-path > .frame-select:not(:last-of-type) { flex-shrink: 999; }
  .app-header .frame-path > .frame-select:last-of-type { flex-shrink: 1; }
}

/* ===========================================================================
   THE NARROW HEADER — the wordmark goes, and the marks stay
   ---------------------------------------------------------------------------
   Below the rail the header gives up its wordmark and nothing else it can
   afford. `Govnix Satellite` is 163px and the path is where the reader finds out
   where they are, so the name stops being drawn and its disc moves onto the
   navigation trigger, which stands in its slot: the header's brand and its menu
   button become one control. There is no wordmark collapse left to describe — it
   used to lose its letters below 600px and keep the disc, and the disc has gone
   the last step and become the button. The kit's reason stands, and the disc
   "reads instantly at 11px and at 88px alike".

   EVERY LEVEL KEEPS ITS MARK AT EVERY WIDTH, and only the LABELS are banded. The
   organization wears the kit's building mark and the feature wears its own
   sticker — the same 24px drawing the feature picker's rows carry, so the control
   and the menu it opens name the same surface twice in one drawing. Taking the
   drawings away as the window WIDENED was the one thing about this path that had
   it backwards: a reader who moves from a phone to a desktop would watch two
   drawings disappear and two words arrive in their place, as if the wider header
   could hold less. What the bands spend is the label, which the reader can read
   in the page beneath anyway.

   The building sits in the same 20px box as every other icon in the kit, which is
   what keeps the pill the row's own height: the mark is taller than the line box
   beside it, and `align-items: center` is what the two share. The sticker is 24px
   and the feature's pill derives its block padding from a sibling's own height —
   see the rule above — so it stands the same height as the two beside it.

   THE CHEVRON STAYS IN EVERY BAND. It is what says the control is a CHOICE rather
   than a label, and the kit's rule for the frame selector is that the chevron is
   the last thing lost to a narrow header. An icon-only pill with no chevron would
   read as the page's logo, which is the one thing this control is not.

   Labels are taken out of the layout with `.sr-only`'s technique rather than
   `display: none`, so a control keeps its accessible name wherever its words go.
   ========================================================================= */

/* ===========================================================================
   THE COMPACT PATH — the levels above the last lose their WORDS
   ---------------------------------------------------------------------------
   THE MARKS STAY AND THE WORDS GO. Below this width the two levels above the
   last lose their LABEL — the control keeps the drawing it is known by at every
   width — so the path reads as a mark, a mark and a name instead of as three
   names fighting over 400 pixels. The record, the level the reader is actually
   ON, keeps its label at every width: that is the rule the whole path is built
   on, and it is the one the user asked for. Read from the right, the last level
   is the page; the rest is how they got there.

   THE WIDTH IS MEASURED. On the longest page in the set — a role, whose record
   reads "Production Administrator · Role" — the organization's own name is drawn
   in 23 of the 100 pixels it holds at 600px and in 0 at 700px, if the labels are
   left in. With the two marks the same path holds the RECORD's whole name at
   600px and at 390px; at 390px it is the one page in the set that still cannot,
   and the record is the control that truncates there — see the note on the
   weights in the Satellite block.

   THE LABELS ARE NOT DROPPED ONE AT A TIME AS EACH RUNS OUT, because there is no
   honest way to write that: a rule that fires when a label has been truncated is
   a rule this stylesheet cannot express, and a third state tuned to the longest
   string in the product would be a number nobody could re-derive. Two states —
   the rail's words and this one — are worth more than that.
   ========================================================================= */
@media (max-width: 699px) {
  /* THE PATH'S OWN GAPS TIGHTEN, which is the last cheap saving in the header and part of the same
     band as the two collapses. Measured on a role page — the longest in the set — at 600px: with
     8px gaps the record is 4px short of its own name; at 4px it is whole with room to spare. The
     path is a chain of related controls rather than a row of separate ones, and a chain reads
     better tight; the rail band keeps the kit's own `--space-2`. */
  .app-header .frame-path { gap: var(--space-1); }

  /* NO COUNT IS SHOWN IN THE COMPACT BAND, at any level. `select-meta` is the quiet qualifier
     beside a name — "1 organization", "12 people" — and it is the cheapest thing a level has to
     give up, because the name beside it already identifies the level. On a role page at 600px the
     three names and their counts want 557px of a 439px path.

     Above this band only the levels ABOVE the last lose their counts; the rail band keeps every
     one of them. */
  .app-header .frame-path .select-meta { display: none; }

  /* THE ORGANIZATION LOSES ITS WORDS HERE, and its mark is already drawn at every width, so this
     band has only to take the label out of the layout. Measured with the labels left in: the
     organization's own name is drawn in 23 of the 100 pixels it holds at 600px and in 0 at 700px —
     a pill holding a chevron and no word at all.

     It is done HERE rather than with a `min-width` floor on the words, because a floor cannot
     promise anything: a floor of "a few characters" would only push that shortage onto the record
     and abbreviate the one level that must not be abbreviated, while a mark is a form that always
     fits — it is 20px wide whether the organization is called Acme or the East India Company.

     `flex: none` for the same reason the sticker has it: a mark at a shrinkable size is a mark
     that gets crushed, and a 17px building reads as dirt on the screen. The label is taken out of
     the flow (still announced) rather than hidden, so the control keeps its name for a reader who
     cannot see the drawing, and the pill keeps the chevron — the mark says WHICH organization, the
     chevron says it can be changed. */
  .app-header .frame-path > .frame-select--org {
    position: relative;
    flex: none;
    padding: calc((2 * var(--space-2) + 1lh - 20px) / 2) var(--space-2);
    gap: var(--space-1);
  }
  .app-header .frame-path > .frame-select--org .frame-select-label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  /* The feature loses its words here too, and only where it is NOT the last level: on a page that
     is the feature itself — People, Roles, Systems — there is no level below to name, and a path of
     a building and a drawing with no word among them would leave the reader comparing two pictures.
     `position: relative` is what keeps the label, once taken out of the flow, from being positioned
     against the viewport instead, and the pill's derived block padding — which is what keeps it the
     same height as its neighbours — is set for every band in the Satellite block. */
  .app-header .frame-path > .frame-select--feature:not(:last-of-type) {
    position: relative;
    flex: none;
    padding: calc((2 * var(--space-2) + 1lh - 24px) / 2) var(--space-2);
    gap: var(--space-1);
  }
  .app-header .frame-path > .frame-select--feature:not(:last-of-type) .frame-select-label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }
}
