diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 8137f885c..4f345becc 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -954,13 +954,22 @@ upgrade, update, release, or artifact-selection behavior. unavailable for source/development builds, and return the canonical release body without inventing a second changelog source. The update banner may preview only the curated `Highlights` section from update-check metadata, - while the post-update card must render the release's categorized user-facing + while the post-update card must offer the release's categorized user-facing change sections (`Added`, `Improved`/`Changed`, `Fixed`, `Security`, `Breaking changes`, `Deprecated`, or `Removed`) as a changelog once per - later installed release. It must not reuse the Highlights summary as the - post-update content, and must stay silent for a first baseline, malformed or - development versions, missing releases, and releases without categorized - changes. `Highlights` remains a pre-update overview only: release rendering + later installed release. Automatic release communication is limited to a + compact non-blocking update notice; the detailed changelog may open only + after explicit operator action. Preparing that notice records the version + immediately so a reload cannot turn it into a recurring prompt. When the + one-time telemetry disclosure owns the same session, it suppresses the + lower-priority release notice instead of creating consecutive notices. + `frontend-modern/src/utils/localStorage.ts` owns that browser-session notice + reservation boundary so the release notice, telemetry disclosure, and + GitHub gratitude prompt cannot create a one-two sequence. The post-update + surface must not reuse the Highlights summary as its content, + and must stay silent for a first baseline, malformed or development + versions, missing releases, and releases without categorized changes. + `Highlights` remains a pre-update overview only: release rendering keeps it to at most three short plain-text bullets of no more than 140 characters each, with links, code, issue references, and nested structure reserved for the categorized or full release notes. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0ab44309c..92a640d54 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1080,6 +1080,9 @@ not a replacement status card, CTA band, or page-local nested card. recurring snooze behavior. It must yield the browser session to an existing release, disclosure, or other low-priority app-shell notice and suppress itself for the remainder of a session that presents a blocking dialog. + Low-priority app-shell consumers must coordinate through + `reserveLowPriorityNoticeSession` rather than restoring component-local + cooldowns that can produce consecutive prompts. Settings selection helpers such as `ResourcePicker` must use the same `Button` primitive for select-all, clear, and chip remove actions instead of restoring footer-local action shells. @@ -4994,7 +4997,8 @@ Clipboard API fallback path and only report copied state after the shared copy path succeeds. The running published version must keep a direct `Current release notes` link in this settings shell so suppressing or dismissing the one-time post-update -notice never makes the changelog undiscoverable. +notice never makes the changelog undiscoverable. Development and source-build +identities must not render that link as if they named a published release. The update verdict is honest about its age. The frontend serves the update verdict from a 24-hour localStorage cache, so the panel's "Up to date" state diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index e480f0f3c..504c5e66e 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,23 +1,29 @@ { "version": 1, - "base_sha": "af03903a734f6741360748fbe661cccb45c880ca", - "verified_at": "2026-08-25T19:48:51Z", + "base_sha": "51d49474e461eaca83a75065ba370f3092d6ba34", + "verified_at": "2026-08-25T20:20:32Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Storage/DiskDetail.tsx", - "frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx" + "frontend-modern/src/components/GitHubStarBanner.tsx", + "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", + "frontend-modern/src/components/WhatsNewCard.tsx", + "frontend-modern/src/utils/localStorage.ts" ], "content_sha256": { - "frontend-modern/src/components/Storage/DiskDetail.tsx": "19487ce71ef93ed4db8c8a36bb76790298408a449313bcb19e7266b1b41e0afc", - "frontend-modern/src/components/Storage/StorageDetailKeyValueRow.tsx": "bc045c2a053c395154c9a4d0ff5bac32bd1c129ab579032ce282e81acd623426" + "frontend-modern/src/components/GitHubStarBanner.tsx": "7c5b51cb927fa778bd6bb201793a20c084d4e3f6bfab39feb4573fe2759f8c85", + "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx": "b0cb063a25f0c9a966522b740afa962163d87a50e241030ac289f1a5679063ad", + "frontend-modern/src/components/WhatsNewCard.tsx": "5e5d7314e46b8aa496f73a42e1abc37a294a3f3ebff3c3c424c33f8d615961d0", + "frontend-modern/src/utils/localStorage.ts": "452dbaa0e4014e0af635b24502ffbe53c738a449670ac6b4c381b3033c53ee0a" }, "routes": [ - "/proxmox/storage" + "/prompt-verification.html", + "/proxmox/overview", + "/settings/system-updates" ], "viewports": [ { - "width": 1280, - "height": 800 + "width": 1440, + "height": 900 }, { "width": 390, @@ -25,17 +31,17 @@ } ], "states": [ - "degraded ZFS pool Overview with long node and content values", - "degraded ZFS pool History with collection availability messaging", - "critical NVMe disk Overview without detailed SMART attributes", - "physical disk History with live I/O and SMART history" + "compact post-update notice over an active infrastructure page without a blocking backdrop", + "opt-in categorized release changelog dialog at desktop and phone widths", + "one-time GitHub star prompt at desktop and phone widths", + "dismissed release notice, changelog dialog, and star prompt states", + "System Updates current-version panel in the signed-in development runtime" ], "interactions": [ - "opened the Storage and Physical Disks tabs", - "expanded standalone1-service-pool and switched between Overview and History", - "expanded the WD Blue disk and switched between Overview and History", - "confirmed mobile key-value stacking and wrapping without clipping", - "confirmed the two-column desktop configuration grid remains intact", - "restored the browser viewport after responsive verification" + "opened the changelog from See what's new and closed it with both Escape and Got it", + "dismissed the compact update notice without opening the changelog", + "closed the GitHub star prompt and confirmed it left the page cleanly", + "navigated the signed-in System Updates page and confirmed development builds do not claim published release notes", + "restored the browser viewport and closed all verification tabs" ] } diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 8b1aa02f5..a697ac9c0 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -720,7 +720,7 @@ { "id": "github-star-banner-action-shell", "category": "action-button", - "summary": "The GitHub star prompt must compose Button and ActionIconButton for its primary, defer, and dismiss actions instead of carrying local floating-prompt button shells.", + "summary": "The one-time GitHub star prompt must compose Button and ActionIconButton for its primary and permanent-dismiss actions instead of carrying local floating-prompt button shells.", "canonical": { "path": "src/components/shared/Button.tsx", "export": "Button" diff --git a/frontend-modern/src/components/GitHubStarBanner.tsx b/frontend-modern/src/components/GitHubStarBanner.tsx index 70dade1dd..b58ec934e 100644 --- a/frontend-modern/src/components/GitHubStarBanner.tsx +++ b/frontend-modern/src/components/GitHubStarBanner.tsx @@ -1,16 +1,20 @@ -import { Show, createSignal, createEffect, onCleanup } from 'solid-js'; +import { Show, createSignal, createEffect } from 'solid-js'; import { createLocalStorageBooleanSignal, + createLocalStorageNumberSignal, createLocalStorageStringSignal, + reserveLowPriorityNoticeSession, STORAGE_KEYS, } from '@/utils/localStorage'; import { useWebSocket } from '@/contexts/appRuntime'; import { ActionIconButton, Button } from '@/components/shared/Button'; +import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState'; import GithubIcon from 'lucide-solid/icons/github'; import StarIcon from 'lucide-solid/icons/star'; import XIcon from 'lucide-solid/icons/x'; const GITHUB_REPO_URL = 'https://github.com/rcourtman/Pulse'; +const ACTIVE_DAYS_BEFORE_PROMPT = 14; function getTodayDateString(): string { return new Date().toISOString().split('T')[0]; // YYYY-MM-DD @@ -19,127 +23,111 @@ function getTodayDateString(): string { export function GitHubStarBanner() { const { initialDataReceived, state } = useWebSocket(); - // Track if user has dismissed the modal (permanent) + // Closing the prompt is permanent; this is gratitude, not a workflow. const [dismissed, setDismissed] = createLocalStorageBooleanSignal( STORAGE_KEYS.GITHUB_STAR_DISMISSED, false, ); - // Track the first date user had infrastructure connected - const [firstSeenDate, setFirstSeenDate] = createLocalStorageStringSignal( - STORAGE_KEYS.GITHUB_STAR_FIRST_SEEN, + // A rendered prompt counts as its single lifetime appearance even if the + // browser closes before the user chooses an action. + const [promptShown, setPromptShown] = createLocalStorageBooleanSignal( + STORAGE_KEYS.GITHUB_STAR_PROMPT_SHOWN, + false, + ); + + const [activeDays, setActiveDays] = createLocalStorageNumberSignal( + STORAGE_KEYS.GITHUB_STAR_ACTIVE_DAYS, + 0, + ); + + const [lastActiveDate, setLastActiveDate] = createLocalStorageStringSignal( + STORAGE_KEYS.GITHUB_STAR_LAST_ACTIVE_DATE, '', ); - // Track snooze date (when "Maybe later" was clicked, don't show again until this date) - const [snoozedUntil, setSnoozedUntil] = createLocalStorageStringSignal( - STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL, - '', - ); + const [showPrompt, setShowPrompt] = createSignal(false); + let blockedByDialogThisSession = false; - const [showModal, setShowModal] = createSignal(false); - - // Check if user qualifies to see the modal + // Count distinct days with connected infrastructure, then offer one quiet + // prompt only when the rest of the app shell has yielded the session. createEffect(() => { - // Already dismissed? Don't show if (dismissed()) { - setShowModal(false); + setShowPrompt(false); return; } + if (promptShown()) return; + + if (dialogStackHasBlockingDialog()) { + blockedByDialogThisSession = true; + setShowPrompt(false); + return; + } + + if (blockedByDialogThisSession) return; + if (!initialDataReceived()) { - setShowModal(false); + setShowPrompt(false); return; } - // Check if user has connected infrastructure const hasInfrastructure = (state.resources || []).length > 0; if (!hasInfrastructure) { - setShowModal(false); + setShowPrompt(false); return; } const today = getTodayDateString(); - const firstSeen = firstSeenDate(); - - // First time seeing infrastructure? Record the date, don't show yet - if (!firstSeen) { - setFirstSeenDate(today); - setShowModal(false); + if (lastActiveDate() !== today) { + setLastActiveDate(today); + setActiveDays(Math.min(activeDays() + 1, ACTIVE_DAYS_BEFORE_PROMPT)); return; } - // Still within snooze period? Don't show - const snoozeDate = snoozedUntil(); - if (snoozeDate && today < snoozeDate) { - setShowModal(false); - return; - } + if (activeDays() < ACTIVE_DAYS_BEFORE_PROMPT) return; + if (!reserveLowPriorityNoticeSession('github-star')) return; - // Returning user (different day than first seen)? Show the modal - if (firstSeen !== today) { - setShowModal(true); - } + setShowPrompt(true); + setPromptShown(true); }); const handleDismiss = () => { setDismissed(true); - setShowModal(false); + setShowPrompt(false); }; const handleStarClick = () => { window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer'); // Auto-dismiss - trust that they starred setDismissed(true); - setShowModal(false); + setShowPrompt(false); }; - const handleMaybeLater = () => { - // Snooze for 7 days before showing again - const snoozeDate = new Date(); - snoozeDate.setDate(snoozeDate.getDate() + 7); - setSnoozedUntil(snoozeDate.toISOString().split('T')[0]); - setShowModal(false); - }; - - createEffect(() => { - if (!showModal()) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return; - handleMaybeLater(); - }; - document.addEventListener('keydown', handleKeyDown); - onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); - }); - return ( - +
-
+

- Enjoying Pulse? + Finding Pulse useful?

- Pulse is built and maintained by an independent developer. If it's been useful for - monitoring your infrastructure, a GitHub star helps more than you'd think. + Starring the project on GitHub helps others discover it.

-
+
-
diff --git a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx index 5b10a1dd8..a6f5750c9 100644 --- a/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx @@ -12,9 +12,15 @@ import ArrowRight from 'lucide-solid/icons/arrow-right'; import Package from 'lucide-solid/icons/package'; import type { UpdateInfo, VersionInfo, UpdatePlan } from '@/api/updates'; import { runtimeCapabilities } from '@/stores/license'; -import { buildDockerImageTag, buildLinuxAmd64DownloadCommand } from '@/components/updateVersion'; +import { + buildDockerImageTag, + buildLinuxAmd64DownloadCommand, + buildReleaseNotesUrl, +} from '@/components/updateVersion'; +import { isReleaseVersion } from '@/components/whatsNewModel'; import { UpdateInstallGuide } from '@/components/Settings/UpdateInstallGuide'; import { UpdateHistorySection } from '@/components/Settings/UpdateHistorySection'; +import { ExternalTextLink } from '@/components/shared/ExternalTextLink'; import { getUpdateChannelCardOptions, type UpdateChannelOptionValue, @@ -122,6 +128,21 @@ export const UpdatesSettingsPanel: Component = (props {(badge) => {badge.label}}
+ + + Current release notes + +
diff --git a/frontend-modern/src/components/Settings/__tests__/SettingsSwitchLabels.test.tsx b/frontend-modern/src/components/Settings/__tests__/SettingsSwitchLabels.test.tsx index c7526e339..6c4f3df33 100644 --- a/frontend-modern/src/components/Settings/__tests__/SettingsSwitchLabels.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/SettingsSwitchLabels.test.tsx @@ -33,6 +33,40 @@ describe('settings switch labels', () => { expect(screen.getByRole('checkbox', { name: 'Automatic Stable Updates' })).toBeInTheDocument(); }); + it('keeps the running release notes available from update settings', () => { + const [updateChannel, setUpdateChannel] = createSignal<'stable' | 'rc'>('stable'); + const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false); + + render(() => ( + ({ + version: '6.4.0', + build: 'community', + runtime: 'server', + isDocker: false, + isSourceBuild: false, + isDevelopment: false, + })} + updateInfo={() => null} + checkingForUpdates={() => false} + updateChannel={updateChannel} + setUpdateChannel={setUpdateChannel} + autoUpdateEnabled={autoUpdateEnabled} + setAutoUpdateEnabled={setAutoUpdateEnabled} + checkForUpdates={vi.fn().mockResolvedValue(undefined)} + setHasUnsavedChanges={vi.fn()} + updatePlan={() => null} + onInstallUpdate={vi.fn()} + isInstalling={() => false} + /> + )); + + expect(screen.getByRole('link', { name: 'Current release notes' })).toHaveAttribute( + 'href', + 'https://github.com/rcourtman/Pulse/releases/tag/v6.4.0', + ); + }); + it('associates the backup polling switch with its visible label', () => { const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true); const [backupPollingInterval, setBackupPollingInterval] = createSignal(3600); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index a89f1d3bf..7bd7b6e72 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1435,6 +1435,15 @@ describe('settings architecture guardrails', () => { ); }); + it('keeps published release notes discoverable from the current-version card', () => { + expect(updatesSettingsPanelSource).toContain('Current release notes'); + expect(updatesSettingsPanelSource).toContain('buildReleaseNotesUrl'); + expect(updatesSettingsPanelSource).toContain('isReleaseVersion'); + expect(updatesSettingsPanelSource).toContain('!props.versionInfo()?.isDevelopment'); + expect(updatesSettingsPanelSource).toContain('!props.versionInfo()?.isSourceBuild'); + expect(updatesSettingsPanelSource).toContain('variant="compactAction"'); + }); + it('keeps the update verdict honest about the age of the check behind it', () => { // The displayed verdict can come from the 24h localStorage cache, so the // Up to date state must carry the age of the check it came from through diff --git a/frontend-modern/src/components/WhatsNewCard.tsx b/frontend-modern/src/components/WhatsNewCard.tsx index b04e16165..42b581865 100644 --- a/frontend-modern/src/components/WhatsNewCard.tsx +++ b/frontend-modern/src/components/WhatsNewCard.tsx @@ -1,10 +1,11 @@ import { Show, createEffect, createSignal } from 'solid-js'; import { useNavigate } from '@solidjs/router'; +import CheckCircleIcon from 'lucide-solid/icons/check-circle'; import InfoIcon from 'lucide-solid/icons/info'; import XIcon from 'lucide-solid/icons/x'; import { updateStore } from '@/stores/updates'; import { UpdatesAPI } from '@/api/updates'; -import { STORAGE_KEYS } from '@/utils/localStorage'; +import { reserveLowPriorityNoticeSession, STORAGE_KEYS } from '@/utils/localStorage'; import { ActionIconButton, Button } from '@/components/shared/Button'; import { Dialog } from '@/components/shared/Dialog'; import { ExternalTextLink } from '@/components/shared/ExternalTextLink'; @@ -55,10 +56,11 @@ const markTelemetryPayloadNoticeSeen = () => { }; /** - * Post-update "What's New" dialog. Shows once after the running version - * changes, and only when that release has categorized user-facing changelog - * entries in its GitHub release notes. Dismissing (or a release without those - * entries) records the version so the dialog stays quiet until the next update. + * Post-update "What's New" notice. A compact non-blocking notice appears once + * after the running version changes and only when that release has categorized + * user-facing changelog entries. The detailed dialog opens only after an + * explicit action. Preparing the notice (or finding no categorized entries) + * records the version so reloads stay quiet until the next update. * * This release communication boundary also owns the one-time, non-blocking * telemetry schema v2 notice. Existing installations see it once; fresh @@ -66,15 +68,22 @@ const markTelemetryPayloadNoticeSeen = () => { */ export function WhatsNewCard() { const navigate = useNavigate(); - const [visible, setVisible] = createSignal(false); + const [noticeVisible, setNoticeVisible] = createSignal(false); + const [dialogVisible, setDialogVisible] = createSignal(false); const [telemetryNoticeVisible, setTelemetryNoticeVisible] = createSignal(false); const [version, setVersion] = createSignal(''); const [changelogHtml, setChangelogHtml] = createSignal(''); const hadPriorReleaseBaseline = readLastSeenVersion() !== null; + const telemetryNoticeAlreadySeen = + readTelemetryPayloadNoticeVersion() === TELEMETRY_PAYLOAD_NOTICE_VERSION; + const telemetryNoticeNeedsSession = hadPriorReleaseBaseline && !telemetryNoticeAlreadySeen; + if (telemetryNoticeNeedsSession) { + reserveLowPriorityNoticeSession('telemetry-update'); + } let checked = false; let telemetryNoticeChecked = false; - const loadNotes = async (currentVersion: string) => { + const loadNotes = async (currentVersion: string, noticeSlotReserved: boolean) => { try { const notes = await UpdatesAPI.getReleaseNotes(); // Mid-update the backend can briefly disagree with the UI about the @@ -89,7 +98,12 @@ export function WhatsNewCard() { } setChangelogHtml(renderMarkdown(changelog)); setVersion(currentVersion); - setVisible(true); + // The compact notice is the only automatic release UI. Record the + // version immediately so reloads cannot turn it into a recurring prompt. + markVersionSeen(currentVersion); + if (noticeSlotReserved) { + setNoticeVisible(true); + } } catch (error) { if ((error as { status?: number }).status === 404) { // No published release for this build — stop asking. @@ -124,7 +138,8 @@ export function WhatsNewCard() { return; } - void loadNotes(currentVersion); + const noticeSlotReserved = reserveLowPriorityNoticeSession('release-update'); + void loadNotes(currentVersion, noticeSlotReserved); }); createEffect(() => { @@ -149,10 +164,17 @@ export function WhatsNewCard() { setTelemetryNoticeVisible(true); }); - // Any close path (button, backdrop, Escape) counts as seen. - const dismiss = () => { - markVersionSeen(version()); - setVisible(false); + const dismissNotice = () => { + setNoticeVisible(false); + }; + + const openChangelog = () => { + setNoticeVisible(false); + setDialogVisible(true); + }; + + const dismissChangelog = () => { + setDialogVisible(false); }; const dismissTelemetryNotice = () => { @@ -212,10 +234,32 @@ export function WhatsNewCard() { - + + + + + @@ -250,7 +294,7 @@ export function WhatsNewCard() { Full release notes → - diff --git a/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx b/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx index 77ad5923e..f05626565 100644 --- a/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx +++ b/frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx @@ -1,12 +1,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState'; import gitHubStarBannerSource from '../GitHubStarBanner.tsx?raw'; -/* ------------------------------------------------------------------ */ -/* Mocks */ -/* ------------------------------------------------------------------ */ - type BannerResource = { id: string; name: string }; const wsState = vi.hoisted(() => ({ resources: [] as BannerResource[] })); @@ -23,43 +18,41 @@ vi.mock('@/utils/logger', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -/* ------------------------------------------------------------------ */ -/* Helpers */ -/* ------------------------------------------------------------------ */ - const DISMISSED_KEY = 'pulse-github-star-dismissed'; -const FIRST_SEEN_KEY = 'pulse-github-star-first-seen'; -const SNOOZED_KEY = 'pulse-github-star-snoozed-until'; +const PROMPT_SHOWN_KEY = 'pulse-github-star-prompt-shown'; +const ACTIVE_DAYS_KEY = 'pulse-github-star-active-days'; +const LAST_ACTIVE_DATE_KEY = 'pulse-github-star-last-active-date'; +const LOW_PRIORITY_NOTICE_OWNER_KEY = 'pulse-low-priority-notice-owner'; const LOCAL_DISMISS_BUTTON_CLASS = 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-hover hover:text-base-content'; const LOCAL_PRIMARY_BUTTON_CLASS = 'inline-flex min-h-9 items-center justify-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700'; -const LOCAL_DEFER_BUTTON_CLASS = - 'inline-flex min-h-9 items-center justify-center rounded-md px-3 py-2 text-sm text-muted transition-colors hover:bg-surface-hover hover:text-base-content'; async function renderBanner() { const mod = await import('../GitHubStarBanner'); render(() => ); } -/** Seed N fake websocket resources into runtime state. */ -function setResourceCount(n: number) { - wsState.resources = Array.from({ length: n }, (_, i) => ({ - id: `res-${i}`, - name: `Resource ${i}`, +function setResourceCount(count: number) { + wsState.resources = Array.from({ length: count }, (_, index) => ({ + id: `res-${index}`, + name: `Resource ${index}`, })); } -/* ------------------------------------------------------------------ */ -/* Tests */ -/* ------------------------------------------------------------------ */ +function seedEligibleEngagement() { + localStorage.setItem(ACTIVE_DAYS_KEY, '13'); + localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-13'); +} describe('GitHubStarBanner', () => { beforeEach(() => { vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-14T12:00:00Z')); wsState.resources = []; mockInitialDataReceived.mockReturnValue(true); localStorage.clear(); + sessionStorage.clear(); }); afterEach(() => { @@ -68,175 +61,107 @@ describe('GitHubStarBanner', () => { cleanup(); }); - /* ---------- Visibility: not shown scenarios ---------- */ - - it('does not render when there are no resources', async () => { - wsState.resources = []; - + it('stays quiet without connected infrastructure', async () => { await renderBanner(); - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); + expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument(); + expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('0'); }); - it('keeps the shell banner on websocket runtime state instead of reopening unified resources', () => { - expect(gitHubStarBannerSource).toContain('useWebSocket'); - expect(gitHubStarBannerSource).toContain('initialDataReceived'); - expect(gitHubStarBannerSource).not.toContain('useResources'); - }); - - it('renders as a non-modal prompt so Assistant remains available', () => { - expect(gitHubStarBannerSource).not.toContain(' { - expect(gitHubStarBannerSource).toContain('@/components/shared/Button'); - expect(gitHubStarBannerSource).toContain(' { - vi.setSystemTime(new Date('2026-03-01T12:00:00Z')); - setResourceCount(3); - - await renderBanner(); - - // Should not show prompt - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - - // Should have recorded today as first-seen date - expect(localStorage.getItem(FIRST_SEEN_KEY)).toBe('2026-03-01'); - }); - - it('does not render before websocket initial data is available', async () => { + it('waits for websocket initial data before recording engagement', async () => { mockInitialDataReceived.mockReturnValue(false); setResourceCount(3); await renderBanner(); - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - const stored = localStorage.getItem(FIRST_SEEN_KEY); - expect(stored === null || stored === '').toBe(true); + expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument(); + expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('0'); }); - it('does not render when first-seen date is today (same day)', async () => { - vi.setSystemTime(new Date('2026-03-01T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); + it('counts one distinct active day without showing the prompt', async () => { setResourceCount(3); await renderBanner(); - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); + await waitFor(() => expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('1')); + expect(localStorage.getItem(LAST_ACTIVE_DATE_KEY)).toBe('2026-03-14'); + expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument(); }); - it('does not render when permanently dismissed', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - localStorage.setItem(DISMISSED_KEY, 'true'); + it('does not count repeated sessions on the same day', async () => { + localStorage.setItem(ACTIVE_DAYS_KEY, '7'); + localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-14'); setResourceCount(3); await renderBanner(); - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); + expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('7'); + expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument(); }); - it('does not render when within snooze period', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // snoozed until the 10th - setResourceCount(3); - - await renderBanner(); - - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - }); - - /* ---------- Visibility: shown scenarios ---------- */ - - it('renders when returning user has infrastructure (different day than first seen)', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); + it('shows one compact prompt after fourteen distinct active days', async () => { + seedEligibleEngagement(); setResourceCount(3); await renderBanner(); await waitFor(() => { - expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument(); + expect(screen.getByText('Finding Pulse useful?')).toBeInTheDocument(); }); - }); - - it('renders when snooze period has expired', async () => { - vi.setSystemTime(new Date('2026-03-15T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // expired 5 days ago - setResourceCount(3); - - await renderBanner(); - - await waitFor(() => { - expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument(); - }); - }); - - /* ---------- Content ---------- */ - - it('displays the expected content when shown', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - setResourceCount(3); - - await renderBanner(); - - await waitFor(() => { - expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument(); - }); - expect(screen.getByText(/independent developer/)).toBeInTheDocument(); + expect( + screen.getByText('Starring the project on GitHub helps others discover it.'), + ).toBeInTheDocument(); expect(screen.getByText('Star on GitHub')).toBeInTheDocument(); - expect(screen.getByText('Maybe later')).toBeInTheDocument(); - expect(screen.getByLabelText("Close and don't show again")).toBeInTheDocument(); + expect(screen.queryByText('Maybe later')).not.toBeInTheDocument(); + expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('14'); + expect(localStorage.getItem(PROMPT_SHOWN_KEY)).toBe('true'); }); - /* ---------- Dismiss (X button) ---------- */ - - it('hides the prompt and persists dismissal when the X button is clicked', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); + it('never reopens after its single lifetime appearance', async () => { + localStorage.setItem(PROMPT_SHOWN_KEY, 'true'); + localStorage.setItem(ACTIVE_DAYS_KEY, '14'); + localStorage.setItem(LAST_ACTIVE_DATE_KEY, '2026-03-13'); setResourceCount(3); await renderBanner(); - await waitFor(() => { - expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument(); - }); + expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument(); + }); - const closeBtn = screen.getByLabelText("Close and don't show again"); - fireEvent.click(closeBtn); + it('stays quiet when another low-priority notice owns the session', async () => { + sessionStorage.setItem(LOW_PRIORITY_NOTICE_OWNER_KEY, 'release-update'); + seedEligibleEngagement(); + setResourceCount(3); + + await renderBanner(); + + await waitFor(() => expect(localStorage.getItem(ACTIVE_DAYS_KEY)).toBe('14')); + expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument(); + expect(localStorage.getItem(PROMPT_SHOWN_KEY)).not.toBe('true'); + }); + + it('permanently dismisses the prompt from the close action', async () => { + seedEligibleEngagement(); + setResourceCount(3); + + await renderBanner(); + await waitFor(() => expect(screen.getByText('Finding Pulse useful?')).toBeInTheDocument()); + + fireEvent.click(screen.getByLabelText("Close and don't show again")); await waitFor(() => { - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); + expect(screen.queryByText('Finding Pulse useful?')).not.toBeInTheDocument(); }); - expect(localStorage.getItem(DISMISSED_KEY)).toBe('true'); }); - /* ---------- Star on GitHub ---------- */ - - it('opens GitHub repo, dismisses permanently on star click', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); + it('opens GitHub and permanently dismisses on the star action', async () => { + seedEligibleEngagement(); setResourceCount(3); - const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); await renderBanner(); - - await waitFor(() => { - expect(screen.getByText('Star on GitHub')).toBeInTheDocument(); - }); + await waitFor(() => expect(screen.getByText('Star on GitHub')).toBeInTheDocument()); fireEvent.click(screen.getByText('Star on GitHub')); @@ -245,119 +170,26 @@ describe('GitHubStarBanner', () => { '_blank', 'noopener,noreferrer', ); - - await waitFor(() => { - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - }); - - // Permanent dismissal expect(localStorage.getItem(DISMISSED_KEY)).toBe('true'); }); - /* ---------- Maybe later (snooze) ---------- */ - - it('hides the prompt and sets a 7-day snooze when "Maybe later" is clicked', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - setResourceCount(3); - - await renderBanner(); - - await waitFor(() => { - expect(screen.getByText('Maybe later')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText('Maybe later')); - - await waitFor(() => { - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - }); - - // Should snooze for 7 days from today - expect(localStorage.getItem(SNOOZED_KEY)).toBe('2026-03-12'); - - // Should NOT permanently dismiss - expect(localStorage.getItem(DISMISSED_KEY)).not.toBe('true'); + it('uses runtime state and remains a non-blocking app-shell prompt', () => { + expect(gitHubStarBannerSource).toContain('useWebSocket'); + expect(gitHubStarBannerSource).toContain('initialDataReceived'); + expect(gitHubStarBannerSource).not.toContain('useResources'); + expect(gitHubStarBannerSource).not.toContain(' { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - setResourceCount(3); - - expect(dialogStackHasBlockingDialog()).toBe(false); - await renderBanner(); - - await waitFor(() => { - expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument(); - }); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(dialogStackHasBlockingDialog()).toBe(false); - - fireEvent.keyDown(document, { key: 'Escape' }); - - await waitFor(() => { - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - }); - expect(dialogStackHasBlockingDialog()).toBe(false); - expect(localStorage.getItem(SNOOZED_KEY)).toBe('2026-03-12'); - expect(localStorage.getItem(DISMISSED_KEY)).not.toBe('true'); - }); - - /* ---------- Edge cases ---------- */ - - it('renders on the exact snooze expiry date (snooze date == today)', async () => { - vi.setSystemTime(new Date('2026-03-10T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - localStorage.setItem(SNOOZED_KEY, '2026-03-10'); // today === snooze date, so today < snooze is false - setResourceCount(3); - - await renderBanner(); - - await waitFor(() => { - expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument(); - }); - }); - - it('does not write first-seen date when there are no resources', async () => { - vi.setSystemTime(new Date('2026-03-01T12:00:00Z')); - wsState.resources = []; - - await renderBanner(); - - // The localStorage signal initializes with '' and syncs it, but the - // component never sets it to a real date when there are no resources. - const stored = localStorage.getItem(FIRST_SEEN_KEY); - expect(stored === null || stored === '').toBe(true); - }); - - it('stays dismissed across re-renders after clicking X', async () => { - vi.setSystemTime(new Date('2026-03-05T12:00:00Z')); - localStorage.setItem(FIRST_SEEN_KEY, '2026-03-01'); - setResourceCount(3); - - await renderBanner(); - - await waitFor(() => { - expect(screen.getByText('Enjoying Pulse?')).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByLabelText("Close and don't show again")); - - await waitFor(() => { - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - }); - - // Verify localStorage has dismissed flag persisted - expect(localStorage.getItem(DISMISSED_KEY)).toBe('true'); - - // Clean up and re-render — should stay hidden due to persisted dismissal - cleanup(); - await renderBanner(); - - await waitFor(() => { - expect(screen.queryByText('Enjoying Pulse?')).not.toBeInTheDocument(); - }); + it('routes prompt actions through shared Button primitives', () => { + expect(gitHubStarBannerSource).toContain('@/components/shared/Button'); + expect(gitHubStarBannerSource).toContain(' { }); it('does not keep its own copy of the bar height', () => { - // This banner was one of five sites hardcoding 5rem for a bar that - // measures ~45px. The bar publishes its measured height instead. expect(gitHubStarBannerSource).not.toMatch(/5rem\s*\+\s*env\(safe-area-inset-bottom/); }); diff --git a/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx b/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx index a7081ce25..1048f85db 100644 --- a/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx +++ b/frontend-modern/src/components/__tests__/WhatsNewCard.test.tsx @@ -32,6 +32,7 @@ describe('WhatsNewCard', () => { getReleaseNotesMock.mockReset(); navigateMock.mockReset(); localStorage.clear(); + sessionStorage.clear(); }); afterEach(cleanup); @@ -126,8 +127,9 @@ describe('WhatsNewCard', () => { expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument(); }); - it('shows the categorized changelog after the running release changes', async () => { + it('announces the running release without opening a blocking dialog', async () => { localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5'); + localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2'); versionInfoMock.mockReturnValue({ version: '6.1.0-rc.1', isDevelopment: false, @@ -155,8 +157,18 @@ describe('WhatsNewCard', () => { await renderCard(); await waitFor(() => { - expect(screen.getByTestId('whats-new-modal')).toBeInTheDocument(); + expect(screen.getByTestId('whats-new-notice')).toBeInTheDocument(); }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByText('Pulse updated to v6.1.0-rc.1')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: "See what's new in Pulse 6.1.0-rc.1" }), + ).toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1'); + + fireEvent.click(screen.getByRole('button', { name: "See what's new in Pulse 6.1.0-rc.1" })); + + expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByText('Pulse v6.1.0-rc.1 changelog')).toBeInTheDocument(); expect(screen.getByText('Added')).toBeInTheDocument(); @@ -175,8 +187,9 @@ describe('WhatsNewCard', () => { ); }); - it('persists dismissal for the current release', async () => { + it('dismisses the compact notice without reopening it on reload', async () => { localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5'); + localStorage.setItem(STORAGE_KEYS.TELEMETRY_PAYLOAD_NOTICE_SEEN, '2'); versionInfoMock.mockReturnValue({ version: '6.1.0-rc.1', isDevelopment: false, @@ -190,14 +203,48 @@ describe('WhatsNewCard', () => { }); await renderCard(); - await waitFor(() => expect(screen.getByText('Got it')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('whats-new-notice')).toBeInTheDocument()); - fireEvent.click(screen.getByText('Got it')); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss update notice' })); await waitFor(() => { - expect(screen.queryByTestId('whats-new-modal')).not.toBeInTheDocument(); + expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument(); }); expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1'); + + cleanup(); + await renderCard(); + + expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('keeps the release notice quiet when the telemetry disclosure owns the session', async () => { + localStorage.setItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN, '6.0.5'); + versionInfoMock.mockReturnValue({ + version: '6.1.0-rc.1', + isDevelopment: false, + isSourceBuild: false, + }); + getReleaseNotesMock.mockResolvedValue({ + version: '6.1.0-rc.1', + releaseNotes: '## Improved\n- Faster resource updates.', + releaseDate: '2026-07-13T12:00:00Z', + isPrerelease: true, + }); + + await renderCard(); + + await waitFor(() => { + expect(screen.getByTestId('telemetry-payload-update-notice')).toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEYS.WHATS_NEW_LAST_SEEN)).toBe('6.1.0-rc.1'); + }); + expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss telemetry payload update' })); + + expect(screen.queryByTestId('telemetry-payload-update-notice')).not.toBeInTheDocument(); + expect(screen.queryByTestId('whats-new-notice')).not.toBeInTheDocument(); }); it('stays quiet when a release has only a highlights summary', async () => { diff --git a/frontend-modern/src/components/__tests__/whatsNewModel.test.ts b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts index 233d734f5..c95f2d10b 100644 --- a/frontend-modern/src/components/__tests__/whatsNewModel.test.ts +++ b/frontend-modern/src/components/__tests__/whatsNewModel.test.ts @@ -252,4 +252,12 @@ describe('post-update telemetry disclosure', () => { expect(whatsNewCardSource).toContain("openTelemetrySettings('disable')"); expect(whatsNewCardSource).toContain('PRIVACY_DOC_URL'); }); + + it('keeps release details opt-in and coordinates the automatic notice session', () => { + expect(whatsNewCardSource).toContain("reserveLowPriorityNoticeSession('release-update')"); + expect(whatsNewCardSource).toContain('markVersionSeen(currentVersion);'); + expect(whatsNewCardSource).toContain('actionLabel="See what\'s new"'); + expect(whatsNewCardSource).toContain('setDialogVisible(true)'); + expect(whatsNewCardSource).not.toContain('setDialogVisible(true);\n markVersionSeen'); + }); }); diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index d03ec02e6..e950907a0 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -5130,6 +5130,11 @@ describe('shared primitive guardrails', () => { expect(gitHubStarBannerSource).toContain('@/components/shared/Button'); expect(gitHubStarBannerSource).toContain(' { beforeEach(() => { localStorage.clear(); + sessionStorage.clear(); }); afterEach(() => { @@ -83,7 +86,23 @@ describe('localStorage signals', () => { it('exposes a broad set of keys across feature areas', () => { expect(Object.keys(STORAGE_KEYS).length).toBeGreaterThan(40); expect(STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY).toBe('workloadsSearchHistory'); - expect(STORAGE_KEYS.GITHUB_STAR_SNOOZED_UNTIL).toBe('pulse-github-star-snoozed-until'); + expect(STORAGE_KEYS.GITHUB_STAR_ACTIVE_DAYS).toBe('pulse-github-star-active-days'); + expect(STORAGE_KEYS.GITHUB_STAR_PROMPT_SHOWN).toBe('pulse-github-star-prompt-shown'); + }); + }); + + describe('low-priority notice sessions', () => { + it('allows one owner to reserve and reuse the session notice slot', () => { + expect(reserveLowPriorityNoticeSession('release-update')).toBe(true); + expect(reserveLowPriorityNoticeSession('release-update')).toBe(true); + expect(sessionStorage.getItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER)).toBe( + 'release-update', + ); + }); + + it('rejects a second low-priority notice owner in the same session', () => { + expect(reserveLowPriorityNoticeSession('telemetry-update')).toBe(true); + expect(reserveLowPriorityNoticeSession('github-star')).toBe(false); }); }); diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index 0a2a9592a..47f11b67f 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -138,6 +138,30 @@ export function createLocalStorageStringSignal( ); } +export type LowPriorityNoticeOwner = 'github-star' | 'release-update' | 'telemetry-update'; + +export const SESSION_STORAGE_KEYS = { + LOW_PRIORITY_NOTICE_OWNER: 'pulse-low-priority-notice-owner', +} as const; + +/** + * Reserve the single low-priority app-shell notice slot for this browser + * session. Informational and promotional prompts use this boundary so a user + * never has to clear a sequence of unrelated notices after opening Pulse. + */ +export function reserveLowPriorityNoticeSession(owner: LowPriorityNoticeOwner): boolean { + if (typeof sessionStorage === 'undefined') return true; + try { + const existingOwner = sessionStorage.getItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER); + if (existingOwner) return existingOwner === owner; + sessionStorage.setItem(SESSION_STORAGE_KEYS.LOW_PRIORITY_NOTICE_OWNER, owner); + return true; + } catch { + // Storage-disabled browsers still get the notice's own persistence rules. + return true; + } +} + /** * Creates a number signal that syncs with localStorage * @param key - The localStorage key @@ -208,8 +232,9 @@ export const STORAGE_KEYS = { // GitHub star prompt GITHUB_STAR_DISMISSED: 'pulse-github-star-dismissed', - GITHUB_STAR_FIRST_SEEN: 'pulse-github-star-first-seen', - GITHUB_STAR_SNOOZED_UNTIL: 'pulse-github-star-snoozed-until', + GITHUB_STAR_PROMPT_SHOWN: 'pulse-github-star-prompt-shown', + GITHUB_STAR_ACTIVE_DAYS: 'pulse-github-star-active-days', + GITHUB_STAR_LAST_ACTIVE_DATE: 'pulse-github-star-last-active-date', // Billing / migration guidance AGENT_MIGRATION_NOTICE_DISMISSED: 'pulse-agent-migration-notice-dismissed',