# Change 4 — Desktop helper pairing UI > **DRAFT.** Conforms to `_GROUND-TRUTH.md` and `03-desktop-pairing-handler.md`. > Proposed new component: > `/Users/june/projects/peach-browser/src/popup/components/DesktopPairingFlow.tsx`. > Nothing here is applied — apply-ready draft only. This is the popup-side counterpart to the background pairing client in Change 3. It is a **sibling of `ChirpPairingFlow.tsx`** — same file layout (a top-level `Component`, a small phase state machine, a SAS-confirmation gate, friendly error copy, an `aria-live` region) — but driven by a typed **code** the user reads off their computer instead of an audio chirp. --- ## 0. Reality vs the brief (READ FIRST) The brief was written against React idioms. This repo is **Solid.js**. The draft below uses Solid, not React. Concrete deviations: | Brief said | Reality in this repo | What the draft uses | | --- | --- | --- | | React, `.tsx` with hooks (`useState`/`useEffect`) | Solid.js (`solid-js`) | `createSignal`, `createEffect`, ``, `onCleanup`, `Component` | | `OutlinedTextField` | No such component; Peach has no Material inputs | `peach-underline-input` `` (the real "PeachField") | | `PeachField` component | **No component named `PeachField` exists** (verified — `grep -rl PeachField src` returns nothing). The underline-style field the brief means is a plain `` | `peach-underline-input` class | | `@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 | ### The "PeachField" underline input — what it actually is There is no `PeachField` component. The underline-style input the brief calls for is a bare `` carrying the `peach-underline-input` class, defined at `src/popup/styles/popup.css:1006`: ```css .peach-underline-input { width: 100%; border: 0; border-bottom: 1px solid var(--peach-t3); padding: 6px 0; background: transparent; color: var(--peach-t0); outline: none; font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace; font-size: 0.8125rem; caret-color: var(--peach-accent); } ``` It is used as a single class on a native `` — see `Lock.tsx:326`, `Lock.tsx:452`, `Lock.tsx:487`, and `RecoveryWordEntry.tsx:297,314`. The big-glyph variant (`peach-unlock-input`, `popup.css:1014`) gives `font-size:22px; letter-spacing:0.24em`, which is the right look for a 6-digit code. The draft reuses `peach-underline-input` + a code-specific class `peach-code-input`. > A **single** field (not 6 auto-advancing boxes) is correct here. The repo's > existing 6-digit SAS surface (`ChirpSync.tsx:445-472`) renders the code as one > mono string, and the master-password/recovery entry uses one underline field. > There is **no** existing auto-advance multi-box pattern to mirror, so a single > `inputmode="numeric"` `maxlength=6` field matches Peach's form style and is > simpler. (Documented per brief.) --- ## 1. Message-name adjustments vs file 03 File 03 §"New message types" defines four messages. The brief named three (`PAIR_WITH_DESKTOP`, `DESKTOP_PAIR_SAS`, `DESKTOP_PAIR_CONFIRM`). This UI uses **all four from 03**, because the popup needs the terminal push to know whether pairing finished or failed. Reconciliation: | Brief name | 03 name | Used here | Notes | | --- | --- | --- | --- | | `PAIR_WITH_DESKTOP` (popup→bg) | `PAIR_WITH_DESKTOP` | yes | payload `{ code }`; resp `{ ok }` or `{ ok:false, error }` | | `DESKTOP_PAIR_SAS` (bg→popup push) | `DESKTOP_PAIR_SAS` | yes | payload `{ sas }` | | `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`). | | — | `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. | **Net adjustment:** brief's `{accept:true}` → `{confirmed:true}` (match 03); brief's three messages → 03's four (add `DESKTOP_PAIR_RESULT`). Names otherwise identical to both 03 and the brief. ### Error-code mapping (per ground truth §0.3 and 03 `DesktopPairingError`) The background's `DesktopPairingError` union (03, lines ~92-97) maps to copy: | Background error | Friendly copy | | --- | --- | | `invalid_code` | **"Code not found or expired"** | | `no_host_found` | "Couldn't find your computer" | | `handshake_failed` | "Pairing failed — try again" | | `sas_mismatch` | "Codes don't match" | | `vault_locked` | "Unlock Peach first, then pair" | > **invalid_code = "not found or expired", combined on purpose.** Ground truth > §0.3 / §2: peach-sync sends **no error frame** — a wrong *or* expired code both > surface identically as a socket close before `PEACH_HELLO_ACK`. The wire cannot > distinguish them, so the background collapses both to `invalid_code` and the UI > shows a **single** "Code not found or expired" message. The brief's separate > "Code not found" (invalid_code) and "Code expired" (expired) cannot be told > apart on this wire and are merged. If a future protocol revision adds an > explicit expiry frame, split the copy then. --- ## 2. Where this slots into the Add-Device UI ### Parent file and exact spot The existing pairing entry point is **`src/popup/views/ChirpSync.tsx`** — the Settings → Sync surface. Its `restView()` (`ChirpSync.tsx:300-426`) is the "rest" mode that lists paired devices and offers pairing. The single pairing CTA is the **"Pair a device" button block** at `ChirpSync.tsx:412-423`: ```tsx

…

``` Today that button is the *only* "Add Device" affordance and it goes straight into the chirp flow. The Desktop Helper option slots in here as a **second choice alongside the chirp button**, inside this same `peach-chirp-action` block. Two ways to wire it (pick one at integration): - **(A) Two buttons.** Keep "Pair a device" (chirp) and add a second `peach-chirp-pair-start`-styled button labelled **"Desktop Helper (enter code)"** directly beneath it. Cheapest, matches the brief's "mode select entry point … alongside the existing Chirp option". - **(B) Mode select.** Replace the single button with a tiny chooser (chirp vs code) that sets a `pairKind` signal. More work; same UX. The draft component `DesktopPairingFlow` itself **also includes a built-in mode-select header** (chirp vs desktop) so it can be the whole surface if the integrator routes "Pair a device" into it. Either way, the **insertion point is `ChirpSync.tsx` `restView()` action block (lines 412-423)**, and the **rendering point is `ChirpSync.tsx` `pairingView()` (lines 428-474)**, which already does `}>`. The desktop flow is rendered the same way: when the user picks Desktop Helper, `pairingView()` renders `` instead of (or in addition to) ``. ### Wiring sketch in ChirpSync.tsx (integration TODO — NOT applied) ```tsx // new signal alongside the others (ChirpSync.tsx ~line 51) const [pairKind, setPairKind] = createSignal<"chirp" | "desktop">("chirp"); // in restView()'s action block (ChirpSync.tsx 412-423), add beneath the // existing chirp button: // in the render switch (ChirpSync.tsx 482-493), when mode()==="pairing" and // pairKind()==="desktop", render instead of the chirp view: setMode("rest")} onDone={() => { setMode("rest"); void refresh(); }} /> ``` `DesktopPairingFlow` is **self-contained** for the message round-trip: it sends `PAIR_WITH_DESKTOP`, listens for `DESKTOP_PAIR_SAS` / `DESKTOP_PAIR_RESULT` pushes via `chrome.runtime.onMessage`, and replies `DESKTOP_PAIR_CONFIRM`. It does **not** reuse ChirpSync's `sasResolve` plumbing (that one is in-popup; this SAS arrives as a push from the background, per 03's split where the background runs the whole pairing). --- ## 3. Popup ⇄ background message contract (must match 03) ```ts // popup → bg : start pairing with the typed code { type: "PAIR_WITH_DESKTOP", payload: { code: string } } // resp: { ok: true } | { ok: false, error: DesktopPairingError } // bg → popup (push) : SAS ready, show confirm gate { type: "DESKTOP_PAIR_SAS", payload: { sas: string } } // popup → bg : user accepted/rejected the SAS { type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } } // resp: { ok: true } // bg → popup (push) : terminal result { type: "DESKTOP_PAIR_RESULT", payload: { status: "complete", device: PairedDevice } | { status: "error", error: DesktopPairingError } } ``` `sendMessage` is the existing bridge (`src/shared/bridge.ts:13`); pushes are received with `chrome.runtime.onMessage.addListener` exactly as `App.tsx:601` does. `DesktopPairingError` is defined in 03 (`desktop-pairing.ts`). --- ## 4. Apply-ready component — `src/popup/components/DesktopPairingFlow.tsx` ```tsx // DesktopPairingFlow — the desktop-helper *code* pairing screen. // // Sibling of ChirpPairingFlow.tsx, but driven by a 6-digit code the user // reads off their computer rather than an audio chirp. The whole pairing // runs in the BACKGROUND (03); this component: // 1. takes a 6-digit code in a single underline field (the "PeachField"), // 2. on Connect -> sends PAIR_WITH_DESKTOP, shows "Searching for helper…", // 3. on the DESKTOP_PAIR_SAS push -> shows the SAS + Confirm / Cancel, // 4. on Confirm -> DESKTOP_PAIR_CONFIRM { confirmed:true } -> "Paired!", // on Cancel -> DESKTOP_PAIR_CONFIRM { confirmed:false } -> error, // 5. maps background errors (DESKTOP_PAIR_RESULT) to friendly copy. // // Wire contract: _GROUND-TRUTH.md §6 + 03-desktop-pairing-handler.md. import { Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"; import { sendMessage } from "../../shared/bridge"; import type { PairedDevice } from "../../shared/sync-types"; // Mirrors DesktopPairingError in 03 (src/background/desktop-pairing.ts). type DesktopPairingError = | "invalid_code" | "no_host_found" | "handshake_failed" | "sas_mismatch" | "vault_locked"; // Local UI phase machine (the analogue of PairingPhase for the chirp flow). type DesktopPhase = | "enter" // user is typing the code | "searching" // PAIR_WITH_DESKTOP sent, awaiting SAS/result | "confirm" // DESKTOP_PAIR_SAS received, awaiting user confirm | "paired" // DESKTOP_PAIR_RESULT status:complete | "error"; // DESKTOP_PAIR_RESULT status:error (or a start failure) export interface DesktopPairingFlowProps { /** Back/cancel out of the desktop flow (return to the device list). */ onCancel: () => void; /** Pairing finished and the device was persisted; dismiss the surface. */ onDone: (device?: PairedDevice) => void; } const PAIRED_DISMISS_MS = 2_000; const CODE_LEN = 6; function errorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } { switch (error) { case "invalid_code": // Ground truth §0.3: wrong vs expired is indistinguishable on the wire. 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." }; case "no_host_found": return { headline: "Couldn't find your computer", sub: "Make sure the Peach helper is running and both devices are on the same Wi-Fi." }; case "sas_mismatch": return { headline: "Codes don't match", sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully." }; case "vault_locked": return { headline: "Unlock Peach first", sub: "Your vault needs to be unlocked before you can pair a device." }; case "handshake_failed": default: return { headline: "Pairing failed", sub: "Something went wrong while connecting. Please try again." }; } } export const DesktopPairingFlow: Component = (props) => { const [phase, setPhase] = createSignal("enter"); const [code, setCode] = createSignal(""); const [sas, setSas] = createSignal(null); const [error, setError] = createSignal("unknown"); const [device, setDevice] = createSignal(undefined); const [busy, setBusy] = createSignal(false); const codeReady = () => /^\d{6}$/.test(code()); // Listen for the two background pushes (mirrors App.tsx:601 idiom). onMount(() => { const handler = (msg: { type?: string; payload?: unknown }) => { if (msg?.type === "DESKTOP_PAIR_SAS") { const p = msg.payload as { sas?: string } | undefined; if (typeof p?.sas === "string") { setSas(p.sas); setPhase("confirm"); } } else if (msg?.type === "DESKTOP_PAIR_RESULT") { const p = msg.payload as | { status: "complete"; device: PairedDevice } | { status: "error"; error: DesktopPairingError } | undefined; if (p?.status === "complete") { setDevice(p.device); setPhase("paired"); } else if (p?.status === "error") { setError(p.error); setPhase("error"); } } }; chrome.runtime.onMessage.addListener(handler); onCleanup(() => chrome.runtime.onMessage.removeListener(handler)); }); // Auto-dismiss after the paired state settles, like ChirpPairingFlow. createEffect(() => { if (phase() !== "paired") return; const t = setTimeout(() => props.onDone(device()), PAIRED_DISMISS_MS); onCleanup(() => clearTimeout(t)); }); async function handleConnect(): Promise { if (!codeReady() || busy()) return; setBusy(true); setPhase("searching"); try { const res = (await sendMessage({ type: "PAIR_WITH_DESKTOP", payload: { code: code() }, })) as { ok: boolean; error?: DesktopPairingError }; // A synchronous failure (e.g. vault_locked) comes back here; the SAS and // terminal result arrive later as pushes. if (!res.ok) { setError(res.error ?? "unknown"); setPhase("error"); } } catch { setError("unknown"); setPhase("error"); } finally { setBusy(false); } } async function respondConfirm(confirmed: boolean): Promise { if (busy()) return; setBusy(true); // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the // final phase (paired vs sas_mismatch error). if (confirmed) setPhase("searching"); else { setError("sas_mismatch"); setPhase("error"); } try { await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } }); } finally { setBusy(false); } } function reset(): void { setPhase("enter"); setSas(null); setDevice(undefined); setError("unknown"); } function onCodeInput(value: string): void { setCode(value.replace(/\D/g, "").slice(0, CODE_LEN)); } return ( ); }; function liveText(phase: DesktopPhase, error: DesktopPairingError | "unknown"): string { switch (phase) { case "enter": return "Enter the pairing code from your computer"; case "searching": return "Searching for the helper"; case "confirm": return "Confirm the code matches on your computer"; case "paired": return "Paired"; case "error": return errorCopy(error).headline; } } ``` > Reused class hooks (`peach-chirp-pair`, `peach-chirp-head`, `peach-chirp-back`, > `peach-chirp-headline`, `peach-chirp-sub`, `peach-chirp-action`, > `peach-chirp-meter*`, `peach-btn*`, `peach-sr-only`, `peach-underline-input`, > `peach-unlock-input`, `peach-label`) all already exist in `popup.css`. New > hooks (`peach-desktop-pair`, `peach-code-input`, `peach-desktop-*`) are > additive and need a small CSS block at integration (out of scope for this > draft — drafts touch only this folder). --- ## 5. Integration TODOs (NOT applied — flagged for the integrator) 1. **Add the three message cases to `messages.ts`** per 03 §"Wiring" (the background side). This component assumes those handlers exist. 2. **Insert the Desktop Helper affordance** into `ChirpSync.tsx` `restView()` action block (lines 412-423) and render `` from `pairingView()` — see §2. 3. **CSS** for the new `peach-desktop-*` / `peach-code-input` hooks in `popup.css` (inline styles cover the load-bearing SAS/confirm layout in the meantime, matching ChirpSync's inline-styled SAS gate at lines 445-472). 4. **Pro gate + lock check.** ChirpSync already gates on `props.isPro`; the desktop button should live behind the same gate, and `vault_locked` from the background is surfaced as friendly copy here. 5. **Single in-flight pairing.** 03 keeps one pairing per worker; if the user backs out mid-flow (`onCancel`), the integrator may want a `DESKTOP_PAIR_CONFIRM { confirmed:false }` or a cancel message to release the background resolver. Left as a TODO since 03 owns that lifecycle.