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
↓ peach-browser-4fba0123.tar.gzatlas at this save
files changed2 files
src/drafts/DESKTOP-PAIRING/04-desktop-pairing-ui.md+495 −0src/drafts/DESKTOP-PAIRING/tests/04-desktop-pairing-ui.test.tsx+226 −0src/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.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+});