Exclusivebeta

Hold Slot — <mv-hold-slot>

Focused work-queue player for processing items one at a time (support tickets, moderation, code reviews, photo culling, lead qualification, flashcards): the current item is shown large, a Next column previews the upcoming items (3 by default, the first one a notch more present) and a single Hold slot lets you park the current item out of the way without losing it, then swap it back later, but only once per turn (hold-limit), so nothing gets juggled forever. Actions come from your own buttons (data-hold-action, optional data-hold-key shortcut, data-hold-requeue to send the item to the back), from the keyboard (Enter primary action, H hold/swap, Z undo, ? shortcut sheet) or from act(); each one fires a cancelable mv-action whose waitUntil(promise) keeps the item on screen while your server answers, leaves with an outcome stamp (Resolved, Escalated…) and can be undone. A progress line reads “14 done · 38 left · avg 1m 12s · ~46m to clear” (time counted only while the tab is visible, seedable from the server), mv-next asks for more items before the queue runs dry, the parked item comes back by itself when nothing else is left, and mv-empty closes the session.

CategoryData display
TypeWeb Component (<mv-hold-slot>)
Statusbeta
Also installsbutton, kbd
Keywordsexclusive, culture, queue, work-queue, triage, inbox, support, tickets, moderation, review, flashcards, keyboard, shortcuts, undo, hold, defer, focus-mode, productivity

When to use

Avoid when

Install

node scripts/add.mjs hold-slot --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["hold-slot"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, components/hold-slot/hold-slot.js, components/hold-slot/hold-slot.css, components/button/button.css, components/kbd/kbd.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="hs-demo" style="width:min(100%,68rem);margin-inline:auto">
  <style>
    #hs-demo { display:grid; gap:1.25rem; align-content:start }
    #hs-demo .hs-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #hs-demo .hs-top { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #hs-demo .hs-top h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #hs-demo .hs-top p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem }
    #hs-demo .hs-top .hs-grow { flex:1 1 12rem; display:grid; gap:.125rem }
    #hs-demo .hs-body { padding:1.125rem 1.25rem 1.25rem }
    #hs-demo .hs-controls { display:flex; align-items:center; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
    #hs-demo .hs-log { flex:1 1 16rem; min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #hs-demo .mv-choice { font-size:.8125rem }
    #hs-demo .hs-pair { display:grid; grid-template-columns:minmax(0,26rem) minmax(0,1fr); gap:1.25rem; align-items:start }
    #hs-demo .hs-note { display:grid; gap:.5rem; padding:1.125rem 1.25rem; border:1px dashed var(--mv-border); border-radius:var(--mv-radius-xl); color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.55 }
    #hs-demo .hs-note h4 { margin:0; color:var(--mv-fg); font-size:.875rem }
    #hs-demo .hs-note p { margin:0 }
    #hs-demo .hs-card-word { display:grid; gap:.375rem; justify-items:start }
    #hs-demo .hs-card-word b { font-size:1.75rem; letter-spacing:-.02em; font-weight:650 }
    #hs-demo .hs-card-word i { color:var(--mv-fg-muted); font-style:normal; font-size:.8125rem }
    #hs-demo .hs-card-word details { margin-top:.5rem; font-size:.875rem }
    #hs-demo .hs-card-word summary { cursor:pointer; color:var(--mv-accent); font-weight:500 }
    #hs-demo .hs-card-word details p { margin:.375rem 0 0; color:var(--mv-fg-muted) }
    @media (max-width:52rem) { #hs-demo .hs-pair { grid-template-columns:minmax(0,1fr) } }
  </style>

  <!-- Main: a Tier 1 support queue. 14 already done this shift, 52 in total, loaded 8 at a time. -->
  <section class="hs-app" aria-label="Support queue">
    <header class="hs-top">
      <span class="hs-grow">
        <h3>Tier 1 support · Morning shift</h3>
        <p>Oldest first · SLA 4 hours · assigned to Lena Fischer</p>
      </span>
      <span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span>Online</span>
    </header>
    <div class="hs-body">
      <mv-hold-slot id="hs-main" label="Support queue" total="52" done="14" avg="1m12s" low-water="4">
        <button type="button" class="mv-button" data-hold-action="resolve" data-hold-primary data-hold-stamp="Resolved">Resolve</button>
        <button type="button" class="mv-button" data-variant="outline" data-hold-action="escalate" data-hold-key="e" data-hold-stamp="Escalated">Escalate</button>
        <button type="button" class="mv-button" data-variant="ghost" data-hold-action="skip" data-hold-stamp="Skipped">Skip</button>
      </mv-hold-slot>
    </div>
    <div class="hs-controls">
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="hs-unlimited"> Unlimited swaps</label>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" data-size="sm" id="hs-slow"> Slow server (700 ms)</label>
      <p class="hs-log" id="hs-log" aria-live="polite">Press H to swap the parked ticket in, Enter to resolve, Z to undo.</p>
      <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="hs-reset">Reload queue</button>
    </div>
  </section>

  <!-- Compact: flashcards from <template> children, "Again" sends the card to the back of the deck. -->
  <div class="hs-pair">
    <section class="hs-app" aria-label="Flashcards">
      <header class="hs-top">
        <span class="hs-grow">
          <h3>Spanish · Travel deck</h3>
          <p>Due today · 2 cards per preview</p>
        </span>
      </header>
      <div class="hs-body">
        <mv-hold-slot id="hs-cards" label="Flashcards" preview="2" exhausted style="--mv-hold-slot-min-height:9rem">
          <template data-id="c1" data-title="la ventanilla" data-subtitle="noun · feminine" data-label="Transport" data-tone="info">
            <div class="hs-card-word"><b>la ventanilla</b><i>noun · feminine</i><details><summary>Show answer</summary><p>the ticket window; also a car or train window.</p></details></div>
          </template>
          <template data-id="c2" data-title="el andén" data-subtitle="noun · masculine" data-label="Transport" data-tone="info">
            <div class="hs-card-word"><b>el andén</b><i>noun · masculine</i><details><summary>Show answer</summary><p>the (train) platform.</p></details></div>
          </template>
          <template data-id="c3" data-title="facturar" data-subtitle="verb" data-label="Airport" data-tone="warning">
            <div class="hs-card-word"><b>facturar</b><i>verb</i><details><summary>Show answer</summary><p>to check in (luggage).</p></details></div>
          </template>
          <template data-id="c4" data-title="la tarjeta de embarque" data-subtitle="noun · feminine" data-label="Airport" data-tone="warning">
            <div class="hs-card-word"><b>la tarjeta de embarque</b><i>noun · feminine</i><details><summary>Show answer</summary><p>the boarding pass.</p></details></div>
          </template>
          <template data-id="c5" data-title="el trasbordo" data-subtitle="noun · masculine" data-label="Transport" data-tone="info">
            <div class="hs-card-word"><b>el trasbordo</b><i>noun · masculine</i><details><summary>Show answer</summary><p>the transfer, change (of train or flight).</p></details></div>
          </template>
          <button type="button" class="mv-button" data-hold-action="good" data-hold-primary data-hold-tone="success">Good</button>
          <button type="button" class="mv-button" data-variant="outline" data-hold-action="again" data-hold-key="a" data-hold-requeue data-hold-stamp="Again later">Again</button>
        </mv-hold-slot>
      </div>
    </section>
    <div class="hs-note">
      <h4>One swap per item</h4>
      <p>Park the current item in Hold and the next one steps in. Swap it back whenever you like, but only once per item: take an action before swapping again, so nothing gets juggled forever.</p>
      <p>Every action is undoable, can be vetoed or awaited by the app (<code>mv-action</code>), and the queue asks for more items (<code>mv-next</code>) before it runs dry.</p>
    </div>
  </div>

  <script type="module">
    const main = document.getElementById("hs-main");
    const cards = document.getElementById("hs-cards");
    const log = document.getElementById("hs-log");
    const say = (text) => { log.textContent = text; };

    const PEOPLE = [
      ["Amara Okafor", "Okafor Studio"], ["Kenji Watanabe", "Hikari Labs"], ["Sofia Marinelli", "Marinelli & Co."],
      ["Diego Fernández", "Ruta Logistics"], ["Priya Raman", "Lotus Health"], ["Oliver Bennett", "Bennett Legal"],
      ["Chloé Dubois", "Atelier Dubois"], ["Tomás Novák", "Brno Robotics"], ["Aisha Khan", "Crescent Retail"],
      ["Lukas Weber", "Weber Bau"], ["Mei Lin", "Jade Analytics"], ["Rafael Costa", "Costa Imóveis"],
    ];
    const TOPICS = [
      { label: "Billing", tone: "danger", title: "Charged twice for the October invoice", body: "Hi, our card was charged $240.00 twice on Oct 1 for the same Pro invoice. Can you refund the duplicate? Our finance team closes the month on Friday.", fields: [["Amount", "$240.00"], ["Invoice", "INV-20417"], ["Plan", "Pro · annual"]] },
      { label: "Login", tone: "warning", title: "SSO redirect loops after the Okta migration", body: "Since this morning, everyone on our team gets sent back to the login page after authenticating through Okta. Password login still works for admins.", fields: [["Users affected", "38"], ["IdP", "Okta"], ["Plan", "Business"]] },
      { label: "Export", tone: "info", title: "CSV export cuts off after 10,000 rows", body: "When I export the Orders report for Q3 the file stops at exactly 10,000 rows. The dashboard shows 14,212 orders for the same filter.", fields: [["Report", "Orders · Q3"], ["Rows", "14,212"], ["Plan", "Pro"]] },
      { label: "Account", tone: "neutral", title: "Transfer workspace ownership to a new admin", body: "Our current owner is leaving the company next week. Please move ownership of the workspace to the address in CC before her account is deactivated.", fields: [["Seats", "12"], ["Workspace", "Main"], ["Plan", "Team"]] },
      { label: "Billing", tone: "danger", title: "VAT number missing on our invoices", body: "We need our EU VAT number on every invoice to reclaim tax. I added it in settings last month but the new invoices still don’t show it.", fields: [["Country", "Germany"], ["Invoices", "3"], ["Plan", "Pro · monthly"]] },
      { label: "Integrations", tone: "info", title: "Slack notifications stopped arriving", body: "The #orders channel hasn’t received a notification since Tuesday. Reconnecting the Slack app from the integrations page didn’t help.", fields: [["Channel", "#orders"], ["Since", "Sep 22"], ["Plan", "Business"]] },
      { label: "Data", tone: "warning", title: "Restore a project deleted by mistake", body: "A teammate deleted the “Spring campaign” project yesterday around 4 PM. Is it possible to restore it with its files and comments?", fields: [["Project", "Spring campaign"], ["Deleted", "Sep 23, 4:07 PM"], ["Plan", "Team"]] },
      { label: "Feature", tone: "neutral", title: "Can we set a different time zone per member?", body: "Half of our team works from Singapore. Due dates show in the workspace time zone, which confuses everyone. Is there a per-member setting?", fields: [["Members", "22"], ["Regions", "EU, APAC"], ["Plan", "Business"]] },
    ];
    const WAIT = ["2h 14m", "1h 52m", "1h 40m", "1h 31m", "1h 18m", "1h 02m", "54m", "47m", "39m", "31m", "22m", "15m"];
    let seq = 0;
    const ticket = () => {
      const i = seq++;
      const t = TOPICS[(i * 5) % TOPICS.length];
      const [name, org] = PEOPLE[(i * 7) % PEOPLE.length];
      const channel = ["Email", "Chat", "Web form"][i % 3];
      const wait = WAIT[i % WAIT.length];
      return {
        id: `T-${4812 + i}`,
        title: t.title,
        subtitle: `${name} · ${t.label}`,
        label: t.label,
        tone: t.tone,
        meta: [`T-${4812 + i}`, `${name}, ${org}`, `via ${channel}`, `waiting ${wait}`],
        body: t.body,
        fields: [...t.fields.map(([label, value]) => ({ label, value })), { label: "Waiting", value: wait }],
      };
    };
    const batch = (n) => Array.from({ length: n }, ticket);

    const load = () => { seq = 0; main.reset(batch(8)); main.held = parked(); };
    // A ticket parked earlier in the shift, waiting for an answer from engineering.
    const parked = () => ({
      id: "T-4807",
      title: "Webhook signatures fail after rotating the signing key",
      subtitle: "Kenji Watanabe · API",
      label: "API",
      tone: "warning",
      meta: ["T-4807", "Kenji Watanabe, Hikari Labs", "via Email", "waiting 3h 05m"],
      body: "We rotated our webhook signing key on Monday as recommended. Since then every event fails signature verification on our side, even with the new secret.\n\nWe replay failed events manually for now. Is the old key still used to sign some events?",
      fields: [{ label: "Failed events", value: "1,284" }, { label: "Endpoint", value: "/hooks/orders" }, { label: "Plan", value: "Business" }, { label: "Waiting", value: "3h 05m" }],
    });
    await customElements.whenDefined("mv-hold-slot");
    main.items = batch(8);
    main.held = parked();

    // Infinite-ish source: 6 more tickets whenever the queue runs low, up to the 52 of the shift.
    main.addEventListener("mv-next", (e) => {
      const room = 52 - 14 - seq;
      if (room <= 0) { main.exhausted = true; return; }
      setTimeout(() => { e.detail.append(batch(Math.min(6, room))); say(`Loaded ${Math.min(6, room)} more tickets.`); }, 300);
    });

    const slow = document.getElementById("hs-slow");
    main.addEventListener("mv-action", (e) => {
      const { action, item, label } = e.detail;
      if (slow.checked) e.detail.waitUntil(new Promise((r) => setTimeout(r, 700)));
      say(`${label} ${item.id}${slow.checked ? " (saving…)" : ""}`);
      if (action === "escalate") say(`Escalated ${item.id} to Tier 2.`);
    });
    main.addEventListener("mv-hold", (e) => say(`Parked ${e.detail.item.id}, now on ${e.detail.next.id}.`));
    main.addEventListener("mv-swap", (e) => say(`Swapped: back to ${e.detail.item.id}, ${e.detail.held.id} parked.`));
    main.addEventListener("mv-undo", (e) => say(`Undid ${e.detail.action} on ${e.detail.item.id}.`));
    main.addEventListener("mv-empty", (e) => say(`Shift cleared: ${e.detail.stats.done} tickets.`));

    document.getElementById("hs-unlimited").addEventListener("change", (e) => { main.holdLimit = e.target.checked ? 0 : 1; });
    document.getElementById("hs-reset").addEventListener("click", () => { main.exhausted = false; load(); say("Queue reloaded."); });

    // The deck starts over a moment after it is cleared.
    const deck = cards.items;
    cards.addEventListener("mv-empty", () => setTimeout(() => { if (cards.isConnected) cards.reset(deck); }, 2500));
  </script>
</div>

Cultural reference

Tetris — Alexey Pajitnov (1984, game). Modern versions of the game preview the next pieces and let the player park the falling piece in a single hold box and swap it back later, but only once per piece. In the UI, a work queue shows the upcoming items and lets you park the current one in a single hold slot and swap it back, once per turn, so hard items can be deferred without being lost or juggled forever.

API

Attributes

NameTypeDefaultDescription
labelstringWork queueName of the queue, used in the current region's accessible name when empty and in the actions group label.
previewnumber (1–10)3How many upcoming items the Next column shows. The rest is summed up as “+34 more”.
hold-limitnumber1Hold/swap uses allowed per turn, i.e. between two actions. 1 = the classic rule (swap once, then act); 0 = unlimited. A refused hold is announced and the slot shows a lock.
low-waternumber5When fewer items than this are loaded after the current one, mv-next is emitted (once, then again after each action while still low, until items are appended or exhausted is set).
totalnumberSize of the whole queue as known by the server (including items not loaded yet). “left” becomes total − done; without it, left counts the loaded items.
donenumber0Items already processed before this session (restored from the server). Seeds the progress line; read at first render and by reset().
avgtime ("1m12s", "72s", ms number)Average handling time of the already-done items, blended with the times measured in this session.
exhaustedbooleanThe source has no more items: mv-next stops and the Next column ends with “End of queue”.
shortcutslocal | global | offlocallocal: shortcuts work while focus is inside the component; global: anywhere on the page except text fields and open dialogs/menus; off: none (keycap hints and the shortcut sheet are hidden).
undo-depthnumber10How many actions can be undone in a row.
data-stateactive | emptySet by the component. data-busy is present while an action waits for its promises, data-hold-locked once the turn's holds are used.
data-hold-actionstring (on a child button)Makes a button an action on the current item: resolve, escalate, skip, approve, again… (hold and undo are reserved and trigger the built-ins). Buttons given as children are moved into the action bar; buttons inside your item content work too.
data-hold-keystring (on an action button)Space-separated shortcut keys ("e", "ArrowRight j"). The primary button always gets Enter; a skip action without keys gets J and →. A keycap hint and aria-keyshortcuts are added.
data-hold-primary / data-hold-requeue / data-hold-stamp / data-hold-tone / data-hold-exiton an action buttonPrimary action (Enter; defaults to the first button) · the item goes to the back of the queue instead of leaving (Again, Snooze) · outcome text stamped on the leaving card and announced ("Resolved"; defaults to the button text) · stamp color: success | warning | danger | info | neutral | accent (defaults by action name) · exit direction: up | down | left | right.

Properties

NameTypeDescription
itemsArray<Item>Everything still to process (current, held, queue). Setting it replaces the queue and clears hold and undo. Item: { id, title, subtitle?, label?, tone?, meta?: string[], body?: string | Node, fields?: { label, value }[] } plus any data of yours; strings are rendered as text, never as HTML. Can be set before the element is defined.
heldItem | nullThe parked item. Set it to restore a parked item from the server (taken out of the queue if it is there); does not use the turn's swap.
current / queueItem | null / Item[]The item being processed and the upcoming ones (read-only).
stats{ done, left, avgMs, etaMs, byAction, holdUsed, canHold, canUndo }Live metrics (read-only). byAction counts outcomes per action name.
busybooleanTrue while an action waits for waitUntil() promises (read-only).
renderItem(item, { place: "current" }) => Node | stringReplaces the default card body (tag, meta, title, body, fields). Template items render their template content.
renderPreview(item, { place: "next" | "hold", index }) => Node | stringReplaces the default preview (tag, title, subtitle). Keep it non-interactive: the held preview is a button.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (hold, next, current, holdEmpty, holdAction, swapAction, swapHint, parkedNow, parkedOne, parkedMany, locked, more, endOfQueue, undo, undoAction, shortcuts, done, left, avg, eta, emptyTitle, emptyText, announce*…). English defaults.
preview / holdLimit / lowWater / total / done / avg / exhausted / shortcuts / undoDepth / labelreflectedMirror the attributes.

Methods

NameDescription
act(action, { requeue?, label?, stamp?, exit?, tone? })Runs an action on the current item, exactly like its button. Returns Promise<boolean>: false if there is no current item, it was cancelled or a waitUntil promise rejected.
hold()Parks the current item (empty slot, the next one steps in) or swaps it with the held one. Returns false when refused (turn limit reached, nothing to swap in, cancelled).
undo()Reverts the last action: its item becomes current again (a requeued copy is removed), the previous current goes back to the front, stats roll back. Returns false if nothing to undo or cancelled.
append(items)Adds items at the end of the queue (the answer to mv-next). Resumes automatically from the empty state.
remove(id)Removes an item wherever it is (claimed by a colleague, deleted elsewhere); not counted as done.
reset(items?)Starts over: stats re-seeded from done / avg, hold and undo cleared, items replace the queue.
focusCurrent()Moves focus to the current item region.

Events

NameDescription
mv-actionCancelable, before an action applies. detail: { action, item, label, requeue, waitUntil(promise) }. preventDefault() keeps the item; waitUntil() shows a busy state until the promise settles, and a rejection keeps the item and fires mv-action-error.
mv-action-errorA waitUntil promise rejected. detail: { action, item, error }.
mv-holdCancelable, before the current item is parked in the empty slot. detail: { item, next }.
mv-swapCancelable, before the held item and the current one trade places. detail: { item (comes back), held (gets parked) }.
mv-undoCancelable, before the last action is reverted (refuse it if the server already committed). detail: { item, action }.
mv-currentThe current item changed. detail: { item, previous, reason: "load" | "action" | "hold" | "swap" | "undo" | "remove" }. Load details for the new item here.
mv-nextThe loaded queue is running low. detail: { remaining, append(items) }. Append synchronously or later; set exhausted when the source is drained.
mv-emptyNothing left to process (queue, current and hold all empty). detail: { stats }.

Content structure

NameDescription
(action buttons)Child elements with data-hold-action (or slot="actions") are moved into the action bar, before the built-in Hold/Swap and Undo buttons.
<template>Each child <template data-id data-title data-subtitle data-label data-tone data-meta="a · b"> is an item; its content is cloned as the current card, its data-* feed the previews.
emptyA child with slot="empty" replaces the default “All caught up” summary.

CSS classes

NameDescription
mv-hold-slot-bar / -progress / -track / -help-toggle / -helpProgress line (numbers in <b>, -eta pill), thin progress track (--_p 0..1), shortcut sheet toggle and panel.
mv-hold-slot-boardThree-column grid: hold | current | next; becomes current on top with hold and next below under 46rem of container width, one column under 30rem.
mv-hold-slot-current / -stage / -cardThe focusable current region and its card (-card-top, -tag, -meta, -title, -body, -fields). A leaving card has data-leaving and an outcome -stamp.
mv-hold-slot-hold / -held / -hold-emptyHold column: the parked item's button (data-tone, aria-disabled when locked, -held-age, -held-cta) or the empty hint.
mv-hold-slot-next / -preview / -moreNext column: previews with data-rank (1 = up next) and the “+n more” / “End of queue” line.
mv-hold-slot-actions / -app-actions / -own-actions / -kbdAction bar: your buttons, then Hold/Swap and Undo; keycap hints are hidden on touch-only screens.

CSS variables

NameDefaultDescription
--mv-hold-slot-accentvar(--mv-accent)Progress track and default card edge.
--mv-hold-slot-holdvar(--mv-info)Tint of the parked item.
--mv-hold-slot-side13.5remWidth of the hold and next columns in the three-column layout.
--mv-hold-slot-min-height16rem (12rem narrow)Minimum height of the current card, so the board doesn't jump between short and long items.

Accessibility

The current item is a focusable region (tabindex="-1") named “Current item, 15 of 52: <title>”; after any action, hold, swap or undo triggered from inside the component, focus moves to it so screen readers read the new item and keyboard users keep their place (focus is never taken from elsewhere on the page). Hold and Next are labelled groups (“Hold, 1 item parked”, “Next, 37 upcoming”) wrapping real lists, so item counts are announced. The outcome of each action is announced politely (“Resolved: Charged twice for the October invoice.”), as are holds, swaps, undo, the item coming back from hold, refused holds (“Already swapped for this item. Take an action first.”) and the cleared queue. Shortcuts are announced once, the first time focus enters the component, and listed in a visible sheet (the Shortcuts button or ?); every action button carries aria-keyshortcuts and an aria-hidden keycap. Shortcuts never fire while typing in a field, Enter on a focused button or link keeps its native behavior, and in global mode keys are ignored inside open dialogs and menus. Every action is also a real button: your action buttons, Hold/Swap, Undo, and the parked item itself (a button named “Swap back: <title>”, aria-disabled with an explanation when the turn's swap is used). While an action is pending the region is aria-busy and the buttons aria-disabled. Colors are never the only signal: tones come with labels, the lock with an icon and the word “Locked”. Reduced motion (OS setting or data-motion="reduce"): cards swap in place without flying to the hold slot, no stamp pop, no list sliding; forced colors: borders and the progress track use system colors.