2fdc9c8169ea

extract the three-state relay choice into one SyncModeSelector so Settings and the pairing surfaces cannot drift: exact disclosure line, radio-group semantics, and an empty private URL field that says sync is paused instead of quietly using the public relay (v4.2.3)

time
2026-07-26 16:48 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync settings ux and relay surfacing
parent
a5b058442cd7
11 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+4 −4src/background/sync-settings.test.ts+13 −4src/background/sync-settings.ts+9 −8src/popup/components/SyncModeSelector.test.tsx+127 −0src/popup/components/SyncModeSelector.tsx+174 −0src/popup/styles/popup.css+24 −0src/popup/views/Settings.tsx+4 −112src/popup/views/contracts.test.tsx+44 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.2",4+  "version": "4.2.3",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.2",4+  "version": "4.2.3",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.2.2",3+  "version": "4.2.3",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/messages.ts+4 −4
@@ -92,7 +92,7 @@9292 } from "./relay-adoption-source-bg";9393 import {9494   getSyncSettings,95-  setPrivateRelayUrl,95+  setRelayUrl,9696   setSyncMode,9797   type SyncMode,9898 } from "./sync-settings";@@ -1528,13 +1528,13 @@15281528           relayUrl?: unknown;15291529         };15301530         try {1531-          // A relay URL implies private mode — offering a custom relay and not1532-          // switching to it would be a trap.1531+          // A custom relay URL implies private mode; Peach's own relay stays1532+          // public. See setRelayUrl.15331533           if (payload.relayUrl !== undefined) {15341534             if (typeof payload.relayUrl !== "string") {15351535               return { ok: false, error: "invalid_relay_url" };15361536             }1537-            return { ok: true, data: await setPrivateRelayUrl(payload.relayUrl) };1537+            return { ok: true, data: await setRelayUrl(payload.relayUrl) };15381538           }15391539           if (payload.mode !== undefined) {15401540             if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {

view file

src/background/sync-settings.test.ts+13 −4
@@ -10,7 +10,7 @@1010   getSyncSettings,1111   isPrivateRelayUnconfigured,1212   relayInForce,13-  setPrivateRelayUrl,13+  setRelayUrl,1414   setSyncMode,1515 } from "./sync-settings";1616 @@ -50,7 +50,7 @@5050   });5151 5252   it("keeps a configured private relay through off and back", async () => {53-    await setPrivateRelayUrl(PRIVATE_RELAY);53+    await setRelayUrl(PRIVATE_RELAY);5454     await setSyncMode("off");5555     expect((await getSyncSettings()).relayUrl).toBe(PRIVATE_RELAY);5656     expect(await setSyncMode("private")).toEqual({ mode: "private", relayUrl: PRIVATE_RELAY });@@ -60,13 +60,22 @@6060 describe("relayInForce", () => {6161   it("returns the relay under public and private", async () => {6262     expect(relayInForce(await getSyncSettings())).toBe(DEFAULT_RELAY_URL);63-    expect(relayInForce(await setPrivateRelayUrl(PRIVATE_RELAY))).toBe(PRIVATE_RELAY);63+    expect(relayInForce(await setRelayUrl(PRIVATE_RELAY))).toBe(PRIVATE_RELAY);6464   });6565 6666   it("refuses when sync is off", async () => {6767     expect(() => relayInForce({ mode: "off", relayUrl: DEFAULT_RELAY_URL })).toThrow("sync_disabled");6868   });6969 70+  // Adoption hands the source's relay over setRelayUrl. When that relay is71+  // Peach's own, the joining device must land on `public` — labelling it72+  // `private` would strand it in the fail-closed state below.73+  it("stays public when the adopted relay is Peach's own", async () => {74+    const settings = await setRelayUrl(DEFAULT_RELAY_URL);75+    expect(settings.mode).toBe("public");76+    expect(relayInForce(settings)).toBe(DEFAULT_RELAY_URL);77+  });78+7079   it("refuses private with no URL rather than falling back to the public relay", async () => {7180     const settings = await setSyncMode("private");7281     expect(isPrivateRelayUnconfigured(settings)).toBe(true);@@ -81,7 +90,7 @@8190   });8291 8392   it("pushes the relay once it is a real choice", async () => {84-    await setPrivateRelayUrl(PRIVATE_RELAY);93+    await setRelayUrl(PRIVATE_RELAY);8594     expect(setRelayUrlForAllPairedDevices).toHaveBeenCalledWith(PRIVATE_RELAY);8695     setRelayUrlForAllPairedDevices.mockClear();8796     await setSyncMode("public");

view file

src/background/sync-settings.ts+9 −8
@@ -99,17 +99,18 @@9999 }100100 101101 /**102- * Point this mesh at a self-hosted relay. Implies `private` — choosing a custom103- * relay and not switching to it would be a trap.102+ * Point this mesh at a relay by URL. A custom URL implies `private` — offering103+ * one and not switching to it would be a trap. Peach's own relay stays104+ * `public`: adoption hands the source's relay over this same path, so calling105+ * that "private" would mislabel every device that joined over the public relay106+ * and strand it in the unconfigured-private state above.104107  */105-export async function setPrivateRelayUrl(input: string): Promise<SyncSettings> {108+export async function setRelayUrl(input: string): Promise<SyncSettings> {106109   const relayUrl = normalizeRelayUrl(input);110+  const mode: SyncMode = relayUrl === DEFAULT_RELAY_URL ? "public" : "private";107111   await setRelayUrlForAllPairedDevices(relayUrl);108-  await chrome.storage.local.set({109-    [RELAY_URL_KEY]: relayUrl,110-    [SYNC_MODE_KEY]: "private" satisfies SyncMode,111-  });112-  return { mode: "private", relayUrl };112+  await chrome.storage.local.set({ [RELAY_URL_KEY]: relayUrl, [SYNC_MODE_KEY]: mode });113+  return { mode, relayUrl };113114 }114115 115116 function relayFor(mode: SyncMode, configured: string): string {

view file

src/popup/components/SyncModeSelector.test.tsx+127 −0
@@ -0,0 +1,127 @@1+// @vitest-environment jsdom2+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";3+import { DEFAULT_RELAY_URL } from "../../shared/rendezvous";4+import { flushView, inputText, renderView } from "../views/view-test-utils";5+6+const sendMessage = vi.hoisted(() => vi.fn());7+vi.mock("../../shared/bridge", () => ({ sendMessage }));8+9+import { RELAY_DISCLOSURE, SyncModeSelector } from "./SyncModeSelector";10+11+let settings = { mode: "public" as "public" | "private" | "off", relayUrl: DEFAULT_RELAY_URL };12+let disposeView: (() => void) | null = null;13+14+beforeEach(() => {15+  settings = { mode: "public", relayUrl: DEFAULT_RELAY_URL };16+  sendMessage.mockReset();17+  sendMessage.mockImplementation(async (msg: { type: string; payload?: Record<string, unknown> }) => {18+    if (msg.type === "GET_SYNC_SETTINGS") return { ok: true, data: settings };19+    if (msg.type === "SET_SYNC_SETTINGS") {20+      const mode = msg.payload?.mode as typeof settings.mode | undefined;21+      if (mode) {22+        settings = { mode, relayUrl: mode === "public" ? DEFAULT_RELAY_URL : settings.relayUrl };23+      } else {24+        const url = String(msg.payload?.relayUrl ?? "");25+        if (!url.startsWith("wss://")) return { ok: false, error: "invalid_relay_url" };26+        settings = { mode: url === DEFAULT_RELAY_URL ? "public" : "private", relayUrl: url };27+      }28+      return { ok: true, data: settings };29+    }30+    return { ok: false };31+  });32+});33+34+afterEach(() => {35+  disposeView?.();36+  disposeView = null;37+});38+39+async function mount(compact = false) {40+  const view = renderView(() => <SyncModeSelector compact={compact} />);41+  disposeView = view.dispose;42+  await flushView();43+  return view.container;44+}45+46+function optionNamed(container: HTMLElement, title: string): HTMLButtonElement {47+  const match = [...container.querySelectorAll<HTMLButtonElement>("button.peach-sync-mode")].find(48+    (b) => b.querySelector(".peach-sync-mode-title")?.textContent === title,49+  );50+  if (!match) throw new Error(`no option titled ${title}`);51+  return match;52+}53+54+describe("sync mode selector", () => {55+  it("offers exactly the three named choices as a radio group", async () => {56+    const container = await mount();57+    const titles = [...container.querySelectorAll(".peach-sync-mode-title")].map((n) => n.textContent);58+    expect(titles).toEqual(["Public relay", "Private relay", "No sync"]);59+    expect(container.querySelector("[role='radiogroup']")).not.toBeNull();60+    expect(optionNamed(container, "Public relay").getAttribute("aria-checked")).toBe("true");61+  });62+63+  it("shows the disclosure once, whichever state is active", async () => {64+    const container = await mount();65+    expect(container.querySelectorAll(".peach-sync-mode-disclosure")).toHaveLength(1);66+    expect(container.querySelector(".peach-sync-mode-disclosure")?.textContent).toBe(RELAY_DISCLOSURE);67+    optionNamed(container, "No sync").click();68+    await flushView();69+    expect(container.querySelector(".peach-sync-mode-disclosure")?.textContent).toBe(RELAY_DISCLOSURE);70+  });71+72+  it("shows no URL field under public or off", async () => {73+    const container = await mount();74+    expect(container.querySelector("#sync-relay-url")).toBeNull();75+    optionNamed(container, "No sync").click();76+    await flushView();77+    expect(container.querySelector("#sync-relay-url")).toBeNull();78+  });79+80+  it("flags private with no URL instead of silently using the public relay", async () => {81+    const container = await mount();82+    optionNamed(container, "Private relay").click();83+    await flushView();84+    const field = container.querySelector<HTMLInputElement>("#sync-relay-url");85+    expect(field).not.toBeNull();86+    // The public relay must not be pre-filled as if it were the user's own.87+    expect(field!.value).toBe("");88+    const warn = container.querySelector(".peach-sync-mode-warn");89+    expect(warn?.textContent).toContain("Sync is paused until you enter your relay URL");90+  });91+92+  it("clears the warning once a real relay is saved", async () => {93+    const container = await mount();94+    optionNamed(container, "Private relay").click();95+    await flushView();96+    inputText(container.querySelector<HTMLInputElement>("#sync-relay-url")!, "wss://relay.example.com");97+    [...container.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent === "save")!.click();98+    await flushView();99+    expect(container.querySelector(".peach-sync-mode-warn")).toBeNull();100+    expect(settings).toEqual({ mode: "private", relayUrl: "wss://relay.example.com" });101+  });102+});103+104+describe("compact form, used mid-pairing", () => {105+  it("is a one-line summary until asked to change", async () => {106+    const container = await mount(true);107+    expect(container.querySelector(".peach-sync-mode-summary")?.textContent).toContain(108+      "Using Public relay",109+    );110+    expect(container.querySelector(".peach-sync-mode")).toBeNull();111+    container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();112+    await flushView();113+    expect(container.querySelectorAll(".peach-sync-mode")).toHaveLength(3);114+  });115+116+  it("reports a committed change so the host can restart pairing", async () => {117+    const onChange = vi.fn();118+    const view = renderView(() => <SyncModeSelector compact onChange={onChange} />);119+    disposeView = view.dispose;120+    await flushView();121+    view.container.querySelector<HTMLButtonElement>(".peach-sync-mode-change")!.click();122+    await flushView();123+    optionNamed(view.container, "No sync").click();124+    await flushView();125+    expect(onChange).toHaveBeenCalledWith({ mode: "off", relayUrl: DEFAULT_RELAY_URL });126+  });127+});

view file

src/popup/components/SyncModeSelector.tsx+174 −0
@@ -0,0 +1,174 @@1+// SyncModeSelector — the one place the relay choice is made.2+//3+// Settings → Sync renders it expanded and is the source of truth; the pairing4+// surfaces render it `compact`, as a quiet "Using Public relay · change" line5+// that expands in place. Both read and write the same GET/SET_SYNC_SETTINGS6+// value, so the two surfaces cannot drift apart7+// (docs/sync-relay-surfacing.md).8+9+import { Component, Show, createSignal, onMount } from "solid-js";10+import { sendMessage } from "../../shared/bridge";11+import { DEFAULT_RELAY_URL } from "../../shared/rendezvous";12+13+export type SyncMode = "public" | "private" | "off";14+15+export interface SyncSettingsSnapshot {16+  mode: SyncMode;17+  relayUrl: string;18+}19+20+/**21+ * True of the same phrase everywhere: the relay is a dumb pipe under both22+ * relay modes, and its absence is what makes "No sync" a visibly smaller23+ * footprint rather than a different kind of trust.24+ */25+export const RELAY_DISCLOSURE =26+  "End-to-end encrypted, through a relay that never stores or accesses any vault or user data.";27+28+export function modeLabel(mode: SyncMode): string {29+  return mode === "public" ? "Public relay" : mode === "private" ? "Private relay" : "No sync";30+}31+32+export interface SyncModeSelectorProps {33+  /** Render as a one-line summary that expands on "change". */34+  compact?: boolean;35+  /** Fires on every committed change, so a host mid-pairing can react. */36+  onChange?(settings: SyncSettingsSnapshot): void;37+}38+39+export const SyncModeSelector: Component<SyncModeSelectorProps> = (props) => {40+  const [mode, setMode] = createSignal<SyncMode>("public");41+  const [relayUrl, setRelayUrl] = createSignal("");42+  const [draftUrl, setDraftUrl] = createSignal("");43+  const [saving, setSaving] = createSignal(false);44+  const [status, setStatus] = createSignal<string | null>(null);45+  const [expanded, setExpanded] = createSignal(false);46+47+  /** Private chosen, no URL yet — never silently fall back to the public relay. */48+  const needsRelayUrl = () => mode() === "private" && relayUrl() === DEFAULT_RELAY_URL;49+50+  function apply(next: SyncSettingsSnapshot) {51+    setMode(next.mode);52+    setRelayUrl(next.relayUrl);53+    setDraftUrl(next.relayUrl === DEFAULT_RELAY_URL ? "" : next.relayUrl);54+  }55+56+  onMount(async () => {57+    const result = (await sendMessage({ type: "GET_SYNC_SETTINGS" })) as {58+      ok: boolean;59+      data?: SyncSettingsSnapshot;60+    };61+    if (result.ok && result.data) apply(result.data);62+  });63+64+  /** Pass a mode to switch; pass nothing to save the typed private relay URL. */65+  async function save(next?: SyncMode) {66+    setSaving(true);67+    setStatus(null);68+    const result = (await sendMessage({69+      type: "SET_SYNC_SETTINGS",70+      payload: next ? { mode: next } : { relayUrl: draftUrl() },71+    })) as { ok: boolean; data?: SyncSettingsSnapshot };72+    if (result.ok && result.data) {73+      apply(result.data);74+      setStatus(next ? null : "Saved");75+      props.onChange?.(result.data);76+    } else {77+      setStatus("Use a valid wss:// relay URL");78+    }79+    setSaving(false);80+  }81+82+  const option = (value: SyncMode, title: string, sub: string) => (83+    <button84+      class="peach-sync-mode"85+      classList={{ "is-active": mode() === value }}86+      role="radio"87+      aria-checked={mode() === value}88+      disabled={saving()}89+      onClick={() => void save(value)}90+    >91+      <span class="peach-sync-mode-main">92+        <span class="peach-sync-mode-title">{title}</span>93+        <span class="peach-sync-mode-sub">{sub}</span>94+      </span>95+      <span class="peach-sync-mode-pip" aria-hidden="true" />96+    </button>97+  );98+99+  return (100+    <Show101+      when={!props.compact || expanded()}102+      fallback={103+        <p class="peach-sync-mode-summary">104+          Using {modeLabel(mode())}105+          {needsRelayUrl() ? " — no URL set" : ""}106+          {" · "}107+          <button class="peach-sync-mode-change" onClick={() => setExpanded(true)}>change</button>108+        </p>109+      }110+    >111+      <div class="peach-sync-mode-group" role="radiogroup" aria-label="How this vault syncs">112+        {option(113+          "public",114+          "Public relay",115+          "Peach's own relay. Works anywhere, nothing to set up.",116+        )}117+        {option(118+          "private",119+          "Private relay",120+          "Your own peach-relay, on your own domain. Same open-source code.",121+        )}122+123+        <Show when={mode() === "private"}>124+          <label class="peach-label" for="sync-relay-url">relay URL</label>125+          <input126+            id="sync-relay-url"127+            class="peach-underline-input"128+            type="url"129+            inputmode="url"130+            autocomplete="off"131+            spellcheck={false}132+            placeholder="wss://relay.example.com"133+            value={draftUrl()}134+            disabled={saving()}135+            onInput={(event) => setDraftUrl((event.target as HTMLInputElement).value)}136+            onKeyDown={(event) => {137+              if (event.key === "Enter") void save();138+            }}139+          />140+          <div class="peach-settings-row peach-settings-row--meta">141+            <span142+              class="peach-settings-value"143+              classList={{ "peach-sync-mode-warn": needsRelayUrl() || status()?.startsWith("Use") }}144+            >145+              {status() ??146+                (needsRelayUrl()147+                  ? "Sync is paused until you enter your relay URL."148+                  : "Only secure wss:// relays are accepted.")}149+            </span>150+            <button151+              class="peach-btn peach-btn--subtle peach-btn--sm"152+              disabled={saving()}153+              onClick={() => void save()}154+            >{saving() ? "saving…" : "save"}</button>155+          </div>156+        </Show>157+158+        {option(159+          "off",160+          "No sync",161+          "This device keeps to itself. Move your vault by hand with Codex or CSV export and import.",162+        )}163+164+        <p class="peach-sync-mode-disclosure">{RELAY_DISCLOSURE}</p>165+166+        <Show when={props.compact}>167+          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => setExpanded(false)}>168+            Done169+          </button>170+        </Show>171+      </div>172+    </Show>173+  );174+};

view file

src/popup/styles/popup.css+24 −0
@@ -10534,3 +10534,27 @@1053410534   background:1053510535     radial-gradient(circle at 50% 50%, var(--peach-accent) 0 42%, transparent 44%);1053610536 }10537+.peach-sync-mode-disclosure {10538+  margin: 10px 2px 0;10539+  font-size: 11px;10540+  line-height: 1.5;10541+  color: var(--peach-t2);10542+}10543+.peach-sync-mode-warn { color: var(--peach-breach); }10544+10545+/* Compact form, used mid-pairing: a quiet line, not a decision to make. */10546+.peach-sync-mode-summary {10547+  margin: 8px 2px 0;10548+  font-size: 11px;10549+  color: var(--peach-t2);10550+  text-align: center;10551+}10552+.peach-sync-mode-change {10553+  background: none;10554+  border: none;10555+  padding: 0;10556+  font: inherit;10557+  color: var(--peach-accent);10558+  text-decoration: underline;10559+  cursor: pointer;10560+}

view file

src/popup/views/Settings.tsx+4 −112
@@ -1,6 +1,7 @@11 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js";22 import { sendMessage } from "../App";33 import { PeachMark } from "../components/PeachMark";4+import { SyncModeSelector } from "../components/SyncModeSelector";45 import { StackDeck } from "../components/settings/StackDeck";56 import type { CardId, StackBodies, StackCardSpec } from "../components/settings/types";67 import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";@@ -269,10 +270,6 @@269270   const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);270271   const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271272   const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272-  const [relayUrl, setRelayUrl] = createSignal("");273-  const [syncMode, setSyncMode] = createSignal<"public" | "private" | "off">("public");274-  const [relaySaving, setRelaySaving] = createSignal(false);275-  const [relayStatus, setRelayStatus] = createSignal<string | null>(null);276273 277274   const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;278275   function syncRelativeTime(when: number | undefined): string {@@ -341,37 +338,6 @@341338       setTimeout(() => setSyncFlash(null), 4000);342339     }343340   }344-  async function loadRelaySettings() {345-    const result = (await sendMessage({ type: "GET_SYNC_SETTINGS" })) as {346-      ok: boolean;347-      data?: { relayUrl: string; mode: "public" | "private" | "off" };348-    };349-    if (result.ok && result.data) {350-      setRelayUrl(result.data.relayUrl);351-      setSyncMode(result.data.mode);352-    }353-  }354-  /** Pass a mode to switch; pass nothing to save the typed private relay URL. */355-  async function saveSyncSettings(mode?: "public" | "private" | "off") {356-    setRelaySaving(true);357-    setRelayStatus(null);358-    const result = (await sendMessage({359-      type: "SET_SYNC_SETTINGS",360-      payload: mode ? { mode } : { relayUrl: relayUrl() },361-    })) as {362-      ok: boolean;363-      error?: string;364-      data?: { relayUrl: string; mode: "public" | "private" | "off" };365-    };366-    if (result.ok && result.data) {367-      setRelayUrl(result.data.relayUrl);368-      setSyncMode(result.data.mode);369-      setRelayStatus(mode ? null : "Saved");370-    } else {371-      setRelayStatus("Use a valid wss:// relay URL");372-    }373-    setRelaySaving(false);374-  }375341   // UI-4 / MV-8: rotate sync identity confirmation dialog.376342   const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);377343   const [rotating, setRotating] = createSignal(false);@@ -589,7 +555,6 @@589555     // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.590556     void loadPendingVouched();591557     void loadPairedDevices();592-    void loadRelaySettings();593558     const onBroadcast = (msg: unknown) => {594559       if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {595560         void loadPendingVouched();@@ -1603,83 +1568,10 @@16031568           {/* Three named choices, not a toggle with an implied default.16041569               LAN sync is retired (docs/sync-lan-retirement.md), so "no relay"16051570               now means no sync rather than a peer-to-peer mode that never1606-              reliably worked. */}1571+              reliably worked. This screen is the source of truth; the pairing1572+              surfaces render the same component compact. */}16071573           <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>1608-1609-          <button1610-            class="peach-sync-mode"1611-            classList={{ "is-active": syncMode() === "public" }}1612-            disabled={relaySaving()}1613-            onClick={() => void saveSyncSettings("public")}1614-          >1615-            <span class="peach-sync-mode-main">1616-              <span class="peach-sync-mode-title">Public relay</span>1617-              <span class="peach-sync-mode-sub">1618-                Peach's own relay. Works anywhere, nothing to set up. It never sees1619-                your vault — everything is encrypted before it leaves the device.1620-              </span>1621-            </span>1622-            <span class="peach-sync-mode-pip" aria-hidden="true" />1623-          </button>1624-1625-          <button1626-            class="peach-sync-mode"1627-            classList={{ "is-active": syncMode() === "private" }}1628-            disabled={relaySaving()}1629-            onClick={() => void saveSyncSettings("private")}1630-          >1631-            <span class="peach-sync-mode-main">1632-              <span class="peach-sync-mode-title">Private relay</span>1633-              <span class="peach-sync-mode-sub">1634-                Your own peach-relay, on your own domain. Same open-source code.1635-              </span>1636-            </span>1637-            <span class="peach-sync-mode-pip" aria-hidden="true" />1638-          </button>1639-1640-          <Show when={syncMode() === "private"}>1641-            <label class="peach-label" for="sync-relay-url">relay URL</label>1642-            <input1643-              id="sync-relay-url"1644-              class="peach-underline-input"1645-              type="url"1646-              inputmode="url"1647-              autocomplete="off"1648-              spellcheck={false}1649-              value={relayUrl()}1650-              disabled={relaySaving()}1651-              onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1652-              onKeyDown={(event) => {1653-                if (event.key === "Enter") void saveSyncSettings();1654-              }}1655-            />1656-            <div class="peach-settings-row peach-settings-row--meta">1657-              <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1658-                {relayStatus() ?? "Only secure wss:// relays are accepted."}1659-              </span>1660-              <button1661-                class="peach-btn peach-btn--subtle peach-btn--sm"1662-                disabled={relaySaving()}1663-                onClick={() => void saveSyncSettings()}1664-              >{relaySaving() ? "saving…" : "save"}</button>1665-            </div>1666-          </Show>1667-1668-          <button1669-            class="peach-sync-mode"1670-            classList={{ "is-active": syncMode() === "off" }}1671-            disabled={relaySaving()}1672-            onClick={() => void saveSyncSettings("off")}1673-          >1674-            <span class="peach-sync-mode-main">1675-              <span class="peach-sync-mode-title">No sync</span>1676-              <span class="peach-sync-mode-sub">1677-                This device keeps to itself. Move your vault by hand with Codex or1678-                CSV export and import.1679-              </span>1680-            </span>1681-            <span class="peach-sync-mode-pip" aria-hidden="true" />1682-          </button>1574+          <SyncModeSelector />16831575 16841576           {/* Paired devices + manual sync — Settings is the hub. */}16851577           <Show when={pairedDevices().length > 0}>

view file

src/popup/views/contracts.test.tsx+44 −0
@@ -453,6 +453,50 @@453453     expect(text).toContain("future changes");454454   });455455 456+  it("Settings keeps the paired-device list and revokes through UNPAIR_DEVICE", async () => {457+    const device = {458+      deviceId: "phone-1",459+      deviceName: "June's Phone",460+      deviceType: "android" as const,461+      longTermPublicKeyHex: "a".repeat(64),462+      signingPublicKeyHex: "b".repeat(64),463+      pairedAt: 1,464+      trustSource: "direct" as const,465+    };466+    sendMessageMock.mockImplementation(async (msg: unknown) => {467+      const m = msg as { type: string };468+      if (m.type === "LIST_PAIRED_DEVICES") return { ok: true, data: { devices: [device] } };469+      return { ok: true, data: null };470+    });471+472+    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);473+    for (let i = 0; i < 30; i++) await Promise.resolve();474+    Array.from(container.querySelectorAll("button"))475+      .find((b) => /sync/i.test(b.textContent ?? ""))476+      ?.click();477+    for (let i = 0; i < 30; i++) await Promise.resolve();478+479+    // The list itself: extracting the relay selector must not take the hub with it.480+    expect(container.textContent).toContain("PAIRED DEVICES");481+    expect(container.textContent).toContain("June's Phone");482+    expect(container.textContent).toContain("Android");483+484+    Array.from(container.querySelectorAll("button"))485+      .find((b) => (b.textContent ?? "").trim() === "remove")!486+      .click();487+    for (let i = 0; i < 10; i++) await Promise.resolve();488+    const confirm = Array.from(489+      container.querySelectorAll<HTMLButtonElement>(".peach-unpair-confirm button"),490+    ).find((b) => (b.textContent ?? "").trim() === "remove");491+    confirm!.click();492+    for (let i = 0; i < 20; i++) await Promise.resolve();493+494+    expect(sendMessageMock).toHaveBeenCalledWith({495+      type: "UNPAIR_DEVICE",496+      payload: { deviceId: "phone-1" },497+    });498+  });499+456500   it("Settings requires current, new, and confirm passwords before enabling save", async () => {457501     sendMessageMock.mockResolvedValue({ ok: true, data: null });458502     const container = mount(

view file