独家
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 个字符、一个随机字符或整个值;实时模式会在输入时在每个字段下方显示相同的复述。自定义或异步格式化器(例如用你的银行 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/marvelous使用 Marvelous 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.