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
↓ peach-browser-d460a48c.tar.gzatlas at this save
files changed9 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 −0manifest.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": {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",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",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()}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+}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+}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+});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+}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,