# Gotchas & gap classes — the consumer failure catalog

Two lookup catalogs the audit reads against: the recurring **composition gotchas** (what breaks
when a consumer wires AdiaUI primitives) and the four **gap/drift classes** (the distance
between consumer state and current substrate). Primitive APIs (props/slots/enums) are owned by
the library `<name>.yaml` — cited here, never duplicated; the a2ui MCP (`lookup_component`,
`get_traits`) is the live SoT. The "fixed-in" versions below are volatile — confirm against the
live CHANGELOG.

## Part A — Composition gotchas (the "passes audit but looks broken" class)

The unifying cause is **defaults aren't contracts**: a primitive's default display / size /
columns is a starting point; when a consumer overrides it, the override wins by *specificity*,
not *correctness*. Reading the primitive's `<name>.yaml` + `.css` end-to-end at selection time
is the upstream defense. Each row: the trap, how to detect it, the right shape.

| # | Failure mode | Detect | Right shape (SoT) |
|---|---|---|---|
| 1 | **Composite without its grammar** — arbitrary `<div>` children inside `<card-ui>`/`<drawer-ui>`; the `@scope` rules only fire for canonical children, so content falls through to `display:block` (no inset, flat chrome) | visually flat, siblings touch the edge | use the composite's slot grammar (`<header><span slot="icon|heading|action">`) |
| 2 | **Parent CSS clobbers child display** — a parent sets `display:block` on an embedded primitive, beating its own `:scope{display:flex}`; empty/loading/error states render inline-mashed | DevTools: `display:block` from a parent rule beats `:scope{display:flex}` | invert the toggle: `.wrap:not([empty]) > [data-empty]{display:none}` — never set a display value when shown |
| 3 | **Mixed control sizes in a row** — `<button-ui>`, `<search-ui>`, `<tag-ui>` default to different heights; same row, mismatched baselines | controls in a row don't share a baseline; measure `getBoundingClientRect().height` | set the **same** `size=` explicitly on every control; if a wrapper doesn't forward `[size]`, file a substrate ticket — don't reach into its internals |
| 4 | **`minmax()` inside `repeat()` fights `@container`** — narrow widths hit the minmax floor and overflow *before* the breakpoint collapses N | grid overflows horizontally before the breakpoint | with container-query collapse use plain `repeat(N, 1fr)`; minmax is for grids *without* CQ-driven N changes |
| 5 | **`<page-ui band>` without inner column-owner** — bare `<h1>`/`<card-ui>` (or a routing `<div>` wrapper) drops the inset silently; a `<div>` wrapper between `page-ui` and its header/section defeats the `@scope` rules → `display:inline` | `getComputedStyle(section).padding === '0px'` | nest `<header-ui>` and `<section-ui>` (or native `<header>`/`<section>`) as *direct* children of `<page-ui band>`; route via `<router-ui>` or sibling `<page-ui band hidden>`, never a wrapping `<div>` |
| 6 | **Bare-slot brand chrome instead of `<admin-entity-item>`** — `[slot=icon]`+`[slot=heading]` siblings misalign with nav icons and have no shared collapse boundary | brand icon left-inset ≠ nav item left-inset | wrap icon+label(+badge) in `<admin-entity-item slot="heading">` (same primitive used in the footer identity row) |
| 7 | **`<router-ui>` silent-empty when provider unregistered** — subpath-import consumers skip the barrel; `router.routes=[…]` is a no-op on an `HTMLUnknownElement`, no error | `querySelector('router-ui').constructor.name === 'HTMLUnknownElement'` | use the main barrel (default), or add `import '@adia-ai/web-components/core/provider'` |
| 8 | **`<table-toolbar-ui>` double chrome** — filter inputs dropped as children render *above* the toolbar's own auto-rendered row → two chrome bands | two adjacent toolbar-shaped rows; any non-`[slot=actions]` child | let the toolbar own its chrome; opt out with `no-filter`/`no-sort`/`no-columns`/`no-search`; `slot="actions"` for trailing buttons |
| 9 | **toolbar + table in one `<section bleed>`** — `bleed` is per-`<section>`, so both share edge-to-edge and toolbar controls touch the card edge | first toolbar control's `left` == card's leading border | two sections (toolbar normal, table `bleed`), or toolbar *outside* the card in `<col-ui gap>` |
| 10 | **`<table-ui>` columns as flat text** — pre-formatting values in the data layer defeats the built-in cell-types → no badge, no right-align, no chips | numeric columns left-aligned, status flat | pass **raw** values (numbers, ISO dates, enums) + set `type:`/`render:` per column. NOTE (gh#288, 0.8.6): `data` can also be seeded declaratively as a `data="[…]"` JSON attribute for SSR/static HTML |
| 11 | **Wrong attribute name → silent no-op** — the browser accepts any attribute; the primitive binds only its declared names (`accordion-item label→text`, `col-def field→key`, `progress-row value` is 0–100, `empty-state heading` not `title`, …) | the row/column/group renders empty; `el.text === undefined` | read the `<name>.yaml` `props:` block (or `lookup_component`) before authoring — the obvious attribute name often isn't the declared one |
| 12 | **Same attribute, different enum across components** — `<drawer-ui side="trailing">` (pane vocabulary) → no matching rule, panel invisible at `opacity:0` | overlay "opens but shows nothing" | check `side` against the component's *own* yaml enum — `<drawer-ui>` takes physical edges (`left/right/…`), `<pane-ui>` takes logical (`leading/trailing`); don't carry vocabulary between siblings |

**Meta-rule:** the substrate's structural audits catch *shape*; #2, #3, #4, #10, #11, #12 are
data/visual/proportional and need rendered-surface review (`surface-qa`). When a primitive in
your composition appears above and you didn't read its yaml, you skipped the literacy gate —
that *is* the finding.

## Part B — The four gap/drift classes (recon classification)

Every recon finding is exactly one class. **Class 0 sorts first and is a build-blocker** —
classes 1–3 can't be verified until it passes. Output each as `{class, evidence:<file:line>,
count, substrate_answer, remediation, leverage, risk}`.

### Class 0 — Manifest gap (build-blocker)

`src/` imports `@adia-ai/*` but `package.json` declares zero — `npm install` honors the empty
manifest, `node_modules/@adia-ai/` never exists, the bundler fails the bare specifier at
`npm run dev` ("Are they installed?" — misleading; they were never *declared*).

```bash
# (a) imports present?
grep -rEn "from ['\"]@adia-ai/" --include='*.ts' --include='*.js' --include='*.tsx' \
  --include='*.jsx' --include='*.vue' --include='*.svelte' src/ 2>/dev/null | wc -l
# (b) declarations present?
node -e "const p=require('./package.json');const d={...p.dependencies,...p.devDependencies};console.log(Object.keys(d).filter(k=>k.startsWith('@adia-ai/')).length)"
# Fires iff (a) > 0 AND (b) === 0.
```

| imports / declared | Verdict |
|---|---|
| 0 / 0 | clean (no use) |
| N / M | normal → check class 1 |
| 0 / M | unused declarations — low-priority sweep |
| **N / 0** | **manifest gap — HALT before composing further UI** |

**Remediation** (operator picks): **install** `npm install @adia-ai/web-components@^0.8.X @adia-ai/web-modules@^0.8.X` (match a known-good sibling's dep shape) for bundler apps; **CDN** `<link>`+`<script>` tags for prototype / static-HTML / no-bundler surfaces. `npm install` with no args "fixes" nothing with zero declarations — surface the gap with the reference shape + exact command; the operator decides.

### Class 1 — Version drift

Installed `@adia-ai/*` trails the substrate's `latest`. Read the 3-tuple — declared / installed / latest.

| declared / installed / latest | Drift | Remediation |
|---|---|---|
| `^0.8.0` / `0.8.4` / `0.8.6` | PATCH lag | `npm install @adia-ai/web-components@^0.8.0` — non-breaking |
| `^0.8.0` / `0.8.4` / `0.9.0` | MINOR lag | read the CHANGELOG; explicit bump (breaking possible) |
| `^0.6.0` / `0.6.0` / `0.8.6` | major-relative | trigger a migration phase — multi-cut breaking sweep (`app-migration`) |
| 11-pkg ranges inconsistent | **lockstep break** | **hard failure** — internal `^0.8.0` deps resolve to mismatched siblings; fix before any other work |

### Class 2 — Spec drift (ADR / rename adoption)

Code uses a shape a release retired, or hasn't adopted the canonical one. Grep the *retired* shape:

| Change (ver) | Retired shape (grep) | Canonical replacement |
|---|---|---|
| harness reset (0.6.x) | `--agent-*`, `AgentElement`, `@agent-ui-kit`, `@adiahealth/*` scope | `--a-*`, `UIElement`, `@adia-ai/*` |
| Material color adoption (0.8.0) | `--a-accent-*` (removed 2026-07-14) | `--a-primary-*` (or the `--md-sys-color-*` bridge — the alias layer) |
| shell consolidation | `<chrome-shell`/`<page-shell` legacy | `<admin-shell>` / `<editor-shell>` / `<chat-shell>` / `<simple-shell>` |
| CSS-import policy | implicit CSS side-effect imports | `/with-css` subpath or explicit CSS import |

Every match is a spec-drift finding; remediation is mechanical find/replace (hand the confirmed
sweep to `app-migration`) except where the retired shape carried state (check the migration notes).

### Class 3 — Capability drift (hand-rolled-when-substrate-now-provides)

Consumer hand-rolled a pattern before the substrate shipped a first-class equivalent; it now
duplicates, drifts visually, and accrues maintenance cost.

| Hand-rolled symptom (grep) | Substrate answer |
|---|---|
| `class=".*toast"` + custom CSS | `<feed-ui>` toast API |
| `class=".*skeleton"` + custom CSS | `loading` prop on `<stat-ui>` / `<table-ui>` |
| custom `<div role="dialog">` | `<modal-ui>` / `<drawer-ui>` / `confirm-dialog` |
| custom `@media` breakpoints | `@bp` responsive attrs (`columns="2 4@md"`) on grid/col/row/text |
| inline hex / rgb | `--a-*` semantic tokens (or the `--md-sys-color-*` bridge) |

Rank by `leverage = (locations × lines-per-location) / replacement-cost`. **Sniff tests before
declaring it:** does the hand-roll *predate* the substrate capability (`git log` vs CHANGELOG)?
Is it doing something the substrate can't (read the yaml)? Is there a changelog entry telling
consumers to migrate (then it's also spec-drift — classify once)? High-leverage (>10 locations,
single PATCH cut) → high-priority; a deliberate 1-location carve-out → accept it.
