# YAML component contract, `<name>.yaml` schema

Authoritative source-of-truth fields for `packages/web-components/components/<name>/<name>.yaml` and `packages/web-modules/<cluster>/<name>/<name>.yaml`. The build pipeline (`scripts/build/components.mjs`) reads these yamls + emits sidecar JSON (`<name>.a2ui.json`) that feeds the docs site, the A2UI runtime registries, and consumer harnesses.

This is the authoritative schema reference for the authoring lane. The JSON Schema lives at `scripts/schemas/component.yaml.schema.json` (referenced by every yaml's `$schema:` key). Amended 2026-08-16 per ADR-0057: that schema is the documented contract + IDE aid, not a run validator, no build step evaluates it against the yamls. The build-time checks that DO exist are hand-written throws in `compileComponent()` (`scripts/build/components.mjs`): a missing `component:` field, a `component: Surface` (reserved, the A2UI v1.0 implicit root container, SPEC REQ-011/gh#1353), a `status:` value outside the five-value enum (see §`status:` below), a missing `category:` field OR a `category:` value outside the twelve-value enum (see §`category:` below, ADR-0065), and malformed `a2ui.allowedParents`/`a2ui.allowedChildren` composition constraints (see §composition constraints below, plus a full-build cross-reference check that every referenced name is a real `component:` in the catalog). Every other schema constraint (`required: [name, tag, component, description]`, `minLength`, …) is IDE-visible only. This file covers the human-facing contract: what each field means, when to use which value, and the canonical shape of a complete yaml.

---

## Canonical fields (top-level)

```yaml
# Edit this file; run `npm run build:components` to regenerate a2ui.json.
$schema: ../../../../scripts/schemas/component.yaml.schema.json
name: UIMyComponent          # Class name (PascalCase, UI-prefixed)
tag: my-component-ui         # Custom element tag (kebab-case, -ui-suffixed)
component: MyComponent       # Short component name (no UI- prefix)
category: form               # Category, see §category field below (ADR-0065, twelve-value enum)
version: 1                   # Schema version (always 1 for now)
status: stable               # Stability tier, see §status field below
description: >-
  Short one-paragraph description of what the component does and when
  to use it. Used by the docs site, sidecar, and a2ui registry. Be
  concrete about behavior + appearance, not generic ("a button").
props:
  …                          # Prop schemas, see §props field below
events:
  …                          # Event schemas, fired by the component
slots:
  …                          # Consumer-fillable light-DOM insertion points, see §slots vs parts below
parts:
  …                          # Template-owned anatomy, see §slots vs parts below
css-vars:
  …                          # CSS custom properties the component reads
```

---

## `slots:` vs `parts:`, consumer-fillable vs template-owned anatomy (ADR-0067)

**Decision rule**: does an author (a human, or an LLM generating an A2UI
document) ever place their OWN content at this named span? If yes, even
with a stamped fallback when nothing is supplied, it's `slots:`. If the
component's own `render()`/template ALWAYS stamps it itself, from a prop or
attribute, and no author-supplied content is ever accepted there, it's
`parts:`. **Check element source, never the description prose alone**
(AGENTS.md: source wins), a name that *sounds* internal
(`actions`, `text`, `leading`) can still be a real insertion point in a
given component; `table-toolbar.yaml`'s `actions` slot LOOKS stamped by
name but its `class.js` explicitly absorbs pre-existing `[slot="actions"]`
children (a real, author-fillable insertion point), the opposite of
`check.yaml`'s `box`, which `static template = () => html\`<span
slot="box"></span>\`` stamps unconditionally every render.

Both keys share the identical `Slot` schema shape (`description:` required,
`fallback:` optional): the only difference is which key an entry lives
under. `scripts/build/components.mjs` forwards both verbatim onto the
sidecar (`x-adiaui.slots` / `x-adiaui.parts`) with no other processing.

**Why the split matters, three real consumers read `slots:` and present
every entry as fillable, with no code-level filtering for anything under
`parts:`:**

- `packages/gen-ui/engine/retrieval/component-entry.js`'s
  `serializeReference()`, feeds the LLM-facing `reference`-detail catalog
  entry (MCP tools, `getComponentAPI()`).
- `packages/gen-ui/engine/compose/strategies/monolithic/_shared.js`'s
  `adaptV09Component()`, feeds the monolithic engine's prompt catalog.
- `scripts/docs/anatomy-sweep.mjs`'s `genSlots()`, renders the docs-site
  "slots" anatomy section.

A `parts:` entry never reaches any of the three above, moving template-owned
anatomy there is a structural fix, not a naming convention alone. An
existing entry under `slots:` that's actually template-owned (e.g. a
component predating this ADR) is a real bug: it advertises to an LLM that
filling it does something, when the component's own template replaces
whatever's there on the next render, the exact gh#284 destructive-replace
shape, applied to a *documented* slot instead of an undocumented one.

`scripts/dev/audit-slot-vocab-vs-css.mjs` (the yaml-vs-CSS `[slot="X"]`
cross-check) reads BOTH `slots:` and `parts:`, a `parts:` entry is still a
real `slot="X"` DOM attribute the component's own CSS may position, just
never author-fillable, so it stays in that audit's declared-vocabulary set.
`scripts/dev/audit-template-child-conflict.mjs` (the gh#284 container-shape
check) is unaffected either way, it only checks for a slot literally named
`default`.

---

## `a2ui.allowedParents:` / `a2ui.allowedChildren:`, composition constraints (SPEC REQ-011, gh#1353)

Optional keys inside the `a2ui:` block, alongside `rules:`. Each is a
non-empty list of catalog `component:` names (NOT tags) naming the parents
this component may sit under / the direct children it may contain. The
reserved name `Surface` (the A2UI v1.0 implicit root container) is legal
only in `allowedParents` and means "may sit at the surface root". **Omitted
means unconstrained**, never write an empty list (that would mean "allowed
nowhere"; the build refuses it).

`allowedParents` matches the nearest custom-element ancestor, not the
immediate DOM parent (gh#3310): the generated lint rule's
`compositionFindings()` walks up past native (non-hyphenated) wrapper
elements, `section`, `div`, `td`, `tr`, `tbody`, and any other plain HTML
tag, until it finds a real catalog component tag or reaches the surface
root. This mirrors Light DOM's own composition reality (AGENTS.md: CSS
positions by tag + ancestor + DOM order), a wrapper interposed for layout
or semantics (a `<section>` inside a card, a `<td>` in a table body) doesn't
change a component's logical host. `allowedChildren`, by contrast, still
matches DIRECT children only, a named-slot child that should be exempt
from the default-slot list is a separate, open gap (gh#3308), not addressed
by this semantics change.

```yaml
a2ui:
  allowedParents:
    - Accordion        # AccordionItem only makes sense inside an Accordion
  rules:
    - …
```

**Authoring rule, verify against element source, exactly like the
`slots:`/`parts:` decision above.** Declare a constraint only when the
component's own source enforces or assumes it (e.g. `stepper.class.js`
queries `stepper-item-ui`; `segmented.class.js` warns on non-`segment-ui`
children). A parent that adopts items through wrappers (menu.class.js's
deliberate descendant query) must NOT constrain, a declared constraint
stricter than the source is a defect, not documentation.

**`allowedChildren` is a default-slot-only check (gh#3308).** A direct
child carrying ANY `slot=` attribute is a sibling-level named slot, not a
default-slot member, the lint-side matcher (`compositionFindings`)
exempts it from `allowedChildren` entirely, regardless of the slot's name.
This is why menu-ui can declare `allowedChildren: [MenuItem, MenuDivider,
MenuLabel]` for its default slot while still accepting an arbitrary
focusable element on `slot="trigger"` ("typically button-ui, but any
focusable element works") without a false positive. When a named slot
*should* be constrained too (rare, most named slots exist precisely
because their content varies), add an `allowedChildrenBySlot:` map
alongside `allowedChildren:`, keyed by slot name, same catalog-name-list
shape:

```yaml
a2ui:
  allowedChildren:
    - MenuItem
    - MenuDivider
    - MenuLabel
  allowedChildrenBySlot:
    trigger:               # only if the trigger slot ITSELF needs constraining
      - ButtonUI
```

A slot with no entry in `allowedChildrenBySlot` (or the key omitted
entirely) stays unconstrained by design: this is the common case, and
matches "omitted means unconstrained" for `allowedChildren`/`allowedParents`
above.

Pipeline: `components.mjs` validates the shape per-yaml, cross-checks every
referenced name against the full catalog on a full build, and forwards the
lists onto `x-adiaui` → `catalog-a2ui_1_0.json`.
`scripts/build/derive-genui-catalog.mjs` then translates them into the
canonical v1.0 key space (yaml `Segmented` → catalog `Segmented`, canonical
per gh#2116) across the five opt-out-scoped catalogs (`adia.core.json`,
`adia.navigation.json`, `adia.data.json`, `adia.agent.json`,
`adia.shells.json`, gh#2211/ADR-0093), where the vendored `@genui/core`
validator enforces them (`UNALLOWED_PARENT`/`UNALLOWED_CHILD`). Module-tier
yamls (web-modules) now carry a v1.0 sidecar too and land in `adia.shells`.
`allowedChildrenBySlot` (gh#3308) is NOT part of this catalog/sidecar
pipeline, `components.mjs` never reads it, it never lands in
`x-adiaui`/`catalog-a2ui_1_0.json`, and the A2UI v1.0 protocol validator
never enforces it. It exists purely for the lint-side check below.

A non-empty `allowedParents`/`allowedChildren`/`allowedChildrenBySlot`
generates an enforced lint rule (LLD-0016 §C4, gh#2647):
`scripts/build/gen-composition-rules.mjs` emits `scripts/lint/
rules/generated/composition/<name>.mjs`, which flags a markup file where
the tag nests under (or contains) a non-declared tag, `allowedChildren`
checked against default-slot children only, `allowedChildrenBySlot`
against the matching named-slot children (see above). Advisory (`warn`)
until a corpus-wide rollout promotes it (`npm run build:composition-rules`
/ `check:composition-rules-fresh`).

### `a2ui.noCompositionConstraint:`: the audited "no contract needed" verdict (lld-0029 C2a, PR #3456)

The third disposition a yaml can carry. Where `allowedParents` /
`allowedChildren` declare a containment edge, `noCompositionConstraint`
records that the authoring-rule audit above was actually run (read
`<name>.class.js` for `querySelector` / `closest` / `this.children`
child-tag expectations, then test any parent/child hypothesis against real
markup in `apps/`, `site/`, `catalog/`, `packages/web-components/patterns/`)
and found no constraint worth declaring. Its whole purpose is to let a
reader, or a gate, tell "audited, categorically needs none" apart from
"never audited". Do not write it as a shortcut: if the audit turns up a
real constraint, the fix is a genuine `allowedParents` / `allowedChildren`
addition, not this marker.

Shape (`scripts/schemas/component.yaml.schema.json`, `a2ui.noCompositionConstraint`):
an object with two required keys and nothing else. `reason` is the verdict
quoted from the disposition table (schema `minLength: 8`); `ticket` is the
issue whose table recorded it (`^gh#[0-9]+$`). As with every other schema
constraint (ADR-0057), the shape is IDE-visible only: `compileComponent()`
never reads this key, so a malformed block does not stop the build.

Real example, `packages/web-components/components/badge/badge.yaml`:

```yaml
a2ui:
  noCompositionConstraint:
    reason: 'Referenced generically from a dozen+ unrelated components, not scoped to one parent; own rule says positioning, not ancestry.'
    ticket: 'gh#3270'
  rules:
    - 'Use for small status/count labels attached to another element (notification counts, status pills, version tags).'
```

Relationship to the two lists. A yaml carries either the marker or a
containment list, never both. This is now enforced (gh#3497), not just
convention: `component.yaml.schema.json` declares the two mutually
exclusive via the `a2ui` object's own `not` clause, and
`compileCompositionConstraints()` in `scripts/build/components.mjs` throws
at build time, naming the file and the offending field, when a yaml
declares both. `npm run check:components-valid` (part of `npm run
build:components`) is where that throw surfaces.

Pipeline: none. The marker is a yaml-only annotation with no sidecar,
`x-adiaui`, catalog, or generated-lint-rule forwarding (lld-0029 C2a, by
design), so a PR that only adds it changes no derived artifact and commits
no regen. The planned `check:composition-coverage` gate (lld-0029 C3,
`scripts/verify/check-composition-coverage.mjs`, not landed as of
2026-09-04) fails any yaml whose `a2ui` block has none of `allowedParents`,
`allowedChildren`, or `noCompositionConstraint`, so the marker counts as
coverage exactly as either list does.

---

## `examples:` field, a2ui example ids (semantic-id grammar, gh#2492)

Each `examples[].a2ui` block is a JSON array of component nodes (the same
`updateComponents.components[]` shape `.claude/docs/specs/a2ui-editor.md`
documents for the editor's live doc store). That spec's line "other
component ids are free-form (convention: `c-{n}` for generated ids)" governs
**editor-generated** ids only, ids the editor mints when a human drags a
component onto the canvas. It was never a license for **authored** ids
inside a component's own yaml examples, and treating it as one produced a
corpus-wide drift toward cryptic, positional ids (`q`, `k1`, `k1v`, `hdr`)
that don't describe what they are once an example has more than one or two
nodes, reported in gh#2492 against `blockquote.yaml`, `badge.yaml`,
`aside.yaml`, `alert.yaml`, and `anchor-bar.yaml` (the last four
corroborating it as a corpus-wide pattern, not a one-off).

**Grammar, authored `a2ui.examples[]` ids only:**

- **Kebab-case, role-descriptive.** The id names what the node IS or DOES in
  the example, not its position in the array. `quote-body`, not `q` or
  `node-2`.
- **Unique per example.** Scoped to one `examples[]` entry, not the whole
  yaml, reusing `header` across two examples in the same file is fine;
  reusing an id twice inside one example is not (the renderer's flat
  `children: string[]` lookup would collide).
- **Compound ids read parent-then-role** for a node that belongs to a named
  cluster: `kpi-revenue`, `kpi-revenue-value`, `kpi-revenue-label`, not
  `k1`, `k1v`, `k1l`. This is what lets a reader studying the copy-paste-able
  example understand the structure from the ids alone, without cross-
  referencing the tree.
- **Exception, the root/wrapper id may stay short when the example has
  exactly one top-level container and the short id is still a real word**,
  e.g. `card`, `row`, `panel`, as long as every id it contains follows the
  grammar. A single generic wrapper doesn't need `card-wrapper-root`; a
  wrapper's *children* still do.
- Ids are internal wiring keys (`children: string[]` references, rendered
  only as `data-a2ui-id`), never user-visible copy, but they double as the
  readable structure of the example a consumer studies, which is the whole
  reason this grammar exists.

**Before / after** (`blockquote.yaml`'s `default` example, gh#2492's
reported repro):

```jsonc
// before
[
  { "id": "q", "component": "Blockquote", "cite": "…", "children": ["body"] },
  { "id": "body", "component": "Text", "textContent": "Stay hungry. Stay foolish." }
]

// after
[
  { "id": "quote", "component": "Blockquote", "cite": "…", "children": ["quote-body"] },
  { "id": "quote-body", "component": "Text", "textContent": "Stay hungry. Stay foolish." }
]
```

```jsonc
// before (badge.yaml chart-dashboard, positional style mixed with semantic
// ids in the SAME example, k1/k1h/k1v alongside header-row/dash-title)
{ "id": "k1", "component": "Card", "children": ["k1h", "k1v"] }

// after
{ "id": "kpi-revenue", "component": "Card", "children": ["kpi-revenue-label", "kpi-revenue-value"] }
```

**Enforcement**: the `EXAMPLE-ID-GRAMMAR` rule
(`scripts/lint/rules/shared/example-id-grammar.mjs`, gh#2649, moved out of
the retired `scripts/verify/check-example-ids.mjs` into the shared lint
rule bank per LLD-0016 §C3) walks every yaml's `examples[].a2ui` nodes and
reports ids that fail the grammar (a bare 1-2 char id, a `^[a-z]\d+`
positional pattern like `k1`/`c2`, non-kebab-case, or a within-example
duplicate). gh#2492 Phase 2 swept the corpus-wide 526 violations across 48
files to zero; `npm run check:example-id-grammar` keeps that promotion
build-blocking in the `npm run check` chain (non-zero exit on any
violation) even though the rule's own bank-default severity is
`advisory`, a new example that violates the grammar now fails the build
immediately, not just on a future sweep. Known gap: the checker's
positional-pattern regex catches `letter+digits` (`k1`, `c2`) but not a
`letter-digit-letter-digit` chain like `g1i1`, out of scope for this
sweep (nothing flagged it), left for a future refinement.

---

## `status:` field, stability tier

**Required** for all new components. Existing components default to `stable` if unset, but new yamls MUST set this explicitly.

| Value | When to use |
| --- | --- |
| `stable` | Public API contract; safe for consumers to depend on. The vast majority of shipped components. No docs-site badge. |
| `beta` | Functional but API may change in MINOR releases. Docs site shows a `warning`-variant `<tag-ui>` badge labeled "beta". |
| `experimental` | Early prototype; expect breaking changes. Docs site shows a `ghost`-variant badge labeled "experimental". |
| `deprecated` | Has a replacement; check the component's `related:` section. Docs site shows a `danger`-variant badge labeled "deprecated". |
| `early-access` | Customer-preview tier; release notes gate. Docs site shows an `info`-variant badge labeled "early access". |

**Ratified, closed enum, compiler-enforced at build time, mirrored in the schema (ADR-0057, ratified 2026-08-15).** The five values above are the whole vocabulary; `draft` is NOT a value (the one `draft` in the estate, `embed-shell.yaml`, was corrected to `experimental` when the enum went live, a sixth value on a single occurrence is data-entry drift, not a vocabulary gap). Enforcement: `scripts/build/components.mjs:105` holds `STATUS_VALUES` and `compileComponent()` (`components.mjs:258-260`) throws on any out-of-enum `status:` at the same place it throws on a missing `component:`, so `npm run verify:components` (`node scripts/build/components.mjs --verify`, a member of the `npm run check` aggregate) hard-fails the yaml with a file-and-value error. An invalid status no longer merely skips a docs badge; it stops the build. `scripts/schemas/component.yaml.schema.json:23-27` declares the same enum (default `stable`) for the `$schema:` IDE contract, but no validator runs that file, the hand-synced constant in `components.mjs` is the live gate. `status` is orthogonal to the ADR-0050 L0–L4 tier ladder (tier = what a component is composed of; status = how much to trust its contract today), and nothing in `packages/gen-ui/engine/retrieval/` filters or ranks on it. Source: ADR-0057.

**Guidance**:

- Set `beta` or `experimental` at FIRST AUTHORING for any component that's not in the stable API contract yet. Don't default to `stable` and bump later: the badge is consumer-facing, and stable→beta is a downgrade signal.
- Only set `stable` after the component has shipped at least one MINOR cycle and gathered consumer feedback. The bar for `stable` is "no API changes anticipated in the next 3 MINOR releases."
- `deprecated` requires a `related:` entry pointing at the replacement. Without one, consumers can't recover.

**Sidecar emission**: `x-adiaui.status` field in `<name>.a2ui.json`. The docs site (`site/site.js`) reads this and injects the badge automatically, no HTML change needed in `<name>.examples.html`.

**Verification**: `grep -L '^status:' packages/web-components/components/*/*.yaml` should return empty (every yaml has a status). Run before opening any authoring PR that adds new yamls.

---

## `category:` field, functional grouping

**Required.** Every yaml sets this; `scripts/build/components.mjs` forwards it verbatim onto the sidecar as `x-adiaui.category`, and `packages/gen-ui/engine/retrieval/catalog.js` reads it from there for every YAML-backed component, no second, hand-maintained category list for anything with a yaml SoT. `catalog.js` does still carry one small, DELIBERATE exception: a 3-entry `PSEUDO_TYPE_CATEGORY` map (`section`/`header`/`footer` → `card-child`) for `@adia-ai/a2ui` registry pseudo-types that have no yaml SoT at all (v0.9 composition slot-children, not real primitives), nothing to derive from, so this one small map stays hand-maintained by design, not drift.

| Value | When to use |
| --- | --- |
| `action` | A standalone, click-to-fire trigger (`button-ui`, `toggle-scheme-ui`). |
| `agent` | AI/agent-facing surfaces, chat, trace, tool output, tabular/chart data views (`chat-thread-ui`, `agent-trace-ui`, `table-ui`, `chart-ui`, `embed-ui`). |
| `container` | A chrome/wrapping surface that holds other content (`card-ui`, `modal-ui`, `drawer-ui`, `menu-ui`, `command-ui`). |
| `data` | Structured/tabular data display, not a full agent surface (`tree-ui`, `heatmap-ui`). |
| `display` | Passive content rendering, text, media, status glyphs (`text-ui`, `icon-ui`, `badge-ui`, `avatar-ui`, `link-ui`, `mark-ui`, `richtext-ui`, a non-editable renderer, not a form field). |
| `feedback` | Status/notification/progress communication (`spinner-ui`, `inline-message-ui`, `progress-ui`, `progress-row-ui`, `step-progress-ui`, `feed-ui`, `feed-item-ui`). |
| `form` | Data-entry composite/field-level components, not raw bindable controls (`field-ui`, `fields-ui`, `rating-ui`, `toggle-option-ui`). |
| `input` | Bindable form controls (`input-ui`, `select-ui`, `check-ui`, `switch-ui`, `textarea-ui`, `radio-ui`). |
| `layout` | Pure structural/spatial primitives, no content semantics of their own (`row-ui`, `col-ui`, `grid-ui`, `stack-ui`, `list-ui`). |
| `navigation` | Wayfinding/switcher controls, including a switcher family's child items (`breadcrumb-ui`, `pagination-ui`, `menu-item-ui`, `segmented-ui`/`segment-ui`, `tabs-ui`/`tab-ui`, `stepper-ui`/`stepper-item-ui`). `toggle-group-ui` is `navigation` too, but its child `toggle-option-ui` is `form` (a wrapper/item split, like `menu-ui`/`menu-item-ui`, not a same-category pair). |
| `shells` | Page-level app-shell composites (`simple-shell-ui` and its siblings). |
| `utility` | Non-visual/accessibility helpers (`skip-nav-ui`, `visually-hidden-ui`). |

**Ratified, closed enum, compiler-enforced at build time, mirrored in the schema (ADR-0065).** These twelve values are the whole vocabulary; the census that ratified them found 18 free-form values in live use (typos like `forms`/`data-display`, one-off singletons, and three named misclassifications), all folded or corrected onto this set as part of the same change. Enforcement: `scripts/build/components.mjs` holds `CATEGORY_VALUES` and `compileComponent()` throws on a MISSING `category:` field (unlike `status:`, `category:` is required, not defaulted) as well as on any out-of-enum value, the same place and severity as the `status:` check above, so `npm run verify:components` hard-fails an invalid OR absent category. `scripts/schemas/component.yaml.schema.json`'s `category` enum mirrors this list for the `$schema:` IDE contract; the hand-synced constant in `components.mjs` is the live gate, same relationship as `status`. Source: ADR-0065.

**The six one-off drift values ADR-0065 folded**, each independently justified by what the component does, not a blanket rule:

| Drift value | Folds to | Example |
| --- | --- | --- |
| `forms` | `form` | `toggle-option.yaml`, spelling drift against `form`'s existing members. |
| `data-display` | `data` | `heatmap.yaml`, joins `tree.yaml`/`tree-item.yaml`, same spelling drift shape. |
| `content` | `display` | `link.yaml`, inline content rendering, same role as `text-ui`/`code-ui`. |
| `typography` | `display` | `mark.yaml`, a text-highlight element, same role as `text-ui`. |
| `control` | `action` | `toggle-scheme.yaml`, a click-to-fire toggle, same shape as `button-ui`. |
| `interaction` | `container` | `admin-command.yaml`, a command-palette surface, same role as `command-ui`. |

Three named misclassifications were also fixed, not folded: `check.yaml`/`switch.yaml`/`textarea.yaml` (`layout` → `input`, bindable form controls, not structural primitives), `tabs.yaml` (`container` → `navigation`, unifying with `tab.yaml`), and `feed.yaml` (`container` → `feedback`, unifying with `feed-item.yaml`). Full rationale and the progress-family partial unification: ADR-0065 Decision §2–§4.

**A sibling family (a wrapper + its child items, e.g. `tabs-ui`/`tab-ui`) is not required to share one category by default**, `menu-ui` (`container`) + `menu-item-ui` (`navigation`) is a deliberate, working split. Where a family's sibling values disagreed with no evident rationale, ADR-0065 unified them; new families should pick per-component, not assume unification is required.

**Sidecar emission**: `x-adiaui.category` field in `<name>.a2ui.json`. `packages/gen-ui/engine/retrieval/catalog.js`'s `buildCatalog()` reads this directly per entry, no separate registration step.

---

## Semantic color-family axis, two role-classes, two names (ADR-0044, ADR-0064)

Every component carries at most one STYLE axis and one FAMILY axis (the
semantic color family: `default | info | success | warning | danger`,
`+primary` where the role-class already carries brand emphasis), and every
enum value belongs to exactly one axis. The family axis's ATTRIBUTE NAME is
decided by role-class, never one universal name (a same-name meaning-flip
is a silent-failure migration and poisons the trained corpus, per ADR-0044
LLD §3):

- **Role-class A, `variant` is unclaimed:** the family axis is named
  `variant`. Badge/tag's ratified shape (ADR-0044), plus `rating-ui` and the
  `variant`-only siblings (inline-message, feed-item, empty-state, menu-item,
  progress-row).
- **Role-class B, an existing identity/style axis already claims the
  selector slot:** the family axis is named `color`. Button's shape
  (`variant`=style, `color`=family), extended by ADR-0064 to `text-ui`
  (`variant`=typography role), `chart-ui`/`heatmap-ui` (`type`=kind),
  `icon-ui` (`weight`=glyph style), `spinner-ui` (`variant`=animation), and
  `toggle-scheme-ui`. Renames owed by this ruling (follow-on build, gh#1376, not yet landed): `icon-ui[tone]` → `[color]`, `heatmap-ui[colorScheme]`
  → `[color]`, `spinner-ui[tone]` → `[color]`.

`accent` is RETIRED from the family enum everywhere, ADR-0044 removed it
from badge/tag/button; ADR-0064 removes it from the seven stragglers
(text, chart, icon, heatmap, rating, spinner, toggle-scheme) with no
replacement value. Never mint `accent` in a new enum.

Two ratified non-family exceptions, the name without the semantics:

- `swatch-ui[color]` / `noodles-ui[color]`, an arbitrary CSS color string,
  not a semantic enum (ADR-0054 §11 exemption, unchanged).
- `spinner-ui[color]` (post-rename), a closed contrast-mode enum
  (`current | subtle | inverse`), NOT the family vocabulary; never assume it
  accepts `info`/`success`/`warning`/`danger` by analogy.

---

## Catalog tiers, L0–L4, `origin`, and the promotion rule (ADR-0050, ADR-0066)

ADR-0050's L0–L4 ladder is the ONLY tier grammar, never mint a second
manifest format. ADR-0066 refines it three ways:

- **`origin: primitive | module` on every L0 entry**, both YAML source
  roots (web-components primitives AND web-modules composites) compile into
  the same `catalog-a2ui_1_0.json` through one shared contract;
  `derive-catalog-tiers.mjs` stamps which root an entry came from onto
  `tier-index.json`. A module is legitimately a member of TWO rungs at two
  grains: its component API (props/events/slots) is L0, its assembled shell
  composition is L3, ruled correct, not a modeling defect.
- **The promotion rule, stated once:** patterns/zettel compositions are the
  SOLE promotion source, and they enter the ladder at exactly one point, pattern → L1 widget, through `curate-l1-widgets.mjs`'s gates (which writes
  only `l1-widgets.json`). The higher rungs (L1 → L2 → L3 → L4) are AUTHORED
  edges, hand-written `tiers/l*-*.json` manifests whose `composes` reference
  the rung below, reserved/unblocked per ADR-0050's own phasing; a module's
  L3 membership comes from an authored L3 manifest, never from its yaml
  (which contributes only the L0 entry + `origin`). There is no
  patterns↔module edge, primitives and modules never "promote" into each
  other, and nothing promotes automatically or in reverse, curation is the
  one-way valve (ADR-0050: "corpus derives from catalogs, never the
  reverse").
- **The two pattern-facing outputs stay separate by design:**
  `site/patterns-index.json` / `pattern-index.md` are a generated,
  developer-facing index over the FULL pattern/template census, intentionally
  independent of the L0–L4 machine-validated schema, a different audience,
  never a convergence gap to "fix".

(`status:` above is orthogonal to the tier ladder, tier = what an entry is
composed of; status = how much to trust its contract today.)

---

## `props:` field, prop schemas

Each prop is a top-level key inside `props:`. The full prop schema:

```yaml
props:
  label:
    description: >-
      Visible label for the field. Wires aria-labelledby on the
      editable surface so screen readers announce it.
    type: string              # string | number | boolean | enum
    default: ""               # default value (omit for boolean true)
    required: true            # ← see §required field below
    reflect: true             # mirror attribute ↔ property
    enum: [primary, ghost]    # for type: enum
    values:                   # alternative enum syntax (legacy)
      - primary
      - ghost
```

### Synthesized universal props, `slot` / `hidden` / `ariaLive` / `traits`

`deriveProps()` (`scripts/build/derive-genui-catalog.mjs`) prepends three
props to every component's generated catalog schema before it ever reads a
yaml's own `props:` block: `slot: {type: string}`, `hidden: {type:
boolean}`, `ariaLive: {type: string}` (REQ-013 accessibility pair, gh#1353).
**No yaml SoT declares any of these**, they're synthesized, not authored,
and a sidecar that ever DOES declare one of these keys itself simply
overwrites the synthesized definition.

**Decided but not yet implemented:** ADR-0097
(`docs/ops/adr/adr-0097-traits-as-a2ui-synthesized-universal-prop.md`,
ratified via PR #2524) rules that `traits: {type: string}` becomes a fourth
member of this synthesized set, carrying the same space-separated grammar
as the existing HTML `[traits="…"]` declarative attribute
(`.claude/docs/specs/traits.md`, "Method 3", `"ripple confetti-burst"`,
not a JSON array), making `traits` legal on every component's A2UI wire
schema with no renderer change, and, once wired, checked by
`catalog-validator.js` against the live trait registry
(`packages/web-components/traits/_catalog.json`) with a **hard FAIL on an
unknown trait name** (the same rejection behavior the raw HTML mechanism
already has). As of this writing `deriveProps()` still synthesizes only the
three props above and `catalog-validator.js` has no trait-name check, gh#2513 tracks the build.

### Sentinel-0 defaults and tag-promoting props (ADR-0102, 2026-09-01)

Not every escape hatch belongs on `CatalogComponentCommon`, a prop scoped
to one component's own semantics stays on that component's own yaml, even
when it changes the rendered tag. `text-ui`'s `level` (integer 0-6,
default `0`) is the worked example: `0` is the sentinel for "no
promotion, current behavior", the same `0 = off` convention `text-ui`'s
own `lines` prop already used (`lines: 0` = no clamp), and `1`-`6`
promotes the rendered element to a real native `<h1>`-`<h6>`, independent
of `variant` (which stays presentational-only: typography tokens, never a
tag). `deriveProps()` needs no change for this, a per-sidecar prop like
`level` already flows into the generated catalog schema generically; only
a `CatalogComponentCommon` universal (`slot`/`hidden`/`ariaLive`/`traits`,
above) needs a wire-schema edit. See ADR-0102 for the full contract
(renderer branch, dead-code removal, non-goals).

### `required: true` field

**When to use**: only for props where omitting them makes the component meaningless or inaccessible.

| Use `required: true` | Don't use `required: true` |
| --- | --- |
| `field-ui.label`, no visible/accessible label without it | `button-ui.variant`, has a sensible default |
| `icon-ui.name`, nothing renders without it | `select-ui.placeholder`, useful but optional |
| `nav-item-ui.text`, empty nav item | `card-ui.size`, affects styling, not function |
| `chart-ui.type`, can't render a chart of "nothing" | `stat-ui.change-indicator`, optional enhancement |
| `tabs-ui.value`, needs an initial selected tab | `tag-ui.variant`, has a default |

**Heuristic**: ask "if I author `<my-component-ui></my-component-ui>` with nothing else, is the component **broken** or just **default-styled**?" If broken → mark required. If default-styled → don't.

**Representative props marked required**:

- `field-ui.label`
- `icon-ui.name`
- `nav-item-ui.text`
- `chart-ui.type`
- `stat-ui.label`, `stat-ui.value`
- `check-ui.label`
- `badge-ui.text`
- `rating-ui.value`
- `tabs-ui.value`

**Sidecar emission**: `required: true` propagates to the JSON Schema `required` array in `<name>.a2ui.json`. The A2UI validator + MCP tools consume this array, correct marking improves validation quality on generated UI trees.

**Anti-pattern**: marking ALL props required because they all "have a useful value." That defeats the validation signal. `required` is a strict-failure constraint, not a "recommended" hint.

### `type: array`, `items.type` is LOAD-BEARING (gh#970)

An array prop's catalog mapping (components.mjs TKT-0010) branches on
`items.type`:

```yaml
options:
  description: Array of {value, label, icon?, …} or grouped {label, options:[…]}.
  type: array
  items:
    type: object     # ← REQUIRED for rich-object arrays
  default: []
  dynamic: true
```

- `items: {type: object}` → `#/$defs/DynamicObjectList`, rich option
  objects validate.
- **`items` omitted → `#/$defs/DynamicStringList` silently**, and the
  dialect validator then REJECTS every real composition that passes rich
  objects ("/options/0 must be string … oneOf"). command-ui and
  combobox-ui both shipped this way; nothing caught it until the
  `exit-gate.corpus` gate at v0.8.34 release pre-flight (that gate runs
  in the release serial suite, NOT in PR CI, the failure lands at cut
  time, weeks after the yaml edit).

**Heuristic**: if the prop description says "Array of {…}", the yaml MUST
declare `items: {type: object}`. A description promising objects with a
string-list schema is the exact silent-mismatch shape.

---

## Reserved `data-*` attribute names (admin-shell scope)

`admin-shell.helpers.css` (in `packages/web-modules/shell/admin-shell/css/`) reserves 5 layout-utility `data-*` attribute names with `admin-shell [data-X]` ancestor scoping:

| Attribute | Effect | Use within `<admin-shell>` |
| --- | --- | --- |
| `[data-col]` | `display: flex; flex-direction: column; gap: var(--page-grid-gap)` | column layout helper |
| `[data-row]` | `display: flex; align-items: center; gap: var(--page-grid-gap)` | row layout helper |
| `[data-layout-grid]` | `display: grid; grid-template-columns: 1fr 1fr` (or `1fr 1fr 1fr` for `data-layout-grid="3"`) | 2- or 3-col grid helper |
| `[data-actions]` | `display: flex; align-items: center; gap: var(--page-actions-gap)` | action button cluster |
| `[data-spacer]` | `flex: 1` | flex spacer for pushing content to edges |

**Authoring contract**:

1. **DO NOT use these attribute names for any other purpose** (table column markers, sort-state, etc.) inside `<admin-shell>` descendants. The CSS rules will apply unintended layout. Use namespaced names instead (`data-page-col`, `data-sort-col`, `data-my-actions`).
2. **`admin-shell` ancestor is required**. An earlier dist CSS shipped bare global selectors that applied to ANY element with these attributes on ANY page loading `admin-shell.min.css`, including `<table>` headers (silent layout breakage in Safari/WebKit). Source + dist now both prefix `admin-shell` ancestor. This is a hard constraint: the parent-tag selector is the only reliable CDN-safe scoping mechanism (LightningCSS strips `@scope` blocks).
3. **Outside `<admin-shell>`, these names have NO EFFECT**. If you need `[data-col]` semantics on a non-admin-shell page, you must author your own CSS (the helpers do not apply globally).

**Documented for consumers** in the adia-factory plugin's composition references (reserved layout-helper attribute names).

---

## `data-msg-*`, exempt component-side config family (gh#1332/#1464, ADR-0060)

`data-msg-required` / `data-msg-pattern` / `data-msg-minlength` /
`data-msg-maxlength` / `data-msg-min` / `data-msg-max` / `data-msg-bad-input`
are read directly by form-associated components (`core/form.js`'s shared
`UIFormElement` validation path, plus `input`, `select`, `tags-input`,
`code`, `date-range-picker`, `datetime-picker`, and
`payment-method-form.class.js`) to override a native constraint-violation's
default message with a consumer-supplied string.

**Disposition: EXEMPT, never declared as a yaml `states:`/`props:` entry.**
Both yaml surfaces this contract offers are the wrong shape for this family:

- `states:` declares **presence-boolean host state the component itself
  reflects outward** (idle/loaded/error, this file's own §Reserved section's
  neighbor pattern), `data-msg-*` carries no state at all; it is a
  consumer-authored string the component only ever *reads*, never sets.
- `props:` would need one string prop per validation-message key, repeated
  across every one of the 7+ consuming components, but the read path is
  `core/form.js`'s shared mixin, not any single component's own yaml SoT.
  Declaring it per-component would multiply one shared mixin contract across
  every consumer's yaml with no single owning SoT to declare it once, a
  cross-cutting mixin-contract change, not a per-component yaml edit.

ADR-0060's own boundary discriminator (§Decision 3) already places this
family outside the trait-tier `data-*` ratification and explicitly routes it
"to gh#1332's Category A/D triage for its own converge-or-ratify call": this
section IS that call. The family stays `data-*`, undeclared in any yaml,
with its contract documented at the shared source instead: `core/form.js`'s
own header JSDoc (the mixin all consumers share) and the canonical
`form-system` pattern doc (`packages/web-components/patterns/form-system/
form-system.examples.html`, rendered directly at `/site/patterns/form-system`), both already enumerate the full family with a worked example. A future
architectural pass that wants to
promote this to a declared per-component contract needs its own ADR (the
scope is a mixin-wide contract change, not a small-ticket edit); nothing
here forecloses that, it only records today's call.

---

## Build pipeline

```bash
npm run build:components       # regenerates all <name>.a2ui.json sidecars from yamls
npm run verify:components      # verifies no drift (CI gate)
node scripts/build/components.mjs --verify   # same as above, direct invocation
```

The build:

1. Reads every `<name>.yaml` under `packages/web-components/components/` and `packages/web-modules/<cluster>/`
2. Hand-checks the source yaml in `compileComponent()`, missing `component:` and out-of-enum `status:` both throw (`components.mjs:255-260`). Amended 2026-08-16 per ADR-0057: it does NOT run `scripts/schemas/component.yaml.schema.json` as a validator (an earlier revision of this list claimed it did); the schema file is documentation + IDE contract only.
3. Emits `<name>.a2ui.json` (the sidecar) co-located with the yaml + js + css
4. Emits the `traits/_catalog.json` aggregate
5. `--verify` mode: re-runs steps 1-4 in-memory and fails if any sidecar drifts from disk content (CI hard-fail)

**Never hand-edit `<name>.a2ui.json`**: it's regenerated from the yaml. The yaml is the SoT.

**Downstream of the sidecars, two more derived artifacts (gh#970's release-PR stop):**

```bash
node scripts/build/derive-genui-catalog.mjs   # genui's five opt-out-scoped catalogs derive FROM the sidecars
npm run check:genui-catalog                    # the drift gate that fails PR CI if you skip the above
```

Any yaml change that alters a sidecar's prop schemas STALES the genui
catalog. The main regen chain (`harvest:chunks` → `build:embeddings:chunks`
→ `build:patterns-index`) does NOT cover it, it's a separate derivation
with its own gate, and skipping it passes every local check that isn't
`check:genui-catalog` itself.

---

## Component creation playbook (full lifecycle)

This is the canonical end-to-end procedure for creating a new component yaml + js + css + examples.html + sidecar. Run in order:

1. **Author `<name>.yaml`** with all required fields:
   - `name:`, `tag:`, `component:`, `category:`, `version: 1`
   - `status:`, pick from the table above
   - `description:`, concrete one-paragraph
   - `props:` with per-prop `type:`, `default:`, optional `required: true`, optional `enum:` / `values:`
   - `events:`, `slots:`, `css-vars:` as applicable
2. **Author `<name>.js`** following the `UIElement` / `UIFormElement` patterns (see [code-style.md](code-style.md))
3. **Author `<name>.css`** following the light-DOM cascade rules (see [css-patterns.md](css-patterns.md))
4. **Build the sidecar**:

   ```bash
   npm run build:components
   ```

   This generates `<name>.a2ui.json`.

5. **Author `<name>.examples.html`** with at minimum:
   - A `<h1>` matching the component name
   - One `<section data-section data-property="usage">` with the canonical worked example
   - Per-prop demo sections matching `data-property="<prop-name>"` (one per prop)
6. **Run the anatomy sweep**:

   ```bash
   node scripts/docs/anatomy-sweep.mjs
   ```

   This auto-generates the **canonical anatomy sections** (`slots`, `data-attrs`, `keyboard`, `css-vars`, `a2ui`, `related`) from the sidecar. The sweep is idempotent, skip-on-already-present, safe to re-run. Hand-author `accessibility` and any prop-demo sections; the sweep covers the schema-derivable sections only.

   Canonical `data-property` vocabulary for `<section data-section data-property="X">`:
   - `usage`, canonical worked example
   - `props`, prop reference table
   - `slots`, named-slot semantics
   - `events`, fired events
   - `data-attrs`, `data-*` attributes the component reads
   - `css-vars`, CSS custom properties
   - `keyboard`, keyboard interaction model
   - `accessibility`, ARIA, screen-reader notes
   - `a2ui`, A2UI runtime integration notes
   - `related`, sibling/replacement components
   - `<prop-name>`, visual demo for a specific prop

   The sweep also **normalizes legacy section data-properties**: `Properties` → `props`, `Events` → `events`, `CSS Tokens` → `css-vars`, `Usage` → `usage`. Run with `--dry` first to preview changes:

   ```bash
   node scripts/docs/anatomy-sweep.mjs --dry
   ```

7. **Run the full verify gate**:

   ```bash
   node scripts/build/components.mjs --verify   # sidecar drift
   npm run verify:traits                         # 100% coverage
   ```

After the playbook, the component is consumable by the docs site, the A2UI runtime, and any harness reading the sidecar.

---

## Cross-references

- `.claude/docs/specs/component-token-contract.md`, token/variant/mode contract
- `.claude/docs/specs/component-implementation-patterns.md`, implementation patterns
- [code-style.md](code-style.md), JS code style rules
- [css-patterns.md](css-patterns.md), light-DOM CSS cascade rules
- [api-contract.md](api-contract.md), props/events/slots conventions
- [authoring-cycle.md](authoring-cycle.md), the 5-step authoring procedure
- `scripts/schemas/component.yaml.schema.json`, JSON Schema (documented contract + IDE aid; not run as a validator, `compileComponent()` enforces only `component:` and the `status` enum, see §Build pipeline)
