Companion controller protocol (phase note, 2026-06-11) (Snow Globe Harvard Spheres)

**Note** from Bead: Snow Globe Harvard Spheres · [canonical source](https://redfish.acequia.io/guerin/.agents/6a8f0a99-c255-4a5c-9c4a-9097d0ee9cd8/2026-06-11/notes/02-companion-controller-protocol.md) · session 2026-06-11 · discussion: Talk: Snow Globe Harvard Spheres

Stephen's directive: *another phase will be to have any media being displayed specify the companion controller that specifies what runs on the controller screen.*

## The idea Today the controller's affordances are fixed (Now Playing / Spin Globe / Add Content) regardless of what's on the sphere. The companion-controller phase inverts that: **the media item declares its own control surface**, and every connected controller renders it while that item is active. Spin-the-globe stops being hardwired UI and becomes merely the *default companion* for surface-skin content. This is the workspace's resource-negotiation pattern applied to the sphere: the item resource carries a pointer to its own UI affordances, negotiated at display time — not a controller reified around one content type.

## Schema Item JSON (in `playlists/items/`) gains one optional field: ```json { "id": "snow-globe-lunar", "type": "iframe", "url": "…/snow-globe.html?model=lunar.glb", "companion": { "url": "…/snow-globe-companion.html", "label": "Snow Globe", "mode": "tab" } } ``` - `companion.url` — page loaded in an iframe on the controller while this item is active. The controller appends `?group=<group>&itemId=<id>` so the companion joins the same acequia group. - `label` — tab caption. - `mode` — `"tab"` (add/replace a dynamic tab; default) or `"takeover"` (companion replaces the tab strip while the item plays — kiosk-style). - **Absent field = today's behavior** (Spin Globe as default companion). Fully backwards compatible: viewer ignores the field; old controllers ignore it too.

## Controller behavior 1. Controller already tracks `playlistId`/`currentIndex` via group state — on item change, look up the active item's `companion`. 2. Present → mount the companion iframe in a dynamic tab (or takeover); absent → show the built-in Spin Globe tab. 3. On item advance, unmount the old companion (its in-flight state writes stop mattering — see namespacing) and mount the next.

## State namespacing Group state today is flat (`xRotation`, `currentIndex`, …). Companions write **item-scoped state** to avoid collisions across items and stale writes after advance: ```json { "itemState": { "<itemId>": { "shake": 1718112000, "eyeAz": 40 } } } ``` - The displayed media (e.g. `snow-globe.html`) reads `itemState[its own itemId]` from the same group. - `xRotation`/`xRotationVelocity` remain global for back-compat — the snow globe maps them to its model turntable, so the *default* spin companion still does something sensible even for snow-globe items. - Stale entries for non-active items are garbage: the viewer (state-leader) may prune `itemState` keys for items not in the current playlist.

## Trust model The companion URL rides the item JSON through the existing propose → admin-approve flow, so approving an item *is* approving its companion. The companion runs on the visitor's phone (not the sphere), in an iframe, with no more authority than any page the visitor could open themselves — consenting-adults posture, same as `type:"iframe"` content on the sphere.

## Worked examples | Media | Companion | |---|---| | Snow globe (this bead) | Shake button (snow burst), eye-aim joystick (sets sweet-spot azimuth), model turntable | | Equirect video | Play/pause/scrub, audio-on-request | | Stellarium iframe | Sky-target picker, time-of-night slider | | Web map | Layer toggles, region bookmarks |

## Sequencing This lands as **epic E5** in [01-snow-globe-viewer-plan.md](01-snow-globe-viewer-plan.md) — after E1 (the snow globe is the first item that genuinely *needs* a companion, so it's the natural pilot), independent of the tracking epics E3/E4. It also addresses flow-weirdness #6 and #11 in [03-controller-flow-audit.md](03-controller-flow-audit.md): browse vs. control get separated because "controls for what's playing" finally has its own home.