Skip to content

Commit b00742b

Browse files
feat(nudges): add unified nudge system (#321)
1 parent cac6246 commit b00742b

25 files changed

Lines changed: 1514 additions & 1057 deletions

packages/app/cypress/e2e/navigation.cy.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,11 @@ describe('First-load navigation', () => {
4848
win.localStorage.removeItem('inferencex-starred');
4949
win.localStorage.removeItem('inferencex-star-modal-dismissed');
5050
win.localStorage.removeItem('inferencex-dsv4-modal-dismissed');
51+
win.localStorage.removeItem('inferencex-dsv4-banner-dismissed');
5152
},
5253
});
5354

54-
// dsv4 launch modal takes precedence over the GitHub star modal on first
55-
// load — only one modal shows at a time. Either is fine for this test, we
56-
// just need *a* first-load modal up to verify it doesn't block navigation.
55+
// Banner (inline) and overlay modal coexist in independent slots.
5756
cy.get('[data-testid="dsv4-launch-modal"]').should('be.visible');
5857
cy.get('body').should('not.have.attr', 'data-scroll-locked');
5958
});
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/**
2+
* E2E tests for the unified NudgeEngine.
3+
*
4+
* Covers: landing modals (priority ordering, dismissal persistence),
5+
* landing banner, dashboard toasts, evaluation toast, and the
6+
* permanent-suppress ("starred") cross-nudge mechanism.
7+
*/
8+
9+
// ---------------------------------------------------------------------------
10+
// Helpers
11+
// ---------------------------------------------------------------------------
12+
13+
function clearAllNudgeStorage(win: Cypress.AUTWindow) {
14+
const keys = [
15+
'inferencex-starred',
16+
'inferencex-star-modal-dismissed',
17+
'inferencex-dsv4-modal-dismissed',
18+
'inferencex-dsv4-banner-dismissed',
19+
'inferencex-reproducibility-nudge-shown',
20+
'inferencex-star-nudge-shown',
21+
'inferencex-export-nudge-shown',
22+
'inferencex-gradient-nudge-shown',
23+
'inferencex-eval-samples-nudge-dismissed',
24+
];
25+
for (const key of keys) {
26+
win.localStorage.removeItem(key);
27+
win.sessionStorage.removeItem(key);
28+
}
29+
}
30+
31+
// ---------------------------------------------------------------------------
32+
// Landing — modal priority & dismissal
33+
// ---------------------------------------------------------------------------
34+
35+
describe('Landing nudges — modals', () => {
36+
it('shows dsv4 modal and banner simultaneously on fresh first load', () => {
37+
cy.visit('/', {
38+
onBeforeLoad: clearAllNudgeStorage,
39+
});
40+
// Banner (inline) and modal (overlay) occupy independent slots
41+
cy.get('[data-testid="launch-banner"]').should('be.visible');
42+
cy.get('[data-testid="dsv4-launch-modal"]').should('be.visible');
43+
// Only one overlay at a time — star modal should not appear
44+
cy.get('[data-testid="github-star-modal"]').should('not.exist');
45+
});
46+
47+
it('dismissing dsv4 modal persists — not shown on reload', () => {
48+
cy.visit('/', {
49+
onBeforeLoad: clearAllNudgeStorage,
50+
});
51+
cy.get('[data-testid="dsv4-launch-modal"]').should('be.visible');
52+
cy.get('[data-testid="dsv4-launch-modal-dismiss"]').click();
53+
cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist');
54+
55+
cy.reload();
56+
cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist');
57+
});
58+
59+
it('shows star modal when dsv4 modal was previously dismissed', () => {
60+
cy.visit('/', {
61+
onBeforeLoad(win) {
62+
clearAllNudgeStorage(win);
63+
win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1');
64+
},
65+
});
66+
cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist');
67+
cy.get('[data-testid="github-star-modal"]').should('be.visible');
68+
});
69+
70+
it('star modal dismiss uses timed strategy — re-shows after expiry', () => {
71+
cy.visit('/', {
72+
onBeforeLoad(win) {
73+
clearAllNudgeStorage(win);
74+
win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1');
75+
},
76+
});
77+
cy.get('[data-testid="github-star-modal"]').should('be.visible');
78+
cy.get('[data-testid="github-star-modal-dismiss"]').click();
79+
cy.get('[data-testid="github-star-modal"]').should('not.exist');
80+
81+
cy.window().then((win) => {
82+
const value = win.localStorage.getItem('inferencex-star-modal-dismissed');
83+
expect(value).to.not.equal(null);
84+
expect(Number(value)).to.be.greaterThan(0);
85+
});
86+
});
87+
88+
it('starring permanently suppresses both star modal and star nudge', () => {
89+
cy.visit('/', {
90+
onBeforeLoad(win) {
91+
clearAllNudgeStorage(win);
92+
win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1');
93+
},
94+
});
95+
cy.get('[data-testid="github-star-modal"]').should('be.visible');
96+
cy.get('[data-testid="github-star-modal-action"]').click();
97+
cy.get('[data-testid="github-star-modal"]').should('not.exist');
98+
99+
cy.window().then((win) => {
100+
expect(win.localStorage.getItem('inferencex-starred')).to.eq('1');
101+
});
102+
});
103+
});
104+
105+
// ---------------------------------------------------------------------------
106+
// Landing — banner
107+
// ---------------------------------------------------------------------------
108+
109+
describe('Landing nudges — banner', () => {
110+
it('shows launch banner on landing page', () => {
111+
cy.visit('/', {
112+
onBeforeLoad(win) {
113+
clearAllNudgeStorage(win);
114+
// Dismiss modals so the banner (highest priority at 60) is the active nudge.
115+
// Actually the banner has priority 60 > dsv4 modal 50, so it should show first.
116+
// But the engine only shows one nudge at a time; the banner wins because of priority.
117+
},
118+
});
119+
// The banner has the highest priority (60), so it should appear.
120+
// However, NudgeEngine only shows one nudge at a time.
121+
// With immediate triggers and priority 60 > 50 > 40, the banner wins.
122+
cy.get('[data-testid="launch-banner"]').should('be.visible');
123+
});
124+
125+
it('banner renders within container constraints (not full-width)', () => {
126+
cy.visit('/', {
127+
onBeforeLoad: clearAllNudgeStorage,
128+
});
129+
cy.get('[data-testid="launch-banner"]').should('be.visible');
130+
// The banner's parent section has the container class for width constraints
131+
cy.get('[data-testid="launch-banner"]').parent('section.container').should('exist');
132+
});
133+
134+
it('dismissing the banner persists across reloads', () => {
135+
cy.visit('/', {
136+
onBeforeLoad: clearAllNudgeStorage,
137+
});
138+
cy.get('[data-testid="launch-banner"]').should('be.visible');
139+
cy.get('[data-testid="launch-banner-dismiss"]').click();
140+
cy.get('[data-testid="launch-banner"]').should('not.exist');
141+
142+
cy.reload();
143+
cy.get('[data-testid="launch-banner"]').should('not.exist');
144+
});
145+
});
146+
147+
// ---------------------------------------------------------------------------
148+
// Dashboard — reproducibility toast
149+
// ---------------------------------------------------------------------------
150+
151+
describe('Dashboard nudges — reproducibility toast', () => {
152+
it('shows reproducibility nudge after 1.5s delay on dashboard', () => {
153+
cy.visit('/inference', {
154+
onBeforeLoad(win) {
155+
clearAllNudgeStorage(win);
156+
},
157+
});
158+
// Should not be visible immediately
159+
cy.get('[data-testid="reproducibility-nudge"]').should('not.exist');
160+
// After the timer fires (~1.5s + buffer)
161+
cy.get('[data-testid="reproducibility-nudge"]', { timeout: 4000 }).should('be.visible');
162+
});
163+
164+
it('reproducibility nudge is session-only — gone after reload', () => {
165+
cy.visit('/inference', {
166+
onBeforeLoad(win) {
167+
clearAllNudgeStorage(win);
168+
},
169+
});
170+
cy.get('[data-testid="reproducibility-nudge"]', { timeout: 4000 }).should('be.visible');
171+
172+
// Session storage should be set
173+
cy.window().then((win) => {
174+
expect(win.sessionStorage.getItem('inferencex-reproducibility-nudge-shown')).to.not.equal(
175+
null,
176+
);
177+
});
178+
});
179+
});
180+
181+
// ---------------------------------------------------------------------------
182+
// Evaluation — eval-samples toast
183+
// ---------------------------------------------------------------------------
184+
185+
describe('Evaluation nudges — eval-samples toast', () => {
186+
it('shows eval-samples nudge after delay on evaluation page', () => {
187+
cy.visit('/evaluation', {
188+
onBeforeLoad(win) {
189+
clearAllNudgeStorage(win);
190+
},
191+
});
192+
cy.get('[data-testid="eval-samples-nudge"]', { timeout: 4000 }).should('be.visible');
193+
});
194+
195+
it('eval-samples nudge uses timed dismissal (localStorage timestamp)', () => {
196+
cy.visit('/evaluation', {
197+
onBeforeLoad(win) {
198+
clearAllNudgeStorage(win);
199+
},
200+
});
201+
cy.get('[data-testid="eval-samples-nudge"]', { timeout: 4000 }).should('be.visible');
202+
203+
// The engine marks it dismissed on show — verify a timestamp is stored
204+
cy.window().then((win) => {
205+
const value = win.localStorage.getItem('inferencex-eval-samples-nudge-dismissed');
206+
expect(value).to.not.equal(null);
207+
expect(Number(value)).to.be.greaterThan(0);
208+
});
209+
});
210+
});
211+
212+
// ---------------------------------------------------------------------------
213+
// Cross-scope isolation
214+
// ---------------------------------------------------------------------------
215+
216+
describe('Nudge scope isolation', () => {
217+
it('landing nudges do not appear on dashboard', () => {
218+
cy.visit('/inference', {
219+
onBeforeLoad: clearAllNudgeStorage,
220+
});
221+
cy.get('[data-testid="dsv4-launch-modal"]').should('not.exist');
222+
cy.get('[data-testid="github-star-modal"]').should('not.exist');
223+
cy.get('[data-testid="launch-banner"]').should('not.exist');
224+
});
225+
226+
it('dashboard nudges do not appear on landing page', () => {
227+
cy.visit('/', {
228+
onBeforeLoad(win) {
229+
clearAllNudgeStorage(win);
230+
// Dismiss all landing nudges so nothing blocks visibility checks
231+
win.localStorage.setItem('inferencex-dsv4-modal-dismissed', '1');
232+
win.localStorage.setItem('inferencex-dsv4-banner-dismissed', '1');
233+
win.localStorage.setItem('inferencex-starred', '1');
234+
},
235+
});
236+
// Wait a bit for any timer-based nudges
237+
cy.wait(2000);
238+
cy.get('[data-testid="reproducibility-nudge"]').should('not.exist');
239+
cy.get('[data-testid="star-nudge"]').should('not.exist');
240+
cy.get('[data-testid="export-nudge"]').should('not.exist');
241+
});
242+
});
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Metadata } from 'next';
22

3-
import { EvalSamplesNudge } from '@/components/eval-samples-nudge';
43
import { EvaluationProvider } from '@/components/evaluation/EvaluationContext';
54
import EvaluationChartDisplay from '@/components/evaluation/ui/ChartDisplay';
5+
import { NudgeEngine } from '@/components/nudge-engine';
66
import { tabMetadata } from '@/lib/tab-meta';
77

88
export const metadata: Metadata = tabMetadata('evaluation');
@@ -11,7 +11,7 @@ export default function EvaluationPage() {
1111
return (
1212
<EvaluationProvider>
1313
<EvaluationChartDisplay />
14-
<EvalSamplesNudge />
14+
<NudgeEngine scope="evaluation" />
1515
</EvaluationProvider>
1616
);
1717
}

packages/app/src/components/dashboard-shell.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
'use client';
22

3-
import { ExportNudge } from '@/components/export-nudge';
43
import { GlobalFilterProvider } from '@/components/GlobalFilterContext';
5-
import { GradientLabelNudge } from '@/components/gradient-label-nudge';
6-
import { ReproducibilityNudge } from '@/components/reproducibility-nudge';
7-
import { StarNudge } from '@/components/star-nudge';
4+
import { NudgeEngine } from '@/components/nudge-engine';
85
import { TabNav } from '@/components/tab-nav';
96
import { UnofficialRunProvider } from '@/components/unofficial-run-provider';
107

118
export function DashboardShell({ children }: { children: React.ReactNode }) {
129
return (
1310
<>
14-
<ReproducibilityNudge />
15-
<StarNudge />
16-
<ExportNudge />
17-
<GradientLabelNudge />
11+
<NudgeEngine scope="dashboard" />
1812
<UnofficialRunProvider>
1913
<main className="relative">
2014
<div className="container mx-auto px-4 lg:px-8 flex flex-col gap-4">

packages/app/src/components/dsv4-launch-modal.tsx

Lines changed: 0 additions & 92 deletions
This file was deleted.

0 commit comments

Comments
 (0)