9378234431ee
sync indicator light (pulsing green syncing / solid green healthy / red problem) plus persistent last-attempt reason in Settings and the topbar sync button (v4.2.10)
- time
- 2026-07-26 18:55 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- sync visibility
- parent
- de6976b06e68
↓ peach-browser-93782344.tar.gzatlas at this save
files changed9 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+39 −10src/popup/styles/popup.css+29 −0src/popup/styles/settings-stack.css+25 −0src/popup/views/Settings.tsx+78 −20src/shared/sync-outcome.test.ts+87 −0src/shared/sync-outcome.ts+2 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.2.9",4+ "version": "4.2.10",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.9",4+ "version": "4.2.10",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.9",3+ "version": "4.2.10",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/App.tsx+39 −10
@@ -15,6 +15,11 @@1515 import { PeachWordmark } from "./components/PeachMark";1616 import { PasskeyApprovalView } from "./views/PasskeyApproval";1717 import { extractDomain } from "../shared/domain";18+import {19+ syncIndicator,20+ syncIndicatorLabel,21+ type SyncOutcomeSummary,22+} from "../shared/sync-outcome";1823 import { popupCopy } from "./copy";1924 import {2025 DEFAULT_BINDINGS,@@ -220,6 +225,23 @@220225 // exactly when you reach for it.221226 const [syncBusy, setSyncBusy] = createSignal(false);222227 const [syncFlash, setSyncFlash] = createSignal<"ok" | "error" | null>(null);228+ // The indicator light + its spoken-word equivalent. Colour alone is not the229+ // signal; the title/aria-label always carries the same claim in English.230+ const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});231+ const [pairedCount, setPairedCount] = createSignal(0);232+ const syncLight = () =>233+ syncBusy() ? "busy" : syncIndicator(syncSummary(), pairedCount());234+ const syncLabel = () => syncIndicatorLabel(syncLight(), syncSummary());235+ async function loadSyncIndicator() {236+ const [stateRes, devicesRes] = await Promise.all([237+ sendMessage({ type: "GET_SYNC_STATE" }),238+ sendMessage({ type: "LIST_PAIRED_DEVICES" }),239+ ]);240+ const s = stateRes as { ok: boolean; data?: SyncOutcomeSummary };241+ if (s.ok && s.data) setSyncSummary(s.data);242+ const d = devicesRes as { ok: boolean; data?: { devices?: unknown[] } };243+ if (d.ok) setPairedCount(d.data?.devices?.length ?? 0);244+ }223245 const [openNoteId, setOpenNoteId] = createSignal<string | undefined>(undefined);224246 // Set on popup boot when peach_recovery_pending flag is found in session225247 // storage — instructs LockView to skip the unlock UI and open the recovery@@ -379,14 +401,14 @@379401 await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });380402 }381403 382- // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget; the383- // background ignores it when no mobile hosts are paired or while384- // another sync is already in flight.404+ // Popup-foreground sync trigger (SEV-1-A). Fire-and-forget — the outcome405+ // arrives on the SYNC_STATE_CHANGED broadcast the indicator listens to.385406 if (unlocked && vaultExists) {386407 void sendMessage({387408 type: "TRIGGER_SYNC",388409 payload: { reason: "foreground" },389410 });411+ void loadSyncIndicator();390412 // UI-1 / HV-1b: surface any remote-mutation alerts the sync merge queued.391413 void loadSyncAlerts();392414 }@@ -606,6 +628,9 @@606628 // entries instead of waiting for the next GET_ENTRIES round-trip.607629 const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {608630 if (msg?.type === "VAULT_LOCKED" && view() !== "lock") handleLock();631+ if (msg?.type === "SYNC_STATE_CHANGED") {632+ setSyncSummary((msg.payload ?? {}) as SyncOutcomeSummary);633+ }609634 if (msg?.type === "PRO_GATE_TRIGGERED") {610635 setUpgradeHighlight(msg.payload?.feature ?? null);611636 setSettingsInitialSection("license");@@ -1138,19 +1163,22 @@11381163 setSyncFlash(null);11391164 try {11401165 await sendMessage({ type: "TRIGGER_SYNC", payload: { reason: "manual", force: true } });1141- let status: string = "syncing";1142- for (let i = 0; i < 40 && status === "syncing"; i++) {1166+ let state: SyncOutcomeSummary = { status: "syncing" };1167+ for (let i = 0; i < 40 && state.status === "syncing"; i++) {11431168 await new Promise((r) => setTimeout(r, 250));11441169 const res = (await sendMessage({ type: "GET_SYNC_STATE" })) as {11451170 ok: boolean;1146- data?: { status?: string };1171+ data?: SyncOutcomeSummary;11471172 };1148- status = res.ok ? res.data?.status ?? "idle" : "error";1173+ state = res.ok ? res.data ?? { status: "idle" } : { status: "error" };11491174 }1150- setSyncFlash(status === "error" ? "error" : "ok");1175+ setSyncSummary(state);1176+ setSyncFlash(state.status === "error" ? "error" : "ok");11511177 await loadSyncAlerts();11521178 await loadEntries();1179+ await loadSyncIndicator();11531180 } catch {1181+ setSyncSummary({ status: "error", outcome: "run_failed" });11541182 setSyncFlash("error");11551183 } finally {11561184 setSyncBusy(false);@@ -1427,8 +1455,8 @@14271455 }}14281456 onClick={() => void handleHeaderSync()}14291457 disabled={syncBusy()}1430- aria-label={syncBusy() ? "Syncing" : "Sync now"}1431- title="Sync now"1458+ aria-label={`Sync now. ${syncLabel()}`}1459+ title={syncLabel()}14321460 >14331461 <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">14341462 <path d="M12.5 6A5.5 5.5 0 0 0 2.7 3.3" />@@ -1436,6 +1464,7 @@14361464 <path d="M2.6 1v2.4h2.4" />14371465 <path d="M11.4 13v-2.4H9" />14381466 </svg>1467+ <span class={`peach-sync-pip is-${syncLight()}`} aria-hidden="true" />14391468 </button>14401469 <button14411470 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"src/popup/styles/popup.css+29 −0
@@ -949,8 +949,37 @@949949 .peach-sync-btn.is-ok { color: var(--peach-accent); }950950 .peach-sync-btn.is-error { color: var(--peach-breach, #d66); }951951 @keyframes peach-sync-spin { to { transform: rotate(360deg); } }952+953+/* Sync indicator light — same taxonomy as the Settings pip, sized for the954+ 14px topbar icon. Unlit when nothing has happened yet; never green when955+ sync is not actually working. */956+.peach-sync-btn { position: relative; }957+.peach-sync-pip {958+ position: absolute;959+ top: 2px;960+ right: 2px;961+ width: 5px;962+ height: 5px;963+ border-radius: 50%;964+ background: transparent;965+}966+.peach-sync-pip.is-ok,967+.peach-sync-pip.is-busy {968+ background: var(--peach-health-ok);969+ box-shadow: 0 0 4px color-mix(in srgb, var(--peach-health-ok) 60%, transparent);970+}971+.peach-sync-pip.is-busy { animation: peach-sync-pip-pulse 1600ms ease-in-out infinite; }972+.peach-sync-pip.is-error {973+ background: var(--peach-breach);974+ box-shadow: 0 0 4px color-mix(in srgb, var(--peach-breach) 60%, transparent);975+}976+@keyframes peach-sync-pip-pulse {977+ 0%, 100% { opacity: 1; }978+ 50% { opacity: 0.35; }979+}952980 @media (prefers-reduced-motion: reduce) {953981 .peach-sync-btn.is-syncing .peach-sync-icon { animation: none; opacity: 0.6; }982+ .peach-sync-pip.is-busy { animation: none; }954983 }955984 956985 .peach-btn:focus-visible {src/popup/styles/settings-stack.css+25 −0
@@ -290,6 +290,31 @@290290 box-shadow: 0 0 6px color-mix(in srgb, var(--peach-health-ok) 55%, transparent);291291 }292292 293+/* Sync indicator light. Solid green = healthy pair, pulsing green = a sync is294+ running, red = sync is not working. `is-idle` deliberately stays unlit295+ rather than green: nothing has happened yet is not the same as fine. */296+.stk-pip--sync { width: 10px; height: 10px; flex-shrink: 0; }297+298+.stk-pip--sync.is-busy {299+ background: var(--peach-health-ok);300+ box-shadow: 0 0 6px color-mix(in srgb, var(--peach-health-ok) 55%, transparent);301+ animation: stk-pip-pulse 1600ms ease-in-out infinite;302+}303+304+.stk-pip--sync.is-error {305+ background: var(--peach-breach);306+ box-shadow: 0 0 6px color-mix(in srgb, var(--peach-breach) 55%, transparent);307+}308+309+@keyframes stk-pip-pulse {310+ 0%, 100% { opacity: 1; }311+ 50% { opacity: 0.35; }312+}313+314+@media (prefers-reduced-motion: reduce) {315+ .stk-pip--sync.is-busy { animation: none; }316+}317+293318 .stk-sync-actions,294319 .stk-fb-chips,295320 .stk-inline-actions {src/popup/views/Settings.tsx+78 −20
@@ -15,6 +15,12 @@1515 } from "../../shared/types";1616 import type { PairedDevice } from "../../shared/sync-types";1717 import {18+ describeSyncOutcome,19+ syncIndicator,20+ syncIndicatorLabel,21+ type SyncOutcomeSummary,22+} from "../../shared/sync-outcome";23+import {1824 DEFAULT_BINDINGS,1925 findConflict,2026 formatKeybinding,@@ -270,6 +276,29 @@270276 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);271277 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");272278 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);279+ // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —280+ // a transient toast is no use for diagnosing a phone that isn't syncing.281+ const [syncSummary, setSyncSummary] = createSignal<SyncOutcomeSummary>({});282+ const syncLight = () =>283+ syncStatus() === "syncing"284+ ? "busy"285+ : syncIndicator(syncSummary(), pairedDevices().length);286+ const syncLightLabel = () => syncIndicatorLabel(syncLight(), syncSummary());287+ const lastSyncLine = () => {288+ const s = syncSummary();289+ if (!s.outcome) return "No sync attempted yet";290+ return `${syncRelativeTime(s.finishedAt)} · ${describeSyncOutcome(s)}`;291+ };292+ async function loadSyncState() {293+ const r = (await sendMessage({ type: "GET_SYNC_STATE" })) as {294+ ok: boolean;295+ data?: SyncOutcomeSummary;296+ };297+ if (r.ok && r.data) {298+ setSyncSummary(r.data);299+ setSyncStatus(r.data.status ?? "idle");300+ }301+ }273302 274303 const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;275304 function syncRelativeTime(when: number | undefined): string {@@ -315,28 +344,30 @@315344 type: "TRIGGER_SYNC",316345 payload: { reason: "manual", force: true },317346 })) as { ok: boolean; error?: string };318- setTimeout(async () => {347+ if (!res.ok) {348+ setSyncStatus("error");349+ setSyncSummary({ status: "error", outcome: "run_failed", detail: res.error });350+ return;351+ }352+ // TRIGGER_SYNC returns as soon as the run is queued, so poll for the353+ // outcome. A single delayed read used to land while the run was still354+ // "syncing" and leave the button disabled forever.355+ let s: SyncOutcomeSummary = { status: "syncing" };356+ for (let i = 0; i < 40 && s.status === "syncing"; i++) {357+ await new Promise((r) => setTimeout(r, 250));319358 const state = (await sendMessage({ type: "GET_SYNC_STATE" })) as {320359 ok: boolean;321- data?: { status: "idle" | "syncing" | "error"; entriesReceived?: number; error?: string };360+ data?: SyncOutcomeSummary;322361 };323- const s = state.data;324- if (!s) return;325- setSyncStatus(s.status);326- if (s.status === "idle") {327- setSyncFlash((s.entriesReceived ?? 0) > 0 ? `Synced — ${s.entriesReceived} updated` : "Up to date");328- setTimeout(() => setSyncFlash(null), 3000);329- await loadPairedDevices();330- } else if (s.status === "error") {331- setSyncFlash(`Sync failed — ${s.error ?? "no host reachable"}`);332- setTimeout(() => setSyncFlash(null), 4000);333- }334- }, 250);335- if (!res.ok) {336- setSyncStatus("error");337- setSyncFlash(`Sync failed — ${res.error ?? "unknown"}`);338- setTimeout(() => setSyncFlash(null), 4000);362+ s = state.ok ? state.data ?? { status: "idle" } : { status: "error", outcome: "run_failed" };339363 }364+ setSyncSummary(s);365+ setSyncStatus(s.status ?? "idle");366+ // The persistent line below carries the outcome; the flash is only the367+ // "that click did something" acknowledgement.368+ setSyncFlash(describeSyncOutcome(s));369+ setTimeout(() => setSyncFlash(null), 4000);370+ await loadPairedDevices();340371 }341372 // UI-4 / MV-8: rotate sync identity confirmation dialog.342373 const [rotateConfirmOpen, setRotateConfirmOpen] = createSignal(false);@@ -555,10 +586,17 @@555586 // list live if a VOUCHED_DEVICE_PENDING broadcast arrives while open.556587 void loadPendingVouched();557588 void loadPairedDevices();589+ void loadSyncState();558590 const onBroadcast = (msg: unknown) => {559- if ((msg as { type?: string } | null)?.type === "VOUCHED_DEVICE_PENDING") {591+ const m = msg as { type?: string; payload?: unknown } | null;592+ if (m?.type === "VOUCHED_DEVICE_PENDING") {560593 void loadPendingVouched();561594 }595+ if (m?.type === "SYNC_STATE_CHANGED") {596+ const payload = (m.payload ?? {}) as SyncOutcomeSummary;597+ setSyncSummary(payload);598+ setSyncStatus(payload.status ?? "idle");599+ }562600 };563601 chrome.runtime?.onMessage?.addListener(onBroadcast);564602 onCleanup(() => {@@ -1573,6 +1611,22 @@15731611 <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>15741612 <SyncModeSelector />15751613 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+15761630 {/* Paired devices + manual sync — Settings is the hub. */}15771631 <Show when={pairedDevices().length > 0}>15781632 <p class="peach-settings-sub-label" style="margin-top:12px">PAIRED DEVICES</p>@@ -1621,7 +1675,11 @@16211675 )}16221676 </For>16231677 <div class="peach-settings-row peach-settings-row--meta">1624- <span class="peach-settings-value" style="color:var(--peach-t2)">{syncFlash() ?? ""}</span>1678+ <span class="peach-settings-value" style="color:var(--peach-t2)">1679+ {(syncSummary().entriesSent ?? 0) + (syncSummary().entriesReceived ?? 0) > 01680+ ? `${syncSummary().entriesSent ?? 0} sent · ${syncSummary().entriesReceived ?? 0} received`1681+ : ""}1682+ </span>16251683 <button16261684 class="peach-btn peach-btn--subtle peach-btn--sm"16271685 disabled={syncStatus() === "syncing"}src/shared/sync-outcome.test.ts+87 −0
@@ -0,0 +1,87 @@1+import { describe, expect, it } from "vitest";2+import {3+ describeSyncOutcome,4+ isSyncFailure,5+ syncIndicator,6+ syncIndicatorLabel,7+ type SyncOutcome,8+} from "./sync-outcome";9+10+describe("syncIndicator", () => {11+ it("pulses green only while a sync is actually running", () => {12+ expect(syncIndicator({ status: "syncing" }, 1)).toBe("busy");13+ // A running sync outranks a stale failure — the light describes now.14+ expect(syncIndicator({ status: "syncing", outcome: "relay_unreachable" }, 1)).toBe("busy");15+ });16+17+ it("is solid green for a completed sync, with or without changes", () => {18+ expect(syncIndicator({ status: "idle", outcome: "completed_changes" }, 1)).toBe("ok");19+ expect(syncIndicator({ status: "idle", outcome: "completed_no_changes" }, 1)).toBe("ok");20+ });21+22+ it("never shows green for a paired-but-unsyncable device", () => {23+ // The exact case that used to be a silent return: a device is paired, so24+ // "you have a pair" is true, but nothing can sync. Green here would lie.25+ expect(syncIndicator({ status: "error", outcome: "no_eligible_host" }, 2)).toBe("error");26+ expect(syncIndicator({ status: "error", outcome: "runner_unavailable" }, 2)).toBe("error");27+ });28+29+ it("stays unlit when there is nothing paired and nothing attempted", () => {30+ expect(syncIndicator({}, 0)).toBe("idle");31+ expect(syncIndicator({ status: "idle", outcome: "no_paired_devices" }, 0)).toBe("idle");32+ });33+34+ it("reads a healthy pair as green before the first attempt of this session", () => {35+ expect(syncIndicator({ status: "idle" }, 1)).toBe("ok");36+ });37+38+ it("resolves every outcome to exactly one indicator, none falling through to ok", () => {39+ const outcomes: SyncOutcome[] = [40+ "completed_changes", "completed_no_changes", "no_paired_devices",41+ "no_eligible_host", "runner_unavailable", "sync_disabled",42+ "private_relay_unset", "vault_locked", "relay_unreachable",43+ "handshake_failed", "vault_id_mismatch", "sync_timeout", "run_failed",44+ ];45+ for (const outcome of outcomes) {46+ const light = syncIndicator({ status: "idle", outcome }, 1);47+ expect(isSyncFailure(outcome) ? light : "not-error").not.toBe("ok");48+ if (isSyncFailure(outcome)) expect(light).toBe("error");49+ }50+ });51+});52+53+describe("describeSyncOutcome", () => {54+ it("names what moved on a successful sync", () => {55+ expect(56+ describeSyncOutcome({ outcome: "completed_changes", entriesSent: 2, entriesReceived: 1 }),57+ ).toBe("Synced — 2 sent, 1 received");58+ });59+60+ it("distinguishes a successful no-op from a failure in plain English", () => {61+ expect(describeSyncOutcome({ outcome: "completed_no_changes" }))62+ .toBe("Up to date — nothing to send or receive");63+ expect(describeSyncOutcome({ outcome: "no_eligible_host" }))64+ .not.toContain("Up to date");65+ });66+67+ it("keeps the raw diagnostic detail visible after the plain-English reason", () => {68+ // On a real device there is no devtools; the inventory is the whole clue.69+ const line = describeSyncOutcome({70+ outcome: "no_eligible_host",71+ detail: "Pixel [type=phone, trust=direct]",72+ });73+ expect(line).toContain("no device here can accept a sync");74+ expect(line).toContain("Pixel [type=phone, trust=direct]");75+ });76+77+ it("says nothing has happened yet rather than implying success", () => {78+ expect(describeSyncOutcome({})).toBe("Not synced yet");79+ });80+});81+82+describe("syncIndicatorLabel", () => {83+ it("states the colour's claim in words", () => {84+ expect(syncIndicatorLabel("error", { outcome: "vault_id_mismatch" }))85+ .toBe("Sync problem — That device holds a different vault");86+ });87+});src/shared/sync-outcome.ts+2 −0
@@ -42,6 +42,8 @@4242 entriesSent?: number;4343 entriesReceived?: number;4444 detail?: string;45+ /** When the last attempt ended, success or not. */46+ finishedAt?: number;4547 }4648 4749 /**