/* Govably canonical components — the classes new UX should reach for.
 *
 * These coexist with Tailwind rather than replacing it: `class="gv-btn gv-btn--primary"`
 * instead of nine utilities that the next screen will spell differently. The app has 753
 * <button> literals in app.js, each with a hand-written class string, and no .btn class of
 * any kind in frontend/app/ — this is the file that gap goes into.
 *
 * Modelled on the .rf-* block in styles.css, which is already exactly this pattern, just
 * scoped to one surface. Naming follows its BEM double-dash convention.
 *
 * Hard rule, enforced by tools/checks/check-design-tokens.js: no literal colors, shadows,
 * radii, font sizes, z-indexes or spacing values in this file. Every one is a var(--gv-*).
 * If a value you need has no token, add the token — do not inline it here.
 *
 * Existing code is NOT being migrated (AGENTS.md §0). Every class here is additive.
 * Documented at /design/components.html.
 */

/* ── Focus ────────────────────────────────────────────────────────────────────
   One ring for everything interactive. :focus-visible, not :focus, so a mouse click
   does not leave a ring behind but a Tab does — the keyboard reachability AGENTS.md §7
   requires, without the visual noise that gets people to delete focus styles. */
.gv-btn:focus-visible,
.gv-check:focus-visible,
.gv-input:focus-visible,
.gv-select:focus-visible,
.gv-textarea:focus-visible,
.gv-readfield--action:focus-visible,
.gv-tabs__tab:focus-visible,
.gv-table th button:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}

/* ── Button ─────────────────────────────────────────────────────────────────── */
.gv-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--gv-space-2);
    /* The compact geometry the app's toolbars already use: 8/12 padding, not 8/16.
       A row of these sits in a toolbar beside a count and a title, so they have to
       take the width the label needs and no more. */
    padding: var(--gv-space-2) var(--gv-space-3);
    border: 1px solid transparent;
    border-radius: var(--gv-radius-md);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    font-weight: var(--gv-weight-semibold);
    line-height: var(--gv-leading-tight);
    white-space: nowrap;
    cursor: pointer;
    text-decoration: none;
    transition: background-color var(--gv-dur-fast) var(--gv-ease),
                border-color var(--gv-dur-fast) var(--gv-ease),
                color var(--gv-dur-fast) var(--gv-ease);
}

.gv-btn:disabled,
.gv-btn[aria-disabled="true"] {
    cursor: not-allowed;
    opacity: 0.55;
}

/* No filled slab. Every button in this system is a white surface with a rule around it;
   what changes between them is the colour of that rule and the label. Primary carries the
   brand rule and brand text, so it leads a group by being the only coloured thing in it —
   not by weighing twice what its neighbours weigh. Filling it made a toolbar look like it
   had a submit button parked in it, and made two adjacent primaries fight.

   brand-700 rather than 600: brand-700 on white is 6.33:1 and clears AA, brand-600 does
   not. The app had five different brand shades for what is meant to be one button. */
.gv-btn--primary {
    background: var(--gv-surface);
    border-color: var(--gv-brand-700);
    color: var(--gv-brand-700);
}
.gv-btn--primary:hover:not(:disabled) {
    background: var(--gv-brand-50);
    border-color: var(--gv-brand-800);
    color: var(--gv-brand-800);
}

/* The default button in this app, and the one most toolbars are made of. Its border is
   the light rule, not the strong one: a toolbar holds four or five of these in a row and
   a strong rule on each turns the bar into a grid. Text is ink-700 rather than --gv-text,
   because full-strength ink on a white button reads as heavily as a heading. */
.gv-btn--secondary {
    background: var(--gv-surface);
    border-color: var(--gv-border);
    color: var(--gv-ink-700);
}
.gv-btn--secondary:hover:not(:disabled) {
    background: var(--gv-surface-sunk);
    border-color: var(--gv-border-strong);
}

.gv-btn--ghost {
    background: transparent;
    border-color: transparent;
    color: var(--gv-text-muted);
}
.gv-btn--ghost:hover:not(:disabled) {
    background: var(--gv-surface-sunk);
    color: var(--gv-text);
}

/* Icon only. The label moves to `title`, so the word arrives on hover as a tooltip
   instead of the button growing and shoving its neighbours along. An icon-only control
   MUST still carry aria-label — `title` is not an accessible name a screen reader can be
   relied on to read, and it never reaches a touch user at all. Square, so a pair of them
   reads as a pair. */
/* .gv-btn.gv-btn--icon, not .gv-btn--icon: the size modifiers are declared later in this
   file and win at equal specificity, so their horizontal padding (24px total) survived
   and box-sizing then held the element open at 26px however narrow the width said. */
.gv-btn.gv-btn--icon {
    /* Both sides from the same value, or it is a rectangle pretending to be an icon
       button — the height comes from padding otherwise and lands ~6px under the width.
       0.8 of the small stop matches the scale the play control on this row uses. */
    width: calc(var(--gv-control-sm) * 0.8);
    min-width: calc(var(--gv-control-sm) * 0.8);
    height: calc(var(--gv-control-sm) * 0.8);
    padding: 0;
    gap: 0;
}

/* Quiet until you reach for it. A destructive action that sits beside an everyday one —
   Delete next to Edit on the motion — should not out-shout it: a solid red button makes
   the eye land on the irreversible thing first. At rest it reads like Edit; on hover the
   outline and the label turn danger-red so the intent is unmistakable before the click.
   The base .gv-btn already carries a 1px transparent border, so nothing shifts when it
   appears. #b91c1c is 6.47:1 on white — AA as text, and well past the 3:1 a boundary
   needs. */
.gv-btn--ghost-danger {
    background: transparent;
    border-color: transparent;
    color: var(--gv-text-muted);
}
.gv-btn--ghost-danger:hover:not(:disabled) {
    background: transparent;
    border-color: var(--gv-danger-fg);
    color: var(--gv-danger-fg);
}

/* A destructive action that lives in a toolbar rather than at the end of a dialog —
   Reset beside Bulk Assign. It is a secondary button that happens to be red: same white
   surface and light rule as its neighbours, so it does not shout across the bar, but red
   from the moment it is on screen rather than only under the cursor. That matters — a
   control that looks ordinary until you hover it has already been clicked by someone who
   never hovered. --gv-btn--danger stays for the filled, committing button at the end of a
   confirm; this is the one you put next to other buttons. */
.gv-btn--danger-quiet {
    background: var(--gv-surface);
    border-color: var(--gv-border);
    color: var(--gv-danger-fg);
}
.gv-btn--danger-quiet:hover:not(:disabled) {
    background: var(--gv-danger-bg);
    border-color: var(--gv-danger-border);
}

/* The committing destructive button — the one at the end of a confirm, where deleting is
   the whole point of the dialog. Same white surface as everything else; it earns its
   weight from a red rule AND red text, where --danger-quiet has red text on a neutral
   rule. So the pair reads in order: a Reset sitting in a toolbar is quieter than a Delete
   you were asked to confirm, without either becoming a slab. */
.gv-btn--danger {
    background: var(--gv-surface);
    border-color: var(--gv-danger-fg);
    color: var(--gv-danger-fg);
}
.gv-btn--danger:hover:not(:disabled) {
    background: var(--gv-danger-bg);
    border-color: var(--gv-danger-fg);
}

/* AI. Its own register — "this spends an AI call, and may take a minute" should be
   legible before the label is read. It carries that in the gold rule and gold label,
   not in a fill: it is the same outlined shape as everything else here, and hovers the
   same way, so it sits in a toolbar as a peer rather than as the heaviest thing in it.

   Sets --gov-ai-body / --gov-ai-ink for the nib icon (the #gov-ai-icon-style block in
   app.html reads those). On a white ground the nib wants its documented colours, so
   these hand back the brand green body and a readable gold ink. A caller passing
   govablyAiIcon({body,ink}) writes inline custom properties on the icon span and still
   overrides these — it should not need to. */
.gv-btn--ai {
    background: var(--gv-surface);
    border-color: var(--gv-ai-fg);
    color: var(--gv-ai-fg);
    --gov-ai-body: var(--gv-ai-nib);
    --gov-ai-ink: var(--gv-ai-ink);
}
.gv-btn--ai:hover:not(:disabled) {
    background: var(--gv-ai-bg);
    border-color: var(--gv-ai-fg-hover);
    color: var(--gv-ai-fg-hover);
}

.gv-btn--sm {
    padding: var(--gv-space-1) var(--gv-space-3);
    font-size: var(--gv-text-xs);
}
.gv-btn--lg {
    padding: var(--gv-space-3) var(--gv-space-5);
    font-size: var(--gv-text-base);
}

/* A secondary action whose job is already done, and which stays clickable to do it
   again — "VoicePrint trained", click to re-train. The icon confirms it; the label says
   so too, because colour must never carry the meaning alone. */
.gv-btn.is-done i { color: var(--gv-success-fg); }

/* Busy state. AGENTS.md §5: async UI needs in-progress, success and failure — this is
   the first of the three, and it disables the control so a second click can't fire. */
.gv-btn.is-busy {
    cursor: progress;
    pointer-events: none;
}

/* ── Form controls ──────────────────────────────────────────────────────────── */
.gv-label {
    display: block;
    margin-bottom: var(--gv-space-1);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-semibold);
    color: var(--gv-text-muted);
}

.gv-input,
.gv-select,
.gv-textarea {
    display: block;
    width: 100%;
    padding: var(--gv-space-2) var(--gv-space-3);
    border: 1px solid var(--gv-border-strong);
    border-radius: var(--gv-radius-md);
    background: var(--gv-surface);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text);
    transition: border-color var(--gv-dur-fast) var(--gv-ease);
}
.gv-input::placeholder,
.gv-textarea::placeholder {
    color: var(--gv-text-disabled);
}
.gv-input:hover:not(:disabled),
.gv-select:hover:not(:disabled),
.gv-textarea:hover:not(:disabled) {
    border-color: var(--gv-ink-400);
}
.gv-input:disabled,
.gv-select:disabled,
.gv-textarea:disabled {
    background: var(--gv-surface-sunk);
    color: var(--gv-text-disabled);
    cursor: not-allowed;
}
.gv-textarea {
    min-height: var(--gv-space-10);
    resize: vertical;
}

/* Invalid is signalled by border AND text, never by color alone — a red border on its
   own is invisible to a red-green colorblind clerk. */
.gv-input[aria-invalid="true"],
.gv-select[aria-invalid="true"],
.gv-textarea[aria-invalid="true"] {
    border-color: var(--gv-danger-fg);
}
.gv-field-error {
    display: flex;
    align-items: center;
    gap: var(--gv-space-1);
    margin-top: var(--gv-space-1);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    color: var(--gv-danger-fg);
}

/* The checkbox is the native control, painted. accent-color is the whole trick — no
   custom SVG and no pseudo-element, so the box keeps the platform's own hit behaviour,
   its indeterminate state, its high-contrast-mode rendering, and stays a real checkbox
   to a screen reader. Anything that redraws the tick gives all of that up to change a
   colour.

   Six call sites in app.js typed this into a style attribute and the size drifted to two
   values before anyone noticed (#428), which is the argument for the class rather than a
   seventh copy. */
.gv-check {
    width: var(--gv-control-3xs);
    height: var(--gv-control-3xs);
    accent-color: var(--gv-brand-900);
    cursor: pointer;
    flex-shrink: 0;
}

/* The label that wraps it, which every one of those six sites also hand-wrote. It is not
   decoration: wrapping the text is what makes the whole row toggle, and on the dense
   lists this appears in — a board list, an attendance roster — the box on its own is a
   14px target.

   Layout only. No font-size, colour or weight: every call site sets those with Tailwind
   utilities that differ by surface (11px medium in the form builder, 13px regular in
   board settings), and a weight here would fight `font-medium` on equal specificity with
   the winner decided by stylesheet order. */
.gv-check-row {
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
    cursor: pointer;
    user-select: none;
}

/* ── Card ───────────────────────────────────────────────────────────────────── */
.gv-card {
    background: var(--gv-surface);
    border: 1px solid var(--gv-border);
    border-radius: var(--gv-radius-lg);
    box-shadow: var(--gv-shadow-sm);
    overflow: hidden;
}
.gv-card__head {
    display: flex;
    align-items: center;
    gap: var(--gv-space-3);
    padding: var(--gv-space-3) var(--gv-space-4);
    border-bottom: 1px solid var(--gv-border);
}
/* A BAND's head carries a sentence beside its heading, and a sentence wraps. Centred,
   the heading and its disclosure sink half a line each time the text grows, so a
   two-line motion leaves the caret lower than the words it opens. Top-aligned, the
   caret, the heading, the play control and the first line of the sentence all start
   level and stay there. Plain cards keep centre: their head holds a title, not prose. */
.gv-card--band > .gv-card__head { align-items: flex-start; }
/* ...unless the head holds no prose. The rule above exists for a head with a SENTENCE
   beside its heading; where the mid slot is a short label — a board name, a count — there
   is nothing to wrap, and top-aligning leaves the caret and the icon sitting above the
   words they belong to instead of on their line. This is the "plain cards keep centre"
   case the note above names, on a band. Written as a child selector so it outweighs that
   rule rather than depending on which of the two comes last. */
.gv-card--band > .gv-card__head--center { align-items: center; }

.gv-card__title {
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-base);
    font-weight: var(--gv-weight-semibold);
    color: var(--gv-text);
    margin: 0;
}
.gv-card__body {
    padding: var(--gv-space-4);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-base);
    color: var(--gv-text);
}
.gv-card__foot {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--gv-space-2);
    padding: var(--gv-space-3) var(--gv-space-4);
    border-top: 1px solid var(--gv-border);
    background: var(--gv-surface-sunk);
}

/* ── Modal ────────────────────────────────────────────────────────────────────
   Built by GV.modal in /shared/gv-ui.js. Hand-writing these classes is fine, but the
   helper is what supplies the focus trap, Escape handling and focus restoration that
   none of the app's 43 existing overlays have. */
.gv-modal {
    position: fixed;
    inset: 0;
    z-index: var(--gv-z-modal);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--gv-space-4);
    background: var(--gv-scrim);
}
.gv-modal__panel {
    display: flex;
    flex-direction: column;
    width: 100%;
    max-width: 32rem;
    max-height: 100%;
    background: var(--gv-surface);
    border-radius: var(--gv-radius-lg);
    box-shadow: var(--gv-shadow-xl);
    overflow: hidden;
}
.gv-modal__panel--sm { max-width: 24rem; }
.gv-modal__panel--lg { max-width: 48rem; }
.gv-modal__panel--xl { max-width: 64rem; }

.gv-modal__head {
    display: flex;
    align-items: center;
    gap: var(--gv-space-3);
    padding: var(--gv-space-4);
    border-bottom: 1px solid var(--gv-border);
}
.gv-modal__title {
    flex: 1;
    min-width: 0;
    margin: 0;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-md);
    font-weight: var(--gv-weight-semibold);
    color: var(--gv-text);
}
.gv-modal__body {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    padding: var(--gv-space-4);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-base);
    color: var(--gv-text);
}
.gv-modal__foot {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--gv-space-2);
    padding: var(--gv-space-3) var(--gv-space-4);
    border-top: 1px solid var(--gv-border);
    background: var(--gv-surface-sunk);
}

/* ── Badge ────────────────────────────────────────────────────────────────────
   One pill, six semantics. The app has eight independent badge systems today,
   including _stagePillCls and _stateBadgeCls, which take the same input and return
   different colors. */
.gv-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-1);
    padding: var(--gv-space-1) var(--gv-space-2);
    border: 1px solid var(--gv-neutral-border);
    border-radius: var(--gv-radius-full);
    background: var(--gv-neutral-bg);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-2xs);
    font-weight: var(--gv-weight-semibold);
    line-height: var(--gv-leading-tight);
    color: var(--gv-neutral-fg);
    white-space: nowrap;
}
/* A dot alongside the word, for status pills in a step bar. Decoration — the word is
   what carries the meaning, so the dot inherits currentColor and is never the only cue. */
.gv-badge__dot {
    width: var(--gv-space-2);
    height: var(--gv-space-2);
    border-radius: var(--gv-radius-full);
    background: currentColor;
    flex: 0 0 auto;
}

.gv-badge--brand   { background: var(--gv-brand-bg);   border-color: var(--gv-brand-border);   color: var(--gv-brand-fg); }
.gv-badge--success { background: var(--gv-success-bg); border-color: var(--gv-success-border); color: var(--gv-success-fg); }
.gv-badge--warning { background: var(--gv-warning-bg); border-color: var(--gv-warning-border); color: var(--gv-warning-fg); }
.gv-badge--danger  { background: var(--gv-danger-bg);  border-color: var(--gv-danger-border);  color: var(--gv-danger-fg); }
.gv-badge--info    { background: var(--gv-info-bg);    border-color: var(--gv-info-border);    color: var(--gv-info-fg); }

/* The meeting header's type badge (#420). No tone modifier on purpose: a special meeting
   is a category, not a warning, and it is the only badge in that header — the word carries
   it without borrowing an alarm colour. A standard meeting shows NO badge, which is the
   whole point of the ticket, so this element is hidden far more often than it is shown.

   Both rules earn their place:
     - it sits beside an h1 that truncates, so it must never be what gives way;
     - .gv-badge sets display:inline-flex and Tailwind's .hidden sets display:none, both
       single-class selectors, so the winner is source order alone. Measured, the Play CDN
       (app.html:20) injects its sheet AFTER these, so .hidden already wins and one class
       would do. This rule is for the day that stops being true: swapping the CDN for a
       built stylesheet linked above components.css is the documented direction, and it
       would silently turn every standard meeting's hidden badge into an empty pill. Two
       classes wins either way, and check-meeting-header-board.js asserts it stays. */
.gv-badge--meeting-type { flex: 0 0 auto; }
.gv-badge--meeting-type.hidden { display: none; }

/* ── Table ──────────────────────────────────────────────────────────────────── */
.gv-table {
    width: 100%;
    border-collapse: collapse;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    color: var(--gv-text);
}
/* COLUMN headers only. A <th scope="row"> is a row's name — a member, an item — and
   must not be rendered as uppercase micro-caps like a column label. */
.gv-table thead th {
    padding: var(--gv-space-2) var(--gv-space-3);
    border-bottom: 1px solid var(--gv-border-strong);
    text-align: left;
    font-size: var(--gv-text-2xs);
    font-weight: var(--gv-weight-semibold);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--gv-text-muted);
    white-space: nowrap;
}
.gv-table tbody th {
    padding: var(--gv-space-3);
    border-bottom: 1px solid var(--gv-border);
    text-align: left;
    font-size: var(--gv-text-sm);
    font-weight: var(--gv-weight-medium);
    color: var(--gv-text);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.gv-table td {
    padding: var(--gv-space-3);
    border-bottom: 1px solid var(--gv-border);
    vertical-align: top;
}
/* The last row keeps its rule, and every cell in it does. This suppressed the border on
   the last row's <td>s only — the row HEADER has its own border-bottom and never got the
   matching override — so the final line ran under the Member column and stopped dead at
   the first data cell. Either both or neither; the roll call wants both, because the
   quick tallies now sit under the table and need separating from it. */
.gv-table tbody tr:hover td { background: var(--gv-surface-sunk); }
.gv-table__num { text-align: right; font-variant-numeric: tabular-nums; }

/* ── Tabs ─────────────────────────────────────────────────────────────────────
   Resting appearance lives here, not in a JS path — AGENTS.md §3, and the reason
   check-meeting-tab-strip.js exists. A tab strip rendered without its controller
   running must still look like a tab strip. */
/* ── Filter chips ─────────────────────────────────────────────────────────────
   One row of them narrows a long list. The selected chip is filled rather than
   merely tinted, because on a toolbar of five it has to be findable at a glance
   without reading them all.

   The count sits in its own pill so the number never runs into the label, and it is
   aria-hidden because the accessible name already carries it as words.             */
.gv-chips {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gv-space-2);
}
.gv-chip {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-2);
    padding: var(--gv-space-1) var(--gv-space-3);
    border: var(--gv-border-width) solid var(--gv-border);
    border-radius: var(--gv-radius-full);
    background: var(--gv-surface);
    color: var(--gv-text-muted);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    font-weight: var(--gv-weight-semibold);
    line-height: var(--gv-leading-snug);
    cursor: pointer;
    white-space: nowrap;
    transition: background-color var(--gv-dur-fast) var(--gv-ease),
                border-color var(--gv-dur-fast) var(--gv-ease);
}
.gv-chip:hover:not(:disabled) { border-color: var(--gv-brand-300); color: var(--gv-text); }
.gv-chip:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
/* Weight and fill, not colour alone: it has to survive a monochrome printout and a
   red-green reader, same rule as every other selected state here. */
.gv-chip[aria-pressed="true"] {
    border-color: var(--gv-brand-700);
    background: var(--gv-brand-50);
    color: var(--gv-brand-fg);
}
.gv-chip__count {
    padding: 0 var(--gv-space-2);
    border-radius: var(--gv-radius-full);
    background: var(--gv-surface-sunk);
    color: var(--gv-text-subtle);
    font-size: var(--gv-text-2xs);
    font-variant-numeric: tabular-nums;
}
.gv-chip[aria-pressed="true"] .gv-chip__count {
    background: var(--gv-surface);
    color: var(--gv-brand-fg);
}
/* A chip whose rows are the ones still owing an answer. Selected, it wears the same
   --gv-warning .gv-row.is-flagged draws down the left of those rows, so the filter and
   what it filters to are visibly one thing — on a screen where brand green now means
   settled, the control for "show me what is not" cannot be green.

   The border is that token exactly; the LABEL is not. --gv-warning is 2.15:1 on white —
   fine for a 4px edge, nowhere near enough for words — so the text takes --gv-warning-fg
   at 4.84:1 on the fill. Fill and weight carry the state alongside the border, the same
   bargain the brand chips make, which is what keeps the pressed state legible without
   asking a 2.15:1 line to prove it.

   Nothing for the chip AT REST: a tone says what SELECTING this chip means, and three
   differently-coloured chips sitting idle is a legend nobody asked for. Hover is the one
   exception, below — see #396. */
.gv-chip--warning[aria-pressed="true"] {
    border-color: var(--gv-warning);
    background: var(--gv-warning-bg);
    color: var(--gv-warning-fg);
}
.gv-chip--warning[aria-pressed="true"] .gv-chip__count {
    background: var(--gv-surface);
    color: var(--gv-warning-fg);
}
/* Under the pointer, unselected, it previews the colour selecting it would give. The tone
   stays quiet at rest for the reason above — but hovering IS the question the tone answers,
   so this is the one state where showing it says something the grey cannot (#396).

   Three rules now claim a warning chip's border, and they are kept disjoint BY STATE
   rather than by source order, because two of them are the same weight:

     .gv-chip:hover:not(:disabled)                                     0,3,0   base, green
     .gv-chip--warning:hover:not(:disabled):not([aria-pressed="true"]) 0,4,0   this one
     .gv-chip--warning[aria-pressed="true"]:hover:not(:disabled)       0,4,0   below

   The :not([aria-pressed]) is what separates the last two. Without it they differ only in
   where they sit in this file, which is how .gv-pick and the pressed chip both broke. */
.gv-chip--warning:hover:not(:disabled):not([aria-pressed="true"]) {
    border-color: var(--gv-warning);
    color: var(--gv-warning-fg);
}
/* And it stays orange under the pointer. `.gv-chip:hover:not(:disabled)` is 0,3,0 against
   the pressed rule's 0,2,0, so hover won and painted this chip's border --gv-brand-300 —
   the selected Unassigned chip turned GREEN the moment you touched it, which is the one
   thing the tone exists to prevent. Written at 0,4,0 rather than reordered: source order
   cannot settle a specificity difference, the same reason .gv-pick's hover excludes the
   chosen one instead of moving below it.

   It still answers the pointer — the ring darkens to the readable stop — because clicking
   a selected chip is how you leave the filter, so it has to look pressable. Scoped to this
   modifier: the brand chips have the same flaw, and fixing them is a change to Discussions
   and Motions that does not belong on a Speakers branch. */
.gv-chip--warning[aria-pressed="true"]:hover:not(:disabled) {
    border-color: var(--gv-warning-fg);
    color: var(--gv-warning-fg);
}

.gv-tabs {
    display: flex;
    align-items: center;
    gap: var(--gv-space-1);
    border-bottom: 1px solid var(--gv-border);
}
.gv-tabs__tab {
    padding: var(--gv-space-2) var(--gv-space-3);
    border: none;
    border-bottom: 2px solid transparent;
    background: transparent;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    font-weight: var(--gv-weight-medium);
    color: var(--gv-text-muted);
    cursor: pointer;
    white-space: nowrap;
    transition: color var(--gv-dur-fast) var(--gv-ease),
                border-color var(--gv-dur-fast) var(--gv-ease);
}
.gv-tabs__tab:hover { color: var(--gv-text); }
.gv-tabs__tab[aria-selected="true"] {
    color: var(--gv-brand-900);
    border-bottom-color: var(--gv-brand-700);
    font-weight: var(--gv-weight-semibold);
}

/* ── Empty state ──────────────────────────────────────────────────────────────
   app.js has ~47 hand-written "No … yet" blocks, drifting between py-10 and py-12. */
.gv-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--gv-space-2);
    padding: var(--gv-space-9) var(--gv-space-4);
    text-align: center;
    font-family: var(--gv-font-ui);
    color: var(--gv-text-subtle);
}
/* Compact: an empty state INSIDE a panel, where the full-height version would be
   more padding than the panel it sits in. */
.gv-empty--inline {
    align-items: flex-start;
    padding: var(--gv-space-2) var(--gv-space-1);
    text-align: left;
    font-size: var(--gv-text-2xs);
    font-style: italic;
}

.gv-empty__icon  { font-size: var(--gv-text-2xl); color: var(--gv-ink-300); }
.gv-empty__title { font-size: var(--gv-text-base); font-weight: var(--gv-weight-semibold); color: var(--gv-text-muted); }
.gv-empty__desc  { font-size: var(--gv-text-sm); max-width: 26rem; line-height: var(--gv-leading-base); }

/* ── Spinner and skeleton ───────────────────────────────────────────────────── */
.gv-spinner {
    display: inline-block;
    width: var(--gv-space-4);
    height: var(--gv-space-4);
    border: 2px solid currentColor;
    border-right-color: transparent;
    border-radius: var(--gv-radius-full);
    animation: gv-spin var(--gv-dur-spin) linear infinite;
    vertical-align: -0.15em;
}
@keyframes gv-spin { to { transform: rotate(360deg); } }

.gv-skeleton {
    background: linear-gradient(90deg, var(--gv-ink-100), var(--gv-ink-50), var(--gv-ink-100));
    background-size: 200% 100%;
    border-radius: var(--gv-radius-sm);
    animation: gv-shimmer 1200ms linear infinite;
}
@keyframes gv-shimmer { to { background-position: -200% 0; } }

/* AGENTS.md §7: anything that animates honours prefers-reduced-motion. The spinner
   still needs to read as "working", so it steps rather than stopping. */
@media (prefers-reduced-motion: reduce) {
    .gv-btn,
    .gv-input,
    .gv-select,
    .gv-textarea,
    .gv-tabs__tab {
        transition: none;
    }
    .gv-spinner  { animation-duration: 2s; animation-timing-function: steps(8); }
    .gv-skeleton { animation: none; }
}

/* ── Step bar ─────────────────────────────────────────────────────────────────
   The action bar at the top of every meeting-workflow step: Review Agenda,
   Recording, Speakers, Discussions, Votes, Document, Presentation.

   Anatomy is fixed — status on the left, actions on the right — because that is
   what all seven already do, and a clerk moving between steps should not have to
   re-find the controls. app.js:11544 already declared this intent with
   MW_TOOLBAR_CARD_CLASS ("other tabs' toolbars point at these same constants
   instead of keeping their own near-duplicate class strings that drift out of
   sync over time"); in practice one tab used it and the rest drifted anyway.
   Those constants now point here.

   Build one with GV.stepBar() rather than assembling the markup — the helper is
   where the state-to-pill mapping and the read-only handling live.

       <div class="gv-stepbar">
         <div class="gv-stepbar__main"> pill + title + meta </div>
         <div class="gv-stepbar__actions"> buttons </div>
       </div>                                                                  */
.gv-stepbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--gv-space-3);
    padding: var(--gv-space-3);
    margin-bottom: var(--gv-space-4);
    background: var(--gv-surface);
    border: 1px solid var(--gv-border);
    border-radius: var(--gv-radius-lg);
    box-shadow: var(--gv-shadow-sm);
}

.gv-stepbar__main {
    display: flex;
    align-items: center;
    gap: var(--gv-space-3);
    min-width: 0;
}

.gv-stepbar__text { min-width: 0; }

.gv-stepbar__title {
    margin: 0;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    font-weight: var(--gv-weight-semibold);
    line-height: var(--gv-leading-tight);
    color: var(--gv-text);
}

/* One line, truncated. A step's description is orientation, not content — letting it
   wrap pushes the whole bar taller and shoves the actions onto a second row. */
.gv-stepbar__meta {
    margin: 0;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text-subtle);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

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

/* Read-only. AGENTS.md §4: hiding a control is a courtesy, firestore.rules is the
   boundary — but a clerk who cannot act should be told why, not shown nothing. */
.gv-stepbar__note {
    display: flex;
    align-items: center;
    gap: var(--gv-space-1);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    color: var(--gv-text-subtle);
}

/* Blocked — the step cannot run yet because an earlier one has not finished. The bar
   stays legible rather than greying out wholesale, so the reason is still readable. */
.gv-stepbar--blocked { background: var(--gv-surface-sunk); }
.gv-stepbar--blocked .gv-stepbar__title { color: var(--gv-text-muted); }

@media (max-width: 640px) {
    .gv-stepbar { align-items: flex-start; }
    .gv-stepbar__main { flex: 1 1 100%; }
    .gv-stepbar__actions { margin-left: 0; flex: 1 1 100%; }
}

/* ── AI nib icon ──────────────────────────────────────────────────────────────
   The angled-writing nib that marks an AI action, replacing the generic wand so an
   AI action is recognisably Govably's. Body = brand green, ink stroke + spark =
   brand gold; the ink redraws and the spark pulses on hover.

   Lived in a <style id="gov-ai-icon-style"> block in app.html, which meant the
   design system could not show the AI button honestly — the markup was in app.js
   and the styling in app.html, and /design/ loads neither. It was also copied into
   two demo pages. Markup is now GV.aiIcon(); app.js's govablyAiIcon() delegates to
   it. (demo/agenda/index.html keeps its own standalone copy — that page is a
   self-contained reimplementation and is out of this system's scope.)

   --gov-ai-body / --gov-ai-ink are the override hooks. .gv-btn--ai sets both,
   because the default gold ink is 2.06:1 on that fill. */
.gov-ai-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1em;
    height: 1em;
    line-height: 0;
    vertical-align: -0.14em;
}
.gov-ai-icon svg { width: 100%; height: 100%; overflow: visible; }
.gov-ai-icon .nib-body {
    fill: none;
    stroke: var(--gov-ai-body, var(--gv-brand-900));
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
}
.gov-ai-icon .nib-ink {
    fill: none;
    /* Falls back to the AI register, not to raw gold. A nib rendered outside a
       .gv-btn--ai sets no --gov-ai-ink of its own, so this fallback is what it
       actually draws with — and left at --gv-gold it kept the old hue after the
       register moved off gold, gold nibs beside indigo buttons. */
    stroke: var(--gov-ai-ink, var(--gv-ai-ink));
    stroke-width: 2.4;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 36;
    stroke-dashoffset: 0;
    opacity: 1;
}
.gov-ai-icon .nib-spark {
    fill: var(--gov-ai-ink, var(--gv-ai-ink));
    transform-box: fill-box;
    transform-origin: center;
}
button:hover .nib-ink, a:hover .nib-ink, .gov-ai-icon:hover .nib-ink { animation: gov-ai-draw 1.5s ease-in-out infinite; }
button:hover .nib-spark, a:hover .nib-spark, .gov-ai-icon:hover .nib-spark { animation: gov-ai-pulse 1.5s ease-in-out infinite; }
@keyframes gov-ai-draw {
    0%   { stroke-dashoffset: 36; opacity: 0; }
    18%  { opacity: 1; }
    55%  { stroke-dashoffset: 0; opacity: 1; }
    88%  { stroke-dashoffset: 0; opacity: 1; }
    100% { stroke-dashoffset: 0; opacity: 0; }
}
@keyframes gov-ai-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.4); } }
@media (prefers-reduced-motion: reduce) {
    button:hover .nib-ink, a:hover .nib-ink, .gov-ai-icon:hover .nib-ink { opacity: 1; stroke-dashoffset: 0; animation: none; }
    button:hover .nib-spark, a:hover .nib-spark, .gov-ai-icon:hover .nib-spark { animation: none; }
}

/* ── Play control ─────────────────────────────────────────────────────────────
   Two variants of one thing:

     .gv-play                 transport — an icon-only play/pause, no time
     .gv-play.gv-play--time   a timestamp that IS the play control

   Both grow a Stop button once playback starts. Before this there was no stop
   anywhere in the app: every control was a play/pause toggle, so a clerk who had
   listened halfway into a motion had no way back to its start except scrubbing.

   Stop means "pause and return to where this control started from" — the segment,
   motion or timestamp it belongs to, not zero. That is what makes it worth having
   beside Pause.

   Build one with GV.play() — it owns the state machine, the aria-labels and the
   binding to the media element. The classes alone give you the look, not the
   behaviour, and the behaviour is the part with the accessibility contract.

   The app plays EXACTLY ONE THING AT A TIME (app.js:45927, a capture-phase listener
   that pauses every other player when one starts). These controls sit inside that
   rule rather than around it: they drive a real media element and let that listener
   do its job.                                                                     */
.gv-play {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-1);
}

.gv-play__btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* The button is shorter than a default line box (17.9px vs 18.6px), so the label was
       being centred as content that overflows its own container. A 1.0 leading makes the
       line box the glyph height and lets align-items actually centre it. */
    line-height: var(--gv-leading-none);
    gap: var(--gv-space-2);
    width: var(--gv-control-md);
    height: var(--gv-control-md);
    padding: 0;
    /* No outline until you go for it. These sit inline with the text they play — a
       motion's language, a quoted turn — and a box drawn round every timestamp turns a
       line of prose into a row of widgets. The border stays declared at 1px in
       transparent, so revealing it on hover shifts nothing.

       What identifies the control at rest is the play glyph, not a boundary: WCAG 1.4.11
       is met by the icon. That is why this is safe here and would not be on a control
       whose only marking was its outline. */
    border: var(--gv-border-width) solid transparent;
    border-radius: var(--gv-radius-md);
    background: var(--gv-surface);
    color: var(--gv-text);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    cursor: pointer;
    flex: 0 0 auto;
    transition: background-color var(--gv-dur-fast) var(--gv-ease),
                border-color var(--gv-dur-fast) var(--gv-ease),
                color var(--gv-dur-fast) var(--gv-ease);
}
/* The triangle is green; the label is not. Green means "this starts playback", which
   is why Stop below keeps the muted icon — a green stop square says nothing. */
.gv-play__btn i { color: var(--gv-brand-700); }
.gv-play__btn[data-gv-play-stop] i { color: var(--gv-text-muted); }
.gv-play__btn:hover:not(:disabled) {
    border-color: var(--gv-brand-700);
    background: var(--gv-brand-50);
}
.gv-play__btn:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
.gv-play__btn:disabled { opacity: 0.5; cursor: not-allowed; }

/* A stop with nothing to stop keeps its BOX but draws nothing. `hidden` collapsed the
   box, so the control changed width the instant playback started and the row jumped;
   leaving it merely disabled put a dim green square on every row of a 35-item agenda,
   the same colour as the play glyph beside it. visibility keeps the space and takes
   the mark away — and drops it out of the accessibility tree, which is right for a
   control that cannot be used. */
[data-gv-play-stop]:disabled { visibility: hidden; }

/* A fixed box for the glyph. fa-play and fa-pause are different widths, so swapping
   them resized the button and nudged everything beside it the instant playback
   started — a 1.5px jump under the cursor that had just clicked it. Measured. */
.gv-play__btn > i { display: inline-block; width: 1em; text-align: center; }

/* The one you press most: a size up, and nothing else. It used to be filled brand-700
   with a white icon, which made the transport a different-looking object from every
   other play button in the app. There is one play treatment now — outlined, green
   triangle, dark label — and size is the only thing that varies. */
.gv-play__btn--main {
    width: var(--gv-control-lg);
    height: var(--gv-control-lg);
}

/* 0.64 = two successive 20% cuts from --gv-control-sm (28px -> 22.4 -> 17.9). Kept as a
   factor rather than a px value so it stays tied to the scale it came from. The timed
   variant overrides width to auto, so this keeps the icon-only button square and only
   takes height off the one carrying a timestamp. */
.gv-play__btn--sm {
    width: calc(var(--gv-control-sm) * 0.64);
    height: calc(var(--gv-control-sm) * 0.64);
    font-size: var(--gv-text-xs);
}

/* The row a transport sits in. Lived in discussions.css as .mbtv-tr, which meant the
   Speakers player was drawn by a stylesheet named for another screen — and would have
   quietly lost its layout the day anyone scoped or moved that file. Two screens use
   this; neither of them owns it. */
.gv-transport {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--gv-space-2);
    flex: 0 0 auto;
    margin: var(--gv-space-2) 0 var(--gv-space-3);
}

/* ── With the time ────────────────────────────────────────────────────────────
   The timestamp is the button. Modelled on .pt-time on the public transcript,
   which is the one place this was already done properly — and which cannot be
   folded in here, because check-portal-a11y.js asserts its literal colours.

   Tabular numerals so a column of timestamps does not shimmy as the digits change,
   and an explicit width is NOT set: a 3-hour meeting needs h:mm:ss and a short one
   does not. */
.gv-play__btn--time {
    width: auto;
    min-width: 0;
    padding: 0 var(--gv-space-3);
    gap: var(--gv-space-2);
}
.gv-play__time {
    white-space: nowrap;      /* "1:23:45–1:31:02" never breaks across two lines */
    font-family: var(--gv-font-mono);
    /* Smaller than the words it points at, not larger. This was 12px semibold against an
       11px quote — a column of thirty timestamps that was both bigger and heavier than the
       sentences beside it, when a time is a reference and the words are the content.
       Medium rather than normal because DM Mono goes thin at this size; the weight is
       buying legibility back, not emphasis. */
    font-size: var(--gv-text-3xs);
    font-weight: var(--gv-weight-medium);
    font-variant-numeric: tabular-nums;
    letter-spacing: -0.01em;
    line-height: var(--gv-leading-none);
}


/* Stop is hidden until there is something to stop. `hidden` alone loses to
   display:inline-flex, so it is stated. */
.gv-play__btn[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
    .gv-play__btn { transition: none; }
}

/* ── Action row ───────────────────────────────────────────────────────────────
   A row a clerk acts on, which expands in place to either a set of CHOICES or
   arbitrary DETAIL. Every step of the meeting workflow that asks for a decision
   already has this shape:

     Discussions  a transcript line -> pick which agenda item it was
     Votes        an unclaimed motion -> pick the item it belongs to
     Speakers     a voice -> reveal every turn they took, each one playable

   All three expand in place rather than opening a dialog, which is right: the
   decision needs the surrounding rows visible to make sense. What differed was
   everything else — three row recipes, and a selection ring drawn at .10 alpha in
   Discussions and .14 in Votes.

   Build one with GV.row(). It owns the disclosure, the aria wiring and the
   arrow-key navigation across choices, which is the part that gets skipped.

     <div class="gv-rows">
       <div class="gv-row">
         <button class="gv-row__head" aria-expanded="false">
           <span class="gv-row__lead">4.b</span>
           <span class="gv-row__body">
             <span class="gv-row__title">Rezoning — 400 Elm St</span>
             <span class="gv-row__meta">Not yet placed</span>
           </span>
           <span class="gv-row__aside">…</span>
         </button>
         <div class="gv-row__panel"> choices, or detail </div>
       </div>
     </div>                                                                       */
/* Outline indent. An agenda is a tree, so a row needs to say how deep it sits — but the
   depth is DATA and the indent is style, so the row sets a number and this rule decides
   what a level is worth. Beats an inline padding-left, which puts the measurement in the
   markup where no stylesheet can reach it. */
.gv-row { margin-left: calc(var(--gv-row-depth, 0) * var(--gv-space-5)); }

.gv-rows {
    /* The default lives here rather than on .gv-row, because a custom property declared
       on the element that READS it wins over the inherited value — so a row defaulting
       its own depth could never be told it was nested. Measured: every row reported
       depth 0 with the wrapper plainly setting 1. */
    --gv-row-depth: 0;
    display: flex;
    flex-direction: column;
    gap: var(--gv-space-2);
}

.gv-row {
    border: 1px solid var(--gv-border);
    border-radius: var(--gv-radius-md);
    background: var(--gv-surface);
    overflow: hidden;
    transition: border-color var(--gv-dur-fast) var(--gv-ease),
                box-shadow var(--gv-dur-fast) var(--gv-ease);
}
.gv-row:hover { border-color: var(--gv-border-strong); }

/* Decided. The ring is the same one everywhere now, and the left edge carries it
   too — a border colour alone is a thin cue on a dense list. */
.gv-row.is-selected {
    border-color: var(--gv-brand-700);
    box-shadow: var(--gv-ring-selected);
}
/* Open but undecided — the clerk is looking at it right now. */
.gv-row.is-open { border-color: var(--gv-brand-400); }
/* Still needs an answer. The counterpart to is-selected: that one marks a decision
   already made, this marks one still owed. On a roster where most rows end up done,
   the emphasis belongs on what is left — "16 of 35 accounted for" means nineteen rows
   to find, and without this every one of them looks like the sixteen that are finished.
   Declared after .is-open so the amber left edge survives expanding the row: an open
   item still owes an answer, and losing the mark the moment you open it takes it away
   exactly when you are working on it. is-open keeps the other three sides. */
.gv-row.is-flagged {
    /* --gv-warning, not --gv-gold-border. Gold is the AI register in this system — the
       nib, Detect Speakers, Train VoicePrint — and a row that still owes an answer has
       nothing to do with AI. It is the same orange the app already flags with, and it is
       a token rather than a hex, so every flagged row in the app moves from one line in
       tokens.css. No per-surface override hook: one declared on .gv-row would beat the
       inherited value and make the ancestor override it exists for impossible.

       3px, not the --gv-space-1 this used to be. A rail is a border width, and border
       widths in this system are literals rather than scale steps — check-design-tokens.js
       keeps them off its scaled-property list on purpose, because a hairline is a hairline.
       The number is Motions' (.mbmv-i, votes.css) and the Document tab's (.mbdoc-item,
       document.css); at 4px the same mark meant the same thing at two different weights
       depending on which tab you were looking at. */
    border-left: 3px solid var(--gv-warning);
}

/* ANSWERED, as opposed to is-muted's "ignore this". The mirror of is-flagged: same edge,
   the other end of the palette — amber still owes you something, brand green does not.
   Nothing else changes, because a settled row should be as readable as any other; muted's
   sunk ground, greyed title and strike-through said "disabled", which is why a clerk who
   had answered an item read the screen as having thrown it away. */
.gv-row.is-done {
    border-left: 3px solid var(--gv-brand-400);   /* 3px: see is-flagged */
}

/* Deliberately set aside: "not discussed", "no action". The third of three answers, and
   the only one that is not a colour — grey, because nothing happened here to report.
   Declared after .is-open for the same reason the other two are.

   DOTTED. Motions draws its equivalent (.mbmv-i.none) dashed and the Document tab draws
   its (.mbdoc-nd) dotted; this follows the Document tab, whose .mbdoc-nd sets out why at
   length. Three states, three marks, read down one rail.

   The sunk ground comes with it, as it does on both of those. What does NOT come with it
   is is-muted's greyed, struck-through title. A clerk who answered "it was never taken up"
   made a decision, and striking the row through reads as having thrown it away — the
   complaint that put is-done here in the first place. The state is in the rail and the
   ground; the words stay legible, and Undo stays findable. */
.gv-row.is-skipped {
    border-left: 3px dotted var(--gv-border-strong);
    background: var(--gv-surface-sunk);
}

.gv-row.is-muted { background: var(--gv-surface-sunk); }
.gv-row.is-muted .gv-row__title { color: var(--gv-text-muted); text-decoration: line-through; }

/* A CONTAINER, not a button. The aside holds controls of its own, and a <button>
   inside a <button> is invalid HTML that browsers unnest. */
.gv-row__head {
    display: flex;
    align-items: center;
    gap: var(--gv-space-3);
    width: 100%;
    padding: var(--gv-space-3);
    font-family: var(--gv-font-ui);
    text-align: left;
}

/* The disclosure: the text region by default, so the target is the whole width of
   everything that is not a control. */
.gv-row__lede {
    display: flex;
    /* Top, not centre. The body is two lines — title over meta — so centring hung the
       number and the caret against the middle of the pair, half a line below the title
       they belong to. */
    align-items: flex-start;
    /* Same gap as .gv-card__disclose, so the caret sits the same distance from what
       follows it whether that is an item number or a section heading. */
    gap: var(--gv-space-2);
    flex: 1;
    min-width: 0;
    padding: 0;
    border: none;
    background: none;
    font-family: inherit;
    text-align: left;
    cursor: pointer;
}
div.gv-row__lede { cursor: default; }
.gv-row__lede:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
    border-radius: var(--gv-radius-sm);
}
button.gv-row__lede:hover .gv-row__title { color: var(--gv-brand-900); }


/* The number, checkbox or avatar. Fixed width so titles line up down the list — a
   ragged left edge is what makes a long list hard to scan. */
.gv-row__lead {
    flex: 0 0 auto;
    /* Hugs its number. A fixed 24px column aligned every title at the same x, but it
       also meant a 6.9px "1" left ~17px of empty box before the title — so the gap the
       eye saw was ~29px where every other gap in the row is 8px. The column is worth
       less than the consistency: tabular-nums already keeps digits the same width, so
       rows differ only when their numbers differ in length. */
    min-width: 0;
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-bold);
    font-variant-numeric: tabular-nums;
    color: var(--gv-text-subtle);
    /* LEFT. The box is a fixed 24px so every title starts at the same x, but right-aligning
       inside it parked the digit at the far edge — a 6.9px "1" sat 25px from the caret
       while the identical caret on The vote sat 8px from its icon. Measured; the boxes
       were always 8px apart, it was the glyph inside that was not. Outline numbers lose
       right-alignment with each other, which "2.a" never had anyway. */
    text-align: left;
}

.gv-row__body { flex: 1; min-width: 0; }
/* Title plus its chip, on one line. The chip describes the thing the title names — a
   share of speech, a count — so it belongs beside it, not over with the controls. */
.gv-row__titleline {
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
    min-width: 0;
}
/* A badge on the title line must not be taller than the line. At the standalone padding
   it stood 23.75px against an 18.2px text line, which inflated the line, pushed the title
   down inside it, and left the top-aligned caret sitting 2.19px above the words it points
   at. Trimming the block padding puts caret, title and badge on one centre line.
   Scoped to the title line — a badge in a table cell still wants its full padding. */
.gv-row__titleline > .gv-badge {
    padding-top: 0;
    padding-bottom: 0;
    line-height: var(--gv-leading-snug);
}
.gv-row__titleline .gv-row__title { flex: 0 1 auto; }

/* Marks what kind of thing the title names. A fixed token rather than an em: the icon
   is a SIBLING of the title, not a child, so an em resolves against the title LINE's
   inherited 16px and lands the glyph larger than the words it labels. Subtle colour
   because it repeats what the list already says — it should be found, not read. */
.gv-row__titleicon {
    flex-shrink: 0;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-subtle);
}

.gv-row__title {
    display: block;
    font-size: var(--gv-text-sm);
    font-weight: var(--gv-weight-medium);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text);
}

/* An identifier, not a name: the Speakers rows are voices ("SPEAKER A"), and the
   person is what the picker beside them assigns. Mono so a column of them lines up
   and so it does not read as somebody's name. Bolder than a prose title because this
   is the anchor a clerk scans the column by, and the row's own name sits in the picker
   to its right at the same size.
   MUST stay after .gv-row__title: both are one class, so the modifier only wins on
   equal specificity by coming later. It used to sit above it, which is why its
   font-size silently never applied — mono rows have been rendering at --gv-text-sm. */
.gv-row__title--mono {
    font-family: var(--gv-font-mono);
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-bold);
    letter-spacing: 0.02em;
}
.gv-row__meta {
    display: block;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-subtle);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

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

/* The chevron rotates rather than swapping glyph, so it reads as the same object
   opening — and it is the only thing that moves. */
.gv-row__caret {
    flex: 0 0 auto;
    /* Explicit width, matching the glyph it already drew. Nothing moves, but the lede
       becomes a known column — which is what lets panel content line up under the title
       out of tokens instead of a measured constant. */
    /* SQUARE, and the same square as the height. The caret rotates 90deg when open, and
       a transform does not change layout — so an 8x18 box painted a 18x8 glyph that
       overflowed ~5px into whatever sat beside it, which is why the number looked
       jammed against the chevron while the identical caret on The vote did not.
       Equal sides make the rotation footprint-neutral. */
    width: calc(var(--gv-text-sm) * var(--gv-leading-snug));
    color: var(--gv-text-disabled);
    font-size: var(--gv-text-xs);
    /* Font Awesome forces line-height:1 on .fas, so leading alone cannot make the 8px
       glyph fill the title's line — the caret has to be given the box and centred in it.
       13px x 1.4 is exactly the title's 18.2px line box, so it sits ON the first line
       instead of 3px above its middle. Measured. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    height: calc(var(--gv-text-sm) * var(--gv-leading-snug));
    /* Explicitly rotate(0deg), NOT the default `none`. Transitioning from `none` to a
       rotation gives the engine nothing to interpolate between, and the caret simply
       never turns when the attribute flips at runtime — it only looked right when the
       row was rendered already-open. Measured: stuck at the identity matrix a full
       second after aria-expanded became true. */
    transform: rotate(0deg);
    transition: transform var(--gv-dur-base) var(--gv-ease);
}
/* Rotates off the toggle's OWN aria-expanded, not an ancestor class, so a surface
   with its own card markup (the Speakers roster) gets the behaviour by setting one
   attribute. */
/* Scoped to the row's OWN head. As a descendant selector this reached every caret
   nested inside an open row — including the vote disclosure's, which then sat at 90deg
   whether it was open or shut, because its parent row was open. Measured: identical
   transform matrices before and after collapsing it. */
.gv-row.is-open > .gv-row__head .gv-row__caret,
[aria-expanded="true"] > .gv-row__caret { transform: rotate(90deg); }

/* A static row keeps the caret's COLUMN but not the caret: visibility, not display, so
   an agenda item with no motions still lines its title up with the items that have
   them. Fifteen rows whose titles start at three different x positions, depending on
   whether each happens to open, is a worse list than one with a blank column. */
.gv-row__caret--none { visibility: hidden; }

/* A lead holding a control rather than a number needs its own width — the numeric lead
   is a narrow fixed column so "1" and "2.a" line up, which would crop a play button. */
.gv-row__lead--wide { min-width: 0; width: auto; }
.gv-row__lede--static { cursor: default; }

/* No rule between the head and the panel. Everything a panel opens onto brings its own
   boundary — bordered bands, a choices group, a turn list — so the line was a second
   divider drawn on top of the first. The padding does the separating. */
.gv-row__panel {
    padding: var(--gv-space-3) var(--gv-space-3) var(--gv-space-3);
}
.gv-row__panel[hidden] { display: none; }

/* ── Choices ──────────────────────────────────────────────────────────────────
   Pick exactly one. Modelled on the Discussions chapter picker, which is the most
   demanding version of this in the app and the one that shows why a plain radio list
   is not enough:

     [A]  stray remarks before meeting start                    [ ▶ Preview ]
          right after the start of the meeting  0:22–0:29  0:06  Speaker A

   Three things that costs us:

   1. The marker is an ORDINAL (A, B, C…), not a dot. A clerk reading a transcript
      alongside this refers to "option C", so the letter has to be visible.
   2. The meta is several distinct FACTS — where it sits in the running order, the
      range, the duration, who was talking — not one string. They are styled apart
      because the clerk scans for different ones at different moments.
   3. There is an action that is NOT the choice. "Preview" plays the stretch and
      deliberately does not select it; the tooltip in the app says so out loud.

   That third one drives the markup. A role="radio" must not contain a focusable
   descendant, so the card is a plain container, the radio is the region that fills
   it, and the action is a SIBLING of the radio rather than a child. A <button>
   nested inside a <button> — which is what the obvious version produces — is invalid
   HTML and browsers unnest it.                                                    */
.gv-choices {
    display: flex;
    flex-direction: column;
    gap: var(--gv-space-1);
    max-height: 20rem;
    overflow-y: auto;
}

/* Once the question is answered, the also-rans recede rather than vanish: the clerk
   should still be able to see what they chose between. */
/* Keyed off the attribute, not the class, so a surface can supply its own group
   container (the Discussions picker has its own layout constraints) and still get the
   radiogroup behaviour and the answered state. */
[data-gv-choices].is-answered .gv-choice:not(.is-committed) { opacity: 0.3; pointer-events: none; }

.gv-choice {
    display: flex;
    align-items: center;
    border: 1px solid var(--gv-border);
    border-radius: var(--gv-radius-md);
    background: var(--gv-surface);
    transition: border-color var(--gv-dur-fast) var(--gv-ease),
                background-color var(--gv-dur-fast) var(--gv-ease),
                opacity var(--gv-dur-slow) var(--gv-ease);
}
.gv-choice:hover { border-color: var(--gv-brand-400); background: var(--gv-brand-50); }

.gv-choice.is-selected {
    border-color: var(--gv-brand-700);
    background: var(--gv-brand-50);
    box-shadow: var(--gv-ring-selected);
}

/* The one the running order says should come next. A nudge, not a decision — it is
   still the clerk's call, so this is quieter than selection. */
.gv-choice.is-suggested { border-color: var(--gv-brand-200); background: var(--gv-brand-50); }

/* Plausible but out of the expected window — earlier in the meeting, or well after. */
.gv-choice.is-distant { opacity: 0.8; }
.gv-choice.is-distant:hover { opacity: 1; }

/* The escape hatch: "Select from the transcript". Not one of the candidates, so it is
   drawn as an outline rather than a card. */
.gv-choice.is-manual { border-style: dashed; }
.gv-choice.is-manual:hover { border-style: solid; border-color: var(--gv-brand-700); }

/* Committed — the answer, after the question is done with. The left bar survives the
   dimming that lands on everything else. */
.gv-choice.is-committed {
    position: relative;
    overflow: hidden;
    border-color: var(--gv-brand-700);
    background: var(--gv-brand-50);
    box-shadow: inset var(--gv-space-1) 0 0 var(--gv-brand-700);
}
/* The answer landing. A wash left-to-right and the ordinal turning into a tick — worth
   keeping from the Discussions picker, where it is what tells the clerk their click
   registered before the screen becomes the next question. */
.gv-choice.is-committed::after {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    background: var(--gv-sweep-commit);
    transform-origin: left;
    animation: gv-commit-sweep 500ms cubic-bezier(0.3, 0.8, 0.3, 1) both;
}
/* font-size:0 rather than color:transparent. Transparent text is still TEXT — it is laid
   out, it is selectable, and a selection drawn across the choice list repaints it, so the
   ordinal showed through underneath the check that was meant to replace it. Collapsing the
   glyph leaves nothing to reveal. */
.gv-choice.is-committed .gv-choice__mark { font-size: 0; position: relative; }
.gv-choice.is-committed .gv-choice__mark::after {
    content: "\f00c";
    font-family: "Font Awesome 6 Free";
    font-weight: 900;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-invert);
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    animation: gv-commit-tick 340ms ease-out 100ms both;
}
@keyframes gv-commit-sweep { from { transform: scaleX(0); opacity: 1; } to { transform: scaleX(1); opacity: 0; } }
@keyframes gv-commit-tick { from { transform: scale(0.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }

/* The radio itself: fills the card, so the whole row is the target. */
.gv-choice__pick {
    flex: 1;
    min-width: 0;
    display: flex;
    align-items: center;
    gap: var(--gv-space-3);
    padding: var(--gv-space-2) var(--gv-space-3);
    border: none;
    background: none;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    color: var(--gv-text);
    text-align: left;
    cursor: pointer;
}
.gv-choice__pick:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: calc(-1 * var(--gv-focus-width));
    border-radius: var(--gv-radius-md);
}

/* Ordinal marker. Square-ish rather than round, so it does not read as a radio dot
   that has been mislabelled. */
.gv-choice__mark {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: var(--gv-control-sm);
    height: var(--gv-control-sm);
    border: 1px solid var(--gv-border-strong);
    border-radius: var(--gv-radius-sm);
    background: var(--gv-surface);
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-bold);
    color: var(--gv-text-muted);
}
.gv-choice.is-suggested .gv-choice__mark { border-color: var(--gv-brand-400); color: var(--gv-brand-900); }
.gv-choice.is-selected .gv-choice__mark,
.gv-choice.is-committed .gv-choice__mark {
    background: var(--gv-brand-700);
    border-color: var(--gv-brand-700);
    color: var(--gv-text-invert);
}
.gv-choice.is-manual .gv-choice__mark { border-style: dashed; color: var(--gv-text-disabled); }

/* No ordinal? Then a plain radio dot — shape as well as colour, so the chosen option
   survives a monochrome printout and a colourblind reader. */
.gv-choice__mark--dot {
    width: var(--gv-space-3);
    height: var(--gv-space-3);
    border-width: 2px;
    border-radius: var(--gv-radius-full);
    background: var(--gv-surface);
}
.gv-choice.is-selected .gv-choice__mark--dot,
.gv-choice.is-committed .gv-choice__mark--dot {
    background: radial-gradient(circle at center, var(--gv-brand-700) 0 45%, var(--gv-surface) 45%);
    border-color: var(--gv-brand-700);
}

.gv-choice__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--gv-space-1); }
.gv-choice__text {
    display: block;
    line-height: var(--gv-leading-snug);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The facts line. Separate spans, not one string, so each can be styled for how it is
   read: the position hint is the reassuring one, the range is the checkable one. */
.gv-choice__facts {
    display: flex;
    align-items: center;
    /* One line, always. Wrapping makes some cards two rows tall and others one, and a
       list you cannot scan down the left edge of is the thing this component exists to
       fix. The prose facts truncate instead. */
    flex-wrap: nowrap;
    gap: var(--gv-space-2);
    min-width: 0;
    font-size: var(--gv-text-2xs);
    color: var(--gv-text-subtle);
}
/* Prose facts give way first... */
.gv-choice__fact {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* ...the numbers never do. A clerk checking a range against a transcript needs to see
   all of it, and it is short enough to always fit. */
.gv-choice__fact--num {
    flex: 0 0 auto;
    font-weight: var(--gv-weight-bold);
    font-variant-numeric: tabular-nums;
    color: var(--gv-text-muted);
}
.gv-choice__fact--hint { color: var(--gv-brand-900); font-weight: var(--gv-weight-bold); }
.gv-choice__fact--warn { color: var(--gv-warning-fg); font-weight: var(--gv-weight-semibold); }

/* An action that is NOT the choice — Preview plays the stretch without picking it.
   Sibling of the radio, never inside it. */
.gv-choice__action {
    flex: 0 0 auto;
    margin-right: var(--gv-space-2);
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-2);
    padding: var(--gv-space-1) var(--gv-space-3);
    border: 1px solid var(--gv-border-strong);
    border-radius: var(--gv-radius-sm);
    background: var(--gv-surface);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-2xs);
    font-weight: var(--gv-weight-semibold);
    color: var(--gv-text-muted);
    cursor: pointer;
    white-space: nowrap;
}
.gv-choice__action:hover {
    border-color: var(--gv-brand-700);
    background: var(--gv-brand-700);
    color: var(--gv-text-invert);
}
.gv-choice__action:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
/* When the action is "hear this stretch", it is the play control rather than a plain
   button, and the range rides on it. */
.gv-choice__action-slot { flex: 0 0 auto; margin-right: var(--gv-space-2); }

/* Once committed there is nothing left to preview against. */
.gv-choice.is-committed .gv-choice__action,
.gv-choice.is-committed .gv-choice__action-slot { opacity: 0; pointer-events: none; }

/* Detail panels — a speaker's turns, an item's history. Not choices: nothing here
   is selectable, so nothing pretends to be. */
.gv-row__detail { display: flex; flex-direction: column; gap: var(--gv-space-2); }
.gv-row__detail-line {
    display: flex;
    align-items: flex-start;
    gap: var(--gv-space-2);
    font-size: var(--gv-text-xs);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text-muted);
}

/* ── Turn ─────────────────────────────────────────────────────────────────────
   A line of transcript: when it was said, what was said, and one thing you can do
   about it. The Speakers panel is a list of these — every turn a voice took, each
   playable, each reassignable to a different person.

       [▶ 0:31]  "Has everybody had the opportunity to go over the…"      [ Move ]

   THREE COLUMNS, and that matters. The clamp that holds the quote to two lines has
   to own a box containing nothing but text: put the play control inside it and
   -webkit-line-clamp silently stops clamping, so a long turn pushes the row to
   whatever height it likes and the list stops being scannable. (Measured: it does
   not clamp. That is not a theoretical objection.) */
.gv-turns {
    display: flex;
    flex-direction: column;
    gap: var(--gv-space-1);
    max-height: 18rem;
    overflow-y: auto;
}

/* What the list is and the one action that applies to all of it ("Move all →"). */
.gv-turns__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--gv-space-2);
    margin-bottom: var(--gv-space-2);
}
.gv-turns__count {
    font-size: var(--gv-text-2xs);
    color: var(--gv-text-muted);
    min-width: 0;
}

/* ── Filter bar ────────────────────────────────────────────────────────────
   A search box and the chips that narrow a long list, on one line above it.

       [ 🔍 Filter voices or people ]   (All 8) (Unassigned 3) (Assigned 5)

   Two screens grew their own copy of this row before it was a component. It lives here
   so the third does not grow a third copy — the chips are .gv-chips, the input is a
   plain .gv-input, and this is only the row that holds them. */
.gv-filter {
    display: flex;
    align-items: center;
    gap: var(--gv-space-3);
    flex-wrap: wrap;
    padding: 0 0 var(--gv-space-3);
}
/* Takes the slack, but stops well short of the full width: a filter input as wide as the
   page reads like the page's main field, which is not what it is. */
.gv-filter__search {
    flex: 1 1 12rem;
    min-width: 8rem;
    max-width: 22rem;
}

.gv-turn {
    display: flex;
    align-items: flex-start;
    gap: var(--gv-space-2);
    padding: var(--gv-space-1);
    border-radius: var(--gv-radius-sm);
    transition: background-color var(--gv-dur-fast) var(--gv-ease);
}
/* Hovering anywhere on the row — including its action — says which line that action
   is about. This was two inline JS handlers painting a hardcoded colour. */
.gv-turn:hover { background: var(--gv-brand-50); }

.gv-turn__text {
    flex: 1;
    min-width: 0;
    font-size: var(--gv-text-2xs);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text-muted);
    font-style: italic;
    /* Two lines, then ellipsis. The full text is in the transcript; this is enough to
       recognise the turn by. */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.gv-turn__action { flex: 0 0 auto; }

@media (prefers-reduced-motion: reduce) {
    .gv-turn { transition: none; }
}

@media (prefers-reduced-motion: reduce) {
    .gv-row, .gv-choice, .gv-row__caret { transition: none; }
    /* The tick still appears — it is the confirmation, not decoration. Only the
       movement goes. */
    .gv-choice.is-committed::after { animation: none; opacity: 0; }
    .gv-choice.is-committed .gv-choice__mark::after { animation: none; }
}

/* ── Picker ───────────────────────────────────────────────────────────────────
   A searchable single-select popover. The Speakers tab's "assign this voice to a
   person" control is the one that shaped it:

       ┌──────────────────────────────┐
       │ [ Search or add a person…  ] │
       ├──────────────────────────────┤
       │ ⊗  Unassign                  │
       │ ──────────────────────────── │
       │ 👤 Chair David Wing        ✓ │
       │ 👤 Vice Chair Ken Stanvick   │
       │ ──────────────────────────── │
       │ +  Add “Nicole Pigeon”       │
       └──────────────────────────────┘

   Four things a plain <select> cannot do, which is why this exists rather than one:
   filter as you type, offer to CREATE the thing you are looking for, offer an
   explicit "none of them", and stay readable at twenty-odd names.

   What it must not lose, and what the hand-rolled version did lose: it is a
   COMBOBOX. The input keeps focus while the arrow keys move a highlight through the
   list, and the highlight is announced through aria-activedescendant. Without that
   a searchable list is mouse-only — you can type to filter and then have no way to
   choose what you filtered to.                                                    */
.gv-picker {
    width: 15rem;
    background: var(--gv-surface);
    border: 1px solid var(--gv-border);
    border-radius: var(--gv-radius-lg);
    box-shadow: var(--gv-shadow-lg);
    overflow: hidden;
    z-index: var(--gv-z-dropdown);
}

.gv-picker__search { padding: var(--gv-space-2); border-bottom: 1px solid var(--gv-border); }
.gv-picker__search input {
    width: 100%;
    padding: var(--gv-space-1) var(--gv-space-2);
    border: 1px solid var(--gv-border-strong);
    border-radius: var(--gv-radius-md);
    background: var(--gv-surface);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    color: var(--gv-text);
}
.gv-picker__search input:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}

.gv-picker__list { max-height: 14rem; overflow-y: auto; padding: var(--gv-space-1) 0; }

.gv-picker__opt {
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
    width: 100%;
    padding: var(--gv-space-1) var(--gv-space-3);
    border: none;
    background: none;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    color: var(--gv-text);
    text-align: left;
    cursor: pointer;
}
/* Hover and keyboard highlight are the same thing to look at, because they mean the
   same thing: this is what Enter will pick. */
/* Grey, not brand. Hovering a row says "the pointer is here", which is not a state the
   row is in — and in a list where selection is ALSO shown by colour, a green wash under
   the cursor reads as a second selection following the mouse around. --gv-surface-sunk is
   what a table row, a secondary button and a ghost button already hover to, and it matches
   the agenda list's own #f7f9fa. Selection keeps the brand colour, on the text. */
.gv-picker__opt:hover,
.gv-picker__opt.is-active { background: var(--gv-surface-sunk); }
.gv-picker__opt[aria-selected="true"] { color: var(--gv-brand-900); font-weight: var(--gv-weight-semibold); }

.gv-picker__opt-icon { flex: 0 0 auto; width: var(--gv-space-3); color: var(--gv-text-disabled); font-size: var(--gv-text-2xs); }
.gv-picker__opt[aria-selected="true"] .gv-picker__opt-icon { color: var(--gv-brand-700); }
.gv-picker__opt-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gv-picker__opt-check { flex: 0 0 auto; color: var(--gv-brand-700); font-size: var(--gv-text-2xs); }

/* "Unassign" — a removal, so it reads as one on hover rather than looking like
   another name in the list. */
.gv-picker__opt--danger:hover,
.gv-picker__opt--danger.is-active { background: var(--gv-danger-bg); color: var(--gv-danger-fg); }
.gv-picker__opt--danger .gv-picker__opt-icon { color: var(--gv-text-disabled); }
.gv-picker__opt--danger:hover .gv-picker__opt-icon { color: var(--gv-danger-fg); }

/* "Add a new person" — always offered, so a name that is not on the roster is never
   a dead end. */
.gv-picker__opt--add { color: var(--gv-brand-700); font-weight: var(--gv-weight-semibold); }

/* An option a model suggested. The same three declarations .gv-btn--ai sets, so the nib
   renders exactly as it does on every AI button in the app — the register is indigo
   (--gv-ai-fg) with the brand-green nib body, and it is stated once there and reused here
   rather than restated as values.

   Only the glyph takes it. The row is an ordinary choice sitting in its ordinary place;
   colouring the whole line would make the suggestion read as a different KIND of option
   rather than a marked one.

   Doubled to 0,3,0 so it holds against the [aria-selected] icon rule above, which is
   usually the SAME row — the drafter's guess is what the motion is resting on. Two
   different facts, two different marks: the nib for "a model chose this", the brand tick
   for "this is the current answer". One colour for both said one thing. */
.gv-picker__opt.gv-picker__opt--ai .gv-picker__opt-icon {
    color: var(--gv-ai-fg);
    --gov-ai-body: var(--gv-ai-nib);
    --gov-ai-ink: var(--gv-ai-ink);
}

.gv-picker__sep { height: 1px; margin: var(--gv-space-1) 0; background: var(--gv-border); }
.gv-picker__empty {
    padding: var(--gv-space-2) var(--gv-space-3);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-2xs);
    font-style: italic;
    color: var(--gv-text-subtle);
}

/* ── Menu (GV.menu) ─────────────────────────────────────────────────────────────
   The ⋯ at the end of a filter bar, holding what acts on many rows at once. It
   borrows the picker's surface and rows deliberately: it is the same popover with a
   different job, and two popovers on one screen that agree about padding and
   disagree about everything else is the drift this file exists to stop.

   Wider than the picker, because these are sentences ("Mark all as not discussed")
   rather than names, and each carries a count on the right.                       */
.gv-menu { width: 17rem; }
.gv-menu__head {
    margin: 0;
    padding: var(--gv-space-2) var(--gv-space-3) 0;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-2xs);
    color: var(--gv-text-muted);
}
/* The count of rows the item would act on. Tabular, so a column of them lines up
   rather than shuffling by a pixel per digit. */
.gv-menu__n {
    margin-left: auto;
    font-size: var(--gv-text-2xs);
    color: var(--gv-text-muted);
    font-variant-numeric: tabular-nums;
}
/* Disabled says "nothing to act on", not "this is broken" — so it greys out and
   stops responding to the pointer, and its count greys with it rather than staying
   dark over dead text. */
.gv-menu .gv-picker__opt[disabled] {
    color: var(--gv-text-disabled);
    cursor: default;
}
.gv-menu .gv-picker__opt[disabled]:hover { background: none; }
.gv-menu .gv-picker__opt[disabled] .gv-menu__n { color: var(--gv-text-disabled); }

/* The chip that opens it. Looks like a value you can change, which is what it is. */
.gv-picker-trigger {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-2);
    max-width: 100%;
    padding: var(--gv-space-1) var(--gv-space-2);
    border: 1px solid var(--gv-border-strong);
    border-radius: var(--gv-radius-md);
    background: var(--gv-surface);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    color: var(--gv-text);
    cursor: pointer;
    white-space: nowrap;
}
.gv-picker-trigger:hover { border-color: var(--gv-brand-400); background: var(--gv-brand-50); }
.gv-picker-trigger:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
/* The value fills, so the caret sits at the right edge rather than trailing the text.
   A trigger is a value you can change, and a caret that floats mid-control reads as
   punctuation after a word instead of the affordance that opens it. min-width:0 is what
   lets it shrink far enough to ellipsis inside a flex row.
   Was per-screen: .ts-voice-pick did exactly this in styles.css, which is the tell that
   it belonged to the component. That override is gone with this. */
.gv-picker-trigger__value {
    flex: 1 1 auto;
    min-width: 0;
    /* A <button> centres its text. Harmless while the value was only as wide as its
       words; now that it fills, a short one drifted to the middle of the control and a
       long one started at the left edge, so the same field read as two shapes. */
    text-align: left;
    overflow: hidden;
    text-overflow: ellipsis;
}
.gv-picker-trigger i { flex: 0 0 auto; color: var(--gv-text-disabled); font-size: var(--gv-text-2xs); }

/* Nothing assigned yet. This is not an empty value to be greyed out — it is the one
   thing the clerk is on this screen to do, so it reads as an invitation. The app drew
   it in blue, which belongs to no scale in this product; it is the brand tint now, the
   same one every other "here, do this" surface uses. */
.gv-picker-trigger--empty {
    border-color: var(--gv-brand-200);
    background: var(--gv-brand-50);
    color: var(--gv-brand-700);
    font-weight: var(--gv-weight-semibold);
}
.gv-picker-trigger--empty:hover { border-color: var(--gv-brand-400); background: var(--gv-brand-100); }
.gv-picker-trigger--empty i { color: var(--gv-brand-700); }

/* ── Pick cell ────────────────────────────────────────────────────────────────
   A one-of-several choice inside a table cell. The Votes roll call is the case that
   shaped it: every voting member against what they did with a motion.

   A labelled button, not a dot. The label is what a clerk reads down a column, and it
   is the one the app has always used — M, 2nd, Aye, Nay, Abstain.

   Selection is a FILL, not a hue: the chosen cell goes solid in its tone with white on
   top, the rest stay outlined. That survives a monochrome printout and a red-green
   reader, which colour alone would not — and it is why the tone is free to be green,
   red or grey without carrying the state on its own.

   A BUTTON with aria-pressed, not a radio input, even though the shape says pick-one.
   Native radios cannot be unchecked by clicking, and mbMvSetVote clears the vote when
   you click the value that is already set — swapping in radios would quietly delete the
   only way to undo a mis-click. The label is repeated in aria-label with the member's
   name, so "Aye" announces as "Aye — Chair David Wing" rather than as the 14th
   identical "Aye" on the screen.                                                     */
.gv-table__pick { text-align: center; padding: var(--gv-space-1); }

.gv-pick {
    --gv-pick-tone: var(--gv-brand-700);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: 1 1 0;
    /* 20% down from --gv-control-xs. Below WCAG 2.5.8's 24px target minimum — these are
       adjacent segments, so the spacing exception does not apply. Deliberate: the roll
       call is a dense desktop grid and the density was asked for. */
    min-height: var(--gv-control-2xs);
    padding: 0 calc(var(--gv-space-2) * 0.8);
    /* --gv-ink-500, not --gv-border. This sits in a table full of text, so the outline
       is what says "clickable" rather than "written down" — which makes it information
       required to identify the control, and WCAG 1.4.11 wants 3:1 for that. The border
       ramp tops out at 1.62:1; ink-500 is 4.39:1. (It is barred from TEXT roles for
       being too light, which does not apply to a graphical boundary.) */
    border: var(--gv-border-width) solid var(--gv-ink-500);
    background: var(--gv-surface);
    color: var(--gv-text-muted);
    border-radius: var(--gv-radius-sm);
    font-family: inherit;
    /* 11px, the smallest stop in the scale — not the 9.6px a straight 20% would give.
       These are labels on a control a clerk clicks all meeting. */
    font-size: var(--gv-text-2xs);
    font-weight: var(--gv-weight-semibold);
    line-height: var(--gv-leading-none);
    white-space: nowrap;
    cursor: pointer;
    transition: background var(--gv-dur-fast) var(--gv-ease),
                border-color var(--gv-dur-fast) var(--gv-ease);
}
.gv-pick--success { --gv-pick-tone: var(--gv-success-fg); }   /* 5.02:1 with white */
.gv-pick--danger  { --gv-pick-tone: var(--gv-danger-fg); }    /* 6.47:1 */
.gv-pick--neutral { --gv-pick-tone: var(--gv-ink-600); }      /* 6.56:1 */
/* For a state that is underway rather than settled — a Draft agenda, which is amber in
   the same sense the dashboard's in-progress step is. --gv-warning-fg, NOT --gv-warning
   (2.15:1) or --gv-step-progress (3.19:1): the tone becomes a FILL under white 14px
   text, so it owes 4.5:1, and those two are a decorative stop and a glyph colour
   respectively. warning-fg is the readable stop tokens.css points to for exactly this,
   and it lands on the same 5.02:1 as success. */
.gv-pick--warning { --gv-pick-tone: var(--gv-warning-fg); }   /* 5.02:1 */
/* For a mark that is not a vote. The roll call carries two questions on one line — who
   moved and seconded it, and how everyone voted — and both were drawn in the brand green
   the base tone gives, so four controls read as one row of the same kind of thing. Blue
   is the register for "this is information about the record", and it is the hue the
   screen this replaced used for exactly these two marks. */
.gv-pick--info    { --gv-pick-tone: var(--gv-info-fg); }      /* 7.56:1 */

/* Hover paints the label in the tone — which is right for a pick you have NOT chosen,
   sitting on a white surface, and wrong for the chosen one, whose surface IS that tone.
   The pressed rule below sets the label to --gv-surface for exactly that reason, but it
   is one selector lighter (0,2,0) than this one (0,3,0), so on hover this won and the
   chosen pick painted its label out in its own fill. Excluded rather than reordered:
   source order cannot settle a specificity difference. */
.gv-pick:hover:not(:disabled):not([aria-pressed="true"]) {
    border-color: var(--gv-pick-tone);
    color: var(--gv-pick-tone);
}
.gv-pick:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}

/* The chosen one fills. Shape and weight, not just hue. */
.gv-pick[aria-pressed="true"] {
    background: var(--gv-pick-tone);
    border-color: var(--gv-pick-tone);
    color: var(--gv-surface);
}
/* It still answers the pointer — clicking it again clears the mark — but the label is
   already the only legible thing on a filled cell, so the ring moves and the label does
   not. Same bargain .gv-btn--danger-quiet:hover makes. */
.gv-pick[aria-pressed="true"]:hover:not(:disabled) {
    border-color: var(--gv-text);
}

.gv-pick:disabled {
    cursor: not-allowed;
    background: var(--gv-surface-sunk);
    color: var(--gv-text-disabled);
    border-color: var(--gv-border-strong);
}

/* A column header carrying a semantic tone, so the meaning is stated once at the top
   rather than repeated in every cell. */
.gv-table thead th.gv-table__th--success { color: var(--gv-success-fg); }
.gv-table thead th.gv-table__th--danger  { color: var(--gv-danger-fg); }

/* ── Said ─────────────────────────────────────────────────────────────────────
   Evidence: what was actually said, under the thing a clerk is writing from it.
   The Votes motion card is the case — the drafted sentence sits above, the
   verbatim quote below it, so the two can be read against each other.

   A <figure>/<blockquote>/<figcaption>, not three divs. This is a quotation with
   an attribution, which is the one thing that markup means, and it is what lets a
   screen reader announce it as a quote rather than as more body text.

   The play control lives with the quote. A clerk checking drafted language against
   the recording should not have to go and find the moment again.                 */
.gv-said {
    /* A rule, not a box. The tinted panel indented the quote by its own padding, which
       pushed this play 12px right of the one on the sentence above — two controls doing
       the same job, not lining up. A divider separates the drafted sentence from the
       words it came from without moving either of them. */
    /* No top rule when it opens the body: the card head already draws one directly
       above it, and two hairlines a few px apart read as a mistake. The rule is kept for
       any other placement. */
    margin: var(--gv-space-3) 0 0;
    padding: var(--gv-space-3) 0 0;
    border-top: var(--gv-border-width) solid var(--gv-border);
    /* Grid, not flex, because <figcaption> must stay a direct child of <figure> and be
       its first or last node — wrapping the quote and its attribution together in a div
       to make three flex columns would break that association. Areas put the play on the
       left of the quote and the attribution under it without moving anything in the DOM. */
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    /* The play occupies the QUOTE's row only. Spanning both rows centred it against
       quote-plus-attribution, which put it 11.8px below the line it plays while the
       identical control on the sentence above sat dead centre on its text. The
       attribution is a caption, not the thing the button plays. */
    grid-template-areas:
        "play quote acts"
        ".    by    acts";
    align-items: center;
    column-gap: var(--gv-space-2);
    row-gap: var(--gv-space-1);
}
.gv-said__quote {
    grid-area: quote;
    margin: 0;
    font-family: var(--gv-font-doc);
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-relaxed);
    color: var(--gv-text);
}
.gv-said__by {
    grid-area: by;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-subtle);
}
.gv-said__acts {
    grid-area: acts;
    /* Spans the quote row AND the attribution row, so the grid's own align-items:center
       centred it against both — 11px below the line it acts on, while the play control
       opposite it sat dead on that line. The same mistake this component already fixed
       for the play, on the other side of the quote. `start` puts it back on the row it
       belongs to, and keeps it there when the quote wraps. */
    align-self: start;
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
}
/* Same story as __acts above, and only visible once a quote wraps: the play sits in the
   quote's cell, so on a two-line quote it centred against both lines and dropped 11px
   below the line it plays. `start` plus the optical nudge — (line-height − control) / 2 —
   puts it on the first line and keeps it there however long the quote runs. */
.gv-said__play {
    grid-area: play;
    align-self: start;
    margin-top: calc((var(--gv-text-sm) * var(--gv-leading-relaxed) - var(--gv-control-sm) * 0.72) / 2);
}
.gv-card__body > .gv-said:first-child { margin-top: 0; padding-top: 0; border-top: 0; }

/* A card acting as a labelled section INSIDE another surface — "The motion", "The vote"
   under an agenda item. Flatter than a standalone card on purpose: the item's own border
   already encloses it, and a second full card inside the first reads as two objects when
   it is one. */
.gv-card--band {
    margin-bottom: var(--gv-space-3);
    border-color: var(--gv-border);
    box-shadow: none;
}
.gv-card--band:last-of-type { margin-bottom: 0; }
/* The outcome group: a result and where it came from, reading as part of the heading it
   follows rather than as status pushed to the far edge. ONE line — it lived in app.js's
   own stylesheet, which the design pages do not load, so there it was an unstyled inline
   span that wrapped to two lines and pulled the heading off centre. */
.gv-card__outcome {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-2);
    min-width: 0;
    white-space: nowrap;
}
.gv-card__outcome > * { min-width: 0; overflow: hidden; text-overflow: ellipsis; }

.gv-card__aside {
    margin-left: auto;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-subtle);
    display: inline-flex;
    align-items: center;
    /* Tight: the cluster holds icon buttons, and 8px between two 28px squares reads
       as two unrelated controls rather than one pair. */
    gap: var(--gv-space-1);
}

/* The quick tallies above the roll call — one row of low-weight buttons. */
.gv-quick {
    display: flex;
    flex-wrap: wrap;
    /* Right-aligned: these set the whole column below them, so they sit over the choice
       columns they fill rather than over the member names, which they do not touch. */
    justify-content: flex-end;
    gap: var(--gv-space-2);
    /* Space above as well as below: these sit directly under the last row of the roll
       call, and butted against it they read as another row of the table rather than as
       the shortcut that fills it in. */
    margin-top: var(--gv-space-3);
    margin-bottom: var(--gv-space-3);
}

/* Names one member of a repeated group — "Motion 1 of 2". Only rendered when there IS
   more than one; a lone "Motion 1 of 1" is a count nobody asked for. */
.gv-groupmark {
    margin: var(--gv-space-4) 0 var(--gv-space-2);
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-semibold);
    letter-spacing: var(--gv-tracking-wide);
    text-transform: uppercase;
    color: var(--gv-text-subtle);
}
.gv-groupmark:first-child { margin-top: 0; }

/* ── Read field ───────────────────────────────────────────────────────────────
   A field's value shown as text, until someone asks to change it. The Votes motion
   is the case: the sentence reads, and pressing it swaps a gv-textarea in place.

   Same size and line-height as the input it stands in for, so the line does not jump
   when the swap happens. A permanently open input is a box inviting you to type, on a
   screen where most of the time you are reading — and a bordered field drawn around
   settled language makes a decision that is already made look provisional.          */
.gv-readfield {
    margin: 0;
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-relaxed);
    color: var(--gv-text);
}

/* The value IS the way in. The modifier, not the base rule: a read field is still
   allowed to be text nobody can change, and only the ones wired to an editor should
   look pressable.

   It renders as a <button> so the keyboard and a screen reader get the same control the
   mouse does — which is why every declaration here is a button giving up its chrome, and
   why text-align/white-space are set: a sentence in a button must still wrap and still
   read from the left like the paragraph it replaced. user-select stays text, so the
   language can be selected and copied rather than only typed over.

   The hover tint is --gv-surface-sunk, the same ground .gv-picker's options and the
   motion's own fold row take, padded and pulled back by the same amount so the highlight
   has room either side of the words without moving them.                             */
.gv-readfield--action {
    display: block;
    width: 100%;
    padding: var(--gv-space-1) var(--gv-space-2);
    margin: calc(var(--gv-space-1) * -1) calc(var(--gv-space-2) * -1);
    border: 0;
    border-radius: var(--gv-radius-sm);
    background: none;
    font: inherit;
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-relaxed);
    color: var(--gv-text);
    text-align: left;
    white-space: normal;
    cursor: text;
    user-select: text;
}
.gv-readfield--action:hover { background: var(--gv-surface-sunk); }


/* A band head that opens and shuts. The BUTTON goes inside the heading, not the heading
   inside the button: a heading is flow content and is invalid inside a button, and
   wrapping the whole head would swallow the badge sitting beside it.

   It reuses .gv-row__caret rather than declaring a second one — same object, same
   rotation, and that rule already carries the reason it must start at an explicit
   rotate(0deg) instead of `none`. */
.gv-card__disclose {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-2);
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: inherit;
    cursor: pointer;
    text-align: left;
}
.gv-card__disclose:hover { color: var(--gv-brand-fg); }
.gv-card__disclose:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
    border-radius: var(--gv-radius-sm);
}
/* display:none from [hidden] loses to the body's own display, so it is stated here. */
.gv-card__body[hidden] { display: none; }

/* A shut band draws no rule under its heading. .gv-card__head carries a border-bottom to
   separate it from its body — with the body hidden that border is a line with nothing
   under it, hanging just above the card's own bottom edge. Measured: 17px clear of the
   card foot, full content width, pointing at nothing. */
.gv-card--band.is-shut > .gv-card__head { border-bottom: 0; }

/* A band inside another band. The Votes roll call is the case: the vote is the record of
   what happened to a motion, so it sits inside the motion rather than beside it — but a
   third bordered box inside two others reads as a separate object instead of a part of
   one. It keeps the head and the disclosure and gives up the box. */
.gv-card--nested {
    margin: var(--gv-space-4) 0 0;
    border: 0;
    border-top: 1px solid var(--gv-border);
    border-radius: 0;
    background: none;
    box-shadow: none;
}
.gv-card--nested > .gv-card__head { padding-left: 0; padding-right: 0; }
/* No padding at all: the band gave up its box, so its body has no walls to hold anything
   off. The head's own rule is the top edge, and what follows starts against it — which is
   already what .gv-card__body > .gv-said:first-child says for a quote opening a body, and
   the 16px kept here was the same statement contradicted for everything else. Under the
   roll call it put 24px between "The vote" and the MEMBER row: this padding, then the
   thead's own. Nothing needs it — .gv-table draws its own cell padding, .gv-quick its own
   margin — and a table indented from a heading it belongs to reads as a separate object. */
.gv-card--nested > .gv-card__body { padding: 0; }

/* ── Pick group ───────────────────────────────────────────────────────────────
   Segmented: the members of one exclusive set, joined into a single control. M and 2nd
   are one choice about a member's role in the motion; Aye/Nay/Abstain are one choice
   about their vote. Two joined groups say that before anyone reads a label, where five
   loose buttons in a row said nothing about which belonged with which.

   role="group" with a name, so the set announces as one thing rather than as buttons
   that happen to sit next to each other.                                             */
.gv-picks {
    display: inline-flex;
    width: 100%;
}
/* Shared edges: each button pulls onto its neighbour's border so the seam is one line,
   not two. */
.gv-picks > .gv-pick { border-radius: 0; margin-left: calc(-1 * var(--gv-border-width)); }
.gv-picks > .gv-pick:first-child {
    margin-left: 0;
    border-top-left-radius: var(--gv-radius-sm);
    border-bottom-left-radius: var(--gv-radius-sm);
}
.gv-picks > .gv-pick:last-child {
    border-top-right-radius: var(--gv-radius-sm);
    border-bottom-right-radius: var(--gv-radius-sm);
}
/* The chosen segment's border has to win the seam it shares with its neighbour, or the
   fill looks clipped down one side. */
.gv-picks > .gv-pick[aria-pressed="true"],
.gv-picks > .gv-pick:hover:not(:disabled),
.gv-picks > .gv-pick:focus-visible { position: relative; z-index: var(--gv-z-base); }

/* Only the chosen segment is drawn.

   A five-member roll call renders twenty-five outlined boxes and at most five of them
   carry an answer, so the outlines are most of the screen's noise and none of its
   information. They cannot simply be faded: .gv-pick's border is --gv-ink-500 rather than
   --gv-border on purpose (see the note there) because it is the boundary that IDENTIFIES
   the control, which 1.4.11 wants at 3:1 — and --gv-border is 1.21:1. Nor can the labels
   fade; --gv-text-muted is 6.56:1 and nothing below it still reads.

   So the boundary moves rather than weakening: the GROUP carries it, one box instead of
   five, still at ink-500. What is left between segments is an internal divider, not the
   thing that identifies the control, so a hairline is enough — and hover still paints a
   full outline in the tone, which is a better affordance than a permanent one. */
.gv-picks:not(.gv-picks--split) {
    border: var(--gv-border-width) solid var(--gv-ink-500);
    border-radius: var(--gv-radius-sm);
    /* Clips the chosen segment's fill to the group's corners, so nothing pokes out. */
    overflow: hidden;
}
.gv-picks:not(.gv-picks--split) > .gv-pick {
    margin-left: 0;
    border-color: transparent;
    border-left-color: var(--gv-border);
    border-radius: 0;
}
/* No divider against the group's own edge, and none beside a fill — the fill is its own
   edge, and a hairline laid over it reads as a seam in the colour. The neighbour keeps
   its divider back under the pointer, or the hover outline comes up three-sided. */
.gv-picks:not(.gv-picks--split) > .gv-pick:first-child,
.gv-picks:not(.gv-picks--split) > .gv-pick[aria-pressed="true"],
.gv-picks:not(.gv-picks--split) > .gv-pick[aria-pressed="true"] + .gv-pick:not(:hover) {
    border-left-color: transparent;
}

/* Marks, not alternatives. Joined segments say "pick one of these": true of Aye / Nay /
   Abstain, false of M and 2nd, which are two independent marks a member can hold neither
   of, or both. So they come apart, and the seam handling above is undone with them.
   Each keeps its own ink-500 outline: standalone controls, so each is its own boundary —
   and there are two of them per row, not fifteen. Written at 0,3,0 so it beats the
   joining rules above regardless of source order. */
.gv-picks.gv-picks--split { gap: var(--gv-space-2); }
.gv-picks.gv-picks--split > .gv-pick {
    margin-left: 0;
    border-radius: var(--gv-radius-sm);
}

/* A toolbar-sized step, for a segmented control that sits among ordinary buttons rather
   than in a grid.

   .gv-pick is tuned for the roll call: 11px labels at 19.2px tall, density that file
   says was asked for across seventy-five segments. The agenda toolbar carries four
   controls at 34px and 14px, and the dense size beside them read as a different CLASS
   of control rather than a smaller one — so only the size changes here. Everything that
   makes it the same component is untouched: the shared seam, the ink-500 group
   boundary, the tone fill.

   Sizes are measured against those neighbours, not chosen: control-sm (28px) left the
   group four pixels short on the row. control-md also clears WCAG 2.5.8's 24px target
   minimum, which the dense variant deliberately does not — that trade bought density in
   a grid, and there is no grid here to trade for.

   The height is set on the SEGMENTS but has to be true of the GROUP, and those are not
   the same number here: in a joined group the segments' own borders are transparent and
   the group carries the outline, so its border sits OUTSIDE them. Segments at a flat
   control-md measured 34px and the control measured 36px, two proud of the 34px buttons
   beside it. Subtracting the border the group adds back is what makes the outer height
   the toolbar's height, which is the one a clerk sees. */
.gv-picks--md > .gv-pick {
    min-height: calc(var(--gv-control-md) - var(--gv-border-width) * 2);
    padding: 0 var(--gv-space-3);
    font-size: var(--gv-text-base);
}
/* The roll call stretches its group across a table cell; a toolbar control is as wide as
   its two labels. */
.gv-picks--md { width: auto; }

/* The group's boundary takes the tone of the chosen segment.

   Inside a joined group each segment's own border is transparent and the GROUP carries
   the outline (see the note above), at --gv-ink-500. That token is the ink ramp's
   neutral, and the ramp is built on the brand olive — so "neutral" is rgb(107,124,120),
   green enough to see. Nobody notices it around a dark or a green fill. Around the amber
   Draft fill it reads as a green ring on an orange chip, which is what it is.

   So the edge follows the fill instead of staying put. That is also the more honest
   rule for a two-segment control whose whole job is to say which state you are in: the
   boundary agrees with the state rather than contradicting it. Both tones are ≥5:1 on
   white, comfortably past the 3:1 the boundary owes as the thing that identifies the
   control. Named for the tone, not for Draft and Finalized, so the next two-state
   control can use them.

   Written at 0,2,0 to tie the .gv-picks:not(.gv-picks--split) rule that sets the group
   border in the first place — the same reason .gv-picks--split spells itself out — and
   placed after it so the tie falls this way. At 0,1,0 the class applied and did
   nothing, which is the failure this note exists to prevent. */
.gv-picks.gv-picks--edge-warning { border-color: var(--gv-warning-fg); }
.gv-picks.gv-picks--edge-brand   { border-color: var(--gv-brand-700); }

/* When the collapsible detail above it is folded away, the nested band becomes the first
   thing in the body — sitting directly under the card head's own border. Drop its rule
   and its top margin so a collapsed motion reads as two stacked lines rather than two
   hairlines a few pixels apart. */
.gv-card__body > [hidden] + .gv-card--nested {
    margin-top: 0;
    border-top: 0;
}

/* ...and where the nested band is the WHOLE of its parent's body, it is a continuation of
   that card rather than an object sitting inside it. The body's vertical padding, the
   band's own top margin and its rule are then all separating it from a container it
   already fills, and every one of them is space with nothing in it.

   Both states were paying for it. Shut, that was 64px wrapped around a hidden body: a
   motion whose roll call is folded away is two headings and the line between them, and it
   stood 168px tall to say so. Open, it was 33px between the motion's heading and the
   vote's — with a SECOND horizontal rule in the middle of it, the band's own, 32px under
   the one the head above already draws — and a 29px floor under the quick-set row.

   :only-child is the whole condition. A band with something rendered above it still needs
   that space, because then the margin and the rule are separating two things you can see;
   this is the narrower claim that the body holds nothing else. The head above keeps its
   border-bottom either way, so the two headings stay separated by exactly one line — and
   dropping border-top with margin-top is what stops the band's own rule landing on top of
   it, two hairlines a pixel apart.

   Both rules are gated on :has(), including the second, which a plain child selector could
   express on its own. Ungated it would drop the margin and the rule while the padding
   stayed, wherever :has() is missing — a shape this screen has never had. Behind it the
   pair stands or falls together and the unsupported case is exactly what was here before,
   which is the bargain .mbdoc-facts makes in document.css. */
.gv-card__body:has(> .gv-card--nested:only-child) {
    padding-top: 0;
    padding-bottom: 0;
}
.gv-card__body:has(> .gv-card--nested:only-child) > .gv-card--nested {
    margin-top: 0;
    border-top: 0;
}

/* ── Step status ─────────────────────────────────────────────────────────────
   How far along a meeting is, wherever it gets reported: the dashboard's step chain,
   the in-meeting tab strip, and the audit timeline's lanes. Three screens, one
   vocabulary — it used to be four sets of Tailwind literals written into app.js, which
   is how the in-progress glyph ended up a different amber from its own status bar.

   These set COLOUR only. Everything else about a step — its glyph, its size, whether it
   is a link — belongs to the screen drawing it, and a step is not one component in three
   places; it is one fact three components report. */
.gv-step--done     { color: var(--gv-step-done); }
.gv-step--progress { color: var(--gv-step-progress); }
.gv-step--none     { color: var(--gv-step-none); }

/* Only where the step is something you can click. The dashboard chain makes each step a
   link into that exact tab and darkens it under the pointer; the tab strip and the audit
   lane render the same state classes, carry no .gv-step-link, and so never match this.

   Two selectors per state because the state class sits in two places. On the dashboard it
   is on the button itself — the glyph and its status bar are both inside, and one colour
   covers both — so the hover is on the same element. Elsewhere it is on the glyph inside
   a container. Supporting only the descendant form is the bug this comment replaced: the
   rule was written, and matched nothing anywhere. */
.gv-step-link:hover .gv-step--done,
.gv-step-link.gv-step--done:hover     { color: var(--gv-step-done-hover); }
.gv-step-link:hover .gv-step--progress,
.gv-step-link.gv-step--progress:hover { color: var(--gv-step-progress-hover); }
.gv-step-link:hover .gv-step--none,
.gv-step-link.gv-step--none:hover     { color: var(--gv-step-none-hover); }

/* The fill inside a step's status bar. It shares the glyph's shade so the two read as a
   single mark — which is what the comment above _DASH_STEP_PALETTE has always claimed
   and the code never did. There is no --none: an unstarted step is the grey track with
   nothing in it, and that track is the screen's, not this component's. */
.gv-step-fill--done     { background: var(--gv-step-done); }
.gv-step-fill--progress { background: var(--gv-step-progress); }

/* ── the toolbar sentence's live references (#448) ──────────────────────────
   Promoted out of discussions.css (.mbtv-bar-link / -l2 / -icon / -iconbtn), where it was
   written for the Discussions bar. The Document tab wanted the same treatment, and a
   second copy under a second prefix is how this app ended up with three hand-rolled chip
   sets and four transports. Two surfaces draw it now, from here.

   A <button> stripped back to text, so it reads as part of the sentence rather than a
   control parked in the middle of it — but it IS a button, so it tabs, takes Enter and
   Space, and is announced. A dotted underline at rest is the only hint the words do
   something; solid on hover confirms it. Nothing moves between the two states. */
.gv-barlink {
    appearance: none; background: none; border: 0; padding: 0; margin: 0;
    font: inherit; color: inherit; cursor: pointer;
    text-decoration: underline dotted;
    text-underline-offset: 2px;
    text-decoration-color: var(--gv-ink-400);
}
.gv-barlink:hover {
    text-decoration: underline solid;
    text-underline-offset: 2px;
    text-decoration-color: currentColor;
}
.gv-barlink:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: 2px;
    border-radius: var(--gv-radius-sm);
}
/* The icon variant carries no words, so an underline would just be a dash beside it. Its
   hint is the ink darkening under the cursor instead. */
.gv-barlink--icon { text-decoration: none; vertical-align: baseline; }
.gv-barlink--icon:hover { text-decoration: none; }
.gv-barlink__icon { color: var(--gv-ink-700); }
.gv-barlink--icon:hover .gv-barlink__icon { color: var(--gv-text); }

/* The second line of a toolbar sentence: for the control that cannot be found by looking.
   Blocked rather than left to wrap, so the break falls between the two sentences every
   time instead of wherever the card happens to be narrow. Quieter than the line above —
   the first is what a clerk must read, this one is for their second meeting. */
.gv-subline {
    display: block;
    font-size: var(--gv-text-2xs);
    color: var(--gv-text-subtle);
    margin-top: var(--gv-space-1);
}
.gv-subline .gv-barlink__icon { color: var(--gv-text-subtle); }

/* ── the filter row (#448) ──────────────────────────────────────────────────
   Promoted out of votes.css (.mbmv-f / .mbmv-srch / .mbmv-more). Motions wrote it, the
   Document tab is the second surface to want it, and the local aliases it used (--line,
   --mut2, --ink, --ink2, --g) are spelled as the tokens they aliased so the component
   stands on its own outside .mbmv's scope.

   A search box, the chips, and the actions menu that applies to everything shown. */
.gv-filterbar {
    display: flex; align-items: center; gap: var(--gv-space-2); flex-wrap: wrap;
    padding-bottom: var(--gv-space-2); flex: 0 0 auto;
}
.gv-filterbar__search {
    display: flex; align-items: center; gap: var(--gv-space-2);
    border: 1px solid var(--gv-border);
    background: var(--gv-surface);
    border-radius: var(--gv-radius-md); padding: var(--gv-space-1) var(--gv-space-2);
    flex: 1 1 120px; min-width: 100px; max-width: 360px;
}
.gv-filterbar__search i { color: var(--gv-text-disabled); font-size: var(--gv-text-xs); }
.gv-filterbar__search input {
    border: 0; outline: 0; background: transparent;
    font: inherit; font-size: var(--gv-text-sm); flex: 1; min-width: 0; color: var(--gv-text);
}
/* The chips absorb the slack, so the ... button is never pushed off the row. Flex
   LINE-BREAKING uses each item's hypothetical main size — basis clamped by min/max, worked
   out before anything grows or shrinks. At `0 0 auto` the chips take max-content and refuse
   to shrink, so the search can only ever have what is genuinely spare and its own 120px
   basis decides the break. A zero basis is the opposite bug: the search takes the whole bar
   and the chips wrap two-and-two underneath it. Scoped to this row, because .gv-chips is
   also drawn where nothing follows it that needs protecting. */
.gv-filterbar .gv-chips { flex: 0 0 auto; }
/* margin-left:auto pins it to the right edge however many chips there are, and it stays
   quiet until hovered: it is not another filter, and nothing in it is part of reading. */
.gv-filterbar__more {
    margin-left: auto; flex: 0 0 auto;
    border: 1px solid transparent; background: transparent;
    color: var(--gv-text-disabled);
    border-radius: var(--gv-radius-md); padding: var(--gv-space-1) var(--gv-space-2); cursor: pointer;
}
.gv-filterbar__more:hover {
    border-color: var(--gv-border);
    background: var(--gv-surface);
    color: var(--gv-ink-700);
}
.gv-filterbar__more:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: 1px;
}

/* ── a button whose label changes while it works (#448) ─────────────────────
   Promoted out of votes.css (.mbmv-find__l / .mbmv-find__g), where it was written for
   Find motions. The Document tab's AI buttons want the same thing, and a second copy
   under a second prefix is how this app ended up with three chip sets.

   The two labels are stacked in one grid cell and the hidden one is the wider of the two,
   so the button is sized by the longest thing it will ever say. No measured px to go
   stale: re-word either string, change the font, zoom the page, and the button still does
   not resize the moment it starts working — which is what makes a toolbar hold still while
   one of its controls is busy.

   Pair it with .gov-ai-running (app.html) on an AI button: that un-gates the nib's draw
   and pulse from :hover, so the icon animates for the duration of the run. */
.gv-btn__swap { display: grid; justify-items: center; }
.gv-btn__swap > * { grid-area: 1 / 1; }
.gv-btn__swap-ghost { visibility: hidden; }


/* ── the stage: a working panel and the recording beside it (#466) ───────────
 * Five workflow steps put a recording next to something the clerk works in --
 * Recording, Speakers, Discussions, Motions and Document. They had FOUR layouts
 * between them: three stage rules in discussions.css, votes.css and document.css
 * with different widths, gutters, viewport offsets and collapse points, plus a
 * Tailwind sticky grid in renderTranscriptTab. The video changed size and moved on
 * every tab change.
 *
 * The numbers here are the DOCUMENT tab's, unchanged. That tab was already where the
 * other three had been converging -- it borrows Discussions' player and Motions'
 * transport and panel, and document.css records two earlier bespoke attempts that were
 * reverted for exactly that reason. What was missing was somewhere for it to live that
 * is not one screen's stylesheet.
 *
 * THE KNOBS ARE DECLARED HERE, NOT IN tokens.css. They are one component's
 * measurements, not decisions the app at large should reach for -- the same call
 * --gv-pick-tone makes on .gv-pick. The gutter is the clearest case: 18px is not on
 * the 4px space grid and never was, so promoting it to --gv-space-* would put a step
 * on that scale which contradicts the scale's own story. */
.gv-stage {
    /* The DEFAULT split. Note the name: this is not --gv-stage-side, and the difference is
       load-bearing rather than cosmetic.

       The drag writes --gv-stage-side on document.documentElement, so one value covers
       every step and survives a tab change. But a custom property declared ON an element
       beats one INHERITED into it, so while this rule declared --gv-stage-side itself, the
       root's value lost to it every time -- the width persisted correctly to localStorage
       and the column never moved. Declaring the default under its own name and reading it
       as the var() FALLBACK inverts that: with nothing on the root the fallback applies,
       and the moment the root has a value it wins, because there is no local declaration
       left to beat it. */
    --gv-stage-side-default: clamp(260px, 36%, 420px);
    /* How far the drag may go, read by GV.stage rather than duplicated in JS. */
    --gv-stage-side-min: 260px;
    --gv-stage-side-max: 640px;
    --gv-stage-main-min: 560px;
    /* The gutter, and the rail's width -- they are the same 18px because the rail IS
       the gutter. A separate handle sitting inside a gap would have pushed the two
       columns further apart than they were before. */
    --gv-stage-rail: 18px;
    /* What sits above the stage: 140px of app header, meeting header and tab strip,
       plus main-content's 24px top padding. Arrived at on the Motions tab and asserted
       by check-votes-css.js. */
    --gv-stage-top: 164px;
    --gv-stage-min-h: 520px;

    display: grid;
    /* Three columns, not two with a gap. The rail occupies exactly the space the gutter
       used to, so converging on this changed no spacing anywhere -- and it gives the
       handle somewhere to live that is the full height of the stage. */
    /* The min() is the FLOOR UNDER THE MAIN COLUMN, and it belongs here rather than in
       GV.stage. The drag clamps in JS, but the split is also restored from localStorage at
       load — and a width that was reasonable on a wide monitor is not reasonable on the
       laptop the same clerk opens tomorrow. Restored unclamped, a 640px split at a 1200px
       window left the document 255px against its own 560px minimum, and it stayed that way
       until something happened to re-clamp it. Expressed here it needs no JS to run first,
       holds at every width, and cannot be got round by any future caller. */
    grid-template-columns:
        minmax(0, 1fr)
        var(--gv-stage-rail)
        min(var(--gv-stage-side, var(--gv-stage-side-default)),
            calc(100% - var(--gv-stage-rail) - var(--gv-stage-main-min)));
    gap: 0;
    height: calc(100vh - var(--gv-stage-top));
    min-height: var(--gv-stage-min-h);
}

/* Both columns: flex, so their children can declare what shrinks. min-height:0 is
   load-bearing -- without it a grid item refuses to shrink below its content and the
   column that should scroll internally pushes the stage past the fold instead. */
.gv-stage__main,
.gv-stage__side { display: flex; flex-direction: column; min-width: 0; min-height: 0; }

/* A containment wall, not a scroll setting. The recording lives in this column, and a
   player that paints outside it lands on top of what the clerk is reading. Whatever
   goes wrong inside, it stays inside. (From .mbdoc-right, which learnt it the hard way.) */
.gv-stage__side { overflow: hidden; }

/* ── the rail ───────────────────────────────────────────────────────────────
 * Invisible until pointed at. At rest this is 18px of blank gutter, which is what it
 * has always been; the line only appears when it is about to do something. A permanent
 * divider between two columns that are already obviously separate would be chrome
 * asserting itself on a screen a clerk spends an hour inside. */
.gv-stage__rail {
    position: relative;
    cursor: col-resize;
    /* No padding/margin: the grid column IS its width. Border-box so the ::after
       hairline cannot widen it. */
    box-sizing: border-box;
    background: transparent;
    border: 0;
    /* Above the columns' own content so the last few pixels of a wide table cannot steal
       the grab. Base, not sticky -- it must stay under menus and modals. */
    z-index: var(--gv-z-base);
    touch-action: none;   /* the pointer handlers own the gesture; no scroll-hijack */
}
/* The hairline, drawn down the middle of the rail rather than at its edge, so it lands
   exactly where the eye already reads the boundary. */
.gv-stage__rail::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 50%;
    width: var(--gv-border-width);
    transform: translateX(-50%);
    background: transparent;
    transition: background var(--gv-dur-fast) var(--gv-ease);
}
.gv-stage__rail:hover::after,
.gv-stage__rail:focus-visible::after { background: var(--gv-border-strong); }
.gv-stage__rail.is-dragging::after { background: var(--gv-focus-color); }
.gv-stage__rail:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: calc(var(--gv-focus-offset) * -1);
    border-radius: var(--gv-radius-sm);
}
/* While a drag is live the whole window keeps the resize cursor and stops selecting
   text, or dragging left across the document highlights three paragraphs on the way. */
.gv-stage-dragging,
.gv-stage-dragging * { cursor: col-resize !important; user-select: none !important; }

/* ── the recording box ──────────────────────────────────────────────────────
 * A fixed 16:9 frame: the <video>, the YouTube iframe and the "no recording" message
 * all fill it identically, so the column never changes height as the source resolves.
 *
 * Laid out in normal flow, NOT `position:absolute; inset:0` -- which is how .mbtv-vid
 * and .mbmv-vid did it, and is the one way a child of this column can end up as wide as
 * the whole window. An absolutely-positioned child sizes itself against its nearest
 * POSITIONED ancestor; lose this box its `position:relative` -- a dropped rule, a
 * stylesheet that did not load, a malformed comment swallowing the block (which has
 * happened in document.css already) -- and the chain walks up to the initial containing
 * block, laying the player out against the VIEWPORT: a full-width band of video across
 * the foot of the page. A flex child cannot do that. It is sized by its parent by
 * definition, so the failure mode is a clipped player, not one that has escaped. */
.gv-stage__vid {
    position: relative;
    display: flex;
    flex: 0 0 auto;
    width: 100%;
    aspect-ratio: 16 / 9;
    max-height: 32vh;
    background: var(--gv-ink-900);
    border-radius: var(--gv-radius-md);
    overflow: hidden;
    box-shadow: var(--gv-shadow-sm);
}
.gv-stage__vid > * { flex: 1 1 auto; min-width: 0; width: 100%; height: 100%; }
/* The video is a GRANDCHILD, not a child: mbTvPlayerHtml wraps it in #ts-video-wrap, and
   mbEnsureTranscriptVideo re-injects it there with Tailwind's `w-full block aspect-video`
   once the media URL resolves. So `> *` sizes the wrap and nothing sizes the video, and
   aspect-video then insists on 16/9 of the wrap's WIDTH -- measured at 511x287 inside a
   511x272 box, saved from spilling only by overflow:hidden. Filling the box and letting
   object-fit letterbox is what makes the picture obey the frame instead of the reverse. */
.gv-stage__vid video,
.gv-stage__vid iframe { width: 100%; height: 100%; }
.gv-stage__vid video { object-fit: contain; background: var(--gv-ink-900); display: block; }
.gv-stage__vid iframe { border: 0; display: block; }

/* ── the media stack ────────────────────────────────────────────────────────
 * A recording and a poster behind it, in the one frame (#491).
 *
 * The poster is a SIBLING of the slot the player lives in, not a child of it, and that is
 * the whole point of this rule existing: mbEnsureTranscriptVideo and the faststart swap
 * both rebuild #ts-video-wrap by assigning innerHTML, so anything inside the slot is gone
 * the moment a recording resolves or is optimised. Outside it, the backdrop survives.
 *
 * With picture, the <video> covers it completely and none of this is visible. Without —
 * an audio-only source, which the loadedmetadata handler turns into a bar — the poster is
 * what the frame shows, and the bar sits on its foot.
 *
 * Stacked by DOM ORDER, with no z-index anywhere. Both children are positioned and neither
 * opens a stacking context, so the slot — written second — paints over the backdrop, and
 * the bar, absolute inside the slot, paints over both. The scale only reaches down to
 * --gv-z-base (1) and this stack wants three layers below anything that token means; order
 * gives them for nothing, and nothing here has to be lifted past a sibling elsewhere. */
.gv-stage__media { position: relative; width: 100%; height: 100%; }
.gv-stage__media > .gv-poster { position: absolute; inset: 0; }
.gv-stage__media-slot { position: relative; width: 100%; height: 100%; }
/* A recording with sound and no picture. The <video> stays — it is transparent when there
   is no video track, so the only thing that ever made it a black rectangle was the ink
   background two rules up, and here it is taken back off. */
.gv-stage__media--audio video { background: none; }

/* The "no recording" message, filling the same frame so the column does not change
   height depending on whether there is anything to play. */
.gv-stage__noplay {
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--gv-text-subtle);
    font-size: var(--gv-text-sm);
    background: var(--gv-surface-sunk);
    text-align: center;
    padding: 0 var(--gv-space-6);
}

/* ── the poster ─────────────────────────────────────────────────────────────
 * What fills the recording frame when there is no recording (#491). That frame is the
 * most prominent thing on its screen and it used to empty out: one line of grey text in
 * the workspace, and on the public portal no column at all, leaving a transcript alone
 * on a page that leads with a video everywhere else. So it holds the org's seal instead,
 * captioned with the board that met and when — the frame still says what you are looking
 * at.
 *
 * DARK ground, deliberately: the same ground the player sits on, so the column does not
 * change colour depending on whether a recording exists. .gv-stage__noplay above stays
 * light, and stays what the archived and "Loading recording…" states use — there the
 * message IS the content and it is read, not glanced at.
 *
 * SIZED BY ITS PARENT, never by itself. .gv-stage__vid hands its children 100%/100% and
 * the portal wraps this in Tailwind's .aspect-video; give this box an aspect-ratio of its
 * own and the two frames start arguing, which is the failure .gv-stage__vid's own comment
 * above records having already had once. It fills whatever it is put in, and the seal
 * scales off that rather than off a px value that suits one of the two. */
.gv-poster {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--gv-space-1);
    width: 100%;
    height: 100%;
    min-width: 0;
    overflow: hidden;
    padding: var(--gv-space-3) var(--gv-space-5);
    background: var(--gv-ink-900);
    color: var(--gv-text-invert);
    text-align: center;
    /* Queried by the short-frame rule at the bottom of this block. The box already has a
       determinate size in both axes — 100% of a parent that sets its own aspect-ratio — so
       size containment costs nothing here, and it is the only way this component can react
       to a frame it is not allowed to measure any other way. */
    container-type: size;
}

/* A proportion of the frame, with a cap. The same markup renders in a 32vh stage box and
   in a 620px portal column, so a fixed px seal is wrong in one of them; the cap stops it
   dominating a wide frame and the floor keeps it legible in the mini player. */
.gv-poster__seal {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 20%;
    max-width: 56px;
    min-width: 32px;
    aspect-ratio: 1;
    flex-shrink: 0;
    margin-bottom: var(--gv-space-1);
    border-radius: var(--gv-radius-full);
    background: var(--gv-surface);
    overflow: hidden;
}
/* `contain`, not `cover`: a seal is often a wordmark on a transparent ground, and cover
   crops the words off. The white disc behind it is what makes a dark seal readable here. */
.gv-poster__seal img { width: 100%; height: 100%; object-fit: contain; display: block; }

/* A seal that carries its own light ground does not want a second circle drawn round it.
   Which seals those are is measured, not assumed — gvSealTone samples the image and only
   adds this when the mark is genuinely light and covers enough of its box. Dark line art,
   a cross-origin image it cannot read, or a failed probe all keep the disc. */
.gv-poster__seal--bare {
    background: none;
    border-radius: var(--gv-radius-sm);
    overflow: visible;
}
.gv-poster__seal--bare img { object-fit: contain; }

/* No seal on file. An outlined disc rather than a white one — a filled white circle with
   nothing in it reads as a broken image, which is the thing this branch exists to avoid. */
.gv-poster__seal--initials {
    background: none;
    border: 1px solid var(--gv-ink-500);
    color: var(--gv-text-invert);
    font-size: var(--gv-text-sm);
    font-weight: 700;
    letter-spacing: var(--gv-tracking-wide);
}

.gv-poster__board {
    font-size: var(--gv-text-base);
    font-weight: 600;
    line-height: var(--gv-leading-tight);
}

/* The two caption lines step back from the board name by opacity rather than by colour.
   Every --gv-text-* stop is tuned for contrast on a LIGHT surface, so none of them is the
   right answer on ink; white at these strengths clears AA on --gv-ink-900 comfortably
   (roughly 11:1 and 7:1) without adding a token that exists for one component. */
.gv-poster__when {
    font-size: var(--gv-text-xs);
    line-height: var(--gv-leading-snug);
    opacity: 0.78;
}

.gv-poster__hint {
    max-width: 34ch;
    margin-top: var(--gv-space-2);
    font-size: var(--gv-text-2xs);
    line-height: var(--gv-leading-snug);
    opacity: 0.62;
}

/* ── the scrubber ───────────────────────────────────────────────────────────
 * Where you are in a recording that has no picture, and how to get somewhere else.
 *
 * A gradient rather than a bar: it fades out upwards into the poster instead of cutting a
 * hard edge across it, so the seal above keeps its whole frame. The browser's own control
 * pill could not be made to do this — its shadow DOM takes no styling — which is why the
 * native set comes off for this case and these thirty lines exist.
 *
 * Play, volume and full screen sit here; ±10s and speed stay in .gv-transport under the
 * frame. The play button is not a second implementation of playback — it calls the same
 * mbTogglePlayVideo and wears .js-playicon, so it follows a recording started anywhere. */
.gv-poster__scrub {
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
    padding: var(--gv-space-6) var(--gv-space-3) var(--gv-space-2);
    background: linear-gradient(to top, var(--gv-ink-900), transparent);
    color: var(--gv-text-invert);
}

/* Play, mute and full screen. Ghost buttons — the picture behind them is the surface, so
   anything with a fill of its own would read as a row of chips laid on the poster. */
.gv-poster__ctl {
    display: flex;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    padding: 0;
    border: 0;
    border-radius: var(--gv-radius-sm);
    background: none;
    color: var(--gv-text-invert);
    font-size: var(--gv-text-xs);
    cursor: pointer;
    opacity: 0.85;
    transition: opacity var(--gv-dur-fast) var(--gv-ease);
}
.gv-poster__ctl:hover { opacity: 1; }
.gv-poster__ctl:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}

/* Tabular, so the numbers stop shuffling sideways once a second while it plays. */
.gv-poster__clock {
    flex-shrink: 0;
    font-size: var(--gv-text-2xs);
    font-variant-numeric: tabular-nums;
    opacity: 0.85;
}

/* The track is drawn by us on both engines: appearance:none strips the platform slider,
   then ::-webkit-slider-thumb and ::-moz-range-thumb put the handle back. Left native it
   inherits the OS accent, which is the one colour on this frame nobody chose. */
.gv-poster__seek,
.gv-poster__vol {
    min-width: 0;
    height: var(--gv-space-1);
    appearance: none;
    -webkit-appearance: none;
    border-radius: var(--gv-radius-full);
    background: var(--gv-ink-600);
    cursor: pointer;
}
.gv-poster__seek { flex: 1 1 auto; }
/* Both sliders, one thumb. Grouped per vendor prefix and never across them: one engine
   discarding a selector it does not know would take the whole rule with it, and the thumb
   would fall back to the platform's — the OS accent blue, the one colour on this frame
   nobody chose. That is exactly what volume did before this selector learnt its name. */
.gv-poster__seek::-webkit-slider-thumb,
.gv-poster__vol::-webkit-slider-thumb {
    appearance: none;
    -webkit-appearance: none;
    width: 12px;
    height: 12px;
    border-radius: var(--gv-radius-full);
    background: var(--gv-text-invert);
    cursor: pointer;
}
.gv-poster__seek::-moz-range-thumb,
.gv-poster__vol::-moz-range-thumb {
    width: 12px;
    height: 12px;
    border: 0;
    border-radius: var(--gv-radius-full);
    background: var(--gv-text-invert);
    cursor: pointer;
}
/* The house ring, on the one surface in the system that is dark. --gv-focus-color is
   --gv-brand-700, which was picked against light surfaces; it reads here, but if the ring
   ever needs an on-ink stop this is the rule that will want it first. */
.gv-poster__seek:focus-visible,
.gv-poster__vol:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}

/* Volume is the seek bar at a fixed width — the shared rules above give it its track and
   thumb, so there is one slider in two sizes rather than two sets of thumb rules to drift
   apart. */
.gv-poster__vol {
    flex: 0 0 auto;
    width: 56px;
}

/* A short frame loses the parts a clerk can do without, in order. Volume goes first — the
   mute button stays, and the OS has a volume control besides — then full screen, which is
   worth least on a frame this small. Play, the clock and the seek bar never go. */
@container (max-width: 340px) {
    .gv-poster__vol { display: none; }
}
@container (max-width: 260px) {
    .gv-poster__ctl.js-ts-full { display: none; }
}

/* A 16:9 frame gets SHORT before it gets narrow, and it is not one size: the stage column
   clamps between roughly 380px and 560px, the portal's is 620px, the mini player is
   smaller than any of them, and max-height above caps the whole thing at 32vh (26vh on a
   short laptop) regardless of width. At 420px wide the box is 187px tall and the seal plus
   three lines measured 194 — seven pixels of hint quietly cut off by the overflow rule
   above, which is the failure this component is least likely to be looked at for.

   The sizes above buy that headroom back; these two rules give it up again in order, worst
   case first, so the frame degrades instead of clipping. The hint goes first because it is
   advice; the caption is the meeting's identity and goes last.

   The lengths are CONTENT-box, which is what a size container queries — so they bite about
   24px of frame higher than they read, the block's own vertical padding. Measured across
   260-620px wide and 80-350px tall: nothing clips at any of them. */
@container (max-height: 150px) {
    .gv-poster__hint { display: none; }
}
@container (max-height: 110px) {
    .gv-poster__seal { display: none; }
    .gv-poster__when { font-size: var(--gv-text-2xs); }
}

/* ── stacked ────────────────────────────────────────────────────────────────
 * ONE breakpoint for all five steps (#466). They had three between them -- 1040px on
 * Discussions, 1180px on Motions, and Document refusing to collapse at all -- so the
 * tabs disagreed at narrow widths even where they agreed at wide ones.
 *
 * 1180 is the widest of the three, and it is the one that has to win. votes.css measures
 * the Motions filter bar as needing up to 760px on a hundred-motion meeting; hold two
 * columns at 1180 and that bar gets 722px and wraps to three lines. Document's
 * never-collapse rule was the deliberate one -- it argued the recording must never drop
 * BELOW the document -- and it is the rule being given up here, because a document
 * squeezed under a filter bar that has wrapped is worse than a recording the clerk has
 * to scroll to. */
@media (max-width: 1180px) {
    .gv-stage { grid-template-columns: 1fr; height: auto; min-height: 0; }
    .gv-stage__rail { display: none; }
    .gv-stage__side { overflow: visible; }
}

/* A short window, not a narrow one. On a 768px-tall laptop a 32vh player leaves the
 * transport and the panel under it fighting for what is left, and the panel is the half a
 * clerk is actually working in. The Document tab was the one of the three that had thought
 * about this (26vh); Discussions said 34vh and Motions said nothing, so the careful answer
 * is the one all five get. */
@media (max-height: 840px) {
    .gv-stage__vid { max-height: 26vh; }
}

@media (prefers-reduced-motion: reduce) {
    .gv-stage__rail::after { transition: none; }
}
