4fba012374b9

add Change 4 UI draft — Solid DesktopPairingFlow (peach-underline-input code entry, not PeachField; not React), 6-digit entry → searching → SAS confirm → paired, wired into ChirpSync.tsx restView/pairingView; invalid_code+expired collapse to one message (no wire error frame); jsdom view-test-utils specs

time
2026-06-17 01:45 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
desktop helper pairing drafts
parent
6158d49b58eb
2 files src/drafts/DESKTOP-PAIRING/04-desktop-pairing-ui.md+495 −0src/drafts/DESKTOP-PAIRING/tests/04-desktop-pairing-ui.test.tsx+226 −0
src/drafts/DESKTOP-PAIRING/04-desktop-pairing-ui.md+495 −0
@@ -0,0 +1,495 @@1+# Change 4 — Desktop helper pairing UI2+3+> **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.7+8+This is the popup-side counterpart to the background pairing client in Change 3.9+It is a **sibling of `ChirpPairingFlow.tsx`** — same file layout (a top-level10+`Component`, a small phase state machine, a SAS-confirmation gate, friendly error11+copy, an `aria-live` region) — but driven by a typed **code** the user reads off12+their computer instead of an audio chirp.13+14+---15+16+## 0. Reality vs the brief (READ FIRST)17+18+The brief was written against React idioms. This repo is **Solid.js**. The draft19+below uses Solid, not React. Concrete deviations:20+21+| 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 |27+28+### The "PeachField" underline input — what it actually is29+30+There is no `PeachField` component. The underline-style input the brief calls for31+is a bare `<input>` carrying the `peach-underline-input` class, defined at32+`src/popup/styles/popup.css:1006`:33+34+```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+```44+45+It 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`. The47+big-glyph variant (`peach-unlock-input`, `popup.css:1014`) gives `font-size:22px;48+letter-spacing:0.24em`, which is the right look for a 6-digit code. The draft49+reuses `peach-underline-input` + a code-specific class `peach-code-input`.50+51+> 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.)57+58+---59+60+## 1. Message-name adjustments vs file 0361+62+File 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 whether65+pairing finished or failed. Reconciliation:66+67+| 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. |73+74+**Net adjustment:** brief's `{accept:true}` → `{confirmed:true}` (match 03);75+brief's three messages → 03's four (add `DESKTOP_PAIR_RESULT`). Names otherwise76+identical to both 03 and the brief.77+78+### Error-code mapping (per ground truth §0.3 and 03 `DesktopPairingError`)79+80+The background's `DesktopPairingError` union (03, lines ~92-97) maps to copy:81+82+| 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" |89+90+> **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.98+99+---100+101+## 2. Where this slots into the Add-Device UI102+103+### Parent file and exact spot104+105+The existing pairing entry point is **`src/popup/views/ChirpSync.tsx`** — the106+Settings → Sync surface. Its `restView()` (`ChirpSync.tsx:300-426`) is the107+"rest" mode that lists paired devices and offers pairing. The single pairing CTA108+is the **"Pair a device" button block** at `ChirpSync.tsx:412-423`:109+110+```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+```118+119+Today that button is the *only* "Add Device" affordance and it goes straight into120+the chirp flow. The Desktop Helper option slots in here as a **second choice121+alongside the chirp button**, inside this same `peach-chirp-action` block. Two122+ways to wire it (pick one at integration):123+124+- **(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.130+131+The draft component `DesktopPairingFlow` itself **also includes a built-in132+mode-select header** (chirp vs desktop) so it can be the whole surface if the133+integrator 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)**, which136+already does `<Show when={sasCode()} fallback={<ChirpPairingFlow .../>}>`. The137+desktop flow is rendered the same way: when the user picks Desktop Helper,138+`pairingView()` renders `<DesktopPairingFlow .../>` instead of (or in addition139+to) `<ChirpPairingFlow .../>`.140+141+### Wiring sketch in ChirpSync.tsx (integration TODO — NOT applied)142+143+```tsx144+// new signal alongside the others (ChirpSync.tsx ~line 51)145+const [pairKind, setPairKind] = createSignal<"chirp" | "desktop">("chirp");146+147+// 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>155+156+// 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+```162+163+`DesktopPairingFlow` is **self-contained** for the message round-trip: it sends164+`PAIR_WITH_DESKTOP`, listens for `DESKTOP_PAIR_SAS` / `DESKTOP_PAIR_RESULT`165+pushes via `chrome.runtime.onMessage`, and replies `DESKTOP_PAIR_CONFIRM`. It166+does **not** reuse ChirpSync's `sasResolve` plumbing (that one is in-popup; this167+SAS arrives as a push from the background, per 03's split where the background168+runs the whole pairing).169+170+---171+172+## 3. Popup ⇄ background message contract (must match 03)173+174+```ts175+// popup → bg : start pairing with the typed code176+{ type: "PAIR_WITH_DESKTOP", payload: { code: string } }177+//   resp: { ok: true } | { ok: false, error: DesktopPairingError }178+179+// bg → popup (push) : SAS ready, show confirm gate180+{ type: "DESKTOP_PAIR_SAS", payload: { sas: string } }181+182+// popup → bg : user accepted/rejected the SAS183+{ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } }184+//   resp: { ok: true }185+186+// bg → popup (push) : terminal result187+{ type: "DESKTOP_PAIR_RESULT",188+  payload: { status: "complete", device: PairedDevice }189+         | { status: "error", error: DesktopPairingError } }190+```191+192+`sendMessage` is the existing bridge (`src/shared/bridge.ts:13`); pushes are193+received with `chrome.runtime.onMessage.addListener` exactly as `App.tsx:601`194+does. `DesktopPairingError` is defined in 03 (`desktop-pairing.ts`).195+196+---197+198+## 4. Apply-ready component — `src/popup/components/DesktopPairingFlow.tsx`199+200+```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.214+215+import { Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";216+import { sendMessage } from "../../shared/bridge";217+import type { PairedDevice } from "../../shared/sync-types";218+219+// Mirrors DesktopPairingError in 03 (src/background/desktop-pairing.ts).220+type DesktopPairingError =221+  | "invalid_code"222+  | "no_host_found"223+  | "handshake_failed"224+  | "sas_mismatch"225+  | "vault_locked";226+227+// Local UI phase machine (the analogue of PairingPhase for the chirp flow).228+type 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)234+235+export 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+}241+242+const PAIRED_DISMISS_MS = 2_000;243+const CODE_LEN = 6;244+245+function 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+}261+262+export 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);269+270+  const codeReady = () => /^\d{6}$/.test(code());271+272+  // 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+  });290+291+  // 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+  });297+298+  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+  }317+318+  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+  }330+331+  function reset(): void {332+    setPhase("enter");333+    setSas(null);334+    setDevice(undefined);335+    setError("unknown");336+  }337+338+  function onCodeInput(value: string): void {339+    setCode(value.replace(/\D/g, "").slice(0, CODE_LEN));340+  }341+342+  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>353+354+      {/* ---- 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>390+391+      {/* ---- 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>402+403+      {/* ---- 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>426+427+      {/* ---- 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>439+440+      {/* ---- 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>451+452+      {/* 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+};457+458+function 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+```468+469+> 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).476+477+---478+479+## 5. Integration TODOs (NOT applied — flagged for the integrator)480+481+1. **Add the three message cases to `messages.ts`** per 03 §"Wiring" (the482+   background side). This component assumes those handlers exist.483+2. **Insert the Desktop Helper affordance** into `ChirpSync.tsx` `restView()`484+   action block (lines 412-423) and render `<DesktopPairingFlow/>` from485+   `pairingView()` — see §2.486+3. **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).489+4. **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.492+5. **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.

view file

src/drafts/DESKTOP-PAIRING/tests/04-desktop-pairing-ui.test.tsx+226 −0
@@ -0,0 +1,226 @@1+// @vitest-environment jsdom2+//3+// DRAFT spec for DesktopPairingFlow (Change 4). Apply-ready — do NOT run from4+// the drafts folder; on apply it lives next to the component as5+// src/popup/components/DesktopPairingFlow.test.tsx, and the import paths below6+// are written for THAT location (./DesktopPairingFlow, ../views/view-test-utils,7+// ../../shared/* ).8+//9+// Mirrors the repo's existing component-test idiom (ChirpPairingFlow.test.tsx):10+//   - @vitest-environment jsdom11+//   - renderView / inputText from view-test-utils (NOT @solidjs/testing-library)12+//   - raw DOM queries + .click()13+//14+// chrome messaging is mocked exactly the way the popup talks to it:15+//   - shared/bridge `sendMessage` is mocked (popup -> bg)16+//   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can17+//     fire DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes (bg -> popup)18+19+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";20+21+// --- mock the background bridge (popup -> bg) -------------------------------22+const sendMessage = vi.fn(async (_msg: unknown) => ({ ok: true }) as { ok: boolean; error?: string });23+vi.mock("../../shared/bridge", () => ({ sendMessage }));24+25+import { DesktopPairingFlow } from "./DesktopPairingFlow";26+import type { PairedDevice } from "../../shared/sync-types";27+import { renderView, inputText } from "../views/view-test-utils";28+29+// --- fake chrome.runtime push channel (bg -> popup) ------------------------30+type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;31+let listeners: RuntimeListener[] = [];32+33+function pushFromBackground(type: string, payload: unknown): void {34+  // Solid effects/handlers run synchronously; the listener registered in35+  // onMount updates the signals immediately when invoked.36+  for (const l of [...listeners]) l({ type, payload });37+}38+39+beforeEach(() => {40+  listeners = [];41+  sendMessage.mockClear();42+  sendMessage.mockResolvedValue({ ok: true });43+  (globalThis as unknown as { chrome: unknown }).chrome = {44+    runtime: {45+      onMessage: {46+        addListener: (fn: RuntimeListener) => { listeners.push(fn); },47+        removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },48+      },49+    },50+  };51+});52+53+describe("DesktopPairingFlow", () => {54+  const cleanups: Array<() => void> = [];55+  afterEach(() => {56+    while (cleanups.length) cleanups.pop()!();57+  });58+59+  function mount(handlers: { onCancel?: () => void; onDone?: (d?: PairedDevice) => void } = {}) {60+    const r = renderView(61+      <DesktopPairingFlow62+        onCancel={handlers.onCancel ?? (() => {})}63+        onDone={handlers.onDone ?? (() => {})}64+      />,65+    );66+    cleanups.push(r.dispose);67+    return r;68+  }69+70+  function codeInput(container: HTMLElement): HTMLInputElement {71+    return container.querySelector("#desktop-pair-code") as HTMLInputElement;72+  }73+  function button(container: HTMLElement, label: string): HTMLButtonElement | undefined {74+    return Array.from(container.querySelectorAll("button")).find((b) => b.textContent === label) as75+      | HTMLButtonElement76+      | undefined;77+  }78+79+  it("renders a 6-digit numeric code input", () => {80+    const { container } = mount();81+    const input = codeInput(container);82+    expect(input).toBeTruthy();83+    expect(input.getAttribute("inputmode")).toBe("numeric");84+    expect(input.getAttribute("maxlength")).toBe("6");85+    // non-digits are stripped and the value is capped at 6 chars86+    inputText(input, "12ab3456789");87+    expect(input.value).toBe("123456");88+  });89+90+  it("Connect sends PAIR_WITH_DESKTOP with the typed code", async () => {91+    const { container } = mount();92+    inputText(codeInput(container), "428913");93+    const connect = button(container, "Connect")!;94+    expect(connect.disabled).toBe(false);95+    connect.click();96+    await Promise.resolve();97+    expect(sendMessage).toHaveBeenCalledWith({98+      type: "PAIR_WITH_DESKTOP",99+      payload: { code: "428913" },100+    });101+    // moves into the searching state102+    expect(container.textContent).toContain("Searching for helper…");103+  });104+105+  it("Connect stays disabled until 6 digits are entered", () => {106+    const { container } = mount();107+    inputText(codeInput(container), "123");108+    expect(button(container, "Connect")!.disabled).toBe(true);109+    inputText(codeInput(container), "123456");110+    expect(button(container, "Connect")!.disabled).toBe(false);111+  });112+113+  it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {114+    const { container } = mount();115+    inputText(codeInput(container), "428913");116+    button(container, "Connect")!.click();117+    await Promise.resolve();118+119+    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });120+121+    expect(container.textContent).toContain("Confirm this code matches on your computer:");122+    expect(container.textContent).toContain("895245");123+    expect(button(container, "Confirm")).toBeTruthy();124+    expect(button(container, "Cancel")).toBeTruthy();125+  });126+127+  it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {128+    const { container } = mount();129+    inputText(codeInput(container), "428913");130+    button(container, "Connect")!.click();131+    await Promise.resolve();132+    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });133+134+    button(container, "Confirm")!.click();135+    await Promise.resolve();136+    expect(sendMessage).toHaveBeenCalledWith({137+      type: "DESKTOP_PAIR_CONFIRM",138+      payload: { confirmed: true },139+    });140+  });141+142+  it("Cancel on the SAS gate sends DESKTOP_PAIR_CONFIRM { confirmed: false } and shows mismatch copy", async () => {143+    const { container } = mount();144+    inputText(codeInput(container), "428913");145+    button(container, "Connect")!.click();146+    await Promise.resolve();147+    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });148+149+    button(container, "Cancel")!.click();150+    await Promise.resolve();151+    expect(sendMessage).toHaveBeenCalledWith({152+      type: "DESKTOP_PAIR_CONFIRM",153+      payload: { confirmed: false },154+    });155+    expect(container.textContent).toContain("Codes don't match");156+  });157+158+  it("shows the combined 'not found or expired' copy on invalid_code (socket close)", async () => {159+    const { container } = mount();160+    inputText(codeInput(container), "000000");161+    button(container, "Connect")!.click();162+    await Promise.resolve();163+164+    // peach-sync cannot distinguish wrong vs expired on the wire (ground truth165+    // §0.3) — the background collapses both to invalid_code via a socket close.166+    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });167+168+    expect(container.textContent).toContain("Code not found or expired");169+    expect(button(container, "Try again")).toBeTruthy();170+  });171+172+  it("shows the 'no computer found' copy on no_host_found", async () => {173+    const { container } = mount();174+    inputText(codeInput(container), "111111");175+    button(container, "Connect")!.click();176+    await Promise.resolve();177+    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });178+    expect(container.textContent).toContain("Couldn't find your computer");179+  });180+181+  it("renders Paired! and the device name on a complete result", async () => {182+    const onDone = vi.fn();183+    const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: Date.now() };184+    const { container } = mount({ onDone });185+    inputText(codeInput(container), "428913");186+    button(container, "Connect")!.click();187+    await Promise.resolve();188+    pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });189+    button(container, "Confirm")!.click();190+    await Promise.resolve();191+192+    pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });193+    expect(container.textContent).toContain("Paired!");194+    expect(container.textContent).toContain("Connected to June's iMac");195+  });196+197+  it("auto-dismisses after the paired state settles", async () => {198+    vi.useFakeTimers();199+    try {200+      const onDone = vi.fn();201+      const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };202+      const { container } = mount({ onDone });203+      inputText(codeInput(container), "428913");204+      button(container, "Connect")!.click();205+      await Promise.resolve();206+      pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });207+      button(container, "Confirm")!.click();208+      await Promise.resolve();209+      pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });210+      vi.advanceTimersByTime(2000);211+      expect(onDone).toHaveBeenCalled();212+    } finally {213+      vi.useRealTimers();214+    }215+  });216+217+  it("surfaces a synchronous start failure as an error phase", async () => {218+    sendMessage.mockResolvedValueOnce({ ok: false, error: "vault_locked" });219+    const { container } = mount();220+    inputText(codeInput(container), "428913");221+    button(container, "Connect")!.click();222+    await Promise.resolve();223+    await Promise.resolve();224+    expect(container.textContent).toContain("Unlock Peach first");225+  });226+});

view file