d460a48c5619

add build-time what's new screen with V4 breaking-change callout, shown once after unlock per version bump (v5.1.4)

time
2026-09-05 16:19 UTC · 1 month ago
machine
koh2
agent
glm-5.2
session
changelog breaking-changes screen
parent
ce280211c5b7
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/App.tsx+17 −2src/popup/styles/popup.css+118 −0src/popup/views/WhatsNew.tsx+40 −0src/shared/release-notes.test.ts+76 −0src/shared/release-notes.ts+61 −0vite.config.ts+2 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.1.3",4+  "version": "5.1.4",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.1.3",5+  "version": "5.1.4",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.1.3",3+  "version": "5.1.4",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/App.tsx+17 −2
@@ -10,6 +10,8 @@1010   type InitialSyncDevice,1111 } from "../shared/initial-sync";1212 import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock";13+import { WhatsNewView } from "./views/WhatsNew";14+import { shouldShowWhatsNew } from "../shared/release-notes";1315 import {1416   ONBOARDING_CARD_STORAGE_KEY,1517   ONBOARDING_LAST_CARD,@@ -59,6 +61,7 @@5961 type View =6062   | "lock"6163   | "pro-celebration"64+  | "whats-new"6265   | "entry"6366   | "list"6467   | "add"@@ -670,6 +673,8 @@670673       if (shouldCelebrate) {671674         setView("pro-celebration");672675         window.setTimeout(() => setView("entry"), 2500);676+      } else if (await shouldShowWhatsNew()) {677+        setView("whats-new");673678       }674679     } else if (shouldCelebrate) {675680       setPendingCelebration(true);@@ -1097,6 +1102,8 @@10971102     if (celebrating) {10981103       setView("pro-celebration");10991104       window.setTimeout(() => setView("entry"), 2500);1105+    } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {1106+      setView("whats-new");11001107     }11011108     // Popup was opened from the inline "Open Peach to unlock" button — close it11021109     // now so the user lands back on the page with the autofill selector ready.@@ -1420,12 +1427,20 @@14201427             <Show14211428               when={14221429                 view() !== "pro-celebration" &&1423-                view() !== "initial-sync"1430+                view() !== "initial-sync" &&1431+                view() !== "whats-new"14241432               }14251433               fallback={14261434                 <Show14271435                   when={view() === "initial-sync"}1428-                  fallback={<ProCelebration />}1436+                  fallback={1437+                    <Show1438+                      when={view() === "whats-new"}1439+                      fallback={<ProCelebration />}1440+                    >1441+                      <WhatsNewView onContinue={() => setView("entry")} />1442+                    </Show>1443+                  }14291444                 >14301445                   <InitialSyncView14311446                     slow={initialSyncSlow()}

view file

src/popup/styles/popup.css+118 −0
@@ -7925,3 +7925,121 @@79257925   text-decoration: underline;79267926   cursor: pointer;79277927 }7928+7929+/* ── What's new / breaking-changes screen (one-shot after unlock) ── */7930+.peach-whats-new {7931+  width: 320px;7932+  height: 520px;7933+  display: flex;7934+  flex-direction: column;7935+  background:7936+    radial-gradient(120% 80% at 0% 0%, color-mix(in srgb, var(--peach-accent) 8%, transparent) 0%, transparent 65%),7937+    var(--peach-bg);7938+  border: 1px solid var(--peach-t3);7939+  box-shadow: 0 2px 8px rgba(0,0,0,0.55), 0 14px 48px rgba(0,0,0,0.7);7940+  overflow: hidden;7941+}7942+7943+.peach-whats-new-inner {7944+  display: flex;7945+  flex-direction: column;7946+  height: 100%;7947+  padding: 36px 24px 20px;7948+}7949+7950+.peach-whats-new-header {7951+  display: flex;7952+  flex-direction: column;7953+  align-items: center;7954+  gap: 6px;7955+  margin-bottom: 20px;7956+  flex-shrink: 0;7957+}7958+7959+.peach-whats-new-mark {7960+  width: 32px;7961+  height: 32px;7962+  margin-bottom: 4px;7963+}7964+7965+.peach-whats-new-title {7966+  font-family: var(--font-display);7967+  font-size: 20px;7968+  font-style: italic;7969+  font-weight: 400;7970+  color: var(--peach-t4);7971+  margin: 0;7972+}7973+7974+.peach-whats-new-version {7975+  font-family: var(--peach-mono);7976+  font-size: 0.6875rem;7977+  color: var(--peach-t2);7978+}7979+7980+.peach-whats-new-scroll {7981+  flex: 1;7982+  overflow-y: auto;7983+  display: flex;7984+  flex-direction: column;7985+  gap: 16px;7986+  padding-bottom: 8px;7987+}7988+7989+.peach-whats-new-note {7990+  padding: 14px 16px;7991+  border-radius: 10px;7992+  background: var(--peach-bg-card);7993+  border: 1px solid var(--peach-t3);7994+}7995+7996+.peach-whats-new-note.is-breaking {7997+  border-color: color-mix(in srgb, var(--peach-breach) 45%, var(--peach-t3));7998+  background: color-mix(in srgb, var(--peach-breach) 6%, var(--peach-bg-card));7999+}8000+8001+.peach-whats-new-badge {8002+  display: inline-block;8003+  font-family: var(--peach-mono);8004+  font-size: 0.5625rem;8005+  text-transform: uppercase;8006+  letter-spacing: 0.05em;8007+  color: var(--peach-breach);8008+  background: color-mix(in srgb, var(--peach-breach) 14%, transparent);8009+  padding: 2px 7px;8010+  border-radius: 4px;8011+  margin-bottom: 8px;8012+}8013+8014+.peach-whats-new-note-heading {8015+  font-size: 0.875rem;8016+  font-weight: 600;8017+  color: var(--peach-t4);8018+  margin: 0 0 6px;8019+}8020+8021+.peach-whats-new-note-body {8022+  font-size: 0.75rem;8023+  line-height: 1.5;8024+  color: var(--peach-t1);8025+  margin: 0;8026+}8027+8028+.peach-whats-new-continue {8029+  margin-top: 16px;8030+  flex-shrink: 0;8031+  width: 100%;8032+  padding: 11px;8033+  border-radius: 999px;8034+  font-size: 0.75rem;8035+  font-weight: 500;8036+  border: 1px solid color-mix(in srgb, var(--peach-accent) 55%, transparent);8037+  background: color-mix(in srgb, var(--peach-accent) 16%, transparent);8038+  color: var(--peach-accent);8039+  cursor: pointer;8040+  transition: background 150ms ease;8041+}8042+8043+.peach-whats-new-continue:hover {8044+  background: color-mix(in srgb, var(--peach-accent) 24%, transparent);8045+}

view file

src/popup/views/WhatsNew.tsx+40 −0
@@ -0,0 +1,40 @@1+import { For, Show } from "solid-js";2+import { PeachMark } from "../components/PeachMark";3+import { CURRENT_NOTES, PEACH_VERSION, markWhatsNewSeen } from "../../shared/release-notes";4+5+export function WhatsNewView(props: { onContinue: () => void }) {6+  async function continueClicked() {7+    await markWhatsNewSeen();8+    props.onContinue();9+  }10+11+  return (12+    <div class="peach-whats-new peach-theme">13+      <div class="peach-whats-new-inner">14+        <div class="peach-whats-new-header">15+          <PeachMark class="peach-whats-new-mark" />16+          <h1 class="peach-whats-new-title">what's new</h1>17+          <span class="peach-whats-new-version">v{PEACH_VERSION}</span>18+        </div>19+20+        <div class="peach-whats-new-scroll">21+          <For each={CURRENT_NOTES}>22+            {(note) => (23+              <div classList={{ "peach-whats-new-note": true, "is-breaking": !!note.breaking }}>24+                <Show when={note.breaking}>25+                  <span class="peach-whats-new-badge">breaking</span>26+                </Show>27+                <h2 class="peach-whats-new-note-heading">{note.heading}</h2>28+                <p class="peach-whats-new-note-body">{note.body}</p>29+              </div>30+            )}31+          </For>32+        </div>33+34+        <button class="peach-btn peach-btn--accent peach-whats-new-continue" onClick={continueClicked}>35+          Continue36+        </button>37+      </div>38+    </div>39+  );40+}

view file

src/shared/release-notes.test.ts+76 −0
@@ -0,0 +1,76 @@1+import { describe, it, expect, beforeEach, vi } from "vitest";2+3+// Mock chrome.storage.local before importing the module under test.4+const store: Record<string, unknown> = {};5+6+vi.stubGlobal("chrome", {7+  storage: {8+    local: {9+      get: vi.fn(async (key: string) => ({ [key]: store[key] })),10+      set: vi.fn(async (entries: Record<string, unknown>) => {11+        Object.assign(store, entries);12+      }),13+    },14+  },15+});16+17+// Import after globals are stubbed so the module captures the mock.18+const {19+  PEACH_VERSION,20+  CURRENT_NOTES,21+  hasReleaseNotes,22+  shouldShowWhatsNew,23+  markWhatsNewSeen,24+} = await import("./release-notes");25+26+describe("release-notes", () => {27+  beforeEach(() => {28+    Object.keys(store).forEach((k) => delete store[k]);29+    vi.clearAllMocks();30+  });31+32+  it("PEACH_VERSION is a non-empty string", () => {33+    expect(typeof PEACH_VERSION).toBe("string");34+    expect(PEACH_VERSION.length).toBeGreaterThan(0);35+  });36+37+  it("CURRENT_NOTES has entries for the V4 release", () => {38+    expect(CURRENT_NOTES.length).toBeGreaterThan(0);39+    const breaking = CURRENT_NOTES.find((n) => n.breaking);40+    expect(breaking).toBeDefined();41+    expect(breaking!.body.toLowerCase()).toContain("android");42+  });43+44+  it("hasReleaseNotes returns true when notes exist", () => {45+    expect(hasReleaseNotes()).toBe(true);46+  });47+48+  it("shouldShowWhatsNew returns true when stored version differs", async () => {49+    store["peach_whats_new_seen"] = "0.0.1";50+    expect(await shouldShowWhatsNew()).toBe(true);51+  });52+53+  it("shouldShowWhatsNew returns false when stored version matches", async () => {54+    store["peach_whats_new_seen"] = PEACH_VERSION;55+    expect(await shouldShowWhatsNew()).toBe(false);56+  });57+58+  it("shouldShowWhatsNew returns true when nothing has been seen yet", async () => {59+    expect(await shouldShowWhatsNew()).toBe(true);60+  });61+62+  it("markWhatsNewSeen stores the current version and suppresses reshowing", async () => {63+    expect(await shouldShowWhatsNew()).toBe(true);64+    await markWhatsNewSeen();65+    expect(await shouldShowWhatsNew()).toBe(false);66+    expect(store["peach_whats_new_seen"]).toBe(PEACH_VERSION);67+  });68+69+  it("a version bump re-triggers the screen", async () => {70+    await markWhatsNewSeen();71+    expect(await shouldShowWhatsNew()).toBe(false);72+    // Simulate an older stored version (as if the user upgraded)73+    store["peach_whats_new_seen"] = "5.0.0";74+    expect(await shouldShowWhatsNew()).toBe(true);75+  });76+});

view file

src/shared/release-notes.ts+61 −0
@@ -0,0 +1,61 @@1+/**2+ * Build-time release notes for the "what's new" screen.3+ *4+ * The screen appears once after unlock when the build version differs from the5+ * last version the user acknowledged. `__PEACH_VERSION__` is injected by Vite6+ * from package.json at build time. When `CURRENT_NOTES` is empty the screen is7+ * skipped entirely — no content, no interruption.8+ *9+ * To ship a new changelog: bump the version in package.json (which Vite injects10+ * as `__PEACH_VERSION__`), then add entries to `CURRENT_NOTES` below. The11+ * version mismatch triggers the screen on next unlock; "Continue" stores the12+ * version and suppresses it until the next bump.13+ */14+15+declare const __PEACH_VERSION__: string;16+17+export interface ReleaseNote {18+  heading: string;19+  body: string;20+  breaking?: boolean;21+}22+23+export const PEACH_VERSION: string = typeof __PEACH_VERSION__ !== "undefined"24+  ? __PEACH_VERSION__25+  : "0.0.0";26+27+export const CURRENT_NOTES: ReleaseNote[] = [28+  {29+    heading: "Vault V4 format",30+    body: "Peach now writes a new vault format that supports passwords up to 65,535 characters. Your existing vaults and exports are fully readable.",31+  },32+  {33+    heading: "Update your Android app",34+    body: "Vault V4 is not readable by Peach Android before v2.15.3. If you sync between devices, update the Android app first — an older Android client cannot open a V4 vault.",35+    breaking: true,36+  },37+];38+39+export function hasReleaseNotes(): boolean {40+  return CURRENT_NOTES.length > 0;41+}42+43+const WHATS_NEW_SEEN_KEY = "peach_whats_new_seen";44+45+export async function shouldShowWhatsNew(): Promise<boolean> {46+  if (!hasReleaseNotes()) return false;47+  try {48+    const seen = await chrome.storage.local.get(WHATS_NEW_SEEN_KEY);49+    return seen[WHATS_NEW_SEEN_KEY] !== PEACH_VERSION;50+  } catch {51+    return false;52+  }53+}54+55+export async function markWhatsNewSeen(): Promise<void> {56+  try {57+    await chrome.storage.local.set({ [WHATS_NEW_SEEN_KEY]: PEACH_VERSION });58+  } catch {59+    // best-effort — the screen just won't reshow, which is the lesser evil60+  }61+}

view file

vite.config.ts+2 −0
@@ -7,6 +7,7 @@77 const target = process.env.BUILD_TARGET || "chrome";88 const manifestPath = target === "firefox" ? "./manifest.firefox.json" : "./manifest.json";99 const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));10+const pkg = JSON.parse(readFileSync("package.json", "utf-8"));1011 if (target === "chrome") {1112   manifest.web_accessible_resources = [{1213     matches: ["<all_urls>"],@@ -46,6 +47,7 @@4647   define: {4748     __PEACH_E2E_PASSKEYS__: JSON.stringify(passkeyE2E),4849     __PEACH_DEBUG__: false,50+    __PEACH_VERSION__: JSON.stringify(pkg.version),4951   },5052   build: {5153     outDir,

view file