ChirpSync.tsx

33.4 KB · raw

1// ChirpSync — the v3 Settings → Sync surface.2//3// Three modes the user can be in:4//5//   1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes6//      to the License section.7//   2. Idle — list of paired devices + "Pair a device" hero button. Stale8//      devices (>7 days since last sync) show a soft warning indicator;9//      vouched devices (received via device manifest, v2 brief §4.2) show10//      an "Added automatically" badge.11//   3. Pairing — ChirpPairingFlow takes over the full surface and drives12//      the three-act bird animation in lockstep with the audio protocol.13//14// The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is15// the binding between that orchestrator, the background-message16// CHIRP_* handlers, and the Settings navigation surrounding it.1718import {19  Component,20  Show,21  createEffect,22  createMemo,23  createSignal,24  For,25  onCleanup,26  onMount,27} from "solid-js";28import { sendMessage } from "../../shared/bridge";29import type {30  DeviceType,31  PairedDevice,32  PairingProgress,33} from "../../shared/sync-types";34import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";35import {36  ChirpPairingFailure,37  runChirpPairing,38  type ChirpPairingDeps,39  type PairingRole,40} from "../chirp-pair";41import { getLocalSubnet } from "../../shared/local-network";42import { isNativePlatform } from "../../shared/native";43import { openCapacitorPairingServer } from "../chirp-pair-capacitor";4445export interface ChirpSyncViewProps {46  isPro: boolean;47  onBack(): void;48  onLearnPro(): void;49  /**50   * Open straight into a pairing path, skipping the rest screen — set when the51   * user taps "Chirp" or "Code" on the Settings sync card. Read once on mount.52   */53  initialPair?: "chirp" | "desktop" | null;54}5556const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5758type Mode = "rest" | "pairing";5960// Which pairing path the user picked. The two paths are entered directly from61// the rest screen (or the Settings card); the birds animate the same way on both.62type PairKind = "chirp" | "desktop" | null;6364// Desktop helper (peach-sync) code-pairing phases. The SAS and success65// screens are shared with the chirp path; "enter"/"connecting" are unique to66// the typed-code flow. Mirrors the background DesktopPairingError union.67type DesktopPhase = "enter" | "connecting" | "sas" | "paired" | "error";68type DesktopPairingError =69  | "invalid_code"70  | "no_host_found"71  | "handshake_failed"72  | "sas_mismatch"73  | "vault_locked";7475const DESKTOP_PAIRED_DISMISS_MS = 2_000;7677function desktopErrorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {78  switch (error) {79    case "invalid_code":80      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.81      return {82        headline: "Code not found or expired",83        sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while.",84      };85    case "no_host_found":86      return {87        headline: "Couldn't find your computer",88        sub: "Make sure both devices are on the same Wi-Fi, then run “peach-sync pair” in a terminal to start pairing.",89      };90    case "sas_mismatch":91      return {92        headline: "Codes don't match",93        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",94      };95    case "vault_locked":96      return {97        headline: "Unlock Peach first",98        sub: "Your vault needs to be unlocked before you can pair a device.",99      };100    case "handshake_failed":101    default:102      return {103        headline: "Pairing failed",104        sub: "Something went wrong while connecting. Please try again.",105      };106  }107}108109export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {110  const [devices, setDevices] = createSignal<PairedDevice[]>([]);111  const [mode, setMode] = createSignal<Mode>("rest");112  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });113  const [error, setError] = createSignal<string | null>(null);114  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);115  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");116  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);117  // Out-of-band pairing verification: the 6-digit code both devices derive118  // from the shared handshake; the user confirms it matches before the pair119  // is persisted (audit HIGH — pairing initiator authentication).120  const [sasCode, setSasCode] = createSignal<string | null>(null);121  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);122123  function resolveSAS(ok: boolean) {124    const r = sasResolve();125    setSasCode(null);126    setSasResolve(null);127    r?.(ok);128  }129130  // --- Desktop helper (peach-sync) code pairing -------------------------131  // The whole NK pairing runs in the background; this view drives the UI and132  // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.133  const [pairKind, setPairKind] = createSignal<PairKind>(null);134  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");135  const [desktopCode, setDesktopCode] = createSignal("");136  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);137  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");138  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);139  const [desktopBusy, setDesktopBusy] = createSignal(false);140141  const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());142143  // Two direct entry points — no intermediate "how do you want to pair?" screen.144  function pairViaChirp(): void {145    setError(null);146    setMode("pairing");147    startChirpFlow();148  }149150  function pairViaDesktop(): void {151    setError(null);152    setMode("pairing");153    startDesktopFlow();154  }155156  function startDesktopFlow(): void {157    setDesktopCode("");158    setDesktopSas(null);159    setDesktopError("unknown");160    setDesktopDevice(undefined);161    setDesktopBusy(false);162    setDesktopPhase("enter");163    setPairKind("desktop");164  }165166  function startChirpFlow(): void {167    setPairKind("chirp");168    void handleStartPairing();169  }170171  function onDesktopCodeInput(value: string): void {172    setDesktopCode(value.replace(/\D/g, "").slice(0, 6));173  }174175  async function handleDesktopConnect(): Promise<void> {176    if (!desktopCodeReady() || desktopBusy()) return;177    setDesktopBusy(true);178    setDesktopPhase("connecting");179    try {180      const res = (await sendMessage({181        type: "PAIR_WITH_DESKTOP",182        payload: { code: desktopCode() },183      })) as { ok: boolean; error?: string };184      // A synchronous start failure (e.g. vault_locked) returns here; the SAS185      // and the terminal result arrive later as pushes.186      if (!res.ok) {187        setDesktopError((res.error as DesktopPairingError) ?? "unknown");188        setDesktopPhase("error");189      }190    } catch {191      setDesktopError("unknown");192      setDesktopPhase("error");193    } finally {194      setDesktopBusy(false);195    }196  }197198  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {199    if (desktopBusy()) return;200    setDesktopBusy(true);201    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the202    // final phase (paired vs sas_mismatch error).203    if (confirmed) setDesktopPhase("connecting");204    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }205    try {206      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });207    } finally {208      setDesktopBusy(false);209    }210  }211212  // Auto-dismiss after the paired state settles, like the chirp flow.213  createEffect(() => {214    if (desktopPhase() !== "paired") return;215    const t = setTimeout(() => {216      setMode("rest");217      setPairKind(null);218      void refresh();219    }, DESKTOP_PAIRED_DISMISS_MS);220    onCleanup(() => clearTimeout(t));221  });222223  async function handleSyncNow(): Promise<void> {224    setSyncStatus("syncing");225    setSyncFlash(null);226    const res = await sendMessage({227      type: "TRIGGER_SYNC",228      payload: { reason: "manual", force: true },229    }) as { ok: boolean; error?: string };230    // Poll briefly for the new state — the dispatcher broadcasts but the231    // popup doesn't yet listen, so a short re-fetch covers it.232    setTimeout(async () => {233      const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {234        ok: boolean;235        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };236      };237      const s = state.data;238      if (!s) return;239      setSyncStatus(s.status);240      if (s.status === "idle") {241        setSyncFlash(242          (s.entriesReceived ?? 0) > 0243            ? `Synced — ${s.entriesReceived} entries updated`244            : "Vault up to date",245        );246        setTimeout(() => setSyncFlash(null), 3000);247        await refresh();248      } else if (s.status === "error") {249        setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);250        setTimeout(() => setSyncFlash(null), 4000);251      }252    }, 250);253    if (!res.ok) {254      setSyncStatus("error");255      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);256      setTimeout(() => setSyncFlash(null), 4000);257    }258  }259260  onMount(() => {261    void refresh();262263    // Deep-link straight into a pairing path when opened from the sync card.264    if (props.initialPair === "chirp") pairViaChirp();265    else if (props.initialPair === "desktop") pairViaDesktop();266267    // Desktop pairing pushes from the background (bg → popup). The whole NK268    // pairing runs in the service worker; the SAS to confirm and the terminal269    // result arrive here as runtime messages.270    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {271      const handler = (msg: { type?: string; payload?: unknown }) => {272        if (pairKind() !== "desktop") return;273        if (msg?.type === "DESKTOP_PAIR_SAS") {274          const p = msg.payload as { sas?: string } | undefined;275          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }276        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {277          const p = msg.payload as278            | { status: "complete"; device: PairedDevice }279            | { status: "error"; error: DesktopPairingError }280            | undefined;281          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }282          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }283        }284      };285      chrome.runtime.onMessage.addListener(handler);286      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));287    }288  });289290  async function refresh(): Promise<void> {291    if (!props.isPro) return;292    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });293    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;294    setDevices(data?.devices ?? []);295  }296297  async function handleStartPairing(): Promise<void> {298    setError(null);299300    // audioCapture is declared as optional_permissions in the manifest.301    // Chrome will silently deny getUserMedia unless we request it here,302    // during a user gesture. Firefox ignores this API but getUserMedia works303    // under its existing host permission grant.304    if (typeof chrome !== "undefined" && chrome.permissions?.request) {305      const granted = await chrome.permissions.request({306        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],307      });308      if (!granted) {309        setPairing({ phase: "mic_denied" });310        setMode("pairing");311        return;312      }313    }314315    setPairing({ phase: "idle" });316    setMode("pairing");317318    const deps: ChirpPairingDeps = {319      onProgress: (p) => setPairing(p),320      fetchLocalIdentity: async () => {321        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });322        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };323        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");324        // The handler returns a LocalIdentity; we trust the background's shape.325        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;326      },327      completePairing: async (paired) => {328        const res = await sendMessage({329          type: "CHIRP_COMPLETE_PAIRING",330          payload: { paired },331        });332        const r = res as { ok: boolean; error?: string };333        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");334      },335      confirmSAS: (sas) =>336        new Promise<boolean>((resolve) => {337          setSasResolve(() => resolve);338          setSasCode(sas);339        }),340      getLocalSubnet,341      openPairingTransport: async (endpoint) => {342        // Native WebSocket transport for the pairing-mode peer scan. The343        // chirp-pair orchestrator only uses send/receive/close, so a thin344        // adapter over the platform WebSocket suffices.345        return new Promise((resolve, reject) => {346          let ws: WebSocket;347          try {348            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);349          } catch (err) {350            reject(err);351            return;352          }353          const inbox: Uint8Array[] = [];354          const waiters: Array<(b: Uint8Array) => void> = [];355          let closed = false;356          ws.binaryType = "arraybuffer";357          const timer = setTimeout(() => {358            try { ws.close(); } catch { /* ignore */ }359            reject(new Error("pairing_transport_timeout"));360          }, 2_000);361          ws.onopen = () => {362            clearTimeout(timer);363            resolve({364              async send(data: Uint8Array) {365                ws.send(data as Uint8Array<ArrayBuffer>);366              },367              receive(): Promise<Uint8Array> {368                if (closed) return Promise.reject(new Error("closed"));369                const v = inbox.shift();370                if (v) return Promise.resolve(v);371                return new Promise<Uint8Array>((res) => waiters.push(res));372              },373              async close() {374                try { ws.close(); } catch { /* ignore */ }375              },376            });377          };378          ws.onmessage = (ev) => {379            if (!(ev.data instanceof ArrayBuffer)) return;380            const bytes = new Uint8Array(ev.data);381            const w = waiters.shift();382            if (w) w(bytes); else inbox.push(bytes);383          };384          ws.onerror = () => {385            clearTimeout(timer);386            reject(new Error("pairing_transport_error"));387          };388          ws.onclose = () => {389            closed = true;390            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));391          };392        });393      },394    };395396    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";397    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;398399    try {400      await runChirpPairing(deps, role);401      await refresh();402    } catch (err) {403      if (err instanceof ChirpPairingFailure) {404        // The orchestrator already pushed the terminal phase into405        // `pairing()`; the inline error copy will render. No further406        // action needed — the user can tap "Try again" or "Cancel".407        return;408      }409      setError((err as Error).message);410      setMode("rest");411    }412  }413414  async function handleUnpair(deviceId: string): Promise<void> {415    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });416    setConfirmUnpair(null);417    await refresh();418  }419420  function relativeTime(when: number | undefined): string {421    if (!when) return "Not synced yet";422    const elapsed = Date.now() - when;423    if (elapsed < 60_000) return "Just now";424    const minutes = Math.floor(elapsed / 60_000);425    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;426    const hours = Math.floor(minutes / 60);427    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;428    const days = Math.floor(hours / 24);429    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;430    return "Not seen recently";431  }432433  function isStale(d: PairedDevice): boolean {434    if (d.lastSyncedAt === undefined) return false;435    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;436  }437438  // --- Sub-views --------------------------------------------------439440  const proGateView = () => (441    <div class="peach-chirp-settings peach-chirp-settings--gated">442      <header class="peach-chirp-settings-header">443        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>444          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">445            <path d="M11 4 L5 9 L11 14" />446          </svg>447        </button>448        <h1 class="peach-chirp-settings-title">Sync</h1>449      </header>450      <div class="peach-chirp-pro-card">451        <div class="peach-chirp-pro-stage" aria-hidden="true">452          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"453               stroke-linecap="round" stroke-linejoin="round">454            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />455            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />456            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />457          </svg>458          <div class="peach-chirp-pro-bird-slot">459            <MobileBird pose="idle" />460          </div>461        </div>462        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>463        <p class="peach-chirp-pro-body">464          Pairs over an inaudible chirp. Syncs locally across your devices.465          No codes, no servers, no cloud.466        </p>467        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>468          Unlock with Peach Pro469        </button>470        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>471      </div>472    </div>473  );474475  const restView = () => (476    <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">477      <header class="peach-chirp-head">478        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>479          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">480            <path d="M10 3 L5 8 L10 13" />481          </svg>482        </button>483        <div class="peach-chirp-title">Sync</div>484        <div class="peach-chirp-head-spacer" />485      </header>486487      <div class="peach-chirp-chip-row">488        <div class="peach-chirp-chip" data-phase="idle">489          <span class="peach-chirp-chip-dot" />490          <span class="peach-chirp-chip-text">ready</span>491        </div>492      </div>493494      <Show when={error()}>495        <div class="peach-chirp-settings-error" role="alert">{error()}</div>496      </Show>497498      <div class="peach-chirp-stage act-idle" aria-hidden="true">499        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">500          <DesktopBird pose="idle" />501        </div>502        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">503          <MobileBird pose="hidden" />504        </div>505      </div>506507      <div class="peach-chirp-copy-host">508        <div class="peach-chirp-copy peach-chirp-copy-rest">509          <div class="peach-chirp-headline">510            {devices().length === 0 ? "Pair a device" : "Sync is ready"}511          </div>512          <div class="peach-chirp-sub">513            {devices().length === 0514              ? "Sync over your local network. No cloud, no account, no servers."515              : "Pair another device, or manage the ones already connected below."}516          </div>517        </div>518      </div>519520      <Show when={devices().length > 0}>521        <section class="peach-chirp-paired-section">522          <h2 class="peach-chirp-paired-heading">Paired devices</h2>523          <ul class="peach-chirp-device-list">524            <For each={devices()}>525              {(d) => (526                <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>527                  <DeviceTypeIcon type={d.deviceType ?? "extension"} />528                  <div class="peach-chirp-device-info">529                    <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>530                    <span class="peach-chirp-device-meta">531                      {relativeTime(d.lastSyncedAt)}532                      <Show when={d.trustSource === "vouched"}>533                        <span class="peach-chirp-device-badge">Added automatically</span>534                      </Show>535                      <Show when={isStale(d)}>536                        <span class="peach-chirp-device-stale">Not seen recently</span>537                      </Show>538                    </span>539                  </div>540                  <Show541                    when={confirmUnpair() !== d.deviceId}542                    fallback={543                      <div class="peach-chirp-unpair-confirm">544                        <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>545                          Cancel546                        </button>547                        <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>548                          Remove549                        </button>550                      </div>551                    }552                  >553                    <button554                      class="peach-icon-btn peach-chirp-device-remove"555                      aria-label={`Unpair ${d.deviceName}`}556                      onClick={() => setConfirmUnpair(d.deviceId)}557                    >558                      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">559                        <path d="M3 3 L11 11 M11 3 L3 11" />560                      </svg>561                    </button>562                  </Show>563                </li>564              )}565            </For>566          </ul>567        </section>568      </Show>569570      <Show when={devices().length > 0}>571        <div class="peach-chirp-sync-now-row">572          <button573            class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"574            disabled={syncStatus() === "syncing"}575            onClick={() => void handleSyncNow()}576          >577            {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}578          </button>579          <Show when={syncFlash()}>580            <span class="peach-chirp-sync-flash" aria-live="polite">581              {syncFlash()}582            </span>583          </Show>584        </div>585      </Show>586587      <div class="peach-pair-method-list peach-pair-method-list--rest">588        <button class="peach-pair-method" onClick={() => pairViaChirp()}>589          <span class="peach-pair-method-icon" aria-hidden="true">590            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">591              <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />592              <path d="M14 7.5 Q15.6 10 14 12.5" />593              <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />594            </svg>595          </span>596          <span class="peach-pair-method-text">597            <span class="peach-pair-method-title">Same room</span>598            <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>599          </span>600        </button>601        <button class="peach-pair-method" onClick={() => pairViaDesktop()}>602          <span class="peach-pair-method-icon" aria-hidden="true">603            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">604              <rect x="2" y="4" width="16" height="10" rx="1.4" />605              <path d="M7 17 L13 17" />606              <path d="M9 14 L9 17 M11 14 L11 17" />607            </svg>608          </span>609          <span class="peach-pair-method-text">610            <span class="peach-pair-method-title">Enter a code</span>611            <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>612          </span>613        </button>614      </div>615616    </div>617  );618619  const backArrow = (620    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">621      <path d="M10 3 L5 8 L10 13" />622    </svg>623  );624625  // The bird stage — always animating — shared by every desktop pairing screen626  // so the bird animation is present regardless of sync method.627  const desktopBirdStage = (active: boolean) => (628    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">629      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">630        <DesktopBird pose="chirping" />631      </div>632      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">633        <MobileBird pose="attentive" />634      </div>635    </div>636  );637638  function desktopLiveText(): string {639    switch (desktopPhase()) {640      case "enter": return "Enter the pairing code from your computer";641      case "connecting": return "Searching for the helper";642      case "sas": return "Confirm the code matches on your computer";643      case "paired": return "Paired";644      case "error": return desktopErrorCopy(desktopError()).headline;645    }646  }647648  // Desktop helper code pairing — birds animate on every screen below.649  const desktopView = () => (650    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">651      <header class="peach-chirp-head">652        <button class="peach-chirp-back" aria-label="Back" onClick={() => { setMode("rest"); setPairKind(null); }}>{backArrow}</button>653        <div class="peach-chirp-title">Enter a code</div>654        <div class="peach-chirp-head-spacer" />655      </header>656657      {desktopBirdStage(desktopPhase() !== "enter")}658659      <Show when={desktopPhase() === "enter"}>660        <div class="peach-desktop-enter">661          <div class="peach-chirp-headline">Enter the code from your computer</div>662          <div class="peach-chirp-sub">663            Open the Peach helper on your computer and type the 6-digit code it shows.664          </div>665          <label class="peach-label" for="desktop-pair-code">pairing code</label>666          <input667            id="desktop-pair-code"668            class="peach-underline-input peach-unlock-input peach-code-input"669            type="text"670            inputmode="numeric"671            autocomplete="one-time-code"672            maxlength={6}673            placeholder="000000"674            aria-label="6-digit pairing code"675            value={desktopCode()}676            ref={(el) => setTimeout(() => el.focus(), 80)}677            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}678            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}679          />680          <div class="peach-chirp-action">681            <button class="peach-btn peach-btn-ghost" onClick={() => { setMode("rest"); setPairKind(null); }}>Cancel</button>682            <button683              class="peach-btn peach-btn-primary peach-desktop-connect"684              disabled={!desktopCodeReady() || desktopBusy()}685              onClick={() => void handleDesktopConnect()}686            >687              Connect688            </button>689          </div>690        </div>691      </Show>692693      <Show when={desktopPhase() === "connecting"}>694        <div class="peach-desktop-searching">695          <div class="peach-chirp-headline">Searching for helper…</div>696          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>697        </div>698      </Show>699700      <Show when={desktopPhase() === "sas"}>701        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">702          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>703          <div704            class="peach-desktop-sas"705            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;"706          >707            {desktopSas()}708          </div>709          <div class="peach-chirp-sub" style="max-width:240px;">710            Only continue if this 6-digit code is identical on both screens.711          </div>712          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">713            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>714              Confirm715            </button>716            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>717              Cancel718            </button>719          </div>720        </div>721      </Show>722723      <Show when={desktopPhase() === "paired"}>724        <div class="peach-desktop-paired">725          <div class="peach-chirp-headline">Paired!</div>726          <div class="peach-chirp-sub">727            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}728          </div>729          <div class="peach-chirp-action">730            <button class="peach-btn peach-btn-primary" onClick={() => { setMode("rest"); setPairKind(null); void refresh(); }}>Done</button>731          </div>732        </div>733      </Show>734735      <Show when={desktopPhase() === "error"}>736        <div class="peach-desktop-error">737          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>738          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>739          <div class="peach-chirp-action">740            <button class="peach-btn peach-btn-ghost" onClick={() => setMode("rest")}>Cancel</button>741            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>742          </div>743        </div>744      </Show>745746      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>747    </div>748  );749750  const chirpPairingView = () => (751    <Show752      when={sasCode()}753      fallback={754        <ChirpPairingFlow755          progress={pairing()}756          onCancel={() => setMode("rest")}757          onRetry={() => void handleStartPairing()}758          onDismiss={() => {759            setMode("rest");760            void refresh();761          }}762        />763      }764    >765      {/* Out-of-band verification gate — pairing is NOT persisted until the766          user confirms this code matches the one on the other device. */}767      <div768        class="chirp-sas-verify"769        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"770      >771        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>772        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">773          Confirm this code matches the one shown on your other device before pairing.774        </div>775        <div 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;">776          {sasCode()}777        </div>778        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">779          <button780            class="peach-btn peach-btn--accent peach-btn--md"781            type="button"782            onClick={() => resolveSAS(true)}783          >784            codes match — pair785          </button>786          <button787            class="peach-btn peach-btn--ghost peach-btn--md"788            type="button"789            onClick={() => resolveSAS(false)}790          >791            they don't match792          </button>793        </div>794      </div>795    </Show>796  );797798  // Pairing surface dispatch: the user already chose chirp vs. code on the rest799  // screen (or the sync card), so there's no method-select step.800  const pairingView = () => (801    <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>802      {desktopView()}803    </Show>804  );805806  const currentMode = createMemo(() => {807    if (!props.isPro) return "gated";808    if (mode() === "pairing") return "pairing";809    return "rest";810  });811812  return (813    <Show814      when={currentMode() === "gated"}815      fallback={816        <Show when={currentMode() === "pairing"} fallback={restView()}>817          {pairingView()}818        </Show>819      }820    >821      {proGateView()}822    </Show>823  );824};825826const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {827  return (828    <div class="peach-chirp-device-icon" data-type={props.type}>829      <Show when={props.type === "android" || props.type === "ios"}>830        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">831          <rect x="6" y="2" width="8" height="16" rx="1.5" />832          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />833        </svg>834      </Show>835      <Show when={props.type === "extension" || props.type === "desktop"}>836        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">837          <rect x="2" y="4" width="16" height="10" rx="1.4" />838          <path d="M7 17 L13 17" />839          <path d="M9 14 L9 17 M11 14 L11 17" />840        </svg>841      </Show>842    </div>843  );844};