236066499ad0
add a topbar sync button that only appears once a device is paired, and drop the manual invitation textarea the QR already carries (v4.1.11)
- time
- 2026-07-26 13:16 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- pairing UX pass
- parent
- efce56bed4e5
↓ peach-browser-23606649.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+59 −0src/popup/styles/popup.css+14 −12src/popup/views/PairDevice.test.tsx+5 −4src/popup/views/PairDevice.tsx+0 −14manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.10",4+ "version": "4.1.11",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.10",4+ "version": "4.1.11",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.1.10",3+ "version": "4.1.11",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/App.tsx+59 −0
@@ -216,6 +216,11 @@216216 const [editBackView, setEditBackView] = createSignal<View>("entry");217217 const [settingsExiting, setSettingsExiting] = createSignal(false);218218 const [showNotes, setShowNotes] = createSignal(false);219+ // Topbar sync affordance. `syncPaired` gates the button's existence — with no220+ // paired device there is nothing to sync and the icon would be a dead control.221+ const [syncPaired, setSyncPaired] = createSignal(false);222+ const [syncBusy, setSyncBusy] = createSignal(false);223+ const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);219224 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);220225 // Set on popup boot when peach_recovery_pending flag is found in session221226 // storage — instructs LockView to skip the unlock UI and open the recovery@@ -385,6 +390,10 @@385390 });386391 // UI-1 / HV-1b: surface any remote-mutation alerts the sync merge queued.387392 void loadSyncAlerts();393+ void sendMessage({ type: "LIST_PAIRED_DEVICES" }).then((res) => {394+ const devices = (res as { ok: boolean; data?: unknown }).data;395+ setSyncPaired(Array.isArray(devices) && devices.length > 0);396+ });388397 }389398 390399 // If the user created a vault but hasn't yet confirmed their recovery@@ -1125,6 +1134,35 @@11251134 void sendMessage({ type: "LOCK" });11261135 }11271136 1137+ // Manual sync from the topbar. TRIGGER_SYNC returns as soon as the run is1138+ // queued, so the outcome is read back from GET_SYNC_STATE rather than from1139+ // the trigger's own reply.1140+ async function handleHeaderSync(): Promise<void> {1141+ if (syncBusy()) return;1142+ setSyncBusy(true);1143+ setSyncFlash(null);1144+ try {1145+ await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1146+ let status: string = "syncing";1147+ for (let i = 0; i < 40 && status === "syncing"; i++) {1148+ await new Promise((r) => setTimeout(r, 250));1149+ const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {1150+ ok: boolean;1151+ data?: { status?: string };1152+ };1153+ status = res.ok ? res.data?.status ?? "idle" : "error";1154+ }1155+ setSyncFlash(status === "error" ? "error" : "ok");1156+ await loadSyncAlerts();1157+ await loadEntries();1158+ } catch {1159+ setSyncFlash("error");1160+ } finally {1161+ setSyncBusy(false);1162+ setTimeout(() => setSyncFlash(null), 2_000);1163+ }1164+ }1165+11281166 function navigate(dir: number) {11291167 const allEntries = entries();11301168 if (allEntries.length === 0) return;@@ -1413,6 +1451,27 @@14131451 <line x1="4" y1="10" x2="7" y2="10" />14141452 </svg>14151453 </button>1454+ <Show when={syncPaired()}>1455+ <button1456+ class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn peach-sync-btn"1457+ classList={{1458+ "is-syncing": syncBusy(),1459+ "is-ok": syncFlash() === "ok",1460+ "is-error": syncFlash() === "error",1461+ }}1462+ onClick={() => void handleHeaderSync()}1463+ disabled={syncBusy()}1464+ aria-label={syncBusy() ? "Syncing" : "Sync now"}1465+ title="Sync now"1466+ >1467+ <svg class="peach-sync-icon" width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">1468+ <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />1469+ <path d="M1.5 8A5.5 5.5 0 0 0 11.3 10.7" />1470+ <path d="M2.6 1v2.4h2.4" />1471+ <path d="M11.4 13v-2.4H9" />1472+ </svg>1473+ </button>1474+ </Show>14161475 <button14171476 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"14181477 onClick={() => setView("settings")}src/popup/styles/popup.css+14 −12
@@ -939,6 +939,20 @@939939 0 0 4px color-mix(in srgb, var(--peach-accent) 16%, transparent);940940 }941941 942+/* Topbar sync. Only rendered when a device is paired, so it never sits there943+ as a control that cannot do anything. */944+.peach-sync-btn.is-syncing { color: var(--peach-accent); }945+.peach-sync-btn.is-syncing .peach-sync-icon {946+ animation: peach-sync-spin 900ms linear infinite;947+ transform-origin: 50% 50%;948+}949+.peach-sync-btn.is-ok { color: var(--peach-accent); }950+.peach-sync-btn.is-error { color: var(--peach-breach, #d66); }951+@keyframes peach-sync-spin { to { transform: rotate(360deg); } }952+@media (prefers-reduced-motion: reduce) {953+ .peach-sync-btn.is-syncing .peach-sync-icon { animation: none; opacity: 0.6; }954+}955+942956 .peach-btn:focus-visible {943957 outline: none;944958 box-shadow: 0 0 0 2px var(--peach-bg), 0 0 0 4px color-mix(in srgb, var(--peach-accent) 50%, transparent);@@ -7152,18 +7166,6 @@71527166 padding: 8px;71537167 border-radius: 10px;71547168 background: #fff;7155-}7156-7157-.peach-adoption-source-code {7158- width: 100%;7159- padding: 8px;7160- resize: none;7161- overflow-wrap: anywhere;7162- border: 1px solid var(--peach-border);7163- border-radius: 8px;7164- background: color-mix(in srgb, var(--peach-t0) 6%, transparent);7165- color: var(--peach-t1);7166- font: 0.58rem/1.35 "IBM Plex Mono", monospace;71677169 }71687170 71697171 .peach-adopt-card > .peach-btn + .peach-btn {src/popup/views/PairDevice.test.tsx+5 −4
@@ -97,7 +97,7 @@9797 return rendered;9898 }9999 100- it("opens the relay room before rendering its QR and manual fallback", async () => {100+ it("opens the relay room before rendering its QR, and offers no manual copy", async () => {101101 const { container } = mount();102102 await vi.waitFor(() => {103103 expect(container.textContent).toContain("Scan this in Peach");@@ -107,9 +107,10 @@107107 type: "START_ADOPTION_SOURCE",108108 });109109 expect(container.querySelector("svg.peach-pair-qr")).toBeTruthy();110- expect(111- (container.querySelector("#pairing-invitation-manual") as HTMLTextAreaElement).value,112- ).toBe(SOURCE_INVITATION);110+ // The invitation is carried by the QR alone — the manual textarea and its111+ // Copy button duplicated the pairing-code path and were removed.112+ expect(container.querySelector("#pairing-invitation-manual")).toBeNull();113+ expect(container.textContent).not.toContain("or copy the invitation");113114 expect(container.textContent).toContain("single-use random token");114115 });115116 src/popup/views/PairDevice.tsx+0 −14
@@ -454,26 +454,12 @@454454 the relay address and a single-use random token.455455 </div>456456 <PairingQrCode value={invitation()} size={224} />457- <label class="peach-label" for="pairing-invitation-manual">or copy the invitation</label>458- <textarea459- id="pairing-invitation-manual"460- class="peach-adoption-source-code"461- readonly462- rows={3}463- value={invitation()}464- />465457 <div class="peach-pair-action">466458 <button467459 class="peach-btn peach-btn-ghost"468460 onClick={() => void useCodeInstead()}469461 >470462 Enter a code instead471- </button>472- <button473- class="peach-btn peach-btn-primary"474- onClick={() => void navigator.clipboard.writeText(invitation())}475- >476- Copy477463 </button>478464 </div>479465 </div>