Skip to content

OTP Field

One-time-code entry with per-character cells. md-otp-field renders length real input cells in its own shadow root, so every cell carries full ARIA. All entry — typing, paste, SMS autofill — flows through one sanitizing pipeline (whitespace-strip → transform → charset filter → clamp), and the field is form-associated via ElementInternals with the full constraint-validation API.

Live preview Open in Storybook
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field label="One-time code" supporting-text="Enter the 6-digit code we sent to your phone"></md-otp-field>

Already installed? See the Installation guide for one-time package setup (core + tokens, fonts). Each tab below shows two patterns for using md-otp-field in your project: Option A registers every AWC UI component at once (simplest), Option B imports only this component for tree-shake-friendly bundles.

<!-- ─── Option A: global registration (all components) ─── -->
<script type="module">
  import '@awc-ui/core/define';
</script>


<!-- ─── Option B: single import (tree-shake only md-otp-field) ─── -->
<script type="module">
  import '@awc-ui/core/components/md-otp-field';
</script>


<md-otp-field></md-otp-field>
  • SMS / email verification codes (validation-type="numeric", the default).
  • Recovery codes (validation-type="alphanumeric" + transform="uppercase").
  • PIN entry on a shared screen (mask).
  • Any short, fixed-length code where per-character boxes communicate progress.
SituationUse instead
Free-form text, emails, passwordsmd-text-field
Variable-length or long codes (more than ~8 characters)md-text-field with restrict
Search-as-you-typemd-search / md-autocomplete
Choosing from known optionsmd-select

length sets the cell count. group-size chunks the cells visually — a dot separator sits between groups (swap it via the separator slot or restyle ::part(separator)).

Lengths and grouping — the last row slots a custom separator into the first gap only
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field length="4" label="4-digit PIN"></md-otp-field>
<md-otp-field group-size="3" label="Grouped 6-digit code"></md-otp-field>
<md-otp-field length="8" group-size="4" label="8-character code"></md-otp-field>
<md-otp-field group-size="2" label="Custom separator"><span slot="separator">—</span></md-otp-field>

validation-type filters what the field accepts: numeric (default), alpha, alphanumeric, or none. Rejected characters never land — they fire mdInvalidInput with the raw attempted string, which is your hook for an “invalid input” shake or hint. transform="uppercase" normalizes accepted characters — a declarative attribute rather than a normalizer callback, so it is authorable from plain HTML and survives SSR.

Alphanumeric recovery code — type a lowercase letter
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field
  length="8"
  validation-type="alphanumeric"
  transform="uppercase"
  label="Recovery code"
  supporting-text="Letters are stored uppercase automatically"
  ></md-otp-field>

The virtual keyboard hint derives from validation-type (numeric shows the digit pad); override it with the inputmode attribute when needed.

mask renders the cells as password inputs for shared-screen privacy. The value itself is never reflected to a DOM attribute in any mode.

Masked cells
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field length="4" mask label="PIN" value="1234"></md-otp-field>
KeyBehavior
CharacterFills the cell, focus auto-advances (last cell keeps focus)
BackspaceClears the current cell if filled; otherwise walks back and clears the previous one
DeleteClears the current cell without moving
ArrowLeft / ArrowRightMove focus (the row is pinned LTR, so arrows never flip in RTL)
Home / EndJump to the first / last cell

Focusing a cell selects its character, so typing replaces. The value is contiguous: clearing a cell shifts later characters left, and a character typed into an empty cell beyond the fill point lands at the first empty position.

Keyboard model — click into a filled cell and walk the row
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field
  value="4926"
  label="One-time code"
  supporting-text="Click cell 2, then try Backspace, Delete, arrows, Home and End"
  ></md-otp-field>

Paste anywhere in the group replaces the whole value from position 0 — the pasted text runs through the same pipeline, and focus lands after the last filled cell. A complete paste fires mdComplete even when the pasted code equals the current value (mdInput stays silent then), so re-pasting the same code still triggers your verification handler.

autocomplete="one-time-code" sits on the first cell, and the cells carry no maxlength, so an SMS-autofill burst arrives intact and is distributed across the cells.

Paste — copy the spaced code above and paste it into any cell
copy 4 9 2 6 1 7 paste
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<!-- Paste "4 9 2 6 1 7" into any cell: whitespace is stripped, the value is
replaced from position 0, and focus lands after the last filled cell. -->
<md-otp-field label="One-time code"></md-otp-field>

supporting-text is the resting hint; error recolors the cells and swaps the line to error-text (announced via role="alert"). Set reserve-supporting-space to keep the line’s height even while it is empty, so toggling the error never shifts the layout.

Error state
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field label="Wrong code" value="492617" error error-text="That code is incorrect — try again"></md-otp-field>

md-otp-field submits value under name via ElementInternals. An empty field submits no entry at all. Form reset clears the code — one-time codes are transient.

  • required blocks submission while the field is empty (value-missing-label is the localizable message).
  • required + incomplete-label also blocks a started-but-incomplete code, with incomplete-label as the message. Without incomplete-label, a partial code passes constraint validation (validate server-side).
  • auto-submit calls form.requestSubmit() the moment the code completes — after mdComplete, so your handler runs first and can preventDefault().
Required + incomplete gating — try submitting a partial code
Verify Reset

Submit with a partial code to see validation

Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<form id="verify">
  <md-otp-field
    name="code"
    label="One-time code"
    required
    incomplete-label="Please enter the complete code."
    ></md-otp-field>

    <md-button variant="filled" type="submit">Verify</md-button>
    <md-button variant="text" type="reset">Reset</md-button>
  </form>

MethodssetFocus() focuses the first empty cell (or the last when complete), clear() empties the code, and getValidity(), checkValidity(), reportValidity(), setCustomValidity(message) mirror the native constraint-validation API (setCustomValidity('') clears a server-side rejection).

EventCancelableDetailFires
mdInputnostringEvery user-driven value change (typing, clearing, paste, autofill)
mdChangenostringCommit: on completion, and when focus leaves the whole group
mdCompleteno{ value }Every cell filled; a complete paste re-fires it even when unchanged
mdInvalidInputno{ attempted, reason }Characters rejected by the charset filter (reason: input-change / input-paste)
mdValidityChangeno{ valid, validationMessage, flags }Only when validity changes

mdInput, mdChange and mdComplete all carry the code, so the difference is not the data — it is when the value is worth acting on.

mdInput is the raw stream: it fires on every user-driven change that actually moved the value — a keystroke, a Backspace, a Delete, a paste, an autofill burst. It is the “live” signal, and it is deliberately silent when the value did not change.

mdChange is the commit. It fires once the code completes, and again when focus leaves the whole group, deduplicated against the last committed value — so a user who types, wanders off and comes back does not get a second commit for the same code.

mdComplete is the verification trigger, and it is the one with a deliberate exception: a paste of a complete code re-fires it even when the pasted code equals the current value. mdInput stays silent in that case (nothing changed), which is exactly why re-pasting a code the server just rejected still re-runs your verification handler. auto-submit hangs off this event too — form.requestSubmit() runs after mdComplete, so your handler sees the code first.

As with the platform’s own controls, none of the three fire on a programmatic change. Setting otp.value = '123456' or calling clear() emits nothing — if your own code changed the value, your own code already knows.

You want to…Listen to
Drive a live character counter or clear an error as the user typesmdInput
Persist or sync the code once the user is done with itmdChange
Call your verification APImdComplete
Re-verify a code the user pasted again after a rejectionmdCompletemdInput stays silent
Shake the field / show “digits only” on a rejected charactermdInvalidInput
Enable or disable a Verify button from validitymdValidityChange (not composed — bind it on the element)

Type a digit, then a letter (rejected), then finish the code — the log shows the order events actually fire in:

Every event, newest first — type a letter to see mdInvalidInput, complete the code to see mdComplete
Events appear here, newest first
Show code for each technology
<md-otp-field id="otp" label="One-time code" required incomplete-label="Please enter all six digits."></md-otp-field>

<script type="module">
const otp = document.getElementById('otp');

otp.addEventListener('mdInput', (e) => console.log('mdInput', e.detail));
otp.addEventListener('mdChange', (e) => console.log('mdChange', e.detail));
otp.addEventListener('mdComplete', (e) => console.log('mdComplete', e.detail.value));
otp.addEventListener('mdInvalidInput', (e) => console.log('mdInvalidInput', e.detail.attempted, e.detail.reason));
// Not composed — bind it on the element itself, never on a shadow ancestor.
otp.addEventListener('mdValidityChange', (e) => console.log('valid?', e.detail.valid));
</script>

The full verify-and-reject flow goes one step further than the preview above — validate the code server-side, show the error, then clear and refocus:

<md-otp-field id="otp" name="code" label="One-time code"></md-otp-field>

<script type="module">
const otp = document.getElementById('otp');

otp.addEventListener('mdComplete', async (e) => {
  const ok = await verify(e.detail.value); // your API call
  otp.error = !ok;
  otp.errorText = ok ? '' : 'That code is incorrect — try again';
  if (!ok) { await otp.clear(); await otp.setFocus(); }
});

otp.addEventListener('mdInvalidInput', (e) => {
  console.log('rejected:', e.detail.attempted, e.detail.reason);
});
</script>

Properties

PropertyAttributeTypeDefaultReflects
lengthlengthnumber6
valuevaluestring''
validationTypevalidation-type'numeric' | 'alpha' | 'alphanumeric' | 'none''numeric'
transformtransform'none' | 'uppercase''none'
maskmaskbooleanfalse
autoSubmitauto-submitbooleanfalse
groupSizegroup-sizenumber0
inputModeinputmodestring''
namenamestring''Yes
disableddisabledbooleanfalseYes
readOnlyreadonlybooleanfalse
requiredrequiredbooleanfalse
errorerrorbooleanfalse
errorTexterror-textstring''
supportingTextsupporting-textstring''
labellabelstring'One-time code'
cellLabelTemplatecell-label-templatestring'Character {index} of {length}'
valueMissingLabelvalue-missing-labelstring'Please enter the complete code.'
incompleteLabelincomplete-labelstring''
densitydensity0 | -1 | -2 | -3 | -40Yes
reserveSupportingSpacereserve-supporting-spacebooleanfalse

Methods

MethodParameters
setFocus()none
clear()none
getValidity()none
setCustomValidity()message: string
checkValidity()none
reportValidity()none

Slots

SlotDescription
separatorCustom separator content between cell groups (falls back to a

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-otp-field-cell-widthCell inline size (48px, tapers with density, 32px floor)
--md-otp-field-cell-heightCell block size (56px, tapers with density, 40px floor)
--md-otp-field-cell-gapGap between cells (spacing-gap-sm token)
--md-otp-field-cell-shapeCell corner radius (shape-corner-small)
--md-otp-field-outline-colorResting cell border colour
--md-otp-field-focus-colorFocused cell border + caret colour
--md-otp-field-font-sizeCell glyph size (24px, tapers with density, 16px floor)

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
separatorEach group separator wrapper
cellEvery character input
cellsThe cell row (role="group" container)
supporting-textSupporting / error text line
  • The cells sit in a role="group" named by label — keep it meaningful (“One-time code”, not “Code”).
  • Every cell has a positional aria-label from cell-label-template (Character {index} of {length} by default — the {index} / {length} placeholders are replaced per cell). Translate the template alongside your other strings.
  • The supporting line is referenced from every cell via aria-describedby (same shadow scope, so the IDREF resolves), and error text is announced via role="alert".
  • aria-invalid lands on the cells while error is set; aria-required on the first cell when required.
  • Focusing a cell selects its content, so keyboard users can overtype without clearing first.

Tab into the group below and listen: each cell announces its own position (“Character 3 of 6”) plus the shared supporting line, and the group itself announces the label. Tab moves cell by cell; the arrows move within the row.

Keyboard and screen-reader behaviour Open in Storybook
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field
  label="One-time code"
  supporting-text="Enter the 6-digit code we sent to your phone"
  required
  ></md-otp-field>

RTL — the component (supporting text, layout) follows the document direction, but the cell row is pinned LTR: codes read left-to-right in RTL locales too, which is standard practice. Arrow keys therefore behave physically and logically at once. See RTL.

<div dir="rtl">
<md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>
Only the chrome mirrors: label and supporting text jump to the right edge, while 1-2-3-4-5-6 stays left-to-right in both — click into either and type Open in Storybook
ltr rtl
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field label="One-time code" value="123456"></md-otp-field>

<div dir="rtl">
  <!-- Label and supporting text move to the right edge; the six cells do not
  move, and typing still fills them left to right. -->
  <md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>

Because the row never flips, the arrow keys need no RTL branch — the same key moves to the same character in both directions:

Arrow keys — click the first cell of either row and press ArrowRight; both move to character 2
ltr rtl
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field label="One-time code" value="1234"></md-otp-field>

<div dir="rtl">
  <!-- ArrowRight still moves to the NEXT character in both rows. -->
  <md-otp-field label="رمز لمرة واحدة" value="1234"></md-otp-field>
</div>

density is a local override of the inherited data-density. Each rung takes 4px off the cell box (48×56 at 0, down to a 32×40 floor at -4) and tapers the glyph with it — all five rungs, same code, so the taper is visible in one place:

Every rung, 0 through -4 — cells shrink 4px per rung down to the 32×40 floor Open in Storybook
0 -1 -2 -3 -4
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-1" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-2" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-3" label="One-time code" value="123456"></md-otp-field>
<md-otp-field density="-4" label="One-time code" value="123456"></md-otp-field>

The two are independent signals, so they compose without any extra wiring, and a local density rung beats the inherited data-density one:

dir=rtl with data-density=-2, and one field locally overriding to -4
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div dir="rtl" data-density="-2">
  <!-- Inherits the -2 rung. -->
  <md-otp-field label="رمز لمرة واحدة" value="123456"></md-otp-field>

  <!-- A local rung is declared on the host, so it wins over the ancestor. -->
  <md-otp-field density="-4" label="رمز لمرة واحدة" value="123456"></md-otp-field>
</div>

Densitydensity="-1…-4" locally overrides the inherited data-density rung, shrinking the cells 4px per rung (48×56 down to a 32×40 floor) and tapering the glyph size with them. See Density.

i18n — translate label, supporting-text, error-text, cell-label-template, value-missing-label and incomplete-label. All are props precisely so you can feed them from your dictionary; the component has no locale logic of its own.

The same field in English and French — keep {index} and {length} when translating the cell template Open in Storybook
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<!-- Every user-facing string is a prop — feed them from your dictionary -->
<md-otp-field
  label="Code à usage unique"
  supporting-text="Saisissez le code à 6 chiffres"
  cell-label-template="Caractère {index} sur {length}"
  value-missing-label="Veuillez saisir le code complet."
  incomplete-label="Code incomplet"
  ></md-otp-field>
Custom propertyPurposeDefault
--md-otp-field-cell-widthCell inline size48px, density-tapered, 32px floor
--md-otp-field-cell-heightCell block size56px, density-tapered, 40px floor
--md-otp-field-cell-gapGap between cells--md-sys-spacing-gap-sm (8px)
--md-otp-field-cell-shapeCell corner radius--md-sys-shape-corner-small (8px)
--md-otp-field-outline-colorResting cell border--md-sys-color-outline
--md-otp-field-focus-colorFocused border + caret--md-sys-color-primary
--md-otp-field-font-sizeCell glyph size24px, density-tapered, 16px floor
Themed instance — focus a cell Open in Storybook
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-otp-field label="Themed code" value="49" style="--md-otp-field-focus-color: var(--md-sys-color-tertiary); --md-otp-field-cell-shape: 999px;"></md-otp-field>

CSS partscells, cell, separator, supporting-text. Reach for them when a token is not enough: ::part(cell) restyles every character box, ::part(separator) restyles every gap (unlike the separator slot, which only fills the first one).

CSS parts — every cell and both separators, not just the first Open in Storybook
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<!-- group-size="2" over the default length of 6 renders TWO separators;
::part(separator) restyles both, where the slot would fill only the first. -->
<md-otp-field
  class="otp-filled"
  group-size="2"
  value="4926"
  label="One-time code"
  supporting-text="Filled cells, wider gaps, italic supporting line"
  ></md-otp-field>
.otp-filled::part(cell) {
background: var(--md-sys-color-surface-container-highest);
border-color: transparent;
}
/* Both gaps, unlike the `separator` slot — which fills only the first. */
.otp-filled::part(separator) {
inline-size: 20px;
}
.otp-filled::part(supporting-text) {
font-style: italic;
}

md-text-field · md-search · md-autocomplete · md-button

For AI Agents — md-otp-field

Two artefacts to give your AI agent so it generates correct UI with this component. The per-component spec answers "how do I use this exact tag?". The main-llm spec answers "which tag should I pick in the first place?".

Per-component

md-otp-field spec card

Identity · when to use / when NOT · decision cues · behavioural contract · do/don't · anti-patterns · full API. Paste into your agent when you're implementing with this component.

Main-LLM spec

AWC UI Operator's Manual

System-prompt preamble · decision matrix · token reference · page recipes · anti-patterns. Paste into the system prompt at the start of a piece of work.

Open spec

md-otp-field readme.md

# md-otp-field

<!-- llm:meta
tag: md-otp-field
category: text-input
status: custom
m3-guidelines: none — M3 has no OTP page
m3-derived-from: https://m3.material.io/components/text-fields/guidelines
behavior-model: contiguous value, one sanitizing pipeline, whole-value paste, declarative transform
form-associated: true
depends-on: none
used-by: none
-->

**One-time-code entry.** Renders `length` real `<input>` cells in its own
shadow root (it owns the inputs, so every cell carries full ARIA), routes all
entry — typing, paste, SMS autofill — through one sanitizing pipeline, and
participates in forms via `ElementInternals` with the full
constraint-validation API. No hidden input, no wrapper `md-text-field`.

> Setup, theming, density and i18n are configured once for the whole library —
> see [`main-llm.md`](../../../../../main-llm.md), shipped beside these manuals in
> the package's `docs/` folder. Quick start:
> `import '@awc-ui/core/define';`

---

## When to use

- SMS / email verification codes (`validation-type="numeric"`, the default).
- Recovery / backup codes (`validation-type="alphanumeric"` +
  `transform="uppercase"`).
- PIN entry on a shared screen (`mask`).
- Any short fixed-length code where per-character boxes communicate progress.

## When NOT to use

| Situation | Use instead |
|---|---|
| Free-form text, emails, passwords | `md-text-field` |
| Variable-length or long codes (> ~8 chars) | `md-text-field` with `restrict` |
| Search-as-you-type | `md-search` / `md-autocomplete` |
| Choosing from known options | `md-select` |

## Decision cues

| Need | Setting |
|---|---|
| 6-digit SMS code | defaults (`length="6"`, numeric) |
| 4-digit PIN, hidden | `length="4" mask` |
| `XXX-XXX` visual grouping | `group-size="3"` |
| Recovery code `A1B2C3D4` | `length="8" validation-type="alphanumeric" transform="uppercase"` |
| Verify the instant the code is complete | listen to `mdComplete`, or `auto-submit` inside a `<form>` |
| Partial entry must fail form validation with its own message | `required incomplete-label="…"` |

## API contract

```html
<md-otp-field
  length="6"
  validation-type="numeric|alpha|alphanumeric|none"   <!-- default: numeric -->
  transform="none|uppercase"                          <!-- default: none -->
  mask auto-submit
  group-size="0"
  inputmode=""                       <!-- override; derives from validation-type -->
  name="code" required
  incomplete-label="…" value-missing-label="…"
  disabled | readonly
  error error-text="…" supporting-text="…"
  label="One-time code"
  cell-label-template="Character {index} of {length}"
  density="-1|-2|-3|-4"              <!-- default: 0 (uncompacted) -->
  reserve-supporting-space
></md-otp-field>
```

**Slots** — `separator` (custom content between groups; falls back to a dot.
Slotted content is assigned to the FIRST gap; additional gaps repeat the
built-in dot — style `::part(separator)` when you need identical custom
content in every gap).

**Events**

| Event | Bubbles/composed | Detail | Fires |
|---|---|---|---|
| `mdInput` | yes/yes | `string` | Every user-driven value change |
| `mdChange` | yes/yes | `string` | Commit: completion, and focus leaving the group |
| `mdComplete` | yes/yes | `{ value }` | Every cell filled; a complete paste re-fires it even when unchanged |
| `mdInvalidInput` | yes/yes | `{ attempted, reason }` | Characters rejected by the charset filter |
| `mdValidityChange` | yes/**no** | `{ valid, validationMessage, flags }` | Only when validity changes; bind on the element |

**Methods** — `setFocus()` (focuses the first empty cell, or the last cell when
the code is complete), `clear()`, plus the constraint-validation set:
`getValidity()`, `setCustomValidity(message)`, `checkValidity()`,
`reportValidity()`.

**Parts** — `cells` (the `role="group"` row), `cell` (every `<input>`),
`separator` (each group gap), `supporting-text`.

### Behavioral contract worth knowing

- **Value pipeline** (every path — typing, paste, autofill, programmatic
  writes, authored attributes): strip whitespace → `transform` →
  `validation-type` charset filter → clamp to `length`. Filter rejects fire
  `mdInvalidInput` with the raw attempted string; clamping is silent.
  Normalization is **declarative**: `transform` is an attribute value, not a
  callback — a custom element attribute can't carry a function, and
  `uppercase` covers the documented use case (recovery codes).
- **The value is contiguous** — clearing a cell shifts later characters left
  (no holes), and a character typed into an empty cell beyond the fill point
  lands at the first empty position.
- **Keyboard**: typing auto-advances (last cell keeps focus); Backspace clears
  the current cell or walks back to the previous; Delete clears in place;
  Arrow keys / Home / End move focus. The cell row is pinned LTR even in RTL
  documents (codes read left-to-right everywhere), so arrows never flip.
- **Paste** anywhere replaces the whole value from position 0. A complete
  paste fires `mdComplete` even if the value didn't change (`mdInput` stays
  silent in that case) — the consumer re-runs verification either way.
- **Autofill**: `autocomplete="one-time-code"` sits on the first cell only;
  the cells carry **no `maxlength`**, so an SMS-autofill burst arrives intact
  and is distributed across cells by the pipeline.
- **Forms**: submits `value` under `name`; empty submits **no entry** (null).
  `required` + empty → `valueMissing` with `value-missing-label`. `required` +
  started-but-incomplete → invalid **only when** `incomplete-label` is
  non-empty (reported through the `valueMissing` flag with that message — the
  shared form helpers expose no `badInput` channel). Form reset **clears**
  the code (one-time codes are transient).
- `auto-submit` calls `form.requestSubmit()` (never `submit()`) **after**
  `mdComplete`, so the form's submit event and validation both run.
- `mask` renders `type="password"` cells; the value is never reflected to an
  attribute in any mode.
- **Programmatic writes are silent.** Setting `value` or calling `clear()`
  emits no `mdInput` / `mdChange` / `mdComplete`; only user-driven entry does.
  A programmatic write still runs the sanitizing pipeline, so an illegal
  character is dropped rather than stored.
- **`mdValidityChange` never fires on mount** — the first evaluation only
  primes the baseline, and a re-publish landing on the same state stays
  silent. Read the initial state with `await el.getValidity()`.
- `mdValidityChange` is **not composed**, so it does not leave the shadow root
  of a composite that embeds this field. `mdInput`, `mdChange`, `mdComplete`
  and `mdInvalidInput` are composed and do cross that boundary.
- **`length` is hardened**: a zero, negative, non-finite or fractional value
  floors to a whole number and clamps to a minimum of 1 cell.
- **`readonly` blocks every edit path** — typing, Backspace/Delete, and paste —
  while arrow/Home/End navigation and focus still work and the value is still
  submitted. `disabled` disables every cell and removes the control from
  submission and constraint validation. An ancestor `<fieldset disabled>` or
  disabled `<form>` disables the field too.
- **Focus selects the cell's character**, so a keystroke replaces rather than
  appends; clicking an already-focused cell re-selects it.
- **`group-size` slotting**: content in the `separator` slot is assigned to the
  **first** gap only (one slot element per name is filled in tree order).
  Later gaps keep the built-in dot. Style `::part(separator)` when every gap
  needs the same custom look.

---

## Do / Don't

House rules, informed by [M3 · Text fields](https://m3.material.io/components/text-fields/guidelines).

| ✅ Do | ❌ Don't |
|---|---|
| Verify on `mdComplete` | Don't poll `mdInput` for a full-length value |
| Set `validation-type` to match the code you issue | Don't leave the numeric default on an alphanumeric code |
| Use `mask` only for PINs on shared screens | Don't mask an SMS code the user just read on their phone |
| Set `name` and let the form submit the value | Don't add a hidden `<input>` to carry the code |
| Translate `label`, `cell-label-template` and the `*-label` messages | Don't ship the English defaults |
| Keep codes short (4-8 cells) | Don't build a long free-text entry out of cells |
| Pair `auto-submit` with a form that shows progress | Don't auto-submit into a page that gives no feedback |
| Use `reserve-supporting-space` when an error can appear | Don't let the first error push the layout down |

---

## Patterns

```html
<!-- 6-digit SMS code, verified the moment it completes -->
<md-otp-field id="sms" name="code" label="Verification code"
              supporting-text="We sent a code to your phone"></md-otp-field>
<script type="module">
  const el = document.getElementById('sms');
  el.addEventListener('mdComplete', (e) => {
    console.log('verify', e.detail.value);
  });
</script>
```

```html
<!-- Auto-submitting form: requestSubmit() runs after mdComplete -->
<form id="verify">
  <md-otp-field name="code" required auto-submit
                value-missing-label="Please enter the complete code."
  ></md-otp-field>
</form>
<script type="module">
  document.getElementById('verify').addEventListener('submit', (e) => {
    e.preventDefault();
    console.log(new FormData(e.target).get('code'));
  });
</script>
```

```html
<!-- 4-digit masked PIN, grouped 2-2 -->
<md-otp-field
  length="4" mask group-size="2" label="PIN"
  cell-label-template="Digit {index} of {length}"
></md-otp-field>
```

```html
<!-- Alphanumeric recovery code, upper-cased as typed -->
<md-otp-field
  length="8" group-size="4"
  validation-type="alphanumeric" transform="uppercase"
  label="Recovery code"
></md-otp-field>
```

```html
<!-- Partial entry must fail validation with its own message -->
<md-otp-field
  id="strict" name="code" required
  value-missing-label="Enter your code."
  incomplete-label="The code is 6 characters."
  reserve-supporting-space
></md-otp-field>
<script type="module">
  const el = document.getElementById('strict');
  const { valid, validationMessage } = await el.getValidity(); // silent on mount
  console.log(valid, validationMessage);
  el.addEventListener('mdValidityChange', (e) => console.log(e.detail.valid));
</script>
```

```html
<!-- Server rejected the code: show it, then clear on the next attempt -->
<md-otp-field id="checked" name="code" error
              error-text="That code is not valid"></md-otp-field>
<script type="module">
  const el = document.getElementById('checked');
  await el.setCustomValidity('That code is not valid');
  el.addEventListener('mdInput', async () => {
    el.error = false;
    await el.setCustomValidity('');
  });
</script>
```

```html
<!-- Custom separator in the first gap; ::part styles every gap -->
<md-otp-field length="6" group-size="3">
  <span slot="separator">-</span>
</md-otp-field>
```

## Anti-patterns

| ❌ Wrong | ✅ Right | Why |
|---|---|---|
| Six `md-text-field`s wired together by hand | One `md-otp-field` | You would re-implement paste distribution, autofill, contiguity and per-cell ARIA. |
| Setting `maxlength` on the cells | Nothing to set | The cells deliberately carry no `maxlength`; one would truncate an SMS-autofill burst before the pipeline can distribute it. |
| Expecting `mdInput` after `el.value = '123456'` | Listen for user entry only, or act on the write you just made | Programmatic writes are silent by design. |
| Expecting `mdInput` when the same code is re-pasted | Listen for `mdComplete` | A complete paste re-fires `mdComplete`; the value did not move, so `mdInput` stays silent. |
| `transform` set to a function | `transform="uppercase"` | It is a declarative attribute value — an attribute cannot carry a function. |
| `required` alone to reject a half-typed code | `required` + `incomplete-label="…"` | Without a message, only a fully empty field is invalid. |
| Listening for `mdValidityChange` on a shadow ancestor | Listen on the `md-otp-field` | It is `composed: false` by design. |
| Reading validity on mount from `mdValidityChange` | `await el.getValidity()` | The first evaluation only primes the baseline. |
| One `slot="separator"` element expecting it in every gap | Style `::part(separator)` | Slotted content fills the first gap only. |
| `density="0"` to escape an inherited rung | `style="--md-sys-density-scale: 0"` | There is no `density="0"` rule; rung 0 is the default and is inert. |
| Using it for a password or a long token | `md-text-field` | Cells stop communicating progress past ~8 characters. |

## Accessibility, RTL, density, i18n

**Accessibility** — the cell row is a `role="group"` named by `label`
(`"One-time code"` by default). Every cell is a real `<input>` with its own
`aria-label` built from `cell-label-template` (`{index}` is 1-based,
`{length}` is the cell count), so a screen reader announces position while
navigating. `aria-required` is set on the first cell when `required`;
`aria-invalid` is set on every cell while `error` is true. The supporting line
is wired in through `aria-describedby` and carries `role="alert"` when `error`
is set with a message. Every cell is a tab stop, and arrows / `Home` / `End`
move between them. `reportValidity()` anchors the browser bubble on the first
cell. Deliberately **not** used: `maxlength` on the cells (it would truncate
autofill bursts) and a single visually-hidden input behind the cells (the real
inputs carry the ARIA instead).

**RTL** — the cell row is pinned `dir="ltr"` at the markup level and re-asserted
in CSS, because codes read left-to-right in every locale. Arrow keys therefore
never flip direction. Surrounding content (label, supporting text) still follows
the document direction.

**Density** — set `density="-1"` … `density="-4"` for a local override, or let
the field inherit an ancestor's `data-density` rung; both drive the same
`--md-sys-density-scale` signal, which tapers cell width, cell height and the
character size. There is no `density="0"` rule — rung 0 is the uncompacted
default.

**i18n** — translate `label`, `cell-label-template`, `supporting-text`,
`error-text`, `value-missing-label` and `incomplete-label`; all six ship
English defaults. `validation-type="numeric"` accepts ASCII digits only, so a
locale that renders native numerals still needs the user to type ASCII.

## Related components

`md-text-field` · `md-number-field` · `md-search` · `md-autocomplete` ·
`md-button`

## Theming

| Custom property | Purpose | Default |
|---|---|---|
| `--md-otp-field-cell-width` | Width of one cell | density-scaled, `48px` at rung 0 |
| `--md-otp-field-cell-height` | Height of one cell | density-scaled, `56px` at rung 0 |
| `--md-otp-field-cell-gap` | Gap between cells | `--md-sys-spacing-gap-sm` (8px) |
| `--md-otp-field-cell-shape` | Cell corner radius | `--md-sys-shape-corner-small` (8px) |
| `--md-otp-field-outline-color` | Resting cell outline | `--md-sys-color-outline` |
| `--md-otp-field-focus-color` | Focused cell outline | `--md-sys-color-primary` |
| `--md-otp-field-font-size` | Character size inside a cell | density-scaled, `24px` at rung 0 |

**CSS parts** — `cells`, `cell`, `separator`, `supporting-text`.

```css
md-otp-field {
  --md-otp-field-cell-width: 40px;
  --md-otp-field-cell-shape: 4px;
  --md-otp-field-focus-color: var(--md-sys-color-tertiary);
}
md-otp-field::part(separator) {
  opacity: 0.6;
}
```

<!-- Auto Generated Below -->


## Overview

`md-otp-field` — Material Design 3 one-time-code field.

Renders `length` real `<input>` cells in its own shadow root (it OWNS its
inputs, so every cell carries full ARIA), pipes all entry — typing, paste,
SMS autofill — through one sanitizing pipeline (strip whitespace →
`transform` → `validationType` charset filter → clamp to `length`), and
participates in forms via `ElementInternals` (no hidden input).

The value is a CONTIGUOUS string: clearing a cell shifts the characters
after it left (there are no holes), and a character typed into an empty
cell beyond the fill point lands at the first empty position.

Normalization is declarative: `transform` names the casing rule as an
attribute value rather than taking a callback, so the rule is authorable
from plain HTML and survives SSR — an element attribute cannot carry a
function.

## Properties

| Property                 | Attribute                  | Description                                                                                                                                                                                                             | Type                                               | Default                             |
| ------------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------- |
| `autoSubmit`             | `auto-submit`              | Submit the owning form (`requestSubmit()`) when the code becomes complete.                                                                                                                                              | `boolean`                                          | `false`                             |
| `cellLabelTemplate`      | `cell-label-template`      | Per-cell `aria-label` template (translatable). `{index}` is 1-based, `{length}` is the cell count.                                                                                                                      | `string`                                           | `'Character {index} of {length}'`   |
| `density`                | `density`                  | Local density rung. Drives the same `--md-sys-density-scale` signal a global `data-density` ancestor sets; local wins. 0 = default, -4 = compact.                                                                       | `-1 \| -2 \| -3 \| -4 \| 0`                        | `0`                                 |
| `disabled`               | `disabled`                 | Disables every cell.                                                                                                                                                                                                    | `boolean`                                          | `false`                             |
| `error`                  | `error`                    | Error visual state — colors cells and swaps supporting text to `errorText`.                                                                                                                                             | `boolean`                                          | `false`                             |
| `errorText`              | `error-text`               | Translatable error message; replaces `supportingText` while `error` is true.                                                                                                                                            | `string`                                           | `''`                                |
| `groupSize`              | `group-size`               | Chunk the cells into groups of this size with a separator between groups. 0 = ungrouped.                                                                                                                                | `number`                                           | `0`                                 |
| `incompleteLabel`        | `incomplete-label`         | When non-empty AND `required`, a started-but-incomplete code is also invalid, reported with this message. Empty (default) = only a fully empty required field blocks submission.                                        | `string`                                           | `''`                                |
| `inputMode`              | `inputmode`                | Virtual-keyboard hint override. Empty derives from `validationType` (`numeric` → `numeric`, everything else → `text`).                                                                                                  | `string`                                           | `''`                                |
| `label`                  | `label`                    | Accessible group label (translatable). Rendered as `aria-label` on the cell group.                                                                                                                                      | `string`                                           | `'One-time code'`                   |
| `length`                 | `length`                   | Number of character cells.                                                                                                                                                                                              | `number`                                           | `6`                                 |
| `mask`                   | `mask`                     | Obscure the entered characters (cells render as `type="password"`).                                                                                                                                                     | `boolean`                                          | `false`                             |
| `name`                   | `name`                     | Form field name — ElementInternals submits the code under it.                                                                                                                                                           | `string`                                           | `''`                                |
| `readOnly`               | `readonly`                 | Value not user-changeable; cells stay focusable and arrow-navigable.                                                                                                                                                    | `boolean`                                          | `false`                             |
| `required`               | `required`                 | The code must be complete before the owning form submits.                                                                                                                                                               | `boolean`                                          | `false`                             |
| `reserveSupportingSpace` | `reserve-supporting-space` | Always reserve the supporting-text line so error text causes no layout jump.                                                                                                                                            | `boolean`                                          | `false`                             |
| `supportingText`         | `supporting-text`          | Translatable supporting text shown under the cells.                                                                                                                                                                     | `string`                                           | `''`                                |
| `transform`              | `transform`                | Case normalization as an attribute value, not a callback (attributes can't carry functions): `uppercase` upper-cases every accepted character (recovery-code entry).                                                    | `"none" \| "uppercase"`                            | `'none'`                            |
| `validationType`         | `validation-type`          | Which characters are accepted. `none` accepts anything (still whitespace-stripped).                                                                                                                                     | `"alpha" \| "alphanumeric" \| "none" \| "numeric"` | `'numeric'`                         |
| `value`                  | `value`                    | The current code. Mutable, deliberately NOT reflected — entered codes must never appear as a DOM attribute (value privacy, like md-text-field). Programmatic writes run through the same sanitizing pipeline as typing. | `string`                                           | `''`                                |
| `valueMissingLabel`      | `value-missing-label`      | Translatable constraint-validation message when `required` and empty.                                                                                                                                                   | `string`                                           | `'Please enter the complete code.'` |


## Events

| Event              | Description                                                                                                                                                                                                                                                                    | Type                                    |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- |
| `mdChange`         | Commit event: fires when the code becomes complete and when focus leaves the whole group.                                                                                                                                                                                      | `CustomEvent<string>`                   |
| `mdComplete`       | Fires when every cell is filled. A complete paste re-fires it even if the value is unchanged.                                                                                                                                                                                  | `CustomEvent<MdOtpFieldCompleteDetail>` |
| `mdInput`          | Fires on every value change from user input (typing, clearing, paste, autofill).                                                                                                                                                                                               | `CustomEvent<string>`                   |
| `mdInvalidInput`   | Fires when typed/pasted characters are rejected by the `validationType` charset.                                                                                                                                                                                               | `CustomEvent<MdOtpFieldInvalidDetail>`  |
| `mdValidityChange` | Fires when this control's validity CHANGES — never on mount, never for a re-publish landing on the same state. `composed: false` by the library convention: each control reports only for itself; `bubbles: true` still reaches `<form>`-level listeners in the same DOM tree. | `CustomEvent<MdOtpFieldValidityDetail>` |


## Methods

### `checkValidity() => Promise<boolean>`

Constraint-validation API, matching md-text-field and the native contract.

#### Returns

Type: `Promise<boolean>`



### `clear() => Promise<void>`

Clear the code programmatically (no `mdInput`/`mdChange` — programmatic writes stay silent).

#### Returns

Type: `Promise<void>`



### `getValidity() => Promise<MdOtpFieldValidityDetail>`

Current validity: boolean, message and flags. Mirrors md-text-field.

#### Returns

Type: `Promise<MdOtpFieldValidityDetail>`



### `reportValidity() => Promise<boolean>`

Like checkValidity(), but also shows the browser's validation message.

#### Returns

Type: `Promise<boolean>`



### `setCustomValidity(message: string) => Promise<void>`

App/server-side validation: a non-empty message marks the control invalid until cleared with `''`.

#### Parameters

| Name      | Type     | Description |
| --------- | -------- | ----------- |
| `message` | `string` |             |

#### Returns

Type: `Promise<void>`



### `setFocus() => Promise<void>`

Focus the first empty cell (or the last cell when the code is complete).

#### Returns

Type: `Promise<void>`




## Shadow Parts

| Part                | Description |
| ------------------- | ----------- |
| `"cell"`            |             |
| `"cells"`           |             |
| `"separator"`       |             |
| `"supporting-text"` |             |


----------------------------------------------

*Built with [StencilJS](https://stenciljs.com/)*

AWC UI Operator’s Manual

# main-llm.md — AWC UI build director

<!-- llm:meta
role: director
audience: llm
library: "@awc-ui/core"
component-count: 57
sub-component-count: 24
manual-count: 81
per-component-docs: ./packages/core/src/components/<tag>/readme.md
-->

**You are building a web app with AWC UI, a Material Design 3 web-component
library.** This file is the entry point and the only document you need before
writing UI code. Everything here is self-contained: tokens, recipes,
composition rules and the ship checklist. Per-component detail lives in
`./packages/core/src/components/<tag>/readme.md` — e.g.
[`md-button`](./packages/core/src/components/md-button/readme.md).

Your job, in order:

1. **Interview** the user (§1). Do not skip it and do not guess.
2. **Lock the configuration** their answers imply (§2) and bootstrap it (§3–§4).
3. **Route every UI need through the decision matrix** (§5) — never pick a
   component by name-similarity.
4. **Load the component's readme.md before writing a single line of its
   markup** (§6). The file is
   `./packages/core/src/components/<tag>/readme.md` — read it in full, do not
   skim it, and do not write the markup from memory of a similar library. Each
   one has a `When NOT to use`, a `Do / Don't` table sourced from
   [m3.material.io](https://m3.material.io), and an `Anti-patterns` table of
   mistakes models actually make. If you are about to use three components,
   load all three readmes first.
5. **Check what nests inside what** (§7) and start from a recipe (§8) rather
   than from a blank page.
6. **Apply the universal rules** (§9) — the API, content and accessibility
   rules every component is bound by, and the ones §10 checks you against.
7. **Run the ship checklist** (§10) before declaring done.

**Fail closed.** If you cannot satisfy a step — no component fits, a token
doesn't exist, an accessible name has nowhere to come from — say so and ask.
Do not invent an `md-*` tag, a prop, or a token. If it is not in this file or
in the component's manual, it does not exist.

---

## §1 — Interview the user

Ask these **one at a time**, in this order. Each answer closes off decisions
downstream, so don't batch them into a wall of questions. Skip a question only
if the user has already answered it unprompted.

If the user says "just pick sensible defaults", use the **bold** option and tell
them what you chose.

### 1.1 Scope and shape

1. **What is the app?** One or two sentences — domain, primary job, who uses it.
2. **What kind of surface is it?**
   - Internal tool / admin console / dashboard
   - Data-heavy CRUD application
   - Consumer-facing product
   - Marketing or content site
   - Mobile-first / PWA
3. **Which framework?** React · Angular · Vue · Svelte · **plain HTML** ·
   Next · Nuxt · SvelteKit · Astro
4. **Does it server-render?** (SSR/SSG, or **client-only SPA**)
5. **Roughly how many distinct screens**, and what are the top 3?

### 1.2 Look and feel

6. **Density** — how much information per screen?
   - `0` — **default**, comfortable, touch-friendly
   - `-1` / `-2` — compact; typical for admin consoles
   - `-3` / `-4` — ultra-compact; dense data tables, trading/ops screens
7. **Theme** — light only, dark only, or **both with a user toggle**?
   Does it follow the OS preference?
8. **Brand color** — a seed/primary color, or **stock MD3 palette**?
9. **Expressive motion** — keep **ripple and shape-morph on** (default), or turn
   them off for a flatter, more utilitarian feel?
10. **Shape language** — **rounded** (MD3 default) or squared?

### 1.3 Internationalization

11. **How many locales**, and which?
12. **Any RTL locales** (Arabic, Hebrew, Farsi, Urdu)? — this changes layout
    verification and directional-icon handling.
13. **Which i18n engine?** (i18next, vue-i18n, ngx-translate, Paraglide, custom)
    Components are engine-agnostic — you localize in the consumer layer.
14. **Locale-formatted values** — dates, numbers, currency? Which locale drives
    `Intl`?

### 1.4 Data and forms

15. **Is there significant tabular data?** How many rows, and is it
    server-paged? (Drives how you page `md-table` — it holds the state, you
    supply each page of rows.)
16. **Are there charts?** Which questions should they answer?
17. **How heavy are the forms?** Validation rules, async validation, multi-step?
18. **Rich text editing anywhere?** — ⚠️ **AWC UI has no RTE component.** If yes,
    you must integrate a third-party editor (TipTap, Lexical, Quill) and style
    it to the MD3 tokens yourself. Confirm this with the user explicitly.

### 1.5 Constraints

19. **Accessibility target** — **WCAG 2.1 AA** (what the library is tested to),
    or stricter?
20. **Browser/device support floor?**
21. **Anything already decided** you must not change — existing design system,
    router, state library, CSS approach?

---

## §2 — Map answers to configuration

| Answer | What you set |
|---|---|
| Admin console / data-heavy | `data-density="-1"` or `-2` on `<html>`; prefer `size="xs"`/`"sm"` on actions |
| Consumer / marketing | leave density alone (`0` is the default); larger button sizes (`md`/`lg`) for CTAs |
| Mobile-first | `md-navigation-bar` + `md-fab`; avoid `md-navigation-rail`, `md-transfer-list`, wide tables |
| Desktop-first | `md-navigation-rail` or `md-app-bar`; rail over bottom bar |
| Dark mode | `data-theme="dark"` on `<html>`; wire a toggle, and mirror OS via `prefers-color-scheme` |
| Both themes with toggle | persist the choice; set the attribute before first paint to avoid a flash |
| Brand color | override the `--md-sys-color-*` roles in your own stylesheet, loaded after the tokens (§4.2) |
| Flat / utilitarian | `data-ripple="off"` and `data-shape-morph="off"` on `<html>` |
| Any RTL locale | `dir="rtl"` on `<html>`; add `mirror-icon` to `md-button`s with directional glyphs; swap the glyph name yourself everywhere else (§4.5) |
| Multiple locales | build a dictionary in the consumer layer and feed component text props from it; never hardcode strings in markup |
| `Intl`-formatted values | pass a `locale` prop where a component exposes one; format everything else before it reaches the component |
| SSR | import from `@awc-ui/core/hydrate` on the server; use the client/server wrappers in `@awc-ui/react` |
| Heavy forms | components are form-associated via `ElementInternals` — use a real `<form>`, `md-button type="submit"`, and native `required` (§4.7) |
| Rich text | integrate a third-party editor; there is no `md-rich-text` |

### 2.1 Global switches — the complete set

All are attributes on `<html>` (or any ancestor; the nearest one wins).

```html
<html
  lang="en"
  dir="ltr"                  <!-- or rtl -->
  data-theme="dark"          <!-- omit for light -->
  data-density="-1"          <!-- -1 … -4; see below -->
  data-ripple="off"          <!-- default on -->
  data-shape-morph="off"     <!-- default on -->
>
```

**`data-density="0"` is inert.** No `[density="0"]` rule exists — density `0` is
simply the base values on `:root`, and a `0` rule would pin them onto every
element (reflected props write `density="0"` almost everywhere) and break global
inheritance. So the *overriding* range is `-1 … -4`. To escape an inherited rung
for one subtree, reset the scale directly:

```css
.opt-out-of-density { --md-sys-density-scale: 0; }
```

Per-component overrides beat the global one: a `density` prop, or
`ripple="off"` / `shape-morph="off"` on the element. **53 of the 57 top-level
components expose a `density` prop.** The four that don't: `md-divider`,
`md-ripple`, `md-sparkline`, `md-tabs`. (Nine sub-components also lack one —
they inherit density from the parent that owns their layout.)

---

## §3 — Install and bootstrap

```bash
npm install @awc-ui/core
```

**Register everything (recommended).** Two imports, once per app entry — one
defines every component, one loads the tokens. Both come from `@awc-ui/core`
itself, so this path needs no second package:

```ts
import { defineCustomElements } from '@awc-ui/core/loader';
import '@awc-ui/core/css/tokens.css';

defineCustomElements(window);
```

`@awc-ui/core/css/tokens.css` is the complete, self-contained token sheet —
light and dark colour roles, shape, elevation, motion, typescale, spacing and
z-index. It is the same set documented in §4.1.

**One-line alternative.** `@awc-ui/core/define` does both steps in a single
import. It loads the package's own token sheet, so it needs nothing else
installed:

```ts
import '@awc-ui/core/define'; // defines every component + loads the token sheet
```

`define` is client-only (it eval-guards `window`) and, because it imports CSS,
it needs a bundler. Never import it into a server graph.

**Per-component registration**, for size-sensitive bundles — load the token
sheet once yourself:

```ts
import '@awc-ui/core/css/tokens.css';
import '@awc-ui/core/components/md-button';
import '@awc-ui/core/components/md-text-field';
```

**Framework wrappers** — use these instead of raw elements; they handle
registration, typed props, and event binding. Below the version floor, drop to
the raw custom elements and the `loader` import above.

| Framework | Package | Requires |
|---|---|---|
| React / Next | `@awc-ui/react` | React 18+ |
| Angular | `@awc-ui/angular` (`AwcUiModule`) | Angular 17+ |
| Vue / Nuxt | `@awc-ui/vue` | Vue 3 |
| Svelte / SvelteKit | `@awc-ui/svelte` | Svelte 4+ |
| Plain HTML / Astro | `@awc-ui/core/loader` | — |

**SSR** — `@awc-ui/core/hydrate` renders Declarative Shadow DOM on the server.
`@awc-ui/react` ships matching client/server wrappers. Keep `define` and
`loader` out of the server graph; both are browser entries.

**Fonts** — components expect Roboto and Material Symbols Outlined to be
available. The library does **not** inject them. Every `icon="…"` prop renders a
Material Symbols glyph inside shadow DOM, so the font must be registered at the
**document** level (font registration crosses shadow boundaries; class rules do
not — the components declare the class rule inside their own roots):

```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap">
```

To swap the whole library to a different Material Symbols cut, set
`--md-sys-icon-font-family` on `:root` and load the matching `@font-face`.

---

## §4 — Global configuration reference

### 4.1 The token system

All AWC UI styling resolves to design tokens. **Never hardcode hex colours,
pixel radii, shadows, or font stacks** — they break theming, dark mode, density
and RTL in one stroke. Every value below is a real custom property defined by
`@awc-ui/tokens`; you may read them, and you may override them.

#### Color roles

Light is the default; `[data-theme="dark"]` swaps the palette. Every role has a
matching `on-` role for content drawn on top of it.

| Token | Light | Dark | Typical usage |
|---|---|---|---|
| `--md-sys-color-primary` | `#6750A4` | `#D0BCFF` | Filled buttons, active states |
| `--md-sys-color-on-primary` | `#FFFFFF` | `#381E72` | Text/icons on primary |
| `--md-sys-color-primary-container` | `#EADDFF` | `#4F378B` | FAB, selected segments |
| `--md-sys-color-on-primary-container` | `#21005D` | `#EADDFF` | Text on primary-container |
| `--md-sys-color-secondary` | `#625B71` | `#CCC2DC` | Secondary accents |
| `--md-sys-color-secondary-container` | `#E8DEF8` | `#4A4458` | Tonal buttons, chips |
| `--md-sys-color-tertiary` | `#7D5260` | `#EFB8C8` | Tertiary accents |
| `--md-sys-color-tertiary-container` | `#FFD8E4` | `#633B48` | Tertiary surfaces |
| `--md-sys-color-error` | `#B3261E` | `#F2B8B5` | Error / destructive |
| `--md-sys-color-on-error` | `#FFFFFF` | `#601410` | Content on error |
| `--md-sys-color-error-container` | `#F9DEDC` | `#8C1D18` | Error surfaces |
| `--md-sys-color-surface` | `#FFFBFE` | `#1C1B1F` | Page / card background |
| `--md-sys-color-on-surface` | `#1C1B1F` | `#E6E1E5` | Body text |
| `--md-sys-color-surface-variant` | `#E7E0EC` | `#49454F` | Muted fills |
| `--md-sys-color-on-surface-variant` | `#49454F` | `#CAC4D0` | Secondary text, icons |
| `--md-sys-color-surface-container-lowest` | `#FFFFFF` | `#0F0D13` | Lowest surface tier |
| `--md-sys-color-surface-container-low` | `#F7F2FA` | `#1D1B20` | Card / elevated button bg |
| `--md-sys-color-surface-container` | `#F3EDF7` | `#211F26` | Default container tier |
| `--md-sys-color-surface-container-high` | `#ECE6F0` | `#2B2930` | Dialog / menu surfaces |
| `--md-sys-color-surface-container-highest` | `#E6E0E9` | `#36343B` | Highest surface tier |
| `--md-sys-color-outline` | `#79747E` | `#938F99` | Borders (3:1 contrast) |
| `--md-sys-color-outline-variant` | `#CAC4D0` | `#49454F` | Dividers (low contrast) |
| `--md-sys-color-inverse-surface` | `#313033` | `#E6E1E5` | Snackbar, inverse chips |
| `--md-sys-color-inverse-on-surface` | `#F4EFF4` | `#313033` | Content on inverse-surface |
| `--md-sys-color-scrim` | `#000000` | `#000000` | Modal scrims |

**Semantic status roles** — beyond the baseline M3 palette, the library adds
`success`, `warning` and `info`, each with `on-`, `-container` and
`on-…-container` companions, in both themes, AA-verified. Used by `md-chip`,
`md-badge`, `md-meter`, `md-status-dot`.

| Token | Light | Dark |
|---|---|---|
| `--md-sys-color-success` | `#2E6B4F` | `#9DD5B0` |
| `--md-sys-color-success-container` | `#B8F0CE` | `#14512F` |
| `--md-sys-color-warning` | `#7A5900` | `#EFC148` |
| `--md-sys-color-warning-container` | `#FFDF9B` | `#5C4200` |
| `--md-sys-color-info` | `#38608F` | `#A2C9FE` |
| `--md-sys-color-info-container` | `#D3E4FF` | `#1E4975` |

#### Shape tokens

| Token | Value | Typical usage |
|---|---|---|
| `--md-sys-shape-corner-none` | `0px` | Square edges |
| `--md-sys-shape-corner-extra-small` | `4px` | Snackbar, small chips |
| `--md-sys-shape-corner-small` | `8px` | Cards (outlined), small surfaces |
| `--md-sys-shape-corner-medium` | `12px` | Cards (default), inputs |
| `--md-sys-shape-corner-large` | `16px` | Dialogs, large cards |
| `--md-sys-shape-corner-extra-large` | `28px` | Bottom sheets |
| `--md-sys-shape-corner-full` | `9999px` | Buttons, chips, FAB |

Partial-corner variants also exist for surfaces that meet an edge:
`--md-sys-shape-corner-extra-small-top`, `-large-top`, `-large-end`,
`-extra-large-top`. Their values are four-value `border-radius` shorthands
(e.g. `16px 16px 0px 0px`), so assign them to `border-radius`, not to a single
corner.

#### Elevation tokens

`-0` is the keyword `none`; `-1` through `-5` are complete `box-shadow` values,
and those five are **re-declared with deeper shadows under `[data-theme="dark"]`**
— so always use the token rather than copying its value.

| Token | When |
|---|---|
| `--md-sys-elevation-0` | `none` — resting cards, flat surfaces |
| `--md-sys-elevation-1` | Elevated buttons and cards at rest |
| `--md-sys-elevation-2` | Hover bump |
| `--md-sys-elevation-3` | FAB, menus, dialogs at rest |
| `--md-sys-elevation-4` – `-5` | Reserved for the most prominent surfaces |

#### Motion tokens

Durations come in four families of four: `short1…4` (50/100/150/200ms),
`medium1…4` (250/300/350/400ms), `long1…4` (450/500/550/600ms), and
`extra-long1…4` (700/800/900/1000ms).

| Token | Value | When |
|---|---|---|
| `--md-sys-motion-duration-short2` | `100ms` | Buttons, ripples, state layers |
| `--md-sys-motion-duration-medium2` | `300ms` | Dialogs, sheets, menus |
| `--md-sys-motion-duration-long2` | `500ms` | Large surface transitions |
| `--md-sys-motion-duration-extra-long1` | `700ms` | Ambient / chart entrances |
| `--md-sys-motion-easing-standard` | `cubic-bezier(0.2, 0, 0, 1)` | Small utility transitions |
| `--md-sys-motion-easing-standard-accelerate` | `cubic-bezier(0.3, 0, 1, 1)` | Exits |
| `--md-sys-motion-easing-standard-decelerate` | `cubic-bezier(0, 0, 0, 1)` | Entrances |
| `--md-sys-motion-easing-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | Expressive transitions |
| `--md-sys-motion-easing-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | Expressive exits |
| `--md-sys-motion-easing-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | Expressive entrances |
| `--md-sys-motion-easing-linear` | `linear` | Progress, indeterminate loops |

> `--md-sys-motion-easing-emphasized` deliberately equals the standard curve:
> the full M3 emphasized curve is a two-phase path interpolator that CSS cannot
> express. The accelerate and decelerate halves *are* expressible, and are the
> ones to reach for.

MD3 Expressive also ships spring pairs, each an easing plus its natural
duration: `--md-sys-motion-spring-{spatial,effects}-{fast,default,slow}-easing`
and `…-duration`. Spatial = position/size/shape; effects = colour/opacity.

#### State-layer opacities

| Token | Value |
|---|---|
| `--md-sys-state-hover-state-layer-opacity` | `0.08` |
| `--md-sys-state-focus-state-layer-opacity` | `0.12` |
| `--md-sys-state-pressed-state-layer-opacity` | `0.12` |
| `--md-sys-state-dragged-state-layer-opacity` | `0.16` |
| `--md-sys-state-disabled-container-opacity` | `0.12` |
| `--md-sys-state-disabled-content-opacity` | `0.38` |

#### Typography, spacing and layering

- **Typescale** — `--md-sys-typescale-<role>-<size>-*` for
  `display`/`headline`/`title`/`label`/`body` × `large`/`medium`/`small`. Each
  role exposes `-font-family`, `-font-size`, `-line-height`, `-font-weight`,
  `-letter-spacing`, plus a `-font` shorthand
  (e.g. `--md-sys-typescale-headline-medium-font: 400 28px/36px Roboto, sans-serif`).
  Use the shorthand on the `font` property for headings you write yourself.
- **Spacing** — a 4px scale that tightens with density:
  `--md-sys-spacing-inset-{xs,sm,md,lg,xl}` (internal padding, 4/8/12/16/24px)
  and `--md-sys-spacing-gap-{xs,sm,md,lg}` (between siblings, 4/8/12/16px), plus
  `--md-sys-spacing-row-height` (`56px` at density 0).
- **Layering** — `--md-sys-z-index-app-bar` (100), `-navigation` (200),
  `-bottom-sheet` (300), `-popup` (1000), `-dialog-scrim` (1001), `-tooltip`
  (1500), `-snackbar` (2000). Use these instead of inventing z-indexes, or your
  overlay will land under a menu.

### 4.2 Theming and rebranding

To rebrand, redefine the role variables **after** the tokens stylesheet — both
themes, or dark mode inherits your light brand colour:

```css
:root {
  --md-sys-color-primary: #00629E;
  --md-sys-color-on-primary: #FFFFFF;
  --md-sys-color-primary-container: #CFE5FF;
  --md-sys-color-on-primary-container: #001D33;
}
[data-theme="dark"] {
  --md-sys-color-primary: #9BCBFF;
  --md-sys-color-on-primary: #003354;
  --md-sys-color-primary-container: #004A78;
  --md-sys-color-on-primary-container: #CFE5FF;
}
```

Per-component knobs are `--md-<component>-*` custom properties, listed in each
manual's Theming section (`--md-button-container-color`,
`--md-card-container-shape`, …). Prefer those over `::part()`, and prefer
`::part()` over reaching into shadow internals, which are unstable and will
break on any release.

**Overriding a colour role forfeits the library's contrast testing.** Re-verify
AA (4.5:1 text, 3:1 borders and dividers) after any palette change. The Theme
Generator at <https://awc-ui.dev/theme-generator> takes a seed colour, emits both
palettes as `--md-sys-color-*` overrides, and runs the WCAG checks live — use it
rather than hand-picking container and `on-` pairs.

### 4.3 Density

`data-density` steps `-1 → -4`; each rung trims ~4px of padding and touch
target, driving `--md-sys-density-scale` and the spacing tokens. `-4` is the
floor. Set it globally, override locally with the `density` prop. Do not go
below `-2` on touch-primary surfaces — you will break the 48px target.

Two details that bite:

- **`density="0"` does nothing** — see §2 for why, and for the
  `--md-sys-density-scale: 0` escape hatch.
- **`md-table` accepts two vocabularies**: the semantic `compact` / `standard` /
  `comfortable` (row heights 36 / 52 / 60px) *and* the numeric rungs. They
  compose — `density="compact"` inside a `data-density="-2"` region condenses
  further.

### 4.4 Dark mode

Set `data-theme="dark"` on `<html>` (or any ancestor — the nearest wins). The
tokens swap automatically and inherit across every shadow boundary; no
per-component code. To mirror the OS, read `prefers-color-scheme` and write the
attribute **before first paint**, or the page flashes light.

### 4.5 RTL

Layout is written with CSS logical properties, so it flips with `dir="rtl"`
without extra work. Two things still need you:

- **Directional glyphs do not flip.** Material Symbols are not auto-mirrored.
  `md-button` has a `mirror-icon` prop that mirrors its own leading/trailing
  glyph — set it on buttons using arrows, chevrons, `send`, `reply`, and leave
  it off for `add`, `search`, `favorite`. **`mirror-icon` exists only on
  `md-button`.** Everywhere else — `md-icon-button icon="…"`, `md-list-item`
  leading/trailing icons, `md-app-bar leading-icon` — swap the glyph name
  yourself (`arrow_back` ↔ `arrow_forward`).
- **Icon props you supply yourself** — e.g. `md-transfer-list`'s
  `move-right-icon` / `move-left-icon` / `move-all-*-icon` — must be swapped by
  you when the direction flips.

Never write physical CSS (`margin-left`, `padding-right`, `left`) around these
components. Use `margin-inline-start`, `padding-inline-end`, `inset-inline-end`.

### 4.6 Internationalization

Components are **i18n-engine-agnostic by design**. Every user-visible string is
either slotted content or a prop. Localize in the consumer layer: resolve your
dictionary to plain strings, then pass them in. Do not add a translation engine
inside a component. `locale` props exist only where a component computes an
`Intl`-formatted value itself: `md-date-picker`, `md-number-field`,
`md-meter`, `md-dialog`, and the charts (`md-bar-chart`, `md-line-chart`,
`md-area-chart`, `md-pie-chart`). `md-time-picker` has no
`locale` prop — it takes `format="12h" | "24h"` instead.

Templated strings keep their placeholder tokens when translated — translate
around the braces, don't remove them:

```html
<md-transfer-list count-template="{checked} / {total} ausgewählt"></md-transfer-list>
```

Other templated props: `md-autocomplete status-template`, `md-otp-field
cell-label-template`. Default prop values are English (`Search`, `Dismiss`,
`No results`, `Move selected to target`, …) — every one of them needs
translating in a localized app.

### 4.7 Forms and validation

Fourteen components are form-associated via `ElementInternals`, so they
participate in `FormData` and constraint validation like native controls:

`md-text-field` · `md-number-field` · `md-otp-field` · `md-select` ·
`md-multi-select` · `md-autocomplete` · `md-checkbox` · `md-radio` ·
`md-switch` · `md-slider` · `md-rating` · `md-date-picker` · `md-time-picker` ·
`md-button`

Rules:

- Use a real `<form>`. `md-button type="submit"` calls `form.requestSubmit()`
  (not `submit()`), so the `submit` event fires **and** built-in constraint
  validation runs. `required` genuinely blocks submit.
  `md-button type="reset"` calls `form.reset()`.
- Give every control a `name`, or it will not appear in `FormData`.
- Do **not** add hidden `<input>`s to mirror values — that's the old pattern and
  it double-submits.
- Validity changes are announced on a `mdValidityChange` event on the control.
  Error presentation is `error` + `error-text` on the field.
- Boolean state props differ by control — `md-checkbox` uses `checked`,
  `md-switch` uses **`selected`**, `md-select-option` uses `selected`. Check the
  manual; guessing `checked` on a switch silently does nothing.

---

## §5 — Component decision matrix

Route by **need**, not by name. If the need isn't listed, find the closest row
and read that component's `When NOT to use`.

### 5.1 Actions

| Need | Use | Don't use |
|---|---|---|
| Discrete labelled action | `md-button` | `md-chip`, raw `<button>` |
| Icon-only action | `md-icon-button` | `md-button` with no label |
| A destructive action | `md-button` + `md-dialog` to confirm | an unconfirmed `filled` button |
| The single most prominent screen action (mobile) | `md-fab` | a second `filled` `md-button` |
| One prominent action that expands to several | `md-fab-menu` + `md-fab-menu-item` | a stack of FABs |
| Primary action + variants of it | `md-split-button` | button + separate menu |
| 2–5 related actions as one unit | `md-button-group` | loose adjacent buttons |
| Mutually exclusive view/mode switch | `md-segmented-button-set` + `md-segmented-button` | radio buttons, tabs |
| Overflow / contextual actions | `md-menu` + `md-menu-item` | a row of text buttons |

### 5.2 Text input

| Need | Use | Don't use |
|---|---|---|
| Any single-line or multi-line text entry | `md-text-field` | raw `<input>` |
| A number with steppers and locale formatting | `md-number-field` | `md-text-field type="number"` |
| A one-time code / PIN | `md-otp-field` | a row of text fields |
| Text entry with suggestions | `md-autocomplete` | `md-select` |
| Site/app-wide search with a results surface | `md-search` | `md-text-field` with an icon |

### 5.3 Selection

| Need | Use | Don't use |
|---|---|---|
| One of many, from a list | `md-select` + `md-select-option` | radio group over ~7 options |
| One of few (2–5), all visible | `md-radio` | `md-select` |
| Several of many | `md-multi-select` | many `md-checkbox`es |
| Several of few, all visible | `md-checkbox` | `md-multi-select` |
| Instant on/off setting | `md-switch` | `md-checkbox` |
| A value in a numeric range | `md-slider` | `md-text-field type=number` |
| Subjective score | `md-rating` | slider |
| A color | `md-color-picker` | `<input type=color>` |
| Assign a subset from a bounded pool, side by side | `md-transfer-list` | two lists + buttons |
| Filter / attribute / removable entry | `md-chip` | small buttons |
| A date | `md-date-picker` | three selects |
| A time | `md-time-picker` | text field |

### 5.4 Navigation

| Need | Use | Don't use |
|---|---|---|
| Top-level destinations, mobile | `md-navigation-bar` + `md-navigation-tab` | tabs |
| Top-level destinations, desktop | `md-navigation-rail` + `md-navigation-rail-tab` | bottom bar |
| App header: title, actions, search | `md-app-bar` | a custom `<header>` |
| A dense action strip | `md-toolbar` | app bar |
| Sibling views **within** one screen | `md-tabs` + `md-tab` + `md-tab-panels` + `md-tab-panel` | navigation bar |
| Hierarchy / where-am-I | `md-breadcrumbs` + `md-breadcrumb-item` | text links |
| A linear multi-step flow | `md-stepper` + `md-step` | tabs |
| Contextual popup actions | `md-menu` + `md-menu-item`, `md-menu-item-group`, `md-sub-menu-item` | dialog |
| Hierarchical command menu (File → Export → PDF) | `md-menu` + `md-sub-menu-item` | nested dialogs |

### 5.5 Containment and feedback

| Need | Use | Don't use |
|---|---|---|
| Group related content | `md-card` | a bare `<div>` with a border |
| Blocking decision or focused task | `md-dialog` | a new page |
| Critical error the user must acknowledge | `md-dialog` | `md-snackbar` |
| Supplementary content from the bottom (mobile) | `md-bottom-sheet` | dialog |
| Supplementary content from the side (desktop) | `md-side-sheet` | dialog |
| Brief confirmation of an action, optionally undoable | `md-snackbar` | dialog, alert |
| Explain a control on hover/focus | `md-tooltip` | a dialog or inline hint |
| Progressive disclosure of sections | `md-accordion` + `md-accordion-item` | tabs |
| Visual separation | `md-divider` | a styled `<hr>` |
| A vertical set of records | `md-list` + `md-list-item` | a table |
| Determinate/indeterminate progress | `md-progress-indicator` | spinner GIF |
| Brand-consistent page/content loading | `md-loading-indicator` | custom spinner |
| Content-shaped loading placeholder | `md-skeleton` | a spinner over the whole page |
| Count or status on an element | `md-badge` | superscript text |
| Compact status dot | `md-status-dot` | a colored emoji |
| Read-only value within a known range (quota, battery) | `md-meter` | `md-progress-indicator` |
| A person or entity image/initials | `md-avatar` | a raw `<img>` |
| Touch feedback inside a custom control | `md-ripple` | custom CSS animation |

### 5.6 Data

| Need | Use | Don't use |
|---|---|---|
| Any table | `md-table-container` wrapping `md-table`, with `-head`/`-body`/`-row`/`-cell`/`-foot` inside the table and `-toolbar`/`-pagination` beside it in the container (§7.1) | a native `<table>` |
| Sorting, selection, paging on that table | the same parts — `md-table` carries the STATE (`sort-by`, `sort-order`, `selection`, `row-offset`, `row-count`, `loading`) and emits events; you own the data and do the actual sorting/paging | expecting it to sort an array for you |
| Hierarchy / reporting lines | `md-organization-chart` | nested lists |
| Compare categories | `md-bar-chart` | pie chart |
| Trend over time | `md-line-chart` | bar chart |
| Trend with cumulative volume | `md-area-chart` | line chart |
| Parts of a whole (≤ ~6 slices) | `md-pie-chart` | bar chart |
| Inline micro-trend in a cell or card | `md-sparkline` | a full chart |

> **There is no data-driven table component.** `md-table` is composable: you
> render the rows. It tracks and announces sort, selection and pagination state
> and emits events when the user changes them, but the sorting, filtering and
> slicing of your data is yours to perform. Render rows from your own array in
> response to those events.

### 5.7 Choosing the variant

The matrix picks the component; this picks its shape. Every value below is a
real enum member — anything not listed here is not a valid value.

| Component | Prop | Values, and when |
|---|---|---|
| `md-button` | `variant` | `filled` the one primary action · `tonal` a strong secondary · `outlined` secondary, and the safe choice for a destructive action behind a confirm · `text` low emphasis, dialog Cancel · `elevated` when it sits on a busy or coloured background (default `filled`) |
| `md-button` / `md-icon-button` / `md-button-group` | `size` | `xs` `sm` `md` `lg` `xl` — default `sm`; go `md`/`lg` for consumer CTAs, `xs`/`sm` for dense admin UI |
| `md-icon-button` | `variant` | `standard` (default) · `filled` · `tonal` · `outlined` |
| `md-button-group` | `variant` | `standard` spaced · `connected` fused into one bar |
| `md-fab` | `size` | `standard` (default) · `medium` · `large` |
| `md-fab` | `variant` | `primary-container` (default) · `secondary-container` · `tertiary-container` · `surface` · `primary` · `secondary` · `tertiary` |
| `md-card` | `variant` | `filled` for a group of comparable items · `elevated` (default) for a hero or featured item · `outlined` for a settings or form section |
| `md-text-field` / `md-select` | `variant` | `outlined` for forms on a surface · `filled` for dense or tinted layouts (`md-text-field` defaults to `filled`, `md-select` to `outlined`) — pick one and use it for every field on the screen |
| `md-text-field` | `type` | any native input type: `password`, `email`, `tel`, `url`, `search`, … (default `text`) |
| `md-text-field` | `multiline` | `"auto-grow"` grows with content · `"fixed"` with `rows` for a fixed comment box · `false` (default) single line |
| `md-search` | `variant` | `contained` (default) · `divided` |
| `md-app-bar` | `variant` | `small` (default) · `medium` · `large` for a prominent headline · `search` for a bar that hosts a field |
| `md-chip` | `variant` | `assist` (default) · `filter` for toggleable facets · `input` for user-entered removable values · `suggestion` |
| `md-chip` | `appearance` | `outlined` (default) · `filled` · `elevated` |
| `md-badge` | `variant` | `small` a bare dot · `large` (default) a count |
| `md-tooltip` | `variant` | `plain` (default) a short label on an icon control · `rich` an explanatory popover that may hold a link or action |
| `md-divider` | — | **no `variant`** — use the booleans `inset`, `inset-start`, `inset-end` |
| `md-side-sheet` | `variant` | `standard` coexists with page content · `modal` overlays with a scrim |
| `md-bottom-sheet` | `variant` | `standard` (default) · `detached` floating above the edge |
| `md-dialog` | `fullscreen` | boolean — a full-screen dialog for a long mobile task |
| `md-date-picker` | `variant` | `modal-input` (default) calendar plus a typed field · `modal` calendar only · `docked` inline, anchored to the field |
| `md-time-picker` | `variant` | `dial` · `input` (default) |
| `md-progress-indicator` | `variant` | `linear` (default) · `circular`; add `indeterminate` when the total is unknown |
| `md-list-item` | `lines` | `1` · `2` · `3` — must match how much supporting text you pass |
| `md-navigation-bar` | — | 3–5 destinations. Fewer than three: use `md-tabs`. More than five: a rail or a menu |
| `md-navigation-rail` | — | 3–7 destinations; cap the overflow with `max-visible` |

### 5.8 Not in the library

Rich text editor · file upload/dropzone · calendar/scheduler view · map ·
toast stack manager (use `md-snackbar` and manage the queue yourself) ·
data grid with virtualized columns. If the user needs one, say so plainly and
integrate a third-party component styled with the MD3 tokens.

**There is no `md-grid`, no `md-data-table`, no `md-layout`, no `md-icon`.**
If a tag is not listed in §6, it does not exist — do not emit it.

---

## §6 — Component inventory

**Every component has exactly one manual, and it is the readme.md in its own
source folder:**

```
./packages/core/src/components/<tag>/readme.md
```

e.g. [`md-select`](./packages/core/src/components/md-select/readme.md),
[`md-button`](./packages/core/src/components/md-button/readme.md).

**Load that file before you use the component.** There is no second, shorter
summary to rely on — this readme is the single source, so anything you do not
read there, you do not know. Each one carries When-to-use, a Do/Don't table
from M3, copy-paste patterns, an anti-patterns table, and the theming surface.

Work through them one at a time: pick the component from the decision matrix
(§5), load its readme, write that component's markup, then move to the next.

There are 81 manuals for 57 components: 24 of them document sub-components that
are only valid inside a parent (a table cell, a tab panel, a select option).
Every sub-component manual names its parent in the first line, and §7 below
summarises the nesting.

`status` in each manual's `llm:meta` block is one of:

- **`md3-mapped`** — has a Material Design 3 guidelines page; the Do/Don't is
  sourced from it.
- **`custom`** — an addition to MD3; guidance is derived house rules.
- **`sub-component`** — only valid inside a specific parent.

| Category | Components |
|---|---|
| Actions | `md-button` `md-icon-button` `md-fab` `md-fab-menu` `md-fab-menu-item` `md-split-button` `md-button-group` `md-segmented-button` `md-segmented-button-set` |
| Text input | `md-text-field` `md-number-field` `md-otp-field` `md-autocomplete` `md-search` |
| Selection | `md-select` `md-select-option` `md-multi-select` `md-checkbox` `md-radio` `md-switch` `md-slider` `md-rating` `md-color-picker` `md-transfer-list` `md-chip` |
| Pickers | `md-date-picker` `md-time-picker` |
| Navigation | `md-app-bar` `md-toolbar` `md-navigation-bar` `md-navigation-tab` `md-navigation-rail` `md-navigation-rail-tab` `md-tabs` `md-tab` `md-tab-panels` `md-tab-panel` `md-breadcrumbs` `md-breadcrumb-item` `md-menu` `md-menu-item` `md-menu-item-group` `md-sub-menu-item` `md-stepper` `md-step` |
| Containment | `md-card` `md-dialog` `md-bottom-sheet` `md-side-sheet` `md-snackbar` `md-tooltip` `md-accordion` `md-accordion-item` `md-divider` `md-list` `md-list-item` |
| Data | `md-table` `md-table-container` `md-table-head` `md-table-body` `md-table-row` `md-table-cell` `md-table-foot` `md-table-toolbar` `md-table-pagination` `md-table-sort-label` `md-table-expand-toggle` `md-organization-chart` |
| Charts | `md-bar-chart` `md-line-chart` `md-area-chart` `md-pie-chart` `md-sparkline` |
| Status & feedback | `md-progress-indicator` `md-loading-indicator` `md-skeleton` `md-badge` `md-status-dot` `md-meter` `md-avatar` `md-ripple` |

---

## §7 — Composition rules

### 7.1 What nests inside what

A sub-component is only valid inside its parent. Putting one anywhere else
produces an unstyled, unregistered-looking element with no keyboard behaviour,
because the parent is what wires roving tabindex, ARIA ids and selection.

| Parent | Children it manages |
|---|---|
| `md-button-group` | `md-button`, `md-icon-button` |
| `md-segmented-button-set` | `md-segmented-button` |
| `md-fab-menu` | `md-fab-menu-item` (anchored to an `md-fab` via `anchor="<id>"`) |
| `md-menu` | `md-menu-item`, `md-sub-menu-item`, `md-menu-item-group` — **not** `md-divider`; separate rows with `md-menu-item`'s own `divider` (or `gap`) prop |
| `md-menu-item-group` | `md-menu-item` |
| `md-sub-menu-item` | a nested `md-menu` in `slot="submenu"` — the items go in *that* menu. There is no default slot, so anything else you nest renders nothing |
| `md-select`, `md-multi-select`, `md-autocomplete` | `md-select-option` |
| `md-list` | `md-list-item`, `md-divider` |
| `md-tabs` | `md-tab` |
| `md-tab-panels` | `md-tab-panel` (one per tab, in tab order) |
| `md-navigation-bar` | `md-navigation-tab` |
| `md-navigation-rail` | `md-navigation-rail-tab`, plus an `md-fab` in `slot="fab"` |
| `md-accordion` | `md-accordion-item` |
| `md-stepper` | `md-step` (horizontal steppers also take `slot="content"`) |
| `md-breadcrumbs` | `md-breadcrumb-item` |
| `md-table-container` | `md-table` — **it wraps the table, not the other way round** — plus `md-table-toolbar` in `slot="top"` and `md-table-pagination` in `slot="bottom"`, which sit outside the scroll region |
| `md-table` | `md-table-head`, `md-table-body`, `md-table-foot` (and bare `md-table-row`). It does **not** accept the container, toolbar or pagination |
| `md-table-head` / `-body` / `-foot` | `md-table-row` |
| `md-table-row` | `md-table-cell`, plus `md-table-expand-toggle` for an expandable row |
| `md-table-cell` | `md-table-sort-label` in a header cell |
| `md-tooltip` | **its trigger** — the tooltip wraps the element it describes in its default slot |
| `md-dialog` | body content in the default slot; `md-button` in `slot="actions"` |
| `md-bottom-sheet` / `md-side-sheet` | content in the default slot; `md-button` in `slot="actions"`; headline in `slot="headline"` |
| `md-app-bar` | `md-icon-button` in `slot="leading"` and `slot="trailing"`; `md-menu` for overflow; a field in `slot="search"` on `variant="search"` |
| `md-toolbar` | `md-icon-button`, `md-button`, `md-button-group`; an `md-fab` in `slot="fab"`; `slot="leading"` / `slot="trailing"` for the end clusters |

### 7.2 Pairs that belong together

These don't nest — they sit next to each other in a working flow. Reaching for
one usually means you want the other.

| This | Goes with | Why |
|---|---|---|
| `md-icon-button` | `md-tooltip` | The tooltip supplies the visible meaning the icon lacks |
| `md-button` (`soft-disabled`) | `md-tooltip` | Explains *why* the action is unavailable |
| `md-fab` | `md-fab-menu` | The FAB is the menu's anchor |
| `md-split-button` | `md-menu` | The trailing half opens it |
| `md-card` | `md-button`, `md-icon-button`, `md-divider` | Footer actions, corner action, internal sections |
| `md-list-item` | `md-checkbox`, `md-switch`, `md-icon-button` | Trailing controls in a selectable or settings row |
| `md-bottom-sheet` / `md-side-sheet` | `md-list` | Action menus and filter panels inside the sheet |
| `md-search` | `md-list`, `md-avatar`, `md-icon-button` | Results in the panel; account and voice/filter affordances in the trailing slot |
| `md-date-picker` | `md-time-picker` | Date + time row for booking and scheduling forms |
| `md-time-picker` | `md-button` | The trigger, when `hide-trigger` is set. The picker **is** its own dialog — don't nest it in another one |
| `md-text-field` | `md-button` | Submit / cancel in the form footer |
| `md-multi-select` / `md-autocomplete` | `md-text-field`, `md-menu`, `md-chip` | The field is the trigger (and inherits its variant, density and error state), the menu is the option surface, chips are the selected values |
| `md-autocomplete` | `md-progress-indicator` | The loading row while suggestions are fetched |
| `md-transfer-list` | `md-checkbox`, `md-text-field`, `md-icon-button` | Per-row select, per-side search, mover controls |
| `md-number-field` | `md-text-field` hooks, `md-icon-button` | It *is* an `md-text-field` internally — every `--md-text-field-*` custom property passes through — and its steppers are `md-icon-button`s |
| `md-otp-field` | `md-button` | Verify action (`auto-submit` covers the no-button flow) |
| Any chart | `md-card` | Charts belong on a dashboard tile |
| `md-line-chart` | `md-segmented-button-set` | Period picker (1W / 1M / 1Y) driving the range |
| `md-sparkline` | `md-list-item`, `md-table-cell` | Trend column beside a value |
| `md-skeleton` | `md-card`, `md-list` | Render N placeholders in the real layout while fetching |
| `md-meter` | `md-chip`, `md-card` | Same semantic status colour on both; quota and usage summaries live on a card |
| `md-color-picker` | `md-text-field`, `md-button` | Label and helper text beside it in a form; save / cancel in the surrounding popover or dialog |
| `md-rating` | `md-text-field`, `md-card` | A rating row in a review form; aggregate scores on a review card |
| `md-accordion` | `md-divider` | Optional inner dividers inside long item content |
| `md-stepper` | `md-button` | Next / back / submit adjacent to the stepper |

### 7.3 Nesting that is always wrong

- A dialog opened from inside a dialog. Use `md-stepper` inside **one**
  `md-dialog`.
- A component inside a native interactive element (`<button>`, `<a>`) — it
  nests interactive controls and destroys the accessibility tree. Use the
  component's own `href` / `type` props.
- `md-tabs` used for top-level app navigation. Tabs switch sibling views of the
  same data; destinations are `md-navigation-bar` / `md-navigation-rail`.

---

## §8 — Page recipes

Complete, runnable screens. Each renders as-is once the components are
registered and the token sheet and font links from §3 are loaded — no
placeholder identifiers, no helper functions to write.

### 8.1 Login screen

`<form>` is load-bearing: it is what makes `required` block submit and what
`type="submit"` calls `requestSubmit()` on.

```html
<main style="display: grid; place-items: center; min-block-size: 100dvh; padding: 24px;">
  <form id="login-form" style="inline-size: min(420px, 100%);">
    <md-card variant="elevated" style="padding: 32px; display: flex; flex-direction: column; gap: 20px;">
      <h1 style="margin: 0; font: var(--md-sys-typescale-headline-medium-font);">Sign in</h1>

      <md-text-field
        variant="outlined"
        label="Email"
        type="email"
        name="email"
        autocomplete="username"
        required
      ></md-text-field>

      <md-text-field
        variant="outlined"
        label="Password"
        type="password"
        name="password"
        autocomplete="current-password"
        password-toggle="internal"
        required
      ></md-text-field>

      <md-button variant="filled" type="submit" full-width>Sign in</md-button>
      <md-button variant="text" href="/forgot-password">Forgot password?</md-button>
    </md-card>
  </form>
</main>

<script type="module">
  document.getElementById('login-form').addEventListener('submit', (e) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log(data.get('email'), data.get('password'));
  });
</script>
```

### 8.2 Settings page (mobile)

Top app bar + grouped rows of instant-apply switches. `md-switch` uses
**`selected`**, not `checked`. The rows are `type="text"` (non-interactive), so
the switch is the only control — one tab stop per setting.

```html
<md-app-bar variant="small" headline="Settings">
  <md-icon-button slot="leading" icon="arrow_back" aria-label="Back"></md-icon-button>
</md-app-bar>

<main style="padding: 16px; display: flex; flex-direction: column; gap: 16px;">
  <md-card variant="outlined">
    <md-list>
      <md-list-item headline="Notifications" supporting-text="Push, email, in-app" lines="2">
        <md-switch slot="trailing" selected aria-label="Enable notifications" data-setting="notifications"></md-switch>
      </md-list-item>
      <md-divider></md-divider>
      <md-list-item headline="Dark mode" supporting-text="Match system" lines="2">
        <md-switch slot="trailing" aria-label="Enable dark mode" data-setting="dark"></md-switch>
      </md-list-item>
      <md-divider></md-divider>
      <md-list-item headline="Sync over cellular">
        <md-switch slot="trailing" selected aria-label="Sync over cellular" data-setting="cellular"></md-switch>
      </md-list-item>
    </md-list>
  </md-card>
</main>

<md-snackbar id="settings-toast" message="Setting saved"></md-snackbar>

<script type="module">
  const toast = document.getElementById('settings-toast');
  document.querySelectorAll('md-switch[data-setting]').forEach((sw) => {
    sw.addEventListener('mdChange', (e) => {
      if (sw.dataset.setting === 'dark') {
        document.documentElement.setAttribute('data-theme', e.detail.selected ? 'dark' : 'light');
      }
      toast.show();
    });
  });
</script>
```

### 8.3 Settings form (deferred save, with validation)

When settings are saved on submit rather than applied instantly, use a real
form. `required` blocks the submit; every control needs a `name` to reach
`FormData`.

```html
<form id="profile-form" style="max-inline-size: 560px; margin: 24px auto; padding: 0 16px;">
  <md-card variant="outlined" style="padding: 24px; display: flex; flex-direction: column; gap: 20px;">
    <h2 style="margin: 0; font: var(--md-sys-typescale-title-large-font);">Profile</h2>

    <md-text-field
      variant="outlined"
      label="Display name"
      name="displayName"
      required
      supporting-text="Shown on your public profile"
    ></md-text-field>

    <md-select variant="outlined" label="Language" name="language" value="en" required>
      <md-select-option value="en" label="English"></md-select-option>
      <md-select-option value="de" label="Deutsch"></md-select-option>
      <md-select-option value="ar" label="العربية"></md-select-option>
    </md-select>

    <!-- md-checkbox has NO label slot. It is a labelable, form-associated
         element: wrap it in a native <label> (which names it AND forwards
         clicks), or give the host an aria-label. Text between the tags is
         NOT rendered. -->
    <label style="display: inline-flex; align-items: center; gap: 12px; cursor: pointer;">
      <md-checkbox name="newsletter" value="yes" supporting-text="Monthly, no more"></md-checkbox>
      <span>Send me product news</span>
    </label>

    <div style="display: flex; gap: 8px; justify-content: flex-end;">
      <md-button variant="text" type="reset">Reset</md-button>
      <md-button variant="filled" type="submit">Save changes</md-button>
    </div>
  </md-card>
</form>

<md-snackbar id="saved-toast" message="Changes saved" action="Undo"></md-snackbar>

<script type="module">
  const form = document.getElementById('profile-form');
  const toast = document.getElementById('saved-toast');

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(form));
    console.log(data); // { displayName, language, newsletter? }
    toast.show();
  });

  toast.addEventListener('mdAction', () => form.reset());
</script>
```

### 8.4 Dashboard with a FAB

```html
<md-app-bar variant="medium" headline="Inbox">
  <md-icon-button slot="trailing" icon="search" aria-label="Search"></md-icon-button>
  <md-icon-button slot="trailing" icon="filter_list" aria-label="Filter"></md-icon-button>
</md-app-bar>

<main style="padding: 16px; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));">
  <md-card variant="filled" interactive style="padding: 16px;">
    <strong>Acme contract</strong>
    <p style="margin: 4px 0 0; color: var(--md-sys-color-on-surface-variant);">Renewal due in 5 days</p>
  </md-card>
  <md-card variant="filled" interactive style="padding: 16px;">
    <strong>Q4 report draft</strong>
    <p style="margin: 4px 0 0; color: var(--md-sys-color-on-surface-variant);">Shared by Alex</p>
  </md-card>
</main>

<md-fab
  icon="add"
  aria-label="New item"
  style="position: fixed; inset-block-end: 16px; inset-inline-end: 16px; z-index: var(--md-sys-z-index-navigation);"
></md-fab>
```

`inset-inline-end` (not `right`) is what keeps the FAB in the correct corner
under `dir="rtl"`.

### 8.5 Destructive confirmation dialog

`md-dialog` traps focus and returns it to the trigger on close — do not add your
own focus management. The error colouring goes through the button's own custom
properties, never a hex.

```html
<md-button id="open-delete" variant="outlined">Delete account</md-button>

<md-dialog id="confirm-delete" headline="Delete account?" icon="warning">
  <p style="margin: 0;">
    This will permanently delete your account and all associated data.
    This action cannot be undone.
  </p>
  <md-button id="cancel-delete" slot="actions" variant="text">Cancel</md-button>
  <md-button
    id="do-delete"
    slot="actions"
    variant="filled"
    style="--md-button-container-color: var(--md-sys-color-error);
           --md-button-label-color: var(--md-sys-color-on-error);"
  >Delete</md-button>
</md-dialog>

<script type="module">
  const dialog = document.getElementById('confirm-delete');
  document.getElementById('open-delete').addEventListener('mdClick', () => dialog.show());
  document.getElementById('cancel-delete').addEventListener('mdClick', () => dialog.close());
  document.getElementById('do-delete').addEventListener('mdClick', () => {
    dialog.close();
    // perform the deletion
  });
</script>
```

If you omit `slot="actions"` entirely, `md-dialog` renders its own Cancel / OK
pair (labels via `cancel-label` / `ok-label`) and closes itself.

### 8.6 Tabbed content page

`md-tab-panels` finds the nearest preceding `md-tabs` (or the one named by
`for`), follows its `mdTabChange`, and wires `aria-controls` /
`aria-labelledby` both ways. **No JavaScript is required**, and `md-tab` has no
`value` or `selected` prop — selection lives on `md-tabs` as `active-tab-index`.

```html
<md-tabs id="profile-tabs" aria-label="Profile sections" active-tab-index="0">
  <md-tab label="Posts"></md-tab>
  <md-tab label="Replies"></md-tab>
  <md-tab label="Likes" badge="3"></md-tab>
</md-tabs>

<md-tab-panels for="profile-tabs">
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Your posts appear here.</p>
  </md-tab-panel>
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Your replies appear here.</p>
  </md-tab-panel>
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Posts you liked appear here.</p>
  </md-tab-panel>
</md-tab-panels>
```

To react to the switch (lazy-loading a panel, for example), listen on the tabs:

```js
document.getElementById('profile-tabs')
  .addEventListener('mdTabChange', (e) => console.log(e.detail.index, e.detail.previousIndex));
```

### 8.7 The full recipe library

Twenty-two more complete screens live in the documentation, each with a live
demo, the full markup, framework tabs, and production notes. When the user's
request matches one of these, read that page (append the slug to
`https://awc-ui.dev/recipes/`) instead of composing from scratch:

| Recipe slug | Screen |
|---|---|
| `two-factor-verification` | OTP entry, resend countdown, backup-codes dialog |
| `kpi-overview` | Stat tiles with sparklines + charts under a range switcher |
| `data-grid-console` | Bulk-action data table: filters, selection, undo |
| `server-fleet-status` | Table of hosts with meters, sparklines, status dots |
| `release-health-drilldown` | Table row → side-sheet master-detail with charts |
| `csv-import-wizard` | Stepper: upload, map columns, validate, import |
| `role-permission-assignment` | Transfer-list permissions with confirm dialog |
| `org-chart-explorer` | Searchable org chart with side-sheet profiles |
| `moderation-queue` | Verdict controls, confidence meter, card queue |
| `checkout-wizard` | Three-step commerce checkout with validation |
| `appointment-booking` | Date + time pickers inside a stepper flow |
| `survey-nps` | Paged survey using every input control |
| `notification-preferences` | Accordion × switch matrix, quiet hours |
| `app-shell` | Responsive rail/bar chrome, SSR-ready, zero CLS |
| `inbox-shell` | Three-zone list + reading-pane layout |
| `mobile-filter-sheet` | Bottom-sheet filters with applied-chip row |
| `product-reviews` | Rating input + per-star meter breakdown |
| `order-tracking` | Shipment timeline stepper with progress meter |
| `portfolio-markets` | Finance charts + holdings table + trade ticket |
| `pricing-page` | Plan cards, billing toggle, comparison table |
| `async-feedback-patterns` | Skeleton/progress/snackbar choreography rules |
| `charts-gallery` | Every chart component, live-retheming palette |

---

## §9 — Universal do's and don'ts

Apply to every component. Component-specific rules live in each manual.

### 9.1 API and styling

| ✅ Do | ❌ Don't |
|---|---|
| Use the custom element directly — it *is* the control, with its own `href` / `type` props | Wrap it in a native `<button>` / `<a>` (§7.3) |
| Set arrays and objects as **JS properties** (`el.items = [...]`) | Pass them as HTML attributes — they won't parse |
| Theme via `--md-<component>-*` properties, then `::part()` | Reach into shadow internals or override `.md-*` classes |
| Change appearance through design tokens (§4.1) | Hardcode hex colors, px spacing, shadows, or font stacks |
| Use `--md-sys-z-index-*` for your own overlays | Invent z-indexes that land under a menu |
| Use logical CSS (`margin-inline-start`, `inset-inline-end`) | Use `margin-left`, `right`, `padding-right` |
| Set `data-density` / `data-theme` / `dir` once, globally | Set them per component unless you mean a local exception |
| Listen to the component's `md*` events | Rely on native `click` — it fires even when the component's `disabled` / `loading` guard suppressed the action |
| Check the manual for the state prop name | Assume `checked`; `md-switch` uses `selected` |
| Render the element, then call its `@Method` | Call `.show()` on an element not yet in the DOM |

Toggling components flip their own state on activation and *then* emit. On
`md-button`, `mdClick` is cancelable — `preventDefault()` on it vetoes the
toggle and any navigation. `mdChange` fires after the flip and is not
cancelable. If a controlled parent rejects a change it must revert the child
explicitly.

`md-button` has a **toggle mode**: set `toggle` and the button flips its own
`selected` on each activation and exposes `aria-pressed`. `selected` is the
state, not the switch — set it for the initial pressed state and read it back
from `mdClick`'s `detail.selected`. Setting `toggle` and then styling
"pressed" yourself, or setting `selected` without `toggle` (which emits no
`aria-pressed` at all), both produce a control that lies to assistive tech.

### 9.2 Content and hierarchy

| ✅ Do | ❌ Don't |
|---|---|
| Use sentence case for all labels | Uppercase or title-case UI text |
| Keep one high-emphasis action per screen region | Compete `filled` buttons against each other |
| Use `soft-disabled` + `md-tooltip` for contextually-unavailable actions | Use `disabled` and remove it from tab order with no explanation |
| Localize every text prop and `aria-label` | Leave default English prop values in a translated app |

**`disabled` vs `soft-disabled`.** Both render the disabled appearance, but
`disabled` also removes the control from the tab order, so a keyboard or screen
reader user can never find out *why* it is off. `soft-disabled` keeps the
control focusable and announced while still blocking activation — which is what
lets an `md-tooltip` on it explain the gate. Use it whenever the reason is
informative rather than obvious.

Nesting rules — dialog inside a dialog, a component inside a native
`<button>` / `<a>`, tabs used as app navigation — live in §7.3.

### 9.3 Accessibility contract

The library ships WCAG 2.1 AA keyboard and ARIA wiring by default. These are the
rules you must not break:

1. **Every icon-only control gets an accessible name.** `md-icon-button`,
   `md-fab`, and icon-only `md-tab` are nameless without `aria-label` (or
   `aria-labelledby`). `md-fab` will also accept `label`, and warns in the
   console when it has neither.
2. **Every form field gets a `label` prop.** `placeholder` is not a label — it
   disappears on type.
3. **Don't break the tab order.** Every interactive control must be reachable by
   Tab and operable by Enter/Space. If you add `tabindex="-1"` to anything,
   have a reason.
4. **Don't write your own focus trap.** `md-dialog`, `md-bottom-sheet` and
   `md-side-sheet` trap focus while open and restore it to the trigger on
   close — including across shadow boundaries. Adding your own fights theirs.
5. **Don't wrap components in your own live regions.** `md-snackbar` announces
   itself (`politeness="polite"` by default, `"assertive"` when the message must
   interrupt). For an error that blocks the task, use `md-dialog`.
6. **Don't disable motion yourself.** Components honour
   `prefers-reduced-motion: reduce` internally.
7. **Directional icons do not auto-flip in RTL** — see §4.5.
8. **Re-verify contrast after overriding any colour role** — the library's AA
   testing covers the shipped palette only. The Theme Generator at
   <https://awc-ui.dev/theme-generator> runs the WCAG checks live (§4.2).

---

## §10 — Before you ship

1. **Every icon-only control has an accessible name.**
2. **Keyboard-only pass**: reach and operate every control; focus is always
   visible; no traps in dialogs/menus/sheets; focus returns to the trigger.
3. **Theme pass**: light *and* dark, if both are supported — and the attribute
   is set before first paint.
4. **RTL pass**, if an RTL locale ships — check directional icons specifically,
   and grep your own CSS for physical properties.
5. **Density pass** at the configured rung — no clipped labels, touch targets
   still adequate. Remember `density="0"` is inert; use
   `--md-sys-density-scale: 0` to opt a subtree out.
6. **Forms**: every control has a `name`; `required` blocks submit; `FormData`
   contains every field; reset works.
7. **No hardcoded strings** left in markup if the app is localized — including
   default prop values like `Search`, `Dismiss`, `No results`.
8. **No hardcoded design values** — grep the diff for hex colours, `px` radii,
   `box-shadow`, and `z-index` literals.
9. **Contrast** re-verified against AA if colour roles were overridden.
10. **SSR**: if server-rendered, confirm no hydration mismatch and that content
    is present with JS disabled.
11. **No shadow-internal CSS** — only tokens, custom properties, and `::part()`.
12. **Every `md-*` tag you emitted appears in §6.** If it doesn't, it doesn't
    exist.