04-desktop-pairing-ui.md

22.4 KB · raw

rendered · source

Change 4 — Desktop helper pairing UI

DRAFT. Conforms to _GROUND-TRUTH.md and 03-desktop-pairing-handler.md. Proposed new component: /Users/june/projects/peach-browser/src/popup/components/DesktopPairingFlow.tsx. Nothing here is applied — apply-ready draft only.

This is the popup-side counterpart to the background pairing client in Change 3. It is a sibling of ChirpPairingFlow.tsx — same file layout (a top-level Component, a small phase state machine, a SAS-confirmation gate, friendly error copy, an aria-live region) — but driven by a typed code the user reads off their computer instead of an audio chirp.


0. Reality vs the brief (READ FIRST)

The brief was written against React idioms. This repo is Solid.js. The draft below uses Solid, not React. Concrete deviations:

Brief said Reality in this repo What the draft uses
React, .tsx with hooks (useState/useEffect) Solid.js (solid-js) createSignal, createEffect, <Show>, onCleanup, Component
OutlinedTextField No such component; Peach has no Material inputs peach-underline-input <input> (the real "PeachField")
PeachField component No component named PeachField exists (verified — grep -rl PeachField src returns nothing). The underline-style field the brief means is a plain <input class="peach-underline-input …"> peach-underline-input class
@solidjs/testing-library for tests Repo does not use it. Component tests use a tiny local helper renderView from src/popup/views/view-test-utils.tsx + raw jsdom renderView, inputText, real DOM queries

The "PeachField" underline input — what it actually is

There is no PeachField component. The underline-style input the brief calls for is a bare <input> carrying the peach-underline-input class, defined at src/popup/styles/popup.css:1006:

.peach-underline-input {
  width: 100%; border: 0; border-bottom: 1px solid var(--peach-t3);
  padding: 6px 0; background: transparent; color: var(--peach-t0);
  outline: none;
  font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
  font-size: 0.8125rem;
  caret-color: var(--peach-accent);
}

It is used as a single class on a native <input> — see Lock.tsx:326, Lock.tsx:452, Lock.tsx:487, and RecoveryWordEntry.tsx:297,314. The big-glyph variant (peach-unlock-input, popup.css:1014) gives font-size:22px; letter-spacing:0.24em, which is the right look for a 6-digit code. The draft reuses peach-underline-input + a code-specific class peach-code-input.

A single field (not 6 auto-advancing boxes) is correct here. The repo's existing 6-digit SAS surface (ChirpSync.tsx:445-472) renders the code as one mono string, and the master-password/recovery entry uses one underline field. There is no existing auto-advance multi-box pattern to mirror, so a single inputmode="numeric" maxlength=6 field matches Peach's form style and is simpler. (Documented per brief.)


1. Message-name adjustments vs file 03

File 03 §"New message types" defines four messages. The brief named three (PAIR_WITH_DESKTOP, DESKTOP_PAIR_SAS, DESKTOP_PAIR_CONFIRM). This UI uses all four from 03, because the popup needs the terminal push to know whether pairing finished or failed. Reconciliation:

Brief name 03 name Used here Notes
PAIR_WITH_DESKTOP (popup→bg) PAIR_WITH_DESKTOP yes payload { code }; resp { ok } or { ok:false, error }
DESKTOP_PAIR_SAS (bg→popup push) DESKTOP_PAIR_SAS yes payload { sas }
DESKTOP_PAIR_CONFIRM (popup→bg) DESKTOP_PAIR_CONFIRM yes payload { confirmed: boolean } — 03 uses confirmed, the brief wrote {accept:true}. The draft follows 03 (confirmed).
— DESKTOP_PAIR_RESULT (bg→push) yes 03-only; payload { status:"complete", device } | { status:"error", error }. The brief had no terminal push; without it the popup can't distinguish "Paired!" from a late error, so it is required.

Net adjustment: brief's {accept:true} → {confirmed:true} (match 03); brief's three messages → 03's four (add DESKTOP_PAIR_RESULT). Names otherwise identical to both 03 and the brief.

Error-code mapping (per ground truth §0.3 and 03 DesktopPairingError)

The background's DesktopPairingError union (03, lines ~92-97) maps to copy:

Background error Friendly copy
invalid_code "Code not found or expired"
no_host_found "Couldn't find your computer"
handshake_failed "Pairing failed — try again"
sas_mismatch "Codes don't match"
vault_locked "Unlock Peach first, then pair"

invalid_code = "not found or expired", combined on purpose. Ground truth §0.3 / §2: peach-sync sends no error frame — a wrong or expired code both surface identically as a socket close before PEACH_HELLO_ACK. The wire cannot distinguish them, so the background collapses both to invalid_code and the UI shows a single "Code not found or expired" message. The brief's separate "Code not found" (invalid_code) and "Code expired" (expired) cannot be told apart on this wire and are merged. If a future protocol revision adds an explicit expiry frame, split the copy then.


2. Where this slots into the Add-Device UI

Parent file and exact spot

The existing pairing entry point is src/popup/views/ChirpSync.tsx — the Settings → Sync surface. Its restView() (ChirpSync.tsx:300-426) is the "rest" mode that lists paired devices and offers pairing. The single pairing CTA is the "Pair a device" button block at ChirpSync.tsx:412-423:

<div class="peach-chirp-action">
  <p class="peach-chirp-mic-notice">…</p>
  <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>
    …<span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>
  </button>
</div>

Today that button is the only "Add Device" affordance and it goes straight into the chirp flow. The Desktop Helper option slots in here as a second choice alongside the chirp button, inside this same peach-chirp-action block. Two ways to wire it (pick one at integration):

The draft component DesktopPairingFlow itself also includes a built-in mode-select header (chirp vs desktop) so it can be the whole surface if the integrator routes "Pair a device" into it. Either way, the insertion point is ChirpSync.tsx restView() action block (lines 412-423), and the rendering point is ChirpSync.tsx pairingView() (lines 428-474), which already does <Show when={sasCode()} fallback={<ChirpPairingFlow .../>}>. The desktop flow is rendered the same way: when the user picks Desktop Helper, pairingView() renders <DesktopPairingFlow .../> instead of (or in addition to) <ChirpPairingFlow .../>.

Wiring sketch in ChirpSync.tsx (integration TODO — NOT applied)

// new signal alongside the others (ChirpSync.tsx ~line 51)
const [pairKind, setPairKind] = createSignal<"chirp" | "desktop">("chirp");

// in restView()'s action block (ChirpSync.tsx 412-423), add beneath the
// existing chirp button:
<button
  class="peach-chirp-pair-start peach-chirp-pair-start--desktop"
  onClick={() => { setPairKind("desktop"); setMode("pairing"); }}
>
  <span>Desktop Helper (enter code)</span>
</button>

// in the render switch (ChirpSync.tsx 482-493), when mode()==="pairing" and
// pairKind()==="desktop", render <DesktopPairingFlow/> instead of the chirp view:
<Show when={pairKind() === "desktop"} fallback={pairingView()}>
  <DesktopPairingFlow onCancel={() => setMode("rest")} onDone={() => { setMode("rest"); void refresh(); }} />
</Show>

DesktopPairingFlow is self-contained for the message round-trip: it sends PAIR_WITH_DESKTOP, listens for DESKTOP_PAIR_SAS / DESKTOP_PAIR_RESULT pushes via chrome.runtime.onMessage, and replies DESKTOP_PAIR_CONFIRM. It does not reuse ChirpSync's sasResolve plumbing (that one is in-popup; this SAS arrives as a push from the background, per 03's split where the background runs the whole pairing).


3. Popup ⇄ background message contract (must match 03)

// popup → bg : start pairing with the typed code
{ type: "PAIR_WITH_DESKTOP", payload: { code: string } }
//   resp: { ok: true } | { ok: false, error: DesktopPairingError }

// bg → popup (push) : SAS ready, show confirm gate
{ type: "DESKTOP_PAIR_SAS", payload: { sas: string } }

// popup → bg : user accepted/rejected the SAS
{ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed: boolean } }
//   resp: { ok: true }

// bg → popup (push) : terminal result
{ type: "DESKTOP_PAIR_RESULT",
  payload: { status: "complete", device: PairedDevice }
         | { status: "error", error: DesktopPairingError } }

sendMessage is the existing bridge (src/shared/bridge.ts:13); pushes are received with chrome.runtime.onMessage.addListener exactly as App.tsx:601 does. DesktopPairingError is defined in 03 (desktop-pairing.ts).


4. Apply-ready component — src/popup/components/DesktopPairingFlow.tsx

// DesktopPairingFlow — the desktop-helper *code* pairing screen.
//
// Sibling of ChirpPairingFlow.tsx, but driven by a 6-digit code the user
// reads off their computer rather than an audio chirp. The whole pairing
// runs in the BACKGROUND (03); this component:
//   1. takes a 6-digit code in a single underline field (the "PeachField"),
//   2. on Connect -> sends PAIR_WITH_DESKTOP, shows "Searching for helper…",
//   3. on the DESKTOP_PAIR_SAS push -> shows the SAS + Confirm / Cancel,
//   4. on Confirm -> DESKTOP_PAIR_CONFIRM { confirmed:true } -> "Paired!",
//      on Cancel  -> DESKTOP_PAIR_CONFIRM { confirmed:false } -> error,
//   5. maps background errors (DESKTOP_PAIR_RESULT) to friendly copy.
//
// Wire contract: _GROUND-TRUTH.md §6 + 03-desktop-pairing-handler.md.

import { Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js";
import { sendMessage } from "../../shared/bridge";
import type { PairedDevice } from "../../shared/sync-types";

// Mirrors DesktopPairingError in 03 (src/background/desktop-pairing.ts).
type DesktopPairingError =
  | "invalid_code"
  | "no_host_found"
  | "handshake_failed"
  | "sas_mismatch"
  | "vault_locked";

// Local UI phase machine (the analogue of PairingPhase for the chirp flow).
type DesktopPhase =
  | "enter"        // user is typing the code
  | "searching"    // PAIR_WITH_DESKTOP sent, awaiting SAS/result
  | "confirm"      // DESKTOP_PAIR_SAS received, awaiting user confirm
  | "paired"       // DESKTOP_PAIR_RESULT status:complete
  | "error";       // DESKTOP_PAIR_RESULT status:error (or a start failure)

export interface DesktopPairingFlowProps {
  /** Back/cancel out of the desktop flow (return to the device list). */
  onCancel: () => void;
  /** Pairing finished and the device was persisted; dismiss the surface. */
  onDone: (device?: PairedDevice) => void;
}

const PAIRED_DISMISS_MS = 2_000;
const CODE_LEN = 6;

function errorCopy(error: DesktopPairingError | "unknown"): { headline: string; sub: string } {
  switch (error) {
    case "invalid_code":
      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire.
      return { headline: "Code not found or expired", sub: "Check the code on your computer and try again. Codes rotate, so grab a fresh one if it's been a while." };
    case "no_host_found":
      return { headline: "Couldn't find your computer", sub: "Make sure the Peach helper is running and both devices are on the same Wi-Fi." };
    case "sas_mismatch":
      return { headline: "Codes don't match", sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully." };
    case "vault_locked":
      return { headline: "Unlock Peach first", sub: "Your vault needs to be unlocked before you can pair a device." };
    case "handshake_failed":
    default:
      return { headline: "Pairing failed", sub: "Something went wrong while connecting. Please try again." };
  }
}

export const DesktopPairingFlow: Component<DesktopPairingFlowProps> = (props) => {
  const [phase, setPhase] = createSignal<DesktopPhase>("enter");
  const [code, setCode] = createSignal("");
  const [sas, setSas] = createSignal<string | null>(null);
  const [error, setError] = createSignal<DesktopPairingError | "unknown">("unknown");
  const [device, setDevice] = createSignal<PairedDevice | undefined>(undefined);
  const [busy, setBusy] = createSignal(false);

  const codeReady = () => /^\d{6}$/.test(code());

  // Listen for the two background pushes (mirrors App.tsx:601 idiom).
  onMount(() => {
    const handler = (msg: { type?: string; payload?: unknown }) => {
      if (msg?.type === "DESKTOP_PAIR_SAS") {
        const p = msg.payload as { sas?: string } | undefined;
        if (typeof p?.sas === "string") { setSas(p.sas); setPhase("confirm"); }
      } else if (msg?.type === "DESKTOP_PAIR_RESULT") {
        const p = msg.payload as
          | { status: "complete"; device: PairedDevice }
          | { status: "error"; error: DesktopPairingError }
          | undefined;
        if (p?.status === "complete") { setDevice(p.device); setPhase("paired"); }
        else if (p?.status === "error") { setError(p.error); setPhase("error"); }
      }
    };
    chrome.runtime.onMessage.addListener(handler);
    onCleanup(() => chrome.runtime.onMessage.removeListener(handler));
  });

  // Auto-dismiss after the paired state settles, like ChirpPairingFlow.
  createEffect(() => {
    if (phase() !== "paired") return;
    const t = setTimeout(() => props.onDone(device()), PAIRED_DISMISS_MS);
    onCleanup(() => clearTimeout(t));
  });

  async function handleConnect(): Promise<void> {
    if (!codeReady() || busy()) return;
    setBusy(true);
    setPhase("searching");
    try {
      const res = (await sendMessage({
        type: "PAIR_WITH_DESKTOP",
        payload: { code: code() },
      })) as { ok: boolean; error?: DesktopPairingError };
      // A synchronous failure (e.g. vault_locked) comes back here; the SAS and
      // terminal result arrive later as pushes.
      if (!res.ok) { setError(res.error ?? "unknown"); setPhase("error"); }
    } catch {
      setError("unknown");
      setPhase("error");
    } finally {
      setBusy(false);
    }
  }

  async function respondConfirm(confirmed: boolean): Promise<void> {
    if (busy()) return;
    setBusy(true);
    // Optimistically move on; the terminal DESKTOP_PAIR_RESULT push decides the
    // final phase (paired vs sas_mismatch error).
    if (confirmed) setPhase("searching"); else { setError("sas_mismatch"); setPhase("error"); }
    try {
      await sendMessage({ type: "DESKTOP_PAIR_CONFIRM", payload: { confirmed } });
    } finally {
      setBusy(false);
    }
  }

  function reset(): void {
    setPhase("enter");
    setSas(null);
    setDevice(undefined);
    setError("unknown");
  }

  function onCodeInput(value: string): void {
    setCode(value.replace(/\D/g, "").slice(0, CODE_LEN));
  }

  return (
    <div class="peach-chirp-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a desktop helper">
      <header class="peach-chirp-head">
        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onCancel()}>
          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
            <path d="M10 3 L5 8 L10 13" />
          </svg>
        </button>
        <div class="peach-chirp-title">Desktop Helper</div>
        <div class="peach-chirp-head-spacer" />
      </header>

      {/* ---- enter: 6-digit code field ---- */}
      <Show when={phase() === "enter"}>
        <div class="peach-desktop-enter">
          <div class="peach-chirp-headline">Enter the code from your computer</div>
          <div class="peach-chirp-sub">
            Open the Peach helper on your computer and type the 6-digit code it shows.
          </div>
          <label class="peach-label" for="desktop-pair-code">pairing code</label>
          <input
            id="desktop-pair-code"
            class="peach-underline-input peach-unlock-input peach-code-input"
            type="text"
            inputmode="numeric"
            autocomplete="one-time-code"
            maxlength={CODE_LEN}
            placeholder="000000"
            aria-label="6-digit pairing code"
            value={code()}
            ref={(el) => setTimeout(() => el.focus(), 80)}
            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}
            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}
          />
          <div class="peach-chirp-action">
            <button class="peach-chirp-cancel peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>
              Cancel
            </button>
            <button
              class="peach-btn peach-btn-primary peach-desktop-connect"
              disabled={!codeReady() || busy()}
              onClick={() => void handleConnect()}
            >
              Connect
            </button>
          </div>
        </div>
      </Show>

      {/* ---- searching ---- */}
      <Show when={phase() === "searching"}>
        <div class="peach-desktop-searching">
          <div class="peach-chirp-headline">Searching for helper…</div>
          <div class="peach-chirp-sub">Connecting to the Peach helper on your computer.</div>
          <div class="peach-chirp-action-handshake">
            <div class="peach-chirp-meter"><span class="peach-chirp-meter-bar" /></div>
            <span class="peach-chirp-meter-label">connecting · noise NK</span>
          </div>
        </div>
      </Show>

      {/* ---- confirm: SAS gate ---- */}
      <Show when={phase() === "confirm"}>
        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px 18px;text-align:center;">
          <div class="peach-chirp-headline">Confirm this code matches on your computer:</div>
          <div
            class="peach-desktop-sas"
            style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"
          >
            {sas()}
          </div>
          <div class="peach-chirp-sub" style="max-width:240px;">
            Only continue if this 6-digit code is identical on both screens.
          </div>
          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">
            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>
              Confirm
            </button>
            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>
              Cancel
            </button>
          </div>
        </div>
      </Show>

      {/* ---- paired ---- */}
      <Show when={phase() === "paired"}>
        <div class="peach-desktop-paired">
          <div class="peach-chirp-headline">Paired!</div>
          <div class="peach-chirp-sub">
            {device()?.deviceName ? `Connected to ${device()!.deviceName}` : "Connected to your computer"}
          </div>
          <div class="peach-chirp-action">
            <button class="peach-btn peach-btn-primary" onClick={() => props.onDone(device())}>Done</button>
          </div>
        </div>
      </Show>

      {/* ---- error ---- */}
      <Show when={phase() === "error"}>
        <div class="peach-desktop-error">
          <div class="peach-chirp-headline">{errorCopy(error()).headline}</div>
          <div class="peach-chirp-sub">{errorCopy(error()).sub}</div>
          <div class="peach-chirp-action">
            <button class="peach-btn peach-btn-ghost" onClick={() => props.onCancel()}>Cancel</button>
            <button class="peach-btn peach-btn-primary" onClick={() => reset()}>Try again</button>
          </div>
        </div>
      </Show>

      {/* Polite live region so phase changes reach assistive tech. */}
      <div class="peach-sr-only" aria-live="polite">{liveText(phase(), error())}</div>
    </div>
  );
};

function liveText(phase: DesktopPhase, error: DesktopPairingError | "unknown"): string {
  switch (phase) {
    case "enter": return "Enter the pairing code from your computer";
    case "searching": return "Searching for the helper";
    case "confirm": return "Confirm the code matches on your computer";
    case "paired": return "Paired";
    case "error": return errorCopy(error).headline;
  }
}

Reused class hooks (peach-chirp-pair, peach-chirp-head, peach-chirp-back, peach-chirp-headline, peach-chirp-sub, peach-chirp-action, peach-chirp-meter*, peach-btn*, peach-sr-only, peach-underline-input, peach-unlock-input, peach-label) all already exist in popup.css. New hooks (peach-desktop-pair, peach-code-input, peach-desktop-*) are additive and need a small CSS block at integration (out of scope for this draft — drafts touch only this folder).


5. Integration TODOs (NOT applied — flagged for the integrator)

  1. Add the three message cases to messages.ts per 03 §"Wiring" (the background side). This component assumes those handlers exist.
  2. Insert the Desktop Helper affordance into ChirpSync.tsx restView() action block (lines 412-423) and render <DesktopPairingFlow/> from pairingView() — see §2.
  3. CSS for the new peach-desktop-* / peach-code-input hooks in popup.css (inline styles cover the load-bearing SAS/confirm layout in the meantime, matching ChirpSync's inline-styled SAS gate at lines 445-472).
  4. Pro gate + lock check. ChirpSync already gates on props.isPro; the desktop button should live behind the same gate, and vault_locked from the background is surfaced as friendly copy here.
  5. Single in-flight pairing. 03 keeps one pairing per worker; if the user backs out mid-flow (onCancel), the integrator may want a DESKTOP_PAIR_CONFIRM { confirmed:false } or a cancel message to release the background resolver. Left as a TODO since 03 owns that lifecycle.