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  // Opened from the Settings sync card straight into a pairing path, so start in112  // "pairing" when a method was passed (avoids a flash of the fallback screen).113  const [mode, setMode] = createSignal<Mode>(props.initialPair ? "pairing" : "rest");114  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });115  const [error, setError] = createSignal<string | null>(null);116  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);117  const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");118  const [syncFlash, setSyncFlash] = createSignal<string | null>(null);119  // Out-of-band pairing verification: the 6-digit code both devices derive120  // from the shared handshake; the user confirms it matches before the pair121  // is persisted (audit HIGH — pairing initiator authentication).122  const [sasCode, setSasCode] = createSignal<string | null>(null);123  const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);124125  function resolveSAS(ok: boolean) {126    const r = sasResolve();127    setSasCode(null);128    setSasResolve(null);129    r?.(ok);130  }131132  // --- Desktop helper (peach-sync) code pairing -------------------------133  // The whole NK pairing runs in the background; this view drives the UI and134  // the SAS confirmation round-trip via the four PAIR_*/DESKTOP_PAIR_* messages.135  const [pairKind, setPairKind] = createSignal<PairKind>(props.initialPair ?? null);136  const [desktopPhase, setDesktopPhase] = createSignal<DesktopPhase>("enter");137  const [desktopCode, setDesktopCode] = createSignal("");138  const [desktopSas, setDesktopSas] = createSignal<string | null>(null);139  const [desktopError, setDesktopError] = createSignal<DesktopPairingError | "unknown">("unknown");140  const [desktopDevice, setDesktopDevice] = createSignal<PairedDevice | undefined>(undefined);141  const [desktopBusy, setDesktopBusy] = createSignal(false);142143  const desktopCodeReady = () => /^\d{6}$/.test(desktopCode());144145  // Two direct entry points — no intermediate "how do you want to pair?" screen.146  function pairViaChirp(): void {147    setError(null);148    setMode("pairing");149    startChirpFlow();150  }151152  function pairViaDesktop(): void {153    setError(null);154    setMode("pairing");155    startDesktopFlow();156  }157158  function startDesktopFlow(): void {159    setDesktopCode("");160    setDesktopSas(null);161    setDesktopError("unknown");162    setDesktopDevice(undefined);163    setDesktopBusy(false);164    setDesktopPhase("enter");165    setPairKind("desktop");166  }167168  function startChirpFlow(): void {169    setPairKind("chirp");170    void handleStartPairing();171  }172173  function onDesktopCodeInput(value: string): void {174    setDesktopCode(value.replace(/\D/g, "").slice(0, 6));175  }176177  async function handleDesktopConnect(): Promise<void> {178    if (!desktopCodeReady() || desktopBusy()) return;179    setDesktopBusy(true);180    setDesktopPhase("connecting");181    try {182      const res = (await sendMessage({183        type: "PAIR_WITH_DESKTOP",184        payload: { code: desktopCode() },185      })) as { ok: boolean; error?: string };186      // A synchronous start failure (e.g. vault_locked) returns here; the SAS187      // and the terminal result arrive later as pushes.188      if (!res.ok) {189        setDesktopError((res.error as DesktopPairingError) ?? "unknown");190        setDesktopPhase("error");191      }192    } catch {193      setDesktopError("unknown");194      setDesktopPhase("error");195    } finally {196      setDesktopBusy(false);197    }198  }199200  async function respondDesktopConfirm(confirmed: boolean): Promise<void> {201    if (desktopBusy()) return;202    setDesktopBusy(true);203    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the204    // final phase (paired vs sas_mismatch error).205    if (confirmed) setDesktopPhase("connecting");206    else { setDesktopError("sas_mismatch"); setDesktopPhase("error"); }207    try {208      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });209    } finally {210      setDesktopBusy(false);211    }212  }213214  // Auto-dismiss after the paired state settles — back to the Settings hub,215  // which reloads the paired-device list on mount.216  createEffect(() => {217    if (desktopPhase() !== "paired") return;218    const t = setTimeout(() => props.onBack(), DESKTOP_PAIRED_DISMISS_MS);219    onCleanup(() => clearTimeout(t));220  });221222  async function handleSyncNow(): Promise<void> {223    setSyncStatus("syncing");224    setSyncFlash(null);225    const res = await sendMessage({226      type: "TRIGGER_SYNC",227      payload: { reason: "manual", force: true },228    }) as { ok: boolean; error?: string };229    // Poll briefly for the new state — the dispatcher broadcasts but the230    // popup doesn't yet listen, so a short re-fetch covers it.231    setTimeout(async () => {232      const state = await sendMessage({ type: "GET_SYNC_STATE" }) as {233        ok: boolean;234        data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };235      };236      const s = state.data;237      if (!s) return;238      setSyncStatus(s.status);239      if (s.status === "idle") {240        setSyncFlash(241          (s.entriesReceived ?? 0) > 0242            ? `Synced — ${s.entriesReceived} entries updated`243            : "Vault up to date",244        );245        setTimeout(() => setSyncFlash(null), 3000);246        await refresh();247      } else if (s.status === "error") {248        setSyncFlash(`Sync failed — ${s.error ?? "no mobile host reachable"}`);249        setTimeout(() => setSyncFlash(null), 4000);250      }251    }, 250);252    if (!res.ok) {253      setSyncStatus("error");254      setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);255      setTimeout(() => setSyncFlash(null), 4000);256    }257  }258259  onMount(() => {260    void refresh();261262    // Deep-link straight into a pairing path when opened from the sync card.263    if (props.initialPair === "chirp") pairViaChirp();264    else if (props.initialPair === "desktop") pairViaDesktop();265266    // Desktop pairing pushes from the background (bg → popup). The whole NK267    // pairing runs in the service worker; the SAS to confirm and the terminal268    // result arrive here as runtime messages.269    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {270      const handler = (msg: { type?: string; payload?: unknown }) => {271        if (pairKind() !== "desktop") return;272        if (msg?.type === "DESKTOP_PAIR_SAS") {273          const p = msg.payload as { sas?: string } | undefined;274          if (typeof p?.sas === "string") { setDesktopSas(p.sas); setDesktopPhase("sas"); }275        } else if (msg?.type === "DESKTOP_PAIR_RESULT") {276          const p = msg.payload as277            | { status: "complete"; device: PairedDevice }278            | { status: "error"; error: DesktopPairingError }279            | undefined;280          if (p?.status === "complete") { setDesktopDevice(p.device); setDesktopPhase("paired"); }281          else if (p?.status === "error") { setDesktopError(p.error); setDesktopPhase("error"); }282        }283      };284      chrome.runtime.onMessage.addListener(handler);285      onCleanup(() => chrome.runtime.onMessage.removeListener(handler));286    }287  });288289  async function refresh(): Promise<void> {290    if (!props.isPro) return;291    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });292    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;293    setDevices(data?.devices ?? []);294  }295296  async function handleStartPairing(): Promise<void> {297    setError(null);298299    // audioCapture is declared as optional_permissions in the manifest.300    // Chrome will silently deny getUserMedia unless we request it here,301    // during a user gesture. Firefox ignores this API but getUserMedia works302    // under its existing host permission grant.303    if (typeof chrome !== "undefined" && chrome.permissions?.request) {304      const granted = await chrome.permissions.request({305        permissions: ["audioCapture" as chrome.runtime.ManifestPermissions],306      });307      if (!granted) {308        setPairing({ phase: "mic_denied" });309        setMode("pairing");310        return;311      }312    }313314    setPairing({ phase: "idle" });315    setMode("pairing");316317    const deps: ChirpPairingDeps = {318      onProgress: (p) => setPairing(p),319      fetchLocalIdentity: async () => {320        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });321        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };322        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");323        // The handler returns a LocalIdentity; we trust the background's shape.324        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;325      },326      completePairing: async (paired) => {327        const res = await sendMessage({328          type: "CHIRP_COMPLETE_PAIRING",329          payload: { paired },330        });331        const r = res as { ok: boolean; error?: string };332        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");333      },334      confirmSAS: (sas) =>335        new Promise<boolean>((resolve) => {336          setSasResolve(() => resolve);337          setSasCode(sas);338        }),339      getLocalSubnet,340      openPairingTransport: async (endpoint) => {341        // Native WebSocket transport for the pairing-mode peer scan. The342        // chirp-pair orchestrator only uses send/receive/close, so a thin343        // adapter over the platform WebSocket suffices.344        return new Promise((resolve, reject) => {345          let ws: WebSocket;346          try {347            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);348          } catch (err) {349            reject(err);350            return;351          }352          const inbox: Uint8Array[] = [];353          const waiters: Array<(b: Uint8Array) => void> = [];354          let closed = false;355          ws.binaryType = "arraybuffer";356          const timer = setTimeout(() => {357            try { ws.close(); } catch { /* ignore */ }358            reject(new Error("pairing_transport_timeout"));359          }, 2_000);360          ws.onopen = () => {361            clearTimeout(timer);362            resolve({363              async send(data: Uint8Array) {364                ws.send(data as Uint8Array<ArrayBuffer>);365              },366              receive(): Promise<Uint8Array> {367                if (closed) return Promise.reject(new Error("closed"));368                const v = inbox.shift();369                if (v) return Promise.resolve(v);370                return new Promise<Uint8Array>((res) => waiters.push(res));371              },372              async close() {373                try { ws.close(); } catch { /* ignore */ }374              },375            });376          };377          ws.onmessage = (ev) => {378            if (!(ev.data instanceof ArrayBuffer)) return;379            const bytes = new Uint8Array(ev.data);380            const w = waiters.shift();381            if (w) w(bytes); else inbox.push(bytes);382          };383          ws.onerror = () => {384            clearTimeout(timer);385            reject(new Error("pairing_transport_error"));386          };387          ws.onclose = () => {388            closed = true;389            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));390          };391        });392      },393    };394395    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";396    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;397398    try {399      await runChirpPairing(deps, role);400      await refresh();401    } catch (err) {402      if (err instanceof ChirpPairingFailure) {403        // The orchestrator already pushed the terminal phase into404        // `pairing()`; the inline error copy will render. No further405        // action needed — the user can tap "Try again" or "Cancel".406        return;407      }408      setError((err as Error).message);409      setMode("rest");410    }411  }412413  async function handleUnpair(deviceId: string): Promise<void> {414    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });415    setConfirmUnpair(null);416    await refresh();417  }418419  function relativeTime(when: number | undefined): string {420    if (!when) return "Not synced yet";421    const elapsed = Date.now() - when;422    if (elapsed < 60_000) return "Just now";423    const minutes = Math.floor(elapsed / 60_000);424    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;425    const hours = Math.floor(minutes / 60);426    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;427    const days = Math.floor(hours / 24);428    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;429    return "Not seen recently";430  }431432  function isStale(d: PairedDevice): boolean {433    if (d.lastSyncedAt === undefined) return false;434    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;435  }436437  // --- Sub-views --------------------------------------------------438439  const proGateView = () => (440    <div class="peach-chirp-settings peach-chirp-settings--gated">441      <header class="peach-chirp-settings-header">442        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>443          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">444            <path d="M11 4 L5 9 L11 14" />445          </svg>446        </button>447        <h1 class="peach-chirp-settings-title">Sync</h1>448      </header>449      <div class="peach-chirp-pro-card">450        <div class="peach-chirp-pro-stage" aria-hidden="true">451          <svg class="peach-chirp-pro-chirp" viewBox="0 0 200 120" fill="none" stroke="currentColor"452               stroke-linecap="round" stroke-linejoin="round">453            <path d="M 124 36 Q 150 30 168 44" stroke-width="1.6" opacity="0.9" />454            <path d="M 128 52 Q 162 50 184 64" stroke-width="1.3" opacity="0.55" />455            <path d="M 124 68 Q 158 76 178 92" stroke-width="1.1" opacity="0.32" />456          </svg>457          <div class="peach-chirp-pro-bird-slot">458            <MobileBird pose="idle" />459          </div>460        </div>461        <h2 class="peach-chirp-pro-headline">A little bird that carries your vault</h2>462        <p class="peach-chirp-pro-body">463          Pairs over an inaudible chirp. Syncs locally across your devices.464          No codes, no servers, no cloud.465        </p>466        <button class="peach-btn peach-btn-primary peach-chirp-pro-cta" onClick={() => props.onLearnPro()}>467          Unlock with Peach Pro468        </button>469        <p class="peach-chirp-pro-foot">Peach Pro · one-time purchase</p>470      </div>471    </div>472  );473474  const restView = () => (475    <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">476      <header class="peach-chirp-head">477        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>478          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">479            <path d="M10 3 L5 8 L10 13" />480          </svg>481        </button>482        <div class="peach-chirp-title">Sync</div>483        <div class="peach-chirp-head-spacer" />484      </header>485486      <div class="peach-chirp-chip-row">487        <div class="peach-chirp-chip" data-phase="idle">488          <span class="peach-chirp-chip-dot" />489          <span class="peach-chirp-chip-text">ready</span>490        </div>491      </div>492493      <Show when={error()}>494        <div class="peach-chirp-settings-error" role="alert">{error()}</div>495      </Show>496497      <div class="peach-chirp-stage act-idle" aria-hidden="true">498        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">499          <DesktopBird pose="idle" />500        </div>501        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">502          <MobileBird pose="hidden" />503        </div>504      </div>505506      <div class="peach-chirp-copy-host">507        <div class="peach-chirp-copy peach-chirp-copy-rest">508          <div class="peach-chirp-headline">509            {devices().length === 0 ? "Pair a device" : "Sync is ready"}510          </div>511          <div class="peach-chirp-sub">512            {devices().length === 0513              ? "Sync over your local network. No cloud, no account, no servers."514              : "Pair another device, or manage the ones already connected below."}515          </div>516        </div>517      </div>518519      <Show when={devices().length > 0}>520        <section class="peach-chirp-paired-section">521          <h2 class="peach-chirp-paired-heading">Paired devices</h2>522          <ul class="peach-chirp-device-list">523            <For each={devices()}>524              {(d) => (525                <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>526                  <DeviceTypeIcon type={d.deviceType ?? "extension"} />527                  <div class="peach-chirp-device-info">528                    <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>529                    <span class="peach-chirp-device-meta">530                      {relativeTime(d.lastSyncedAt)}531                      <Show when={d.trustSource === "vouched"}>532                        <span class="peach-chirp-device-badge">Added automatically</span>533                      </Show>534                      <Show when={isStale(d)}>535                        <span class="peach-chirp-device-stale">Not seen recently</span>536                      </Show>537                    </span>538                  </div>539                  <Show540                    when={confirmUnpair() !== d.deviceId}541                    fallback={542                      <div class="peach-chirp-unpair-confirm">543                        <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>544                          Cancel545                        </button>546                        <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>547                          Remove548                        </button>549                      </div>550                    }551                  >552                    <button553                      class="peach-icon-btn peach-chirp-device-remove"554                      aria-label={`Unpair ${d.deviceName}`}555                      onClick={() => setConfirmUnpair(d.deviceId)}556                    >557                      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">558                        <path d="M3 3 L11 11 M11 3 L3 11" />559                      </svg>560                    </button>561                  </Show>562                </li>563              )}564            </For>565          </ul>566        </section>567      </Show>568569      <Show when={devices().length > 0}>570        <div class="peach-chirp-sync-now-row">571          <button572            class="peach-btn peach-btn-ghost peach-btn-sm peach-chirp-sync-now"573            disabled={syncStatus() === "syncing"}574            onClick={() => void handleSyncNow()}575          >576            {syncStatus() === "syncing" ? "Syncing…" : "Sync now"}577          </button>578          <Show when={syncFlash()}>579            <span class="peach-chirp-sync-flash" aria-live="polite">580              {syncFlash()}581            </span>582          </Show>583        </div>584      </Show>585586      <div class="peach-pair-method-list peach-pair-method-list--rest">587        <button class="peach-pair-method" onClick={() => pairViaChirp()}>588          <span class="peach-pair-method-icon" aria-hidden="true">589            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">590              <path d="M3 8 L6 8 L10.5 4 L10.5 16 L6 12 L3 12 Z" />591              <path d="M14 7.5 Q15.6 10 14 12.5" />592              <path d="M16.4 5.5 Q19 10 16.4 14.5" opacity="0.5" />593            </svg>594          </span>595          <span class="peach-pair-method-text">596            <span class="peach-pair-method-title">Same room</span>597            <span class="peach-pair-method-sub">Pair a nearby phone or browser by chirp</span>598          </span>599        </button>600        <button class="peach-pair-method" onClick={() => pairViaDesktop()}>601          <span class="peach-pair-method-icon" aria-hidden="true">602            <svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">603              <rect x="2" y="4" width="16" height="10" rx="1.4" />604              <path d="M7 17 L13 17" />605              <path d="M9 14 L9 17 M11 14 L11 17" />606            </svg>607          </span>608          <span class="peach-pair-method-text">609            <span class="peach-pair-method-title">Enter a code</span>610            <span class="peach-pair-method-sub">Type the code from your desktop sync helper</span>611          </span>612        </button>613      </div>614615    </div>616  );617618  const backArrow = (619    <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">620      <path d="M10 3 L5 8 L10 13" />621    </svg>622  );623624  // The bird stage — always animating — shared by every desktop pairing screen625  // so the bird animation is present regardless of sync method.626  const desktopBirdStage = (active: boolean) => (627    <div class={`peach-chirp-stage ${active ? "act-handshake" : "act-listening"}`} aria-hidden="true">628      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">629        <DesktopBird pose="chirping" />630      </div>631      <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">632        <MobileBird pose="attentive" />633      </div>634    </div>635  );636637  function desktopLiveText(): string {638    switch (desktopPhase()) {639      case "enter": return "Enter the pairing code from your computer";640      case "connecting": return "Searching for the helper";641      case "sas": return "Confirm the code matches on your computer";642      case "paired": return "Paired";643      case "error": return desktopErrorCopy(desktopError()).headline;644    }645  }646647  // Desktop helper code pairing — birds animate on every screen below.648  const desktopView = () => (649    <div class="peach-chirp-pair peach-desktop-pair" data-phase={desktopPhase()} role="dialog" aria-label="Pair a desktop helper">650      <header class="peach-chirp-head">651        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>652        <div class="peach-chirp-title">Enter a code</div>653        <div class="peach-chirp-head-spacer" />654      </header>655656      {desktopBirdStage(desktopPhase() !== "enter")}657658      <Show when={desktopPhase() === "enter"}>659        <div class="peach-desktop-enter">660          <div class="peach-chirp-headline">Enter the code from your computer</div>661          <div class="peach-chirp-sub">662            Open the Peach helper on your computer and type the 6-digit code it shows.663          </div>664          <label class="peach-label" for="desktop-pair-code">pairing code</label>665          <input666            id="desktop-pair-code"667            class="peach-underline-input peach-unlock-input peach-code-input"668            type="text"669            inputmode="numeric"670            autocomplete="one-time-code"671            maxlength={6}672            placeholder="000000"673            aria-label="6-digit pairing code"674            value={desktopCode()}675            ref={(el) => setTimeout(() => el.focus(), 80)}676            onInput={(e) => onDesktopCodeInput((e.target as HTMLInputElement).value)}677            onKeyDown={(e) => { if (e.key === "Enter") void handleDesktopConnect(); }}678          />679          <div class="peach-chirp-action">680            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>681            <button682              class="peach-btn peach-btn-primary peach-desktop-connect"683              disabled={!desktopCodeReady() || desktopBusy()}684              onClick={() => void handleDesktopConnect()}685            >686              Connect687            </button>688          </div>689        </div>690      </Show>691692      <Show when={desktopPhase() === "connecting"}>693        <div class="peach-desktop-searching">694          <div class="peach-chirp-headline">Searching for helper…</div>695          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>696        </div>697      </Show>698699      <Show when={desktopPhase() === "sas"}>700        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">701          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>702          <div703            class="peach-desktop-sas"704            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;"705          >706            {desktopSas()}707          </div>708          <div class="peach-chirp-sub" style="max-width:240px;">709            Only continue if this 6-digit code is identical on both screens.710          </div>711          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">712            <button class="peach-btn peach-btn-primary" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(true)}>713              Confirm714            </button>715            <button class="peach-btn peach-btn-ghost" disabled={desktopBusy()} onClick={() => void respondDesktopConfirm(false)}>716              Cancel717            </button>718          </div>719        </div>720      </Show>721722      <Show when={desktopPhase() === "paired"}>723        <div class="peach-desktop-paired">724          <div class="peach-chirp-headline">Paired!</div>725          <div class="peach-chirp-sub">726            {desktopDevice()?.deviceName ? `Connected to ${desktopDevice()!.deviceName}` : "Connected to your computer"}727          </div>728          <div class="peach-chirp-action">729            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>730          </div>731        </div>732      </Show>733734      <Show when={desktopPhase() === "error"}>735        <div class="peach-desktop-error">736          <div class="peach-chirp-headline">{desktopErrorCopy(desktopError()).headline}</div>737          <div class="peach-chirp-sub">{desktopErrorCopy(desktopError()).sub}</div>738          <div class="peach-chirp-action">739            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>740            <button class="peach-btn peach-btn-primary" onClick={() => startDesktopFlow()}>Try again</button>741          </div>742        </div>743      </Show>744745      <div class="peach-sr-only" aria-live="polite">{desktopLiveText()}</div>746    </div>747  );748749  const chirpPairingView = () => (750    <Show751      when={sasCode()}752      fallback={753        <ChirpPairingFlow754          progress={pairing()}755          onCancel={() => props.onBack()}756          onRetry={() => void handleStartPairing()}757          onDismiss={() => props.onBack()}758        />759      }760    >761      {/* Out-of-band verification gate — pairing is NOT persisted until the762          user confirms this code matches the one on the other device. */}763      <div764        class="chirp-sas-verify"765        style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:24px 18px;text-align:center;"766      >767        <div style="font-size:14px;font-weight:600;color:var(--peach-text);">Verify it's really your device</div>768        <div style="font-size:11.5px;line-height:1.45;color:var(--peach-text-muted);max-width:240px;">769          Confirm this code matches the one shown on your other device before pairing.770        </div>771        <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;">772          {sasCode()}773        </div>774        <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">775          <button776            class="peach-btn peach-btn--accent peach-btn--md"777            type="button"778            onClick={() => resolveSAS(true)}779          >780            codes match — pair781          </button>782          <button783            class="peach-btn peach-btn--ghost peach-btn--md"784            type="button"785            onClick={() => resolveSAS(false)}786          >787            they don't match788          </button>789        </div>790      </div>791    </Show>792  );793794  // Pairing surface dispatch: the user already chose chirp vs. code on the rest795  // screen (or the sync card), so there's no method-select step.796  const pairingView = () => (797    <Show when={pairKind() === "desktop"} fallback={chirpPairingView()}>798      {desktopView()}799    </Show>800  );801802  const currentMode = createMemo(() => {803    if (!props.isPro) return "gated";804    if (mode() === "pairing") return "pairing";805    return "rest";806  });807808  return (809    <Show810      when={currentMode() === "gated"}811      fallback={812        <Show when={currentMode() === "pairing"} fallback={restView()}>813          {pairingView()}814        </Show>815      }816    >817      {proGateView()}818    </Show>819  );820};821822const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {823  return (824    <div class="peach-chirp-device-icon" data-type={props.type}>825      <Show when={props.type === "android" || props.type === "ios"}>826        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">827          <rect x="6" y="2" width="8" height="16" rx="1.5" />828          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />829        </svg>830      </Show>831      <Show when={props.type === "extension" || props.type === "desktop"}>832        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">833          <rect x="2" y="4" width="16" height="10" rx="1.4" />834          <path d="M7 17 L13 17" />835          <path d="M9 14 L9 17 M11 14 L11 17" />836        </svg>837      </Show>838    </div>839  );840};