/* 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);
}

/* A step BACK rather than a deletion -- Revert to Draft, on Review Agenda and Minutes (#843).
   Built like --danger-quiet: the same white surface and light rule as its neighbours, and
   coloured from the moment it is on screen rather than only under the cursor. Amber, the
   colour of the Draft pill, because amber is where pressing it takes the document; red read
   as a delete, and beside the green Approved pill the pair was the loudest thing on the row. */
.gv-btn--warning-quiet {
    background: var(--gv-surface);
    border-color: var(--gv-border);
    color: var(--gv-warning-fg);
}
.gv-btn--warning-quiet:hover:not(:disabled) {
    background: var(--gv-warning-bg);
    border-color: var(--gv-warning-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);
}

/* Split button: an action with a default and a way to vary it (#654). The label runs the
   usual thing; the caret welded to its edge offers the alternatives.

   The two <button>s stay two buttons — one control drawn as one, not a button with a
   pretend second half. Each is separately focusable and separately labelled, which is what
   a keyboard user needs: tab to the action, tab again to the choices.

   The rounding is the whole trick. Each half keeps .gv-btn's own radius on its outer edge
   and loses it on the inner, and the caret pulls left by exactly one border width so the
   two rules become one shared rule instead of a double-thick seam. --gv-border-width is
   named for precisely this (see tokens.css). */
.gv-btn-split {
    display: inline-flex;
    align-items: stretch;
}
.gv-btn--split-main {
    border-top-right-radius: 0;
    border-bottom-right-radius: 0;
}
.gv-btn--split-caret {
    margin-left: calc(-1 * var(--gv-border-width));
    border-top-left-radius: 0;
    border-bottom-left-radius: 0;
    /* Square-ish: it holds one 9px glyph, and --gv-space-3 either side would make the
       caret as wide as a short label. */
    padding-left: var(--gv-space-2);
    padding-right: var(--gv-space-2);
}
.gv-btn--split-caret i { font-size: var(--gv-text-3xs); }
/* Whichever half is hovered or focused owns the shared rule, or the seam reads as the
   other half's edge cutting across it. */
.gv-btn-split .gv-btn:hover:not(:disabled),
.gv-btn-split .gv-btn:focus-visible {
    position: relative;
    z-index: var(--gv-z-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);
}

/* A field's helper line: what the field does, or where its value came from. Quieter than
   the label and never red. A problem with the value is .gv-field-error, not this. The
   warning variant is for a value the app had to guess ("No board time set — using 2:00 PM"),
   worth a second look but not wrong. */
.gv-field-hint {
    margin-top: var(--gv-space-1);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text-subtle);
}
.gv-field-hint--warn {
    color: var(--gv-warning-fg);
}

/* "(optional)" after a label. It sits in the label's line at the label's size, but
   unbolded and quieter, so the field name still reads first. */
.gv-label__optional {
    font-weight: var(--gv-weight-normal);
    color: var(--gv-text-subtle);
}

/* 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;
}

/* ── Start panel ──────────────────────────────────────────────────────────────
   What every workflow step shows before its work has started: Review Agenda (#186),
   Recording, Speakers, Motions and Minutes (#671). One shape for all five, so the steps
   read as a set and change together here:

       the AI nib
       title
       [the field, when the thing is a link]
       ( the one button )
       1 · 2 · 3   what happens after pressing it
       ───────────
       the other ways to start, as links in a sentence
       notes

   ONE BUTTON, CENTRED, and everything else quieter than it. The complaint #186 answered
   was that uploading looked like the least important thing on the screen; anything that
   gives a second start equal weight puts the screen back there. The other starts are
   text links below the rule.

   Was .ag-start in styles.css, with its colours typed in; lifted here onto tokens when
   Recording took the same shape. */
.gv-start {
    max-width: 32.5rem;
    margin: 0 auto;
    /* The panel owns its breathing room, so every step's empty screen sits the same
       distance under the tab row whatever it is drawn inside. */
    padding: var(--gv-space-8) var(--gv-space-5);
    text-align: center;
    font-family: var(--gv-font-ui);
}
.gv-start__nib {
    display: block;
    margin-bottom: var(--gv-space-3);
    /* A fixed size, not an em of whatever the panel sits in (#671): Agenda's panel is
       inside a text-sm table, Recording's a card, so an em drew five different nibs. */
    font-size: var(--gv-control-lg);
    color: var(--gv-brand-900);
    line-height: var(--gv-leading-none);
}
/* A step that can't start yet — no recording to read. Its own icon, in grey, where the
   nib would be: the panel still says what the step is for, and the link under it says
   where to go first. */
.gv-start__nib--muted {
    color: var(--gv-ink-400);
    font-size: var(--gv-text-xl);
}
/* The white card a start panel sits in when nothing else on the step holds it — Recording
   (and Speakers, Discussions and Motions before there is a recording), Motions before any
   motions, Minutes before any pages. Review Agenda's and Speakers' panels are already inside
   a card of their own (the agenda table, .ts-roster), so they don't take a second one. */
.gv-start-card {
    background: var(--gv-surface);
    border: var(--gv-border-width) solid var(--gv-border);
    border-radius: var(--gv-radius-lg);
}
/* The one card on a step with no stage — Review Agenda's, and Recording's before there is
   a recording — fills the window as a stage does (Vinny, review of #676): the same top,
   measured by GV.stage's stageFit and written on the card as --gv-stage-top (105px until
   it is), and the same 12px gap under it. A MIN height, so a long agenda still grows. */
.gv-fill {
    --gv-stage-top: 105px;
    min-height: calc(100vh - var(--gv-stage-top) - var(--gv-space-3));
}
/* On a stage — the recording and its panel beside it (Motions; Minutes, through its
   document column in document.css) — the card runs the column's full height, so its
   bottom edge lines up with the side panel's instead of stopping under the steps (Vinny,
   review of #676). It scrolls rather than overflowing if the window is shorter than it. */
.gv-stage__main > .gv-start-card {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}
.gv-start__title {
    /* The title is followed straight by the field or the button: the numbered steps under
       the button are the explanation (Vinny, review of #676), so no sentence sits here. */
    margin: 0 0 var(--gv-space-4);
    font-size: var(--gv-text-lg);
    font-weight: var(--gv-weight-bold);
    color: var(--gv-text);
}
/* Only where a panel has no steps to explain it — a view-only member's agenda, a step
   waiting on its recording — and then it reads as the title's second line. */
.gv-start__sub {
    max-width: 26.25rem;
    margin: calc(var(--gv-space-1) - var(--gv-space-4)) auto var(--gv-space-4);
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text-muted);
}
.gv-start__cta {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-2);
    padding: var(--gv-space-3) var(--gv-space-5);
    border: 0;
    border-radius: var(--gv-radius-lg);
    background: var(--gv-brand-900);
    color: var(--gv-text-invert);
    font-family: inherit;
    font-size: var(--gv-text-base);
    font-weight: var(--gv-weight-semibold);
    line-height: var(--gv-leading-tight);
    cursor: pointer;
    transition: background-color var(--gv-dur-fast) var(--gv-ease);
}
.gv-start__cta:hover:not(:disabled) { background: var(--gv-brand-800); }
.gv-start__cta:focus-visible,
.gv-start__link:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
.gv-start__cta:disabled { cursor: progress; opacity: 0.6; }
.gv-start__cta i { font-size: var(--gv-text-base); }

/* The steps are the promise the button makes. Numbered, because they happen in order. */
.gv-start__steps {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--gv-space-2) var(--gv-space-5);
    margin: var(--gv-space-3) 0 0;
    padding: 0;
    list-style: none;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-muted);
}
.gv-start__steps li { display: flex; align-items: center; gap: var(--gv-space-2); }
.gv-start__steps li span {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: var(--gv-space-4);
    height: var(--gv-space-4);
    border-radius: var(--gv-radius-full);
    background: var(--gv-brand-100);
    color: var(--gv-brand-700);
    font-size: var(--gv-text-3xs);
    font-weight: var(--gv-weight-bold);
}

.gv-start__foot {
    margin-top: var(--gv-space-5);
    padding-top: var(--gv-space-3);
    border-top: var(--gv-border-width) solid var(--gv-border);
}
.gv-start__alt {
    margin: 0;
    font-size: var(--gv-text-sm);
    color: var(--gv-text-muted);
}
.gv-start__note {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--gv-space-2);
    margin: var(--gv-space-2) 0 0;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-muted);
}
.gv-start__note i { color: var(--gv-ink-400); font-size: var(--gv-text-2xs); }
.gv-start__link {
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    font-weight: var(--gv-weight-semibold);
    color: var(--gv-brand-900);
    cursor: pointer;
}
.gv-start__link:hover { text-decoration: underline; }
.gv-start__sep { margin: 0 var(--gv-space-2); color: var(--gv-text-disabled); }

/* Recording's link field, between the sentence and the button. Left-aligned inside a
   centred panel: a URL reads from its start. */
.gv-start__field {
    max-width: 26.25rem;
    margin: 0 auto;
    text-align: left;
}
/* What the field found in a pasted link, as a tag at its right end (review of #676):
   nothing sits under the field, so the checkbox follows it directly and nothing moves when
   a link is pasted. The word carries the state; the icon is beside it, never instead. The
   field makes room for the tag only while there is one. */
.gv-start__input { position: relative; }
.gv-start__input.has-tag .gv-input { padding-right: calc(var(--gv-space-10) + var(--gv-space-8)); }
.gv-start__tag {
    position: absolute;
    top: 50%;
    right: var(--gv-space-2);
    transform: translateY(-50%);
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-1);
    padding: var(--gv-space-1) var(--gv-space-2);
    border-radius: var(--gv-radius-full);
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-semibold);
    line-height: var(--gv-leading-tight);
    white-space: nowrap;
    pointer-events: none;
}
.gv-start__tag--ok  { background: var(--gv-success-bg); color: var(--gv-success-fg); }
.gv-start__tag--try { background: var(--gv-neutral-bg); color: var(--gv-neutral-fg); }
.gv-start__tag--err { background: var(--gv-danger-bg);  color: var(--gv-danger-fg); }
.gv-start__check {
    justify-content: center;
    margin: var(--gv-space-3) 0 var(--gv-space-4);
    font-size: var(--gv-text-sm);
    color: var(--gv-text-muted);
}
.gv-start__status:empty { display: none; }
.gv-start__status { margin: 0 0 var(--gv-space-3); }
/* A file chosen instead of a link: its name, its size, and a way to take it back. */
.gv-start__file {
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
    padding: var(--gv-space-2) var(--gv-space-3);
    border: var(--gv-border-width) solid var(--gv-border);
    border-radius: var(--gv-radius-md);
    background: var(--gv-surface-sunk);
    font-size: var(--gv-text-sm);
    color: var(--gv-text);
}
.gv-start__file i { color: var(--gv-ink-400); }
.gv-start__file-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-weight: var(--gv-weight-semibold);
}
.gv-start__file-size { flex: 0 0 auto; color: var(--gv-text-muted); }
.gv-start__remove {
    flex: 0 0 auto;
    width: var(--gv-control-2xs);
    height: var(--gv-control-2xs);
    padding: 0;
    border: 0;
    border-radius: var(--gv-radius-full);
    background: none;
    color: var(--gv-text-muted);
    cursor: pointer;
}
.gv-start__remove:hover { background: var(--gv-surface); color: var(--gv-text); }
.gv-start__remove:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
/* The panel takes a dropped file too. Brand rule and brand wash while one is held over
   it, so the state survives a colourblind reader. */
.gv-start.is-dragover {
    border-radius: var(--gv-radius-lg);
    outline: var(--gv-border-width) dashed var(--gv-brand-700);
    outline-offset: var(--gv-space-3);
    background: var(--gv-brand-50);
}

/* ── 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);
}
/* A card that is a tab's panel, hung straight off a .gv-tabs strip with no gap between them.
   The strip's bottom rule is the card's top edge, so the card drops its own top border (two
   rules would stack into a 2px line) and squares its top corners, which would otherwise
   pull away from that straight rule. Tab and panel read as one piece. First use: the Admin
   Portal organization page's Users table (#801). */
.gv-card--tabbed {
    border-top: 0;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
}

/* ── 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); }
/* A golden clone (#796): the Golden pill's colour, outlined -- it is the copy, not the reference. */
.gv-badge--golden-clone { background: transparent; 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. */
/* The whole row, its header included (#811): a row whose name is a <th scope="row"> -- the
   Users tab, the Organizations list -- used to tint every cell but the name, leaving a white
   gap at the start of the row under the pointer. */
.gv-table tbody tr:hover td,
.gv-table tbody tr:hover th { background: var(--gv-surface-sunk); }
.gv-table__num { text-align: right; font-variant-numeric: tabular-nums; }
/* A short count read on its own rather than summed down the column -- an org's users (#811) --
   sits in the middle of its column. Still tabular, so the digits line up row to row. */
/* Written against th and td under .gv-table, so it outranks `.gv-table thead th`'s left
   alignment and the header's label centres over its numbers too. */
.gv-table th.gv-table__num--center,
.gv-table td.gv-table__num--center { text-align: center; }
/* A column header that sorts (#811): the label is the button, so it keeps the header's own
   micro-caps -- a button resets font and case, which is why each one used to be hand-classed
   back to look like its header. Muted at rest like every column label; the sorted column's
   label goes to full text colour, keyed off the aria-sort its <th> already carries, so the
   look and what a screen reader hears cannot disagree. The arrow is the label's own child. */
.gv-table__sort {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-1);
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    letter-spacing: inherit;
    text-transform: inherit;
    color: inherit;
    cursor: pointer;
}
.gv-table__sort:hover { color: var(--gv-text); }
.gv-table th[aria-sort] .gv-table__sort { color: var(--gv-text); }
.gv-table__sort .fas { font-size: var(--gv-text-2xs); }

/* A column's filter (#811): a small funnel beside the header's label, opening a GV.menu of the
   column's values. Quiet at rest like the label it follows; while the column is filtered it
   carries .is-active and turns brand, so a narrowed table says which columns narrowed it even
   before anyone reads the chips. 2xs control: it must not make the header row taller. */
.gv-table__filter {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--gv-control-2xs);
    height: var(--gv-control-2xs);
    margin-left: var(--gv-space-1);
    padding: 0;
    border: 0;
    border-radius: var(--gv-radius-sm);
    background: none;
    color: var(--gv-text-disabled);
    font-size: var(--gv-text-2xs);
    vertical-align: middle;
    cursor: pointer;
}
.gv-table__filter:hover { color: var(--gv-text); background: var(--gv-surface-sunk); }
.gv-table__filter.is-active { color: var(--gv-brand-700); background: var(--gv-brand-50); }

/* A table whose columns hold still (#811). Left to the browser, a table sizes each column to the
   rows it is showing, so filtering or searching -- which changes the rows -- shuffles every
   column sideways under the pointer. Fixed layout takes the widths from a <colgroup> instead and
   keeps them whatever is listed. Pair it with a min-width on the table so a narrow card scrolls
   (the table sits in an overflow-x container) rather than crushing the columns. */
.gv-table--fixed { table-layout: fixed; }

/* A table with many columns in a card that is not wide (#805): the side padding drops a step, so
   seven or eight columns fit a laptop-width card without scrolling. Rows keep their height. */
.gv-table--compact thead th,
.gv-table--compact tbody th,
.gv-table--compact td {
    padding-left: var(--gv-space-2);
    padding-right: var(--gv-space-2);
}
/* A last column pinned to the right edge of the table's scroll container: a row's actions stay
   in reach when the table is wider than its card and scrolls sideways (#805). Opaque so the
   columns sliding under it don't show through; the row's hover tint still reaches it, because
   `tbody tr:hover td` / `th` outrank it. */
.gv-table th.gv-table__pin-end,
.gv-table td.gv-table__pin-end {
    position: sticky;
    right: 0;
    background: var(--gv-surface);
}

/* ── Page head ────────────────────────────────────────────────────────────────
   The top of a screen (#811): its title, a facts line under it (counts, states -- badges
   welcome), and the screen's own actions at the right edge. The Admin Portal's Organizations
   page is the first; each screen had hand-sized its own h2 in Tailwind greys. */
.gv-pagehead {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--gv-space-4);
    margin-bottom: var(--gv-space-5);
}
.gv-pagehead__title {
    margin: 0;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xl);
    font-weight: var(--gv-weight-bold);
    line-height: var(--gv-leading-tight);
    color: var(--gv-text);
}
.gv-pagehead__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gv-space-2);
    margin: var(--gv-space-1) 0 0;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    color: var(--gv-text-muted);
}
/* A fact on that line that is also a way in (#811): "● 2 in trial" filters the screen to those 2.
   Quiet -- the line's own muted text, a dotted underline to say it can be pressed, and the
   state's colour shrunk to a dot (the tone modifier) rather than a filled badge, so a count does
   not out-shout the title above it. */
.gv-pagehead__fact {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-1);
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    color: inherit;
    text-decoration: underline dotted;
    text-underline-offset: 3px;
    cursor: pointer;
}
.gv-pagehead__fact:hover { color: var(--gv-text); text-decoration-style: solid; }
.gv-pagehead__fact:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
.gv-pagehead__dot {
    width: var(--gv-space-2);
    height: var(--gv-space-2);
    border-radius: var(--gv-radius-full);
    background: currentColor;
    flex: none;
}
.gv-pagehead__fact--warning .gv-pagehead__dot { color: var(--gv-warning-fg); }
.gv-pagehead__fact--danger .gv-pagehead__dot { color: var(--gv-danger-fg); }
.gv-pagehead__actions {
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
    flex: none;
}

/* ── 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);
}
/* A filter IN FORCE, with its own way out (#811): "Type: Town ×". Not a toggle -- the pill is
   a statement of what is narrowing the list, and the × is the one control on it, a real button
   named for what it removes. The look the Dashboard and the Meeting audit hand-drew for the same
   job, as a component: the pressed chip's brand fill, a pill's radius, the × in a small round
   button that lifts to white on hover. A <span>, so it holds a button without nesting one. */
.gv-chip--filter {
    gap: var(--gv-space-1);
    /* No vertical padding: the 24px × sets the pill's height, so the pill is no taller than a
       chip while its one control still meets the 24px target minimum (WCAG 2.5.8). */
    padding: 0 var(--gv-space-1) 0 var(--gv-space-3);
    border-color: var(--gv-brand-border);
    background: var(--gv-brand-50);
    color: var(--gv-brand-fg);
    font-weight: var(--gv-weight-medium);
    cursor: default;
}
.gv-chip--filter:hover:not(:disabled) { border-color: var(--gv-brand-border); color: var(--gv-brand-fg); }
.gv-chip__remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--gv-control-xs);
    height: var(--gv-control-xs);
    padding: 0;
    border: 0;
    border-radius: var(--gv-radius-full);
    background: transparent;
    color: inherit;
    font-size: var(--gv-text-2xs);
    cursor: pointer;
}
.gv-chip__remove:hover { background: var(--gv-surface); }
.gv-chip__remove:focus-visible {
    outline: var(--gv-focus-width) solid var(--gv-focus-color);
    outline-offset: var(--gv-focus-offset);
}
.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);
}
/* How many a tab holds (#813): "To finish 4". Its own pill after the label, so the number
   never runs into the words -- the gv-chip__count shape -- and taking the brand wash when its tab
   is selected. Read out with the tab's name, which is the point: a screen reader hears
   "To finish 4, tab, selected", the same thing a sighted clerk reads. */
.gv-tabs__count {
    display: inline-block;
    margin-left: var(--gv-space-2);
    padding: 0 var(--gv-space-2);
    border-radius: var(--gv-radius-full);
    background: var(--gv-neutral-bg);
    color: var(--gv-text-muted);
    font-size: var(--gv-text-2xs);
    font-weight: var(--gv-weight-semibold);
    font-variant-numeric: tabular-nums;
}
.gv-tabs__tab[aria-selected="true"] .gv-tabs__count {
    background: var(--gv-brand-bg);
    color: var(--gv-brand-fg);
}

/* ── 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);
    /* 8/12, not 12 all round (#665). The bar is chrome above the work, and a quarter of
       its height was padding doing nothing: 12 + a 34px button + 12 came to 59px on every
       step. Eight vertical with the small button below takes it to 44 — the same row, a
       quarter shorter, on six tabs. Horizontal stays 12: the pill and the sentence still
       need clearing from the card's edge, and nothing is gained by crowding them. */
    padding: var(--gv-space-2) 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;
}

/* The instruction, for a bar whose sentence is built as HTML rather than plain text —
   the workflow's lines name their own buttons (.gv-barlink), so they arrive as markup
   and cannot go through __title/__meta. --gv-text-muted rather than the text-gray-500
   these bars carried as Tailwind: gray-500 is not a token, and new code takes the token
   (DESIGN-SYSTEM.md §3). It is one stop darker, 6.56:1 against the card. */
.gv-stepbar__say {
    min-width: 0;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-sm);
    line-height: var(--gv-leading-snug);
    color: var(--gv-text-muted);
    /* ONE LINE, ALWAYS (#665). The sentences are written short enough to hold at the width
       the workspace gives them -- every one is under 70 characters, and the checks measure
       that -- and this is the floor under them: a bar that wraps is a bar that is two rows
       tall, on the row that was just docked to the step tabs to save 20px. A window narrow
       enough to clip the tail ends it in an ellipsis instead of pushing the buttons down. */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* A bar whose line is allowed to run to TWO lines (#512). items-baseline, not
   items-start: the two columns are different heights per row -- a text-sm line box is
   20px, a full-size gv-btn 34px -- so aligning their top edges leaves the text high by
   half the difference. Baselines are the thing that is actually supposed to line up,
   and the browser computes the offset for free. gap-6 is a MINIMUM separation: it is
   the only thing standing between the last word of a wrapped line and the first button. */
.gv-stepbar--wrap {
    align-items: baseline;
    gap: var(--gv-space-6);
}

/* DOCKED (#665). The bar hangs off the step tabs instead of floating under them: the tab
   strip's own hairline is the line between the header and the bar, so this drops the top
   border and the top corners with it, and the shadow goes because a card that is attached
   to something does not cast one.

   It costs 20px of page above the bar (#meetings-detail-heading's mb-5, removed with this)
   and the card's own separation, which the hairline above now carries. --gv-stage-top in
   this file is the chrome above the stage and moved by the same 20px; check-votes-css.js
   asserts that number literally, so the two cannot drift apart unnoticed. */
.gv-stepbar--docked {
    border-top: 0;
    border-top-left-radius: 0;
    border-top-right-radius: 0;
    box-shadow: none;
}

/* Inside a stage the bar sits directly on top of the scroller, which carries the gap as
   its own padding-top — so the gap is the same at rest but scrolls away with the content
   rather than standing as a strip of page between a pinned bar and the rows (#603). */
.gv-stepbar--flush { margin-bottom: 0; }

/* THE FIRST PANEL ON A STEP (#665). Whatever a step draws first hangs off the tab strip:
   no space above it, and no top edge of its own, because the strip's hairline is already
   that line. Applied by the step to its own first element rather than inferred from
   :first-child — which panel comes first changes with the state (an upload form, a
   progress card, a roster), and a selector that guesses gets it wrong in exactly the
   states nobody looks at. */
.mw-dock { margin-top: var(--gv-space-3); }   /* 12px; was 8, a touch tight under the header (Dom, 2026-09-23) */
/* Once only. A stage is the step's first element AND the panel in its first column is
   marked too, so without this the gap was paid twice and the document sat 16px down while
   the recording beside it sat 8. */
.mw-dock .mw-dock { margin-top: 0; }
/* THE FIRST ROW TAKES THE CARD'S TOP CORNERS (Dom, 2026-09-23). The panels cannot clip —
   menus hang out of them — so a square first row painted over the panel's rounded corners
   and the card came out rounded at the border and square inside it. Every panel on these
   steps is --gv-radius-lg; the row's corner is that less the border it sits inside. A card
   head one level down too, because Roll call's head sits inside a display:contents section
   on Speakers -- and ONLY a card head: rounding any grandchild rounded Discussions' filter
   row, which clips its own content, and cut the corner off the filter box. */
.mw-dock > :first-child,
.mw-dock > :first-child > .gv-card__head {
    border-top-left-radius: calc(var(--gv-radius-lg) - var(--gv-border-width));
    border-top-right-radius: calc(var(--gv-radius-lg) - var(--gv-border-width));
}

/* TIGHTER SIDES WHILE A MEETING IS OPEN (Dom, 2026-09-23). main-content's 24px gutter is
   sized for the dashboard's cards; around a two-column workspace it was page doing
   nothing. 12px, and the white header reaches out over exactly that much so it still runs
   edge to edge. Ids, not Tailwind classes, so the injected Tailwind sheet cannot outweigh
   either half and leave the header short of one edge. */
#main-content:has(#view-meetings:not(.hidden) #meetings-detail-heading:not(.hidden)) {
    padding-top: var(--gv-space-2);
    padding-left: var(--gv-space-3);
    padding-right: var(--gv-space-3);
}
/* The top as well (Dom, 2026-09-23): 8px above the meeting's line rather than the 24px the
   dashboard's gutter gave it. The heading reaches up over that padding and paints it, so
   nothing scrolls visibly through the gap above the sticky bar. */
#meetings-detail-heading {
    margin-top: calc(-1 * var(--gv-space-2));
    padding-top: var(--gv-space-2);
    margin-left: calc(-1 * var(--gv-space-3));
    margin-right: calc(-1 * var(--gv-space-3));
    padding-left: var(--gv-space-3);
    padding-right: var(--gv-space-3);
}
/* NOT WELDED ANY MORE (Dom, 2026-09-23). The first panel used to hang off the tab strip —
   no gap, top border and top corners squared off — so the header and the page read as one
   slab. It sits on the canvas now with a gap above it and its own rounded corners, like
   every other card: the header is white chrome, the page's own grey is below it, and the
   step's work is the rounded thing on it. The rules that squared each column's first
   panel went with this; the class stays on every step's first element because it is still
   the one place that says how far the work starts below the tabs. */


/* ── THE STEP'S FIRST ROW IS A BAND OF CHROME (#665, option B) ──────────────
   Docking closed the gap between the tab strip and the step, and left a second problem
   behind it: what lands on that hairline is either a row of BARE CONTROLS — which reads
   as floating in the page, with its search box welded to the line — or a panel whose
   head happens to be white. Either way the chrome stops and the page starts with nothing
   saying so.

   The band is the answer: the first row takes the header's own tint and its bottom rule,
   so it is the LAST ROW OF THE CHROME rather than the first thing in the page. The
   controls get their padding back WITHOUT a gap opening above them, because the band
   itself runs from the strip — which is the whole difference between this and the margin
   that was there before #665.

   One band on every step, whatever it holds: a filter row on Discussions and Motions, a
   card's heading on Speakers, the column names on Review Agenda, meeting facts' head on
   Recording, the menu row on Minutes. Three of those already carried the tint and the
   rule for their own reasons; what this does is make it the rule rather than a
   coincidence, and give it to the two that had neither. */
.mw-band {
    background: var(--gv-band);
    border-bottom: var(--gv-border-width) solid var(--gv-border);
    /* Vertical padding is what unsticks the controls from the hairline. The horizontal
       step matches the panels below, so the search box starts on the same line as the
       rows it filters. */
    padding: var(--gv-space-2) var(--gv-space-4);
}
/* A docked panel's own head IS the band — it is the row on the line. Only while docked:
   a card further down the page keeps its white head, which is what .gv-card__head is for
   everywhere else in the app. */
.mw-dock > .gv-card > .gv-card__head,
.mw-dock > :first-child > .gv-card__head {
    background: var(--gv-band);
}

/* THE START PANEL (#665) was .mw-start here, for Motions and Minutes. All five steps draw
   .gv-start now (#671). */

/* Meeting facts' heading row, which carries the recording's controls now the bar is gone
   (#665). The rows below keep their own padding, so the head is bordered rather than
   spaced away from them. */
.ts-facts__head {
    display: flex;
    align-items: center;
    gap: var(--gv-space-3);
    padding: var(--gv-space-3) var(--gv-space-5);
    border-bottom: 1px solid var(--gv-border);
    background: var(--gv-band);
}
.ts-facts__acts { margin-left: auto; display: flex; align-items: center; gap: var(--gv-space-2); }

/* ── the meeting's identity, on one line (Dom, 2026-09-22) ───────────────────
   Board · Meeting — date · time [type] · Location. It was three stacked lines beside the
   tabs, and every one of them is read when the meeting is opened and then not again: the
   row below, which a clerk works from all day, was the one being pushed around.

   THE DOTS BELONG TO THE ELEMENT BEFORE THEM. Both the board and the location can be
   absent — an org with no boards, a meeting with no location set — and the painter hides
   the element rather than emptying it. A separator drawn by the element AFTER would then
   open the line with a stray middot on exactly those meetings. */
.mw-head__line {
    display: flex;
    align-items: baseline;
    flex-wrap: nowrap;
    gap: var(--gv-space-2);
    min-width: 0;
}
/* Every part gives way, and in the order they matter: the location first, because it is
   the same string on nearly every meeting an org holds; then the board, which the tile
   beside it already identifies by colour and glyph; the title last, because the date is
   the one thing on the line that says WHICH meeting this is. */
.mw-head__board,
.mw-head__title,
.mw-head__where {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.mw-head__board {
    flex: 0 2 auto;
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-medium);
    color: var(--gv-brand-fg);
}
.mw-head__title {
    flex: 0 1 auto;
    margin: 0;
    font-size: var(--gv-text-base);
    font-weight: var(--gv-weight-semibold);
    color: var(--gv-text);
    line-height: var(--gv-leading-tight);
}
.mw-head__where {
    flex: 0 8 auto;
    font-size: var(--gv-text-xs);
    color: var(--gv-text-subtle);
}
/* The separators. aria-hidden by construction — generated content is not in the
   accessibility tree — so a screen reader hears the three parts in order and no middots. */
.mw-head__board::after,
.mw-head__where::before { content: "·"; color: var(--gv-ink-400); }
/* On the outside of the flex gap, so the dot sits midway between the two parts rather than
   hard against the one that draws it. */
.mw-head__board::after { margin-left: var(--gv-space-2); }
.mw-head__where::before { margin-right: var(--gv-space-2); }
/* No location on this meeting: the painter empties it as well as hiding it, and an empty
   span would otherwise still draw its dot. */
.mw-head__where:empty { display: none; }
/* AND IT GOES FIRST when the line runs short. Flexbox shrinks proportionally, so the
   weights above spread an overflow across all three parts and every one of them ends in
   "…" — including the board, which is two words long and should never need to. A
   breakpoint drops the least useful part outright instead: the location is the same
   string on nearly every meeting an org holds, and the meetings list carries it too. */
@media (max-width: 1120px) {
    .mw-head__where { display: none; }
}

/* WHAT ACTS ON THE MEETING, at the end of the meeting's own line (Dom, 2026-09-22).
   Its history today — and the place anything else meeting-wide would go, rather than the
   step strip, which belongs to whichever step is open.

   margin-left:auto holds the right edge however short the title is; flex-shrink:0 keeps
   the label whole and makes the LOCATION give way instead, which is the right order — the
   location is the same string on nearly every meeting an org holds and is dropped outright
   under 1120px anyway. */
.mw-meeting-actions {
    margin-left: auto;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    gap: var(--gv-space-1);
    padding-left: var(--gv-space-3);
}
.mw-meeting-actions:empty { display: none; }
/* Labelled, because a rewinding clock is not an icon anyone recognises and nothing on this
   screen explains it. Quiet until pointed at: it is a thing you go looking for once, not
   part of reading the meeting. */
.mw-meeting-btn {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-2);
    padding: var(--gv-space-1) var(--gv-space-2);
    border: var(--gv-border-width) solid transparent;
    border-radius: var(--gv-radius-md);
    background: transparent;
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-medium);
    color: var(--gv-text-subtle);
    white-space: nowrap;
    cursor: pointer;
}
.mw-meeting-btn:hover { border-color: var(--gv-border); background: var(--gv-surface); color: var(--gv-text); }
.mw-meeting-btn:focus-visible { outline: var(--gv-focus-width) solid var(--gv-focus-color); outline-offset: 1px; }
.mw-meeting-btn i { font-size: var(--gv-text-xs); }

/* Golden meeting (#790), at the far right of the meeting's line: one pill that is both the
   indicator and the switch, for super admins only. Golden, it is the brand pill with a filled
   star; not golden, the same pill faint and outlined -- there to press, not to read. */
.mw-golden { cursor: pointer; }
.mw-golden i { font-size: var(--gv-text-xs); }
.mw-golden:focus-visible { outline: var(--gv-focus-width) solid var(--gv-focus-color); outline-offset: 1px; }
.mw-golden--off { background: transparent; color: var(--gv-text-subtle); }
.mw-golden--off:hover { background: var(--gv-surface); color: var(--gv-text); }

/* The step's state and its one control, at the right end of the tab row (#665). Aligned
   to the tabs' baseline rather than their box: the tabs carry a 2px active underline and
   -1px of margin to sit on the header's hairline, so matching their padding-bottom is what
   puts a 25px button level with the words beside it. */
.mw-step-actions {
    display: flex;
    align-items: center;
    gap: var(--gv-space-2);
    flex-shrink: 0;
    /* One space step: the tabs' own py-1.5 is 6px (Dom, 2026-09-23, was py-2.5), and a
       25px control against their 20px line box needs a little under that to sit level
       with the words. */
    padding-bottom: var(--gv-space-1);
    padding-left: var(--gv-space-4);
}
.mw-step-actions:empty { display: none; }

/* ONE PILL HEIGHT IN THE BAR (#665). Three shapes met in this row: Review Agenda's and
   the Document step's own pill (11px uppercase on a full radius, 19px tall), the Recording
   and Speakers status pill (14px on an 8px radius, 26.5px — the tallest thing on the row,
   and taller than the buttons opposite it), and GV.stepPill's .gv-badge at 23.8. The
   agenda pill is the one the other two come to: it is the smallest, it is the shape the
   two oldest pills already share, and a status is a label rather than a control — nothing
   on this row should be taller than the buttons.

   Applied to the bar rather than to .gv-badge, which is used in rows and cards elsewhere
   at its own size. */
/* The font is .gv-badge's own --gv-text-2xs; only the padding differs, and that is the
   whole of the 4.8px. Half a space step vertically — the 4px step is precisely what makes
   the badge too tall here, so the correction is expressed against it rather than as a
   number of its own. */
.gv-status-join__state .gv-badge,
.ag-statusbar__state .gv-badge,
.gv-stepbar__main .gv-badge {
    padding: calc(var(--gv-space-1) / 2) var(--gv-space-2);
    letter-spacing: 0.025em;
    text-transform: uppercase;
}
/* An icon in place of the dot, for the two pills that have always carried one. Sized to
   the dot it replaces rather than to the label, so the pill's height comes from the text
   either way. */
.gv-badge__mark { font-size: var(--gv-text-3xs); }

/* Three quarters of the step, not the full 8px .gv-badge uses: the dot sits against an
   11px uppercase label in a 19px pill, where 8px reads as a bullet rather than a status
   light. */
.gv-status-join__state .gv-badge__dot,
.ag-statusbar__state .gv-badge__dot,
.gv-stepbar__main .gv-badge__dot {
    width: calc(var(--gv-space-2) * 0.75);
    height: calc(var(--gv-space-2) * 0.75);
}

/* THE STATE AND ITS ONE CONTROL, JOINED (#836, #843): "DRAFT | Approve Minutes" at the right
   end of the Minutes menu row (mbEdMenubarHtml) and "DRAFT | Approve Agenda" at the right end
   of Review Agenda's top row (agStatusBarHtml). One shape on one light grey rule, in both
   states: the colour is in the pill's fill and the button's words -- green Approve, amber
   Revert to Draft (.gv-btn--warning-quiet) -- not round the edge. A green or red outline
   round the whole control made it the loudest thing on the row, approved most of all
   (Vinny, 2026-10-05).
   One class for both screens, for the reason both pills are one call (GV.stepPill, #665):
   two copies of one idea drift. Joined only when there is a button: a view-only member's
   pill on Minutes stands alone, round at both ends and in its own border. */
.gv-status-join { display: inline-flex; align-items: stretch; flex: 0 0 auto; }
.gv-status-join__state { display: inline-flex; align-items: stretch; }
.gv-status-join:has(> .gv-btn) .gv-badge {
    border-top-right-radius: 0; border-bottom-right-radius: 0; border-right-width: 0;
    border-color: var(--gv-border);
}
.gv-status-join > .gv-btn { border-top-left-radius: 0; border-bottom-left-radius: 0; }
/* Approve: .gv-btn--primary's white surface and green words, on the grey rule. */
.gv-status-join > .gv-btn--primary { border-color: var(--gv-border); }
.gv-status-join > .gv-btn--primary:hover:not(:disabled) { border-color: var(--gv-brand-border); }
/* A PILL THAT OPENS A MENU (#836, #863): Minutes' DRAFT ▾ (the draft's settings, mbDocDraftMenu)
   and Review Agenda's APPROVED ▾ (the public portal, agPortalMenu). The pill is a <button>, so
   it presses like one -- a pointer, a wash on hover and while open, a focus ring -- and carries
   a caret. One rule for both screens, moved here from document.css when the agenda's pill
   became the second, for the reason the join itself is shared: two copies of one idea drift.
   A pill that opens nothing stays a plain <span> and gets none of this. */
.gv-status-join__pill { cursor: pointer; font-family: inherit; }
/* A shade darker on hover, whatever the pill's fill: no token is a light wash over a colour. */
.gv-status-join__pill:hover, .gv-status-join__pill[aria-expanded="true"] { filter: brightness(0.95); }
.gv-status-join__pill:focus-visible { outline: var(--gv-focus-width) solid var(--gv-focus-color); outline-offset: 1px; }
/* Half a space step: the 2px it had in document.css, as the scale says it. */
.gv-status-join__caret { margin-left: calc(var(--gv-space-1) / 2); font-size: var(--gv-text-3xs); }

/* An icon-only button in this row is the ⋯, and it has to be exactly as tall as the
   buttons beside it. .gv-btn--icon sizes itself from 0.8 of the small control stop, which
   is 22.4px against the 25px a gv-btn--sm label button comes to — close enough to look
   like a mistake rather than a size. Squared off the label button's own height instead:
   4px of padding top and bottom, a 15px line box, two borders. */
.gv-stepbar__actions .gv-btn.gv-btn--icon {
    width: calc(var(--gv-text-sm) + 2 * var(--gv-space-1) + 2 * var(--gv-border-width) + 2px);
    min-width: calc(var(--gv-text-sm) + 2 * var(--gv-space-1) + 2 * var(--gv-border-width) + 2px);
    height: calc(var(--gv-text-sm) + 2 * var(--gv-space-1) + 2 * var(--gv-border-width) + 2px);
}

.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);
}

/* A glyph instead of a badge, for a list whose options have a SHAPE rather than a position:
 * four levels of detail, not candidate A through F.
 *
 * No box around it. The badge's border and fill are what make an ordinal legible as a
 * marker — a bare letter beside a label reads as part of the label — and a glyph does not
 * have that problem. Boxed, four icons in four outlined squares read as a toolbar down the
 * left edge of the list.
 *
 * Selection tints the glyph rather than filling behind it, which is why these two rules
 * exist at all: the base .is-selected treatment paints the marker brand-700 and the mark
 * white, and a solid green square is exactly the weight this variant is avoiding. */
.gv-choice__mark--icon {
    border-color: transparent;
    background: none;
    color: var(--gv-text-subtle);
    font-size: var(--gv-text-sm);
    font-weight: var(--gv-weight-normal);
}
.gv-choice.is-selected .gv-choice__mark--icon,
.gv-choice.is-committed .gv-choice__mark--icon {
    background: none;
    border-color: transparent;
    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 room for an action that applies to all of it. The Speakers panel
   carries only the count since #897; Assign… on the row covers "all of it". */
.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;
    /* THE WHOLE TURN, not a two-line preview of it.
       This used to clamp to two lines on the reasoning that the full text was in the
       transcript anyway. It was not enough to answer the question this panel asks. A turn
       could open in one voice and continue in another, and a clerk reading two clamped
       lines assigned the whole block to whoever spoke the opening (#540). Turns are built
       from Deepgram's word-level speaker now, so what is here is one person start to
       finish, and hiding the end of it serves nobody. */
    overflow-wrap: anywhere;
}

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

/* What was said, and under it who said it (#897):

       [▶ 7:01]  "Miss Buxton, are you related to the old fire chief…"
                 Low Confidence  Not Speaker D?  Move to [Jill Buxton] [Other ▾]

       [▶ 17:47] "When is the bus due for replacement or purchase?…"
                 ✓ Moved to Major Gleason  Undo

   The Speakers panel offers, on every line, the person it most likely is instead, so a voice
   that is really two people is sorted one tap at a time. The row goes UNDER the quote rather
   than in the action column: names beside it would squeeze the quote into a sliver, and the
   quote is the thing being judged. __body is a column so __text keeps a box of its own,
   which is the rule above that holds this whole component together.

   It names only the OTHER person. A pressed button for the voice's own person on every line
   said what the row above already says, and doubled the list's height saying it. */
.gv-turn__body {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: var(--gv-space-2);
}
.gv-turn__who {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--gv-space-2);
    /* Stepped in from the quote, so it reads as about the line above rather than as the next
       line of it (Vinny, 2026-10-08). Padding, so a wrapped row stays stepped in too. */
    padding-left: var(--gv-space-3);
}
/* "Not Speaker D? Move to": the question, and what the buttons after it do, quieter than they
   are. aria-hidden in the markup: the group is named with the question, and each button's
   own name says "Move to Jill Buxton". */
.gv-turn__who-label {
    font-size: var(--gv-text-xs);
    color: var(--gv-text-muted);
}
/* "Not Speaker D?" itself, in weight: muted like "Move to", it sank into the page (Vinny,
   2026-10-08). The same weight as "Moved to", the row's other state. */
.gv-turn__ask { font-weight: var(--gv-weight-medium); }
/* "Low Confidence": drawn only on a line whose own-voice score is below TS_BD_UNSURE_BELOW,
   so the doubtful lines are the only ones the eye stops on. A confident line shows nothing.
   The words carry the meaning and the fill only repeats it, so it survives a monochrome
   screen. The number is in the hover and the accessible name. */
.gv-turn__conf {
    padding: 0 var(--gv-space-2);
    border-radius: var(--gv-radius-full);
    background: var(--gv-warning-bg);
    color: var(--gv-warning-fg);
    font-size: var(--gv-text-2xs);
    white-space: nowrap;
    cursor: default;
}
/* "Move to", the suggestions and Other: one unit that wraps as a whole, so Other is never
   left on a line by itself. */
.gv-turn__moveto {
    display: inline-flex;
    flex-wrap: nowrap;
    align-items: center;
    gap: var(--gv-space-2);
    max-width: 100%;
}
/* The row's links -- Move to's choices and Undo: a .gv-barlink quieted to the "Move to" label
   beside them. Undo too, so a moved row is the height an open one is and moving a line never
   pushes the rest down. Scoped under the row because .gv-barlink is declared later and would
   win on colour at equal weight. */
.gv-turn__who .gv-turn__link {
    display: inline-flex;
    align-items: center;
    gap: var(--gv-space-1);
    font-size: var(--gv-text-xs);
    color: var(--gv-text-muted);
}
/* "✓ Moved to Major Gleason": settled, so it wears the green that means settled on this
   screen (the done edge on a voice row), not the warning amber. Weight as well as colour. */
.gv-turn__moved {
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-medium);
    color: var(--gv-brand-fg);
}

@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); }
/* The current value, in both families that have one: aria-selected is the combobox's
   (role=option), aria-checked is a menu's pick-one group (role=menuitemradio, #654). Two
   attributes because the roles demand different ones — one look, because to a clerk they
   are the same fact. */
.gv-picker__opt[aria-selected="true"],
.gv-picker__opt[aria-checked="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,
.gv-picker__opt[aria-checked="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); }
/* A heading over a run of options: the board's groups in the Speakers picker (Voting
   Members, Regular speakers, ...). Not an option and not focusable, so it never takes
   the Enter that picks the first match. */
.gv-picker__group {
    padding: var(--gv-space-2) var(--gv-space-3) var(--gv-space-1);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-3xs);
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--gv-text-subtle);
}
.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; }
/* A short menu of plain verbs -- a row's Edit / Clone / Delete, the portal's two demos -- sized
   to its longest line rather than to the bulk-action width above, which leaves a few short
   words floating in a wide empty panel (#811). GV.menu applies it with { fit: true }. */
.gv-menu--fit { width: max-content; min-width: 10rem; max-width: 20rem; }
.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); }

/* ── Tooltip (GV.tip) ─────────────────────────────────────────────────────────
   The app's own tooltip, for words the screen has cut short (#896): a speaker's name
   ending in "…" in the transcript. It replaced the browser's title there, which shows
   after a pause the app can't shorten. One node, placed by GV.tip beside whatever is
   under the pointer; never a target itself, so it can't get in the way of the next
   thing the clerk points at.

   It is drawn like the other surfaces that float over the page here -- the Picker and
   its Menu: a hairline border, the text colour, the popover shadow. Its fill is the
   app's light gray, --gv-ink-100 (Vinny, 2026-10-07): the gray of a plain .gv-badge
   and a tab's count pill, and the backdrop behind the Minutes document. Not
   --gv-surface-sunk, which was tried first: at #f7f8f7 it read as white. Not a dark
   chip either: nothing in this system is dark but the video frame. The shadow
   is --gv-shadow-md, which the foundations name for popovers; the picker's larger one
   belongs to a panel you work in, not a line you glance at. It fades in over
   --gv-dur-fast, the hover-feedback duration, and not at all for a clerk who asked
   for less motion.

   No display here: the [hidden] attribute is how GV.tip puts it away, and a display
   rule would override it. */
.gv-tip {
    position: fixed;
    top: 0;
    left: 0;
    z-index: var(--gv-z-tooltip);
    max-width: 20rem;
    padding: var(--gv-space-1) var(--gv-space-2);
    border: 1px solid var(--gv-border);
    border-radius: var(--gv-radius-md);
    background: var(--gv-ink-100);
    color: var(--gv-text);
    font-family: var(--gv-font-ui);
    font-size: var(--gv-text-xs);
    font-weight: var(--gv-weight-normal);
    line-height: var(--gv-leading-snug);
    box-shadow: var(--gv-shadow-md);
    overflow-wrap: anywhere;
    pointer-events: none;
    animation: gv-tip-in var(--gv-dur-fast) var(--gv-ease);
}
@keyframes gv-tip-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
    .gv-tip { animation: none; }
}

/* ── 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 pick that STATES a fact instead of offering a choice — the roll call's Absent segment,
   which reports what Speakers says and is settable nowhere (#251).

   It keeps `disabled`, because it genuinely cannot be operated and assistive tech has to
   say so. It must NOT keep the greyed-out LOOK: dimming says "this control is broken",
   and this is the opposite — the one answer on the row that is already settled. Without
   this rule the grey wins on source order (both selectors were (0,2,0)) and a member who
   IS absent renders identically to one who is not, which is the entire point of the
   segment.

   The same bargain the Speakers roll call strikes on a locked checkbox, and for the same
   reason — see the accent-color/opacity override on .ts-bm-present.

   Scoped to the pressed state on purpose: the Absent segment on a member who is present
   stays properly dim, because there the disabled look is telling the truth. */
.gv-pick[aria-pressed="true"]:disabled {
    background: var(--gv-pick-tone);
    border-color: var(--gv-pick-tone);
    color: var(--gv-surface);
}

/* 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.

   No padding-bottom (#603). The 8px under the row used to be the bar's own, which made it
   a strip of page that never scrolls: a list scrolled under the filters vanished 8px below
   them, where Review Agenda's rows run right up to the edge of its pinned toolbar. The
   space now lives at the top of the scroller that follows (.mbdoc-doc, .mbmv-ag), so it
   looks the same at rest and scrolls away with the content. */
.gv-filterbar {
    display: flex; align-items: center; gap: var(--gv-space-2); flex-wrap: wrap;
    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;
    /* 1000px, up from 640 (Dom, 2026-09-17): wanted a bigger recording and transcript. On most
       screens the main column's floor below is what stops the drag first. */
    --gv-stage-side-max: 1000px;
    --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: 12px;   /* narrower gutter, with a visible grip (Dom, 2026-09-22) */
    /* What sits above the stage: the meeting header with its step tabs, plus
       main-content's 24px top padding. Was 164px (140px of chrome) while the tabs had a
       row of their own under the title; #603 put them on the title's row and set the
       date a size smaller (text-xl -> text-base), which took 51px out of the chrome --
       measured old against new markup at the same width -- and hands it to the stage.
       #665 took another 20px out by docking the step bar to the tabs: the header's mb-5
       was the gap between them, and the bar hangs off the hairline now. 113 -> 93.

       97 since the meeting's identity went onto ONE line and the tabs onto a row of
       their own under it (Dom, 2026-09-22). The two rows no longer share the height of
       the taller of them, which costs 4px -- measured old markup against new at the same
       width, 90px of heading against 94. The board tile came down from 36px square to 24
       in the same change, which is what holds it to 4 rather than 12.
       105 since the step's first panel stopped docking to the tabs and sits 8px below
       them on the canvas, as a rounded card (Dom, 2026-09-23).
       Only the fallback: GV.stage's stageFit measures where the stage really starts and
       writes the result on the stage itself, which beats this declaration.
       Asserted by check-votes-css.js. */
    --gv-stage-top: 105px;
    /* A gap below it, so the cards end ABOVE the bottom of the window with their rounded
       corners showing, instead of running off it. 12px, the same as the gap between the
       header and the cards above (.mw-dock), so the work sits evenly in the window
       (Dom, 2026-09-23). */
    --gv-stage-bottom: var(--gv-space-3);
    --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) - var(--gv-stage-bottom));
    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; }

/* ── ONE SCROLLBAR FOR EVERY WORKING LIST (Dom, 2026-09-23) ─────────────────────
   Thin, and the same grey whether or not the pointer is over the list (Dom, 2026-09-23):
   it was faint at rest and darkened only on hover, so a clerk who had not moused into the
   column could not see there was more below. Speakers, Discussions and Motions each had
   their own -- one a chunky grey track, one the browser's default -- and take this now, on
   the element that scrolls, so all four look and behave the same. The thumb itself still
   darkens under the pointer, to say it can be grabbed. */
.gv-scroll {
    scrollbar-width: thin;
    scrollbar-color: var(--gv-ink-400) transparent;
}
.gv-scroll::-webkit-scrollbar { width: 6px; }
.gv-scroll::-webkit-scrollbar-track { background: transparent; }
.gv-scroll::-webkit-scrollbar-thumb { background: var(--gv-ink-400); border-radius: var(--gv-radius-full); }
.gv-scroll::-webkit-scrollbar-thumb:hover { background: var(--gv-ink-500); }

/* ── THE RECORDING, AT THE TOP OF EVERY STAGE'S SIDE COLUMN ─────────────────────
   .gv-recvid, built by mbRecVidHtml (app/document/stage/recording-video.js) on all five
   steps that play the recording (Dom, 2026-09-23). It was the Minutes tab's own, styled in
   document.css; the other four drew a video and a loose transport row. One set of rules now.

   The player card is always there when there is something to play: back, play, forward,
   speed, a progress bar, the time, and the video button.
   OPEN: the video above the card, a small gap between them, the button reading Hide video.
   FOLDED (.is-off): the video keeps its place at zero height -- a display:none iframe can
   stop answering the YouTube API, and showing it again must not restart the recording --
   and the card sits at the top of the column, its button reading Show video.

   Never shrinks. The default flex is `0 1 auto`, and a short window would squeeze the
   transport toward zero height; the side column clips, so it would vanish rather than
   overflow somewhere it could be seen. The transcript panel under it is the one thing in
   the column that gives up height, because it scrolls its own content. */
.gv-recvid {
    flex: 0 0 auto; display: flex; flex-direction: column; min-height: 0;
    /* Measured by the player card below, which gives up controls as the column narrows. */
    container: gv-recvid / inline-size;
}
.gv-recvid__above { display: flex; flex-direction: column; min-height: 0; flex: 0 0 auto; }
.gv-recvid.is-off .gv-recvid__above { height: 0; overflow: hidden; }

/* THE PLAYER CARD. One row: the step's transport, dissolved (display: contents) so its
   buttons and clock are items of the row, with `order` putting the progress bar between
   the speed and the clock without rebuilding the step's transport. The slot is dissolved
   the same way -- it is only there for a step that repaints its transport by id.

   A card of its own (Dom, 2026-09-23): the same white, border and corners as the
   transcript under it, the buttons flat inside it and all at the small control size, so
   the row is slim. Only play keeps a fill, a pale green, so the one you press most is
   still the one you see first. A hairline after the time sets Show video apart, since it
   changes the column rather than the playback. */
.gv-recvid__tr {
    display: flex;
    align-items: center;
    gap: var(--gv-space-1);
    /* A gap under the open video. Folded there is none (Dom, 2026-09-23): the card's top lines
       up with the first card in the main column on every step. The gap below stays, and the
       transcript panel under it takes the height that frees, so the space between the two is
       unchanged. */
    margin: var(--gv-space-2) 0 var(--gv-space-2);
    padding: var(--gv-space-1) var(--gv-space-2);
    background: var(--gv-surface);
    border: var(--gv-border-width) solid var(--gv-border);
    border-radius: var(--gv-radius-lg);
}
.gv-recvid.is-off .gv-recvid__tr { margin-top: 0; }
.gv-recvid__slot,
.gv-recvid__tr .gv-transport { display: contents; }
.gv-recvid__tr .gv-transport > * { order: 1; flex: none; }
.gv-recvid__tr .gv-play__btn {
    background: transparent;
    width: var(--gv-control-sm);
    height: var(--gv-control-sm);
}
.gv-recvid__tr .gv-play__btn--main { background: var(--gv-brand-50); }
.gv-recvid__tr .gv-play__btn:hover:not(:disabled) { background: var(--gv-brand-50); }
.gv-recvid__tr .js-playrate { margin: 0 var(--gv-space-1); }
.gv-recvid__tr .gv-transport > span { order: 3; margin-left: 0; }

.gv-recvid__prog {
    order: 2; position: relative; flex: 1 1 auto; min-width: 48px; height: 6px;
    margin: 0 var(--gv-space-2);
    border-radius: var(--gv-radius-full); background: var(--gv-ink-200); cursor: pointer; touch-action: none;
}
/* A taller target than the bar draws, so it can be grabbed without aiming for 6px. */
.gv-recvid__prog::before { content: ""; position: absolute; left: 0; right: 0; top: -6px; bottom: -6px; }
.gv-recvid__prog:hover,
.gv-recvid__prog.is-dragging { height: 8px; }
.gv-recvid__fill {
    position: absolute; left: 0; top: 0; bottom: 0; width: 0;
    border-radius: var(--gv-radius-full); background: var(--gv-brand-700);
}

/* The length, and the hairline after it. The line belongs to the length, not to the
   button: drawn as the button's own left border it squared that side off, and the hover
   outline came out rounded on the right and cut flat on the left. Where a step's clock
   already shows the length there is no span, so the line goes on the clock instead. */
.gv-recvid__dur {
    order: 4; flex: none; align-self: stretch; display: flex; align-items: center;
    font-size: var(--gv-text-2xs); color: var(--gv-text-subtle); white-space: nowrap;
    font-variant-numeric: tabular-nums;
}
.gv-recvid__dur,
.gv-recvid__tr:not(:has(.gv-recvid__dur)) .gv-transport > span {
    align-self: stretch; display: flex; align-items: center;
    padding-right: var(--gv-space-3);
    border-right: var(--gv-border-width) solid var(--gv-border);
}
.gv-recvid__btn {
    order: 5; margin-left: auto;
    width: auto; display: inline-flex; align-items: center; gap: var(--gv-space-2);
    padding: 0 var(--gv-space-3);
    font-size: var(--gv-text-xs); font-weight: var(--gv-weight-semibold); white-space: nowrap;
}
.gv-recvid__tr .gv-recvid__btn { width: auto; margin-left: var(--gv-space-2); }

/* SHOW VIDEO NEVER GOES (Dom, 2026-09-23). It is the only way back to the picture, and in a
   narrow column the row ran out of room and pushed it off the card's right edge, where the
   column clips it. So it never shrinks, the bar can get short, and as the column narrows the
   card gives things up in order. Its own label goes FIRST -- the camera icon alone still says
   what it does, and its tooltip and aria-label keep the words -- which buys the other
   controls about 80px before any of them has to go. Then the speed, then the ten-second
   skips, then the length. Play, the bar, the time and the video button always stay. */
.gv-recvid__btn { flex: none; }
.gv-recvid__prog { min-width: var(--gv-space-6); }
@container gv-recvid (max-width: 480px) {
    .gv-recvid__tr .gv-recvid__btn span { display: none; }
    .gv-recvid__tr .gv-recvid__btn { width: var(--gv-control-sm); padding: 0; }
}
@container gv-recvid (max-width: 360px) {
    .gv-recvid__tr .js-playrate { display: none; }
}
@container gv-recvid (max-width: 290px) {
    .gv-recvid__tr .gv-transport > .gv-play__btn:not(.gv-play__btn--main) { display: none; }
}
@container gv-recvid (max-width: 230px) {
    .gv-recvid__dur { display: none; }
}

/* ── the rail ───────────────────────────────────────────────────────────────
 * A faint line down a 12px gutter with a small pill half-way down (Dom, 2026-09-22, option A
 * of 4). It was 18px of blank gutter with the line appearing only on hover, and clerks did
 * not know the columns could be resized. The pill is the handle; it turns blue under the
 * pointer and while dragging. */
.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: var(--gv-border);
    transition: background var(--gv-dur-fast) var(--gv-ease);
}
/* The grip: a small solid pill over the line (option A; dots at this size could not be seen).
   Quieter since Dom, 2026-09-23: a light grey at partial opacity, and a touch thinner, so it
   reads as a hint at rest and only takes the green under the pointer. */
.gv-stage__rail::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    width: 4px;
    height: 32px;
    transform: translate(-50%, -50%);
    border-radius: var(--gv-radius-full);
    background: var(--gv-ink-300);
    opacity: 0.7;
    z-index: var(--gv-z-base);
    transition: background var(--gv-dur-fast) var(--gv-ease), opacity var(--gv-dur-fast) var(--gv-ease);
}
/* Green under the pointer and while dragging (Dom, 2026-09-22), line and pill together, so the
   rail says it can be moved. */
.gv-stage__rail:hover::before,
.gv-stage__rail.is-dragging::before { background: var(--gv-brand-600); opacity: 1; }
.gv-stage__rail:hover::after,
.gv-stage__rail:focus-visible::after,
.gv-stage__rail.is-dragging::after { background: var(--gv-brand-600); }
.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. */
/* NO PAGE SCROLLBAR ON A STAGE STEP (Dom, 2026-09-23). Speakers, Discussions, Motions and
   Minutes are sized to the window and scroll inside their own columns; the page itself has
   nothing below them. But the stage's height is the window less a FIXED guess at the
   chrome above it (--gv-stage-top), and whenever the header comes out a few pixels taller
   than the guess the page grows a scrollbar that moves the whole screen by those few
   pixels. Motions happened to land under the line; the other three did not.

   So while the open step's pane holds a stage, the page does not scroll at all. Only where
   the stage really is window-sized: under 1181px wide it stacks into one tall column
   (below), and under 660px tall its 520px minimum plus the header no longer fits — both
   of those need the page to scroll, and keep it. */
@media (min-width: 1181px) and (min-height: 660px) {
    /* #view-meetings too (code review, 2026-09-23): the sidebar leaves a meeting by hiding
       that view, not by closing the workspace, and without it Dashboard and Settings stayed
       unscrollable after visiting one of these steps. */
    html:has(#view-meetings:not(.hidden) #meetings-inline-agenda:not(.hidden) > :not(.hidden) .gv-stage) { overflow: hidden; }
}

@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; }
}
