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

view file

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

view file

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

view file

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"

view file

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 {

view file

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 {

view file

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

view file

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+});

view file

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 /**

view file