# Composition traps — selection, wiring, and theming facts that don't announce themselves

_Load when picking primitives for a screen or debugging "the component is there but looks/behaves wrong." Complements the plugin-root [`component-model.md`](../../../references/component-model.md) (vocabulary) and [`authoring-components.md`](../../../references/authoring-components.md) (project-component invariants) — nothing here repeats them._

## Picking the right primitive

- `<stack-ui>` is **z-axis overlay** (all children share one grid cell — badge over avatar), not a vertical stack. Vertical = `<col-ui>`.
- Sidebar navigation is `<nav-ui>` + `<nav-item-ui>`; `<menu-ui>`/`<menu-item-ui>` is for popover dropdowns (Popover API) only.
- High-frequency, low-cardinality view pickers (Kanban/List, Day/Week/Month) are `<segmented-ui>` — all options visible, active highlighted. Reserve `<select-ui>` for lower-frequency, many-option knobs.
- 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="Remember me">` directly — never one `check-ui` wrapped in `<field-ui inline>`.
- `<field-ui>` is for **form** contexts. On toolbars of knobs the label is redundant (the trigger shows the value) — use the bare control + `aria-label`. N radio/check siblings inside one `<field-ui label>` need a `<col-ui gap="1">` wrapper, or they overlap (field-ui stacks a single input).
- `<empty-state-ui>` takes `heading=`, not `title=` — `title` sets the invisible native tooltip and the message silently doesn't render.
- `<stat-ui>` is **KPI weight** (title-weight value) — right for numeric metrics in dashboard tiles, visually wrong for string values at prose scale (names, IDs, statuses) in a detail panel. Read-only detail fields: `<field-ui><input-ui readonly>` when the form-mirror look is intentional, else a `<col-ui gap="0">` pair of `<text-ui size="sm" color="subtle">` label + `<text-ui>` value (compact multi-pair: native `<dl>` or a 2-column grid of text pairs).

## Attribute and slot honesty

- Components **silently accept any made-up attribute** (`text-ui muted`, `card-ui hover-elevate` are no-ops). Check the component's yaml / `lookup_component` for the real prop list before authoring.
- Many components take primary content via 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=` (CSS can't select text nodes — label presence is computed in JS).
- `slot="X"` is **inert in light DOM** — it does nothing unless the parent's CSS targets `[slot=X]`; positioning is CSS by tag + ancestor + DOM order.
- `select-ui` dynamic options go through the property: `el.options = […]`. It stamps its listbox popover around initial `<option>` children at connect; later-appended options land outside the listbox as visible flow content.
- `size="lg"` on an action `<col-ui>` stack applies only when the stack contains a `variant="primary"` button — pure-outline stacks carry no size.

## Structure inside containers

- `<card-ui>`/`<drawer-ui>` body content wraps in `<section>` — direct flow children bypass the body slot and lose the card inset. Canonical order: void media → `<header>` → `<section>`+ → `<footer>`; `<section bleed>` zeros the inset but keeps inline padding.
- `header-ui` has **no own CSS** — its icon/heading/description/action grid comes from the parent's `@scope` (card/drawer/modal/page/app-shell). In bespoke chrome, reuse the element + slot vocabulary but supply the grid (and ellipsis) locally.
- Native `<thead>/<tbody>/<tr>/<td>` are **foster-parented out of the DOM** when they appear inside any non-`<table>` ancestor, including custom elements — gone before JS runs. Never author them inside a custom element.
- Clickable grid cards: wrap in `<a href style="display:contents">` — link semantics + keyboard focus, while the parent grid still sees the card as the cell; hover rides on `a:hover card-ui`.
- A grid `auto`/`max-content` track collapses to ~1px around a flex wrapper whose child has explicit width (intrinsic size doesn't propagate) — set the width on the wrapper.
- Don't put `stretch` on a `<button-ui>` inside a `<col-ui>` action stack — col-ui already stretches children; reserve it for a lone button outside a stretching parent.
- Controls in one row can still mismatch in height even with a shared `size=` set — each primitive maps the universal `[size]` token to its own CSS differently (some are padding-driven, some floor on `--a-size` directly). Set the same explicit `size=` across the group as the first move; if they still don't align, diff computed heights rather than guessing. `<search-ui>` forwards its `size` attribute onto the inner input — size it like any other control, no need to reach into its internals.
- `bleed` is a per-`<section>` knob: `<table-toolbar-ui>` + `<table-ui>` sharing one `<section bleed>` puts the toolbar's controls flush on the card edge; sharing a plain `<section>` pads every table row. Split into two adjacent sections (toolbar plain, table `bleed`) — or the yaml-canonical pairing: toolbar **outside** the card in a `<col-ui gap="3">` (`[for="<table-id>"]` still reaches the table; `variant="card"` when it stands alone).

## Registration and CSS wiring

- Composites render internal `*-ui` tags you never wrote — `chat-input-ui` internally renders `textarea-ui` + `select-ui`; import those primitives too (or the barrel) or they stay unregistered and collapse to 0px.
- Component CSS loads via `<link>`/CSS import, **separate from the JS module graph** — a JS-only side-effect import registers the element but leaves it unstyled.

## Theming and tokens (beyond token-only)

- Never override a foundation component's surface token (e.g. `--card-bg`) with a bespoke elevation ladder — inherit the system ramp; divergent ladders reintroduce light/dark contrast inconsistency. Legit exceptions: per-element status tints; non-card divs opting into a nested-tile role.
- Global `[color]` presentational utilities override component color **by cascade-layer order** — filled controls repurposing `color=` must opt out; the global `[weight]` attribute does NOT beat component-scoped font-weight (use a variant).
- `--a-data-0..9` chart tokens color **identity** (series, groups, tracks); semantic tones (info/success/danger/accent) are for **state** — never mix the two roles.
- Text/icons on a filled primary disc use `--a-chrome-light` (theme-stable against any fill) — radio dots, step circles, badge counters.
- Use tokens for their named role, never by coincidental value — no `--a-bg` as foreground; primary fill = `--a-primary` + `--a-chrome-light` text.
- Square/1:1 cells inheriting `--a-radius-md` render as circles — use `--a-radius-sm` for small square cells.
- Subtle structure chrome (gridlines, weekend tints, today-column) draws from one barely-there contrast budget — calibrate against both schemes; too low is invisible, too high is chrome riot.
- Scheme vs palette are separate axes: `data-scheme` (light/dark/system) vs `theme` (named palette); `themes.css` loads **separately** from the styles barrel.

## Raw-CSS mechanics that bite compositions

- `background: <color>` shorthand silently resets `background-clip`/`origin`/`position`/`size` — state changes that alter only color use `background-color:` longhand.
- `translate`/`scale`/`rotate` are independent properties, not `transform` aliases — writing one and reading the other silently no-ops.
- An offsetting ancestor `transform` (e.g. `translate(-50%,-50%)`) breaks CSS anchor positioning for top-layer popovers; an identity transform doesn't.
- A `@media` override at equal specificity must come **after** its base rule in source order, or it is silently ignored.
- Toggling a child primitive's visibility with `display: none ↔ display: block` clobbers the primitive's intrinsic `:scope { display: flex }` (icon/heading/description mash inline). Invert the toggle — `.wrap:not([empty]) > [data-empty] { display: none }` — so no display value is set when shown.
- `repeat(N, minmax(<min>, 1fr))` fights `@container`-driven column collapse — narrow widths hit the minmax floor and overflow *before* the breakpoint reduces N. Container-query responsive grids use plain `repeat(N, 1fr)`; minmax is for grids whose N never changes.

## Message placement

- Status/auth pages: `<alert-ui>` carries the full message when the user is a **passive recipient** (session expired, locked out); the header `text-ui` carries it when the user is actively completing something they initiated.
- Centered prose/marketing header: `<col-ui>` inside `<header>` with kicker + display heading + deck.

## Component selection — the ambiguous picks

The MCP is authoritative for props and the full roster; this table settles only the picks
that are routinely gotten wrong:

| Need | Use |
| --- | --- |
| Layout | `col-ui` (vertical) · `row-ui` (horizontal) · `grid-ui` (2-D) · `stack-ui` (**z-overlay only** — badge over avatar) |
| Sidebar navigation | `nav-ui` + `nav-item-ui`; `menu-ui`/`menu-item-ui` is for popover dropdowns (Popover API) only |
| Option picker — 2–7 choices, high-frequency (view switch) | `segmented-ui` + `segment-ui` children, all options visible |
| Option picker — many options or low-frequency | `select-ui` |
| Rows: leading control + title + subtext + trailing badge | `list-ui` > `list-item-ui`, not bespoke flex divs |
| Overlays | `modal-ui` (modal) · `drawer-ui` (side panel) · `popover-ui` (anchored) · `confirm-dialog-ui` (confirmation; web-modules) |
| Feedback | `toast-ui` · `alert-ui` · `progress-ui` · `skeleton-ui` (loading placeholder) · `spinner-ui` |
| Empty state | `empty-state-ui` (`heading=`, not `title=`) |
| Command palette | `command-ui` |
| Charts | `chart-ui` (+ `chart-legend-ui`); `--a-data-0..9` for series identity, semantic tones for state — never mixed |
| Form field | `field-ui` wrapper in form contexts; a standalone checkbox is a bare `check-ui label=` |
| Tabular data | `table-ui` — native `<thead>/<tbody>/<tr>` inside any custom element are foster-parented out of the DOM |
