c9f3aba8b8ce

rebuild SyncPairing UI as show/enter modes; add code/input styles, recent-pair toast, and vault-lock teardown

time
2026-05-01 02:45 UTC · 6 months ago
machine
koh2
parent
76230f557838
3 files src/popup/App.tsx+20 −0src/popup/styles/popup.css+79 −102src/popup/views/SyncPairing.tsx+235 −122
src/popup/App.tsx+20 −0
@@ -167,6 +167,7 @@167167   } | null>(null);168168   const [backExitNotice, setBackExitNotice] = createSignal(false);169169   const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);170+  const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);170171   const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);171172   const [incomingPairTick, setIncomingPairTick] = createSignal(0);172173   const incomingPairSecondsLeft = () => {@@ -461,6 +462,7 @@461462 462463     await refreshPendingPasskey();463464     await refreshIncomingPairing();465+    await consumeRecentPairing();464466 465467     // Track tab changes so smart sort stays current466468     if (!Capacitor.isNativePlatform()) {@@ -704,6 +706,18 @@704706     setIncomingPair(data);705707   }706708 709+  // Drains a single one-shot record left by the background when 6-digit710+  // pairing succeeded while the popup was closed (or the SyncPairing view was711+  // navigated away from before it could show its own success card).712+  async function consumeRecentPairing() {713+    const res = await sendMessage({ type: "GET_RECENT_PAIRING" });714+    const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;715+    if (data?.deviceName) {716+      setRecentPairToast(data.deviceName);717+      window.setTimeout(() => setRecentPairToast(null), 4000);718+    }719+  }720+707721   async function respondIncomingPairing(accept: boolean) {708722     if (incomingPairResponding()) return;709723     setIncomingPairResponding(true);@@ -1121,6 +1135,12 @@11211135                 </button>11221136               </div>11231137             </header>1138+1139+            <Show when={recentPairToast()}>1140+              <div class="peach-pair-toast" role="status" aria-live="polite">1141+                Paired with <strong>{recentPairToast()}</strong>1142+              </div>1143+            </Show>11241144 11251145             <Show when={incomingPair() !== null}>11261146               <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">

view file

src/popup/styles/popup.css+79 −102
@@ -4815,50 +4815,10 @@48154815   text-align: center;48164816 }48174817 4818-.peach-pairing-discover {4819-  justify-content: flex-start;4820-  align-items: stretch;4821-  text-align: left;4822-  gap: 12px;4818+.peach-pairing-code-screen {4819+  gap: 18px;48234820 }48244821 4825-.peach-pairing-discover .peach-pairing-headline {4826-  text-align: center;4827-  margin-bottom: 4px;4828-}4829-4830-.peach-pairing-peer-card {4831-  display: flex;4832-  align-items: center;4833-  justify-content: space-between;4834-  gap: 12px;4835-  padding: 10px 12px;4836-  border: 1px solid var(--peach-t3);4837-  border-radius: 10px;4838-  background: var(--peach-bg-card);4839-}4840-4841-.peach-pairing-peer-info {4842-  display: flex;4843-  flex-direction: column;4844-  gap: 2px;4845-  flex: 1;4846-  min-width: 0;4847-}4848-4849-.peach-pairing-peer-name {4850-  font-size: 13px;4851-  color: var(--peach-t0);4852-  white-space: nowrap;4853-  overflow: hidden;4854-  text-overflow: ellipsis;4855-}4856-4857-.peach-pairing-peer-meta {4858-  font-size: 11px;4859-  color: var(--peach-t2);4860-}4861-48624822 .peach-pairing-headline {48634823   font-family: var(--font-display);48644824   font-style: italic;@@ -4881,81 +4841,82 @@48814841   text-align: center;48824842 }48834843 4884-.peach-pairing-code-display {4885-  display: flex;4886-  gap: 6px;4887-}4888-4889-.peach-pairing-code-digit {4890-  display: flex;4891-  align-items: center;4892-  justify-content: center;4893-  width: 36px;4894-  height: 48px;4895-  background: var(--peach-s2);4896-  border: 1px solid var(--peach-t3);4897-  border-radius: 6px;4898-  font-family: var(--font-mono);4899-  font-size: 22px;4844+/* 6-digit pairing code — both display (.peach-pairing-code) and entry4845+   (.peach-pairing-code-input) share IBM Plex Mono / 36px / wide tracking4846+   so the two modes feel like the same surface. */4847+.peach-pairing-code {4848+  font-family: "IBM Plex Mono", monospace;4849+  font-size: 36px;4850+  letter-spacing: 0.25em;49004851   color: var(--peach-t0);4901-  letter-spacing: 0;4852+  text-align: center;4853+  padding: 24px 0;4854+  text-shadow:4855+    0 0 24px color-mix(in srgb, var(--peach-accent) 22%, transparent),4856+    0 0 48px color-mix(in srgb, var(--peach-accent) 12%, transparent);4857+  background: transparent;4858+  border: 0;4859+  cursor: default;4860+  user-select: text;49024861 }49034862 4904-.peach-pairing-countdown-ring {4905-  position: relative;4906-  display: inline-flex;4907-  align-items: center;4908-  justify-content: center;4863+.peach-pairing-code--loading,4864+.peach-pairing-code--expired {4865+  font-size: 18px;4866+  letter-spacing: 0.04em;4867+  color: var(--peach-t2);4868+  cursor: pointer;4869+  text-shadow: none;49094870 }49104871 4911-.peach-pairing-countdown-num {4912-  position: absolute;4913-  font-family: var(--font-mono);4914-  font-size: 14px;4915-  color: var(--peach-t1);4872+.peach-pairing-code-input {4873+  font-family: "IBM Plex Mono", monospace;4874+  font-size: 36px;4875+  letter-spacing: 0.25em;4876+  color: var(--peach-t0);4877+  background: transparent;4878+  border: 0;4879+  border-bottom: 1px solid var(--peach-t3);4880+  outline: none;4881+  text-align: center;4882+  padding: 18px 0;4883+  width: 220px;4884+  caret-color: var(--peach-accent);4885+  transition: border-color 160ms ease;49164886 }49174887 4918-.peach-pairing-ring-track {4919-  fill: none;4920-  stroke: var(--peach-t3);4921-  stroke-width: 2;4888+.peach-pairing-code-input::placeholder {4889+  color: var(--peach-t3);49224890 }49234891 4924-.peach-pairing-ring-progress {4925-  fill: none;4926-  stroke: var(--peach-accent);4927-  stroke-width: 2;4928-  stroke-linecap: round;4929-  transform: rotate(-90deg);4930-  transform-origin: 50% 50%;4931-  transition: stroke-dashoffset 0.5s linear;4892+.peach-pairing-code-input:focus {4893+  border-bottom-color: var(--peach-accent);49324894 }49334895 4934-.peach-pairing-code-inputs {4935-  display: flex;4936-  gap: 8px;4896+.peach-pairing-toggle {4897+  background: transparent;4898+  border: 0;4899+  color: var(--peach-t2);4900+  font-size: 12px;4901+  padding: 8px 4px;4902+  cursor: pointer;4903+  transition: color 120ms ease;49374904 }49384905 4939-.peach-pairing-code-box {4940-  width: 36px;4941-  height: 48px;4942-  text-align: center;4943-  background: var(--peach-s2);4944-  border: 1px solid var(--peach-t3);4945-  border-radius: 6px;4946-  font-family: var(--font-mono);4947-  font-size: 20px;4948-  color: var(--peach-t0);4949-  outline: none;4950-  transition: border-color 120ms ease;4906+.peach-pairing-toggle:hover:not(:disabled) {4907+  color: var(--peach-t1);49514908 }49524909 4953-.peach-pairing-code-box:focus {4954-  border-color: var(--peach-accent);4910+.peach-pairing-toggle:disabled {4911+  opacity: 0.5;4912+  cursor: default;49554913 }49564914 4957-.peach-pairing-code-box.has-error {4958-  border-color: rgba(220, 80, 60, 0.45);4915+.peach-pairing-hint {4916+  font-size: 11px;4917+  color: var(--peach-t2);4918+  margin: 0;4919+  text-align: center;49594920 }49604921 49614922 .peach-pairing-status {@@ -4973,8 +4934,8 @@49734934   color: var(--peach-breach);49744935 }49754936 4976-.peach-pairing-error-msg {4977-  color: var(--peach-t1);4937+.peach-pairing-error--muted {4938+  color: var(--peach-t2);49784939 }49794940 49804941 .peach-pairing-spinner {@@ -6201,6 +6162,22 @@62016162   border-bottom: 1px solid color-mix(in srgb, var(--peach-accent) 32%, transparent);62026163   font-family: "IBM Plex Mono", monospace;62036164   flex-shrink: 0;6165+}6166+6167+.peach-pair-toast {6168+  padding: 8px 12px;6169+  font-family: "IBM Plex Mono", monospace;6170+  font-size: 12px;6171+  color: var(--peach-t1);6172+  background: color-mix(in srgb, var(--peach-accent) 10%, var(--peach-bg-card));6173+  border-bottom: 1px solid color-mix(in srgb, var(--peach-accent) 22%, transparent);6174+  flex-shrink: 0;6175+  text-align: center;6176+}6177+6178+.peach-pair-toast strong {6179+  color: var(--peach-t0);6180+  font-weight: 500;62046181 }62056182 62066183 .peach-pair-request-header {

view file

src/popup/views/SyncPairing.tsx+235 −122
@@ -1,90 +1,188 @@1-import { createSignal, For, onCleanup, onMount, Show } from "solid-js";1+import { createSignal, onCleanup, onMount, Show } from "solid-js";22 import { sendMessage } from "../App";3-import type { DiscoveredPeer, PairedDevice, PairingStep } from "../../shared/sync-types";3+import type { PairedDevice } from "../../shared/sync-types";44 55 interface SyncPairingProps {66   onClose: () => void;77   onPaired: (device: PairedDevice) => void;88 }99 10+type Mode = "show" | "enter";11+1012 function Spinner() {1113   return <span class="peach-pairing-spinner" />;1214 }1315 16+function formatCode(code: string): string {17+  if (code.length <= 3) return code;18+  return `${code.slice(0, 3)} ${code.slice(3)}`;19+}20+1421 export function SyncPairingView(props: SyncPairingProps) {15-  const [step, setStep] = createSignal<PairingStep>({ stage: "idle" });22+  const [mode, setMode] = createSignal<Mode>("show");23+  const [code, setCode] = createSignal<string | null>(null);24+  const [expiresAt, setExpiresAt] = createSignal<number | null>(null);25+  const [showError, setShowError] = createSignal<string | null>(null);1626 17-  const [discoveredPeers, setDiscoveredPeers] = createSignal<DiscoveredPeer[]>([]);18-  const [discoveryActive, setDiscoveryActive] = createSignal(false);19-  const [discoveryError, setDiscoveryError] = createSignal<string | null>(null);20-  const [pairingPeerId, setPairingPeerId] = createSignal<string | null>(null);27+  const [enteredCode, setEnteredCode] = createSignal("");28+  const [connecting, setConnecting] = createSignal(false);29+  const [enterError, setEnterError] = createSignal<string | null>(null);2130 22-  onMount(() => {23-    void startDiscovery();24-  });31+  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | null>(null);2532 26-  onCleanup(() => {27-    if (discoveryActive()) void sendMessage({ type: "STOP_PEER_DISCOVERY" });28-  });33+  let expiryTimer: ReturnType<typeof setTimeout> | null = null;2934 35+  function clearExpiryTimer() {36+    if (expiryTimer !== null) {37+      clearTimeout(expiryTimer);38+      expiryTimer = null;39+    }40+  }41+3042   function handleRuntimeMessage(msg: unknown) {31-    const m = msg as { type?: string; peer?: DiscoveredPeer; deviceId?: string };32-    if (m?.type === "SYNC_PEER_DISCOVERED" && m.peer) {33-      const peer = m.peer;34-      setDiscoveredPeers((prev) => {35-        if (prev.some((p) => p.deviceId === peer.deviceId)) return prev;36-        return [...prev, peer];37-      });43+    const m = msg as { type?: string; payload?: { deviceName?: string } };44+    if (m?.type === "PAIRING_COMPLETED" && m.payload?.deviceName) {45+      // Background completed an auto-accept while we were on the show screen.46+      const device: PairedDevice = {47+        deviceId: m.payload.deviceName,48+        displayName: m.payload.deviceName,49+        x25519PublicKey: "",50+        pairedAt: Date.now(),51+      };52+      clearExpiryTimer();53+      setCode(null);54+      setExpiresAt(null);55+      setPairedDevice(device);56+      props.onPaired(device);3857     }39-    if (m?.type === "SYNC_PEER_LOST" && m.deviceId) {40-      const id = m.deviceId;41-      setDiscoveredPeers((prev) => prev.filter((p) => p.deviceId !== id));58+  }59+60+  async function startShow() {61+    setShowError(null);62+    setCode(null);63+    setExpiresAt(null);64+    clearExpiryTimer();65+    const res = await sendMessage({ type: "START_PAIRING_SERVER" });66+    const response = res as {67+      ok: boolean;68+      error?: string;69+      data?: { token: string; expiresAt: number };70+    };71+    if (!response.ok || !response.data) {72+      setShowError(73+        response.error === "pro_required"74+          ? "Sync requires Peach Pro."75+          : "Couldn't start the pairing server.",76+      );77+      return;4278     }79+    setCode(response.data.token);80+    setExpiresAt(response.data.expiresAt);81+    const remaining = Math.max(0, response.data.expiresAt - Date.now());82+    expiryTimer = setTimeout(() => {83+      setCode(null);84+      setExpiresAt(null);85+      setShowError("Code expired — tap to refresh.");86+    }, remaining);4387   }4488 45-  async function startDiscovery() {46-    chrome.runtime.onMessage.addListener(handleRuntimeMessage);47-    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));89+  async function stopShow() {90+    clearExpiryTimer();91+    setCode(null);92+    setExpiresAt(null);93+    await sendMessage({ type: "STOP_PAIRING_SERVER" });94+  }4895 49-    const res = await sendMessage({ type: "START_PEER_DISCOVERY" });50-    const response = res as { ok: boolean; error?: string };51-    if (response.ok) {52-      setDiscoveryActive(true);53-      setDiscoveryError(null);96+  async function refreshCode() {97+    await stopShow();98+    await startShow();99+  }100+101+  async function switchTo(target: Mode) {102+    if (mode() === target) return;103+    if (target === "enter") {104+      await stopShow();105+      setEnterError(null);106+      setEnteredCode("");107+      setMode("enter");54108     } else {55-      setDiscoveryError(response.error ?? "discovery_failed");109+      setEnterError(null);110+      setMode("show");111+      await startShow();56112     }57113   }58114 59-  async function handlePairWithPeer(peer: DiscoveredPeer) {60-    setPairingPeerId(peer.deviceId);61-    setStep({ stage: "connecting", peerName: peer.deviceName });115+  function onEnterInput(e: InputEvent) {116+    const target = e.currentTarget as HTMLInputElement;117+    const raw = target.value.replace(/[^0-9]/g, "").slice(0, 6);118+    setEnteredCode(formatCode(raw));119+    if (enterError()) setEnterError(null);120+  }121+122+  function digitsEntered(): string {123+    return enteredCode().replace(/\D/g, "");124+  }125+126+  async function connect() {127+    const token = digitsEntered();128+    if (token.length !== 6) return;129+    setConnecting(true);130+    setEnterError(null);62131     const res = await sendMessage({63-      type: "INITIATE_PAIR_WITH_PEER",64-      payload: { peer },132+      type: "CONNECT_WITH_CODE",133+      payload: { token },65134     });66-    const response = res as { ok: boolean; error?: string };67-    setPairingPeerId(null);135+    setConnecting(false);136+    const response = res as { ok: boolean; error?: string; data?: { deviceName?: string } };68137     if (!response.ok) {69-      setStep({ stage: "error", message: response.error ?? "pair_failed" });138+      const msg =139+        response.error === "no peer responded to pairing code" ||140+        (response.error ?? "").includes("resolve failed")141+          ? "Couldn't find that device. Make sure both are on the same WiFi."142+          : response.error === "pair_rejected"143+            ? "The other device declined."144+            : response.error === "invalid_code"145+              ? "Enter all six digits."146+              : "Couldn't connect. Try again.";147+      setEnterError(msg);70148       return;71149     }72-    const paired: PairedDevice = {73-      deviceId: peer.deviceId,74-      displayName: peer.deviceName,75-      x25519PublicKey: peer.x25519PublicKey,150+    const name = response.data?.deviceName ?? "Peach";151+    const device: PairedDevice = {152+      deviceId: name,153+      displayName: name,154+      x25519PublicKey: "",76155       pairedAt: Date.now(),77156     };78-    setStep({ stage: "done", device: paired });79-    props.onPaired(paired);157+    setPairedDevice(device);158+    props.onPaired(device);80159   }81160 161+  onMount(() => {162+    chrome.runtime.onMessage.addListener(handleRuntimeMessage);163+    void startShow();164+  });165+166+  onCleanup(() => {167+    chrome.runtime.onMessage.removeListener(handleRuntimeMessage);168+    clearExpiryTimer();169+    // Pairing server keeps running in the background per spec — the user170+    // showed the code, so a peer can still complete pairing after the popup171+    // closes. The background clears it on success or vault-lock.172+  });173+82174   return (83175     <div class="peach-overlay-view ov-enter-right">84176       <div class="peach-overlay-header">85177         <button86178           class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"87-          onClick={props.onClose}179+          onClick={() => {180+            // Closing the popup is fine; if user explicitly hits back, also181+            // tear down the active pairing server so they don't leave a token182+            // active without realising.183+            void sendMessage({ type: "STOP_PAIRING_SERVER" });184+            props.onClose();185+          }}88186           aria-label="Close"89187         >90188           <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">@@ -96,97 +194,112 @@96194       </div>97195 98196       <div class="peach-pairing-body">99-        <Show when={step().stage === "idle"}>100-          <div class="peach-pairing-content peach-pairing-discover">101-            <p class="peach-pairing-headline">Add a device</p>102-            <Show when={discoveryError()}>103-              <p class="peach-pairing-error">104-                {discoveryError() === "pro_required"105-                  ? "Sync requires Peach Pro."106-                  : "Native sync host is not installed yet."}197+        <Show when={pairedDevice()}>198+          {(device) => (199+            <div class="peach-pairing-content">200+              <div class="peach-pairing-success-icon">201+                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">202+                  <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />203+                  <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />204+                </svg>205+              </div>206+              <p class="peach-pairing-headline">Paired!</p>207+              <p class="peach-pairing-subline peach-pairing-subline--center">208+                {device().displayName}107209               </p>108-            </Show>109-            <Show when={!discoveryError() && discoveredPeers().length === 0}>110-              <p class="peach-pairing-status">111-                <Spinner />112-                Searching for Peach on your local network…113-              </p>114-            </Show>115-            <For each={discoveredPeers()}>116-              {(peer) => (117-                <div class="peach-pairing-peer-card">118-                  <div class="peach-pairing-peer-info">119-                    <span class="peach-pairing-peer-name">{peer.deviceName}</span>120-                    <span class="peach-pairing-peer-meta">121-                      {peer.alreadyPaired ? "Already paired ✓" : "Found on network"}122-                    </span>123-                  </div>124-                  <Show when={!peer.alreadyPaired}>125-                    <button126-                      class="peach-btn peach-btn--accent peach-btn--sm"127-                      onClick={() => void handlePairWithPeer(peer)}128-                      disabled={pairingPeerId() !== null}129-                    >130-                      {pairingPeerId() === peer.deviceId ? "…" : "Pair →"}131-                    </button>132-                  </Show>210+              <button211+                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"212+                onClick={props.onClose}213+              >214+                Done215+              </button>216+            </div>217+          )}218+        </Show>219+220+        <Show when={!pairedDevice() && mode() === "show"}>221+          <div class="peach-pairing-content peach-pairing-code-screen">222+            <p class="peach-pairing-headline">Add a device</p>223+224+            <Show when={code()} fallback={225+              <Show when={!showError()} fallback={226+                <button227+                  class="peach-pairing-code peach-pairing-code--expired"228+                  onClick={() => void refreshCode()}229+                >230+                  refresh231+                </button>232+              }>233+                <div class="peach-pairing-code peach-pairing-code--loading">234+                  <Spinner />133235                 </div>236+              </Show>237+            }>238+              {(c) => (239+                <div class="peach-pairing-code" aria-label="pairing code">240+                  {formatCode(c())}241+                </div>134242               )}135-            </For>243+            </Show>244+136245             <p class="peach-pairing-subline peach-pairing-subline--center">137-              Open Peach on the other device. Both devices need to be on the138-              same WiFi network.246+              Open Peach on your other device, tap247+              <br />248+              Add a device, and enter this code.139249             </p>140-          </div>141-        </Show>250+            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>142251 143-        <Show when={step().stage === "connecting" || step().stage === "verifying"}>144-          <div class="peach-pairing-content">145-            <Spinner />146-            <p class="peach-pairing-status">147-              {step().stage === "connecting" ? "Connecting…" : "Verifying…"}148-            </p>252+            <Show when={showError()}>253+              <p class="peach-pairing-error peach-pairing-error--muted">{showError()}</p>254+            </Show>255+256+            <button257+              class="peach-pairing-toggle"258+              onClick={() => void switchTo("enter")}259+            >260+              I have a code to enter →261+            </button>149262           </div>150263         </Show>151264 152-        <Show when={step().stage === "done"}>153-          <div class="peach-pairing-content">154-            <div class="peach-pairing-success-icon">155-              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">156-                <circle cx="16" cy="16" r="14" stroke="var(--peach-health-ok)" stroke-width="1.5" />157-                <path d="M10 16L14 20L22 12" stroke="var(--peach-health-ok)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />158-              </svg>159-            </div>160-            <p class="peach-pairing-headline">Paired!</p>161-            <p class="peach-pairing-subline peach-pairing-subline--center">162-              {(step() as { stage: "done"; device: PairedDevice }).device.displayName}163-            </p>265+        <Show when={!pairedDevice() && mode() === "enter"}>266+          <div class="peach-pairing-content peach-pairing-code-screen">267+            <p class="peach-pairing-headline">Add a device</p>268+269+            <input270+              class="peach-pairing-code-input"271+              type="text"272+              inputmode="numeric"273+              autocomplete="off"274+              autocorrect="off"275+              spellcheck={false}276+              placeholder="000 000"277+              value={enteredCode()}278+              onInput={onEnterInput}279+              disabled={connecting()}280+              aria-label="enter pairing code"281+            />282+164283             <button165284               class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"166-              onClick={props.onClose}285+              onClick={() => void connect()}286+              disabled={digitsEntered().length !== 6 || connecting()}167287             >168-              Done288+              {connecting() ? "Connecting…" : "Connect →"}169289             </button>170-          </div>171-        </Show>172290 173-        <Show when={step().stage === "error"}>174-          <div class="peach-pairing-content">175-            <div class="peach-pairing-error-icon">176-              <svg width="32" height="32" viewBox="0 0 32 32" fill="none">177-                <circle cx="16" cy="16" r="14" stroke="var(--peach-breach)" stroke-width="1.5" />178-                <path d="M11 11L21 21M21 11L11 21" stroke="var(--peach-breach)" stroke-width="1.5" stroke-linecap="round" />179-              </svg>180-            </div>181-            <p class="peach-pairing-headline">Pairing failed</p>182-            <p class="peach-pairing-subline peach-pairing-subline--center peach-pairing-error-msg">183-              {(step() as { stage: "error"; message: string }).message}184-            </p>291+            <p class="peach-pairing-hint">Both devices must be on the same WiFi.</p>292+293+            <Show when={enterError()}>294+              <p class="peach-pairing-error peach-pairing-error--muted">{enterError()}</p>295+            </Show>296+185297             <button186-              class="peach-btn peach-btn--ghost peach-btn--md"187-              onClick={() => setStep({ stage: "idle" })}298+              class="peach-pairing-toggle"299+              onClick={() => void switchTo("show")}300+              disabled={connecting()}188301             >189-              Try again302+              ← Show my code instead190303             </button>191304           </div>192305         </Show>

view file