/*
 * The frame every dialog shares: a panel with a titled header strip over a
 * body. It is the page chrome's, and the page's modals (`ModalDialog` in
 * `web/components.tsx`) are its main use; the exercises' `(?)` help panel
 * wears it too, in a widget's shadow root (`client/components/help-dialog.ts`),
 * because a help panel is uniform chrome rather than part of any one
 * exercise. One file, so the two cannot drift apart.
 *
 * Served both ways, as `group.css` is, and read the same way: each colour is
 * `var(--_twin, var(--exercise-token))`, the twin in a shadow root and the
 * token on the page, where `web/content.css` maps it onto the palette.
 *
 * What differs stays with its owner: where the panel sits and how wide it
 * is, its backdrop, its type size, and what goes in the body.
 */

.dialog-panel {
  background: var(--_surface, var(--exercise-surface));
  border: 1px solid var(--_border, var(--exercise-border));
  border-radius: 6px;
  box-shadow: 0 18px 44px
    color-mix(
      in srgb,
      var(--_shadow, var(--exercise-shadow)) 22%,
      transparent
    );
  color: var(--_text, var(--exercise-text));
  padding: 0;
}

.dialog-header {
  align-items: baseline;
  border-bottom: 1px solid var(--_divider, var(--exercise-divider));
  display: flex;
  flex: 0 0 auto;
  gap: 1rem;
  justify-content: space-between;
  padding: 0.6rem 0.85rem;
}

.dialog-title {
  font-size: 0.95rem;
  font-weight: 700;
  line-height: 1.3;
  margin: 0;
}

/* Every property a page's button rules set is restated, because on the page
   this is a `button` like any other and would otherwise be drawn as one. */
.dialog-close {
  background: transparent;
  border: 0;
  color: var(--_text-muted, var(--exercise-text-muted));
  cursor: pointer;
  font: inherit;
  font-size: 1.2rem;
  line-height: 1;
  margin: 0;
  min-height: 0;
  padding: 0 0.2rem;
}

.dialog-close:hover {
  background: transparent;
  color: var(--_text, var(--exercise-text));
}

.dialog-body {
  padding: 0.75rem 0.85rem 0.9rem;
}

/*
 * The app shell: navigation, sheets, the ledgers of records, dialogs, forms.
 * Never loaded by a content document — a lesson in an iframe has no chrome —
 * which is why it is a second file and a second cached download.
 *
 * The colour tokens it reads are declared in ./content.css, which every
 * document that loads this one also loads. See ./styles.ts.
 */

html {
  background: var(--paper);
}

body {
  background:
    radial-gradient(circle at 76% 18%, var(--body-glow), transparent 25rem),
    linear-gradient(180deg, var(--paper-top) 0%, var(--paper) 100%);
  color: var(--ink);
  display: flex;
  flex-direction: column;
  font: 16px / 1.5 var(--body-font);
  margin: 0;
  min-height: 100vh;
}

/* Lay related fields side by side on wide viewports instead of stacking
   every full-width input. Collapses to a single column when narrow.
   auto-fill (not auto-fit) keeps empty tracks, so a row with fewer fields
   than columns gets ordinary-width fields, not stretched ones. */
.field-grid {
  display: grid;
  gap: 0.9rem 1rem;
  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
}

/* A column-flex cell keeps the input on the cell's bottom edge, so inputs
   across a row line up even when one label wraps to a second line. */
.field-grid > label,
.field-grid > label + label {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  margin-top: 0;
}

.field-grid + label,
label + .field-grid {
  margin-top: 0.9rem;
}

/* For fields whose values are real text (titles, revision names) rather
   than numbers or dates: fewer, wider columns. */
.field-grid.wide-fields {
  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
}

/* For a schedule: a track wide enough to hold a whole date and time, so the
   columns land above the control's own width rather than at it. Without
   this the tracks can settle near their 12rem floor, and a datetime field
   held to its min-content width would then spill over its neighbour. */
.field-grid.time-fields {
  grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
}

/* A field another control has switched off — the schedule date under a
   grade-visibility choice that does not use it. Drawn like a disabled button
   for the same reason: without a rule of its own it keeps the live field's
   white fill, and the only way to learn it is inert is to click into it. The
   faint ink is deliberate; a disabled control is exempt from the contrast
   minimum, and reading as unavailable is the whole point.

   Chrome only. The same base rule in CONTENT_STYLES would reach the disabled
   inputs an exercise preview renders, where the text is the student's work
   and has to stay legible. */
input:disabled {
  background: var(--surface-soft);
  border-color: var(--rule);
  color: var(--ink-faint);
  cursor: not-allowed;
}

/* A submit button placed as the last grid item lands in the grid's last
   column — the right edge of the row it wraps onto — bottom-aligned with
   the inputs rather than the labels above them. */
.field-grid > button {
  align-self: end;
  grid-column-end: -1;
  justify-self: end;
  margin: 0;
}

/* Compact buttons for dense rows: tables, inline forms, record headline. */
.create-bar button,
.record-headline button {
  font-size: 0.85rem;
  min-height: 0;
  padding: 0.4rem 0.8rem;
  margin: 0;
}

/* Buttons in a cell are compact: an icon, or a short inline action that has
   to sit in a row without stretching it. A modal dialog rendered inside a
   cell (the members roster keeps each row's dialogs beside their triggers)
   is not in the table as far as the reader is concerned — it is in the top
   layer — so its buttons keep the ordinary chrome. The exclusion is wrapped
   in :where() so it adds no specificity: a bare :not() takes its argument's,
   which would outrank .icon-button and flatten the icons this rule is for. */
table button:not(:where(.modal-dialog button)) {
  min-height: 0;
  padding: 0;
  margin: 0;
  border: none;
}

/* Application header and shell */

.app-header {
  align-items: center;
  border-bottom: 1px solid var(--rule);
  display: flex;
  flex-wrap: nowrap;
  gap: clamp(1rem, 4vw, 3rem);
  margin: 0 auto;
  max-width: 85rem;
  padding: 1.5rem var(--shell-gutter);
  width: 100%;
}

.noscript-banner {
  background: var(--gold-soft);
  border-bottom: 1px solid var(--gold);
  color: var(--gold);
  font-weight: 600;
  padding: 0.85rem var(--shell-gutter);
  text-align: center;
}

.brand {
  align-items: baseline;
  color: var(--ink);
  display: inline-flex;
  flex: 0 0 auto;
  font-family: var(--logo-font);
  font-size: clamp(2rem, 4vw, 2.85rem);
  gap: 0.58rem;
  letter-spacing: -0.045em;
  line-height: 1;
  text-decoration: none;
}

.brand:hover {
  color: var(--ink);
  text-decoration: none;
}

.brand-mark {
  color: var(--blue);
  display: inline-block;
  font-family: Georgia, "Times New Roman", serif;
  font-size: 0.92em;
  font-weight: 400;
  line-height: 0.9;
  transform: translateY(-0.02em);
}

.app-nav {
  align-items: center;
  display: flex;
  flex: 1 1 auto;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: clamp(1rem, 3vw, 2.4rem);
  min-width: 0;
}

.app-nav a {
  color: var(--ink);
  font-size: 0.96rem;
  font-weight: 560;
  text-decoration: none;
}

.app-nav a:hover {
  color: var(--blue);
}

.nav-divider {
  align-self: center;
  background: var(--rule);
  flex: 0 0 auto;
  height: 1.5rem;
  width: 1px;
}

.nav-profile {
  align-items: center;
  color: var(--ink);
  display: inline-flex;
  flex: 0 0 auto;
  font-size: 0.96rem;
  font-weight: 620;
  text-decoration: none;
}

.nav-profile:hover {
  color: var(--blue);
}

/* The monogram that stands in for the name on a phone; see the 700px block
   below, and `ProfileLink` in ./layout.tsx for the accessible-name handling. */
.nav-profile-mark {
  display: none;
}

/* The read-only account facts sit below the edit form, divided from it by a
   rule with breathing room on both sides. Drop the strip's trailing rule,
   which otherwise reads as a stray line above the footer. */
.profile-sheet .summary-strip {
  border-bottom: none;
  border-top: 1px solid var(--rule);
  margin-top: 1rem;
}

/* Turnstile renders into a bare div that takes no part in the form rhythm —
   labels and buttons get their top margins from the content layer, and
   without one of its own the widget sits flush against the email field's
   bottom edge. The label's 0.9rem rather than the button's 1.1rem, because
   the widget reads as another field to complete, not as an action. */
.cf-turnstile {
  margin-top: 0.9rem;
}

.logout-form {
  margin: 0;
  text-align: right;
}

/* Quiet styling; the sheet-footer default supplies the compact size. */
.logout-form button {
  background: transparent;
  border-color: var(--rule);
  color: var(--ink);
  font-weight: 560;
}

.logout-form button:hover {
  border-color: var(--blue);
  color: var(--blue);
  filter: none;
}

.page-shell {
  /* Grow to absorb any leftover viewport height so the footer is pinned to
     the bottom on short pages, while long content still pushes it below the
     fold. Width is centered by the auto margins as before. */
  flex: 1 0 auto;
  margin: 0 auto;
  max-width: 85rem;
  padding: 0.75rem var(--shell-gutter) 4rem;
  width: 100%;
}

/* Pages without a breadcrumb keep the roomier top spacing. The second
   selector is the same rule for a page the profile prompt has pushed down:
   without it, the strip's presence would silently take that spacing away. */
.page-shell > .page-content:first-child,
.profile-prompt + .page-content {
  margin-top: 2rem;
}

/* Asks a signed-in reader for what their account is still missing. Drawn as
   a notice rather than as a banner across the window, because it belongs to
   the content column and not to the app chrome: it is a request, and one the
   reader may decline. */
.profile-prompt {
  align-items: center;
  background: var(--blue-soft);
  border: 1px solid color-mix(in srgb, var(--blue) 22%, transparent);
  border-radius: 4px;
  color: var(--blue-strong);
  display: flex;
  flex-wrap: wrap;
  font-size: 0.92rem;
  gap: 0.75rem 1.25rem;
  justify-content: space-between;
  margin-bottom: 1.75rem;
  padding: 0.7rem 1rem;
}

/* Enough to keep the sentence from being squeezed to one word per line when
   the two controls are on the same row, and — being a minimum rather than a
   width — what makes the row break in two before that can happen. */
.profile-prompt p {
  flex: 1 1 18rem;
  margin: 0;
}

.profile-prompt-actions {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
}

/* This form is a wrapper around one button, not a stack of fields, so it
   wants neither of the two things an ordinary form gives its submit. Flex,
   because a block form would put its inline-level button on a baseline and
   inherit the line box's leading; and no top margin, because the 1.1rem that
   separates a submit from the labels above it has nothing above it here.

   That margin is what the misalignment actually was: it made the form 61px
   tall around a 43px button and left the button sitting 9px below the link
   beside it — which centring the row could not correct, since the box being
   centred was the right height and the button inside it was not. */
.profile-prompt-actions form {
  display: flex;
  margin: 0;
}

.profile-prompt-actions form button {
  margin-top: 0;
}

/* The strip is already a tinted panel, so the quieter control drops its own
   fill and reads against that instead of stacking a second surface on it. */
.profile-prompt-actions .ghost {
  border-color: color-mix(in srgb, var(--blue) 30%, transparent);
  color: var(--blue-strong);
}

.profile-prompt-actions .ghost:hover {
  background: color-mix(in srgb, var(--blue) 12%, transparent);
}

/* The row under the navbar: the breadcrumb, and opposite it whatever the
   page put in `headerAside`. A wrapper rather than a rule on the breadcrumb
   itself, because the aside is not navigation and does not belong inside the
   nav landmark — and because the gap below the row is the row's, so an empty
   aside leaves the spacing exactly as it was. */
.page-header {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 1.5rem;
  justify-content: space-between;
  margin-bottom: 2.75rem;
}

/* Takes the row's leftover width, so a long trail wraps against the page
   rather than against its own content — which is what it did before the
   wrapper made it a flex item. */
.page-header .breadcrumb {
  flex: 1 1 auto;
  min-width: 0;
}

.breadcrumb {
  align-items: baseline;
  color: var(--ink-muted);
  display: flex;
  flex-wrap: wrap;
  font-size: 0.92rem;
  gap: 0.4rem 0.55rem;
  letter-spacing: 0.005em;
}

.breadcrumb-link {
  color: var(--ink-muted);
  text-decoration: none;
}

.breadcrumb-link:hover {
  color: var(--blue);
  text-decoration: underline;
}

.breadcrumb-sep {
  color: var(--ink-faint);
}

/* The trail's last step names the page, and a page can be named by free
   text the author wrote — a revision is named by its note. That is one line
   about where you are, so a long name is cut short rather than allowed to
   become the row; the whole of it is still in the markup, and still read
   out. */
.breadcrumb-current {
  color: var(--ink);
  font-weight: 560;
  max-width: 46ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.page-content {
  display: grid;
  /* A single-column grid with no explicit track is an implicit auto column,
     which sizes to its content's min-content and can overflow the viewport
     (a wide table or long value in a sheet drags the whole column past the
     screen edge on a phone). Pinning the track to minmax(0, 1fr) caps it at
     the container width so wide content wraps or scrolls within its sheet. */
  gap: 1.75rem;
  grid-template-columns: minmax(0, 1fr);
}

/* Sheets */

.sheet {
  background: var(--surface-translucent);
  border: 1px solid var(--rule);
  border-radius: 4px;
  overflow: hidden;
}

.sheet-header,
.sheet-section {
  padding: 1.25rem clamp(1rem, 2vw, 1.5rem);
}

.sheet-header {
  background: linear-gradient(180deg, var(--surface), var(--surface-soft));
  border-bottom: 1px solid var(--rule);
}

.sheet-header h2 {
  margin-bottom: 0.3rem;
}

.sheet-header .small {
  margin: 0;
}

.sheet-section + .sheet-section {
  border-top: 1px solid var(--rule);
}

/* A sheet that is a disclosure: its header is the summary, so a closed
   drawer still reads as one of the page's cards rather than as a stray link.
   The triangle is drawn rather than left to the UA marker, and drawn with
   borders rather than a glyph because generated text would join the
   summary's accessible name. */

.archived-sheet > summary {
  cursor: pointer;
  list-style: none;
}

.archived-sheet > summary::-webkit-details-marker {
  display: none;
}

/* Closed, the header's rule would sit on the sheet's own bottom border. */
.archived-sheet:not([open]) > summary {
  border-bottom: 0;
}

.archived-sheet > summary h2 {
  margin-bottom: 0;
}

.archived-sheet > summary h2::before {
  border: 0.3rem solid transparent;
  border-left-color: currentcolor;
  content: "";
  display: inline-block;
  height: 0;
  margin-right: 0.45rem;
  width: 0;
}

.archived-sheet[open] > summary h2::before {
  transform: rotate(90deg);
}

.archived-sheet .sheet-section > .small {
  margin-top: 0;
}

/* Summary strips */

/* The cells self-arrange into however many columns the width affords, so a
   strip can render as one row, one column, or an in-between grid. Rather than
   draw dividers with per-cell borders — which follow source order, not grid
   position, and so leave a stray line on the first cell of a wrapped row and
   none between rows — the 1px gap does the work: the rule colour is painted on
   the strip's content box (never its padding) and each cell paints over its
   own track, so the rule shows through only the interior gaps, in whichever
   directions cells actually neighbour, at any column count. Flex-wrap (not a
   uniform grid) is what keeps this clean on a partial last row: the cells grow
   to fill each row, so no phantom empty track is left showing the rule colour
   as a solid block. */
.summary-strip {
  background: var(--rule-soft);
  background-clip: content-box;
  border-bottom: 1px solid var(--rule);
  display: flex;
  flex-wrap: wrap;
  gap: 1px;
  margin: 0;
  padding: 1.25em;
}

.summary-item {
  background: var(--surface-translucent);
  display: flex;
  flex: 1 1 9rem;
  flex-direction: column-reverse;
  gap: 0.2rem;
  padding: 1rem clamp(0.85rem, 1.4vw, 1.1rem);
}

.summary-item dd {
  font-family: var(--logo-font);
  font-size: 1.65rem;
  font-weight: 500;
  line-height: 1.1;
  margin: 0;
}

.summary-item dt {
  color: var(--ink-muted);
  font-size: 0.8rem;
}

/* A localized date carries far more characters than the short stat values the
   big display serif is meant for, and at that size a full "month day, year,
   time" wraps to two or three lines and stretches every cell in the strip to
   match. The font stays uniform with the other stats; instead a script (see
   the layout) measures each cell and picks the longest date representation
   that fits on one line, dropping the time and then abbreviating as needed. */
.summary-item dd time {
  white-space: nowrap;
}

/* Email + account-status row on the admin user record */
.record-headline {
  align-items: center;
  border-bottom: 1px solid var(--rule);
  display: flex;
  gap: 1rem;
  justify-content: space-between;
}

.record-headline .summary-strip {
  border-bottom: none;
  flex: 1 1 auto;
}

.record-headline > form {
  margin: 0;
  padding-right: clamp(1rem, 2vw, 1.5rem);
}

/* Status badges */

.status-badge {
  align-items: center;
  background: var(--surface-soft);
  border: 1px solid var(--rule);
  border-radius: 999px;
  color: var(--ink-muted);
  display: inline-flex;
  font-size: 0.76rem;
  font-weight: 700;
  gap: 0.35rem;
  padding: 0.24rem 0.6rem;
  white-space: nowrap;
}

/* Each badge's border is its own accent, gone faint. Mixed from the token
   rather than written out at an alpha, so it tracks the accent when the
   accent moves — the green here had been left behind at the pre-AA
   #26935d after --green was darkened to #1b7048. */

.status-badge-ok {
  background: var(--green-soft);
  border-color: color-mix(in srgb, var(--green) 28%, transparent);
  color: var(--green);
}

.status-badge-warn {
  background: var(--gold-soft);
  border-color: color-mix(in srgb, var(--gold) 25%, transparent);
  color: var(--gold);
}

.status-badge-danger {
  background: var(--red-soft);
  border-color: color-mix(in srgb, var(--red) 24%, transparent);
  color: var(--red);
}

.status-badge-neutral {
  background: var(--blue-soft);
  border-color: color-mix(in srgb, var(--blue) 28%, transparent);
  color: var(--blue-strong);
}

/* A closed assignment lingers, greyed, for reference; any link it still
   carries (e.g. released results) keeps its normal styling. */
tr.assignment-closed td {
  color: var(--ink-muted);
}

/* A cell with nothing in it: a dash that is there to be looked past, so the
   rows that do have a value (a due date) are the ones that catch the eye. */
.table-empty {
  color: var(--ink-muted);
}

/* A column of figures: set right, on digits of one width, so the scores in
   it line up by place and a reader can compare down the column. */
th.numeric,
td.numeric {
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* A practice/reading score: recorded for the student's own signal, muted to
   set it apart from graded scores that count toward the course total. */
a.score-uncounted {
  color: var(--ink-muted);
}

/* Ledger tables: the sortable headings, the scroller, and the audit log.
   Here rather than in content.css, which every content document also loads:
   these are the app shell's own furniture, and a lesson has none of it. */

/* A heading that reorders the table under it. The shell script builds this
   button out of the heading's own text on load, so it takes the heading's
   small uppercase type rather than a button's or a link's: the roster should
   still read as a ledger with a couple of live headings, not as a row of
   controls. Hover and the triangle are what say it does something. */
th > .column-sort {
  align-items: center;
  background: none;
  border: 0;
  color: inherit;
  cursor: pointer;
  display: inline-flex;
  font: inherit;
  gap: 0.3em;
  letter-spacing: inherit;
  padding: 0;
  text-align: inherit;
  text-transform: inherit;
}

/* Winning back the button hover, which would otherwise paint a blue pill
   across a table heading. */
th > .column-sort:hover {
  background: none;
  color: var(--ink);
  text-decoration: underline;
}

/* Which way the sorted column runs. Hidden from the accessibility tree — the
   heading's aria-sort already says it, and in words. The triangle is set well
   under the heading's own size (at full size it out-weighs the word it
   qualifies) and centred on it rather than sat on the baseline, where a
   shape with no descender reads as having slipped. */
.column-sort-mark {
  font-size: 0.62em;
  line-height: 1;
}

/* Tables carry a content-driven minimum width (one column per gradebook
   assignment, long endpoint URLs, …) that can exceed a narrow sheet. The
   sheet clips its overflow, so wrap the table in a scroller: it fills the
   sheet on wide screens and scrolls sideways instead of hiding columns on a
   phone. */
.table-scroll {
  overflow-x: auto;
}

/* The admin audit log is one event per *pair* of rows: the four fields anyone
   scans by, then a full-width line carrying the request id and — behind a
   disclosure, when the event recorded any — its metadata. So the rule that
   divides events goes after the pair rather than through the middle of one,
   and the detail line sits tight under the row it belongs to. */

.audit-event td {
  border-bottom: 0;
  padding-bottom: 0.3rem;
}

.audit-event-detail td {
  padding-top: 0;
}

/* The action's code, under the words for it: small and quiet, because it is
   there to be searched for rather than read. */
.audit-action-code {
  color: var(--ink-muted);
  display: block;
  font-size: 0.75rem;
}

/* Indented by exactly the drawn marker below, so the id starts in the same
   place whether or not the event recorded metadata to disclose. */
.audit-request {
  color: var(--ink-muted);
  margin: 0;
  padding-left: 0.85rem;
}

.audit-request > summary {
  cursor: pointer;
  list-style: none;
  /* Only the text is the hit target; a summary left to fill the row would
     make the whole line clickable with nothing to say so. */
  width: fit-content;
}

.audit-request > summary:hover {
  color: var(--ink);
}

/* The triangle is drawn rather than left to the browser: the native marker's
   box is a different width in every engine, so a metadata-less event would
   sit a few pixels off from the rows around it. Borders rather than a glyph
   because generated text joins the summary's accessible name, and "black
   right-pointing small triangle" is not part of it. */

.audit-request > summary::-webkit-details-marker {
  display: none;
}

.audit-request > summary::before {
  border: 0.3rem solid transparent;
  border-left-color: currentcolor;
  content: "";
  display: inline-block;
  height: 0;
  margin: 0 0.25rem 0 -0.85rem;
  vertical-align: 0.04rem;
  width: 0;
}

.audit-request[open] > summary::before {
  transform: rotate(90deg);
}

.audit-request pre {
  margin: 0.55rem 0 0;
}

/* Sheet footer create bars */

.sheet-footer {
  background: var(--surface-soft);
  border-top: 1px solid var(--rule);
  padding: 1rem clamp(1rem, 2vw, 1.5rem);
}

/* Footers hold secondary actions, not primary content: every button in a
   sheet footer defaults to the compact dense-row size. A modal kept beside
   its trigger in a footer (the course editor) is in the top layer, not the
   footer, and keeps its own — excluded under :where(), as in a table cell. */
.sheet-footer button:not(:where(.modal-dialog button)),
.sheet-footer .button:not(:where(.modal-dialog .button)) {
  font-size: 0.85rem;
  margin: 0;
  min-height: 0;
  padding: 0.4rem 0.8rem;
}

/* A band of navigation links echoing the summary strip: cells of a bold label
   over a muted hint, each a whole clickable link, split by hairline dividers.
   It sits at the foot of a sheet body, divided from the content above by a top
   rule with generous breathing room on both sides — so the horizontal line
   floats free of the vertical dividers, a softer "lines on paper" look than a
   flush edge-to-edge grid. Dividers are drawn the same way as the summary
   strip: a 1px gap over a rule-coloured content box, each cell painting its
   own track, so the rule shows only between neighbouring cells — vertically in
   one row, horizontally once the band wraps — with no stray line on a wrapped
   row's first cell. Cell backgrounds match the sheet, so the band keeps its
   boxless, flush look; only the hairlines are new. The content-box paint stops
   short of the top padding, leaving the top rule floating free as before.

   The band bleeds past the section's side padding by exactly that padding —
   the negative side margins cancel it, since a cell's own horizontal padding
   is the same clamp — so each cell sits flush to the sheet edge while its text
   is pushed back onto the body text column. That is what keeps every row's
   first cell aligned with the text above: not just the source-order first
   cell (which a :first-child rule could reach) but the first cell of each
   wrapped row too, which no selector can. The last cell's text lands on the
   column's right edge by the same measure. */
.link-strip {
  background: var(--rule-soft);
  background-clip: content-box;
  border-top: 1px solid var(--rule);
  display: flex;
  flex-wrap: wrap;
  gap: 1px;
  margin: 2rem calc(-1 * clamp(1rem, 2vw, 1.5rem)) 0;
  padding-top: 1.25rem;
}

/* A strip that opens a sheet's body has nothing above it to be divided from,
   so it drops the rule and the space meant to hold that rule clear. Without
   this it renders as an empty band under the header — which is what a sheet
   whose whole body is its links looks like. */
.sheet-section > .link-strip:first-child {
  border-top: 0;
  margin-top: 0;
  padding-top: 0;
}

.link-strip-item {
  background: var(--surface-translucent);
  display: flex;
  flex: 1 1 11rem;
  flex-direction: column;
  gap: 0.2rem;
  padding: 0.35rem clamp(1rem, 2vw, 1.5rem);
  text-decoration: none;
}

.link-strip-label {
  font-weight: 600;
}

.link-strip-item:hover .link-strip-label {
  color: var(--blue);
}

.link-strip-hint {
  color: var(--ink-muted);
  font-size: 0.8rem;
}

/* The Revisions footer: the inline upload bar flexes to fill the row and the
   "Create revision" link sits at its right end. Wraps to stacked rows when
   the footer is too narrow. */
.revision-actions {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: 0.9rem;
}

.revision-actions .create-bar {
  flex: 1 1 16rem;
  /* A flex item will not shrink below its min-content width unless told to,
     and this form's min-content is the file input's — "Choose File" plus
     "No file chosen" at the browser's own size — which is wider than a
     phone's footer. So the form spilled out of the sheet and the row inside
     it, wrapping against the form's width rather than the footer's, clipped
     the details field and half the Upload button off the right edge. With
     the floor lifted the form takes the footer's width and its own wrap
     does the rest; the inputs already carry `min-width: 0`. */
  min-width: 0;
  width: auto;
}

/* A sheet footer's action row: buttons (or small forms wrapping one)
   gathered at the right edge. */
.sheet-actions {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  justify-content: flex-end;
}

.sheet-actions form {
  margin: 0;
}

/* Cancel and its save button, kept on one line. They are two halves of the
   same decision, and the footer wraps: in the revision editor's narrow rail
   the note field is wide enough to push them apart, which stranded the save
   alone on a second row and read as a third, unrelated action. */
.sheet-actions .action-pair {
  align-items: center;
  display: flex;
  gap: 0.75rem;
}

/* A note field sharing the footer with its submit button (the revision
   editor's "what changed"). Inputs are full-width blocks by default, which in
   this flex row would push the button onto its own line; this one takes the
   free space instead and leaves the button at the end. */
.sheet-actions .details-field {
  flex: 1 1 12rem;
  min-width: 0;
  width: auto;
}

.diagnostics {
  color: var(--red);
  margin-top: 1rem;
}

/* A warning does not refuse the save, so it must not read as something that
   did. Gold is the same signal the banner uses for "worth knowing, not
   wrong"; red stays the default so a diagnostic that forgets to say which it
   is looks like the error it is treated as. */
.diagnostics .warning {
  color: var(--gold);
}

/* Per-proof engine checks under the editor: subtle, informational — a stub
   starter legitimately fails to verify, so these are not content errors. */
.proof-checks {
  color: var(--ink-muted, #5f7388);
  font-size: 0.9rem;
  list-style: none;
  margin-top: 0.75rem;
  padding: 0;
}

.proof-checks li[data-state="ok"] {
  color: var(--green, #1b7048);
}

.proof-checks li[data-state="error"] {
  color: var(--ink-muted, #5f7388);
}

.create-bar {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin: 0;
  width: 100%;
}

.create-bar input,
.create-bar select {
  flex: 1 1 12rem;
  min-width: 0;
  width: auto;
}

/* Restated over the min-width:0 above, which would otherwise let the bar
   crop a date rather than wrap. */
.create-bar input[type="datetime-local"] {
  flex-basis: 17rem;
  min-width: min-content;
}

.create-bar button {
  flex: 0 0 auto;
}

/* A choice's note takes a row of its own under the fields and the submit
   button, rather than queueing as one more item in the flow. */
.create-bar .choice-note {
  flex: 1 1 100%;
  margin: 0;
  order: 1;
}

/* A footer laying a standalone action button (course edit) on the same row as
   an add-bar (clone), to the right of the add-bar's submit button. The add-bar
   grows to fill the row; the button holds its natural width at the end. */
.footer-row {
  align-items: center;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}

.footer-row > .create-bar {
  flex: 1 1 auto;
  width: auto;
}

/* Two add-bars sharing one footer (corrections: publish + excuse). A grid
   with display:contents forms lines up the fields into equal columns across
   both rows, so the two inputs are the same size regardless of button width. */
.correction-bars {
  align-items: center;
  display: grid;
  gap: 0.75rem;
  grid-template-columns: 1fr 1fr auto;
}

.correction-bars .create-bar {
  display: contents;
}

/* Notices and errors */

.notice,
.error {
  border: 1px solid;
  border-radius: 4px;
  font-size: 0.92rem;
  padding: 0.85rem 1rem;
}

.notice {
  background: var(--blue-soft);
  border-color: color-mix(in srgb, var(--blue) 22%, transparent);
  color: var(--blue-strong);
}

.error {
  background: var(--red-soft);
  border-color: color-mix(in srgb, var(--red) 24%, transparent);
  color: var(--red);
}

/* Worth knowing, not wrong — the same gold the noscript banner and warning
   badges use, for notices flagging a true state rather than a mistake. */
.notice-warn {
  background: var(--gold-soft);
  border-color: color-mix(in srgb, var(--gold) 25%, transparent);
  color: var(--gold);
}

/* A score graded out of points the assignment no longer uses: the number is
   right, its denominator has moved on. The tint marks it; the note child
   names the current figure, so the mark never speaks by colour alone. */
.points-drift {
  background: var(--gold-soft);
  border-radius: 4px;
  color: var(--gold);
  padding: 0.12rem 0.45rem;
}

.notice p {
  margin: 0 0 0.6rem;
}

/* The way onward from an error page. Paragraphs here have no top margin, so
   without this the only thing the reader can act on sits flush against the
   message that stopped them. */
.error-next {
  margin: 1.15rem 0 0;
}

.error-next + p {
  margin: 0.75rem 0 0;
}

.copy-field {
  display: flex;
  gap: 0.5rem;
}

.copy-field input {
  background: var(--surface);
  flex: 1;
  font-family: var(--body-font);
  font-size: 0.9rem;
  min-width: 0;
}

/* Hold its width, and drop the `form button` top margin from the content
   layer: inside a form that margin pushes the button down out of line with
   the input it copies, and the stretch that would otherwise match their
   heights cannot see past it. */
.copy-field button {
  flex: none;
  margin: 0;
}

/* Dialogs

   The frame — panel, header strip, close button, body padding — is
   `dialog.css`, which the exercises' help panel wears too and which is served
   ahead of this sheet. What is here is the page modal's own: its width, its
   backdrop, and one layout for the forms in its body, which is what keeps six
   different modals looking like one kind of thing. */

.modal-dialog {
  max-width: 32rem;
  width: calc(100vw - 2rem);
}

/* The one place in the sheet that spells a token's fallback out. A
   ::backdrop only began inheriting custom properties from its originating
   element in recent browsers; where it does not, this resolves to the light
   value, which is the right answer under either palette — a backdrop is
   dark either way. */
.modal-dialog::backdrop {
  background: color-mix(
    in srgb,
    var(--shadow-color, #102235) 35%,
    transparent
  );
}

/* A body's form is a column on one rhythm: the gap spaces every field, note
   and button alike, so the content layer's per-element top margins (and the
   container a modal happens to be rendered in) have no say. */
.modal-dialog .dialog-body form {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
}

.modal-dialog .dialog-body form > * {
  margin: 0;
}

/* A role's or a sharing scope's note belongs to the choice above it. */
.modal-dialog .dialog-body .choice-note {
  margin-top: -0.45rem;
}

/* One size of button in every modal, whatever the modal is rendered inside:
   a sheet footer's compact rule and a table cell's bare one both reach a
   dialog kept beside its trigger. The action ends the form, at its right. */
.modal-dialog .dialog-body button {
  font-size: 0.9rem;
  margin: 0;
  min-height: 0;
  padding: 0.45rem 0.95rem;
}

.modal-dialog .dialog-body form > button {
  align-self: flex-end;
}

/* A form that exists only to own a button elsewhere (`form=`), such as the
   Clear beside an override's Save. The flex rule above would otherwise
   outrank the browser's own `[hidden]`. */
.modal-dialog .dialog-body form[hidden] {
  display: none;
}

/* That Clear, at the left edge of the action row: a destructive action kept
   apart from the save in the corner, so it never reads as half of it. */
.modal-dialog .dialog-body .sheet-actions > .danger {
  margin-inline-end: auto;
}

/* A second form (the course editor's archive) is a separate action, not
   more fields: a hairline says so. */
.modal-dialog .dialog-body form + form {
  border-top: 1px solid var(--rule-soft);
  margin-top: 0.9rem;
  padding-top: 0.9rem;
}

.icon-button {
  background: transparent;
  border: 0;
  border-radius: 4px;
  color: var(--ink);
  min-height: 0;
  padding: 0.3rem 0.5rem;
  position: relative;
  right: 0.5rem;
  bottom: 0.3rem;
  filter: grayscale();
}

/* An icon button is the bare glyph, whichever element carries it: the content
   layer's `button:hover` would paint a blue pill behind three of these and
   nothing behind the download, which is an anchor. The hover is said by the
   duotone foreground below, and that reaches both. */
.icon-button:hover {
  background: transparent;
  filter: none;
}

/* An icon that has to POST — the library's archive toggle — sits in a form
   of its own beside the download link, so the form is inline like the link. */
.icon-form {
  display: inline;
  margin: 0;
}

/* Duotone icons carry two explicit fills — a gold backing layer beneath a
   blue foreground glyph — rather than one text color at two opacities. */
.icon-duo-back {
  fill: var(--gold);
  /* Half opacity keeps the backing at the soft gold the duotone used before,
     rather than the full, much darker --gold. */
  fill-opacity: 0.5;
}

.icon-duo-fore {
  fill: var(--blue);
}

.icon-button .icon {
  font-size: 1.5rem;
}

.icon-button:hover .icon-duo-fore {
  fill: var(--blue-strong);
}

/* The crown is drawn larger than the text it follows and taken out of flow so
   it cannot stretch the row's line box. That needs a positioning context on
   the marker itself: without one the glyph resolves against whatever ancestor
   happens to be positioned — outside the members table's scroller — and so
   stays put while the rows scroll under it. */
.owner-crown {
  position: relative;
}

.owner-crown .icon {
  font-size: 1.5em;
  position: absolute;
}

/* Result and review surfaces */

.result-exercise + .result-exercise {
  border-top: 1px solid var(--rule-soft);
  margin-top: 1.25rem;
  padding-top: 1.25rem;
}

.result-exercise-header {
  align-items: baseline;
  display: flex;
  gap: 0.75rem;
  justify-content: space-between;
  margin-bottom: 0.75rem;
}

.result-exercise-prompt {
  margin-bottom: 0.75rem;
}

.result-exercise-prompt > :first-child {
  margin-top: 0;
}

.result-exercise-prompt > :last-child {
  margin-bottom: 0;
}

.instructor-comment {
  background: var(--surface);
  border-left: 3px solid var(--rule-soft);
  margin-top: 0.9rem;
  padding: 0.6rem 0.9rem;
}

.instructor-comment h4 {
  color: var(--ink-muted);
  font-size: 0.78rem;
  letter-spacing: 0.03em;
  margin: 0 0 0.3rem;
  text-transform: uppercase;
}

.instructor-comment p {
  margin: 0;
}

/* Drawn as a segmented track (see `.segmented`): the two options are one
   list seen two ways, not two separate places to go. */
.review-filter {
  margin-bottom: 1rem;
}

.submission-review-list {
  display: grid;
  gap: 1rem;
}

/* The manual-evaluation disclosure keeps its full-width layout; the compact
   approve control sits on the summary's line, flush right, styled to match
   the summary rather than as a full button. */

.submission-review-footer {
  margin-top: 0.9rem;
  position: relative;
}

.submission-review-footer .manual-evaluation {
  margin-top: 0;
}

.approve-score-form {
  position: absolute;
  right: 0;
  top: 0;
}

.approve-score {
  background: none;
  border: 0;
  box-shadow: none;
  color: var(--ink-muted);
  cursor: pointer;
  font-size: 0.86rem;
  font-weight: 600;
  line-height: 1.5;
  /* Shed the base button's block chrome so it sits on the summary's line:
     the 42px min-height and the form-button top margin would otherwise push
     it below the full-width disclosure and under the card's clipped edge. */
  margin: 0;
  min-height: 0;
  padding: 0;
  white-space: nowrap;
}

.approve-score:hover {
  color: var(--green);
}

.review-action-error {
  color: var(--red);
  margin: 0.6rem 0 0;
}

.submission-review-header {
  align-items: baseline;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1rem;
  justify-content: space-between;
}

.submission-review-header h3 {
  margin: 0;
}

.submission-review-header .small {
  margin: 0.1rem 0 0;
}

.submission-review-status {
  font-weight: 600;
}

.submission-review-meta {
  color: var(--ink-muted);
  font-size: 0.82rem;
  margin: 0.35rem 0 0.9rem;
}

.submission-review-prompt {
  margin-bottom: 0.9rem;
}

.submission-review-prompt .result-exercise-prompt {
  margin: 0.6rem 0 0;
}

/* Manual grading is the exception (auto-graded work rarely needs it), so
   the form waits behind a disclosure instead of padding out every card. */

.manual-evaluation {
  margin-top: 0.9rem;
}

/* The card's two disclosures, the prompt above the answer and the grading
   form below it, share one quiet summary. */
.manual-evaluation summary,
.submission-review-prompt summary {
  color: var(--ink-muted);
  cursor: pointer;
  font-size: 0.86rem;
  font-weight: 600;
  width: fit-content;
}

.manual-evaluation summary:hover,
.submission-review-prompt summary:hover {
  color: var(--ink);
}

.manual-evaluation-form {
  margin-top: 0.9rem;
}

.manual-evaluation-form button {
  margin-top: 0.9rem;
}

/* Content frames: the isolated content document embedded in a sheet. */

.content-sheet {
  position: relative;
}

.content-frame {
  /* What shows before the document inside it paints — and, in the editor,
     for as long as the preview has nothing to show. An iframe with no
     background of its own falls through to the browser's canvas, which is
     white: invisible against a cream sheet and a lit slab against a dark
     one. The loaded document covers this. */
  background: var(--surface);
  border: 0;
  display: block;
  /* Fallback height until the document reports its own. */
  height: 30rem;
  width: 100%;
}

/* Stands in for the frame while nothing has compiled — inert everywhere
   except inside a split the editor has marked empty, so a page that passes
   one never shows it by accident. */
.content-frame-empty {
  display: none;
}

.content-split.preview-empty .content-frame {
  display: none;
}

.content-split.preview-empty .content-frame-empty {
  align-content: center;
  color: var(--ink-muted);
  display: grid;
  font-size: 0.95rem;
  gap: 0.5rem;
  /* The frame's fallback height, so the column keeps its shape rather than
     collapsing to two lines of text beside a tall editor. */
  min-height: 30rem;
  padding: 2rem;
  text-align: center;
}

/* Held to a short measure so the sentence breaks where a sentence should
   rather than running the width of a wide column. */
.content-split.preview-empty .content-frame-empty > * {
  margin-inline: auto;
  max-width: 26rem;
}

.content-frame-empty-title {
  color: var(--ink);
  font-family: var(--logo-font);
  font-size: 1.35rem;
}

/* The glyph is the whole control, so it carries an AA ratio rather than
   reading as decoration: --ink-faint would leave it at 2.8:1, short of both
   the 4.5:1 it wants as text and the 3:1 it wants as an icon. The audit
   cannot see this one — axe returns a symbol-only element as "incomplete"
   (messageKey "nonBmp"), since it cannot tell text from icon, and the gate
   reports violations. */
.content-fullscreen {
  color: var(--ink-muted);
  font-size: 1.05rem;
  line-height: 1;
  padding: 0.45rem 0.6rem;
  position: absolute;
  right: 0.15rem;
  text-decoration: none;
  top: 0.15rem;
  z-index: 1;
}

.content-fullscreen:hover {
  color: var(--ink);
  text-decoration: none;
}

/* Content split: sheets beside the content document on wide screens. */

.content-split,
.content-split-rail {
  display: grid;
  gap: 1.75rem;
  /* Same reasoning as .page-content: the single-column default (below the
     70rem two-column breakpoint) must clamp to the container, not grow to
     its widest sheet. The 70rem rules below override this with the split's
     two-column templates. */
  grid-template-columns: minmax(0, 1fr);
}

/* The editor source field sizes itself to its content (the preview bundle
   grows it as the author types) instead of offering a drag handle. What is
   typed here is Markdown source, so it gets the same monospace treatment as
   the listings it produces: indentation lines up, a directive's attributes
   stay scannable, and an ASCII table in a code fence looks the way it will
   render. Ligatures off, with the listings and against the exercises: an
   author is editing the text, and needs the characters they actually typed. */
[data-editor-source] {
  font-family: var(--mono-font, "Fira Code", ui-monospace, monospace);
  font-variant-ligatures: none;
  /* Monospace runs wider than the body face at the same nominal size, and
     this field lives in a split column — a notch down keeps a full line of
     source visible without wrapping. */
  font-size: 0.9375rem;
  line-height: 1.55;
  min-height: 12rem;
  overflow: hidden;
  resize: none;
  tab-size: 2;
}

/* A sheet whose body *is* Carnap Markdown source — the editor, or a saved
   revision's read-only text. The source runs to the card's own edges: the
   section gives up its padding and the field its border, and the sheet's
   frame becomes the source's frame. Anything else inside the section — the
   editor's diagnostics — brings the padding back for itself. */
.source-sheet .sheet-section {
  padding: 0;
}

.source-sheet [data-editor-diagnostics]:not(:empty) {
  border-top: 1px solid var(--rule);
  padding: 1rem clamp(1rem, 2vw, 1.5rem);
}

/* The unenhanced fields, which the shared input and <pre> rules do frame,
   give up the same border to sit flush the way a CodeMirror view does. */
.source-sheet [data-editor-source],
.source-sheet [data-source-view] pre {
  border: none;
  border-radius: 0;
  margin: 0;
}

/* How Carnap Markdown renders in a CodeMirror view — shared by the editor and
   by the read-only viewer on a revision page, because a directive should look
   the same whether or not it can still be typed. The typography repeats the
   source-field rules above rather than inheriting them: CodeMirror sets its
   own on .cm-content, and those win inside the view. */
.markdown-source .cm-content {
  font-family: var(--mono-font, "Fira Code", ui-monospace, monospace);
  font-size: 0.9375rem;
  font-variant-ligatures: none;
  line-height: 1.55;
  tab-size: 2;
}

/* Line numbers are how a reader finds the line a diagnostic names, so they
   are content and carry an AA contrast ratio — --ink-faint would read as
   decoration at 2.8:1. */
.markdown-source .cm-gutters {
  background: transparent;
  border: none;
  color: var(--ink-muted);
}

.markdown-source .cm-editor {
  background: transparent;
}

/* The inset the section gave up, put back on the scroller so it covers the
   gutter and the text together and the two stay on one baseline. Shared, so
   the editor and a revision's source start their text at the same place. */
.markdown-source .cm-scroller {
  padding: 0.9rem clamp(1rem, 2vw, 1.5rem) 0.9rem clamp(0.5rem, 1vw, 0.75rem);
}

/* Directive lines are highlighted from the source view's own highlight style
   in markdown-editor.ts, along with the rest of the Markdown: the parser gives
   them syntax nodes, so they need no rule of their own here. */

/* The fold arrow, on the lines that open a code block or a directive. Wide
   enough to be a target of its own beside the line numbers. */
.markdown-source .cm-foldGutter span {
  color: var(--ink-muted);
  padding: 0 0.25rem;
}

.markdown-source .cm-foldGutter span:hover {
  color: var(--blue-strong);
}

/* The stand-in for a folded block, at the end of the line that opened it: a
   tinted chip, so it reads as content that is present but collapsed rather
   than as an ellipsis the author typed. It is a real button — the keyboard's
   way to unfold, since the gutter is hidden from assistive tech — so the
   sheet's button styling (a 42px-tall bordered pill) has to be undone. */
.markdown-source .cm-foldPlaceholder {
  background: var(--surface-soft);
  border: 1px solid var(--rule);
  border-radius: 3px;
  color: var(--ink-muted);
  display: inline;
  font: inherit;
  font-weight: 400;
  margin: 0 0.15rem;
  min-height: 0;
  padding: 0 0.3rem;
}

.markdown-source .cm-foldPlaceholder:hover {
  background: var(--surface-soft);
  color: var(--blue-strong);
}

/* The writable editor: the sheet's whole body, so it fills the card and puts
   the inset the section gave up back on its own scroller — where it covers
   the gutter and the text together and the two stay on one baseline. */
.editor-source {
  background: var(--control-surface);
}

.editor-source .cm-editor {
  /* Tall enough to look like a place to write in, and free to grow from
     there — the same shape the autosizing textarea had. */
  min-height: 12rem;
}

/* The insertion point, which CodeMirror's base theme would otherwise paint
   black. It ships both colours — black and white — and picks between them from
   the EditorView.darkTheme facet rather than from the OS, so a view that never
   sets that facet gets the black one in both schemes and the caret disappears
   into the dark surface (1.37:1 against it). Setting the caret from the ink
   token follows the palette instead, and needs no facet to be reconfigured
   when the reader's preference changes. */
.editor-source .cm-content {
  caret-color: var(--ink);
}

/* Without a border of its own to recolour, the editor marks focus with a ring
   drawn inside its own box — outside it would land on the card's edge. */
.editor-source .cm-editor.cm-focused {
  outline: 2px solid var(--blue);
  outline-offset: -2px;
}

.editor-source .cm-activeLine,
.editor-source .cm-activeLineGutter {
  background: var(--surface-soft);
}

/* The compiler's diagnostics, on the lines they are about. CodeMirror's
   default underline is a red wave — the same shape a spellchecker draws,
   which is exactly the signal this editor exists to stop diluting — so an
   error reads as a tinted band instead. */
.editor-source .cm-lintRange-error {
  background: var(--red-soft);
  background-image: none;
  box-shadow: inset 0 -2px 0 var(--red);
}

/* The bubble a diagnostic is read in. .cm-tooltip-lint names the <ul>
   *inside* the bubble, not the bubble itself, so a rule hung on it would never
   match; this one names the wrapper. Without it CodeMirror's base theme paints
   the bubble #f5f5f5 — from a rule scoped to &light, which follows the
   EditorView.darkTheme facet and not the OS — and in dark mode the message
   inherits the light ink and vanishes into it. */
.editor-source .cm-tooltip {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: 4px;
  color: var(--ink);
  font-family: var(--body-font);
  font-size: 0.85rem;
  max-width: 32rem;
}

.editor-source .cm-diagnostic-error {
  border-left-color: var(--red);
}

/* The compiler separates a diagnostic's context lines (theorem, line, rule,
   inference path) with newlines; without this they collapse into one run-on. */
.editor-source .cm-diagnostic {
  white-space: pre-line;
}

/* The read-only viewer takes the lighter surface the editor uses, not the
   muted one a <pre> defaults to: the syntax colours are chosen against that
   surface, and two of them (the attribute gold, the gutter's ink) fall under
   4.5:1 on the darker one. The <pre> moves with it so the page looks the same
   whether or not the bundle ran. */
[data-source-view],
[data-source-view] pre {
  background: var(--control-surface);
}

/* Its own inset, matching the CodeMirror scroller's, since a <pre> has no
   gutter to leave room for on the left. */
.source-sheet [data-source-view] pre {
  padding: 0.9rem clamp(1rem, 2vw, 1.5rem);
}

/* A segmented control: one track divided in two, with the half you are on
   raised out of it. Two separately-bordered controls said "here are two
   things you can do"; a track says "here is one thing, seen one of two
   ways", which is what both of the pairs wearing this are.

   Only the drawing lives here, because the two are not the same kind of
   control. A split view's switch is a pair of buttons that flip which pane
   is on screen without navigating; the review filter is a pair of links to
   two server-rendered lists. So the half that is on is read from
   `aria-pressed` on the one and `aria-current` on the other, and whatever
   else each needs sits in its own block below. */
.segmented {
  background: var(--surface-soft);
  border: 1px solid var(--rule);
  /* The sheet's radius outside, the button's inside: this is a control made
     of controls, and both radii are already in use on the page. */
  border-radius: 6px;
  display: grid;
  gap: 0.25rem;
  grid-auto-flow: column;
  padding: 0.25rem;
  width: fit-content;
}

/* The halves give up their own frame entirely — the track is the frame. A
   link is laid out as a box so that it fills its half the way a button
   does, rather than sitting on the surrounding text's baseline. The half
   you are not on is muted, but with the stronger muted ink: the track is
   --surface-soft, one of the warm fills plain --ink-muted misses AA on
   (4.39:1 — see docs/a11y.md). */
.segmented > :is(a, button) {
  align-items: center;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 4px;
  color: var(--ink-muted-strong);
  display: inline-flex;
  font-weight: 600;
  justify-content: center;
  min-height: 36px;
  padding: 0.35rem 1.1rem;
  text-decoration: none;
}

/* Hover on the half you are not on hints that it is live without borrowing
   the raised treatment, which means "this is the one you are looking at". */
.segmented > :is(a, button):hover {
  background: transparent;
  color: var(--ink);
}

.segmented > :is([aria-current], [aria-pressed="true"]) {
  background: var(--control-surface);
  border-color: color-mix(in srgb, var(--blue) 28%, transparent);
  box-shadow: 0 1px 2px
    color-mix(in srgb, var(--shadow-color) 14%, transparent);
  color: var(--blue-strong);
}

/* The switch between a split's views is inert without JS, so it stays hidden
   until its bundle marks it enhanced. That is why this `display` stays off
   `.segmented`: a control that always belongs on the page must not inherit
   it. */
.split-switch {
  display: none;
  /* Equal columns rather than content-width, so the track doesn't resize as
     the label changes under translation. The review filter keeps the track's
     auto columns instead: its two labels are of unlike length, and the first
     carries a count that changes as submissions are reviewed. */
  grid-auto-columns: 1fr;
  /* Overriding the shared `fieldset` block, which frames a stack of form
     fields: this fieldset is a control, and the track is the whole of its
     frame. `min-inline-size` undoes the min-content floor a fieldset carries
     by default, which would otherwise beat `fit-content`. */
  margin: 0;
  min-inline-size: 0;
}

.split-switch[data-enhanced] {
  display: grid;
}

.split-switch button {
  min-width: 7rem;
}

/* The course's staff/student switch (see `CourseViewSwitch`): server-rendered
   links, so it needs no enhancement and shows from the first paint. Equal
   halves for the same reason as the split switch — the track should not
   resize as the two words change under translation. */
.course-view-switch {
  grid-auto-columns: 1fr;
}

.course-view-switch a {
  min-width: 7rem;
}

/* Whichever column is not being looked at is not on the page at all, at any
   width: wanting the document to have the page is not something that stops
   being wanted on a large screen. Only a split that has a switch carries
   `data-mode`; the ones that simply stack (the course page, an assignment)
   go on stacking. */
.content-split[data-mode="rail"] > .content-split-doc,
.content-split[data-mode="content"] > .content-split-rail {
  display: none;
}

@media (max-width: 69.99rem) {
  /* Two columns is not a state a narrow window can be in, so the switch does
     not offer it — and a page opened in it shows the column it names as its
     fallback. The bundle presses that column's half, so the control agrees
     with the page. */
  .split-switch [data-view-target="split"] {
    display: none;
  }

  /* Where there is one column, the switch leaves the header row for the foot
     of the window. In the row it wrapped under the breadcrumb on any phone,
     and on a tablet whenever the item's title ran long — a title is free
     text, so that was the ordinary case and not a corner of it. Fixed to the
     viewport it is also always in reach, which a control for flipping
     between two long columns wants to be. Only the drawing moves: it stays
     after the breadcrumb in the markup, so the tab order and the accessible
     name are what they were. Below 70rem and not at some phone width, because
     this is where the page itself changes shape; one breakpoint, not two.

     A notch smaller than the shared track, since it now sits over the text
     rather than beside a heading; the halves keep well clear of the 24px
     target floor. The surface is opaque so the words underneath don't show
     through, and the shell grows its bottom padding by the switch's height so
     the last lines of a column can still scroll out from under it. The
     footer is left to sit beneath it. */
  .split-switch[data-enhanced] {
    background: var(--surface);
    bottom: max(0.85rem, env(safe-area-inset-bottom));
    box-shadow: 0 6px 22px
      color-mix(in srgb, var(--shadow-color) 22%, transparent);
    left: 50%;
    position: fixed;
    transform: translateX(-50%);
    z-index: 2;
  }

  .split-switch button {
    font-size: 0.86rem;
    min-height: 30px;
    min-width: 5.5rem;
    padding: 0.25rem 0.85rem;
  }

  .page-shell:has(.split-switch[data-enhanced]) {
    padding-bottom: 7rem;
  }

  .content-split[data-mode="split"][data-narrow-view="rail"]
    > .content-split-doc,
  .content-split[data-mode="split"][data-narrow-view="content"]
    > .content-split-rail {
    display: none;
  }
}

/* The boundary between the columns, and the handle for moving it: drawn in
   the gutter the grid already leaves between them, so it costs no width and
   the columns meet exactly where `--split-rail` says.

   It shows only where there are two columns to divide *and* the bundle that
   moves them has run — like the switch, and for the same reason: a handle
   that cannot be dragged is a decoration in the tab order. Hidden, it is out
   of that order too, which is what `display: none` is doing here. */
.content-split-resizer {
  display: none;
}

@media (min-width: 70rem) {
  .content-split[data-mode="split"] {
    position: relative;
  }

  .content-split[data-mode="split"] > .content-split-resizer[data-enhanced] {
    bottom: 0;
    cursor: col-resize;
    display: block;
    left: var(--split-rail, 40%);
    position: absolute;
    top: 0;
    /* The gap, exactly. */
    width: 1.75rem;
    z-index: 1;
  }

  /* A hairline down the middle of that space, which the columns already
     suggest — it only has to be seen to be reached for. It thickens and takes
     the accent under the pointer and under focus, where "you can move this"
     is the thing to say. */
  .content-split-resizer::after {
    background: var(--rule);
    border-radius: 1px;
    content: "";
    inset: 0.25rem auto;
    left: calc(50% - 1px);
    position: absolute;
    width: 2px;
  }

  .content-split-resizer:hover::after,
  .content-split-resizer:focus-visible::after {
    background: var(--blue);
    width: 3px;
  }

  /* The line above is the focus ring: a 1.75rem-wide box drawn around empty
     gutter is a ring around nothing. */
  .content-split-resizer:focus-visible {
    outline: none;
  }

  /* While the handle is held: the pointer is setting a width, not selecting
     text across both columns, and not talking to the preview — which would
     otherwise swallow it the moment the drag crossed the iframe. */
  .content-split[data-resizing] {
    cursor: col-resize;
    user-select: none;
  }

  .content-split[data-resizing] iframe {
    pointer-events: none;
  }
}

/* While the source doesn't compile, the last good preview stays up but
   reads as stale. Not when there has never been one: dimming the "nothing
   to preview" placeholder would only make it harder to read. */
.content-split.preview-stale:not(.preview-empty) > .content-split-doc {
  opacity: 0.55;
}

@media (min-width: 70rem) {
  /* Split pages outgrow the 85rem shell cap: the navbar keeps its width,
     but the columns spread into the gutters that would otherwise sit
     empty on wide screens. The breadcrumb stays capped to the navbar's
     content width so it keeps lining up with the brand above it. */
  .page-shell:has(.content-split) {
    max-width: 120rem;
  }

  /* One column asked for on purpose is an ordinary page again: the gutters
     the second column was spreading into have nothing to hold, and a lone
     editor run out to 120rem is a line no one wants to read or write. */
  .page-shell:has(.content-split[data-mode]:not([data-mode="split"])) {
    max-width: 85rem;
  }

  .page-shell:has(.content-split) .page-header {
    margin-inline: auto;
    max-width: var(--page-measure);
    width: 100%;
  }

  .content-split {
    align-items: start;
    grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
  }

  /* Where the columns are two views, the boundary between them is the
     reader's to set, and `--split-rail` is where they put it — the same 2:3
     to begin with, said as a share of the width so the drag has one number
     to move. `minmax(0, …)` on both, so a long unbroken line of source or a
     wide table stretches its column rather than the page. */
  .content-split[data-mode="split"] {
    grid-template-columns: minmax(0, var(--split-rail, 40%)) minmax(0, 1fr);
  }

  /* And where one view has the page, it has all of it: the hidden column
     leaves no track behind for its share of the width to sit empty in. */
  .content-split[data-mode]:not([data-mode="split"]) {
    grid-template-columns: minmax(0, 1fr);
  }

  /* The course page splits its columns evenly: the content-heavy rail (the
     assignment table and enrollment links) on the left, the members roster
     on the right. */
  .content-split.course-split {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  }
}

/* The course split's tables need more room than the editor's document
   column before two columns stop feeling cramped, so between the shared
   70rem breakpoint and 90rem it stays a single column at the normal shell
   width; the even columns engage only once there is room for them. */
@media (min-width: 70rem) and (max-width: 89.99rem) {
  .page-shell:has(.course-split) {
    max-width: 85rem;
  }

  .content-split.course-split {
    grid-template-columns: none;
  }
}

/* Footer */

.app-footer {
  align-items: center;
  border-top: 1px solid var(--rule);
  display: flex;
  gap: 1.5rem;
  justify-content: space-between;
  margin: 4rem auto 0;
  max-width: 85rem;
  padding: 1.75rem var(--shell-gutter) 2.5rem;
  width: 100%;
}

.app-footer .brand {
  font-size: 1.7rem;
}

.app-footer .footer-meta {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

/* The meta lines sit against the right edge only when the brand holds the
   left one. A signed-in page's footer has no brand (the navbar's is a
   screen's height up) and the lines are the footer's whole content, which is
   read from the left like the rest of the page. */
.app-footer .brand + .footer-meta {
  align-items: flex-end;
  text-align: right;
}

.app-footer .copyright {
  color: var(--ink-muted);
  font-size: 0.82rem;
}

.app-footer .footer-link {
  color: var(--ink-muted);
  font-size: 0.82rem;
}

.app-footer .footer-link:hover {
  color: var(--blue-strong);
}

/* Support page */

.charity-list {
  display: flex;
  flex-direction: column;
  gap: 1.75rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* The mark and the name share the first row; the blurb spans both columns, so
   it begins at the margin instead of indenting under the name. The column is a
   fixed width rather than auto-sized so that icons of different sizes still
   hang the names at the same place. */
.charity {
  display: grid;
  gap: 0 0.55rem;
  grid-template-columns: 1.1rem 1fr;
}

.charity p {
  grid-column: 1 / -1;
  margin: 0.35rem 0 0;
}

/* Shown at the icon's own size, never enlarged past it. Site icons are often
   16px originals, and scaling one up to logo size only makes it soft — as a
   small flair beside the name it stays crisp, and a big original (800px, in
   one case here) simply scales down into the same slot. */
.charity-icon {
  block-size: auto;
  inline-size: auto;
  justify-self: center;
  margin-block-start: 0.3rem;
  max-block-size: 1rem;
  max-inline-size: 1rem;
  object-fit: contain;
}

/* For an organization whose site offers no usable icon. */
.charity-monogram {
  align-items: center;
  background: var(--rule-soft, #e5ded3);
  block-size: 1rem;
  border-radius: 3px;
  color: var(--ink-muted, #5f7388);
  display: flex;
  font-size: 0.7rem;
  font-weight: 700;
  inline-size: 1rem;
  justify-content: center;
  justify-self: center;
  margin-block-start: 0.3rem;
}

.charity-name {
  font-size: 1.05rem;
  font-weight: 600;
}

@media (max-width: 820px) {
  .app-header {
    flex-wrap: wrap;
  }

  .app-footer {
    align-items: flex-start;
    flex-direction: column;
  }

  .app-footer .brand + .footer-meta {
    align-items: flex-start;
    text-align: left;
  }
}

/* The account name gives way to a monogram in a ring. An admin's three nav
   items plus an ordinary first name overflow a 390px phone and wrap the bar to
   two rows; the German nav (Kurse · Inhalte · Verwaltung) does it with a
   shorter name still. The monogram holds the bar to one line from 700px down
   to 481px, the narrowest width that still shows the wordmark — measured with
   both of those navs and an admin's three items, with room to spare.

   The ring's 2rem clears the 24px target minimum (WCAG 2.5.8) with room to
   spare, which the bare text link only cleared by the width of a long name. */
@media (max-width: 700px) {
  .nav-profile-name {
    display: none;
  }

  .nav-profile-mark {
    align-items: center;
    border: 1px solid var(--rule);
    border-radius: 50%;
    display: inline-flex;
    font-size: 0.9rem;
    font-weight: 640;
    height: 2rem;
    justify-content: center;
    line-height: 1;
    width: 2rem;
  }

  .nav-profile:hover .nav-profile-mark {
    border-color: var(--blue);
  }
}

@media (max-width: 640px) {
  /* On a phone the course-record footer stacks the clone title field, its
     "Clone course" button, and "Edit" onto three lines. Dropping the clone
     form's own box (display: contents) floats its input and submit button
     into the footer-row flex flow beside "Edit": the wide title field takes
     its own line and the two buttons — Clone course and Edit — then share
     the next. Same technique as .correction-bars. */
  .footer-row > .create-bar {
    display: contents;
  }

  /* The Revisions footer, the same way: the upload form's box goes, so its
     two fields and Upload button flow in .revision-actions beside "Create
     revision". Each field is then pinned to a line of its own — the file
     picker and a details field are both too wide to share one on a phone,
     and a row that sometimes paired them and sometimes didn't read as a
     glitch — which leaves the two buttons to share the last row: the two
     ways of making a revision, side by side. */
  .revision-actions .create-bar {
    display: contents;
  }

  .revision-actions .create-bar input {
    flex-basis: 100%;
  }

  /* The corrections footer's three columns are two fields and a button, and
     only the fields are elastic — so on a phone the button's natural width
     ("Publish correction") eats the row and squeezes both fields down to a
     few characters. One column per control instead: each form reads as its
     fields followed by the action that submits them. */
  .correction-bars {
    grid-template-columns: 1fr;
  }
}

/* On a phone the sheets run to the screen's edge. The shell gutter bottoms
   out at 20px a side, which with the sheet's border puts text 37px in — on a
   360px screen that is 12% of the width spent on framing, and it is what
   tips the summary strip from two stats a row to one. So the gutter goes,
   and with it the sheets' side borders and corners: a box whose sides are
   the screen's sides is a band, and a band has no corners to round. What is
   not a sheet — the breadcrumb, the profile prompt, a notice — keeps a 1rem
   inset, which is the sheets' own inner padding at this width, so the
   breadcrumb lines up with the text below it rather than sitting 17px off.
   The navbar and app footer carry their own gutter and do not move. 480px
   and not the 640px phone block above: between the two the gutter is wider
   and the screen wider still, and full-width bands there read as a tablet
   that has lost its margins.

   The "Carnap" wordmark gives way to the ⊨ mark at the same width, so the
   page turns into its phone layout at one point rather than two: below it the
   wordmark, nav links and monogram would wrap the navbar to two rows. The
   footer keeps the full wordmark (it stacks vertically, with room). */
@media (max-width: 480px) {
  .app-header .brand-word {
    display: none;
  }

  .page-shell {
    padding-inline: 0;
  }

  .page-header,
  .profile-prompt,
  .page-content > .notice {
    margin-inline: 1rem;
  }

  .page-content .sheet {
    border-inline: 0;
    border-radius: 0;
  }
}
