エクスクルーシブ
Read Back <mv-read-back>
パイロットが管制の許可を復唱するように、重要なフォームで入力内容を読み返して確認します。フォームを囲み、重要なフィールドに data-read-back を付けると、送信時にインラインのパネルが各値を入力時とは異なる形で読み返します。そのため、うっかりミスがそのまま惰性で確定されず、目に見えるようになります。
文化的リファレンス
変身物語(エコーとナルキッソスの神話)、オウィディウス(8年、書籍)。ニンフのエコーは、自分に向けられた最後の言葉を繰り返すことでしか話せない呪いを受けており、話し手は誰もが自分の言葉をそのまま返されます。UI では、フォームの重要な値を確定前に、あえて別の形式(文字での表記、桁区切り、相対値、単位や桁の明示)で復唱し、本人が実際に何を入力したかを聞き取って入力ミスに気づけるようにします。
仕組み
パイロットが管制承認を復唱するように、重要度の高いフォームで復唱確認を行います:フォームをラップし、重要なフィールドに data-read-back を付けると、送信時にインラインパネルが各値を入力時とは異なる形で読み返します。そのため、うっかりミスが惰性で確認されてしまうことなく、目に見えるようになります。金額は小切手のように文字で書き表され(「twelve thousand five hundred dollars and 00 cents」、通貨対応、英語の数詞はローカル実装)、紛らわしい区切り文字にはフラグが立ちます(「1,25」は $125.00 と読まれます)。口座番号はグループ分けされ、検証され(IBAN の mod-97 と国ごとの桁数、国名付き)、名義人の名前と並べて表示されます。日付は曜日と相対日数になり(「Friday, October 2 · in 8 days」。年は今年でない場合のみ)、過去、週末、遠い未来にはフラグが立ちます。数量には単位、文字表記、パック計算、合計が付き(「2 bottles × 150 = 300 mL · 2 × $8.40 = $16.80」)、小数点の安全対策(先頭のゼロがない「.5」、末尾の「5.0」)も行われます。コードは ICAO の通話表で綴られ、紛らわしい文字(O/0、I/1)が指摘されます。編集された値は変更前の値を、変更箇所を強調して表示します。典型的な値との桁違いは言葉で指摘されます(「10× your usual amount, an extra zero?」)。各行には該当フィールドにそのまま戻る Change ボタンがあり、確認時に末尾 4 文字、ランダムな 1 文字、または値全体の再入力を必須にできます。ライブモードでは入力中に各フィールドの下に同じ読み返しを表示します。カスタムまたは非同期のフォーマッター(銀行 API による受取人名の照合など)はフィールドや型ごとに組み込めます。すべて Intl に対応し(デフォルトは en-US)、mv-read-back はキャンセル可能(少額ならスキップ)で、mv-confirm もキャンセル可能です(自分で fetch して送信)。
| カテゴリー | フォーム |
|---|---|
| タイプ | Web Component(<mv-read-back>) |
| ステータス | 安定版 |
| キット | ユーザーを取りこぼさないフォーム |
| 同時にインストールされるもの | button, input |
| Keywords | exclusive, culture, confirmation, read-back, verification, double-check, payment, wire-transfer, iban, amount-in-words, number-to-words, dosage, medication-safety, quantity, date, relative-date, phonetic-alphabet, diff, form, error-prevention, intl |
When to use
- A payment, wire transfer or payout must be checked before it leaves, where one extra zero or swapped digit is costly
- Clinicians or pharmacists enter doses and quantities where a misplaced decimal point is dangerous
- An admin edits production settings (DNS records, limits, bulk deletes) and should see exactly what changes before saving
- Order quantities, pack sizes or dates are typed by hand and mistakes are only noticed after shipping or booking
Avoid when
- The action is destructive but the values are not the risk; one deliberate gesture is the safeguard needed → use Hold Button instead
- A critical action needs two people or two synchronized keys, not a value check → use Two-Key instead
- The risk is something forgotten (attachment, subject, placeholder) rather than a value typed wrong → use Forgot instead
インストール
node scripts/add.mjs read-back --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["read-back"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, components/button/button.css, components/input/input.css, core/dom.js, core/element.js, core/motion.js, components/read-back/read-back.js, components/read-back/read-back.css。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-read-back>
<form>
<label>Amount <input name="amount" value="12500" data-read-back="amount"></label>
<label>Account number <input name="account" value="021000021" data-read-back="account"></label>
<button>Send transfer</button>
</form>
</mv-read-back>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="rb-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#rb-demo { display:grid; gap:1.25rem; align-content:start }
#rb-demo .rb-card { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#rb-demo .rb-head { display:flex; align-items:center; gap:.75rem; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
#rb-demo .rb-head h3 { margin:0; font-size:.9375rem; font-weight:650; letter-spacing:-.01em }
#rb-demo .rb-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#rb-demo .rb-glyph { display:grid; place-items:center; flex:none; width:2rem; height:2rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
#rb-demo .rb-glyph svg { width:1.0625rem; height:1.0625rem }
#rb-demo mv-read-back { padding:1.125rem 1.25rem 1.25rem }
#rb-demo form { display:grid; gap:1rem; margin:0 }
#rb-demo .rb-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem 1.25rem; align-items:start }
#rb-demo .rb-span { grid-column:1 / -1 }
#rb-demo .rb-mono { font-family:var(--mv-font-mono); letter-spacing:.02em }
#rb-demo .rb-amount { display:grid; grid-template-columns:minmax(0,1fr) 5.75rem; gap:.5rem }
#rb-demo .rb-submit { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding-top:.25rem }
#rb-demo .rb-submit p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; flex:1 1 16rem }
#rb-demo .rb-controls { display:flex; align-items:center; gap:.75rem 1.5rem; flex-wrap:wrap; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#rb-demo .rb-controls .mv-choice { font-size:.8125rem }
#rb-demo .rb-inline { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500 }
#rb-demo .rb-inline .mv-select { width:auto; min-width:11.5rem }
#rb-demo .rb-log { flex:1 1 14rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); text-align:end }
#rb-demo .rb-pair { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1.25rem; align-items:start }
#rb-demo .rb-note { margin:0; color:var(--mv-fg-muted); font-size:.8125rem }
@media (max-width:46rem) {
#rb-demo .rb-grid, #rb-demo .rb-pair { grid-template-columns:minmax(0,1fr) }
#rb-demo .rb-log { text-align:start }
}
</style>
<!-- Main: a wire transfer, live echo under each field + read-back on submit (already open here) -->
<section class="rb-card" aria-labelledby="rb-wire-title">
<header class="rb-head">
<span class="rb-glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 10h18M5 10V20M9.5 10V20M14.5 10V20M19 10V20M3 20h18M12 3l9 5H3z"/></svg></span>
<div>
<h3 id="rb-wire-title">New international wire</h3>
<p>From Operating account ···· 4417 · $48,210.55 available</p>
</div>
</header>
<mv-read-back id="rb-wire" mode="both" challenge="last4">
<form id="rb-wire-form" onsubmit="event.preventDefault()">
<div class="rb-grid">
<div class="mv-field">
<label class="mv-label" for="rb-name">Beneficiary</label>
<input class="mv-input" id="rb-name" name="beneficiary" value="Lindqvist & Okafor GmbH" required autocomplete="off">
</div>
<div class="mv-field">
<label class="mv-label" for="rb-iban">IBAN</label>
<input class="mv-input rb-mono" id="rb-iban" name="iban" value="DE89370400440532013000" data-read-back="account" data-holder-from="beneficiary" required spellcheck="false" autocomplete="off">
</div>
<div class="mv-field">
<label class="mv-label" for="rb-amount">Amount</label>
<div class="rb-amount">
<input class="mv-input" id="rb-amount" name="amount" inputmode="decimal" value="12500" data-read-back="amount" data-currency="currency" data-typical="1250" data-read-back-echo="#rb-amount-echo" required autocomplete="off">
<select class="mv-select" id="rb-currency" name="currency" aria-label="Currency">
<option selected>USD</option>
<option>EUR</option>
<option>GBP</option>
</select>
</div>
<div id="rb-amount-echo"></div>
</div>
<div class="mv-field">
<label class="mv-label" for="rb-date">Execution date</label>
<input class="mv-input" id="rb-date" name="date" type="date" data-read-back="date" data-business-days required>
</div>
<div class="mv-field rb-span">
<label class="mv-label" for="rb-ref">Payment reference</label>
<input class="mv-input rb-mono" id="rb-ref" name="reference" value="INV-2048-K7" data-read-back="code" required spellcheck="false" autocomplete="off">
</div>
</div>
<div class="rb-submit">
<p>Wires are read back before they leave: check every line, then confirm.</p>
<button class="mv-button" type="submit">Send transfer</button>
</div>
</form>
</mv-read-back>
<div class="rb-controls">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="rb-live" checked> Live echo under fields</label>
<label class="rb-inline" for="rb-challenge">To confirm
<select class="mv-select" data-size="sm" id="rb-challenge">
<option value="">Nothing to re-type</option>
<option value="last4" selected>Last 4 of the IBAN</option>
<option value="char">One random character</option>
</select>
</label>
<p class="rb-log" id="rb-log" aria-live="polite">Payee check: simulated, 500 ms.</p>
</div>
</section>
<div class="rb-pair">
<!-- Live only: a pharmacy order where decimal slips are dangerous -->
<section class="rb-card" aria-labelledby="rb-rx-title">
<header class="rb-head">
<span class="rb-glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="7" width="12" height="14" rx="2.5"/><path d="M8 3h8v4H8zM12 11v6M9 14h6"/></svg></span>
<div>
<h3 id="rb-rx-title">Pharmacy order</h3>
<p>Ibuprofen oral suspension · 100 mg per 5 mL</p>
</div>
</header>
<mv-read-back id="rb-rx" mode="live">
<form onsubmit="event.preventDefault()">
<div class="mv-field">
<label class="mv-label" for="rb-dose">Dose per intake</label>
<div class="mv-input-group">
<input class="mv-input" id="rb-dose" inputmode="decimal" value="50" data-read-back="quantity" data-unit="mL" data-unit-name="milliliter" data-typical="5" data-typical-label="the usual dose" autocomplete="off">
<span class="mv-input-group-addon">mL</span>
</div>
</div>
<div class="mv-field">
<label class="mv-label" for="rb-supply">Supply</label>
<input class="mv-input" id="rb-supply" type="number" min="1" value="2" data-read-back="quantity" data-unit="bottle" data-pack="150" data-pack-unit="mL" data-unit-price="8.40">
</div>
<div class="mv-field">
<label class="mv-label" for="rb-start">First intake</label>
<input class="mv-input" id="rb-start" type="date" data-read-back="date">
</div>
</form>
</mv-read-back>
</section>
<!-- Edit form: only what changed is read back, with the change highlighted -->
<section class="rb-card" aria-labelledby="rb-dns-title">
<header class="rb-head">
<span class="rb-glyph" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.8 3 2.8 15 0 18M12 3c-2.8 3-2.8 15 0 18"/></svg></span>
<div>
<h3 id="rb-dns-title">Edit DNS record</h3>
<p>api.example.com · production zone</p>
</div>
</header>
<mv-read-back id="rb-dns" mode="both" only-changed>
<form onsubmit="event.preventDefault()">
<div class="rb-grid">
<div class="mv-field">
<label class="mv-label" for="rb-type">Type</label>
<select class="mv-select" id="rb-type" data-read-back="text">
<option selected>A</option>
<option>AAAA</option>
<option>CNAME</option>
</select>
</div>
<div class="mv-field">
<label class="mv-label" for="rb-ttl">TTL (seconds)</label>
<input class="mv-input" id="rb-ttl" type="number" min="60" value="300" data-was="3600" data-read-back="quantity" data-unit="second">
</div>
</div>
<div class="mv-field">
<label class="mv-label" for="rb-target">Points to</label>
<input class="mv-input rb-mono" id="rb-target" value="203.0.113.101" data-was="203.0.113.10" data-read-back="text" spellcheck="false" autocomplete="off">
</div>
<div class="rb-submit">
<p class="rb-note" id="rb-dns-note" aria-live="polite">Unsaved changes.</p>
<button class="mv-button" type="submit">Save record</button>
</div>
</form>
</mv-read-back>
</section>
</div>
<script type="module">
await customElements.whenDefined("mv-read-back");
const wire = document.getElementById("rb-wire");
const rx = document.getElementById("rb-rx");
const dns = document.getElementById("rb-dns");
const log = document.getElementById("rb-log");
const say = (text) => { log.textContent = text; };
const pad = (n) => String(n).padStart(2, "0");
const iso = (days) => { const d = new Date(); d.setDate(d.getDate() + days); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
for (const [id, days] of [["rb-date", 8], ["rb-start", 1]]) {
const el = document.getElementById(id);
el.defaultValue = iso(days);
el.value = iso(days);
}
// Simulated confirmation of payee: an async formatter adds a line once the bank answers.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
wire.formatters = {
iban: async ({ echo, field }) => {
await sleep(500);
const name = field.form.elements.beneficiary.value.trim();
const known = /lindqvist/i.test(name);
return known
? { detail: [echo.detail, "Payee name matches the bank’s records"].filter(Boolean) }
: { detail: echo.detail, warning: "Payee name doesn’t match the bank’s records" };
},
};
rx.refresh();
wire.open({ focus: false });
wire.addEventListener("mv-read-back", (e) => say(`Read-back: ${e.detail.items.length} values to check.`));
wire.addEventListener("mv-back", (e) => say(e.detail.item ? `Back to “${e.detail.item.label}”.` : "Back to the form."));
wire.addEventListener("mv-confirm", (e) => {
e.preventDefault(); // the demo sends nothing
const amount = e.detail.items.find((i) => i.type === "amount");
say(`Confirmed: ${amount?.text ?? "wire"} released.`);
});
document.getElementById("rb-live").addEventListener("change", (e) => { wire.mode = e.target.checked ? "both" : "submit"; });
document.getElementById("rb-challenge").addEventListener("change", (e) => { wire.challenge = e.target.value || null; });
const note = document.getElementById("rb-dns-note");
dns.addEventListener("mv-confirm", (e) => { e.preventDefault(); note.textContent = "Record saved. Propagation can take up to 5 minutes."; });
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
mode | submit | live | both | submit | submit: the read-back panel opens when the form is submitted. live: the echo is shown under each field while typing, submits are not held. both: live echo plus the read-back on submit. |
challenge | last4 | char | full | What must be re-typed before Confirm works: the last 4 characters, one randomly chosen character (its position is named, e.g. “the 7th character”) or the whole value. Applies to the field marked data-read-back-challenge, else the first account, amount or code field. Amounts and quantities always use full (compared as numbers); values shorter than 6 characters fall back to full. | |
for | string (id) | Id of the form to guard when <mv-read-back> does not wrap it (default: a child <form>, else the closest ancestor form). | |
locale | BCP 47 tag | en-US | Formatting locale for numbers, currencies, dates, relative days and country names; also used to parse typed numbers (group and decimal separators). Spelled-out words are English; other locales fall back to the Intl long form (“12.500,00 Euro”) unless you provide a formatter. |
currency | ISO 4217 code | USD | Default currency of amount fields (per field: data-currency). |
ratio | number | 3 | How far from data-typical a value must be (above or below, as a factor) before it is flagged. Near powers of ten add “an extra zero?” / “a missing zero?”. |
changes | boolean | Edit forms: values that differ from their initial value (defaultValue, defaultSelected, defaultChecked or data-was) show “was …”, and text or code changes are highlighted. | |
only-changed | boolean | Implies changes; only fields whose value moved are read back (fields with no initial value, or with data-read-back-always, still are). | |
data-read-back | amount | account | date | quantity | code | text | <custom> | On a field (input, select, textarea, radio, or a fieldset / radiogroup of choices): echo it, formatted by type. Empty: inferred (date inputs → date, number inputs → quantity, else text). Any other name is echoed as text and looked up in formatters. | |
data-read-back-label | string | Name shown and announced for the field (default: its <label>, legend, aria-label, aria-labelledby, placeholder, then name). | |
data-currency | ISO code | field name or id | Amount fields: currency code, or the name/id of the field holding it (a currency select). | |
data-typical | number | Amount and quantity fields: the usual value; values ratio× above or below it get a magnitude warning in words. | |
data-typical-label | string | your usual amount | your usual quantity | How the typical value is named in the warning (“10× the usual dose”). |
data-unit / data-unit-plural / data-unit-name | string | Quantity fields: unit shown after the number (word units such as “box” are pluralized, symbols such as “mL” are not), an irregular plural, and the unit’s spoken name used in words (“milliliter”). | |
data-pack / data-pack-unit | number / string | Quantity fields: items per unit, for the total line (“12 boxes × 24 = 288 units”). | |
data-unit-price | number | Quantity fields: price per unit, for a total (“2 × $8.40 = $16.80”) in the field’s currency. | |
data-holder / data-holder-from | string / field name or id | Account fields: the holder’s name, literally or read from another field, shown next to the grouped number. | |
data-group | sizes, e.g. "3,3,4" | 4 | Account fields: grouping pattern (the last size repeats). |
data-business-days | boolean | Date fields: flag Saturdays and Sundays. | |
data-allow-past | boolean | Date fields: don’t flag past dates. | |
data-case-sensitive | boolean | Code fields: case matters (mixed-case letters are spelled “lowercase Kilo”, the challenge compares case). | |
data-read-back-echo | CSS selector | Live mode: container that receives the field’s echo line (default: right after the field or its .mv-input-group). | |
data-read-back-challenge | boolean | Marks the field whose value must be re-typed when challenge is set. | |
data-was | string | Previous value of the field (overrides the initial value) and turns change tracking on for it. | |
data-read-back-skip | boolean | On a submit button (e.g. “Save draft”): submits without a read-back. | |
data-read-back-panel | boolean | On an element inside the form or the component: the read-back panel is placed there instead of at the end of <mv-read-back>. | |
data-open / data-pending | set by the component | On the host: while the panel is shown / while an async formatter is still answering (Confirm is aria-disabled). |
Properties
| Name | タイプ | Description |
|---|---|---|
formatters | Record<string, (ctx) => string | EchoPatch | Promise<EchoPatch>> | Custom echo, keyed by field name, id or data-read-back type (in that order). ctx: { field, type, raw, value (parsed number / Date / string), locale, currency, echo: { value, words, detail, warnings } }. Return a string (replaces the main value) or { value?, words?, detail?, warnings?, warning? } merged over the default echo. A promise shows “Checking…” and holds Confirm until it settles; a rejection adds “Couldn’t verify this value”. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (title, text, flags, change, back, confirm ({action}), confirmPlain, checking, was, holder, challengeLast4, challengeChar, challengeFull, challengeMismatch, above, below, extraZero, missingZero, separator, leadingZero, trailingZero, past, weekend, farFuture, ibanValid, ibanInvalid, ibanLength, digits, lookAlike, whitespace, lookupFailed, announce*…). English defaults. |
now | Date | number | Reference “today” for relative dates and past/future flags (default: the current time). |
items | ReadBackItem[] | Read-only: the echo shown in the open panel ({ field, name, type, label, raw, value, text, words, detail, warnings, was, changed, pending, spoken }); empty when closed. |
isOpen | boolean | Read-only: the panel is shown. |
mode / challenge / locale / currency / ratio / changes / onlyChanged | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
open({ submitter?, focus? }) | Shows the read-back for the current values without a submit (no mv-read-back event); focus defaults to true. Returns the items. |
close({ focus? }) | Hides the panel without submitting. |
confirm() | Same as the Confirm button: checks the challenge, emits mv-confirm and submits. Returns true when confirmed. |
echo(field | selector) | Promise of one field’s echo (formatters included), for your own UI or logs. |
refresh() | Recomputes the live echoes and the open panel after values were changed from code. |
Events
| Name | Description |
|---|---|
mv-read-back | Cancelable, before the panel opens on submit. detail: { items, form, submitter }. preventDefault() skips the read-back for this submit and lets the form go through (e.g. small amounts). |
mv-confirm | Cancelable: the user confirmed the read-back (challenge passed). detail: { items, form, submitter }. Not cancelled: the form is submitted with the original submitter (requestSubmit). preventDefault(): nothing is submitted, send it yourself. |
mv-back | The user went back instead of confirming. detail: { field (the field of the Change button, null for Go back / Escape), item }. |
Content structure
| Name | Description |
|---|---|
(content) | A <form> (or any content when for= points to a form elsewhere). The panel and a visually hidden live region are appended to the component; live echo lines are inserted after each field. |
CSS classes
| Name | Description |
|---|---|
mv-read-back-panel | The read-back (role=group): -head (-icon, -title, -text, -flag), -list of .mv-read-back-item, -challenge (-challenge-label, -challenge-input, -error), -actions (-back, -confirm). data-warn when a line is flagged. |
mv-read-back-item | One echoed value: -label, -echo (-value, -words, -detail, -warning), -edit. data-type, data-warn, data-changed, data-pending. |
mv-read-back-diff | <mark> around the changed part of an edited text or code value. |
mv-read-back-live | Live echo line under a field: -live-main (-live-icon, -live-value, -live-part), -live-warning. data-type, data-warn. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-read-back-warn | var(--mv-warning) | Color of flagged lines, warnings and the count badge (always paired with an icon and words). |
--mv-read-back-accent | var(--mv-accent) | Tint of the panel icon and the diff underline. |
--mv-read-back-diff | accent at 22% | Background of the highlighted change. |
Accessibility
The read-back is plain text, never an image or a color: when it opens on submit, focus moves to its title (a focusable heading-like paragraph labelling a role="group" panel, described by its instructions) and a polite live region reads the whole echo once (“Read back, 4 values. Amount: $12,500.00. twelve thousand five hundred dollars and 00 cents. Check: 10× your usual amount, an extra zero?…”); later edits while it is open are announced as “Read back updated”, async checks as they resolve. Account numbers are exposed to screen readers character by character (“D E 8 9, 3 7 0 4…”) instead of as big numbers, codes through their phonetic spelling. Every line has a real <button> “Change” whose accessible name includes the field (“Change Amount”): it closes the panel, focuses the exact field (the checked radio for a group), scrolls it into view and selects its text. Go back and Escape return to the first flagged field, else to the submit button; Confirm is a real button labelled with the original action (“Correct, send transfer”), aria-disabled and aria-busy while a check is pending (pressing it announces why). The challenge field has a real <label> naming what to type, aria-invalid and an error linked with aria-describedby; Enter confirms. After confirming, focus returns to the submit button. Warnings are never color-only: a warning icon, a visually hidden “Check:” prefix, words, a left bar and a count badge (“1 to double-check”); the changed part of an edited value is both tinted and underlined. Live echo lines are linked to their field with aria-describedby (read when the field is focused) and are not live regions, so typing never chatters. Reduced motion (OS or data-motion="reduce"): no entrance slide and no pending pulse. Forced colors: borders and warnings use CanvasText, diffs use Highlight.