Exclusivobeta
Pass Card <mv-pass-card>
Un pequeño presupuesto visible de excepciones a las reglas (“passes”) para los momentos en que una regla tiene que ceder: hacer merge durante una congelación de release, saltarse una comprobación obligatoria, una entrega fuera de plazo, una compra por encima de un límite de gasto, un cambio de fecha sin cargo, una excepción al SLA. La cartera indica “2 of 3 left this quarter · Refills Oct 1” con un ticket troquelado por pase: los disponibles son sólidos, un pase en vigor lleva un rayo, uno pendiente de aprobación está rayado, uno gastado está hueco y perforado, y el ticket se voltea a la vista cuando se gasta un pase. Donde una regla bloquea la acción (blocked, rule, action), la barrera ofrece “Use a pass”: un formulario en línea que permite elegir un tipo de pase (cada uno con su duración, su límite por periodo y su regla de aprobación), indica en dos columnas exactamente lo que cubre el pase y lo que no, exige un motivo etiquetado con una longitud mínima, pide un aprobador cuando el pase lo necesita (queda entonces retenido, no en vigor) y muestra cuántos quedarán tras usarlo antes de gastar nada. Usar un pase pasa por un mv-pass-use cancelable cuyo waitUntil(promise) permite a tu servidor registrarlo (un rechazo no gasta nada); la barrera se convierte entonces en “Pass in effect · covers PR #4821 until 3:40 PM” y el pase caduca a su hora. Cada uso queda en una tabla de registro de pases (cuándo, quién, pase y objetivo, motivo, estado: in effect, awaiting approval, used, returned, declined) que los revisores pueden consultar; los pases no usados se pueden devolver (mv-pass-return) y no cuentan, las aprobaciones se resuelven con approve() / deny(), y “Request more” emite mv-pass-request. Configuración declarativa mediante un hijo JSON, y todos los textos en inglés se pueden sustituir.
| Categoría | Formularios |
|---|---|
| Tipo | Web Component (<mv-pass-card>) |
| Estado | beta |
| Kit | Acciones destructivas y secretos seguros |
| También 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
Instalación
node scripts/add.mjs pass-card --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["pass-card"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): 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
Inicio rápido, el marcado mínimo 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>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables 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>Referencia cultural
Monopoly, Parker Brothers (a partir de The Landlord's Game de Lizzie Magie) (1935, juego). La tarjeta “Get Out of Jail Free” es una exención física y escasa de una regla: puedes guardarla hasta que de verdad importe, te libera exactamente una vez y después tienes que devolverla al mazo. Aquí se convierte en una cartera visible con unas pocas excepciones a las reglas por periodo, cada una gastada de forma deliberada con un motivo declarado, limitada a lo que cubre, registrada para los revisores y devuelta si no se usa.
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.