b44e8d8142cc

Pro activation celebration — plays once on first popup open after license activation

time
2026-04-27 13:43 UTC · 6 months ago
machine
koh2
parent
f2a05b205496
5 files src/background/license.ts+13 −0src/background/messages.ts+10 −0src/popup/App.tsx+40 −5src/popup/styles/popup.css+66 −0src/popup/views/ProCelebration.tsx+18 −0
src/background/license.ts+13 −0
@@ -166,3 +166,16 @@166166 export async function deactivateLicense(): Promise<void> {167167   await chrome.storage.local.remove("peach_license");168168 }169+170+const CELEBRATION_KEY = "peach_pro_celebrated";171+172+export async function shouldPlayCelebration(): Promise<boolean> {173+  const info = await getLicenseInfo();174+  if (!info.valid || info.plan !== "pro") return false;175+  const result = await chrome.storage.local.get(CELEBRATION_KEY) as Record<string, boolean | undefined>;176+  return !result[CELEBRATION_KEY];177+}178+179+export async function markCelebrationPlayed(): Promise<void> {180+  await chrome.storage.local.set({ [CELEBRATION_KEY]: true });181+}

view file

src/background/messages.ts+10 −0
@@ -17,6 +17,8 @@1717   deactivateLicense,1818   getLicenseInfo,1919   isProUser,20+  markCelebrationPlayed,21+  shouldPlayCelebration,2022 } from "./license";2123 import {2224   getPasskeyProviderState,@@ -403,6 +405,14 @@403405       }404406       case "DEACTIVATE_LICENSE": {405407         await deactivateLicense();408+        return { ok: true };409+      }410+      case "SHOULD_PLAY_CELEBRATION": {411+        const should = await shouldPlayCelebration();412+        return { ok: true, data: should };413+      }414+      case "MARK_CELEBRATION_PLAYED": {415+        await markCelebrationPlayed();406416         return { ok: true };407417       }408418       case "GET_IDENTITIES": {

view file

src/popup/App.tsx+40 −5
@@ -2,6 +2,7 @@22 import { ACCENT_COLORS } from "../shared/types";33 import type { AccentColorName, PasskeyEntry, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";44 import { LockView } from "./views/Lock";5+import { ProCelebration } from "./views/ProCelebration";56 import { VaultView, type SortOrder } from "./views/Vault";67 import { EntryView } from "./views/Entry";78 import { AddView } from "./views/Add";@@ -31,6 +32,7 @@3132 3233 type View =3334   | "lock"35+  | "pro-celebration"3436   | "entry"3537   | "list"3638   | "add"@@ -192,6 +194,7 @@192194   const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");193195   const [listSearchSeed, setListSearchSeed] = createSignal("");194196   const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);197+  const [pendingCelebration, setPendingCelebration] = createSignal(false);195198 196199   let contentPhaseTimer: number | undefined;197200 @@ -281,10 +284,11 @@281284     // so it must be populated before openVault runs. Previously refreshCurrentTab282285     // ran after openVault, leaving popups that booted already-unlocked falling283286     // back to loadedEntries[0] instead of the domain match.284-    const [unlockResponse, vaultResponse] = await Promise.all([287+    const [unlockResponse, vaultResponse, , celebrationResponse] = await Promise.all([285288       sendMessage({ type: "IS_UNLOCKED" }),286289       sendMessage({ type: "HAS_VAULT" }),287290       refreshCurrentTab(),291+      sendMessage({ type: "SHOULD_PLAY_CELEBRATION" }),288292     ]);289293     const unlocked = Boolean(290294       (unlockResponse as { ok: boolean; data?: boolean }).data@@ -292,11 +296,25 @@292296     const vaultExists = Boolean(293297       (vaultResponse as { ok: boolean; data?: boolean }).data294298     );299+    const shouldCelebrate = Boolean(300+      (celebrationResponse as { ok: boolean; data?: boolean }).data301+    );295302     setHasVault(vaultExists);296303     await Promise.all([restoreSettings(), refreshProStatus()]);297304 305+    // Mark played before any animation so a crash mid-sequence never replays it306+    if (shouldCelebrate) {307+      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });308+    }309+298310     if (unlocked && vaultExists) {299-      await openVault();311+      await openVault(undefined, shouldCelebrate);312+      if (shouldCelebrate) {313+        setView("pro-celebration");314+        window.setTimeout(() => setView("entry"), 2500);315+      }316+    } else if (shouldCelebrate) {317+      setPendingCelebration(true);300318     }301319 302320     await refreshPendingPasskey();@@ -416,6 +434,9 @@416434         !e.metaKey &&417435         !e.altKey;418436 437+      // No input during the pro celebration sequence438+      if (v === "pro-celebration") return;439+419440       // Help dialog: Escape closes it; eat the keypress so it doesn't420441       // also navigate away from the underlying view.421442       if (showShortcutsHelp()) {@@ -556,7 +577,7 @@556577     return loadedEntries;557578   }558579 559-  async function openVault(preferredEntryId?: string) {580+  async function openVault(preferredEntryId?: string, skipViewSet = false) {560581     // Purge expired trash entries on each vault open561582     const retDays = trashRetentionDays();562583     if (retDays > 0) {@@ -600,14 +621,23 @@600621     setContentTransitioning(false);601622     setContentPhase("enter");602623     queueContentIdle();603-    setView("entry");624+    if (!skipViewSet) setView("entry");604625   }605626 606627   async function handleUnlock() {607628     setHasVault(true);608629     triggerPipPulse();609630     await restoreSettings();610-    await openVault();631+    const celebrating = pendingCelebration();632+    if (celebrating) {633+      await sendMessage({ type: "MARK_CELEBRATION_PLAYED" });634+      setPendingCelebration(false);635+    }636+    await openVault(undefined, celebrating);637+    if (celebrating) {638+      setView("pro-celebration");639+      window.setTimeout(() => setView("entry"), 2500);640+    }611641   }612642 613643   function handleSelectEntry(entry: VaultEntry) {@@ -763,6 +793,10 @@763793               />764794             }765795           >796+            <Show797+              when={view() !== "pro-celebration"}798+              fallback={<ProCelebration />}799+            >766800             <section767801               id="hd-shell"768802               class="peach-shell peach-theme"@@ -989,6 +1023,7 @@9891023               </Suspense>9901024             </Show>9911025             </section>1026+            </Show>9921027           </Show>9931028         </Show>9941029       </Show>

view file

src/popup/styles/popup.css+66 −0
@@ -5252,3 +5252,69 @@52525252   background: var(--peach-accent);52535253   transition: width 200ms ease-out;52545254 }5255+5256+/* ── Pro activation celebration ── */5257+@keyframes peach-pro-bloom {5258+  0%   { filter: drop-shadow(0 0 0px var(--peach-glow)); }5259+  40%  { filter: drop-shadow(0 0 24px var(--peach-accent))5260+                 drop-shadow(0 0 48px var(--peach-glow)); }5261+  100% { filter: drop-shadow(0 0 0px var(--peach-glow)); }5262+}5263+5264+@keyframes peach-pro-wordmark-in {5265+  from { opacity: 0; transform: translateY(4px); }5266+  to   { opacity: 1; transform: translateY(0); }5267+}5268+5269+@keyframes peach-pro-fade-out {5270+  from { opacity: 1; }5271+  to   { opacity: 0; }5272+}5273+5274+.peach-pro-celebration {5275+  background:5276+    radial-gradient(120% 80% at 0% 0%, var(--shell-accent-a) 0%, transparent 65%),5277+    radial-gradient(85% 100% at 100% 100%, var(--shell-accent-b) 0%, transparent 70%),5278+    var(--peach-bg);5279+  border: 1px solid var(--peach-t3);5280+  box-shadow:5281+    0 2px 8px rgba(0,0,0,0.55),5282+    0 14px 48px rgba(0,0,0,0.7),5283+    0 0 0 1px rgba(240,232,216,0.04);5284+  overflow: hidden;5285+  width: 320px;5286+  height: 520px;5287+  display: flex;5288+  flex-direction: column;5289+  align-items: center;5290+  justify-content: center;5291+  gap: 8px;5292+  padding: 32px 24px;5293+  text-align: center;5294+}5295+5296+.peach-pro-celebration.is-leaving {5297+  animation: peach-pro-fade-out 400ms ease forwards;5298+}5299+5300+.peach-pro-bloom-target {5301+  font-size: 48px;5302+  animation: peach-pro-bloom 600ms cubic-bezier(0.16, 1, 0.3, 1) forwards;5303+  margin-bottom: 12px;5304+}5305+5306+.peach-pro-wordmark {5307+  font-family: "Cormorant Garamond", "Times New Roman", serif;5308+  font-size: 22px;5309+  font-style: italic;5310+  font-weight: 400;5311+  color: var(--peach-accent);5312+  animation: peach-pro-wordmark-in 400ms cubic-bezier(0.16, 1, 0.3, 1) 500ms both;5313+}5314+5315+.peach-pro-thankyou {5316+  font-size: 12px;5317+  font-style: italic;5318+  color: var(--peach-t2);5319+  animation: peach-pro-wordmark-in 400ms cubic-bezier(0.16, 1, 0.3, 1) 800ms both;5320+}

view file

src/popup/views/ProCelebration.tsx+18 −0
@@ -0,0 +1,18 @@1+import { createSignal, onCleanup, onMount } from "solid-js";2+3+export function ProCelebration() {4+  const [leaving, setLeaving] = createSignal(false);5+6+  onMount(() => {7+    const t = window.setTimeout(() => setLeaving(true), 1800);8+    onCleanup(() => window.clearTimeout(t));9+  });10+11+  return (12+    <div class="peach-pro-celebration" classList={{ "is-leaving": leaving() }}>13+      <div class="peach-pro-bloom-target">🍑</div>14+      <div class="peach-pro-wordmark">peach pro</div>15+      <div class="peach-pro-thankyou">thank you.</div>16+    </div>17+  );18+}

view file