# Approval Card

> Questions an agent asks, one step at a time, before acting.

- **Slug:** `approval-card`
- **Kind:** component · **Category:** Agent
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/ApprovalCard.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/approval-card
- **All files in one bundle:** https://viewership.ai/ui/components/approval-card.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/approval-card.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/approval-card/`:

   - `ApprovalCard.astro` (raw: https://viewership.ai/ui/raw/approval-card/ApprovalCard.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `button`: `Button.astro` (docs: https://viewership.ai/ui/components/button.md)
   - `glide-menu`: `GlideMenu.astro`, `glide-menu.ts` (docs: https://viewership.ai/ui/components/glide-menu.md)

npm packages: none.

## Usage

```astro
---
import ApprovalCard from "../components/viewership/approval-card/ApprovalCard.astro";
---

<ApprovalCard />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import ApprovalCard from "./ApprovalCard.astro";
import type { ApprovalQuestion } from "./ApprovalCard.astro";

const questions: ApprovalQuestion[] = [
  {
    q: "Which angle should lead the spring campaign?",
    type: "radio",
    options: ["Trail readiness", "Weekend deals", "Gear care tips"],
  },
  {
    q: "Which channels get the first draft?",
    type: "check",
    options: ["Email newsletter", "Reddit threads", "YouTube script"],
  },
  {
    q: "How should we word the offer?",
    type: "radio",
    options: ["Free shipping", "Ten percent off", "Match last year"],
  },
];
---

<div class="stage">
  <ApprovalCard questions={questions} />
</div>

<style>
  .stage {
    display: flex;
    width: 100%;
    justify-content: center;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `questions` | `{ q: string; type: "radio" \| "check"; options: string[] }[]` |  | One step per question. Free-text answer always allowed. |
| `labels` | `Partial<ApprovalLabels>` |  | Text overrides. |
| `resettable` | `boolean` | `true` | Show Start over after sending. |
| `autoAdvance` | `boolean` | `true` | Advance after a pointer or Space pick, not arrow keys. |
| `vs-approval-submit` | `CustomEvent<ApprovalAnswer[]>` |  | Fires on send. Skipped steps are empty. |
| `vs-approval-change` | `CustomEvent<{ index: number; answer: ApprovalAnswer }>` |  | Fires when a step's answer changes. |
| `vs-approval-dismiss` | `CustomEvent<{ index: number }>` |  | Fires on close, or Skip on the last step. |

## Source

### `src/components/viewership/approval-card/ApprovalCard.astro`

```astro
---
/**
 * ApprovalCard: questions an agent asks a person before acting.
 *
 * Adapted from Beautiful UI ApprovalCard (MIT, © 2026 Shane Levine).
 *
 * One question at a time. Options are native radios and checkboxes. Single
 * choice auto-advances on pointer or Space, not on arrow keys. Every
 * question also takes a free-text answer.
 *
 * Events (bubbling, on the root):
 *   vs-approval-submit   detail: ApprovalAnswer[] (one per question)
 *   vs-approval-change   detail: { index, answer: ApprovalAnswer }
 *   vs-approval-dismiss  detail: { index } (the question showing when closed)
 *
 * @example
 * <ApprovalCard questions={[{ q: "Publish today?", type: "radio", options: ["Yes", "Tomorrow"] }]} />
 */
import type { HTMLAttributes } from "astro/types";
import Button from "../button/Button.astro";
import GlideMenu from "../glide-menu/GlideMenu.astro";

export interface ApprovalQuestion {
  /** The question; it is the heading of its step. */
  q: string;
  /** "radio" = pick one, "check" = pick any. */
  type: "radio" | "check";
  options: string[];
}

/** What `vs-approval-submit` reports for each question. */
export interface ApprovalAnswer {
  question: string;
  /** Picked options, by label. */
  selected: string[];
  /** Picked options, by index. */
  indices: number[];
  /** Free-text answer ("" when empty). */
  custom: string;
}

export interface ApprovalLabels {
  skip: string;
  continue: string;
  send: string;
  customPlaceholder: string;
  customLabel: string;
  sentMessage: string;
  startOver: string;
  dismiss: string;
  reopen: string;
  previous: string;
  next: string;
  /** Screen-reader step text. {n} and {total} are replaced. */
  step: string;
}

type Props = HTMLAttributes<"div"> & {
  questions: ApprovalQuestion[];
  labels?: Partial<ApprovalLabels>;
  /** Show "Start over" after sending. */
  resettable?: boolean;
  /** Single-choice answers advance on their own (pointer or Space). */
  autoAdvance?: boolean;
};

const { questions = [], labels, resettable = true, autoAdvance = true, class: className, ...rest } = Astro.props;

const l: ApprovalLabels = {
  skip: "Skip",
  continue: "Continue",
  send: "Send",
  customPlaceholder: "Something else…",
  customLabel: "Something else",
  sentMessage: "Answers sent",
  startOver: "Start over",
  dismiss: "Dismiss",
  reopen: "Open approval",
  previous: "Previous question",
  next: "Next question",
  step: "Question {n} of {total}",
  ...labels,
};

const uid = `vs-approval-${Math.random().toString(36).slice(2, 8)}`;
const total = questions.length;
const checkPath = '<path d="M20 6L9 17l-5-5" />';
---

<div
  class:list={["vs-approval-card", className]}
  data-auto-advance={autoAdvance ? "" : undefined}
  data-labels={JSON.stringify({ continue: l.continue, send: l.send, step: l.step })}
  {...rest}
>
  <div class="card" data-card>
    <button type="button" class="dismiss" aria-label={l.dismiss} data-dismiss>
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="M18 6L6 18M6 6l12 12"></path>
      </svg>
    </button>

    <div class="pad">
      <div class="viewport" data-viewport>
        <div class="track" data-track>
          {questions.map((question, qi) => {
            const titleId = `${uid}-q${qi}`;
            const kind = question.type === "radio" ? "radio" : "checkbox";
            return (
              <div class="question" data-question={qi} data-type={question.type} data-active={qi === 0 ? "" : undefined}>
                <div class="title" id={titleId}>{question.q}</div>
                <GlideMenu class="options">
                  <div class="group" role={kind === "radio" ? "radiogroup" : "group"} aria-labelledby={titleId}>
                    {question.options.map((option, i) => (
                      <label class="option" data-menu-row>
                        <input class="native" type={kind} name={`${titleId}-opt`} value={String(i)} data-option={option} />
                        <span class:list={["mark", `mark--${kind}`]} aria-hidden="true">
                          {kind === "radio" ? (
                            <span class="dot"></span>
                          ) : (
                            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
                          )}
                        </span>
                        <span class="option-label">{option}</span>
                      </label>
                    ))}
                  </div>
                  <label class="option option--custom" data-menu-row>
                    <input class="custom" type="text" placeholder={l.customPlaceholder} aria-label={`${l.customLabel}: ${question.q}`} autocomplete="off" data-custom />
                  </label>
                </GlideMenu>
              </div>
            );
          })}
        </div>
      </div>
    </div>

    <div class="footer">
      <div class="pager">
        <button type="button" class="pager-btn" aria-label={l.previous} disabled data-prev>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 15l-6-6-6 6"></path></svg>
        </button>
        <span class="counter" aria-hidden="true" data-counter>{`1 / ${total}`}</span>
        <span class="vh" data-step-text>{l.step.replace("{n}", "1").replace("{total}", String(total))}</span>
        <button type="button" class="pager-btn" aria-label={l.next} disabled={total <= 1} data-next>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg>
        </button>
      </div>
      <div class="actions">
        <Button variant="ghost" size="sm" data-skip>{l.skip}</Button>
        <Button variant="accent" size="sm" disabled data-continue>{total <= 1 ? l.send : l.continue}</Button>
      </div>
    </div>
  </div>

  <div class="sent" role="status" tabindex="-1" hidden data-sent>
    <span class="sent-pill">
      <span class="sent-check" aria-hidden="true">
        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" set:html={checkPath} />
      </span>
      {l.sentMessage}
    </span>
    {resettable && <button type="button" class="start-over" data-reset>{l.startOver}</button>}
  </div>

  <button type="button" class="reopen" hidden data-reopen>{l.reopen}</button>
  <span class="vh" aria-live="polite" data-live></span>
</div>

<script>
  interface ApprovalAnswer {
    question: string;
    selected: string[];
    indices: number[];
    custom: string;
  }

  const ROLL_MS = 400;
  const ADVANCE_MS = 480;

  /** Odometer: each character that changes rolls up (or down). Built inline so it needs no scoped CSS. */
  function rollCounter(el: HTMLElement, value: string, animate: boolean) {
    const from = el.dataset.value ?? el.textContent ?? value;
    el.dataset.value = value;
    if (!animate || from === value) {
      el.textContent = value;
      return;
    }
    const fromN = parseInt(from, 10);
    const toN = parseInt(value, 10);
    const down = Number.isFinite(fromN) && Number.isFinite(toN) && toN < fromN;
    el.replaceChildren();
    const cols: HTMLElement[] = [];
    for (let i = 0; i < value.length; i++) {
      const o = from[i] ?? "";
      const n = value[i] ?? "";
      if (o === n) {
        el.append(n);
        continue;
      }
      const box = document.createElement("span");
      box.style.cssText = "display:inline-block;position:relative;overflow:hidden;height:1em;line-height:1em;vertical-align:-0.05em";
      const col = document.createElement("span");
      col.style.cssText = `display:flex;flex-direction:column;transition:transform 350ms cubic-bezier(0.4,0,0.2,1);transform:translateY(${down ? "-1em" : "0"})`;
      for (const ch of down ? [n, o] : [o, n]) {
        const cell = document.createElement("span");
        cell.style.cssText = "height:1em;line-height:1em";
        cell.textContent = ch;
        col.append(cell);
      }
      box.append(col);
      el.append(box);
      cols.push(col);
    }
    requestAnimationFrame(() =>
      requestAnimationFrame(() => cols.forEach((col) => (col.style.transform = `translateY(${down ? "0" : "-1em"})`))),
    );
    window.setTimeout(() => {
      if (el.dataset.value === value) el.textContent = value;
    }, ROLL_MS);
  }

  function init(root: HTMLElement) {
    const card = root.querySelector<HTMLElement>("[data-card]")!;
    const viewport = root.querySelector<HTMLElement>("[data-viewport]")!;
    const track = root.querySelector<HTMLElement>("[data-track]")!;
    const questions = [...root.querySelectorAll<HTMLElement>("[data-question]")];
    const prev = root.querySelector<HTMLButtonElement>("[data-prev]")!;
    const next = root.querySelector<HTMLButtonElement>("[data-next]")!;
    const skip = root.querySelector<HTMLButtonElement>("[data-skip]")!;
    const cont = root.querySelector<HTMLButtonElement>("[data-continue]")!;
    const counter = root.querySelector<HTMLElement>("[data-counter]")!;
    const stepText = root.querySelector<HTMLElement>("[data-step-text]")!;
    const sent = root.querySelector<HTMLElement>("[data-sent]")!;
    const reset = root.querySelector<HTMLButtonElement>("[data-reset]");
    const reopen = root.querySelector<HTMLButtonElement>("[data-reopen]")!;
    const dismiss = root.querySelector<HTMLButtonElement>("[data-dismiss]")!;
    const live = root.querySelector<HTMLElement>("[data-live]")!;
    const labels = JSON.parse(root.dataset.labels || "{}") as { continue: string; send: string; step: string };
    const autoAdvance = root.hasAttribute("data-auto-advance");
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
    const total = questions.length;
    if (!total) return;

    let qi = 0;
    let measured = false;
    let advanceTimer: number | undefined;
    let intent = 0; // timestamp of the last pointer / Space activation

    const inputs = (q: HTMLElement) => [...q.querySelectorAll<HTMLInputElement>("input.native")];
    const customOf = (q: HTMLElement) => q.querySelector<HTMLInputElement>("[data-custom]")!;

    const answerOf = (q: HTMLElement): ApprovalAnswer => {
      const picked = inputs(q).filter((i) => i.checked);
      return {
        question: q.querySelector(".title")?.textContent ?? "",
        selected: picked.map((i) => i.dataset.option ?? ""),
        indices: picked.map((i) => Number(i.value)),
        custom: customOf(q).value.trim(),
      };
    };
    const hasAnswer = (q: HTMLElement) => {
      const a = answerOf(q);
      return a.indices.length > 0 || a.custom.length > 0;
    };

    /* the viewport clips to the active question; the track slides it into place */
    const sync = (animate: boolean) => {
      const item = questions[qi]!;
      const anim = animate && !reduce.matches;
      viewport.toggleAttribute("data-animate", anim);
      track.toggleAttribute("data-animate", anim);
      viewport.style.height = `${item.offsetHeight}px`;
      track.style.transform = `translate3d(0, ${-item.offsetTop}px, 0)`;
    };

    const updateFooter = () => {
      const last = qi === total - 1;
      prev.disabled = qi <= 0;
      next.disabled = last;
      cont.disabled = !hasAnswer(questions[qi]!);
      cont.textContent = last ? labels.send : labels.continue;
    };

    const render = (animate: boolean) => {
      questions.forEach((q, i) => {
        const active = i === qi;
        q.toggleAttribute("data-active", active);
        q.inert = !active;
      });
      const value = `${qi + 1} / ${total}`;
      rollCounter(counter, value, animate && !reduce.matches);
      stepText.textContent = labels.step.replace("{n}", String(qi + 1)).replace("{total}", String(total));
      updateFooter();
      sync(animate);
    };

    const goTo = (index: number) => {
      window.clearTimeout(advanceTimer);
      const target = Math.min(Math.max(index, 0), total - 1);
      if (target === qi) return;
      const hadFocus = questions[qi]!.contains(document.activeElement);
      qi = target;
      render(measured);
      live.textContent = stepText.textContent ?? "";
      // Focus would be stranded in the now-inert question; carry it along.
      if (hadFocus) {
        const q = questions[qi]!;
        const into = inputs(q).find((i) => i.checked) ?? inputs(q)[0] ?? customOf(q);
        into.focus({ preventScroll: true });
      }
    };

    const send = () => {
      window.clearTimeout(advanceTimer);
      card.hidden = true;
      sent.hidden = false;
      root.dispatchEvent(new CustomEvent<ApprovalAnswer[]>("vs-approval-submit", { bubbles: true, detail: questions.map(answerOf) }));
      (reset ?? sent).focus();
    };

    const advance = () => {
      if (qi === total - 1) send();
      else goTo(qi + 1);
    };

    const close = () => {
      window.clearTimeout(advanceTimer);
      card.hidden = true;
      reopen.hidden = false;
      root.dispatchEvent(new CustomEvent("vs-approval-dismiss", { bubbles: true, detail: { index: qi } }));
      reopen.focus();
    };

    const emitChange = (q: HTMLElement) => {
      updateFooter();
      const index = questions.indexOf(q);
      root.dispatchEvent(new CustomEvent("vs-approval-change", { bubbles: true, detail: { index, answer: answerOf(q) } }));
    };

    questions.forEach((q) => {
      const custom = customOf(q);
      const radio = q.dataset.type === "radio";

      q.addEventListener("pointerdown", () => (intent = performance.now()));
      q.addEventListener("keydown", (event) => {
        const target = event.target as HTMLElement;
        if (event.key === " " && target.matches("input.native")) intent = performance.now();
        if (event.key === "Enter" && (target === custom || target.matches("input.native")) && hasAnswer(q)) {
          event.preventDefault();
          advance();
        }
      });

      q.addEventListener("change", (event) => {
        const input = event.target as HTMLInputElement;
        if (!input.matches("input.native")) return;
        if (radio && input.checked) custom.value = "";
        emitChange(q);
      });

      // Auto-advance only on a deliberate pick (pointer or Space), never while arrowing
      // through options. Listening to click also catches re-picking the checked option.
      q.addEventListener("click", (event) => {
        const input = event.target as HTMLElement;
        if (!radio || !autoAdvance || !input.matches("input.native")) return;
        if (performance.now() - intent > 800) return;
        window.clearTimeout(advanceTimer);
        advanceTimer = window.setTimeout(advance, ADVANCE_MS);
      });

      custom.addEventListener("input", () => {
        window.clearTimeout(advanceTimer);
        if (radio && custom.value) inputs(q).forEach((i) => (i.checked = false));
        emitChange(q);
      });
    });

    prev.addEventListener("click", () => goTo(qi - 1));
    next.addEventListener("click", () => goTo(qi + 1));
    skip.addEventListener("click", () => (qi === total - 1 ? close() : goTo(qi + 1)));
    cont.addEventListener("click", advance);
    dismiss.addEventListener("click", close);

    reopen.addEventListener("click", () => {
      reopen.hidden = true;
      card.hidden = false;
      render(false);
      dismiss.focus();
    });

    reset?.addEventListener("click", () => {
      questions.forEach((q) => {
        inputs(q).forEach((i) => (i.checked = false));
        customOf(q).value = "";
      });
      qi = 0;
      sent.hidden = true;
      card.hidden = false;
      render(false);
      questions[0]!.querySelector<HTMLInputElement>("input")?.focus();
    });

    // Questions can reflow (fonts, container width): keep the viewport fitted.
    const ro = new ResizeObserver(() => {
      if (!card.hidden) sync(false);
    });
    questions.forEach((q) => ro.observe(q));

    root.setAttribute("data-enhanced", "");
    render(false);
    measured = true;

    document.addEventListener(
      "astro:before-swap",
      () => {
        window.clearTimeout(advanceTimer);
        ro.disconnect();
      },
      { once: true },
    );
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-approval-card:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-approval-card {
    width: 100%;
    max-width: 320px;
  }
  .vh {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }

  /* ── card ── */
  .card {
    position: relative;
    overflow: hidden;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-card);
    animation: vs-fade-up 380ms var(--vs-ease-out) both;
  }
  .dismiss {
    position: absolute;
    top: 10px;
    right: 10px;
    z-index: 10;
    display: inline-flex;
    width: 28px;
    height: 28px;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: none;
    border-radius: var(--vs-radius-control);
    background: transparent;
    color: var(--vs-ink-3);
    cursor: pointer;
    transition:
      background-color 100ms ease-out,
      color 100ms ease-out;
  }
  .dismiss:hover {
    background: var(--vs-hover);
    color: var(--vs-ink);
  }
  .pad {
    padding: 12px;
  }

  /* ── question stack ── */
  .viewport {
    overflow: hidden;
  }
  .viewport[data-animate] {
    transition: height 360ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .track {
    display: flex;
    flex-direction: column;
    gap: 26px;
    will-change: transform;
  }
  .track[data-animate] {
    transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  /* Before the script measures, only the first question renders (no height flash). */
  .vs-approval-card:not([data-enhanced]) .question:not(:first-child) {
    display: none;
  }
  [data-enhanced] .question:not([data-active]) {
    opacity: 0;
    pointer-events: none;
  }
  [data-enhanced] .track[data-animate] .question {
    transition: opacity 360ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  .title {
    padding-right: 28px;
    font-size: 14px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .options {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-top: 10px;
  }
  .group {
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  .option {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 4px 8px 4px 4px;
    border-radius: var(--vs-radius-control);
    text-align: left;
    cursor: pointer;
    transition: background-color 100ms ease-out;
  }
  /* inset ring: the sliding viewport clips anything drawn outside the row */
  .option:has(.native:focus-visible) {
    box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-accent) 70%, transparent);
  }
  .native {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    margin: 0;
    opacity: 0;
    cursor: pointer;
  }
  .mark {
    display: flex;
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    box-shadow: inset 0 0 0 1.5px var(--vs-line-strong);
    color: transparent;
    transition:
      background-color 200ms ease-out,
      color 200ms ease-out,
      box-shadow 200ms ease-out;
  }
  .mark--radio {
    border-radius: 50%;
  }
  .mark--checkbox {
    border-radius: 5px;
  }
  .native:checked + .mark {
    background: var(--vs-ink);
    color: var(--vs-canvas);
    box-shadow: none;
  }
  .dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--vs-canvas);
    transform: scale(0);
    transition: transform 200ms ease-out;
  }
  .native:checked + .mark .dot {
    transform: scale(1);
  }
  .option-label {
    font-size: 13px;
    line-height: 1;
    color: var(--vs-ink-2);
    transition: color 200ms ease-out;
  }
  .native:checked ~ .option-label {
    color: var(--vs-ink);
  }
  .option--custom {
    cursor: text;
  }
  .custom {
    min-width: 0;
    height: 16px;
    flex: 1;
    padding: 0 0 0 6px;
    border: none;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    outline: none;
  }
  .custom::placeholder {
    color: var(--vs-ink-3);
  }
  .option--custom:has(.custom:focus-visible) {
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--vs-accent) 55%, transparent);
  }

  /* ── footer ── */
  .footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px;
  }
  .pager {
    display: flex;
    align-items: center;
    gap: 1px;
    color: var(--vs-ink-3);
  }
  /* 24px hit area around the 18px upstream glyph box */
  .pager-btn {
    display: flex;
    width: 24px;
    height: 24px;
    margin: 0 -2px;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: none;
    border-radius: 5px;
    background: transparent;
    color: inherit;
    cursor: pointer;
    transition: color 100ms ease-out;
  }
  .pager-btn:enabled:hover {
    color: var(--vs-ink);
  }
  .pager-btn:disabled {
    opacity: 0.3;
    cursor: default;
  }
  .counter {
    display: inline-flex;
    align-items: center;
    font-size: 12px;
    font-weight: 500;
    font-variant-numeric: tabular-nums;
    letter-spacing: -0.1px;
    line-height: 1;
    white-space: pre;
    color: var(--vs-ink-3);
  }
  .actions {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-right: -2px;
  }

  /* ── sent / closed ── */
  .sent {
    display: flex;
    align-items: center;
    gap: 12px;
    outline: none;
    animation: vs-pop-in 260ms var(--vs-ease-out) both;
  }
  .sent[hidden] {
    display: none;
  }
  .sent-pill {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 10px 4px 4px;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-green-tint);
    color: var(--vs-green);
    font-size: 12.5px;
    font-weight: 500;
  }
  .sent-check {
    display: flex;
    width: 18px;
    height: 18px;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    background: var(--vs-green);
    color: var(--vs-surface);
  }
  .start-over {
    min-height: 24px;
    padding: 0 2px;
    border: none;
    background: transparent;
    color: var(--vs-ink-3);
    font: inherit;
    font-size: 12px;
    font-weight: 500;
    cursor: pointer;
    transition: color var(--vs-duration-fast) ease-out;
  }
  .start-over:hover {
    color: var(--vs-ink);
  }
  .reopen {
    padding: 8px 12px;
    border: none;
    border-radius: var(--vs-radius-control);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-btn);
    color: var(--vs-ink);
    font: inherit;
    font-size: 12.5px;
    font-weight: 500;
    cursor: pointer;
    transition: background-color var(--vs-duration-fast) ease-out;
  }
  .reopen:hover {
    background: var(--vs-hover);
  }
  :is(.dismiss, .pager-btn, .start-over, .reopen):focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }

  @media (prefers-reduced-motion: reduce) {
    .card,
    .sent {
      animation: none;
    }
    .mark,
    .dot,
    .option-label {
      transition: none;
    }
  }
</style>
```
