# The adia-ui component model

How to _think_ about the catalog. The **live** catalog is the a2ui MCP (`get_component_map`, `lookup_component`, `get_traits`) — query it for exact names, props, and counts; don't memorize them here (they drift per release). This file teaches the vocabulary so the MCP's answers make sense. (Claims re-verified against the kit's live source 2026-08-26; re-verify on a MINOR cut.)

## Three tiers

| Tier | Package | What it is | Examples (shape, not a contract) |
| --- | --- | --- | --- |
| **Primitives** | `@adia-ai/web-components` | Atomic custom elements — controls, layout, display, overlays | `<button-ui>` `<input-ui>` `<select-ui>` `<table-ui>` `<card-ui>` `<modal-ui>` `<drawer-ui>` |
| **Layout primitives** | (same) | The box model — never hand-roll a bare `<div>` for layout | `<col-ui>` `<row-ui>` `<grid-ui>` `<stack-ui>` `<page-ui>` `<section-ui>` |
| **Traits** | (subpath) | Reusable _behaviors_ attached to any element — not data, not components | `pressable` `focusable` `draggable` `ripple` `scale-press` `roving-tabindex` `focus-trap` |
| **Composites / shells** | `@adia-ai/web-modules` | Multi-component surfaces / page chrome | `<admin-shell>` `<admin-sidebar>` `<chat-shell>` `<editor-shell>` |

**Naming:** kebab-case with a `-ui` **suffix** (`<button-ui>`, not `<a-button>`). Shells/composites carry a domain prefix (`<admin-*>`, `<chat-*>`).

**Light DOM is the substrate.** Components render in the light DOM — a `slot="X"` attribute is *inert* metadata unless CSS targets `[slot="X"]`; positioning is CSS by tag + ancestor + DOM order, never slot routing. Grep before believing any "the slot routes content" claim.

**Rule of first resort:** reach for a catalog primitive before authoring anything. Never use a raw `<button>`/`<input>`/`<div>` where a `*-ui` primitive exists — raw elements skip focus rings, theming, density, and form association. Author a project component (see [authoring-components.md](authoring-components.md)) only when no primitive composes to the need.

**Browser baseline:** Chromium 125+ · Safari 18.0+ · Firefox 129+ — the floor that makes `light-dark()`, `@scope`, popovers, and OKLCH usable without fallbacks.

## Registration is a side effect of import

Importing a component module **registers its tag**. Two forms:

```js
import '@adia-ai/web-components';            // the barrel — registers every primitive (incl. router-ui)
import '@adia-ai/web-components/components/button/button.js';  // one component
```

Registration uses `defineIfFree('button-ui', UIButton)` internally — idempotent, so double-import is safe. The non-registering class import (`.../button/class`) exists for tests/subclassing. **In SSR this import must be client-only** — see [ssr-integration.md](ssr-integration.md).

Registration corollaries that bite:

- **Composites stamp primitives your HTML never mentions** — `chat-input-ui` internally renders `textarea-ui` + `select-ui`; unless those primitives are registered too, they stay undefined and collapse to 0×0.
- **Shells register via the cluster barrel** (`@adia-ai/web-modules/shell|chat|editor`) — importing one shell's `.js` registers only the host; its bespoke children (`admin-sidebar`, `chat-thread`, …) only register when their sibling JS loads.
- **Some tags are defined in a parent component's `.js`**, and some components are deliberately CSS-only (stylesheet + metadata, no `.js`) — when auditing whether a tag is real, grep for its `customElements.define`, don't `ls` folders.
- **JS registers, CSS loads separately** — component CSS arrives via `<link>`/the CSS barrel, not the module graph; a JS-only side-effect import yields a registered but unstyled element.

## Signals, not a virtual DOM

Components extend `UIElement` (or `UIFormElement` for form-participating controls) and use fine-grained signals:

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

- `signal(v)` — reactive value (`.value` get/set)
- `computed(() => …)` — memoized derived value
- `effect(() => …)` — runs on dependency change; auto-cleans on disconnect

Declared `static properties` are wrapped as signals automatically, so setting `el.disabled = true` re-renders. Don't run a parallel `CustomEvent`-only state path that competes with signals.

**No context-request protocol — a ratified non-goal, not an oversight.** There is no
`context-request`/`ContextProvider` event channel anywhere in `web-components` or `web-modules`
(reactivity review, `.claude/docs/reports/2026-08-20-reactivity-review/02-web-modules-state.md`
§4 — zero hits in `web-components`/`web-modules`, confirmed repo-wide by direct grep). "Context"
reaches a component two ways only: **CSS cascade** (theme/
density tokens land on every descendant for free, no wiring needed) and **host-injected
properties** (a shell or app hands a child a JS reference it needs — a renderer, a store, a
`runTurn` callback — as a plain property assignment, never a request/response round-trip). If a
component needs data or a capability from outside its own subtree, that's a property the owning
shell/app injects, or a `data-wiring` pattern (`signal()`/`DataClient`) — never a new
context-request channel authored ad hoc.

## Choosing components — the recurring calls

Selection mistakes that keep recurring (verify props with `lookup_component` when unsure):

- **`<stack-ui>` is a z-axis overlay** — all children share one grid cell. Vertical stacking is `<col-ui>`.
- **High-frequency, low-cardinality view pickers** (Day/Week/Month, Kanban/List) are `<segmented-ui>`, not `<select-ui>` — all options visible, active one highlighted; reserve `select-ui` for lower-frequency, many-option knobs.
- **Sidebar navigation is `<nav-ui>` + `<nav-item-ui>`**; `<menu-ui>`/`<menu-item-ui>` are for Popover-API dropdowns, never persistent nav.
- **A list of rows** (leading control + title + subtext + trailing badge) is `<list-ui> > <list-item-ui>`, not bespoke flex divs.
- **A standalone checkbox is `<check-ui name label="…">`** — never one `check-ui` wrapped in `<field-ui inline>`. `<field-ui>` is for form contexts only; toolbar knobs use the bare control + `aria-label` (the trigger already shows the value).
- **Multiple radio/check siblings inside one `<field-ui label>`** need a `<col-ui gap="1">` wrapper — field-ui stacks a single input; without it they overlap.
- **Card/drawer body content wraps in `<section>`** (canonical order: void media → `<header>` → `<section>`+ → `<footer>`) — direct flow children bypass the body slot and lose the `--card-inset` margin; `<section bleed>` zeros the inset but keeps inline padding.
- **Clickable grid cards:** wrap the card in `<a href style="display:contents">` — link semantics + keyboard focus without a layout box, so the grid still sees the card as the cell; hover rides `a:hover card-ui`.
- **No `stretch` on a `<button-ui>` inside a `<col-ui>` action stack** (col-ui already stretches children); `size="lg"` on an action stack applies only when it contains a `variant="primary"` button.
- **`<toast-ui>` is a facade over `<feed-ui>`** — `UIToast.show()` routes to `UIFeed.post()`; the top-layer/queue/focus logic lives in the feed component. (The `Adia<X>` class-name convention this facade predates was swept to `UI<X>` at v0.2.0 — `packages/web-components/CHANGELOG-pre-0.2.0.md`.)

## Attribute honesty — silent-failure class

- **Components silently accept any made-up attribute** — `text-ui muted`, `card-ui hover-elevate` are no-ops with zero warnings. Check the real prop list (`lookup_component`) before authoring; a rendered-but-unstyled state usually means an invented attr.
- **`empty-state-ui` takes `[heading]`, not `[title]`** — `title=` becomes the native tooltip and the message silently doesn't render.
- **Primary content often rides the default slot, not `text=`/`label=`** — `kbd-ui` and `card-ui` are the canonical traps. `button-ui` accepts both `text=` and child text; prefer `text=` for generated UI.
- **Shell-tier bespoke children reflect state as attributes** (`admin-sidebar[collapsed]`) — read and style via attribute selectors; coordinate via `querySelector`, not a central store. This is a parent-reaching-into-child sanction scoped to shell-tier bespoke composition only — it never licenses the reverse direction; ordinary app components still follow `data-wiring`'s data-down/events-up rule, where a child reaching into a parent's internals is a defect.

## Traits — behavior by declaration

```html
<button-ui traits="pressable scale-press ripple">Save</button-ui>
```

or programmatically: `static traits = [pressable, scalePress]`. A trait is a factory (`defineTrait({ name, setup({host}) { …; return cleanup } })`) that manages attributes/events and tears itself down on disconnect. Query the live set with the MCP's `get_traits`.

- **Traits require `UIElement`** — `traits="…"` on an HTMLElement-based or native element is silently ignored.
- **`data-stream-*` attributes** are a universal ingestion trait: any element with a settable `.data` can subscribe to a shared, refcounted transport.

## Tokens — the only styling currency

Three levels, all `--a-*`:

| Level | Role | Examples |
| --- | --- | --- |
| **L1 primitive scales** | dimensionless base values | `--a-space-2` `--a-size-md` `--a-radius-sm` `--a-duration-fast` `--a-font-family-ui` |
| **L2 semantic families** | role vocabulary | `--a-primary` `--a-danger` `--a-primary-bg` |
| **L3 state × role** | what components consume | `--a-primary-bg-hover` `--a-danger-fg-active` `--a-ui-bg-disabled` |

- **Color scheme** uses `light-dark()` at the token layer — never swap tokens by hand. Toggle with `<toggle-scheme-ui scheme="auto" target=":root" persist>`. Theming is **two independent axes**: `data-scheme` (light/dark) vs `data-theme` (named palette) — and `themes.css` loads separately from the styles barrel.
- **Density / spacing / radius are `@property` multipliers** — three knobs rescale the whole system (the parametric spatial system); e.g. `--a-density: 0.8` at a provider boundary.
- **Zero raw colors, zero raw px ≥ 3** in component CSS — always a token. This is mechanized by the `adia-lint` hook and by the MCP's `check_anti_patterns`.
- **Use a token for its named role, never by coincidence** — `--a-bg` is never a foreground, `--a-fg` never a background; a primary fill is `--a-primary` + `--a-chrome-light` text.
- **Text/icons on a filled primary disc** (radio dots, step circles, badge counters) use `--a-chrome-light` — theme-stable against any fill. (`--a-primary-fg` also resolves light post-Material-adoption; `--a-chrome-light` stays correct on arbitrary fills.)
- **Series/group identity colors are `--a-data-0..9`**; semantic tones (info/success/danger/accent) mark *state*, never identity.
- **Never re-ladder a foundation surface token** — don't override `--card-bg` with a bespoke elevation ladder; divergent ladders reintroduce the light/dark contrast inconsistency the symmetric canvas ramp prevents. (Per-element status tints are the legitimate exception.)
- **Global `[color]`/`[weight]` utilities override component colors by `@layer` order** — a filled control that repurposes `color=` must opt out explicitly; conversely `[weight]` does *not* beat component-scoped `font-weight` (use a variant).
- **Square/1:1 cells inheriting `--a-radius-md` render as circles** — small square cells take `--a-radius-sm`.
- **Subtle structure chrome** (gridlines, weekend tints, today-column) shares one "barely-there" contrast budget — calibrate against the canvas surfaces in both schemes; too low is invisible, too high is chrome riot.

## "Registers" — typographic treatments, opt-in

A _register_ (e.g. `scale="ui-sm"`, `scale="content-md"`) is a typographic treatment applied two ways together: **link the register stylesheet** (`styles/scale.css`, or wrap the surface in `<theme-provider scale="…">`, which adopts it on demand) **and** put the **attribute** on the surface. One without the other is a no-op — a common smell. Body/UI text defaults to `--a-font-family-ui`; registers opt a subtree into a different family/rhythm.

## Layout realities

- **A grid `auto`/`max-content` track collapses to ~1px around a flex wrapper** whose explicit width lives on an inner child — intrinsic max-content doesn't propagate through the wrapper; set the width on the wrapper itself (CSS, or JS-mirrored via ResizeObserver).
- **Full-height shells need an unbroken flex chain** — see the mount gotcha in [shell-admin.md](shell-admin.md); it applies to every shell.

## Where this is incomplete

The deep internals — the `html` template engine's escaping contract, `BaseController` delegation, the icon loader (`installIconLoadersForRegistered` + Vite `import.meta.glob`), `@bp` responsive notation, and the A2UI runtime/`<a2ui-root>` resolver — are framework-owned and version-specific. Treat the MCP (`lookup_component`, `search_chunks`) as the source of truth for them rather than encoding them here.
