# BUILDING_COMPONENTS — playbook for adding a conformance-backed reference component

Audience: an implementer (human or a cheaper model) adding the *next* component
(menu, tooltip, select, combobox, …). **Follow this literally.** The hard knowledge
is already encoded here and in the worked example (`priv/popover-reference/` +
`lib/live_interaction_contracts/components/popover.ex`). Do **not** re-derive it.

Golden rule: **copy the popover reference and change the minimum.** Every fixture
boot gotcha and every Playwright gotcha is already solved in those files; starting
from scratch reintroduces bugs that took many iterations to find.

---

## 0. Definition of done (a component is complete only when ALL are true)

- [ ] `lib/live_interaction_contracts/components/<name>.ex` — one function component,
      named slots, single explicit `id`, all wiring derived from it. Unstyled.
- [ ] Any new client behavior lives INSIDE the component as a **colocated hook**
      (`<script :type={Phoenix.LiveView.ColocatedHook} name=".Lic<Name>">`, referenced
      by `phx-hook=".Lic<Name>"`). No separate JS file, no manual registration.
- [ ] `priv/<name>-reference/app.exs` — fixture that **dogfoods the real component**
      via `Code.require_file`, then `Phoenix.LiveView.ColocatedAssets.compile()` and
      serves the extracted output at `/colocated` (the real shipped artifact).
- [ ] `priv/<name>-reference/driver.mjs` — conformance driver that **self-asserts**
      (exits non-zero on any failure), runs 2 runs × 3 engines, byte-identical.
- [ ] Registered in `lib/mix/tasks/live_interaction_contracts.test.ex` `@suites`.
- [ ] Added as a blocking step in `.github/workflows/conformance.yml` (spikes job).
- [ ] A contract doc (`<NAME>_CONTRACT.md`) OR a section in an existing contract.
- [ ] `mix live_interaction_contracts.test --suite <name>` prints the green fuse line.
- [ ] Classified on **two orthogonal axes** (see "Classification: two axes" below) —
      do not collapse them.

---

## Classification: two axes (do not conflate)

A component is described by **two independent** facts. Conflating them makes agents
mislabel `ignore_attributes` usage.

**Axis A — machine classification (about the browser-owned STATE: open / active / value):**

- **green** — the state lives *off* server-reconciled attributes (top layer, editing
  state, scroll). Patch-safe by nature.
- **amber** — the state is *reflected into a server-reconciled attribute* (`<dialog open>`,
  `<details open>`) and the component repairs it with a reflected-state adapter.
- **red** — raw native, unrepaired (a fixture, not a shipped component).

**Axis B — adapter/reflection technique (what protection the component applies):**

- **none** — nothing client-managed to protect (tooltip: `aria-describedby` is a server
  constant).
- **protected-reflection** — the machine STATE is fine (green), but a *client-managed
  ARIA/presentation mirror* (`aria-expanded`, `aria-activedescendant`, `data-highlighted`) is
  protected with `JS.ignore_attributes([...])` so a patch does not strip it.
- **reflected-state-adapter** — the machine STATE ITSELF is attribute-reflected and
  protected with `JS.ignore_attributes([...])` (dialog's `open`).

**The rule that resolves the apparent contradiction:**

> Using `ignore_attributes` to protect a **client-managed ARIA mirror** keeps the machine
> **green** (popover/menu are green *and* use protected-reflection). Using
> `ignore_attributes` to protect the **machine's own open/active STATE** makes it
> **amber** (dialog). Amber is about *where the state lives*, not *whether you used the
> technique*.

Examples: popover = green / protected-reflection. menu = green / protected-reflection.
tooltip = green / none. dialog adapter = amber / reflected-state-adapter.

---

## 1. The invariants you must not violate (why the recipe is shaped this way)

These are frozen (`KERNEL.md`). Breaking one produces a component that looks fine and
fails under patching in production.

1. **Open/active state that is browser-owned must not live in a server-reconciled
   attribute.** Reconciliation strips it. Use the top layer (`popover`, `showModal`),
   never `<dialog open>` server-rendered, never a client-set attribute the server
   also renders.
2. **Structural attributes a machine needs (`popover`, `role`) must be server-rendered
   constants** in the component template. A client-added one is stripped.
3. **Client-managed attributes that reflect browser state (`aria-expanded`,
   `aria-activedescendant`, `data-highlighted`) must be protected** with
   `phx-mounted={JS.ignore_attributes([...])}` on the element the hook writes, or they
   are stripped when a patch re-renders that element. (This is **protected-reflection**,
   Axis B — it keeps the machine **green**; it does not make it amber. See popover's
   `aria-expanded`.)
4. **Cursor / active-descendant: use `aria-activedescendant`, never roving tabindex.**
   Focus stays on the input/container; the active item is a logical-id reference.
   Non-form-element focus does not survive patches. Never re-`focus()` in `updated()`.
5. **Observation is lossy telemetry only.** A hook may relay a native event
   (`toggle`, `close`, `cancel`) to the server; the server may react (cleanup, logging)
   but must **never** derive canonical state or authorization from it.
6. **Channel (async results): the server render path must drop superseded versions.**
   A client version stamp alone is convention, not correctness. (See `CHANNEL_CONTRACT.md`.)
7. **Existence-in-DOM is the authorization boundary and is server-owned.** Gate
   privileged content by whether the server renders it, never by client hiding.

---

## The four reference books — what to borrow, what to leave

You do not invent interaction behavior from scratch. Each concern has a canonical
source; consult the right book for each, and **borrow, don't copy** — React Aria and
Base UI are built on a JS-owns-all-state axiom that is false in LiveView, so some of
their ideas are category errors here (see the paper §9).

| Concern | Book | Borrow |
|---|---|---|
| **Behavior** — keyboard map, focus order, ARIA roles/states, typeahead, dismissal | **React Aria** + WAI-ARIA APG | the *specification of the behavior* (what ArrowDown does, what Escape does, where `aria-activedescendant` points). This is runtime-agnostic and transfers 100%. Copy the keyboard/ARIA maps. |
| **Anatomy + styling contract** — part names, `data-state`/`data-highlighted` hooks | **Base UI / Radix** | the part-naming vocabulary (Trigger/Content/Item → HEEx slots) and the `data-*` state attributes for styling. Cheap, valuable, ownership-orthogonal. |
| **Ownership** — who writes each atom, what is server/browser/client | **`KERNEL.md`** (this repo) | non-negotiable. This overrides anything the other books imply about state ownership. |
| **Patch-safety** — does the state survive LiveView patches | **the conformance fuse** (this repo) | the proof. React Aria/Base UI never test this axis (single-runtime); you must. |

**Do NOT borrow (category errors that do not transfer):**

- **JS-owns-all-state** — false in LiveView; delegate to the browser / server per KERNEL.
- **Implicit context anatomy** (`Popover.Root` discovering `Popover.Trigger` via React
  context) — HEEx has none; wire with explicit ids (which the ARIA relationships need anyway).
- **controlled/uncontrolled duality** — ownership here is fixed per atom by the KERNEL
  taxonomy, not chosen per usage. Deleting this degree of freedom is an upgrade, not a loss.
- **JS reimplementations of platform-delegated behavior** (focus trap, portal, dismissal,
  modality) — the platform now does these; delegate (`showModal`, `popover`, top layer)
  instead. This is where you are at or above React Aria *for free*.
- **presence / mount-unmount animation wrappers** — superseded by native top-layer + CSS
  transitions (`@starting-style`) for delegated overlays.

So the per-component rhythm is: **behavior → React Aria · anatomy → Base UI · ownership
→ KERNEL · patch-safety → fuse.** Four books, each for its own concern.

### API naming is BINDING, not advisory (decided 2026-07-07)

Public attr / slot / data-attribute names MUST use the React Aria + Base UI
vocabulary directly — do not invent synonyms:

| Concept | Name (source) |
|---|---|
| placement preset | `placement` (React Aria `placement`) |
| open-state relay | `on_open_change` (RA/Base UI `onOpenChange`) |
| menu activation | `on_action` (React Aria Menu `onAction`) |
| select/combobox value commit | `on_value_change` (Base UI `onValueChange`) |
| combobox query | `on_input_change` (React Aria ComboBox `onInputChange`) |
| highlighted item styling hook | `data-highlighted` (Base UI/Radix) |
| list children slot | `:item` (Base UI `*.Item`) |

Documented deviations (justified by HEEx or ownership, not taste): `mode`
("auto"/"manual" — native popover vocabulary, ownership-driven); `trigger_attrs`
(HEEx has no `asChild`/render-prop, so the trigger accepts an attrs map); the
single-function-component + explicit-`id` anatomy (HEEx has no implicit context);
Phoenix event names are snake_case strings, not camelCase callbacks. Any NEW
deviation must be added to this list with its reason.

---

## 2. Classify the machine first (decides green vs amber and the recipe)

```
Does the native machine reflect its state into an attribute the server template
does not carry (e.g. <dialog open>, <details open>)?
  YES -> RED raw. Needs an ADAPTER: phx-mounted={JS.ignore_attributes(["open"])}
         + a JS open/close/relay hook. Classify AMBER. (See CONTRACT_DIALOG.md.)
  NO  -> state is in the top layer / editing state / scroll -> patch-safe by nature.
         Classify GREEN (but client-managed ARIA still needs rule 1.3 protection).

Does it coordinate over server-rendered children (highlight / active item)?
  YES -> add a Cursor (aria-activedescendant, logical-id rebind). See CURSOR_CONTRACT.md.

Does it exchange versioned async query/results?
  YES -> add a Channel (server monotonic guard). See CHANNEL_CONTRACT.md.
```

Composition of the queued components (already derived — do not re-decide):

| Component | = | Classification | New pieces vs popover |
|---|---|---|---|
| **menu** | popover + cursor | green (+ cursor machinery) | add `aria-activedescendant` over `<:item>` children; ArrowUp/Down |
| **tooltip** | popover (hover/focus trigger) | green | trigger opens on `mouseenter`/`focus`, closes on leave/blur; `role="tooltip"` |
| **select** | popover + cursor + hidden form input | amber (value is form state; popup is cursor) | server-owned selected value in a hidden `<input>`; cursor over options |
| **combobox** | popover + cursor + channel | amber | already spiked (`priv/combobox-spike/`); productionize into a component |
| **tabs / accordion** | (details-like) | red/amber | `open`-reflected → adapter, per-panel; separate design, do NOT copy dialog blindly |

---

## 3. The recipe (mechanical)

### Step 1 — component (`lib/live_interaction_contracts/components/<name>.ex`)

Copy `popover.ex`. Keep the anatomy: `attr :id, required: true`, named slots, derive
all wiring from `@id`. Change the markup to the new machine. Apply §1 rules:
- structural attrs (`popover`, `role`) are literal in the template;
- any client-managed reflected attr gets `phx-mounted={JS.ignore_attributes([...])}`;
- open/active state is never server-rendered.

### Step 2 — hook (colocated, inside the component)

Add the client JS at the end of the component's `~H` as a colocated hook:

```heex
<script :type={Phoenix.LiveView.ColocatedHook} name=".Lic<Name>">
  export default { mounted() {...}, destroyed() {...} }
</script>
```

and reference it with `phx-hook=".Lic<Name>"` (the leading dot namespaces it by
module, so consumer hook names can never collide). Keep JS minimal: mirror
browser-owned state into protected ARIA and relay native events as lossy
observation. No client ownership of open/active beyond the one-frame cursor.
Consumers get every hook from one import: `phoenix-colocated/live_interaction_contracts`.

### Step 3 — fixture (`priv/<name>-reference/app.exs`)

Copy `priv/popover-reference/app.exs`. Change only: the module names, the port (next
free: popover 4129, so use 4130+), `Code.require_file` the new component, `import` it,
and render `<.<name>>` with server-patchable content + the same server patch buttons
(`btn-content`, `btn-attr`, `btn-sib`, plus any machine-specific ones). **Do not touch
the layout/boot block** — it has the working csrf/check_origin wiring plus the
colocated-hook extraction (`ColocatedAssets.compile()` + `/colocated` static serving +
the `type="module"` LiveSocket init).

### Step 4 — driver (`priv/<name>-reference/driver.mjs`)

Copy `priv/popover-reference/driver.mjs`. Keep the harness scaffolding verbatim
(`waitConnected`, `settle`, `jsClick`, the engine loop, the self-assert block). Change
only `BASE` port, the `snap()` fields (read the new machine's native state), and the
conformance cases + `score()`. Every state read MUST come from the native source
(`:popover-open`, `dialog.open`, `document.activeElement`, `el.value`, `scrollTop`,
`aria-activedescendant`), **never** from HTML string comparison. Node identity is a JS
expando (`el.__mark`). End with the `score()`/`process.exit(1)` fuse.

### Step 5 — register the suite

In `lib/mix/tasks/live_interaction_contracts.test.ex`, add to `@suites`:
```elixir
"<name>" => %{dir: "<name>-reference", app: "app.exs", driver: "driver.mjs", port: 4130, assert: false, ledger: false}
```

### Step 6 — wire CI

In `.github/workflows/conformance.yml`, add to the `spikes` job:
```yaml
      - run: mix live_interaction_contracts.test --suite <name>
```
and add the results json to that job's `upload-artifact` paths.

### Step 7 — contract doc

Write `<NAME>_CONTRACT.md` (copy the shape of `REFERENCE_POPOVER_CONTRACT.md` or
`CONTRACT_DIALOG.md`). State: classification + why, the responsibilities, the
conformance points, and the honest caveats. Put version-specific numbers in the driver
output, not in the durable prose (`SPEC.md` tier rule).

### Step 8 — run and verify

```
cd live_interaction_contracts
mix live_interaction_contracts.test --suite <name>
```
Must end with `✓ <name> ... fuse OK`. If it fails, read `priv/<name>-reference/server.log`.

---

## 4. Gotcha catalogue (the landmines — all already handled in the templates)

**Fixture boot (all in the copied boot block — do not remove any):**
- `check_origin: false` in the endpoint config, or the WebSocket 403s.
- `<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()}>` in `<head>`,
  `plug :protect_from_forgery` in the pipeline, and `params: {_csrf_token: csrf}` on the
  `LiveSocket` — miss any and LiveView refuses the socket.
- Kill a stuck fixture with `kill $(lsof -nP -t -iTCP:<port> -sTCP:LISTEN)` — `pkill -f`
  does **not** kill the BEAM node the launcher spawned (port stays in use → next boot
  `:eaddrinuse`).
- Wait for boot by polling `curl -sf http://127.0.0.1:<port>/`, not a fixed sleep.

**Playwright:**
- Use `jsClick` (synthetic `el.click()` via `evaluate`) to trigger server patches — a
  real `page.click()` **moves focus to the clicked button** and will corrupt any
  focus/aria assertion. Use a real `page.click()` only when you are deliberately
  testing the trigger or focus behavior.
- A closed popover is `display:none`; `waitForSelector` defaults to *visible* and hangs.
  Use `{ state: 'attached' }`.
- A modal `<dialog>` blocks real pointer clicks on background elements; `jsClick`
  (synthetic) still reaches them. To test inertness, check `document.elementFromPoint`
  hit-testing and whether a background element can take `.focus()`, not a real click.
- `popover="auto"` light-dismisses on real outside pointer interaction; `jsClick`
  (synthetic) usually does not — so patches via `jsClick` don't close it, but a real
  click would. Choose `mode` deliberately.
- For deterministic async ordering (Channel), use a **server-side query/release gate**
  (see `combobox-spike/app.exs`), never real timing/sleeps.
- **`page.hover()` can silently no-op** for hover-triggered components: after a page
  reload the layout is identical, so if the simulated OS cursor is already at the target
  element's coordinates, no `pointermove` fires and `mouseenter` never dispatches (seen
  deterministically on Firefox) — the component looks dead through no fault of its own.
  Move the cursor to a neutral point first: `await page.mouse.move(0, 0)` before every
  `page.hover(...)`. Applies to any hover-driven component (tooltip and future ones).

**Cross-browser:**
- **WebKit does not move focus to a `<button>` on click** (Chromium/Firefox do). For an
  `aria-activedescendant` component whose keyboard target is a click-opened trigger, the
  trigger receives no keydowns on WebKit → the cursor looks dead. Fix: a **one-time
  `trigger.focus()` on the native `toggle`/open event** (a user-gesture focus handoff,
  allowed) — **never** a refocus in `updated()`/on a patch (focus theft, forbidden). See
  CURSOR_CONTRACT.md §"Focus handoff". This bites every cursor-over-click-trigger
  component (menu/select/combobox/command-palette), so handle it in the hook up front.

**morphdom / LiveView:**
- Node identity survives non-destructive patches (observed, not guaranteed — the fuse
  is why). `push_navigate` and remove/reinsert replace nodes (destructive controls).
- A client-set attribute the server template does not carry is stripped on the next
  patch that touches the element → rule 1.3.

---

## 5. Determinism & honesty requirements (non-negotiable)

- 2 runs × 3 engines; assert run1 === run2 per engine. Flaky = not done.
- Never claim a result you did not run. Paste/keep the driver output.
- Never copy the driver's version-specific numbers into `SPEC.md`/`KERNEL.md`/contracts.
- If a conformance point fails, the component is amber or red — **update the
  classification, not the claim.** A failing point is a finding, not a bug to hide.

### What the conformance driver tests — and does not

The driver tests **primitive invariants** (interaction physics: does open state
survive a patch, does the cursor collapse deterministically, is aria live). It does
**not** test business workflows. This boundary is load-bearing for adoption:

> **conformance harness = interaction physics · product E2E = business workflow.**

A product E2E should **depend on a conformance-passed primitive and assume its
mechanics**, not re-test browser mechanics in every workflow. Re-litigating "does the
menu stay open under a patch" inside each product E2E is exactly what makes product
E2E slow and flaky. Prove the primitive once here; let E2E test only "did the user
complete the flow." Do not merge the two layers.

---

## 6. App Dogfood mode — conformance against an app's own component

The reference-component recipe above dogfoods a **package** component
(`Code.require_file` + the shipped hook). To dogfood a **host app's** component
(e.g. `HankenWeb.Components.HankenUI.SomeMenu`), you cannot `Code.require_file` it in
isolation — it needs the app's deps, assigns, LiveView context, and its **real
compiled hook** (registered in the app's `app.js`, possibly colocated). Do **not**
re-implement the component in a fixture; that tests a copy, not the shipped code.

The honest, faithful pattern (a scaffold, not magic auto-generation):

1. **Add a dev-only isolation route in the app** (behind `if Mix.env() in [:dev, :test]`)
   that renders the real component in a minimal LiveView, with the standard patch-trigger
   buttons (`btn-content` / `btn-attr` / `btn-sib`, plus machine-specific ones) beside it.
   This reuses the app's real component, its assigns, and — because it loads the app's
   own `app.js` — its **real registered hooks**. That is the whole point: same compiled
   code paths as production.
2. **Point a conformance driver at that route** (a `.mjs` following the §4 driver
   template; native-state reads, JS expando, self-assert, 2×3 engines). It navigates to
   the app's dev route instead of a package fixture.
3. **Run it against the app** by booting the app's own server (`mix phx.server` /
   the app's test server) and driving the dev route. The audit
   (`mix live_interaction_contracts.audit --path <app>` — **a shipped task**, see
   README) tells you *which* app components to point this at first. (By contrast,
   full fixture auto-generation is **not** shipped — see the backlog note below.)

What this gives that the package fixture cannot: it exercises the app's real assigns,
real hook, real asset pipeline, and the app's actual LiveView version. What it costs:
the app owns the dev route and the driver is app-specific — a scaffold to copy, not a
one-command generator. (Full auto-generation of the fixture from a component module is
a backlog item; it needs the component's required assigns/hooks, which are not
statically knowable.)

Classification and the two axes apply identically to app components. If an app
component fails a conformance point, that is a real finding in the app — report it,
do not weaken the driver.

---

## 7. Suggested order

`menu` → `tooltip` → `select` → `combobox` (productionize the existing spike) →
`tabs/accordion` (own design). `menu` first: it is the first reuse of the popover
anatomy *composed with* the cursor contract — if the anatomy model holds there, the
rest is mechanical.
