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
↓ peach-browser-daf6d2a0.tar.gzatlas at this save
files changed6 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 −0manifest.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": {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",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",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;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 & 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>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(