# Using `@adia-ai/web-components` — Consumer Guide

The complete reference for engineers and agents **integrating** AdiaUI into an app. For authoring new primitives (contributing to the library), see [`README.md`](./README.md) § "Authoring a primitive".

> This document answers the five most-common questions consumers ask. If something here is wrong, surprising, or missing, that's a bug — open an issue with the surface it describes.

---

## Contents

1. [Install](#install)
2. [The mental model](#the-mental-model)
3. [Property reactivity (signal-backed)](#property-reactivity-signal-backed)
4. [Property binding patterns (templates / framework integration)](#property-binding-patterns)
5. [Event contract — `CustomEvent` with `detail`](#event-contract)
6. [Form participation — `UIFormElement` + `ElementInternals`](#form-participation)
7. [Data binding with `data-stream-*`](#data-binding-with-data-stream-)
8. [Lifecycle — `connected` / `render` / `updated` / `disconnected`](#lifecycle)
9. [Theming, density, size](#theming-density-size)
10. [Registration — auto vs explicit](#registration--auto-vs-explicit)
11. [TypeScript](#typescript)
12. [Anti-patterns](#anti-patterns)

---

## Install

```bash
npm install @adia-ai/web-components
```

Pair with [`@adia-ai/web-modules`](../web-modules) for composite shells (admin / chat / editor / simple / theme):

```bash
npm install @adia-ai/web-components @adia-ai/web-modules
```

### Importing

The package is ESM-only — works under any modern bundler (Vite, esbuild, webpack 5+, Rollup) or plain `<script type="module">`.

```js
// Everything — registers every *-ui tag (95 primitives)
import '@adia-ai/web-components';
import '@adia-ai/web-components/css';

// Or per-component for tree-shaking-conscious bundles
import '@adia-ai/web-components/components/button';
import '@adia-ai/web-components/components/button.css';
```

> **No `.js` suffix on component subpaths.** Import the directory name —
> `…/components/button`, never `…/components/button/button.js`. The
> `exports` wildcard matches only the directory form; appending the
> explicit filename fails to resolve under Vite 8 (rolldown). The on-disk
> filename is irrelevant to the subpath specifier.

Subpath exports also available:

```js
// Core utilities — signals, templating, base classes
import { signal, computed, effect, html, UIElement, UIFormElement } from '@adia-ai/web-components/core';

// Traits — composable behavior decorators
import '@adia-ai/web-components/traits';
```

### Looking up a component's API

Three reliable surfaces, in order of immediacy:

```bash
# 1. CLI (since v0.6.7) — prints yaml summary + live demo URL + first example
npx adia-ui-doc table-toolbar     # full prop / slot / event surface
npx adia-ui-doc                   # bare invocation lists all 96 components
```

```bash
# 2. Per-component examples (ship in npm tarball since v0.6.7)
cat node_modules/@adia-ai/web-components/components/table-toolbar/table-toolbar.examples.md

# Or the richer .examples.html for Properties tables + multi-section walks
open node_modules/@adia-ai/web-components/components/table-toolbar/table-toolbar.examples.html
```

```bash
# 3. The component's yaml (authoritative — all props, defaults, descriptions)
cat node_modules/@adia-ai/web-components/components/table-toolbar/table-toolbar.yaml
```

The yaml is the source of truth for every property, slot, event, and token.
The CLI reads it. The `.d.ts` files are generated from it. When this USAGE
guide and the yaml disagree, the yaml wins — file a doc-bug FEEDBACK.

For the live demo of every component: <https://ui-kit.exe.xyz/site/components/>.

---

## The mental model

Every AdiaUI primitive is a **standard light-DOM custom element**. There's no shadow DOM, no compiler, no framework — just `customElements.define()` extending a small `UIElement` base class. The base class wires a signal-backed property system on top.

The contract every primitive obeys:

| Surface | What it is | How to use |
|---|---|---|
| **Properties** | Reactive — every prop in `static properties` is backed by a signal. Setting `el.prop = v` triggers a re-render. | `slider.value = 75` |
| **Attributes** | Mirror of properties (where `reflect: true`). Setting `[attr]` round-trips through the property setter. | `<slider-ui value="50">` |
| **Events** | `CustomEvent` with typed `detail` payload. Bubbling unless noted otherwise. | `slider.addEventListener('change', e => e.detail.value)` |
| **Slots** | Light-DOM children projected by attribute selectors (`:scope > [slot="icon"]`). No `<slot>` element. | `<card-ui><span slot="heading">Title</span></card-ui>` |

This is the entire surface. There are no magic helpers; no proprietary state-management; no compiler. If you know vanilla web components + standard DOM, you know AdiaUI.

---

## Property reactivity (signal-backed)

Every property declared in a primitive's `static properties` map is **signal-backed**. The setter mutates an internal `signal()`; the host's render effect subscribes to all signals and re-runs on every property change. So:

```js
const slider = document.querySelector('slider-ui');

slider.value = 75;      // thumb moves to 75%, readout updates, aria-valuenow reflects
slider.value = 10;      // thumb moves to 10%
slider.disabled = true; // [disabled] reflects, click handlers no-op
```

This works for **every primitive**. There's no "is this prop reactive?" question — they all are.

### Attributes round-trip

Setting an attribute also propagates. `attributeChangedCallback` writes back into the property setter, which mutates the signal, which fires the render effect:

```js
slider.setAttribute('value', '25');   // same effect as slider.value = 25
```

This means you can drive a primitive from either side. Whatever your tooling sets — properties or attributes — works.

### Why this matters

Consumers sometimes assume that property changes only update the DOM if explicitly subscribed to via `attributeChangedCallback`. **That's not the case here.** The signal-backed property system means every property is reactive by default, with zero per-component opt-in. If you set a property and the DOM doesn't update, it's a bug in the primitive, not a missing subscription on your side.

---

## Property binding patterns

When wiring a primitive's property to external state (your own signals, framework state, RxJS observables, etc.), the binding pattern matters. **An eagerly-evaluated value snapshot is one-shot**, not reactive.

### Using the built-in `html` template

The package ships its own lightweight template tag at `@adia-ai/web-components/core`. It detects signals and functions as bound values and wraps them in effects:

```js
import { html, signal, stamp } from '@adia-ai/web-components/core';

const minL = signal(0.5);

// ✅ Reactive — signal passed directly. Re-fires on every minL.value mutation.
const tpl1 = html`<slider-ui .value=${minL} min="0" max="1" step="0.01"></slider-ui>`;

// ✅ Reactive with transform — function-as-value. Function re-runs on dependency change.
const tpl2 = html`<slider-ui .value=${() => minL.value * 100} min="0" max="100"></slider-ui>`;

// ❌ NON-REACTIVE — eagerly evaluated. Snapshots at render time only.
const tpl3 = html`<slider-ui .value=${minL.value * 100} min="0" max="100"></slider-ui>`;
//                              ^^^^^^^^^^^^^^^^^^^^^^^^
//                              evaluates to a number now; nothing to subscribe to
```

The `.prop=` syntax sets the JS property (not the attribute). The `attr=` syntax sets an attribute. Both round-trip through the signal-backed setter, so either works for reactive updates from your side.

### Template attribute bindings — full / property / no-partial (§152, v0.5.3)

The `html\`` template literal supports three attribute-binding modes:

| Syntax | Behavior |
|---|---|
| `attr="${value}"` (or just `attr=${value}`) | **Full attribute replacement.** The entire attribute value is the placeholder. The string `value` becomes the attribute value via `setAttribute()`. Works for any string-typed attribute. |
| `.prop=${value}` (leading dot) | **Property assignment.** Sets `element.prop = value` directly (bypasses attributes). Preferred for objects, functions, arrays, signals — anything that doesn't serialize to a string. |
| `attr="prefix-${value}-suffix"` | **NOT SUPPORTED.** Partial interpolation is not implemented — the attribute-part contract is whole-value-only. The framework will `console.warn` at scan time. Compute the full attribute value as a single expression and interpolate the whole. |

Example of the partial-interpolation pitfall and its fix:

```js
// ❌ NOT SUPPORTED — partial attribute interpolation
// Will console.warn at scan time + the `{{p:0}}` placeholder text reaches the DOM as literal.
html`<div style="background:${bgCss}; color:${labelColor}">…</div>`;

// ✅ Compute the full attribute as one expression
const swatchStyle = `background:${bgCss}; color:${labelColor};`;
html`<div style="${swatchStyle}">…</div>`;

// ✅ Or use property binding with an object (CSSStyleDeclaration-like)
html`<div .style=${{ background: bgCss, color: labelColor }}>…</div>`;
```

**Why the constraint exists**: the engine pre-processes static-string chunks to insert placeholder markers (`{{p:N}}` for attribute positions, `<!--p:N-->` for content positions). At scan time, the post-substitution attribute value must match `^\{\{p:N\}\}$` for the placeholder-to-part mapping to register. Sub-string placeholders break this contract. The runtime guard (added v0.5.3 §152) makes the failure mode explicit instead of silent.

### Using React, Lit, Vue, Svelte, etc.

When the binding system isn't AdiaUI's `html` tag, the rule generalizes:

**Set the primitive's property inside whatever effect/watcher hook your framework provides.** The property setter mutates the signal, the host re-renders. Don't try to "bind" the value via attributes alone — properties are the reactive path.

```jsx
// React example
function MinLSlider({ minL, onMinLChange }) {
  const ref = useRef(null);
  useEffect(() => {
    if (ref.current) ref.current.value = minL * 100;
  }, [minL]);
  return <slider-ui ref={ref} min="0" max="100" onChange={e => onMinLChange(e.detail.value / 100)} />;
}
```

```html
<!-- Lit example -->
<slider-ui .value=${this.minL * 100} @change=${e => this.minL = e.detail.value / 100}></slider-ui>
```

```html
<!-- Vue example -->
<slider-ui :value="minL * 100" @change="onMinLChange($event.detail.value / 100)"></slider-ui>
```

```svelte
<!-- Svelte example -->
<slider-ui value={minL * 100} on:change={e => minL = e.detail.value / 100}></slider-ui>
```

### Derived reactive bindings

When the value you want to render is a **transformation of a signal** — e.g. you store `0..1` but the slider displays `0..100`, or the displayed text is `${count} items` — pass a **function** as the binding. The template engine detects functions and wraps them in an `effect()`, so the binding re-evaluates whenever any signal read inside the function changes.

```js
// ✅ Derived reactive — re-evaluates whenever minL changes
html`<slider-ui .value=${() => minL.value * 100} min="0" max="100" />`

// ✅ Direct signal — re-evaluates whenever minL changes
html`<slider-ui .value=${minL} min="0" max="1" step="0.01" />`

// ❌ Static snapshot — reads minL.value ONCE at render time; thumb never moves
html`<slider-ui .value=${minL.value * 100} />`

// ❌ Function as the value, not a binding — slider receives the function object, not its result
html`<slider-ui .value=${(v) => v * 100} />`
```

The rule: **inside `.prop=${…}`, anything that reads `.value` directly is a snapshot. Wrap in `() => …` to get reactivity.**

**Dev-mode guard (v0.5.3 §153 / FEEDBACK-07 §3):** primitives that read their value synchronously (e.g. `<slider-ui>`) emit a `console.warn` when their `.value` is assigned a function — this catches the case where the function wasn't wrapped by the template engine (e.g. assigned imperatively `sliderEl.value = fn`, or returned by a framework binding that doesn't recognize the AdiaUI reactivity contract). The warn names the two correct patterns:

```
[slider-ui] .value received a function. Did you mean:
  .value=${fn()}            ← call the function to get the current value
  .value=${signal.value}     ← read the signal's current value
Functions are not auto-invoked at render time; the slider reads
.value synchronously. See USAGE.md "Reactive binding" for the
parent-template-re-read pattern that works across frameworks.
```

If you see this warn in dev console, either:
1. You're using AdiaUI's `html\``: ensure the function reads at least one signal value (otherwise the function isn't reactive, just expensive). Pattern A above with `() => signal.value * 100` is correct.
2. You're using a different framework (React/Lit/Vue): the binding system isn't AdiaUI's `html` engine, so functions don't auto-wrap. Use Pattern B from the parent-template-re-read recipe — read the signal in the parent's render, pass the extracted value imperatively or via your framework's binding.

For more complex derived values, `computed()` gives you a named reactive holder you can pass directly or read in templates:

```js
import { signal, computed, html } from '@adia-ai/web-components/core';

const minL = signal(0.4);
const displayPct = computed(() => Math.round(minL.value * 100));

html`<slider-ui .value=${displayPct} />`        // ✅ pass the computed signal direct
html`<text-ui>${displayPct}</text-ui>`           // ✅ same in text bindings
html`<text-ui>${() => `${displayPct.value}%`}</text-ui>`  // ✅ function form with template literal
```

`computed()` memoizes (re-runs only when its dependencies change) and gives you a stable handle you can pass to multiple bindings without re-instantiating the effect at every render.

### The eager-evaluation trap

Reported real-world bug: a consumer wrote `.value=${signal.value * 100}` and reported "the slider doesn't move on undo." The slider IS reactive; the binding pattern wasn't. `signal.value * 100` evaluates eagerly to a number; the template system never sees the signal, so it can't subscribe.

**Fix:** pass `${signal}` (signal direct) or `${() => signal.value * 100}` (function as value). Both get effect-wrapped. See the "Derived reactive bindings" section above for the full pattern.

---

## Event contract

Every interactive primitive emits **`CustomEvent` with a `detail` payload**. Since v0.4.5, this is uniform across all 17 form-bearing primitives.

| Event | Fires on | Detail shape |
|---|---|---|
| `change` | Value commit (blur, drag end, keystroke commit) | `{ value }` or `{ value, checked }` for boolean primitives |
| `input` | Every value tick (typing, dragging) | `{ value }` |
| `submit` | Enter without shift in `<textarea-ui>` | (none — `event.preventDefault()` to suppress form submission) |
| Component-specific | See per-component yaml | Per-component detail |

### Listening

```js
// Typed payload (canonical)
slider.addEventListener('change', (e) => {
  console.log(e.detail.value);    // canonical reading path
});

// Untyped (backwards-compatible — `this.value = v` runs before dispatch)
slider.addEventListener('change', (e) => {
  console.log(e.target.value);    // also valid
});
```

Both paths return the same value. Pick whichever your tooling makes ergonomic.

### Value-semantic vs boolean-semantic

| Class | Components | Detail |
|---|---|---|
| Value-semantic | input, select, slider, textarea, range, rating, search, otp-input, calendar-picker | `{ value }` |
| Boolean-semantic | switch, check, radio, option-card | `{ value, checked }` |
| Special | upload | `{ value, files }` |

For boolean primitives, `value` is the form-submission string ("on" by default; the form value attribute if set), and `checked` is the actual checked state.

### Component-specific events

Some primitives emit named events beyond `change`/`input`:

- `<select-ui>` — emits `action` for option-callbacks with `detail.action`
- `<search-ui>` — emits `search` after debounce with `detail.value`
- `<chat-composer>` — emits `composer-submit` on Enter with `detail.value`
- `<color-picker-ui>` — emits `input` + `change` with rich detail (`{ value, l, c, h, hex, oklch }`)

Each primitive's yaml lists its complete event surface. Live demos at `https://ui-kit.exe.xyz/site/components/<name>`.

### Bubbling

All standard events bubble. Internal-routing collisions (e.g. a trait listening for an event then re-emitting one with the same name) are avoided by name discipline — see the `event-name-collision` memory entry for the lesson.

### Authoring events in yaml (for contributors)

> *This subsection is for contributors authoring new primitives or extending an existing primitive's event surface. If you're a consumer integrating AdiaUI, skip ahead.*

Each component's `<name>.yaml` declares its event surface in a top-level `events:` block. The build pipeline (`npm run build:components`) carries that block into `<name>.a2ui.json` under `x-adiaui.events`, and v0.4.8 §80 codegen reads it from there to emit typed `addEventListener` overloads + `CustomEvent<X>` aliases in `<name>.d.ts`. **The yaml is the single source of truth**; the sidecar + `.d.ts` regenerate from it.

#### When to add an `events:` entry

Whenever your primitive's `class.js` calls `this.dispatchEvent(new CustomEvent('NAME', ...))` or `new Event('NAME', ...)`, the yaml needs a matching entry. The `check-form-bearing-dts.mjs` audit (v0.5.0-staged) catches drift between dispatch literals and `.d.ts` typings; populating yaml correctly prevents that drift.

#### The shape

```yaml
events:
  open:
    description: Fired when the modal becomes visible.
    detail:
      trigger:
        type: string
        description: One of 'click' | 'keyboard' | 'programmatic'.
  close:
    description: Fired when the modal is dismissed.
    # No detail block → CustomEvent<unknown> at the .d.ts surface.
  toggle:
    description: Fired when state flips.
    detail:
      open:
        type: boolean
        description: New open state after the toggle.
    bubbles: false   # default is true; set false only for non-bubbling events
```

#### What ends up where

For a yaml entry like the `toggle` event above on `<my-thing-ui>`:

| Surface | Shape |
|---|---|
| `<my-thing-ui>.a2ui.json` | `x-adiaui.events.toggle = { description, detail: { open: { type, description } }, bubbles? }` |
| `<my-thing-ui>.d.ts` (codegen) | `interface MyThingToggleEventDetail { open: boolean; }` + `type MyThingToggleEvent = CustomEvent<MyThingToggleEventDetail>` + `addEventListener` overload for `'toggle'` |
| Runtime | `this.dispatchEvent(new CustomEvent('toggle', { detail: { open: this.open }, bubbles: true }))` |

#### Detail-key types

The codegen recognizes these `type:` values inside a detail key:

| yaml `type:` | TS emitted |
|---|---|
| `string` | `string` |
| `number` / `integer` | `number` |
| `boolean` | `boolean` |
| `object` | `Record<string, unknown>` |
| `array` | `unknown[]` |
| missing | `unknown` |

If you need a string-literal union, declare via `enum: [a, b, c]` and the codegen emits `'a' | 'b' | 'c'`.

#### Worked example: `<modal-ui>` close

Yaml at `packages/web-components/components/modal/modal.yaml`:

```yaml
events:
  close:
    description: Fires when the modal is dismissed (backdrop click, Escape, programmatic hide()).
```

Sidecar after `npm run build:components`:

```json
{ "x-adiaui": { "events": { "close": { "description": "Fires when the modal is dismissed..." } } } }
```

Emitted `.d.ts` after `npm run codegen:dts`:

```ts
export type ModalCloseEvent = CustomEvent<unknown>;
export class UIModal extends UIElement {
  addEventListener(type: 'close', listener: (ev: ModalCloseEvent) => unknown, options?: ...): void;
}
```

Reference: [`docs/conventions/component-events.md`](../../docs/conventions/component-events.md) for the 1-page convention summary; [`scripts/build/dts-codegen.mjs`](../../scripts/build/dts-codegen.mjs) for the codegen logic that reads `x-adiaui.events`; [`scripts/release/check-form-bearing-dts.mjs`](../../scripts/release/check-form-bearing-dts.mjs) for the drift audit.

---

## Form participation

15 primitives extend `UIFormElement` and participate in standard forms via `ElementInternals`:

```
input, select, slider, switch, segmented, check, radio, textarea,
range, color-picker, rating, option-card, search, otp-input, code
```

They behave like native form controls:

```html
<form id="settings">
  <input-ui name="username" type="text" value="alice"></input-ui>
  <select-ui name="theme" value="ocean">…</select-ui>
  <slider-ui name="density" value="50" min="0" max="100"></slider-ui>
  <switch-ui name="notifications" checked></switch-ui>
  <button-ui type="submit" text="Save"></button-ui>
</form>
```

```js
const form = document.getElementById('settings');
const data = new FormData(form);
// → { username: "alice", theme: "ocean", density: "50", notifications: "on" }
```

### Validation

The full validation surface from native form controls works:

```js
input.checkValidity();        // boolean
input.reportValidity();       // boolean + shows error UI
input.setInvalid('Custom');   // mark invalid with custom message
input.setValid();             // clear error state
input.validity;               // ValidityState — { valid, valueMissing, patternMismatch, ... }
input.validationMessage;      // localized error message
input.willValidate;           // boolean
```

Constraint attributes (`required`, `pattern`, `minlength`, `maxlength`) work natively. CSS pseudo-classes (`:valid`, `:invalid`, `:required`, `:disabled`) work in your own CSS without any opt-in.

### Custom validation timing

`UIFormElement` validates **on blur if dirty** and **on form reset**. Override the timing via:

```js
const input = document.querySelector('input-ui');
input.addEventListener('blur', () => {
  if (!input.value.includes('@')) {
    input.setInvalid('Must be an email');
  } else {
    input.setValid();
  }
});
```

### Form events

Submit events bubble normally:

```js
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const data = new FormData(form);
  fetch('/api/save', { method: 'POST', body: data });
});
```

---

## Data binding with `data-stream-*`

Many AdiaUI components (`<chart-ui>`, `<heatmap-ui>`, `<table-ui>`, `<stat-ui>`, `<progress-row-ui>`, and ~7 others) support **declarative remote-data binding** via the `data-stream-*` attribute family. You point a component at a URL, and it self-fetches (REST poll) or self-subscribes (SSE) and pushes the result into one of its properties. No JS glue required.

This is the canonical "wire a component to a backend" pattern in AdiaUI. Reach for it before writing a `fetch()` loop in your app code.

### Concept

A component that opts in watches its `data-stream-*` attributes. When `data-stream-src` is set on a connected element, the component starts a transport (poll, SSE, or manual) and assigns each payload to a target property (e.g. `data` on `<table-ui>`, `value` on `<stat-ui>`). Change the attribute and the transport restarts; remove the attribute (or disconnect the element) and the transport tears down.

Because the binding is attribute-driven, it survives SSR, server-rendered partials, htmx swaps, framework hydration, and templating engines that have no concept of property assignment.

### Attribute reference

| Attribute | Purpose | Default |
|---|---|---|
| `data-stream-src` | URL — REST endpoint or SSE source. Setting this activates the binding. | (required) |
| `data-stream-mode` | `"poll"`, `"sse"`, or `"manual"`. `manual` fetches once on connect and on attribute change. | `"poll"` |
| `data-stream-interval` | Poll interval in milliseconds. Ignored for `sse` / `manual`. | `5000` |
| `data-stream-path` | Dot-path into the JSON response to extract before binding (e.g. `claims` extracts `response.claims`; `data.items` extracts `response.data.items`). | (whole response) |
| `data-stream-target` | Which component property receives the payload. | component-specific (`data` for `<table-ui>`, `value` for `<stat-ui>`, `data` for `<chart-ui>`, …) |
| `data-stream-method` | HTTP method for REST mode. `POST` is supported when you need a request body via headers/config. | `"GET"` |
| `data-stream-headers` | JSON-stringified object of request headers (e.g. `'{"Authorization":"Bearer …"}'`). | `null` |
| `data-stream-id` | Logical transport key. Multiple elements with the same `data-stream-id` share one underlying fetch/SSE connection. | (per-element) |

### REST polling

```html
<table-ui
  data-stream-src="/api/claims"
  data-stream-interval="10000"
  data-stream-target="data"
></table-ui>
```

The table fetches `/api/claims` every 10s and assigns the response to its `data` property. If the response is wrapped (e.g. `{ claims: [...] }`), add `data-stream-path="claims"` to drill in.

### SSE streaming

```html
<stat-ui
  label="Active"
  data-stream-src="/api/active-count"
  data-stream-mode="sse"
  data-stream-target="value"
></stat-ui>
```

Opens an `EventSource` against `/api/active-count`. Each SSE message's `data` field is JSON-parsed (when possible) and pushed into the stat's `value`. The connection closes on disconnect.

### Shared transport

When two or more components need the same source, give them a matching `data-stream-id` and they will share a single fetch/SSE subscription:

```html
<table-ui
  data-stream-src="/api/claims"
  data-stream-id="shared-claims"
  data-stream-target="data"
></table-ui>

<stat-ui
  label="Total"
  data-stream-src="/api/claims"
  data-stream-id="shared-claims"
  data-stream-path="length"
  data-stream-target="value"
></stat-ui>
```

One request feeds both. Each element still applies its own `data-stream-path` and `data-stream-target` independently. The shared transport is reference-counted; the last element to disconnect tears it down.

### SSR / server rendering

`data-stream-*` is **client-only**. Server-rendered markup that contains these attributes is safe — the attributes are just strings on the element — and the transport only activates once the component is upgraded in the browser. This means you can:

- Server-render the initial empty/skeleton state of a `<table-ui>` and let the client take over.
- Emit `data-stream-*` from any templating layer (Jinja, ERB, Handlebars, htmx fragments) without server-side coupling to the data-stream runtime.
- Hydrate framework apps without special-casing — React/Vue/Svelte just need to render the attribute; they do not need to subscribe.

If you need a server-rendered first paint of *real* data, render it normally (slot or attribute) — the data-stream binding will replace it on first successful payload.

### Authoritative source

The implementation lives at:

```
packages/web-components/core/data-stream.js
```

A component opts in by declaring a `data_stream:` block in its YAML descriptor (see `packages/web-components/components/*/*.yaml`). That block is the source of truth for which target properties are bindable and which defaults apply for that specific component. When in doubt, read the component's YAML and `core/data-stream.js` — this document is a discoverability summary, not a spec.

---

## Lifecycle

`UIElement` exposes four overridable lifecycle methods. Defaults are no-ops. Override what you need:

```js
import { UIElement } from '@adia-ai/web-components/core';

class MyWidget extends UIElement {
  static properties = {
    label: { type: String, default: '' },
  };

  #onKeydown = (e) => {       // stable arrow field — required for remove
    if (e.key === 'Escape') this.label = '';
  };

  connected() {
    // Called once per insertion.
    // Set up listeners on document / external targets here.
    document.addEventListener('keydown', this.#onKeydown);
  }

  render() {
    // Called every time any property signal mutates.
    // Update internal DOM here. Reading `this.foo` subscribes the host's
    // render effect to that signal.
    this.textContent = this.label || 'Empty';
  }

  updated(changed) {
    // Called after `render()`.
    // `changed` is a Map<propName, oldValue> for this tick.
    if (changed.has('label')) {
      this.dispatchEvent(new CustomEvent('label-change', {
        bubbles: true, detail: { value: this.label }
      }));
    }
  }

  disconnected() {
    // Called once per removal.
    // Clean up listeners + intervals + observers here.
    document.removeEventListener('keydown', this.#onKeydown);
  }
}

customElements.define('my-widget', MyWidget);
```

### Why the `#field` arrow pattern

`addEventListener('event', fn)` and `removeEventListener('event', fn)` only match when `fn` is the same reference. Inline arrows allocate a fresh closure every call, so the remove never fires:

```js
// ❌ Wrong — different reference each call
connected()    { document.addEventListener('click', (e) => this.foo()); }
disconnected() { document.removeEventListener('click', (e) => this.foo()); }  // doesn't remove

// ✅ Correct — stable reference
#onClick = (e) => this.foo();
connected()    { document.addEventListener('click', this.#onClick); }
disconnected() { document.removeEventListener('click', this.#onClick); }
```

The same applies to `bind()` — call it once in the constructor or use a field, not at the listener site.

### Order of operations

1. `constructor()` — `installProps()` defines reactive getters/setters from `static properties`
2. Element inserted into DOM → `connectedCallback()` fires
3. `connected()` (the overridable hook) runs in `untracked()` so reads don't subscribe outer effects
4. Host's main render effect installs: subscribes to all property signals, calls `template()` + `render()`
5. On any property change: effect re-runs, re-stamps template, calls `render()` then `updated(changed)`
6. Element removed → `disconnectedCallback()` fires → `disconnected()` (overridable)

---

## Theming, density, size

AdiaUI is parametric. Three attributes drive global appearance:

```html
<div theme="ocean" density="compact" size="sm">
  <!-- all descendants re-theme / re-densify / re-scale -->
</div>
```

| Attribute | Values | Effect |
|---|---|---|
| `[theme]` | `default`, `ocean`, `forest`, `sunset`, `lavender`, `rose`, `slate`, `midnight` | Swaps the OKLCH color ramp |
| `[density]` | `compact` (0.85×), `spacious` (1.15×) | Multiplies `--a-density` token |
| `[size]` | `sm`, `md`, `lg` | Shifts typescale + component dimensions |
| `[radius]` | `sharp` (0), `rounded` (1), `round` (2) | Multiplies `--a-radius-k` token |

Each is a CSS-variable override; no class toggles, no re-imports. Set on any ancestor → applies to that subtree.

For a control surface to let users change these at runtime, use [`<theme-panel>`](https://ui-kit.exe.xyz/site/components/theme-panel) from `@adia-ai/web-modules/theme`.

---

## Registration — auto vs explicit

### Auto-register (default)

Importing a primitive's `.js` file immediately runs `customElements.define('button-ui', UIButton)`. This is the canonical path — once imported, the tag works everywhere:

```js
import '@adia-ai/web-components/components/button';
// <button-ui> is now usable in any markup
```

The `sideEffects` field in `package.json` keeps this path tree-shake-safe.

### Conflict-safe registration (`defineIfFree`)

Multiple packages may try to register the same tag (e.g. when a host page imports the AdiaUI bundle AND a downstream consumer imports per-component subpaths). Native `customElements.define` throws `NotSupportedError` on duplicate registration. The package ships a no-op-on-duplicate helper:

```js
import { defineIfFree, isRegistered, register } from '@adia-ai/web-components/core';

class MyWidget extends HTMLElement { /* … */ }

// Registers if free; returns false (no throw) if already taken
const ok = defineIfFree('my-widget', MyWidget);

// Check before registering
if (!isRegistered('my-widget')) {
  register('my-widget', MyWidget);
}
```

When subclassing an AdiaUI primitive under a different tag name:

```js
import { UISlider } from '@adia-ai/web-components/components/slider';
import { register } from '@adia-ai/web-components/core';

class MySlider extends UISlider {
  static properties = { ...UISlider.properties, custom: { type: String } };
}
register('my-slider', MySlider);
```

The `slider-ui` import auto-registers, but that doesn't interfere — `my-slider` is a distinct tag that hosts your subclass.

### Non-side-effect class imports (since v0.4.7)

Every component ships a sibling `class` subpath that exports the class **without** running `customElements.define`. Use it when you need test isolation, tag-name override, or selective composition:

```js
// Auto-register (default; tag registered immediately):
import '@adia-ai/web-components/components/slider';

// Class import (no auto-register; you decide when to define):
import { UISlider } from '@adia-ai/web-components/components/slider/class';

// Subclass + register under a different tag
class MySlider extends UISlider {
  static properties = { ...UISlider.properties, custom: { type: String } };
}
customElements.define('my-slider', MySlider);
```

**Test isolation example** — register the class under a unique per-test tag so subclass tests don't clobber the global registration:

```js
import { UISlider } from '@adia-ai/web-components/components/slider/class';
import { defineIfFree } from '@adia-ai/web-components/core';

beforeEach(() => {
  const tag = `slider-ui-test-${Math.random().toString(36).slice(2, 8)}`;
  defineIfFree(tag, class extends UISlider { /* test overrides */ });
});
```

The class subpath is available for every component that has an `<x-ui>` tag (~95 primitives). TypeScript types are picked up from the same per-primitive `.d.ts` files used by the auto-register path — no separate `.d.ts` per subpath needed.

---

## TypeScript

Since v0.4.6, the package ships `.d.ts` declarations. v0.4.6 covered the 17 form-bearing primitives (input/select/slider/etc.) with hand-authored rich event types; v0.4.7 codegen extended coverage to the remaining 73 non-form primitives (button/card/table/chart/etc.) from each component's `.a2ui.json` sidecar. **90 of 95** primitives that ship a JS class are now typed (the 5 CSS-only "elements" — `header`, `footer`, `section`, etc. — have no class). Adding the package to your dependencies automatically picks up:

- Typed properties on every primitive (e.g. `el.value` is `number` on `<slider-ui>`, `boolean` on `<switch-ui>`)
- Typed events — `addEventListener('change', e => e.detail.value)` infers the detail shape
- `HTMLElementTagNameMap` augmentation — `document.querySelector('slider-ui')` returns `UISlider`
- Public types for `UIElement`, `UIFormElement`, signal, computed, effect, html, stamp

```ts
import type { UISlider, SliderChangeEvent } from '@adia-ai/web-components';

const slider: UISlider = document.querySelector('slider-ui')!;
slider.value = 75;                              // typed Number — string would error

slider.addEventListener('change', (e: SliderChangeEvent) => {
  console.log(e.detail.value);                  // number, inferred
});

slider.addEventListener('change', (e) => {      // also inferred
  e.detail.value.toFixed(2);                    // ok — Number.prototype is in scope
});
```

For older bundlers without `exports` field support, add to `tsconfig.json`:

```json
{
  "compilerOptions": {
    "moduleResolution": "bundler"
  }
}
```

---

## Anti-patterns

Things to avoid, with why:

### ❌ Don't bind eagerly-evaluated values to reactive props

```js
// BREAKS — value is a number, not a signal
<slider-ui .value=${signal.value * 100}></slider-ui>

// WORKS — pass the signal or a function
<slider-ui .value=${signal}></slider-ui>
<slider-ui .value=${() => signal.value * 100}></slider-ui>
```

### ❌ Don't subscribe via `attributeChangedCallback` to a primitive's attributes

The base class already handles this. Use the property setter instead:

```js
// BREAKS — fragile, duplicates the base class's mirror
const observer = new MutationObserver(...);
observer.observe(slider, { attributes: ['value'] });

// WORKS — set the property; signals propagate
slider.value = 75;
```

### ❌ Don't use shadow DOM patterns

AdiaUI is light-DOM. `::part()`, `::slotted()`, `<slot>` elements don't exist:

```js
// BREAKS — no shadow tree
slider.shadowRoot.querySelector('.thumb');

// WORKS — children are real DOM
slider.querySelector('[slot="thumb"]');
```

### ❌ Don't add inline event handlers via `onchange=`

The HTML `on*` attributes don't pick up the typed `detail`:

```html
<!-- BREAKS — fires but no typed handler signature -->
<slider-ui onchange="console.log(this.value)"></slider-ui>

<!-- WORKS — addEventListener gets the CustomEvent -->
<slider-ui id="s"></slider-ui>
<script>
  document.getElementById('s').addEventListener('change', e => console.log(e.detail.value));
</script>
```

### ❌ Don't try to register a tag name twice

`customElements.define()` throws on re-registration. The auto-register pattern handles this for you. If you import a primitive twice (e.g. via two different paths), the bundler de-dupes; if it doesn't, the first define wins and the second throws.

### ❌ Don't write `static properties` with un-typed defaults

```js
// BREAKS — undefined defaults cause attribute parsing surprises
static properties = { value: {} };

// WORKS — declare type + default
static properties = {
  value: { type: Number, default: 0, reflect: true },
};
```

### ❌ Don't read property values from inside `constructor()`

Properties are installed by `installProps()` in `super()`, but the host's render effect doesn't install until `connectedCallback()`. Reading properties in the constructor returns defaults; mutating them is fine, but if you depend on the rendered DOM existing, do it in `connected()` or later.

---

## Worked examples

Migrations from common custom-helper shapes to AdiaUI primitives. Each example pairs a real consumer's pre-AdiaUI helper with the equivalent AdiaUI declaration.

### Replace a hand-rolled color-token swatch with `<swatch-ui>` (v0.4.9+)

Pre-v0.4.9, design-token tools commonly hand-rolled a `renderSwatch()` helper composing: background color, key label, gamut badge (sRGB / P3 markers), APCA contrast badge, copy-to-clipboard button, light/dark auto-contrast label, keyboard select. v0.4.9 extends `<swatch-ui>` with all six surfaces — the helper collapses to one declarative tag.

```diff
- // Pre — 70 LOC helper
- function renderSwatch({ color, label, detail, gamutBadge, copyValue, selected, onSelect }) {
-   const el = document.createElement('div')
-   el.className = 'token-swatch'
-   el.style.backgroundColor = color
-   el.tabIndex = 0
-   el.setAttribute('role', 'button')
-   el.setAttribute('aria-pressed', String(selected))
-   if (selected) el.classList.add('selected')
-
-   const labelEl = document.createElement('span')
-   labelEl.className = 'swatch-label'
-   labelEl.textContent = label
-   labelEl.style.color = isLight(color) ? 'var(--fg-dark)' : 'var(--fg-light)'
-   el.appendChild(labelEl)
-
-   const detailEl = document.createElement('span')
-   detailEl.className = 'swatch-detail'
-   detailEl.textContent = detail
-   el.appendChild(detailEl)
-
-   if (gamutBadge) {
-     const badge = document.createElement('span')
-     badge.className = 'swatch-badge'
-     badge.textContent = gamutBadge === 'out-of-gamut' ? '△' : '✦'
-     badge.setAttribute('aria-label', gamutBadge)
-     el.appendChild(badge)
-   }
-
-   const copyBtn = document.createElement('button')
-   copyBtn.className = 'swatch-copy'
-   copyBtn.textContent = '⧉'
-   copyBtn.setAttribute('aria-label', `Copy ${copyValue}`)
-   copyBtn.addEventListener('click', async (e) => {
-     e.stopPropagation()
-     try { await navigator.clipboard.writeText(copyValue); copyBtn.textContent = '✓' }
-     catch { copyBtn.textContent = '⚠' }
-     setTimeout(() => { copyBtn.textContent = '⧉' }, 1200)
-   })
-   el.appendChild(copyBtn)
-
-   el.addEventListener('click', (e) => {
-     if (e.target === copyBtn) return
-     onSelect({ color, label })
-   })
-   el.addEventListener('keydown', (e) => {
-     if (e.key !== 'Enter' && e.key !== ' ') return
-     if (e.target === copyBtn) return
-     e.preventDefault()
-     onSelect({ color, label })
-   })
-
-   return el
- }

+ // Post — one declarative tag
+ html`<swatch-ui
+   shape="block" size="lg"
+   color=${color}
+   label=${label}
+   detail=${detail}
+   badge=${gamutBadge}
+   copyable
+   copy-value=${copyValue}
+   selectable
+   ?selected=${selected}
+   auto-contrast
+   @select=${(e) => onSelect({ color: e.detail.color, label: e.detail.label })}
+ ></swatch-ui>`
```

The primitive owns: badge symbol + ARIA mapping, copy-clipboard state machine (⧉ → ✓ / ⚠ → ⧉ after 1.2s), `role="button"` + `tabindex="0"` only when `[selectable]`, focus ring on `:focus-visible` + always-on when `[selected]`, click-on-copy-doesn't-toggle-selection guard, label auto-contrast via OKLCH luminance (when `[auto-contrast]` is set).

The `select` event detail is `{ value, color, label }` — `value` is `[color]` (or `[label]` as fallback) so consumers binding to a value-shaped select handler get the canonical accessor uniformly across form-bearing primitives.

For multiple simultaneous badges (e.g. both `out-of-gamut` and `apca-fail`), pass a comma-separated list: `badge="out-of-gamut, apca-fail"` — v0.4.9 renders both stacked in the upper-right.

### Guard global keyboard shortcuts with `isFocusInInput()` (v0.4.8+)

```diff
- // Pre — manual cast boilerplate at every shortcut site
- document.addEventListener('keydown', (e) => {
-   const active = document.activeElement as HTMLElement | null
-   if (active && (
-     active.tagName === 'INPUT' ||
-     active.tagName === 'TEXTAREA' ||
-     active.isContentEditable
-   )) return
-   if (e.key === '/') openCommandPalette()
- })

+ // Post — one import + one call
+ import { isFocusInInput } from '@adia-ai/web-modules/editor'
+
+ document.addEventListener('keydown', (e) => {
+   if (isFocusInInput()) return
+   if (e.key === '/') openCommandPalette()
+ })
```

`isFocusInInput()` also recognizes AdiaUI text-input primitives (`<input-ui>`, `<textarea-ui>`, `<otp-input-ui>`, `<search-ui>`) by host tag-name — important because focus may land on the custom-element host before the inner native input mounts.

### Use derived reactive bindings for unit-conversion (v0.4.6+)

```diff
- // Pre — eager evaluation; slider doesn't move on undo
- <slider-ui .value=${minL.value * 100} min="0" max="100" step="1"></slider-ui>

+ // Post — function-as-value gets effect-wrapped
+ <slider-ui .value=${() => minL.value * 100} min="0" max="100" step="1"></slider-ui>
```

The function re-runs on every dependency mutation. Same pattern works for derived computations (e.g. `() => clamp(minL.value * 100, 0, 100)`) and signal-array transforms.

### Persist `<editor-sidebar>` width across reloads (v0.4.9+)

```diff
- <editor-sidebar collapsible name="dts-layout">
+ <editor-sidebar collapsible persist="dts-layout">
```

`[persist]` is the preferred name in v0.4.9+. `[name]` keeps working (back-compat alias; deprecated, not scheduled for v0.5.x removal). Storage namespace + `sidebar-toggle` event detail are unchanged.

---

## v0.5.9 — Consumer-feedback discoverability sweep

This section collects 11 docs items (§221a–§221k) that emerged from rounds R9, R10, R13, R15, R16, and R20. They document existing behavior that consumers reasonably misread — no behavior changes, only docs gaps closed.

### §221a — Universal `[size]` prop family

Every interactive primitive accepts `[size]` with `xs | sm | md | lg | xl` (some additionally accept `2xs` / `2xl`). The token wiring is in `themes.css`:

```css
[size="xs"]  { --a-size: 24px; }   /* compact toolbar / inline densities */
[size="sm"]  { --a-size: 28px; }
[size="md"]  { --a-size: 32px; }   /* canonical default */
[size="lg"]  { --a-size: 40px; }
[size="xl"]  { --a-size: 48px; }   /* hero CTAs, top-level brand */
```

Authoring: `<button-ui size="lg" text="Continue">`. The element inherits `--a-size` from the closest `[size]` ancestor when the prop is unset, so a `<row-ui size="sm">` containing 5 controls makes them all small without per-element repeat.

When in doubt about whether a primitive supports `[size]`: every form-bearing primitive does (input / textarea / select / switch / check / radio / button / range / slider / search). Display primitives that participate in size cascades also do (avatar / badge / tag / icon / chip / card).

### §221b — `<input-ui>` migration from native `<input>`

Common gotchas during native-`<input>` → `<input-ui>` migration:

| Native pattern | `<input-ui>` equivalent |
|---|---|
| `<input type="text">` | `<input-ui>` (no `type` needed) |
| `<input type="email" required>` | `<input-ui type="email" required>` |
| `<input type="password">` | `<input-ui type="password">` (wraps a native `<input>` for `-webkit-text-security` disc masking — only path that still uses a native input) |
| `<input type="number" min="0" max="100" step="0.1">` | `<input-ui type="number" min="0" max="100" step="0.1">` (renders contenteditable + stepper buttons; no native `<input type="number">` underneath) |
| `<input placeholder="…" value="…">` | `<input-ui placeholder="…" value="…">` |
| `<input oninput="…">` | `el.addEventListener('input', e => e.detail.value)` (CustomEvent payload, not native InputEvent) |
| Native form serialization | Works — ElementInternals form participation via `name=` |
| `el.value` reads/writes | Same; programmatic `.value = "…"` is round-tripped |

The biggest authoring shift: events carry their payload in `e.detail`, not on the host element. Read `e.detail.value`, not `e.target.value`. (Same applies to every form-bearing primitive.)

### §221c — `<switch-ui>` vs `<check-ui>` vs `<segmented-ui>` — the "named binary state" trap

Three primitives can render "is X on or off?" but they signal different semantics:

- **`<switch-ui>`** — for **mutable system state** the user toggles immediately. Use for "Dark mode", "Notifications", "Show grid". Action commits on flip; no submit needed.
- **`<check-ui>`** — for **decisions the user is committing to a form**. Use for "I agree to the terms", "Subscribe me to updates", multi-select lists.
- **`<segmented-ui>`** — for **explicit choice between named options** (two or more). Use for "Light / Dark / System", "Day / Week / Month". Even with two options, segmented signals "I'm picking which one" rather than "I'm flipping a switch".

The trap: authoring a `<switch-ui>` for "Confidentiality: Public / Private" reads as "click to toggle confidentiality" — but a `<segmented-ui options="Public,Private">` better signals an explicit named choice.

### §221d — `<select-ui>` native `<option>` children

`<select-ui>` consumes native HTML `<option>` and `<optgroup>`, NOT a sibling custom element. Authoring:

```html
<select-ui name="theme" value="dark">
  <option value="light">Light</option>
  <option value="dark">Dark</option>
  <option value="system">System</option>
</select-ui>
```

Or programmatically:

```js
el.options = [
  { value: 'light',  label: 'Light'  },
  { value: 'dark',   label: 'Dark'   },
  { value: 'system', label: 'System' },
];
```

§225 (v0.5.9) added a once-per-element `console.warn` when unrecognized child elements appear (e.g. `<select-option>`) — they're silently ignored, so the warn surfaces the authoring drift.

### §221e — `<accordion-item-ui>` action buttons in custom headers

When a custom `[slot="header"]` carries action buttons (Copy / Download / Edit), the click would normally bubble to the header's toggle handler and open/close the panel. Mark action elements `[data-no-toggle]` or place them at `[slot="action"]` (or `[slot="actions"]`):

```html
<accordion-item-ui>
  <div slot="header">
    <span>Section title</span>
    <button-ui slot="action" icon="copy" text="" data-no-toggle></button-ui>
  </div>
  <div>Body content</div>
</accordion-item-ui>
```

§206 (v0.5.7) added the `[slot="action"]` / `[slot="actions"]` / `[data-no-toggle]` exclusion list to the toggle-cascade.

### §221f — `<color-picker-ui>` inline pattern

For a compact swatch-and-popover authoring shape:

```html
<popover-ui trigger="click" placement="bottom-start">
  <button-ui slot="trigger" text="" icon="palette" aria-label="Pick a color"></button-ui>
  <color-picker-ui slot="content" value="oklch(0.7 0.15 220)" alpha="off"></color-picker-ui>
</popover-ui>
```

The popover handles focus management + click-outside dismiss; the button-ui exposes the right ARIA semantics. `<color-picker-ui>` fires `change` events that bubble through the popover — listen on the popover host.

### §221g — `[data-scheme]` ancestor opt-in for color scheme

The light/dark color scheme follows `[data-scheme="light"]` or `[data-scheme="dark"]` on any ancestor, falling back to `prefers-color-scheme`. Common patterns:

```html
<!-- Whole-app explicit scheme (e.g. user preference saved) -->
<html data-scheme="dark"> … </html>

<!-- Per-section override (e.g. dark hero on light page) -->
<section data-scheme="dark"> … </section>

<!-- Dynamic toggle via <toggle-scheme-ui> writes to <html data-scheme> -->
<toggle-scheme-ui persist></toggle-scheme-ui>
```

The `themes.css` token cascade reads `[data-scheme]` first, then `@media (prefers-color-scheme: dark)`, then defaults. Setting `[data-scheme]` empty (`data-scheme=""`) is **not** the same as removing the attribute — empty resolves to no-match and falls through to the media query, while a missing attribute does the same. Don't bother setting `data-scheme=""` — just remove the attribute.

### §221h — Effect-dispatch timing (signals)

`effect(fn)` runs `fn` synchronously on creation, then re-runs on every signal change. Re-runs are scheduled via `queueMicrotask` to batch multiple signal mutations in the same tick into one effect run. This means:

```js
const count = signal(0);
const log = signal('');

effect(() => log.value = `Count is ${count.value}`);

count.value = 1;       // schedules microtask
count.value = 2;       // microtask is already scheduled, no re-schedule
count.value = 3;
// log.value is still '' here (effect hasn't run yet)

await new Promise(r => queueMicrotask(r));
// log.value is now 'Count is 3' (effect ran once for the batch)
```

For test isolation where you need the effect to flush before assertions, await **two** queueMicrotasks — one for the effect to run, one for any downstream effects it triggers:

```js
const tick = () => new Promise(r => queueMicrotask(r));
count.value = 5;
await tick();   // first microtask — effect runs, may schedule another
await tick();   // second microtask — any downstream effects flush
expect(log.value).toBe('Count is 5');
```

The double-`queueMicrotask` pattern is what `core/element.js`'s render scheduler uses internally — same semantics propagate to consumer effects.

### §221i — Backticks inside HTML comments inside `html\`…\`` templates

JS-spec footgun: the first backtick inside an HTML comment **closes** the outer template literal. The TS / parser error reads as "Unterminated template literal" or "TS1005" at the backtick column:

```js
// ❌ TS1005 at the backtick column
const tpl = html`
  <div>
    <!-- Use `data-scheme="dark"` for dark mode -->
  </div>
`;

// ✅ Use single-quoted strings or HTML entities inside HTML comments
const tpl = html`
  <div>
    <!-- Use [data-scheme="dark"] for dark mode -->
  </div>
`;
```

Same rule applies to nested `<style>` and `<script>` blocks inside `html\`...\`` — anything that contains a literal backtick must be escaped (`\\\``) or rephrased.

### §250 — Template parser — invariants + unsupported syntaxes (FEEDBACK-27)

`html\`\`` is intentionally smaller than `lit-html`. The parser recognizes exactly four placeholder shapes; anything else fails (some loudly, some silently — §250 graduates every documented failure to load-time `console.warn`).

| Syntax | Behavior | Notes |
|---|---|---|
| `attr=${val}` (full attribute interpolation) | ✅ Supported. Writes the stringified value to the attribute. | The whole attribute value must be the placeholder — `attr="prefix-${val}"` is **NOT** supported (silent garbage pre-v0.5.3; load-time warn v0.5.3 §152+). |
| `.prop=${val}` (property binding) | ✅ Supported. Assigns to the JS property, NOT the DOM attribute. | This is the canonical pattern for boolean state + object values. Custom elements reflect declared `static properties` to DOM attributes automatically — set `.disabled = true` and the host reflects `[disabled]`. |
| `@event=${handler}` (event listener) | ✅ Supported. Calls `addEventListener(event, handler)`. | `handler` can be a function or a signal-wrapped function. |
| `?attr=${bool}` (Lit-style boolean attribute) | ❌ **NOT supported (load-time `console.warn` v0.5.11 §250).** | Pre-§250: silently registered a literal `?attr` attribute (browser ignored it). v0.5.11 §250 emits a `console.warn` at scan time + degrades to no-op. Use `.attr=${bool}` instead. |

**Migration for `?attr=${bool}` patterns:**

```js
// ❌ Lit-style boolean attribute — silently inert pre-v0.5.11; warns + no-ops post-§250
html`<button-ui ?disabled=${isLoading}>Save</button-ui>`;

// ✅ Property binding — the primitive reflects the property to [disabled] for you
html`<button-ui .disabled=${isLoading}>Save</button-ui>`;
```

Why AdiaUI doesn't implement `?attr=`: custom elements declare their reflective property/attribute mapping via `static properties = { disabled: { type: Boolean, reflect: true } }`. The runtime handles the property-to-attribute reflection automatically. Adding a `?attr=` shape would create two valid syntaxes for the same effect — discoverability tax without behavioral benefit.

**Other documented parser invariants** (closed in earlier cycles):

- HTML comments containing apostrophes (`<!-- foo's bar -->`) — fixed at parser level in v0.5.3 §155. Native; no consumer action needed.
- HTML comments containing quoted attributes (`<!-- attr="value" -->`) — same fix (v0.5.3 §155).
- Backticks inside HTML comments — see §221i above (JS-spec footgun; not a parser bug).

### §-TBD (v0.6.8) — Host element display defaults (FB-56)

Per the HTML spec, **all custom elements default to `display: inline`** — and `UIElement` subclasses are no exception. When you use a `UIElement` subclass as a layout container (filling a flex parent, holding an SVG canvas, wrapping a fluid grid), the inline default collapses the host to zero height and any `height: 100%` descendant collapses with it.

**Symptom you're hitting this trap:** content renders in DevTools with the expected DOM, but its computed height is 0 because the custom-element host between it and a layout parent has `display: inline`. Inspect the host element directly — `getComputedStyle(host).display === 'inline'` confirms.

If you author a `UIElement` to be a layout container, set an explicit `display` on the host tag — the in-tree convention is `:host(:where(*))` so consumer-app CSS can still override without `!important`:

```js
import { css } from '@adia-ai/web-components/core/element';

class MyContainer extends UIElement {
  static styles = css`
    :host(:where(*)) {       /* zero-specificity wrap; consumers override at (0,0,1) */
      display: flex;
      flex-direction: column;
      flex: 1 1 auto;
      min-height: 0;
    }
  `;
  static template = (host) => html`<slot></slot>`;
}
```

Or, if the host should be transparent to layout (its children inherit the parent's grid/flex context directly):

```css
my-passthrough-element { display: contents; }
```

See also: the slot-routing pass-through pattern that's the natural consumer of `display: contents` (cross-referenced from §345's `.map()` vs `repeat()` discussion where `wrap()` spans use the same mechanism).

**`UIElement` does not bake a `display` rule into the host** because container vs leaf vs passthrough is a consumer-layout decision, not a framework one. Per primitive, a global default would risk breaking layouts where the consumer explicitly wants `inline` semantics (e.g. inline badge inside flowing text, `<icon>` rendered alongside surrounding `<text-ui>`, `<kbd-ui>` keystroke marker). The in-tree primitive set spans `inline` / `inline-block` / `block` / `flex` / `grid` / `contents` — no single default fits.

**In-tree convention reference:** scan `packages/web-components/components/*/<name>.css` for the `:host(:where(*))` pattern. The 30+ flex-container primitives (`<editor-shell>`, `<nav>`, `<pane>`, `<page>`, `<card>`, etc.) all use this shape; copy from the closest-fit primitive.

### §-TBD (v0.6.8) — SVG composition + namespacing (FB-57)

`html\`\`` handles SVG content in three modes, with one historical trap closed in v0.6.8.

**Mode 1 — inline SVG in a single template (always worked):**

```js
html`
  <svg viewBox="0 0 100 100">
    <circle cx="50" cy="50" r="10"/>
    <path d="M 0 0 L 100 100"/>
  </svg>
`
```

When the parser sees `<svg>` in `tpl.innerHTML`, it enters SVG insertion mode for its descendants. Children arrive as proper `SVGElement` instances.

**Mode 2 — nested template interpolations (now works, post-v0.6.8 FB-57 fix):**

```js
html`
  <svg viewBox="0 0 100 100">
    ${dots.map(d => html`<circle cx="${d.x}" cy="${d.y}" r="3"/>`)}
  </svg>
`
```

Each nested `html\`<circle/>\`` is a separate template parsed at document level (no SVG context), so pre-v0.6.8 the circles arrived as `HTMLUnknownElement` — present in the DOM but invisible to SVG layout (zero bounding rect, no stroke, no fill, no error). v0.6.8 `mount()` detects when the container is inside an SVG (or MathML) subtree and re-namespaces the cloned fragment via `createElementNS`. The natural composition pattern works.

**Mode 3 — imperative `.innerHTML` commit (escape hatch for very large SVG):**

For SVG content too large to template cleanly (thousands of nodes, dynamic shape strings):

```js
class MyChart extends UIElement {
  static template = (host) => html`<svg viewBox="0 0 ${host.w} ${host.h}"></svg>`;
  updated() {
    const svg = this.querySelector('svg');
    svg.innerHTML = host._buildSvgString();  // SVG-namespaced via SVGSVGElement.innerHTML
  }
}
```

`.innerHTML=${str}` as a template binding also works for this (the property name is already lowercase, so the FB-55 PROP_CASE_FIX entry just maps `innerhtml` → `innerHTML`, and the assignment routes through the SVGSVGElement's `innerHTML` setter which DOES enter SVG insertion mode).

**`<foreignObject>` boundary:** content inside `<foreignObject>` returns to HTML namespace per HTML5 §12.2.5 foreign-content insertion mode. The template engine respects this — interpolations inside `<foreignObject>` are HTML-namespaced even when the outer container is SVG. Use it as the documented escape hatch for HTML widgets embedded in SVG graphs (tooltips, popovers, foreign Cells in dataviz).

**MathML** uses the same mechanism: nested `${html\`<mi/>\`}` inside `<math>` gets re-namespaced to MathML NS. `<annotation-xml encoding="text/html">` is the MathML equivalent of `<foreignObject>` for HTML re-entry.

**Migration:** zero. Pre-v0.6.8 code that already worked (inline SVG + `.innerHTML` commits) keeps working. Code that was silently broken (nested SVG interpolations) starts producing visible output.

### §-TBD (v0.6.8) — `.camelCaseProp=${expr}` property binding (FB-55)

Pre-v0.6.8 trap: the HTML parser lowercases attribute names inside `<template>.innerHTML` per HTML5 §13.2.5.32, so `.className=${expr}` arrived at the binding scanner as `.classname`. The binding then wrote `element['classname'] = v` — a lowercase enumerable **expando** — never invoking the `HTMLElement.prototype.className` setter. CSS classes never applied; no warning, no error.

Same trap affected every camelCase DOM property surface: `.className`, `.innerText`, `.tabIndex`, `.ariaLabel`, `.contentEditable`, `.readOnly`, `.maxLength`, `.minLength`, `.colSpan`, `.rowSpan` — AND every camelCase property declared via `UIElement.static properties` (e.g. `<color-picker>`'s `.maxChroma`, `<grid>`'s `.columnGap`, `<breadcrumb>`'s `.collapseKeepLeading`, plus 80+ others across primitives). Even the canonical reactive-binding example from `README.md` (`<my-panel .minL=${minL}>`) silently failed.

v0.6.8 fix: a two-layer name resolution in `core/template.js`:

1. **`PROP_CASE_FIX` static map** at scan time covers built-in DOM camelCase properties (zero-cost lookup, no prototype walk for the common case).
2. **Prototype-walk fallback** at `applyValue()` time finds canonical camelCase property names on the element's instance + prototype chain via case-insensitive match — caches the resolution on the part after first hit. Catches UIElement custom-element props installed per-instance in `installProps()`.

```js
// ✅ Works post-v0.6.8 — both forms route to the canonical setter
html`<span .className=${'foo bar'}>text</span>`;
html`<color-picker .maxChroma=${0.3}></color-picker>`;
html`<my-panel .minL=${minL}></my-panel>`;
```

**Backward compatibility:** when neither the map nor the prototype walk finds a case-insensitive match, the original lowercase name is preserved. Consumers deliberately writing to lowercase expando properties (`.myexpando=${v}`) are unaffected.

**Discoverability note:** the simpler form `class="${expr}"` (full-attribute interpolation) works without needing to know the camelCase property name and is the v0.6.8 partial-interp warn-text's first recommendation. Reach for `.className=` when you specifically want the property-binding semantics (e.g., when the value carries non-string types, or for performance in tight inner loops).

### §345 (v0.5.19) — `.map()` vs `repeat()` for reactive lists (FB-47)

Use **`repeat(items, keyFn, tplFn)`** for any list whose items are signal-driven and should preserve identity across re-renders. Plain `.map()` returns an `Array` of template results, which the template engine materializes via `container.replaceChildren()` on every parent update — correct, but per-item DOM is re-created from scratch.

```js
import { html, repeat } from '@adia-ai/web-components/core/element';

// ❌ .map() — every re-render destroys and re-creates each wrapper-span
html`
  <div class="row">
    ${stops.map(stop => renderSwatch(stop))}
  </div>
`;

// ✅ repeat() — wrapper-spans + child custom elements preserved across re-renders
html`
  <div class="row">
    ${repeat(stops, stop => stop.key, stop => renderSwatch(stop))}
  </div>
`;
```

**When `repeat()` matters:**

- Lists of custom elements with internal state (`<swatch-ui>` with chrome slots, `<chat-thread>` with scroll position, `<select-ui>` with focus).
- Lists where re-creation causes flicker (animations restart, transitions interrupt, IntersectionObservers re-fire).
- Long lists (≥10 items) where per-item DOM allocation costs add up under signal-driven update churn.

**Mechanism:** `repeat()` keys each item by `keyFn(item)` and stores wrapper-spans in a key-indexed map on the container. On re-render with the same key, the wrapper is reused; `stamp()` falls through to its same-strings cache hit and only `update()`s the parts in-place. With `.map()`, there is no key channel — the runtime cannot tell which result corresponds to which prior item, so it conservatively rebuilds every wrapper.

**Key selection:** pick a stable consumer-supplied identifier (database id, slot.key, etc.), NOT array index. Array indices are unstable under insert/remove and defeat the keyed-reuse contract.

**Sizing heuristic (FB-53 #3):** `.map()` is fine for static lists of ≤10 items that don't re-render or only re-render at navigation boundaries. `repeat()` adds per-render bookkeeping (Map lookups, key comparison, DOM reuse) that pays for itself in two cases: (a) lists of ≥20 items that re-render on every signal change (e.g. a palette grid with 66 swatches updating on every slider drag), or (b) any-size list whose items host custom elements with internal state (chrome slots, focus position, scroll offset). Don't migrate every `.map()` reflexively — migrate when one of those two thresholds applies.

### §349 (v0.5.19) — Event handling inside reactive lists (FB-49)

Custom-element children rendered inside `repeat()` (or as an `${array}` interpolation) are each wrapped in a `<span style="display:contents">` by the template engine. These wrapper spans generate no boxes (`display:contents`) and cannot be click targets, but they ARE in the DOM tree.

For most consumer event handlers, this is transparent. `Element.closest()` walks the **DOM tree** (not the box tree), so `display:contents` ancestors are walked through normally:

```js
// ✅ Bubble-phase listener on or inside the list — closest() works fine
tree.addEventListener('tree-select', (e) => {
  const item = e.detail.item;  // tree-ui already does the right walk for you
});

// ✅ Bubble-phase listener on a parent — closest() walks past display:contents wrappers
host.addEventListener('click', (e) => {
  const item = e.target.closest('tree-item-ui');
  if (item && host.contains(item)) { /* … */ }
});
```

Reach for `e.composedPath()` only when one of these specific cases applies:

1. **Cross-subtree listeners.** If the listener's host is in a different subtree than the items (capture-phase on an ancestor far above), `closest()` walks UP from `e.target` but doesn't tell you which descendant of the listener's host was clicked. `composedPath()` returns the full leaf-to-document chain — filter that for the descendant tag you want.
2. **Crossing shadow-DOM boundaries.** If `e.target` is retargeted to a shadow host (per the shadow DOM spec), `closest()` walks the light-DOM ancestor chain from that host. `composedPath()` exposes the actual leaf inside the shadow root.
3. **Click on whitespace between list items.** Text nodes sometimes retarget to their parent element. If the parent is a `display:contents` wrapper-span, `closest('your-tag')` from that wrapper returns null (the wrapper's siblings include your tag, but not its descendants). `composedPath()` gives sibling context to walk.

```js
// ✅ For capture-phase / cross-subtree / shadow-DOM cases
host.addEventListener('click', (e) => {
  const item = e.composedPath().find(
    (el) => el?.tagName?.toLowerCase() === 'tree-item-ui' && host.contains(el)
  );
}, { capture: true });
```

**Don't reach for `composedPath()` reflexively** — it rebuilds the full path on every call and is slower than `closest()`. Default to `closest()` for direct bubble-phase handlers; use `composedPath()` only when one of the three cases above applies.

**CSS child-combinator caveat (FB-53 #2):** `display:contents` wrapper-spans ARE in the DOM tree even though they generate no boxes. The CSS child combinator (`>`) walks the DOM tree (not the box tree), so consumer rules of the shape `.parent > .my-item` **will not match** items inside a `repeat()` because the wrapper-span sits between `.parent` and the items:

```html
<!-- Actual DOM produced by repeat(): -->
<div class="parent">
  <span style="display:contents">      <!-- ← repeat() per-item wrapper -->
    <my-item class="my-item">…</my-item>
  </span>
</div>
```

Use the descendant combinator (a single space) instead, which traverses any depth:

```css
/* ❌ Won't match — wrapper-span sits between .parent and .my-item */
.parent > .my-item { … }

/* ✅ Descendant combinator pierces through the wrapper-spans */
.parent .my-item { … }

/* ✅ Or use :scope/has() if you need precision: */
.parent > :is(.my-item, span > .my-item) { … }
```

Same caveat applies to `:nth-child()` / `:nth-of-type()` / `:first-child` / `:last-child` against the parent — those count actual children (the wrapper-spans), not the items inside. If you need nth-item styling, key it via a data attribute on the item itself rather than relying on positional pseudo-classes against the wrapper layer.

### §-TBD (v0.6.8) — UIElement lifecycle hooks (FB-59 + FB-43/44/47/58 cluster)

UIElement orchestrates the custom-element lifecycle and exposes two extension points: the W3C standard `connectedCallback()` (rarely overridden) and the convenience **`connected()` hook (the recommended subclass extension point)**. Same pattern for `disconnected()`.

#### Canonical hook order

For a UIElement subclass with `static template = () => html\`<x/>\`` mounted into the DOM with consumer-supplied light-DOM children:

1. **`adoptStyles(ctor)`** — adopted stylesheets attached to the document
2. **`prepareParts(ctor)`** — `static template` markup parsed into the part registry
3. **`this.connected()`** — subclass hook fires here, inside `untracked(...)`. **Light-DOM children supplied by the parent template are intact at this point.**
4. **Effect first-run** — `stamp(result, this)` runs here if `ctor.template(this)` returns non-null. This is the call that **wipes consumer-supplied light-DOM children** (via `mount()` → `container.replaceChildren(f)`).
5. **`this.render()`** — explicit render hook for imperative DOM updates
6. **`this.updated(changedProps)`** — change-set hook for property-specific reactions

**The key insight for container components: `connected()` runs BEFORE the stamp.** If your container UIElement has both a non-null `static template` AND wants to capture consumer-supplied light-DOM children, do the capture in `connected()`. The wipe runs in step 4, which is after step 3.

Source: `packages/web-components/core/element.js:107-142` — `UIElement.connectedCallback()`. `connected()` fires at line 122 inside `untracked(...)`; the stamp runs at line 127 inside the effect registered at lines 123-137.

#### Container UIElement pattern (FB-43/44/47/58 cluster — the right shape)

When your UIElement is a container for consumer-supplied light-DOM children AND has its own internal `static template`, capture the children in `connected()`:

```js
class DtsGraph extends UIElement {
  static template = () => html`<div class="dts-graph-root"></div>`;

  // ✅ connected() runs BEFORE the stamp wipes children — capture here
  connected() {
    this._series = [...this.querySelectorAll('dts-graph-band, dts-graph-curve, …')];

    // Optional: MutationObserver for late-arriving children (e.g. parent template
    // toggles `${cond ? html\`<band/>\` : ''}` and the new band lands later)
    this._mo = new MutationObserver(() => this._captureSeries());
    this._mo.observe(this, { childList: true, subtree: true });
  }

  _captureSeries() {
    const present = [...this.querySelectorAll('dts-graph-band, …')];
    // Defensive cache-fallback: post-stamp wipe makes `present` empty, but the
    // initial capture from connected() above already saved the refs. Keep them.
    if (present.length > 0) this._series = present;
  }

  disconnected() {
    this._mo?.disconnect();
  }
}
```

The `if (present.length > 0) keep` cache-fallback is the architectural pattern that makes the capture robust to either ordering. It works in any lifecycle environment with no specification dependency.

#### `connected()` vs `connectedCallback()` reference

| Hook | Who calls it | When (relative to step list above) | Use case |
|---|---|---|---|
| `connectedCallback()` | The browser, per W3C custom-elements spec | Orchestrates the whole sequence | Almost never override — UIElement runs the lifecycle for you |
| **`connected()`** | UIElement, from within `connectedCallback()` at line 122 | Step 3 — after `prepareParts()`, BEFORE the template stamp | **Subclass setup, light-DOM capture, observer arming, signal effects** |
| `render()` | The effect, after every signal change | Step 5 — after `stamp()` if the template returned non-null | Imperative DOM updates that don't fit the template engine |
| `updated(changedProps)` | The effect | Step 6 — after `render()`, when properties changed | React to specific prop changes |
| `disconnected()` | UIElement, from `disconnectedCallback()` | Element leaves the DOM | Teardown observers, timers, listeners |

#### When NOT to override `connectedCallback()` directly

`UIElement.connectedCallback()` orchestrates the entire lifecycle. Overriding it directly requires re-invoking `super.connectedCallback()` correctly, and you'd have to choose whether your work goes before or after super:

- **BEFORE super**: you miss the `prepareParts(ctor)` call that initializes the part registry. `static template` won't stamp. Other downstream initialization breaks.
- **AFTER super**: your work runs after step 6 (`updated()`), which means after the stamp wiped your light-DOM children. You'd need to capture in `constructor()` instead — but `constructor()` runs before the element is in the DOM, so light-DOM children aren't appended yet.

`connected()` is the right hook for 99% of consumer code. It runs at exactly the right time: children present, stamp not yet run.

#### Why `connected()` is wrapped in `untracked()`

Per the comment in `element.js:112-121`: `connected()` commonly reads reactive properties via template-literal interpolation (`${this.label}` etc.) when stamping inner DOM. If the outer call path is inside another effect (e.g. a parent's render loop `appendChild`'ing this node), those reads would subscribe the outer effect to this element's signals — invisible cross-element coupling that looks like spooky re-runs on unrelated state changes. The `untracked()` wrapper prevents that subscription leak. The element's own effect (registered at lines 123-137) re-reads the same signals in its own tracking context, so reactivity within the element is unaffected.

This means: inside `connected()`, you CAN read signals/props for setup logic, but those reads won't establish reactive subscriptions. Use the element's own `render()` or signal effects for reactive work; use `connected()` for one-shot setup.

### §221j — Typography token cheatsheet

Quick-reference for component-CSS authoring. Cross-reference [`styles/typography.css`](./styles/typography.css):

| Layer | Token family | Examples | When to use |
|---|---|---|---|
| L0 — raw sizes | `--a-ui-{tiny,xs,sm,md,lg}` | `10px / 12px / 13px / 14px / 15px` | Inline UI text in component CSS (badge labels, kbd text) |
| L0 — raw weights | `--a-weight-{normal,medium,semibold,bold,extrabold}` | `400 / 500 / 600 / 700 / 800` | font-weight in component CSS |
| L0 — body roles | `--a-{body,heading,title,display,section,subsection,caption,label,kicker,deck,metric,code}-{family,weight,size,leading,tracking,case,color}` | `--a-heading-weight` | Inside `<text-ui variant="heading">` styles |
| L1 — UI scale | `--a-ui-{size,leading,line-height,weight,family,tracking,case,color,px,py}` | `--a-ui-size` (= 14px default) | Buttons / inputs / labels / control-row densities |
| L1 — focus / ring | `--a-focus-ring{,-invalid}` | `0 0 0 2px var(--a-accent)` | `:focus-visible` shadow recipes |

Common misreads:

- `--a-font-semibold` → **doesn't exist**. Use `--a-weight-semibold`.
- `--a-text-sm` → **doesn't exist**. Use `--a-label-size` (semantic) or `--a-ui-sm` (raw size).
- `--a-font-weight-normal` → **doesn't exist (removed in v0.5.0)**. Use `--a-weight-normal`.

§231 (v0.5.8) graduated a mechanical audit (`scripts/release/check-component-css-vs-styles-tokens.mjs`) that catches references to undefined `--a-…` tokens at release time.

### §221k — `<text-ui variant>` chooser guide

The 12 canonical variants ship with distinct typographic roles. Pick by intent, not by visual size:

| Variant | Role | Looks like | Choose when |
|---|---|---|---|
| `display` | Top-level brand / hero | Tallest possible | Page-level hero one-liner |
| `title` | Section landmark | Large H1-ish | Top of an authoritative page / dialog |
| `heading` | Sub-landmark | H2-ish | Major sub-section divider |
| `subsection` | Lesser landmark | H3-ish | Card title within a section |
| `section` | Inline section | Smaller heading | Form group label, navlist heading |
| `body` (default) | Running prose | Body copy | Paragraph text, descriptions |
| `caption` | Annotation | Slightly smaller, muted | Image captions, footnotes |
| `label` | Form control label | UI-sized, medium-weight | Field labels above inputs |
| `kicker` | Eyebrow text above a title | UPPERCASE, small | Optional micro-heading above a `title` |
| `deck` | Sub-title under a title | Slightly larger than body | One-line lead after a `title` |
| `metric` | Numeric KPI | Large bold figure | Big-number stats |
| `code` | Inline code | Monospace | Inline code references |

Two heuristics consumers ask about repeatedly:

1. **"Should I use `kicker` or `label`?"** — `kicker` for content (eyebrow above a section title); `label` for form-control bindings (above an `<input-ui>` / `<select-ui>` etc.).
2. **"`subsection` or `section`?"** — `subsection` is for visible-rank H3-ish headings; `section` is for inline form-group labels or small-cap navlist headings. Visual rank: `display > title > heading > subsection > section`.

§210 (v0.5.7) closed the variant-vs-CSS-rule drift; §232 (v0.5.9) graduates a mechanical audit that catches future enum-vs-rule drift across all primitives with enum props.

**§247 (v0.5.10) ARIA-role clarification (FB-23 §2):** `<text-ui variant="heading">` (and `title` / `display` / `subsection` / `section`) is **presentational-only** — it does NOT set `role="heading"` + `aria-level` on the host. Document-outline assistive technology will treat the element as `role="generic"`. For **semantic** headings (screen-reader heading-navigation, document outline), one of:

```html
<!-- ✅ Native semantic — wrap with <h1>-<h6> -->
<h2><text-ui variant="heading">Section title</text-ui></h2>

<!-- ✅ ARIA-only — add role + aria-level to the host directly -->
<text-ui variant="heading" role="heading" aria-level="2">Section title</text-ui>

<!-- ⚠️ Presentational-only — fine for visual eyebrows / kickers / captions; do NOT use for document-outline-significant headings -->
<text-ui variant="kicker">Eyebrow text</text-ui>
```

The `<text-ui>` API stayed presentational-only by design — the typography role tokens are decoupled from ARIA. v0.6.0 may add a `level=` (or `as=`) attribute to opt-in to semantic-heading rendering; v0.5.x consumers wire ARIA on the host explicitly.

---

## §247 (v0.5.10) — `<swatch-ui shape="block">` label-position

For `<swatch-ui shape="block">`, the label renders **BELOW the tile** (flex-column stack). The label-on-tile use case (key inside the colored tile, with `data-on-light` / `data-on-dark` chrome contrast) is **NOT supported** in v0.5.x — the existing auto-contrast classes are designed for a future overlay mode, not currently consumed by any rendered layout.

```html
<!-- ✅ Default block layout — label below tile -->
<swatch-ui shape="block" color="oklch(0.7 0.15 220)">
  <span slot="label">Accent 600</span>
</swatch-ui>

<!-- ❌ NOT a label-on-tile API in v0.5.x — label still renders below -->
<swatch-ui shape="block" color="oklch(0.7 0.15 220)" data-label-position="overlay">
  <span slot="label">Accent 600</span>
</swatch-ui>
```

If your design needs label-on-tile (e.g. Tokens Studio's C1.3 hand-rolled `<div class="dts-swatch">` pattern), file a v0.6.0 feature request. The leading API shape is `<swatch-ui label-position="overlay">`; alternatives include `<swatch-ui shape="block-overlay">` or a sibling `<swatch-tile-ui>` primitive with slot composition.

---

## More

- [`README.md`](./README.md) — package overview + authoring guide
- [Component yaml contracts](./components/) — every primitive's complete API (props, events, slots, examples)
- [Live demos](https://ui-kit.exe.xyz/site/components/) — every primitive in action
- [`docs/specs/component-token-contract.md`](../../docs/specs/component-token-contract.md) — full authoring contract
- [`docs/specs/component-implementation-patterns.md`](../../docs/specs/component-implementation-patterns.md) — render strategies
- [`docs/specs/traits.md`](../../docs/specs/traits.md) — composable behaviors

For consumer questions not covered here, file an issue against [adiahealth/gen-ui-kit](https://github.com/adiahealth/gen-ui-kit/issues). This document is the canonical answer — if it doesn't have your answer, it's a doc gap worth fixing.
