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```tsx144145const [pairKind, setPairKind] = createSignal<"chirp" | "desktop">("chirp");146147148149<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>155156157158<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```ts175176{ type: "PAIR_WITH_DESKTOP", payload: { code: string } }177178179180{ type: "DESKTOP_PAIR_SAS", payload: { sas: string } }181182183{ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } }184185186187{ 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```tsx201202203204205206207208209210211212213214215import { Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";216import { sendMessage } from "../../shared/bridge";217import type { PairedDevice } from "../../shared/sync-types";218219220type DesktopPairingError =221 | "invalid_code"222 | "no_host_found"223 | "handshake_failed"224 | "sas_mismatch"225 | "vault_locked";226227228type DesktopPhase =229 | "enter" 230 | "searching" 231 | "confirm" 232 | "paired" 233 | "error"; 234235export interface DesktopPairingFlowProps {236 237 onCancel: () => void;238 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 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 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 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 308 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 322 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 {}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 {}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 {}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 {}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 {}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 {}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.