/**
 * DONAT — shared components.
 *
 * Loaded by every document, public and portal alike, which is why nothing in here reaches for a
 * raw palette value. Every rule uses the semantic tokens from base.css (`--bg`, `--fg`,
 * `--line`, `--accent`, `--ok`/`--warn`/`--bad`/`--info` and their washes), so a button, a field
 * or a table dropped inside `.on-ink` is already correct. There is no `.card--dark` in this
 * application and there should never need to be one.
 *
 * Two naming systems live here on purpose. The design's own names are `.btn`, `.btn--primary`,
 * `.btn--sm`. The names the JavaScript emits are `.btn-primary`, `.btn-secondary`,
 * `.btn-compact` — nine panels and two forms build them through `widgets.js`, and renaming
 * those would be a rewrite of the client rather than of its appearance. So the aliases are
 * grouped into the same rules and are indistinguishable on screen. New markup should use the
 * `--` names.
 */

@layer ui {
  /* ==========================================================================================
     Buttons
     ========================================================================================== */

  .btn,
  .btn-primary,
  .btn-secondary,
  .btn-danger,
  .btn-danger-quiet {
    --btn-h: 2.875rem;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--s2);
    min-height: var(--btn-h);
    padding-inline: var(--s5);
    border: 1px solid transparent;
    border-radius: var(--r2);
    font-family: var(--font-sans);
    font-size: var(--t-base);
    font-weight: 600;
    line-height: 1.15;
    letter-spacing: -0.005em;
    text-align: center;
    text-decoration: none;
    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);
  }

  /* A press that moves is a press you felt on a phone with no hover state to fall back on. */
  .btn:active,
  .btn-primary:active,
  .btn-secondary:active,
  .btn-danger:active,
  .btn-danger-quiet:active {
    transform: translateY(1px);
  }

  .btn--primary,
  .btn-primary {
    /* The one glaze detail on a button: a hairline sheen along the top edge, which is what the
       light does to a glazed donut and the only ornament this component gets. */
    background: var(--accent);
    box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.24);
    color: var(--accent-fg);
  }

  .btn--primary:hover,
  .btn-primary:hover {
    background: var(--accent-hover);
  }

  .btn--quiet,
  .btn-secondary {
    border-color: var(--line-strong);
    background: transparent;
    color: var(--fg);
  }

  .btn--quiet:hover,
  .btn-secondary:hover {
    border-color: var(--fg-muted);
    background: var(--bg-sunken);
  }

  .btn--ghost {
    background: transparent;
    color: var(--fg-muted);
  }

  .btn--ghost:hover {
    background: var(--bg-sunken);
    color: var(--fg);
  }

  .btn--danger,
  .btn-danger {
    background: var(--bad);
    color: var(--bad-fg);
  }

  /**
   * Destructive, and quiet until it is reached for.
   *
   * The solid variant above is right for a batch action an administrator has explicitly
   * ticked rows for. It is wrong repeated once per card in a list: a Remove on every
   * announcement and a Delete on every event made `--bad` the most-used colour in the half of
   * the application members actually read, and put a reversible Hide in the same crimson as an
   * irreversible Delete. This carries the danger in the hover and focus states, where the
   * pointer already is, and lets the card's own content hold the page at rest.
   */
  .btn--danger-quiet,
  .btn-danger-quiet {
    border-color: var(--line-strong);
    background: transparent;
    color: var(--fg);
  }

  .btn--danger-quiet:hover,
  .btn-danger-quiet:hover,
  .btn--danger-quiet:focus-visible,
  .btn-danger-quiet:focus-visible {
    border-color: var(--bad);
    background: var(--bad-wash);
    color: var(--bad);
  }

  /* The `.on-ink` override that used to live here is gone: `--bad-fg` is paired with `--bad` in
     every context now, so the plum band, the dark scheme and paper are all covered by the one
     declaration above. */

  .btn--sm,
  .btn-compact,
  .btn-inline {
    --btn-h: 2.25rem;
    padding-inline: var(--s4);
    font-size: var(--t-sm);
  }

  /* A 36px control is fine under a mouse and too small under a thumb. Everything grows back to
     the 44px floor on a touch pointer rather than shrinking the desktop layout to suit phones. */
  @media (pointer: coarse) {
    .btn--sm,
    .btn-compact,
    .btn-inline {
      --btn-h: 2.75rem;
    }
  }

  .btn--lg {
    --btn-h: 3.375rem;
    padding-inline: var(--s6);
    font-size: var(--t-md);
  }

  .btn--block,
  .btn-block {
    width: 100%;
  }

  .btn-half {
    flex: 1 1 0;
    min-width: 7.5rem;
  }

  .btn--icon {
    --btn-h: 2.75rem;
    width: var(--btn-h);
    padding-inline: 0;
    border-radius: var(--r-pill);
  }

  /* Text that acts. Not a `.btn` — it belongs in a sentence or under a list, and giving it a box
     would make every panel look like it has two primary actions. */
  .btn-link {
    display: inline-flex;
    align-items: center;
    min-height: 2.25rem;
    padding: 0;
    color: var(--accent);
    font-size: var(--t-base);
    font-weight: 600;
    text-decoration: underline;
    text-decoration-color: var(--accent-line);
    text-decoration-thickness: 1.5px;
    text-underline-offset: 0.25em;
    transition: text-decoration-color var(--dur-1) var(--ease);
  }

  .btn-link:hover {
    text-decoration-color: var(--accent);
    text-decoration-thickness: 2px;
  }

  .btn[disabled],
  .btn-primary[disabled],
  .btn-secondary[disabled],
  .btn-danger[disabled],
  .btn-danger-quiet[disabled],
  .btn-link[disabled] {
    cursor: not-allowed;
    opacity: 0.48;
    transform: none;
  }

  [aria-busy='true'] {
    cursor: progress;
  }

  /* ==========================================================================================
     Fields
     ========================================================================================== */

  .form-box {
    padding: clamp(var(--s5), 4vw, var(--s7));
    border: 1px solid var(--line);
    border-radius: var(--r4);
    background: var(--bg-raise);
    box-shadow: var(--sh2);
  }

  .form-group + .form-group,
  .form-group + .form-actions,
  .form-group + .btn,
  .form-group + .btn-primary {
    margin-top: var(--s5);
  }

  .form-label {
    display: block;
    margin-bottom: var(--s2);
    color: var(--fg);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .form-label__optional {
    margin-left: var(--s2);
    color: var(--fg-faint);
    font-weight: 400;
    letter-spacing: 0.06em;
    text-transform: none;
  }

  .form-control {
    /* 1rem exactly, and not a step smaller: iOS Safari zooms the whole page when a focused input
       is under 16px, which on the sign-in form means the code field jumps off screen as it is
       tapped. */
    width: 100%;
    min-height: 3rem;
    padding: var(--s3) var(--s4);
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--bg-raise);
    color: var(--fg);
    font-family: var(--font-sans);
    font-size: 1rem;
    line-height: 1.4;
    transition:
      border-color var(--dur-1) var(--ease),
      box-shadow var(--dur-1) var(--ease);
  }

  .form-control::placeholder {
    color: var(--fg-faint);
  }

  .form-control:focus {
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-wash);
    outline: none;
  }

  .form-control:focus-visible {
    outline: none;
  }

  .form-control[aria-invalid='true'] {
    border-color: var(--bad);
  }

  .form-control[disabled] {
    background: var(--bg-sunken);
    color: var(--fg-muted);
    cursor: not-allowed;
  }

  textarea.form-control {
    min-height: 7rem;
    resize: vertical;
    line-height: var(--lh-normal);
  }

  /* The arrow is a background image rather than an appearance override alone, because
     `appearance: none` removes the platform arrow and leaves nothing in its place.

     One rule, three grounds. The fill is baked into the data URI and cannot read a custom
     property from inside it, so the whole `url()` is the token — `--select-arrow` in base.css
     carries the bond-600 arrow on paper and the light one on ink and inside `.on-ink`. This
     replaced a second rule scoped to `.on-ink`, which was correct until there was a dark page
     for it not to cover. */
  select.form-control {
    appearance: none;
    padding-right: var(--s7);
    background-image: var(--select-arrow);
    background-repeat: no-repeat;
    background-position: right var(--s4) center;
    background-size: 11px 7px;
  }

  /**
   * A field with a fixed tail printed inside it.
   *
   * One caller today — the STI address on the recruitment form and the sign-in page, where the
   * member types `juan.delacruz` and `@ortigas-cainta.sti.edu.ph` is drawn beside it rather than
   * typed. It lives in `ui.css` and not `site.css` because those two pages load different third
   * stylesheets: `recruit.html` takes `site`, `login.html` takes `portal`, and a definition in
   * either would leave the other rendering an unframed input next to a bare run of text.
   *
   * The frame moves off the input and onto this wrapper, so the border, the invalid state and
   * the focus ring enclose the pair and the control reads as one object. `:focus-within` is what
   * makes that work without a line of JavaScript.
   */
  .input-suffix {
    display: flex;
    align-items: stretch;
    overflow: hidden;
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--bg-raise);
    transition:
      border-color var(--dur-1) var(--ease),
      box-shadow var(--dur-1) var(--ease);
  }

  .input-suffix:focus-within {
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-wash);
  }

  /* The attribute is set on the input by the page script, so the wrapper has to reach in for it
     — without this the field turns red on a frame that is no longer painted. */
  .input-suffix:has(.form-control[aria-invalid='true']) {
    border-color: var(--bad);
  }

  /* `min-width: 0` is load-bearing: a flex item's default `min-width: auto` refuses to shrink
     below its intrinsic width, so a long value would push the domain off the end instead of
     scrolling inside the box. */
  .input-suffix > .form-control {
    flex: 1 1 auto;
    min-width: 0;
    border: 0;
    border-radius: 0;
    background: none;
  }

  .input-suffix > .form-control:focus {
    box-shadow: none;
  }

  .input-suffix__domain {
    display: flex;
    flex: 0 0 auto;
    align-items: center;
    padding-inline: var(--s3);
    border-left: 1px solid var(--line);
    background: var(--bg-sunken);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    white-space: nowrap;
  }

  /* The domain is 26 characters. Side by side it leaves a phone about forty pixels to type in,
     so below this width the pair stacks and the tail becomes a strip under the field. */
  @media (max-width: 26.5em) {
    .input-suffix {
      flex-wrap: wrap;
    }

    .input-suffix > .form-control {
      flex: 1 0 100%;
    }

    .input-suffix__domain {
      flex: 1 0 100%;
      padding-block: var(--s2);
      border-top: 1px solid var(--line);
      border-left: 0;
    }
  }

  /* ==========================================================================================
     The theme control
     ========================================================================================== */

  /**
   * Paper or ink, in the bar.
   *
   * It lives here rather than in site.css or portal.css because both shells carry one and they
   * do not share a stylesheet — the marketing pages load `site`, the two pages behind sign-in
   * load `portal`, and a definition in either would leave the other rendering an unstyled
   * button. That is the same trap `.status-page` is down here for.
   *
   * Written against the semantic tokens, so it is already correct inside the app bar and the
   * sign-in bar, which carry `.on-ink`. The public top bar paints its own plum without that
   * class and gives this its own two lines, next to the ones `.menu-btn` and `.topnav__link`
   * already have there.
   *
   * It ships `hidden` and `js/theme.js` reveals it, for the reason the hero's play control does:
   * with no JavaScript the press could not do anything, and the scheme still follows the
   * operating system without it, because that half of base.css is a media query.
   */
  .theme-toggle {
    display: grid;
    flex: 0 0 auto;
    place-items: center;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: var(--r2);
    color: var(--fg-muted);
    transition:
      background-color var(--dur-1) var(--ease),
      color var(--dur-1) var(--ease);
  }

  .theme-toggle:hover {
    background: var(--bg-sunken);
    color: var(--fg);
  }

  .theme-toggle__icon {
    width: 1.125rem;
    height: 1.125rem;
  }

  /* The icon names the destination, not the state: on paper you are offered the moon. `is-dark`
     is set from the scheme in force rather than from the click, so it cannot end up claiming the
     opposite of what the page is doing. */
  .theme-toggle__icon--sun {
    display: none;
  }

  .theme-toggle.is-dark .theme-toggle__icon--moon {
    display: none;
  }

  .theme-toggle.is-dark .theme-toggle__icon--sun {
    display: block;
  }

  .pwa-install-btn {
    display: grid;
    flex: 0 0 auto;
    place-items: center;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: var(--r2);
    color: var(--fg-muted);
    background: transparent;
    transition:
      background-color var(--dur-1) var(--ease),
      color var(--dur-1) var(--ease);
  }

  .topbar .pwa-install-btn {
    display: none;
  }

  @media (min-width: 900px) {
    .topbar .pwa-install-btn {
      display: grid;
    }
  }

  .pwa-install-btn:hover {
    background: var(--bg-sunken);
    color: var(--fg);
  }

  .pwa-install-btn__icon {
    width: 1.125rem;
    height: 1.125rem;
  }

  /**
   * The checkbox, drawn rather than borrowed.
   *
   * This used to be a native box with `accent-color` and nothing else, which is fine while it is
   * checked — the browser paints the glaze — and wrong the rest of the time. An unchecked
   * checkbox is painted by the platform from the *system* scheme, not the page's: on the dark
   * theme Chromium gives it a light grey slab that reads as a disabled control, and it was the
   * one field on the form ignoring `--line` and `--bg-raise` while every input beside it obeyed.
   *
   * So: `appearance: none` and the states drawn here, against the same tokens as `.form-control`.
   * The tick is `--check-mark`, a data URI, for the reason `--select-arrow` is one — a background
   * image cannot read a custom property, and `::before` is not available on a replaced element.
   */
  .form-checkbox {
    appearance: none;
    flex: 0 0 auto;
    width: 1.375rem;
    height: 1.375rem;
    margin: 0;
    border: 1px solid var(--line-strong);
    border-radius: var(--r1);
    background: var(--bg-raise);
    background-image: none;
    background-repeat: no-repeat;
    background-position: center;
    background-size: 13px 10px;
    cursor: pointer;
    transition:
      background-color var(--dur-1) var(--ease),
      border-color var(--dur-1) var(--ease),
      box-shadow var(--dur-1) var(--ease);
  }

  .form-checkbox:hover {
    border-color: var(--accent);
  }

  .form-checkbox:checked {
    border-color: var(--accent);
    background-color: var(--accent);
    background-image: var(--check-mark);
  }

  .form-checkbox:focus-visible {
    border-color: var(--accent);
    box-shadow: 0 0 0 3px var(--accent-wash);
    outline: none;
  }

  .form-checkbox[aria-invalid='true'] {
    border-color: var(--bad);
  }

  .form-group--inline {
    display: flex;
    align-items: flex-start;
    gap: var(--s3);
  }

  .form-label--inline,
  .form-group--inline .form-label {
    margin: 0;
    font-family: var(--font-sans);
    font-size: var(--t-base);
    font-weight: 400;
    letter-spacing: 0;
    line-height: 1.45;
    text-transform: none;
  }

  .field-hint {
    margin-top: var(--s2);
    color: var(--fg-muted);
    font-size: var(--t-sm);
    line-height: 1.5;
  }

  .field-hint strong {
    color: var(--fg);
  }

  /* A `<label>` wrapping the box and the sentence, so the whole card is the hit target and the
     association needs no `for`. Worth the extra element on a phone, where a 22px checkbox is
     under the 44px anything is expected to be.

     Moved here from site.css when the constitution reader adopted it: that page and the
     dashboard load portal.css instead of site.css, so a shared component defined there is
     unstyled in half the application. */
  .consent-row {
    display: flex;
    align-items: flex-start;
    gap: var(--s3);
    padding: var(--s4);
    border: 1px solid var(--line);
    border-radius: var(--r2);
    background: var(--bg-raise);
    cursor: pointer;
    transition:
      background-color var(--dur-1) var(--ease),
      border-color var(--dur-1) var(--ease);
  }

  .consent-row:hover {
    border-color: var(--line-strong);
  }

  /* `:has()` and not a class toggled from script: the state already exists in the DOM, and a
     script that has to be told about it is a second copy that can disagree. A browser without
     `:has()` simply leaves the card in its resting border, which is not a broken form. */
  .consent-row:has(.form-checkbox:checked) {
    border-color: var(--accent);
    background: var(--accent-wash);
  }

  .consent-row:has(.form-checkbox[aria-invalid='true']) {
    border-color: var(--bad);
    background: var(--bad-wash);
  }

  /* A disabled box means the reader has not reached the end of the document yet, and the row has
     to look inert or it reads as a control that is ignoring them. */
  .consent-row:has(.form-checkbox:disabled) {
    background: var(--bg-sunken);
    cursor: default;
    opacity: 0.7;
  }

  .consent-row__text {
    color: var(--fg);
    font-size: var(--t-base);
    line-height: 1.45;
  }

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

  /* A row of two full-width buttons is the phone default; they only sit side by side once there
     is room for both labels without wrapping. */
  .form-actions > .btn,
  .form-actions > .btn-primary,
  .form-actions > .btn-secondary,
  .form-actions > .btn-danger {
    flex: 1 1 12rem;
  }

  @media (min-width: 520px) {
    .form-actions > .btn,
    .form-actions > .btn-primary,
    .form-actions > .btn-secondary,
    .form-actions > .btn-danger {
      flex: 0 1 auto;
    }
  }

  .form-feedback {
    display: flex;
    gap: var(--s2);
    margin-bottom: var(--s5);
    padding: var(--s3) var(--s4);
    border-left: 3px solid currentcolor;
    border-radius: 0 var(--r2) var(--r2) 0;
    font-size: var(--t-base);
    line-height: 1.5;
  }

  .form-feedback--error {
    background: var(--bad-wash);
    color: var(--bad);
  }

  .form-feedback--ok {
    background: var(--ok-wash);
    color: var(--ok);
  }

  .check-option {
    display: flex;
    align-items: center;
    gap: var(--s2);
    color: var(--fg-muted);
    font-size: var(--t-sm);
  }

  /* ==========================================================================================
     Cards
     ========================================================================================== */

  .info-card {
    position: relative;
    padding: clamp(var(--s4), 3vw, var(--s6));
    border: 1px solid var(--line);
    border-radius: var(--r4);
    background: var(--bg-raise);
    box-shadow: var(--sh1);
  }

  .info-card + .info-card,
  .info-card + .stat-grid,
  .stat-grid + .info-card,
  .dash-columns + .info-card,
  .info-card + .dash-columns {
    margin-top: var(--s5);
  }

  /* A tone is a keyline in that tone's colour on the leading edge and nothing else. Tinting the
     whole card would make a dashboard of six cards read as six warnings. */
  .info-card--events,
  .info-card--announcements,
  .info-card--security {
    border-top: 3px solid var(--card-tone, var(--accent));
  }

  .info-card--events {
    --card-tone: var(--prober);
  }

  .info-card--announcements {
    --card-tone: var(--munchkin);
  }

  .info-card--security {
    --card-tone: var(--cipher);
  }

  .info-card__head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--s3);
    margin-bottom: var(--s4);
  }

  .info-card__title {
    font-size: var(--t-lg);
    font-weight: 600;
  }

  .info-card__actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s2);
  }

  .badge {
    display: inline-flex;
    align-items: center;
    gap: var(--s2);
    padding: 0.3rem var(--s3);
    border: 1px solid var(--line);
    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.1em;
    text-transform: uppercase;
    white-space: nowrap;
  }

  /* ==========================================================================================
     Panel headings
     ========================================================================================== */

  .dash-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--s4);
    margin-bottom: var(--s6);
    padding-bottom: var(--s4);
    border-bottom: 1px solid var(--line);
  }

  .dash-head__text {
    min-width: 0;
  }

  .dash-title {
    font-size: var(--t-2xl);
    font-weight: 700;
  }

  .dash-subtitle {
    margin-top: var(--s2);
    max-width: var(--maxw-prose);
    color: var(--fg-muted);
    font-size: var(--t-base);
    line-height: 1.55;
  }

  .dash-head__actions,
  .dash-head__buttons {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s3);
  }

  .dash-columns {
    display: grid;
    gap: var(--s5);
  }

  @media (min-width: 900px) {
    .dash-columns {
      grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
      align-items: stretch;
    }

    /**
     * Two things go wrong when cards are laid side by side rather than stacked, and both are
     * fixed here rather than by weakening the stacked case.
     *
     * `.info-card + .info-card` gives a stacked card its top margin. In a grid that adjacency
     * still holds, so the second *column* inherited it and sat 24px lower than the first — the
     * two card tops visibly disagreed.
     *
     * And a trailing "All events" style action sat directly under a list of three where its
     * neighbour had a list of two, so the two links landed at different heights. Stretching the
     * cards to a common height and pushing any trailing control to the bottom lines them up.
     */
    .dash-columns > .info-card + .info-card {
      margin-top: 0;
    }

    .dash-columns > .info-card {
      display: flex;
      flex-direction: column;
    }

    .dash-columns > .info-card > .btn-link:last-child,
    .dash-columns > .info-card > .btn-primary:last-child,
    .dash-columns > .info-card > .btn-secondary:last-child {
      margin-top: auto;
      padding-top: var(--s4);
      align-self: flex-start;
    }
  }

  /* ==========================================================================================
     Numbers
     ========================================================================================== */

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

  .stat-tile {
    padding: var(--s4);
    border: 1px solid var(--line);
    border-radius: var(--r3);
    background: var(--bg-raise);
  }

  .stat-tile__label {
    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;
  }

  .stat-tile__value {
    margin-top: var(--s2);
    color: var(--fg);
    font-family: var(--font-display);
    font-size: var(--t-2xl);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    line-height: 1;
    letter-spacing: var(--track-tight);
  }

  /**
   * A tile with nothing in it yet.
   *
   * The size and weight above exist to make a number carry a card. Applied to the em dash that
   * stands in for a missing date they produce a thick black bar that reads as a redaction, and a
   * member who has attended no events gets three of them side by side. Quiet and normal-weight
   * says "not yet" instead.
   */
  .stat-tile__value--empty {
    color: var(--fg-muted);
    font-family: var(--font-sans);
    font-size: var(--t-md);
    font-weight: 400;
    letter-spacing: 0;
  }

  .stat-tile__note {
    margin-top: var(--s2);
    color: var(--fg-faint);
    font-size: var(--t-sm);
    line-height: 1.4;
  }

  .stat-grid--stamped .cache-stamp {
    margin-top: var(--s3);
  }

  .cache-stamp {
    color: var(--fg-faint);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    letter-spacing: 0.04em;
  }

  /* ==========================================================================================
     Compact lists
     ========================================================================================== */

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

  .compact-list__item {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--s1) var(--s3);
    padding: var(--s3) 0;
    border-top: 1px solid var(--line-quiet);
  }

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

  .compact-list__marker {
    grid-row: 1 / span 2;
    align-self: start;
    padding: 0.2rem var(--s2);
    border-radius: var(--r1);
    background: var(--list-tone-wash, var(--bg-sunken));
    color: var(--list-tone, var(--fg-muted));
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: 0.08em;
  }

  .compact-list--events {
    --list-tone: var(--prober-ink);
    --list-tone-wash: var(--info-wash);
  }

  .compact-list--announcements {
    --list-tone: var(--munchkin-ink);
    --list-tone-wash: var(--warn-wash);
  }

  .compact-list__title {
    color: var(--fg);
    font-size: var(--t-base);
    font-weight: 600;
    line-height: 1.35;
  }

  .compact-list__meta {
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    letter-spacing: 0.02em;
  }

  /* ==========================================================================================
     Tables
     ========================================================================================== */

  .table-container {
    border: 1px solid var(--line);
    border-radius: var(--r3);
    background: var(--bg-raise);
  }

  .dash-table {
    font-size: var(--t-base);
  }

  .dash-table th {
    padding: var(--s3) var(--s4);
    border-bottom: 1px solid var(--line);
    background: var(--bg-sunken);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: var(--track-label);
    text-align: left;
    text-transform: uppercase;
    white-space: nowrap;
  }

  .dash-table th:first-child {
    border-top-left-radius: var(--r3);
  }

  .dash-table th:last-child {
    border-top-right-radius: var(--r3);
  }

  /**
   * A sortable column heading.
   *
   * The heading itself is the control, so the button inherits the `th`'s type rather than the
   * button element's: it is monospace, micro, uppercase and tracked out like every other
   * heading in the row, and only the arrow beside it says it can be pressed. A heading that
   * suddenly looked like a `.btn-secondary` would read as an action performed *on* the column
   * rather than as the column's own label.
   *
   * `width: 100%` so the target fills the cell. The heading is short — "Grade", "Role" — and a
   * hit area the width of the word alone is a control you have to aim at, in a row of seven of
   * them.
   *
   * Not given a `min-height: 44px`: this row is a table heading rather than a form, the cells
   * already carry `--s3` of vertical padding, and forcing the touch target here would make the
   * header band taller than the rows beneath it on every table in the dashboard. Below 768px
   * `thead` is `display: none` entirely and the sort control is not reachable by touch at all,
   * which is the case that rule would have been for.
   */
  .dash-table__sort {
    display: inline-flex;
    gap: var(--s2);
    align-items: center;
    width: 100%;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    font: inherit;
    letter-spacing: inherit;
    text-align: inherit;
    text-transform: inherit;
    cursor: pointer;
  }

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

  .dash-table__sort.is-active {
    color: var(--fg);
  }

  /**
   * The arrow.
   *
   * Held at a constant width so that switching between `↕`, `↑` and `↓` cannot shift the
   * heading text beside it — a column that jumps sideways when you sort it reads as a layout
   * bug rather than as feedback. Dimmed until the column is the sorted one, so a row of seven
   * headings does not present seven equally-weighted arrows.
   */
  .dash-table__sort-arrow {
    flex: none;
    width: 1ch;
    color: var(--fg-faint);
  }

  .dash-table__sort.is-active .dash-table__sort-arrow,
  .dash-table__sort:hover .dash-table__sort-arrow {
    color: var(--accent);
  }

  .dash-table td {
    padding: var(--s3) var(--s4);
    border-top: 1px solid var(--line-quiet);
    color: var(--fg);
    vertical-align: top;
  }

  .dash-table tbody tr:first-child td {
    border-top: 0;
  }

  .dash-table .is-numeric {
    font-variant-numeric: tabular-nums;
    text-align: right;
  }

  .cell-stack {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
  }

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

  /* Two states in one cell — Active/Inactive beside Hidden on the members roster. `flex-start`
     rather than the default stretch for the reason `.row-actions` below gives: a stretched pill
     comes out as tall as the cell and reads as a control. Wraps, because the two together are
     wider than the Status column at the narrower desktop widths. */
  .cell-pills {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: var(--s2);
  }

  /* `align-items` because the default stretch turns anything shorter than a button into a button's
     worth of height — a `.dash-pill` dropped in beside one came out 46px tall with a 999px radius,
     which is a circle, and read as a second control rather than as a status. */
  .row-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s2);
  }

  /**
   * The sort control that stands in for the column headings on a phone.
   *
   * `sortControl()` in widgets.js builds it; the media query below is the reason it exists. Below
   * 768px `thead` is `display: none`, and the sort buttons live in the headings — so sorting was a
   * desktop-only feature from the moment it was added, on the tables most likely to be read at a
   * door on a phone.
   *
   * Hidden with `display: none` above the breakpoint rather than `aria-hidden`, and that is the
   * accessible choice rather than the lazy one: `display: none` takes the control out of the
   * accessibility tree *and* out of the tab order, whereas an `aria-hidden` control stays
   * keyboard-reachable while invisible — a tab stop that focuses nothing a sighted user can see.
   *
   * Ends up last in source order inside the card but is drawn above the table, because it is a
   * control *for* the table and reading order should match. It is emitted before the table in
   * `members.js` and `admin.js`, so no ordering rule is needed here.
   */
  .table-sort {
    display: none;
  }

  @media (max-width: 767px) {
    .table-sort {
      display: flex;
      align-items: center;
      gap: var(--s2);
      margin-bottom: var(--s3);
    }

    /* The select takes the slack. The label and the direction button are both a fixed few
       characters wide, and a column name is the part that varies. */
    .table-sort__select {
      flex: 1 1 auto;
      min-width: 0;
    }

    .table-sort__label {
      flex: 0 0 auto;
      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;
    }
  }

  /**
   * Below 768px the table stops being a table.
   *
   * `widgets.js` puts the column heading on every cell as `data-label`, so each row can become a
   * stack of labelled pairs with no JavaScript and no duplicate markup. A six-column attendance
   * ledger is unreadable at 320px any other way — horizontal scrolling hides the column that
   * says who the row is about, which is the one an officer is looking for.
   *
   * Each row becomes its own card rather than a hairline-separated block. Accounts and Members
   * carry six and seven columns, so a row is a dozen lines tall on a phone and a 1px rule is not
   * enough to tell where one person ends and the next begins.
   */
  @media (max-width: 767px) {
    .table-container {
      border: 0;
      border-radius: 0;
      background: none;
    }

    .dash-table thead {
      display: none;
    }

    .dash-table,
    .dash-table tbody,
    .dash-table tr,
    .dash-table td {
      display: block;
      width: auto;
    }

    .dash-table tr {
      padding: var(--s4);
      border: 1px solid var(--line);
      border-radius: var(--r3);
      background: var(--bg-raise);
    }

    .dash-table tr + tr {
      margin-top: var(--s3);
    }

    /**
     * The label sits above the value, not beside it.
     *
     * A two-column pair leaves the value about 150px at 320px, and the longest thing in these
     * tables is an STI address — `first.last@ortigas-cainta.sti.edu.ph`, forty characters with no
     * natural break in it. Beside a label it wrapped into a four-line ribbon or pushed the row
     * wide. Above the label it gets the full width of the card, and `anywhere` is what lets it
     * break at all, since `break-word` will not split a run with no spaces in it.
     */
    .dash-table td {
      display: grid;
      grid-template-columns: minmax(0, 1fr);
      gap: 0.15rem;
      padding: var(--s2) 0;
      border: 0;
      overflow-wrap: anywhere;
    }

    .dash-table td::before {
      color: var(--fg-muted);
      content: attr(data-label);
      font-family: var(--font-mono);
      font-size: var(--t-micro);
      letter-spacing: var(--track-label);
      text-transform: uppercase;
    }

    .dash-table td:empty {
      display: none;
    }

    .dash-table .is-numeric {
      text-align: left;
    }
  }

  /* Once there is room for a label and a value on one line, pair them again — it is a denser
     read, and at this width the long address still fits beside its label. */
  @media (min-width: 560px) and (max-width: 767px) {
    .dash-table td {
      grid-template-columns: 8.5rem minmax(0, 1fr);
      gap: var(--s3);
      align-items: baseline;
    }
  }

  /**
   * Above the stacking breakpoint the table scrolls rather than compresses.
   *
   * `.table-container` has carried `overflow-x: auto` for a while and it never once engaged,
   * because `table { width: 100% }` in base.css means a table can always satisfy its container
   * by shrinking columns to their min-content width instead of overflowing it. What that
   * produced was not a scrollbar but "Arwildan / Dayag" and "Senior / Developer" broken across
   * two lines each, in the two columns people actually read the table for, while the container
   * reported that everything fitted.
   *
   * `max-content` with a `min-width` floor is the pair that fixes it: the table takes the width
   * its content genuinely wants, the floor keeps a narrow table filling its card rather than
   * huddling at the left edge, and the overflow rule above finally has something to scroll.
   * Applied to `.dash-table` rather than to one column, because Accounts and Members are now
   * eight columns wide and picking a favourite to protect would just move the wrapping.
   *
   * Below 768px none of this applies — `thead` is hidden and every cell is `display: block`, so
   * there are no columns left to have widths.
   */
  @media (min-width: 768px) {
    .table-container {
      overflow-x: auto;
      overscroll-behavior-x: contain;
    }

    .dash-table {
      width: max-content;
      min-width: 100%;
    }

    /* The two columns worth protecting explicitly. A name and a job title are the identity of
       the row; everything else in these tables is a short code, a pill or a number that reads
       fine wrapped. */
    .dash-table td[data-label='Full name'],
    .dash-table td[data-label='Name'],
    .dash-table td[data-label='Title'] {
      white-space: nowrap;
    }
  }

  /* ==========================================================================================
     Detail lists
     ========================================================================================== */

  .detail-list {
    display: grid;
    gap: 0;
  }

  .detail-list__term {
    padding-top: var(--s3);
    border-top: 1px solid var(--line-quiet);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .detail-list__value {
    padding-bottom: var(--s3);
    color: var(--fg);
    font-size: var(--t-base);
    word-break: break-word;
  }

  .detail-list__term:first-child {
    border-top: 0;
    padding-top: 0;
  }

  @media (min-width: 560px) {
    .detail-list {
      grid-template-columns: minmax(9rem, auto) 1fr;
      column-gap: var(--s5);
    }

    .detail-list__term {
      grid-column: 1;
      padding-bottom: var(--s3);
    }

    .detail-list__value {
      grid-column: 2;
      padding-top: var(--s3);
      border-top: 1px solid var(--line-quiet);
    }

    .detail-list__value:nth-of-type(1) {
      border-top: 0;
      padding-top: 0;
    }
  }

  /* ==========================================================================================
     Messaging
     ========================================================================================== */

  .dash-notice {
    display: flex;
    gap: var(--s3);
    margin-bottom: var(--s5);
    padding: var(--s4);
    border: 1px solid var(--notice-tone, var(--line));
    border-left-width: 3px;
    border-radius: var(--r2);
    background: var(--notice-wash, var(--bg-sunken));
  }

  .dash-notice:last-child {
    margin-bottom: 0;
  }

  .dash-notice__icon {
    display: grid;
    flex: 0 0 auto;
    place-items: center;
    width: 1.375rem;
    height: 1.375rem;
    margin-top: 0.1rem;
    border-radius: var(--r-pill);
    background: var(--notice-tone, var(--fg-muted));
    color: var(--bg-raise);
    font-family: var(--font-mono);
    font-size: var(--t-xs);
    font-weight: 700;
    line-height: 1;
  }

  .dash-notice__body {
    min-width: 0;
  }

  .dash-notice__text {
    color: var(--fg);
    font-size: var(--t-base);
    line-height: 1.55;
  }

  .dash-notice__action {
    margin-top: var(--s3);
  }

  .dash-notice--error {
    --notice-tone: var(--bad);
    --notice-wash: var(--bad-wash);
  }

  .dash-notice--success {
    --notice-tone: var(--ok);
    --notice-wash: var(--ok-wash);
  }

  .dash-notice--warning {
    --notice-tone: var(--warn);
    --notice-wash: var(--warn-wash);
  }

  .dash-notice--info {
    --notice-tone: var(--info);
    --notice-wash: var(--info-wash);
  }

  /* Offline is its own tone rather than a reuse of warning, because it is not the club's problem
     and not the member's mistake — it is a fact about the device. The class name is fixed by
     docs/pwa-offline-contract.md. */
  .dash-notice--offline {
    --notice-tone: var(--fg-muted);
    --notice-wash: var(--bg-sunken);
  }

  .dash-pill {
    display: inline-flex;
    align-items: center;
    gap: var(--s1);
    padding: 0.2rem var(--s3);
    border: 1px solid var(--pill-tone, var(--line-strong));
    border-radius: var(--r-pill);
    background: var(--pill-wash, transparent);
    color: var(--pill-tone, var(--fg-muted));
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    white-space: nowrap;
  }

  .dash-pill--success {
    --pill-tone: var(--ok);
    --pill-wash: var(--ok-wash);
  }

  .dash-pill--warning {
    --pill-tone: var(--warn);
    --pill-wash: var(--warn-wash);
  }

  .dash-pill--danger {
    --pill-tone: var(--bad);
    --pill-wash: var(--bad-wash);
  }

  .dash-pill--info {
    --pill-tone: var(--info);
    --pill-wash: var(--info-wash);
  }

  /* ==========================================================================================
     Empty and loading states
     ========================================================================================== */

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

  .dash-empty__title {
    color: var(--fg);
    font-family: var(--font-display);
    font-size: var(--t-lg);
    font-weight: 600;
    letter-spacing: var(--track-tight);
  }

  .dash-empty__text {
    margin: var(--s2) auto 0;
    max-width: 34rem;
    color: var(--fg-muted);
    font-size: var(--t-base);
    line-height: 1.55;
  }

  .dash-empty__action {
    margin-top: var(--s4);
  }

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

  .skeleton-row {
    display: flex;
    flex-direction: column;
    gap: var(--s2);
  }

  .skeleton-bar {
    height: 0.875rem;
    border-radius: var(--r1);
    background: var(--bg-sunken);
  }

  .skeleton-bar--short {
    width: 55%;
  }

  .dash-loading {
    display: grid;
    place-items: center;
    min-height: 12rem;
    padding: var(--s6) 0;
  }

  /* The painting, at a size that reads as the club's mark rather than a favicon. Decorative:
     the status line below it is what assistive tech gets. */
  .dash-loading__logo {
    width: 8rem;
    height: 8rem;
  }

  /* ==========================================================================================
     Modal
     ========================================================================================== */

  .dash-modal {
    position: fixed;
    inset: 0;
    z-index: var(--z-modal);
    display: grid;
    place-items: center;
    padding: var(--s4);
    background: var(--scrim);
  }

  .dash-modal__panel {
    display: flex;
    flex-direction: column;
    width: min(100%, 42rem);
    max-height: min(90svh, 46rem);
    border: 1px solid var(--line);
    border-radius: var(--r4);
    background: var(--bg-raise);
    box-shadow: var(--sh3);
  }

  .dash-modal__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s3);
    padding: var(--s5) var(--s5) var(--s4);
    border-bottom: 1px solid var(--line);
  }

  .dash-modal__title {
    font-size: var(--t-xl);
  }

  .dash-modal__close {
    display: grid;
    flex: 0 0 auto;
    place-items: center;
    width: 2.75rem;
    height: 2.75rem;
    border-radius: var(--r-pill);
    color: var(--fg-muted);
    font-size: var(--t-md);
  }

  .dash-modal__close:hover {
    background: var(--bg-sunken);
    color: var(--fg);
  }

  .dash-modal__body {
    flex: 1 1 auto;
    padding: var(--s5);
    overflow-y: auto;
    overscroll-behavior: contain;
  }

  .dash-modal__actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--s3);
    padding: var(--s4) var(--s5) var(--s5);
    border-top: 1px solid var(--line);
  }

  /* ==========================================================================================
     Connection state, updates, and the live status region
     ========================================================================================== */

  /**
   * The offline strip.
   *
   * `js/pwa.js` prepends this to `<body>` on any page that does not already carry one, so it has
   * to be correct on the public pages too. It takes part in the layout rather than overlaying the
   * top of the page: an overlay would cover the first line of whatever it is warning about, and
   * on the dashboard it would sit on top of the app bar.
   */
  .offline-banner {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--s2);
    padding: var(--s2) var(--s4);
    background: var(--band-raise);
    color: #f7f2f8;
    font-size: var(--t-sm);
    font-weight: 500;
    line-height: 1.4;
    text-align: center;
  }

  .offline-banner__dot {
    flex: 0 0 auto;
    width: 0.5rem;
    height: 0.5rem;
    border-radius: var(--r-pill);
    background: var(--crust);
  }

  .sw-toast {
    position: fixed;
    right: var(--s4);
    bottom: calc(var(--s4) + env(safe-area-inset-bottom, 0px));
    left: var(--s4);
    z-index: var(--z-toast);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--s3);
    margin-inline: auto;
    width: min(100% - 2 * var(--s4), 30rem);
    padding: var(--s3) var(--s3) var(--s3) var(--s5);
    border-radius: var(--r-pill);
    border: 1px solid var(--line-strong);
    background: var(--band-raise);
    box-shadow: var(--sh3);
  }

  .sw-toast__text {
    color: #f7f2f8;
    font-size: var(--t-sm);
    font-weight: 500;
  }

  .sw-toast__actions {
    display: flex;
    gap: var(--s2);
  }

  .sw-toast__btn {
    min-height: 2.5rem;
    padding-inline: var(--s4);
    border-radius: var(--r-pill);
    color: #c3b6cd;
    font-size: var(--t-sm);
    font-weight: 600;
  }

  .sw-toast__btn:hover {
    background: rgb(255 255 255 / 0.08);
    color: #f7f2f8;
  }

  .sw-toast__btn--primary {
    background: var(--glaze);
    color: var(--ink-900);
  }

  .sw-toast__btn--primary:hover {
    background: #f7cbf9;
    color: var(--ink-900);
  }

  /**
   * The live region.
   *
   * An officer at a door is not looking at the screen, so this is the primary channel for a scan
   * result rather than a courtesy. `pointer-events: none` and no padding when empty: `announce()`
   * in dashboard.js clears the children between messages, and a permanently visible empty pill
   * floating over the scanner would cover the bottom of the camera stage.
   */
  .status-region {
    position: fixed;
    bottom: calc(var(--s5) + env(safe-area-inset-bottom, 0px));
    left: 50%;
    z-index: var(--z-toast);
    max-width: calc(100vw - 2 * var(--s4));
    transform: translateX(-50%);
    pointer-events: none;
  }

  .status-region__text {
    display: block;
    padding: var(--s3) var(--s5);
    border-radius: var(--r-pill);
    background: var(--region-tone, var(--band-raise));
    box-shadow: var(--sh3);
    color: var(--region-fg, #f7f2f8);
    font-size: var(--t-base);
    font-weight: 600;
    text-align: center;
  }

  .status-region--success {
    --region-tone: #14522f;
  }

  .status-region--error {
    --region-tone: #8c1738;
  }

  .status-region--info {
    --region-tone: var(--band-raise);
  }

  /* ==========================================================================================
     The status page
     ========================================================================================== */

  /**
   * A whole page that is one message: 404, the roster that does not exist yet, the offline
   * fallback in worker/shell.js, and the `<noscript>` block on the dashboard.
   *
   * It lives here rather than in site.css because those four callers do not share a stylesheet
   * set — dashboard.html deliberately does not load the marketing layer, so a definition there
   * would leave the JavaScript-disabled state of the portal rendering as unstyled prose. That is
   * exactly the class of bug that is invisible in the CSS and only shows up in the browser.
   */
  .status-page {
    display: grid;
    place-items: center;
    min-height: 60svh;
    padding: var(--s8) var(--gutter);
    text-align: center;
  }

  .status-panel {
    max-width: 34rem;
  }

  .status-code {
    display: block;
    margin-bottom: var(--s4);
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    font-weight: 500;
    letter-spacing: var(--track-label);
    text-transform: uppercase;
  }

  .status-panel__text,
  .status-panel .section-desc {
    margin-top: var(--s4);
    color: var(--fg-muted);
    font-size: var(--t-md);
    line-height: var(--lh-normal);
  }

  .status-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--s3);
    margin-top: var(--s6);
  }

  /* ---------------------------------------------------------------------------------------- */
  /* The constitution reader                                                                  */
  /* ---------------------------------------------------------------------------------------- */

  /**
   * Here rather than in site.css or portal.css because both halves mount it: the recruitment form
   * is on the marketing set and the gate page is on the portal set. See the layer order in
   * base.css — a class defined in one of those two is invisible to the other.
   *
   * The container scrolls rather than the page, and that is the whole mechanism the reading
   * requirement rests on. A sentinel at the end of the text is watched with the container as the
   * observer root, so "the end has been seen" is the browser's own geometry rather than a sum of
   * scroll properties that round differently at every zoom level.
   */
  .consti-reader {
    /* Tall enough to read in, short enough that the accept control below it is reachable without
       hunting for it. `svh` rather than `vh` so a phone's collapsing address bar does not leave
       the control off screen. */
    max-height: min(68svh, 46rem);
    margin-top: var(--s5);
    padding: clamp(var(--s4), 3vw, var(--s6));
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: none;
    border: 1px solid var(--line);
    border-radius: var(--r3);
    background: var(--bg-raise);
    /* A hint that there is more below, which a flat panel of justified text does not give. */
    box-shadow: inset 0 -1.5rem 1.5rem -1.5rem color-mix(in oklab, var(--fg) 12%, transparent);
  }

  .consti-reader::-webkit-scrollbar {
    display: none;
  }

  .consti-progress {
    position: sticky;
    top: 0;
    z-index: 1;
    display: grid;
    grid-template-columns: 1fr auto;
    gap: var(--s1) var(--s3);
    margin: calc(var(--s2) * -1) calc(var(--s2) * -1) var(--s5);
    padding: var(--s2);
    border-bottom: 1px solid var(--line);
    background: color-mix(in oklab, var(--bg-raise) 94%, transparent);
    backdrop-filter: blur(8px);
  }

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

  .consti-progress__value {
    color: var(--accent);
  }

  .consti-progress__track {
    grid-column: 1 / -1;
    width: 100%;
    height: 0.2rem;
    border: 0;
    overflow: hidden;
    border-radius: var(--r-pill);
    background: var(--bg-sunken);
    appearance: none;
  }

  .consti-progress__track::-webkit-progress-bar {
    border-radius: var(--r-pill);
    background: var(--bg-sunken);
  }

  .consti-progress__track::-webkit-progress-value {
    border-radius: var(--r-pill);
    background: var(--accent);
  }

  .consti-progress__track::-moz-progress-bar {
    border-radius: var(--r-pill);
    background: var(--accent);
  }

  .consti-reader:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }

  .consti-meta {
    margin: 0 0 var(--s5);
    padding-bottom: var(--s4);
    border-bottom: 1px solid var(--line);
    color: var(--fg-muted);
    font-size: var(--t-sm);
    line-height: 1.5;
  }

  /* The title page, set as one block: four short lines that are a masthead rather than four
     headings, which is also why the renderer emits paragraphs for them. */
  .consti-cover {
    margin: 0;
    color: var(--fg);
    font-family: var(--font-display);
    font-size: var(--t-md);
    font-weight: 600;
    letter-spacing: var(--track-label);
    line-height: 1.35;
    text-align: center;
    text-transform: uppercase;
  }

  .consti-cover + .consti-cover {
    margin-top: var(--s1);
    font-size: var(--t-base);
    font-weight: 500;
  }

  .consti-heading {
    margin: var(--s6) 0 var(--s3);
    color: var(--fg);
    font-size: var(--t-md);
    letter-spacing: var(--track-label);
    text-align: center;
    text-transform: uppercase;
  }

  /* Number over title, one heading. Centred because the printed document centres them, and this
     is a transcription rather than a redesign. */
  .consti-article {
    display: flex;
    flex-direction: column;
    gap: var(--s1);
    margin: var(--s7) 0 var(--s4);
    padding-top: var(--s5);
    border-top: 1px solid var(--line);
    text-align: center;
  }

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

  .consti-article__title {
    color: var(--fg);
    font-size: var(--t-lg);
    line-height: 1.25;
  }

  .consti-para,
  .consti-section {
    margin: 0 0 var(--s4);
    color: var(--fg);
    font-size: var(--t-base);
    line-height: 1.6;
  }

  .consti-section__label {
    color: var(--fg);
    font-weight: 600;
  }

  .consti-items {
    margin: 0 0 var(--s4);
    padding-left: var(--s5);
    list-style: none;
  }

  .consti-item {
    margin-bottom: var(--s3);
    color: var(--fg);
    font-size: var(--t-base);
    line-height: 1.6;
  }

  .consti-item__label {
    color: var(--accent);
    font-family: var(--font-mono);
    font-size: var(--t-sm);
    font-weight: 500;
  }

  /* The three bullets in Article II are printed with a dot rather than a number, so they keep
     one — as a marker the list itself owns, not a character in the text. */
  .consti-item--dot {
    position: relative;
  }

  .consti-item--dot::before {
    position: absolute;
    left: calc(var(--s4) * -1);
    color: var(--accent);
    /* The empty alternative text is the point of the slash syntax: the dot is a marker the list
       owns, and without it a screen reader reads "black circle" before every one of these three
       items. */
    content: '\25CF' / '';
    font-size: 0.6em;
    line-height: 2.6;
  }

  .consti-revisions {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: var(--s2) var(--s4);
    margin: 0 0 var(--s5);
    padding: var(--s4);
    border-radius: var(--r2);
    background: var(--bg-sunken);
    font-size: var(--t-sm);
  }

  .consti-revisions__label {
    margin: 0;
    color: var(--fg-muted);
  }

  .consti-revisions__date {
    margin: 0;
    color: var(--fg);
    font-family: var(--font-mono);
    text-align: right;
  }

  .consti-signatures {
    width: 100%;
    border-collapse: collapse;
    font-size: var(--t-sm);
  }

  .consti-signatures__caption {
    margin-bottom: var(--s2);
    color: var(--fg-muted);
    font-family: var(--font-mono);
    font-size: var(--t-micro);
    letter-spacing: var(--track-label);
    text-align: left;
    text-transform: uppercase;
  }

  .consti-signatures th,
  .consti-signatures td {
    padding: var(--s2) var(--s3);
    border-bottom: 1px solid var(--line);
    color: var(--fg);
    text-align: left;
    vertical-align: top;
  }

  .consti-signatures thead th {
    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;
  }

  /* The observer's target. It needs height, because an element with none can never be 90%
     visible and the accept control would stay disabled forever. */
  .consti-end {
    height: var(--s5);
  }

  .consti-accept {
    display: flex;
    flex-direction: column;
    gap: var(--s4);
    margin-top: var(--s5);
    margin-bottom: var(--s8);
  }

  .consti-accept .btn {
    align-self: flex-start;
  }

  .consti-accept__done {
    margin: 0;
    color: var(--ok);
    font-size: var(--t-base);
  }

  /* The page's own shell, distinct from the reader inside it. */
  .consti {
    padding: clamp(var(--s6), 5vw, var(--s8)) 0 0;
  }

  .consti .container {
    max-width: 52rem;
  }

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

  .consti h1 {
    margin-bottom: var(--s4);
    font-size: var(--t-2xl);
  }

  .consti .lede {
    max-width: var(--maxw-prose);
    margin-top: var(--s4);
  }

  #constiIntro {
    display: grid;
    gap: var(--s3);
  }

  .turnstile-dialog {
    inset: 50% auto auto 50%;
    margin: 0;
    width: min(calc(100vw - 2rem), 26rem);
    transform: translate(-50%, -50%);
    padding: 0;
    overflow: hidden;
    border: 1px solid var(--line);
    border-radius: var(--r4);
    background: var(--bg-raise);
    box-shadow: var(--sh3);
    color: var(--fg);
  }

  .turnstile-dialog::backdrop {
    background: var(--scrim);
  }

  .turnstile-dialog__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s3);
    padding: var(--s4) var(--s5);
    border-bottom: 1px solid var(--line);
  }

  .turnstile-dialog__head h2 {
    margin: 0;
    font-size: var(--t-lg);
  }

  .turnstile-dialog__body {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: var(--s5) var(--s4);
    min-height: 9rem;
    gap: var(--s3);
  }

  .turnstile-dialog__hint {
    margin: 0;
    font-size: var(--t-sm);
    color: var(--fg-muted);
    text-align: center;
  }
}

