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
3 files src/popup/styles/popup.css+30 −0src/popup/views/ChirpSync.desktop.test.tsx+258 −0src/popup/views/ChirpSync.tsx+336 −3
src/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;

view file

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+});

view file

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 

view file