Exclusivobeta
Pass Card <mv-pass-card>
Um pequeno orçamento visível de exceções a regras (“passes”) para os momentos em que uma regra precisa ceder: fazer merge durante um congelamento de release, pular uma verificação obrigatória, uma entrega atrasada, uma compra acima do limite de gastos, uma remarcação sem taxa, uma exceção de SLA. A carteira mostra “2 of 3 left this quarter · Refills Oct 1” com um bilhete picotado por passe: os disponíveis são sólidos, um passe em vigor leva um raio, um aguardando aprovação é listrado, um usado é vazado e perfurado, e o bilhete vira visivelmente quando um passe é usado. Onde uma regra bloqueia a ação (blocked, rule, action), o portão oferece “Use a pass”: um formulário na própria página que permite ao usuário escolher um tipo de passe (cada um com sua duração, seu limite por período e sua regra de aprovação), mostra em duas colunas exatamente o que o passe cobre e o que não cobre, exige um motivo identificado com tamanho mínimo, pede um aprovador quando o passe precisa de um (ele fica então em espera, não em vigor) e mostra quantos restarão depois do uso antes de gastar qualquer coisa. Usar um passe passa por um mv-pass-use cancelável cujo waitUntil(promise) permite que seu servidor o registre (uma rejeição não gasta nada); o portão então vira “Pass in effect · covers PR #4821 until 3:40 PM” e o passe expira no horário. Cada uso entra em uma tabela de registro de passes (quando, quem, passe e alvo, motivo, status: in effect, awaiting approval, used, returned, declined) que os revisores podem ler; passes não usados podem ser devolvidos (mv-pass-return) e não contam, as aprovações são resolvidas com approve() / deny(), e “Request more” emite mv-pass-request. Configuração declarativa por um filho JSON, com todos os textos em inglês substituíveis.
| Categoria | Formulários |
|---|---|
| Tipo | Web Component (<mv-pass-card>) |
| Status | beta |
| Kit | Ações destrutivas e segredos com segurança |
| Também instala | button, field, radio, checkbox, select, textarea |
| Keywords | exclusive, culture, exception, override, waiver, exemption, late-pass, extension, merge-freeze, bypass, allowance, quota, budget, audit-log, accountability, approval, policy, compliance, ticket |
When to use
- Engineers may occasionally bypass a merge freeze or a required check, but each bypass must be justified and reviewable
- Students or customers get a few extensions, late passes or free reschedules per term, and should see how many they have left
- A spending limit, approval step or SLA rule can be exceeded a limited number of times, sometimes with a manager's sign-off
- Admins want exceptions to stay rare and accountable without making every one a support ticket
Avoid when
- Every critical action needs a second person to co-sign it, not a limited budget of exceptions → use Two-Key instead
- The limit is a metered quota or rate that refills continuously, like API calls or AI messages → use Recharge instead
- Elevated rights are granted for a time window and the UI must revert when it ends, with no per-period budget → use Midnight instead
Instalação
node scripts/add.mjs pass-card --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["pass-card"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/pass-card/pass-card.js, components/pass-card/pass-card.css, components/button/button.css, components/field/field.css, components/radio/radio.css, components/checkbox/checkbox.css, components/select/select.css, components/textarea/textarea.css, components/textarea/char-count.js.
Uso
Início rápido, a menor marcação que funciona:
<mv-pass-card label="Freeze passes" total="3" blocked rule="Merging to main is frozen until Oct 3" action="PR #4821"></mv-pass-card>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<div id="pc-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#pc-demo { display:grid; gap:1.25rem; align-content:start }
#pc-demo .pc-pr { display:grid; gap:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle); overflow:hidden }
#pc-demo .pc-pr-head { display:grid; gap:.375rem; padding:1rem 1.25rem; background:var(--mv-surface); border-bottom:1px solid var(--mv-border) }
#pc-demo .pc-pr-title { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:0; font-size:1.0625rem; font-weight:650; letter-spacing:-.01em }
#pc-demo .pc-pr-title span { color:var(--mv-fg-subtle); font-weight:400 }
#pc-demo .pc-pr-sub { margin:0; color:var(--mv-fg-muted); font-size:.8125rem }
#pc-demo .pc-pr-sub code { padding:.0625rem .375rem; border-radius:var(--mv-radius-sm); background:var(--mv-bg-muted); font:.75rem var(--mv-font-mono); color:var(--mv-fg) }
#pc-demo .pc-checks { display:grid; gap:0; margin:0; padding:0; list-style:none; background:var(--mv-surface); border-bottom:1px solid var(--mv-border) }
#pc-demo .pc-checks li { display:flex; align-items:center; gap:.625rem; padding:.625rem 1.25rem; font-size:.8125rem; border-top:1px solid var(--mv-border) }
#pc-demo .pc-checks li:first-child { border-top:0 }
#pc-demo .pc-checks svg { width:1rem; height:1rem; flex:none }
#pc-demo .pc-ok { color:color-mix(in oklab, var(--mv-success), var(--mv-fg) 25%) }
#pc-demo .pc-no { color:color-mix(in oklab, var(--mv-danger), var(--mv-fg) 20%) }
#pc-demo .pc-checks .mv-badge { margin-inline-start:auto }
#pc-demo .pc-body { display:grid; gap:1rem; padding:1rem 1.25rem 1.25rem }
#pc-demo .pc-merge { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
#pc-demo .pc-merge p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem }
#pc-demo .pc-controls { display:flex; align-items:center; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1rem; border:1px dashed var(--mv-border-strong); border-radius:var(--mv-radius-lg) }
#pc-demo .pc-controls .mv-choice { font-size:.8125rem }
#pc-demo .pc-log { flex:1 1 14rem; min-width:0; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:end }
#pc-demo .pc-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,24rem),1fr)); gap:1.25rem; align-items:start }
#pc-demo .pc-panel { display:grid; gap:.875rem; padding:1rem 1.125rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#pc-demo .pc-kicker { margin:0; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
#pc-demo .pc-assign { display:grid; gap:.25rem }
#pc-demo .pc-assign strong { font-size:.9375rem; letter-spacing:-.01em }
#pc-demo .pc-assign span { color:var(--mv-fg-muted); font-size:.8125rem }
#pc-demo .pc-expense { display:flex; align-items:baseline; justify-content:space-between; gap:.75rem; flex-wrap:wrap }
#pc-demo .pc-expense strong { font-size:.9375rem }
#pc-demo .pc-expense span { font-variant-numeric:tabular-nums; font-weight:600 }
#pc-demo .pc-note { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
</style>
<!-- Main: a pull request blocked by a release freeze, with a team budget of freeze passes -->
<section class="pc-pr" aria-label="Pull request #4821">
<header class="pc-pr-head">
<p class="pc-pr-title">Fix rounding in checkout totals <span>#4821</span></p>
<p class="pc-pr-sub">Maya Chen wants to merge 3 commits into <code>main</code> from <code>fix/checkout-rounding</code></p>
</header>
<ul class="pc-checks">
<li><svg class="pc-ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>All 14 checks passed</li>
<li><svg class="pc-ok" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>Approved by Jonas Weber and Leila Haddad</li>
<li id="pc-freeze-row"><svg class="pc-no" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4.5" y="10.5" width="15" height="10" rx="2.5"/><path d="M8 10.5V7.5a4 4 0 0 1 8 0v3"/></svg>Release freeze for 4.2 (until Oct 3)<span class="mv-badge" data-variant="danger" id="pc-freeze-badge">Blocking</span></li>
</ul>
<div class="pc-body">
<mv-pass-card id="pc-main" label="Freeze passes" total="3" period="this quarter" refills="2026-10-01"
blocked rule="Merging to main is frozen until Oct 3 for the 4.2 release"
action="PR #4821 · Fix rounding in checkout totals" user="Maya Chen" requestable log-limit="4">
<script type="application/json">{
"approvers": ["Priya Nair · Release captain", "Tomás Rivera · Engineering manager"],
"kinds": [
{ "id": "freeze", "label": "Merge during freeze", "duration": "24h",
"covers": ["Merging this pull request to main", "The automatic deploy to staging that follows"],
"excludes": ["Production deploys (the release captain ships those)", "Skipping required reviews or failing checks"] },
{ "id": "hotfix", "label": "Production hotfix", "description": "Ship to production now", "duration": "4h", "limit": 1, "approval": true,
"covers": ["Merging and deploying this pull request to production", "One rollback if the deploy misbehaves"],
"excludes": ["Database migrations", "Any other pull request"] }
]
}</script>
</mv-pass-card>
<div class="pc-merge">
<p id="pc-merge-note">Merging is blocked by the release freeze.</p>
<button type="button" class="mv-button" id="pc-merge" aria-disabled="true" aria-describedby="pc-merge-note">Merge pull request</button>
</div>
</div>
</section>
<div class="pc-controls">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="pc-fail"> Server rejects the next pass</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="pc-approve" aria-disabled="true">Approve as Priya Nair</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="pc-reset">Reset demo</button>
<p class="pc-log" id="pc-log" aria-live="polite">Pick “Use a pass” to try it.</p>
</div>
<div class="pc-grid">
<!-- Inline: a student's late passes, right on the closed assignment -->
<section class="pc-panel" aria-label="Assignment">
<p class="pc-kicker">Course portal · Writing 210</p>
<div class="pc-assign">
<strong>Essay 3 · The ethics of automation</strong>
<span>Due Sep 22, 11:59 PM · 1,500 words · 15% of the grade</span>
</div>
<mv-pass-card id="pc-late" variant="inline" label="Late passes" total="4" period="this semester" refills="2027-01-19"
blocked rule="Submissions closed on Sep 22 at 11:59 PM" action="Essay 3 · The ethics of automation" user="Samuel Adeyemi" min-reason="10">
<script type="application/json">{
"kinds": [
{ "id": "late", "label": "48-hour extension", "duration": "48h",
"covers": ["Submitting this essay up to 48 hours late, with no penalty"],
"excludes": ["Exams and in-class quizzes", "Group project deadlines"] }
],
"log": [
{ "id": "sa-1", "kind": "late", "by": "Samuel Adeyemi", "at": "2026-09-09T21:40:00", "reason": "Flu, doctor's note sent to the TA", "target": "Reading response 2", "status": "used" }
]
}</script>
</mv-pass-card>
<p class="pc-note">Your instructor sees every late pass and its reason.</p>
</section>
<!-- Card, exhausted: spending-limit overrides need a manager's approval -->
<section class="pc-panel" aria-label="Expense">
<p class="pc-kicker">Company card · Elena Petrova</p>
<div class="pc-expense">
<strong>Figma Organization · annual plan</strong>
<span>$1,440.00</span>
</div>
<mv-pass-card id="pc-limit" label="Limit overrides" total="2" period="this month" refills="2026-10-01"
blocked rule="This purchase is over your $1,000.00 per-transaction limit" action="Figma Organization · $1,440.00"
user="Elena Petrova" require-approval requestable log-limit="2">
<script type="application/json">{
"approvers": ["Tomás Rivera · Engineering manager"],
"kinds": [{ "id": "limit", "label": "One purchase over limit", "covers": ["This one purchase, up to $5,000.00"], "excludes": ["Recurring charges after the first one", "Cash withdrawals"] }],
"log": [
{ "id": "ep-2", "kind": "limit", "by": "Elena Petrova", "at": "2026-09-16T10:12:00", "reason": "Booth deposit for DevWorld Berlin, due today", "target": "DevWorld Berlin · $1,250.00", "status": "used", "approver": "Tomás Rivera" },
{ "id": "ep-1", "kind": "limit", "by": "Elena Petrova", "at": "2026-09-03T16:48:00", "reason": "Reserved instances renewal before the price change", "target": "AWS · $2,180.00", "status": "used", "approver": "Tomás Rivera" }
]
}</script>
</mv-pass-card>
</section>
</div>
<script type="module">
const main = document.getElementById("pc-main");
const merge = document.getElementById("pc-merge");
const mergeNote = document.getElementById("pc-merge-note");
const badge = document.getElementById("pc-freeze-badge");
const fail = document.getElementById("pc-fail");
const approveBtn = document.getElementById("pc-approve");
const out = document.getElementById("pc-log");
const say = (text) => { out.textContent = text; };
// Relative dates keep the main log recent whenever the demo is opened.
const ago = (days, hh, mm) => { const t = new Date(); t.setDate(t.getDate() - days); t.setHours(hh, mm, 0, 0); return t.toISOString(); };
const initialLog = () => [
{ id: "fz-2", kind: "freeze", by: "Aiko Tanaka", at: ago(2, 16, 5), target: "PR #4802 · Update currency rounding tables", reason: "Needed the new tables before the Tokyo launch. Turned out the fix landed before the freeze, so I handed it back.", status: "returned" },
{ id: "fz-1", kind: "freeze", by: "Daniel Okafor", at: ago(6, 11, 42), target: "PR #4790 · Patch image upload validation", reason: "Security fix for the image upload endpoint. Waiting 9 days for the freeze to lift would leave it exposed.", status: "used" },
];
main.log = initialLog();
const sync = () => {
const state = main.state;
const covered = state === "covered";
merge.setAttribute("aria-disabled", String(!covered));
mergeNote.textContent = covered ? "A freeze pass covers this merge." : state === "pending" ? "Waiting for the release captain to approve the hotfix pass." : "Merging is blocked by the release freeze.";
badge.textContent = covered ? "Pass in effect" : state === "pending" ? "Awaiting approval" : "Blocking";
badge.dataset.variant = covered ? "success" : state === "pending" ? "warning" : "danger";
approveBtn.setAttribute("aria-disabled", String(!main.log.some((e) => e.status === "pending")));
};
customElements.whenDefined("mv-pass-card").then(sync);
// A fake server that records each pass (and can refuse it).
const server = (ms = 700) => new Promise((resolve, reject) => {
const bad = fail.checked;
setTimeout(() => (bad ? reject(new Error("503")) : resolve({ id: `srv-${Math.random().toString(36).slice(2, 7)}` })), ms);
});
for (const el of document.querySelectorAll("#pc-demo mv-pass-card")) {
el.addEventListener("mv-pass-use", (e) => {
e.detail.waitUntil(server());
say(`mv-pass-use · ${e.detail.kind.label} · ${e.detail.remaining} left after`);
});
el.addEventListener("mv-pass-return", (e) => {
e.detail.waitUntil(server(400));
say(`mv-pass-return · ${e.detail.remaining} left after`);
});
el.addEventListener("mv-pass-request", (e) => {
e.detail.waitUntil(server(600));
say(`mv-pass-request · ${e.detail.remaining} of ${e.detail.total} left`);
});
el.addEventListener("mv-pass-error", (e) => {
fail.checked = false;
say(`mv-pass-error · ${e.detail.action} failed (${e.detail.error.message}), nothing was spent`);
});
}
main.addEventListener("mv-pass-change", (e) => { sync(); say(`mv-pass-change · ${e.detail.reason} · ${e.detail.remaining} of ${e.detail.total} left`); });
merge.addEventListener("click", () => {
if (merge.getAttribute("aria-disabled") === "true") return;
say("Merged #4821 into main under a freeze pass.");
});
approveBtn.addEventListener("click", () => {
const pending = main.log.find((e) => e.status === "pending");
if (pending) main.approve(pending.id, { by: "Priya Nair" });
});
document.getElementById("pc-reset").addEventListener("click", () => {
main.close();
main.log = initialLog();
main.requested = false;
fail.checked = false;
sync();
say("Demo reset.");
});
</script>
</div>Referência cultural
Monopoly, Parker Brothers (a partir de The Landlord's Game, de Lizzie Magie) (1935, jogo). A carta “Saia livre da prisão” é uma isenção rara e física de uma regra: você pode guardá-la até o momento em que ela realmente importa, ela liberta você exatamente uma vez, e depois precisa voltar ao baralho. Aqui ela se torna uma carteira visível com algumas exceções a regras por período, cada uma usada de forma deliberada com um motivo declarado, limitada ao que cobre, registrada para os revisores e devolvida se não for usada.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
total | number | 3 | Passes in the budget for the current period. Passes in effect, awaiting approval or used count against it; returned and declined ones do not. |
label | string | Passes | Wallet title (“Freeze passes”, “Late passes”, “Limit overrides”). |
period | string | this period | How the period reads after the count: “2 of 3 left this quarter”. |
refills | date (ISO, a bare YYYY-MM-DD is local) | When the budget refills, shown as “Refills Oct 1” in the wallet, the gate and the empty state. Display only: your app resets the log when the period changes. | |
blocked | boolean | A rule currently blocks the action: shows the gate with “Use a pass”. The gate reads “Pass in effect” or “Waiting for approval” while a pass for the same action is active or pending. | |
rule | string | The rule that blocks, as the gate title (“Merging to main is frozen until Oct 3 for the 4.2 release”). Children with slot="rule" replace it with rich content. | |
action | string | What is blocked (“PR #4821 · Fix rounding in checkout totals”). Stored as the target of the pass; a pass in effect covers the gate only when its target matches. | |
user | string | You | Name recorded as “who” in the log. Only this user’s passes in effect or pending show Return / Withdraw. |
require-approval | boolean | Every pass needs an approver (a kind can also require it with approval: true). The approver select becomes required and the pass is held until approve(). | |
min-reason | number | 15 | Minimum reason length in characters (at least 1: a reason is always required). |
requestable | boolean | Shows “Request more” in the wallet (and in the gate once no pass is left). | |
requested | boolean | Set by the component once a request is sent (the button reads “Requested” and is disabled). Remove it to allow another request. | |
variant | "card" | "inline" | card | card: wallet, gate and log in one card. inline: only the gate (with the count in its hint), to sit in a list row or next to a blocked button. |
hide-log | boolean | Hides the pass log (always hidden in the inline variant). | |
log-limit | number | 4 | Rows shown before “Show all N”. |
locale | BCP 47 tag | en-US | Locale for dates and times (Intl.DateTimeFormat). |
data-state | available | empty | blocked | covered | pending | Set by the component (styleable). data-empty when no pass is left, data-open while the form is open, data-busy while a waitUntil promise is pending. |
Properties
| Name | Tipo | Description |
|---|---|---|
kinds | Array<{ id, label, description?, covers?: string[], excludes?: string[], duration?: "24h" | "48h" | "7d" | ms, limit?: number, approval?: boolean }> | Pass types. With several, the form shows them as radio cards; limit caps that kind per period (“1 of 1 left”, disabled at 0); duration sets how long a pass stays in effect once used (none = one action). Also settable from the JSON child. |
log | Array<{ id, kind, by, at, reason, target?, status: "active" | "pending" | "used" | "returned" | "denied", approver?, until? }> | Uses this period (dates as ISO strings, Date or epoch ms). Read it to persist; an active pass past its until reads as used. Also settable from the JSON child. |
approvers | string[] | Names offered in the approver select. |
remaining | number | Passes left this period (read-only). |
used | number | Passes spent this period: in effect, awaiting approval or used (read-only). |
state | "available" | "empty" | "blocked" | "covered" | "pending" | Current gate state, or wallet state when nothing is blocked (read-only). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (English defaults: title, use, useFor, afterUse, noneLeft, covers, excludes, validFor, reason, reasonHint, reasonShort, approver, summary, summaryHeld, accountable, confirm, confirmApproval, coveredTitle, coveredUntil, pendingTitle, pendingText, logTitle, logNote, status*, announce*, …). |
Methods
| Name | Description |
|---|---|
use({ kind?, reason, approver?, target? }) | Uses a pass programmatically (same path as the form, including the cancelable mv-pass-use and waitUntil). Resolves to the logged entry, or null if refused, cancelled, failed or no pass is left. |
returnPass(id) | Hands back a pass in effect or awaiting approval (cancelable mv-pass-return): it is marked returned and no longer counts. Resolves true when done. |
approve(id, { by? }) | Settles a pending pass: it takes effect now for its kind’s duration. Call it when your server confirms the approval. |
deny(id, { by?, note? }) | Settles a pending pass as declined: it is not counted. |
request() | Asks for more passes (cancelable mv-pass-request with waitUntil), then sets requested. Resolves true once sent. |
open() / close() | Opens or closes the “Use a pass” form (open only while the gate is blocked and a pass is left). |
Events
| Name | Description |
|---|---|
mv-pass-use | Cancelable, before a pass is spent. detail: { entry, kind, reason, approver, target, remaining (after use), total, waitUntil(promise) }. Call waitUntil() synchronously with your request: the confirm button shows “Logging…”, a rejection spends nothing and shows an error, an object it resolves to is merged into the entry (server id, until…). preventDefault() refuses the use. |
mv-pass-return | Cancelable, before a pass is handed back. detail: { entry, remaining (after return), total, waitUntil(promise) }. |
mv-pass-request | Cancelable, “Request more” was chosen. detail: { remaining, total, target, waitUntil(promise) }. |
mv-pass-change | The log changed. detail: { reason: "use" | "return" | "approve" | "deny" | "expire", entry, log, remaining, total }. Use it to persist. |
mv-pass-error | A waitUntil promise rejected. detail: { action: "use" | "return" | "request", error }. |
Content structure
| Name | Description |
|---|---|
rule | Rich content (a link to the freeze calendar, a policy name…) shown as the gate title instead of the rule attribute while the action is blocked. |
CSS classes
| Name | Description |
|---|---|
mv-pass-card-wallet | Wallet: -head, -mark, -title, -summary, -request, -meter, -count (-count-value, -count-of), -legend. |
mv-pass-card-ticket | One ticket per pass in .mv-pass-card-tickets, data-state available | pending | active | used; -ticket-glyph holds its icon. data-dense on the strip above 10 passes. |
mv-pass-card-gate | Blocked-action strip, data-state blocked | empty | covered | pending: -gate-icon, -gate-title, -rule (slot), -gate-text, -gate-hint, -use, -gate-return, -gate-request. |
mv-pass-card-form | Use form: -form-title, -kinds (mv-choice-group), -scope (-scope-col[data-scope=covers|excludes], -scope-list, -validity), -reason-field, -approver, -after (-pip[data-state]), -form-error, -form-actions, -confirm. |
mv-pass-card-log | Log: -log-title, -log-note, -log-toggle, -table (rows with data-status; cells with data-col), -status[data-status], -status-sub, -row-return. Rows stack as cards under a 40rem container width. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-pass-card-ticket | var(--mv-accent) | Color of available tickets, the wallet mark and the after-use pips. |
--mv-pass-card-active | var(--mv-success) | Pass in effect: ticket, covered gate, status pill. |
--mv-pass-card-held | var(--mv-warning) | Pass awaiting approval: striped ticket, pending gate, status pill. |
--mv-pass-card-blocked | var(--mv-danger) | Tint of the blocked gate and of declined passes. |
--mv-pass-card-spent | var(--mv-fg-subtle) | Used tickets and neutral status pills. |
Accessibility
Counts are always text: the wallet is a labelled section whose description reads “2 of 3 left this quarter · Refills Oct 1”, and the legend names each state in words (“1 in effect · 1 used”); the ticket strip and after-use pips are decorative (aria-hidden) and states also differ by icon and border style (bolt, stripes, hollow dashed and punched), never by color alone. The gate is a labelled group; “Use a pass” is a real button with aria-expanded / aria-controls, described by the hint (“1 of 3 left after this”, or “No passes left this quarter. Refills Oct 1.”). With no pass left it stays focusable with aria-disabled so the reason can be read. The form is a native <form> (novalidate) labelled by its title: pass types are native radios in a fieldset with a legend, covers / does-not-cover are headed lists, the reason is a <textarea> with a visible label, required and minlength, a hint and an error message linked through aria-describedby with aria-invalid; the approver is a labelled, required <select>. Submitting with a missing field focuses the first invalid control. Escape closes the form and returns focus to “Use a pass”; Ctrl/Cmd+Enter submits from the reason. While a waitUntil promise is pending the confirm button is aria-busy and aria-disabled. After a pass is used, focus moves to the gate title (“Pass in effect”) and a polite live region announces “Pass used for PR #4821. 1 of 3 left this quarter. Logged for reviewers.”; returns, approvals and declines are announced the same way, failures assertively. The log is a real table with a caption-like heading (aria-labelledby), column headers and <time datetime> cells; Return / Withdraw buttons carry a full label (“Return the pass used Sep 24, 3:40 PM for PR #4821”). Under 40rem the rows stack as cards with explicit table roles so the semantics survive. Motion (ticket flip, pulsing next pip, new-row highlight) is off under prefers-reduced-motion or data-motion="reduce"; forced-colors mode keeps borders and states visible.