← Choice Health companion-surface-design-v0.md raw .md

Companion Surface Design, v0 — the caller's second screen

Status: design handoff for the implementing Claude — open decisions marked OD-CSn Date: 2026-07-02 Responds to: 2026-07-02-companion-surface-fable5-brief.md; supersedes the bespoke Space Channel page as the caller-facing target Depends on: companion-channel-design (link/token/receipt), remote-control-design (InputEvent, arbiter, surface schema, capability tiers), scheduler-design (offers as quotes), quality-contract (RunRecords), flow-blueprint-shift-left (skeleton-provided correction routes) Two invariants carried throughout: the call is always voice-complete (I7), and every web action is just another deterministic input to the same router (I8/R6).


1. Product principle and use cases

Principle: the page is a live sign board plus a shared memory. It renders exactly what the compiled artifact says the current station offers (never inventing controls — R4), and it externalizes what RIFF believes so the caller can catch errors before they become bookings. Its core loop is bidirectional reflection:

Use cases A–F from the brief are adopted verbatim as the scope: menu mirror, offers-as-buttons, visible facts, form assist, progress/outcome, receipt.

2. Current-state assessment

The foundation is real and tested: InputEvent normalization + InputArbiter (first-valid-wins, stale/locked/invalid drops), the flag-gated companion_control WS path with dedupe and per-call scoping, riffc surface emission with L0-10/11/12, public scoped endpoints, and endpoint/control test suites. The gaps are exactly the brief's list: _companion_spec_provider returns None (control frames drop cleanly but never route), the page is Space Channel-bespoke with a hardcoded label map, no generic surface renderer, no tap send path, no forms, no trust panel, no offer rendering. Assessment: this is a wiring-and-renderer project, not an architecture project — the design below adds no new engine concepts, only the caller-facing contract and its rendering rules.

3. Caller page information architecture

Three fixed bands, one mobile column, one-handed (R13):

┌──────────────────────────────┐
│ ① IDENTITY   Bella's · ● live │   tenant name/logo · status dot
├──────────────────────────────┤
│ ② NOW (dominant)              │   the active surface card —
│    renders §4 by surface kind │   the ONLY primary-action zone
├──────────────────────────────┤
│ ③ WHAT WE HAVE                │   trust panel: fact chips
│    ✓ Problem: water heater    │   (confirmed / heard / pending)
│    ◌ Address: 114 Red River…  │   chips carry a "fix" affordance
│    · Callback: pending        │
└──────────────────────────────┘

Rules: exactly one primary-action zone (the NOW card); the facts band never contains primary actions except per-chip "fix"; transient alerts (stale tap, reconnecting) render as a slim banner between ① and ②, never as modals; at call_end the receipt replaces the whole column. Buttons ≥48px targets, ≥16px body text, 4.5:1 contrast, DTMF order preserved, aria-live="polite" on the NOW card, motion respects prefers-reduced-motion.

Fact states (answers Q2/Q3, encodes R8):

4. Screen designs by surface kind

5. Control-event and active-surface contract

Adopt the brief's frames with these deltas:

surface_state (server→client), the single rendering input:

Client→server: tap and submit exactly as the brief's frames (client_seq, decision_epoch, node — R5). New third type: fix {slot} — the correction request (§9). No text type in v0 (OD-R3 stands).

Routing: all three land in the existing companion_control seam → InputEvent → the shared InputArbiter (build item: the live session must share one arbiter across voice/DTMF/tap — brief §10 item 5 — and _companion_spec_provider must return the active node's locked-choice spec, item 4). Nothing new; the seam exists and is tested.

6. Privacy, PII, token, role, session pinning

All inherited and restated as the checklist the implementation is tested against: server-side projection only (R7 — the caller stream never contains confidences, heard_open, tool args, or other calls' data); slot visibility is declarative — slot_types gains caller_visible: bool (default true) and caller_display formatting hints (phone formatting etc.), and the facts projection consumes them; tokens/roles/pinning per companion-channel §4 (pinning live-phase only; receipt shareable with minimal PII); wrong ?call= yields nothing (existing test stands); observer role gets the engineer stream, never this page.

7. Audio / read-along contract (answers Q14)

Pre-rendered assets are the timing spine (R12): menus, holds, confirms, read-backs, link-sent/failed, and safety prompts ship as compiled assets with text + duration_ms; the page renders the text and animates progress locally from started_at_seq — no audio streaming to the page, ever. Slot-TTS fragments render as the template text with bound values. The page never advances the NOW card ahead of the server frame (R3): trace may know the next state, but surface_state for epoch N+1 is withheld until the engine commits the transition — the existing say-watchdog already bounds that. Progress bars are cosmetic; epochs are truth.

8. Scheduler-offer rendering contract

Covered in §4-choice, plus: offers arrive in surface_state already shaped by the Do's tool_result (offer_ids + display fields — the page computes nothing about time); a tap that loses the re-validation race (offer taken mid-decision) is not an error surface — the flow's compiled offer_invalid → re-offer route (blueprint L0-16) drives the next surface_state, and the page just renders it; a caller volunteering a different time does it by voice in v0 — the screen offers no freeform time entry (R10; revisit with text events).

9. Error, stale-input, and correction behavior

10. Build order for the implementing Claude

Adopt the brief's §10 sequence with two re-orderings and the reasons: (4) live spec provider and (5) shared arbiter move ahead of (3) tap send — taps without a routing spec drop silently and prove nothing; wire the seam, then the buttons. And (1) active surface provider should emit the facts three-state shape from day one, even before the trust panel renders it, so RunRecords capture fact transitions from the first live test. Resulting order: surface provider → generic renderer (media/menu first) → spec provider + shared arbiter → tap path → submit + forms → trust panel → offers → receipt swap-in → fix routes (blueprint-gated) → evidence hardening.

11. Acceptance tests and RunRecord evidence

The brief's §11 list is adopted in full. Additions binding it to the Quality Contract: every acceptance run emits a RunRecord whose evidence includes rendered-surface ids + epochs (server-logged at emission, so "what the page showed" is provable without trusting the client), all input_dropped events with reasons, audio started_at_seq/duration vs. audio_done deltas (read-along sync evidence), and fact-state transition events. Parity evidence (answers Q15): L1 gains a companion persona — a scripted tap driver alongside the voice/DTMF personas — and parity means: same scenario driven by voice vs. taps yields identical route sequences, identical terminal, identical booked offer_id; the parity comparison is a Layer-1 invariant (INV: modality parity), so a divergence is a hard fail with class transport or flow_logic, not a judgment call.

12. Open decisions with defaults

id decision default
OD-CS1 Raw transcript Off in v0; script text + facts only. Revisit only with a privacy design.
OD-CS2 Link trigger Offer-in-flow; add contextual offers at high-value states (entering address collect, offer presentation: "I can text you a link so you can see these times") as compiled blueprint states. Auto-send stays tenant-config, off.
OD-CS3 Typed input Typed submit for typed slots only; voice fallback mandatory (unchanged).
OD-CS4 Second device Pin live; receipt shareable with minimal PII (unchanged).
OD-CS5 Branding Theme tokens only {name, logo, color pair, contact}; compile-time contrast check on the tenant color pair (an inaccessible theme is an L0 error, not a support ticket). No renderer forks.
OD-CS6 Commit challenge Amended to match OD-R1: v1 booking confirms are tap-tier (read-back + explicit yes is the safety); challenge activates with cancel/reschedule/payment. The flip is a one-line artifact change (surface.tier: commit), designed and tested now, dormant.
OD-CS7 Tool status Caller-safe progress only (unchanged).
OD-CS8 Web-only calls Out of v0 (unchanged).
OD-CS9 Correction scope Tiers 1–2 in v0; tier 3 (fix_{slot}) ships with the booking blueprint, not per-flow.
OD-CS10 Tap during audio Allowed = barge-in (barge_in {via: web_tap}), consistent with DTMF barge-in.
OD-CS11 Optimistic rendering Never advance the card client-side; pending state only.