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
7 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 −14
manifest.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": {

view file

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",

view file

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",

view file

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")}

view file

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 {

view file

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 

view file

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>

view file