04-desktop-pairing-ui.md
Change 4 — Desktop helper pairing UI
DRAFT. Conforms to
_GROUND-TRUTH.mdand03-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, <Show>, onCleanup, Component |
OutlinedTextField |
No such component; Peach has no Material inputs | peach-underline-input <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 <input class="peach-underline-input …"> |
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 <input> carrying the peach-underline-input class, defined at
src/popup/styles/popup.css:1006:
.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 <input> — 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 singleinputmode="numeric"maxlength=6field 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 toinvalid_codeand 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:
<div class="peach-chirp-action">
<p class="peach-chirp-mic-notice">…</p>
<button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>
…<span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>
</button>
</div>
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
pairKindsignal. 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 <Show when={sasCode()} fallback={<ChirpPairingFlow .../>}>. The
desktop flow is rendered the same way: when the user picks Desktop Helper,
pairingView() renders <DesktopPairingFlow .../> instead of (or in addition
to) <ChirpPairingFlow .../>.
Wiring sketch in ChirpSync.tsx (integration TODO — NOT applied)
// 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:
<button
class="peach-chirp-pair-start peach-chirp-pair-start--desktop"
onClick={() => { setPairKind("desktop"); setMode("pairing"); }}
>
<span>Desktop Helper (enter code)</span>
</button>
// in the render switch (ChirpSync.tsx 482-493), when mode()==="pairing" and
// pairKind()==="desktop", render <DesktopPairingFlow/> instead of the chirp view:
<Show when={pairKind() === "desktop"} fallback={pairingView()}>
<DesktopPairingFlow onCancel={() => setMode("rest")} onDone={() => { setMode("rest"); void refresh(); }} />
</Show>
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)
// 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
// 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<DesktopPairingFlowProps> = (props) => {
const [phase, setPhase] = createSignal<DesktopPhase>("enter");
const [code, setCode] = createSignal("");
const [sas, setSas] = createSignal<string | null>(null);
const [error, setError] = createSignal<DesktopPairingError | "unknown">("unknown");
const [device, setDevice] = createSignal<PairedDevice | undefined>(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<void> {
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<void> {
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 (
<div class="peach-chirp-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a desktop helper">
<header class="peach-chirp-head">
<button class="peach-chirp-back" aria-label="Back" onClick={() => props.onCancel()}>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 3 L5 8 L10 13" />
</svg>
</button>
<div class="peach-chirp-title">Desktop Helper</div>
<div class="peach-chirp-head-spacer" />
</header>
{/* ---- enter: 6-digit code field ---- */}
<Show when={phase() === "enter"}>
<div class="peach-desktop-enter">
<div class="peach-chirp-headline">Enter the code from your computer</div>
<div class="peach-chirp-sub">
Open the Peach helper on your computer and type the 6-digit code it shows.
</div>
<label class="peach-label" for="desktop-pair-code">pairing code</label>
<input
id="desktop-pair-code"
class="peach-underline-input peach-unlock-input peach-code-input"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength={CODE_LEN}
placeholder="000000"
aria-label="6-digit pairing code"
value={code()}
ref={(el) => setTimeout(() => el.focus(), 80)}
onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}
/>
<div class="peach-chirp-action">
<button class="peach-chirp-cancel peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>
Cancel
</button>
<button
class="peach-btn peach-btn-primary peach-desktop-connect"
disabled={!codeReady() || busy()}
onClick={() => void handleConnect()}
>
Connect
</button>
</div>
</div>
</Show>
{/* ---- searching ---- */}
<Show when={phase() === "searching"}>
<div class="peach-desktop-searching">
<div class="peach-chirp-headline">Searching for helper…</div>
<div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>
<div class="peach-chirp-action-handshake">
<div class="peach-chirp-meter"><span class="peach-chirp-meter-bar" /></div>
<span class="peach-chirp-meter-label">connecting · noise NK</span>
</div>
</div>
</Show>
{/* ---- confirm: SAS gate ---- */}
<Show when={phase() === "confirm"}>
<div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px 18px;text-align:center;">
<div class="peach-chirp-headline">Confirm this code matches on your computer:</div>
<div
class="peach-desktop-sas"
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;"
>
{sas()}
</div>
<div class="peach-chirp-sub" style="max-width:240px;">
Only continue if this 6-digit code is identical on both screens.
</div>
<div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">
<button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>
Confirm
</button>
<button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>
Cancel
</button>
</div>
</div>
</Show>
{/* ---- paired ---- */}
<Show when={phase() === "paired"}>
<div class="peach-desktop-paired">
<div class="peach-chirp-headline">Paired!</div>
<div class="peach-chirp-sub">
{device()?.deviceName ? `Connected to ${device()!.deviceName}` : "Connected to your computer"}
</div>
<div class="peach-chirp-action">
<button class="peach-btn peach-btn-primary" onClick={() => props.onDone(device())}>Done</button>
</div>
</div>
</Show>
{/* ---- error ---- */}
<Show when={phase() === "error"}>
<div class="peach-desktop-error">
<div class="peach-chirp-headline">{errorCopy(error()).headline}</div>
<div class="peach-chirp-sub">{errorCopy(error()).sub}</div>
<div class="peach-chirp-action">
<button class="peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>Cancel</button>
<button class="peach-btn peach-btn-primary" onClick={() => reset()}>Try again</button>
</div>
</div>
</Show>
{/* Polite live region so phase changes reach assistive tech. */}
<div class="peach-sr-only" aria-live="polite">{liveText(phase(), error())}</div>
</div>
);
};
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 inpopup.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)
- Add the three message cases to
messages.tsper 03 §"Wiring" (the background side). This component assumes those handlers exist. - Insert the Desktop Helper affordance into
ChirpSync.tsxrestView()action block (lines 412-423) and render<DesktopPairingFlow/>frompairingView()— see §2. - CSS for the new
peach-desktop-*/peach-code-inputhooks inpopup.css(inline styles cover the load-bearing SAS/confirm layout in the meantime, matching ChirpSync's inline-styled SAS gate at lines 445-472). - Pro gate + lock check. ChirpSync already gates on
props.isPro; the desktop button should live behind the same gate, andvault_lockedfrom the background is surfaced as friendly copy here. - Single in-flight pairing. 03 keeps one pairing per worker; if the user
backs out mid-flow (
onCancel), the integrator may want aDESKTOP_PAIR_CONFIRM { confirmed:false }or a cancel message to release the background resolver. Left as a TODO since 03 owns that lifecycle.