191e3b1947ed
add occasional Peach Pro nudge with 90-day snooze and never-again (v5.3.0)
- time
- 2026-10-06 02:16 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- peach pro nudge screen
- parent
- a27dae22457c
↓ peach-browser-191e3b19.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+90 −0src/popup/views/ProNudge.test.tsx+79 −0src/popup/views/ProNudge.tsx+62 −0src/shared/pro-nudge.test.ts+57 −0src/shared/pro-nudge.ts+36 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.62",4+ "version": "5.3.0",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.2.62",5+ "version": "5.3.0",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.2.62",3+ "version": "5.3.0",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/App.tsx+17 −2
@@ -12,6 +12,8 @@1212 import { LockView, RESTORE_SOURCE_STORAGE_KEY, type RestoreSource } from "./views/Lock";1313 import { WhatsNewView } from "./views/WhatsNew";1414 import { shouldShowWhatsNew } from "../shared/release-notes";15+import { ProNudgeView } from "./views/ProNudge";16+import { claimProNudge } from "../shared/pro-nudge";1517 import {1618 ONBOARDING_CARD_STORAGE_KEY,1719 ONBOARDING_LAST_CARD,@@ -63,6 +65,7 @@6365 | "lock"6466 | "pro-celebration"6567 | "whats-new"68+ | "pro-nudge"6669 | "entry"6770 | "list"6871 | "add"@@ -689,6 +692,8 @@689692 window.setTimeout(() => setView("entry"), 2500);690693 } else if (await shouldShowWhatsNew()) {691694 setView("whats-new");695+ } else if (!proStatus() && await claimProNudge()) {696+ setView("pro-nudge");692697 }693698 } else if (shouldCelebrate) {694699 setPendingCelebration(true);@@ -1124,6 +1129,8 @@11241129 window.setTimeout(() => setView("entry"), 2500);11251130 } else if (!openedForInlineUnlock && await shouldShowWhatsNew()) {11261131 setView("whats-new");1132+ } else if (!openedForInlineUnlock && !proStatus() && await claimProNudge()) {1133+ setView("pro-nudge");11271134 }11281135 // Popup was opened from the inline "Open Peach to unlock" button — close it11291136 // now so the user lands back on the page with the autofill selector ready.@@ -1448,7 +1455,8 @@14481455 when={14491456 view() !== "pro-celebration" &&14501457 view() !== "initial-sync" &&1451- view() !== "whats-new"1458+ view() !== "whats-new" &&1459+ view() !== "pro-nudge"14521460 }14531461 fallback={14541462 <Show@@ -1456,7 +1464,14 @@14561464 fallback={14571465 <Show14581466 when={view() === "whats-new"}1459- fallback={<ProCelebration />}1467+ fallback={1468+ <Show when={view() === "pro-nudge"} fallback={<ProCelebration />}>1469+ <ProNudgeView1470+ onSeePro={() => { setSettingsInitialSection("license"); setView("settings"); }}1471+ onDismiss={() => setView("entry")}1472+ />1473+ </Show>1474+ }14601475 >14611476 <WhatsNewView onContinue={() => setView("entry")} />14621477 </Show>src/popup/styles/popup.css+90 −0
@@ -8046,3 +8046,93 @@80468046 border-radius: 999px;80478047 font-size: 0.75rem;80488048 }8049+8050+/* ── Peach Pro nudge (occasional, after unlock, free plan only) ── */8051+.peach-pro-nudge {8052+ width: 100%;8053+ height: 100%;8054+ background:8055+ radial-gradient(90% 60% at 100% 0%, color-mix(in srgb, var(--peach-accent) 14%, transparent) 0%, transparent 70%),8056+ var(--peach-bg);8057+ overflow-y: auto;8058+}8059+8060+.peach-pro-nudge-inner {8061+ display: flex;8062+ flex-direction: column;8063+ gap: 10px;8064+ min-height: 100%;8065+ padding: 28px 24px 20px;8066+ animation: peach-pro-nudge-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both;8067+}8068+8069+.peach-pro-nudge-mark {8070+ width: 32px;8071+ height: 32px;8072+ margin-bottom: 6px;8073+}8074+8075+.peach-pro-nudge-headline { font-size: 34px; }8076+8077+.peach-pro-nudge-list {8078+ margin: 8px 0 0;8079+ padding: 0;8080+ list-style: none;8081+ border-top: 1px solid var(--peach-border);8082+}8083+8084+.peach-pro-nudge-item {8085+ display: grid;8086+ grid-template-columns: 24px 1fr;8087+ column-gap: 8px;8088+ row-gap: 2px;8089+ padding: 9px 0;8090+ border-bottom: 1px solid var(--peach-border);8091+}8092+8093+.peach-pro-nudge-item .peach-pro-grid-num { grid-row: span 2; padding-top: 2px; }8094+8095+.peach-pro-nudge-actions {8096+ display: flex;8097+ flex-direction: column;8098+ gap: 12px;8099+ margin-top: auto;8100+ padding-top: 16px;8101+}8102+8103+.peach-pro-nudge-actions .peach-pro-upgrade-cta { padding: 11px; }8104+8105+.peach-pro-nudge-dismissals {8106+ display: flex;8107+ justify-content: center;8108+ align-items: center;8109+ gap: 8px;8110+ color: var(--peach-t2);8111+ font-size: 0.6875rem;8112+}8113+8114+.peach-pro-nudge-quiet {8115+ background: none;8116+ border: none;8117+ padding: 4px 2px;8118+ color: var(--peach-t1);8119+ font: inherit;8120+ cursor: pointer;8121+}8122+8123+.peach-pro-nudge-quiet:hover { color: var(--peach-t0); text-decoration: underline; }8124+8125+.peach-pro-nudge-quiet:focus-visible {8126+ outline: 2px solid var(--peach-accent);8127+ outline-offset: 2px;8128+ border-radius: 4px;8129+}8130+8131+@keyframes peach-pro-nudge-in {8132+ from { opacity: 0; transform: translateY(8px); }8133+ to { opacity: 1; transform: none; }8134+}8135+8136+@media (prefers-reduced-motion: reduce) {8137+ .peach-pro-nudge-inner { animation: none; }8138+}src/popup/views/ProNudge.test.tsx+79 −0
@@ -0,0 +1,79 @@1+// @vitest-environment jsdom2+3+import { afterEach, describe, expect, it, vi } from "vitest";4+5+import { flushView, renderView } from "./view-test-utils";6+7+const snoozeMock = vi.fn(async () => {});8+const silenceMock = vi.fn(async () => {});9+10+vi.mock("../../shared/pro-nudge", () => ({11+ snoozeProNudge: () => snoozeMock(),12+ silenceProNudge: () => silenceMock(),13+}));14+15+const { ProNudgeView } = await import("./ProNudge");16+17+function button(container: HTMLElement, label: string): HTMLButtonElement {18+ const match = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === label);19+ if (!match) throw new Error(`no button "${label}"`);20+ return match;21+}22+23+describe("ProNudgeView", () => {24+ let dispose: (() => void) | undefined;25+26+ afterEach(() => {27+ dispose?.();28+ vi.clearAllMocks();29+ });30+31+ it("snoozes for 90 days, then dismisses", async () => {32+ const onDismiss = vi.fn();33+ const view = renderView(() => <ProNudgeView onSeePro={() => {}} onDismiss={onDismiss} />);34+ dispose = view.dispose;35+36+ button(view.container, "Remind me in 90 days").click();37+ await flushView();38+39+ expect(snoozeMock).toHaveBeenCalledOnce();40+ expect(silenceMock).not.toHaveBeenCalled();41+ expect(onDismiss).toHaveBeenCalledOnce();42+ });43+44+ it("silences permanently, then dismisses", async () => {45+ const onDismiss = vi.fn();46+ const view = renderView(() => <ProNudgeView onSeePro={() => {}} onDismiss={onDismiss} />);47+ dispose = view.dispose;48+49+ button(view.container, "Don't show again").click();50+ await flushView();51+52+ expect(silenceMock).toHaveBeenCalledOnce();53+ expect(onDismiss).toHaveBeenCalledOnce();54+ });55+56+ it("still dismisses when storage fails", async () => {57+ silenceMock.mockRejectedValueOnce(new Error("quota"));58+ const onDismiss = vi.fn();59+ const view = renderView(() => <ProNudgeView onSeePro={() => {}} onDismiss={onDismiss} />);60+ dispose = view.dispose;61+62+ button(view.container, "Don't show again").click();63+ await flushView();64+65+ expect(onDismiss).toHaveBeenCalledOnce();66+ });67+68+ it("opens Pro without recording a dismissal", () => {69+ const onSeePro = vi.fn();70+ const view = renderView(() => <ProNudgeView onSeePro={onSeePro} onDismiss={() => {}} />);71+ dispose = view.dispose;72+73+ button(view.container, "See Peach Pro").click();74+75+ expect(onSeePro).toHaveBeenCalledOnce();76+ expect(snoozeMock).not.toHaveBeenCalled();77+ expect(silenceMock).not.toHaveBeenCalled();78+ });79+});src/popup/views/ProNudge.tsx+62 −0
@@ -0,0 +1,62 @@1+import { For } from "solid-js";2+import { PeachMark } from "../components/PeachMark";3+import { silenceProNudge, snoozeProNudge } from "../../shared/pro-nudge";4+5+const PRO_HIGHLIGHTS = [6+ ["Peach Sync", "Encrypted, peer-to-peer, across every device"],7+ ["Peach Codex", "Your whole vault, restorable from paper"],8+ ["Passkeys", "Zero-knowledge WebAuthn, built in"],9+ ["Duress vault", "A convincing decoy under coercion"],10+] as const;11+12+export function ProNudgeView(props: { onSeePro: () => void; onDismiss: () => void }) {13+ async function dismiss(record: () => Promise<void>) {14+ await record().catch(() => {15+ // Best effort: a storage failure only means the nudge may return sooner.16+ });17+ props.onDismiss();18+ }19+20+ return (21+ <section class="peach-pro-nudge peach-theme" aria-labelledby="pro-nudge-title">22+ <div class="peach-pro-nudge-inner">23+ <PeachMark class="peach-pro-nudge-mark" />24+ <p class="peach-pro-eyebrow">PEACH PRO · $39 ONCE</p>25+ <h1 id="pro-nudge-title" class="peach-pro-headline peach-pro-nudge-headline">26+ Peach stays free.<br />Pro keeps it <em>growing.</em>27+ </h1>28+ <p class="peach-pro-subtitle">29+ No subscription, no account. One purchase supports an independent30+ password manager and unlocks the extras, forever.31+ </p>32+33+ <ol class="peach-pro-nudge-list">34+ <For each={PRO_HIGHLIGHTS}>35+ {([name, description], index) => (36+ <li class="peach-pro-nudge-item">37+ <span class="peach-pro-grid-num">{String(index() + 1).padStart(2, "0")}</span>38+ <span class="peach-pro-grid-name">{name}</span>39+ <span class="peach-pro-grid-desc">{description}</span>40+ </li>41+ )}42+ </For>43+ </ol>44+45+ <div class="peach-pro-nudge-actions">46+ <button type="button" class="peach-pro-upgrade-cta" onClick={props.onSeePro}>47+ See Peach Pro48+ </button>49+ <div class="peach-pro-nudge-dismissals">50+ <button type="button" class="peach-pro-nudge-quiet" onClick={() => void dismiss(snoozeProNudge)}>51+ Remind me in 90 days52+ </button>53+ <span aria-hidden="true">·</span>54+ <button type="button" class="peach-pro-nudge-quiet" onClick={() => void dismiss(silenceProNudge)}>55+ Don't show again56+ </button>57+ </div>58+ </div>59+ </div>60+ </section>61+ );62+}src/shared/pro-nudge.test.ts+57 −0
@@ -0,0 +1,57 @@1+import { describe, it, expect, beforeEach, vi } from "vitest";2+3+const store: Record<string, unknown> = {};4+5+vi.stubGlobal("chrome", {6+ storage: {7+ local: {8+ get: vi.fn(async (key: string) => ({ [key]: store[key] })),9+ set: vi.fn(async (entries: Record<string, unknown>) => {10+ Object.assign(store, entries);11+ }),12+ },13+ },14+});15+16+const { claimProNudge, snoozeProNudge, silenceProNudge } = await import("./pro-nudge");17+18+const DAY = 24 * 60 * 60 * 1000;19+const T0 = 1_800_000_000_000;20+21+describe("pro-nudge", () => {22+ beforeEach(() => {23+ Object.keys(store).forEach((k) => delete store[k]);24+ });25+26+ it("does not show on first sight, only after a grace period", async () => {27+ expect(await claimProNudge(T0)).toBe(false);28+ expect(await claimProNudge(T0 + 13 * DAY)).toBe(false);29+ expect(await claimProNudge(T0 + 14 * DAY)).toBe(true);30+ });31+32+ it("shows at most once per cadence window even if never dismissed", async () => {33+ await claimProNudge(T0);34+ expect(await claimProNudge(T0 + 14 * DAY)).toBe(true);35+ expect(await claimProNudge(T0 + 15 * DAY)).toBe(false);36+ expect(await claimProNudge(T0 + 44 * DAY)).toBe(true);37+ });38+39+ it("stays hidden for 90 days after snoozing", async () => {40+ await claimProNudge(T0);41+ await claimProNudge(T0 + 14 * DAY);42+ await snoozeProNudge(T0 + 14 * DAY);43+ expect(await claimProNudge(T0 + 103 * DAY)).toBe(false);44+ expect(await claimProNudge(T0 + 104 * DAY)).toBe(true);45+ });46+47+ it("never shows again after silencing", async () => {48+ await claimProNudge(T0);49+ await silenceProNudge();50+ expect(await claimProNudge(T0 + 10_000 * DAY)).toBe(false);51+ });52+53+ it("treats unreadable storage as not due", async () => {54+ vi.mocked(chrome.storage.local.get).mockRejectedValueOnce(new Error("unavailable"));55+ expect(await claimProNudge(T0 + 1_000 * DAY)).toBe(false);56+ });57+});src/shared/pro-nudge.ts+36 −0
@@ -0,0 +1,36 @@1+const NEXT_AT_KEY = "peach_pro_nudge_next_at";2+const DAY_MS = 24 * 60 * 60 * 1000;3+const FIRST_SIGHT_GRACE_MS = 14 * DAY_MS;4+const CADENCE_MS = 30 * DAY_MS;5+const SNOOZE_MS = 90 * DAY_MS;6+7+type NextAt = number | "never";8+9+/**10+ * Returns whether the Peach Pro nudge is due now, and if so records the11+ * showing so a popup closed without a choice waits a full cadence window.12+ */13+export async function claimProNudge(now = Date.now()): Promise<boolean> {14+ let nextAt: NextAt | undefined;15+ try {16+ nextAt = (await chrome.storage.local.get(NEXT_AT_KEY))[NEXT_AT_KEY] as NextAt | undefined;17+ } catch {18+ return false;19+ }20+ if (nextAt === "never") return false;21+ if (typeof nextAt !== "number") {22+ await chrome.storage.local.set({ [NEXT_AT_KEY]: now + FIRST_SIGHT_GRACE_MS });23+ return false;24+ }25+ if (now < nextAt) return false;26+ await chrome.storage.local.set({ [NEXT_AT_KEY]: now + CADENCE_MS });27+ return true;28+}29+30+export async function snoozeProNudge(now = Date.now()): Promise<void> {31+ await chrome.storage.local.set({ [NEXT_AT_KEY]: now + SNOOZE_MS });32+}33+34+export async function silenceProNudge(): Promise<void> {35+ await chrome.storage.local.set({ [NEXT_AT_KEY]: "never" satisfies NextAt });36+}