04-desktop-pairing-ui.md

22.4 KB · raw

rendered · source

1# Change 4 — Desktop helper pairing UI23> **DRAFT.** Conforms to `_GROUND-TRUTH.md` and `03-desktop-pairing-handler.md`.4> Proposed new component:5> `/Users/june/projects/peach-browser/src/popup/components/DesktopPairingFlow.tsx`.6> Nothing here is applied — apply-ready draft only.78This is the popup-side counterpart to the background pairing client in Change 3.9It is a **sibling of `ChirpPairingFlow.tsx`** — same file layout (a top-level10`Component`, a small phase state machine, a SAS-confirmation gate, friendly error11copy, an `aria-live` region) — but driven by a typed **code** the user reads off12their computer instead of an audio chirp.1314---1516## 0. Reality vs the brief (READ FIRST)1718The brief was written against React idioms. This repo is **Solid.js**. The draft19below uses Solid, not React. Concrete deviations:2021| Brief said | Reality in this repo | What the draft uses |22| --- | --- | --- |23| React, `.tsx` with hooks (`useState`/`useEffect`) | Solid.js (`solid-js`) | `createSignal`, `createEffect`, `<Show>`, `onCleanup`, `Component` |24| `OutlinedTextField` | No such component; Peach has no Material inputs | `peach-underline-input` `<input>` (the real "PeachField") |25| `PeachField` component | **No component named `PeachField` exists** (verified — `grep -rl PeachField src` returns nothing). The underline-style field the brief means is a plain `<input class="peach-underline-input …">` | `peach-underline-input` class |26| `@solidjs/testing-library` for tests | Repo does **not** use it. Component tests use a tiny local helper `renderView` from `src/popup/views/view-test-utils.tsx` + raw jsdom | `renderView`, `inputText`, real DOM queries |2728### The "PeachField" underline input — what it actually is2930There is no `PeachField` component. The underline-style input the brief calls for31is a bare `<input>` carrying the `peach-underline-input` class, defined at32`src/popup/styles/popup.css:1006`:3334```css35.peach-underline-input {36  width: 100%; border: 0; border-bottom: 1px solid var(--peach-t3);37  padding: 6px 0; background: transparent; color: var(--peach-t0);38  outline: none;39  font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;40  font-size: 0.8125rem;41  caret-color: var(--peach-accent);42}43```4445It is used as a single class on a native `<input>` — see `Lock.tsx:326`,46`Lock.tsx:452`, `Lock.tsx:487`, and `RecoveryWordEntry.tsx:297,314`. The47big-glyph variant (`peach-unlock-input`, `popup.css:1014`) gives `font-size:22px;48letter-spacing:0.24em`, which is the right look for a 6-digit code. The draft49reuses `peach-underline-input` + a code-specific class `peach-code-input`.5051> A **single** field (not 6 auto-advancing boxes) is correct here. The repo's52> existing 6-digit SAS surface (`ChirpSync.tsx:445-472`) renders the code as one53> mono string, and the master-password/recovery entry uses one underline field.54> There is **no** existing auto-advance multi-box pattern to mirror, so a single55> `inputmode="numeric"` `maxlength=6` field matches Peach's form style and is56> simpler. (Documented per brief.)5758---5960## 1. Message-name adjustments vs file 036162File 03 §"New message types" defines four messages. The brief named three63(`PAIR_WITH_DESKTOP`, `DESKTOP_PAIR_SAS`, `DESKTOP_PAIR_CONFIRM`). This UI uses64**all four from 03**, because the popup needs the terminal push to know whether65pairing finished or failed. Reconciliation:6667| Brief name | 03 name | Used here | Notes |68| --- | --- | --- | --- |69| `PAIR_WITH_DESKTOP` (popup→bg) | `PAIR_WITH_DESKTOP` | yes | payload `{ code }`; resp `{ ok }` or `{ ok:false, error }` |70| `DESKTOP_PAIR_SAS` (bg→popup push) | `DESKTOP_PAIR_SAS` | yes | payload `{ sas }` |71| `DESKTOP_PAIR_CONFIRM` (popup→bg) | `DESKTOP_PAIR_CONFIRM` | yes | **payload `{ confirmed: boolean }`** — 03 uses `confirmed`, the brief wrote `{accept:true}`. The draft follows 03 (`confirmed`). |72| — | `DESKTOP_PAIR_RESULT` (bg→push) | yes | 03-only; payload `{ status:"complete", device } \| { status:"error", error }`. The brief had no terminal push; without it the popup can't distinguish "Paired!" from a late error, so it is required. |7374**Net adjustment:** brief's `{accept:true}` → `{confirmed:true}` (match 03);75brief's three messages → 03's four (add `DESKTOP_PAIR_RESULT`). Names otherwise76identical to both 03 and the brief.7778### Error-code mapping (per ground truth §0.3 and 03 `DesktopPairingError`)7980The background's `DesktopPairingError` union (03, lines ~92-97) maps to copy:8182| Background error | Friendly copy |83| --- | --- |84| `invalid_code` | **"Code not found or expired"** |85| `no_host_found` | "Couldn't find your computer" |86| `handshake_failed` | "Pairing failed — try again" |87| `sas_mismatch` | "Codes don't match" |88| `vault_locked` | "Unlock Peach first, then pair" |8990> **invalid_code = "not found or expired", combined on purpose.** Ground truth91> §0.3 / §2: peach-sync sends **no error frame** — a wrong *or* expired code both92> surface identically as a socket close before `PEACH_HELLO_ACK`. The wire cannot93> distinguish them, so the background collapses both to `invalid_code` and the UI94> shows a **single** "Code not found or expired" message. The brief's separate95> "Code not found" (invalid_code) and "Code expired" (expired) cannot be told96> apart on this wire and are merged. If a future protocol revision adds an97> explicit expiry frame, split the copy then.9899---100101## 2. Where this slots into the Add-Device UI102103### Parent file and exact spot104105The existing pairing entry point is **`src/popup/views/ChirpSync.tsx`** — the106Settings → Sync surface. Its `restView()` (`ChirpSync.tsx:300-426`) is the107"rest" mode that lists paired devices and offers pairing. The single pairing CTA108is the **"Pair a device" button block** at `ChirpSync.tsx:412-423`:109110```tsx111<div class="peach-chirp-action">112  <p class="peach-chirp-mic-notice">…</p>113  <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>114    …<span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>115  </button>116</div>117```118119Today that button is the *only* "Add Device" affordance and it goes straight into120the chirp flow. The Desktop Helper option slots in here as a **second choice121alongside the chirp button**, inside this same `peach-chirp-action` block. Two122ways to wire it (pick one at integration):123124- **(A) Two buttons.** Keep "Pair a device" (chirp) and add a second125  `peach-chirp-pair-start`-styled button labelled **"Desktop Helper (enter126  code)"** directly beneath it. Cheapest, matches the brief's "mode select127  entry point … alongside the existing Chirp option".128- **(B) Mode select.** Replace the single button with a tiny chooser (chirp vs129  code) that sets a `pairKind` signal. More work; same UX.130131The draft component `DesktopPairingFlow` itself **also includes a built-in132mode-select header** (chirp vs desktop) so it can be the whole surface if the133integrator routes "Pair a device" into it. Either way, the **insertion point is134`ChirpSync.tsx` `restView()` action block (lines 412-423)**, and the135**rendering point is `ChirpSync.tsx` `pairingView()` (lines 428-474)**, which136already does `<Show when={sasCode()} fallback={<ChirpPairingFlow .../>}>`. The137desktop flow is rendered the same way: when the user picks Desktop Helper,138`pairingView()` renders `<DesktopPairingFlow .../>` instead of (or in addition139to) `<ChirpPairingFlow .../>`.140141### Wiring sketch in ChirpSync.tsx (integration TODO — NOT applied)142143```tsx144// new signal alongside the others (ChirpSync.tsx ~line 51)145const [pairKind, setPairKind] = createSignal<"chirp" | "desktop">("chirp");146147// in restView()'s action block (ChirpSync.tsx 412-423), add beneath the148// existing chirp button:149<button150  class="peach-chirp-pair-start peach-chirp-pair-start--desktop"151  onClick={() => { setPairKind("desktop"); setMode("pairing"); }}152>153  <span>Desktop Helper (enter code)</span>154</button>155156// in the render switch (ChirpSync.tsx 482-493), when mode()==="pairing" and157// pairKind()==="desktop", render <DesktopPairingFlow/> instead of the chirp view:158<Show when={pairKind() === "desktop"} fallback={pairingView()}>159  <DesktopPairingFlow onCancel={() => setMode("rest")} onDone={() => { setMode("rest"); void refresh(); }} />160</Show>161```162163`DesktopPairingFlow` is **self-contained** for the message round-trip: it sends164`PAIR_WITH_DESKTOP`, listens for `DESKTOP_PAIR_SAS` / `DESKTOP_PAIR_RESULT`165pushes via `chrome.runtime.onMessage`, and replies `DESKTOP_PAIR_CONFIRM`. It166does **not** reuse ChirpSync's `sasResolve` plumbing (that one is in-popup; this167SAS arrives as a push from the background, per 03's split where the background168runs the whole pairing).169170---171172## 3. Popup ⇄ background message contract (must match 03)173174```ts175// popup → bg : start pairing with the typed code176{ type: "PAIR_WITH_DESKTOP", payload: { code: string } }177//   resp: { ok: true } | { ok: false, error: DesktopPairingError }178179// bg → popup (push) : SAS ready, show confirm gate180{ type: "DESKTOP_PAIR_SAS", payload: { sas: string } }181182// popup → bg : user accepted/rejected the SAS183{ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } }184//   resp: { ok: true }185186// bg → popup (push) : terminal result187{ type: "DESKTOP_PAIR_RESULT",188  payload: { status: "complete", device: PairedDevice }189         | { status: "error", error: DesktopPairingError } }190```191192`sendMessage` is the existing bridge (`src/shared/bridge.ts:13`); pushes are193received with `chrome.runtime.onMessage.addListener` exactly as `App.tsx:601`194does. `DesktopPairingError` is defined in 03 (`desktop-pairing.ts`).195196---197198## 4. Apply-ready component — `src/popup/components/DesktopPairingFlow.tsx`199200```tsx201// DesktopPairingFlow — the desktop-helper *code* pairing screen.202//203// Sibling of ChirpPairingFlow.tsx, but driven by a 6-digit code the user204// reads off their computer rather than an audio chirp. The whole pairing205// runs in the BACKGROUND (03); this component:206//   1. takes a 6-digit code in a single underline field (the "PeachField"),207//   2. on Connect -> sends PAIR_WITH_DESKTOP, shows "Searching for helper…",208//   3. on the DESKTOP_PAIR_SAS push -> shows the SAS + Confirm / Cancel,209//   4. on Confirm -> DESKTOP_PAIR_CONFIRM { confirmed:true } -> "Paired!",210//      on Cancel  -> DESKTOP_PAIR_CONFIRM { confirmed:false } -> error,211//   5. maps background errors (DESKTOP_PAIR_RESULT) to friendly copy.212//213// Wire contract: _GROUND-TRUTH.md §6 + 03-desktop-pairing-handler.md.214215import { Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";216import { sendMessage } from "../../shared/bridge";217import type { PairedDevice } from "../../shared/sync-types";218219// Mirrors DesktopPairingError in 03 (src/background/desktop-pairing.ts).220type DesktopPairingError =221  | "invalid_code"222  | "no_host_found"223  | "handshake_failed"224  | "sas_mismatch"225  | "vault_locked";226227// Local UI phase machine (the analogue of PairingPhase for the chirp flow).228type DesktopPhase =229  | "enter"        // user is typing the code230  | "searching"    // PAIR_WITH_DESKTOP sent, awaiting SAS/result231  | "confirm"      // DESKTOP_PAIR_SAS received, awaiting user confirm232  | "paired"       // DESKTOP_PAIR_RESULT status:complete233  | "error";       // DESKTOP_PAIR_RESULT status:error (or a start failure)234235export interface DesktopPairingFlowProps {236  /** Back/cancel out of the desktop flow (return to the device list). */237  onCancel: () => void;238  /** Pairing finished and the device was persisted; dismiss the surface. */239  onDone: (device?: PairedDevice) => void;240}241242const PAIRED_DISMISS_MS = 2_000;243const CODE_LEN = 6;244245function errorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {246  switch (error) {247    case "invalid_code":248      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.249      return { headline: "Code not found or expired", sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while." };250    case "no_host_found":251      return { headline: "Couldn't find your computer", sub: "Make sure the Peach helper is running and both devices are on the same Wi-Fi." };252    case "sas_mismatch":253      return { headline: "Codes don't match", sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully." };254    case "vault_locked":255      return { headline: "Unlock Peach first", sub: "Your vault needs to be unlocked before you can pair a device." };256    case "handshake_failed":257    default:258      return { headline: "Pairing failed", sub: "Something went wrong while connecting. Please try again." };259  }260}261262export const DesktopPairingFlow: Component<DesktopPairingFlowProps> = (props) => {263  const [phase, setPhase] = createSignal<DesktopPhase>("enter");264  const [code, setCode] = createSignal("");265  const [sas, setSas] = createSignal<string | null>(null);266  const [error, setError] = createSignal<DesktopPairingError | "unknown">("unknown");267  const [device, setDevice] = createSignal<PairedDevice | undefined>(undefined);268  const [busy, setBusy] = createSignal(false);269270  const codeReady = () => /^\d{6}$/.test(code());271272  // Listen for the two background pushes (mirrors App.tsx:601 idiom).273  onMount(() => {274    const handler = (msg: { type?: string; payload?: unknown }) => {275      if (msg?.type === "DESKTOP_PAIR_SAS") {276        const p = msg.payload as { sas?: string } | undefined;277        if (typeof p?.sas === "string") { setSas(p.sas); setPhase("confirm"); }278      } else if (msg?.type === "DESKTOP_PAIR_RESULT") {279        const p = msg.payload as280          | { status: "complete"; device: PairedDevice }281          | { status: "error"; error: DesktopPairingError }282          | undefined;283        if (p?.status === "complete") { setDevice(p.device); setPhase("paired"); }284        else if (p?.status === "error") { setError(p.error); setPhase("error"); }285      }286    };287    chrome.runtime.onMessage.addListener(handler);288    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));289  });290291  // Auto-dismiss after the paired state settles, like ChirpPairingFlow.292  createEffect(() => {293    if (phase() !== "paired") return;294    const t = setTimeout(() => props.onDone(device()), PAIRED_DISMISS_MS);295    onCleanup(() => clearTimeout(t));296  });297298  async function handleConnect(): Promise<void> {299    if (!codeReady() || busy()) return;300    setBusy(true);301    setPhase("searching");302    try {303      const res = (await sendMessage({304        type: "PAIR_WITH_DESKTOP",305        payload: { code: code() },306      })) as { ok: boolean; error?: DesktopPairingError };307      // A synchronous failure (e.g. vault_locked) comes back here; the SAS and308      // terminal result arrive later as pushes.309      if (!res.ok) { setError(res.error ?? "unknown"); setPhase("error"); }310    } catch {311      setError("unknown");312      setPhase("error");313    } finally {314      setBusy(false);315    }316  }317318  async function respondConfirm(confirmed: boolean): Promise<void> {319    if (busy()) return;320    setBusy(true);321    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the322    // final phase (paired vs sas_mismatch error).323    if (confirmed) setPhase("searching"); else { setError("sas_mismatch"); setPhase("error"); }324    try {325      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });326    } finally {327      setBusy(false);328    }329  }330331  function reset(): void {332    setPhase("enter");333    setSas(null);334    setDevice(undefined);335    setError("unknown");336  }337338  function onCodeInput(value: string): void {339    setCode(value.replace(/\D/g, "").slice(0, CODE_LEN));340  }341342  return (343    <div class="peach-chirp-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a desktop helper">344      <header class="peach-chirp-head">345        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onCancel()}>346          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">347            <path d="M10 3 L5 8 L10 13" />348          </svg>349        </button>350        <div class="peach-chirp-title">Desktop Helper</div>351        <div class="peach-chirp-head-spacer" />352      </header>353354      {/* ---- enter: 6-digit code field ---- */}355      <Show when={phase() === "enter"}>356        <div class="peach-desktop-enter">357          <div class="peach-chirp-headline">Enter the code from your computer</div>358          <div class="peach-chirp-sub">359            Open the Peach helper on your computer and type the 6-digit code it shows.360          </div>361          <label class="peach-label" for="desktop-pair-code">pairing code</label>362          <input363            id="desktop-pair-code"364            class="peach-underline-input peach-unlock-input peach-code-input"365            type="text"366            inputmode="numeric"367            autocomplete="one-time-code"368            maxlength={CODE_LEN}369            placeholder="000000"370            aria-label="6-digit pairing code"371            value={code()}372            ref={(el) => setTimeout(() => el.focus(), 80)}373            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}374            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}375          />376          <div class="peach-chirp-action">377            <button class="peach-chirp-cancel peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>378              Cancel379            </button>380            <button381              class="peach-btn peach-btn-primary peach-desktop-connect"382              disabled={!codeReady() || busy()}383              onClick={() => void handleConnect()}384            >385              Connect386            </button>387          </div>388        </div>389      </Show>390391      {/* ---- searching ---- */}392      <Show when={phase() === "searching"}>393        <div class="peach-desktop-searching">394          <div class="peach-chirp-headline">Searching for helper…</div>395          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>396          <div class="peach-chirp-action-handshake">397            <div class="peach-chirp-meter"><span class="peach-chirp-meter-bar" /></div>398            <span class="peach-chirp-meter-label">connecting · noise NK</span>399          </div>400        </div>401      </Show>402403      {/* ---- confirm: SAS gate ---- */}404      <Show when={phase() === "confirm"}>405        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px 18px;text-align:center;">406          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>407          <div408            class="peach-desktop-sas"409            style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"410          >411            {sas()}412          </div>413          <div class="peach-chirp-sub" style="max-width:240px;">414            Only continue if this 6-digit code is identical on both screens.415          </div>416          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">417            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>418              Confirm419            </button>420            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>421              Cancel422            </button>423          </div>424        </div>425      </Show>426427      {/* ---- paired ---- */}428      <Show when={phase() === "paired"}>429        <div class="peach-desktop-paired">430          <div class="peach-chirp-headline">Paired!</div>431          <div class="peach-chirp-sub">432            {device()?.deviceName ? `Connected to ${device()!.deviceName}` : "Connected to your computer"}433          </div>434          <div class="peach-chirp-action">435            <button class="peach-btn peach-btn-primary" onClick={() => props.onDone(device())}>Done</button>436          </div>437        </div>438      </Show>439440      {/* ---- error ---- */}441      <Show when={phase() === "error"}>442        <div class="peach-desktop-error">443          <div class="peach-chirp-headline">{errorCopy(error()).headline}</div>444          <div class="peach-chirp-sub">{errorCopy(error()).sub}</div>445          <div class="peach-chirp-action">446            <button class="peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>Cancel</button>447            <button class="peach-btn peach-btn-primary" onClick={() => reset()}>Try again</button>448          </div>449        </div>450      </Show>451452      {/* Polite live region so phase changes reach assistive tech. */}453      <div class="peach-sr-only" aria-live="polite">{liveText(phase(), error())}</div>454    </div>455  );456};457458function liveText(phase: DesktopPhase, error: DesktopPairingError | "unknown"): string {459  switch (phase) {460    case "enter": return "Enter the pairing code from your computer";461    case "searching": return "Searching for the helper";462    case "confirm": return "Confirm the code matches on your computer";463    case "paired": return "Paired";464    case "error": return errorCopy(error).headline;465  }466}467```468469> Reused class hooks (`peach-chirp-pair`, `peach-chirp-head`, `peach-chirp-back`,470> `peach-chirp-headline`, `peach-chirp-sub`, `peach-chirp-action`,471> `peach-chirp-meter*`, `peach-btn*`, `peach-sr-only`, `peach-underline-input`,472> `peach-unlock-input`, `peach-label`) all already exist in `popup.css`. New473> hooks (`peach-desktop-pair`, `peach-code-input`, `peach-desktop-*`) are474> additive and need a small CSS block at integration (out of scope for this475> draft — drafts touch only this folder).476477---478479## 5. Integration TODOs (NOT applied — flagged for the integrator)4804811. **Add the three message cases to `messages.ts`** per 03 §"Wiring" (the482   background side). This component assumes those handlers exist.4832. **Insert the Desktop Helper affordance** into `ChirpSync.tsx` `restView()`484   action block (lines 412-423) and render `<DesktopPairingFlow/>` from485   `pairingView()` — see §2.4863. **CSS** for the new `peach-desktop-*` / `peach-code-input` hooks in487   `popup.css` (inline styles cover the load-bearing SAS/confirm layout in the488   meantime, matching ChirpSync's inline-styled SAS gate at lines 445-472).4894. **Pro gate + lock check.** ChirpSync already gates on `props.isPro`; the490   desktop button should live behind the same gate, and `vault_locked` from the491   background is surfaced as friendly copy here.4925. **Single in-flight pairing.** 03 keeps one pairing per worker; if the user493   backs out mid-flow (`onCancel`), the integrator may want a494   `DESKTOP_PAIR_CONFIRM { confirmed:false }` or a cancel message to release the495   background resolver. Left as a TODO since 03 owns that lifecycle.