diff --git a/packages/app/cypress/e2e/csv-export.cy.ts b/packages/app/cypress/e2e/csv-export.cy.ts
index 026d3121..f37cfd9e 100644
--- a/packages/app/cypress/e2e/csv-export.cy.ts
+++ b/packages/app/cypress/e2e/csv-export.cy.ts
@@ -1,7 +1,7 @@
describe('CSV Export', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
cy.get('[data-testid="chart-figure"]').should('exist');
diff --git a/packages/app/cypress/e2e/custom-user-values.cy.ts b/packages/app/cypress/e2e/custom-user-values.cy.ts
index 1456033a..7d3545ed 100644
--- a/packages/app/cypress/e2e/custom-user-values.cy.ts
+++ b/packages/app/cypress/e2e/custom-user-values.cy.ts
@@ -15,7 +15,7 @@ const selectCustomPowerMetric = () => {
describe('Custom User Values', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
cy.get('[data-testid="model-selector"]').should('be.visible');
diff --git a/packages/app/cypress/e2e/drill-down-trend.cy.ts b/packages/app/cypress/e2e/drill-down-trend.cy.ts
index bd06ec77..45d3a873 100644
--- a/packages/app/cypress/e2e/drill-down-trend.cy.ts
+++ b/packages/app/cypress/e2e/drill-down-trend.cy.ts
@@ -6,7 +6,7 @@
describe('Drill-Down Trend Chart Modal', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
// Wait for scatter graph to render with data points
diff --git a/packages/app/cypress/e2e/dropdown-switching.cy.ts b/packages/app/cypress/e2e/dropdown-switching.cy.ts
index b45cb51c..3f1d9197 100644
--- a/packages/app/cypress/e2e/dropdown-switching.cy.ts
+++ b/packages/app/cypress/e2e/dropdown-switching.cy.ts
@@ -6,7 +6,7 @@
describe('Dropdown one-click switching', () => {
beforeEach(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
cy.get('[data-testid="inference-chart-display"]').should('exist');
diff --git a/packages/app/cypress/e2e/evaluation-chart.cy.ts b/packages/app/cypress/e2e/evaluation-chart.cy.ts
index 020f9a1f..75a103db 100644
--- a/packages/app/cypress/e2e/evaluation-chart.cy.ts
+++ b/packages/app/cypress/e2e/evaluation-chart.cy.ts
@@ -1,7 +1,7 @@
describe('Evaluation Chart', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/evaluation');
cy.get('[data-testid="evaluation-chart-display"]').should('exist');
@@ -43,7 +43,7 @@ describe('Evaluation Chart', () => {
describe('Evaluation Chart — Content & Interactions', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/evaluation');
cy.get('[data-testid="evaluation-chart-display"]').should('be.visible');
diff --git a/packages/app/cypress/e2e/gpu-power.cy.ts b/packages/app/cypress/e2e/gpu-power.cy.ts
index 9991e5f8..d250786b 100644
--- a/packages/app/cypress/e2e/gpu-power.cy.ts
+++ b/packages/app/cypress/e2e/gpu-power.cy.ts
@@ -7,7 +7,7 @@ describe('PowerX', () => {
beforeEach(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
});
@@ -34,7 +34,7 @@ describe('PowerX', () => {
beforeEach(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
win.localStorage.setItem('inferencex-feature-gate', '1');
},
});
diff --git a/packages/app/cypress/e2e/gpu-specs.cy.ts b/packages/app/cypress/e2e/gpu-specs.cy.ts
index c569d7ae..2801f31e 100644
--- a/packages/app/cypress/e2e/gpu-specs.cy.ts
+++ b/packages/app/cypress/e2e/gpu-specs.cy.ts
@@ -1,7 +1,7 @@
describe('GPU Specs Tab', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/gpu-specs');
// Wait for GPU Specs tab content to be present in the DOM
@@ -210,7 +210,7 @@ describe('GPU Specs Tab', () => {
describe('GPU Specs Bar Chart View', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/gpu-specs');
cy.get('h2').contains('GPU Specifications').should('exist');
@@ -284,7 +284,7 @@ describe('GPU Specs Bar Chart View', () => {
describe('GPU Specs Radar Chart View', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/gpu-specs');
cy.get('h2').contains('GPU Specifications').should('exist');
@@ -381,7 +381,7 @@ describe('GPU Specs Radar Chart View', () => {
describe('GPU Specs Navigation', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
cy.get('[data-testid="chart-section-tabs"]').should('be.visible');
@@ -397,7 +397,7 @@ describe('GPU Specs Navigation', () => {
describe('Topology Dialog Navigation', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/gpu-specs');
cy.get('h2').contains('GPU Specifications').should('exist');
diff --git a/packages/app/cypress/e2e/gradient-labels.cy.ts b/packages/app/cypress/e2e/gradient-labels.cy.ts
index 333baa6d..5c4b2601 100644
--- a/packages/app/cypress/e2e/gradient-labels.cy.ts
+++ b/packages/app/cypress/e2e/gradient-labels.cy.ts
@@ -2,7 +2,7 @@ describe('Gradient Labels Toggle', () => {
before(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
// Wait for chart to load
@@ -80,7 +80,7 @@ describe('Gradient Labels Toggle', () => {
it('URL param i_gradlabel=1 enables gradient labels on load', () => {
cy.visit('/inference?i_gradlabel=1', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
@@ -90,7 +90,7 @@ describe('Gradient Labels Toggle', () => {
it('URL param i_advlabel=1 enables parallelism labels on load', () => {
cy.visit('/inference?i_advlabel=1', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
@@ -119,7 +119,7 @@ describe('Gradient Labels with non-default Y-axis metrics', () => {
before(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts
index 1e59b72a..27c8aa6c 100644
--- a/packages/app/cypress/e2e/historical-trends.cy.ts
+++ b/packages/app/cypress/e2e/historical-trends.cy.ts
@@ -5,7 +5,7 @@
describe('Historical Trends Tab', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/historical');
cy.get('[data-testid="historical-trends-display"]').should('be.visible');
@@ -37,7 +37,7 @@ describe('Historical Trends Tab', () => {
describe('Historical Trends — Content & Interactions', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/historical');
cy.get('[data-testid="historical-trends-display"]').should('be.visible');
diff --git a/packages/app/cypress/e2e/inference-chart.cy.ts b/packages/app/cypress/e2e/inference-chart.cy.ts
index 93350d69..7230b814 100644
--- a/packages/app/cypress/e2e/inference-chart.cy.ts
+++ b/packages/app/cypress/e2e/inference-chart.cy.ts
@@ -1,7 +1,7 @@
describe('Inference Chart', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
});
diff --git a/packages/app/cypress/e2e/line-labels.cy.ts b/packages/app/cypress/e2e/line-labels.cy.ts
index 011bbda3..1ef3583a 100644
--- a/packages/app/cypress/e2e/line-labels.cy.ts
+++ b/packages/app/cypress/e2e/line-labels.cy.ts
@@ -2,7 +2,7 @@ describe('Line Labels Toggle', () => {
before(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
// Wait for chart to load
@@ -69,7 +69,7 @@ describe('Line Labels Toggle', () => {
it('URL param i_linelabel=1 enables line labels on load', () => {
cy.visit('/inference?i_linelabel=1', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
diff --git a/packages/app/cypress/e2e/model-architecture.cy.ts b/packages/app/cypress/e2e/model-architecture.cy.ts
index f0646c4e..1df8ed14 100644
--- a/packages/app/cypress/e2e/model-architecture.cy.ts
+++ b/packages/app/cypress/e2e/model-architecture.cy.ts
@@ -4,7 +4,7 @@ describe('Model Architecture Diagram', () => {
cy.viewport(1280, 800);
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
// Wait for the page to load
diff --git a/packages/app/cypress/e2e/navigation.cy.ts b/packages/app/cypress/e2e/navigation.cy.ts
index c15e49b5..8fa96593 100644
--- a/packages/app/cypress/e2e/navigation.cy.ts
+++ b/packages/app/cypress/e2e/navigation.cy.ts
@@ -4,7 +4,7 @@
describe('Chart Section Tabs — E2E', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
});
@@ -46,15 +46,15 @@ describe('First-load navigation', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.removeItem('inferencex-starred');
- win.localStorage.removeItem('inferencex-star-modal-dismissed');
- win.localStorage.removeItem('inferencex-dsv4-modal-dismissed');
+ win.localStorage.removeItem('inferencex-nudge:github-star-modal');
+ win.localStorage.removeItem('inferencex-nudge:dsv4-launch-modal');
},
});
// dsv4 launch modal takes precedence over the GitHub star modal on first
// load — only one modal shows at a time. Either is fine for this test, we
// just need *a* first-load modal up to verify it doesn't block navigation.
- cy.get('[data-testid="dsv4-launch-modal"]').should('be.visible');
+ cy.get('[data-testid="nudge-dsv4-launch-modal"]').should('be.visible');
cy.get('body').should('not.have.attr', 'data-scroll-locked');
});
diff --git a/packages/app/cypress/e2e/reliability-chart.cy.ts b/packages/app/cypress/e2e/reliability-chart.cy.ts
index ae9d7f8b..17a12e50 100644
--- a/packages/app/cypress/e2e/reliability-chart.cy.ts
+++ b/packages/app/cypress/e2e/reliability-chart.cy.ts
@@ -1,7 +1,7 @@
describe('Reliability Chart', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/reliability');
cy.get('[data-testid="reliability-chart-display"]').should('exist');
@@ -49,7 +49,7 @@ describe('Reliability Chart', () => {
describe('Reliability Chart — Content & Interactions', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/reliability');
cy.get('[data-testid="reliability-chart-display"]').should('be.visible');
diff --git a/packages/app/cypress/e2e/sanity.cy.ts b/packages/app/cypress/e2e/sanity.cy.ts
index a18e11cc..c44ed128 100644
--- a/packages/app/cypress/e2e/sanity.cy.ts
+++ b/packages/app/cypress/e2e/sanity.cy.ts
@@ -38,7 +38,7 @@ describe('Page Load & Navigation', () => {
it('navigates from the footer to the land acknowledgement page', () => {
cy.visit('/', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
@@ -66,7 +66,7 @@ describe('Page Load & Navigation', () => {
describe('Theme Toggle', () => {
it('theme persists across page reload (localStorage)', () => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
win.localStorage.setItem('theme', 'light');
});
cy.visit('/');
diff --git a/packages/app/cypress/e2e/speed-overlay.cy.ts b/packages/app/cypress/e2e/speed-overlay.cy.ts
index dfa64243..9c7a9b89 100644
--- a/packages/app/cypress/e2e/speed-overlay.cy.ts
+++ b/packages/app/cypress/e2e/speed-overlay.cy.ts
@@ -2,7 +2,7 @@ describe('Bus / Race Car Speed Overlay', () => {
before(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
@@ -55,7 +55,7 @@ describe('Bus / Race Car Speed Overlay', () => {
it('URL param i_speed=1 enables the overlay on first load', () => {
cy.visit('/inference?i_speed=1', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
@@ -69,7 +69,7 @@ describe('Donkey / Elytra Minecraft Overlay', () => {
before(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
@@ -129,7 +129,7 @@ describe('Donkey / Elytra Minecraft Overlay', () => {
it('URL param i_mc=1 enables the minecraft overlay on first load', () => {
cy.visit('/inference?i_mc=1', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
@@ -146,7 +146,7 @@ describe('Y-Axis Metric Search', () => {
before(() => {
cy.visit('/inference', {
onBeforeLoad(win) {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
},
});
cy.get('[data-testid="scatter-graph"]').should('be.visible');
diff --git a/packages/app/cypress/e2e/throughput-calculator.cy.ts b/packages/app/cypress/e2e/throughput-calculator.cy.ts
index 28df2034..aa4f6f0c 100644
--- a/packages/app/cypress/e2e/throughput-calculator.cy.ts
+++ b/packages/app/cypress/e2e/throughput-calculator.cy.ts
@@ -6,7 +6,7 @@ describe('TCO Calculator', () => {
describe('tab navigation', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
});
@@ -37,7 +37,7 @@ describe('TCO Calculator', () => {
describe('controls, interactions, and features', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/calculator');
cy.get('[data-testid="calculator-bar-chart"] svg .bar').should('have.length.greaterThan', 0);
@@ -514,7 +514,7 @@ describe('TCO Calculator', () => {
describe('direct URL navigation', () => {
it('navigating to /calculator directly loads the calculator tab with data', () => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/calculator');
cy.url().should('include', '/calculator');
diff --git a/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts b/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts
index e17a4aff..ac9d9ccb 100644
--- a/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts
+++ b/packages/app/cypress/e2e/ttft-x-axis-toggle.cy.ts
@@ -1,7 +1,7 @@
describe('TTFT X-Axis Toggle (E2E chart)', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
cy.get('[data-testid="chart-figure"]').should('have.length.at.least', 2);
diff --git a/packages/app/cypress/e2e/url-params.cy.ts b/packages/app/cypress/e2e/url-params.cy.ts
index d260f01d..f70e92d1 100644
--- a/packages/app/cypress/e2e/url-params.cy.ts
+++ b/packages/app/cypress/e2e/url-params.cy.ts
@@ -6,7 +6,7 @@
describe('URL Parameter Persistence', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
});
diff --git a/packages/app/cypress/e2e/yaxis-metrics-render.cy.ts b/packages/app/cypress/e2e/yaxis-metrics-render.cy.ts
index 351061eb..77a79694 100644
--- a/packages/app/cypress/e2e/yaxis-metrics-render.cy.ts
+++ b/packages/app/cypress/e2e/yaxis-metrics-render.cy.ts
@@ -30,7 +30,7 @@ describe('Y-Axis Metrics All Render Data', () => {
before(() => {
cy.window().then((win) => {
- win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now()));
+ win.localStorage.setItem('inferencex-nudge:github-star-modal', String(Date.now()));
});
cy.visit('/inference');
cy.get('[data-testid="scatter-graph"]')
diff --git a/packages/app/src/app/(dashboard)/evaluation/page.tsx b/packages/app/src/app/(dashboard)/evaluation/page.tsx
index 57411388..f1be9b0d 100644
--- a/packages/app/src/app/(dashboard)/evaluation/page.tsx
+++ b/packages/app/src/app/(dashboard)/evaluation/page.tsx
@@ -1,6 +1,5 @@
import type { Metadata } from 'next';
-import { EvalSamplesNudge } from '@/components/eval-samples-nudge';
import { EvaluationProvider } from '@/components/evaluation/EvaluationContext';
import EvaluationChartDisplay from '@/components/evaluation/ui/ChartDisplay';
import { tabMetadata } from '@/lib/tab-meta';
@@ -11,7 +10,6 @@ export default function EvaluationPage() {
return (
-
);
}
diff --git a/packages/app/src/app/layout.tsx b/packages/app/src/app/layout.tsx
index 93088655..976218da 100644
--- a/packages/app/src/app/layout.tsx
+++ b/packages/app/src/app/layout.tsx
@@ -12,6 +12,7 @@ import { Header } from '@/components/header/header';
import { CircuitBackground } from '@/components/circuit-background';
import { MinecraftBackgroundLazy } from '@/components/minecraft/minecraft-background-lazy';
import { MinecraftDecorations } from '@/components/minecraft/minecraft-decorations';
+import { NudgeProvider, NudgeRoot } from '@/components/nudges';
import { ThemeProvider } from '@/components/ui/theme-provider';
import {
AUTHOR_HANDLE,
@@ -191,9 +192,12 @@ export default async function RootLayout({
disableTransitionOnChange
>
-
-
{children}
-
+
+
+ {children}
+
+
+
{process.env.VERCEL && }
diff --git a/packages/app/src/components/dashboard-shell.tsx b/packages/app/src/components/dashboard-shell.tsx
index c7991fc7..68a5a197 100644
--- a/packages/app/src/components/dashboard-shell.tsx
+++ b/packages/app/src/components/dashboard-shell.tsx
@@ -1,28 +1,18 @@
'use client';
-import { ExportNudge } from '@/components/export-nudge';
import { GlobalFilterProvider } from '@/components/GlobalFilterContext';
-import { GradientLabelNudge } from '@/components/gradient-label-nudge';
-import { ReproducibilityNudge } from '@/components/reproducibility-nudge';
-import { StarNudge } from '@/components/star-nudge';
import { TabNav } from '@/components/tab-nav';
import { UnofficialRunProvider } from '@/components/unofficial-run-provider';
export function DashboardShell({ children }: { children: React.ReactNode }) {
return (
- <>
-
-
-
-
-
-
-
-
- {children}
-
-
-
- >
+
+
+
+
+ {children}
+
+
+
);
}
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..a5471940 100644
--- a/packages/app/src/components/inference/ui/ScatterGraph.tsx
+++ b/packages/app/src/components/inference/ui/ScatterGraph.tsx
@@ -4,7 +4,6 @@ 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 { useInference } from '@/components/inference/InferenceContext';
import ChartLegend from '@/components/ui/chart-legend';
import { useUnofficialRun } from '@/components/unofficial-run-provider';
@@ -2056,7 +2055,7 @@ const ScatterGraph = React.memo(
track('latency_advanced_labels_toggled', { enabled: checked });
if (checked && !showGradientLabels) {
window.dispatchEvent(
- new CustomEvent(GRADIENT_NUDGE_EVENT, {
+ new CustomEvent('inferencex:parallelism-label-enabled', {
detail: {
enableGradient: () => {
setShowGradientLabels(true);
diff --git a/packages/app/src/components/landing/landing-page.tsx b/packages/app/src/components/landing/landing-page.tsx
index 6c390779..6bd53854 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 { NudgeBannerSlot } from '@/components/nudges';
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/nudges/Nudge.tsx b/packages/app/src/components/nudges/Nudge.tsx
new file mode 100644
index 00000000..0f4e0a0a
--- /dev/null
+++ b/packages/app/src/components/nudges/Nudge.tsx
@@ -0,0 +1,148 @@
+'use client';
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { usePathname } from 'next/navigation';
+
+import { track } from '@/lib/analytics';
+import {
+ type NudgeContext,
+ type NudgeEntry,
+ isDismissed,
+ isWithinSchedule,
+ markDismissed,
+} from '@/lib/nudges';
+
+import { NudgeBanner } from './NudgeBanner';
+import { NudgeModal } from './NudgeModal';
+import { NudgeToast } from './NudgeToast';
+import { useNudgeSlot } from './NudgeProvider';
+import { useNudgeTrigger } from './use-nudge-trigger';
+
+interface NudgeProps {
+ entry: NudgeEntry;
+}
+
+/**
+ * Single nudge orchestrator. Handles the full lifecycle:
+ *
+ * route match → schedule check → persistence check → condition →
+ * trigger fires → slot request (modal/banner) → render → analytics
+ *
+ * Toast nudges skip the slot — `BottomToast` already self-coordinates a single
+ * visible toast via the `inferencex:dismiss-toast` window event.
+ */
+export function Nudge({ entry }: NudgeProps) {
+ const pathname = usePathname() ?? '';
+ const slot = useNudgeSlot();
+ const [visible, setVisible] = useState(false);
+ const [shownOnce, setShownOnce] = useState(false);
+
+ const routeMatches = useMemo(() => {
+ if (!entry.routes || entry.routes.length === 0) return true;
+ return entry.routes.some((re) => re.test(pathname));
+ }, [entry.routes, pathname]);
+
+ // Re-evaluated on every render so external state changes (e.g. another
+ // module called `markDismissed`) are picked up promptly.
+ const passesPreconditions =
+ routeMatches &&
+ isWithinSchedule(entry.schedule) &&
+ !isDismissed(entry.id, entry.persistence) &&
+ (entry.condition?.() ?? true);
+
+ const persistOn = entry.persistOn ?? (entry.kind === 'toast' ? 'show' : 'dismiss');
+
+ const { fired, detail } = useNudgeTrigger({
+ trigger: entry.trigger,
+ enabled: passesPreconditions,
+ alreadyShown: shownOnce,
+ });
+
+ // All kinds go through the slot manager so only one nudge per kind is
+ // visible at a time. Priority resolves ties: e.g. dsv4-launch-modal wins
+ // over github-star-modal, and eval-samples-nudge wins over reproducibility
+ // on /evaluation.
+ useEffect(() => {
+ if (!fired || shownOnce) return;
+ if (!passesPreconditions) return;
+ const priority = entry.priority ?? 0;
+ const granted = slot.requestSlot(entry.kind, entry.id, priority);
+ if (!granted) return;
+ setVisible(true);
+ setShownOnce(true);
+ if (persistOn === 'show') {
+ markDismissed(entry.id, entry.persistence);
+ }
+ track('nudge_shown', { id: entry.id, kind: entry.kind });
+ }, [
+ fired,
+ shownOnce,
+ passesPreconditions,
+ entry.id,
+ entry.kind,
+ entry.persistence,
+ entry.priority,
+ persistOn,
+ slot,
+ ]);
+
+ // External-dismiss listeners: hide without persisting (the underlying state
+ // these events represent is what gates re-show — e.g. starring sets
+ // `inferencex-starred` which `condition` checks).
+ const visibleRef = useRef(visible);
+ visibleRef.current = visible;
+ useEffect(() => {
+ if (!entry.externalDismissEvents || entry.externalDismissEvents.length === 0) return;
+ const handler = () => {
+ if (!visibleRef.current) return;
+ setVisible(false);
+ slot.releaseSlot(entry.kind, entry.id);
+ };
+ const events = entry.externalDismissEvents;
+ for (const name of events) window.addEventListener(name, handler);
+ return () => {
+ for (const name of events) window.removeEventListener(name, handler);
+ };
+ }, [entry.externalDismissEvents, entry.id, entry.kind, slot]);
+
+ // Release the slot if we get force-unmounted (route change while visible).
+ useEffect(
+ () => () => {
+ slot.releaseSlot(entry.kind, entry.id);
+ },
+ [entry.id, entry.kind, slot],
+ );
+
+ const dismiss = useCallback(() => {
+ if (!visible) return;
+ setVisible(false);
+ markDismissed(entry.id, entry.persistence);
+ slot.releaseSlot(entry.kind, entry.id);
+ track('nudge_dismissed', { id: entry.id, kind: entry.kind });
+ }, [visible, entry.id, entry.persistence, entry.kind, slot]);
+
+ const onAction = useCallback(() => {
+ setVisible(false);
+ markDismissed(entry.id, entry.persistence);
+ slot.releaseSlot(entry.kind, entry.id);
+ track('nudge_action_clicked', { id: entry.id, kind: entry.kind });
+ }, [entry.id, entry.persistence, entry.kind, slot]);
+
+ const ctx = useMemo(
+ () => ({ id: entry.id, triggerDetail: detail, dismiss }),
+ [entry.id, detail, dismiss],
+ );
+
+ if (!visible) return null;
+ if (!slot.isSlotHolder(entry.kind, entry.id)) return null;
+
+ const content = entry.render(ctx);
+
+ if (entry.kind === 'modal') {
+ return ;
+ }
+ if (entry.kind === 'banner') {
+ return ;
+ }
+ return ;
+}
diff --git a/packages/app/src/components/nudges/NudgeBanner.tsx b/packages/app/src/components/nudges/NudgeBanner.tsx
new file mode 100644
index 00000000..ae889f91
--- /dev/null
+++ b/packages/app/src/components/nudges/NudgeBanner.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { ArrowRight, X } from 'lucide-react';
+import { useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+
+import type { NudgeContent, NudgeContext } from '@/lib/nudges';
+
+interface NudgeBannerProps {
+ id: string;
+ content: NudgeContent;
+ ctx: NudgeContext;
+ onAction: () => void;
+}
+
+/**
+ * Inline announcement banner. The whole row is the primary action target; the X
+ * button on the right dismisses without triggering the action. Mirrors the
+ * original `LaunchBanner` styling.
+ */
+export function NudgeBanner({ id, content, ctx, onAction }: NudgeBannerProps) {
+ const router = useRouter();
+ const action = content.primaryAction;
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
+ if (!action) return;
+ e.preventDefault();
+ onAction();
+ if (action.onClick) action.onClick(ctx);
+ if (action.href) {
+ if (action.target === '_blank') {
+ window.open(action.href, '_blank', 'noopener,noreferrer');
+ } else if (action.inApp) {
+ router.push(action.href);
+ } else {
+ window.location.href = action.href;
+ }
+ }
+ },
+ [action, ctx, onAction, router],
+ );
+
+ const handleDismiss = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ ctx.dismiss();
+ },
+ [ctx],
+ );
+
+ const href = action?.href ?? '#';
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/nudges/NudgeBannerSlot.tsx b/packages/app/src/components/nudges/NudgeBannerSlot.tsx
new file mode 100644
index 00000000..bd065fcd
--- /dev/null
+++ b/packages/app/src/components/nudges/NudgeBannerSlot.tsx
@@ -0,0 +1,20 @@
+'use client';
+
+import { Nudge } from './Nudge';
+import { NUDGE_REGISTRY } from './registry';
+
+/**
+ * Inline banner mount point. Drop this anywhere in a page layout and any
+ * registry entry whose `kind === 'banner'` and whose `routes` match the current
+ * pathname will render here. Wrap somewhere in the tree with ``
+ * (already done in the root layout).
+ */
+export function NudgeBannerSlot() {
+ return (
+ <>
+ {NUDGE_REGISTRY.filter((e) => e.kind === 'banner').map((entry) => (
+
+ ))}
+ >
+ );
+}
diff --git a/packages/app/src/components/nudges/NudgeModal.tsx b/packages/app/src/components/nudges/NudgeModal.tsx
new file mode 100644
index 00000000..69bcd9c5
--- /dev/null
+++ b/packages/app/src/components/nudges/NudgeModal.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { X } from 'lucide-react';
+import { useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+
+import { Button } from '@/components/ui/button';
+
+import type { NudgeContent, NudgeContext } from '@/lib/nudges';
+
+interface NudgeModalProps {
+ id: string;
+ content: NudgeContent;
+ ctx: NudgeContext;
+ onAction: () => void;
+}
+
+/**
+ * Bottom-right modal-card. Renders the nudge as a non-blocking dialog with a
+ * dismiss (X) and a primary CTA. Mirrors the look of the original
+ * `Dsv4LaunchModal` / `GitHubStarModal` so the visual change is invisible.
+ */
+export function NudgeModal({ id, content, ctx, onAction }: NudgeModalProps) {
+ const router = useRouter();
+ const titleId = `nudge-${id}-title`;
+ const descriptionId = `nudge-${id}-description`;
+
+ const handleAction = useCallback(() => {
+ const action = content.primaryAction;
+ if (!action) return;
+ onAction();
+ if (action.onClick) action.onClick(ctx);
+ if (action.href) {
+ if (action.target === '_blank') {
+ window.open(action.href, '_blank', 'noopener,noreferrer');
+ } else if (action.inApp) {
+ router.push(action.href);
+ } else {
+ window.location.href = action.href;
+ }
+ }
+ }, [content.primaryAction, ctx, onAction, router]);
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/nudges/NudgeProvider.tsx b/packages/app/src/components/nudges/NudgeProvider.tsx
new file mode 100644
index 00000000..6ce6432a
--- /dev/null
+++ b/packages/app/src/components/nudges/NudgeProvider.tsx
@@ -0,0 +1,105 @@
+'use client';
+
+import {
+ type ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+import type { NudgeKind } from '@/lib/nudges';
+
+/**
+ * Slot manager for kinds that can only host one nudge at a time (modal,
+ * banner). Each candidate calls `requestSlot` with its priority; the highest
+ * priority requester wins. Toasts coordinate via the existing
+ * `inferencex:dismiss-toast` event in BottomToast and don't use the slot.
+ */
+interface NudgeProviderState {
+ requestSlot: (kind: NudgeKind, id: string, priority: number) => boolean;
+ releaseSlot: (kind: NudgeKind, id: string) => void;
+ isSlotHolder: (kind: NudgeKind, id: string) => boolean;
+}
+
+const NudgeContext = createContext(null);
+
+interface SlotState {
+ holder: string | null;
+ candidates: Map;
+}
+
+function pickHolder(candidates: Map): string | null {
+ let bestId: string | null = null;
+ let bestPriority = -Infinity;
+ for (const [id, priority] of candidates) {
+ if (priority > bestPriority) {
+ bestPriority = priority;
+ bestId = id;
+ }
+ }
+ return bestId;
+}
+
+export function NudgeProvider({ children }: { children: ReactNode }) {
+ const [, forceRender] = useState(0);
+ const slotsRef = useRef