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

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.2.62",5+  "version": "5.3.0",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.2.62",3+  "version": "5.3.0",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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>

view file

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

view file

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

view file

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

view file

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

view file

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

view file