# REFERENCE_POPOVER_CONTRACT (v1, frozen)

Popover is the **first green reference machine** — the cleanest case of safe
delegation, and the template for how a delegated machine should be contracted. v1
freezes the *contract* and the headless reference component API, not a styled helper.

## Why popover is green

- **Open state is browser-owned** and lives in the **top layer + `:popover-open`
  pseudo-class** — not in any server-renderable attribute — so reconciliation cannot
  touch it.
- Openness survived every tested non-destructive patch, including a
  disconnect/reconnect cycle and wholesale content replacement, on all three engines.
- The server can observe `toggle` only as **lossy telemetry**.

## Contract

A conforming popover delegation MUST:

1. **Keep open state browser-owned.** The server MUST NOT own or render popover
   open/close state, and MUST NOT drive it by asserting an attribute in markup.
2. **Server-render the `popover` attribute as a static constant.** It is a structural
   attribute the machine requires; a client-added one is stripped by reconciliation
   (same mechanism that makes dialog red). It must be constant in the server template.
3. **Server-render the content.** Content inside the popover stays HEEx/streams with
   authorization intact; the popover is a shell, not an island.
4. **Treat toggle observation as lossy.** The server MAY lazy-load or clean up on an
   observed toggle, but MUST NOT derive canonical state or authorization from it (see
   `../archive/observation-spike/OBSERVATION_SPIKE_REPORT.md`).
5. **Give the node a stable id** so its identity persists across patches.

## Destructive boundaries (documented, not hidden)

- `push_navigate` replaces the node → openness lost (full-view replacement).
- Removing the element from the DOM drops top-layer membership even if the same node
  object is reinserted.

Both are accepted; they are not delegation failures, they are the platform's rule that
top-layer membership requires continuous DOM presence.

## HEEx-native anatomy (the design decision, solved)

React headless libs compose parts via implicit context
(`<Popover.Root><Popover.Trigger/><Popover.Popup/></Popover.Root>`). **HEEx has no
implicit context**, so parts cannot discover each other. The Phoenix-native answer,
now implemented and conformance-backed (`lib/.../components/popover.ex`):

> **one function component + named slots + a single explicit `id`; the component
> derives all wiring from that id.**

```heex
<.popover id="user-menu" role="menu" on_open_change="menu_toggled">
  <:trigger>Open menu</:trigger>
  <:content><.link navigate={~p"/profile"}>Profile</.link></:content>
</.popover>
```

From `id` alone the component derives: the native `popovertarget` invoker, the
`popover` content element, `aria-controls`, `aria-expanded`, and the observation
hook. The caller supplies one id and two slots; contract compliance is not their
concern. This is the reusable anatomy idiom — dialog-adapter, menu (popover+cursor),
and combobox (popover+cursor+channel) all follow the same shape.

**A finding the anatomy surfaced:** `aria-expanded` reflects browser-owned openness
but is a *client-managed attribute on the trigger* — so a patch that re-renders the
trigger would strip it (the attribute-reflection rule, applied to ARIA). The
component protects it with `phx-mounted={JS.ignore_attributes(["aria-expanded"])}`
and the conformance suite proves it survives a trigger re-render on all three
engines. So even a **green machine's component** touches the amber `ignore_attributes`
technique — for its ARIA reflection, not its open state. Documented, not hidden.

## Component & helper status

The reference component (`<.popover>`, unstyled, headless) and its shipped hook
(colocated inside the component; extracted by `mix compile`) are the conformance-backed
reference — the analogue of a
Base UI headless part, founded on the contract. It is dogfooded by the `popover`
conformance suite (7/7, all engines) and gated in CI. It is **unstyled by design**;
bring classes via the slots. It is a thin derivation of this contract, not a styled
component kit.

## Positioning (v1, conformance-backed)

Anchored placement is **zero-JS** (CSS Anchor Positioning — evidence:
`reports/POSITIONING_SPIKE_REPORT.md`). Contract constraints:

1. Positioning CSS MUST be a **server-owned static attribute** (style/class emitted
   by the server template) — that is what makes it patch-safe; a client-set style
   would be stripped exactly like any client-added attribute.
2. `inset: auto; margin: 0` MUST accompany anchor positioning on a popover to
   neutralize the UA centering styles.
3. Flip behavior MUST come from `position-try-fallbacks`, never from JS.
4. Engines without support get an **unanchored but functional** popover — openness
   is independent of positioning. A JS positioning fallback is a contract violation,
   not a courtesy.

The component ships this as the `placement` attr ("bottom"/"top" — the proven
variants); the reference suite fuses glued-open, patch-stability, anchor-follow on a
patch-moved trigger, viewport-edge flip, and scroll tracking on all three engines.

*Evidence: `reports/CONFORMANCE_REPORT.md` (popover rows), `reports/COMBOBOX_SPIKE_REPORT.md` (test 7,
popover-as-listbox-shell).*
