익스클루시브
One at a Time <mv-one-at-a-time>
오류를 한꺼번에 쏟아내는 대신 한 번에 하나씩 요청하는 우선순위 기반 폼 유효성 검사입니다.
문화적 레퍼런스
형사 콜롬보, 리처드 레빈슨 & 윌리엄 링크 (NBC) (1968, 시리즈). 경위는 볼일을 다 마친 듯 문으로 향하다가 “한 가지만 더”라며 돌아서서, 가장 중요한 질문 하나를 한 번에 하나씩 던집니다. 여기서도 폼은 보낼 준비가 된 것처럼 보이지만, 제출하는 순간 컴포넌트가 돌아서서 가장 중요한 문제 하나를 해당 필드에 고정해 보여 주고, 그다음에야 다음 문제를 보여 주며, 폼을 보낼 수 있을 때까지 남은 문제를 셉니다.
작동 방식
오류를 한꺼번에 쏟아내는 대신 한 번에 하나씩 요청하는, 우선순위 기반 폼 유효성 검사입니다. 긴 폼(온보딩, 신청서, 결제, 설정)을 감싸거나 for=로 폼을 지정하면, 폼에 novalidate가 설정되고 제출 시 모든 문제가 수집됩니다(validity와 validationMessage로 읽는 네이티브 제약 조건 검사, priority와 blocking이 있는 앱 규칙, 선택 필드에 대한 선언적 제안). 이를 순위대로 정렬하고(blocking 우선, 그다음 priority, 그다음 문서 순서, 필드당 문제 하나) 가장 중요한 하나만 보여 줍니다. 해당 필드 바로 아래에 그 필드를 가리키는 꺾쇠가 달린 차분한 안내로 삽입됩니다. “Before you continue · VAT number: Your company’s VAT number is needed to issue invoices in the EU.”, 카운터(“2 more after this (1 optional)”), 진행 점이 함께 표시되어 끝없는 반복처럼 느껴지지 않습니다. 필드는 화면에 보이도록 스크롤되고 포커스를 받으며, aria-invalid와 aria-describedby가 설정되고, 문제가 해결되는 즉시 안내가 실시간으로 “Looks good”으로 바뀝니다. Enter 또는 Continue로 다시 제출합니다. 차단 문제가 남아 있지 않으면 폼이 제출되고(mv-all-clear 후 앱 자체의 submit 핸들러 실행), 그렇지 않으면 안내가 다음 필드로 옮겨 갑니다. select, 체크박스, 라디오는 선택하면 저절로 다음으로 넘어가지만, 최종 제출에는 항상 의도적인 동작이 필요합니다. 차단하지 않는 제안은 “Skip, I’ll add it later”와 함께 마지막에 나오고, “Show everything”은 숙련된 사용자를 위해 남은 항목을 나열하며(각 항목을 누르면 해당 필드로 이동), 규칙은 어떤 필드든 읽을 수 있는 평범한 동기 함수입니다. 안내는 여러 열로 된 행에서 빠져나와 그리드를 깨뜨리지 않고, 닫힌 <details> 안에 숨은 필드는 펼쳐지며, mv-present(취소 가능)를 사용하면 앱이 마법사 단계를 열거나 자체 방식으로 문제를 보여 줄 수 있습니다.
| 카테고리 | 폼 |
|---|---|
| 유형 | Web Component (<mv-one-at-a-time>) |
| 상태 | 안정 |
| 키트 | 사용자를 놓치지 않는 폼 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, form, validation, form-validation, constraint-validation, error-summary, one-at-a-time, prioritized, focus-management, onboarding, checkout, application-form, suggestion, skippable, accessibility, novalidate, submit |
When to use
- A long onboarding, application or checkout form fails on submit and a list of ten red errors would discourage people
- Some problems matter more than others (a tax ID needed to invoice, a legal consent) and must be asked first
- Optional but valuable fields (phone, logo, team size) deserve a gentle, skippable ask right before the form is sent
- Validation messages should be written as one clear request at a time, anchored to the field, with native constraints kept
Avoid when
- The form is short (login, newsletter): inline field errors are faster to scan than a one-by-one flow → use Field instead
- The value is valid but the content probably forgot something (an attachment mentioned, a leftover placeholder) → use Forgot instead
- People need hints while filling the form in, before they ever submit it → use Insist instead
설치
node scripts/add.mjs one-at-a-time --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["one-at-a-time"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/one-at-a-time/one-at-a-time.js, components/one-at-a-time/one-at-a-time.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-one-at-a-time>
<form>
<label>Email <input name="email" type="email" required></label>
<label>VAT number <input name="vat" required data-one-at-a-time-message="Your VAT number is needed to issue invoices."></label>
<button>Continue</button>
</form>
</mv-one-at-a-time>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="oat-demo" style="width:min(100%,62rem);margin-inline:auto">
<style>
#oat-demo .oat-layout { display:grid; grid-template-columns:minmax(0,1fr) 16.5rem; gap:1.25rem; align-items:start }
#oat-demo .oat-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) }
#oat-demo .oat-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
#oat-demo .oat-head h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
#oat-demo .oat-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#oat-demo form { display:grid; gap:1.125rem; padding:1.25rem }
#oat-demo .oat-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem }
#oat-demo .oat-row[data-cols="3"] { grid-template-columns:minmax(0,2fr) minmax(0,1fr) minmax(0,1fr) }
#oat-demo .oat-foot { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; margin:.25rem -1.25rem -1.25rem; padding:.875rem 1.25rem; border-top:1px solid var(--mv-border); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#oat-demo .oat-foot .oat-note { order:-2; margin-right:auto; color:var(--mv-fg-muted); font-size:.75rem }
#oat-demo .oat-foot [formnovalidate] { order:-1 }
#oat-demo .oat-done { display:none; align-items:center; gap:.75rem; margin:1.25rem 1.25rem 0; padding:.75rem 1rem; border:1px solid color-mix(in oklab, var(--mv-success) 35%, var(--mv-border)); border-radius:var(--mv-radius-lg); background:color-mix(in oklab, var(--mv-success) 7%, var(--mv-surface)); font-size:.8125rem }
#oat-demo .oat-done[data-show] { display:flex }
#oat-demo .oat-done svg { flex:none; width:1.125rem; height:1.125rem; color:var(--mv-success) }
#oat-demo .oat-side { position:sticky; top:1rem; display:grid; gap:1rem; padding:1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface-raised); box-shadow:var(--mv-shadow-sm); font-size:.8125rem }
#oat-demo .oat-side h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.8125rem; font-weight:600 }
#oat-demo .oat-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#oat-demo .oat-queue { display:grid; gap:.375rem; margin:0; padding:0; list-style:none }
#oat-demo .oat-queue li { display:flex; align-items:baseline; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
#oat-demo .oat-queue li b { color:var(--mv-fg); font-weight:500 }
#oat-demo .oat-queue li[data-now] b { color:var(--mv-accent) }
#oat-demo .oat-queue li span { margin-left:auto; white-space:nowrap }
#oat-demo .oat-queue .oat-empty { color:var(--mv-fg-subtle) }
#oat-demo .oat-log { display:grid; gap:.25rem; margin:0; padding:0; list-style:none; font:.6875rem/1.45 var(--mv-font-mono); color:var(--mv-fg-muted) }
#oat-demo .oat-log li { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#oat-demo .oat-log li b { color:var(--mv-fg); font-weight:500 }
#oat-demo .oat-sep { height:1px; background:var(--mv-border) }
#oat-demo .oat-actions { display:flex; flex-wrap:wrap; gap:.5rem }
#oat-demo .mv-choice { font-size:.8125rem }
#oat-demo .oat-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:52rem) {
#oat-demo .oat-layout { grid-template-columns:minmax(0,1fr) }
#oat-demo .oat-side { position:static }
}
@media (max-width:34rem) {
#oat-demo .oat-row, #oat-demo .oat-row[data-cols="3"] { grid-template-columns:minmax(0,1fr) }
}
</style>
<div class="oat-layout">
<section class="oat-card" aria-labelledby="oat-title">
<div class="oat-head">
<div>
<h3 id="oat-title">Billing details</h3>
<p>Kestrel Labs workspace · Step 3 of 3</p>
</div>
<span class="mv-badge" data-variant="secondary">Pro · $240 / month</span>
</div>
<div class="oat-done" id="oat-done" role="status">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9.5"/><path d="m8 12.5 2.8 2.8L16.5 9.5"/></svg>
<span id="oat-done-text">Workspace created. The first invoice goes out on October 1, 2026.</span>
</div>
<mv-one-at-a-time id="oat">
<form id="oat-form" aria-labelledby="oat-title">
<div class="mv-field">
<label class="mv-label" for="oat-company" data-required>Company name</label>
<input class="mv-input" id="oat-company" name="company" value="Kestrel Labs GmbH" required autocomplete="organization">
</div>
<div class="mv-field">
<label class="mv-label" for="oat-email" data-required>Billing email</label>
<input class="mv-input" id="oat-email" name="email" type="email" value="dana.whitfield.example.com" required autocomplete="email" spellcheck="false">
</div>
<div class="oat-row">
<div class="mv-field">
<label class="mv-label" for="oat-country" data-required>Country</label>
<select class="mv-select" id="oat-country" name="country" required autocomplete="country-name">
<option value="">Select a country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="DE" selected>Germany</option>
<option value="FR">France</option>
<option value="NL">Netherlands</option>
<option value="GB">United Kingdom</option>
<option value="JP">Japan</option>
</select>
</div>
<div class="mv-field">
<label class="mv-label" for="oat-vat">VAT number</label>
<input class="mv-input" id="oat-vat" name="vat" placeholder="DE123456789" autocomplete="off" spellcheck="false">
</div>
</div>
<div class="oat-row" data-cols="3">
<div class="mv-field">
<label class="mv-label" for="oat-street" data-required>Street address</label>
<input class="mv-input" id="oat-street" name="street" value="Torstraße 128" required autocomplete="street-address">
</div>
<div class="mv-field">
<label class="mv-label" for="oat-zip" data-required>Postal code</label>
<input class="mv-input" id="oat-zip" name="zip" value="10119" required pattern="[0-9A-Za-z \-]{3,10}" autocomplete="postal-code" data-one-at-a-time-message="Use the postal code printed on your company registration.">
</div>
<div class="mv-field">
<label class="mv-label" for="oat-city" data-required>City</label>
<input class="mv-input" id="oat-city" name="city" value="Berlin" required autocomplete="address-level2">
</div>
</div>
<div class="mv-field">
<label class="mv-label" for="oat-phone" data-optional="optional">Phone</label>
<input class="mv-input" id="oat-phone" name="phone" type="tel" placeholder="+49 30 1234567" autocomplete="tel"
data-one-at-a-time-suggest="Your onboarding specialist can call you to set up SSO and invite your team.">
</div>
<label class="mv-choice">
<input type="checkbox" class="mv-checkbox" id="oat-terms" name="terms" required
data-one-at-a-time-label="Terms of Service"
data-one-at-a-time-message="Accept the Terms of Service and the Data Processing Agreement to create the workspace.">
I agree to the Terms of Service and the Data Processing Agreement
</label>
<div class="oat-foot">
<span class="oat-note">You can change this later in Settings.</span>
<!-- The primary button comes first in the DOM: it is the one Enter submits with. -->
<button type="submit" class="mv-button">Create workspace</button>
<button type="submit" class="mv-button" data-variant="ghost" formnovalidate>Save draft</button>
</div>
</form>
</mv-one-at-a-time>
</section>
<aside class="oat-side" aria-label="Validation monitor">
<h4>Still to check <span class="mv-badge" data-variant="secondary" data-shape="pill" id="oat-count">-</span></h4>
<ol class="oat-queue" id="oat-queue"><li class="oat-empty">Nothing yet</li></ol>
<div class="oat-sep"></div>
<div style="display:grid;gap:.5rem">
<span class="oat-k">Events</span>
<ol class="oat-log" id="oat-log"><li>Press <b>Create workspace</b></li></ol>
</div>
<div class="oat-sep"></div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="oat-auto" checked>
<span class="mv-choice-text"><span class="mv-choice-title">Move on after a choice</span></span>
</label>
<div class="oat-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="oat-all">Show everything</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="oat-reset">Start over</button>
</div>
</aside>
</div>
<p class="oat-hint">Fix the field in the prompt, then press Enter or Continue · Esc hides the prompt · the VAT rule only applies to EU countries</p>
<script type="module">
const oat = document.getElementById("oat");
const form = document.getElementById("oat-form");
const $ = (id) => document.getElementById(id);
const EU = new Set(["DE", "FR", "NL"]);
// App rules: a priority above the native checks, because invoices cannot be issued without it.
oat.rules = [
{
id: "vat",
field: "vat",
priority: 10,
message: "Your company’s VAT number is needed to issue invoices in the EU.",
validate: (value, { get }) => !EU.has(get("country")) || /^[A-Z]{2}[0-9A-Z]{8,12}$/.test(value.replace(/\s+/g, "").toUpperCase()) || (value.trim() ? "That doesn’t look like an EU VAT number (e.g. DE123456789)." : false),
},
];
await customElements.whenDefined("mv-one-at-a-time");
let fresh = true;
const log = (name, text) => {
const list = $("oat-log");
if (fresh) { list.replaceChildren(); fresh = false; }
const li = document.createElement("li");
const b = document.createElement("b");
b.textContent = name;
li.append(b, ` ${text}`);
list.prepend(li);
while (list.children.length > 5) list.lastElementChild.remove();
};
const renderQueue = () => {
const issues = oat.issues;
const q = $("oat-queue");
$("oat-count").textContent = issues.length ? `${issues.length} left` : "clear";
$("oat-count").dataset.variant = issues.length ? "secondary" : "success";
if (!issues.length) {
q.replaceChildren(Object.assign(document.createElement("li"), { className: "oat-empty", textContent: "Nothing left to check" }));
return;
}
q.replaceChildren(...issues.map((i, n) => {
const li = document.createElement("li");
if (n === 0) li.dataset.now = "";
const b = document.createElement("b");
b.textContent = i.label;
const s = document.createElement("span");
s.textContent = n === 0 ? "asking" : i.blocking ? "needed" : "optional";
li.append(b, s);
return li;
}));
};
oat.addEventListener("mv-present", (e) => {
log("mv-present", `${e.detail.issue.label} · ${e.detail.remaining} more`);
queueMicrotask(renderQueue);
});
oat.addEventListener("mv-skip", (e) => log("mv-skip", e.detail.issue.label));
oat.addEventListener("mv-all-clear", (e) => {
log("mv-all-clear", e.detail.skipped.length ? `${e.detail.skipped.length} skipped` : "submitting");
queueMicrotask(renderQueue);
});
form.addEventListener("submit", (e) => {
e.preventDefault(); // demo: no real request
if (e.submitter?.formNoValidate) { log("submit", "draft saved, not checked"); return; }
$("oat-done").dataset.show = "";
log("submit", "workspace created");
});
$("oat-auto").addEventListener("change", (e) => { oat.manual = !e.target.checked; });
$("oat-all").addEventListener("click", () => oat.showAll());
$("oat-reset").addEventListener("click", () => {
// A real reset also clears the browser's “user interacted” flags (and the component's state).
form.reset();
delete $("oat-done").dataset.show;
fresh = true;
log("reset", "form restored");
setTimeout(() => { oat.present({ focus: false, scroll: false }); renderQueue(); });
});
// Show the mechanic right away, without moving focus or scrolling the page.
oat.present({ focus: false, scroll: false });
renderQueue();
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
for | id | Checks the form with this id instead of the form inside (or around) the element. Without it, the first <form> inside is used, then the closest ancestor form; a form rendered later is picked up when it appears. | |
manual | boolean | false | Turns off moving on by itself after a choice. By default, once a select, checkbox, radio, file, range or color field (or one marked data-one-at-a-time-advance) fixes the current issue, the next one is presented after about half a second; the last issue never submits on its own either way. |
data-state | asking | Set on the element while a prompt is open (styleable). | |
data-one-at-a-time-priority | number | 0 | On a field: rank of its native issue or suggestion. Higher is asked first; blocking issues always come before suggestions, ties follow document order. |
data-one-at-a-time-message | string | On a field: request shown when it fails native validation, instead of the browser's validationMessage (write it as one clear ask). | |
data-one-at-a-time-label | string | On a field: name shown as the prompt title (default: aria-labelledby, aria-label, <label> text without its controls, the legend of a radio group, placeholder, then name). | |
data-one-at-a-time-suggest | string | On an optional field: when it is empty (unchecked, no file…) it becomes a non-blocking suggestion with this text, asked after every blocking issue and skippable. | |
data-one-at-a-time-anchor | marker attribute | On a wrapper around a field: the prompt is inserted after it (default: .mv-field, the fieldset of a checkbox or radio group, a wrapping <label>, else the field; the component then climbs out of any grid or flex row shared with other fields). | |
data-one-at-a-time-ignore | marker attribute | On a field or a container: excluded from the checks (native and rules). | |
data-one-at-a-time-skip | marker attribute | On a submit button: submits without any check, like formnovalidate (which is honored too), e.g. Save draft. | |
data-one-at-a-time-advance | marker attribute | On a field (e.g. a custom picker): a committed change that fixes the current issue moves on by itself, like a select. | |
data-one-at-a-time | blocking | suggestion | Set by the component on the field currently asked about (styleable), removed afterwards. |
Properties
| Name | 유형 | Description |
|---|---|---|
rules | Array<{ id?, field?, validate(value, ctx), message?, priority?, blocking?, label? }> | App rules. field: a name, a selector or an element of the form (omit it for a form-level rule anchored above the submit button; a rule whose field is not in the DOM is skipped). validate(value, { field, form, get(name) }) returns false or a string (the message) when there is a problem, anything else when it is fine; value is the string, checked state, radio group value, array (multi-select) or FileList. message: string or (ctx) => string. priority: number (default 0). blocking: default true; false makes it a skippable suggestion. A throwing rule never blocks. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (keys: eyebrow, resolved, optional, counter ({n}), counterOptional ({n}, {k}), last, continue, submit, skip, showAll ({n}), hideAll, listLabel, current, announce ({label}, {message}, {counter}), announceResolved ({label}, {action}), announceStill ({label}, {message})). English defaults. |
form | HTMLFormElement | null | The form being checked (read-only). |
current | { id, field, label, message, blocking, priority, source } | null | The issue being asked about (read-only). source: "native" | "rule" | "suggestion". |
issues | Array<issue> | Every issue left at the last evaluation, ranked, the one asked about first (read-only). |
skipped | string[] | Ids of the suggestions skipped for this form session (read-only). Cleared by reset() and on form reset. |
manual | boolean | Mirrors the attribute. |
Methods
| Name | Description |
|---|---|
check() | Evaluates the form without presenting anything and returns the ranked issues (skipped suggestions left out). |
present({ focus?, scroll? }) | Asks about the most important issue now, without a submit (a Review step, a server error you mapped to a rule, a demo). Both options default to true; with focus: false nothing is announced either. Returns the issue presented, or null when all is clear. |
showAll() | Opens the “Show everything” list in the prompt (presenting first if needed). Returns false when there is nothing to show. |
dismiss() | Hides the prompt and restores the field's ARIA; the next submit starts over. Escape does the same from the field or the prompt. |
reset() | Forgets skipped suggestions and hides the prompt (done automatically on the form's reset event). |
Events
| Name | Description |
|---|---|
mv-present | Cancelable, before an issue is presented. detail: { field (null for a form-level rule), remaining (issues left after this one), issue, issues (ranked), submitter }. Open the wizard step or tab that holds the field here (focus waits a frame for hidden fields); preventDefault() keeps the submit held but lets you present the issue your own way (no prompt, no ARIA changes). |
mv-skip | A suggestion was skipped (“Skip, I’ll add it later”, or submitting again past it). detail: { field, id, issue }. |
mv-all-clear | Nothing blocking and no suggestion left: emitted right before the submission goes on to the app's own submit handlers. detail: { form, submitter, skipped }. |
Content structure
| Name | Description |
|---|---|
(content) | A <form> (or nothing, with for=). The prompt is inserted inside the form, after the anchor of the field, and removed afterwards; two visually hidden live regions are appended to the element. |
CSS classes
| Name | Description |
|---|---|
mv-one-at-a-time-prompt | Inserted wrapper (role=group, labelled by its eyebrow and title). data-kind="blocking | suggestion", data-side="below | above" (above for form-level rules), data-resolved once the issue is fixed. Spans every grid column. |
mv-one-at-a-time-card | The visual card with its caret (--_caret-x, measured). Parts: -head, -icon, -eyebrow, -tag (“Optional”), -count, -dots (i[data-state=done|current|todo]), -title, -message, -actions, -continue, -skip, -all-toggle. |
mv-one-at-a-time-list / -item | “Show everything” list: one button per issue (-item-index, -item-label, -item-message), aria-current on the one asked about. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-one-at-a-time-color | var(--mv-accent) | Tint of a blocking prompt: border, caret, icon, current dot. Deliberately not the danger color: it is a request, not a scolding. |
--mv-one-at-a-time-suggest-color | var(--mv-info) | Tint of a suggestion prompt. |
--mv-one-at-a-time-resolved-color | var(--mv-success) | Tint once the current issue is fixed, and of the done dots. |
Accessibility
The prompt is a labelled group (eyebrow + field name) inserted right after the field in the DOM, so reading and Tab order stay natural: field, Continue (or Skip), Show everything, next field. The field asked about gets aria-invalid="true" (blocking issues only, removed live as soon as it is fixed, the previous value is restored afterwards) and the prompt's message is added to its aria-describedby; for a radio group every radio is wired. Focus moves to the field (the checked radio or the first one, the first focusable part of a form-associated custom element, or Continue for a form-level rule), with preventScroll, while the component scrolls the field and its prompt into view together (instant under reduced motion). Each issue is announced once in an assertive live region, a beat after focus moves: “Before you continue: VAT number. Your company’s VAT number is needed to issue invoices in the EU. 2 more after this.”; fixing it is announced politely once (“VAT number looks good. Press Enter or Continue to continue.”), and submitting again while it is still unanswered keeps focus on it with a polite “Still needed: …” and a soft ring (no shake). Everything works from the keyboard: Enter in a field submits again, Escape from the field or the prompt hides it, the “Show everything” toggle is a real button with aria-expanded and aria-controls, and its list items are buttons (aria-current on the one being asked) that move focus to their field. State is never color-only: an icon (a turn-back arrow, a bulb for suggestions, a check once fixed), the words “Before you continue” / “Looks good”, an “Optional” tag and a text counter carry it; the progress dots are aria-hidden. The final submission always takes a deliberate action (Enter, Continue or the submit button), never a checkbox tick. Reduced motion (OS or data-motion="reduce"): no height, slide or ring animation. Forced colors: system colors for the card, caret, icon and current item.