64236f0049f1
feat: desktop helper pairing UI — mode in ChirpSync, method-select, birds always animate, code entry + SAS confirm (v4.0.2)
- time
- 2026-06-17 13:16 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- desktop helper pairing integration
- parent
- 08e56309a014
↓ peach-browser-64236f00.tar.gzatlas at this save
files changed3 files
src/popup/styles/popup.css+30 −0src/popup/views/ChirpSync.desktop.test.tsx+258 −0src/popup/views/ChirpSync.tsx+336 −3src/popup/styles/popup.css+30 −0
@@ -1027,6 +1027,36 @@10271027 padding-top: 2px;10281028 }10291029 1030+/* Desktop helper (peach-sync) code pairing — additive hooks. The chirp pairing1031+ classes (peach-chirp-*) carry the shared layout; these only style the parts1032+ unique to the typed-code path. Inline styles cover the SAS/confirm gate. */1033+.peach-desktop-pair { display: flex; flex-direction: column; }1034+.peach-desktop-enter,1035+.peach-desktop-searching,1036+.peach-desktop-paired,1037+.peach-desktop-error {1038+ display: flex; flex-direction: column; gap: 8px;1039+ padding: 4px 18px 16px; text-align: center; align-items: center;1040+}1041+.peach-desktop-enter .peach-label { align-self: flex-start; }1042+.peach-code-input {1043+ text-align: center; letter-spacing: 0.42em; padding-left: 0.42em;1044+}1045+.peach-code-input::placeholder { letter-spacing: 0.42em; }1046+/* Method chooser — two large tappable cards before the birds appear. */1047+.peach-pair-method-list {1048+ display: flex; flex-direction: column; gap: 10px; padding: 4px 18px 8px;1049+}1050+.peach-pair-method {1051+ display: flex; flex-direction: column; align-items: flex-start; gap: 2px;1052+ width: 100%; text-align: left;1053+}1054+.peach-pair-method-title { font-size: 0.875rem; font-weight: 600; }1055+.peach-pair-method-sub { font-size: 0.6875rem; color: var(--peach-t2); }1056+.peach-chirp-pair-start--desktop {1057+ border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);1058+}1059+10301060 /* Breach badge */10311061 .peach-breach-badge {10321062 display: flex;src/popup/views/ChirpSync.desktop.test.tsx+258 −0
@@ -0,0 +1,258 @@1+// @vitest-environment jsdom2+//3+// Desktop helper code pairing (Change 4), exercised through the integrated4+// ChirpSyncView state machine. Per the brief's "UI Design Correction", desktop5+// pairing is a MODE inside ChirpSync (birds always animate), not a separate6+// component — so these specs navigate: rest → method-select → desktop enter →7+// connecting → SAS → paired/error, asserting the popup↔background message8+// contract at each step.9+//10+// chrome messaging is mocked the way the popup uses it:11+// - shared/bridge `sendMessage` is mocked (popup → bg)12+// - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can13+// fire DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes (bg → popup)14+15+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";16+17+// --- mock the background bridge (popup → bg) -------------------------------18+const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));19+vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));20+21+// Mutated per-test to script the PAIR_WITH_DESKTOP start response.22+let startResponse: { ok: boolean; error?: string } = { ok: true };23+24+import { ChirpSyncView } from "./ChirpSync";25+import type { PairedDevice } from "../../shared/sync-types";26+import { renderView, inputText } from "./view-test-utils";27+28+// --- fake chrome.runtime push channel (bg → popup) ------------------------29+type RuntimeListener = (msg: { type?: string; payload?: unknown }) => void;30+let listeners: RuntimeListener[] = [];31+32+function pushFromBackground(type: string, payload: unknown): void {33+ for (const l of [...listeners]) l({ type, payload });34+}35+36+beforeEach(() => {37+ listeners = [];38+ startResponse = { ok: true };39+ mockSendMessage.mockReset();40+ mockSendMessage.mockImplementation(async (msg: { type?: string }) => {41+ if (msg?.type === "PAIR_WITH_DESKTOP") return startResponse;42+ return { ok: true };43+ });44+ (globalThis as unknown as { chrome: unknown }).chrome = {45+ runtime: {46+ onMessage: {47+ addListener: (fn: RuntimeListener) => { listeners.push(fn); },48+ removeListener: (fn: RuntimeListener) => { listeners = listeners.filter((l) => l !== fn); },49+ },50+ },51+ };52+});53+54+describe("ChirpSync — desktop helper pairing", () => {55+ const cleanups: Array<() => void> = [];56+ afterEach(() => {57+ while (cleanups.length) cleanups.pop()!();58+ });59+60+ function mount() {61+ const r = renderView(62+ <ChirpSyncView isPro={true} onBack={() => {}} onLearnPro={() => {}} />,63+ );64+ cleanups.push(r.dispose);65+ return r;66+ }67+68+ function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {69+ return Array.from(container.querySelectorAll("button")).find(70+ (b) => (b.textContent ?? "").trim() === text,71+ ) as HTMLButtonElement | undefined;72+ }73+ function buttonContaining(container: HTMLElement, text: string): HTMLButtonElement | undefined {74+ return Array.from(container.querySelectorAll("button")).find(75+ (b) => (b.textContent ?? "").includes(text),76+ ) as HTMLButtonElement | undefined;77+ }78+ function codeInput(container: HTMLElement): HTMLInputElement {79+ return container.querySelector("#desktop-pair-code") as HTMLInputElement;80+ }81+82+ function gotoMethodSelect(container: HTMLElement): void {83+ buttonByText(container, "Pair a device")!.click();84+ }85+ function gotoDesktopEnter(container: HTMLElement): void {86+ gotoMethodSelect(container);87+ buttonContaining(container, "Desktop helper")!.click();88+ }89+90+ it("offers both pairing methods on the method-select screen", () => {91+ const { container } = mount();92+ gotoMethodSelect(container);93+ expect(container.textContent).toContain("How do you want to pair?");94+ expect(buttonContaining(container, "Same room")).toBeTruthy();95+ expect(buttonContaining(container, "Desktop helper")).toBeTruthy();96+ });97+98+ it("renders a 6-digit numeric code input with birds present", () => {99+ const { container } = mount();100+ gotoDesktopEnter(container);101+ const input = codeInput(container);102+ expect(input).toBeTruthy();103+ expect(input.getAttribute("inputmode")).toBe("numeric");104+ expect(input.getAttribute("maxlength")).toBe("6");105+ // birds always animate on the desktop screens (design correction)106+ expect(container.querySelector(".peach-chirp-bird-desktop")).toBeTruthy();107+ // non-digits are stripped and the value is capped at 6 chars108+ inputText(input, "12ab3456789");109+ expect(input.value).toBe("123456");110+ });111+112+ it("Connect sends PAIR_WITH_DESKTOP with the typed code, then searches", async () => {113+ const { container } = mount();114+ gotoDesktopEnter(container);115+ inputText(codeInput(container), "428913");116+ const connect = buttonByText(container, "Connect")!;117+ expect(connect.disabled).toBe(false);118+ connect.click();119+ await Promise.resolve();120+ expect(mockSendMessage).toHaveBeenCalledWith({121+ type: "PAIR_WITH_DESKTOP",122+ payload: { code: "428913" },123+ });124+ expect(container.textContent).toContain("Searching for helper…");125+ });126+127+ it("Connect stays disabled until 6 digits are entered", () => {128+ const { container } = mount();129+ gotoDesktopEnter(container);130+ inputText(codeInput(container), "123");131+ expect(buttonByText(container, "Connect")!.disabled).toBe(true);132+ inputText(codeInput(container), "123456");133+ expect(buttonByText(container, "Connect")!.disabled).toBe(false);134+ });135+136+ it("shows the SAS on a DESKTOP_PAIR_SAS push, with Confirm/Cancel", async () => {137+ const { container } = mount();138+ gotoDesktopEnter(container);139+ inputText(codeInput(container), "428913");140+ buttonByText(container, "Connect")!.click();141+ await Promise.resolve();142+143+ pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });144+145+ expect(container.textContent).toContain("Confirm this code matches on your computer:");146+ expect(container.textContent).toContain("895245");147+ expect(buttonByText(container, "Confirm")).toBeTruthy();148+ expect(buttonByText(container, "Cancel")).toBeTruthy();149+ });150+151+ it("Confirm sends DESKTOP_PAIR_CONFIRM { confirmed: true }", async () => {152+ const { container } = mount();153+ gotoDesktopEnter(container);154+ inputText(codeInput(container), "428913");155+ buttonByText(container, "Connect")!.click();156+ await Promise.resolve();157+ pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });158+159+ buttonByText(container, "Confirm")!.click();160+ await Promise.resolve();161+ expect(mockSendMessage).toHaveBeenCalledWith({162+ type: "DESKTOP_PAIR_CONFIRM",163+ payload: { confirmed: true },164+ });165+ });166+167+ it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {168+ const { container } = mount();169+ gotoDesktopEnter(container);170+ inputText(codeInput(container), "428913");171+ buttonByText(container, "Connect")!.click();172+ await Promise.resolve();173+ pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });174+175+ buttonByText(container, "Cancel")!.click();176+ await Promise.resolve();177+ expect(mockSendMessage).toHaveBeenCalledWith({178+ type: "DESKTOP_PAIR_CONFIRM",179+ payload: { confirmed: false },180+ });181+ expect(container.textContent).toContain("Codes don't match");182+ });183+184+ it("shows the combined 'not found or expired' copy on invalid_code", async () => {185+ const { container } = mount();186+ gotoDesktopEnter(container);187+ inputText(codeInput(container), "000000");188+ buttonByText(container, "Connect")!.click();189+ await Promise.resolve();190+191+ // peach-sync cannot distinguish wrong vs expired on the wire (ground truth192+ // §0.3) — the background collapses both to invalid_code via a socket close.193+ pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "invalid_code" });194+195+ expect(container.textContent).toContain("Code not found or expired");196+ expect(buttonByText(container, "Try again")).toBeTruthy();197+ });198+199+ it("shows the 'no computer found' copy on no_host_found", async () => {200+ const { container } = mount();201+ gotoDesktopEnter(container);202+ inputText(codeInput(container), "111111");203+ buttonByText(container, "Connect")!.click();204+ await Promise.resolve();205+ pushFromBackground("DESKTOP_PAIR_RESULT", { status: "error", error: "no_host_found" });206+ expect(container.textContent).toContain("Couldn't find your computer");207+ });208+209+ it("renders Paired! and the device name on a complete result", async () => {210+ const device: PairedDevice = { deviceId: "d1", deviceName: "June's iMac", deviceType: "desktop", pairedAt: 1700000000000 };211+ const { container } = mount();212+ gotoDesktopEnter(container);213+ inputText(codeInput(container), "428913");214+ buttonByText(container, "Connect")!.click();215+ await Promise.resolve();216+ pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });217+ buttonByText(container, "Confirm")!.click();218+ await Promise.resolve();219+220+ pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });221+ expect(container.textContent).toContain("Paired!");222+ expect(container.textContent).toContain("Connected to June's iMac");223+ });224+225+ it("auto-dismisses to the device list after the paired state settles", async () => {226+ vi.useFakeTimers();227+ try {228+ const device: PairedDevice = { deviceId: "d1", deviceName: "Mac", deviceType: "desktop", pairedAt: 0 };229+ const { container } = mount();230+ gotoDesktopEnter(container);231+ inputText(codeInput(container), "428913");232+ buttonByText(container, "Connect")!.click();233+ await Promise.resolve();234+ pushFromBackground("DESKTOP_PAIR_SAS", { sas: "895245" });235+ buttonByText(container, "Confirm")!.click();236+ await Promise.resolve();237+ pushFromBackground("DESKTOP_PAIR_RESULT", { status: "complete", device });238+ expect(container.textContent).toContain("Paired!");239+ vi.advanceTimersByTime(2000);240+ // returns to the rest surface (the "Pair a device" hero is back)241+ expect(container.textContent).not.toContain("Paired!");242+ expect(buttonByText(container, "Pair a device")).toBeTruthy();243+ } finally {244+ vi.useRealTimers();245+ }246+ });247+248+ it("surfaces a synchronous start failure (vault_locked) as an error", async () => {249+ startResponse = { ok: false, error: "vault_locked" };250+ const { container } = mount();251+ gotoDesktopEnter(container);252+ inputText(codeInput(container), "428913");253+ buttonByText(container, "Connect")!.click();254+ await Promise.resolve();255+ await Promise.resolve();256+ expect(container.textContent).toContain("Unlock Peach first");257+ });258+});src/popup/views/ChirpSync.tsx+336 −3
@@ -18,9 +18,11 @@1818 import {1919 Component,2020 Show,21+ createEffect,2122 createMemo,2223 createSignal,2324 For,25+ onCleanup,2426 onMount,2527 } from "solid-js";2628 import { sendMessage } from "../../shared/bridge";@@ -50,6 +52,55 @@5052 5153 type Mode = "rest" | "pairing";5254 55+// Which pairing path the user picked on the method-select screen. `null` =56+// still choosing. The birds animate the same way on both paths.57+type PairKind = "chirp" | "desktop" | null;58+59+// Desktop helper (peach-sync) code-pairing phases. The SAS and success60+// screens are shared with the chirp path; "enter"/"connecting" are unique to61+// the typed-code flow. Mirrors the background DesktopPairingError union.62+type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";63+type DesktopPairingError =64+ | "invalid_code"65+ | "no_host_found"66+ | "handshake_failed"67+ | "sas_mismatch"68+ | "vault_locked";69+70+const DESKTOP_PAIRED_DISMISS_MS = 2_000;71+72+function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {73+ switch (error) {74+ case "invalid_code":75+ // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.76+ return {77+ headline: "Code not found or expired",78+ sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",79+ };80+ case "no_host_found":81+ return {82+ headline: "Couldn't find your computer",83+ sub: "Make sure the Peach helper is running and both devices are on the same Wi-Fi.",84+ };85+ case "sas_mismatch":86+ return {87+ headline: "Codes don't match",88+ sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",89+ };90+ case "vault_locked":91+ return {92+ headline: "Unlock Peach first",93+ sub: "Your vault needs to be unlocked before you can pair a device.",94+ };95+ case "handshake_failed":96+ default:97+ return {98+ headline: "Pairing failed",99+ sub: "Something went wrong while connecting. Please try again.",100+ };101+ }102+}103+53104 export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {54105 const [devices, setDevices] = createSignal<PairedDevice[]>([]);55106 const [mode, setMode] = createSignal<Mode>("rest");@@ -71,6 +122,92 @@71122 r?.(ok);72123 }73124 125+ // --- Desktop helper (peach-sync) code pairing -------------------------126+ // The whole NK pairing runs in the background; this view drives the UI and127+ // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.128+ const [pairKind, setPairKind] = createSignal<PairKind>(null);129+ const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");130+ const [desktopCode, setDesktopCode] = createSignal("");131+ const [desktopSas, setDesktopSas] = createSignal<string | null>(null);132+ const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");133+ const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);134+ const [desktopBusy, setDesktopBusy] = createSignal(false);135+136+ const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());137+138+ function openPairMethodSelect(): void {139+ setError(null);140+ setPairKind(null);141+ setMode("pairing");142+ }143+144+ function startDesktopFlow(): void {145+ setDesktopCode("");146+ setDesktopSas(null);147+ setDesktopError("unknown");148+ setDesktopDevice(undefined);149+ setDesktopBusy(false);150+ setDesktopPhase("enter");151+ setPairKind("desktop");152+ }153+154+ function startChirpFlow(): void {155+ setPairKind("chirp");156+ void handleStartPairing();157+ }158+159+ function onDesktopCodeInput(value: string): void {160+ setDesktopCode(value.replace(/\D/g, "").slice(0, 6));161+ }162+163+ async function handleDesktopConnect(): Promise<void> {164+ if (!desktopCodeReady() || desktopBusy()) return;165+ setDesktopBusy(true);166+ setDesktopPhase("connecting");167+ try {168+ const res = (await sendMessage({169+ type: "PAIR_WITH_DESKTOP",170+ payload: { code: desktopCode() },171+ })) as { ok: boolean; error?: string };172+ // A synchronous start failure (e.g. vault_locked) returns here; the SAS173+ // and the terminal result arrive later as pushes.174+ if (!res.ok) {175+ setDesktopError((res.error as DesktopPairingError) ?? "unknown");176+ setDesktopPhase("error");177+ }178+ } catch {179+ setDesktopError("unknown");180+ setDesktopPhase("error");181+ } finally {182+ setDesktopBusy(false);183+ }184+ }185+186+ async function respondDesktopConfirm(confirmed: boolean): Promise<void> {187+ if (desktopBusy()) return;188+ setDesktopBusy(true);189+ // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the190+ // final phase (paired vs sas_mismatch error).191+ if (confirmed) setDesktopPhase("connecting");192+ else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }193+ try {194+ await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });195+ } finally {196+ setDesktopBusy(false);197+ }198+ }199+200+ // Auto-dismiss after the paired state settles, like the chirp flow.201+ createEffect(() => {202+ if (desktopPhase() !== "paired") return;203+ const t = setTimeout(() => {204+ setMode("rest");205+ setPairKind(null);206+ void refresh();207+ }, DESKTOP_PAIRED_DISMISS_MS);208+ onCleanup(() => clearTimeout(t));209+ });210+74211 async function handleSyncNow(): Promise<void> {75212 setSyncStatus("syncing");76213 setSyncFlash(null);@@ -110,6 +247,28 @@110247 111248 onMount(() => {112249 void refresh();250+251+ // Desktop pairing pushes from the background (bg → popup). The whole NK252+ // pairing runs in the service worker; the SAS to confirm and the terminal253+ // result arrive here as runtime messages.254+ if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {255+ const handler = (msg: { type?: string; payload?: unknown }) => {256+ if (pairKind() !== "desktop") return;257+ if (msg?.type === "DESKTOP_PAIR_SAS") {258+ const p = msg.payload as { sas?: string } | undefined;259+ if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }260+ } else if (msg?.type === "DESKTOP_PAIR_RESULT") {261+ const p = msg.payload as262+ | { status: "complete"; device: PairedDevice }263+ | { status: "error"; error: DesktopPairingError }264+ | undefined;265+ if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }266+ else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }267+ }268+ };269+ chrome.runtime.onMessage.addListener(handler);270+ onCleanup(() => chrome.runtime.onMessage.removeListener(handler));271+ }113272 });114273 115274 async function refresh(): Promise<void> {@@ -411,9 +570,9 @@411570 412571 <div class="peach-chirp-action">413572 <p class="peach-chirp-mic-notice">414- Chirp uses your microphone to pair. Your browser will ask for permission.573+ Pair over a chirp in the same room, or with the desktop helper by code.415574 </p>416- <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>575+ <button class="peach-chirp-pair-start" onClick={() => openPairMethodSelect()}>417576 <svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">418577 <path d="M3 9 L9 3 L15 9" />419578 <path d="M3 13 L9 7 L15 13" opacity="0.5" />@@ -425,7 +584,171 @@425584 </div>426585 );427586 428- const pairingView = () => (587+ const backArrow = (588+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">589+ <path d="M10 3 L5 8 L10 13" />590+ </svg>591+ );592+593+ // The bird stage — always animating — shared by every desktop pairing screen594+ // so the bird animation is present regardless of sync method.595+ const desktopBirdStage = (active: boolean) => (596+ <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">597+ <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">598+ <DesktopBird pose="chirping" />599+ </div>600+ <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">601+ <MobileBird pose="attentive" />602+ </div>603+ </div>604+ );605+606+ function desktopLiveText(): string {607+ switch (desktopPhase()) {608+ case "enter": return "Enter the pairing code from your computer";609+ case "connecting": return "Searching for the helper";610+ case "sas": return "Confirm the code matches on your computer";611+ case "paired": return "Paired";612+ case "error": return desktopErrorCopy(desktopError()).headline;613+ }614+ }615+616+ // Method-select — the first screen, before any birds. Two paths: a same-room617+ // audio chirp, or a typed code from the peach-sync desktop helper.618+ const methodSelectView = () => (619+ <div class="peach-chirp-pair peach-desktop-pair" data-phase="method" role="region" aria-label="Choose a pairing method">620+ <header class="peach-chirp-head">621+ <button class="peach-chirp-back" aria-label="Back" onClick={() => setMode("rest")}>{backArrow}</button>622+ <div class="peach-chirp-title">Pair a device</div>623+ <div class="peach-chirp-head-spacer" />624+ </header>625+ <div class="peach-chirp-copy-host">626+ <div class="peach-chirp-copy">627+ <div class="peach-chirp-headline">How do you want to pair?</div>628+ <div class="peach-chirp-sub">629+ In the same room? Use a chirp. Pairing with the Peach desktop helper? Enter its code.630+ </div>631+ </div>632+ </div>633+ <div class="peach-pair-method-list">634+ <button class="peach-pair-method peach-chirp-pair-start" onClick={() => startChirpFlow()}>635+ <span class="peach-pair-method-title">Same room</span>636+ <span class="peach-pair-method-sub">Chirp — audio</span>637+ </button>638+ <button class="peach-pair-method peach-chirp-pair-start peach-chirp-pair-start--desktop" onClick={() => startDesktopFlow()}>639+ <span class="peach-pair-method-title">Desktop helper</span>640+ <span class="peach-pair-method-sub">enter code</span>641+ </button>642+ </div>643+ <div class="peach-chirp-action">644+ <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>645+ </div>646+ </div>647+ );648+649+ // Desktop helper code pairing — birds animate on every screen below.650+ const desktopView = () => (651+ <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">652+ <header class="peach-chirp-head">653+ <button class="peach-chirp-back" aria-label="Back" onClick={() => setPairKind(null)}>{backArrow}</button>654+ <div class="peach-chirp-title">Desktop Helper</div>655+ <div class="peach-chirp-head-spacer" />656+ </header>657+658+ {desktopBirdStage(desktopPhase() !== "enter")}659+660+ <Show when={desktopPhase() === "enter"}>661+ <div class="peach-desktop-enter">662+ <div class="peach-chirp-headline">Enter the code from your computer</div>663+ <div class="peach-chirp-sub">664+ Open the Peach helper on your computer and type the 6-digit code it shows.665+ </div>666+ <label class="peach-label" for="desktop-pair-code">pairing code</label>667+ <input668+ id="desktop-pair-code"669+ class="peach-underline-input peach-unlock-input peach-code-input"670+ type="text"671+ inputmode="numeric"672+ autocomplete="one-time-code"673+ maxlength={6}674+ placeholder="000000"675+ aria-label="6-digit pairing code"676+ value={desktopCode()}677+ ref={(el) => setTimeout(() => el.focus(), 80)}678+ onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}679+ onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}680+ />681+ <div class="peach-chirp-action">682+ <button class="peach-btn peach-btn-ghost" onClick={() => setPairKind(null)}>Cancel</button>683+ <button684+ class="peach-btn peach-btn-primary peach-desktop-connect"685+ disabled={!desktopCodeReady() || desktopBusy()}686+ onClick={() => void handleDesktopConnect()}687+ >688+ Connect689+ </button>690+ </div>691+ </div>692+ </Show>693+694+ <Show when={desktopPhase() === "connecting"}>695+ <div class="peach-desktop-searching">696+ <div class="peach-chirp-headline">Searching for helper…</div>697+ <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>698+ </div>699+ </Show>700+701+ <Show when={desktopPhase() === "sas"}>702+ <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">703+ <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>704+ <div705+ class="peach-desktop-sas"706+ 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;"707+ >708+ {desktopSas()}709+ </div>710+ <div class="peach-chirp-sub" style="max-width:240px;">711+ Only continue if this 6-digit code is identical on both screens.712+ </div>713+ <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">714+ <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>715+ Confirm716+ </button>717+ <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>718+ Cancel719+ </button>720+ </div>721+ </div>722+ </Show>723+724+ <Show when={desktopPhase() === "paired"}>725+ <div class="peach-desktop-paired">726+ <div class="peach-chirp-headline">Paired!</div>727+ <div class="peach-chirp-sub">728+ {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}729+ </div>730+ <div class="peach-chirp-action">731+ <button class="peach-btn peach-btn-primary" onClick={() => { setMode("rest"); setPairKind(null); void refresh(); }}>Done</button>732+ </div>733+ </div>734+ </Show>735+736+ <Show when={desktopPhase() === "error"}>737+ <div class="peach-desktop-error">738+ <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>739+ <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>740+ <div class="peach-chirp-action">741+ <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>742+ <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>743+ </div>744+ </div>745+ </Show>746+747+ <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>748+ </div>749+ );750+751+ const chirpPairingView = () => (429752 <Show430753 when={sasCode()}431754 fallback={@@ -470,6 +793,16 @@470793 </button>471794 </div>472795 </div>796+ </Show>797+ );798+799+ // Pairing surface dispatch: method-select first (no birds yet), then either800+ // the chirp flow or the desktop-helper flow depending on the chosen method.801+ const pairingView = () => (802+ <Show when={pairKind() !== null} fallback={methodSelectView()}>803+ <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>804+ {desktopView()}805+ </Show>473806 </Show>474807 );475808