daf6d2a04ad2

feat: Simplify Sync Settings with progressive disclosure (v4.3.0)

time
2026-07-27 19:57 UTC · 3 months ago
machine
koh2
parent
399fb468341b
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+22 −0src/popup/views/Settings.tsx+89 −74src/popup/views/contracts.test.tsx+33 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.18",4+  "version": "4.3.0",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.18",4+  "version": "4.3.0",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.18",3+  "version": "4.3.0",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/styles/popup.css+22 −0
@@ -2147,6 +2147,28 @@21472147   transition: color 120ms ease;21482148 }21492149 2150+.peach-sync-disclosure-toggle {2151+  min-height: 48px;2152+  padding: 10px 0;2153+  text-align: left;2154+}2155+2156+.peach-sync-disclosure-toggle > span:first-child {2157+  display: flex;2158+  flex-direction: column;2159+  gap: 3px;2160+}2161+2162+.peach-sync-disclosure-summary {2163+  color: var(--peach-t2);2164+  font-size: 0.6875rem;2165+  line-height: 1.35;2166+}2167+2168+.peach-sync-disclosure-content {2169+  padding-bottom: 4px;2170+}2171+21502172 /* ── Feedback form ──────────────────────────────────────────────────────── */21512173 .peach-feedback-group {21522174   flex: 1;

view file

src/popup/views/Settings.tsx+89 −74
@@ -373,6 +373,8 @@373373   const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);374374   const [rotating, setRotating] = createSignal(false);375375   const [rotateError, setRotateError] = createSignal<string | null>(null);376+  const [showSyncConnection, setShowSyncConnection] = createSignal(false);377+  const [showSyncRecovery, setShowSyncRecovery] = createSignal(false);376378 377379   async function loadPendingVouched() {378380     const r = (await sendMessage({ type: "GET_PENDING_VOUCHED_DEVICES" })) as {@@ -1568,19 +1570,21 @@15681570     return (15691571       <div class="peach-settings-body">15701572         <div class="peach-settings-group">1571-          <div class="peach-settings-row peach-settings-row--meta">1572-            <span>Status</span>1573-            <span class="peach-settings-value" style="display:inline-flex;align-items:center;gap:8px">1574-              {isPro() ? "Pro" : "Free"}1575-              <span class={`stk-pip ${isPro() ? "is-on" : ""}`} aria-hidden="true" />1573+          <p class="peach-settings-sub-label" style="margin-top:0">SYNC STATUS</p>1574+          <div1575+            class="peach-settings-row peach-settings-row--meta"1576+            style="align-items:center;gap:8px"1577+            role="status"1578+            aria-label={syncLightLabel()}1579+            title={syncLightLabel()}1580+          >1581+            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1582+            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1583+              {syncFlash() ?? lastSyncLine()}15761584             </span>15771585           </div>15781586 1579-          {/* Pick a pairing method and begin — no separate hub screen. */}1580-          <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1581-          {/* One path in: this browser displays an invitation and the other1582-              device scans it. An extension can neither listen nor use a camera,1583-              so displaying is the only role it can take. */}1587+          <p class="peach-settings-sub-label">ADD A DEVICE</p>15841588           <button15851589             class="peach-sync-pair-option"15861590             onClick={() => props.onOpenSyncPairing?.("code")}@@ -1593,7 +1597,7 @@15931597             </span>15941598             <span class="peach-sync-pair-option-main">15951599               <span class="peach-sync-pair-option-title">Show a code</span>1596-              <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>1600+              <span class="peach-sync-pair-option-sub">Scan it with Peach on your phone, then confirm one 8-digit code</span>15971601             </span>15981602             <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>15991603           </button>@@ -1603,33 +1607,8 @@16031607             </p>16041608           </Show>16051609 1606-          {/* Three named choices, not a toggle with an implied default.1607-              LAN sync is retired (docs/sync-lan-retirement.md), so "no relay"1608-              now means no sync rather than a peer-to-peer mode that never1609-              reliably worked. This screen is the source of truth; the pairing1610-              surfaces render the same component compact. */}1611-          <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>1612-          <SyncModeSelector />1613-1614-          {/* Sync indicator light: pulsing green while syncing, solid green on1615-              a healthy pair, red on any problem. The line beside it says the1616-              same thing in words — colour is never the only signal. */}1617-          <div1618-            class="peach-settings-row peach-settings-row--meta"1619-            style="align-items:center;gap:8px"1620-            role="status"1621-            aria-label={syncLightLabel()}1622-            title={syncLightLabel()}1623-          >1624-            <span class={`stk-pip stk-pip--sync is-${syncLight()}`} aria-hidden="true" />1625-            <span class="peach-settings-value" style="color:var(--peach-t2);line-height:1.5;flex:1;min-width:0;overflow-wrap:anywhere">1626-              {syncFlash() ?? lastSyncLine()}1627-            </span>1628-          </div>1629-1630-          {/* Paired devices + manual sync — Settings is the hub. */}16311610           <Show when={pairedDevices().length > 0}>1632-            <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>1611+            <p class="peach-settings-sub-label">PAIRED DEVICES</p>16331612             <For each={pairedDevices()}>16341613               {(d) => (16351614                 <div class="peach-settings-row peach-settings-row--top">@@ -1687,7 +1666,7 @@16871666               >{syncStatus() === "syncing" ? "syncing…" : "sync now"}</button>16881667             </div>16891668           </Show>1690-          {/* UI-3 / MV-7: devices vouched by a paired peer awaiting confirmation. */}1669+16911670           <Show when={pendingVouched().length > 0}>16921671             <p class="peach-settings-sub-label">16931672               PENDING DEVICES@@ -1719,44 +1698,80 @@17191698             </For>17201699           </Show>17211700 1722-          {/* UI-4 / MV-8: rotate sync identity (re-keys the vault, invalidates lost devices). */}1723-          <p class="peach-settings-sub-label" style="margin-top:12px">RECOVERY</p>1724-          <Show1725-            when={rotateConfirmOpen()}1726-            fallback={1727-              <div class="peach-settings-row peach-settings-row--top">1728-                <div class="peach-settings-row-main">1729-                  <span>Rotate sync identity</span>1730-                  <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1731-                    Use this if a paired device was lost or stolen. Re-keys the vault so the old device's key is useless.1732-                  </span>1733-                </div>1734-                <button1735-                  class="peach-btn peach-btn--subtle peach-btn--sm"1736-                  style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1737-                  onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1738-                >rotate</button>1739-              </div>1740-            }1701+          <button1702+            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1703+            onClick={() => setShowSyncConnection((value) => !value)}1704+            aria-expanded={showSyncConnection()}1705+            aria-controls="peach-sync-connection"17411706           >1742-            <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1743-              This will disconnect all paired devices — they will need to pair again — and your 12 recovery words will change. You'll be shown the new words next.1744-              <Show when={rotateError()}>1745-                <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1707+            <span>1708+              <span class="peach-settings-sub-label" style="margin:0">CONNECTION</span>1709+              <span class="peach-sync-disclosure-summary">Relay and privacy choices</span>1710+            </span>1711+            <span class="peach-settings-advanced-chevron">1712+              {showSyncConnection() ? "▲" : "▼"}1713+            </span>1714+          </button>1715+          <Show when={showSyncConnection()}>1716+            <div id="peach-sync-connection" class="peach-sync-disclosure-content">1717+              <SyncModeSelector />1718+            </div>1719+          </Show>1720+1721+          <button1722+            class="peach-settings-advanced-toggle peach-sync-disclosure-toggle"1723+            onClick={() => setShowSyncRecovery((value) => !value)}1724+            aria-expanded={showSyncRecovery()}1725+            aria-controls="peach-sync-recovery"1726+          >1727+            <span>1728+              <span class="peach-settings-sub-label" style="margin:0">RECOVERY &amp; SECURITY</span>1729+              <span class="peach-sync-disclosure-summary">For a lost or stolen device</span>1730+            </span>1731+            <span class="peach-settings-advanced-chevron">1732+              {showSyncRecovery() ? "▲" : "▼"}1733+            </span>1734+          </button>1735+          <Show when={showSyncRecovery()}>1736+            <div id="peach-sync-recovery" class="peach-sync-disclosure-content">1737+              <Show1738+                when={rotateConfirmOpen()}1739+                fallback={1740+                  <div class="peach-settings-row peach-settings-row--top">1741+                    <div class="peach-settings-row-main">1742+                      <span>Rotate sync identity</span>1743+                      <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1744+                        Disconnects every device and replaces the keys used for sync.1745+                      </span>1746+                    </div>1747+                    <button1748+                      class="peach-btn peach-btn--subtle peach-btn--sm"1749+                      style="color:var(--peach-breach);border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)"1750+                      onClick={() => { setRotateError(null); setRotateConfirmOpen(true); }}1751+                    >rotate</button>1752+                  </div>1753+                }1754+              >1755+                <div class="peach-info-notice" style="margin-top:8px;border-color:color-mix(in srgb,var(--peach-breach) 40%,transparent)">1756+                  This disconnects every paired device and changes your 12 recovery words. You'll be shown the new words next.1757+                  <Show when={rotateError()}>1758+                    <div style="color:var(--peach-breach);margin-top:6px">{rotateError()}</div>1759+                  </Show>1760+                  <div style="display:flex;gap:6px;margin-top:10px">1761+                    <button1762+                      class="peach-btn peach-btn--subtle peach-btn--sm"1763+                      disabled={rotating()}1764+                      onClick={() => setRotateConfirmOpen(false)}1765+                    >Cancel</button>1766+                    <button1767+                      class="peach-btn peach-btn--sm"1768+                      style="background:var(--peach-breach);border-color:var(--peach-breach)"1769+                      disabled={rotating()}1770+                      onClick={() => void doRotateSyncIdentity()}1771+                    >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1772+                  </div>1773+                </div>17461774               </Show>1747-              <div style="display:flex;gap:6px;margin-top:10px">1748-                <button1749-                  class="peach-btn peach-btn--subtle peach-btn--sm"1750-                  disabled={rotating()}1751-                  onClick={() => setRotateConfirmOpen(false)}1752-                >Cancel</button>1753-                <button1754-                  class="peach-btn peach-btn--sm"1755-                  style="background:var(--peach-breach);border-color:var(--peach-breach)"1756-                  disabled={rotating()}1757-                  onClick={() => void doRotateSyncIdentity()}1758-                >{rotating() ? "Rotating…" : "Rotate sync identity"}</button>1759-              </div>17601775             </div>17611776           </Show>17621777         </div>

view file

src/popup/views/contracts.test.tsx+33 −0
@@ -510,6 +510,39 @@510510     expect(container.textContent).not.toContain("PAIRED DEVICES");511511   });512512 513+  it("Settings keeps connection and recovery details collapsed until requested", async () => {514+    sendMessageMock.mockImplementation(async (msg: any) => {515+      if (msg?.type === "LIST_PAIRED_DEVICES") {516+        return { ok: true, data: { devices: [] } };517+      }518+      return { ok: true, data: null };519+    });520+521+    const container = mount(<SettingsView {...settingsProps({ isPro: true })} />);522+    for (let i = 0; i < 30; i++) await Promise.resolve();523+    Array.from(container.querySelectorAll("button"))524+      .find((button) => /sync/i.test(button.textContent ?? ""))525+      ?.click();526+    for (let i = 0; i < 20; i++) await Promise.resolve();527+528+    expect(container.textContent).toContain("SYNC STATUS");529+    expect(container.textContent).toContain("ADD A DEVICE");530+    expect(container.textContent).not.toContain("Public relay");531+    expect(container.textContent).not.toContain("Rotate sync identity");532+533+    const connection = Array.from(container.querySelectorAll("button"))534+      .find((button) => button.textContent?.includes("CONNECTION"));535+    connection?.click();536+    await Promise.resolve();537+    expect(container.textContent).toContain("Public relay");538+539+    const recovery = Array.from(container.querySelectorAll("button"))540+      .find((button) => button.textContent?.includes("RECOVERY & SECURITY"));541+    recovery?.click();542+    await Promise.resolve();543+    expect(container.textContent).toContain("Rotate sync identity");544+  });545+513546   it("Settings requires current, new, and confirm passwords before enabling save", async () => {514547     sendMessageMock.mockResolvedValue({ ok: true, data: null });515548     const container = mount(

view file