Current section
Files
Jump to
Current section
Files
live_interaction_contracts
BUILDING_COMPONENTS.md
BUILDING_COMPONENTS.md
# 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.