# 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`.*
