/**
 * DONAT — sign-in and the dashboard.
 *
 * The portal is a tool, not a poster. Same palette, same faces, same components as the public
 * pages, drawn tighter: less air between blocks, monospace on anything read off a screen at a
 * door, and no ornament that carries no information. The QR language the rest of the design is
 * built from appears exactly three times here — the scanner's corner brackets, the filled
 * module beside the active rail link, and one module above each panel heading. Anywhere else it
 * would be decoration on a screen somebody is trying to work in.
 *
 * ## One markup, two layouts, no JavaScript
 *
 * `dashboard.html` ships one tree. Below 1024px `.rail` is a band that sticks under the app bar
 * and `.dash-nav` is a horizontally scrolling tab strip; from 1024px the same nodes are a sticky
 * column beside the panels. The switch is `display: contents` on the two levels between
 * `.dash-nav` and its links, so `dashboard.js` renders one structure and never learns about the
 * viewport. Nothing on this page depends on a class the client toggles for layout.
 *
 * ## Where a semantic token would be wrong
 *
 * `.pass-qr` and `.qrmaker-code` are hardcoded white. A camera samples luminance, not a palette:
 * the quiet zone around a symbol has to be white on any ground and in any later theme or it stops
 * being a symbol. `.scanner__stage` is plum-black and its brackets are drawn in `--glaze` for a
 * related reason — a viewfinder is framing for hardware rather than a surface in the design, it is
 * dark whatever the page is, and the paper accent would vanish into it.
 *
 * ## One quiet grey, not two
 *
 * Nothing here uses `--fg-faint`. It measures 2.9:1 on paper, which is right for a placeholder and
 * wrong for a student number, a group title or a timestamp — and this is the half of the
 * application where those are the content rather than the trim. Secondary text is `--fg-muted`
 * (4.9:1 on `--bg`, 5.6:1 on `--bg-raise`) and the hierarchy is carried by size, case and the
 * monospace face instead.
 *
 * Everything else is the semantic tokens from base.css, so both pages are already correct on
 * paper and on plum with no variant classes.
 */

@layer portal {
  .account-avatar {
    display: grid;
    grid-template-columns: 6rem minmax(0, 1fr);
    align-items: center;
    gap: var(--s4);
    margin-bottom: var(--s5);
    padding: var(--s4);
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--surface);
  }

  .account-avatar__image,
  .account-avatar__fallback {
    grid-row: 1 / span 2;
    width: 6rem;
    height: 6rem;
    border: 2px solid var(--surface);
    border-radius: 50%;
    box-shadow: 0 0 0 1px var(--line), var(--shadow-sm);
    object-fit: cover;
  }

  .account-avatar__fallback {
    display: grid;
    place-items: center;
    background: var(--accent-wash);
    color: var(--accent);
    font-family: var(--font-display);
    font-size: var(--t-xl);
    font-weight: 700;
  }

  .account-avatar > .btn {
    justify-self: start;
  }

  .media-picker {
    display: grid;
    justify-items: start;
    gap: var(--s3);
    margin-top: var(--s4);
  }

  .media-picker__preview {
    display: grid;
    gap: var(--s2);
    width: min(100%, 28rem);
  }

  .rich-text > * {
    margin: 0 0 var(--s3);
  }

  .rich-text > :last-child {
    margin-bottom: 0;
  }

  .rich-text h3,
  .rich-text h4,
  .rich-text h5 {
    color: var(--fg);
    font-size: var(--t-md);
    line-height: var(--lh-snug);
  }

  .rich-text__list {
    padding-left: var(--s5);
  }

  .rich-text blockquote {
    padding-left: var(--s4);
    border-left: 3px solid var(--accent);
    color: var(--fg-muted);
  }

  .rich-text code {
    padding: 0.1em 0.35em;
    border-radius: var(--r1);
    background: var(--bg-sunken);
    font-family: var(--font-mono);
    font-size: 0.9em;
  }

  .rich-text a {
    overflow-wrap: anywhere;
  }

  .media-picker__preview img,
  .post__image,
  .event-item__image {
    display: block;
    width: 100%;
    height: auto;
    max-height: 28rem;
    border-radius: var(--r2);
    object-fit: cover;
  }

  .post__image,
  .event-item__image {
    margin-top: var(--s4);
  }

  /**
   * One inline edge for the whole shell.
   *
   * The app bar, the rail and the panels all read it, so their content sits on the same two
   * vertical lines at every width — and the safe-area insets are folded in here rather than
   * repeated, because in landscape on a notched phone the rail and the panel are both against
   * the edge the notch is on.
   */
  .page--portal,
  .page--auth {
    --edge: clamp(var(--s4), 2.5vw, var(--s6));
    --edge-l: calc(var(--edge) + env(safe-area-inset-left, 0px));
    --edge-r: calc(var(--edge) + env(safe-area-inset-right, 0px));
    --shell: calc(1440px + 2 * var(--edge));
  }

  /* ==========================================================================================
     The app bar
     ========================================================================================== */

  /**
   * Paper rather than the public site's plum, and a hairline rather than a shadow.
   *
   * The bar is written against `--bg-raise` and `--line` alone, so it stays correct if the
   * document ever carries `.on-ink` — which is the reason not to reach for `--ink-800` here the
   * way site.css does for the marketing chrome.
   */
  /**
   * The bar is plum on every page in the application.
   *
   * It was paper here and plum on the public pages, which made signing in feel like arriving at a
   * different product — and the dashboard is the same club, one link away. The element carries
   * `.on-ink` in the markup, so `--bg` is already `--ink-800` and every control inside it picks
   * up the plum context without a single override. `.topbar` is the sign-in page's name for the
   * same thing; the two are grouped rather than duplicated so they cannot drift apart.
   */
  .appbar,
  .topbar {
    position: sticky;
    top: 0;
    z-index: var(--z-topbar);
    border-bottom: 1px solid var(--line);
    background: var(--bg);
  }

  .appbar__inner,
  .topbar__inner {
    display: flex;
    align-items: center;
    gap: var(--s2);
    /* Exactly `--topbar-h` tall, and never wrapping: the rail below sticks to that number, and a
       bar that grew to two rows would leave the tab strip floating in the gap. */
    max-width: var(--shell);
    min-height: var(--topbar-h);
    margin-inline: auto;
    padding-inline: var(--edge-l) var(--edge-r);
  }

  .appbar__end,
  .topbar__end {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    gap: var(--s2);
    margin-left: auto;
  }

  /* The brand takes the leftover room so `__end` is pushed against the right edge. Without it a
     bar with only two children centres them both. */
  .appbar .brand,
  .topbar .brand {
    margin-right: auto;
  }

  /**
   * Sign out, in the bar, at every width.
   *
   * It carries no class of its own, and below 1024px it is the only sign-out control on screen:
   * `.rail__foot` is hidden there because a band that sticks under the app bar cannot spend 44px
   * on a second copy. So this one is compacted on a narrow screen rather than dropped. The shell
   * this replaced had its single sign-out inside a nav that was `display: none` below 900px,
   * which meant there was no way to sign out at all on the shared club phone — the one device
   * where signing out is the entire privacy boundary.
   */
  #signOutBtn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 2.75rem;
    padding-inline: var(--s3);
    border-radius: var(--r2);
    color: var(--fg-muted);
    font-size: var(--t-sm);
    font-weight: 600;
    white-space: nowrap;
  }

  #signOutBtn:hover {
    background: var(--bg-sunken);
    color: var(--fg);
  }

  /* Two controls and a wordmark have to fit inside 320px. At the width where they stop doing
     that, the public-site link gives up its padding instead of disappearing — it is the only way
     back to the marketing pages from behind sign-in. */
  @media (max-width: 559px) {
    .appbar__public {
      padding-inline: var(--s3);
      font-size: var(--t-xs);
    }
  }

  /* ---- The wordmark -----------------------------------------------------------------------
     site.css owns `.brand*` for the public pages and is not loaded behind sign-in, so these two
     bars carry their own copy. Scoped to the bars, so the two files cannot disagree if a page
     ever loads both. */

  .appbar .brand,
  .topbar .brand,
  .auth-bar .brand {
    display: flex;
    flex: 0 1 auto;
    align-items: center;
    gap: var(--s3);
    min-width: 0;
    color: var(--fg);
  }

  .appbar .brand__mark,
  .topbar .brand__mark,
  .auth-bar .brand__mark {
    flex: 0 0 auto;
    width: 2rem;
    height: 2rem;
  }

  .appbar .brand__word,
  .topbar .brand__word,
  .auth-bar .brand__word {
    font-family: var(--font-display);
    font-size: var(--t-md);
    font-weight: 800;
    line-height: 1;
    letter-spacing: -0.02em;
    white-space: nowrap;
  }

  /* The word after the wordmark — "Operations" — is the first thing to go when the row is tight,
     because it names the page and the page already has a heading. */
  .appbar .brand__tag {
    display: none;
  }

  @media (min-width: 700px) {
    .appbar .brand__tag {
      display: block;
      padding-left: var(--s3);
      border-left: 1px solid var(--line);
      color: var(--fg-muted);
      font-family: var(--font-mono);
      font-size: var(--t-micro);
      letter-spacing: var(--track-label);
      text-transform: uppercase;
      white-space: nowrap;
    }
  }

  /* ==========================================================================================
     The shell
     ========================================================================================== */

  /**
   * Block below 1024px, grid above it.
   *
   * Not a one-column grid, and the reason is `position: sticky` on the rail: a sticky grid item
   * can only travel inside its own grid area, and in a single-column grid that area is exactly
   * the band's own height — so the band would scroll away instead of sticking under the app bar.
   * As a block child of a block it can travel the whole page. Above 1024px the rail and the
   * panels share one tall grid row, which is the case where sticky in a grid does work.
   */
  .app {
    display: block;
    max-width: var(--shell);
    margin-inline: auto;
  }

  .app__main {
    /* A grid child is `min-width: auto`, and a `.dash-table` inside one will use that to push the
       column wider than the viewport rather than scrolling inside its own container. */
    min-width: 0;
    /* The bottom is generous because `.status-region` is fixed over it: an officer's scan result
       must never be the thing covering the last row of the table it is about. */
    padding:
      var(--s5) var(--edge-r) calc(var(--s8) + env(safe-area-inset-bottom, 0px)) var(--edge-l);
  }

  /* Where the skip link lands. The site-wide `:target` allowance clears the app bar alone, and
     below 1024px the rail band is between the two — without this the panel heading is scrolled
     to a position the band is already covering. */
  .app__main:target {
    scroll-margin-top: calc(var(--topbar-h) + 7.5rem);
  }

  /* ==========================================================================================
     The rail — a band below 1024px, a column above it
     ========================================================================================== */

  /**
   * Sticky under the app bar, and opaque, because the panels scroll underneath it. `--z-raise`
   * keeps it above the content and below the bar, so the two can never trade places while a
   * long attendance ledger scrolls past.
   */
  .rail {
    position: sticky;
    top: var(--topbar-h);
    z-index: var(--z-raise);
    display: flex;
    flex-direction: column;
    gap: var(--s1);
    padding-block: var(--s2) 0;
    border-bottom: 1px solid var(--line);
    background: var(--bg);
  }

  /* The second sign-out control, and the reason it is not on screen here: see `#signOutBtn`. */
  .rail__foot {
    display: none;
  }

  /* ---- Who is signed in ------------------------------------------------------------------- */

  .dash-identity {
    display: flex;
    align-items: center;
    gap: var(--s3);
    min-width: 0;
    padding-inline: var(--edge-l) var(--edge-r);
  }

  .dash-identity__row {
    display: flex;
    align-items: center;
    gap: var(--s3);
    min-width: 0;
  }

  .dash-identity__avatar {
    display: grid;
    flex: 0 0 auto;
    place-items: center;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: var(--r-pill);
    background: var(--accent-wash);
    color: var(--accent);
    font-family: var(--font-display);
    font-size: var(--t-base);
    font-weight: 700;
    line-height: 1;
  }

  .dash-identity__text {
    display: flex;
    align-items: baseline;
    gap: var(--s2);
    min-width: 0;
  }

  /* Truncated rather than wrapped or hidden. A full name plus a role plus a student number does
     not fit 320px, and a name is the part somebody still recognises from its first few
     characters. `min-width: 0` is what lets a flex item shrink under its own text at all. */
  .dash-identity__name {
    min-width: 0;
    overflow: hidden;
    color: var(--fg);
    font-size: var(--t-base);
    font-weight: 600;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .dash-identity__role {
    flex: 0 0 auto;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .dash-identity__number {
    flex: 0 0 auto;
    margin-left: auto;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
  }

  /* ---- The tab strip ---------------------------------------------------------------------- */

  /**
   * Eleven panels in four groups, on a phone.
   *
   * Sideways scroll rather than a wrap: three rows of chrome above the content is worse than one
   * row somebody drags. `overscroll-behavior-x: contain` stops a drag that runs out of strip from
   * turning into a browser back-swipe, which on iOS would leave the dashboard entirely.
   */
  .dash-nav {
    display: flex;
    gap: var(--s1);
    overflow-x: auto;
    padding-inline: var(--edge-l) var(--edge-r);
    overscroll-behavior-x: contain;
    scroll-snap-type: x proximity;
    scrollbar-width: none;
  }

  .dash-nav::-webkit-scrollbar {
    display: none;
  }

  /**
   * The whole of the responsive switch.
   *
   * With these two levels generating no boxes, every `li` becomes a flex item of the strip
   * itself. The group structure `dashboard.js` renders is what the 1024px column needs, and it
   * costs nothing here — which is why there is no second copy of the links and no hamburger.
   */
  .dash-group,
  .dash-nav-list {
    display: contents;
  }

  .dash-group-title {
    display: none;
  }

  .dash-nav li {
    flex: 0 0 auto;
    scroll-snap-align: center;
  }

  .dash-nav-link {
    display: flex;
    align-items: center;
    min-height: 3rem;
    padding-inline: var(--s3);
    border-bottom: 2px solid transparent;
    color: var(--fg-muted);
    font-size: var(--t-base);
    font-weight: 500;
    white-space: nowrap;
  }

  .dash-nav-link:hover {
    color: var(--fg);
  }

  /* An underline, not a pill: in a row that scrolls, a filled pill reads as a button nobody has
     pressed yet rather than as the section you are already in. */
  .dash-nav-link.active {
    border-bottom-color: var(--accent);
    color: var(--fg);
    font-weight: 600;
  }

  /* ==========================================================================================
     Panels
     ========================================================================================== */

  /**
   * Hidden, not merely invisible.
   *
   * `display: none` takes a closed panel out of the accessibility tree and stops its images
   * loading. Ten panels left in the tree would put ten `<h1>`s in the outline and read the
   * scanner's controls out to somebody who is looking at their pass.
   */
  .dash-panel {
    display: none;
  }

  .dash-panel.active {
    display: block;
  }

  /* `dashboard.js` focuses the panel on every switch so a keyboard or screen-reader user lands
     on the new content instead of staying in the rail. A ring around a whole panel is noise and
     is not what that focus is for; the ring stays for the keyboard case that means it. */
  .dash-panel:focus {
    outline: none;
  }

  .dash-panel:focus-visible {
    outline: 2px solid var(--ring);
    outline-offset: 4px;
  }

  /* One filled module above the heading — the finder language reduced to its smallest piece. A
     panel is where you start reading, which is the one thing a finder pattern says. */
  .dash-panel.active::before {
    display: block;
    width: var(--module);
    height: var(--module);
    margin-bottom: var(--s4);
    border-radius: 1px;
    background: var(--accent);
    content: '';
  }

  /**
   * The rhythm between the blocks a panel hands to `ctx.mount`.
   *
   * ui.css can only space the pairs it knows about, and a panel is an array assembled in
   * JavaScript: an `.event-list` after a form card, or a `.qrmaker-result` after one, are pairs
   * it has never seen. So the panel owns the gap and its children give up their own block
   * margins to it. That is also what makes the portal denser than the public pages without
   * editing a component the marketing site shares.
   */
  .dash-panel > * {
    margin-bottom: 0;
  }

  .dash-panel > * + * {
    margin-top: var(--s5);
  }

  /* ==========================================================================================
     From 1024px: the same nodes as a column
     ========================================================================================== */

  @media (min-width: 1024px) {
    /**
     * A fixed shell: the document never scrolls, the panel area does.
     *
     * Switching panels used to move the entire screen. Two causes, and both are structural rather
     * than cosmetic. A tall panel gives the document a scrollbar and a short one takes it away, so
     * every switch between, say, the roster and My Pass shifted the whole layout sideways by the
     * scrollbar's width. And with the rail merely `sticky` inside a document whose height is set by
     * the panel, a switch to a shorter panel clamps the scroll position and the rail slides with
     * it. Neither is fixable by adjusting a value.
     *
     * So above this breakpoint the body is exactly the viewport and does not scroll at all. The
     * rail is a static column that cannot move because nothing around it moves, and `.app__main`
     * owns the only scrollbar on the page — with `scrollbar-gutter: stable`, so its own content
     * changing length does not shift it either.
     *
     * Below 1024px this is deliberately not applied. The rail is a band in the flow there, and
     * making an inner element the scroll container on a phone costs the platform behaviours that
     * come free with document scrolling: momentum, the URL bar collapsing, and overscroll.
     */
    .page--portal {
      display: flex;
      flex-direction: column;
      height: 100svh;
      overflow: hidden;
    }

    .app {
      display: grid;
      /* `width`, because the body is now a column flex container and `.app` still carries
         `margin-inline: auto` from the base rule. Auto inline margins on a flex item make it
         content-sized instead of stretched, which collapsed the whole shell to 941px and pushed the
         rail 250px in from the left. An explicit width is what the auto margins can centre — the
         same trap the scanner stage hit. */
      flex: 1 1 auto;
      grid-template-columns: 264px minmax(0, 1fr);
      width: 100%;
      min-height: 0;
    }

    .app__main {
      /**
       * Capped and centred in its column.
       *
       * Uncapped, the panels stretched from the rail to the right edge of the window — a table or
       * a paragraph 1100px wide, and a composition whose whole mass sat to the right of the rail
       * with nothing balancing it. A measure this side of 1000px is what the content actually
       * wants, and centring what is left of the column puts equal air on both sides of it.
       *
       * `max-width` has to sit on an inner box rather than on the scroll container, or the
       * scrollbar would be pulled 62rem in from the right edge instead of tracking the window.
       */
      height: 100%;
      overflow-y: auto;
      overscroll-behavior: contain;
      scrollbar-gutter: stable;
      padding-top: var(--s6);
    }

    .app__main > .dash-panel {
      max-width: 62rem;
      margin-inline: auto;
    }

    /* Static, not sticky: it is a column of a grid that is exactly the viewport tall, so there is
       nothing for it to stick to. It scrolls on its own only when a short laptop window cannot fit
       four groups and eleven links. */
    .rail {
      position: static;
      gap: var(--s5);
      height: 100%;
      max-height: none;
      overflow-y: auto;
      padding: var(--s6) var(--s5) var(--s4) var(--edge-l);
      border-right: 1px solid var(--line);
      border-bottom: 0;
      background: none;
    }

    .dash-identity {
      display: block;
      padding-inline: 0;
    }

    .dash-identity__text {
      flex-direction: column;
      align-items: flex-start;
      gap: 0;
    }

    .dash-identity__number {
      margin: var(--s3) 0 0;
    }

    .dash-nav {
      display: block;
      overflow: visible;
      padding-inline: 0;
      scroll-snap-type: none;
    }

    /* Both levels generate boxes again, and the list becomes the vertical stack the group
       structure was rendered for. */
    .dash-group {
      display: block;
    }

    .dash-group + .dash-group {
      margin-top: var(--s5);
    }

    .dash-group-title {
      display: block;
      margin-bottom: var(--s2);
      padding-inline: var(--s3);
      color: var(--fg-muted);
      font-family: var(--font-mono);
      font-size: var(--t-micro);
      font-weight: 500;
      letter-spacing: var(--track-label);
      text-transform: uppercase;
    }

    .dash-nav-list {
      display: flex;
      flex-direction: column;
      gap: 1px;
    }

    .dash-nav-link {
      gap: var(--s3);
      width: 100%;
      min-height: 2.75rem;
      border-bottom: 0;
      border-radius: var(--r2);
      text-align: left;
    }

    /* One module, on every link, filled on the active one. Present but transparent elsewhere so
       the labels stay on one vertical line and the marker is the only thing that changes. */
    .dash-nav-link::before {
      flex: 0 0 auto;
      width: var(--module);
      height: var(--module);
      border-radius: 1px;
      background: transparent;
      content: '';
    }

    .dash-nav-link:hover {
      background: var(--bg-sunken);
    }

    .dash-nav-link.active {
      background: var(--bg-raise);
      box-shadow: var(--sh1);
    }

    .dash-nav-link.active::before {
      background: var(--accent);
    }

    .rail__foot {
      display: block;
      padding-top: var(--s4);
      border-top: 1px solid var(--line-quiet);
    }
  }

  /* ==========================================================================================
     Shortcut tiles — the three ways into the overview
     ========================================================================================== */

  .shortcut-grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s4);
  }

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

  @media (min-width: 1000px) {
    .shortcut-grid {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }
  }

  /* A pressable tile, so it is a real `<button>` and everything a button gets — the press
     compression in motion.css, the focus ring, Space and Enter — comes for free. The branch is
     carried by a 3px top edge in its own sprinkle rather than by a tinted surface, for the same
     reason `.info-card` tones are a keyline: three tinted cards read as three warnings. */
  .shortcut {
    display: flex;
    flex-direction: column;
    /* Stretch, not `flex-start`. A column flex container with `align-items: flex-start` sizes
       every child to its own text, so the title and the sentence under it ended up hugging their
       longest word instead of filling the tile — three tiles in a row, each with a different
       ragged right edge. Only the badge should hug, and it says so itself below. */
    align-items: stretch;
    gap: var(--s2);
    height: 100%;
    padding: var(--s4);
    border: 1px solid var(--line);
    border-top: 3px solid var(--tile-tone, var(--accent));
    border-radius: var(--r4);
    background: var(--bg-raise);
    box-shadow: var(--sh1);
    text-align: left;
    transition:
      border-color var(--dur-1) var(--ease),
      box-shadow var(--dur-1) var(--ease),
      transform var(--dur-1) var(--ease);
  }

  .shortcut:hover {
    border-color: var(--line-strong);
    /* Re-stated because the line above is a four-sided shorthand and would take the tone edge
       with it. */
    border-top-color: var(--tile-tone, var(--accent));
    box-shadow: var(--sh2);
    transform: translateY(-1px);
  }

  .shortcut--munchkin {
    --tile-tone: var(--munchkin);
  }

  .shortcut--prober {
    --tile-tone: var(--prober);
  }

  .shortcut--cipher {
    --tile-tone: var(--cipher);
  }

  .shortcut__badge {
    align-self: flex-start;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .shortcut__title {
    font-size: var(--t-lg);
  }

  .shortcut__text {
    color: var(--fg-muted);
    font-size: var(--t-base);
    line-height: 1.5;
  }

  /* ==========================================================================================
     The pass — held up in a dim corridor for somebody else to scan
     ========================================================================================== */

  .pass-live {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--s5);
  }

  /* The symbol is the only thing here sized to its content. Everything under it is a full-width
     block, so the detail rows and the single-use notice keep their own alignment instead of
     being centred into a ragged column. */
  .pass-live > .detail-list,
  .pass-live > .dash-notice,
  .pass-live > .pass-actions {
    width: 100%;
  }

  /**
   * White, hardcoded, whatever is behind it.
   *
   * This is the one place a semantic token would be wrong. A decoder binarises luminance: the
   * quiet zone has to be the light half of that comparison on any ground, so a token that could
   * ever resolve to plum would turn a working pass into a refusal at the door. The padding is
   * that quiet zone — the empty margin a symbol needs before a reader can find its edges at all.
   *
   * The floor is `min(260px, 100%)` rather than a flat 260px: inside a card inside a panel at
   * 320px there are 256px to spend, and a symbol that overflows its card is worse than one four
   * pixels under the comfortable minimum.
   */
  .pass-qr {
    display: grid;
    place-items: center;
    width: min(100%, 22rem);
    min-width: min(260px, 100%);
    aspect-ratio: 1;
    padding: var(--s3);
    /* The tile is hardcoded white, so its edge has to be too: `--line` is a white alpha in the
       dark scheme and would draw nothing at all against the quiet zone. */
    border: 1px solid var(--bond-300);
    border-radius: var(--r3);
    background: #fff;
  }

  .pass-qr > svg {
    width: 100%;
    height: auto;
  }

  .pass-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--s3);
  }

  /* Stacked and full width on a phone, side by side once both labels fit on one line. */
  .pass-actions > * {
    flex: 1 1 100%;
  }

  @media (min-width: 480px) {
    .pass-actions > * {
      flex: 0 1 auto;
    }
  }

  .pass-footnote {
    max-width: 44ch;
    color: var(--fg-muted);
    font-size: var(--t-sm);
    line-height: 1.5;
    text-align: center;
  }

  /**
   * A spent pass reads as finished, not as a failure.
   *
   * Sunken and green: the member did the thing the pass is for. Nothing here is red, and there is
   * deliberately no QR — `pass.js` withholds it, because a code the scanner will refuse is worse
   * than no code at all.
   */
  .pass-used {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--s4);
    padding: var(--s5) var(--s4);
    border-radius: var(--r3);
    background: var(--bg-sunken);
    text-align: center;
  }

  .pass-used > .detail-list,
  .pass-used > .dash-notice {
    width: 100%;
    text-align: left;
  }

  .pass-used > .pass-actions {
    width: 100%;
  }

  .pass-used__mark {
    display: grid;
    place-items: center;
    width: 3rem;
    height: 3rem;
    border-radius: var(--r-pill);
    background: var(--ok);
    /* The surface colour, not white: on plum `--ok` is the light sprinkle and the tick has to be
       the dark half of the pair. */
    color: var(--bg-raise);
    font-size: var(--t-lg);
    line-height: 1;
  }

  .pass-used__title {
    font-size: var(--t-xl);
  }

  .pass-used__text {
    max-width: 46ch;
    color: var(--fg-muted);
    font-size: var(--t-base);
    line-height: 1.55;
  }

  /* ==========================================================================================
     The scanner — one hand, at a door, watching a queue rather than the screen
     ========================================================================================== */

  /**
   * The whole scanner is one narrow column, centred.
   *
   * Constraining the wrapper rather than the stage alone is what keeps the hint and the controls
   * aligned to the viewfinder's own left edge. Centring the stage by itself left the caption and
   * the Stop button hanging off to one side of it, which reads as three unrelated things instead
   * of one instrument.
   */
  .scanner {
    display: flex;
    flex-direction: column;
    gap: var(--s4);
    width: min(100%, 32rem);
    margin-inline: auto;
  }

  /**
   * The stage has to look right empty.
   *
   * `scanner.js` attaches the `<video>` to `#cameraMount` *after* the redraw that creates this
   * box, so for a frame or two it is a bare rectangle — and it is a bare rectangle again every
   * time the camera is released. Plum-black rather than a surface token: this is framing for
   * hardware, and a pale stage flashes at the officer each time the camera opens.
   *
   * Square on a phone, 4:3 with room to spare above it. A member holds their phone portrait, and
   * a 4:3 stage on a narrow screen is a letterbox with the QR in the middle third of it.
   */
  .scanner__stage {
    position: relative;
    /**
     * Capped, and centred in whatever space it is given.
     *
     * Without a cap the stage takes the whole panel column, which on a laptop is a 1000px black
     * rectangle — and none of that size helps. What the decoder needs is the symbol occupying
     * enough of the frame to sample, which is a function of how close the officer holds the phone,
     * not of how large the preview is. A viewfinder the size of a real one is easier to aim and
     * leaves the event picker and the last scan result on screen at the same time.
     *
     * `width`, not `max-width`. `scanner.js` builds this as an empty `<div>` and attaches the
     * video to it afterwards, and an auto-width element with auto inline margins is sized from its
     * content — which for an empty div is nothing, so the stage collapsed to zero and the
     * aspect-ratio gave it no height either. An explicit width is what the auto margins can then
     * centre.
     */
    width: 100%;
    aspect-ratio: 1;
    overflow: hidden;
    border-radius: var(--r4);
    background: var(--ink-900);
  }

  @media (min-width: 600px) {
    .scanner__stage {
      aspect-ratio: 4 / 3;
    }
  }

  .scanner__video {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  /**
   * Four corner brackets, and nothing that moves.
   *
   * The same finder-pattern language as the rest of the design, at the same module unit: a 5×1
   * module arm on each side of every corner. Eight solid gradient layers rather than pseudo
   * elements, because `scanner.js` builds this as a single empty `<div>` and both of its
   * pseudos would still only make three corners.
   *
   * Nothing here animates, on purpose. A sweeping line or a pulsing frame is the convention in
   * scanner apps and it is wrong for this one: the officer is watching a queue of people, and
   * motion in the corner of the eye pulls attention to the screen for no news.
   *
   * `--glaze` rather than `--accent`: the stage under it is always dark, and the paper accent is
   * a deep purple that would disappear into it.
   */
  .scanner__reticle {
    --arm: calc(var(--module) * 5);
    --hair: calc(var(--module) / 2);
    position: absolute;
    inset: 12%;
    background-image:
      linear-gradient(var(--glaze) 0 0), linear-gradient(var(--glaze) 0 0),
      linear-gradient(var(--glaze) 0 0), linear-gradient(var(--glaze) 0 0),
      linear-gradient(var(--glaze) 0 0), linear-gradient(var(--glaze) 0 0),
      linear-gradient(var(--glaze) 0 0), linear-gradient(var(--glaze) 0 0);
    background-repeat: no-repeat;
    background-position:
      left top, left top,
      right top, right top,
      left bottom, left bottom,
      right bottom, right bottom;
    background-size:
      var(--arm) var(--hair), var(--hair) var(--arm),
      var(--arm) var(--hair), var(--hair) var(--arm),
      var(--arm) var(--hair), var(--hair) var(--arm),
      var(--arm) var(--hair), var(--hair) var(--arm);
  }

  /* The camera is off, denied, or held warm between two people. Same footprint as the live stage
     so the panel does not jump when it arms. */
  .scanner__stage--idle {
    display: grid;
    place-items: center;
    gap: var(--s3);
    padding: var(--s5) var(--s4);
    background: var(--bg-sunken);
    text-align: center;
  }

  .scanner__idle-mark {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-2xl);
    line-height: 1;
  }

  .scanner__idle-text {
    max-width: 34ch;
    color: var(--fg-muted);
    font-size: var(--t-base);
    line-height: 1.5;
  }

  .scanner__hint {
    color: var(--fg-muted);
    font-size: var(--t-sm);
    line-height: 1.5;
  }

  .scanner__controls {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s3);
  }

  /* Full width and stacked on a phone: this is a one-handed screen, and "Scan next person" is
     the target a thumb goes for without looking. */
  .scanner__controls > * {
    flex: 1 1 100%;
    min-height: 2.75rem;
  }

  @media (min-width: 480px) {
    .scanner__controls > * {
      flex: 0 1 auto;
    }
  }

  /* ---- Scans that have not reached the server yet ------------------------------------------
     Every row is an attendance record that only exists on this device, so the list is mono and
     dense: an officer is checking a count and a name, not reading prose. */

  .queue-list {
    display: flex;
    flex-direction: column;
  }

  .queue-list__item {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2) var(--s3);
    padding: var(--s3) 0;
    border-top: 1px solid var(--line-quiet);
    font-family: var(--font-mono);
    font-size: var(--t-sm);
  }

  .queue-list__item:first-child {
    padding-top: 0;
    border-top: 0;
  }

  .queue-list__label {
    margin-right: auto;
    color: var(--fg);
    font-weight: 600;
  }

  .queue-list__time {
    color: var(--fg-muted);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
  }

  /* The server's reason for refusing this one, on its own line: it is a sentence, and it is the
     only thing on the row that tells the officer what to do next. */
  .queue-list__error {
    flex: 1 1 100%;
    color: var(--bad);
    font-size: var(--t-xs);
    line-height: 1.45;
  }

  /* ==========================================================================================
     The QR maker
     ========================================================================================== */

  /* No display of its own: `qrmaker.js` builds the form once and never redraws it, and the field
     rhythm ui.css gives a plain stack is exactly right for two fields and a button. */
  .qrmaker-form {
    max-width: 42rem;
  }

  .qrmaker-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s3);
    margin-top: var(--s5);
  }

  /* Its own container, because it is re-rendered on its own — replacing the whole panel would
     take the caret out of the link field with it. */
  .qrmaker-result {
    margin-top: var(--s5);
  }

  /* White for the same reason as the pass, and larger, because these end up on a poster. */
  .qrmaker-code {
    display: grid;
    place-items: center;
    width: min(100%, 24rem);
    min-width: min(260px, 100%);
    aspect-ratio: 1;
    margin-inline: auto;
    padding: var(--s3);
    /* The tile is hardcoded white, so its edge has to be too: `--line` is a white alpha in the
       dark scheme and would draw nothing at all against the quiet zone. */
    border: 1px solid var(--bond-300);
    border-radius: var(--r3);
    background: #fff;
  }

  .qrmaker-code__svg {
    width: 100%;
    height: auto;
  }

  /* Error level, version and module count: the officer's evidence that this symbol has the
     recovery to survive the logo punched through its middle. */
  .qrmaker-meta {
    margin-top: var(--s4);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.02em;
    text-align: center;
  }

  /* Exactly what was encoded, which is not always what was typed — bare domains get a scheme
     added, and a poster is a bad place to discover that. */
  .qrmaker-target {
    margin-top: var(--s1);
    color: var(--fg);
    font-family: var(--font-mono);
    font-size: var(--t-sm);
    text-align: center;
    word-break: break-all;
  }

  .qrmaker-empty {
    padding: var(--s6) var(--s4);
    border: 1px dashed var(--line-strong);
    border-radius: var(--r3);
    text-align: center;
  }

  .qrmaker-empty__text {
    max-width: 34ch;
    margin-inline: auto;
    color: var(--fg-muted);
    font-size: var(--t-base);
    line-height: 1.55;
  }

  /* The byte count has passed the limit, so the code cannot be generated at all. On the hint
     rather than on the field: the number is the thing that has to change. */
  .qrmaker-hint--over {
    color: var(--bad);
    font-weight: 500;
  }

  /* ==========================================================================================
     Events and announcements
     ========================================================================================== */

  /**
   * `events.js` and `announcements.js` wrap every row in `card()`, so the `.info-card` around
   * each item *is* the card per event — these two lists only place them, and `.event-item` and
   * `.post` only lay out what is inside. A second border and a second surface on the inner
   * element would draw a frame inside a frame.
   *
   * The margin reset is the one thing that has to be said: ui.css stacks consecutive
   * `.info-card`s with a margin, and inside a grid that has a gap the two add up.
   */
  .event-list,
  .post-list {
    display: grid;
    gap: var(--s4);
  }

  .event-list > .info-card + .info-card,
  .post-list > .info-card + .info-card {
    margin-top: 0;
  }

  .event-item {
    display: flex;
    flex-direction: column;
    gap: var(--s3);
  }

  /**
   * The title and the date it belongs to.
   *
   * `flex-start`, not `space-between`. On a wide panel space-between put the stamp against the
   * far border — nine hundred pixels from the title it describes, reading as chrome belonging
   * to the card rather than as the event's date. Trailing the title keeps the two as one line
   * of information, and it still wraps beneath when the title is long.
   */
  .event-item__head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: flex-start;
    gap: var(--s2) var(--s4);
  }

  .event-item__title {
    font-size: var(--t-lg);
  }

  /* Mono, because it is data an officer reads against a poster or a group chat and a proportional
     face makes two similar dates look alike.

     No `flex: 0 0 auto` — that existed to stop the stamp being compressed by the space-between
     layout above, which is gone. */
  .event-item__when {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.02em;
  }

  .event-item__meta {
    color: var(--fg-muted);
    font-size: var(--t-sm);
  }

  /* `white-space: pre-line`, because the description is a member's typing rendered as text: the
     paragraph breaks they put in are the only structure it has.

     `--fg`, not `--fg-muted`. This is what the card is for. Muted here put the description
     below the monospace meta line above it in the reading order the eye actually follows, and
     a card whose content is quieter than its own byline reads as having nothing in it. */
  .event-item__body {
    max-width: var(--maxw-prose);
    color: var(--fg);
    font-size: var(--t-base);
    line-height: var(--lh-normal);
    white-space: pre-line;
  }

  .event-item__foot {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2) var(--s3);
    padding-top: var(--s3);
    border-top: 1px solid var(--line-quiet);
  }

  .event-item__author {
    margin-right: auto;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
  }

  /* The same family, quieter: an announcement is a paragraph, not a record with a time and a
     place attached. No `align-items` here — a column flex container that starts its children
     would size each line of the post to its own text and leave the body hugging its longest
     word inside a full-width card. */
  .post {
    display: flex;
    flex-direction: column;
    gap: var(--s2);
  }

  .post__title {
    font-size: var(--t-lg);
  }

  .post__meta {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
  }

  /* `--fg` for the reason `.event-item__body` gives: the announcement is the content, and it was
     set quieter than the byline describing it. */
  .post__body {
    max-width: var(--maxw-prose);
    margin-top: var(--s1);
    color: var(--fg);
    font-size: var(--t-base);
    line-height: var(--lh-prose);
    white-space: pre-line;
  }

  /* ==========================================================================================
     Roster search, the admin editor, the password card
     ========================================================================================== */

  .roster-search {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: var(--s4);
    margin-bottom: var(--s5);
  }

  .roster-search > .form-group {
    flex: 1 1 18rem;
  }

  /* A fieldset defaults to `min-inline-size: min-content`, which is what makes one refuse to
     shrink and push a card wider than its column. */
  .roster-grade-filter,
  .roster-branch-filter {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2) var(--s4);
    min-inline-size: 0;
    padding: 0;
  }

  /* `members.js` emits a bare `<input type="checkbox">`, so `.form-checkbox` never applies here.
     The label is the target, and it is the label that has to reach 44px. */
  .roster-grade-filter .check-option,
  .roster-branch-filter .check-option {
    min-height: 2.75rem;
  }

  .roster-grade-filter input,
  .roster-branch-filter input {
    width: 1.25rem;
    height: 1.25rem;
    margin: 0;
    accent-color: var(--accent);
  }

  /**
   * The roster on a phone: cards, not a stacked table.
   *
   * `ui.css` turns any `.dash-table` below 768px into a stack of labelled pairs, which is the
   * right answer for a six-column ledger and the wrong one for a nine-column roster — sixteen
   * lines per member, most of them a label. So at those widths the table is hidden outright and
   * `rosterList` in widgets.js supplies the same rows as cards.
   *
   * Both are in the document at every width and this is what chooses; `.table-sort` above works
   * the same way. `display: none` on `.roster-table` beats the `display: block` that ui.css gives
   * every `.dash-table` because `portal` is declared after `ui` in the layer order in base.css —
   * the two selectors are the same specificity, so without the layers this would be a coin toss
   * decided by link order. The emptied `.table-container` leaves nothing behind: ui.css strips its
   * border and background at this width already.
   */
  .roster-list {
    display: none;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  @media (max-width: 767px) {
    .roster-table {
      display: none;
    }

    .roster-list {
      display: grid;
      gap: var(--s3);
    }
  }

  .roster-list__item {
    display: flex;
    align-items: stretch;
    border: 1px solid var(--line);
    border-radius: var(--r3);
    background: var(--bg-raise);
  }

  /* The label, not the checkbox, is what reaches 44px — the same arrangement the grade and
     sub-branch filters above use, and for the same reason. */
  .roster-list__select {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    justify-content: center;
    min-width: 2.75rem;
    min-height: 2.75rem;
    cursor: pointer;
  }

  .roster-list__select input {
    width: 1.25rem;
    height: 1.25rem;
    margin: 0;
    accent-color: var(--accent);
  }

  /* Everything except the checkbox is one control that opens the profile. `min-width: 0` on both
     the button and the name is what lets a long name wrap instead of widening the card. */
  .roster-list__open {
    display: flex;
    flex: 1 1 auto;
    align-items: center;
    gap: var(--s4);
    min-width: 0;
    min-height: 3.75rem;
    padding: var(--s3) var(--s4);
    border: 0;
    border-radius: inherit;
    background: none;
    color: var(--fg);
    font: inherit;
    text-align: left;
  }

  /* The tokens `.profile-avatar` uses, at the smaller size: the disc on the card and the disc in
     the modal it opens should read as the same object rather than as two decorations. */
  .roster-list__disc {
    display: grid;
    flex: 0 0 auto;
    place-items: center;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: var(--r-pill);
    background: var(--accent-wash);
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
  }

  .roster-list__text {
    display: grid;
    flex: 1 1 auto;
    gap: var(--s1);
    min-width: 0;
  }

  .roster-list__top {
    display: flex;
    align-items: center;
    gap: var(--s3);
  }

  .roster-list__name {
    flex: 1 1 auto;
    min-width: 0;
    font-weight: 600;
    overflow-wrap: anywhere;
  }

  .roster-list__top > .dash-pill {
    flex: 0 0 auto;
  }

  /* The second line: the grade, the section, and for an administrator the marker on a row the
     club cannot see. It wraps because "GRADE 12" and "ICT 1-101" and a pill do not always fit on
     one line at 320px, and a wrapped second line is better than a squeezed one. */
  .roster-list__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2) var(--s3);
  }

  /* One rule for both, deliberately: they are two readings of the same line and a separate
     declaration block is how one of them ends up a different size after an edit to the other. */
  .roster-list__grade,
  .roster-list__section {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  /* The affordance arrives where it has to be legible, matching `.roster-table__open` on the
     table this replaces. */
  .roster-list__open:hover .roster-list__name,
  .roster-list__open:focus-visible .roster-list__name {
    color: var(--accent);
    text-decoration: underline;
    text-underline-offset: 0.2em;
  }

  .edit-user-form {
    display: flex;
    flex-direction: column;
    gap: var(--s5);
  }

  .edit-user-form__section {
    padding-bottom: var(--s2);
    border-bottom: 1px solid var(--line-quiet);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .edit-user-form__grid {
    display: grid;
    gap: var(--s4);
  }

  /* The grid gap is the rhythm inside it, so the stacking margin ui.css gives adjacent fields
     would double the gap of every row after the first. */
  .edit-user-form__grid > .form-group + .form-group {
    margin-top: 0;
  }

  /* Eight fields in a modal: two columns as soon as a label and its input still fit in half of
     it, one column before that. */
  @media (min-width: 640px) {
    .edit-user-form__grid {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
  }

  .security-summary {
    display: flex;
    flex-direction: column;
    gap: var(--s3);
  }

  /* Neutral, in both of its states. It reads "Password enabled" or "Optional protection" from one
     node with no tone class, and colouring it green would turn the second one — which is a fact
     about a choice, not a warning — into something the member has to fix. */
  .security-summary__status {
    align-self: flex-start;
    display: inline-flex;
    align-items: center;
    padding: 0.2rem var(--s3);
    border: 1px solid var(--line-strong);
    border-radius: var(--r-pill);
    background: var(--bg-sunken);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: 0.06em;
    text-transform: uppercase;
  }

  /* It also carries `.dash-subtitle`, whose top margin the flex gap above has already spent. */
  .security-summary__text {
    margin-top: 0;
  }

  /* ==========================================================================================
     Recruitment — the officer application gate, and the titles it offers
     ========================================================================================== */

  /**
   * `[lamp] [state + detail] [action]`.
   *
   * Below 640px the button drops to a second row under the text rather than being squeezed
   * beside it, and it starts at the text's edge rather than the lamp's, so the column the eye is
   * already reading down is the one the action sits in.
   */
  .gate {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: start;
    gap: var(--s3) var(--s4);
  }

  /**
   * Lit or unlit, and the difference is a fill rather than only a hue: open is a solid dot inside
   * a ring of its own wash, closed is a hollow ring on the sunken ground. Somebody who cannot
   * tell the mint from the grey can still see which one is filled.
   *
   * `margin-top` sits it on the first line of the heading rather than centred on the block, which
   * is where a status light belongs.
   */
  .gate__lamp {
    width: 0.85rem;
    height: 0.85rem;
    margin-top: 0.42rem;
    border: 2px solid var(--line-strong);
    border-radius: 50%;
    background: var(--bg-sunken);
  }

  .gate__lamp--on {
    border-color: var(--ok);
    background: var(--ok);
    box-shadow: 0 0 0 4px var(--ok-wash);
  }

  /* An `h2`, so `--t-2xl` from the type layer would shout over the panel's own heading. */
  .gate__state {
    font-size: var(--t-lg);
    line-height: var(--lh-snug);
  }

  .gate__detail {
    margin-top: var(--s2);
    max-width: 54ch;
    color: var(--fg-muted);
    line-height: var(--lh-prose);
  }

  .gate__action {
    grid-column: 2;
  }

  @media (min-width: 640px) {
    .gate {
      grid-template-columns: auto minmax(0, 1fr) auto;
    }

    .gate__action {
      grid-column: 3;
      align-self: center;
    }
  }

  /**
   * The row that adds a title.
   *
   * The input is capped rather than filling the card: a 700px box in front of the word
   * "Secretary" reads as somewhere to write a paragraph, and every title in the list is two words
   * long. It keeps its `flex-basis` so the button wraps under it on a narrow rail instead of the
   * two of them shrinking together.
   */
  .title-add {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s3);
  }

  .title-add__input {
    flex: 1 1 14rem;
    max-width: 22rem;
  }

  /* Carries the rule between the control and the list it changes, being the last node before it. */
  .title-add__hint {
    margin: var(--s2) 0 var(--s5);
    padding-bottom: var(--s5);
    border-bottom: 1px solid var(--line);
  }

  /* ==========================================================================================
     Sign in
     ========================================================================================== */

  /* `--bg`, not `--bg-raise`. The comment in login.html says this bar is deliberately
     identical to the marketing one, and it was a step lighter than it on every page — the
     raised token is the band's card surface, not the band. Both bars are `--band` now, and the
     claim is true in both schemes. */
  .auth-bar {
    position: sticky;
    top: 0;
    z-index: var(--z-topbar);
    border-bottom: 1px solid var(--line);
    background: var(--bg);
  }

  /* One link back to the public site is the whole chrome. Somebody who came here to sign in
     should have nothing else to click. */
  .auth-bar .container {
    display: flex;
    align-items: center;
    min-height: var(--topbar-h);
  }

  /**
   * The sign-in page fills the viewport.
   *
   * Its content is short — a heading, three steps and a form — so without this the document ends
   * partway down and the html element's own plum shows below the footer as a stray dark band. A
   * flex column with the main growing puts the footer on the fold instead, at any window height.
   */
  .page--auth {
    display: flex;
    flex-direction: column;
    min-height: 100svh;
  }

  .page--auth > .auth {
    flex: 1 0 auto;
  }

  .auth {
    padding-block: var(--s7) var(--s8);
  }

  .auth__grid {
    display: grid;
    gap: var(--s6);
  }

  /**
   * The form comes first on a phone.
   *
   * Below 900px the prose is below it, because somebody who followed a "Sign in" link has already
   * decided — making them scroll past three steps and a paragraph about passwords to reach the
   * field is charging them for our explanation. The DOM keeps the intro first, so a screen reader
   * still gets the heading that names the page before the form under it.
   */
  .auth__form {
    order: -1;
  }

  @media (min-width: 900px) {
    .page--auth[data-page='login'] > .auth {
      display: grid;
      align-items: center;
    }

    .auth__grid {
      grid-template-columns: minmax(0, 1fr) minmax(0, 26rem);
      align-items: center;
      gap: var(--s8);
    }

    .auth__form {
      order: 0;
    }
  }

  /* The finder mark and its label, from the public pages' section head. site.css owns that
     pattern and is not loaded here, so this is scoped to the one place it appears. */
  .auth__intro .section-mark {
    display: flex;
    align-items: center;
    gap: var(--s2);
    margin-bottom: var(--s4);
  }

  /* `--t-3xl` is a landing-page size. This is a heading over a form, and the form is the point. */
  .auth__intro h1 {
    font-size: var(--t-2xl);
  }

  .auth__intro .lede {
    margin-top: var(--s4);
  }

  /**
   * Numbered, unlike every heading on the public site.
   *
   * The finder mark replaces `01 / 02 / 03` out there because those sections are not a sequence.
   * These three are: the address, the email, the code, in that order, and the number is the
   * information.
   */
  .auth-steps {
    display: grid;
    gap: var(--s3);
    margin-top: var(--s5);
    counter-reset: auth-step;
  }

  .auth-steps > li {
    /**
     * Indent-plus-absolute counter, deliberately not a grid.
     *
     * A grid container makes every *element* child its own grid item, and these list items mix
     * text with a `<strong>` holding the STI domain — so on a two-column grid the marker, the
     * words before the domain, the domain itself and the words after it became four items and the
     * sentence broke across three rows. Anonymous inline content cannot be kept together in a
     * grid; padding and an out-of-flow marker can hold any inline content at all.
     */
    position: relative;
    padding-left: calc(1.5rem + var(--s3));
    color: var(--fg-muted);
    font-size: var(--t-base);
    line-height: 1.5;
    counter-increment: auth-step;
  }

  .auth-steps > li::before {
    position: absolute;
    top: 0.1rem;
    left: 0;
    display: grid;
    place-items: center;
    width: 1.5rem;
    height: 1.5rem;
    border: 1px solid var(--accent-line);
    border-radius: var(--r1);
    background: var(--accent-wash);
    color: var(--accent);
    content: counter(auth-step);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 600;
    line-height: 1;
  }

  .auth-steps strong {
    /* The address the college gives out is long and the domain is the part that has to be typed
       exactly, so it is set as data rather than emphasised prose. */
    font-family: var(--font-mono);
    font-size: 0.92em;
    word-break: break-word;
  }

  .auth-aside {
    max-width: var(--maxw-prose);
    margin-top: var(--s5);
    padding-top: var(--s4);
    border-top: 1px solid var(--line-quiet);
    color: var(--fg-muted);
    font-size: var(--t-sm);
    line-height: 1.55;
  }

  /* `.form-box` from ui.css is the surface, the border and the shadow; this only holds whatever
     is inside it apart. `login.js` replaces every child on each draw, so the noscript block and
     the form it is swapped for both land here. */
  .auth-form {
    display: grid;
    gap: var(--s5);
  }

  /* The inline padding belongs to the `.container` inside it; this only owns the block edges, and
     the bottom one has to clear a home-bar gesture area. */
  .auth-foot {
    padding-block: var(--s5) calc(var(--s5) + env(safe-area-inset-bottom, 0px));
    border-top: 1px solid var(--line);
    color: var(--fg-muted);
    font-size: var(--t-sm);
    line-height: 1.6;
    text-align: center;
  }

  .auth-foot p + p {
    margin-top: var(--s2);
  }

  /* ---- The form login.js builds ----------------------------------------------------------- */

  .auth-step {
    display: grid;
    gap: var(--s5);
  }

  /**
   * The gap owns the rhythm, so the children give up their own.
   *
   * ui.css spaces a plain stack of fields with margins, and this form is not one: it has a
   * segmented control, a message region, a sent-to block and a row of alternatives between the
   * fields. One gap and one reset is the only way those cannot disagree. `.auth-step` is also
   * used by the password form in `overview.js` and the details form in `details.js`, and the
   * same reasoning holds there.
   */
  .auth-step > * {
    margin-block: 0;
  }

  /* A tablist wrapper with nothing else in it: the segmented control below is the whole of it,
     and the space around it belongs to `.auth-step`. */
  .auth-mode {
    display: block;
  }

  /* Two alternatives for one task, so they share a track rather than sitting apart as two
     buttons. The active segment is raised out of the sunken track — the state is carried by the
     surface and the shadow as well as by `aria-selected`. */
  .auth-mode__tabs {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--s1);
    padding: var(--s1);
    border-radius: var(--r2);
    background: var(--bg-sunken);
  }

  .auth-mode__tab {
    min-height: 2.75rem;
    padding-inline: var(--s3);
    border-radius: calc(var(--r2) - 1px);
    color: var(--fg-muted);
    font-size: var(--t-base);
    font-weight: 600;
  }

  .auth-mode__tab:hover {
    color: var(--fg);
  }

  .auth-mode__tab.is-active {
    background: var(--bg-raise);
    box-shadow: var(--sh1);
    color: var(--fg);
  }

  .auth-mode__tab[disabled] {
    cursor: not-allowed;
    opacity: 0.55;
  }

  /* The one action on the step, so it takes the full width of the box. */
  .auth-provider {
    width: 100%;
  }

  .auth-alternatives {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--s3);
  }

  .auth-sent {
    padding: var(--s4);
    border: 1px solid var(--accent-line);
    border-radius: var(--r2);
    background: var(--accent-wash);
  }

  .auth-sent__text {
    color: var(--fg-muted);
    font-size: var(--t-sm);
  }

  /* Mono and breaking anywhere: an STI address is long enough to overflow a form box on a phone,
     and this is the one line on screen the member checks character by character against the
     inbox they are looking for. */
  .auth-sent__email {
    margin-top: var(--s1);
    color: var(--fg);
    font-family: var(--font-mono);
    font-size: var(--t-base);
    font-weight: 600;
    word-break: break-word;
  }

  /* Development only: `OTP_DEBUG` on a local server sends no mail, so the code is printed here
     because there is nowhere else to read it. Dashed and in the warning tone so it can never be
     mistaken for part of the product. */
  .auth-debug {
    padding: var(--s4);
    border: 1px dashed var(--warn);
    border-radius: var(--r2);
    background: var(--warn-wash);
    text-align: center;
  }

  .auth-debug__label {
    color: var(--warn);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .auth-debug__code {
    margin-top: var(--s2);
    color: var(--fg);
    font-family: var(--font-mono);
    font-size: var(--t-2xl);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    /* The trailing letter-space is real width, and centred text is off by half of it without the
       indent to cancel it. */
    letter-spacing: 0.28em;
    text-indent: 0.28em;
  }

  /**
   * The code field.
   *
   * Six digits being copied from an email, one character at a time, by somebody standing up. This
   * is the one field in the application where legibility per character beats tidiness: mono so a
   * 0 cannot be an O, wide tracking so the eye can hold its place, and centred because the string
   * is a number rather than a sentence. `login.js` gives it only `.form-control`, so it is
   * addressed by the id it is looked up by.
   */
  #loginCode {
    min-height: 3.5rem;
    font-family: var(--font-mono);
    font-size: var(--t-2xl);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.3em;
    text-align: center;
    text-indent: 0.3em;
  }

  /* ==========================================================================================
     Scrollbars
     ========================================================================================== */

  /**
   * The portal draws its own.
   *
   * Above 1024px the panel area owns the only scrollbar on the page, so it is a permanent part of
   * the interface rather than something the operating system paints over the top of it — and a
   * chrome-grey bar down the edge of a plum-and-paper application is the one piece of furniture
   * that would still look borrowed.
   *
   * Two mechanisms, because there is no single standard. Firefox reads `scrollbar-width` and
   * `scrollbar-color` and cannot be styled further; WebKit and Blink ignore both and use the
   * pseudo-elements. Neither is a fallback for the other, so both are stated and they are kept to
   * the same two values.
   *
   * Scoped to the portal deliberately. The public pages scroll the document, and replacing the
   * browser's own scrollbar on a page somebody is reading is a cost with no benefit — theirs is
   * the one they can already aim at.
   */
  .page--portal,
  .page--portal .app__main,
  .page--portal .rail,
  .page--portal .table-container,
  .page--portal .dash-modal__body {
    scrollbar-width: thin;
    scrollbar-color: var(--scrollbar-thumb) transparent;
  }

  .page--portal .app__main::-webkit-scrollbar,
  .page--portal .rail::-webkit-scrollbar,
  .page--portal .table-container::-webkit-scrollbar,
  .page--portal .dash-modal__body::-webkit-scrollbar {
    width: 12px;
    height: 12px;
  }

  /* No track at all. A tinted channel down the side reads as a border on the panel, and the panel
     already has one. */
  .page--portal .app__main::-webkit-scrollbar-track,
  .page--portal .rail::-webkit-scrollbar-track,
  .page--portal .table-container::-webkit-scrollbar-track,
  .page--portal .dash-modal__body::-webkit-scrollbar-track {
    background: transparent;
  }

  /**
   * The thumb is inset by a transparent border rather than by being made narrow.
   *
   * `background-clip: content-box` paints only inside that border, so the visible bar is 6px while
   * the element the pointer has to hit stays 12px. Making the bar itself 6px would halve the
   * target, and this is a control somebody grabs while holding a phone in the other hand.
   */
  .page--portal .app__main::-webkit-scrollbar-thumb,
  .page--portal .rail::-webkit-scrollbar-thumb,
  .page--portal .table-container::-webkit-scrollbar-thumb,
  .page--portal .dash-modal__body::-webkit-scrollbar-thumb {
    border: 3px solid transparent;
    border-radius: var(--r-pill);
    background-color: var(--scrollbar-thumb);
    background-clip: content-box;
    transition: background-color var(--dur-1) var(--ease);
  }

  .page--portal .app__main::-webkit-scrollbar-thumb:hover,
  .page--portal .rail::-webkit-scrollbar-thumb:hover,
  .page--portal .table-container::-webkit-scrollbar-thumb:hover,
  .page--portal .dash-modal__body::-webkit-scrollbar-thumb:hover {
    background-color: var(--accent);
  }

  .page--portal ::-webkit-scrollbar-corner {
    background: transparent;
  }

  /* The rail's bar is quieter than the panel's: it appears only on a short window, and it is 264px
     from the thing being read. */
  .page--portal .rail {
    scrollbar-color: var(--scrollbar-thumb-quiet) transparent;
  }

  .page--portal .rail::-webkit-scrollbar-thumb {
    background-color: var(--scrollbar-thumb-quiet);
  }

  /* ==========================================================================================
     Member cards — the panel, and the sheet it prints
     ========================================================================================== */

  /* ==========================================================================================
     Member cards and Member accounts — the roster tables that carry a select-all
     ========================================================================================== */

  /**
   * The select-all column, shared by the two panels that batch actions over a roster.
   *
   * Sized to its control so the columns that carry information get the rest of the table. Both
   * `cards.js` and `admin.js` build their table themselves rather than through `widgets.js`'s
   * `table()`, because a control has to go in a column *heading* and that helper takes headings
   * as strings. The two are grouped here rather than each carrying a copy, so a change to the
   * select-all — which is one interaction wearing two names — cannot land in one panel only.
   */
  .cards-table__select,
  .accounts-table__select {
    width: 1px;
    white-space: nowrap;
  }

  .cards-table__select input,
  .accounts-table__select input {
    width: 1.25rem;
    height: 1.25rem;
    margin: 0;
    accent-color: var(--accent);
  }

  /**
   * Below 768px `ui.css` hides `thead` outright and stacks each row into labelled pairs — which
   * would take the select-all with it, on exactly the devices where ticking forty rows by hand is
   * worst. So the heading row comes back as a single control above the stack, and only the one
   * cell that carries the checkbox is shown. `::after` gives it the visible wording that the
   * column heading used to; the checkbox's own `aria-label` is what a screen reader reads, and it
   * already names the count.
   */
  @media (max-width: 767px) {
    .cards-table thead,
    .accounts-table thead {
      display: block;
    }

    .cards-table thead tr,
    .accounts-table thead tr {
      display: block;
    }

    .cards-table thead th:not(.cards-table__select),
    .accounts-table thead th:not(.accounts-table__select) {
      display: none;
    }

    .cards-table thead .cards-table__select,
    .accounts-table thead .accounts-table__select {
      display: flex;
      align-items: center;
      gap: var(--s3);
      width: auto;
      min-height: 2.75rem;
      margin-bottom: var(--s3);
      padding: var(--s3) var(--s4);
      border: 1px solid var(--line);
      border-radius: var(--r3);
      background: var(--bg-raise);
    }

    .cards-table thead .cards-table__select::after,
    .accounts-table thead .accounts-table__select::after {
      content: 'Select all shown';
    }
  }

  /**
   * The name is the one affordance on an accounts row.
   *
   * A real `<button>`, because it opens a dialog rather than navigating — and because everything
   * a button already gets, the press compression in motion.css and the focus ring, comes free.
   * It is drawn as the row's text rather than as a control: the name is what an administrator is
   * already reading when they decide to act on an account, so a separate Edit column would be a
   * second thing to find in every row and would say nothing the name does not. The underline
   * arrives on hover and focus, which is where the affordance has to be legible.
   */
  /* `.roster-table__open` is the same interaction on the members panel, opening a profile
     rather than an editor. Grouped rather than copied, so the treatment cannot drift between
     two tables that a member reads as the same thing. */
  .accounts-table__open,
  .roster-table__open {
    max-width: 100%;
    padding: 0;
    color: var(--fg);
    font: inherit;
    font-weight: 600;
    text-align: left;
    overflow-wrap: break-word;
  }

  .accounts-table__open:hover,
  .roster-table__open:hover {
    color: var(--accent);
    text-decoration: underline;
    text-underline-offset: 0.2em;
  }

  /**
   * The batch summary, restated under the accounts table.
   *
   * The bulk buttons are in the panel header and a long roster scrolls them off the top, which
   * is where a count of what is about to change matters. The breakdown (active/suspended) is
   * what the buttons need to know, and restating it here keeps the two aligned.
   */
  .accounts-selection {
    margin-top: var(--s4);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
  }

  /**
   * The created-at line in the editor.
   *
   * A quiet timestamp under the form, not a field. It answers "when was this account added",
   * which is a question an administrator asks when investigating a problem, and putting it in
   * the grid would make it look like something editable.
   */
  .edit-user-form__meta {
    margin-top: var(--s4);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
  }

  /**
   * The print-backgrounds warning.
   *
   * Warning-toned rather than a plain hint, and it is the one piece of chrome in this panel that
   * is always on screen. A browser printing with background graphics off drops the teal finder
   * patterns to white, and a symbol with no finders cannot be located by a decoder at all — the
   * sheet looks correct, the cards are cut and handed out, and it is discovered at a door. There
   * is no way to detect the setting from script, so it is stated where the button is.
   */
  .cards-print-note {
    display: flex;
    align-items: flex-start;
    gap: var(--s3);
    margin-bottom: var(--s5);
    padding: var(--s3) var(--s4);
    border: 1px solid var(--warn);
    border-radius: var(--r2);
    background: var(--warn-wash);
  }

  .cards-print-note::before {
    flex: 0 0 auto;
    color: var(--warn);
    content: '!';
    font-family: var(--font-mono);
    font-size: var(--t-base);
    font-weight: 700;
    line-height: var(--lh-normal);
  }

  .cards-print-note__text {
    color: var(--fg);
    font-size: var(--t-sm);
    line-height: var(--lh-normal);
  }

  /* The batch, restated under the table: the buttons are in the panel header and a long roster
     scrolls them off the top, which is where a count of what is about to be printed matters. */
  .cards-selection {
    margin-top: var(--s4);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
  }

  /* ==========================================================================================
     The social layer — upvotes, comment threads, member profiles
     ========================================================================================== */

  /**
   * The like toggle, and the comment toggle beside it.
   *
   * One rule for both, because they are the two things any member can do with a card and
   * drawing them as a monospace data chip beside an underlined hyperlink made a row of two
   * controls look like a row of two unrelated objects. They are buttons, so they are set in
   * the sans face at the button's own weight.
   *
   * The height comes from `--btn-h` rather than a literal, which is the whole point of
   * restating it here: `.btn-compact` steps up to 2.75rem under a coarse pointer, and the
   * previous hardcoded 2.25rem left the like pill 8px shorter than the button next to it on
   * every phone.
   *
   * `tabular-nums` on the count fixes the digit width, so 9 -> 10 grows by exactly one column
   * rather than reflowing the row.
   */
  .upvote-btn,
  .comment-toggle {
    --btn-h: 2.25rem;
    display: inline-flex;
    align-items: center;
    gap: var(--s2);
    min-height: var(--btn-h);
    padding-inline: var(--s4);
    border: 1px solid var(--line-strong);
    border-radius: var(--r-pill);
    background: transparent;
    color: var(--fg-muted);
    font-family: var(--font-sans);
    font-size: var(--t-sm);
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    line-height: 1.15;
    cursor: pointer;
    transition:
      background-color var(--dur-1) var(--ease),
      border-color var(--dur-1) var(--ease),
      color var(--dur-1) var(--ease),
      transform var(--dur-1) var(--ease);
  }

  /* The 44px floor under a thumb, matching `.btn-compact` exactly. */
  @media (pointer: coarse) {
    .upvote-btn,
    .comment-toggle {
      --btn-h: 2.75rem;
    }
  }

  .upvote-btn:hover:not([disabled]),
  .comment-toggle:hover:not([disabled]) {
    border-color: var(--accent-line);
    background: var(--accent-wash);
    color: var(--accent);
  }

  /* The same press the buttons have. One press gesture in the application, not two. */
  .upvote-btn:active:not([disabled]),
  .comment-toggle:active:not([disabled]) {
    transform: translateY(1px);
  }

  /* Liked. The filled heart in the markup carries this without colour, so the state survives
     a monochrome display and WCAG 1.4.1. */
  .upvote-btn[aria-pressed='true'] {
    border-color: var(--accent-line);
    background: var(--accent-wash);
    color: var(--accent);
  }

  /* Offline. Dimmed and not-allowed rather than hidden: a control that vanishes reads as a
     bug, and the `title` on the button says why it cannot be used. */
  .upvote-btn[disabled],
  .comment-toggle[disabled] {
    cursor: not-allowed;
    opacity: 0.48;
    transform: none;
  }

  .upvote-btn__icon {
    font-size: var(--t-base);
    line-height: 1;
  }

  /**
   * The row of member actions under a card.
   *
   * `space-between` with the member controls grouped in their own wrapper and the officer
   * control pushed to the far edge. Adjacency is what made a Remove read as one of a member's
   * options: Like and Remove were the same size, the same distance apart as two related
   * buttons, and the destructive one was the only one filled.
   *
   * Below 26rem the two groups stack and the officer control returns to the left edge, because
   * a right-aligned button under a left-aligned pair is a ragged column on a 320px screen.
   */
  .event-item__actions,
  .post__actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--s3);
    margin-top: var(--s4);
  }

  .event-item__actions-group,
  .post__actions-group {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s3);
  }

  @media (max-width: 26rem) {
    .event-item__actions,
    .post__actions {
      justify-content: flex-start;
    }
  }

  /**
   * The thread under an open event.
   *
   * Separated from the event by a rule and a step of ground rather than by a nested card. A
   * card inside a card is two borders describing one boundary, and on a phone the inner one
   * costs the text its width.
   */
  .comment-thread-block {
    margin-top: var(--s4);
    padding-top: var(--s4);
    border-top: 1px solid var(--line);
  }

  .comment-thread {
    display: flex;
    flex-direction: column;
    gap: var(--s4);
  }

  .comment-thread__empty {
    color: var(--fg-muted);
    font-size: var(--t-sm);
  }

  /**
   * One comment: initials in a disc, then the text.
   *
   * A two-column grid rather than a float, so a long unbroken word in the body cannot ride up
   * beside the avatar. `min-width: 0` on the body is what lets `overflow-wrap` in the text
   * actually take effect — a grid item's default `auto` minimum would otherwise push the
   * column wider than the panel.
   */
  .comment-item {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--s3);
    align-items: start;
  }

  /* A reply is indented and marked with a rule down its inline edge. One level only, because
     the server refuses a reply to a reply — so there is no depth for this to scale to. */
  .comment-item--reply {
    margin-inline-start: var(--s5);
    padding-inline-start: var(--s4);
    border-inline-start: 2px solid var(--line);
  }

  .comment-avatar {
    display: grid;
    place-items: center;
    width: 2rem;
    height: 2rem;
    border-radius: var(--r-pill);
    background: var(--accent-wash);
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
  }

  .comment-avatar__image,
  .profile-avatar__image {
    display: block;
    width: 100%;
    height: 100%;
    border-radius: inherit;
    object-fit: cover;
  }

  .comment-body {
    min-width: 0;
  }

  .comment-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--s2) var(--s3);
  }

  .comment-author {
    color: var(--fg);
    font-size: var(--t-sm);
    font-weight: 600;
  }

  .comment-time {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
  }

  /* `pre-wrap` for the same reason `.post__body` has it: the body is stored as the member
     typed it, and their paragraph breaks are part of what they wrote. */
  .comment-text {
    margin-top: var(--s1);
    color: var(--fg);
    font-size: var(--t-sm);
    line-height: var(--lh-normal);
    white-space: pre-wrap;
    overflow-wrap: break-word;
  }

  .comment-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s3);
    margin-top: var(--s2);
  }

  .comment-form {
    margin-top: var(--s4);
  }

  .comment-form--reply {
    margin-top: var(--s3);
  }

  /**
   * A comment that arrived in this session.
   *
   * A brief accent wash, so a member who has just posted can find their own line in a long
   * thread. It lives with the component rather than in motion.css because it is this
   * component's entry state and nothing else uses it, and it is inside a
   * `prefers-reduced-motion` guard — a member who has asked for less movement gets the
   * comment with no highlight, which loses nothing they need.
   */
  @media (prefers-reduced-motion: no-preference) {
    .comment-item.is-new {
      animation: comment-in var(--dur-1) var(--ease);
    }

    @keyframes comment-in {
      from {
        background: var(--accent-wash);
      }
      to {
        background: transparent;
      }
    }
  }

  /* ==========================================================================================
     The member profile dialog
     ========================================================================================== */

  /* The margin is the fix for a real collision: with nothing here, the role pill sat about 22px
     from the STUDENT NUMBER term below it and the two read as one block. */
  .profile-head {
    display: flex;
    align-items: center;
    gap: var(--s4);
    margin-bottom: var(--s5);
  }

  .profile-avatar {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    width: 3rem;
    height: 3rem;
    border-radius: var(--r-pill);
    background: var(--accent-wash);
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: var(--t-base);
    font-weight: 700;
  }

  .profile-name {
    color: var(--fg);
    font-size: var(--t-base);
    font-weight: 600;
  }

  .profile-pills {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s2);
    margin-top: var(--s2);
  }

  /**
   * The three attendance tiles.
   *
   * `auto-fit` with a floor rather than a fixed three columns: at 320px the tiles stack, and
   * "Events attended" is the one a member opened the dialog for so it must not be squeezed to
   * two characters to keep a row intact.
   */
  .profile-stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
    gap: var(--s3);
    margin-top: var(--s5);
  }

  /**
   * One column on a narrow dialog, rather than two-then-one.
   *
   * `auto-fit` with an 8rem floor fits two tiles in the modal at phone widths and sends the
   * third to a row of its own, where it sat half-clipped against the dialog's scroll edge — a
   * lone wide tile under a pair, which reads as a layout that broke. Three full-width rows is
   * plainer and says the same thing.
   */
  @media (max-width: 26rem) {
    .profile-stats {
      grid-template-columns: minmax(0, 1fr);
    }
  }

  /* ===================================================================================
     A birthday, said once
     =================================================================================== */

  /**
   * The greeting that closes the overview's heading.
   *
   * ## Why it has no box
   *
   * It had one: a 1px border, a rounded corner and an `--accent-wash` tint. The comment here used
   * to open by insisting this was "not a `.dash-notice`", which was true of the class name and
   * false of everything a reader actually sees — `.dash-notice` *is* a 1px border, a rounded
   * corner and a tint, with a glyph in a left gutter. On this dashboard that pairing has exactly
   * one meaning, learned from every error, success and offline message: the system is telling you
   * something. Spending it on a congratulation put the warmest line on the panel into the slot
   * reserved for the coldest — and made it the only full-bleed band on a page that is otherwise a
   * grid of cards, which is the part that read as wrong before anyone worked out why.
   *
   * So the box is gone rather than restyled, and nothing replaced it. A left accent rule in
   * particular was not the answer: `.dash-notice` already owns `border-left-width: 3px`, so a rule
   * would have walked straight back into the vocabulary this is trying to leave. What is left is a
   * line of prose inside the heading, which is what the greeting always was.
   *
   * ## What carries it instead
   *
   * Three things, all typographic. It sits at `--fg` while `.dash-subtitle` directly above sits at
   * `--fg-muted`, so it is the brightest line in the block without being the largest. The names
   * are the only bold text in it, so the people are what the eye catches in a sentence that is
   * otherwise mostly grammar. And `--maxw-prose` gives it a measure — the same one the subtitle
   * uses — because a sentence allowed to run the full width of a dashboard reads as a banner no
   * matter what colour it is.
   *
   * A grid rather than a flex row, so a greeting long enough to wrap — three names with titles
   * does it — hangs its second line under the text and not under the cake.
   */
  .birthday-greeting {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: baseline;
    gap: var(--s2);
    max-width: var(--maxw-prose);
    margin-top: var(--s3);
  }

  /**
   * The cake: the one piece of decoration on the panel.
   *
   * A `::before` rather than a span from JavaScript, because a mark that never varies with state is
   * decoration — `overview.js` builds no glyph and says so.
   *
   * The `/ ''` is what makes that reasoning true rather than merely intended. Pseudo-element
   * content does reach the accessibility tree, so before this the emoji was announced mid-sentence
   * as "birthday cake"; the alt-text form gives it an empty accessible name instead. Declared
   * twice on purpose — a browser without support for the alt form keeps the first declaration and
   * still draws a cake, rather than discarding an invalid one and drawing nothing.
   */
  .birthday-greeting::before {
    content: '🎂';
    content: '🎂' / '';
    /* Sized off the text rather than fixed, so it keeps its proportion when the reader scales
       the page — a fixed rem here leaves a tiny cake beside 2x text. */
    font-size: 1.25em;
    line-height: 1;
  }

  .birthday-greeting__text {
    margin: 0;
    color: var(--fg);
    font-size: var(--t-base);
    /* Wraps to a second line rather than scrolling: with several birthdays this is a sentence,
       not a label, and it is the one place in the panel where a long line is expected. */
    line-height: 1.55;
    text-wrap: pretty;
  }

  /**
   * The name — the only bold text in the sentence, so the people are what the eye lands on.
   */
  .birthday-greeting__name {
    font-weight: 600;
  }

  /**
   * The job title, in parentheses after a name.
   *
   * Quieter than the name it follows and never on a line of its own. The title is context for who
   * is being congratulated; the name is the point.
   *
   * `--t-sm` rather than `--t-xs`: one step below the body size reads as an aside, two steps reads
   * as fine print, and at 12px against 15px text it looked like a markup error rather than a
   * choice. The parentheses stay — they bind the title to the name in front of it, and they are
   * the pause a screen reader gets, which no amount of type treatment would give it.
   */
  .birthday-greeting__title {
    color: var(--fg-muted);
    font-size: var(--t-sm);
  }

  /* ===================================================================================
     DONAT Blaster's cabinet

     One game now, and this block is deliberately still generic. It began as five prefixed
     near-duplicates — `munchkin-*`, `catch-*`, `blaster-*`, `meadow-*` and `beat-*` — each declaring
     its own HUD strip, stat cell, stage, round on-glass button and phone breakpoint, byte-identical
     apart from the name, plus a legend card written three times and a mobile media query written
     four. Nine hundred lines to say one thing five times, and nothing enforcing that the five stayed
     in step, so they drifted.

     Collapsing them into one `.arcade-*` vocabulary is what then made removing four games a
     two-line change each rather than a hunt through a stylesheet. That is why what remains is not
     re-specialised down to `.blaster-*`: the cost of the general form has already been paid, and the
     generality is what keeps a per-game rule from creeping back. If you find yourself adding one,
     that is the regression this replaced.

     Blaster signs its cabinet cyan — the Prober branch's sprinkle — via `data-hue` on the root, which
     resolves `--arcade-hue`. It is the only stated difference between one cabinet and another, along
     with `.arcade--tall` for its 4/5 portrait stage.

     The five `.arcade-buff--*` tones are all live: Blaster has five power-ups and each one names its
     own colour, because a pill's colour describes the *effect* rather than the cabinet — a shield is
     cyan regardless of whose game it is picked up in.

     ## Where the boldness is spent

     Almost nothing here is loud. The hue appears in exactly four places — a hairline along the top
     of the HUD strip, the lip under the stage, the primary on-glass key, and the matching buff pill
     — and the rest of the chrome is the same quiet raised-surface-and-hairline treatment every other
     panel in the portal uses. That restraint is deliberate: the bold element is inside the glass,
     drawn on the canvas, where `arcade.js` reuses the site's `.aperture` annulus as the games'
     readout. Chrome that competed with it would leave the page with two centres and no focus.

     ## The stage is a lit screen, not a surface of the page

     `.arcade__stage` is `--ink-900` in both colour schemes rather than following `--bg`. A game is a
     screen inside the document, the way a cabinet's glass is dark in a lit arcade, and a canvas that
     went cream on the paper theme would have to redraw all its own art to suit. The hard 4px lip
     under it is the one piece of physicality in the chrome — it makes the stage read as a panel set
     into the page rather than an image pasted on it.
     =================================================================================== */

  .arcade {
    /* The fallback matters: a game that forgets its `data-hue`, or a new one added before this list
       is updated, gets the brand's glaze rather than an unstyled `initial` that would fail every
       `color-mix()` below and take the borders with it. */
    --arcade-hue: var(--glaze);
    min-width: 0;
    max-width: 74rem;
    margin-inline: auto;
  }

  .arcade[data-hue='butter'] {
    --arcade-hue: var(--spr-butter);
  }

  .arcade[data-hue='crust'] {
    --arcade-hue: var(--crust);
  }

  .arcade[data-hue='cyan'] {
    --arcade-hue: var(--spr-cyan);
  }

  .arcade[data-hue='mint'] {
    --arcade-hue: var(--spr-mint);
  }

  .arcade[data-hue='glaze'] {
    --arcade-hue: var(--glaze);
  }

  /* -----------------------------------------------------------------------------------
     Topline — the readouts and the controls
     ----------------------------------------------------------------------------------- */

  .arcade__topline {
    display: flex;
    align-items: center;
    gap: var(--s4);
    margin-bottom: var(--s3);
  }

  /**
   * The HUD strip.
   *
   * `grid-auto-flow: column` with equal auto-columns rather than a fixed `repeat(4, …)`, because
   * three of the games show four readouts and two show three — and a fixed count is precisely what
   * forced the two lighter games to declare their own copy of this rule with `repeat(3, …)`. Now the
   * strip fits whatever a game hands it.
   *
   * The strip sits on the same ink as the stage and its bezel, not on the dashboard's raised
   * surface. A score set on `--bg-raise` reads as one more widget in the portal; set on `--ink-900`
   * under the same hard lip as the stage it reads as the cabinet's own readout — the numbers belong
   * to the machine, not to the page around it.
   *
   * The hue is painted as a 2px rule along the top by a background image rather than a border or a
   * pseudo-element: a border-top colour change would fight the 1px hairline that frames the strip,
   * and a pseudo-element would need a positioning context this does not otherwise want.
   */
  .arcade__hud {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    border: 1px solid var(--line-strong);
    border-radius: var(--r2);
    background-color: var(--ink-900);
    background-image: linear-gradient(var(--arcade-hue), var(--arcade-hue));
    background-repeat: no-repeat;
    background-size: 100% 2px;
    box-shadow: 0 3px 0 var(--line-strong);
    color: var(--highlight);
  }

  .arcade-stat {
    display: grid;
    gap: var(--s1);
    min-width: 0;
    padding: var(--s2) var(--s3);
  }

  .arcade-stat + .arcade-stat {
    border-left: 1px solid var(--ink-700);
  }

  .arcade-stat__label {
    color: rgb(255 243 208 / 0.55);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--track-label);
    line-height: 1.2;
    text-transform: uppercase;
  }

  .arcade-stat__value {
    overflow: hidden;
    color: var(--highlight);
    font-family: var(--font-display);
    font-size: var(--t-md);
    font-variant-numeric: tabular-nums;
    font-weight: 800;
    line-height: 1;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /**
   * A tally rather than a number — hearts, hull, lives.
   *
   * Mono and a size down. `● ● ○ ○` set in display weight at the score's size is wider than the
   * column it sits in, and on a phone it pushed the score cell off its own centre.
   */
  .arcade-stat__value--tally {
    color: var(--arcade-hue);
    font-family: var(--font-mono);
    font-size: var(--t-base);
    font-weight: 700;
    letter-spacing: 0.08em;
  }

  .arcade__actions {
    display: flex;
    flex: 0 0 auto;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2);
  }

  /* Every control in the row is a touch target on the shared club phone, so they share one floor
     rather than each game setting its own. 3rem is the same minimum the portal's forms use. */
  .arcade__actions > * {
    min-height: 3rem;
    white-space: nowrap;
  }

  .game-fullscreen-toggle {
    flex: 0 0 auto;
    min-height: 3rem;
    padding-inline: var(--s3);
    white-space: nowrap;
  }

  /* -----------------------------------------------------------------------------------
     Active effects and the fleet — one row, two moments
     ----------------------------------------------------------------------------------- */

  /**
   * The mid row holds the buff pills and the hull strip together.
   *
   * They are the same slot at different moments: the pills speak during a flight, the hull chips
   * speak on the ground, and neither has anything to say while the other is talking. Giving each
   * its own reserved row stacked two empty lines above a portrait stage — which is what the
   * previous layout did — is how a cabinet grows a chimney. One row, one reserved height, and the
   * fleet pulls right while the pills flow left.
   */
  .arcade__mid {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2) var(--s3);
    min-height: 2.75rem;
    margin-bottom: var(--s3);
  }

  .arcade__mid .arcade__buffs,
  .arcade__mid .arcade__select {
    margin-bottom: 0;
  }

  .arcade__mid .arcade__buffs {
    flex: 1 1 auto;
    min-height: 0;
  }

  .arcade__mid .arcade__select {
    margin-left: auto;
  }

  /**
   * The buff row keeps its height when it is empty.
   *
   * A row that appears only while a power-up is live pushes the stage down at the exact moment the
   * player most needs the picture to hold still — they have just picked something up and are watching
   * for what it did. So the space is always reserved and only the pills come and go.
   */
  .arcade__buffs {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2);
    min-height: 1.75rem;
  }

  .arcade-buff {
    display: inline-flex;
    align-items: center;
    padding: 0.2rem 0.55rem;
    border: 1px solid var(--line);
    border-radius: var(--r-pill);
    background: var(--bg-raise);
    color: var(--fg);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
  }

  /* Named rather than resolved from `--arcade-hue`, because a pill's colour says which *effect* is
     live, not which game it is. A game signs its cabinet in one hue and can still tint a shield pill
     cyan and a fever pill crust. */
  .arcade-buff--butter {
    border-color: var(--spr-butter);
    color: var(--spr-butter);
  }

  .arcade-buff--crust {
    border-color: var(--crust);
    color: var(--crust);
  }

  .arcade-buff--cyan {
    border-color: var(--spr-cyan);
    color: var(--spr-cyan);
  }

  .arcade-buff--mint {
    border-color: var(--spr-mint);
    color: var(--spr-mint);
  }

  .arcade-buff--glaze {
    border-color: var(--glaze);
    color: var(--glaze);
  }

  /* -----------------------------------------------------------------------------------
     Ship select — the pre-game tab strip
     ----------------------------------------------------------------------------------- */

  /**
   * The hull picker, as one joined strip rather than three loose pills.
   *
   * The first cut put a free-floating chip next to the other two, which read as three suggestions
   * rather than one choice — and a choice is what this is: exactly one hull flies, so the control
   * is one body with three positions, the way a tab set works. It sits between the buff row and
   * the stage because it is a control about the *next* flight, in reach of the thumb that will
   * press launch.
   */
  .arcade__select {
    display: inline-flex;
    align-items: stretch;
    margin-bottom: var(--s3);
    border: 1px solid var(--line-strong);
    border-radius: var(--r-pill);
    background: var(--bg-raise);
    overflow: hidden;
  }

  /**
   * One position in the strip. Inactive positions are muted and carry no colour — the branch
   * colour arrives only on the position that is actually flying, so the strip says "these three,
   * this one" in one glance, in greyscale as well as in colour.
   */
  .arcade-ship {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    min-height: 2.75rem;
    padding: 0 1.1rem;
    border: 0;
    background: transparent;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    cursor: pointer;
  }

  .arcade-ship + .arcade-ship {
    border-left: 1px solid var(--line);
  }

  .arcade-ship:hover:not(:disabled):not([aria-pressed='true']) {
    color: var(--fg);
  }

  .arcade-ship:focus-visible {
    outline: 2px solid var(--arcade-hue);
    outline-offset: -2px;
  }

  .arcade-ship--butter[aria-pressed='true'] {
    color: var(--spr-butter);
  }

  .arcade-ship--cyan[aria-pressed='true'] {
    color: var(--spr-cyan);
  }

  .arcade-ship--mint[aria-pressed='true'] {
    color: var(--spr-mint);
  }

  /* The flying hull takes its colour into a bar across the foot of its position — legible in
     greyscale, where a tinted wash alone would not be. */
  .arcade-ship[aria-pressed='true'] {
    box-shadow: inset 0 -3px 0 currentColor;
  }

  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .arcade-ship[aria-pressed='true'] {
      background: color-mix(in srgb, currentColor 12%, transparent);
    }
  }

  /* Locked for the length of a flight — a hull is a decision made on the ground. */
  .arcade-ship:disabled {
    opacity: 0.45;
    cursor: default;
  }

  /* -----------------------------------------------------------------------------------
     The stage — a lit screen set into the page
     ----------------------------------------------------------------------------------- */

  /**
   * The bezel.
   *
   * `--ink-900` in both colour schemes rather than following `--bg`: a game is a screen inside the
   * document, and a canvas that went cream on the paper theme would have to redraw all of its own
   * art to suit. The hard 4px lip is the one piece of physicality in the whole chrome — no blur, no
   * spread, just an offset block, which is what makes it read as a panel set into the page rather
   * than a picture pasted onto it.
   *
   * The inset hairline is the inside edge of the bezel catching the light. It is `--highlight` at
   * four percent, which is under the threshold where it would read as a border in its own right.
   */
  .arcade__stage {
    position: relative;
    width: 100%;
    overflow: hidden;
    border: 1px solid var(--line-strong);
    border-radius: var(--r2);
    background: var(--ink-900);
    box-shadow:
      inset 0 0 0 1px rgb(255 243 208 / 0.045),
      0 4px 0 var(--line-strong);
  }

  /* Where `color-mix()` is available the lip takes the cabinet's own colour, which is the fourth and
     last place the hue appears. Declared in `@supports` with the plain version above it so a browser
     without it keeps a lip rather than losing the shadow to an invalid declaration. */
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .arcade__stage {
      box-shadow:
        inset 0 0 0 1px rgb(255 243 208 / 0.045),
        0 4px 0 color-mix(in srgb, var(--arcade-hue) 32%, var(--ink-700));
    }
  }

  /**
   * A portrait stage is capped, a landscape one is not.
   *
   * DONAT Blaster is played up the screen, and an uncapped 4/5 box on a desktop monitor becomes a
   * two-metre column that no thumb and no eye can follow. 30rem is roughly a phone held at arm's
   * length, which is the shape the game was designed for.
   */
  .arcade--tall .arcade__stage {
    max-width: 30rem;
    margin-inline: auto;
  }

  .arcade__canvas {
    display: block;
    width: 100%;
    height: auto;
    touch-action: none;
  }

  /* The aspect is set by the ratio class rather than on `.arcade__canvas` itself, so a second game at
     a different shape stays a one-word change at the call site. Only `--tall` has a game right now. */
  .arcade--tall .arcade__canvas {
    aspect-ratio: 4 / 5;
  }

  .arcade__canvas:focus-visible {
    outline: 3px solid var(--ring);
    /* Inset, because an outline drawn outside the canvas is clipped by the bezel's `overflow:
       hidden` and a keyboard user would get no focus ring at all on the one element that takes
       the arrow keys. */
    outline-offset: -4px;
  }

  /* At a desk there is room to set a legend beside a portrait stage instead of under it — but
     only when the game actually shipped one. Blaster dropped its guide, and a two-column grid
     with an empty second column is a portrait stage squeezed for nothing, so the layout is
     conditional on the guide's presence. */
  @media (min-width: 64rem) {
    .arcade--tall:has(.arcade__guide) {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.72fr);
      column-gap: var(--s6);
      align-items: start;
    }

    .arcade--tall .arcade__topline,
    .arcade--tall .arcade__mid {
      grid-column: 1 / -1;
    }

    .arcade--tall .arcade__guide {
      grid-column: 2;
      margin-top: 0;
    }
  }

  /* -----------------------------------------------------------------------------------
     On-glass controls
     ----------------------------------------------------------------------------------- */

  /**
   * The controls sit on the picture, not under it.
   *
   * A thumb reaching for a button below the stage covers the stage. So they float over the bottom
   * corner — and the cluster itself is `pointer-events: none` with only the keys taking input, so a
   * drag that starts on empty glass still reaches the canvas and steers. Without that, the strip
   * above the buttons became a dead zone in the two games that are played by dragging.
   *
   * Right-aligned rather than spread, because every one of these games is played one-handed on a
   * phone, and a button on the far left of a portrait screen is a two-hand control.
   */
  .arcade__pad {
    position: absolute;
    right: max(var(--s4), env(safe-area-inset-right, 0px));
    bottom: var(--s4);
    display: flex;
    align-items: center;
    gap: var(--s2);
    pointer-events: none;
  }

  .arcade__key {
    pointer-events: auto;
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    padding: 0;
    border: 2px solid rgb(255 243 208 / 0.72);
    border-radius: 50%;
    background: rgb(16 11 22 / 0.82);
    color: var(--highlight);
    box-shadow: 0 4px 0 rgb(10 7 16 / 0.8);
    font-family: var(--font-sans);
    font-size: 1.5rem;
    font-weight: 700;
    line-height: 1;
    touch-action: none;
  }

  /* The primary key is the one the game is played with, so it is the one that carries the hue. */
  .arcade__key--primary {
    border-color: var(--arcade-hue);
    color: var(--arcade-hue);
  }

  .arcade__key--secondary {
    border-color: rgb(255 243 208 / 0.5);
    color: rgb(255 243 208 / 0.86);
  }

  .arcade__key:hover {
    border-color: var(--highlight);
    background: var(--ink-700);
  }

  /* The lip compresses rather than the button moving away from the finger. */
  .arcade__key:active {
    transform: translateY(2px);
    box-shadow: 0 2px 0 rgb(10 7 16 / 0.8);
  }

  .arcade__key:focus-visible {
    outline: 3px solid var(--highlight);
    outline-offset: 3px;
  }

  /* -----------------------------------------------------------------------------------
     The legend
     ----------------------------------------------------------------------------------- */

  /**
   * What the pickups do.
   *
   * Deliberately not dressed as `.dash-notice`. In this dashboard a 1px border plus a tinted wash
   * plus a rounded rectangle is reserved vocabulary meaning "the system is telling you something",
   * and members learn it from every error and offline message they see. A list of what the glaze
   * drops are worth is not a warning, so it gets the neutral raised surface and a mono eyebrow —
   * the same treatment every other reference block in the portal uses.
   */
  .arcade__guide {
    margin-top: var(--s5);
    padding: var(--s4) var(--s5);
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--bg-raise);
  }

  .arcade__guide-title {
    display: block;
    margin: 0 0 var(--s3);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .arcade__guide-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
    gap: var(--s3);
  }

  .arcade-guide-item {
    display: flex;
    align-items: flex-start;
    gap: var(--s3);
    padding: var(--s2) var(--s3);
    border: 1px solid var(--line-quiet);
    border-radius: var(--r1);
    background: var(--bg-inset);
  }

  .arcade-guide-item__icon {
    flex: 0 0 auto;
    font-size: 1.4rem;
    line-height: 1;
  }

  .arcade-guide-item__body {
    display: grid;
    gap: 0.15rem;
    min-width: 0;
  }

  .arcade-guide-item__name {
    color: var(--fg);
    font-family: var(--font-sans);
    font-size: var(--t-sm);
    font-weight: 700;
    line-height: 1.2;
  }

  .arcade-guide-item__desc {
    margin: 0;
    color: var(--fg-muted);
    font-size: var(--t-xs);
    line-height: 1.35;
  }

  /* -----------------------------------------------------------------------------------
     Fullscreen

     `game-fullscreen.js` targets these class names: it requests the Fullscreen API on `.game-shell`
     and, when that is refused — which is what happens inside a cross-origin frame and on iOS Safari
     for a non-video element — falls back to toggling `.game-shell--expanded` and
     `.game-fullscreen-open`. Both paths have to land somewhere, so both are styled.
     ----------------------------------------------------------------------------------- */

  .game-shell:fullscreen,
  .game-shell--expanded {
    box-sizing: border-box;
    width: 100vw;
    height: 100vh;
    max-width: none;
    margin: 0;
    padding: clamp(0.75rem, 2vw, 1.5rem);
    /* A column, so the stage takes whatever height the chrome leaves — and `hidden`, because a
       fullscreen game that scrolls is a game half off the screen. The old width formula kept a
       hardcoded reserve for the rows above the stage, and every row added above it (the fleet
       strip was the second) silently pushed the pad keys back under the fold. */
    display: flex;
    flex-direction: column;
    gap: var(--s3);
    overflow: hidden;
    background: var(--bg);
  }

  /* The fallback has to leave the document flow itself, which `:fullscreen` gets for free. */
  .game-shell--expanded {
    position: fixed;
    z-index: 1000;
    inset: 0;
  }

  .game-fullscreen-open {
    overflow: hidden;
  }

  /* The legend is reference material for someone deciding how to play. Fullscreen is for someone
     already playing, so it goes — and its absence is what gives the stage the extra height. */
  .game-shell:fullscreen .arcade__guide,
  .game-shell--expanded .arcade__guide {
    display: none;
  }

  /* The rows above the stage keep their own margins in the panel; in the column they would stack
     on top of the gap and double the spacing. */
  .game-shell:fullscreen > *,
  .game-shell--expanded > * {
    margin-bottom: 0;
  }

  /**
   * The stage flexes to fill the height the chrome leaves; the canvas fits inside it.
   *
   * The canvas keeps its intrinsic 4/5 ratio and is capped by both box dimensions, so on a wide
   * desk monitor the picture grows to full height and centres, and on a tall phone it fills the
   * width — the same rule, no per-breakpoint work. `margin: auto` centres it inside the flex
   * stage when one dimension binds before the other.
   *
   * Doubled selector because `game-shell` and the ratio class are on the *same* element:
   * `:fullscreen` matches it directly, while the `--expanded` fallback needs the compound form.
   */
  .arcade--tall.game-shell:fullscreen .arcade__stage,
  .arcade--tall.game-shell--expanded .arcade__stage {
    display: flex;
    flex: 1 1 auto;
    min-height: 0;
    width: auto;
    max-width: 100%;
    margin-inline: auto;
  }

  .arcade--tall.game-shell:fullscreen .arcade__canvas,
  .arcade--tall.game-shell--expanded .arcade__canvas {
    width: auto;
    height: auto;
    max-width: 100%;
    max-height: 100%;
    margin: auto;
  }

  /* Fullscreen collapses the two-column desk layout: the legend is hidden, so there is no second
     column left to hold. */
  .arcade--tall.game-shell:fullscreen,
  .arcade--tall.game-shell--expanded {
    display: flex;
  }

  /* The on-glass keys are a thumb's controls. On a fine pointer they are noise — two emoji circles
     parked on the corner of a picture that a keyboard is already driving — so they step aside and
     the card's prompt names the keys instead. A touch-capable desk screen still matches `coarse`
     only when it is the primary pointer, which is the right test: the keys exist for whichever
     input the player is actually holding. */
  @media (pointer: fine) {
    .game-shell .arcade__pad {
      display: none;
    }
  }

  /* -----------------------------------------------------------------------------------
     Phones

     One copy of this. It was four before — the same eight declarations under the `munchkin-`,
     `catch-`, `blaster-` and `meadow-`/`beat-` prefixes — and they had already drifted: two of the
     games never shrank their on-glass key, so on a narrow screen the button covered the corner
     of the picture it was meant to sit beside.
     ----------------------------------------------------------------------------------- */

  @media (max-width: 39rem) {
    /* The HUD and the controls stop competing for one row and stack: four readouts and three buttons
       do not fit across a phone, and squeezing them was what pushed the score to an ellipsis. */
    .arcade__topline {
      align-items: stretch;
      flex-direction: column;
    }

    .arcade__actions {
      width: 100%;
    }

    .arcade__actions > * {
      flex: 1 1 0;
      min-width: 0;
    }

    .arcade-stat {
      padding-inline: var(--s3);
    }

    .arcade-stat__value {
      font-size: var(--t-md);
    }

    .arcade-stat__value--tally {
      font-size: var(--t-sm);
    }

    .arcade__pad {
      right: var(--s3);
      bottom: var(--s3);
    }

    .arcade__key {
      width: 3rem;
      height: 3rem;
      font-size: 1.3rem;
    }

    .arcade__guide {
      padding: var(--s4);
    }
  }

  /* Ambient chrome motion, and there is exactly one piece of it: the on-glass key's lip. Everything
     else that moves in a game is drawn on the canvas, where each game gates its own atmosphere. */
  @media (prefers-reduced-motion: reduce) {
    .arcade__key:active {
      transform: none;
    }
  }

  /* ===================================================================================
     The codex — everything DONAT Blaster says outside the glass

     `js/dash/blaster/codex.js` is the other half of a game that was explaining none of itself: twelve
     hostile families, five bosses, eight weapon rungs, ten pickups, three hulls and three difficulty
     grades, all of it left for the player to infer. They do not infer it. They conclude that the mint
     diamond and the mint cross are the same enemy and that the letters on the drops are decorative.

     So this sub-section dresses six things: the pre-launch block (fleet cards + difficulty), the
     tabbed reference card, the chain readout in the HUD, the ability key on the glass, the personal
     bests row and the run report. Every one of them is generated from the same table the game plays
     from, which is why none of them gets a bespoke colour — the content is already the interesting
     part.

     ## This block spends no new hue

     The cabinet's comment above enumerates exactly four places `--arcade-hue` appears — the HUD's top
     rule, the stage's lip, the primary on-glass key, and the matching buff pill — and says the
     restraint is what keeps the page from having two centres. Nothing below adds a fifth. The
     difficulty strip's chosen position, the codex's selected tab and the active bests cell all mark
     themselves with `currentColor` and a 3px bar, the same mechanism `.arcade-ship[aria-pressed]`
     uses. The one exception is the three hull branch colours, and those are the buff pills' argument
     rather than the cabinet's: butter/cyan/mint describe *which hull*, not which game, exactly as a
     shield pill is cyan in whoever's cabinet it is picked up in.

     ## Two tokens this file asked for that `base.css` never defined

     `--r-full` and `--line-subtle` were used above — `.arcade__select`, `.arcade-buff`,
     `.arcade-guide-item` — and exist nowhere in the repository. An undefined custom property with no
     fallback makes the whole declaration invalid at computed-value time, so both pills rendered
     square and every guide item's border fell back to `currentColor`: a near-black hairline box
     where a whisper was intended. They are now `--r-pill` and `--line-quiet`, the tokens that
     actually exist, fixed at the three rules rather than patched from down here. If either name
     reappears, that is the regression.
     =================================================================================== */

  /* -----------------------------------------------------------------------------------
     Pre-launch — the block that gets out of the way
     ----------------------------------------------------------------------------------- */

  /**
   * Three fleet cards and a difficulty strip, above the stage, gone while flying.
   *
   * The cabinet's own comment argues that stacking mostly-empty rows above a portrait stage is how a
   * game grows a chimney, and reserves one shared row for the buffs and the old ship strip on
   * exactly that reasoning. Three cards carrying stat bars and an ability blurb are not empty — but
   * they are *inert* from the moment a flight starts, and a hundred and forty pixels of disabled
   * controls between the HUD and the picture for the length of a run is the same mistake in a
   * larger size.
   */
  .arcade__prelaunch {
    margin-bottom: var(--s3);
  }

  .arcade__prelaunch-body {
    display: grid;
    gap: var(--s3);
  }

  /**
   * The collapse is `display: none`, not a height animation.
   *
   * `visibility: hidden` was the first cut and keeps every one of those pixels, which is the whole
   * problem. `max-height: 0; overflow: hidden` collapses the box but leaves three cards of hull
   * names, stat labels and ability copy in the accessibility tree, so a screen reader still reads
   * out a picker that cannot be operated. And a *transition* between the two states would animate
   * the stage's position at the precise moment the game starts, which is the reflow the buff row's
   * reserved height exists to prevent, one row up.
   */
  .arcade__prelaunch--flying .arcade__prelaunch-body {
    display: none;
  }

  .arcade__prelaunch-summary {
    display: none;
    overflow: hidden;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: 0.04em;
    line-height: 1.4;
    text-overflow: ellipsis;
    text-transform: uppercase;
    white-space: nowrap;
  }

  .arcade__prelaunch--flying .arcade__prelaunch-summary {
    display: block;
  }

  /**
   * The controls line up with the picture they are about to drive.
   *
   * `.arcade--tall .arcade__stage` is capped at 30rem and centred; a pre-launch block left at the
   * cabinet's full 74rem would put a difficulty strip a metre wide over a phone-shaped stage, and
   * the two would read as belonging to different objects. So the block matches the cap.
   *
   * Above 48rem the *body* is let out to 44rem and the summary is not. That is not an inconsistency:
   * the summary is a status line about what is on the stage, so it belongs over the stage, while the
   * cards are the one thing on this page a player reads *instead of* the stage — nothing is
   * happening on it yet — and three cards sharing 30rem gives each a 15-character measure for its
   * blurb, which is a newspaper column, not a card.
   */
  .arcade--tall .arcade__prelaunch-body,
  .arcade--tall .arcade__prelaunch-summary {
    max-width: 30rem;
    margin-inline: auto;
  }

  @media (min-width: 48rem) {
    .arcade--tall .arcade__prelaunch-body {
      max-width: 44rem;
    }
  }

  /* -----------------------------------------------------------------------------------
     The fleet — three cards, because the trade-off is the point
     ----------------------------------------------------------------------------------- */

  /**
   * Three across, always, at every width.
   *
   * `repeat(3, …)` rather than `auto-fit`, because these three are a comparison: hull against speed
   * against guns. Reflowing them into a column on a phone turns a comparison into a list, and a
   * player scrolling between the Ghost's speed bar and the Hardshell's cannot see that one is longer
   * than the other — which is the only question the control is asking.
   *
   * What gives on a narrow screen is the prose inside each card, not the arrangement. See the
   * suppression rules under the card body.
   */
  .arcade-fleet {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--s2);
  }

  /**
   * One hull.
   *
   * `--fleet-branch` is declared here and only *read* by the checked rules below. Three cards each
   * flying their own colour at rest is three suggestions; the branch colour arriving on the one that
   * is actually chosen is a choice — the same sentence `.arcade-ship`'s comment makes about its
   * three positions, kept deliberately because the two controls are the same decision at different
   * sizes.
   *
   * The fallback is `--arcade-hue` rather than `initial` for the same reason `.arcade`'s own
   * `--arcade-hue` falls back to `--glaze`: a fourth hull added before this list is updated should
   * come out looking like the cabinet, not take every `border-color` below down with it.
   *
   * `text-align` is the only button reset needed — `base.css`'s reset layer already hands buttons
   * `font: inherit`, `border: 0`, `background: none` and `cursor: pointer`.
   */
  .arcade-fleet__card {
    --fleet-branch: var(--arcade-hue);
    display: grid;
    gap: var(--s2);
    align-content: start;
    min-width: 0;
    min-height: 3rem;
    padding: var(--s2);
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--bg-raise);
    color: var(--fg);
    text-align: left;
    transition: border-color var(--dur-1) ease;
  }

  .arcade-fleet__card--butter {
    --fleet-branch: var(--spr-butter);
  }

  .arcade-fleet__card--cyan {
    --fleet-branch: var(--spr-cyan);
  }

  .arcade-fleet__card--mint {
    --fleet-branch: var(--spr-mint);
  }

  /**
   * The chosen hull, in colour and out of it.
   *
   * The 3px bar across the foot is the greyscale carrier, and it is `--fleet-branch` rather than
   * `currentColor` because the card's text colour is deliberately *not* the branch colour: butter is
   * `#ffd75e`, and a hull name set in it on `--bg-raise` is about 1.4:1 on the paper theme, which
   * `tools/check-contrast.mjs` is there to catch. So the branch lands on the border, the bar, the
   * glyph and the stat fills — shapes and edges, where lightness does not have to carry meaning —
   * and the name and the blurb stay `--fg` and `--fg-muted`.
   */
  .arcade-fleet__card[aria-checked='true'] {
    border-color: var(--fleet-branch);
    box-shadow: inset 0 -3px 0 var(--fleet-branch);
  }

  /* The wash is the same twelve-percent mix the ship strip uses, behind the same guard, so the two
     controls read as one family and a browser without `color-mix()` still has the bar. */
  @supports (color: color-mix(in srgb, red 50%, blue)) {
    .arcade-fleet__card[aria-checked='true'] {
      background: color-mix(in srgb, var(--fleet-branch) 10%, var(--bg-raise));
    }
  }

  .arcade-fleet__card:hover:not(:disabled):not([aria-checked='true']) {
    border-color: var(--line-strong);
  }

  /**
   * `--ring`, not `--arcade-hue`.
   *
   * The ship strip outlines itself in the cabinet's hue, which works on the ink the strip usually
   * sits over and does not work here: a cabinet hue is picked to read against the stage's black, and
   * `--spr-cyan` drawn on cream paper is a ring at roughly 1.4:1 — a focus indicator a sighted
   * keyboard user cannot find. `--ring` is the application's one ring and flips with the surface.
   */
  .arcade-fleet__card:focus-visible {
    outline: 2px solid var(--ring);
    outline-offset: 2px;
  }

  /* Locked for the length of a flight — a hull is a decision made on the ground. */
  .arcade-fleet__card:disabled {
    opacity: 0.45;
    cursor: default;
  }

  .arcade-fleet__head {
    display: flex;
    align-items: center;
    gap: var(--s1);
    min-width: 0;
  }

  .arcade-fleet__glyph {
    flex: 0 0 auto;
    color: var(--fg-muted);
    font-size: 1.15rem;
    line-height: 1;
  }

  .arcade-fleet__card[aria-checked='true'] .arcade-fleet__glyph {
    color: var(--fleet-branch);
  }

  /* Ellipsis rather than wrap: three cards across a 30rem stage are about 9.5rem each, and a hull
     name breaking to a second line makes one card taller than its two neighbours, which reads as
     emphasis nobody asked for. */
  .arcade-fleet__name {
    overflow: hidden;
    min-width: 0;
    color: var(--fg);
    font-family: var(--font-display);
    font-size: var(--t-sm);
    font-weight: 800;
    line-height: 1.2;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .arcade-fleet__bars {
    display: grid;
    gap: var(--s1);
  }

  .arcade-fleet__blurb {
    margin: 0;
    color: var(--fg-muted);
    font-size: var(--t-xs);
    line-height: 1.35;
  }

  .arcade-fleet__ability {
    display: grid;
    gap: 0.15rem;
    padding-top: var(--s2);
    border-top: 1px solid var(--line-quiet);
  }

  .arcade-fleet__ability-name {
    color: var(--fg);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: 0.04em;
    line-height: 1.2;
    text-transform: uppercase;
  }

  .arcade-fleet__ability-desc {
    color: var(--fg-muted);
    font-size: var(--t-xs);
    line-height: 1.35;
  }

  /**
   * What a narrow card drops, and what it keeps.
   *
   * Mobile first, so the phone case is the plain rule and the desk is the exception. Below 48rem a
   * card is roughly 9.5rem wide and prose set in it is a fifteen-character measure — so the blurb,
   * the ability description and the bars' numeric notes go.
   *
   * The ability *name* stays, and that is the one judgement call here. It is not decoration: it is
   * the label on the third on-glass key, and a player who picks a hull without knowing which key
   * they are getting finds out by pressing it mid-fight. The numbers behind the dropped notes are
   * not lost either — the codex's Fleet tab states hull and speed in full, which is what a reference
   * card is for.
   */
  .arcade-fleet__blurb,
  .arcade-fleet__ability-desc,
  .arcade-bar__note {
    display: none;
  }

  @media (min-width: 48rem) {
    .arcade-fleet__blurb,
    .arcade-fleet__ability-desc,
    .arcade-bar__note {
      display: block;
    }
  }

  /* -----------------------------------------------------------------------------------
     Stat bars
     ----------------------------------------------------------------------------------- */

  /**
   * Label and note on one line, track under both.
   *
   * The DOM order is label, track, note — so the note is placed explicitly rather than by source
   * order. Named areas would read better and cost a `grid-template-areas` string on a three-cell
   * grid; explicit `grid-area` line numbers say the same thing in three declarations.
   */
  .arcade-bar {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    gap: 0.2rem var(--s2);
    align-items: baseline;
  }

  .arcade-bar__label {
    grid-area: 1 / 1;
    overflow: hidden;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: 0.04em;
    line-height: 1.2;
    text-overflow: ellipsis;
    text-transform: uppercase;
    white-space: nowrap;
  }

  .arcade-bar__note {
    grid-area: 1 / 2;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-variant-numeric: tabular-nums;
    line-height: 1.2;
    white-space: nowrap;
  }

  .arcade-bar__track {
    grid-area: 2 / 1 / 3 / -1;
    overflow: hidden;
    height: 4px;
    border-radius: var(--r-pill);
    background: var(--line);
  }

  /**
   * The fill is sized by the inline `--fill` the picker writes, never by a width the stylesheet
   * cannot see.
   *
   * `var(--fill, 0%)` and not `var(--fill)`: a bar whose property has not been written yet would
   * otherwise have an invalid `width` and fall back to `auto`, which on a block child is 100% — a
   * hull that has not reported its numbers would show as maxed out on every axis, which is the worst
   * possible direction for that failure to go.
   *
   * Grey by default. The bar lengths are the comparison; the branch colour arrives on the chosen
   * card only, and even then it is confirming a choice already made rather than carrying the data.
   */
  .arcade-bar__fill {
    display: block;
    width: var(--fill, 0%);
    height: 100%;
    background: var(--fg-muted);
  }

  .arcade-fleet__card[aria-checked='true'] .arcade-bar__fill {
    background: var(--fleet-branch);
  }

  /* -----------------------------------------------------------------------------------
     Difficulty — one joined body, three positions
     ----------------------------------------------------------------------------------- */

  /**
   * The control the hull picker used to be, and the right control here.
   *
   * Three grades genuinely are three labels for one axis, so a joined strip is honest about them in
   * a way it was not about three flight models. It is written out rather than made to share
   * `.arcade__select`'s rules because the two now differ in three places — this one is full width,
   * has a two-line position, and holds the 3rem touch floor the newer controls all keep — and a
   * shared rule plus three overrides is longer than two honest rules.
   *
   * Full width rather than shrink-to-fit: inside a 30rem body a hugging strip leaves a ragged edge
   * above three cards that do fill the width, and on a phone a strip sized to its own text is three
   * small targets in the middle of a wide row.
   */
  .arcade-grade {
    display: flex;
    align-items: stretch;
    overflow: hidden;
    border: 1px solid var(--line-strong);
    border-radius: var(--r-pill);
    background: var(--bg-raise);
  }

  .arcade-grade__opt {
    display: flex;
    flex: 1 1 0;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.1rem;
    min-width: 0;
    min-height: 3rem;
    padding: var(--s1) var(--s2);
    color: var(--fg-muted);
  }

  .arcade-grade__opt + .arcade-grade__opt {
    border-left: 1px solid var(--line);
  }

  .arcade-grade__opt:hover:not(:disabled):not([aria-checked='true']) {
    color: var(--fg);
  }

  /**
   * The chosen grade takes `currentColor`, not the cabinet's hue.
   *
   * A fifth place for `--arcade-hue` would have been free to write and would have cost the thing the
   * block above is protecting. A difficulty setting is not the cabinet's identity, so it marks
   * itself the way the ship strip does — a bar across the foot of the position, which survives
   * greyscale — and takes `--fg` for its text, which is the only visible difference from a position
   * that is merely hovered.
   */
  .arcade-grade__opt[aria-checked='true'] {
    color: var(--fg);
    box-shadow: inset 0 -3px 0 currentColor;
  }

  /* Inset, because the strip clips itself with `overflow: hidden` and an outline drawn outside the
     position would be cropped to nothing on the first and last of the three. */
  .arcade-grade__opt:focus-visible {
    outline: 2px solid var(--ring);
    outline-offset: -2px;
  }

  .arcade-grade__opt:disabled {
    opacity: 0.45;
    cursor: default;
  }

  .arcade-grade__name {
    font-family: var(--font-mono);
    font-size: var(--t-sm);
    font-weight: 700;
    letter-spacing: 0.04em;
    line-height: 1.1;
    text-transform: uppercase;
  }

  /* The payout, and it is the reason the strip is worth having: a difficulty setting that does not
     say what it pays is a setting everybody leaves alone. Muted and a size down, because it is the
     consequence of the choice rather than the choice. */
  .arcade-grade__pay {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-variant-numeric: tabular-nums;
    line-height: 1.1;
  }

  /* -----------------------------------------------------------------------------------
     The codex — five tabs over one grid
     ----------------------------------------------------------------------------------- */

  /**
   * The root carries `.arcade__guide` as well, which is where its padding, border, raised surface
   * and eyebrow come from. Nothing is repeated here.
   *
   * That shared class is also what puts this beside a portrait stage at 64rem through the cabinet's
   * existing `:has(.arcade__guide)` rule — a rule written for a legend no game ever shipped. Every
   * layout below therefore has to survive a column as narrow as 18rem, which after the guide card's
   * own padding is about 15rem of content. That is the width every grid in this sub-section is
   * measured against, not the full-width case.
   */

  /**
   * Five tabs will not fit across a phone, so the strip scrolls sideways rather than wrapping.
   *
   * A wrapped tablist puts "Bosses" and "Fleet" on a second row under a divider that then belongs to
   * neither row, and the selected-tab underline stops meaning "this one" the moment there are two
   * rows of them. `overscroll-behavior-x: contain` stops a horizontal flick at the end of the strip
   * from turning into a page-level back-swipe, which is the failure that makes a scrollable strip
   * feel broken on iOS.
   */
  .arcade-codex__tabs {
    display: flex;
    gap: var(--s1);
    margin-bottom: var(--s3);
    overflow-x: auto;
    overflow-y: hidden;
    border-bottom: 1px solid var(--line);
    overscroll-behavior-x: contain;
    scrollbar-width: thin;
  }

  .arcade-codex__tab {
    flex: 0 0 auto;
    min-height: 3rem;
    padding: 0 var(--s3);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
    transition: color var(--dur-1) ease;
  }

  /**
   * The selected tab is marked by a bar, not by a colour.
   *
   * `inset 0 -3px 0` rather than `border-bottom`, because a border would push the tab up by three
   * pixels against the four that are not selected, and the strip's own bottom hairline sits exactly
   * where the bar goes — a border-colour change there would leave the unselected tabs looking like
   * they had lost their underline rather than the selected one having gained a marker.
   */
  .arcade-codex__tab[aria-selected='true'] {
    color: var(--fg);
    box-shadow: inset 0 -3px 0 currentColor;
  }

  .arcade-codex__tab:hover:not([aria-selected='true']) {
    color: var(--fg);
  }

  .arcade-codex__tab:focus-visible {
    outline: 2px solid var(--ring);
    outline-offset: -2px;
  }

  /**
   * The panel deliberately declares no `display`.
   *
   * `codex.js` hides the four inactive panels with the `hidden` attribute, which is what takes them
   * out of the accessibility tree as well as out of the picture. That works because the UA's
   * `[hidden] { display: none }` is the only rule touching their display — the moment an author rule
   * here says `display: grid`, all five panels render at once and the tab set becomes decoration
   * over a flat list. The grid lives one level down, on `.arcade-codex__grid`, for exactly this
   * reason.
   */
  .arcade-codex__panels,
  .arcade-codex__panel {
    min-width: 0;
  }

  /* 14rem, because the narrow case is the 18rem desk column rather than the phone: 15rem of content
     resolves to a single column there, and a 13rem minimum would have squeezed two thumbnails and
     two descriptions into it. */
  .arcade-codex__grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
    gap: var(--s3);
  }

  /* The pickup legend's rows are a glyph and two short lines, so they tile tighter than a bestiary
     entry with a 52px drawing in it. */
  .arcade-codex__grid--legend {
    grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
  }

  /* The scoped `border-color` patch that used to sit here is gone: `.arcade-guide-item` asked for
     `--line-subtle`, which `base.css` does not define, so its border fell back to `currentColor` and
     every legend row was framed in a near-black hairline. That is now repaired at the rule itself,
     which is where it belongs — a patch scoped to the codex would have left the same bug anywhere
     else a guide item is ever used. */

  /**
   * The weapon ladder is one column at every width.
   *
   * Eight rungs in a two-column grid put rung 5 level with rung 1, and a ladder whose steps are
   * side by side stops reading as a progression — which is the only thing the numbers are for.
   * Tighter gap than the other panels because consecutive rungs are one object, not eight.
   */
  .arcade-codex__grid--rungs {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s2);
  }

  /* -----------------------------------------------------------------------------------
     Codex entries
     ----------------------------------------------------------------------------------- */

  .arcade-entry {
    display: flex;
    align-items: flex-start;
    gap: var(--s3);
    min-width: 0;
    padding: var(--s2) var(--s3);
    border: 1px solid var(--line-quiet);
    border-radius: var(--r1);
    background: var(--bg-inset);
  }

  /**
   * The thumbnail well is `--ink-900` in both schemes, exactly like the stage.
   *
   * These pictures are produced by the game's own `drawFamily` / `drawBoss`, which choose their
   * colours for a black stage. Set on the paper theme's cream the Cipher is a pale shape on pale
   * paper and half the bestiary disappears — and the alternative, asking every drawer to take a
   * background parameter, would put the codex's layout concerns inside the render module. So the
   * well brings the stage's ground with it. Same argument the cabinet makes for `.arcade__stage`,
   * one card down.
   */
  .arcade-entry__figure {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    min-width: 3.25rem;
    min-height: 3.25rem;
    border-radius: var(--r1);
    background: var(--ink-900);
  }

  /**
   * The canvas needs a CSS box or it draws at its backing-store size.
   *
   * `thumbnail()` sets `width`/`height` *attributes* to `box × devicePixelRatio` — so on a 1.5×
   * phone a 52px sprite arrives as a 78px element, half again too big, and on a 1× desktop it is
   * correct by accident. Once that multiplication has happened the original `box` is not recoverable
   * from CSS, which is why the honest fix is an inline `--thumb` written alongside the attributes.
   *
   * Until that lands, `var(--thumb, 3.25rem)` gives the right answer for the 52px families and a
   * 52px box for the 68px bosses — undersized, not broken — and starts working the moment the
   * property appears without another change here.
   */
  .arcade-entry__thumb {
    display: block;
    width: var(--thumb, 3.25rem);
    height: var(--thumb, 3.25rem);
  }

  /* The fallback when a drawer throws or the context is refused, and the figure for the Fleet tab,
     which has no sprite to draw. On the well's ink, so it takes the stage's foreground. */
  .arcade-entry__glyph {
    color: var(--highlight);
    font-size: 1.5rem;
    line-height: 1;
  }

  .arcade-entry__body {
    display: grid;
    gap: 0.15rem;
    min-width: 0;
  }

  .arcade-entry__name {
    color: var(--fg);
    font-family: var(--font-sans);
    font-size: var(--t-sm);
    font-weight: 700;
    line-height: 1.2;
  }

  /* Points, the wave a family arrives on, a boss's phase count — numbers in a row, so tabular. */
  .arcade-entry__meta {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.03em;
    line-height: 1.3;
  }

  .arcade-entry__desc {
    margin: 0;
    color: var(--fg-muted);
    font-size: var(--t-xs);
    line-height: 1.35;
  }

  /* -----------------------------------------------------------------------------------
     Weapon rungs
     ----------------------------------------------------------------------------------- */

  .arcade-rung {
    display: flex;
    align-items: flex-start;
    gap: var(--s3);
    min-width: 0;
    padding: var(--s2) var(--s3);
    border: 1px solid var(--line-quiet);
    border-radius: var(--r1);
    background: var(--bg-inset);
  }

  /* A fixed circle rather than a padded pill, so rung 1 and rung 8 are visibly the same object at
     different points on the ladder. Tabular for the same reason, even at one digit — the ladder can
     grow past nine without the chips changing width under each other. */
  .arcade-rung__no {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    width: 1.75rem;
    height: 1.75rem;
    border: 1px solid var(--line);
    border-radius: var(--r-pill);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    line-height: 1;
  }

  .arcade-rung__body {
    display: grid;
    gap: 0.15rem;
    min-width: 0;
  }

  .arcade-rung__name {
    color: var(--fg);
    font-family: var(--font-sans);
    font-size: var(--t-sm);
    font-weight: 700;
    line-height: 1.25;
  }

  .arcade-rung__desc {
    margin: 0;
    color: var(--fg-muted);
    font-size: var(--t-xs);
    line-height: 1.35;
  }

  /* -----------------------------------------------------------------------------------
     The chain — a HUD cell that never changes width
     ----------------------------------------------------------------------------------- */

  /**
   * `.arcade-combo` is also an `.arcade-stat`, so the cell's padding, its left hairline and the
   * label and value treatments are already correct and are not repeated. Only two things are new: a
   * tone, and a meter.
   *
   * The tone is resolved into one local property rather than written out as five pairs of rules,
   * which mirrors how `.arcade[data-hue]` resolves `--arcade-hue` and keeps the recolouring to two
   * declarations at the bottom instead of ten.
   */
  .arcade-combo {
    --combo-tone: var(--arcade-hue);
    position: relative;
  }

  .arcade-combo[data-tone='butter'] {
    --combo-tone: var(--spr-butter);
  }

  .arcade-combo[data-tone='crust'] {
    --combo-tone: var(--crust);
  }

  .arcade-combo[data-tone='cyan'] {
    --combo-tone: var(--spr-cyan);
  }

  .arcade-combo[data-tone='mint'] {
    --combo-tone: var(--spr-mint);
  }

  .arcade-combo[data-tone='glaze'] {
    --combo-tone: var(--glaze);
  }

  /**
   * `—` and `×6` have to occupy the same box.
   *
   * The HUD's `grid-auto-columns: minmax(0, 1fr)` already keeps the *cell* from changing width, so
   * the risk is one level in: the value box collapsing around an em-dash and the number jumping
   * sideways when the chain starts. A `ch` floor holds it. `font-variant-numeric: tabular-nums` is
   * not redeclared — `.arcade-stat__value` already carries it, and this cell is that class.
   */
  .arcade-combo__mult {
    min-width: 3ch;
  }

  /* The number takes the tone only while a chain is actually running. At zero it is an em-dash in
     the strip's own `--highlight`, which is the non-colour half of the state: the glyph changes
     from a dash to a multiplier, so the readout survives greyscale before any tone is involved. */
  .arcade-combo--live .arcade-combo__mult {
    color: var(--combo-tone);
  }

  /**
   * The meter is a band across the foot of the cell, not a third row in it.
   *
   * As a grid row it added seven pixels to the HUD strip's height and — worse — pushed the chain's
   * own number down out of line with the score's, because `.arcade-stat`'s implicit rows stretch and
   * a three-row cell distributes the slack differently from a two-row one. Out of flow it costs the
   * strip nothing and cannot reflow it, which is the entire requirement: this thing is repainted
   * sixty times a second while the player is watching something die.
   *
   * Full-bleed to the cell edges rather than inset to its padding, so it answers the 2px hue rule
   * the strip already draws along its own top.
   */
  .arcade-combo__track {
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    overflow: hidden;
    height: 3px;
    background: var(--ink-700);
  }

  /* `0%` fallback for the same reason the stat bars have one: an unwritten property must read as an
     empty window, never as a full one. */
  .arcade-combo__fill {
    display: block;
    width: var(--fill, 0%);
    height: 100%;
    background: var(--combo-tone);
  }

  /* -----------------------------------------------------------------------------------
     The ability key — a cooldown drawn on the glass
     ----------------------------------------------------------------------------------- */

  /**
   * The third on-glass key, beside nova and fire.
   *
   * `abilityKey()` builds it from the chassis' own `padKey`, so it is already the right size, lip,
   * ground and focus ring; the only thing added here is a positioning context. `.arcade__key` sets
   * none, and without one the sweep's containing block is `.arcade__pad` and the indicator lands
   * across all three keys at once.
   */
  .arcade__key--ability {
    position: relative;
    transition: border-color var(--dur-2) ease;
  }

  /**
   * Ready is the resting state, so it gets the smallest possible change.
   *
   * A pulse was the obvious first thought and is the wrong one: this key is charged most of the
   * time, so an animation on the ready state is an animation running most of the time, on top of a
   * moving picture, in the corner the player is aiming at. And the state does not need colour to
   * carry it — the sweep below is a complete ring at 100%, which is the greyscale marker; firming
   * the border from the `--secondary` tone's 50% cream to full `--highlight` is only confirming it.
   */
  .arcade__key--armed {
    border-color: var(--highlight);
    color: var(--highlight);
  }

  /**
   * The sweep, plain version: a bar across the foot of the disc.
   *
   * Inset to 18% and lifted 18% off the bottom so both ends stay inside the circle — a chord that
   * high in a disc is about 80% of the diameter, so the bar has room without any clipping, and
   * `overflow: hidden` on the key is avoided (it would be a second reason for the lip to disappear).
   * It sits below the 1.5rem glyph's descender, so it never covers what it is annotating.
   *
   * The hard stop is a two-position gradient rather than a `width`, so the element's own box stays
   * fixed and only the paint moves — one property, compositor-owned, at sixty updates a second.
   * `--ink-700` behind it is the unfilled part of the track: without it the bar vanishes at zero and
   * the key looks like it has no cooldown rather than a spent one.
   */
  .arcade-key__sweep {
    position: absolute;
    inset-inline: 18%;
    bottom: 18%;
    height: 3px;
    border-radius: var(--r-pill);
    background-color: var(--ink-700);
    background-image: linear-gradient(to right, var(--highlight) var(--sweep, 0%), transparent 0);
    pointer-events: none;
  }

  /**
   * The sweep, as a dial.
   *
   * A conic gradient masked to a ring is the shape everybody already reads as a cooldown, and it
   * starts at twelve and runs clockwise for free. Both halves are tested, not just the gradient: on
   * a browser with `conic-gradient` and no `mask` the gradient fills the whole disc and buries the
   * glyph, which is worse than the bar it replaced. The ring is 4px at the inside edge of the key's
   * own 2px border — an absolutely positioned child resolves `inset: 0` against the padding box —
   * so it reads as a collar rather than as a second border.
   *
   * `height: auto` is not tidying: with `top`, `bottom` and a fixed `height` all set, the browser
   * drops `bottom` and the ring inherits the bar's 3px.
   */
  @supports (background: conic-gradient(red, blue)) and (mask: radial-gradient(black, black)) {
    .arcade-key__sweep {
      inset: 0;
      height: auto;
      border-radius: 50%;
      background-color: transparent;
      background-image: conic-gradient(var(--highlight) var(--sweep, 0%), var(--ink-700) 0);
      mask: radial-gradient(farthest-side, transparent calc(100% - 4px), black calc(100% - 4px));
    }
  }

  /* -----------------------------------------------------------------------------------
     Personal bests
     ----------------------------------------------------------------------------------- */

  /**
   * The combination being flown, stated in full, and the other eight underneath.
   *
   * Nine numbers put in front of somebody as nine equals is nine numbers nobody reads. The grid is
   * hull-major — three grades across, one hull per row — which is the order `bestsRow()` emits them
   * in, so the columns line up as difficulties without a single explicit placement.
   */
  .arcade-bests {
    display: grid;
    gap: var(--s3);
    margin-top: var(--s3);
    padding: var(--s3) var(--s4);
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--bg-raise);
  }

  .arcade-bests__now {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--s1) var(--s3);
  }

  .arcade-bests__label {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .arcade-bests__score {
    color: var(--fg);
    font-family: var(--font-display);
    font-size: var(--t-md);
    font-variant-numeric: tabular-nums;
    font-weight: 800;
    line-height: 1;
  }

  .arcade-bests__grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--s1);
  }

  .arcade-bests__cell {
    display: grid;
    gap: 0.1rem;
    min-width: 0;
    padding: var(--s1) var(--s2);
    border: 1px solid var(--line-quiet);
    border-radius: var(--r1);
    background: var(--bg-inset);
  }

  /* The one the player is about to fly. A stronger edge and the same 3px bar every other chosen
     thing in this cabinet wears — and the bar is `currentColor`, which here is the inherited `--fg`,
     because the cell is a readout and has no branch of its own to claim. */
  .arcade-bests__cell--active {
    border-color: var(--line-strong);
    box-shadow: inset 0 -2px 0 currentColor;
  }

  .arcade-bests__cell-label {
    overflow: hidden;
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    line-height: 1.2;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .arcade-bests__cell-score {
    color: var(--fg);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
    font-weight: 700;
    line-height: 1.2;
  }

  /* Reference material for somebody deciding what to fly, which is not what fullscreen is for. Same
     argument, and the same rule, as the legend one section up. */
  .game-shell:fullscreen .arcade-bests,
  .game-shell--expanded .arcade-bests {
    display: none;
  }

  /* -----------------------------------------------------------------------------------
     The run report
     ----------------------------------------------------------------------------------- */

  /**
   * What the run was, in the thirty seconds after it ended.
   *
   * The card drawn on the canvas at the moment of dying can hold a score and a wave count and no
   * more. This is the other audience for the same event: somebody deciding whether to press launch
   * again, who has time to read which hull it was and what the shot count says about how they were
   * playing. It is not hidden in fullscreen, unlike the bests row and the legend — a fullscreen
   * player has just as much reason to read it, and it appears at the only moment the stage has
   * nothing to show.
   */
  .arcade-report {
    display: grid;
    gap: var(--s4);
    margin-top: var(--s4);
    padding: var(--s4) var(--s5);
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--bg-raise);
  }

  .arcade-report__head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--s2) var(--s3);
  }

  .arcade-report__title {
    margin: 0;
    color: var(--fg);
    font-family: var(--font-display);
    font-size: var(--t-md);
    font-weight: 800;
    line-height: 1.1;
  }

  .arcade-report__who {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  /**
   * The one place in this sub-section that is allowed to be loud, and it is still a pill.
   *
   * A banner across the top of the card was the alternative and it was wrong twice: it is the
   * `.dash-notice` vocabulary, which in this dashboard means the system is telling you something,
   * and it would be the largest object on a page whose largest object is supposed to be the stage.
   * A bordered mono pill pushed to the end of the head row says the same thing in one line.
   *
   * `--accent` and `--accent-line`, not `--glaze`. They *are* the glaze — the semantic pair is the
   * brand hue driven dark enough to be text on paper and flipped back to the raw value on ink. Raw
   * `--glaze` at this size on `--bg-raise` is about 1.9:1, which is what `tools/check-contrast.mjs`
   * exists to stop. No wash behind it, for the `.dash-notice` reason above.
   */
  .arcade-report__flag {
    margin-left: auto;
    padding: 0.2rem 0.6rem;
    border: 1px solid var(--accent-line);
    border-radius: var(--r-pill);
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
  }

  /* The same pill when it is reporting the standing record rather than a new one. It is the more
     common of the two states, so it is the quieter — the badge has to mean something the one time
     it lights up. */
  .arcade-report__flag--quiet {
    border-color: var(--line);
    color: var(--fg-muted);
  }

  /* 7rem, so a 15rem codex-width column still gets two pairs across rather than a single tall stack
     of eight. The hairline on the leading edge is what groups a label with its value; boxing each
     pair would have made eight more cards on a page that already has three. */
  .arcade-report__stats {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
    gap: var(--s3);
  }

  .arcade-report__stat {
    display: grid;
    gap: 0.1rem;
    min-width: 0;
    padding-left: var(--s3);
    border-left: 1px solid var(--line-quiet);
  }

  .arcade-report__stat-label {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--track-label);
    line-height: 1.2;
    text-transform: uppercase;
  }

  /* `overflow-wrap`, because not every value is a number: "Bosses downed" is a comma list of names
     and one long boss name in a 7rem column would otherwise push the whole grid wider than its
     container. */
  .arcade-report__stat-value {
    color: var(--fg);
    font-family: var(--font-display);
    font-size: var(--t-base);
    font-variant-numeric: tabular-nums;
    font-weight: 800;
    line-height: 1.2;
    overflow-wrap: break-word;
  }

  /* No `gap`: consecutive rows are separated by their own `border-top`, which is a rule between two
     numbers rather than air between two boxes — the table has to read as a table. */
  .arcade-report__kills {
    display: grid;
  }

  .arcade-report__kills-title {
    margin-bottom: var(--s1);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 700;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  /**
   * Three columns, and the two numeric ones are fixed rather than `auto`.
   *
   * `auto` would size each to its widest cell, which aligns the table but lets a single five-figure
   * points row squeeze the name column on every other line. Fixed widths hold the two number
   * columns still, and the name takes whatever is left and ellipses — the row is ranked by points,
   * so the number is the load-bearing part and the name is the label on it.
   */
  .arcade-report__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 3.5rem 4.5rem;
    gap: var(--s2);
    align-items: baseline;
    padding: 0.25rem 0;
    border-top: 1px solid var(--line-quiet);
  }

  .arcade-report__row-name {
    overflow: hidden;
    color: var(--fg);
    font-size: var(--t-sm);
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .arcade-report__row-count,
  .arcade-report__row-points {
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-variant-numeric: tabular-nums;
    text-align: right;
  }

  .arcade-report__row-count {
    color: var(--fg-muted);
  }

  /* The column the table is sorted by, so it is the one that is not muted. */
  .arcade-report__row-points {
    color: var(--fg);
    font-weight: 700;
  }

  /* -----------------------------------------------------------------------------------
     The codex on a phone, and beside the stage on a desk
     ----------------------------------------------------------------------------------- */

  @media (max-width: 39rem) {
    /* Matching the cabinet's own phone rule for `.arcade__guide`: the card's generous side padding
       is what makes a 12rem entry grid impossible on a 360px screen. */
    .arcade-report {
      padding: var(--s4);
    }

    /* Blaster's longest hostile names are twelve characters and its scores are five figures, so on
       a phone the kill table gives the numbers exactly what they need and the rest to the name. */
    .arcade-report__row {
      grid-template-columns: minmax(0, 1fr) 2.75rem 4rem;
      gap: var(--s1);
    }
  }

  /**
   * The two-column desk layout, completed.
   *
   * The cabinet's `:has(.arcade__guide)` rule places the topline, the mid row and the guide, and
   * leaves everything else to auto-placement — which was correct while the only unplaced child was
   * the stage. The pre-launch block is a second one, and sparse auto-flow would drop it in column 1
   * and then find column 2 of the same row free for the stage, standing the cabinet up beside its
   * own controls. Naming the column for both is the whole fix; the guide's own `grid-column: 2`
   * still resolves after them, in the row below.
   */
  @media (min-width: 64rem) {
    .arcade--tall:has(.arcade__guide) .arcade__prelaunch,
    .arcade--tall:has(.arcade__guide) .arcade__stage {
      grid-column: 1;
    }
  }

  /**
   * The two things in here that move, damped.
   *
   * Neither the chain meter nor the ability sweep is listed: both are written by the engine every
   * frame, and a transition on them would not be motion the reader asked to be spared — it would be
   * a lag between the number and the bar that claims to measure it. What is listed is the ordinary
   * chrome easing on the three controls that change state under a pointer.
   */
  @media (prefers-reduced-motion: reduce) {
    .arcade-fleet__card,
    .arcade-codex__tab,
    .arcade__key--ability {
      transition: none;
    }
  }

  /**
   * The sheet.
   *
   * Off screen at all times. `cards.js` appends it to `<body>` rather than into the panel, so the
   * print rules below have no shared ancestor to argue with: everything that is not the sheet is
   * hidden, and the sheet is not inside any of it.
   */
  .card-sheet {
    display: none;
  }
}

/**
 * Printing a batch of cards.
 *
 * Unlayered and last, which is deliberate — an unlayered rule beats every layer, so this cannot
 * be reintroduced into the document by anything in `ui`, `portal` or `motion` later. The whole
 * dashboard goes, not the parts of it that were remembered: rail, app bar, panels, buttons, the
 * offline banner and the status region are all `body > *`, and only the sheet is excluded.
 *
 * Sizes are in inches because the output is a physical card. CR80 is 3.375in × 2.125in — the
 * standard ID size, so a finished card fits a badge holder or a lanyard sleeve — and the grid gap
 * is the cut margin, with a hairline on each card as the trim guide.
 */
@media print {
  body.page--portal > *:not(#cardPrintSheet) {
    display: none !important;
  }

  /* Two columns of CR80 across portrait long bond. See the `@page` rule at the end of this block
     for why two and not three. `justify-content: center` keeps the pair centred so the cut
     margins are even on both sides of the sheet. */
  #cardPrintSheet {
    display: grid;
    grid-template-columns: repeat(2, 3.375in);
    gap: 0.2in;
    justify-content: center;
  }

  .card-sheet__card {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.06in;
    width: 3.375in;
    height: 2.125in;
    padding: 0.1in;
    border: 1px dashed #b9b9b9;
    background: #ffffff;
    /* A card must never be split across a page break; `avoid` on the item is what keeps a row of
       three whole rather than sending the bottom third of one to the next sheet. */
    break-inside: avoid;
  }

  /**
   * Ask for the colour, and keep telling the operator to allow it.
   *
   * `print-color-adjust: exact` is the correct CSS-side request and is honoured by some
   * browsers; none of them let script read the setting back, and the ones that ignore it print
   * the finders white. So this is defence in depth under the on-screen warning, never a
   * replacement for it.
   */
  .card-sheet__card,
  .card-print__qr {
    print-color-adjust: exact;
    -webkit-print-color-adjust: exact;
  }

  /* Vector, so it resolves at the printer's own resolution rather than the screen's — which is
     why sizing a symbol at 1.5in has no pixel-snapping problem to solve.

     1.5in is close to the ceiling the card imposes: 2.125in tall, less 0.2in padding, 0.06in
     gap and a 0.269in text block, leaves 1.596in. The remainder is deliberate slack — a name
     that wraps to a second line, or a printer rounding inches to its own dot pitch, would
     otherwise push the QR past the card edge and into the cut margin. */
  .card-print__qr {
    width: 1.5in;
    height: 1.5in;
  }

  .card-sheet__id {
    max-width: 100%;
    text-align: center;
  }

  /* The name is what makes a borrowed card fail the way a borrowed school ID does, so it is the
     larger of the two and it is never allowed to spill off the card. */
  .card-sheet__name {
    overflow: hidden;
    color: #111111;
    font-family: var(--font-sans);
    font-size: 9pt;
    font-weight: 600;
    line-height: 1.15;
    white-space: nowrap;
    text-overflow: ellipsis;
  }

  .card-sheet__number {
    color: #444444;
    font-family: var(--font-mono);
    font-size: 7.5pt;
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.04em;
    line-height: 1.2;
  }

  /**
   * Long bond (8.5in x 13in), portrait.
   *
   * Named explicitly rather than left to the operator. `size: landscape` on its own means "the
   * default paper, rotated", so whatever the printer happened to be set to decided the sheet —
   * which is how a run of cards ends up on A4 when the tray holds long bond.
   *
   * Two columns, not three. Three CR80 cards plus their cut margins is 10.525in and portrait
   * long bond offers 7.8in once the margin is off, so a third column cannot fit without either
   * turning the page sideways or shrinking the card below the sleeve size it exists to match.
   * Two across at five rows is ten cards a page, which is more per sheet than three across in
   * landscape managed anyway.
   */
  @page {
    size: 8.5in 13in;
    margin: 0.35in;
  }
}
