# Spectra Scope — Collaboration Protocol (v1, 2026-08-14)

Authoritative spec for the co-op layer. Server (`collab-server/`) and face
client (`router-scope/src/collab.js`) both implement THIS document; neither
invents beyond it. Owner rulings: share links carry a role chosen at link
creation — **control** (full app control) or **observe** (watch only); every
remote user's mouse cursor is rendered on all screens with a name chip.

## Architecture

- One Rust crate `ring-ui/collab-server/` (axum + tokio): serves the face
  statically at `/` and a WebSocket at `/ws`. Also usable as a library:
  `start(face_dir, host, port) -> SessionHandle { control_url, observe_url }`
  so the Tauri app can host from inside the process.
- v1 = one session per server instance. Two tokens generated at start
  (16 hex chars each): one mapped to role `control`, one to `observe`.
- Share link form: `http://<host>:<port>/?session=<token>`. The page loads
  normally; the client reads `?session` and connects
  `ws://<same-origin>/ws?token=<token>&name=<urlencoded name>`.
- Invalid token on `/ws` → close with code 4001. Static serving works
  without a token (local solo use), but no collab without one.
- Defaults: bind `127.0.0.1`, port `8937` — both are flags (`--host`,
  `--port`, `--face`). No hardcoded machine-specific hosts/IPs anywhere
  (wide-audience law).

## Messages (JSON text frames)

Client → server:
- `{"t":"cursor","x":<0..1>,"y":<0..1>,"view":"<tab-id>"}` — normalized to
  viewport (clientX/innerWidth, clientY/innerHeight). Throttle ≥ 30 ms.
- `{"t":"state","state":{"tab":"..","panel":"..","fixture":"..","scrub":<n>}}`
  — full state vector, sent by CONTROL connections on any local navigation.
  Fields mirror the face's existing deep-link params (?tab/?panel/?fixture/
  ?scrub); absent field = unchanged.

Server → client:
- `{"t":"welcome","id":"<peer-id>","role":"control"|"observe",
   "state":{...last known state or null},
   "peers":[{"id","name","color","role"}]}` — on join.
- `{"t":"roster","peers":[...]}` — on any join/leave, full list.
- `{"t":"cursor","id":"<peer-id>","x":..,"y":..,"view":".."}` — relayed to
  everyone EXCEPT the sender.
- `{"t":"state","state":{...},"from":"<peer-id>"}` — relayed to everyone
  except the sender. Server stores it as last-known state for late joiners.
- `{"t":"denied","reason":"observe-role"}` — reply to a `state` message from
  an observe connection; the message is NOT broadcast. This is server-side
  enforcement, not client courtesy.

Server assigns peer colors at join from a fixed palette (8 distinct hues,
cycled); names come from the client (`name` query param), server trims to
24 chars, falls back to `guest-<n>`.

## Client behavior (collab.js — classic script, loads after app.js)

- No session param → does nothing (zero cost to solo use).
- Hooks the face's single navigation entry point (the same code path the
  deep-link params flow through). CONTROL role: after applying a local nav,
  send `state`. Applying a REMOTE `state` goes through the same entry point
  with an echo-guard flag so it does not re-send.
- OBSERVE role: local navigation is suppressed (controls visually disabled
  via a `collab-observer` body class + pointer handler block) and a small
  "OBSERVER" chip shows in the masthead; the view follows remote state only.
- Cursor layer: one absolutely-positioned overlay div; peers' cursors
  rendered as a colored arrow + name chip; own cursor never rendered; peers
  on a DIFFERENT tab than the local view are hidden from the overlay but
  shown dimmed in the roster. Positions are viewport-normalized
  (known v1 approximation: scroll offsets inside panels are not mapped —
  punchlisted, not hidden).
- Roster chip row in the masthead: each peer's color dot + name, role-tagged.
- Disconnect: overlay cursor removed on `roster` without that id; ws close →
  reconnect with backoff (3 tries) then honest "collab disconnected" chip.

## Tauri app integration

- `start_share(observe_only_defaults: bool)` → invokes library start; returns
  `{control_url, observe_url}`; `stop_share()`; `share_status()`.
- Share dialog in the face masthead (Tauri arm only, detected via
  `window.__TAURI__`): start/stop, both links shown with copy buttons, the
  role choice is WHICH LINK you hand out (control vs observe) — label them
  plainly. When sharing over a network the user picks bind host in the
  dialog (`127.0.0.1` default, `0.0.0.0` to expose; honest warning line).
- The served face for the in-app server comes from the app's bundled
  resources copy of `router-scope/` (tauri `resources`), so browser viewers
  see the identical face.

## Honesty rules (carried from the face constitution)

- A role is enforced, never decorative: observe connections firing `state`
  get `denied` server-side (tested in E2E).
- No fabricated presence: roster shows only live ws connections.
- Absent states honest: no session → no collab UI beyond the Share button
  (Tauri) / nothing (browser solo).
