Packages

A real-browser external-app verifier for patch-safe Phoenix LiveView interactions, with secondary unstyled reference components.

Current section

Files

Jump to
live_interaction_contracts CURSOR_CONTRACT.md
Raw

CURSOR_CONTRACT.md

# CURSOR_CONTRACT (v1, frozen)
Cursor is client-owned coordination state over server-rendered, identity-bearing
children. It is the one place JavaScript owns interaction state; everything else is
delegated or observed.
## Contract
A conforming Cursor MUST:
1. **Use `aria-activedescendant` as the primary strategy.** Focus stays on a
persistent input or container; the active option is referenced by logical id.
2. **Not use roving tabindex as the default.** A focused non-form element is blurred
to `<body>` when a LiveView patch touches its subtree — LiveView's focus
preservation covers form inputs only. Roving tabindex is therefore not patch-safe
and MUST NOT be the default strategy. (It may be offered only where the item set is
known never to be server-patched while focused — a narrow case.)
3. **Keep the active descendant pointed at a live node, or collapse
deterministically.** After any result/child change, `aria-activedescendant` must
either reference an existing node or be cleared by a documented, deterministic
policy. It MUST NEVER reference a removed or stale node.
4. **Never re-assert focus on every patch.** Re-focusing the active item in
`updated()` is the focus-stealing anti-pattern; it is forbidden. This is *why* the
aria-activedescendant model is mandated — it needs no per-patch focus assertion.
## Focus handoff (cross-browser) — allowed vs forbidden
The `aria-activedescendant` model requires the keyboard target (the input or the
trigger/container the hook listens on) to **hold focus**, or the arrow/Enter keydowns
never arrive. **WebKit does not move focus to a `<button>` on click** (Chromium and
Firefox do), so a menu/select trigger opened by a click would receive no keys on
WebKit and the cursor would appear dead.
The fix — and the boundary — is:
- **Allowed: a one-time focus handoff on the user's open gesture.** On the native
`toggle`/open event, call `trigger.focus()` **once**. This is completing a
user-initiated gesture and normalizing browser interop; it makes "the keyboard
target holds focus" true on every engine.
- **Forbidden: re-focusing on every patch.** A `trigger.focus()` (or item focus) in
`updated()`, or on any server patch, is focus-stealing — it yanks focus back from
wherever the user moved it and breaks the ownership model (rule 4).
> **The line: focus handoff on a user gesture is fine; refocus on a patch is not.**
> The first is browser-interop completion; the second is focus theft.
A conforming cursor component that opens on click MUST do the one-time open-gesture
focus handoff (or the keyboard is broken on WebKit), and MUST NOT refocus on patches.
## Persistence requirements (from Interaction Persistence, client tier)
Cursor state is client-owned and NOT preserved by LiveView across node replacement.
A conforming Cursor MUST supply explicit rebind machinery:
- **When a child node is replaced but its logical id survives** (e.g. a tag change in
the result markup): re-project presentation (`data-highlighted`, aria) onto the new node
by logical id, in the container hook's `updated()`.
- **When the container/hook node itself is replaced**: recover the active logical id
from **external storage keyed by the logical container id** on `mounted()`. Hook
instance state does not cross node replacement.
Both are userland machinery. LiveView contributes node replacement and the
`updated()`/`mounted()` lifecycle; it does **not** rebind by logical identity.
## Collapse policy
The reference implementation collapses to **clear (no highlight)** when the active id
is absent from new results. `first-item` and `nearest-surviving` are permitted
alternatives; the choice MUST be deterministic and documented. `none/clear` is the
default because it cannot produce a highlight the user did not choose and cannot
dangle.
## Non-goals
No styling. No keyboard-shortcut catalog. No public Cursor component in v1 — this is a
behavior contract, verified by `cursor-spike/` and `combobox-spike/`, not a package.
*Evidence: `reports/CURSOR_REBIND_SPIKE_REPORT.md`, `reports/COMBOBOX_SPIKE_REPORT.md`.*