diff --git a/packages/app/src/components/dsv4-launch-modal.tsx b/packages/app/src/components/dsv4-launch-modal.tsx
deleted file mode 100644
index 80eed9f8..00000000
--- a/packages/app/src/components/dsv4-launch-modal.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-'use client';
-
-import { ArrowRight, Sparkles, X } from 'lucide-react';
-import { useCallback, useEffect, useState } from 'react';
-
-import { track } from '@/lib/analytics';
-import { isDsv4ModalDismissed, saveDsv4ModalDismissed } from '@/lib/dsv4-launch-storage';
-import { Button } from '@/components/ui/button';
-
-const PRESET_HREF = '/inference?preset=dsv4-launch';
-
-let sessionDismissed = false;
-
-export function shouldShowDsv4Modal(): boolean {
- if (sessionDismissed) return false;
- return !isDsv4ModalDismissed();
-}
-
-export function Dsv4LaunchModal() {
- const [open, setOpen] = useState(false);
-
- useEffect(() => {
- if (shouldShowDsv4Modal()) {
- setOpen(true);
- track('dsv4_modal_shown');
- }
- }, []);
-
- const dismiss = useCallback(() => {
- setOpen(false);
- sessionDismissed = true;
- saveDsv4ModalDismissed();
- }, []);
-
- const handleDismiss = useCallback(() => {
- dismiss();
- track('dsv4_modal_dismissed');
- }, [dismiss]);
-
- const handleExplore = useCallback(() => {
- track('dsv4_modal_explored');
- dismiss();
- // Hard navigation so `?preset=` is in the URL when InferenceContext mounts.
- window.location.href = PRESET_HREF;
- }, [dismiss]);
-
- if (!open) return null;
-
- return (
-
- );
-}
diff --git a/packages/app/src/components/eval-samples-nudge.tsx b/packages/app/src/components/eval-samples-nudge.tsx
deleted file mode 100644
index f7a142ae..00000000
--- a/packages/app/src/components/eval-samples-nudge.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-'use client';
-
-import { MessageSquareText } from 'lucide-react';
-import { useCallback, useEffect, useState } from 'react';
-
-import { BottomToast } from '@/components/ui/bottom-toast';
-import { track } from '@/lib/analytics';
-
-const DISMISS_KEY = 'inferencex-eval-samples-nudge-dismissed';
-const OPEN_EVENT = 'inferencex:eval-samples-opened';
-const SHOW_DELAY_MS = 1500;
-/** Re-show the nudge after a week so returning users see it again. Mirrors the GitHub-star modal's cadence. */
-const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
-
-function shouldShow(): boolean {
- try {
- const value = localStorage.getItem(DISMISS_KEY);
- if (!value) return true;
- const dismissedAt = Number(value);
- if (Number.isNaN(dismissedAt)) return true;
- return Date.now() - dismissedAt >= DISMISS_DURATION_MS;
- } catch {
- return false;
- }
-}
-
-function markShown(): void {
- try {
- localStorage.setItem(DISMISS_KEY, String(Date.now()));
- } catch {
- // localStorage unavailable — fail silently.
- }
-}
-
-/**
- * Periodic nudge that points users at the per-sample drawer on the evaluation
- * tab. localStorage stores a dismissal timestamp; the nudge re-shows after one
- * week so returning users notice the feature again. The drawer-opens event
- * also marks the nudge dismissed (if the user finds the affordance on their own).
- */
-export function EvalSamplesNudge() {
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- if (!shouldShow()) return;
-
- const timer = window.setTimeout(() => {
- if (!shouldShow()) return;
- markShown();
- setVisible(true);
- track('evaluation_samples_nudge_shown');
- }, SHOW_DELAY_MS);
-
- const handleOpened = () => {
- window.clearTimeout(timer);
- markShown();
- setVisible(false);
- };
- window.addEventListener(OPEN_EVENT, handleOpened);
- return () => {
- window.clearTimeout(timer);
- window.removeEventListener(OPEN_EVENT, handleOpened);
- };
- }, []);
-
- const handleDismiss = useCallback(() => {
- track('evaluation_samples_nudge_dismissed');
- }, []);
-
- if (!visible) return null;
-
- return (
-
}
- title="See the model's actual answers"
- description="Click Prompts on any row to compare each prompt, the expected answer, and what the model actually responded."
- onDismiss={handleDismiss}
- />
- );
-}
diff --git a/packages/app/src/components/export-nudge.test.ts b/packages/app/src/components/export-nudge.test.ts
deleted file mode 100644
index ab3e96e2..00000000
--- a/packages/app/src/components/export-nudge.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-
-import {
- COPY_THRESHOLD,
- SESSION_KEY,
- saveExportNudgeShown,
- shouldShowExportNudge,
-} from '@/components/export-nudge';
-
-describe('ExportNudge constants', () => {
- it('uses the expected sessionStorage key', () => {
- expect(SESSION_KEY).toBe('inferencex-export-nudge-shown');
- });
-
- it('requires 2 copies before showing', () => {
- expect(COPY_THRESHOLD).toBe(2);
- });
-});
-
-describe('shouldShowExportNudge', () => {
- const mockStorage = new Map
();
-
- beforeEach(() => {
- mockStorage.clear();
- vi.stubGlobal('sessionStorage', {
- getItem: (key: string) => mockStorage.get(key) ?? null,
- setItem: (key: string, value: string) => mockStorage.set(key, value),
- removeItem: (key: string) => mockStorage.delete(key),
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
- });
-
- it('returns true when no session value is stored', () => {
- expect(shouldShowExportNudge()).toBe(true);
- });
-
- it('returns false when nudge was already shown this session', () => {
- mockStorage.set(SESSION_KEY, '1');
- expect(shouldShowExportNudge()).toBe(false);
- });
-
- it('returns false when sessionStorage throws (fails closed)', () => {
- vi.stubGlobal('sessionStorage', {
- getItem: () => {
- throw new Error('SecurityError');
- },
- setItem: () => {
- throw new Error('SecurityError');
- },
- });
- expect(shouldShowExportNudge()).toBe(false);
- });
-});
-
-describe('saveExportNudgeShown', () => {
- const mockStorage = new Map();
-
- beforeEach(() => {
- mockStorage.clear();
- vi.stubGlobal('sessionStorage', {
- getItem: (key: string) => mockStorage.get(key) ?? null,
- setItem: (key: string, value: string) => mockStorage.set(key, value),
- removeItem: (key: string) => mockStorage.delete(key),
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
- });
-
- it('stores a value in sessionStorage', () => {
- saveExportNudgeShown();
- expect(mockStorage.get(SESSION_KEY)).toBe('1');
- });
-
- it('makes shouldShowExportNudge return false after saving', () => {
- saveExportNudgeShown();
- expect(shouldShowExportNudge()).toBe(false);
- });
-
- it('does not throw when sessionStorage is unavailable', () => {
- vi.stubGlobal('sessionStorage', {
- getItem: () => {
- throw new Error('SecurityError');
- },
- setItem: () => {
- throw new Error('SecurityError');
- },
- });
- expect(() => saveExportNudgeShown()).not.toThrow();
- });
-});
diff --git a/packages/app/src/components/export-nudge.tsx b/packages/app/src/components/export-nudge.tsx
deleted file mode 100644
index 73e2f925..00000000
--- a/packages/app/src/components/export-nudge.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-'use client';
-
-import { track } from '@/lib/analytics';
-import { Download } from 'lucide-react';
-import { useCallback, useEffect, useRef, useState } from 'react';
-
-import { BottomToast } from '@/components/ui/bottom-toast';
-
-export const SESSION_KEY = 'inferencex-export-nudge-shown';
-export const COPY_THRESHOLD = 2;
-
-/** Check if the export nudge should show (fails closed if sessionStorage is unavailable). */
-export function shouldShowExportNudge(): boolean {
- try {
- return !sessionStorage.getItem(SESSION_KEY);
- } catch {
- return false;
- }
-}
-
-/** Persist that the nudge was shown this session. */
-export function saveExportNudgeShown(): void {
- try {
- sessionStorage.setItem(SESSION_KEY, '1');
- } catch {
- // ignore — nudge still shows this mount but won't persist
- }
-}
-
-export function ExportNudge() {
- const [visible, setVisible] = useState(false);
- const copyCount = useRef(0);
- const hasShown = useRef(false);
-
- const showNudge = useCallback(() => {
- if (hasShown.current) return;
- if (!shouldShowExportNudge()) return;
- hasShown.current = true;
- saveExportNudgeShown();
- setVisible(true);
- track('export_nudge_shown');
- }, []);
-
- useEffect(() => {
- const handleCopy = (e: ClipboardEvent) => {
- const target = e.target as HTMLElement | null;
- if (!target) return;
- const isTooltip = target.closest('[data-chart-tooltip]');
- if (!isTooltip) return;
-
- copyCount.current += 1;
- if (copyCount.current >= COPY_THRESHOLD) {
- showNudge();
- }
- };
-
- document.addEventListener('copy', handleCopy);
- return () => document.removeEventListener('copy', handleCopy);
- }, [showNudge]);
-
- if (!visible) return null;
-
- return (
- }
- title="Need the data?"
- description="Use the download button on any chart to export as PNG or CSV — no need to copy from tooltips."
- onDismiss={() => track('export_nudge_dismissed')}
- />
- );
-}
diff --git a/packages/app/src/components/github-star-modal.tsx b/packages/app/src/components/github-star-modal.tsx
deleted file mode 100644
index 9d281354..00000000
--- a/packages/app/src/components/github-star-modal.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-'use client';
-
-import { track } from '@/lib/analytics';
-import { shouldShowDsv4Modal } from '@/components/dsv4-launch-modal';
-import {
- DISMISS_DURATION_MS,
- DISMISS_KEY,
- STARRED_EVENT,
- STARRED_KEY,
- saveDismissTimestamp,
- saveStarred,
-} from '@/lib/star-storage';
-import { Star, X } from 'lucide-react';
-import { useCallback, useEffect, useState } from 'react';
-
-import { GITHUB_OWNER, GITHUB_REPO } from '@semianalysisai/inferencex-constants';
-import { Button } from '@/components/ui/button';
-
-const GITHUB_REPO_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`;
-
-let sessionDismissed = false;
-
-function shouldShowModal(): boolean {
- if (sessionDismissed) return false;
- // Defer to the dsv4 launch modal until the user has resolved it — only one
- // modal at a time, and the launch modal is more time-sensitive.
- if (shouldShowDsv4Modal()) return false;
- try {
- if (localStorage.getItem(STARRED_KEY)) return false;
- const value = localStorage.getItem(DISMISS_KEY);
- if (!value) return true;
- const dismissedAt = Number(value);
- if (Number.isNaN(dismissedAt)) return true;
- return Date.now() - dismissedAt >= DISMISS_DURATION_MS;
- } catch {
- return false;
- }
-}
-
-export function GitHubStarModal() {
- const [open, setOpen] = useState(false);
- const [ready, setReady] = useState(false);
-
- useEffect(() => {
- if (shouldShowModal()) {
- setOpen(true);
- track('star_modal_shown');
- }
- setReady(true);
- }, []);
-
- useEffect(() => {
- const handleStarred = () => {
- setOpen(false);
- sessionDismissed = true;
- };
- window.addEventListener(STARRED_EVENT, handleStarred);
- return () => window.removeEventListener(STARRED_EVENT, handleStarred);
- }, []);
-
- const handleDismiss = useCallback(() => {
- setOpen(false);
- sessionDismissed = true;
- saveDismissTimestamp();
- track('star_modal_dismissed');
- }, []);
-
- const handleStar = useCallback(() => {
- window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer');
- setOpen(false);
- sessionDismissed = true;
- saveStarred();
- track('star_modal_starred');
- }, []);
-
- return (
- <>
- {ready && }
- {open && (
-
- )}
- >
- );
-}
diff --git a/packages/app/src/components/gradient-label-nudge.test.ts b/packages/app/src/components/gradient-label-nudge.test.ts
deleted file mode 100644
index 0202f2e6..00000000
--- a/packages/app/src/components/gradient-label-nudge.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-
-import {
- GRADIENT_NUDGE_EVENT,
- SESSION_KEY,
- saveGradientNudgeShown,
- shouldShowGradientNudge,
-} from '@/components/gradient-label-nudge';
-
-describe('GradientLabelNudge constants', () => {
- it('uses the expected sessionStorage key', () => {
- expect(SESSION_KEY).toBe('inferencex-gradient-nudge-shown');
- });
-
- it('uses the expected custom event name', () => {
- expect(GRADIENT_NUDGE_EVENT).toBe('inferencex:parallelism-label-enabled');
- });
-});
-
-describe('shouldShowGradientNudge', () => {
- const mockStorage = new Map();
-
- beforeEach(() => {
- mockStorage.clear();
- vi.stubGlobal('sessionStorage', {
- getItem: (key: string) => mockStorage.get(key) ?? null,
- setItem: (key: string, value: string) => mockStorage.set(key, value),
- removeItem: (key: string) => mockStorage.delete(key),
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
- });
-
- it('returns true when no session value is stored', () => {
- expect(shouldShowGradientNudge()).toBe(true);
- });
-
- it('returns false when nudge was already shown this session', () => {
- mockStorage.set(SESSION_KEY, '1');
- expect(shouldShowGradientNudge()).toBe(false);
- });
-
- it('returns false when sessionStorage throws (fails closed)', () => {
- vi.stubGlobal('sessionStorage', {
- getItem: () => {
- throw new Error('SecurityError');
- },
- setItem: () => {
- throw new Error('SecurityError');
- },
- });
- expect(shouldShowGradientNudge()).toBe(false);
- });
-});
-
-describe('saveGradientNudgeShown', () => {
- const mockStorage = new Map();
-
- beforeEach(() => {
- mockStorage.clear();
- vi.stubGlobal('sessionStorage', {
- getItem: (key: string) => mockStorage.get(key) ?? null,
- setItem: (key: string, value: string) => mockStorage.set(key, value),
- removeItem: (key: string) => mockStorage.delete(key),
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
- });
-
- it('stores a value in sessionStorage', () => {
- saveGradientNudgeShown();
- expect(mockStorage.get(SESSION_KEY)).toBe('1');
- });
-
- it('makes shouldShowGradientNudge return false after saving', () => {
- saveGradientNudgeShown();
- expect(shouldShowGradientNudge()).toBe(false);
- });
-
- it('does not throw when sessionStorage is unavailable', () => {
- vi.stubGlobal('sessionStorage', {
- getItem: () => {
- throw new Error('SecurityError');
- },
- setItem: () => {
- throw new Error('SecurityError');
- },
- });
- expect(() => saveGradientNudgeShown()).not.toThrow();
- });
-});
diff --git a/packages/app/src/components/gradient-label-nudge.tsx b/packages/app/src/components/gradient-label-nudge.tsx
deleted file mode 100644
index 7175b5f7..00000000
--- a/packages/app/src/components/gradient-label-nudge.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-'use client';
-
-import { track } from '@/lib/analytics';
-import { Palette } from 'lucide-react';
-import { useCallback, useEffect, useRef, useState } from 'react';
-
-import { BottomToast } from '@/components/ui/bottom-toast';
-
-export const GRADIENT_NUDGE_EVENT = 'inferencex:parallelism-label-enabled';
-export const SESSION_KEY = 'inferencex-gradient-nudge-shown';
-
-export function shouldShowGradientNudge(): boolean {
- try {
- return !sessionStorage.getItem(SESSION_KEY);
- } catch {
- return false;
- }
-}
-
-export function saveGradientNudgeShown(): void {
- try {
- sessionStorage.setItem(SESSION_KEY, '1');
- } catch {
- // ignore
- }
-}
-
-export function GradientLabelNudge() {
- const [visible, setVisible] = useState(false);
- const hasShown = useRef(false);
- const enableGradientRef = useRef<(() => void) | null>(null);
-
- const showNudge = useCallback(() => {
- if (hasShown.current) return;
- if (!shouldShowGradientNudge()) return;
- hasShown.current = true;
- saveGradientNudgeShown();
- setVisible(true);
- track('gradient_nudge_shown');
- }, []);
-
- useEffect(() => {
- const handleEvent = (e: Event) => {
- const detail = (e as CustomEvent).detail;
- if (detail?.enableGradient) {
- enableGradientRef.current = detail.enableGradient;
- }
- showNudge();
- };
-
- window.addEventListener(GRADIENT_NUDGE_EVENT, handleEvent);
- return () => window.removeEventListener(GRADIENT_NUDGE_EVENT, handleEvent);
- }, [showNudge]);
-
- const handleTryGradient = useCallback(() => {
- enableGradientRef.current?.();
- track('gradient_nudge_accepted');
- }, []);
-
- if (!visible) return null;
-
- return (
- }
- title="Try Gradient Labels"
- description="Gradient labels color-code data points by parallelism level, making it easier to spot performance patterns at a glance."
- action={{
- label: 'Enable Gradient Labels',
- onClick: handleTryGradient,
- }}
- onDismiss={() => track('gradient_nudge_dismissed')}
- />
- );
-}
diff --git a/packages/app/src/components/inference/ui/ScatterGraph.tsx b/packages/app/src/components/inference/ui/ScatterGraph.tsx
index 9cb8414b..7682ca8e 100644
--- a/packages/app/src/components/inference/ui/ScatterGraph.tsx
+++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx
@@ -4,7 +4,7 @@ import { track } from '@/lib/analytics';
import * as d3 from 'd3';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
-import { GRADIENT_NUDGE_EVENT } from '@/components/gradient-label-nudge';
+import { GRADIENT_NUDGE_EVENT } from '@/lib/nudges/registry';
import { useInference } from '@/components/inference/InferenceContext';
import ChartLegend from '@/components/ui/chart-legend';
import { useUnofficialRun } from '@/components/unofficial-run-provider';
diff --git a/packages/app/src/components/landing/landing-page.tsx b/packages/app/src/components/landing/landing-page.tsx
index 6c390779..f8cde14d 100644
--- a/packages/app/src/components/landing/landing-page.tsx
+++ b/packages/app/src/components/landing/landing-page.tsx
@@ -6,11 +6,9 @@ import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { Card } from '@/components/ui/card';
-import { Dsv4LaunchModal } from '@/components/dsv4-launch-modal';
-import { GitHubStarModal } from '@/components/github-star-modal';
import { IntroSection } from '@/components/intro-section';
import { CuratedViewCard } from '@/components/landing/curated-view-card';
-import { LaunchBanner } from '@/components/landing/launch-banner';
+import { NudgeEngine } from '@/components/nudge-engine';
import { FAVORITE_PRESETS } from '@/components/favorites/favorite-presets';
import { track } from '@/lib/analytics';
import { navigateInApp } from '@/lib/client-navigation';
@@ -25,10 +23,8 @@ export function LandingPage() {
return (
-
-
+
-
{/* Split: Dashboard vs Presets */}
diff --git a/packages/app/src/components/landing/launch-banner.tsx b/packages/app/src/components/landing/launch-banner.tsx
deleted file mode 100644
index 65a970c1..00000000
--- a/packages/app/src/components/landing/launch-banner.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-'use client';
-
-import { ArrowRight, Sparkles, X } from 'lucide-react';
-import { useCallback, useEffect, useState } from 'react';
-
-import { track } from '@/lib/analytics';
-
-const DISMISS_KEY = 'inferencex-dsv4-banner-dismissed';
-const BANNER_ID = 'dsv4-launch';
-const PRESET_ID = 'dsv4-launch';
-
-function isDismissed(): boolean {
- try {
- return localStorage.getItem(DISMISS_KEY) === BANNER_ID;
- } catch {
- return false;
- }
-}
-
-export function LaunchBanner() {
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- if (!isDismissed()) {
- setVisible(true);
- track('launch_banner_shown', { banner_id: BANNER_ID });
- }
- }, []);
-
- const handleDismiss = useCallback((e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation();
- try {
- localStorage.setItem(DISMISS_KEY, BANNER_ID);
- } catch {
- // localStorage unavailable
- }
- setVisible(false);
- track('launch_banner_dismissed', { banner_id: BANNER_ID });
- }, []);
-
- if (!visible) return null;
-
- const href = `/inference?preset=${PRESET_ID}`;
-
- const handleClick = (e: React.MouseEvent
) => {
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
- e.preventDefault();
- track('launch_banner_clicked', { banner_id: BANNER_ID, preset_id: PRESET_ID });
- // Hard navigation so the `?preset=` param is guaranteed to be in the URL
- // when InferenceContext first mounts and reads window.location.search.
- window.location.href = href;
- };
-
- return (
-
- );
-}
diff --git a/packages/app/src/components/nudge-engine.tsx b/packages/app/src/components/nudge-engine.tsx
new file mode 100644
index 00000000..4bf98cdc
--- /dev/null
+++ b/packages/app/src/components/nudge-engine.tsx
@@ -0,0 +1,468 @@
+'use client';
+
+import { ArrowRight, X } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import { track } from '@/lib/analytics';
+import {
+ isDismissed,
+ isPermanentlySuppressed,
+ isWithinSchedule,
+ markDismissed,
+} from '@/lib/nudges/persistence';
+import { NUDGE_REGISTRY } from '@/lib/nudges/registry';
+import type { NudgeDefinition, NudgeTrigger } from '@/lib/nudges/types';
+import { BottomToast } from '@/components/ui/bottom-toast';
+import { Button } from '@/components/ui/button';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function trackNudgeEvent(def: NudgeDefinition, event: 'shown' | 'dismissed' | 'action'): void {
+ const name = def.analytics?.[event] ?? `${def.id}_${event}`;
+ track(name, def.analytics?.properties);
+}
+
+function isEligible(def: NudgeDefinition): boolean {
+ if (!isWithinSchedule(def.schedule)) return false;
+ if (def.permanentSuppressKey && isPermanentlySuppressed(def.permanentSuppressKey)) return false;
+ if (isDismissed(def.storageKey, def.dismissal)) return false;
+ if (def.conditions?.some((c) => !c.check())) return false;
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// NudgeEngine
+// ---------------------------------------------------------------------------
+
+interface NudgeEngineProps {
+ scope: 'dashboard' | 'landing' | 'evaluation';
+}
+
+/**
+ * Two independent slots:
+ * - **banner** — inline content (one at a time)
+ * - **overlay** — toasts and modals (one at a time)
+ *
+ * A banner and an overlay can be visible simultaneously because they
+ * occupy different visual layers.
+ */
+export function NudgeEngine({ scope }: NudgeEngineProps) {
+ const scopeNudges = useMemo(() => NUDGE_REGISTRY.filter((n) => n.scope === scope), [scope]);
+
+ const [activeBannerId, setActiveBannerId] = useState(null);
+ const [activeOverlayId, setActiveOverlayId] = useState(null);
+ const bannerShownRef = useRef(false);
+ const overlayShownRef = useRef(false);
+
+ const triggerCountsRef = useRef>({});
+ const eventDetailRef = useRef | null>(null);
+ const sessionDismissedRef = useRef>(new Set());
+
+ const activeBanner = activeBannerId ? scopeNudges.find((n) => n.id === activeBannerId) : null;
+ const activeOverlay = activeOverlayId ? scopeNudges.find((n) => n.id === activeOverlayId) : null;
+
+ const showNudge = useCallback((def: NudgeDefinition) => {
+ if (sessionDismissedRef.current.has(def.id)) return;
+ if (!isEligible(def)) return;
+
+ if (def.type === 'banner') {
+ if (bannerShownRef.current) return;
+ bannerShownRef.current = true;
+ markDismissed(def.storageKey, def.dismissal);
+ setActiveBannerId(def.id);
+ } else {
+ if (overlayShownRef.current) return;
+ overlayShownRef.current = true;
+ markDismissed(def.storageKey, def.dismissal);
+ setActiveOverlayId(def.id);
+ }
+ trackNudgeEvent(def, 'shown');
+ }, []);
+
+ const dismissBanner = useCallback(() => {
+ if (!activeBanner) return;
+ trackNudgeEvent(activeBanner, 'dismissed');
+ sessionDismissedRef.current.add(activeBanner.id);
+ setActiveBannerId(null);
+ bannerShownRef.current = false;
+ }, [activeBanner]);
+
+ const dismissOverlay = useCallback(() => {
+ if (!activeOverlay) return;
+ trackNudgeEvent(activeOverlay, 'dismissed');
+ sessionDismissedRef.current.add(activeOverlay.id);
+ setActiveOverlayId(null);
+ overlayShownRef.current = false;
+ }, [activeOverlay]);
+
+ const handleBannerAction = useCallback(() => {
+ if (!activeBanner) return;
+ trackNudgeEvent(activeBanner, 'action');
+ activeBanner.content.onLinkClick?.();
+ sessionDismissedRef.current.add(activeBanner.id);
+ setActiveBannerId(null);
+ bannerShownRef.current = false;
+ }, [activeBanner]);
+
+ const handleOverlayAction = useCallback(() => {
+ if (!activeOverlay) return;
+ trackNudgeEvent(activeOverlay, 'action');
+ const detail = eventDetailRef.current ?? undefined;
+
+ if (activeOverlay.type === 'toast') {
+ activeOverlay.content.action?.onClick(detail);
+ } else if (activeOverlay.type === 'modal') {
+ activeOverlay.content.primaryAction?.onClick(detail);
+ }
+
+ sessionDismissedRef.current.add(activeOverlay.id);
+ setActiveOverlayId(null);
+ overlayShownRef.current = false;
+ }, [activeOverlay]);
+
+ // -------------------------------------------------------------------------
+ // Trigger setup
+ // -------------------------------------------------------------------------
+
+ useEffect(() => {
+ const cleanups: (() => void)[] = [];
+ const sorted = [...scopeNudges].toSorted((a, b) => b.priority - a.priority);
+
+ for (const def of sorted) {
+ if (!isEligible(def)) continue;
+ if (sessionDismissedRef.current.has(def.id)) continue;
+
+ const triggers = Array.isArray(def.trigger) ? def.trigger : [def.trigger];
+
+ for (const trigger of triggers) {
+ const cleanup = setupTrigger(trigger, def, showNudge, triggerCountsRef, eventDetailRef);
+ if (cleanup) cleanups.push(cleanup);
+ }
+ }
+
+ return () => {
+ for (const fn of cleanups) fn();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeBannerId, activeOverlayId, scope]);
+
+ // -------------------------------------------------------------------------
+ // Permanent suppress event listener
+ // -------------------------------------------------------------------------
+
+ useEffect(() => {
+ const handlers: [string, () => void][] = [];
+
+ for (const def of scopeNudges) {
+ if (!def.permanentSuppressEvent) continue;
+
+ const handler = () => {
+ if (def.type === 'banner' && activeBannerId === def.id) {
+ setActiveBannerId(null);
+ bannerShownRef.current = false;
+ } else if (activeOverlayId === def.id) {
+ setActiveOverlayId(null);
+ overlayShownRef.current = false;
+ }
+ sessionDismissedRef.current.add(def.id);
+ if (def.permanentSuppressKey) {
+ try {
+ localStorage.setItem(def.permanentSuppressKey, '1');
+ } catch {
+ // Storage unavailable.
+ }
+ }
+ };
+ window.addEventListener(def.permanentSuppressEvent, handler);
+ handlers.push([def.permanentSuppressEvent, handler]);
+ }
+
+ return () => {
+ for (const [event, handler] of handlers) {
+ window.removeEventListener(event, handler);
+ }
+ };
+ }, [activeBannerId, activeOverlayId, scopeNudges]);
+
+ // -------------------------------------------------------------------------
+ // Render — banner and overlay can coexist
+ // -------------------------------------------------------------------------
+
+ return (
+ <>
+ {activeBanner && (
+
+ )}
+ {activeOverlay?.type === 'toast' && (
+
+ )}
+ {activeOverlay?.type === 'modal' && (
+
+ )}
+ >
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Trigger setup — returns a cleanup function
+// ---------------------------------------------------------------------------
+
+function setupTrigger(
+ trigger: NudgeTrigger,
+ def: NudgeDefinition,
+ showNudge: (def: NudgeDefinition) => void,
+ countsRef: React.RefObject>,
+ eventDetailRef: React.RefObject | null>,
+): (() => void) | null {
+ const triggerKey = `${def.id}:${trigger.type}:${'event' in trigger ? trigger.event : ''}`;
+
+ if (trigger.type === 'immediate') {
+ showNudge(def);
+ return null;
+ }
+
+ if (trigger.type === 'timer') {
+ const timer = window.setTimeout(() => showNudge(def), trigger.delayMs);
+ return () => window.clearTimeout(timer);
+ }
+
+ if (trigger.type === 'event') {
+ const threshold = trigger.threshold ?? 1;
+ const delayTimers = new Set();
+ const handler = (e: Event) => {
+ if (!countsRef.current) return;
+ countsRef.current[triggerKey] = (countsRef.current[triggerKey] ?? 0) + 1;
+ if (e instanceof CustomEvent && e.detail) {
+ eventDetailRef.current = e.detail;
+ }
+ if (countsRef.current[triggerKey] >= threshold) {
+ if (trigger.delayMs) {
+ const t = window.setTimeout(() => showNudge(def), trigger.delayMs);
+ delayTimers.add(t);
+ } else {
+ showNudge(def);
+ }
+ }
+ };
+ window.addEventListener(trigger.event, handler);
+ return () => {
+ window.removeEventListener(trigger.event, handler);
+ for (const t of delayTimers) window.clearTimeout(t);
+ };
+ }
+
+ if (trigger.type === 'dom-event') {
+ const threshold = trigger.threshold ?? 1;
+ const delayTimers = new Set();
+ const handler = (e: Event) => {
+ if (trigger.selector) {
+ const target = e.target as HTMLElement | null;
+ if (!target?.closest(trigger.selector)) return;
+ }
+ if (!countsRef.current) return;
+ countsRef.current[triggerKey] = (countsRef.current[triggerKey] ?? 0) + 1;
+ if (countsRef.current[triggerKey] >= threshold) {
+ if (trigger.delayMs) {
+ const t = window.setTimeout(() => showNudge(def), trigger.delayMs);
+ delayTimers.add(t);
+ } else {
+ showNudge(def);
+ }
+ }
+ };
+ document.addEventListener(trigger.event, handler);
+ return () => {
+ document.removeEventListener(trigger.event, handler);
+ for (const t of delayTimers) window.clearTimeout(t);
+ };
+ }
+
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Renderers
+// ---------------------------------------------------------------------------
+
+function ToastRenderer({
+ def,
+ onDismiss,
+ onAction,
+}: {
+ def: NudgeDefinition;
+ onDismiss: () => void;
+ onAction: () => void;
+}) {
+ const { content } = def;
+ const Icon = content.icon;
+ return (
+ }
+ title={content.title}
+ description={content.description}
+ action={
+ content.action
+ ? {
+ label: content.action.label,
+ icon: content.action.icon,
+ onClick: onAction,
+ }
+ : undefined
+ }
+ onDismiss={onDismiss}
+ />
+ );
+}
+
+function ModalRenderer({
+ def,
+ onDismiss,
+ onAction,
+}: {
+ def: NudgeDefinition;
+ onDismiss: () => void;
+ onAction: () => void;
+}) {
+ const { content } = def;
+ const Icon = content.icon;
+ const idPrefix = def.id;
+
+ return (
+
+ );
+}
+
+function BannerRenderer({
+ def,
+ onDismiss,
+ onAction,
+}: {
+ def: NudgeDefinition;
+ onDismiss: () => void;
+ onAction: () => void;
+}) {
+ const { content } = def;
+ const Icon = content.icon;
+
+ const handleClick = (e: React.MouseEvent) => {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
+ e.preventDefault();
+ onAction();
+ };
+
+ const handleDismiss = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onDismiss();
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/reproducibility-nudge.tsx b/packages/app/src/components/reproducibility-nudge.tsx
deleted file mode 100644
index 4d32a93b..00000000
--- a/packages/app/src/components/reproducibility-nudge.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-'use client';
-
-import { ShieldCheck } from 'lucide-react';
-import { useRouter } from 'next/navigation';
-import { useCallback, useEffect, useState } from 'react';
-
-import { BottomToast } from '@/components/ui/bottom-toast';
-import { track } from '@/lib/analytics';
-
-const SESSION_KEY = 'inferencex-reproducibility-nudge-shown';
-const SHOW_DELAY_MS = 1500;
-const ABOUT_URL = '/about#reproducibility';
-
-function shouldShow(): boolean {
- try {
- return !sessionStorage.getItem(SESSION_KEY);
- } catch {
- return false;
- }
-}
-
-function markShown(): void {
- try {
- sessionStorage.setItem(SESSION_KEY, '1');
- } catch {
- // ignore
- }
-}
-
-export function ReproducibilityNudge() {
- const router = useRouter();
- const [visible, setVisible] = useState(false);
-
- useEffect(() => {
- if (!shouldShow()) return;
- const timer = window.setTimeout(() => {
- markShown();
- setVisible(true);
- track('reproducibility_nudge_shown');
- }, SHOW_DELAY_MS);
- return () => window.clearTimeout(timer);
- }, []);
-
- const handleAction = useCallback(() => {
- track('reproducibility_nudge_see_how_clicked');
- router.push(ABOUT_URL);
- }, [router]);
-
- if (!visible) return null;
-
- return (
- }
- title="Every result is reproducible"
- description="Each data point is produced by a public GitHub Actions run. Click any point on a chart to jump to the exact run, logs, and artifacts."
- action={{
- label: 'See how',
- onClick: handleAction,
- }}
- onDismiss={() => track('reproducibility_nudge_dismissed')}
- />
- );
-}
diff --git a/packages/app/src/components/star-nudge.test.ts b/packages/app/src/components/star-nudge.test.ts
deleted file mode 100644
index 36e605dc..00000000
--- a/packages/app/src/components/star-nudge.test.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
-
-import { NUDGE_SESSION_KEY, saveNudgeShown, shouldShowNudge } from '@/components/star-nudge';
-
-describe('StarNudge constants', () => {
- it('uses the expected sessionStorage key', () => {
- expect(NUDGE_SESSION_KEY).toBe('inferencex-star-nudge-shown');
- });
-});
-
-describe('shouldShowNudge', () => {
- const mockSession = new Map();
- const mockLocal = new Map();
-
- beforeEach(() => {
- mockSession.clear();
- mockLocal.clear();
- vi.stubGlobal('sessionStorage', {
- getItem: (key: string) => mockSession.get(key) ?? null,
- setItem: (key: string, value: string) => mockSession.set(key, value),
- removeItem: (key: string) => mockSession.delete(key),
- });
- vi.stubGlobal('localStorage', {
- getItem: (key: string) => mockLocal.get(key) ?? null,
- setItem: (key: string, value: string) => mockLocal.set(key, value),
- removeItem: (key: string) => mockLocal.delete(key),
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
- });
-
- it('returns true when no session value is stored', () => {
- expect(shouldShowNudge()).toBe(true);
- });
-
- it('returns false when nudge was already shown this session', () => {
- mockSession.set(NUDGE_SESSION_KEY, '1');
- expect(shouldShowNudge()).toBe(false);
- });
-
- it('returns false when user has already starred', () => {
- mockLocal.set('inferencex-starred', '1');
- expect(shouldShowNudge()).toBe(false);
- });
-
- it('returns false when storage throws', () => {
- vi.stubGlobal('localStorage', {
- getItem: () => {
- throw new Error('SecurityError');
- },
- });
- expect(shouldShowNudge()).toBe(false);
- });
-});
-
-describe('saveNudgeShown', () => {
- const mockSession = new Map();
- const mockLocal = new Map();
-
- beforeEach(() => {
- mockSession.clear();
- mockLocal.clear();
- vi.stubGlobal('sessionStorage', {
- getItem: (key: string) => mockSession.get(key) ?? null,
- setItem: (key: string, value: string) => mockSession.set(key, value),
- removeItem: (key: string) => mockSession.delete(key),
- });
- vi.stubGlobal('localStorage', {
- getItem: (key: string) => mockLocal.get(key) ?? null,
- setItem: (key: string, value: string) => mockLocal.set(key, value),
- removeItem: (key: string) => mockLocal.delete(key),
- });
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- vi.restoreAllMocks();
- });
-
- it('stores a value in sessionStorage', () => {
- saveNudgeShown();
- const stored = mockSession.get(NUDGE_SESSION_KEY);
- expect(stored).toBe('1');
- });
-
- it('does not throw when sessionStorage is unavailable', () => {
- vi.stubGlobal('sessionStorage', {
- getItem: () => {
- throw new Error('SecurityError');
- },
- setItem: () => {
- throw new Error('SecurityError');
- },
- });
- expect(() => saveNudgeShown()).not.toThrow();
- });
-
- it('makes shouldShowNudge return false after saving', () => {
- saveNudgeShown();
- expect(shouldShowNudge()).toBe(false);
- });
-});
diff --git a/packages/app/src/components/star-nudge.tsx b/packages/app/src/components/star-nudge.tsx
deleted file mode 100644
index 28acb375..00000000
--- a/packages/app/src/components/star-nudge.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-'use client';
-
-import { track } from '@/lib/analytics';
-import { Star } from 'lucide-react';
-import { useCallback, useEffect, useRef, useState } from 'react';
-
-import { GITHUB_OWNER, GITHUB_REPO } from '@semianalysisai/inferencex-constants';
-import { STARRED_EVENT, STARRED_KEY } from '@/lib/star-storage';
-import { BottomToast } from '@/components/ui/bottom-toast';
-
-const GITHUB_REPO_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`;
-
-export const NUDGE_SESSION_KEY = 'inferencex-star-nudge-shown';
-const TAB_CHANGE_THRESHOLD = 2;
-
-export function shouldShowNudge(): boolean {
- try {
- if (localStorage.getItem(STARRED_KEY)) return false;
- if (sessionStorage.getItem(NUDGE_SESSION_KEY)) return false;
- return true;
- } catch {
- return false;
- }
-}
-
-export function saveNudgeShown(): void {
- try {
- sessionStorage.setItem(NUDGE_SESSION_KEY, '1');
- } catch {
- // sessionStorage unavailable
- }
-}
-
-export function StarNudge() {
- const [visible, setVisible] = useState(false);
- const tabChangeCount = useRef(0);
- const hasShown = useRef(false);
-
- const showNudge = useCallback(() => {
- if (hasShown.current) return;
- if (!shouldShowNudge()) return;
- hasShown.current = true;
- saveNudgeShown();
- setVisible(true);
- track('star_nudge_shown');
- }, []);
-
- useEffect(() => {
- const handleTabChange = () => {
- if (hasShown.current) return;
- tabChangeCount.current += 1;
- if (tabChangeCount.current >= TAB_CHANGE_THRESHOLD) {
- showNudge();
- }
- };
-
- const handleAction = () => {
- if (hasShown.current) return;
- setTimeout(() => showNudge(), 1500);
- };
-
- const handleStarred = () => setVisible(false);
-
- window.addEventListener('inferencex:tab-change', handleTabChange);
- window.addEventListener('inferencex:action', handleAction);
- window.addEventListener(STARRED_EVENT, handleStarred);
- return () => {
- window.removeEventListener('inferencex:tab-change', handleTabChange);
- window.removeEventListener('inferencex:action', handleAction);
- window.removeEventListener(STARRED_EVENT, handleStarred);
- };
- }, [showNudge]);
-
- const handleStar = useCallback(() => {
- window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer');
- track('star_nudge_starred');
- }, []);
-
- if (!visible) return null;
-
- return (
- }
- title="Finding us useful?"
- description="Help the project grow so we can add more benchmarks! Star us on GitHub."
- action={{
- label: 'Star on GitHub',
- icon: (
-
- ),
- onClick: handleStar,
- }}
- onDismiss={() => track('star_nudge_dismissed')}
- />
- );
-}
diff --git a/packages/app/src/components/ui/github-icon.tsx b/packages/app/src/components/ui/github-icon.tsx
new file mode 100644
index 00000000..8e37e623
--- /dev/null
+++ b/packages/app/src/components/ui/github-icon.tsx
@@ -0,0 +1,12 @@
+export function GitHubIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/packages/app/src/lib/dsv4-launch-storage.ts b/packages/app/src/lib/dsv4-launch-storage.ts
deleted file mode 100644
index b70a03ca..00000000
--- a/packages/app/src/lib/dsv4-launch-storage.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export const DSV4_MODAL_DISMISSED_KEY = 'inferencex-dsv4-modal-dismissed';
-
-export function isDsv4ModalDismissed(): boolean {
- try {
- return localStorage.getItem(DSV4_MODAL_DISMISSED_KEY) === '1';
- } catch {
- return false;
- }
-}
-
-export function saveDsv4ModalDismissed(): void {
- try {
- localStorage.setItem(DSV4_MODAL_DISMISSED_KEY, '1');
- } catch {
- // localStorage unavailable
- }
-}
diff --git a/packages/app/src/lib/nudges/persistence.test.ts b/packages/app/src/lib/nudges/persistence.test.ts
new file mode 100644
index 00000000..01ea991d
--- /dev/null
+++ b/packages/app/src/lib/nudges/persistence.test.ts
@@ -0,0 +1,214 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ clearDismissal,
+ isDismissed,
+ isPermanentlySuppressed,
+ isWithinSchedule,
+ markDismissed,
+ markPermanentlySuppressed,
+} from './persistence';
+import type { NudgeDismissal } from './types';
+
+// ---------------------------------------------------------------------------
+// Storage mocks
+// ---------------------------------------------------------------------------
+
+function makeMockStorage() {
+ const store = new Map();
+ return {
+ getItem: (key: string) => store.get(key) ?? null,
+ setItem: (key: string, value: string) => store.set(key, value),
+ removeItem: (key: string) => store.delete(key),
+ _store: store,
+ };
+}
+
+let mockLocal: ReturnType;
+let mockSession: ReturnType;
+
+beforeEach(() => {
+ mockLocal = makeMockStorage();
+ mockSession = makeMockStorage();
+ vi.stubGlobal('localStorage', mockLocal);
+ vi.stubGlobal('sessionStorage', mockSession);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+// ---------------------------------------------------------------------------
+// isDismissed / markDismissed / clearDismissal
+// ---------------------------------------------------------------------------
+
+describe('session dismissal', () => {
+ const strategy: NudgeDismissal = { type: 'session' };
+ const key = 'test-session-nudge';
+
+ it('returns false when not dismissed', () => {
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+
+ it('returns true after markDismissed', () => {
+ markDismissed(key, strategy);
+ expect(isDismissed(key, strategy)).toBe(true);
+ });
+
+ it('returns false after clearDismissal', () => {
+ markDismissed(key, strategy);
+ clearDismissal(key, strategy);
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+
+ it('uses sessionStorage, not localStorage', () => {
+ markDismissed(key, strategy);
+ expect(mockSession._store.has(key)).toBe(true);
+ expect(mockLocal._store.has(key)).toBe(false);
+ });
+
+ it('returns false when sessionStorage throws', () => {
+ vi.stubGlobal('sessionStorage', {
+ getItem: () => {
+ throw new Error('SecurityError');
+ },
+ setItem: () => {
+ throw new Error('SecurityError');
+ },
+ });
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+});
+
+describe('permanent dismissal', () => {
+ const strategy: NudgeDismissal = { type: 'permanent' };
+ const key = 'test-permanent-nudge';
+
+ it('returns false when not dismissed', () => {
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+
+ it('returns true after markDismissed', () => {
+ markDismissed(key, strategy);
+ expect(isDismissed(key, strategy)).toBe(true);
+ });
+
+ it('returns false after clearDismissal', () => {
+ markDismissed(key, strategy);
+ clearDismissal(key, strategy);
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+
+ it('uses localStorage', () => {
+ markDismissed(key, strategy);
+ expect(mockLocal._store.has(key)).toBe(true);
+ expect(mockSession._store.has(key)).toBe(false);
+ });
+});
+
+describe('timed dismissal', () => {
+ const oneWeek = 7 * 24 * 60 * 60 * 1000;
+ const strategy: NudgeDismissal = { type: 'timed', durationMs: oneWeek };
+ const key = 'test-timed-nudge';
+
+ it('returns false when not dismissed', () => {
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+
+ it('returns true immediately after markDismissed', () => {
+ markDismissed(key, strategy);
+ expect(isDismissed(key, strategy)).toBe(true);
+ });
+
+ it('returns false after duration has elapsed', () => {
+ const pastTimestamp = Date.now() - oneWeek - 1;
+ mockLocal._store.set(key, String(pastTimestamp));
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+
+ it('returns true when within duration', () => {
+ const recentTimestamp = Date.now() - oneWeek + 60_000;
+ mockLocal._store.set(key, String(recentTimestamp));
+ expect(isDismissed(key, strategy)).toBe(true);
+ });
+
+ it('returns false for corrupted value', () => {
+ mockLocal._store.set(key, 'not-a-number');
+ expect(isDismissed(key, strategy)).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isPermanentlySuppressed / markPermanentlySuppressed
+// ---------------------------------------------------------------------------
+
+describe('permanent suppress', () => {
+ const key = 'inferencex-starred';
+
+ it('returns false when not set', () => {
+ expect(isPermanentlySuppressed(key)).toBe(false);
+ });
+
+ it('returns true after markPermanentlySuppressed', () => {
+ markPermanentlySuppressed(key);
+ expect(isPermanentlySuppressed(key)).toBe(true);
+ });
+
+ it('dispatches window event when event name provided', () => {
+ const dispatchSpy = vi.fn();
+ vi.stubGlobal('window', {
+ ...globalThis.window,
+ dispatchEvent: dispatchSpy,
+ });
+ markPermanentlySuppressed(key, 'inferencex:starred');
+ expect(dispatchSpy).toHaveBeenCalledOnce();
+ const event = dispatchSpy.mock.calls[0][0] as Event;
+ expect(event.type).toBe('inferencex:starred');
+ });
+
+ it('does not dispatch event when event name is omitted', () => {
+ const dispatchSpy = vi.fn();
+ vi.stubGlobal('window', {
+ ...globalThis.window,
+ dispatchEvent: dispatchSpy,
+ });
+ markPermanentlySuppressed(key);
+ expect(dispatchSpy).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// isWithinSchedule
+// ---------------------------------------------------------------------------
+
+describe('isWithinSchedule', () => {
+ it('returns true when no schedule provided', () => {
+ expect(isWithinSchedule(undefined)).toBe(true);
+ });
+
+ it('returns true when within range', () => {
+ expect(
+ isWithinSchedule({
+ showAfter: '2020-01-01',
+ hideAfter: '2099-12-31',
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false before showAfter', () => {
+ expect(isWithinSchedule({ showAfter: '2099-01-01' })).toBe(false);
+ });
+
+ it('returns false after hideAfter', () => {
+ expect(isWithinSchedule({ hideAfter: '2020-01-01' })).toBe(false);
+ });
+
+ it('returns true when only showAfter is in the past', () => {
+ expect(isWithinSchedule({ showAfter: '2020-01-01' })).toBe(true);
+ });
+
+ it('returns true when only hideAfter is in the future', () => {
+ expect(isWithinSchedule({ hideAfter: '2099-12-31' })).toBe(true);
+ });
+});
diff --git a/packages/app/src/lib/nudges/persistence.ts b/packages/app/src/lib/nudges/persistence.ts
new file mode 100644
index 00000000..3ff11fb7
--- /dev/null
+++ b/packages/app/src/lib/nudges/persistence.ts
@@ -0,0 +1,83 @@
+import type { NudgeDismissal } from './types';
+
+// ---------------------------------------------------------------------------
+// Dismissal state — read / write / clear
+// ---------------------------------------------------------------------------
+
+export function isDismissed(storageKey: string, strategy: NudgeDismissal): boolean {
+ try {
+ if (strategy.type === 'session') {
+ return sessionStorage.getItem(storageKey) !== null;
+ }
+ const value = localStorage.getItem(storageKey);
+ if (value === null) return false;
+ if (strategy.type === 'permanent') return true;
+ // timed — check expiry
+ const dismissedAt = Number(value);
+ if (Number.isNaN(dismissedAt)) return false;
+ return Date.now() - dismissedAt < strategy.durationMs;
+ } catch {
+ return false;
+ }
+}
+
+export function markDismissed(storageKey: string, strategy: NudgeDismissal): void {
+ try {
+ if (strategy.type === 'session') {
+ sessionStorage.setItem(storageKey, '1');
+ } else if (strategy.type === 'permanent') {
+ localStorage.setItem(storageKey, '1');
+ } else {
+ localStorage.setItem(storageKey, String(Date.now()));
+ }
+ } catch {
+ // Storage unavailable — fail silently.
+ }
+}
+
+export function clearDismissal(storageKey: string, strategy: NudgeDismissal): void {
+ try {
+ if (strategy.type === 'session') {
+ sessionStorage.removeItem(storageKey);
+ } else {
+ localStorage.removeItem(storageKey);
+ }
+ } catch {
+ // Storage unavailable.
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Permanent suppress — cross-nudge suppression (e.g. "user starred the repo")
+// ---------------------------------------------------------------------------
+
+export function isPermanentlySuppressed(key: string): boolean {
+ try {
+ return localStorage.getItem(key) !== null;
+ } catch {
+ return false;
+ }
+}
+
+export function markPermanentlySuppressed(key: string, event?: string): void {
+ try {
+ localStorage.setItem(key, '1');
+ } catch {
+ // Storage unavailable.
+ }
+ if (event) {
+ window.dispatchEvent(new Event(event));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Schedule check
+// ---------------------------------------------------------------------------
+
+export function isWithinSchedule(schedule?: { showAfter?: string; hideAfter?: string }): boolean {
+ if (!schedule) return true;
+ const now = Date.now();
+ if (schedule.showAfter && now < new Date(schedule.showAfter).getTime()) return false;
+ if (schedule.hideAfter && now >= new Date(schedule.hideAfter).getTime()) return false;
+ return true;
+}
diff --git a/packages/app/src/lib/nudges/registry.test.ts b/packages/app/src/lib/nudges/registry.test.ts
new file mode 100644
index 00000000..1087ea63
--- /dev/null
+++ b/packages/app/src/lib/nudges/registry.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+
+import { NUDGE_REGISTRY } from './registry';
+
+describe('NUDGE_REGISTRY integrity', () => {
+ it('has no duplicate IDs', () => {
+ const ids = NUDGE_REGISTRY.map((n) => n.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('has no duplicate storage keys', () => {
+ const keys = NUDGE_REGISTRY.map((n) => n.storageKey);
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+
+ it('every entry has a valid type', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ expect(['toast', 'modal', 'banner']).toContain(nudge.type);
+ }
+ });
+
+ it('every entry has a valid scope', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ expect(['dashboard', 'landing', 'evaluation']).toContain(nudge.scope);
+ }
+ });
+
+ it('every entry has a non-empty title and description', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ expect(nudge.content.title.length).toBeGreaterThan(0);
+ expect(nudge.content.description.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('every entry has a numeric priority', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ expect(typeof nudge.priority).toBe('number');
+ }
+ });
+
+ it('every entry has at least one trigger', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ const triggers = Array.isArray(nudge.trigger) ? nudge.trigger : [nudge.trigger];
+ expect(triggers.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('every entry has a valid dismissal type', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ expect(['session', 'permanent', 'timed']).toContain(nudge.dismissal.type);
+ }
+ });
+
+ it('timed dismissals have a positive durationMs', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ if (nudge.dismissal.type === 'timed') {
+ expect(nudge.dismissal.durationMs).toBeGreaterThan(0);
+ }
+ }
+ });
+
+ it('contains the expected set of migrated nudges', () => {
+ const ids = NUDGE_REGISTRY.map((n) => n.id).toSorted();
+ expect(ids).toEqual([
+ 'dsv4-launch-banner',
+ 'dsv4-launch-modal',
+ 'eval-samples',
+ 'export',
+ 'github-star-modal',
+ 'gradient-label',
+ 'reproducibility',
+ 'star-nudge',
+ ]);
+ });
+
+ it('preserves testId for every entry', () => {
+ for (const nudge of NUDGE_REGISTRY) {
+ expect(nudge.content.testId).toBeTruthy();
+ }
+ });
+});
diff --git a/packages/app/src/lib/nudges/registry.tsx b/packages/app/src/lib/nudges/registry.tsx
new file mode 100644
index 00000000..af326272
--- /dev/null
+++ b/packages/app/src/lib/nudges/registry.tsx
@@ -0,0 +1,276 @@
+import {
+ ArrowRight,
+ Download,
+ MessageSquareText,
+ Palette,
+ ShieldCheck,
+ Sparkles,
+ Star,
+} from 'lucide-react';
+
+import { GITHUB_OWNER, GITHUB_REPO } from '@semianalysisai/inferencex-constants';
+
+import { GitHubIcon } from '@/components/ui/github-icon';
+import { STARRED_EVENT, STARRED_KEY, saveStarred } from '@/lib/star-storage';
+import type { NudgeDefinition } from './types';
+
+const GITHUB_REPO_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}`;
+
+/**
+ * Event name dispatched by ScatterGraph when parallelism labels are enabled.
+ * Exported so the dispatch site can import a stable constant.
+ */
+export const GRADIENT_NUDGE_EVENT = 'inferencex:parallelism-label-enabled';
+
+// ---------------------------------------------------------------------------
+// Registry — every engagement nudge in one place
+// ---------------------------------------------------------------------------
+
+export const NUDGE_REGISTRY: NudgeDefinition[] = [
+ // -------------------------------------------------------------------------
+ // Dashboard toasts
+ // -------------------------------------------------------------------------
+ {
+ id: 'reproducibility',
+ type: 'toast',
+ trigger: { type: 'timer', delayMs: 1500 },
+ dismissal: { type: 'session' },
+ storageKey: 'inferencex-reproducibility-nudge-shown',
+ priority: 10,
+ scope: 'dashboard',
+ content: {
+ icon: ShieldCheck,
+ iconClassName: 'text-brand',
+ title: 'Every result is reproducible',
+ description:
+ 'Each data point is produced by a public GitHub Actions run. Click any point on a chart to jump to the exact run, logs, and artifacts.',
+ action: {
+ label: 'See how',
+ onClick: () => {
+ window.location.href = '/about#reproducibility';
+ },
+ },
+ testId: 'reproducibility-nudge',
+ },
+ analytics: {
+ shown: 'reproducibility_nudge_shown',
+ dismissed: 'reproducibility_nudge_dismissed',
+ action: 'reproducibility_nudge_see_how_clicked',
+ },
+ },
+ {
+ id: 'star-nudge',
+ type: 'toast',
+ trigger: [
+ { type: 'event', event: 'inferencex:tab-change', threshold: 2 },
+ { type: 'event', event: 'inferencex:action', delayMs: 1500 },
+ ],
+ dismissal: { type: 'session' },
+ storageKey: 'inferencex-star-nudge-shown',
+ permanentSuppressKey: STARRED_KEY,
+ permanentSuppressEvent: STARRED_EVENT,
+ priority: 20,
+ scope: 'dashboard',
+ content: {
+ icon: Star,
+ iconClassName: 'text-yellow-500 fill-yellow-500',
+ title: 'Finding us useful?',
+ description: 'Help the project grow so we can add more benchmarks! Star us on GitHub.',
+ action: {
+ label: 'Star on GitHub',
+ icon: ,
+ onClick: () => {
+ window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer');
+ },
+ },
+ testId: 'star-nudge',
+ },
+ analytics: {
+ shown: 'star_nudge_shown',
+ dismissed: 'star_nudge_dismissed',
+ action: 'star_nudge_starred',
+ },
+ },
+ {
+ id: 'export',
+ type: 'toast',
+ trigger: {
+ type: 'dom-event',
+ event: 'copy',
+ selector: '[data-chart-tooltip]',
+ threshold: 2,
+ },
+ dismissal: { type: 'session' },
+ storageKey: 'inferencex-export-nudge-shown',
+ priority: 15,
+ scope: 'dashboard',
+ content: {
+ icon: Download,
+ iconClassName: 'text-blue-500',
+ title: 'Need the data?',
+ description:
+ 'Use the download button on any chart to export as PNG or CSV — no need to copy from tooltips.',
+ testId: 'export-nudge',
+ },
+ analytics: {
+ shown: 'export_nudge_shown',
+ dismissed: 'export_nudge_dismissed',
+ },
+ },
+ {
+ id: 'gradient-label',
+ type: 'toast',
+ trigger: { type: 'event', event: 'inferencex:parallelism-label-enabled' },
+ dismissal: { type: 'session' },
+ storageKey: 'inferencex-gradient-nudge-shown',
+ priority: 25,
+ scope: 'dashboard',
+ content: {
+ icon: Palette,
+ iconClassName: 'text-purple-500',
+ title: 'Try Gradient Labels',
+ description:
+ 'Gradient labels color-code data points by parallelism level, making it easier to spot performance patterns at a glance.',
+ action: {
+ label: 'Enable Gradient Labels',
+ onClick: (eventDetail?: unknown) => {
+ const detail = eventDetail as { enableGradient?: () => void } | undefined;
+ detail?.enableGradient?.();
+ },
+ },
+ testId: 'gradient-label-nudge',
+ },
+ analytics: {
+ shown: 'gradient_nudge_shown',
+ dismissed: 'gradient_nudge_dismissed',
+ action: 'gradient_nudge_accepted',
+ },
+ },
+
+ // -------------------------------------------------------------------------
+ // Evaluation toast
+ // -------------------------------------------------------------------------
+ {
+ id: 'eval-samples',
+ type: 'toast',
+ trigger: { type: 'timer', delayMs: 1500 },
+ dismissal: { type: 'timed', durationMs: 7 * 24 * 60 * 60 * 1000 },
+ storageKey: 'inferencex-eval-samples-nudge-dismissed',
+ permanentSuppressEvent: 'inferencex:eval-samples-opened',
+ priority: 30,
+ scope: 'evaluation',
+ content: {
+ icon: MessageSquareText,
+ iconClassName: 'text-brand',
+ title: "See the model's actual answers",
+ description:
+ 'Click Prompts on any row to compare each prompt, the expected answer, and what the model actually responded.',
+ testId: 'eval-samples-nudge',
+ },
+ analytics: {
+ shown: 'evaluation_samples_nudge_shown',
+ dismissed: 'evaluation_samples_nudge_dismissed',
+ },
+ },
+
+ // -------------------------------------------------------------------------
+ // Landing modals
+ // -------------------------------------------------------------------------
+ {
+ id: 'dsv4-launch-modal',
+ type: 'modal',
+ trigger: { type: 'immediate' },
+ dismissal: { type: 'permanent' },
+ storageKey: 'inferencex-dsv4-modal-dismissed',
+ priority: 50,
+ scope: 'landing',
+ content: {
+ icon: Sparkles,
+ iconClassName: 'text-brand',
+ title: 'DeepSeek V4 Pro is live',
+ description:
+ 'Day-zero benchmarks for DeepSeek V4 Pro are now available across the latest NVIDIA and AMD GPUs. Results are experimental — see how the new model performs across hardware.',
+ testId: 'dsv4-launch-modal',
+ containerClassName: 'border-brand/40',
+ badge: 'New',
+ dismissLabel: 'Maybe Later',
+ primaryAction: {
+ label: 'Explore',
+ icon: ,
+ onClick: () => {
+ window.location.href = '/inference?preset=dsv4-launch';
+ },
+ },
+ },
+ analytics: {
+ shown: 'dsv4_modal_shown',
+ dismissed: 'dsv4_modal_dismissed',
+ action: 'dsv4_modal_explored',
+ },
+ },
+ {
+ id: 'github-star-modal',
+ type: 'modal',
+ trigger: { type: 'immediate' },
+ dismissal: { type: 'timed', durationMs: 7 * 24 * 60 * 60 * 1000 },
+ storageKey: 'inferencex-star-modal-dismissed',
+ permanentSuppressKey: STARRED_KEY,
+ permanentSuppressEvent: STARRED_EVENT,
+ priority: 40,
+ scope: 'landing',
+ content: {
+ icon: Star,
+ iconClassName: 'text-yellow-500 fill-yellow-500',
+ title: 'Star InferenceX on GitHub',
+ description:
+ 'Star InferenceX on GitHub to get notified when we publish new benchmark data. We update GPU performance comparisons regularly — starring is the easiest way to stay in the loop and help the project grow.',
+ testId: 'github-star-modal',
+ dismissLabel: 'Maybe Later',
+ primaryAction: {
+ label: 'Star on GitHub',
+ icon: ,
+ onClick: () => {
+ window.open(GITHUB_REPO_URL, '_blank', 'noopener,noreferrer');
+ saveStarred();
+ },
+ },
+ actionClassName: 'star-button-glow',
+ },
+ analytics: {
+ shown: 'star_modal_shown',
+ dismissed: 'star_modal_dismissed',
+ action: 'star_modal_starred',
+ },
+ },
+
+ // -------------------------------------------------------------------------
+ // Landing banner
+ // -------------------------------------------------------------------------
+ {
+ id: 'dsv4-launch-banner',
+ type: 'banner',
+ trigger: { type: 'immediate' },
+ dismissal: { type: 'permanent' },
+ storageKey: 'inferencex-dsv4-banner-dismissed',
+ priority: 60,
+ scope: 'landing',
+ content: {
+ icon: Sparkles,
+ iconClassName: 'text-brand',
+ title: 'DeepSeek V4 Pro benchmarks are live',
+ description: 'First inference numbers across NVIDIA and AMD GPUs, click to explore.',
+ testId: 'launch-banner',
+ badge: 'New',
+ href: '/inference?preset=dsv4-launch',
+ onLinkClick: () => {
+ window.location.href = '/inference?preset=dsv4-launch';
+ },
+ },
+ analytics: {
+ shown: 'launch_banner_shown',
+ dismissed: 'launch_banner_dismissed',
+ action: 'launch_banner_clicked',
+ properties: { banner_id: 'dsv4-launch', preset_id: 'dsv4-launch' },
+ },
+ },
+];
diff --git a/packages/app/src/lib/nudges/types.ts b/packages/app/src/lib/nudges/types.ts
new file mode 100644
index 00000000..810cfb16
--- /dev/null
+++ b/packages/app/src/lib/nudges/types.ts
@@ -0,0 +1,129 @@
+import type { ComponentType, ReactNode } from 'react';
+
+export type NudgeTrigger =
+ | { type: 'immediate' }
+ | { type: 'timer'; delayMs: number }
+ | {
+ type: 'event';
+ event: string;
+ /** Show after the event fires this many times (default 1). */
+ threshold?: number;
+ /** Delay (ms) between threshold being met and the nudge appearing. */
+ delayMs?: number;
+ }
+ | {
+ type: 'dom-event';
+ /** Native DOM event name (e.g. 'copy'). */
+ event: string;
+ /** CSS selector — only count events whose target matches. */
+ selector?: string;
+ threshold?: number;
+ /** Delay (ms) between threshold being met and the nudge appearing. */
+ delayMs?: number;
+ };
+
+export type NudgeDismissal =
+ | { type: 'session' }
+ | { type: 'permanent' }
+ | { type: 'timed'; durationMs: number };
+
+export interface NudgeCondition {
+ check: () => boolean;
+ /** Re-evaluate when this window event fires. */
+ listenEvent?: string;
+}
+
+export interface NudgeAction {
+ label: string;
+ icon?: ReactNode;
+ /**
+ * Called when the user clicks the action button.
+ * For event-triggered nudges the trigger's CustomEvent detail is forwarded
+ * so the handler can access runtime data without a special case in the engine.
+ */
+ onClick: (eventDetail?: unknown) => void;
+}
+
+export interface NudgeContent {
+ icon: ComponentType<{ className?: string }>;
+ iconClassName?: string;
+ title: string;
+ description: string;
+ action?: NudgeAction;
+ /** data-testid on the nudge container (preserves existing selectors). */
+ testId?: string;
+
+ // -- Modal-specific (ignored by toasts/banners) --
+
+ /** Label for the dismiss button (default "Maybe Later"). */
+ dismissLabel?: string;
+ /** Label + handler for the primary CTA (modals only). */
+ primaryAction?: NudgeAction;
+ /** Extra CSS class on the modal container (e.g. branded border). */
+ containerClassName?: string;
+ /** Extra CSS class on the primary action button (e.g. glow effect). */
+ actionClassName?: string;
+ /** Badge text rendered next to the title (e.g. "New"). */
+ badge?: string;
+
+ // -- Banner-specific (ignored by toasts/modals) --
+
+ /** href for the banner link (the whole banner is clickable). */
+ href?: string;
+ /** Called when the banner link is clicked (for analytics). */
+ onLinkClick?: () => void;
+}
+
+// ---------------------------------------------------------------------------
+// Analytics overrides
+// ---------------------------------------------------------------------------
+
+export interface NudgeAnalyticsOverrides {
+ shown?: string;
+ dismissed?: string;
+ action?: string;
+ /** Extra properties attached to every analytics event for this nudge. */
+ properties?: Record;
+}
+
+// ---------------------------------------------------------------------------
+// NudgeDefinition — a single registry entry
+// ---------------------------------------------------------------------------
+
+export type NudgeType = 'toast' | 'modal' | 'banner';
+
+export interface NudgeDefinition {
+ id: string;
+ type: NudgeType;
+ trigger: NudgeTrigger | NudgeTrigger[];
+ dismissal: NudgeDismissal;
+ /** localStorage / sessionStorage key for dismissal state. */
+ storageKey: string;
+ conditions?: NudgeCondition[];
+ /** Higher priority wins when multiple nudges are eligible simultaneously. */
+ priority: number;
+ /** Which NudgeEngine instance manages this nudge. */
+ scope: 'dashboard' | 'landing' | 'evaluation';
+
+ // Scheduling (time-bound campaigns)
+ schedule?: {
+ showAfter?: string;
+ hideAfter?: string;
+ };
+
+ /**
+ * A secondary localStorage key that permanently suppresses the nudge.
+ * Example: `inferencex-starred` suppresses both star-nudge and github-star-modal.
+ */
+ permanentSuppressKey?: string;
+ /**
+ * Window event that triggers a permanent-suppress write + immediate hide.
+ * Example: `inferencex:starred`, `inferencex:eval-samples-opened`.
+ */
+ permanentSuppressEvent?: string;
+
+ content: NudgeContent;
+
+ /** Override default `{id}_shown` / `{id}_dismissed` / `{id}_action` event names. */
+ analytics?: NudgeAnalyticsOverrides;
+}