Exclusifbêta

Pass Card <mv-pass-card>

Un petit budget visible de dérogations aux règles (« laissez-passer ») pour les moments où une règle doit plier : merger pendant un gel de release, sauter une vérification obligatoire, une remise tardive, un achat au-dessus d’un plafond de dépenses, un report sans frais, une exception au SLA. Le portefeuille affiche « 2 of 3 left this quarter · Refills Oct 1 » avec un ticket à encoches par laissez-passer : les disponibles sont pleins, un laissez-passer en vigueur porte un éclair, un laissez-passer en attente d’approbation est rayé, un laissez-passer utilisé est vide et poinçonné, et le ticket se retourne visiblement quand un laissez-passer est utilisé. Là où une règle bloque l’action (blocked, rule, action), la barrière propose « Use a pass » : un formulaire en ligne qui permet de choisir un type de laissez-passer (chacun avec sa durée, sa limite par période et sa règle d’approbation), indique sur deux colonnes exactement ce que le laissez-passer couvre et ce qu’il ne couvre pas, exige un motif libellé d’une longueur minimale, demande un approbateur quand le laissez-passer en requiert un (il est alors suspendu, pas en vigueur), et affiche le nombre restant après utilisation avant que quoi que ce soit ne soit dépensé. Utiliser un laissez-passer passe par un mv-pass-use annulable dont waitUntil(promise) permet à votre serveur de l’enregistrer (un rejet ne dépense rien) ; la barrière devient alors « Pass in effect · covers PR #4821 until 3:40 PM » et le laissez-passer expire à l’heure dite. Chaque utilisation est consignée dans un tableau de journal (quand, qui, laissez-passer et cible, motif, statut : en vigueur, en attente d’approbation, utilisé, rendu, refusé) consultable par les relecteurs ; les laissez-passer inutilisés peuvent être rendus (mv-pass-return) et ne comptent pas, les approbations se règlent avec approve() / deny(), et « Request more » émet mv-pass-request. Configuration déclarative via un enfant JSON, toutes les chaînes anglaises remplaçables.

CatégorieFormulaires
TypeWeb Component (<mv-pass-card>)
Statutbêta
KitActions destructives et secrets en sécurité
Installe aussibutton, field, radio, checkbox, select, textarea
Keywordsexclusive, 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

Installation

node scripts/add.mjs pass-card --out ./src/marvelous

Agent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["pass-card"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : 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.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<mv-pass-card label="Freeze passes" total="3" blocked rule="Merging to main is frozen until Oct 3" action="PR #4821"></mv-pass-card>

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les 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>

Référence culturelle

Monopoly, Parker Brothers (d’après The Landlord's Game de Lizzie Magie) (1935, jeu de société). La carte « Vous êtes libéré de prison » est une exemption rare et tangible à une règle : on peut la garder jusqu’au moment où elle compte vraiment, elle libère exactement une fois, puis il faut la remettre dans la pioche. Ici, elle devient un portefeuille visible de quelques dérogations aux règles par période, chacune utilisée délibérément avec un motif déclaré, limitée à ce qu’elle couvre, consignée pour les relecteurs et rendue si elle n’a pas servi.

API

Attributes

NameTypeDefaultDescription
totalnumber3Passes in the budget for the current period. Passes in effect, awaiting approval or used count against it; returned and declined ones do not.
labelstringPassesWallet title (“Freeze passes”, “Late passes”, “Limit overrides”).
periodstringthis periodHow the period reads after the count: “2 of 3 left this quarter”.
refillsdate (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.
blockedbooleanA 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.
rulestringThe 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.
actionstringWhat 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.
userstringYouName recorded as “who” in the log. Only this user’s passes in effect or pending show Return / Withdraw.
require-approvalbooleanEvery 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-reasonnumber15Minimum reason length in characters (at least 1: a reason is always required).
requestablebooleanShows “Request more” in the wallet (and in the gate once no pass is left).
requestedbooleanSet by the component once a request is sent (the button reads “Requested” and is disabled). Remove it to allow another request.
variant"card" | "inline"cardcard: 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-logbooleanHides the pass log (always hidden in the inline variant).
log-limitnumber4Rows shown before “Show all N”.
localeBCP 47 tagen-USLocale for dates and times (Intl.DateTimeFormat).
data-stateavailable | empty | blocked | covered | pendingSet 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

NameTypeDescription
kindsArray<{ 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.
logArray<{ 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.
approversstring[]Names offered in the approver select.
remainingnumberPasses left this period (read-only).
usednumberPasses 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).
stringsPartial<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

NameDescription
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

NameDescription
mv-pass-useCancelable, 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-returnCancelable, before a pass is handed back. detail: { entry, remaining (after return), total, waitUntil(promise) }.
mv-pass-requestCancelable, “Request more” was chosen. detail: { remaining, total, target, waitUntil(promise) }.
mv-pass-changeThe log changed. detail: { reason: "use" | "return" | "approve" | "deny" | "expire", entry, log, remaining, total }. Use it to persist.
mv-pass-errorA waitUntil promise rejected. detail: { action: "use" | "return" | "request", error }.

Content structure

NameDescription
ruleRich 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

NameDescription
mv-pass-card-walletWallet: -head, -mark, -title, -summary, -request, -meter, -count (-count-value, -count-of), -legend.
mv-pass-card-ticketOne 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-gateBlocked-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-formUse 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-logLog: -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

NameDefaultDescription
--mv-pass-card-ticketvar(--mv-accent)Color of available tickets, the wallet mark and the after-use pips.
--mv-pass-card-activevar(--mv-success)Pass in effect: ticket, covered gate, status pill.
--mv-pass-card-heldvar(--mv-warning)Pass awaiting approval: striped ticket, pending gate, status pill.
--mv-pass-card-blockedvar(--mv-danger)Tint of the blocked gate and of declined passes.
--mv-pass-card-spentvar(--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.

Cette page a été traduite par IA. Signaler un problème de traduction