<!-- GENERATED, do not hand-edit. Source: component yaml SoT
     (screenReader/behavioral/fulfills/disambiguation/composes/a2ui.allowedChildren).
     Regenerate: `npm run docs:factory-behavior-index`. -->

# Component behavior index

One `##` section per `component.md`-eligible component (screenReader +
behavioral both authored, same eligibility test `component.md` itself
uses). **Grep this file by tag, never load it whole**
(`grep -A 20 '^## `<tag>`' component-behavior-index.md`); at ~130 sections
this file is a reading aid, not a document meant to be read end to end
(LLD-0022 §C1).
## `<a2ui-root>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

_None._

### Screen-reader spec

The runtime owns the rendered children. The host does not add a live region
or announce unresolved component types; it dispatches `a2ui-error` as a
JavaScript event. Consumers that need an accessible error announcement must
present it through their own UI. Focus and keyboard behavior belong to the
rendered components.

### Behavioral spec

Callers using `process(message)` or `processAll(messages)` own the document
boundary. Before starting a new document through either method, call `reset()`.
Reset clears the rendered surfaces and the set of already-reported unresolved
component types; it is a document reset, not just an error-counter reset.

Within that boundary, each unresolved component type emits `a2ui-error` once,
with an Error in `detail.error` naming the type. Rendering continues for types
that resolve. Neither `process()` nor `processAll()` starts a new deduplication
window. Without `reset()`, encountering the same unresolved type in a later
document suppresses its error event even though it belongs to a new document.

A new stream connection or `replaceDoc()` call starts a fresh unresolved-type
reporting window automatically. Setting `doc` delegates to `replaceDoc()`.

## `<accordion-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** `<accordion-item-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tree-ui` | For arbitrary nesting depth, tree-ui is the right fit, since accordion's zero-or-multi-open model only handles one flat level of sections. |
| `tabs-ui` | For a view switcher where exactly one panel is always visible, tabs-ui is the right fit; accordion supports zero-open and multi-open, tabs never does. |

### Screen-reader spec

Each auto-stamped `[slot="header"]` gets `role="button"`, `tabindex="0"`, and an `aria-expanded` attribute kept in sync on every render() pass as [open] toggles, so a screen reader announces the current disclosure state after every interaction, not just on first paint. Headers sit in normal Tab order, one stop per item, in DOM order; Enter and Space both route through the same `header.click()` path a pointer click uses, so keyboard and pointer users trigger identical behavior. A consumer-authored `[slot="header"]` is left entirely alone: no `role`/`tabindex`/`aria-expanded` is stamped onto it (only the caret is restored, FN-1), so its accessibility contract is the consumer's own from that point forward. There is no `aria-controls` linking a header to its `[slot="body"]` panel and no `role="region"` on the body: the header/panel relationship is DOM adjacency only, with no explicit ARIA cross-reference.

### Behavioral spec

Single-open is the default: opening one item force-closes every other currently-open item in the same accordion-ui host (`#onItemToggle` walks all `accordion-item-ui` children and closes any other than the one that just opened). This coordination only fires when [multiple] is unset: with [multiple] set, items toggle fully independently and the host never intervenes. A click landing inside a `[slot="action"]`/`[slot="actions"]` region, or on an element carrying `data-no-toggle`, is explicitly exempt from the toggle handler, so a consumer can place action buttons inside a custom header without triggering expand/collapse. There is no loading, error, or empty state: content is always synchronously present in the light DOM, and an item with no children simply renders an empty body when opened. [variant="contained"] is a pure CSS re-point (surface, border, radius, an open-state divider) with no JS-level state behind it: an individual item can opt back to `variant="flat"` even when its host accordion is contained.

## `<action-list-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** `<action-item-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `menu-ui` | Use menu-ui instead when the actions should be hidden behind a trigger and rendered in a popover; action-list-ui is always visible inline. |
| `list-ui` | Use list-ui instead when the items are passive display data with no click/keyboard activation semantics at all. |

### Screen-reader spec

The host stamps `role="list"` and each `action-item-ui` stamps `role="listitem"` when the consumer hasn't already set an explicit role (adopt-or-stamp, gh#753): this is a passive list role, not `listbox`/`menu`, even though items are click- and keyboard-activatable; a screen reader announces item count/position via the list semantics, not a selection or menu model. Every enabled item carries `tabindex="0"` individually (not a roving-tabindex single stop): Tab moves through every item in DOM order, one stop each, while ArrowUp/ArrowDown/Home/End move focus WITHOUT changing tab order, wrapping at both ends (`(idx + length) % length`). A [disabled] item gets `aria-disabled="true"` and `tabindex="-1"`, removing it from both Tab order and arrow-key cycling (`#items()` filters `:not([disabled])` before computing navigation targets). Enter and Space on a focused item click() it, routing through the same `action` event path a pointer click uses.

### Behavioral spec

There is no selection state: action-list-ui is a fire-and-forget command list, not a single/multi-select control; clicking or keyboard-activating an item dispatches one `action` CustomEvent ({ value, text, item }) and the list itself retains no memory of which item was last triggered. [icon]/[text]/[description] each stamp their own light-DOM child (`icon-ui`/`span[slot=text]`/`span[slot=subtitle]`) only when the consumer hasn't already authored that slot: every stamp is dataset-marked (`data-action-item-stamped`) so render() only ever updates or removes elements the component itself created, never a consumer-authored slot child. Setting [description] to empty after it was non-empty removes the stamped subtitle element entirely rather than leaving an empty node. There is no loading, error, or empty state of its own: items are always synchronously present in the light DOM, and an action-list-ui with zero action-item-ui children simply renders an empty list with no items to navigate.

## `<adia-mark-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `icon-ui` | adia-mark-ui is not a generic icon, it has no [name] prop, always renders the same five-path wordmark SVG, and is never themeable to another color (see anti_patterns). |

### Screen-reader spec

`connected()` stamps `role="img"` and, unless the consumer already set an explicit `aria-label`, `aria-label="Adia"`, so the mark always announces as a single named image, never as its five constituent SVG paths. The SVG root itself carries `aria-hidden="true"`, keeping the raw path geometry out of the accessibility tree entirely; the host-element role/label pair is the sole accessible surface.

### Behavioral spec

Fully static: `static template` returns identical markup on every render regardless of [size] or [inline], since neither prop is interpolated into the template (both are surfaced purely via CSS through the reflected attribute). There is no press/focus/keyboard handling, no loading/error/empty state, and no async lifecycle: `stamp()`'s first-mount `replaceChildren` only ever regenerates the same template-owned SVG content, which is SSR-safe by construction (gh#284's "template content never depends on light-DOM children" shape, same as check-ui/switch-ui but with zero interpolated values). Background and wordmark fills are always the inverse-surface/ inverse-foreground token pair: there is no variant/tone/color prop to diverge from that pairing.

## `<adia-wordmark-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `adia-mark-ui` | Use adia-mark-ui instead for the square icon/tile use (app-switcher slot, favicon-style avatar) with the fixed inverse-on-inverse background pairing; the two are not interchangeable, wordmark has no background of its own. |

### Screen-reader spec

`connected()` stamps `role="img"` and, unless the consumer already set an explicit `aria-label`, `aria-label="Adia"`: identical contract to `adia-mark-ui`. The SVG root carries `aria-hidden="true"`, so the five logotype paths never enter the accessibility tree; the host role/label pair is the sole accessible surface, and it announces the same name regardless of [size]/[inline].

### Behavioral spec

Fully static: `static template` returns identical markup on every render; neither [size] nor [inline] is interpolated into the SVG markup, both surface purely via CSS through the reflected attribute. No press/focus/keyboard handling, no loading/error/empty state, no async lifecycle: `stamp()`'s first-mount `replaceChildren` only ever regenerates the same template-owned content (gh#284's SSR-safe "template content never depends on light-DOM children" shape). Unlike `adia-mark-ui`'s fixed inverse-on-inverse pairing, the wordmark fill defaults to the current scheme's on-surface foreground token (`--a-fg-strong`, gh#390): it tracks scheme changes but has no variant/tone prop to diverge from that single foreground token.

## `<agent-artifact-ui>`

**Composes:** `<icon-ui>`, `<badge-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `code-ui` | Skip agent-artifact-ui's header chrome for a bare code block that needs no title, kind badge, or collapse affordance; the header is pure overhead there. |
| `alert-ui` | [tone] only recolors the container border and the kind badge; it is not alert-ui's severity+role pairing and should not stand in for a real alert when content needs to interrupt a screen reader. |
| `card-ui` | card-ui is the generic container with no header/collapse contract at all; reach for it instead when there's no title, kind, or action row to show. |

### Screen-reader spec

The header built in `#build()` is `role="button"` `tabindex="0"` with `aria-expanded` set to the inverse of [collapsed], kept in sync on every render (`render()` re-sets `aria-expanded` from the live `collapsed` value). The header carries no explicit `aria-label`: its accessible name is whatever a screen reader composes from its content: the [title] text span (hidden via the DOM `hidden` property when title is empty) and the kind badge-ui, which self-labels via its own `aria-label` mirrored from its `text` attribute (the uppercased [kind] value): while the leading icon and the caret icon are both `aria-hidden="true"` (icon-ui's own default when no `label` attribute is set, and neither `#iconEl` nor `#caretEl` ever receives one here). Toggling relies entirely on that native `aria-expanded` state change on a `role="button"` element: there is no separate live region announcing expand/collapse. Slotted action buttons living in the header's actions cluster keep their own independent accessible names; `#onHeaderClick`/`#onHeaderKey` explicitly skip toggling when the event target is inside `[slot="primary"], [slot="secondary"]`, so they don't inherit the header's button semantics. The body is a plain unlabeled `<div>`; setting it `hidden` when [collapsed] removes it from the accessibility tree entirely, not just visually.

### Behavioral spec

`#build()` runs once from `connected()` and partitions the light-DOM children by logical slot (primary, secondary, or body), walking recursively into any child whose `role="presentation"` or `style.display === "contents"` (the template engine's transparent wrapper span around interpolated children) so wrapped action buttons still reach the header instead of being stranded in the body (the wrapper-trap class, FB-92/96/98). Captured secondary buttons are appended to the actions cluster BEFORE primary buttons, so visual/DOM order is secondary-then-primary regardless of authoring order. Clicking the header (outside `[slot="primary"], [slot="secondary"]`) flips [collapsed], dispatches a bubbling `artifact-toggle` event with `{ collapsed }` reflecting the NEW state, then calls `render()` directly in addition to the framework's own property-driven re-render. Space/Enter on the header (`#onHeaderKey`, same target exclusion) calls `preventDefault()` and re-enters the same click handler: no separate keyboard code path. [tone] drives two independent mechanisms off the same attribute: JS maps it through `TONE_TO_BADGE_VARIANT` (neutral→default, accent→accent, warning→warning, danger→danger) onto the kind badge's `variant`, while CSS `:scope[tone="…"]` selectors separately override `--agent-artifact-border` for accent/warning/danger (neutral has no override and falls back to `--a-border-subtle`). [icon], [title], and [kind] each independently hide their own header cell when unset: there's no combined empty-header collapse. `disconnected()` removes both header listeners and nulls all six cached element references (`#headerEl`/`#iconEl`/`#titleEl`/`#kindEl`/`#caretEl`/`#bodyEl`): symmetric cleanup on every disconnect. Composing icon-ui and badge-ui (`composes:`, ADR-0027, the consumer imports them explicitly) covers the header chrome only; the default slot is never dictated, so code-ui, canvas-ui, a plain `<div>`, anything renders there.

## `<agent-feedback-bar-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `rating-ui` | rating-ui is a star-based ordinal scale; use that for a 1-5 quality rating, not binary agent feedback. |

### Screen-reader spec

`#build()` creates all three buttons via the shared `makeButton()` helper as `<button-ui variant="ghost" size="sm">`; the thumbs-up/ thumbs-down buttons are icon-only with no `text`, so `makeButton()` sets a `title` ("Rate this response positively" / "Rate this response negatively"), which button-ui's own accessibility fallback mirrors into `aria-label` automatically (icon-only buttons with no explicit `aria-label`/`aria-labelledby`, per its §184/FEEDBACK-08 §8 safety net): agent-feedback-bar-ui never sets `aria-*` attributes directly, it relies entirely on that button-ui contract. The save button gets a real visible `text` from [saveLabel] whenever it's shown, so it needs no title/aria-label fallback. [disabled] sets no `aria-disabled`/ `disabled` attribute on the row or its buttons: it's a CSS-only `pointer-events: none` + dimmed-opacity treatment (`:scope[disabled]` in the stylesheet) layered on top of the JS-side guards in `#onUp`/ `#onDown`/`#onSave`, which independently no-op while `this.disabled` is true. The status span (`[data-feedback-status]`) is a plain unlabeled `<span>` with no `aria-live` region: text written via `setStatus()` is not automatically announced to a screen reader unless the host wires its own live region around this component.

### Behavioral spec

Thumbs-up (`#onUp`) and thumbs-down (`#onDown`) share a single `#rated` flag: once either fires and sets it, both are gated off by `!this.#rated`. `#emitRate(rating)` dispatches the bubbling `feedback-rate` event with `detail: { rating }` FIRST (rating is always the hardcoded `5` for thumbs-up or `2` for thumbs-down, never derived from anything else): any host listener that calls `.setStatus()` synchronously during that dispatch runs before the component's own courtesy default is evaluated. Only if `#statusEl.textContent` is still empty afterward does the component supply its own ack via `setStatus(text, { lock: 'rate' })` ("Thanks!" when `rating >= 4`, i.e. the thumbs-up path; "Noted" otherwise, i.e. thumbs-down): that default call is what sets `#rated` and disables both buttons in the common case where the host doesn't lock rating itself. Save has no equivalent self-locking default: `#emitSave()` dispatches `feedback-save` with an empty `detail: {}` and does nothing else: `#saved` only flips (disabling the save button) when the host explicitly calls `setStatus(text, { lock: 'save' | 'all' })`; left uncalled, Save stays repeatable indefinitely. `render()`: the framework's reactive re-render, distinct from the one-time `#build()`: re-syncs [saveLabel]/[saveIcon] onto the live save button's `text`/`icon`/ `hidden` attributes on every property change, so toggling [saveLabel] empty↔non-empty after connect shows/hides the save button reactively, not just at initial mount. `reset()` clears both `#rated`/`#saved` flags, blanks the status text, and re-enables all three buttons: it dispatches no event of its own. `disconnected()` removes the three `press` listeners (button-ui's own bubbling activation event, not `click`) and nulls all four cached refs.

## `<agent-questions-ui>`

**Composes:** `<button-ui>`, `<option-card-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `agent-suggestions-ui` | Use agent-suggestions-ui instead for optional follow-ups the user can ignore; agent-questions always gates the turn on an answer. |

### Screen-reader spec

agent-questions-ui itself sets no ARIA directly; it composes `<option-card-ui>` per option and relies entirely on that primitive's own accessibility machinery. Each card gets `role="radio"`, `tabindex="0"`, and `aria-checked` mirroring `.checked` on every render (option-card's `render()`). In [multi] mode, `#render()` overrides the connect-time role to `role="checkbox"` on every card right after stamping it: `aria-checked` still tracks `.checked` regardless of role, so a checkbox group announces checked/unchecked correctly even though option-card's own internals were built radio-first. Disabled cards (post-answer, in both modes) get `aria-disabled="true"` from option-card-ui's own render. The submit button is a real `<button-ui>` (hidden entirely in single-select via `this.#submitEl.hidden = !this.multi`), so it only enters the accessibility tree at all in multi mode, and stays `disabled` (native semantics) until at least one option is selected, and again once answered (`this.#submitEl.disabled = this.#answered || this.#selected.size === 0` in `#render()`).

### Behavioral spec

Two independent selection paths depending on [multi], not a shared toggle method. Single-select: `#onCardChange` listens for option-card-ui's own `change` event (which only ever fires on becoming selected, never on deselect), clears `#selected`, sets `#answered = true`, and immediately dispatches `questions-answer` with `detail: { selected: [id], option }`: there is no separate submit step and no way to change the pick afterward (all cards become `disabled` once answered). Multi-select: the container intercepts `click` and `keydown` at the CAPTURE phase (`#onOptionsClickCapture` / `#onOptionsKeydownCapture`, both installed with `useCapture: true`) to stop option-card-ui's bubble-phase click handler before it runs: necessary because option-card-ui's `#select()` can only select, never toggle off, so multi mode needs its own toggle logic (`#toggleMulti`) driving `#selected` as a Set. Arrow keys are swallowed in multi mode (`e.stopPropagation()`) rather than forwarded to option-card-ui's forced-select arrow navigation, since a checkbox group has no "arrow moves the pick" semantics. Multi answers only fire on explicit submit-button press (`#onSubmit`), with `detail.selected` as the full array of picked ids in Set-iteration order and `detail.option` as the first picked option object. Setting `.options` (the JS setter, not an attribute) clears `#selected` and resets `#answered = false` unconditionally: replacing options always resets the answered state, even mid-answer. `disconnected()` removes all three internal listeners and nulls the element refs symmetrically with what `connected()`/`#build()` wires up. Options are passed via the JS `options` setter (an array of `{ id, label, description, icon }`), not as light-DOM children or slotted markup: there is no declarative-children path.

## `<agent-reasoning-ui>`

**Composes:** `<timeline-ui>`, `<timeline-item-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `agent-trace-ui` | agent-trace-ui is structured tool-call/metrics data, not narrative prose; reach for it instead of agent-reasoning when the surface is quantitative diagnostics rather than a progress narrative. |
| `agent-questions-ui` | agent-questions-ui is a gating disambiguation prompt the turn waits on, not a progress narrative; reach for it instead when the agent needs an answer before continuing. |

### Screen-reader spec

The summary row (`#summaryEl`, built once in `#buildShell()`) gets `role="button"`, `tabindex="0"`, and `aria-expanded` mirroring `!this.collapsed`, refreshed on every `#render()` call: clicking it or pressing Space/Enter (`#onSummaryKey`) toggles `[collapsed]` and dispatches `reasoning-toggle`. The status icon (`#renderStatusIcon()`: spinner/check-circle/warning-circle/circle) is stamped as a bare `<icon-ui>` with no `label` attribute, so icon-ui's own connect-time default (`role="presentation"`, `aria-hidden="true"` when `this.label` is falsy) makes it fully decorative: the announced state comes from the adjacent `<span data-reasoning-label>` text and `<span data-reasoning-counter>` (`done/total`), not the glyph. Individual step rows are `<timeline-item-ui>` instances this component composes but doesn't itself add ARIA to; their accessibility is that primitive's own contract. `reasoning-step-toggle` is a delegated listener on `#bodyEl` for the bubbling `timeline-toggle` event: it sets no ARIA itself, only re-dispatches with `{ stepId }` resolved from `dataset.stepId`.

### Behavioral spec

Two parallel input paths, not modes on a single prop: the imperative pipeline (`addStep`/`updateStep`/`completeStep`/`failStep`/`addThought`/ `setPlan`/`startIteration`/`endIteration`) mutates the internal `#entries` array incrementally and re-renders after each call, while the `entries` setter is a full declarative replace that also resets `#iterationStack = []`: any imperative iteration in progress is discarded the moment `entries` is assigned. `#appendToCurrentScope()` nests a new entry inside the innermost open iteration's own `steps` array ONLY when `entry.kind === 'step'`; a `thought` or `plan` entry added while an iteration is open still lands in the top-level `#entries` list as a sibling, not nested inside the iteration block. `finish(summaryLabel, { status })` walks every still-`active` step/iteration and force-resolves it to `completed` or `error` (matching the finish status) before firing `reasoning-finish` with `detail: { summary, status }`; `fail(text)` is sugar for `finish(text, { status: 'error' })`. Auto-collapse only fires when `!noAutocollapse && !isError`: a 1200ms `setTimeout` sets `[collapsed] = true` and re-renders; an error-status finish never auto-collapses regardless of `[noAutocollapse]`. The live elapsed-time ticker (`#timerInterval`, 1s cadence) updates the summary's `data-reasoning-time` text and mirrors elapsed seconds into any `active`-and-not-`completed` `<timeline-item-ui>`'s `duration` attribute, unless that item carries `dataset._frozenDuration`, and stops permanently the moment `finish()` runs (`clearInterval` inside `finish()`, not `disconnected()`). Lifecycle is symmetric and idempotent: `connected()` guards re-entry with a `#bound` flag and wires the interval, the shell, and a `ResizeObserver` (for spine relayout on row-height reflow); `disconnected()` tears down the interval, `#finishTimer`, the queued `requestAnimationFrame` spine layout, the `ResizeObserver`, and both summary/body listeners, then resets `#bound = false` so a later reconnect rebinds cleanly. An unrecognized `[status]` value falls back silently to `'active'` in `#render()`; an unrecognized `finish()` status falls back to `'completed'` the same way (both via `REASONING_STATUSES.has()`).

## `<agent-suggestions-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `agent-questions-ui` | Use agent-questions-ui instead for a disambiguating question the agent must gate the conversation on: it always renders a submit affordance and blocks progress until answered; agent-suggestions never does. |

### Screen-reader spec

agent-suggestions-ui sets no ARIA of its own; every chip is a real `<button-ui>` element, so accessibility comes entirely from button-ui's own connect-time logic. Each chip gets button-ui's default `role="button"` and `tabindex="0"` (button-ui only defaults these when the author hasn't already set a role/tabindex, and agent-suggestions never does). The chip's accessible name is the truncated label: `#render()` sets `text=` on the chip to `truncate(label, 42)`, and button-ui mirrors a non-empty `[text]` into `aria-label` on every render, so a screen reader announces the (possibly ellipsis-truncated) label, never the full untruncated prompt. `[disabled]` on the host is forwarded to every chip's own `disabled` attribute (native semantics); the host itself only gets `opacity: 0.6; pointer-events: none` via CSS, no `aria-disabled`.

### Behavioral spec

`suggestions` is a JS-only setter (`el.suggestions = [...]`): no attribute equivalent, no slotted-children path, and setting it clears and fully rebuilds the chip row via `#render()`. Each entry may be a bare string (used as both label and prompt) or an object `{ label, prompt, icon }`; label/prompt resolution falls back in both directions: `label = s.label || s.prompt`, `prompt = s.prompt || s.label`, so an entry with only one of the two still produces a usable chip. `[variant]` (`outline` default, `ghost`) and `[size]` (`sm` default, `md`) are read fresh on every `#render()` and applied uniformly to all chips; there's no per-suggestion override. Each chip listens for button-ui's `press` event (not `click`) and, when the host isn't `[disabled]`, dispatches a bubbling `suggestion-select` with `detail: { label, prompt, index }`: `index` is the array position at render time, not a stable id. When `[disabled]` is set, the press handler still runs but its own early return (`if (this.disabled) return`) suppresses the event, so pressing a disabled chip is a no-op at the JS layer too, not merely blocked by the CSS `pointer-events: none`. `disconnected()` clears the host with `this.innerHTML = ''`, dropping all chip children and their listeners: there is no separate per-chip listener teardown, since DOM removal is what releases them.

## `<agent-trace-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `agent-reasoning-ui` | agent-reasoning-ui is the agent's narrative inner monologue: step-by-step thoughts/plans/iterations; agent-trace is metrics, not prose. |

### Screen-reader spec

agent-trace-ui sets no ARIA of its own on the disclosure structure: the summary/body is built from native `<details>`/`<summary>` elements (`#buildShell()`), which carry implicit disclosure semantics in the accessibility tree (expanded/collapsed state exposed automatically from the native `open` attribute; no `aria-expanded` is authored). Purely decorative alignment placeholders get explicit `aria-hidden="true"`: `data-trace-header-spacer` (the empty Detail-column header when no metric has an `aux`) and `data-trace-row-caret-spacer` (per-row filler when that row has no `details` but sibling rows do, keeping grid columns aligned). Every `<icon-ui>` the component stamps: the summary caret, each row's caret, the pill dot, and each feedback line's icon: omits icon-ui's `label` attribute, so icon-ui's own connect-time logic (`if (!this.label) this.setAttribute('aria-hidden', 'true')`) makes all of them decorative; the announced content is always the adjacent text (pill label/value, row label/value/aux, feedback text), never the icon glyphs.

### Behavioral spec

`pills`, `metrics`, and `feedback` are independent JS-only setters (no attribute equivalent, no slotted-children path); each clones its input array (`Array.isArray(v) ? v.slice() : []`) and calls `#render()`, but `#render()` is a guarded no-op until `#rootEl` exists (`if (!this.#rootEl) return;`): setting any of the three before the element connects has no visible effect until `connected()` runs `#buildShell()`, which rebuilds the shell and calls `#render()` again, picking up whatever was set pre-connect. The summary header uses whichever of `pills` or [label] is truthy: non-empty pills win outright (joined with a literal `·` separator span), and [label] (default `'Trace'`) only ever appears when `pills` is empty. The "Detail" header word and the row-caret column are two independent, data-driven decisions: `hasAux` (any metric with a non-empty `aux`) controls whether the header word renders at all, while `hasAnyDetails` (any metric with a non-empty `details`) controls whether an extra caret column exists across every row: a metric with `details` but no `aux` still gets a populated caret column even though its own Detail cell is blank. A metric row becomes its own native `<details>`/`<summary>` (independently expandable) only when that row's own `details` is non-empty; other rows render as a plain `<div>`. `details` itself branches on type: an array renders as escaped `<dt>`/`<dd>` pairs via `#renderDetails()`; a string is trusted raw HTML and rendered unescaped: the only unescaped-injection path in the component (every other label/value/aux/text goes through `escapeText`/`escapeAttr`). Expanding/collapsing the root `<details>` is native browser behavior: the component only listens for the resulting `toggle` event (`#onToggle`) to sync `this.collapsed` (reflecting the `collapsed` attribute on the host, but not re-triggering `#buildShell()`/`#render()` since `static template = () => null` makes the property system's own render pathway a no-op) and dispatch `trace-toggle` with `detail: { collapsed }`. `connected()` unconditionally wipes and rebuilds the host (`this.innerHTML = ''`) before attaching the `toggle` listener; `disconnected()` removes that listener and nulls `#rootEl`, but, unlike `<agent-suggestions-ui>`, does not itself clear `this.innerHTML`.

## `<alert-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `toast-ui` | alert-ui is not for a transient, self-dismissing notification, use toast-ui for that. |
| `empty-state-ui` | alert-ui is not for a zero-data placeholder, use empty-state-ui for that. |

### Screen-reader spec

`#updateRole()` sets `role="alert"` for [variant="danger"] or [variant="warning"] (interrupts a screen reader immediately, matching their higher severity) and `role="status"` for every other variant (announced without interrupting). The accessible name is composed and mirrored into `aria-label` on every render: title+description mode joins `[title]. [description]`, dunning mode joins `title. amount. meta` (all three deviant of Intl-formatted), and plain [text] mode has no separate aria-label: the content slot's own text is what's announced. Author-provided rich content (a slotted `<span slot="content">` with real children already inside, detected by the ABSENCE of `data-alert-auto`) is left alone except for still mirroring [title]/[description] to aria-label if set: the author owns the accessible name in that mode. The close button is a real `<button-ui aria-label="Close">`, so it announces "Close, button" regardless of the alert's own variant/role.

### Behavioral spec

[pattern="dunning"] branches into a completely separate render path (`#renderDunning()`) that skips the standard title/description/text precedence chain: while dunning is set, [title]/[text]/rich-content modes below don't apply, only the reason-keyed default title, Intl-formatted [amount]/[currency], [dueAt], [cardLast4], and [reason] feed the stamped content. Standard (non-dunning) content resolution is three-mode precedence, checked in order: (1) author-provided rich content already inside the content slot (no `data-alert-auto` flag) is left untouched; (2) [title] and/or [description] set → bolded headline + body paragraph, `data-alert-auto="title-desc"`; (3) [text] alone → single-line message, `data-alert-auto="text"`. There is no loading state: alert-ui renders synchronously. [closable] stamps a close button that removes the element from the DOM entirely on press/click (`#close()` fires a `close` event then calls `this.remove()`): there is no "hidden but still present" dismissed state to restore from. `#normalizeAliases()` silently remaps two hallucination-class inputs once per session (warn-once): `variant="error"` → `variant="danger"`, and `[closeable]` → `[closable]`.

## `<anchor-bar-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `modal-ui` | Use modal-ui instead for a surface that must trap focus or block the rest of the page; anchor-bar-ui coexists with page interaction and never joins modal-ui's mutual-exclusion convention. |
| `drawer-ui` | Use drawer-ui instead for a surface that must trap focus or block the rest of the page; anchor-bar-ui coexists with page interaction and never joins drawer-ui's mutual-exclusion convention. |

### Screen-reader spec

anchor-bar-ui sets no ARIA role or label of its own: `class.js` contains no `role=`/`aria-*` assignment on the host at all; the element relies entirely on slotted content (typically a `<toolbar-ui role="toolbar" aria-label="…">` or a `<text-ui aria-live="polite">` count) to carry its own accessible semantics, per the examples' own convention of adding `aria-live="polite"` to a running selection count. The only attribute the component ever sets on itself is `popover="manual"`, applied once when [viewport] is bound (`#bindPopover()`) and removed on unbind: this is a Popover API state marker, not an ARIA construct, and manual popovers never light-dismiss or close on Escape (visibility is entirely [open]-driven). Closed state is signaled to assistive tech via CSS `visibility: hidden` (not `display`/`[hidden]`), which pulls the bar out of the accessibility tree and tab order while closed without triggering a `display:none` reset of slotted ResizeObserver-driven children. The component never calls `.focus()` on itself or any descendant at any lifecycle point: no focus is stolen on open; slotted interactive content keeps its normal tab order.

### Behavioral spec

`updated(changed)` branches on two independent property changes: a `viewport` transition calls `#bindPopover()` (true) or `#unbindPopover()` (false), and an `open` transition calls `#sync()` then dispatches a plain bubbling `open` or `close` `Event` (no `detail` payload) matching the new [open] value. `connected()` calls `#bindPopover()` only if [viewport] is already true at connect time: no popover wiring happens for the default (sticky) mode, and no `toggle` listener is ever attached while [viewport] is false. `#bindPopover()` sets `popover="manual"` only if the attribute isn't already present, guards against double-binding the `toggle` listener via a private `#popoverBound` flag, then calls `#sync()`. `#sync()` is the single source of truth reconciling [open] with actual popover state: it no-ops when [viewport] is false, and otherwise calls `showPopover()`/`hidePopover()` only when `:popover-open` disagrees with [open]: both calls are wrapped in try/catch since the Popover API may be unavailable. `render()` re-calls `#sync()` on every reactive pass while [viewport] is true, so the popover stays in sync even after the class methods change [open] out of band. `#onToggle` listens for the native `toggle` event (fired by browser-driven or programmatic `showPopover()`/`hidePopover()` calls) and reflects `e.newState === 'open'` back into the [open] property, but only when it actually differs: preventing an update→sync→toggle feedback loop. `disconnected()` is symmetric cleanup: if still popover-open, it calls `hidePopover()` (try/catch-guarded); if a `toggle` listener is bound, it clears `#popoverBound` and removes the listener: mirroring `#unbindPopover()`'s own teardown without touching the `popover` attribute itself (the element is leaving the DOM regardless). Anchor-bar deliberately does not join the modal/drawer mutual-exclusion convention; it coexists with page interaction and open popovers rather than blocking either.

## `<aside-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `admin-sidebar` | Not for app-shell sidebars: admin-shell no longer reads `<aside-ui slot=>` (ADR-0024); use the bespoke `<admin-sidebar slot="leading\| trailing" collapsible resizable>` instead. |
| `chat-sidebar` | Not for app-shell sidebars: use the bespoke `<chat-sidebar>` under `<chat-shell>` instead of `<aside-ui>`. |
| `editor-sidebar` | Not for app-shell sidebars: use the bespoke `<editor-sidebar>` under `<editor-shell>` instead of `<aside-ui>`. |

### Screen-reader spec

No class file exists for this component: it is a CSS-only slot stub with no `connected()`/`render()` lifecycle of its own, so it stamps no role or ARIA attributes at all. Accessible semantics come entirely from the container parent that reads [collapsible]/[width] via `@scope`; consumers wanting a landmark role should add one explicitly (or let the parent primitive's own a11y wiring supply it) rather than relying on aside-ui for any implicit announcement.

### Behavioral spec

There is no interaction model and no lifecycle: aside-ui ships zero JS (`.class.js` does not exist for this component); it is pure CSS positioned by tag + ancestor + DOM order. [collapsible] and [width] are plain reflected attributes read by the container parent's own styling and JS; the aside element itself never toggles, animates, or responds to either attribute changing. There is no loading, error, or empty state. No Card/Drawer/Modal/Page/AppShell CSS or JS currently reads `<aside-ui>` (a grep across those components' `.css`/`.class.js` returns zero matches): treat any such parent-styling wiring as unimplemented, not as a placement contract.

## `<avatar-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `avatar-group-ui` | For a stacked/overflowed set of avatars, reach for the sibling avatar-group-ui rather than composing rows of avatar-ui by hand: it owns the +N overflow counter and size propagation this primitive doesn't. |

### Screen-reader spec

connected() stamps `role="img"`, and render() derives the accessible name via `syncAutoAriaLabel()` from [text] first, then [icon], never clobbering a consumer-set `aria-label` (gh#1647's value-tracking guard: the component only owns the aria-label value it itself last wrote, so a hand-authored aria-label survives every re-render). The rendered `<img>` element's own `alt` mirrors [text] (empty string when [text] is unset, never left undefined); this is somewhat redundant with the host's own `role="img"` + aria-label, which is what a screen reader actually announces. There is no focus/keyboard handling of any kind: avatar-ui is a decorative or identity-labeling image element, never independently focusable, and carries no interactive semantics beyond the static `role="img"`.

### Behavioral spec

The real fallback precedence, read from render(), is narrower than the yaml `description`'s "image → initials → icon" phrasing might suggest: with [src] set, a successful load always wins; only an `onerror` (failed image load) falls through, and it falls through directly to [text]-derived initials, NEVER to [icon], even when [icon] is also set. With [src] unset, [icon] takes priority OVER [text]: an avatar with both [text] and [icon] set and no [src] renders the icon, not initials; initials render only when [icon] is also empty. All three surfaces (image/icon/initials) are mutually exclusive DOM children: render() actively removes the other two before creating the active one, so more than one visual representation is never mounted at once. An avatar with none of [src]/[text]/[icon] set renders nothing at all (render() returns before creating any child): there is no default placeholder glyph. There is no loading or error state exposed to the consumer: the image-load failure is handled entirely internally via the swap to initials.

## `<badge-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tag-ui` | badge-ui is not for anything interactive or removable, use tag-ui for filter chips or user-managed labels, which fires its own `remove` event and stays proportional/sentence-case rather than badge's uppercase/mono/wide-tracked register. |

### Screen-reader spec

`connected()` stamps `role="status"` unless the consumer already set an explicit role before connection (e.g. `<chart-legend-ui>` overriding to `role="button"` for a toggleable legend chip: badge-ui never clobbers that choice). `render()` mirrors [text] into `aria-label` whenever [text] is non-empty, so the accessible name always matches the visible pill text (including the CSS-rendered `attr(text)` content) even though the text itself paints via CSS rather than a text node. An icon has no accessible-name contribution of its own: it's a decorative `icon-ui` child, so [text] (or the [status] shorthand's mapped text) is the sole source of the announced label; a badge with an icon and no [text]/ [status] announces nothing. `role="status"` means a badge mounted or updated inside a live region gets announced as a passive state change, not an interruption: there is no separate live-region wiring beyond that role.

### Behavioral spec

There is no interaction model: no press/focus/keyboard handling, no `disabled` state. [status] and [variant]/[text] are mutually exclusive in effect: while [status] is set, `render()`'s shorthand mapping overwrites [variant]/[text] on every render pass, so setting both is unsupported (last shorthand write wins, not a merge). [icon] is added or removed by direct DOM diffing against the existing `icon-ui` child (`querySelector(':scope > icon-ui')`) rather than a full re-stamp, so changing only [weight] on an already-present icon updates the existing element in place instead of remove+recreate. There is no loading, error, or empty state: badge-ui renders synchronously from its properties with no async lifecycle of its own.

## `<block-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `col-ui` | For spacing children relative to each other, col-ui (or row-ui / grid-ui) is the right fit: block applies [padding] / [margin] to itself and owns no flex or grid, so it cannot lay its children out at all. |

### Screen-reader spec

No role, label, or ARIA attribute is stamped by this component at any point: `static template` returns `null` and `render()` only ever touches inline `--block-padding`/`--block-margin` custom properties. block-ui is accessibility-neutral by design: it contributes nothing to the accessibility tree beyond whatever semantics its children or a wrapping landmark supply.

### Behavioral spec

Pure CSS-token resolver, no interaction model. `render()` runs on every [padding]/[margin] change and only does unit conversion: a responsive value (containing `@`) is resolved against the current breakpoint via `parseResponsive`/`breakpoint.value` and written to the `--block-padding`/`--block-margin` custom properties; a non-responsive value clears those properties so block.css's static rules take over instead: the two paths are mutually exclusive per dimension, never both active at once. Named rungs (`xs`..`xl`) map to the parametric `--a-{padding,margin}-{rung}` scale; bare integers map to `--a-space-N`; `none` resolves to literal `0`. There is no loading, error, focus, or empty state: [app]/[panel]/[active] are inert pass-through markup this component never reads or reacts to itself.

## `<blockquote-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `alert-ui` | blockquote-ui is not a general callout or alert container: reach for alert-ui when the content is a status/notice rather than a quotation. |
| `card-ui` | blockquote-ui is not a general callout container: reach for card-ui when the content needs its own bounded surface rather than inline prose chrome. |

### Screen-reader spec

`connected()` sets `role="blockquote"` on the host exactly once, and only if no `role` attribute is already present (`if (!this.hasAttribute( 'role')) this.setAttribute('role', 'blockquote')`): this is the component's only ARIA-relevant call in `class.js`; it exists because assistive tech doesn't infer semantics from a custom-element tag name the way it would from a native `<blockquote>`. There is no aria-label, live-region, or other ARIA construct: the quote body (default slot) and the citation line (`[slot="cite"]`) are read as ordinary text content in DOM order, so a screen reader announces the quote body followed by the citation line (visually prefixed with an em-dash via CSS `::before`, which is not exposed to the accessibility tree since it's decorative).

### Behavioral spec

`render()` implements the two-attribution-path precedence described in [cite]'s own prop entry, resolved fresh on every reactive pass. It first looks for author-provided slotted content: a `[slot="cite"]` element that does NOT carry `data-cite-stamped`, and if present, removes any previously auto-stamped span (`stamped?.remove()`) and returns immediately: slot content always wins over the [cite] prop when both are set, matching the yaml's own contract, and no further cite handling runs that pass. Absent author-provided slot content, [cite] drives an auto-stamped `<span slot="cite" data-cite-stamped>`: if a stamped span already exists from a prior render, its `textContent` is updated in place (avoiding a remove/recreate churn); otherwise a new span is created and appended as the last child. If [cite] is empty/falsy and a stamped span still exists (e.g. the caller cleared [cite] after setting it), the stamped span is removed entirely: there is no empty citation line left behind. The `data-cite-stamped` marker is what lets `render()` distinguish "component-owned, safe to overwrite/remove" content from "author-owned, must not touch" content on every subsequent pass.

## `<breadcrumb-ui>`

**Composes:** `<menu-ui>`, `<button-ui>`, `<menu-item-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `nav-ui` | For offering a set of destinations, nav-ui is the right fit. breadcrumb-ui does not offer choices, it reports the path TO the current page as an ordered trail with auto-inserted separators, collapsing the middle into an overflow menu when it does not fit. |

### Screen-reader spec

connected() stamps `role="navigation"` and `aria-label="Breadcrumb"` on the host once, unconditionally (no adopt-or-stamp guard on the label: a consumer-set aria-label is overwritten on every connect). Every render() pass marks exactly the LAST visible-in-DOM item `aria-current="page"` and strips it from every other item, so the current-page marker always tracks DOM order even as items are added/removed/collapsed. Separator characters are inserted as `aria-hidden="true"` `<span>`s between consecutive visible siblings, so a screen reader never announces the separator glyph itself: only the item text and (for the overflow trigger) its own aria-label. The overflow `…` trigger is a `button-ui[aria-label="Show collapsed crumbs"]` that opens a `menu-ui` popover; that popover inherits menu-ui's own keyboard nav and top-layer rendering rather than breadcrumb-ui wiring any keyboard handling of its own.

### Behavioral spec

[collapse] only takes effect once the item count is at least `max(collapseKeepLeading + collapseKeepTrailing + 1, 4)`: a short trail never collapses even with [collapse] set. The collapsed middle items are hidden (`data-collapsed`) and re-surfaced only inside the `…` overflow `menu-ui` popover as `menu-item-ui` entries; activating one navigates via `window.location.href` for a real href, and is a no-op for a placeholder `#` link. A collapsed link's href is preserved by copying it onto the menu-item's `value`, but a non-link crumb (a bare `<span>`) loses any click affordance entirely once collapsed: only its text survives into the menu. `collapse-keep-trailing` is floored at 1 internally regardless of the attribute value (gh#2813): the LAST item always receives `aria-current="page"`, so a consumer setting `collapse-keep-trailing="0"` no longer pulls the current-page crumb into the collapsed/hidden set: it stays visible in the trail rather than becoming reachable only by opening the overflow menu. Set `collapse-keep-leading="0"` instead if the intent was to push the overflow trigger to the very start of the trail. render() is fully idempotent: it strips all previously-stamped separators and the overflow menu before recomputing, so no chrome accumulates across repeated attribute changes. There is no loading, error, or empty state: items are always synchronously present in the light DOM. Light-DOM children are plain `<a href>` links or a final non-link `<span>` for the current page, never breadcrumb's own prop-driven markup.

## `<button-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `link-ui` | button-ui is not for navigation (route changes): use link-ui, which carries `<a href>` semantics and announces "link" to a screen reader instead of "button". |
| `switch-ui` | button-ui is not for toggleable on/off state: use switch-ui for a single toggle; button-ui has no pressed/checked persistence, only a momentary `press` event. |
| `segmented-ui` | button-ui is not for toggleable on/off state: use `segmented-ui multiple` + segment-ui for a multi-select cluster; button-ui has no pressed/checked persistence, only a momentary `press` event. |
| `segment-ui` | button-ui is not for toggleable on/off state: use segment-ui inside `segmented-ui multiple` for a multi-select cluster; button-ui has no pressed/checked persistence, only a momentary `press` event. |

### Screen-reader spec

The accessible name comes from [ariaLabel] when set, or falls back to the rendered [text]/child text content (reflected in render() via setAttribute, deliberately outside `static properties` so it doesn't clobber native ariaLabel reflection). An icon-only button (no [text] and no non-whitespace child text) MUST set [ariaLabel] explicitly: render() reflects the icon-only state as `[data-icon-only]`, but that attribute is a CSS hook only, not an accessible-name source, so a screen reader announces nothing for an unlabeled icon-only button. Focus order follows DOM order like any native `<button>`; the `:focus-visible` ring (`[focused]` state) only appears for keyboard/assistive-tech focus, not pointer clicks. There is no live-region announcement on press: the `press` event is a same-frame DOM event, not an async operation, so no "action completed" announcement is needed from button-ui itself; a consumer wiring an async action behind `press` (e.g. a save that shows a toast) owns announcing that outcome via its own live region.

### Behavioral spec

[disabled] both prevents interaction and removes the element from the tab order (native `disabled` semantics via HTML `<button>` under the hood): it is reflected so `button-ui[disabled]` is CSS-targetable, but there is no separate "loading" state modeled in yaml: a button mid-async-action is a caller-composed pattern (disable the button + swap [icon] to a spinner glyph, or slot a `<spinner-ui>` into the default slot alongside the icon slots), not a built-in state. There is no error state either: a failed action re-enables the button and the caller surfaces the error elsewhere (toast, inline form error), not on button-ui itself. [touchTarget] extends the hit-slop without changing painted size or focus-ring geometry; it is opt-in because the larger hit area can overlap adjacent interactive elements in dense rows (e.g. a table action column), which is a layout defect the author must check for, not something button-ui guards against.

## `<calendar-grid-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `calendar-picker-ui` | calendar-grid-ui is the substrate calendar-picker-ui composes internally for a single-date field (trigger + popover + form-association). Reach for calendar-picker-ui instead when the goal IS a complete single-date field, it wraps calendar-grid-ui and adds everything this component deliberately omits. |
| `date-range-picker-ui` | calendar-grid-ui is the substrate date-range-picker-ui composes internally, as two side-by-side grids sharing [rangeStart]/[rangeEnd]. Reach for date-range-picker-ui instead when the goal IS a start/end date range, it owns the two-grid cross-constraint wiring this primitive doesn't. |

### Screen-reader spec

`connected()` unconditionally stamps `role="group"` on every connect, and sets `aria-label="Calendar"` only when the consumer hasn't already supplied one (`!this.hasAttribute('aria-label')`). The month-nav buttons are hardcoded `<button-ui aria-label="Previous month">` / `aria-label="Next month">` with `tabindex="-1"`, keeping them out of the day-grid's own tab sequence. Day cells are `<button-ui>` elements whose visible day number is passed via the `text` attribute (button-ui's own `::after { content: attr(text) }`): calendar-grid-ui stamps no separate per-cell `aria-label`, so each day's accessible name comes from button-ui's own text-attribute handling. Exactly one day cell carries `tabindex="0"` at a time (the `#focusedDay` cell: a roving-tabindex composite pattern); every other cell is `tabindex="-1"`. There is no `aria-live` region: month navigation and selection both re-render via a full `innerHTML` replace (`#renderCalendar()`), so a screen reader picks up the new month title only through the moved focus, not a proactive announcement.

### Behavioral spec

View-month resolution on connect is a precedence chain checked in order: a parseable [value] wins outright; else a `YYYY-MM`-shaped [month] hint is used; else the grid falls back to the real current month (the private `#viewYear`/`#viewMonth` fields default to `new Date()` at construction). `render()` re-syncs the displayed month to [value] any time [value] changes to a date outside the currently-shown month: an external write to [value] always wins, even mid-navigation. Adjacent-month filler days render with `[data-outside]` but, per gh#2499, that flag alone no longer disables them: clicking one both selects it (`#selectDate`) and navigates the grid to its month; only [min]/[max] independently disable a filler (or in-month) cell. [disabled] blocks everything: `#onClick`/ `#onKey` both early-return, and the CSS also sets `pointer-events: none`. [readonly] is narrower: only `#selectDate()` checks it (`if (this.disabled || this.readonly) return`), so under [readonly] the nav buttons, PageUp/PageDown, and arrow-key focus movement keep working: only committing a selection is blocked. Keyboard contract: arrow keys move the roving focus ±1/±7 days, rolling into `navigate()` (and re-anchoring `#focusedDay`) across a month boundary and firing `input` with `{ focusDate }`; Enter/Space commits the focused day through the same min/max check used for disabling, firing the sole `change` dispatch with `{ value }`; PageUp/PageDown call `navigate(±1)` without moving focus; Home/End jump focus to day 1 / the month's last day. Range highlighting only activates when both [rangeStart] and [rangeEnd] parse to valid dates AND `rangeStart <= rangeEnd`: a single, unset, or reversed pair renders no `[data-in-range]` cells at all, and the two endpoint cells always render via [value]'s `[data-selected]`, never `[data-in-range]` (the in-range predicate is strictly-between), so the two states never double up. `navigate(delta)` and `focusDay(day)` are public methods beyond the documented props/events, and `viewYear`/ `viewMonth` are read-only getters exposing the displayed month so a multi-pane consumer (date-range-picker-ui) can track a grid that navigated on its own (e.g. a filler-day click) without re-parsing rendered DOM text. Click/keydown listeners are bound once in `connected()` (guarded by `#bound`) and symmetrically removed in `disconnected()`, which also resets `#bound = false`.

## `<calendar-picker-ui>`

**Composes:** `<calendar-grid-ui>`, `<time-picker-ui>`, `<divider-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `date-range-picker-ui` | For a start/end date range, reach for date-range-picker-ui instead of composing two calendar-picker-ui instances by hand, it owns the two-calendar cross-constraint logic this primitive doesn't. |

### Screen-reader spec

connected() stamps `role="group"` on the host; the trigger itself carries no `aria-expanded`/`aria-haspopup` of its own: open/closed state is exposed only via the popover actually being present/absent in the accessibility tree, not an ARIA state attribute. On the closed→open transition (gated so it never re-fires on every open-state render), DOM focus is moved into the composed calendar-grid-ui via its own `focusDay()` method rather than a raw `.focus()`: this seeds the grid's internal roving-tabindex/`#focusedDay` state BEFORE moving focus, so the very first Enter or arrow-key press after open has a correct baseline instead of starting from day 1 (a fix landed after a review finding, PR #1437). Only the open/close affordance (Enter/Space to open, Escape to close) lives on this host's own keydown handler: every other key (arrow nav, PageUp/PageDown, Home/End, day-selection Enter/Space) is calendar-grid-ui's own keydown contract, which only fires once focus is actually inside it, not merely inside the host. In a time-pane mode, Tab carries focus from the calendar pane into the time pane's Spinbutton segments in normal DOM order: no explicit focus trap or wraparound is wired between the two panes.

### Behavioral spec

Selecting a day does NOT auto-close the popover in single-pane (date) mode: it stays open until an outside click, Escape, or (when a `[slot="footer"]` Apply/Cancel pair is authored) an explicit commit; this matches the primitive's prior inlined-grid behavior on purpose. In a two-pane (minute/second precision) mode, a date selection and a time selection are each held as a partial `{date, time}` pending pair: an `input` event fires on every partial change, but `value` (and the `change` event) is only written once BOTH halves are present via `#commitFromParts`, which also re-validates min/max and dispatches `invalid` (reason: `parse` / `below-min` / `above-max`) on failure rather than silently clamping. Selecting only the date half with no pending time advances focus into the time pane's first (hour) segment automatically: a11y-motivated date → time → commit sequencing, not a visual affordance. `readonly` blocks the popover from ever opening (checked in the click and keydown handlers) while leaving the trigger keyboard-focusable for inspection; `disabled`/`readonly` additionally act as a belt-and-braces circuit breaker that force-closes an already-open popover on the next render if either becomes true while open. There is no built-in loading or error surface beyond the ElementInternals validity state (`valueMissing`/`badInput`/ `rangeUnderflow`/`rangeOverflow`): a malformed min/max or externally supplied date/datetime string surfaces only through that native validity API, not a visible error message of its own.

## `<canvas-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `editor-canvas` | editor-canvas already wraps a canvas-ui internally for its own preview pane: reach for that composite instead of canvas-ui when the goal is a ready-made editor + live-preview surface rather than a bare mount point. |

### Screen-reader spec

canvas.js itself sets no `role` or `aria-*` attribute anywhere: the only attributes it stamps are `data-canvas-surface` (a styling hook, not ARIA) on the internal wrapper `<div>` created in `render()`, and `preset` on the optional `<theme-ui>` wrapper when [theme] is set. All accessible semantics for the rendered content come entirely from whatever `<a2ui-root>` renders into that surface: a separate `@adia-ai/web-modules` package outside this component's own source, so canvas-ui contributes no accessibility tree of its own beyond an unlabeled generic host element.

### Behavioral spec

`render()` is idempotent, guarded by the private `#bound` flag: its DOM-building body (a `[data-canvas-surface]` wrapper holding a fresh `<a2ui-root>`, optionally nested in `<theme-ui preset="...">` when [theme] is set) runs at most once per mount. Because [theme] is only read inside that one-shot body, changing the `theme` attribute after first render does NOT rebuild or re-wrap the surface: it is captured at mount, not reactive. `disconnected()` resets `#bound = false` and nulls `#rootEl`/`#rootContainer`, so a later reconnect rebuilds a brand-new `<a2ui-root>` rather than reusing the old one. `#ensureRenderer()` dynamically imports `<a2ui-root>` from `@adia-ai/web-modules` exactly once (cached in `#rendererReady`); on failure it warns exactly once EVER across all canvas-ui instances (`static #warnedMissingRenderer`, not per-instance) and resolves `false` instead of throwing. `process()`/`processAll()`/`replaceDoc()`/`reset()` all route through `#whenRoot()`, which takes a synchronous fast path once the root is upgraded and otherwise queues behind the lazy-load promise: no call issued before the renderer resolves is lost. `replaceDoc()` passes through to `<a2ui-root>#replaceDoc` (the no-blank-frame transactional bracket, ADR-0061/gh#1364) when present, else degrades to `reset()` + a `process()` loop for an older cached root that predates it: `#restoreVersion()` (used by `back()`/`forward()`) degrades the same way. The four surface-lifecycle passthroughs (`beginSurfaceUpdate`, `applyTo`, `commitSurfaceUpdate`, `abortSurfaceUpdate`) deliberately bypass `#whenRoot()`'s queue and read `this.#rootEl?.renderer` synchronously, because a caller bracketing its own async generation call needs the `generationId` back synchronously; each feature-detects its target method and no-ops (`null`/`false`) otherwise. `pushVersion()` truncates any forward (redo) history when called after `back()`, caps the stack at `MAX_HISTORY` (10) by dropping the oldest entry, and stores a shallow copy of the message array. `#onClick`/`#onChange`/`#onInput` (bound on the host, not `document`) act only when `this.#rootEl?.contains(e.target)`, re-dispatching one `canvas-interaction` event with detail `{ type, targetTag, targetId, value }`: `value` falls back from `.value` to a `text` attribute to `''`, covering stamped elements whose label lives in an attribute. A separate `a2ui-retry` listener re-dispatches as `canvas-interaction` with defaults `{ type: 'retry', targetTag: 'button-ui', targetId: 'retry', value: '' }` then spreads `...e.detail` over them, so the source event's own detail keys win. `connected()`/`disconnected()` stay symmetric across all five listeners (one on `document` for keydown, four on the host).

## `<card-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `block-ui` | For padding or margin with no chrome, block-ui is the right fit: it applies a spacing scale and nothing else, where card-ui draws a border and owns header, body and footer regions. |
| `section-ui` | Not a substitute inside a card. section-ui's own record states that card.css styles the bare native <section> tag and never the literal section-ui tag, so a <section-ui> written inside a <card-ui> renders unstyled; author a plain <section> for card body regions. |

### Screen-reader spec

card-ui stamps no role, aria-label, or any other accessibility attribute of its own: it is a purely structural/visual container, and every accessible name/role a card needs comes from its actual content (a heading slot's text, an interactive child's own semantics). Setting [clickable] only changes the CSS cursor; it does NOT add a `role`, `tabindex`, or a click/keyboard handler to the card itself: a "clickable" card with no other focusable content inside it is functionally inert to a keyboard or screen-reader user despite looking clickable to a pointer user, so a genuinely interactive card needs its own focusable/actionable child (a wrapping `<a>`, a `button-ui`) rather than relying on [clickable] alone. Setting [draggable] wires the `draggable` trait, which is pointer-only (`pointerdown`/`pointermove`/ `pointerup`): there is no keyboard equivalent for repositioning a draggable card, and no ARIA (`aria-grabbed`, live-region announcement of the drop) is added by the trait.

### Behavioral spec

[draggable]'s trait attaches once, lazily: either at connect time (if [draggable] was already true) or on the first update that flips it to true (`updated()`'s `changed.has('draggable')` check), and is never detached again even if [draggable] later becomes false, so toggling draggable off does not un-wire the pointer listeners (only the visual cursor/handle affordance is expected to change; the trait's setup stays attached for the element's remaining lifetime). Dragging stamps `data-draggable-dragging` for the duration of the gesture and fires a `drag-end` CustomEvent with `{ x, y }` on release: card-ui's own `events:` contract does not list this event today since it belongs to the composed trait, not card-ui's own dispatch surface, so a consumer wiring `drag-end` should know it comes from the `draggable` trait, not card.class.js directly. [raw] and [variant] are pure CSS re-points with no JS-level state; there is no loading, error, or empty state: card-ui renders synchronously from whatever light-DOM content it's given, and an empty card (no header/section/footer children) simply renders its chrome with nothing inside. Set [variant]/[raw] for a chrome-only decision (border, shadow, tinted fill) rather than reaching for a different container primitive; set [clickable] only when the card itself is the interactive target (e.g. a choice card or navigable summary card), and [draggable] only when the card is a reorderable/repositionable surface: neither implies the other, and setting [draggable] wins the cursor affordance over [clickable] when both are set.

## `<chart-legend-ui>`

**Composes:** `<badge-ui>`, `<swatch-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chart-ui` | Bind chart-legend-ui to a chart-ui via [for] to auto-mirror that chart's series data and ratio bucket, or pass [items] directly for a legend that renders standalone with no chart ancestor. |
| `heatmap-ui` | Bind chart-legend-ui to a heatmap-ui via [for] to auto-mirror that chart's series data and ratio bucket, or pass [items] directly for a legend that renders standalone with no chart ancestor. |

### Screen-reader spec

`connected()` stamps `role="list"` on the host. Each rendered row is a composed `<badge-ui>` given `role="button"` when [interactive] (the default) or `role="listitem"` when not, so the legend degrades from an operable toggle list to a plain description list without changing its outer list semantics. Interactive rows additionally get `tabindex="0"` and `aria-pressed` mirroring the row's active/hidden state, kept in sync on every toggle by `#onRowToggle()`, so assistive tech announces each series' current visibility, not just its label. The swatch itself is a decorative `<swatch-ui>` child; the badge's own `text` attribute (the series label) is the sole accessible name per row, matching badge-ui's own `aria-label` mirroring contract.

### Behavioral spec

No loading/error/empty state beyond "renders nothing when the resolved item list is empty" (`#paint()` clears `innerHTML` and returns early). Item source resolves in strict priority order: explicit [items] JSON wins if parseable, else a `[for]`-bound target's `.legendData` property is mirrored, else the legend is empty: set both and [items] always wins, there is no merge. [for] resolution re-runs on every `render()` (property/attribute change), re-subscribing to the target's `legend-update` event each time so a stale target is dropped automatically if [for] changes; an unresolved [for] id logs one console warning per element (WeakSet-deduped, never repeats). [ratio] follows a pin > mirror > none chain (ADR-0074): an explicit `[ratio]` in {3:2, 1:1, 2:3} always wins; absent that, the `[for]`-bound target's `data-ratio-resolved` attribute is mirrored on every `legend-update`; with neither, no bucket resolves and [position]'s flex-direction CSS applies unchanged (pre-ADR-0074 fallback, zero regression for standalone legends). When [interactive], a row click or Enter/Space toggles that key's hidden-state in an internal Set, flips `[data-active]` and `aria-pressed` on the row in place (no full re-render), and fires a bubbling `toggle` event with `{key, active, mode}` where `mode` is [on-toggle]'s value: chart-ui reads this event when wired via [for] to actually hide/mute the bound series; chart-legend-ui itself never touches the chart, it only reports the toggle. A [deemphasized] item flag (set per-item in the mirrored/explicit data, not a legend prop) renders that row's swatch with the muted neutral token instead of its categorical color, matching chart-ui's own internal legend muting for de-emphasized series (REQ-R-007). Prefer [for] over hand-syncing [items] whenever a bound chart already exists: it keeps series add/remove and the ratio-bucket layout in lock-step with the chart's own resolved state instead of drifting.

## `<chart-ui>`

**Composes:** `<skeleton-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chart-legend-ui` | Pair with an external `<chart-legend-ui for="…">` for a toggleable series legend rather than composing one inline: it communicates with chart-ui purely through its `for=`-bound bubbling events (`chart-hover`/`chart-select`/`chart-leave`/`toggle`), so it needs no auto-import (ADR-0027). |
| `tooltip-ui` | Pair with an external `<tooltip-ui follows="pointer" for="…">` for a pointer-tracking tooltip rather than composing one inline: it communicates with chart-ui purely through its `for=`-bound bubbling events (`chart-hover`/`chart-select`/`chart-leave`), so it needs no auto-import (ADR-0027). |

### Screen-reader spec

connected() stamps `role="img"` and a generic `aria-label="{type} chart"` (e.g. "bar chart") unless a consumer already set either: this is a coarse label, not a data summary; a consumer needing per-datum detail announced should author its own `aria-label`/`aria-describedby` pointing at real content. The host is independently focusable (`tabindex="0"`) with a full virtual-focus keyboard model (OD-CHART-06): ArrowRight/ArrowDown and ArrowLeft/ArrowUp step one datum at a time in DOM order, Home/End jump to the first/last datum, Enter/Space fires `chart-select` for the focused datum, and Escape clears focus and fires the same leave event the pointer path fires. First focus lands on the first datum; subsequent focus events preserve the last keyboard position rather than resetting: a deliberate "resume where you left off" convention. Focus is indicated by a `data-a11y-focus` attribute on the focused SVG element plus `data-a11y-focused` on the host (CSS paints the outline): this is a custom, non-native focus indicator since the focusable datums themselves are SVG shapes, not natively focusable elements. Every keyboard focus change dispatches the identical `chart-hover` event shape the pointer-hover path dispatches, so a composed `tooltip-ui[follows=pointer][for]` tracks keyboard focus transparently with no separate code path of its own.

### Behavioral spec

Three distinct states: [loading]=true swaps the entire chart body for a `skeleton-ui` placeholder that preserves the element's box dimensions (aspect-ratio CSS keeps applying): this is "fetching," not "confirmed empty," and clearing [loading] restores the real chart on the next render. Once not loading, an empty `.data` array (or a malformed inline `data="[…]"` HTML attribute, which is JSON-parsed once at connect and silently ignored on parse failure) removes the `has-data` host attribute and renders only a consumer-authored `[slot="empty"]` child if one was provided: this "confirmed empty" state is captured once via `cloneNode` on first render and re-stamped on every subsequent data-empty transition, so it survives repeated data-present → data-empty → data-present cycles without the consumer re-authoring it each time. There is no distinct built-in error state: a fetch failure is a caller-composed pattern (render an error message into the same `[slot="empty"]`), same shape as table-ui's own documented gap. Hiding a series (via a `toggle` event bubbled from an external `chart-legend-ui[for]` matching this chart's `id`) doesn't remove data: it adds the series key to an internal hidden-set and triggers a full re-render that excludes it from the plot, so toggling a series back on restores it from the same underlying `.data`, no refetch needed. `.data` re-renders the FULL SVG string on every change: past roughly 5,000 rows (type-dependent; scatter and multi-line are worst) a one-time console warning fires (`--chart-perf-budget` CSS token overrides the threshold), but nothing blocks the render itself; callers over budget are expected to downsample before assigning `.data`.

## `<chat-shell>`

**Composes:** `<button-ui>`, `<code-ui>`, `<drawer-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `admin-shell` | For a data-driven CRUD or admin app, admin-shell is the right fit. Its own record states it wires <admin-topbar> / <admin-sidebar> / <admin-content> / <admin-command> around a resizable-sidebar, command-palette shell; it carries none of chat-shell's LLM-streaming, markdown-rendering, or proxy-url integration. |
| `editor-shell` | For a design-tool, code-editor, or canvas layout, editor-shell is the right fit. Its own record states it wires <editor-toolbar> / <editor-sidebar> / <editor-canvas> / <editor-statusbar> around a central work surface, a shape chat-shell's message-thread-plus- composer orchestration does not share. |
| `simple-shell` | For a marketing, landing, or error page, simple-shell is the right fit. Its own record states it is deliberately thin (1 host + 2 CSS-only children); chat-shell's LLM-streaming orchestration and bespoke chat-* vocabulary would be pure overhead there. |

### Screen-reader spec

chat-shell itself sets no top-level ARIA role; the accessible structure is delegated entirely to its composed children: chat-thread owns the live-region announcement of new messages as they stream in, and chat-composer owns the input's own labeling. On connect (gh#2849), chat-shell also stamps `role="main"` on its `<chat-thread>` child: chat-shell is the document's app root (never nested/repeatable), so it's the one place that knows chat-thread is the page's sole primary-content region, unlike `page-ui` (ADR-0105), which deliberately stamps no landmark of its own. The stamp is idempotent and non-overriding: skipped if chat-thread already carries a `role` attribute, is already a native `<main>`, or already contains a nested `<main>`/`[role="main"]` descendant (`shared/main-landmark.js`'s `ensureMainLandmark()`), and deferred a microtask past connect to survive the `document.body. innerHTML =` parse-order quirk where children can attach after the parent's `connectedCallback` fires. [streaming] toggling true propagates to chat-thread[streaming] (drives its live-region "assistant is responding" state, so screen-reader users get an in-progress signal rather than silence during the stream) and chat-composer[disabled] (removes the input + send affordance from the tab order for the duration, preventing a duplicate submit mid-stream). The persistent `<drawer-ui data-mobile-nav-drawer>` child (ADR-0090, shipped at every container width, only its visibility is container-query-driven) carries its own focus-trap contract identical to modal-ui/drawer-ui's standard dialog semantics: below the mobile-nav breakpoint it is a real, focus-trapping overlay, so a consumer doing global "count the dialogs/drawers on this page" accessibility audits must scope past it with `:not([data-mobile-nav-drawer])` (gh#2031) or it double-counts as a second, usually-hidden dialog.

### Behavioral spec

There is exactly one lifecycle boolean surfaced at the shell level: [streaming]: driving the `idle`/`streaming` state pair; there is no separate `error` prop despite an `error` EVENT firing on any LLM/network failure. A consumer must listen for the `error` event and render its own recovery UI (a retry affordance in chat-composer, an inline error bubble in chat-thread): chat-shell does not fall back into any built-in error state or auto-clear [streaming] with an error indicator baked in (it does clear [streaming] back to idle when the request settles, error or not, but the visual error treatment itself is caller-composed). "Empty" (no messages yet) is likewise not a chat-shell-level state: it is chat-thread's own `<chat-empty>` slotted child, shown/hidden by chat-thread based on its own message count, not by any chat-shell attribute. `abort` lets a consumer cancel an in-flight stream, which fires `abort` (not `error`) and is expected to return [streaming] to false without treating the abort as a failure state. `clear` calls `abort` internally before wiping the thread (gh#3524): a live stream is never a valid state to "start over" from, so a consumer never needs to call `abort` before `clear` to avoid a stuck [streaming]/disabled composer - `clear` alone is always sufficient. `thinking` opts a request into Anthropic extended-thinking mode; thinking tokens are billed output, so it is off by default (gh#3477). `thinking-budget` (gh#3517) only takes effect while `thinking` is set and raises that cost further, a larger budget spends more tokens before the model's answer even starts; a consumer surfacing this attribute to a caller should treat it as a cost dial, not a quality knob to leave maxed out.

## `<chat-thread>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chat-thread-ui` | Not the unrelated primitive chat-thread-ui, a standalone ad-hoc message surface for use OUTSIDE a chat-shell (e.g. a comment thread inside card-ui). Reach for that one only when there is no chat-shell at all. |

### Screen-reader spec

chat-thread carries no ARIA role of its own beyond what chat-shell stamps: on connect (gh#2849), `<chat-shell>` sets `role="main"` on its `<chat-thread>` child: chat-thread is the document's sole primary-content region wherever chat-shell is the document's app root, and the stamp is idempotent (skipped if chat-thread already carries a `role` attribute, is already a native `<main>`, or already contains a nested `<main>`/`[role="main"]` descendant). chat-thread itself owns no live-region announcement: new messages arriving via the MutationObserver in `#setupChildObserver()` trigger an instant scroll-to-bottom (`scrollToBottomInstant()`) when the user hasn't scrolled away, but fire no `aria-live` announcement of their own; a screen-reader user's signal that a response is streaming comes from chat-shell's own `chat-composer[disabled]` state during the stream, not from anything chat-thread announces. `[empty]`'s toggle is a CSS-only visibility gate for the slotted `<chat-empty>` sibling (`chat-thread:not([empty]) > chat-empty`), not an announced state change.

### Behavioral spec

`[empty]` is computed from live children on every mutation (`#syncEmptyFromChildren()`: anything except a `<chat-empty>` stub counts as a message), not set by the host directly; a fresh, childless thread stamps `empty` true even though the property system's default is already `true` (gh#956: `toggleAttribute()` runs unconditionally because reflect-on-change alone would leave a true-over-true default attribute-less, silently hiding the authored empty state forever). Auto- scroll is a stateful heuristic, not a fixed rule: `#autoScroll` starts `true` and flips `false` the moment the user scrolls more than `SCROLL_BOTTOM_TOLERANCE` (40px) away from the bottom, resuming only once they scroll back within that tolerance, so a user reading scrollback during a stream is never yanked back to the live edge, but returning to the bottom re-arms auto-scroll for the next message. `scrollToBottom()` (smooth) and `scrollToBottomInstant()` are both public: the observer itself only ever calls the instant form; a consumer wanting an animated jump (e.g. a "jump to latest" affordance) calls the smooth variant directly. `[streaming]` is purely a reflected pass-through from the host (chat-shell sets it): chat-thread applies no behavior of its own when it toggles beyond whatever a consumer's own CSS keys off it. Message rendering itself lives in the host (chat-shell is the one source of truth for markdown + escape rules); chat-thread never renders a message itself, only the scroll container it lands in.

## `<chat-thread-ui>`

**Composes:** `<chat-input-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chat-thread` | Reach for the module-tier chat-thread instead when authoring a full chat-shell composite; that shell child owns scroll-to-bottom plus [empty] state this primitive doesn't replicate. |

### Screen-reader spec

chat-thread-ui stamps no ARIA role, aria-live region, or landmark of any kind: the messages `<section>` is a plain scrolling container, not `role="log"`, and there is no `aria-live` wiring around streaming content, so a screen reader gets no automatic announcement of new messages arriving or of a streaming assistant reply growing token by token; a consumer needing that must add its own live-region wrapper. The author-facing `role="user"`/`role="assistant"`/... attribute documented on message children (see `.examples.html`) is NOT an ARIA role: it's `#adoptAuthoredChild()`'s own selector for which speaker authored a bare child at connect time, read once and then discarded (the original element is replaced by an internally-built `data-role` wrapper, so this attribute never actually lands in the live DOM as a literal `role="user"`, which wouldn't be a valid ARIA role token anyway). `stopStreaming()` calls `.focus()` on the composed `chat-input-ui` once streaming ends, returning keyboard focus to the composer automatically: the only explicit focus management this primitive performs.

### Behavioral spec

[streaming] gates the input: `startStreaming()` sets [streaming] and disables the composed `chat-input-ui`; `stopStreaming()` clears it, re-enables the input, removes the trailing cursor span from the last message, re-renders that last assistant bubble's content as markdown (raw text/cursor during streaming, formatted markdown only once streaming ends), and refocuses the input. While [streaming] is true, `#onSubmit` is a no-op: a submit event from the composer is silently dropped rather than queued, so a consumer must wait for `stopStreaming()` before the next message can be sent. `appendMessage()`/`appendChunk()` maintain an internal `#messages` array independent of the DOM: `appendChunk()` mutates the LAST message's content only, and is a no-op if the thread is empty or the last message is a user turn (chunks only ever extend an assistant/other-role reply, never a user message). `clear()` empties both the internal model and the DOM section in one call, with no confirmation step. There is no built-in loading or error state beyond [streaming] itself: a failed generation is a caller-composed pattern (e.g. call `stopStreaming()` and `appendMessage()` an error string), not a distinct prop this primitive exposes.

## `<check-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `switch-ui` | Choose switch-ui instead when the value applies its effect the instant it flips rather than waiting on a form submit (a live setting toggle); check-ui is for a binary opt-in choice, such as a list item or a form-acceptance gate, that takes effect on submit. |

### Screen-reader spec

The host itself carries `role="checkbox"` and `tabindex="0"` (`connected()`): there is no wrapped native `<input type="checkbox">` per ADR-0025, so every ARIA state is manually maintained rather than inherited from the platform. `render()` sets `aria-checked="mixed"` when [indeterminate] is true, else `aria-checked` mirrors [checked] as the literal string `"true"`/`"false"`. `aria-disabled="true"` is set when [disabled], removed otherwise: [disabled] does NOT remove the element from the tab order the way a native disabled checkbox would (no `tabindex` change on disable), so a screen reader/keyboard user can still focus it, but activation is a no-op (`#toggle()` returns early). [label] mirrors to `aria-label`; [labelHidden] (gh#1010) keeps that same aria-label while suppressing the visible `::after` text paint, for compositions that already render the label elsewhere and would otherwise show it twice.

### Behavioral spec

Toggling is click- or keyboard-driven (`#toggle()`, bound to both `click` and Space/Enter via `#onKey`): there is no native form control underneath, so `#toggle()` sets [checked], always clears [indeterminate] (any indeterminate state is a one-shot display value; the moment the user interacts it collapses to a definite checked/ unchecked, never re-enters indeterminate on its own), calls `syncValue()` to keep the underlying form value in sync ([value]-or-`"on"` when checked, empty string when unchecked), and dispatches a bubbling `change` event carrying `{ value, checked }`. [disabled] blocks `#toggle()` entirely (an early return) rather than removing the click/keydown listeners: the guard is inside the handler, not at the wiring layer. There is no loading or error state modeled; a caller surfacing a validation error composes it via the wrapping <field-ui>'s own error slot, not on check-ui itself.

## `<choice-card-ui>`

**Composes:** `<choice-ui>`, `<icon-ui>`
**Allowed children (a2ui):** `<choice-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `card-ui` | Never nest choice-card-ui inside a plain card-ui: choice-card-ui owns its own card chrome (background, border, radius) and IS the card variant for this divided-rows pattern, the same relationship integration-card-ui has to card-ui. |

### Screen-reader spec

choice-card-ui itself stamps no role, aria-label, or any other accessibility attribute: it has no `connected()`/`render()` override at all (`static properties = {}`), so every accessible name, role, and interaction model for the group comes entirely from its `<choice-ui>` children, not from the container. A screen reader encounters the container as a plain grouping element with no group-level semantics of its own; a consumer needing an accessible group label/description should add one directly (e.g. `aria-label` on the host, or a heading positioned before it) rather than expect choice-card-ui to supply it.

### Behavioral spec

choice-card-ui carries no interaction model, no props, and no loading/error/empty state of its own: it is a pure structural wrapper. Visual row-dividing between `<choice-ui>` children is CSS-only (adjacent-sibling border), so choice-card-ui never composes a `<divider-ui>` and has no JS logic to keep dividers in sync as children are added or removed; the divider is a byproduct of DOM adjacency, not a stamped element. Every stateful behavior a consumer might expect from a "choice card": press handling, link vs. button mode, disabled state: lives entirely on the child `<choice-ui>` rows, not on this container.

## `<code-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `richtext-ui` | code-ui's own [editable] mode is the current, actively maintained editing path (Phase 2/3, see class docstring); reach for richtext-ui only for genuinely mixed rich-text content, not for a monospace code buffer. |

### Screen-reader spec

The auto-stamped copy button (omitted entirely in [bare] mode) is a `<button-ui variant="ghost">` (gh#2812: previously a `<div role="button" tabindex="0">` with only a `click` listener, so Enter/Space did nothing; the primitive's host carries role="button" + tabindex and synthesizes click on Enter/Space, per ADR-0025's no-native-button rule). Form-validity failures ([required] with an empty editable buffer) set `aria-invalid="true"` on the host via `#runConstraints()`, cleared once the value satisfies constraints again. code-ui otherwise leaves CodeMirror's own accessibility semantics alone, with one exception (gh#3563): CodeMirror's `contentDOM` carries `role="textbox"` (plus `aria-readonly`/`aria-multiline`) with no accessible name of its own, which Lighthouse's aria-input-field-name flags regardless of [editable] (a read-only textbox still needs a name); `#attachEditor()` labels it from the resolved language slug ("javascript editor" / "javascript snippet", falling back to "code" when [language] named nothing CodeMirror recognizes but [editable] triggered the mount anyway). Beyond that one label, code-ui stamps no other ARIA of its own onto the mounted `EditorView`.

### Behavioral spec

Content-source priority for the static (non-mounted) path is [text] attribute first, then a `<template>` child (parsed via a dedent helper so authored indentation doesn't leak into the rendered code: an escaping-free authoring path for markup-heavy snippets), then plain authored textContent (which requires HTML-entity escaping, same as bare `<pre><code>`). CodeMirror is mounted lazily and only when [language] names a supported language OR [editable] is set: inline instances and `language="diff"`/`[data-line-states]` (diff/line-state coloring) always stay on the static `<pre><code>` path regardless of [editable]. A CodeMirror core-bundle load failure is fatal to the mount attempt (falls back to the already-visible static markup, fires `language-load-error[phase=core]`); a language-pack load failure is recoverable (editor mounts anyway in plain-text/no-highlight mode, fires `language-load-error[phase=language]`): these are the only two distinct failure states, both non-blocking. Once mounted with an active editor focus, render()'s sync-from-[text] path deliberately backs off (`if (this.editable && this.#cmView.hasFocus) return`) rather than clobbering in-flight keystrokes: a caller that wants to force-replace focused content must blur the editor first. `formResetCallback()` and `formStateRestoreCallback()` both restore the doc directly into the live CodeMirror buffer when mounted, or fall back to the `text` property when not: the two code paths intentionally diverge depending on mount state.

## `<col-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `row-ui` | For a horizontal stack, row-ui is the right fit; col-ui only stacks vertically. The two share [gap] / [align] / [justify] / [grow], so the axis is the whole difference. |
| `stack-ui` | For children layered on top of each other, stack-ui is the right fit: it puts every child in ONE grid cell on the z-axis, where col-ui gives each child its own row. |
| `grid-ui` | For a two-dimensional arrangement, grid-ui is the right fit, since col-ui is one-dimensional; [columns] and per-child placement have no col-ui equivalent. |

### Screen-reader spec

No class file stamps any role, label, or ARIA attribute: col-ui is a pure layout primitive with no semantic contribution of its own. Accessible structure comes entirely from its children; a consumer needing a landmark or list semantic must add it explicitly rather than relying on col-ui to supply one.

### Behavioral spec

`render()` only resolves responsive (`@bp`-notated) values for [gap], [align], and [justify] into inline `--col-gap`/`--col-align`/ `--col-justify` custom properties, subscribing to `breakpoint.value` only when at least one of the three actually uses `@bp` notation (no breakpoint subscription cost otherwise). A non-responsive value clears the corresponding custom property so col.css's static rules apply instead: same mutually-exclusive-per-property pattern as block-ui's padding/margin resolution. [align] additionally sets inline `text-align` (start/center/end) alongside the flex `align-items` mapping, so cross-axis alignment affects both flex positioning and text flow together. [columnGap] and [rowGap] are CSS-only: `render()` never reads or resolves them (no `@bp` support, no custom-property write); they apply purely through col.css's static attribute selectors. [grow] is likewise CSS-only via `:scope[grow]`. There is no loading, error, or empty state.

## `<color-area-ui>`

**Composes:** `<slider-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `color-input-ui` | For a compact form field, color-input-ui is the right fit: it OPENS this component in a popover from an inline swatch button, so the two compose rather than compete. Reach for color-area-ui directly when the authoring surface should be inline and always visible. |
| `swatch-ui` | For displaying a color rather than picking one, swatch-ui is the right fit. It stays display-only even with [selectable] set: selection toggles a visual and ARIA state and fires select, it never opens a picker or lets a user choose an arbitrary color. color-area-ui is the picking surface, and it is form-associated. |

### Screen-reader spec

The 2D area is exposed as `role="slider"` with `aria-label="Color area: chroma and lightness"` and a live `aria-valuetext` set every render to the current `oklch(...)` string, so a screen reader announces the resolved color, not raw x/y coordinates. The hue track is a second, independent `role="slider"` (`aria-label="Hue"`) with `aria-valuenow` mirroring the rounded hue degree. Both are keyboard-operable via arrow keys (Shift for a coarser step) without needing the composed `slider-ui` rows, which exist as a second, redundant input surface for the same three channels. `[disabled]` toggles `aria-disabled` on the host. The two copy buttons are `role="button"` with `aria-label="Copy hex"`/`"Copy oklch"`, and their `icon-ui` glyph swaps to `check`/`warning` for 1.5s after a copy attempt to give a non-color-dependent success/failure signal.

### Behavioral spec

[value] parses on every external change (`#parseValue`, skipped when the pending update originated internally via `#internalUpdate` to avoid feedback loops): accepts `#rrggbb`/`#rgb` hex or an `oklch(L C H)` string, including CSS Color L4 "powerless" `none`/`NaN` channels (normalized to 0) and percent-notated L/C. A value that matches neither form logs one console warning per element (WeakSet-deduped) and leaves the picker's prior in-memory OKLCH state unchanged rather than resetting it. Pointer drag on the area or hue track fires `input` continuously and `change` on release; arrow-key adjustment fires `change` directly (no separate `input` phase) with Shift for a coarser step size on every channel. Every commit (`#commit()`) runs consumer constraints BEFORE gamut mapping, in fixed order: `max-l`/`min-l` clamp lightness, `max-chroma` clamps chroma, then `hue-drift-max` clamps hue to within that many degrees of `base-hue` (or the hue resolved at first commit, if `base-hue` is unset) using shortest-circular-path drift math. Any clamp that actually moved a value is collected and reported in one `constraint-clamp` event (`{clamps: [...]}`) per commit: the event fires only when at least one axis clamped, and never blocks the commit itself. After constraints, chroma is gamut-mapped via binary search (8 iterations) against the sRGB gamut before the final hex/ oklch value is computed and both `input`/`change` and the field's own `value` are updated together. There is no loading or empty state; the canvas area redraws (via `ResizeObserver`, absent under SSR per gh#285) on every hue change and on container resize. `onFormReset()` resets [value] to the `#3b82f6` default, not to whatever the DOM attribute originally said. Reach for the `max-chroma`/`max-l`/`min-l`/ `hue-drift-max`/`base-hue` constraint props together when the picker must stay inside a consumer-declared gamut/hue envelope (e.g. keeping a generated theme color within brand bounds) rather than allowing free OKLCH authoring.

## `<color-input-ui>`

**Composes:** `<button-ui>`, `<popover-ui>`, `<color-area-ui>`, `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `color-area-ui` | For an always-visible inline authoring surface, color-area-ui is the right fit. color-input-ui does not replace it, it OPENS it: a popover anchored to an inline swatch button, so reach for color-input-ui when the control must sit in a form row rather than occupy the layout. |

### Screen-reader spec

The trigger splits into two independently-focusable controls under one non-interactive `[slot="trigger"]` wrapper (gh#2509): a `button-ui` swatch (`aria-haspopup="dialog"`, `aria-label` defaults to "Pick a color") that opens the popover, and a sibling `role="textbox"`, `contenteditable="plaintext-only"` span carrying the live hex/oklch text with `aria-label` set to "Hex color value" or "OKLCH color value" depending on [format]: never nested inside the button, since a contenteditable textbox inside a `role="button"` ancestor is an invalid ARIA nesting. [disabled] sets `contenteditable="false"`, `tabindex="-1"`, and `aria-disabled="true"` on the value span independently of the swatch button's own disabled state. The clear button (when [clearable]) is `aria-label="Clear"`, `tabindex="-1"` (mouse-only by design, matching the rest of the clearable-field family) and stays `hidden` unless the field is clearable, non-empty, and enabled. Empty state renders no announced color text: the "No color" placeholder paints via a CSS `::before` pseudo, never as real textContent, so it is not exposed as editable/selectable content.

### Behavioral spec

A picker commit (`change`/`input` from the inner `color-area-ui`) writes [value] as hex or oklch per [format] and re-dispatches the same event type on the host with `{value, hex, oklch, l, c, h}`: the parsed OKLCH channel scalars ride along even though [value] itself is just a string. Typing directly into the value span validates against hex (`#rgb`/`#rrggbb`) or `oklch(L C H)` regexes on Enter/blur; an invalid typed string is rejected and the field reverts to its last committed value rather than committing garbage. While the value span holds focus (`#editing`), `#syncFromValue()` never overwrites its textContent: a render pass triggered by something unrelated (e.g. the popover's own open-state MutationObserver tick) cannot clobber an in-progress typed draft or move the caret; the one exception is a `forceText` write right after a typed commit is normalized, so the on-screen text updates to the canonical form without waiting for blur. [open] is a two-way mirror of the inner popover: setting it opens/closes the popover programmatically, and dismissing the popover via outside-click/Escape syncs [open] back to `false` via a `toggle`-event listener plus a MutationObserver fallback watching the popover's own `open` attribute (both wired once, torn down in `disconnected()`). [clearable]'s clear button click clears [value] to empty, fires `input`+`change` with an all-empty/NaN detail shape (so consumers relying on "detail always carries hex/oklch/l/c/h" don't see bare `undefined`), then returns focus to the swatch trigger; the public `clear()` method instead resets silently, no events, no focus move (the "programmatic reset" contract shared across the clearable- field family). A `hueDriftMax` constraint with no explicit [baseHue] resolves its reference hue from whichever commit path fires first: a drag on the picker or a typed value, so a drag-then-type sequence doesn't silently drop the drag-established anchor. There is no loading or async state; disabled short-circuits the clear handler and freezes the value span but does not close an already-open popover.

## `<combobox-ui>`

**Composes:** `<icon-ui>`, `<button-ui>`, `<spinner-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `select-ui` | Prefer select-ui with [searchable] instead when typeahead is a convenience over a button-first dropdown, not the primary interaction; combobox-ui's typeahead input IS the primary surface. |

### Screen-reader spec

The host is presentational; the actual `role="combobox"` lives on the internal `[data-input]` contenteditable span (no native `<input>`, per ADR-0025), which owns `aria-autocomplete="list"`, a live `aria-expanded` mirrored from [open], `aria-controls` pointing at the listbox id, and `aria-labelledby` defaulting to the internal label span's id. A wrapping `<field-ui>` (or a consumer) setting `aria-labelledby` directly on the HOST is forwarded onto `#inputEl` by an `attributeChangedCallback`-driven sync (gh#2606): otherwise a field-supplied accessible name would land on the wrong element and never reach the actual combobox surface. Each rendered row is `role="option"` with `aria-selected`/`aria-disabled` as appropriate; the active row drives the input's `aria-activedescendant` rather than DOM focus moving into the listbox (WAI-APG list-autocomplete pattern). A `.renderOption` hook that replaces a row's visual content still gets an explicit `aria-label` stamped from the option's plain-text label, so filtering/screen-reader naming never depend on custom markup being legible as text (gh#2740 acceptance criterion).

### Behavioral spec

Options resolve once at `connected()` from declarative children unless `.options` was already set programmatically (the programmatic path always wins if it ran first: no merge). Typing opens the popover automatically (firing `open` with `{trigger: 'typing'}`), re-filters via [filter-mode] (`substring` default, `prefix`, or `fuzzy` character-subsequence matching), and pre-activates the first match so Enter commits it immediately: WAI-APG's list-autocomplete contract. [max-options] caps how many rows RENDER at all; [max-visible-items] independently caps how many rows are VISIBLE before the listbox scrolls (measured from the first row's real height once the popover is open, so it tracks [size]/density automatically): the two limits are orthogonal, not layered. Commit paths differ by trigger: clicking or Enter/Tab on an active row always commits that option; Enter with no active row and no typed text just closes; Enter or Tab with typed text but no match follows a strict precedence: [creatable] fires `create` THEN commits as free text (both fire), plain [free-text] commits the typed value with no `create` event, and neither set fires an `invalid` event (Enter only) and restores the last committed display, discarding the typed query. Every real commit (option click, free-text, clear) fires `change` with `{value, option, source}` where `option` is `null` for a free-text/cleared commit, then closes the popover and fires `close`. [readonly] short-circuits every commit path to just closing the popover: no value change, no `change` event. Backspace on an empty, [clearable] field with a committed value clears immediately (no confirmation) via the same `#clear()` path the clear button and public `clear()` method use, all of which dispatch `change` except the public `clear()`'s `source: 'programmatic'` variant, which still dispatches (unlike color-input's silent public `clear()`). Blur with an unresolved typed query and [free-text] off silently restores the input display to the last committed value: no event fires for that discard. There is no async/loading contract beyond [loading] toggling a spinner row and suppressing the "No matches" empty state while true; fetching is entirely the consumer's responsibility via the `input` event's `{query}` detail.

## `<command-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `admin-command` | command-ui is content-only: it owns neither a backdrop/overlay nor a global Cmd+K listener. For the canonical app-wide command palette, wrap it in admin-command at the shell tier, which supplies the native `<dialog>`, focus management, and the cross-platform shortcut. |
| `menu-ui` | Reach for menu-ui instead for a small, non-searchable popover action menu; command-ui's search/filter input is the whole point of this primitive. |
| `modal-ui` | Reach for modal-ui instead for a generic centered dialog with no search/filter behavior. |

### Screen-reader spec

The search field is a `contenteditable="plaintext-only"` span (no native `<input>`, per ADR-0055/ADR-0025) carrying `role="combobox"`, `aria-autocomplete="list"`, a live `aria-expanded` mirroring [open], and `aria-controls` pointing at the result list's id: matching combobox-ui's own editable-surface pattern rather than introducing a second one. Each rendered result is `role="option"`, with a disabled source `<option>` carrying `aria-disabled="true"` and excluded from keyboard/pointer navigation (`:not([aria-disabled])` in every query). Placeholder text paints via a `[data-empty]::before` CSS pseudo, never as literal textContent, so it is never announced as if it were typed content. There is no `aria-activedescendant` wiring: the active option is tracked purely via a `[data-active]` DOM attribute rather than an ARIA-linked pointer from the combobox to its active option, a known gap relative to the full ARIA 1.2 combobox pattern.

### Behavioral spec

Options resolve from declarative `<option>`/`<optgroup>` children (parsed once via a wrapper-piercing walk that also descends into `display:contents`/`role="presentation"` wrapper spans, the same FB-98-class fix applied to select-ui/combobox-ui) unless `.options` is set programmatically before first render, in which case the programmatic value wins outright: there is no merge between the two sources. Typing into the search span filters items by case-insensitive substring match against label, value, or `data-keywords` (`#matches()`); a filtered, empty result set renders either the consumer's own `slot="empty"` content (captured once at first render, reused on every subsequent empty state: a fix for a prior yaml-drift finding where an "overridable empty state" was documented but never actually wired) or the literal fallback text "No results found.": the two are mutually exclusive, not layered. With an empty query and no active filter, a "Recent" group renders first (max 3 entries, most- recent-first, pushed to on every `select`), built by resolving each recent value against the live item set and silently dropping any value that no longer resolves (e.g. removed via `setItems()`) or that is now disabled: a recent entry is never rendered stale. Arrow Up/Down move the active item circularly (wrapping past either end back to the opposite end) among enabled options only; Enter and Tab both commit the active item via `#selectActive()` (Tab does not move focus elsewhere first: it's treated as a synonym for Enter here, not the standard focus-advance); Escape fires a bubbling `dismiss` event instead of closing anything itself: [open] is caller-owned, this component never sets it to false on its own. A `select` event always carries `{value, label, category}`, where `category` is the parent `<optgroup>`'s label (or `''` for an ungrouped item): resolved from the internal `#items`/`#itemByEl` map rather than read back off the DOM, since the rendered option element itself carries no group information. Setting [open] to true schedules (via `requestAnimationFrame`) a focus of the search span on the next paint; there is no loading or async state: filtering and rendering are fully synchronous against the in-memory item list.

## `<context-menu-ui>`

**Composes:** `<menu-item-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `menu-ui` | It is not for a button-triggered menu: use menu-ui for that. context-menu-ui is pointer-anchored (opens on `contextmenu`/ long-press), not button-anchored. |
| `popover-ui` | Reach for popover-ui directly instead for arbitrary popover content that isn't a menu; context-menu-ui always renders a `role="menu"` surface of `<menu-item-ui>` children. |

### Screen-reader spec

The cloned popover surface carries `role="menu"` and `aria-label` mirrored from the host's own `aria-label` attribute (falls back to the literal string "Context menu" when unset): set `aria-label` on `<context-menu-ui>` itself to name the menu for screen readers, not on any child. On open, focus moves via `queueMicrotask` to the first non-`[disabled]` `<menu-item-ui>` inside the surface (matches `menu-ui`'s own focus-on-open behavior: same menu vocabulary, same focus contract). Escape closes the menu and returns focus to the target that opened it (`#lastTarget?.focus?.()`); an outside pointerdown or an item select close the same way but WITHOUT restoring focus to the target: only the Escape path does. Keyboard-only users can open the menu without a pointer at all: Shift+F10 or the dedicated `ContextMenu` key, while focus sits on a bound target, opens the menu positioned at that target's center (`openAt(null, null, target)` computes the center from `getBoundingClientRect()`), giving keyboard users the same entry point mouse users get from a physical right-click.

### Behavioral spec

Two independent trigger paths converge on the same `#show(x, y)`: a real `contextmenu` event (right-click, unless `e.defaultPrevented` by something upstream) and a synthetic touch long-press (`touchstart` → `setTimeout(longPressMs)`, cleared by `touchend`/ `touchcancel` if the press ends early). Both are gated by `#hasItems()`: with zero `<menu-item-ui>` children, neither path calls `preventDefault()`, so the native OS/browser context menu still fires through untouched; the "no target binding" anti-pattern below is the no-target case, but a target with no items is silently the same dead end. Items are never moved into the popover: `#show()` clones each `<menu-item-ui>`/`<menu-divider-ui>` child (`cloneNode(true)`) into a detached `[popover=manual]` surface appended to `document.body` fresh on every open (`replaceChildren()` first), so the light-DOM children under `<context-menu-ui>` stay the authoring source of truth and any dynamic item mutation must happen there, not on a previously-opened surface. Target resolution is exclusive, not merged: if [target-selector] (or its deprecated [for] alias) resolves to a non-empty selector, wrap-mode's default-slot scan never runs at all: a component author supplying both a selector AND a wrapped child gets selector-only binding, silently. Close reasons are exactly three: `"select"` (surface click on a non-disabled item), `"outside"` (`pointerdown` outside the surface, listener attached one frame after open via `requestAnimationFrame` so the opening click/tap itself can't immediately self-close), and `"escape"` (which also `stopPropagation()`s so Escape doesn't bubble to an ancestor). There is no loading or disabled state modeled: the component is a stateless behavioral wrapper (`display: contents`) with exactly `idle`/`open`.

## `<date-range-picker-ui>`

**Composes:** `<calendar-grid-ui>`, `<button-ui>`, `<icon-ui>`, `<popover-ui>`, `<text-ui>`, `<divider-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `calendar-picker-ui` | date-range-picker-ui replaces two adjacent calendar-picker-ui instances hand-synchronized with JS: that composition misses the preset rail, the range-integrity validation (`to >= from`, min/max clamping), and this primitive's unified start/end selection model. |

### Screen-reader spec

The host itself carries `role="combobox"` with `aria-haspopup="dialog"` and `aria-expanded` mirrored live from [open]; the popover panel carries `role="dialog"` and is labeled either by the host's own `[id]` (via `aria-labelledby`, when the host has one) or the static `aria-label="Date range picker"` fallback. Opening moves focus, one microtask later (so the grid is paint-stable), to the first non-disabled, non-outside-month day cell in the START pane: never the trigger, never the preset rail. Tab is trapped inside the popover the entire time it's open: `#onPopoverKey`'s own focus-cycle (not the browser's native tab order) wraps from the last focusable descendant back to the first and vice-versa with Shift+Tab, built fresh each keypress from `button-ui`/`calendar-grid-ui`/day-cell/`[tabindex="0"]` descendants: the trigger sits OUTSIDE that cycle by design. Escape closes from two independent listeners: one scoped to the popover (`#onPopoverKey`, stops propagation) and one on `document` (`#onDocKey`, a "belt-and-braces" fallback for when focus has drifted to the trigger or elsewhere), and returns focus to the trigger either way. The [clearable] clear affordance (`aria-label="Clear"`) is permanently `tabindex="-1"`: reachable by pointer/touch only, never by keyboard Tab: the same deliberate shape combobox-ui's own clear button uses (gh#2204), not a date-range-picker-specific gap.

### Behavioral spec

Selection is ONE surface spanning both calendar panes (gh#948): a click in EITHER grid always writes to whichever half of the range is still pending, regardless of which pane (start-month / end-month) physically received the click. First click sets a pending `from` with empty `to` and fires `input`; a second click sets `to` and immediately calls `#commitRange()`: UNLESS the second click lands on a date earlier than the pending `from`, in which case the selection restarts there instead of committing a reversed range (industry-standard dual-pane behavior, not an `invalid` event). `#commitRange()` itself still guards order/min/max and fires `invalid` (never committing) on any of: `to < from` (reason `reversed`), `from < [min]` (reason `below-min`), `to > [max]` (reason `above-max`). Presets and manual selection commit through the SAME `#commitRange()` path, so both fire an identical `change` shape. Comparison mode has an asymmetry worth knowing: clicking a primary preset commits through `#commitRange()` (fires `change`, closes the popover) exactly like a manual selection, but clicking a comparison-cluster preset (`[data-comparison-preset- label]`) only computes the derived range and assigns `this.rangeCompareValue` directly: it does NOT fire `change` and does NOT close the popover, unlike every other commit path in this component (see the Findings note in this PR: a consumer that only listens for `change` to read `compareValue` will silently miss a comparison-preset pick that happens after the primary range is already committed). [clearable]'s clear button is visible only once a FULLY committed range exists (`parseRange(this.value)` is truthy) and neither [disabled] nor [readonly] is set: an in-progress `from`-only pending selection never shows it. There is no distinct loading state; [disabled] blocks the trigger from opening the popover at all (trigger stays rendered but inert), while [readonly] still opens the popover and allows keyboard/grid inspection but blocks preset and day-cell clicks from mutating anything (`#onPopoverClick`'s early readonly branch).

## `<demo-toggle-ui>`

**Composes:** `<switch-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `switch-ui` | For a boolean the user is setting, switch-ui is the right fit. demo-toggle-ui composes one but carries no form value: its switch swaps which of two content slots is visible, so nothing it toggles is submitted or read back. |

### Screen-reader spec

demo-toggle-ui sets no ARIA role or label of its own on the host: it is a plain, unstyled-for-a11y container. The switch is a real `<switch-ui role="switch">` (that component's own contract), but the stamped instance here (`static parts.bar`) never passes switch-ui's own [label] prop, so switch-ui's template never renders its `<span slot="label">` child and the switch exposes NO accessible name at all: a screen reader announces bare "switch, off/on" with no indication it toggles between [label-on]/[label-off] (see the Findings note in this PR: the adjacent `<text-ui data-demo-toggle-label>` is visually adjacent but never wired to the switch via `aria-labelledby` or switch-ui's own `[label]`). The active/inactive slot swap itself (`display: none` in default mode, `visibility: hidden` in overlay mode) carries no live-region announcement: a screen-reader user who isn't watching the visible label has no independent signal the content changed.

### Behavioral spec

On connect, state resolves once: an explicitly authored `[state]` wins (coerced to exactly "on" or "off", any other value silently becomes "off"); otherwise `[initial]` seeds it (default "off"). The embedded `<switch-ui>` is the single interaction surface: its own `change` event is caught at the host boundary and `stopPropagation()`- ed, then re-dispatched as demo-toggle-ui's own `change` with `{state}`, so a consumer never sees two events per toggle. The public `toggle()` method mirrors this same `change` dispatch for programmatic driving, bypassing the embedded switch entirely. Slot visibility, NOT a `[data-code]` block, is what the state toggle actually drives: CSS keys off `[state="on"|"off"]` to show/hide `[slot="on"]`/`[slot="off"]` (default mode: `display:none`; `[data-mode="overlay"]`: `visibility:hidden`, both slots stay laid out so nothing reflows). The existing `a2ui.rules` entry "shows/hides the [data-code] block when toggled" (above) does not match this component's actual DOM contract: `[data-code]` appears nowhere in demo-toggle.class.js or demo-toggle.css; see the Findings note in this PR. `render()` also syncs the embedded switch's `checked` boolean to the resolved state on every render, so a state change driven through `toggle()` or an authored `[state]` attribute (not just a manual switch click) still visually flips the switch itself. It is a doc-tooling primitive, not a product-surface control: restrict it to component demo pages (`<name>.html`) and docs surfaces, never `apps/`.

## `<description-list-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `fields-ui` | description-list-ui is for READ-ONLY key:value pairs in dense detail views, such as user profile fields, metadata panels, or audit-log entries. It is not for editable forms: use fields-ui for input and validation instead. |

### Screen-reader spec

The host carries `role="group"`, not the native implicit `list` semantics a bare `<dl>` would otherwise carry: deliberate: ARIA 1.2's `list` role requires `listitem` children, and `<dt>`/`<dd>` aren't `listitem`s, so `group` is the accurate fit for a labeled-pairs grouping (per the component's own connect-time comment). No `aria-label` is set on the host by the component itself: an author wrapping description-list-ui in a labeled `<section>`/`<field-ui>` (or setting `aria-label` directly) is what gives the group an accessible name; description-list-ui alone announces only as an unnamed group. Because the underlying markup is real `<dt>`/`<dd>` (native HTML semantics preserved, per the component's own description), screen readers get standard definition-list term/description pairing with no extra ARIA authored on the pairs themselves.

### Behavioral spec

[items] and declarative `<dt>`/`<dd>` children are mutually EXCLUSIVE per render, not composable: `render()` calls `#resolveItems()` first, and if that resolves to a non-empty array (from the JS `items` setter OR a parsed `items=` JSON attribute, in that priority order), it REPLACES `this.innerHTML` wholesale with freshly generated `<dt data-dl-term>`/`<dd data-dl-desc>` pairs: any hand-authored `<dt>`/`<dd>` children present alongside a non-empty [items] are wiped on the very first render, not merged. Only when [items] resolves to empty does `render()` return early and leave declarative children untouched. `render()` re-runs on every reactive-property change ([layout]/[align] are the only two reflected properties), so with [items] populated, the innerHTML rebuild happens on every layout/align toggle too: safe because the same array is used, but worth knowing if a consumer ever hand-edits a child node after initial render expecting it to survive a later layout/align change. Term/description text is escaped via a minimal `&`/`<`/`>` replace (not full HTML-entity escaping) before being written through `innerHTML`: sufficient to block tag injection but not a general-purpose sanitizer.

## `<display-field-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `field-ui` | display-field-ui wraps NO interactive surface: no focus, no contenteditable, no form participation, no [for]/id minting. Reach for field-ui plus input-ui instead when the value is genuinely editable. |
| `input-ui` | Reach for field-ui plus input-ui instead of display-field-ui when the value is genuinely editable, not a read-only display value. |
| `stat-ui` | Reach for stat-ui instead of display-field-ui for a prominent metric or KPI, rather than a labeled read-out value. |
| `description-list-ui` | Reach for description-list-ui instead of display-field-ui when there are MANY read-only pairs to lay out at once, rather than one labeled field. |

### Screen-reader spec

The host is `role="group"` with `aria-labelledby` pointed at the auto-minted (or author-supplied) `[slot="label"]` element's own monotonic id (`display-field-label-N`): this is what lets a foreign, otherwise-unlabelable element (an iframe mount with no `label`/`for` surface of its own) still get announced with a real caption: the group itself carries the accessible name, not the iframe. Setting [hint] wires a second id (`display-field-hint-N`) into `aria-describedby` on the host; clearing [hint] removes both the element and the `aria-describedby` attribute rather than leaving an empty description target. The leading [icon] is always stamped with `aria-hidden="true"`: it never contributes to the accessible name or description, purely decorative. Clearing [label] hides the label element (`hidden = true`) and removes `aria-labelledby` entirely, leaving the group unnamed: authors should treat [label] as effectively required (the yaml already marks it `required: true`) since an unlabeled display-field-ui gives assistive tech nothing to announce beyond "group".

### Behavioral spec

[value] and default-slot (foreign-element) content are mutually exclusive, resolved fresh on every render via a live DOM query (`:scope > :not([slot])`, i.e. any direct child carrying no `slot` attribute at all): when that query finds ANY such child, it's treated as foreign content and wins outright: the auto-stamped `[slot="value"]` span (if one exists from a prior render) is removed and the [value] attribute is never displayed, even if both are set simultaneously. Only when no un-slotted child exists does the component mint/update a `[slot="value"]` span from [value]'s text. Because this check re-runs every render (not just on connect), toggling between the two modes at runtime, e.g. removing a previously-slotted foreign child, is fully supported and falls back to [value] on the next render. The label element gets special connect-time placement: if the component has to auto-create it (no author-supplied `[slot="label"]` present), it's `insertBefore(this.firstChild)`ed so it reads FIRST for assistive tech regardless of where [value]/[hint]/[icon] end up in DOM order (visual placement is grid-area driven, independent of DOM order): an author-supplied `[slot="label"]` child, by contrast, is used wherever it already sits in the light DOM and is NOT reordered to the front, so an author placing it after other children gets a DOM-order/AT-order mismatch even though the visual grid still looks correct. On a disconnect→reconnect DOM move (e.g. a keyed-list reorder via `UIElement.reconcile`'s `insertBefore`), `connected()` re-binds any slot children still physically present via query-or-leave-null rather than re-creating them: without this, `render()` would see null internal refs and stamp duplicates alongside the surviving nodes.

## `<divider-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `menu-divider-ui` | Inside a menu or popover, reach for menu-divider-ui instead, since it carries menu-specific token spacing, not this primitive's generic rule. |

### Screen-reader spec

The host carries `role="separator"` always. `aria-orientation` is set to `"vertical"` only when [vertical] is true; the attribute is omitted entirely for the default horizontal orientation rather than ever set to `"horizontal"`: separator's ARIA-spec default orientation IS horizontal, so an absent attribute and an explicit `"horizontal"` are equivalent, and this component relies on that default rather than stating it. [label] text, when present, renders inside a `[slot="label"]` span that is a plain content child of the separator: it is not wired into any `aria-label`/`aria-labelledby` on the host itself, so a screen reader landing on the separator announces it via its own text content (the label span), same as any other inline content inside a labeled region: there is no separate accessible-name plumbing beyond that.

### Behavioral spec

[label] is resolved every render, not just on first stamp: a non-empty [label] lazily creates (or re-finds) a single `[slot="label"]` span and sets its `textContent`; clearing [label] back to empty removes that span outright rather than leaving an empty node, so toggling [label] on and off at runtime is fully supported and idempotent (re-running render() with the same label value re-finds the existing span rather than creating a duplicate). [grow] is NOT a JS-observed property at all: it is the shared global `[grow]` CSS attribute (`flex: 1; min-width: 0`, defined once in `styles/api/layout.css`) that divider.css does not locally override, so setting `grow` on a `<divider-ui>` works purely through that shared attribute selector with zero involvement from this component's own `static properties` or `render()`: it is not reflected, tracked, or validated here. Do not stack multiple consecutive dividers; restructure the section instead.

## `<drawer-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

| Role | Fit | When | Requires |
|---|---|---|---|
| `detail-view` | primary | dataShape: record; cardinality: one; intent: inspect,edit | side |

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `modal-ui` | Drawer-ui is for edge-anchored detail/edit panels and inspector flows opened from a row/list item, never for centered confirmations, which belong in modal-ui. |

### Screen-reader spec

Uses a native `<dialog>` (via `showModal()`) for the platform's own focus trap, Escape-to-dismiss, and `::backdrop`: accessibility semantics ride on browser-native dialog behavior rather than hand-rolled ARIA. [text] mirrors onto BOTH the host's own `aria-label` and the internal `<dialog>`'s `aria-label` (cleared on both when [text] is empty): a drawer authored with only bare `<header>`/`<section>` children and no [text] gets no accessible name from the component itself; author an explicit heading or set [text] to name it. On open, `showModal()` hands focus management to the browser (typically the first focusable descendant); on close, focus explicitly returns to whatever had focus before the drawer opened (`#previousFocus`, captured at `showModal()` time). The stamped close button always carries `aria-label="Close"` regardless of [text].

### Behavioral spec

Three independent close paths converge on the SAME native `dialog.close()`/`close` event, each first stamping a `#closeReason` that becomes `detail.reason` on the drawer's own `close` CustomEvent: the close button (`close-button`, caught via a `press` listener on the host itself matching `[slot="close"]`), the browser's native `cancel` event i.e. Escape (`escape`, suppressed entirely when [permanent] is set: `open` is never flipped false), and a direct click on the `<dialog>` element itself i.e. the backdrop, since the panel is a child that would receive its own click target (`backdrop`, also suppressed under [permanent]). Setting `.open = false` from consumer code with none of those three paths having fired defaults the reason to `programmatic`: reset back to that default immediately after every dispatch so a later close never inherits a stale prior reason. Closing is animated, not immediate: `#animateClose()` stamps `[data-closing]` (carrying the CSS transition spec), forces a synchronous reflow, THEN removes `[data-open]`: ORDER matters here, batching both attribute changes into one style update was observed to make Safari skip the slide-out animation outright, and only calls the real `dialog.close()` after `--drawer-duration` ms. The `opened` event mirrors this on the open side: fired `--drawer-duration` ms after `showModal()`, and explicitly cancelled (timer cleared) if the drawer closes again before that timer fires, so a rapid open→close sequence never emits a stale `opened` after the drawer is already sliding shut. A consumer that wipes the host's children via `.innerHTML` mid-session doesn't break the drawer outright: `render()` re-stamps a fresh `<dialog>` via `ensure('dialog')` and re-binds all three dialog listeners onto the NEW node every time the dialog part identity changes, specifically so `#syncDialog()` never calls `showModal()` on a detached, orphaned dialog, but the previously-authored header/section/footer skeleton is still destroyed in the process (see the anti-pattern below). Note for the record (Findings, this PR): the yaml's [text]/[side]/[size]/[permanent] props all correspond to `reflect: true` properties in drawer.class.js's `static properties`, but only [open]'s prop entry above carries `reflect: true`: a yaml documentation gap (this field isn't build-consumed downstream, so it's cosmetic, not a runtime bug), left uncorrected per this batch's docs-only scope.

## `<drilldown-ui>`

**Composes:** `<icon-ui>`, `<skeleton-ui>`, `<empty-state-ui>`, `<input-ui>`, `<button-ui>`
**Allowed children (a2ui):** `<breadcrumb-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tree-ui` | Drilldown-ui is distinct from tree-ui: tree-ui is inline expand/collapse, right for file-tree shapes, wrong once the expanded tree becomes a wall of rows. |
| `nav-ui` | Drilldown-ui is distinct from nav-ui: nav-ui is flat app navigation, not hierarchical drill-in. |
| `context-menu` | Drilldown-ui is distinct from context-menu: context-menu is a transient popover submenu, not a persistent content panel. |

### Screen-reader spec

The host carries `role="group"`; the row panel carries `role="listbox"` with `aria-busy` mirroring the current level's loading state and `aria-label` set to the level's own title (or the literal "Items" when untitled); each row is `role="option"` with a roving `tabindex` (exactly one row is `tabindex="0"` at a time, every other row `tabindex="-1"`): standard listbox keyboard-navigation shape. A visually-hidden `aria-live="polite"` region announces the level title + item count on every level change (drill-in, back, filter, or a programmatic `.path` assignment), so a screen-reader user gets an audible cue when the panel's content is swapped out from under them even though the DOM node itself doesn't change identity. The back button's `aria-label` is dynamic: `"Back to <parent label>"` when the parent level has a label, else the bare `"Back"`, and the button is `hidden` entirely at the root level rather than disabled. Per-row `aria-selected` is set once at row creation and always `"false"`: it is NEVER updated to `"true"` when a row becomes the active (roving-tabindex, focused) option; see the Findings note in this PR (ARIA listbox authoring practice expects the active option to carry `aria-selected="true"`, and this component's own `#activate()` only ever touches `tabindex`, never `aria-selected`).

### Behavioral spec

`path` is a hand-rolled attribute reflection, not the generic `reflect:` mechanism (array values can't round-trip through `String(v)`): it has its own `observedAttributes`/ `attributeChangedCallback` pair, JSON-encoding/decoding both directions, with an `#applyingPathAttr` re-entrancy guard so the component's own `setAttribute('path', …)` write doesn't loop back through `attributeChangedCallback` as if the consumer had set it. Setting `.path` directly (not via drill/back) restores straight to that nested level WITHOUT animating through intermediate levels: the deep-link/URL-restore path, and is fully synchronous (state update + attribute reflection + re-render all happen in the same call, no scheduler dependency), which is what lets `#focusOnNextRender` (set immediately before a path assignment) always be consumed by the render that assignment triggers. `children` resolution has three shapes handled uniformly through `#resolveChildren()`: a plain array resolves synchronously; a function returning an array/Promise resolves lazily, with in-flight loads deduped via a `WeakMap` keyed by the item object itself (so re-rendering mid-fetch doesn't refire the loader) and resolved results cached the same way (so drilling back into an already-loaded branch never re-fetches); a function that throws synchronously silently resolves to an empty array. Reach for a `children` FUNCTION (not a plain array) only when a level's contents are genuinely fetched lazily: sync arrays resolve immediately with no loading level ever shown. `select` fires on leaf activation always, and on branch activation ONLY when [select-on-drill] is set: branches otherwise only drill in, never fire `select`. `navigate` fires on every level change regardless of cause (drill, back, filter has NO effect on `navigate`: filtering only affects which rows RENDER within the current level, it never changes `.path` itself). Keyboard: ArrowUp/ArrowDown move the roving active row; ArrowRight on a branch row drills in (mirrors Enter/Space); ArrowLeft OR Backspace calls `back()`, but the guard that excludes typing-in-the-filter-input from triggering navigation only checks `e.key === 'Backspace'`, not `e.key === 'ArrowLeft'` (see the Findings note in this PR): a user moving their text cursor left inside the filter field with the ArrowLeft key navigates the drilldown up a level instead, losing their in-progress filter query: Backspace is correctly guarded in the identical code path, ArrowLeft is not.

## `<embed-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `image-ui` | Embed-ui is not for static media: use image-ui for raster/vector images, not an iframe wrapper. |
| `icon-ui` | Embed-ui is not for static media: use icon-ui for icons, not an iframe wrapper. |

### Screen-reader spec

The component sets no ARIA role, label, or live-region behavior of its own on the host or the iframe: accessibility of the embedded content is entirely the embedded document's own responsibility (the standard `<iframe>` contract: a screen reader announces "iframe" and descends into whatever accessibility tree the embedded origin provides). embed-ui itself contributes no accessible name for the frame: an author wrapping it in a labeled `<figure>`/`<section>` (as the canonical example does, pairing it with a `<header>` heading + description) is what gives it context for assistive tech; there is no [alt]/[title]/[aria-label] prop on this component to set one directly.

### Behavioral spec

The sandbox attribute is computed fresh every render from [allow-same-origin] alone: `"allow-scripts"` when unset (the safe default), `"allow-scripts allow-same-origin"` when set. `allow-scripts` itself is UNCONDITIONAL, there is no prop path to omit it, so every embed can run script by design (needed for common video-player embeds); [allow-same-origin] is the one opt-in axis, and setting it alongside the always-on `allow-scripts` is the specific pairing gh#2431 called out as a Chrome-flagged anti-pattern (it lets the embedded document script its way out of the sandbox and read/write this page's own origin): reserve it for a same-origin or fully trusted embed only. [aspect] and [height] are mutually exclusive per render, not layered: when [aspect] is set, the host's own `aspect-ratio` CSS property drives sizing and the iframe fills it at 100%×100%: [height] is read nowhere in that branch; when [aspect] is empty, [height] drives the iframe's pixel/CSS height directly (a bare numeric string like `"400"` is treated as pixels via a regex test; any other CSS length string like `"50vh"` passes through verbatim). [width] applies in BOTH branches identically (host inline style, iframe stays 100%). Note for the record (Findings, this PR): the yaml's own prop key for this attribute is spelled `allow-same-origin` (kebab-case) rather than the `allowSameOrigin` camelCase every other multi-word prop in this catalog uses as its yaml key (e.g. `targetSelector`, `dueAt`, `noPresets`, `selectOnDrill`): `scripts/build/components.mjs` forwards yaml prop KEYS onto the sidecar's JSON Schema `properties` verbatim with no case conversion, so `embed.a2ui.json`'s schema literally has an `allow-same-origin` property while the actual JS class property (and the emitted `.d.ts`, which independently camelCases it) is `allowSameOrigin`: an A2UI document authored consistently with every sibling prop in the catalog (`"allowSameOrigin": true`) would not match this component's own schema. Left uncorrected per this batch's docs-only scope (renaming the yaml key is a structural sidecar-shape change, not a docs edit). embed-ui is also not a general cross-origin app-integration mechanism: for a first-party AdiaUI app route, use direct component composition instead of iframing it, and never embed one first-party route inside another via embed-ui.

## `<empty-state-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

| Role | Fit | When | Requires |
|---|---|---|---|
| `empty-state` | primary | dataShape: none | (none) |

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `skeleton-ui` | Empty-state-ui is for a genuine zero-data state, not a loading state; use skeleton-ui while data is still being fetched. |
| `alert-ui` | Empty-state-ui replaces the entire content area for a full-section zero-data or failure state; for an inline notice inside otherwise- populated content, use alert-ui instead. |

### Screen-reader spec

empty-state-ui sets NO ARIA role, live-region, or accessible-name wiring of its own anywhere in its lifecycle: it is plain content in the accessibility tree, relying entirely on its own visible icon/heading/description text nodes to convey meaning, same as any other static markup. A consumer that swaps a populated list for an empty-state-ui at runtime (the common zero-data transition) gets no automatic screen-reader announcement of that content swap from this component itself: if that announcement matters, the consumer's own surrounding markup needs to supply the live region (this component does not gate or wire one). The leading icon carries no `aria-hidden`/label handling either: whatever `<icon-ui>` itself contributes to the accessibility tree by default is what ships here, unmodified by empty-state-ui.

### Behavioral spec

Icon/heading/description each follow the SAME stamp-once-then-toggle policy, independently: `connected()` looks for an author-supplied `[slot="X"]` child first, and only creates + stamps its own element when none exists: a stamped element is tagged (`dataset.emptyStateStamped = '1'`) specifically so `render()` can tell the two cases apart. `render()` then only ever MUTATES elements it stamped itself (checked via that tag): an author-supplied `[slot="icon"|"heading"|"description"]` override is NEVER touched by subsequent [icon]/[heading]/[description] attribute changes; it is the source of truth for that slot from the moment it's detected, permanently (ADR-0010). For a stamped element, an empty attribute value hides it (`hidden = true`) rather than removing it from the DOM: toggling an attribute from empty back to non-empty re-shows the same node rather than re-creating it. [minimal] is read only ONCE, at connect time, to decide whether the auto-stamped icon gets `size="lg"`: changing [minimal] after connect does NOT retroactively resize an already-stamped icon (it only affects the surrounding layout chrome via CSS, which IS live); this is a connect-time-only decision baked into the stamped icon's own `size` attribute, not a per-render sync. The [slot="action"] CTA is entirely user-provided: empty-state-ui never stamps or removes anything in that slot itself.

## `<feed-ui>`

**Composes:** `<button-ui>`, `<feed-item-ui>`
**Allowed children (a2ui):** `<feed-item-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `alert-ui` | Do not use feed-ui for content that belongs in the document's normal flow: reach for alert-ui instead. feed-ui exists specifically for the Popover-API top-layer placement (`[popover="manual"]` when supported) that keeps notification stacks out of z-index wars with modals and drawers. |
| `text-ui` | Do not use feed-ui for a status message that belongs in the document's normal flow: reach for an inline text-ui instead. feed-ui is for top-layer, out-of-flow notification stacks only. |

### Screen-reader spec

`connected()` stamps `role="region"` and `aria-label="Feed"` on the container unless either is already set. Per-item roles are inferred by `UIFeedItem.render()` from the prop shape (spec §2.4), not set by the container: `duration <= 0` (or falsy) is sticky; sticky + a danger/ warning `variant` gets `role="alert"`; sticky + a non-empty `action` gets `role="alertdialog"` plus `aria-modal="false"` (focus-trapped but not page-blocking); everything else gets `role="status"`. `aria-live` mirrors that split: `"assertive"` for alert/alertdialog, `"polite"` otherwise, so auto-fade toasts announce passively while action-required items interrupt.

### Behavioral spec

The container itself has no interaction model: it's a passive lane that lazily mounts into `document.body` via `UIFeed.get(position)` on first `post()` for that position, and tears itself down via `releaseContainerIfEmpty()` once its last child item exits (promoting the oldest `[queued]` sibling first, per the Phase-2 max-queue FIFO). `[max]` caps simultaneously VISIBLE items per lane; `UIFeed.post()` marks anything beyond that cap `[queued]` and it becomes visible only as visible items dismiss and vacate a slot. `UIFeed.clear(position)` dismisses every item in a lane (delegates to each item's own `dismiss()`, so per-item exit transitions still run); `UIFeed.purge()` is a hard teardown of every container across all positions: test cleanup, addressing the L-B4 container-leak audit finding. There is no loading or error state at the container level; those live on `feed-item-ui`.

## `<field-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `check-ui` | Do not wrap check-ui in field-ui: check-ui already carries its own [label] attribute, and wrapping it produces a doubled or right-justified affordance. |
| `switch-ui` | Do not wrap switch-ui in field-ui: switch-ui already carries its own [label] attribute, and wrapping it produces a doubled or right-justified affordance. |
| `radio-ui` | Do not wrap radio-ui in field-ui: radio-ui already carries its own [label] attribute, and wrapping it produces a doubled or right-justified affordance. |

### Screen-reader spec

Single mode mints an id on the slotted control when missing and binds a real `<label for="…">`, PLUS mirrors the association as `aria-labelledby` on the control: label[for] alone does not reliably name a custom element carrying its own ARIA role, so both are needed for the label to actually appear in the accessibility tree as the control's name. This stamp is withheld for check-ui/switch-ui/radio-ui/textarea-ui (independently self-naming) so a more specific consumer-authored name is never silently overwritten. [hint] is wired into the control's `aria-describedby` so a screen reader announces it after the name/value, but [hint] is suppressed the moment `error` is set, so a control never gets both hint and error read back to back; only the error message survives. The error message itself is NOT `aria-describedby`: it renders with `role="alert"`, so a screen reader interrupts and announces it immediately when the slotted control's `error` property changes, rather than waiting for the user to navigate onto it. In `group` mode the host carries `role="group"` + `aria-labelledby` pointing at the shared label instead of any per-control `[for]`; because of that, EVERY member control must carry its own `aria-label` (placeholder is never a name): a group whose member has no `aria-label` announces as an unnamed control inside a named group, which is confusing but not silent, so this is easy to miss in manual testing and must be checked explicitly.

### Behavioral spec

There is no field-level `error` prop: `error` is mirrored FROM the slotted control's own `error` property (`UIFormElement.error` is the source of truth), so setting an error is always `<input-ui error="…">`, never `<field-ui error="…">`; field-ui only renders whatever message the control already carries, in danger styling, below the control. Setting `error` suppresses `[hint]` entirely: a field never shows both at once, so an author relying on a persistent hint ("must be 8+ characters") loses it the instant validation fails, and should fold that constraint into the error message itself rather than assume the hint survives. There is no field-level loading state: a control mid-async-validation (e.g. a debounced username-availability check) is a caller-composed pattern using the control's own affordances (a trailing spinner slot, or setting `error` only after the check resolves), not a field-ui prop. `group` and `inline` are mutually exclusive layout modes, not stacking behaviors: setting both silently drops `inline` (`group` wins) rather than erroring, so a consumer who intended an inline group gets the stacked group layout instead with no warning.

## `<fields-ui>`

**Composes:** `<field-ui>`
**Allowed children (a2ui):** `<field-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `field-ui` | Not an alternative: fields-ui is the CONTAINER for field-ui children, laying them out on a shared six-column grid and propagating [inline] to each. A single labelled control needs field-ui alone; reach for fields-ui only to group several. |

### Screen-reader spec

`static template = () => null`: fields-ui renders no markup of its own and neither `connected()` nor `render()` sets a `role` or `aria-*` attribute on the host. It contributes no accessibility semantics beyond what each `<field-ui>` child already provides; a screen reader traverses the group exactly as it would any other container, in DOM order.

### Behavioral spec

`connected()` runs `#syncInline()` once, then installs a `MutationObserver` (guarded by `typeof MutationObserver !== 'undefined'` for SSR shims per gh#285) watching `childList` on itself; the observer callback only re-runs `#syncInline()` when an added/removed node is itself a direct `field-ui` child (`#hasFieldChild()`), so unrelated childList churn is ignored. `render()` also calls `#syncInline()` on every attribute→property reflection pass, since `[inline]`'s effect is propagation to children rather than host markup. `#syncInline()` is unconditional both ways: when `[inline]` is set it stamps `inline` on every direct `field-ui` child that lacks it; when unset it strips `inline` from every child regardless of whether fields-ui was the one that set it: the class comment states this plainly as the documented contract, since there is no way to distinguish host-driven from author-set `inline` on a child. `disconnected()` disconnects and nulls the observer. There is no loading, error, or empty state. Setting `[inline]` on the host does not affect column span: per-field `[rows]` still wins on each child regardless of layout mode. Skip the wrapper for a single standalone field; for fields that belong to visually distinct groups, nest separate `<fields-ui>` blocks instead of fighting one shared grid across unrelated rows.

## `<footer-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chat-shell` | Do not substitute footer-ui for bespoke shell-tier chrome: chat-shell carries its own footer-equivalent module with shell-specific slot vocabulary this stub does not replicate. |
| `editor-shell` | Do not substitute footer-ui for bespoke shell-tier chrome: editor-shell carries its own footer-equivalent module with shell-specific slot vocabulary this stub does not replicate. |

### Screen-reader spec

There is no custom element class file for footer-ui (no `.class.js`, no `.js` behind the tag): it is a CSS-only slot stub with zero JS, zero lifecycle, zero ARIA wiring of its own. No role is stamped, no `aria-label` is set; the browser's default semantics for whatever native element the parent's markup uses apply unchanged. Accessible structure comes entirely from the children placed in it (e.g. `<button-ui>` elements already carry their own accessible names) and from the parent container's own landmark role, not from footer-ui itself.

### Behavioral spec

No interaction model, no state, no lifecycle: footer-ui contributes no script at all; it exists purely as a `[slot="..."]`-addressable target that the closest container parent's `@scope` CSS lays out and styles (grid/flex placement, spacing, the heading+description layout switch described in the slots below). `[justify]` is a plain CSS `justify-content` passthrough with no JS branching. Prefer flat `<button-ui>` children over nesting a `<row-ui>` inside footer-ui: a nested row double-applies flex layout and breaks the parent's gap rhythm. Not for arbitrary trailing content that isn't chrome: a data-summary block that belongs to the body content stays in the default slot, not footer.

## `<frame-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `card-ui` | Not for a top-level dialog or panel: use card-ui when you also need a bordered/elevated surface with a rich header grid. card-ui composes frame-ui's same region contract and adds chrome on top. |
| `drawer-ui` | Not for a top-level dialog or panel: use drawer-ui when you need a backdrop and dismiss affordance. drawer-ui composes frame-ui's same region contract and adds chrome on top. |
| `modal-ui` | Not for a top-level dialog or panel: use modal-ui when you need a backdrop and dismiss affordance. modal-ui composes frame-ui's same region contract and adds chrome on top. |
| `page-ui` | Not for a top-level dialog or panel: use page-ui for a full page shell. page-ui composes frame-ui's same region contract and adds chrome on top. |

### Screen-reader spec

`static template = () => null`: frame-ui renders no template and stamps no `role`, `aria-*`, or accessible name of its own; `UIElement`'s base `connectedCallback` runs with no override in `UIFrame`, so there is no JS-driven accessibility wiring at all. The class exists solely to register the custom element tag (per its own class-file comment); every accessible property comes from whatever native `<header>`/`<section>`/ `<footer>` (or `slot="header|body|footer"`) children the consumer places inside, in DOM order.

### Behavioral spec

`static properties = {}`: there are no attributes, no state, no lifecycle beyond tag registration; all region behavior lives in frame.css rather than JS. Region assignment is by tag OR `[slot]` match (`:scope > :is(header, footer, [slot="header"], [slot="footer"])` pins to a fixed `flex: 0 0 auto` rail; `:scope > :is(section, [slot="body"])` minus anything already claimed by the header/footer selectors becomes the sole `flex: 1 1 auto; overflow: auto` scroll region): an element matching both a rail tag and a rail slot is not a conflict since both selectors resolve to the same rail treatment, but an element with neither a matching tag nor a matching slot flows as ordinary flex content, uncontained. All three regions are optional; the common shape omits `<header>` entirely. `min-block-size: 0` on the flex column is required for the scroll region to actually scroll (the flex-shrink gotcha): dropping it silently breaks scrolling without an error. Requires a definite-height ancestor chain; in a content-sized parent the frame simply collapses to content height and nothing scrolls (graceful degradation, not a thrown error or console warning).

## `<grid-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `row-ui` | Not for a single-axis stack: row-ui is the one-dimensional flex primitive and stays cheaper (CSS-only, no `breakpoint.value` subscription) when you don't need column spanning or a `minmax()` density floor. |
| `col-ui` | Not for a single-axis stack: col-ui is the one-dimensional flex primitive and stays cheaper (CSS-only, no `breakpoint.value` subscription) when you don't need column spanning or a `minmax()` density floor. |

### Screen-reader spec

No ARIA role, live region, or accessible-name wiring of its own: grid-ui is a pure layout container. `render()` only ever touches `style.*` properties (`gridTemplateColumns`, `gridAutoFlow`, `gridAutoColumns`, `columnGap`/`rowGap`, the `--grid-min-col` custom property); it never reads or writes `role`/`aria-*`, so screen readers traverse children in DOM order exactly as they would through any other container. If the grid's content is itself a semantic list or table, wrap accordingly: grid-ui contributes no accessibility semantics beyond what its children already carry.

### Behavioral spec

`render()` subscribes to the `breakpoint` signal by reading `breakpoint.value` unconditionally at the top of the method: every instance re-renders on every breakpoint crossing regardless of whether `[columns]`/`[gap]` actually use `@bp` notation, since the signal read happens before the `cols?.includes('@')` branch. For `[columns]`: a responsive value (`"@"` present) resolves via `parseResponsive()` and is written as an inline `gridTemplateColumns`, forcing `gridAutoFlow:'row'` + `gridAutoColumns:'auto'` (overriding the CSS default of `grid-auto-flow:column`, which only makes sense with no explicit template); a scalar value clears all three inline styles and lets the CSS `[columns="N"]` rules take over instead. `[minColumnWidth]` sets or removes the `--grid-min-col` custom property consumed by `#colsToTemplate()`'s `auto-fill`/`auto-fit` `minmax()` floor (12rem when unset): it has no effect for numeric `[columns]`. `[gap]` mirrors the same responsive/scalar split via `#gapToCss()`: digit-only values map to `--a-space-N`, everything else to `--a-gap-<token>`; `[columnGap]`/ `[rowGap]` are pure CSS (`--a-column-gap-self`/`--a-row-gap-self`) and win over `[gap]` on their own axis without touching `render()`. There is no loading, error, or empty state. Prefer the `@bp` responsive-value grammar on `[columns]`/`[gap]` over hand-rolling media queries at the call site, which keeps the breakpoint vocabulary centralized in `core/responsive.js`.

## `<header-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

_None._

### Screen-reader spec

No ARIA wiring of its own, deprecated or not: `<header-ui>` is a CSS-only slot stub (no `.class.js`/`.js`, no lifecycle), so the browser's native `<header>` semantics apply exactly as they would for the bare tag it aliases.

### Behavioral spec

There is no interaction model and no JS: `<header-ui>` registers no custom-element class, so its only "behavior" is the `@scope` CSS the container parent (Card/Drawer/Modal/Page/AppShell) already applies to bare `<header>`. Kept only for the ADR-0098 one-release deprecation window; scheduled for removal.

## `<heatmap-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chart-ui` | For a continuous trend over one axis, chart-ui's line/area/bar modes are the right fit; heatmap-ui is for a two-discrete-axis density pattern, not a single-axis trend. |
| `stat-ui` | For a single scalar metric/KPI value with no two-axis grid shape, stat-ui's standalone metric display is a lighter fit than a heatmap. |

### Screen-reader spec

gh#2821 fix: the host is a real ARIA grid, not `role="img"`. `connected()` stamps `role="grid"`; `render()` keeps `aria-rowcount`/ `aria-colcount` in sync with `[rows]`/`[cols]` on every rebuild. Each row of cells is wrapped in a `role="row"` (`aria-rowindex`) container: `display:contents` so it stays transparent to the `[data-heatmap-grid]` CSS Grid auto-placement: satisfying the ARIA requirement that a `role="gridcell"` have a `role="row"` ancestor. Every cell carries `role="gridcell"` + `aria-colindex` + a non-empty `aria-label`: an explicit `d.label` wins; otherwise a synthesized one: a calendar date ("Aug 15: 4" / "Aug 15: no data") for `[type="day-grid"]` with a resolvable `[start-date]`, else a row/column coordinate ("Row 3, Column 5: 4" / "... no data"). A plain numeric dataset with no `label` field now announces per cell instead of nothing. The host's accessible name resolves in priority order: the slotted `[slot="title"]` element's textContent (wired via `aria-labelledby`, generating an `id` on the title element if it doesn't already have one) → the `[label]` attribute (`aria-label`) → the fallback "Heatmap" (`aria-label`), so the visualization is never nameless. Keyboard: roving tabindex (WAI-ARIA APG grid pattern): exactly one gridcell is `tabindex="0"` (the first cell by default, or the last-focused cell if still in bounds after a rebuild), every other cell is `tabindex="-1"`. Arrow keys move the roving cell one step in that direction, clamped to the grid bounds. Enter/Space on the focused cell fires the same `cell-click`/ `chart-select` events a pointer click does (silently no-ops on an empty cell, matching click's own behavior). Real keyboard focus is restored onto the correct cell across `render()`'s full-DOM rebuild when a cell owned focus beforehand, instead of dropping to `<body>`.

### Behavioral spec

`render()` is fully rebuild-based, not diffed: `replaceChildren()` clears the host (re-appending a `slot="title"` child if one existed) and reconstructs the optional month-labels row, the cell grid, and the legend from scratch on every call, batched via `#requestRender()`'s single-flight `requestAnimationFrame` guard so multiple synchronous property writes collapse into one paint. `data` accepts either a JSON string (a parse failure silently falls back to `[]`) or an already- parsed array via the `data` property setter; `connected()` additionally re-parses a raw `data` attribute if the internal buffer is still empty at connect time. `#buckets()` recomputes the value→0..4 bucket function once per render from the current min/max of the dataset, branching on `[scale]`: `log` clamps values to `Math.max(1, …)` before `Math.log` to avoid `-Infinity` on zero/negative inputs; `quantile` sorts and reads the 20/40/60/80th percentile breakpoints; `linear` is a straight min-max normalize. A dataset where `min === max` short-circuits every cell to bucket 4 rather than dividing by zero. Pointer events (`pointerover`/ `pointermove`/`pointerleave`/`click`/`keydown`) are bound once in `connected()` guarded by a `#bound` flag and removed in `disconnected()`; hover/move dispatch both the legacy `cell-hover` shape and the canonical `chart-hover`/`chart-leave`/`chart-select` shape so `tooltip-ui[follows=pointer][for=…]` can attach without caring whether the source is a chart or a heatmap. Cells with no matching data point get `[data-empty]` instead of a bucket and are excluded from click/Enter/Space activation (both route through the same `#activateCell()`), though they stay focusable/navigable via arrow keys like any other cell. `render()`'s rebuild-based nature (above) means a roving-tabindex coordinate (gh#2821) is tracked in `#focusCoord` independent of any specific DOM node: clamped to the current `[rows]`/`[cols]` bounds on every render and re-applied to whichever cell now occupies that coordinate; if that coordinate's cell owned real keyboard focus before the rebuild, focus is restored onto its replacement rather than dropping to `<body>`. There is no loading or error state: a malformed `data` string degrades to an empty grid, not a thrown error.

## `<icon-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `adia-mark-ui` | For the AdiaUI wordmark, adia-mark-ui is the right fit and icon-ui cannot substitute for it. Its own record states it is not a generic icon: no [name] prop, always the same five-path wordmark SVG, never themeable to another color. |

### Screen-reader spec

`connected()` stamps `role="img"` when [label] is non-empty, else `role="presentation"` plus `aria-hidden="true"`: a decorative icon is invisible to assistive tech by default. `render()` unconditionally re-applies `aria-label` from [label] on every render pass (including registry-arrival re-renders), and this line is NOT gated on whether the SVG actually resolved, per gh#2774's CDN-failure-mode hardening, a fetch/registry failure never costs the accessible name even when the visual icon renders blank. There is no live-region wiring; icon-ui is a static visual with a name, not a status announcement.

### Behavioral spec

`render()` calls `getIcon(this.name, this.weight || 'regular')` and writes the returned markup into `innerHTML` only when it differs from the current content (`this.innerHTML !== svg`), avoiding needless re-paints. A private reactive signal `#rev` is bumped once `whenIconRegistryReady` resolves (subscribed in `connected()`, aborted in `disconnected()` via `#readyCtrl`), so an `<icon-ui>` mounted before Phosphor's async loader map arrives automatically re-renders and picks up the SVG the moment the registry becomes ready: no manual re-mount needed. A [name] set with no resolving SVG AND a registry that never got wired at all (`iconRegistryUnwired()`) triggers exactly one deferred `console.error` (`warnIfIconRegistryUnwired()`, guarded by `_unwiredCheckScheduled` module-level state so it fires once total, not once per icon) diagnosing the two known causes (gh#287): a plain typo against an otherwise-wired registry is correctly NOT flagged. [size] branches on `#isFreeFormSize()`: a bare number or number+unit (px/rem/em/%) sets the inline `--icon-size` custom property directly (`#normalizeSize()` appends `px` to a bare number); anything else (the named sm/md/lg tokens) rides the universal `--a-icon-size` system instead, and any stale inline `--icon-size` is removed when [size] reverts to a named value.

## `<image-ui>`

**Composes:** `<skeleton-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `icon-ui` | For a registry icon that resolves by name rather than a URL, icon-ui is the fit; image-ui is for any content image with a real src URL. |
| `embed-ui` | For iframe-style embedded content, embed-ui is the fit; image-ui is only for a plain <img>-backed content image. |

### Screen-reader spec

image-ui stamps no ARIA role or live-region wiring of its own: the accessible surface is the plain `<img alt="...">` element it adopts or creates in the `image` slot. `render()` copies [alt] onto that `<img>`'s `alt` attribute on every render pass (falling back to `''` when unset), so a decorative image with `alt=""` reads as expected by assistive tech; image-ui does not stamp `aria-hidden` itself even for empty alt: the a2ui rule set documents pairing `alt=""` with `aria-hidden="true"` as the consumer's responsibility for purely decorative images. Loading and error states are exposed only as the bare host attributes `[loaded]`/`[error]` (renamed from `data-loaded`/`data-error`, gh#1464) for CSS/consumer hooks: neither is announced via `aria-busy` or a live region; a screen reader user gets no explicit notification of the loading→loaded or loading→error transition beyond whatever the adopted `<img>`'s own load/error semantics provide natively.

### Behavioral spec

`connected()` adopts an author-supplied `[slot="skeleton"]` or `[slot="image"]` child if present, otherwise stamps a `<skeleton-ui>` and a lazy-loading (`loading="lazy"`) `<img>` respectively: the adopt-or-stamp pattern (gh#1461) lets a consumer pre-author either slot. The skeleton gets `data-overlay` stamped unconditionally (gh#1362) so its own `@scope` proximity-0 rule wins the absolute-overlay-positioning fight against image-ui's proximity-1 rule, per CSS cascade proximity ordering: this is a workaround for a real cascade limitation, not a style choice. `#onLoad`/`#onError` listeners attach in `connected()`; a missed-load guard (gh#999) checks `#img.complete && naturalWidth > 0` immediately after attaching, since a parse-time-loaded or cache-hit `<img>` may have already fired `load` before the listener existed: without this check `[loaded]` never stamps and the image stays permanently hidden behind the skeleton (gh#988). On error, `#onError` retries once against [fallback] if set and not already the current src; a second failure (or no fallback) stamps `[error]` with no further recovery: the skeleton stays visible indefinitely, with no built-in visual error affordance beyond that attribute. `render()` only re-assigns `#img.src` when it differs from [src], clearing `[loaded]`/`[error]` first so a src change re-triggers the full loading cycle. `disconnected()` tears down both listeners and drops internal references, so a detach/reattach cycle re-adopts (not re-creates) any still-present children. Set [width]/ [height] to lock dimensions and prevent layout shift while the image loads.

## `<inline-edit-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `input-ui` | For anything that needs to always look editable at rest, input-ui is the fit; inline-edit-ui reads as plain text until the user commits to editing it. |
| `field-ui` | For a labeled field with helper/error text, field-ui is the fit; it composes a label + input rather than a bare text region. |

### Screen-reader spec

`connected()` stamps `role="textbox"` unless the consumer already set an explicit role, and adds `tabindex="0"` unless one is already present or the element is [disabled], so a static-looking text node is reachable and identified as an editable text field even before the user activates edit mode. There is no separate `aria-readonly`/`aria-disabled` announcement beyond whatever the inherited `UIFormElement` state reflects onto the host attributes; entering edit mode sets the native `contenteditable="plaintext-only"` attribute rather than a distinct ARIA state, which most assistive tech surfaces as an editable-text affordance on its own. No live region announces the `edit-start`/ `edit-end`/`change`/`cancel` custom events: they bubble as plain DOM events for consumer wiring, not for screen-reader narration.

### Behavioral spec

Static state shows the host's `textContent` as the value (the source of truth while not editing); `connected()` reconciles an author-supplied [value] prop against slotted text once at connect (prop wins if both are set, slotted text is adopted as the initial value if only text was given). Activation is click (`#onClick` calls `preventDefault()` then `startEdit()`) or keyboard Enter/Space when not already editing. `startEdit()` snapshots `#originalValue`, sets `editing = true` and `contenteditable="plaintext-only"`, fires `edit-start`, then defers focus + caret placement to a microtask (`#placeCaretAtEnd()` always collapses to the end, never select-all, since neither click nor keyboard activation carries a caret coordinate here). Commit behavior is gated by [commit] (`blur` | `enter` | `manual`): under `blur` (default), losing focus calls `commitEdit()`; under `enter`, blur instead calls `cancelEdit()` and only Enter commits; under `manual`, blur is a no-op and the consumer must call `commitEdit()` itself. Enter always commits and Escape always cancels while editing, regardless of [commit]: both set `#suppressBlur` first so the subsequent `.blur()` call doesn't recurse into `#onBlur`'s own commit/cancel logic. `commitEdit()` trims `textContent`, only fires `change` (with `{value, oldValue}` detail) when the trimmed value actually differs from `#originalValue`, and always fires `edit-end` with `{committed: true}`. `cancelEdit()` restores `textContent` to `#originalValue` verbatim, fires `cancel`, then `edit-end` with `{committed: false}`. [disabled]/[readonly] block both click and keyboard activation; `disconnected()` tears down all four listeners so a detach/reattach cycle re-binds cleanly. It is form-participating via `UIFormElement`, so it belongs inside a `<form>` with a `name=` when the edited value should submit; standalone usage only needs the `edit-start`/`change`/`cancel`/`edit-end` events.

## `<inline-message-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `alert-ui` | Once the notice needs a surface (banner, form-level error summary, dismiss), alert-ui is the fit; inline-message-ui never carries a surface fill, border, or padding box. |
| `toast-ui` | For a transient, self-dismissing notification, toast-ui is the fit; inline-message-ui is a static, non-dismissing annotation line under a field. |

### Screen-reader spec

The host sets no ARIA role of its own: it is a transparent inline annotation. Screen-reader pickup is expected to happen through the surrounding `<field-ui>`'s `aria-describedby` wiring, not through anything inline-message-ui stamps itself. For dynamic message updates (e.g. a validation message appearing after blur) opt into a live region via [live="polite"] or [live="assertive"]; `render()` reflects `aria-live` from [live] and strips the attribute entirely when [live=""] (the legacy alias for `off`, kept for existing consumers: [live="off"] is the A2UI v1.0 canonical spelling, SPEC REQ-013, and reflects the same `aria-live="off"` value rather than being stripped). The leading icon is decorative severity signaling, not a second accessible name: no `aria-label` is set on the icon or the host.

### Behavioral spec

Two independent content-precedence rules, both consumer-wins: (1) any non-empty child that is not the `[slot="leading"]` icon and not our own `[data-im-text]` span, an element or non-blank text node, counts as consumer-provided body content and causes `#syncBodyText()` to remove its own auto-stamped `[data-im-text]` span and return without touching [text] again; (2) any `[slot="leading"]` child lacking both the `_uiPart` marker and `data-im-auto` is treated as consumer-owned and `render()` returns before touching the icon at all: variant changes stop re-stamping it. When inline-message owns the icon, the resolved glyph is [icon] if set, else `VARIANT_ICON[variant]` (info/check-circle/warning/x-circle); the family-less `default` variant resolves to no icon and `render()` calls `this.drop('leading')`. There is no loading state and no dismiss: the component renders synchronously and has no removal affordance of its own. [variant] carries `enum_meta` tier/when metadata (gh#2831).

## `<input-ui>`

**Composes:** `<button-ui>`, `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `calendar-picker-ui` | For date entry, calendar-picker-ui is the fit; input-ui's `type` enum has no `date` value, and setting one anyway renders a plain contenteditable with no calendar affordance. |
| `time-picker-ui` | For time-of-day entry, time-picker-ui is the fit; input-ui has no native time-picker affordance to fall back on. |
| `color-input-ui` | For a compact color-entry swatch + popover, color-input-ui is the fit; input-ui renders no swatch or picker for a color value. |
| `color-area-ui` | For the full OKLCH color-picker surface directly (no popover), color-area-ui is the fit; input-ui renders no swatch or picker for a color value. |
| `search-ui` | For search-box entry with a magnifying-glass icon, clear affordance, and debounced search event, search-ui is the fit; input-ui's plain text mode has none of those. |

### Screen-reader spec

`connected()` and `#reconcileTypeMode()` set `role="spinbutton"` for `type="number"`, else `role="textbox"`. In number mode, `render()` mirrors the numeric value into `aria-valuenow` (raw canonical number) and `aria-valuetext` (the formatted display string plus [suffix] when set) whenever `valueAsNumber` is finite, and removes both attributes otherwise; `aria-valuemin`/`aria-valuemax` mirror [min]/[max] the same way (present only when set). An invalid/unparseable number-mode value sets `aria-invalid="true"`. [label] renders as an inline leading caption wired via `aria-labelledby` (a generated `input-label-N` id) on the editable surface, not the deprecated inert above-the-field `UIFormElement.label` rendering, which is why `static labelDeprecated = false` opts input-ui out of that warning. The contenteditable surface sets `autocomplete="off"` (gh#1593) to suppress the browser's saved- values dropdown: genuine autofill instead switches the whole surface to a native `<input>` (`#isAutofillMode`, gh#1935), which keeps normal browser autofill/AT behavior since it's a real form control at that point. It does NOT also carry `aria-autocomplete` (gh#3563): the span has no `[role]` of its own, and `role="textbox"`/`"spinbutton"` lives on the host instead (ARIA roles don't inherit to children), so the span's default computed role ("generic") doesn't permit `aria-autocomplete` at all, and "none" is that attribute's own spec default besides.

### Behavioral spec

Three states share one contenteditable-vs-native decision (`#isNativeInput`): `type="password"` always uses a native `<input>` (only path needing `-webkit-text-security` disc masking); any `autocomplete` value other than `""`/`"off"`/`"none"` also forces a native `<input>` (gh#1935: a contenteditable div is never a browser autofill target regardless of the attribute); everything else, including `type="number"`, is contenteditable. `#reconcileTypeMode()` runs on every render and rebuilds the whole shell + rewires every listener when the built DOM's mode (native vs. contenteditable, detected via `[data-controls]` presence) disagrees with the live `type`: a runtime `type` flip after connect is a full teardown/ rebuild, not a restyle, because number-mode needs different structure (role, `inputmode`, stepper controls, `beforeinput` filtering) that CSS alone can't reconcile. Number-mode steppers use pointerdown-armed hold-to-repeat (400ms initial delay, 60ms interval, `#REPEAT_INITIAL_MS`/`#REPEAT_INTERVAL_MS`) with `pointerdown` preventDefault to keep focus on the editable surface, and abort on any document-wide pointerup/leave/cancel (drag-off-then-lift works without a per-button leave handler). Value formatting funnels through `#toCanonical()` → `Number()` → `#format()`/`#formatDisplay()`: when [locale] is set, both `.` and the locale's own decimal separator parse as input, but internal storage (`.value`) always stays canonical JS-Number form so `Number(v)` round-trips unchanged regardless of display locale. Consumer-supplied `[slot="leading"]`/`[slot="trailing"]` children are captured before `innerHTML` wipes the shell and re-inserted at fixed anchor points (leading after label/prefix, trailing after suffix/text but before the number-mode controls column) by `#installAffordances()`: §199 (v0.5.7) closed a schema- vs-impl gap where these slots were declared since v1 but never actually rendered into the chrome. `input`/`change` events fire as plain bubbling `CustomEvent`s with `detail: { value }`; number-mode additionally fires a bare `submit` `Event` on Enter alongside `change`.

## `<inspector-ui>`

**Composes:** `<tabs-ui>`, `<tab-ui>`, `<code-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tabs-ui` | For a general-purpose tabbed panel, tabs-ui is the fit directly; inspector-ui composes tabs-ui internally but is scoped to the a2ui-editor's own fixed four-pane developer tooling. |

### Screen-reader spec

inspector-ui sets no ARIA attributes of its own: accessibility is entirely delegated to the composed <tabs-ui>/<tab-ui>/<code-ui> primitives it builds in `#build()`. There is no live-region wiring on the message/surface/code panes: `update()` and `setHTML()` mutate `<code-ui>`'s `text` attribute directly, so updates are silent to screen readers unless the underlying `<code-ui>` or `<tabs-ui>` itself announces the change.

### Behavioral spec

`connected()` captures any declarative textContent as the initial surface JSON, clears `innerHTML`, and calls `#build()` exactly once: there is no re-build path; the four panes and their `<tab-ui>` shells are constructed a single time at connect. [value] (active tab) is the only prop kept in sync bidirectionally: `render()` pushes [value] down into the live `<tabs-ui>` only when they've drifted, and `#onChange` (wired to the tabs' own `change` event) pulls the tabs' selection back up into [value] when the user switches tabs: a one-hop sync loop, not a full re-render. `update(schema, messages)` and `setHTML(html)` are the only two imperative mutation entry points: `update()` re-stringifies `schema` via `JSON.stringify(…, null, 2)` into the surface pane, and formats `messages` into a numbered log line (`type/messageType` + optional `[surfaceId]`) in the messages pane while also updating that tab's own label with a live count; `setHTML()` only touches the code pane, defaulting to `'(empty)'` for a falsy/empty string. All three panes fall back to fixed placeholder strings (`'{}'`, `'(no messages)'`, `'(empty)'`) when never populated. The declarative initial-surface payload (textContent captured once at `connected()`) is a convenience for static demos; a live editor should drive it entirely through `update()`.

## `<integration-card-ui>`

**Composes:** `<icon-ui>`, `<badge-ui>`, `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `option-card-ui` | For a single-select radio choice with no status or async action lifecycle, option-card-ui is the fit; integration-card-ui is its own surface, never wrapped in another card. |
| `card-ui` | For a generic bordered surface with no provider/status/action contract, card-ui is the fit; integration-card-ui IS a card variant and should never be nested inside a plain card-ui. |

### Screen-reader spec

The host carries `role="group"` with `aria-labelledby` pointing at the heading element (`#headingId`, generated once at connect unless the author already set an id): the whole tile announces as a labeled group keyed to the provider name. The status badge gets an explicit `aria-label` mirroring its text (e.g. "Connected") so screen readers announce the state immediately rather than waiting on the icon registry to resolve the badge's own rendering. The action button similarly gets an explicit `aria-label` built from a status-specific verb + the integration name/provider (e.g. "Connect to Slack", "Retry connecting Slack") so it reads correctly outside the group's own context. `aria-pressed` reflects "is the integration on?" only for `connected` (true) and `available` (false); `pending`/`error`/ `coming-soon` omit `aria-pressed` entirely since toggle semantics don't apply. The error message pane gets `role="status"` when shown (status="error" with a non-empty [errorMessage]) so its appearance is announced without stealing focus.

### Behavioral spec

The action button and status badge are both driven purely off `#normalizedStatus()`, computed fresh every render: there is no cached/stateful status transition logic. `ACTION_FOR_STATUS` and `BADGE_FOR_STATUS` are the two lookup tables: `coming-soon` has no entry in `ACTION_FOR_STATUS` and additionally short-circuits in `#renderActionButton()` via `this.drop('button')`: the badge alone carries that state's label, there is no action affordance at all. Content precedence for the description mirrors alert-ui's pattern: `#hasConsumerDescription()` treats any direct child that isn't one of the component's own stamped `data-integration-card-*` parts and isn't `[slot="actions"]` as consumer-authored, in which case the auto- stamped `<p data-integration-card-description>` is removed entirely and [description] is ignored: otherwise [description] renders (or the paragraph is hidden when empty). `#renderLogo()` guards against unresolved AdiaUI template placeholders (`{{p:N}}` strings) by skipping the render entirely when [logo] starts with that prefix, since integration-card's synchronous connected()/render() can fire before a parent template's reconciliation pass substitutes the real value: without the guard, `icon-ui` would warn "not found" on every such first paint. Logo resolution sniffs for a URL via a bare `/` substring check: any match renders an `<img loading="lazy" decoding="async">`, otherwise the resolved string is treated as an icon name for `<icon-ui>`. Three distinct events fire from the action button, chosen by `ACTION_FOR_STATUS[status].emits` (`connect` for `available`, `configure` for `connected`, `retry` for `error`, nothing for `pending`), each a bubbling `CustomEvent<{ provider }>`; a separate `#onMenuAction` listener re-dispatches `disconnect` or `reauth` when a bubbled `action` event's `detail.action` (or a `[action]` attribute on `e.target`, not `data-action` despite the file's own top-of-file docblock, which is a doc/impl mismatch, see Findings) equals `disconnect`/`reauth`/`re-authenticate`.

## `<kbd-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `code-ui` | For a code snippet, code-ui is the right fit: it renders preformatted text with a copy button and opt-in syntax highlighting. kbd-ui sets role="presentation" unconditionally and carries no copy affordance, because a key cap is decoration rather than content a reader acts on. |

### Screen-reader spec

`connected()` sets `role="presentation"` unconditionally: the key cap is announced as plain text (or not at all, depending on the AT), never as a distinct semantic unit. There is no dynamic ARIA state: kbd-ui has no interactive affordance, so nothing changes on press/hover.

### Behavioral spec

Content comes entirely from innerHTML/light-DOM children: there is no `render()` method and no synced textContent prop beyond the mirrored `textContent` used for A2UI serialization. `static template = () => null` means kbd-ui never rebuilds its own DOM; whatever the author (or A2UI payload) puts inside stays untouched across the component's lifecycle. [size] is the only reactive prop and only ever affects CSS (em-relative sizing scale), never structure. Commonly nested as `<kbd-ui slot="trailing">` inside `<menu-item-ui>` for a shortcut hint.

## `<link-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `button-ui` | For submitting a form, triggering an action/modal, or copying to clipboard, button-ui is the fit; link-ui is only for affordances whose purpose is navigation. `<button-ui variant="link">` (link-look styling on a button DOM) was removed as an anti-pattern: it announced "button" to screen readers for something users expected to be a link, broke middle-click-to-new-tab, and hid link targets from crawlers. |

### Screen-reader spec

The stamped `<a>` carries the browser's native `role="link"`: no explicit `role` attribute is set by the component. [disabled] does NOT remove `href` (which would drop the element from the accessibility tree); instead `render()` sets `aria-disabled="true"` on the host and `tabindex="-1"` on the anchor, so the disabled state is announced to screen readers rather than the link silently vanishing. Enabled state removes both. There is no separate accessible name management: the anchor's own text content ([text] or slotted children) is what gets announced, same as any native `<a>`.

### Behavioral spec

Two operating modes, detected by `data-autostamped` on the internal `<a>` in `#ensureAnchor()`: (1) autostamped: no author-provided `<a>` child at connect time, so the component creates one and owns its `href`/`target`/`rel`/content across every render; (2) slot-passthrough: an author already nested a `<a>` child, so the component leaves its attributes and content alone entirely (advanced-authoring escape hatch) except for the shared disabled handling, which applies in both modes. In autostamped mode, `render()` re-derives `href` from [href] (empty string removes the attribute: the element stays activatable via the `press` event even with no native nav target), `target` passthrough, and `rel`: an explicit [rel] always wins, otherwise `target="_blank"` auto-adds `rel="noopener noreferrer"` (tab-napping / referrer-leak protection), otherwise `rel` is removed. `press` is a bubbling, cancelable `CustomEvent` with `detail: { href, target }`, dispatched from the host's own `click` listener BEFORE native navigation runs: a handler that calls `preventDefault()` on it also suppresses the underlying click event, blocking native nav. Keyboard activation is Enter-only (native `<a>` behavior, no Space handling: that's button semantics, not link semantics); the explicit `#onKey` listener only exists to suppress Enter when [disabled].

## `<list-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `nav-ui` | For route-aware sidebar navigation, nav-ui is the fit; list-ui rows are non-navigable. |
| `tree-ui` | For hierarchical content with arbitrary nesting, tree-ui is the fit; list-ui is a flat row list only. |
| `table-ui` | For column-based tabular data, table-ui is the fit; list-ui has no column model. |

### Screen-reader spec

`connected()` adopts-or-stamps `role="list"`: a consumer-authored role survives (gh#753), the host only stamps when no `role` attribute is already present. When [selectable], every matched child (matched by `LIST-ITEM-UI` tag OR `role="listitem"`, not role alone, see §behavioral for why) gets `aria-selected="true"/"false"` mirroring the selected key, plus roving `tabindex` (`0` for the selected row, `-1` otherwise; if nothing is selected, the first item still gets `tabindex="0"` so the list stays keyboard-reachable). Turning [selectable] off strips `aria-selected`/`tabindex`/`[selected]` only from children the list itself stamped in a prior selectable render: an unconditional strip previously clobbered consumer-managed focusability on rows the list never touched (gh#746).

### Behavioral spec

`[selected]` (ADR-0056, gh#1303/#1363 B7) is the declared, reflected selection-item API surface stamped on children: `aria-selected` is separate wiring, not the API itself. `#items()` matches children by BOTH tag name (`LIST-ITEM-UI`) and `role="listitem"`, deliberately not role alone: light-DOM custom-element upgrade order means a child `<list-item-ui>` sets its own `role="listitem"` inside its own `connected()`, which has not necessarily run yet when the parent list's first `render()` executes: a role-only filter would find zero items on that first selectable paint and never stamp `aria-selected`/`[selected]` at all. Click-select and arrow-key navigation (`ArrowUp`/`ArrowDown`/`Home`/`End`/`Enter`/`Space`) both route through the same `#selectKey()`, which no-ops when the target key equals the already-selected key (no redundant `selection-change`) and otherwise fires a bubbling `CustomEvent('selection-change', { detail: { key, previousKey } })`. Clicking is scoped to `list-item-ui, [role="listitem"]` children whose `parentElement` is this list: a nested list's rows inside a child `<list-item-ui>` won't accidentally claim the click. [selectable] mirrors table-ui/segment-ui's selection contract; pass [selectedKey] + listen for `selection-change`, don't manage `[selected]`/`aria-selected`/`tabindex` on the child rows yourself; the parent owns and re-stamps all three on every render.

## `<list-window-ui>`

**Composes:** `<skeleton-ui>`, `<list-item-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `list-ui` | For short lists (< 50 items), list-ui is the fit; it renders every child and carries a simpler selection/keyboard contract. list-window-ui trades that simplicity for constant-time windowing. |

### Screen-reader spec

`connected()` adopts-or-stamps `role="list"` (survives an author-set role) and `tabindex="0"` when absent, making the whole windowed region a single keyboard-focusable list landmark rather than exposing individual rows to Tab. Every `render()` pass refreshes `aria-rowcount` to the full (non-windowed) `items.length`, so assistive tech reports the TRUE collection size even though only a handful of rows are ever in the DOM, and toggles `aria-busy="true"` while [loading], removed otherwise. Each materialized row gets `role="listitem"` and `aria-rowindex` set to its 1-based index within the FULL collection (not its position among currently-rendered rows), so screen readers correctly announce "item 4,832 of 10,000" even though row 4,832 is one of only ~12 DOM nodes present at any time. Keyboard scrolling (`#onKeydown`) only activates when the host itself is the focused element (`e.target === this`): focus inside a row's own interactive descendant defers to that row's own keydown handling instead of hijacking Arrow/Page/Home/End for host-level scrolling.

### Behavioral spec

`render()` on every reactive-signal pass (items, itemSize, overscan, direction, loading, …) runs a fixed four-step sequence: (1) surface state attributes ([empty] toggle, aria-rowcount, aria-busy); (2) resize the phantom spacer to the full virtual scroll height via `#updatePhantomSize()`; (3) re-check and, if needed, re-apply the pin-bottom invariant BEFORE re-materializing: `wasPinned` is computed from `this.#lastWasAtBottom || this.#isAtBottom()`, i.e. the state captured on the PREVIOUS scroll/render cycle, specifically so an `items[]` append (which grows total scroll height before this render runs) doesn't get misread as "no longer at bottom"; (4) materialize only the currently-visible index range via `#materialize()`, which reuses cached row DOM nodes (`#rowCache`, keyed by `#keyFn`: item.id when present, else array index) to preserve focus/scroll state across re-renders rather than tearing down and rebuilding every row. Variable- height mode measures each row via a shared `ResizeObserver` (`#onResize`), writing observed heights into `#measurementCache` (LRU-capped at 100,000 entries, `#MEASURE_CACHE_CAP`) and firing a bubbling `measure` `CustomEvent<{ index, height }>` per changed row; fixed-size mode (`itemSize > 0`) skips measurement entirely. IntersectionObserver-backed sentinels at both ends fire `scroll-start` / `scroll-end` `CustomEvent`s (each `detail: { index }`) when the corresponding edge sentinel enters view. `item-click` fires as a bubbling `CustomEvent<{ item, index }>` only for clicks on a `[data-row]` element that is a direct child of the internal window container: clicks elsewhere in the host (e.g. on the empty-state slot) are ignored. The consumer-set `renderRow` property deliberately shadows the base class's own `render()` lifecycle method by name: the base class still dispatches the prototype `render()` via its own reactive-effect wiring, while `renderRow` is a plain instance field read by `#materialize()` for row content: the two are unrelated despite the naming collision. Prefer setting [itemSize] for the fixed-size fast path; the [estimatedSize] + ResizeObserver variable-height fallback is strictly more expensive per row and should only be used when rows genuinely vary in height.

## `<loading-overlay-ui>`

**Composes:** `<spinner-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `button-ui` | loading-overlay-ui is container-scoped, not control-scoped. For a single button's own busy state during a submit or async action, reach for `<button-ui loading>` instead. |

### Screen-reader spec

`connected()` adopts-or-stamps `role="status"` (a polite live region) and `aria-live="polite"` on the host, both only when not already author-set, plus an `aria-label` mirroring [label] (default "Loading…") kept fresh on every `render()`. Separately, and more consequentially, the parent element (cached at connect as `#parent`, since disconnect can happen after the DOM has already detached) gets `aria-busy="true"` applied, not the overlay's own host, so assistive tech correctly treats the covered region, not the overlay chrome itself, as busy. `#releaseBusy()` only removes `aria-busy` from the parent when its current value is exactly `"true"`: if the consumer authored their own `aria-busy` before the overlay mounted (either value), the overlay never touches it, avoiding clobbering consumer-owned busy state. The backdrop's pointer-events:auto (CSS, not this file) is what actually blocks click/focus during the busy window: there is no native `[inert]` toggling, a deliberate choice to avoid disturbing the consumer's own focus model.

### Behavioral spec

The reactive `active` setter is wrapped in the constructor (not relying on a render-cycle) so a state transition kicks the delay timer SYNCHRONOUSLY with the property assignment, matching the drawer-ui Safari-safe pattern rather than deferring to a microtask. `#syncActive()` always clears any in-flight timer first, so rapid active-toggle churn during the grace window never stacks timers: a `delay="0"` activation paints immediately (`#applyBusy()`), a positive delay arms a `setTimeout` that re-checks `this.active` when it fires (if the flag was cleared during the grace window, the overlay never paints at all: the flash-avoidance mechanism), and deactivating while `#activeApplied` is true calls `#releaseBusy()` immediately regardless of any pending timer. The default centered `<spinner-ui size="lg" tone="subtle">` is stamped only when `this.children.length === 0` at render time: any authored child at all (skeleton-ui, progress-ui, custom markup) suppresses the auto-spinner entirely, and the auto-stamped spinner is tagged `[data-loading-overlay-auto]` so subsequent `render()` calls can find and refresh its `label` attribute without re-creating it. [variant] carries `enum_meta` tier/when metadata (gh#2831). The consumer owns making the parent `position: relative` (or otherwise positioned): the component absolutely positions itself to fill the offsetParent and never mutates the parent's own layout styles.

## `<mark-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `text-ui` | Reach for text-ui[strong] instead when the goal is semantic emphasis (a weight change), not a highlight rail: mark-ui paints a background behind the span without changing its weight. |
| `tag-ui` | Reach for tag-ui instead when the content needs block-shaped pill chrome rather than staying inline: mark-ui keeps the wrapped text in normal inline flow, same line-height and baseline as the surrounding text. |

### Screen-reader spec

`connected()` adopts-or-stamps `role="mark"`: only when the host has no author-set `role` already, so a consumer override survives. This mirrors the native `<mark>` element's implicit ARIA role, giving screen readers the conventional "highlighted"/"marked" announcement for the wrapped span without requiring an explicit `<mark>` tag.

### Behavioral spec

`static template = () => null` and no `render()` override: mark-ui never touches its own children; whatever content the author or A2UI payload places inside stays exactly as authored. [variant] (warning/info/success/danger) is the only reactive prop, and it only ever affects the CSS background-highlight color token: it never restructures the DOM, since there is no structure to restructure.

## `<menu-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** `<menu-item-ui>`, `<menu-divider-ui>`, `<menu-label-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `select-ui` | menu-ui does not hold a value: activating an item fires an [action] event and closes. For single-select form input, reach for select-ui instead. |
| `nav-ui` | For persistent side navigation rather than a transient action surface, reach for nav-ui instead. |
| `action-list-ui` | For an inline (non-popover) command list, reach for action-list-ui instead. |
| `command-ui` | For a searchable command palette, reach for command-ui (hosted inside admin-command) instead of menu-ui's fixed action list. |

### Screen-reader spec

The hoisted popover div stamps `role="menu"` at creation (`#ensurePopover()`). `<menu-item-ui>` only stamps `role="menuitem"` (plus `tabindex="-1"` when none is set) while it is actually inside a `[role="menu"], [role="menubar"]` ancestor: closing the menu moves items back into the host's light DOM where no such ancestor exists, so the role and tabindex are stripped again on that `connected()` re-run, avoiding an axe `aria-required-parent` violation on a closed menu (FB-95). A consumer-supplied `role` on `<menu-item-ui>` (e.g. `role="menuitemradio"`) is left untouched: the component never clobbers it, and also leaves tabindex/aria to that consumer. `<menu-item-ui>` mirrors its [disabled] prop to `aria-disabled="true"` and is excluded from the roving-focus item set (`#enabledItems()` filters on `.disabled`). `<menu-divider-ui>` stamps `role="separator"`. `<menu-label-ui>` stamps `role="presentation"` so it is skipped by both roving focus and screen-reader item announcement, since it only visually labels the group below it. Roving tabindex (gh#2823, WAI-ARIA APG Menu Button pattern): `#focusItem()` keeps exactly one enabled item at `tabindex="0"` at all times while the menu is open, every other enabled item is `tabindex="-1"`, established on the first enabled item as soon as `#show()` runs (even for a mouse-opened menu, without stealing DOM focus) and moved to whichever item Arrow/Home/End lands on next.

### Behavioral spec

Opens on trigger click (toggles [open]) or on ArrowDown/Enter/Space while the trigger has focus, which also flags the next open as keyboard-initiated so the first enabled item receives focus one animation frame after the popover shows. Items live in the host's light DOM while closed and are moved into the popover div on `#show()` (a descendant query, not a direct-child one, so items produced by the template engine's `.map()`/`repeat()` wrapper spans are still collected), then moved back before the popover element on `#hide()` so the next open can re-adopt them. Roving focus inside the open menu: ArrowDown/ArrowUp move to the next/previous enabled item (wrapping), Home/End jump to first/last, Enter/Space activates the focused item. Activating an item fires `action` with `{ value, text }`, closes the menu, and returns focus to the trigger. Escape closes the menu and returns focus to the trigger; Tab closes the menu but lets focus move naturally (no focus redirect). A pointerdown outside the menu closes it (listener attached one frame after open, so the opening click itself doesn't immediately dismiss it). The popover's native `toggle` event is also mirrored back into [open], so a popover dismissed by the platform (e.g. another popover opening) stays in sync with the component's own state. [placement] + [offset] tune where the popover lands relative to the required `slot="trigger"` child, flip to `top-start`/`top-end` when the trigger sits near the bottom of the viewport.

## `<modal-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `drawer-ui` | Reach for drawer-ui instead when the content is an edge-anchored multi-field editor or a mobile-sheet pattern. |
| `admin-command` | Reach for admin-command instead, not modal-ui, for the Cmd+K command palette, which owns its own keyboard/filter loop despite looking like an overlay. |

### Screen-reader spec

On open, native `<dialog>.showModal()` sets `aria-modal="true"` and moves focus to the first focusable descendant inside the surface (typically the built-in close button, or the first field in an author-supplied form); the element that had focus before open is restored on close (`#previousFocus?.focus()`). Focus is trapped inside the dialog for the full open duration: Tab from the last focusable element wraps to the first, and Shift+Tab from the first wraps to the last: this is an explicit `keydown` handler (`#onDialogKeydown`), not just the browser's native `showModal()` trap, because Chromium's own wrap-around is not reliably atomic under a fast/automated Tab (gh#2227) and can hand focus to `document.body` instead of looping back into the dialog. [text] stamps both the visible title and the surface's `aria-label`, so a screen reader announces the modal's purpose immediately on open: a modal author must always set [text] (or a `<span slot="heading">`) rather than leaving the surface unlabeled. Escape (unless [permanent]) and a backdrop click both dismiss and fire the bubbling `close` event; native `<dialog>` handles the `::backdrop` and `cancel`/`close` semantics, so no extra `role="dialog"` or manual `aria-hidden` toggling on background content is needed: the browser's native modal semantics already exclude the rest of the page from the accessibility tree while open.

### Behavioral spec

[permanent] suppresses both dismiss paths (backdrop click and Escape): use it only when the surface must be resolved via an explicit in-content action (e.g. a required multi-step confirmation), since it removes the screen-reader user's normal "get me out of this" affordance and a replacement action inside the modal becomes mandatory. There is no built-in loading or error state on modal-ui itself: a modal hosting an async action (e.g. a destructive-confirm submit) is expected to disable its own footer buttons and show a `spinner-ui`/inline error inside its slotted content; modal-ui's own lifecycle only tracks open/closing/closed via [open] and the internal `[data-closing]` exit-animation attribute (dialog.close() is deferred until the exit transition finishes). Setting [open] back to true during that closing window is currently a silent no-op, not a handled reopen: the native `<dialog>` is still open until the deferred `dialog.close()` fires, so the reopen branch's `!dialog.open` guard never matches and the request is dropped; a caller needing to reopen immediately must wait for the `close` event first. An empty default slot renders an open, correctly-focus-trapped surface with no content: modal-ui does not warn or fall back for a consumer that forgets a header/body/footer child, so an empty modal is a caller-authored bug, not a modal-ui failure mode. A settings-row toggle inside a modal body (e.g. a preferences modal) is a `switch-ui[label]` on its own, never `field-ui`-wrapped: field-ui is for wide controls needing a separate `<label for>` row, and wrapping a self-labeling widget in it produces a doubled/misaligned affordance (gh#2730); `modal.examples.html`'s preferences example follows this.

## `<nav-group-ui>`

**Composes:** `<icon-ui>`, `<badge-ui>`, `<nav-item-ui>`
**Allowed children (a2ui):** `<nav-item-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `nav-item-ui` | For a single link with no shared label, use nav-item-ui directly rather than wrapping it in a one-item nav-group-ui. |
| `tabs-ui` | For switching sub-views inside one page, reach for tabs-ui instead: nav-group-ui groups navigation links, it doesn't hold in-page view state. |
| `tab-ui` | For switching sub-views inside one page, reach for tab-ui (inside tabs-ui) instead: nav-group-ui groups navigation links, it doesn't hold in-page view state. |

### Screen-reader spec

`connected()` stamps `role="group"` on the host. `render()` mirrors [open] into `aria-expanded` on every render pass, so assistive tech always reflects the current disclosure state even though [collapsible] is what gates whether that state can change interactively. There is no `aria-label` wiring beyond the visible header text/slot content: the group's accessible name comes from its rendered header, not a dedicated attribute. Selection is communicated visually (filled vs. regular icon weight, `data-selected-within` for CSS) AND via ARIA (gh#2823): `#syncHeaderWeight()` mirrors the same contains-selection state onto the header element's `aria-current` attribute: the generic `"true"` WAI-ARIA APG enum value for "current within a set of related elements," not `"page"`, which stays reserved for the actual `<nav-item-ui>` representing the route itself (`nav-item.class.js`). The header element gets `tabindex="0"` so it's independently focusable regardless of [collapsible].

### Behavioral spec

The auto-generated header (built once in `connected()` when no `slot="header"` child is supplied) listens for `keydown` (Enter/Space) and toggles [open], firing `group-toggle` with `{ text, open }`, but only when [collapsible] is true; a non-collapsible group's header is focusable but inert to both click styling and keyboard toggle. A `MutationObserver` watches the subtree for `childList` changes and `selected`-attribute flips on descendants (gh#501): whenever a child `nav-item-ui[selected]` appears, disappears, or changes, the group re-syncs its own header icon weight and `data-selected-within` attribute without any consumer wiring: this is scoped to the icon's `weight` attribute specifically so the observer's own writes (outside the filtered `selected` attribute) can't feed back into itself. On the primary rail, calling `showPopover()` builds (once) a popover listing each direct `nav-item-ui` child, wires each option's click to `nav.select(child)` on the closest `<nav-ui>`, and re-syncs `aria-current`/`aria-selected` on the popover options every time it opens; the popover is anchored via `anchorPopover()` and its cleanup callback is re-run and replaced on each open. `disconnected()` tears down the header keydown listener, the anchor cleanup, hides any open popover, and disconnects the selection observer.

## `<nav-item-ui>`

**Composes:** `<icon-ui>`, `<badge-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tab-ui` | For an in-page view toggle, use tab-ui instead of nav-item-ui. |
| `menu-item-ui` | For a popover menu action, use menu-item-ui instead of nav-item-ui. |
| `button-ui` | For an action styled like a nav row but performing a command rather than navigating, use button-ui[variant="ghost"] instead. |

### Screen-reader spec

`connected()` stamps `role="link"` and sets `tabindex` to `0`, or `-1` when [disabled] (recomputed on every `render()` pass too, so toggling [disabled] at runtime updates focusability immediately). `render()` mirrors [selected] into `aria-current="page"`, removing the attribute entirely when not selected: this is the sole ARIA signal of the active route. [disabled] mirrors into `aria-disabled="true"` alongside `tabindex="-1"` in both `connected()` and every `render()` pass (gh#2823: a disabled item used to be only unreachable via Tab and visually greyed, not announced as disabled to assistive tech that reaches it another way, e.g. a screen-reader browse mode that isn't gated by `tabindex`); removed entirely when [disabled] clears.

### Behavioral spec

Click and `keydown` (Enter/Space) both funnel through the same `#onClick` handler, which no-ops immediately when [disabled] and otherwise calls `this.closest('nav-ui')?.select(this)`: the item itself never dispatches `nav-select`; per gh#1254, `nav.select()` is the single source of that event and only fires it when selection actually changes, so re-clicking an already-selected item is a true no-op (no duplicate event). The leading icon's Phosphor weight is swapped `fill`/`regular` in `render()` keyed directly off [selected] (a CSS-only color change isn't possible since filled/regular are distinct SVGs). There is no hover/press/loading state machine beyond native `:hover`/ `:active` CSS: the class itself only manages selection, disabled gating, and focusability. `disconnected()` removes both the click and keydown listeners.

## `<nav-ui>`

**Composes:** `<nav-group-ui>`, `<nav-item-ui>`, `<icon-ui>`, `<popover-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tabs-ui` | Reach for nav-ui when the user navigates AWAY: a different page, route, or anchor. Reach for tabs-ui instead as an in-page view switcher, nav-ui is never that. |

### Screen-reader spec

`connected()` stamps `role="navigation"` unconditionally. [heading], when set, is mirrored into `aria-label` on both `connected()` and every `render()` pass (removed when [heading] is cleared): on [variant="section"] this same [heading] text is also painted visibly via a CSS `::before` kicker, but on [variant="primary"] it is aria-only (a hand-placed `<span data-nav-label>` is the visible equivalent there, per the a2ui composition rules). Selecting a `<nav-item-ui>` toggles its own `selected` attribute; group collapse driven by `#collapseSiblingGroups()` sets `open = false` on sibling `<nav-group-ui>` elements, which resets their `aria-expanded` state even on `variant="section"` where the CSS cascade keeps children visually visible regardless of `[open]`, so that path is an accessibility-state-only change with no visible effect in the section variant.

### Behavioral spec

Selection state lives on `<nav-item-ui selected>`; `select(item)` is a no-op when the item is already selected (gh#1254: no attribute churn, no event, no hover flush) and otherwise clears the previous selection, sets `selected` on the new item, flushes a Safari-specific stuck-`:hover` workaround (`pointer-events: none` toggled off next frame), collapses every sibling top-level `<nav-group-ui>` except the one containing the newly selected item (all groups, when the selection is ungrouped) unless [multi-expand] is set, then fires `nav-select` with `{ item, text, value }`. `<nav-item-ui>` owns its own click/keyboard activation and calls `select()` directly; nav-ui's own click listener handles ONLY group expand/popover behavior for [variant="primary"] and is a deliberate no-op for [variant="section"]. Collapse state (`#isCollapsed`) is [collapsed]-or-narrower-than-96px, computed from `getBoundingClientRect()`: unavailable under SSR/linkedom, where only the explicit [collapsed] attribute can answer. `render()` updates `title` tooltips on groups/items to their text only while collapsed: on every render pass, both variants (gh#2823: was gated to `variant="primary"` only, so a collapsed section rail never got tooltips on its truncated labels). A ResizeObserver (skipped entirely for `variant="section"`, which has no live-resize collapse behavior of its own, and absent under SSR) re-evaluates collapse on resize and re-runs this same tooltip update for the primary rail. Clicking a collapsed group's header opens its flyout popover; clicking again inside that popover but outside the option row is guarded against re-showing the just-dismissed flyout.

## `<noodles-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `canvas-ui` | noodles-ui has no pan/zoom mechanism of its own, and canvas-ui cannot host it either: canvas-ui is an A2UI runtime mount point that forbids static children. A consumer that needs pan/zoom over the node graph has to build that separately. |

### Screen-reader spec

gh#2824 fix, extended gh#2859. `connected()` stamps `aria-hidden="true"` on the decorative SVG overlay (the connection semantics live in the ports + live region, not the raw `<path>` markup) and creates a visually-hidden `role="status" aria-live="polite"` region (`[data-noodle-live]`) for keyboard-connect state changes. Each port indicator dot (`[data-noodle-port-indicator]`) is `role="button"` with an `aria-label` borrowed from its node (`aria-label` → text content → id → "node", suffixed with the port side, e.g. "Node A: right port") and `aria-pressed` reflecting whether it's the pending keyboard-connect source. Node semantics themselves are left untouched: only the port dots (which have no default accessible name of their own) get synthesized ARIA; whatever a consumer authored on the node stays authoritative for the node.
Keyboard: a port is Tab-focusable exactly when the pointer drag-to-connect gesture would also be live: `[editable]` set, `[readonly]` unset, AND `[show-ports]` set (mirroring `:scope:not([show-ports]) [data-noodle-port-indicator]`'s existing `pointer-events:none`, so nothing becomes a silent invisible Tab stop). Enter/Space on an idle port selects it as the pending connection source (`aria-pressed="true"`, live-region announces "Connecting from…"); Enter/Space on a second, different port completes the connection through the same `connect()` API drag-to-connect uses (announces "Connected… to…"); Enter/Space on the SAME pending port, or Escape at any point while a connection is pending, cancels it (announces "Connection cancelled."). A structural rebuild (`#rebuildPortIndicators()`, triggered by the same childList/ `data-noodle-port`/style mutations that already retrigger it) silently drops a stale pending selection without announcing a cancel, since that's not a user action.
gh#2859 closes the three gaps gh#2824 documented as deliberately out of scope:
- **Live pending-connection preview.** While a connection is pending,
  focusing a different port (via Tab or the arrow-key cycling below)
  draws the same dashed preview path the pointer drag gesture draws,
  from the pending source to whichever port now has focus
  (`#onPortFocus`); it clears when focus returns to the pending source,
  the connection completes, or it's cancelled.
- **Arrow-key port cycling.** ArrowRight/ArrowDown moves focus to the
  next port in build order; ArrowLeft/ArrowUp to the previous; both
  wrap at the ends. Independent of, and available alongside, native Tab
  order: `#focusAdjacentPort`.
- **Keyboard disconnect.** Delete or Backspace on a focused, IDLE
  (non-pending) port disconnects every connection touching that exact
  port, announcing the count (`#onPortDisconnect`). This is a genuinely
  new capability, not keyboard catching up to a pointer affordance:
  pointer has never had a disconnect gesture either (`disconnect(id)`
  was API-only). Gated to the idle state: Delete/Backspace while a
  connection is pending is a no-op, since Enter/Escape already own that
  state on the same port.

gh#2859 fix (bug found while building the preview above, not a new capability): the `MutationObserver` driving structural rebuilds watched this element's whole subtree, which includes the live region, so every `#announce()` call (an `[data-noodle-live]` textContent write) queued a rebuild that silently dropped `#pendingFrom` on its own microtask timing. Existing gh#2824 tests never caught this because they dispatch both keydown events of the two-step flow synchronously, beating the microtask; any REAL two separate keypresses (the only way a person actually uses this) hit the race and had their pending connection cancelled out from under them between the two presses. The observer callback now ignores mutation records targeting (or inside) the live region.

### Behavioral spec

noodles-ui is itself the container for the port-bearing node children: they render as its light-DOM children (typically nested through an ordinary layout child like `<row-ui>`), and it draws only the connector lines between them, not the nodes themselves. It is a connector overlay, not a general drawing surface: for arbitrary shapes/lines unrelated to a node graph, use raw SVG instead.
Read-only by default: connections render from the [connections] prop or `connect()`/`disconnect()`/`setConnections()` calls, with no user interaction beyond whatever the port-bearing children themselves expose. Setting [editable] arms pointerdown handlers on each port indicator dot; [readonly] short-circuits `#onPortPointerDown` even when [editable] is set, so connections stay visible but undraggable. Drag-to-connect: pointerdown on a port dot captures the pointer, tracks pointermove to draw a dashed preview path (`#dragPath`/`#computePath`) and highlight the nearest in-range drop target (`#findDropTarget`, hit radius = 2×[portSize]), and on pointerup either calls `connect()` if a valid target was under the cursor or discards the preview. `disconnected()` flushes any in-flight drag via `#dragCancel` so per-drag pointermove/pointerup listeners and their closures are released before the host detaches, rather than leaking. Layout tracking is fully reactive: a `ResizeObserver` on the host and each port child, plus a `MutationObserver` watching `childList`/`subtree`/`data-noodle-port`/ `style`, both trigger `#scheduleUpdate()`'s rAF-batched `#performUpdate()`, which recomputes port positions and reconciles SVG `<path>` elements by id (add/update/remove) rather than a full redraw.
gh#2824 fix: `#scheduleUpdate()` no longer no-ops when `requestAnimationFrame` is unavailable (SSR): it now runs `#performUpdate()` synchronously instead, so a server-rendered tree with a [connections] document already set ships the real connector `<path>` elements (positioned via whatever `getBoundingClientRect()` the DOM shim returns: typically degenerate/zero-sized with no real layout) rather than an empty SVG overlay; the client's first rAF-scheduled `#performUpdate()` after hydration reconciles them to measured positions through the same by-id add/update/remove path, no separate reconciliation logic needed.
gh#2824 fix: a keyboard path now exists alongside drag-to-connect (additive; pointer behavior is unchanged): each port dot is a Tab-focusable `role="button"` (exactly when pointer drag would also be live: [editable] + not [readonly] + [show-ports]) with a two-step select-source/select-target Enter/Space flow and Escape-to-cancel, announced through a visually-hidden `aria-live="polite"` region.
gh#2859 fix: the three keyboard-follow-up items (see `screenReader` above for the full narrative): a `#keyboardPreviewPath` field renders through the SAME dashed `<path data-noodle-preview>` element the pointer drag preview already used (`previewPath = (#dragState && #dragPath) || #keyboardPreviewPath` in `#performUpdate()`), driven by a `focus` listener on each port dot (`#onPortFocus`); arrow keys (`#focusAdjacentPort`) walk `#portIndicators`' own insertion order (build order); Delete/Backspace (`#onPortDisconnect`) filters `#connections` for any edge whose `from`+`fromPort` or `to`+`toPort` matches the focused port and calls `disconnect(id)` on each match. Also gh#2859 fix (not a new capability: a bug this work surfaced): the `MutationObserver` callback now ignores mutation records targeting the live region, so `#announce()`'s own `textContent` write no longer triggers a spurious `#rebuildPortIndicators()` that dropped `#pendingFrom` mid-flow on real (non-synchronous) keyboard input, see `screenReader` above for the failure mode this was silently causing.

## `<number-format-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `input-ui` | number-format-ui is strictly a DISPLAY wrapper around `Intl.NumberFormat`. For an editable numeric field, use input-ui[type="number"] instead. |
| `stat-ui` | For a large headline metric in a KPI/dashboard context, prefer stat-ui instead: it composes number-format-ui's own formatting with label/trend chrome rather than duplicating it. |

### Screen-reader spec

No ARIA role is stamped: `connected()` deliberately relies on the rendered text node being read by assistive technology naturally, the same as any other text content. `render()` additionally sets `aria-label` to `"<formatted text> (raw: <this.value>)"` whenever [value] is finite and formatting produced non-empty text, so a screen reader announces both the formatted glyphs (which may include symbols like "€" or abbreviations like "M" that different readers pronounce inconsistently) and the underlying raw number for disambiguation; the `aria-label` is removed entirely when formatting produces empty output (e.g. [numberStyle="currency"] with no [currency] set, or a non-finite [value]) so AT falls back to reading the (empty) text content rather than announcing a stale label. Per gh#1647 this `aria-label` is intentionally NOT wired into the shared `#lastAutoAriaLabel` consumer-override guard that five sibling primitives use: it is unconditional, component-owned chrome that recomputes on every render, not a consumer-overridable name.

### Behavioral spec

There is no interaction model: no press/focus/keyboard handling, no `disabled` state, and the element never becomes a form participant. `render()` runs synchronously from the current properties on every property change: it computes `#format()` via `Intl.NumberFormat`, writes the result into `textContent`, and refreshes `aria-label` in the same pass. Validation is silent-empty rather than throwing: a non-finite [value] renders nothing, [numberStyle="currency"] without [currency] renders nothing, [numberStyle="unit"] without [unit] renders nothing, and any exception `Intl.NumberFormat` itself throws (e.g. an invalid [locale] or [unit] identifier) is caught and falls back to `String(v)` rather than leaving the element blank or propagating the error: that fallback also logs a one-time `console.warn` per element instance (a local `WeakSet` guard, same pattern as `core/data-stream.js`) naming the rejected options and the underlying `Intl` error, so the misconfiguration is diagnosable in dev without changing the rendered output. There is no loading, error, or async lifecycle: every render is a pure synchronous recompute from current props.

## `<option-card-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `radio-ui` | option-card-ui is not for dense forms with short labels, use radio-ui there instead. |
| `segmented-ui` | For compact horizontal single-select with short labels, use segmented-ui instead of option-card-ui. |

### Screen-reader spec

`connected()` stamps `role="radio"` on every card and (gh#2823) `role="radiogroup"` on the shared ancestor the `[name]` group lives under (`#group()`: the nearest `fieldset`/`[role="radiogroup"]`, else the immediate parent; idempotent, never overrides a consumer-supplied role). `render()` maintains a true WAI-ARIA APG roving tabindex across that group: `#syncTabindex()` keeps exactly one card at `tabindex="0"`: the checked one, or (when none is checked) the first card in DOM order: every other sibling sharing `[name]` is `tabindex="-1"`, so keyboard users land once (Tab) and arrow between options rather than Tabbing through every card individually (the previous, pre-gh#2823 shape). `render()` also mirrors [checked] into `aria-checked` on every pass, sets `aria-disabled="true"` only while [disabled] is set (removed otherwise), and (gh#2823) wires an `aria-describedby` from the card to its own [description] text: whichever `[slot="description"]` element is currently present (attr-stamped or consumer-slotted) gets an id minted if it doesn't already carry one, and the card's `aria-describedby` points at it; removed when no description exists.

### Behavioral spec

Click and Space/Enter (via `#onKey`) both route through the same `#select()`: it is a no-op while [disabled], [readonly], or already [checked], otherwise it walks siblings sharing [name] under `#group()` (nearest `fieldset`/`[role="radiogroup"]` ancestor, else the immediate parent) and un-checks any other checked one before setting [checked] and firing a bubbling `change` event with `{value, checked}`. ArrowDown/ ArrowRight and ArrowUp/ArrowLeft move to the next/previous sibling by DOM order (wrapping via modulo) and both `focus()` and `click()` it, so arrow navigation always selects as it moves: there is no arrow-without-select mode; the resulting `checked` flip drives `#syncTabindex()` (gh#2823) so the roving `tabindex="0"` follows the same card without any separate focus-management step. `#onKey` bails out entirely when `e.target !== this`, so none of this fires when focus is inside a nested form control (e.g. a textarea in the default spillover slot): Space/Enter/arrows behave as normal text-editing keys there instead of being intercepted. The default-slot spillover content is shown/hidden purely by CSS keyed off the [checked] attribute, not by JS toggling: `#ensureLayout()` only stamps [heading]/[description]/[icon] into slots from attributes when no slotted content already exists for that slot, so attribute changes never clobber consumer-authored rich content once present.

## `<otp-input-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `input-ui` | Reach for otp-input-ui for any fixed-length verification code (TOTP, email/SMS OTP, MFA enrollment) rather than input-ui[type="number"]: it owns per-digit focus management, paste-splitting of a full code across boxes, and a `complete` event that fires exactly once per fill cycle so a host doesn't have to reimplement any of that. |
| `field-ui` | otp-input-ui has no label slot of its own, so pair it with a preceding description text rather than wrapping it in field-ui. |

### Screen-reader spec

`connected()` stamps `role="group"` and `aria-label="One-time password"` on the host once, before `#stamp()` builds the digit inputs. Each generated `<input>` gets its own `aria-label="Digit N"` (1-indexed) and `autocomplete="one-time-code"` so iOS/Android autofill can surface an inbound SMS code without any host wiring. There is no live-region announcement of progress (e.g. "3 of 6 filled") beyond the native behavior of focus moving between labeled inputs, and no `aria-invalid`/error-state ARIA: otp-input-ui has no validation state of its own.

### Behavioral spec

`#stamp()` rebuilds all [length] digit `<input>`s from scratch on every call (innerHTML wipe + recreate) rather than diffing, seeding values from [value] (digits only, truncated to [length]), so changing [length] after initial connection discards any typed progress along with the old inputs. Each input is `maxLength="1"`, `inputMode="numeric"`, and its `input` handler strips non-digit characters and keeps only the first character typed; on a successful single-digit entry, focus auto-advances to the next box (no advance on the last box or on a cleared value). Backspace on an empty box moves focus to the previous box and clears it (not the box that had focus), so Backspace-Backspace from an empty last box walks backward, clearing as it goes. Paste is fully intercepted (`e.preventDefault()`): digits are extracted from clipboard text and distributed one-per-box starting at the box that had focus when the paste happened (paste-at-cursor, clamped to the remaining boxes from that index to the end: a paste longer than the remaining space is truncated, boxes before the cursor are left untouched), then focus moves to the first empty box at or after that index or, if all of them are filled, the last box. `input`/`change` fire together on every digit mutation (typed, backspaced, or pasted); `complete` latches: it fires once on the mutation that transitions the value from not-all-filled to all-filled, and does not re-fire on a further mutation (overtyping a digit, re-pasting the same code) while every box stays filled. It re-arms the moment any box is cleared (typed deletion or Backspace), so the next fill fires `complete` again. The public `focus()` method (distinct from the native DOM one, since [static template] is null) moves focus to the first empty box or box 0 if all are filled; `clear()` empties every box, resets [value], re-arms the `complete` latch, and focuses box 0.

## `<page-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `section-ui` | Not a substitute for page-ui's own body region. section-ui's record states that page-ui owns its shell-tier body region, that a nested <section-ui> must not stand in for it, and that [scroll] is a no-op there because page-ui already owns the scroll surface. |
| `frame-ui` | For a dialog or panel rather than a full page, frame-ui is the right fit. Its own record states that page-ui composes frame-ui's same region contract and adds chrome on top, so the two are layered rather than interchangeable. |

### Screen-reader spec

page-ui deliberately stamps no ARIA landmark role, live region, or accessible-name fallback of its own (gh#2823: confirmed by design, not an oversight): it is a bare layout/chrome primitive per ADR-0105 ("region elements never self-style; layout is always stamped by the closest host pattern's `@scope`"): page-ui is itself a *host pattern* in that ADR's vocabulary, not a region, and ADR-0105's forward note (2026-08-28, gh#2793) deprecates the `-ui` region stubs it composes (`<header-ui>`/`<section-ui>`/`<footer-ui>`) in favor of bare `<header>`/`<section>`/`<footer>` specifically because the native tags carry real implicit landmark roles (banner/region/contentinfo) the `-ui` stubs never did: they ship no `.class.js`/`.js` at all (CSS-only slot stubs, ADR-0105 Context §2), so an accessible-name fallback lives on the same native elements too (an `<h1>`–`<h6>` or a display/title/ heading/subsection `<text-ui>` slotted into the header names it). page-ui does not force a landmark of its own for two reasons: (1) it would duplicate whatever landmark the composed header/section/footer already provide, producing nested/redundant regions; (2) unlike a single per-document `<main>`, page-ui is a reusable, composable primitive: "Drop in directly, or nest inside an `<admin-shell>`'s main column" (this file's own `description`), so it cannot know whether ITSELF is the page's one primary-content landmark or one of several nested instances, and guessing wrong (e.g. unconditionally stamping `role="main"`) would be worse than stamping nothing. Owning a document's single `<main>` landmark is the responsibility of whichever shell or standalone-route wrapper actually knows there is exactly one of it, not yet built for `<admin-shell>` as of this writing, a real gap but a separate one from page-ui's own scope. [stickyHeader]'s IntersectionObserver toggles `[data-header-stuck]` purely as a CSS hook for the border/shadow cue: it carries no `aria-*` state and fires no announcement, since a visually-pinned header is not a content change worth interrupting a screen-reader user for. [band]'s always-sticky header/footer are likewise silent: no observer, no attribute, no announcement, since nothing about the accessibility tree changes between banded and non-banded layout. [stickyFooter] is the same CSS-only shape as [band]'s footer half: no observer, no attribute, no announcement.

### Behavioral spec

page-ui itself has no interaction model: no press/keyboard handling, no focus management, no disabled state. Its only runtime behavior is two independently-installed observers, gated by props: [stickyHeader] (only when [band] is false) inserts a zero-size sentinel `<div data-page-sentinel>` immediately before the first `<header>`/ `<header-ui>` child and watches it with an `IntersectionObserver`; when the sentinel scrolls out of the viewport the page toggles `[data-header-stuck]`, and CSS alone renders the border/shadow: no DOM mutation beyond the one attribute. `render()` re-evaluates this gate every pass: it installs the sentinel if [stickyHeader] just turned true (and [band] is false), and tears it down (`disconnect()`, remove the sentinel node, clear the attribute) the moment either [stickyHeader] goes false or [band] goes true: [band] always wins over [stickyHeader]. Separately, [padding]="10" installs a `ResizeObserver`-backed breakpoint classifier (`observeBreakpoint`) that reflects `[data-pad-step]` as "6"/"8"/"10" based on the page's own measured container width (thresholds at 481px/769px): a ResizeObserver rather than a literal `@container` rule specifically because page-ui establishes the `page-content` container on itself and a container query can never match its own establishing element. Changing [padding] away from "10" tears the step observer down and removes the attribute; both observers are also torn down unconditionally in `disconnected()`. On environments without `IntersectionObserver` (SSR DOM shims, gh#285) the sticky-header install silently no-ops rather than throwing. [stickyFooter] installs no observer of its own: it is a pure CSS attribute selector, same as [band]'s footer half; setting it never touches `render()`'s observer-gating logic above, which is [stickyHeader]/[padding]-only.

## `<pagination-ui>`

**Composes:** `<button-ui>`, `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `button-ui` | For cursor-based / infinite-scroll pagination with no fixed [total], use a plain load-more button-ui instead: pagination-ui is offset-based and needs a known total to build its numbered window. |

### Screen-reader spec

The component wraps its buttons in a `<nav aria-label="Pagination">` (adopted from server-rendered markup when present, gh#1687, or created fresh) so the whole control reads as a named landmark, unless [noLandmark] is set (gh#3574): a composing container that already owns the accessibility tree (e.g. a `role="grid"` table-ui host) can opt out, and the freshly-created `<nav>` gets `role="presentation"` instead of `aria-label`, so it no longer exposes a navigation landmark; only the fresh-create path checks the flag, an adopted SSR `<nav>` is trusted to already carry whichever shape the server rendered. Every prev/next/page cell is a `<button-ui>`, which resolves to a native interactive element; prev and next each carry a fixed `aria-label` ("Previous page" / "Next page") since their visual content is only a caret icon with no text. Each page-number cell carries `aria-label="Page N"` alongside its visible number text. The active page is marked with `aria-current="page"` (set/cleared every render as [page] changes) rather than a private `data-active` attribute (gh#1332). That's the sole signal a screen reader gets for "this is the current page," there is no live-region announcement of the page change itself. Disabled prev/next buttons (at the first/last page) get both a `disabled` attribute and `tabindex="-1"`, removing them from the tab order rather than leaving a focusable-but-inert button. The ellipsis is a bare `<span data-ellipsis aria-hidden="true">` (gh#2823, previously carried no `aria-hidden`), and it carries visible "…" text only, not a focusable stop, and is now hidden from assistive tech entirely rather than left to announce a bare "…" character.

### Behavioral spec

Keyboard/interaction model is entirely delegated to the nested `<button-ui>` cells: pagination-ui listens for their `press` event (button-ui's canonical activation event, which only fires when the button isn't disabled) rather than raw `click`, so disabled-state gating is free. `#onPress` resolves the pressed element back to a target page via its `data-prev`/`data-next`/`data-page` marker, clamps against [1, total], ignores a press that resolves to the already-current page, then writes the new [page] and dispatches a bubbling `page-change` CustomEvent with `{ page }` in `detail`: the component does not update [total] or fetch data itself. `render()` re-derives the full visible item list every pass via `#buildRange()` and diffs it into the `<nav>` with a keyed reconcile (`this.reconcile` keyed by item key), so unaffected buttons are never removed/recreated, only their `variant`/`disabled`/`tabindex`/ `aria-current` attributes are patched in place: all writes go through a compare-before-write guard (`setAttrIfChanged`/ `removeAttrIfPresent`) so an already-correct adopted SSR `<nav>` survives a re-render with zero DOM mutations (gh#1755). The page-number window holds a constant width (`W = 2*siblings + 5`) across every page position specifically so the row doesn't reflow as the current page advances: three fixed layouts (near-start, near-end, middle) each yield exactly W cells with one or two ellipsis placeholders. On first connect, an existing server-rendered `<nav slot="nav">` is adopted rather than replaced, but only when its children's tag/marker shape exactly matches the range the very next render would produce (`#seedKeyMapFromAdoptedNav`); a shape mismatch abandons adoption and clears the adopted children for a clean from-scratch rebuild rather than risking silently doubled or wrong-tag buttons.

## `<pane-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `editor-sidebar` | Inside <editor-shell>, editor-sidebar is the right fit over a bare pane-ui: its own record states it wraps <pane-ui resizable> and adds [collapsed] reflection, localStorage persistence, and a toggle() / collapse() / expand() public API on top of it, none of which pane-ui provides alone. |
| `admin-sidebar` | Inside <admin-shell>, admin-sidebar is the right fit, not pane-ui: its own record states it owns resize, snap-to-collapsed, and persistence directly, and pane.yaml's own composition contract above confirms admin-sidebar.js never queries or wraps <pane-ui> in current source. |

### Screen-reader spec

The `<header>` child, if present, gets `role="button"`, `tabindex="0"`, and `aria-expanded` mirroring `![collapsed]` on every render: it is the toggle control a screen reader user activates via Enter/Space (`#onKey`) or click (`#onClick`), both delegating to `toggle()`. A caret icon is adopt-or-stamped into `[slot="caret"]` inside the header (`<icon-ui name="caret-right">`) as a visual affordance only: it carries no independent accessible name. Content and footer are not aria-hidden when collapsed; pane-ui only visually hides them via CSS, so authors relying on true removal from the accessibility tree should additionally gate visibility server-side or check `[collapsed]` before rendering conditional content.

### Behavioral spec

Resize is pointer-capture driven, not document-level: `#onResizeDown` captures the pointer on the resize handle itself (`setPointerCapture`) so drag tracking survives the cursor outrunning the 4px grabber, and locks `document.documentElement.style.cursor` to `col-resize` for the duration so the cursor stays consistent across the whole viewport (restored in `#onResizeUp`). `[edge="trailing"]` negates the drag delta sign (`#onResizeMove`'s `sign` local) because a trailing pane's grabber sits on its left edge while the pane itself is anchored to the right of its flex row: dragging leftward must grow it, not shrink it. Width is clamped to `[minWidth, maxWidth]` on every pointer move and written directly as `this.style.width`, bypassing any CSS width transition while `[resizing]` is reflected (so the pane tracks the pointer 1:1, no lag). `toggle()` flips `[collapsed]` and fires a bubbling `toggle` event; it is the single choke point both the header click/keydown handlers and any external programmatic caller go through. The resize handle is stamped once, lazily, only when `[resizable]` is set and no `[slot="resize"]` child exists yet (adopt-or-stamp): toggling `[resizable]` off after connect does not remove an already-stamped handle or tear down its listeners.

## `<password-strength-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `input-ui` | input-ui is the actual password field. password-strength-ui is a read-only indicator paired with it via JS; it holds no form association and is never a substitute for the real input. |

### Screen-reader spec

The host sets `role="meter"` with `aria-valuemin="0"` / `aria-valuemax="3"` in `connected()`. Each render updates `aria-valuenow` to the current 0-3 score (removed entirely, not set to 0, when the value is empty: the empty state is a distinct "no score yet" condition, not a score-of-zero) and `aria-valuetext` to the human label (`Weak`/`Fair`/`Good`/`Strong`, or `Empty` when unscored) so assistive tech announces the qualitative label instead of a bare number. The `[slot="label"]` span additionally carries `aria-live="polite"` so a sighted-adjacent or screen-magnifier user gets a live announcement as the bucket changes while typing, without interrupting mid-keystroke.

### Behavioral spec

`scorePassword()` is a pure heuristic (not zxcvbn): length thresholds at 8/12/16 chars each add a point, character-class diversity (lower/upper/digit/symbol) adds up to two more at the 2-class and 3-class (not 4-class) thresholds plus a bonus for all four classes, a same-char-run-of-3+ penalty subtracts one point, and any password under 8 chars is hard-floored to score ≤ 0 regardless of other bonuses: the floor is applied last, after all additive scoring. Empty string scores the sentinel `-1` (distinct from a real 0/"Weak" score) so the bar renders fully unlit rather than showing a misleading "Weak" for no input. `render()` only emits `score-change` on a bucket transition (`#lastEmittedScore` tracked field), not on every keystroke that keeps the same bucket: consumers listening for score changes don't get spammed while a password idles within one band. [value] is deliberately declared without `reflect: true` and with `dynamic: true` so the a2ui static-extraction pipeline skips it, see the yaml-level Security note and a2ui.rules for the current gap where the runtime's generic prop-apply still falls through to `setAttribute` for props absent from `prop-apply.js`'s JS_PROPS allowlist (gh#2560, unresolved as of this writing).

## `<pipeline-status-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `stepper-ui` | For a multi-step wizard the user progresses through, reach for stepper-ui instead: pipeline-status-ui is a single current-state pill, not a step sequence. |
| `timeline-ui` | For a chronological record view of past events, reach for timeline-ui instead: pipeline-status-ui's own history is a collapsed disclosure detail, not the primary UI. |

### Screen-reader spec

The host sets `role="status"` and a static `aria-label="Pipeline status"` in `connected()`: the live region announces content changes without interrupting. The stage label and message are plain text nodes inside that live region (`[data-pipeline-label]` / `[data-pipeline-msg]`), so every `#update()` re-render re-announces the current stage + message to assistive tech. The expandable history is a plain `<details>`/`<summary>` pair (`[data-pipeline-history]`, hidden until at least one stage has completed): native disclosure semantics, no extra ARIA needed. There is no distinct announcement for the [status="error"] state beyond whatever text the caller puts in [message]; callers surfacing an error should put the failure reason in [message] so it reaches the live region.

### Behavioral spec

`#update()` is a state machine keyed on [status] and prior-vs-current [stage], tracked via private fields `#prevStage`/`#prevMessage` (not derived from `[data-*]` attributes, so history survives attribute-inspection tooling). On a [stage] change (while status ≠ 'completed'), the PREVIOUS stage+message pair is pushed into the internal `#history` array before the new stage is painted: the push happens one render behind the visible stage, by design (you see history for stages you've already moved past, not the one in progress). Setting `[status="completed"]` triggers one final flush of whatever stage was active into history, repaints the dot as 'complete', clears the message, and force-closes the `<details>` via `removeAttribute('open')` even if the user had it manually expanded: there is no way to keep it pinned open post-completion. `STAGE_LABELS` only covers six known agent-pipeline stage keys (interpret/analyze/plan/generate/validate/render); any other [stage] string is displayed verbatim, unlabeled: this is a soft convention, not an enum constraint (the yaml prop below intentionally has no `enum:` on [stage]).

## `<popover-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `menu-ui` | For a plain list of action items, reach for menu-ui instead: it's the role=menu specialization of this same anchoring mechanism. |
| `tooltip-ui` | For a read-only hover hint with no interactive content, reach for tooltip-ui instead. |
| `modal-ui` | For a centered, focus-trapping dialog, reach for modal-ui instead. Never nest modal-ui inside popover-ui's [slot="content"]: popovers are non-modal and stacking a true dialog inside one breaks focus management. |
| `drawer-ui` | For an edge-anchored multi-field form, reach for drawer-ui instead. Never nest drawer-ui inside popover-ui's [slot="content"]: the same non-modal / focus-management conflict as modal-ui. |

### Screen-reader spec

`[slot="trigger"]` MUST be a focusable element (button-ui, a link with href): a bare span/div child silently breaks keyboard activation, since popover-ui adds no tabindex or role of its own to the trigger slot. The content panel is promoted to the top layer via the native Popover API (`popover="manual"` + `showPopover()`/`hidePopover()`), which gives it correct top-layer stacking and ESC-key handling for free: no ad hoc z-index or manual ESC listener is needed for dismissal semantics beyond what `#onKey` adds for click/manual triggers. There is no ARIA role stamped on the content slot itself (no `role="dialog"`/`role="menu"`): popover-ui is a positioning primitive, not a semantic one; callers building a specific pattern (e.g. a menu) are expected to add the matching ARIA role to their slotted content, or use the specialized <menu-ui> which already does.

### Behavioral spec

Positioning combines the native Popover API with CSS Anchor Positioning (falling back to a JS anchor calc in `core/anchor.js` where the CSS feature is unsupported): `#show()` calls `showPopover()` then immediately re-anchors via `anchorPopover()` with the current [placement]/[offset]/[matchWidth], and tears the anchor binding down again in `#hide()`. For click/manual triggers, outside-dismiss and ESC listeners are attached one animation frame AFTER open (`requestAnimationFrame` in `#show()`) specifically so the opening click itself doesn't immediately re-trigger `#onOutside` and close the popover it just opened. Hover triggers use a 120ms close delay (`HOVER_CLOSE_DELAY`, via `#onLeave`'s `setTimeout`) so the pointer can cross the gap between trigger and content without prematurely closing: entering either the trigger or the content cancels the pending close (`#onEnter` on both). `#onToggle` listens for the browser's own native `toggle` event on the content element and syncs `[open]` FROM that: this is the path that reconciles state when the browser closes the popover on its own (native light-dismiss, or an OS-level ESC the component's own listener didn't catch), preventing `[open]` from going stale relative to actual DOM visibility. [offset] was renamed from the earlier `[gap]` (gh#1335) specifically because a raw-pixel number attribute can't share a name with the global Scale-grammar `[gap]` attribute (ADR-0053): don't reintroduce `[gap]` on this component.

## `<preview-ui>`

**Composes:** `<tabs-ui>`, `<tab-ui>`, `<select-ui>`, `<button-ui>`, `<code-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `code-ui` | A bare code-ui only shows escaped source with no live render; reach for preview-ui instead when the working component and its copyable source must appear together, guaranteed never to drift. |

### Screen-reader spec

The hand-rolled multi-format source tab strip (`#tabbedSourceCell`, active only when extra `<template data-preview-source>` siblings exist) implements a local ARIA tablist: `role="tablist"`/`role="tab"`/`role="tabpanel"`, `aria-selected`, roving `tabindex` (0 on the active tab, -1 on the rest), ArrowLeft/ ArrowRight keyboard navigation, and `aria-controls`/`aria-labelledby` pairing the tabs to the single swapped `<code-ui role="tabpanel">`: matching `<tabs-ui>`'s own keyboard contract without reusing that component (tabs-ui has no first-class trailing-content slot for the Copy control this cell also needs). The render-header's Hide/Show Code toggle and the Copy button are both plain `role="button"` `div`s with `tabindex="0"` and explicit Enter/Space key handling, since they're div-based rather than real `<button>` elements. The [play] trigger is a real `<button-ui>` whose text and `aria-expanded` flip between the play label and "Close" on mount/ teardown, so its accessible state tracks the live/torn-down content.

### Behavioral spec

Wrap example markup once, as plain AdiaUI HTML with attributes only, no inline `style=`, no `<script>` wiring; if a sample needs those to look right, the fix belongs in the component, not the demo. The captured slotted markup is the single source of truth for both panes: `dedent()` normalizes it (strips shared indentation, folds boolean-attribute `attr=""` down to bare `attr`, and re-delimits JSON-quote-bearing attribute values to single quotes where safe) into the code pane's literal text, while the SAME string is re-parsed via `innerHTML =` into the render pane so real custom elements mount live: the two panes can never drift because there is only ever one source string. `connected()` is written idempotent by design (an early-return guard checks for already-stamped `[data-preview-render-cell]`/`[data-preview-render]`/ `[data-preview-row]`): a genuine disconnect/reconnect (element moved in the DOM) must not re-stamp or duplicate content, so play-mode listener wiring is deliberately placed BEFORE that guard (every connectedCallback re-wires play listeners even when the guard skips content rebuild) to keep listener add/remove strictly paired 1:1 per connect: the mirror of a known prior listener-accumulation bug class. A [play]-gated example defers its live mount entirely: the captured source is stashed on the instance and only re-parsed into the render cell when the trigger is pressed, and pressing again (or the mounted content's own bubbling dismiss event: `close`, `context-menu-close`, `dismiss`, `tour-skip`, `tour-finish`) tears it back down; popover-ui and toast-ui dispatch none of those events, so for those two the re-press-the-trigger path is the ONLY teardown route. Split→stack layout self-correction (`#fit`) is monotonic: once a render cell's `scrollWidth` overflows its `clientWidth`, the row or whole instance flips to `[data-stack]`/`layout="stack"` and never flips back, so it settles in one pass instead of oscillating, and re-runs via `ResizeObserver` as the viewport narrows.

## `<progress-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `spinner-ui` | For circular loading indicators, reach for spinner-ui instead: progress-ui is bar-only; the former `variant="spinner"` mode was retired (gh#1617). |
| `stat-ui` | For a percent-complete number shown inline without a bar, reach for stat-ui instead. |

### Screen-reader spec

The host carries `role="progressbar"` with static `aria-valuemin="0"`/`aria-valuemax="100"` set once in `connected()`. Every render sets `aria-busy` to reflect indeterminate state and either writes `aria-valuenow` (clamped 0-100) or removes it entirely when indeterminate: an absent `aria-valuenow` is the correct signal for "progress unknown," not a stale `0`. `syncAutoAriaLabel()` derives a composed announcement ("CPU, 4.2 / 10 GHz" style, joining [label] and [meta] when both are set) and applies it via a value-tracking guard (`#lastAutoAriaLabel`) that never clobbers an aria-label a consumer set independently: the same pattern used by tag-ui and select-ui (gh#1644/#1646/#1647), so a hand-authored `aria-label` always wins over the derived one.

### Behavioral spec

`value == null` (including the legacy back-compat sentinel `-1`) is the indeterminate branch: the fill element's width is forced to 100% and CSS drives the animated activity sweep off that same state rather than off a literal computed percentage; any other value is clamped into `[0, 100]` before being written as both the fill's inline width and `aria-valuenow`. [meta] has NO visible effect unless [label] is also set: the row-layout grid that gives [meta] a place to sit only exists when [label] activates it, so an author who sets [meta] alone silently gets nothing rendered (by design, not a bug: a standalone bar has no row to place meta into). Once [label] IS set, [meta] as an attribute owns the meta slot's text and overwrites any child content there; if [label] is set but [meta] is empty, the slot's own children (if hand-authored via `[slot="meta"]`) are left intact rather than wiped: only a genuinely childless meta slot hides. `parts.fill`/`parts.track` are template-owned anatomy, not light-DOM insertion points: do not target them from a2ui composition or consumer markup, they exist purely for the shadow-adjacent internal render structure.

## `<qr-code-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

_None._

### Screen-reader spec

The host sets `role="img"` in `connected()`, with `aria-label` defaulting to "QR code" and overridable by the [label] prop for better context ("Share link QR code", "2FA setup QR"): every render re-applies the current [label] to `aria-label` so a live prop change stays reflected. There is no separate text alternative describing the encoded content itself (the QR pattern is opaque to assistive tech regardless); [label] should describe what scanning the code DOES, not attempt to convey the encoded value.

### Behavioral spec

`render()` guards against redundant re-encoding with a cheap signature check (`#lastSig`, a joined string of every input prop): encoding is non-trivial (QR version selection, Reed-Solomon ECC, 8 mask-pattern evaluations), so an unrelated re-render that changes none of the QR-affecting props skips `#resolveMatrix()` entirely as long as an `<svg>` is already present. `#resolveMatrix()` is a strict precedence chain: a valid parsed [matrix] always wins over [value] when both are set (BYO override, not a merge); an encode failure (data exceeds v1-10 capacity at the requested ECC) is caught internally, sets the reflected `[error]` state attribute, clears the rendered SVG, and logs a console warning with the value's UTF-16 code-unit length: no exception ever reaches the consumer, so a caller must poll `[error]` or watch for an empty render rather than try/catch around anything. [color]/[background] empty-string defaults resolve to hardcoded `#000000`/`#ffffff` specifically because `currentColor`-style theme awareness would silently produce light-on-dark cells in dark mode that most phone camera scanners refuse to decode: the `--qr-code-fg`/`--qr-code-bg` CSS tokens style only the HOST element's own `color`/`background`, never the SVG fill, so setting those tokens alone does nothing to the rendered code without also setting the [color]/[background] props.

## `<radio-group-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** `<radio-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `select-ui` | For a long option set, select-ui is the right fit. select-ui's own record draws the line at four: at or below that it points to radio-ui, which is what radio-group-ui groups. Reach for radio-group-ui when every choice should be visible at once under one accessible name. |

### Screen-reader spec

`connected()` sets `role="radiogroup"` on the host and auto-mints a label `<div data-radio-group-label>` from [label] (only if one doesn't already exist: `#ensureLabelElement()` recognizes only its own marker attribute, never a bare consumer-authored `[slot="label"]`), then wires `aria-labelledby` to that element's generated id. `render()` keeps the label element's text content in sync with the [label] prop on every update, and toggles `aria-required` from the [required] prop. The auto-minted label is a template-owned anatomy part (gh#1454): authoring your own `[slot="label"]` child does not get adopted; only [label] the prop drives it.

### Behavioral spec

This component is deliberately NOT form-associated: it extends plain `UIElement`, not `UIFormElement`, and carries no [value] or [name] of its own (see the class-file's ADR-0056 scope note: a parent-level [value] was evaluated and explicitly rejected, gh#1379, because it would contradict the "each child radio-ui is its own form participant" design). [required] here is presentation/ARIA only (`aria-required` reflects it): the actual HTML-constraint-validation requirement still lives on each individual radio-ui's own [required] attribute; setting [required] on the group does not itself block form submission. Label id generation uses a monotonic static counter (`#idSeq`/`#nextId()`) scoped to the whole `UIRadioGroup` class, not per-instance, so ids stay unique across every radio-group-ui instance on one page without coordination.

## `<radio-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `field-ui` | Never wrap radio-ui in field-ui: radio-ui already renders its own label via a CSS attr() pattern, so a field-ui wrapper would double the label. |
| `radio-group-ui` | For a visible group of radios needing a shared accessible name, wrap them in radio-group-ui rather than a bare layout container: it supplies the group's role=radiogroup + aria-labelledby association a plain container cannot (gh#729). |
| `segment-ui` | Reach for segment-ui instead when the visual language should be button-style rather than dot-and-label. |
| `segmented-ui` | Reach for segment-ui inside segmented-ui instead when the visual language should be button-style rather than dot-and-label. |

### Screen-reader spec

The host itself carries `role="radio"` and `tabindex="0"` (radio-ui IS the control, not a wrapper around a native `<input type=radio>`): `aria-checked` mirrors [checked] on every render, and `aria-label` is set from [label] when present. Arrow-key navigation (`#onKey`) moves focus AND selection together between sibling radios sharing the same [name] within the nearest `fieldset`/ `[role="radiogroup"]` ancestor: Down/Right moves forward, Up/Left moves backward, wrapping circularly (`#sibling()`'s modulo index math), matching the native radio-group roving-selection behavior users expect from arrow keys, not just Tab.

### Behavioral spec

`#select()` is the single choke point for becoming checked: invoked from both the click handler and the Enter/Space keydown handler, never duplicated. It first walks the sibling group (same [name], same closest `fieldset`/`[role="radiogroup"]`/parent scope) and unchecks every OTHER currently-checked radio in that group before setting `this.checked = true`: the mutual exclusion is done in JS here, not delegated to native radio-input grouping, since the host itself is the control (no shadow `<input>`). `#select()` no-ops early if [disabled] or already [checked]: clicking an already-selected radio never re-fires `change`. `render()` calls `syncValue(this.value || 'on')` only when [checked] is true, mirroring the native HTML radio convention where an empty [value] submits the literal string `'on'`; an unchecked radio contributes nothing to form data regardless of its [value]. Per the ADR-0056 exemption noted in the class-file header, [checked] (not [selected]) is the correct state surface here: radio-ui is a native form-control primitive that self-selects, unlike the parent-managed selection-item shape (`segment-ui`, `toggle-option-ui`) ADR-0056 otherwise governs.

## `<range-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `slider-ui` | Reach for slider-ui instead when a visible track + fill should communicate position along a range visually: range-ui has no track chrome at all, its affordance is purely drag-anywhere-in-the field. Both are single-value controls, neither a two-handle min+max range. |

### Screen-reader spec

The host carries `role="spinbutton"` (not `role="slider"`: spinbutton is the correct ARIA role for a control that also accepts direct value entry via Home/End/PageUp/PageDown, which range-ui supports) with `tabindex="0"`. Every render sets `aria-valuemin`/`aria-valuemax`/`aria-valuenow` from [min]/[max]/ [value] and composes `aria-valuetext` as the formatted value plus [suffix] (e.g. "63 rem") so assistive tech announces the unit, not a bare number. `aria-label` is derived from [label] through a value- tracking guard (`#lastAutoAriaLabel`, the same gh#1647 pattern used by tag-ui/select-ui/progress-ui) that never overwrites a consumer's own explicit aria-label.

### Behavioral spec

There are two synchronized DOM layers under `[slot="field"]`: `[data-layer="base"]` and `[data-layer="fill"] aria-hidden="true"`, each holding an identical copy of the label/value/suffix markup; `render()` updates both layers' value text and drives a `--range-fill-pct` CSS custom property (percentage of [value] within the [min],[max] range) that the fill layer's CSS clips against to paint the accent highlight: this is why the field's inner markup appears twice in the DOM, not a duplication bug. `#onPointerDown` jumps the value immediately to the clicked position (`#valueFromX`) BEFORE starting the drag tracking, so even a single click-without- drag commits a value change (dispatching `input`): drag is layered on top of that initial jump, not a replacement for it. `#setValue` is the single choke point for value writes: it snaps through `#snap()` (rounds to the nearest [step] from [min], clamps into [min,max], and re-parses through `toFixed(10)` to shave float drift) and no-ops (skips both the property write and the `input` event) when the snapped result equals the current value, so sub-step pointer jitter during a drag doesn't spam `input` events. `change` fires once on drag release (`#onPointerUp`) or once per discrete keyboard commit: never during the drag itself, which only fires `input`. Decimal display precision in `#format()` is derived from [step]'s own decimal places (e.g. `step="0.1"` shows one decimal place), not a separate precision prop.

## `<rating-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `agent-feedback-bar-ui` | For thumbs-up/down binary agent feedback, reach for agent-feedback-bar-ui instead: different interaction shape and semantics from an ordinal 0..max scale. |

### Screen-reader spec

`connected()` stamps `role="slider"` with `aria-orientation="horizontal"` and a fixed `aria-valuemin="0"`; `tabindex="0"` unless [readonly] (then `-1`) or the consumer already set one. Every `render()` pass refreshes `aria-valuemax` from [max], `aria-valuenow` from [value], and a spoken `aria-valuetext` ("`{value}` out of `{max}`") so assistive tech announces the numeric rating in words rather than raw slider bounds. [readonly] additionally stamps `aria-readonly="true"` (removed when not readonly). The rendered symbols themselves (`icon-ui` pairs per slot) carry `aria-hidden="true"`: they're a visual fill effect, not separate announced content; the slider role + valuetext is the sole accessible surface.

### Behavioral spec

Two icon-ui children per symbol slot (an outline "bg" layer + a filled "fg" layer) let CSS clip the fill layer to a full/half/empty state without swapping icon names on each render: `render()` recomputes `data-fill` per slot from either the live [value] or, while hovering, a private `#hoverValue` preview that never touches the committed [value]. Pointer and keyboard input are independent of the slider's own reflected attributes: `#onPointer`/`#onClick` derive the hovered/clicked slot from `pointermove`/`click` target geometry (splitting each slot's left half for [allowHalf] instead of exposing a separate half-star hit target), while `#onKey` steps by 0.5 or 1 depending on [allowHalf] and commits directly via arrow keys / Home / End. [readonly] and `disabled` (inherited from `UIFormElement`) both short-circuit every pointer/keyboard handler at the top, so the element renders identically but accepts no input. `#commit()` is the single write path: it sets [value], clears any hover preview, calls `syncValue()` for form participation, fires a bubbling `change` with `{ value }` in `detail`, and re-renders; there is no separate `input`-vs-`change` distinction.

## `<relative-time-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

_None._

### Screen-reader spec

`connected()` stamps `role="time"` unless the consumer already set an explicit role. There is no separate ARIA live-region wiring: the formatted relative phrase ("3 hours ago") is the element's own `textContent`, so assistive tech reads it as ordinary text content on first encounter, not as an announced update: the periodic tick re-renders silently (`textContent` swap only, no `render()` re-run) and is not wrapped in `aria-live`, matching the low-urgency nature of a relative-time drift. `render()` additionally sets a `title` attribute to the full locale-formatted date/time (unless the consumer opts out via `data-suppress-title`) so a mouse or screen-reader-hover user can get full precision on demand; `datetime` is also mirrored onto the element's own attribute for any tooling that inspects it directly (there is no child `<time>` element: the host element stands in for one).

### Behavioral spec

`#format()` computes the delta between `Date.now()` and the parsed [datetime] against a fixed threshold table (seconds → minutes → hours → days → weeks → months → years, first row whose absolute delta fits wins) and formats via `Intl.RelativeTimeFormat`, falling back to a hand-built "N units ago"/"in N units" string if `Intl.RelativeTimeFormat` throws (very old runtimes). An empty or unparseable [datetime] renders nothing: there is no error state, just blank output. `#startTick()` clears any existing interval and starts a fresh `setInterval` at [updateInterval] seconds (skipped entirely when the value is `0` or non-finite, i.e. no polling cost for frozen/historical timestamps); it is called both from `connected()` and at the end of every `render()` pass, so changing [updateInterval] on a live instance immediately restarts the tick at the new cadence rather than waiting out the old one. The tick handler bypasses the full `render()` path and only reassigns `textContent` from `#format()`: cheap enough to run every minute without touching the `title`/`datetime` attribute writes. `disconnected()` always stops the tick; there is no other cleanup.

## `<richtext-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `code-ui` | For editable markdown source, reach for a CodeMirror-backed `<code-ui language="markdown" editable>` instead, since richtext-ui is display-only, never an editor. Reach for plain `<code-ui>` instead when the surface is syntax-highlighted code blocks with no surrounding prose; richtext is for multi-paragraph markdown, not a single code block. |
| `text-ui` | Reach for text-ui instead for single-line, non-markdown typography, where richtext-ui's markdown parsing and article chrome are overhead a plain text run doesn't need. |

### Screen-reader spec

No dedicated `role` is stamped: the element is a generic content container and its rendered markdown produces real semantic HTML (headings, lists, paragraphs, links, `<code>`) via `renderMarkdown()`, so assistive tech reads it the same as any other markup with those tags; there is no separate accessible-name or live-region wiring layered on top. A fetch failure or a runtime error while loading [src] renders its message as visible paragraph text inside the body (`Failed to load: …` / `Error: …`) rather than a distinct alert affordance: it is announced only because it is ordinary rendered content, not because of any ARIA role targeting the error case.

### Behavioral spec

A private `[data-richtext-body]` div is the actual render target, created once in `connected()` and never replaced: this keeps a stable insertion point distinct from the host element itself. SSR hydration is handled by checking for that div in the DOM before creating one: if it already exists (server-rendered), the client adopts it as-is rather than wiping and re-parsing its now-plain-text content as markdown. Absent that, any pre-existing default-slot textContent is captured once into a private field and cleared before the body div is appended: this is the "authored children" markdown source. `render()` applies a fixed precedence ladder each pass: a changed [src] wins and triggers an async `#load()` (fetch, then `renderMarkdown()` into the body, with fetch-failure and thrown-error paths both rendering a `<p>` message in place of content); otherwise a changed [markdown] value re-renders synchronously; otherwise the captured authored-slot markdown renders exactly once (consumed after first render: clearing the private field means it never wins again once [src] or [markdown] is later set). [flush] carries no JS branch: it is a pure CSS attribute selector toggling the article-chrome padding/measure/margin.

## `<row-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `col-ui` | col-ui is the vertical counterpart, sharing the same gap-token grammar; reach for col-ui instead when stacking children top to bottom rather than side by side. |
| `header-ui` | Reach for header-ui's own `slot="action"` convention instead of wrapping actions in a row-ui inside it just to lay them out horizontally, since the chrome parent already lays those actions out correctly on its own. |
| `footer-ui` | Reach for footer-ui's own `slot="action"` convention instead of wrapping actions in a row-ui inside it just to lay them out horizontally, since the chrome parent already lays those actions out correctly on its own. |

### Screen-reader spec

No role or ARIA wiring of its own: row-ui is a pure layout primitive with no semantic meaning beyond CSS flex direction, so it is invisible to assistive tech beyond exposing its children in DOM order (already the announced reading order; row-ui does not reorder content visually in a way that would diverge from source order, since it lays out only along the horizontal main axis).

### Behavioral spec

All positioning is CSS custom-property driven rather than class toggles: `render()` sets `--row-gap`/`--row-align`/`--row-justify` inline only when the corresponding prop contains `@` (responsive `token@breakpoint` syntax), resolving the active breakpoint token via `parseResponsive()`/`breakpoint.value`; a non-responsive value clears the inline override and lets the plain CSS attribute selector win instead: this avoids paying the responsive-resolution cost on the common static case. [wrapAt] is handled separately from the boolean [wrap]: it sets `this.style.flexWrap` directly by comparing the current breakpoint's index against the named breakpoint's index in `BP_NAMES`, rather than toggling the [wrap] attribute, specifically to avoid reactive re-entrancy (toggling the [wrap] attribute would re-trigger a property update cycle). [draggable] lazily attaches the shared `draggable` trait exactly once (via `#dragAttached` guard): either on first `connected()` if already set, or reactively from `updated()` if toggled on afterward; there is no matching "detach on false" path, matching the trait system's typical attach-once contract (see traits.md).

## `<search-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** `<input-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `input-ui` | search-ui ships a magnifying-glass prefix icon, an unconditional built-in clear affordance, and a debounced `search` event out of the box; reach for it over hand-composing `<input-ui prefix="magnifying-glass">` for any live-search or filter box. |
| `command-ui` | For command-palette interactions (search + a results menu wired together), reach for command-ui instead, since it composes search-ui with a menu rather than making callers wire that up by hand. |

### Screen-reader spec

`connected()` stamps `role="search"` (a landmark role) on the host, idempotently (`setAttrIfChanged`) so a byte-identical SSR-rendered host survives upgrade with zero mutations. Because a landmark role is never the accessible-name-bearing element, this host forwards its own `aria-labelledby` (set by a wrapping `<field-ui>` targeting the host) down onto the internal `<input-ui>` (role="textbox"), which is the actual name-bearing surface accname visits: `#syncAriaLabelledby()` runs on every render and on the `aria-labelledby` attribute changing directly (via a hand-rolled `attributeChangedCallback`, since it's not a declared property). There is no separate announcement for the clear action or the debounced search firing: those are behavior, not distinct accessible states.

### Behavioral spec

search-ui never stamps a raw `<input>`: it composes `<input-ui>` once in `connected()` (guarded by `querySelector('input-ui')` so SSR-rendered markup is adopted rather than re-stamped) with a fixed `prefix="magnifying-glass"` / `suffix="x-circle"` pair; the suffix slot element becomes the clear button via a click listener. Every render pass re-syncs `placeholder`/`disabled`/[inline]/`aria-labelledby`/`value` onto the internal input through idempotent compare-then-write helpers (`setAttrIfChanged`/`removeAttrIfPresent`, gh#1755) rather than unconditional `setAttribute` calls: this render effect re-runs on ANY reactive property changing, so an unconditional write would re-mutate an already-correct, byte-identical SSR-adopted child on every unrelated re-render and defeat zero-mutation upgrade. [inline] is a CSS-only universal attribute (ADR-0037 §2, no declared property) but still has to be forwarded in JS because the internal input-ui lives across a light-DOM composition boundary that plain `:scope[inline]` CSS can't reach: handled via a hand-rolled `observedAttributes`/ `attributeChangedCallback` pair, same shape as `[aria-labelledby]`. `#onInput` fires `input` and `change` synchronously on every keystroke (event-shape parity with other `UIFormElement` primitives) and restarts a [debounce]-ms timer that fires the debounced `search` event; Enter fires `search` immediately (clearing any pending timer first) and Escape routes to the same clear path as the suffix button click. `#onClear` resets [value], the internal input's value, and fires `input`/`change`/`search` (with an empty `detail.value`) all in the same tick, then refocuses the input: clearing is a full synchronous commit, not a debounced one.

## `<section-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `card-ui` | card.css styles only the bare native `<section>` tag, never the literal `section-ui` catalog tag. Inside a `<card-ui>`, author a plain `<section>` instead for body-region chrome (padding/border scoping, `[bleed]`, `[scroll]`); a literal `<section-ui>` written there renders unstyled. |
| `drawer-ui` | drawer.css styles only the bare native `<section>` tag, never the literal `section-ui` catalog tag. Inside a `<drawer-ui>`, author a plain `<section>` instead for body-region chrome (padding/border scoping, `[bleed]`, `[scroll]`); a literal `<section-ui>` written there renders unstyled. |
| `modal-ui` | modal.css styles only the bare native `<section>` tag, never the literal `section-ui` catalog tag. Inside a `<modal-ui>`, author a plain `<section>` instead for body-region chrome (padding/border scoping, `[bleed]`, `[scroll]`); a literal `<section-ui>` written there renders unstyled. |
| `page-ui` | page-ui owns its own shell-tier body region; do not substitute a nested `<section-ui>` for it. Reach for page-ui's own body region instead when the goal is the page's top-level body, not a body sub-section. `[scroll]` is a no-op on `<section-ui>` inside page-ui for the same reason, since page-ui already owns the scroll surface. |
| `admin-content` | admin-content owns its own body region via its bespoke children; reach for admin-content's own children contract instead of substituting a nested `<section-ui>` there. |

### Screen-reader spec

No JS-authored ARIA wiring exists because there is no JS at all: section-ui resolves to the bare native `<section>` element (see behavioral), so its accessible semantics come entirely from the native HTML `<section>` landmark contract plus whatever the parent's `@scope` CSS and any author-supplied `aria-label`/heading association add. There is nothing this component adds or could add beyond that native baseline.

### Behavioral spec

`section-ui` is a nominal/catalog-facing tag name only: no custom element is ever registered or instantiated for it (confirmed: no `.class.js` exists in this directory, unlike every interactive sibling). The rendered output is always a bare native `<section>`, styled entirely by the closest container parent's `@scope` rules; the same "slot stub" pattern applies to Header and Footer (which likewise resolve to native `<header>`/`<footer>`): only Aside in this quartet is a real registered custom element. [scroll] and [bleed] are pure CSS attribute selectors with no JS branch: card.css/drawer.css/modal.css key `[bleed]`/`[scroll]` off the bare `section` tag only (confirmed by source, gh#3461), never the literal `section-ui` tag, so a literal `<section-ui>` written inside `<card-ui>`/`<drawer-ui>`/`<modal-ui>` renders unstyled rather than a no-op; page.css is the one stylesheet that also aliases `section-ui` alongside `section`, which is why the a2ui `allowedParents` contract on this component names Page (and, separately, AdminSidebar's nav body) and not Card/Drawer/Modal.

## `<segment-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tab-ui` | tab-ui is a view switcher's child that drives content panel visibility; reach for it instead when the element is a navigation target, not a toggle-group option. |
| `select-ui` | A native `<option>` inside select-ui is a dropdown entry; reach for select-ui instead when the surface is a dropdown, not a toggle-group. segment-ui carries no independent selection contract of its own. |

### Screen-reader spec

`connected()` stamps `role="radio"` unconditionally: the parent `<segmented-ui>` overrides this to `"checkbox"` per segment when it is in `[multiple]` mode (segmented.class.js's render loop), so segment-ui's own role is really just the single-select default. `tabindex="-1"` is the initial default (roving tabindex: the parent grants `"0"` to exactly the active/focusable segment). `render()` mirrors [text] into `aria-label` when set, refreshes `aria-checked` from [selected] every pass, and sets `aria-disabled="true"` when [disabled] (removed otherwise), so the accessible state always tracks the live prop values rather than being stamped once at connect.

### Behavioral spec

segment-ui carries no interaction handling of its own: no click, keyboard, or focus logic lives here; `<segmented-ui>`'s own `#handleClick`/`#handleKeydown` own selection and roving focus entirely, treating segment-ui purely as a value/label/icon data carrier plus the visual `[selected]` attribute they toggle. The one piece of local DOM management is the leading icon: `render()` diffs the existing `:scope > icon-ui` child against the current [icon] value (by name), removing and recreating it only when the icon actually changes rather than tearing down and rebuilding on every render: mirroring `button-ui`'s icon-stamping pattern. [text] does not render via a text node; it paints through CSS `attr(text)` on a `::after` pseudo-element, matching badge-ui's approach, so the DOM has no visible text child to diff against.

## `<segmented-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** `<segment-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tabs-ui` | For longer option sets, reach for tabs-ui instead, since segmented-ui's indicator geometry and keyboard model don't scale past a handful of segments (roughly 3-5). |
| `select-ui` | For longer option sets, reach for `<select-ui multiple>` instead, since segmented-ui's indicator geometry and keyboard model don't scale past a handful of segments (roughly 3-5). |

### Screen-reader spec

The host role tracks [multiple] every render (`"group"` vs `"radiogroup"`, re-set on every pass rather than once at connect: a gh#1369 review fix so a mid-life [multiple] flip doesn't leave a stale host role while children's roles already update reactively). Each child segment's role is likewise synced per-pass to `"checkbox"` under [multiple] or `"radio"` otherwise. Selection state syncs via `[selected]`/`tabindex` on each segment: exactly one segment carries `tabindex="0"` at a time under single-select (roving tabindex), and under [allowEmpty] with nothing selected, the first enabled segment still gets `tabindex="0"` so the empty state remains keyboard-reachable (mirrors `<list-ui>`'s roving-tabindex fallback). [label] (inherited from `UIFormElement`) sets `aria-label` on the host when present. A one-shot `console.warn` (WeakSet-guarded so it never fires twice per instance) flags any direct child that isn't `<segment-ui>`: a bare `<segment>` tag renders visible text but gets no sliding indicator, role, or `aria-checked` wiring, which is otherwise a silent accessibility gap.

### Behavioral spec

The sliding indicator is a template-owned anatomy part (`[data-indicator]`), never a light-DOM insertion point: `#updateIndicator()` guarantees exactly one indicator element exists at all times, defensively removing any stale ones before creating a fresh one (guards against HMR/ disconnect-reconnect cycles leaving duplicates). It positions via `transform: translateX(N * 100%)` and disables its own CSS transition for exactly one frame on first paint (via `requestAnimationFrame`) so the indicator doesn't visibly slide in from the origin on initial mount. [multiple] suppresses the indicator entirely: `#updateIndicator` itself guards on `this.multiple` (not just the `render()` call site), because a `ResizeObserver` and a `document.fonts.ready` callback both call it directly and asynchronously, bypassing render()'s own guard; without the guard living in the method itself, a multiple-mode instance with exactly one selected segment could grow a spurious indicator after a late resize or font-load event (its comma-free `value` would coincidentally exact-match a single segment's own value, the same equality check single-select uses). [multiple]'s value uses the same comma-separated-set encoding as `<select-ui multiple>`; `#select()` branches entirely on [multiple] to decide between add/remove-from-set vs. direct value replacement. Keyboard navigation is unified via `#nextFocusTarget()` (Arrow keys/Home/End move a roving focus cursor among ENABLED segments only) but selection semantics diverge after that: single-select is a true radiogroup where arrow-move both moves focus AND selects (native radio behavior); [multiple] is a checkbox toolbar where arrow-move only moves focus and Space/Enter is required to toggle the focused segment. `connected()` wires a `ResizeObserver` and a `document.fonts.ready` watcher specifically to recompute indicator position after layout shifts from font loading or container resize: both are common sources of initial-layout drift for a transform-based indicator computed from segment index rather than measured geometry.

## `<select-ui>`

**Composes:** `<icon-ui>`, `<tag-ui>`, `<button-ui>`, `<adia-mark-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `segmented-ui` | For a short option set (≤4), reach for segmented-ui instead, since it surfaces every choice at once without a popover interaction. |
| `radio-ui` | For a short option set (≤4), reach for radio-ui instead, since it surfaces every choice at once without a popover interaction. |

### Screen-reader spec

`connected()` stamps `role="combobox"` on the host; `#syncAccessibleName()` resolves the accessible name with a fixed precedence: an author-set `aria-label` or `aria-labelledby`, or a wrapping `<field-ui>`'s [label], always wins (FB-88/gh#1646: the component never clobbers a consumer's own naming choice); only when the select is otherwise unnamed does it fall back to deriving one from [placeholder]. The internal listbox carries `role="listbox"` (each row `role="option"`, `aria-selected` synced from the live selection set every render pass: Set-membership comparison under [multiple], direct value equality otherwise) and gains `aria-multiselectable="true"` specifically under [multiple]. The host's own `aria-expanded` mirrors the popover's open state on every render. [hint] wires an `aria-describedby` pointing at a per-instance generated hint id rather than relying on a wrapping field to supply one: this select can self-describe. Under [multiple] with an editable contenteditable trigger, that trigger surface additionally carries its own `role="combobox"` and `aria-autocomplete="list"`, mirroring `<combobox-ui>`'s editable-surface pattern rather than inventing a new one.

### Behavioral spec

select-ui is a compound trigger + popover-listbox primitive that composes `icon-ui` (caret + option-row affixes), `tag-ui` (multi-select chips), `button-ui` (clear-all / select-all / +N-overflow affordances: gh#276 forbids stamping a raw `<button>`), and `adia-mark-ui` (the [mark] leading visual): none auto-imported per ADR-0027, so a consumer importing select-ui piecemeal must import each explicitly. `syncValue()` branches early on [multiple]: single-select delegates straight to the parent `UIFormElement` implementation, while multi-select maintains its own comma-separated value string and Set-based selection bookkeeping (§FB-46) so that toggling one option never disturbs the others already selected. Options ARE NOT limited to being direct light-DOM children: a `role="presentation"` wrapper (used by `.map()`/`repeat()`-rendered option lists) is walked through transparently when collecting the selectable set, so repeat-rendered and hand-authored options behave identically. Pre-selection at connect time scans for options already carrying a `selected` marker and seeds [value] from them: joined with a comma under [multiple], taking just the first match otherwise, so a select mounted with pre-selected `<option>` children needs no programmatic value assignment to start correct. [clearable] surfaces a "Clear all" `button-ui` only when [multiple] AND at least one value is selected AND the control is neither [disabled] nor [readonly]: it has no effect on single-select at all (see anti_patterns if this component gains one): matching search-ui's precedent of NOT forwarding a same-named affordance that doesn't apply to every mode. [autocomplete] (gh#3109) is read from the host once when the trigger is stamped and copied onto the searchable contenteditable surface; the surface is never swapped for a native `<input>` the way input-ui does under an autofill-relevant value (ADR-0055), so the attribute is a hint, not an autofill trigger, and is inert without [searchable].

## `<shader-texture-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chart-ui` | Never reach for shader-texture-ui for data visualization, since it is a purely decorative ambient background layer with no data-binding contract at all. Reach for chart-ui instead whenever the surface needs to represent real data. |

### Screen-reader spec

`connected()` stamps `aria-hidden="true"` on the host, and the internal `<canvas>` it creates also carries `aria-hidden="true"`: belt-and-suspenders, since the element is purely decorative and never carries content, interaction, or an accessible name of any kind. There is no role, no live region, and nothing else for assistive tech to announce; a WebGL1- unavailable environment renders nothing at all (no fallback text, no error state) rather than degrading to any announced content.

### Behavioral spec

`[variant]` is read once at mount, not reactively: live variant-swapping mid-life is explicitly out of scope (ruled in LLD-3777, not merely deferred); a consumer that changes `[variant]` post-connect must remount the element. `connected()` creates a `<canvas>` and requests a WebGL1 context via `getContext('webgl', …)`; a `null` context (no WebGL support, or a test-environment shim with no WebGL canvas adapter: both return `null` identically) short-circuits the rest of setup silently, so the "unsupported" and "shimmed for tests" cases both produce the same harmless empty-canvas outcome. An unrecognized [variant] falls back to the default shader with a one-time, WeakSet-guarded console warning rather than rendering nothing. The animation loop is driven by `requestAnimationFrame` and gated by `#shouldAnimate()`, which returns false: pausing the loop entirely rather than merely skipping visual updates: under any of three independent conditions: [static] is set, the document is hidden (`visibilitychange` listener), or `prefers-reduced-motion: reduce` is active (tracked live via a `matchMedia` listener, not just checked once at mount, so an OS-level motion-preference change mid-session takes effect immediately rather than waiting for an unrelated re-render). `disconnected()` tears down the `visibilitychange` listener, the `matchMedia` listener, and cancels any pending animation frame: there is no partial-teardown path.

## `<skeleton-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `empty-state-ui` | Not for the post-load zero-data case; reach for empty-state-ui instead, a different lifecycle phase entirely (after loading completes, not while it's in progress). |
| `spinner-ui` | Not for indeterminate-duration circular loading with no known shape; reach for spinner-ui instead. |

### Screen-reader spec

`connected()` sets `aria-hidden="true"` unconditionally: the shimmer box itself is never announced. A consumer stamping several skeleton-ui blocks to preview a card's shape should still convey the loading state through an ancestor's own `aria-busy`/`aria-live` region (skeleton-ui does not provide one); it is purely a visual placeholder, invisible to assistive tech by design.

### Behavioral spec

Purely presentational: `static template = () => null` means no DOM is stamped; the element's own light-DOM children (if any) are left alone. `#applySize()` runs on `connected()` and every `render()`, writing `this.style.width`/`this.style.height` directly from the [width]/[height] props (inline style, not a CSS custom property): an author-set inline `style="width:…"` on the host is overwritten on the next render if [width]/[height] also changed. The shimmer animation is pure CSS keyframe looping; [static] only toggles the animation off (`animation: none`-class rule) and has no other effect. There is no loading→loaded transition logic in the component itself: the consumer swaps skeleton-ui out of the DOM (or toggles a sibling's visibility) once data arrives; skeleton-ui has no built-in timer, promise, or completion signal. Shape is driven purely by CSS sizing ([width]/[height]/[cornerRadius]); there is no content-aware variant enum, so author the placeholder's dimensions to match what will render.

## `<skip-nav-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `visually-hidden-ui` | For content that must stay hidden from sighted users, visually-hidden-ui is the right fit: it hides permanently, for text only assistive tech needs. skip-nav-ui is a real link that BECOMES visible on keyboard focus, so it is the wrong choice for anything that should never appear on screen. |

### Screen-reader spec

Renders a real `<a href="[target]">`: no ARIA role overrides. The link is visually hidden by CSS until it receives keyboard focus (the `:focus-within` state, styled via the `focused` state selector), at which point it becomes visible at the top-left of the viewport. On activation, `#onClick()` does not `preventDefault()`: the browser still performs its native hash-jump for back-button history, but additionally queues a microtask that ensures the target element has `tabindex="-1"` (stamping it if absent) and calls `.focus()` on it, because a native hash-jump scrolls but does not reliably move focus in every browser, which would otherwise strand a screen-reader cursor at the skip link itself instead of resuming inside the target region.

### Behavioral spec

No shadow DOM, no internal state machine: the entire behavior is the `#onClick` handler described above. [target] and [text] are both `reflect: true` plain strings with no validation: an author who points [target] at a nonexistent id gets a silent no-op (`#onClick` returns early when `document.getElementById(id)` finds nothing) rather than an error. There is no keyboard-vs-mouse branching: the same click handler fires whether the link is activated by mouse click or Enter/Space on a focused anchor (native `<a>` semantics), and there is no distinct "activated" visual state beyond the existing focused/idle pair. skip-nav-ui does not belong inside shell/nav components themselves, since its entire contract depends on DOM position, as the very first focusable element in `<body>`, before the nav/shell chrome it lets keyboard users skip.

## `<slider-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `range-ui` | range-ui is a draggable numeric field, not a two-thumb slider; do not reach for it as a substitute for slider-ui's [dual] mode. |
| `step-progress-ui` | Not for a step-wizard progress indicator; reach for step-progress-ui instead. |
| `stepper-ui` | Not for a step-wizard progress indicator; reach for stepper-ui instead. |
| `progress-ui` | Not for a determinate percent-complete bar with no interactive input; reach for progress-ui instead. |
| `field-ui` | Prefer [label] over wrapping slider-ui in field-ui unless external hint/error/action-slot composition is needed; [label] is a first-class in-header caption wired to `aria-label` on the host. |

### Screen-reader spec

Single-thumb mode: host carries `role="slider"` plus `aria-valuemin`/`aria-valuemax` (set once in `connected()`) and `aria-valuenow`/`aria-valuetext` (kept live in `render()`, the latter suffixed with [suffix] when set). Dual-thumb mode: the host itself becomes `role="group"` (its own `aria-valuemin`/`aria-valuemax` are removed since a group has no single value), and each stamped thumb div carries its own `role="slider"` with an auto-generated `aria-label` ("`[label] lower bound`"/"`[label] upper bound`", or "Lower bound"/ "Upper bound" when [label] is unset) plus its own live `aria-valuemin`/`aria-valuemax`/`aria-valuenow`/`aria-valuetext`. When [hint] is set, a `slider-hint-N` id is minted and wired to `aria-describedby` on the host (distinct from `aria-label`, which carries [label]): set on `connected()` only, so toggling [hint] after first render does not add/remove the `aria-describedby` wiring (`§184` scope). [label] is re-mirrored to `aria-label` on every render regardless of mode.

### Behavioral spec

On first `connected()` (guarded by `!this.querySelector('[slot="track"]')`, so re-parenting an already-stamped instance doesn't re-stamp), the component mints its own header/track/thumb(s)/hint light-DOM structure from props: an author who pre-supplies a `[slot="track"]` child opts out of auto-stamping entirely. [dual] switches thumb count and geometry: dual reserves `2×thumb-width` of travel space so the two thumbs can never visually overlap (touch edge-to-edge at equal values); `render()` enforces `lowerValue ≤ upperValue` bidirectionally by clamping whichever value did NOT just change (so direct `.lowerValue =`/`.upperValue =` programmatic writes behave intuitively regardless of which prop moved last). Drag lifecycle is a real pointer-capture state machine (`pointerdown` → `pointermove`* → `pointerup`/`pointercancel`/ `lostpointercapture`, the latter two added specifically to cover a drag interrupted by an OS gesture or touch steal, so `[dragging]` never gets stuck set): value changes fire `input` continuously (throttled per [throttle] ms via `UIFormElement`'s shared throttle machinery) while `change` fires once, after `flushPendingInput()`, on pointerup/track- click/keyboard-commit. Track click picks the nearer thumb in dual mode by comparing distance-to-each-thumb's forward-geometry center, then inverts that specific thumb's geometry so the chosen thumb lands exactly under the cursor. `#format()` derives display-decimal count from [step]'s own decimal places (e.g. `step="0.5"` renders one decimal). Keyboard: arrow keys move by [step], PageUp/PageDown by `10×[step]`, Home/End jump to [min]/[max]; in dual mode the currently-focused thumb (via `document.activeElement`) is the one that moves.

## `<spinner-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `progress-ui` | For a known percent-complete (a real fraction done, not just "in progress"), reach for progress-ui instead: spinner-ui has no fraction-complete to show by definition. |
| `skeleton-ui` | For a known-shape content placeholder, reach for skeleton-ui instead. spinner-ui, skeleton-ui and progress-ui are siblings; never nest one inside another. |

### Screen-reader spec

`connected()` sets `role="progressbar"` and `aria-busy="true"` once (guarded by `hasAttribute` checks, so a consumer-set role/aria-busy is respected and not clobbered), and both `connected()` and `render()` write `aria-valuetext` from [label] (falling back to the literal string "Loading" if [label] is empty/falsy). There is no `aria-valuenow`: indeterminate progress has none by definition. `prefers-reduced-motion: reduce` is handled entirely in CSS: the spinning keyframe animation is replaced by a static ellipsis, with no JS media-query observer involved (WCAG 2.3.3).

### Behavioral spec

Purely CSS-driven: `static template = () => null`, no timers, no ResizeObserver, no IntersectionObserver. The four [variant]s (arc, ring, dots, knight) are all `::before`/CSS-only paint EXCEPT dots, which stamps three real `<span data-spinner-dot="N">` light-DOM children via `#syncDots()` (called from both `connected()` and every `render()`): real children were chosen over a box-shadow/pseudo-element approach because flex-gap math and independent per-dot animation-delay both require real DOM nodes, not shadow paint. `#syncDots()` is idempotent (no-ops if exactly 3 dot children already exist) and tears the dots back out if [variant] changes away from `dots`. [paused] freezes the CSS animation in place via a reflected attribute selector: no JS pause/ resume logic. There is no lifecycle beyond mount/unmount: the spinner animates for as long as it's connected to the DOM, and the consumer is solely responsible for removing/hiding it when the operation completes. Inside a button, set [tone="current"] so the spinner matches the label color, and disable the button while the operation runs (the spinner itself does not disable its host). Never stack multiple sibling spinners in one region: one parent-level spinner reads as "busy", several read as noise.

## `<stack-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `col-ui` | Never reach for stack-ui for vertical content flow: that's the "vstack" idiom from other frameworks, and it's col-ui here. stack-ui is overlap-only, all children share one grid cell rather than flowing. |

### Screen-reader spec

No ARIA is applied by the component itself: stack-ui is a pure layout primitive with no semantic role of its own. Screen-reader behavior is entirely a function of what's stacked: if an overlay child is decorative (a badge dot, a loading veil) the author should mark it `aria-hidden="true"`; if it's meaningful (a badge count) it should carry its own accessible name via its own component's contract. Because every child occupies the same visual region, DOM order (not visual [align]) determines both z-paint order (later child paints on top) and reading order for assistive tech: author children in the order they should be announced, not the order they should visually stack.

### Behavioral spec

`static template = () => null`: no light-DOM stamping, no lifecycle logic beyond the reflected `[align]` attribute driving a CSS `place-items` rule on a `display: grid` host with every child sharing grid area 1/1. There is no JS-side z-index management; paint order follows DOM order (later siblings paint over earlier ones) unless a child sets its own `z-index`. No resize/mutation observers: the component has nothing to react to beyond the single [align] prop.

## `<stat-ui>`

**Composes:** `<icon-ui>`, `<skeleton-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `progress-ui` | Not for inline percent/progress bars: reach for progress-ui instead, a different visual and semantic role than a standalone metric tile. |

### Screen-reader spec

`[loading]` sets `aria-busy="true"` on the host (cleared once loading ends) and swaps the value/change slots for `<skeleton-ui>` shimmer placeholders: the label stays live text throughout since it's static metadata, not fetched data. No other explicit ARIA role is applied; stat-ui relies on its plain-text value/label/change children being read in DOM order (label, then value, then change) by assistive tech, so the eyebrow label always precedes the number it describes. The change badge's trend arrow is a real `<icon-ui data-trend-icon>` (not a CSS pseudo- element glyph), so a screen reader honors icon-ui's own accessible-name contract rather than being silently skipped as decorative paint.

### Behavioral spec

A plain (non-form) `UIElement` subclass with `static template = () => null`: `connected()` adopts-or-stamps `[slot="value"]`, `[slot="label"]`, `[slot="change"]` (itself containing a stamped `icon-ui[data-trend-icon]` + `[data-trend-text]` pair), and `[slot="icon"]` children exactly once. `render()` re-normalizes the change slot's child SHAPE (not just its text) on every pass: any deviation from the exact icon-then-text two-node shape: a stale previous render, the loading-state skeleton swap, or an orphaned disconnect/reconnect leftover: triggers a full clear-and-reinsert of the canonical pair before writing new text, so [change]'s content can never end up partially stale. [trend] only ever affects the change badge's icon+color (`up`→arrow-up/success, `down`→arrow-down/danger, anything else including `flat`→no icon, no trend color) and is read ONLY when [change] is non-empty: [trend] alone with no [change] has no visible effect. [bleed] and [band] are pure reflected-attribute layout toggles gating CSS grid-template-area rules in stat.css; the component's JS makes no layout decisions itself. There is no chart-rendering logic in stat-ui: a `slot="chart"` child is entirely the consumer's own chart-ui (or other) instance; stat-ui only reflows its own grid to make room for it.

## `<step-progress-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `stepper-ui` | For a labeled/expanded stepper where each step has its own text (and optionally click-to-navigate), reach for stepper-ui instead: density is the deciding boundary between the two. |
| `progress-ui` | For multiple concurrent labeled progress rows (not a single linear step sequence), reach for a stack of progress-ui with [label] set instead of step-progress-ui. |

### Screen-reader spec

Host carries `role="progressbar"` with `aria-valuemin="0"` set once in `connected()`, and `aria-valuemax`/`aria-valuenow` kept live in `render()` from the clamped [total]/[value] (both coerced with `| 0` and clamped to `[0, total]`, so an out-of-range or non-integer [value] never reaches the ARIA attributes unclamped). The [caption] text is NOT wired to any `aria-label`/`aria-describedby`: it renders as plain visible text in its own `slot="caption"` span, read in normal DOM-order flow rather than as the progressbar's accessible name. Individual step dashes carry no per-dash semantics (no `aria-valuetext` per step, no `role="listitem"`): they're pure decorative `<span>` track marks toggled via `[data-active]`.

### Behavioral spec

`connected()` auto-mints a `slot="caption"` span and a `slot="track"` div UNLESS the consumer already supplied both custom slots (checked via `logicalSlotted`): a partial override (only one of the two slots present) is not supported; either both are auto-minted or neither is. `render()` diffs the track's dash-span COUNT against [total] on every pass (appending/removing `<span>` children to match) rather than recreating the track wholesale, then toggles `[data-active]` on the first [value] dashes. The auto-minted caption's text sync is itself guarded by a `dataset.custom` check that the component never actually sets on its own stamped span: in practice this means the auto-minted caption is always kept in sync with [caption] on every render (the guard exists as a future consumer-opt-out hook, not currently exercised by any code path). There is no state machine beyond synchronous prop→DOM projection; the yaml's `empty`/`in-progress`/`complete` states are derived purely from [value] vs [total] and have no dedicated CSS attribute of their own: they describe the value range, not a stamped `[state]` selector.

## `<stepper-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** `<stepper-item-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `timeline-ui` | Not for read-only event history, reach for timeline-ui instead: stepper-ui implies forward-progress semantics, timeline-ui does not. |

### Screen-reader spec

Non-interactive mode carries no interactive ARIA at all: no `role`, `tabindex`, or `aria-current` on any child, since it's a read-only visual progress display. [interactive] mode stamps `role="button"` on every `stepper-item-ui` child and sets `aria-current="step"` on exactly the item matching the current [step] index; disabled items additionally carry `aria-disabled="true"` and are excluded from the roving-tabindex set entirely (no `tabindex` attribute at all, so they're unreachable by Tab). The roving-tabindex focus target defaults to the current step if it's not disabled, else the first enabled item. Keyboard order respects [orientation]: ArrowRight/ArrowLeft for horizontal, ArrowDown/ArrowUp for vertical, both wrapping at the ends; Home/End jump to first/last enabled item.

### Behavioral spec

A `MutationObserver` (childList + subtree + `disabled`-attribute filter) covers two related gaps rather than one: (1) during static-HTML parsing this element's own `connectedCallback`/first `render()` can fire BEFORE its `stepper-item-ui` children are even appended (parent connects before children in document order), so the observer catches children landing a tick later and re-renders; (2) a child's `[disabled]` flip after mount must re-drive this parent's roving-tabindex computation without the host manually re-invoking render(), since `disabled` lives on descendant DOM rather than this element's own reactive properties. `#select(index)` dispatches the cancelable `step-request` event BEFORE mutating [step]: `preventDefault()` on it stops the mutation, the subsequent `change` event, AND leaves focus/roving-tabindex state exactly where it was; a host that never listens sees pre-veto uncontrolled behavior unchanged (additive, non-breaking). `isDisabled()` deliberately reads the raw `disabled` ATTRIBUTE (not just the property) because in some environments a child's attribute lands on the DOM node synchronously during parsing before its own property-mirror replay has completed by the time the parent's first render runs (gh#736): attribute-read is the race-proof source of truth. `next()`/`prev()`/`goTo()` are imperative JS methods that mutate [step] directly and do NOT go through the `step-request` veto gate: that gate only applies to click/keyboard-driven navigation in [interactive] mode.

## `<stream-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `text-ui` | For already-complete, static (post-stream) text, reach for text-ui instead: stream-ui's entire contract assumes a live, in-progress feed. |
| `richtext-ui` | For already-complete, static (post-stream) rich text, reach for richtext-ui instead: stream-ui's entire contract assumes a live, in-progress feed. |

### Screen-reader spec

No ARIA role or live-region wiring is applied by the component itself: stream-ui writes directly to `textContent`, so a screen reader using a page-level live region would need the CONSUMER to wrap it in `aria-live="polite"` (or similar) if incremental announcements are desired; by default the streamed text is silent until something else reads it. The blinking cursor (suppressed via [no-cursor]) is a pure CSS pseudo-element with no semantic content, so it introduces no extra announcement either way.

### Behavioral spec

Assign the live token source via the `.tokens` JS-only property; there is no HTML-attribute equivalent, so it cannot be set declaratively. Typically placed inside `<chat-thread-ui>` message bodies for LLM responses, or standalone for log tailing. `.tokens = iterable` is the sole trigger: setting it (even to the same reference) immediately calls `#startStream()`, which first aborts any IN-FLIGHT stream via an internal `AbortController` (so reassigning `.tokens` mid-stream is safe and cleanly supersedes the previous run, rather than interleaving two streams' output). `textContent` is cleared and [streaming] flips true before the first `stream-start` event fires. [pace] branches the render strategy: `pace="0"` (default) assigns the full accumulated buffer to `textContent` on every token (real-time, batched by however fast the source yields); `pace > N` instead walks each token CHARACTER BY CHARACTER with an `await new Promise(setTimeout(pace))` between characters, so a large [pace] value visibly slows to a typewriter cadence: this per-character await loop respects the abort signal on every iteration, so an in-progress typewriter reveal stops immediately when superseded. On normal completion, [streaming] flips false and `stream-end` fires; an exception from the iterable fires `stream-error` with `detail: { error }` UNLESS the stream was aborted (an abort suppresses both `stream-error` and `stream-end`: neither fires for a superseded/stopped stream). `stop()` aborts without clearing accumulated text; `clear()` calls `stop()` then also resets the internal buffer and `textContent` to empty. `disconnected()` calls `stop()` automatically, so removing a mid-stream instance from the DOM cleanly cancels the in-flight iteration rather than leaking it.

## `<swatch-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `color-area-ui` | For interactive color PICKING, reach for color-area-ui instead: swatch-ui is display-only even with [selectable] set (selection toggles a visual/ARIA state and fires `select`, it never opens a picker or lets the user choose an arbitrary color). |

### Screen-reader spec

[selectable] stamps `role="button"` + `tabindex="0"` (only if the consumer hasn't already set a role/tabindex: the component never overrides an author-set value) and mirrors [selected] to `aria-pressed="true"/"false"` on every render. Badge pips each carry `role="img"` with a per-variant `aria-label` (`out-of-gamut` → "Outside sRGB gamut", `p3-only` → "Display-P3 only", `apca-pass` → "Contrast passes APCA", `apca-fail` → "Contrast fails APCA") so the symbol glyph (△ ✦ ✓ ✗) is never announced as raw punctuation. The copy button is a real `<button-ui data-copy aria-label="Copy value">`, dynamically re-labeled to `"Copy <value>"` once a copy target resolves, and flashes its own `text` glyph (⧉ → ✓/⚠) to reflect copy success/failure: that flash is purely visual (the aria-label is not updated for the transient result state). [auto-contrast] only affects the LABEL's text color for legibility against the tile fill; it has no ARIA-visible effect.

### Behavioral spec

Stamps up to five light-DOM children on `connected()`/first `render()` (tile, badge, label, detail, copy button) via a `#stamp()` guarded by an internal `#stamped` flag, so re-connection doesn't re-stamp. Author- provided default-slot content (rich label markup) is captured BEFORE the `innerHTML = ''` wipe and re-inserted into the label region: pure- whitespace text nodes are filtered out of that capture so indented multi-line author HTML doesn't corrupt the stale-text-node detection used to keep [label] synced. A `[slot="chrome"]` child (sibling-of-tile chrome like override dots or custom indicators: distinct from the default slot, which funnels into the label) is handled with unusual care: it must survive the wipe, land immediately after the tile (not inside it, so consumer CSS can `position: absolute` against the host's padding- box), and a `MutationObserver` re-runs `#absorbChromeSlot()` for chrome children that arrive AFTER the initial stamp: covering template-engine rendering paths where property updates and child-node interpolation land in separate passes, including chrome nested inside a `display: contents` wrapper span some template engines insert. [auto-contrast] resolves the effective color via a 1×1 canvas pixel probe converted to OKLab L (handles any CSS color form including unresolved `var()` refs by reading computed style), then toggles `data-on-light`/`data-on-dark` on the label at an L≈0.62 threshold: the probe result is cached against the last- seen color string so it only re-runs when the color actually changes. [badge] accepts a space/comma-separated list of variants, silently dropping any unrecognized value (consumer-typed-by-attribute defense); the badge child SET is diffed against the currently-rendered variants so an unchanged badge list is a no-op, not a full replace. Selection (`click`/Enter/Space when [selectable]) explicitly ignores activation originating inside the copy button (checked via `composedPath()`) so the two affordances never collide.

## `<swiper-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `tabs-ui` | Never reach for swiper-ui for step-by-step wizard content or unrelated tab panels, reach for tabs-ui for those instead; swiper-ui is for content browsing, not panel switching. |

### Screen-reader spec

Host carries `role="region"` + `aria-roledescription="carousel"` + `aria-label` (default "Carousel", never overwritten if a consumer already set one). Each slide gets `role="group"` + `aria-roledescription="slide"` + an auto `aria-label` ("N of TOTAL") unless the consumer already set one. The auto-stamped dots container is `role="tablist"` with each dot `role="tab"` + `aria-label="Page N"` + `aria-current` mirroring the active page; prev/next paddle buttons get `aria-label="Previous slide"`/`"Next slide"`. The optional `[counter]` text ("N of TOTAL") is plain visible text with NO `aria-live` region: it is not announced on slide change; a consumer needing that announced should wire their own live region off the `change` event.

### Behavioral spec

ArrowRight/ArrowLeft (while focus is inside the host) call `next()`/`prev()` directly, independent of drag/scroll-snap. `[autoplay]` pauses on `mouseenter`/`focusin` and resumes on `mouseleave`/`focusout` (only if focus moved OUTSIDE the host, `!this.contains(e.relatedTarget)`): both transitions fire `autoplay-pause`/`autoplay-resume` (with `detail.reason` "hover" or "focus" on pause) so a consumer can react (e.g. a play/pause icon). Autoplay only ever resumes automatically if `[autoplay]` is still set; a consumer-triggered `pause()` while `[autoplay]` is on behaves identically to a hover-pause: the timer stops until the next hover/focus-out resume trigger. Pointer drag (mouse/pen; native touch already scroll-pans) stamps `[data-dragging]` while active and `[data-just-dragged]` briefly after release (click-suppression window, so a drag-release doesn't also register as a slide-advancing click). There is no error or loading state: every slide is caller-authored content, never fetched by swiper-ui itself.

## `<switch-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `check-ui` | Reach for check-ui instead for opt-in lists, terms acceptance, or multi-select rows, and for anything needing a third/indeterminate state. |
| `field-ui` | Never wrap switch-ui in field-ui: the widget already self-labels via [label]; wrapping it doubles the label. |

### Screen-reader spec

`connected()` stamps `role="switch"` and `tabindex="0"` on the host itself: the host IS the control, no wrapped native input. `render()` mirrors the live [checked] state into `aria-checked` on every render pass (string "true"/"false", not a boolean reflect). [hint] wires an `aria-describedby` pointing at a per-instance generated `switch-hint-N` id, set only while [hint] is non-empty: the switch can self-describe without a wrapping field. [label] renders inline as a template-owned `<span slot="label">`, not via the deprecated `UIFormElement.label` above-the-field rendering (`static labelDeprecated = false` opts this primitive out of that base-class warning): a11y rules forbid wrapping in `<field-ui>` since the widget already self-labels via [label].

### Behavioral spec

Reach for switch-ui for an immediately-applied setting toggle (notifications on/off, dark mode, feature flags) where flipping it IS the action, with no separate form-submit step implied. Binary on/off only, there is no third/indeterminate state (for tri-state, `<check-ui>` with `[indeterminate]` is the intended substitute; see the a2ui rule declaring the state-model boundary). `#toggle()` flips [checked] on click and fires a bubbling `change` CustomEvent with `detail: { value, checked }`; it no-ops entirely when [disabled]. The keyboard model adds only Space and Enter (`#onKey`) beyond the click default: both call `preventDefault()` then the same `#toggle()` path, so click and keyboard commit through one code path with one event shape. `render()` also calls `syncValue()` with the configured [value] (or `'on'` when unset) while checked, empty string while unchecked: the standard checkbox/switch form-submission convention. There is no loading or error state modeled by this primitive; a consumer surfacing either wraps it externally.

## `<table-footer-ui>`

**Composes:** `<pagination-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `table-toolbar-ui` | For controls that filter, sort, search or scope a table, table-toolbar-ui is the right fit: both are companions to a sibling table-ui, but the toolbar sits above it and changes WHICH rows are shown, where table-footer-ui sits below and reports and pages the current result set. |
| `pagination-ui` | Not an alternative: table-footer-ui composes pagination-ui and adds the "Showing X to Y of N" range label plus the derivation-model page and page-size API. Reach for a bare pagination-ui only outside a table. |

### Screen-reader spec

The range region carries `aria-live="polite"` with `aria-atomic="true"` (#stamp), so every page move, filter change, or sort re-derivation that updates the range text is announced without stealing focus from wherever the user currently is: including a `[slot="empty"]` transition into or out of the confirmed-empty state, since that content renders inside the same live region. The pager itself is a composed `<pagination-ui>` (never re-implemented here) and inherits that component's own ARIA and keyboard model; table-footer-ui adds no keyboard handling of its own beyond forwarding pager clicks as events.

### Behavioral spec

Four distinct range states, not one: range-total ABSENT renders both regions hidden (indeterminate: "not yet known" is never conflated with "zero"); range-total EXPLICIT "?" is the open/unproven-total state (gh#1877/ADR-0082 Amendment): "Showing X–Y" with no "of N" and the pager held in has-more mode until a real total resolves; range-total EXPLICIT "0" hides the pager and renders `[slot="empty"]` in the range region if supplied, else hides it (REQ-S-002, confirmed-empty, mutually exclusive with the normal range text); and page-size ABSENT or "0" renders a count-only label ("128 items") with no pager (REQ-S-005, the ordinary default). There is no built-in error state: a fetch failure is a caller-composed pattern using the same empty-state slot or an external banner, same as table-ui. derived pages ≤ 1 hides the pager but still renders the range label (REQ-S-003: a single page is still information).

## `<table-toolbar-ui>`

**Composes:** `<text-ui>`, `<search-ui>`, `<button-ui>`, `<field-ui>`, `<select-ui>`, `<input-ui>`, `<menu-ui>`, `<menu-item-ui>`, `<icon-ui>`, `<check-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `toolbar-ui` | For a general action bar with no table behind it, toolbar-ui is the right fit: it takes arbitrary items and spills what does not fit into an overflow menu. table-toolbar-ui is a companion to a specific sibling table-ui and its controls act on that table. |

### Screen-reader spec

The host stamps `role="toolbar"` on itself (#stamp). Each popover trigger (filter/sort/columns) carries `aria-haspopup="menu"`, and the "More table actions" overflow trigger additionally carries `aria-label="More table actions"`; every popover panel is `role="menu"` (composed of `<menu-item-ui>` rows, each `role="menuitem"`), opened via the platform Popover API and top-layer-promoted onto `document.body` rather than nested in the toolbar's own DOM. The count badge is `role="status"` so a row-count change (filter/search narrowing the set) announces without a separate live region. Escape closes the active popover and returns focus to its trigger button (`#onDocKey` / `#closePopover`): a keyboard map beyond the pressable/focusable trait defaults.

### Behavioral spec

No built-in loading, error, or empty state: table-toolbar-ui only renders controls (title, count badge, search, filter/sort/columns popovers, page-size select); it has no data of its own to be empty or fail to fetch, and defers all state feedback to the target table-ui it drives via `toolbar-*` CustomEvents (search, filter-set, filter-clear, sort, columns-set, paginate). Only one popover panel is open at a time: opening a second kind closes the first (`#togglePopover`), and clicking outside or pressing Escape closes whichever is open.

## `<table-ui>`

**Composes:** `<check-ui>`, `<icon-ui>`, `<progress-ui>`, `<pagination-ui>`, `<skeleton-ui>`, `<badge-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

| Role | Fit | When | Requires |
|---|---|---|---|
| `collection-view` | primary | dataShape: collection; cardinality: many; intent: browse,inspect,compare | (none) |

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `card-ui` | For a static, non-interactive key/value or summary display with no rows-of-records shape, card-ui is a lighter fit: table-ui's ARIA grid semantics and keyboard nav are overhead a non-tabular surface shouldn't carry. |

### Screen-reader spec

The host renders `role="grid"` semantics (CSS grid + ARIA grid roles, not a native `<table>`), so a screen reader announces row/column position via ARIA rather than table markup: column headers must carry accurate `sortable`/`aria-sort` state for a sort-enabled column, which the component manages automatically as `columns[].sortable` toggles sort direction. [loading] sets `aria-busy="true"` on the host and swaps body rows for skeleton-ui ghost rows while keeping the header/column structure intact, so assistive tech reading the grid mid-fetch encounters a consistent (if content-empty) grid rather than a structural gap; data updates are deferred until [loading] returns to false, so no announcement race between "still loading" and new row content can occur. Keyboard grid navigation (arrow keys move the active cell, matching the ARIA grid pattern) is the primary navigation path for a non-pointer user: Tab enters/exits the grid as a single stop, not once per cell. Row selection (via the composed check-ui checkboxes) announces via each checkbox's own native checked state; there is no separate live-region announcement of "N rows selected" today: a consumer needing that summary must add its own live region bound to the table's selection events.

### Behavioral spec

Three distinct empty/loading states, not one: [loading]=true renders N ghost skeleton rows (count = min([paginate], 8) when [paginate] is set, else 5: a table with paginate="20" still renders only 8 ghost rows, not 20) with the header/columns preserved: this is "fetching," not "confirmed empty." Once loading clears, an empty `.data` array renders a `[data-empty]` overlay (icon + message) positioned after the body, replacing real rows entirely: this is "confirmed empty," distinct from mid-fetch. There is no separate built-in error state: a fetch failure is a caller-composed pattern (set `.data = []` and render an error message via the same `[data-empty]` slot's content, or swap in a dedicated error banner above the table): table-ui itself has no `error` prop or state. With [range-total] set (server-confirmed row count, ADR-0082), an explicit `range-total="0"` is the authoritative "confirmed empty" signal distinct from "no total reported yet," which matters for not flashing the empty overlay during an in-flight request that simply hasn't reported a count. A malformed `data="[...]"` JSON attribute is swallowed silently (caught, left empty) and falls through to the same empty-state rendering as a genuinely empty dataset: there is no distinct "parse error" surface.

## `<tabs-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** `<tab-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `nav-ui` | For navigating away to a different page, route, or anchor, nav-ui is the right fit: tabs-ui switches views within the same logical page with no route or URL change. |
| `segmented-ui` | For a form-control segmented selector that returns a value rather than switching visible content, segmented-ui is the right fit. |

### Screen-reader spec

Host carries `role="tablist"`. `aria-selected` and a roving `tabindex` (`0` for the active tab, `-1` for the rest) live on the SYNTHESIZED `[slot="tab-button"]` strip button `tabs.class.js` renders per `<tab-ui>` (ADR-0056, #1363 B7): never on `<tab-ui>` itself, which contributes none of the tablist's own ARIA state. `tab.js` sets `role="tabpanel"` on every `<tab-ui>` and mirrors `[disabled]` to `aria-disabled`; `[text]` mirrors to `aria-label` when set: that half is the tabpanel's own ARIA state, distinct from the strip button's.

### Behavioral spec

`tab-ui[selected]:empty { display: none; }` (tabs.css, gh#3017) collapses a selected panel with no content of its own: the general fix for a padded empty box wherever tabs-ui hosts panels outside itself (e.g. page-ui's [band] with-tabs shape, whose real panels live in the page body as [data-tab-panel] siblings, never inside <tab-ui>). Known tradeoff (gh#3058): a panel populated ASYNCHRONOUSLY after selection (a fetch, a lazy-mounted view) is briefly empty too, and this same rule hides it identically to the "never has content" case: there is no loading-state distinction. Once real content lands and the panel stops matching `:empty`, `[selected]` alone re-applies and it becomes visible again with no further action needed, but a consumer whose async content can take a visible moment to arrive should render its OWN loading affordance inside the panel (a skeleton-ui, a spinner-ui) rather than leaving it briefly and silently blank.

## `<tag-ui>`

**Composes:** `<button-ui>`, `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `badge-ui` | For a READ-ONLY status flag (Beta/New/Deprecated, a count), badge-ui is the right fit: it has no remove event and carries the [status] shorthand tag-ui lacks. |

### Screen-reader spec

The host stamps `role="status"` and `tabindex="0"` on connect (tag.class.js `connected()`), and derives its accessible name from [text] via `syncAutoAriaLabel`: a consumer-set aria-label is never clobbered. Disabled sets `aria-disabled="true"` and `tabindex="-1"`, removing it from tab order entirely rather than leaving a focusable- but-inert stop. The dismiss button is a stamped `<button-ui aria-label= "Remove">`, itself focusable, so a removable tag is two tab stops (the chip, then Remove): beyond Enter/Space activating the dismiss button (the pressable trait default), the chip itself has no additional keyboard map.

### Behavioral spec

No loading, error, or empty state: tag-ui is a stateless label with exactly two rendered modes, idle and disabled. Removing a tag is destructive and immediate: clicking or Enter/Space-activating the dismiss button fires `remove` (detail: {text, value}) AND calls `this.remove()` unconditionally in the same handler (tag.class.js `#onClick`/`#onKeydown`): the `remove` CustomEvent is not `cancelable`, so a listener cannot block the removal; there is no confirm step or undo built in, only a `remove`-event hook a consumer can use to react after the fact (e.g. re-inserting a replacement tag).

## `<tags-input-ui>`

**Composes:** `<tag-ui>`, `<icon-ui>`, `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `select-ui` | For CLOSED option sets (pick N from a fixed list), select-ui with [multiple] is the right fit: it gates the value against a declared `options[]` rather than accepting any typed string. |

### Screen-reader spec

The host's role switches per render: `role="combobox"` with `aria-haspopup="listbox"` and `aria-expanded` mirroring [suggesting] while `.suggestions` is non-empty, else plain `role="group"` (both branches in `render()`): a consumer never sees combobox semantics unless suggestions are actually wired. An `aria-label` is set from [placeholder] (falling back to "Tags") only when the author hasn't already supplied one, matching the value-tracking pattern other form primitives use to never clobber a consumer's own naming. The inline editable surface itself is `role="searchbox"` with `aria-autocomplete="list"` and `aria-controls` pointing at the suggestions listbox id: `#moveActiveSuggestion()` maintains `aria-activedescendant` on that surface as Arrow Up/Down move through `role="option"` rows, clearing it on close (`#closeSuggestions()`) or whenever the rendered suggestion list changes. Each committed token renders as a `<tag-ui role="listitem">` inside a `[role="list"]` chip-list container, so AT users get a list-item count independent of the visually-adjacent inline input.

### Behavioral spec

Enter commits the typed buffer as a chip (or the highlighted suggestion if one is active); Backspace from an EMPTY inline input removes the last chip (`#handleKeydown`): Backspace with any typed text present is a plain text edit, never a chip removal, so there's no accidental data loss mid-type. Escape closes an open suggestion popover without clearing typed text. A [delimiter] character typed inline (`#handleInput`) commits everything before it and keeps the remainder as the new buffer; the sentinel `delimiter="enter"` disables that path entirely. Paste splits on [pasteSplit] into multiple tokens (`#handlePaste`): a single fragment inserts as plain text rather than auto-committing, so the user can keep typing; a rejected trailing fragment (e.g. hit the [max] cap) is left in the buffer instead of silently dropped. Validation runs three built-in gates in order (min/max length, [unique] dedup, [max] count) before an optional consumer `validateFn`: sync rejection is immediate, async rejection flips the host into a [validating] state (a `circle-notch` spinner shows) until the promise settles; a rejected candidate fires `invalid` with a `reason`, an accepted one fires `commit` then `change` (`change` carries a `source` naming keyboard/paste/delimiter/suggestion/ backspace/chip-click/programmatic/clear). [readonly] renders chips without their remove affordance; [disabled] blocks all interaction including [clearable]'s clear button. There is no built-in loading/empty-message state beyond [validating]: the host shows no chips when [value] is empty, with no distinct "still loading" visual.

## `<text-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `mark-ui` | For a highlight rail, mark-ui is the right fit. Its own record draws the line: reach for text-ui[strong] when the goal is semantic emphasis, a weight change, where mark-ui paints a background behind the span without changing its weight. |

### Screen-reader spec

text-ui sets no role, aria attribute, or tabindex of its own (text.class.js has no connected()/aria wiring at all): it renders as plain inline/block text and is read exactly as its text content would be with no wrapper present.

### Behavioral spec

Stateless: text-ui has no loading, error, or empty state; [truncate] and [lines] only change CSS clamp behavior (`--_text-lines` custom property), never the underlying text content or DOM structure.

## `<textarea-ui>`

**Composes:** `<button-ui>`, `<icon-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chat-input-ui` | For a chat composer that wants Enter-to-send plus attachments and a model picker, chat-input-ui (inside chat-composer) is the right fit: textarea-ui's Enter-submits/Shift+Enter-newlines model is unconditional, with no opt-in/opt-out attribute to bolt that behavior onto this primitive instead. |
| `field-ui` | Wrap textarea-ui in field-ui for the canonical labeled stack, or use the inline [label]/[hint]/[error] props for compact use. |

### Screen-reader spec

`connected()` stamps `role="textbox"` and `aria-multiline="true"` on the host: the host IS the contenteditable surface, no wrapped native `<textarea>`. `render()` derives an auto `aria-label` from [label] (falling back to [placeholder]) via `syncAutoAriaLabel`'s value-tracking guard, so an author-set `aria-label` is never clobbered once one is present. [label] renders as a template-owned `<label slot="label">` above the field rather than the deprecated `UIFormElement.label` rendering (`static labelDeprecated = false`). There is no additional live-region announcement beyond the standard `change`/`input` events: screen readers pick up value changes through the textbox role's normal editing feedback, not an `aria-live` region.

### Behavioral spec

Enter (without Shift) is unconditional: it always dispatches a bubbling `submit` Event and prevents the newline; Shift+Enter always inserts a newline instead. This has no opt-in/opt-out attribute: a chat-style Enter-to-send composer that also wants free-form multiline entry belongs on `<chat-input-ui>` instead, not this primitive with a workaround. `input` fires per keystroke and can be trailing-debounced via [throttle] (`scheduleThrottledInput()`) for expensive input-driven work; `change` always fires unthrottled on blur, and any pending throttled `input` flushes immediately before it so consumers see a clean input→…→input→change ordering even under throttling. Paste is intercepted (`#onPaste`) and forced through `execCommand('insertText', …)` as plain text, stripping any pasted rich formatting. [clearable] surfaces an `x` affordance only while the value is non-empty and the field is neither [disabled] nor [readonly]; activating it empties the value and fires `input` then `change`. There is no built-in loading or empty-vs-error distinction beyond the plain [error]/[hint] props (hint hides once error is set): a consumer layering async validation supplies its own loading indicator.

## `<theme-provider>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `frame-ui` | For a layout skeleton that owns no CSS delivery of its own, frame-ui is the right fit: theme-provider is purely a foundation-loading mechanism, not a layout primitive. |

### Screen-reader spec

Invisible to the a11y tree by design: `connected()` sets `role="presentation"` (unless a consumer already set a different `[role]`) alongside imperative `style.display = 'contents'`, matching the template-engine/traits-host convention that a pure layout-transport wrapper contributes no semantics of its own. Its children's own roles are entirely unaffected; this element is a load/adopt mechanism, never a landmark.

### Behavioral spec

On connect: adopts the slim foundation stylesheet (or, with `[eager]`, the FULL foundation: every primitive's CSS, matching pre-gh#2791 behavior) into `document.adoptedStyleSheets`, deduped by sheet identity so multiple provider instances never double-adopt. Default ("sloppy") mode also installs a subtree `MutationObserver` that watches for not-yet-`customElements.get()`-registered tags, batches newly-seen ones via one `queueMicrotask`-coalesced flush per checkpoint (not one load per mutation), and dynamic-imports + registers each via `core/asset-loader.js`'s `ensure()`: a `:not(:defined) { visibility: hidden }` scoped rule prevents an unstyled flash while a tag is still loading, cleared automatically once the browser's native custom-element-upgrade reaction fires. `[eager]` skips the observer and the FOUC guard entirely: the consumer is responsible for registering component JS themselves, same as before gh#2791. `disconnected()` symmetrically tears down the observer and discards any still-pending tag batch, so an unmounted provider (SPA route change, etc.) never keeps loading into a subtree it no longer owns. Opt-in infra: NOT in the all-in-one @adia-ai/web-components barrel (it still carries the on-demand-loading machinery); import @adia-ai/web-components/components/theme-provider explicitly.

## `<time-picker-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `calendar-picker-ui` | For combined date+time selection, compose calendar-picker-ui with [precision="minute"] instead: it uses this primitive as its time pane rather than duplicating the segment model. |

### Screen-reader spec

The host itself is `role="group"` with a default `aria-label="Time"` set only when the author hasn't supplied `aria-label`/`aria-labelledby` (`connected()`): the field-ui wrap is the canonical way to override it. Each segment (`#stampSegments()`) is its own `role="spinbutton"` span carrying `aria-label` ("Hours"/"Minutes"/"Seconds"/"AM/PM"), `aria-valuemin`/`aria-valuemax`, and `aria-valuenow`/`aria-valuetext` kept in sync by `#syncSegmentsFromValue()` on every render: a segment the user is actively typing into (`document.activeElement === seg`) is skipped so screen readers don't announce over an in-progress edit. An empty value reports an empty `aria-valuenow` and `aria-valuetext` set to [placeholder] ("--" by default) per segment, distinguishing "unset" from "midnight"/"zero" for AT users. Focus moves between segments (`#moveFocus`) with ArrowLeft/ArrowRight; the host toggles an [editing] + [segment="…"] attribute pair on focusin/focusout (`#onFocusIn`/`#onFocusOut`) reflecting which segment currently owns focus, deferred one microtask on focusout so moving between two segments of the SAME picker doesn't flicker the editing state off.

### Behavioral spec

Beyond the click/focus default, each segment adds a WAI-ARIA Spinbutton keyboard model (`#onKeydown`): ArrowUp/ArrowDown step by [step] seconds (minute/second segments) or by 1 (hour, wrapping 0-23) or by 12 (meridiem toggle AM/PM); PageUp/PageDown step by 10x; Home/End jump the focused segment to its min/max; Enter commits whatever's been typed into all segments via `#commitFromSegments()`. Direct digit entry is gated in `#onSegBeforeInput`: only digits (or A/P/M on the meridiem segment) are accepted, each segment capped at 2 characters, and auto-advances focus to the next segment either when a second digit is typed or when the first digit alone can't possibly admit a second (e.g. typing "3" on an h23 hour segment, whose max is 23, immediately commits and advances since no second digit fits). Committing checks against [min]/[max] (parsed as ISO time strings) and fires `invalid` with `reason: 'min'|'max'|'parse'` on violation rather than silently clamping the value: [value] is left unchanged, but `#commitFromSegments()` returns immediately on that branch without calling `#syncSegmentsFromValue()`, so the segment DOM keeps showing whatever was typed rather than resyncing to the last valid value; a consumer relying on the visible segments matching [value] after a rejected commit needs to force a resync itself (e.g. re-set [value]). Blur on any segment (`#onSegBlur`) always commits, so a manually-typed value lands in [value] even without pressing Enter. There is no built-in loading state; [disabled] and [readonly] both flip every segment's `contentEditable` to `false` (`#applyEditableState`), but [readonly] segments stay focusable for inspection while [disabled] additionally blocks the Arrow/Page/Home/End step handlers outright.

## `<timeline-ui>`

**Composes:** `<icon-ui>`, `<button-ui>`
**Allowed children (a2ui):** `<timeline-item-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `stepper-ui` | For a multi-step process with current/upcoming/complete progress state (a wizard / numbered-circle step pattern), stepper-ui is the right fit: timeline-ui is read-only event-time history. |

### Screen-reader spec

Neither timeline-ui nor timeline-item-ui sets a `role`, `aria-live`, or any status-announcing ARIA of its own (timeline.class.js has no connected() aria wiring): item status/duration/outcomes changes are silent to assistive tech beyond whatever the plain text content of the label/description/time slots conveys. The one interactive element is the outcomes toggle, a stamped `<button-ui aria-label="Toggle details">` (no `aria-expanded` set) that dispatches a `timeline-toggle` event (detail: {expanded}) on click: Enter/Space activation is the pressable-trait default, no additional keyboard map beyond it.

### Behavioral spec

No built-in loading or error state at the container level: status is entirely per-item via [status] (idle/active/completed/error) and [spinner], author- or data-driven, never inferred by timeline-ui itself. The outcomes sub-list only renders when `.outcomes` (a JS-set array property, not an attribute) is non-empty; setting it back to an empty array removes both the list and the toggle button entirely rather than leaving an empty expanded panel. Expansion state (#expanded) is per-item, private, and does NOT reset on a fresh `.outcomes` assignment (timeline.class.js's setter never touches it): an already-expanded item stays expanded across a reassignment; only the toggle button itself (Enter/Space/click) flips it. There is no persisted/controlled- expanded prop.

## `<toast-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `alert-ui` | For a message that stays until dismissed, alert-ui is the right fit: it is an inline persistent banner, where toast-ui auto-dismisses and is meant for short-lived feedback after an action. |
| `empty-state-ui` | For a zero-data placeholder, empty-state-ui is the right fit; toast-ui is a transient notification and never fills a region that has no content. |

### Screen-reader spec

`<toast-ui>` itself carries no ARIA: it never stays in the DOM long enough to matter (removed via `queueMicrotask` right after posting). The announcement is entirely the spawned `<feed-ui>`/`<feed-item-ui>` pair's responsibility (see feed.yaml's own screenReader field): the container gets `role="region"`, and the item's role/aria-live are inferred from `duration`/`variant`/`action`: a plain auto-fade toast (the common case) gets `role="status"` + `aria-live="polite"`, so it announces passively without interrupting; `[duration=0]` (sticky) combined with a `danger`/`warning` `[variant]` upgrades to `role="alert"` + `aria-live="assertive"`. The declarative `<toast-ui>` markup form forwards no `action`, so it can never reach feed-item's `role="alertdialog"` tier, but the imperative `UIToast.show({ duration: 0, action: '…' })` DOES forward `action`, and IS action-required (`isSticky && !!this.action` in feed.class.js), so `alertdialog` + `aria-modal="false"` + a focus trap ARE reachable through that path. `UIFeed.post()` called directly reaches the identical tier the same way: `UIToast.show()` is not a lesser path for this specific case.

### Behavioral spec

Toast has no persistent state of its own: `connected()` posts once (a re-entrant `__routedToFeed` guard makes this safe under HMR re-attachment) and the element dissolves on the next microtask; every subsequent lifecycle (visible, queued, dismissed, exit transition) belongs to the spawned `feed-item-ui`, not `toast-ui` (see feed.yaml's own behavioral field for the queue/max/dismiss mechanics). `[duration]` maps directly to the item's auto-dismiss timer: `0` disables it, producing a sticky item the consumer (or the user, via its close button) must dismiss explicitly. There is no error or loading state: toast is fire-and-forget, with no acknowledgement path back to the caller beyond the `FeedHandle` `UIToast.show()`/`UIFeed.post()` return (`dismiss()`/`update()`): the declarative `<toast-ui>` markup form discards that handle entirely, since the element removes itself before any caller code could read it back off the DOM.

## `<toc-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `nav-ui` | For navigation you author yourself, nav-ui is the right fit: it takes nav-item children you write. toc-ui writes its own, scanning a target container for headings, slugifying an id onto any that lack one, and stamping a <nav> from what it finds. |

### Screen-reader spec

Host carries `role="navigation"` plus `aria-label` (from `[label]`, defaulting to "Table of contents"): set on `connected()` and again on the stamped `<nav>` itself, so both the host and its stamped landmark announce the same name. Each target heading gets an auto-slugified `id` (from `textContent`, de-duplicated against existing document ids) if it lacks one, since the stamped `<a href="#id">` links depend on it: toc-ui never overwrites an existing heading id. The active link is marked with `[data-active]` (a presentational attribute, not `aria-current`) as the `IntersectionObserver` updates which section is in view; there is no live-region announcement of that change, only the `section-change` event for a consumer to act on.

### Behavioral spec

On connect, scan/stamp is deferred one microtask (so sibling content from the same render pass has time to mount before headings are read), then `#scanHeadings()` reads `[headings]` (default `h2,h3`) from the resolved `[target]` root (or the parent element if unset), excluding any headings that are descendants of toc-ui itself. Depth is RELATIVE: the shallowest heading found becomes depth 0, not literal h-level, so `[data-depth]` styling stays consistent regardless of whether a page starts at h2 or h3. Re-render (on a `[target]`/`[headings]`/`[offset]`/ `[label]` prop change) is short-circuited via a cheap id+depth key comparison: an unchanged heading structure skips re-stamping the `<nav>` entirely and only re-attaches the `IntersectionObserver`. Zero headings found removes any stale `aria-label` and stamps nothing: this is toc-ui's only empty state, and it has no distinct loading state (heading discovery is synchronous DOM query, never async).

## `<toggle-scheme-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `switch-ui` | For a settings page offering explicit Light/Dark/Auto as three visible, equally-weighted choices bound to application state, a plain switch-ui group or radio set is the better fit: toggle-scheme-ui's icon-swap-on-click affordance models exactly two visible states (light/dark), with "auto" only ever a resolution mode behind the scenes, never a third clickable option. |

### Screen-reader spec

The internal `<button-ui>` gets its `aria-label`/`title` from [label-light]/[label-dark] (defaults "Switch to dark mode"/"Switch to light mode"), re-synced on every scheme change by `#syncButton()`, so AT announces the ACTION the next press performs, not the current state. A screen-reader user hears what pressing the button will do, mirroring the visible icon swap, rather than a state pair like `aria-pressed` would announce. There is no `aria-pressed` or `role="switch"`: the component is modeled as an icon action button whose accessible name itself changes, not a binary switch control. `scheme-change` fires with `source: "press"|"media"|"programmatic"` on every transition, including autonomous ones: an OS-level `prefers-color-scheme` flip while [scheme="auto"] fires `source: "media"` with no user action at all, so a consumer wiring a live region off this event will announce OS-driven changes the user never initiated, not only their own presses.

### Behavioral spec

No loading or error state: `#initState()` resolves synchronously on connect (persisted localStorage value first when [persist], then the [scheme] attribute, then `matchMedia('(prefers-color-scheme: dark)')` as the final fallback), so `[active-scheme]` is always populated before the button first paints. The load-bearing distinction the yaml alone doesn't show is `#userTouched`: before any user-driven mutation (a press, or an explicit `.setScheme()`/`.toggle()` call), the component keeps re-running `#initState()` on every post-connect `[scheme]` attribute write, letting a reactive consumer's attribute binding win a race against the template engine's strip-then-restamp cycle; after the first user-driven change, that auto-reinit permanently stops, so the user's explicit choice can't be silently overwritten by a later, unrelated attribute re-application. `.setScheme("auto")` is the only path that clears a persisted override and re-attaches the prefers-color-scheme listener: pressing the button, or calling `.toggle()`, always lands on an explicit light or dark and detaches that listener, matching the documented "defeats auto" contract.

## `<toolbar-ui>`

**Composes:** `<button-ui>`
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `table-toolbar-ui` | For the bar above a table, table-toolbar-ui is the right fit: it is bound to a sibling table-ui and owns filter, sort and column popovers, a search input, a range summary and a page-size select. toolbar-ui knows nothing about a table and only overflows whatever actions it is given. |

### Screen-reader spec

Host carries `role="toolbar"` (`connected()`); `<toolbar-group-ui>` children carry `role="group"`. The auto-created spillover trigger button (only rendered once an item actually overflows) gets `aria-label="More actions"` + `aria-haspopup="menu"`; its popover menu carries `role="menu"`. `applySpilloverLabels()` also RESTORES each spilled icon-only button's visible `[text]` (derived from its `aria-label` or a humanized icon name, e.g. `text-bold` -> "Text Bold") the moment it moves into the menu: an icon-only button makes sense in a dense visible bar but not as an unlabeled menu row, and `stripSpilloverLabels()` removes that synthesized text again if the item returns to the visible bar on a resize.

### Behavioral spec

A `ResizeObserver` on the host queues a reflow (`#queueReflow`, RAF-batched) whenever available width changes; the reflow walks items from the END and moves whichever don't fit (with `OVERFLOW_BUFFER`, 8px, of headroom) into the spillover popover, keeping `<toolbar-group-ui>` children together as an atomic unit: no group is ever split between the visible bar and the menu. Trailing `<divider-ui>` separators are trimmed rather than spilled; dividers never move to the menu at all. A `MutationObserver` also watches for authored DOM changes (new/removed items) and re-triggers the same reflow, guarded against re-entrancy while a reflow-driven mutation is already in flight (`#measuring`). There is no error or loading state: every state transition here is purely layout-driven (fits / spills), not data- or network-driven.

## `<tooltip-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `popover-ui` | For content opened by an explicit click on a focusable trigger, popover-ui is the right fit: it requires a [slot="trigger"] that is itself focusable and adds no tabindex of its own. tooltip-ui shows on hover or focus of the children it wraps, so it needs no separate trigger element. |

### Screen-reader spec

Trigger mode: on show, `#show()` stamps `aria-describedby` on the HOST (the wrapped-children element) pointing at the popover's generated `id`, and removed again on hide: this is the only ARIA wiring the component performs; the popover `div` itself carries `role="tooltip"` but no `aria-live` (trigger-mode tooltips are read via the `aria-describedby` relationship on focus, not announced as a live region). Pointer mode's popover instead carries `aria-live="polite"` (`#paintPointerContent`) since it's a `[popover="manual"]` element with no accessible-name relationship to any host: the chart it tracks isn't itself focused, so a live-region announcement is the only way its content reaches assistive tech at all, unlike trigger mode's describedby link.

### Behavioral spec

Trigger mode: `mouseenter`/`focusin` start a `[delay]`-ms timer before `#show()` creates the popover (a fresh `<div popover="manual">` appended to `document.body` and anchored via `anchorPopover()`); `mouseleave`/`focusout` cancel a pending timer or `#hide()` an already- shown one immediately: no fade delay on exit, only on entry. Re-hover before the delay elapses restarts the same timer rather than stacking. Pointer mode has no delay at all: `chart-hover` shows/repositions immediately (`#createPopover()` lazily on first hover, reused across the whole hover session) and `chart-leave` hides. Neither mode has a built-in error or loading state: tooltip content is always the caller-supplied `[text]` (trigger) or the `chart-hover` event detail (pointer), rendered synchronously with no async gap to represent.

## `<tour-ui>`

**Composes:** `<button-ui>`, `<tour-step-ui>`
**Allowed children (a2ui):** `<tour-step-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `onboarding-checklist-ui` | For a persistent, self-paced todo-list of setup steps with no page dimming or spotlighting, onboarding-checklist-ui is the better fit. |
| `tooltip-ui` | For a single hover hint on one element with no multi-step orchestration or navigation, tooltip-ui is lighter and doesn't need a target sequence at all. |

### Screen-reader spec

Each step's popover carries `role="dialog"` and `aria-modal="false"` with `tabIndex=-1`, and focus is moved into it via a queued microtask on every `#renderStep()` call, so for a screen-reader user, advancing a step means focus itself relocating to the new popover content rather than a background announcement. The popover has no `aria-labelledby` pointing at its own `<h3 data-tour-title>`, so its accessible name comes from reading the popover's full text content in DOM order, not a distinct dialog-title-then-body announcement. The spotlight overlay is `aria-hidden="true"` and never enters the accessibility tree: it is purely a visual scrim. Escape (skip), ArrowRight (next), and ArrowLeft (previous) are handled by a single `document`-level keydown listener while [active], additive to the internal Skip/Back/Next `<button-ui>` elements' own default Enter/Space activation, not a replacement for it.

### Behavioral spec

Three states, not two: `idle` (dormant, nothing mounted), `active` (scrim + popover live, Escape/arrow-key navigation wired), and `complete` (last step reached). `complete` is presentation-only: `#renderStep()` swaps the Next button's label to "Done", but there is no separate teardown path for it; pressing that Done button routes through the same `finish()` as any other step's Next press. Teardown (`#teardown()`) runs from exactly three call sites: `disconnected()`, `skip()`, and `finish()`, and NOT from an `active` watcher: setting `.active = false` directly (bypassing `skip()`/`finish()`) leaves the spotlight and popover mounted in `document.body` even though the reflected attribute now reads false; `.skip()`/`.finish()` (or removing the element) are the only ways to actually tear down. [storage-key] gates auto-start on subsequent loads by checking the stored value is the exact string `"done"`, not a generic truthy check, so a stale or unrelated value under that key can't accidentally suppress the tour. There is no loading or error state to model: every step's content already exists in the light DOM as `<tour-step>` children before the tour starts, and a step whose `target` selector resolves to nothing degrades silently to a viewport-centered popover with no spotlight, never an error state.

## `<tree-ui>`

**Composes:** `<icon-ui>`
**Allowed children (a2ui):** `<tree-item-ui>`

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `action-list-ui` | For a flat list with no nesting, action-list-ui is a lighter fit, since tree-ui's WAI-ARIA tree/group/treeitem role wiring is overhead a non-hierarchical list shouldn't carry. |
| `nav-group-ui` | For a flat list with no nesting, nav-group-ui is a lighter fit, since tree-ui's WAI-ARIA tree/group/treeitem role wiring is overhead a non-hierarchical list shouldn't carry. |

### Screen-reader spec

tree-ui stamps `role="tree"` on itself; each tree-item-ui host is `role="group"` (the WAI-ARIA structural container for its own nested items), while the item's focusable `[slot="row"]`, not the host, is `role="treeitem"` with `tabindex="0"` (tree.class.js's connected(), FEEDBACK-91: expanded/selected belong on the row, never the group). A row with children reflects `aria-expanded` (true/false, only when `hasChildren`); `aria-selected="true"` is set only on the currently selected row, cleared from the previous one on every `select()` call. Keyboard map beyond the pressable/focusable trait defaults: ArrowRight expands a closed item with children or moves focus into its first child; ArrowLeft collapses an open item or moves focus to its parent; ArrowDown/ArrowUp move focus to the next/previous VISIBLE row (`#nextVisible`/`#prevVisible`, skipping collapsed subtrees); Enter and Space both select the focused item (`#onKey`). There is no roving tabindex: every row keeps `tabindex="0"`, so Tab moves through every visible row individually rather than treating the tree as one stop.

### Behavioral spec

No built-in loading, error, or empty state: tree-ui renders whatever tree-item-ui children are present in the light DOM; an empty tree is simply a tree-ui with no children, not a distinct state. Selection is single-select and host-managed: `select()` clears `[selected]` off the previously selected item before setting it on the new one, and forwards originating Ctrl/Cmd/Shift modifier state in the `tree-select` event detail so a consumer can layer multi-select on top: tree-ui itself never multi-selects. A single click on a row selects it AND toggles open/closed when it has children (`#onClick`): the two are not independent gestures via pointer, only via keyboard (ArrowRight/Left vs. Enter/Space separate expand from select). `expandTo()` walks every ancestor open and scrolls the target row into view: the only built-in reveal-a-hidden-selection affordance; there is no auto-expand-on-select behavior without calling it explicitly.

## `<upload-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `chat-composer` | chat-composer's `attach` slot is documented as a Future affordance, not yet built. Today, an agent chat surface wanting file attachment composes upload-ui itself (e.g. in [compact] mode) into that slot; there is no separate built-in upload path to defer to yet. |

### Screen-reader spec

The internal `[data-dropzone]` (not the host) is the real focusable/name-bearing surface: `role="button"`, `tabindex="0"`, stamped once in `connected()`. `[compact]` mode gives the dropzone an `aria-label` derived from [label] (falling back to "Upload files"), but only when the host has no `aria-labelledby` of its own: a wrapping `<field-ui>` that stamps `aria-labelledby` on the HOST is forwarded onto `[data-dropzone]` by `#syncAriaLabelledby()` (gh#2606), since `aria-labelledby` always outranks `aria-label` in accessible-name computation and the field-ui wrap's name must reach the actual focusable, not just sit unused on the host. This forwarding re-runs on every `aria-labelledby` attribute change via `attributeChangedCallback` (declared in `observedAttributes` specifically because it isn't a reactive property otherwise). There is no live-region announcement of file selection: the rendered `[data-filelist]` file names are plain text, not an `aria-live` region.

### Behavioral spec

Beyond click, `[data-dropzone]`'s keyboard model adds Enter and Space (`#onKeyZone`) to open the file picker: both call `.open()`, the same public trigger the [compact] icon-only mode exposes for programmatic/toolbar use. `.open()` prefers `window.showOpenFilePicker` when available (parsing [accept] into its `types` array via `#parseAcceptTypes`), falling back to a hidden native `<input type="file">` click (`#pickFallback`) otherwise; a user-cancelled picker (`AbortError`) is swallowed silently, not surfaced as an error. Drag-and-drop (`dragover`/`dragleave`/`drop`) toggles a `[data-dragover]` attribute on the dropzone for the drag-active visual and no-ops entirely when [disabled]. Selecting files (click or drop) always replaces the whole selection (`#setFiles`), never appends: there is no incremental add. Selected files are packed into a `FormData` under [name] (or `'file'` if unset) and handed to `internals.setFormValue()` for real form participation, then a bubbling `change` CustomEvent fires with `detail: { value, files }`. `.clear()` (public) empties the selection and also runs automatically on native form reset (`onFormReset`): a consumer's own Clear/Reset action should call it rather than reaching into `[data-dropzone]`'s internal anatomy, which is not a contract surface. There is no built-in loading/progress state for the selection itself: a consumer wiring an upload progress bar (see the `Progress` example) manages that state externally.

## `<visually-hidden-ui>`

**Composes:** _(none)_
**Allowed children (a2ui):** _(unconstrained)_

**Fulfills:**

_None._

**Disambiguation:**

| Neighbor | Cue |
|---|---|
| `skip-nav-ui` | For a bypass link that must APPEAR on keyboard focus, skip-nav-ui is the right fit: visually-hidden-ui stays hidden at all times, so a link inside it can be focused without ever becoming visible. |

### Screen-reader spec

The host applies the canonical sr-only CSS pattern (`position:absolute`, a 1px box, `clip-path:inset(100%)`, `overflow:hidden`) rather than `display:none` or `visibility:hidden` specifically because both of those remove an element from the accessibility tree - this component's entire contract depends on staying IN the tree while being visually unpainted, so a screen reader reads its slotted content exactly as if it were regular visible text, in normal DOM order relative to its siblings. It carries no ARIA role or live-region behavior of its own - announcing a dynamic update needs a separate `aria-live` region composed alongside it; this element only handles the visual-hiding half of that pattern, not the announcement-timing half.

### Behavioral spec

Stateless - `idle` is the only state, since this is a pure CSS atom with `static template = () => null` and an empty `static properties` object: no connect/disconnect side effect, no attribute reactivity, nothing to load or error. The one behavioral risk worth naming explicitly: the stylesheet's `white-space:nowrap` is part of what makes the clip technique hold, so a consumer nesting a further wrapping element inside the default slot (rather than passing plain text or inline content) can defeat the clip on some user agents and make the content visible again - the component's own scoped CSS handles the common plain-text case, but does not protect against that nesting.
