Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions e2e/tour.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';

// A first visit: the config marks the tour as seen for every other spec.
test.use({ storageState: { cookies: [], origins: [] }, reducedMotion: 'reduce' });

test('the tour shows on a first visit, steps through, and stays away once skipped', async ({ page }) => {
await page.goto('./');
const tour = page.getByRole('dialog', { name: 'Welcome to StatLab' });
await expect(tour).toBeVisible();
expect((await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).include('.tour-card').analyze()).violations).toEqual([]);

await page.getByRole('button', { name: 'Show me around' }).click();
await expect(page.getByRole('dialog', { name: 'The modules' })).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByRole('dialog', { name: 'Your avatar' })).toBeVisible();
await page.getByRole('button', { name: 'Back' }).click();
await expect(page.getByRole('dialog', { name: 'The modules' })).toBeVisible();

await page.getByRole('button', { name: 'Skip tour' }).click();
await expect(page.locator('.tour')).toHaveCount(0);
await page.reload();
await page.waitForTimeout(1000);
await expect(page.locator('.tour')).toHaveCount(0);

// The helper can bring it back.
await page.getByRole('button', { name: /Where to\?/ }).click();
await page.getByRole('button', { name: 'Show me around the site' }).click();
await expect(page.getByRole('dialog', { name: 'Welcome to StatLab' })).toBeVisible();
});

test('on a phone the tour points at the Lessons button instead of the hidden sidebar', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 740 });
await page.goto('./');
await page.getByRole('button', { name: 'Show me around' }).click();
await expect(page.getByRole('dialog', { name: 'The lessons' })).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByRole('dialog', { name: 'The modules' })).toHaveCount(0);
});
8 changes: 7 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ export default defineConfig({
// Tests download R and packages from the network; one retry absorbs a blip,
// and a trace of the failed attempt makes the uploaded report diagnosable.
retries: process.env.CI ? 1 : 0,
use: { baseURL: 'http://localhost:4173/statlab/', trace: 'retain-on-failure' },
// The first-visit tour is marked as seen, so it does not cover the pages under
// test; e2e/tour.spec.ts clears this to test the tour itself.
use: {
baseURL: 'http://localhost:4173/statlab/',
trace: 'retain-on-failure',
storageState: { cookies: [], origins: [{ origin: 'http://localhost:4173', localStorage: [{ name: 'statlab.tour.v1', value: 'done' }] }] },
},
webServer: {
command: 'npm run build && npm run preview -- --port 4173 --strictPort',
url: 'http://localhost:4173/statlab/',
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import RStatus from './components/RStatus';
import Logo from './components/Logo';
import Sidebar from './components/Sidebar';
import Tour from './components/Tour';
import SiteGuide from './components/SiteGuide';
import Toaster from './components/Toaster';
import { confetti, prefersReducedMotion, showToast } from './components/celebrate';
Expand Down Expand Up @@ -127,6 +128,7 @@ export default function App() {
</main>
<Toaster />
<SiteGuide />
<Tour />
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export default function Sidebar({ open = false }: Props) {
</div>
))}

<div className="sidebar-part">
<div className="sidebar-part sidebar-reference">
<p className="sidebar-part-title">Reference</p>
<ul>
<li><NavLink to="/which-model" className="sidebar-lesson"><span className="sidebar-lesson-icon" aria-hidden="true">🧭</span>Which model should I use?</NavLink></li>
Expand Down
1 change: 1 addition & 0 deletions src/components/SiteGuide.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
.site-guide-compact .site-guide-button { padding: 0.25rem; }
/* The R status pill also sits bottom right: stand on top of it while it shows. */
body:has(.r-status.ready, .r-status.busy) .site-guide { bottom: calc(max(1rem, env(safe-area-inset-bottom)) + 3.5rem); }
.site-guide-tour { display: block; margin: 0.8rem 0 0; padding: 0; font: inherit; font-size: 0.88rem; background: none; border: 0; color: var(--brand-ink); text-decoration: underline; cursor: pointer; }
2 changes: 2 additions & 0 deletions src/components/SiteGuide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ALL_LESSONS, findLesson } from '../content/manifest';
import { currentLook } from '../state/avatar';
import { getProgress, lastVisitedLesson } from '../state/progress';
import Avatar from './Avatar';
import { startTour } from './Tour';
import './SiteGuide.css';

const STARTERS = ['I am new to R', 'Which test do I need?', 'How many participants?', 'Make a plot'];
Expand Down Expand Up @@ -70,6 +71,7 @@ export default function SiteGuide() {
<button key={starter} type="button" onClick={() => { setText(starter); inputRef.current?.focus(); }}>{starter}</button>
))}
</div>
<button type="button" className="site-guide-tour" onClick={() => { setOpen(false); startTour(); }}>Show me around the site</button>
</>
) : hits.length > 0 ? (
<ul className="site-guide-hits" aria-live="polite">
Expand Down
22 changes: 22 additions & 0 deletions src/components/Tour.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* Above the helper (50), toasts (60) and confetti (70): nothing else is clickable during the tour. */
.tour { position: fixed; inset: 0; z-index: 80; }
.tour-dim, .tour-spotlight { position: fixed; pointer-events: none; }
.tour-dim { inset: 0; background: rgba(15, 23, 42, 0.55); }
.tour-spotlight {
border-radius: 14px;
box-shadow: 0 0 0 3px #fff, 0 0 0 7px var(--brand), 0 0 0 200vmax rgba(15, 23, 42, 0.55);
transition: left var(--medium, 0.25s), top var(--medium, 0.25s), width var(--medium, 0.25s), height var(--medium, 0.25s);
}
.tour-card {
position: fixed; box-sizing: border-box; padding: 1.1rem 1.2rem 1rem;
background: var(--surface); color: var(--ink); border-radius: 16px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.35);
}
.tour-card h2 { margin: 0.1rem 0 0.4rem; font-size: 1.15rem; }
.tour-card p { margin: 0 0 0.9rem; line-height: 1.5; color: var(--ink-2); }
.tour-card .tour-count { margin: 0; font-size: 0.72rem; font-weight: 750; letter-spacing: 0.09em; text-transform: uppercase; color: var(--brand); }
.tour-actions { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.tour-actions .button-primary { margin-left: auto; }
.tour-skip { font: inherit; font-size: 0.9rem; background: none; border: 0; padding: 0.4rem 0.2rem; color: var(--muted); text-decoration: underline; cursor: pointer; }
.tour-skip:hover { color: var(--brand-ink); }
@media (prefers-reduced-motion: reduce) { .tour-spotlight { transition: none; } }
37 changes: 37 additions & 0 deletions src/components/Tour.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Tour, { startTour, TOUR_KEY } from './Tour';

describe('Tour', () => {
beforeEach(() => {
localStorage.removeItem(TOUR_KEY);
vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());

it('opens on a first visit and is not shown again once skipped', () => {
const { unmount } = render(<Tour />);
act(() => vi.advanceTimersByTime(500));
expect(screen.getByRole('dialog', { name: 'Welcome to StatLab' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Skip tour' }));
expect(screen.queryByRole('dialog')).toBeNull();
expect(localStorage.getItem(TOUR_KEY)).toBe('done');
unmount();

render(<Tour />);
act(() => vi.advanceTimersByTime(500));
expect(screen.queryByRole('dialog')).toBeNull();
act(() => startTour());
expect(screen.getByRole('dialog', { name: 'Welcome to StatLab' })).toBeTruthy();
});

it('leaves out steps whose target is not on the page', () => {
render(<Tour />);
act(() => vi.advanceTimersByTime(500));
// jsdom lays nothing out, so only the two steps without a target remain.
expect(screen.getByText('1 of 2')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Show me around' }));
fireEvent.click(screen.getByRole('button', { name: 'Start learning' }));
expect(localStorage.getItem(TOUR_KEY)).toBe('done');
});
});
151 changes: 151 additions & 0 deletions src/components/Tour.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import './Tour.css';

export const TOUR_KEY = 'statlab.tour.v1';
const RESTART = 'statlab:tour';

/** Opens the tour again, from anywhere (the helper's "Show me around" button). */
export function startTour() {
window.dispatchEvent(new Event(RESTART));
}

type Step = { target?: string; title: string; text: string };

/**
* Each step points at one part of the page. A step whose target is missing or
* off screen (the sidebar on a phone, the Continue button off the home page)
* is left out, so one list serves every page and screen size.
*/
export const STEPS: Step[] = [
{ title: 'Welcome to StatLab', text: 'A quick tour of where everything is. Skip it whenever you like; the helper in the corner can show it again.' },
{ target: '.topbar-menu', title: 'The lessons', text: 'On a small screen the modules, your avatar and the tools live behind this button.' },
{ target: '.sidebar-part', title: 'The modules', text: 'The course, in order: open a module to see its lessons. A tick means you finished it.' },
{ target: '.sidebar-avatar', title: 'Your avatar', text: 'Design it, then spend your points in the shop. It also explains your mistakes in the exercises.' },
{ target: '.sidebar-stats', title: 'Streak and points', text: 'Days in a row, points earned, and lessons done. Exercises, questions and finished lessons all earn points.' },
{ target: '.sidebar-reference', title: 'Reference tools', text: 'Which model to use, how many participants you need, a review quiz, and an R Workspace for your own data.' },
{ target: '.hero-cta', title: 'Where to start', text: 'This button always opens your next lesson, so you never have to look for where you left off.' },
{ target: '.site-guide-button', title: 'Lost? Ask your avatar', text: 'Type what you are looking for, like "compare two groups", and it points you to the right lesson or tool.' },
{ target: '.r-status', title: 'R, in your browser', text: 'R starts here on its own, which takes a moment the first time. If it ever stops responding, press Restart R.' },
{ title: 'You are all set', text: 'Everything runs in your browser and your progress stays on this computer. Have fun!' },
];

function visible(selector?: string): Element | null {
if (!selector) return null;
const element = document.querySelector(selector);
if (!element) return null;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && rect.right > 0 && rect.left < window.innerWidth ? element : null;
}

function seen(): boolean {
try {
return localStorage.getItem(TOUR_KEY) === 'done';
} catch {
return true; // No storage: showing the tour on every page load would be worse than never.
}
}

export default function Tour() {
const [steps, setSteps] = useState<Step[] | null>(null);
const [at, setAt] = useState(0);
const [rect, setRect] = useState<DOMRect | null>(null);
const card = useRef<HTMLDivElement>(null);
const primary = useRef<HTMLButtonElement>(null);
const id = useId();

const open = useCallback(() => {
// Measured at the start: steps whose target is not on this page drop out.
setSteps(STEPS.filter((step) => !step.target || visible(step.target)));
setAt(0);
}, []);

useEffect(() => {
// After the first paint, so the sidebar and home page are there to point at.
const timer = window.setTimeout(() => { if (!seen()) open(); }, 400);
window.addEventListener(RESTART, open);
return () => {
window.clearTimeout(timer);
window.removeEventListener(RESTART, open);
};
}, [open]);

const step = steps?.[at];

useLayoutEffect(() => {
if (!step) return;
const element = visible(step.target);
element?.scrollIntoView({ block: 'nearest' });
const measure = () => setRect(visible(step.target)?.getBoundingClientRect() ?? null);
measure();
window.addEventListener('resize', measure);
window.addEventListener('scroll', measure, true);
return () => {
window.removeEventListener('resize', measure);
window.removeEventListener('scroll', measure, true);
};
}, [step]);

useEffect(() => primary.current?.focus(), [step]);

if (!steps || !step) return null;

function finish() {
try {
localStorage.setItem(TOUR_KEY, 'done');
} catch {
// Nothing to do: the tour simply shows again next time.
}
setSteps(null);
}

const last = at === steps.length - 1;
const pad = 6;
const width = Math.min(340, window.innerWidth - 32);
let place: React.CSSProperties = { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
if (rect) {
const clampLeft = (x: number) => Math.max(16, Math.min(x, window.innerWidth - width - 16));
const height = card.current?.offsetHeight ?? 200;
const clampTop = (y: number) => Math.max(16, Math.min(y, window.innerHeight - height - 16));
if (rect.right + pad + 16 + width < window.innerWidth - 16 && rect.width < window.innerWidth / 2) {
place = { left: rect.right + pad + 16, top: clampTop(rect.top) };
} else if (window.innerHeight - rect.bottom > height + 32) {
place = { left: clampLeft(rect.left), top: rect.bottom + pad + 12 };
} else {
place = { left: clampLeft(rect.right - width), top: clampTop(rect.top - pad - 12 - height) };
}
}

return (
<div className="tour" onKeyDown={(e) => { if (e.key === 'Escape') finish(); }}>
{rect ? (
<div
className="tour-spotlight"
style={{ left: rect.left - pad, top: rect.top - pad, width: rect.width + 2 * pad, height: rect.height + 2 * pad }}
aria-hidden="true"
/>
) : (
<div className="tour-dim" aria-hidden="true" />
)}
<div
ref={card}
className="tour-card"
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-text`}
style={{ ...place, width }}
>
<p className="tour-count">{at + 1} of {steps.length}</p>
<h2 id={`${id}-title`}>{step.title}</h2>
<p id={`${id}-text`}>{step.text}</p>
<div className="tour-actions">
{!last && <button type="button" className="tour-skip" onClick={finish}>Skip tour</button>}
{at > 0 && <button type="button" className="button-secondary" onClick={() => setAt(at - 1)}>Back</button>}
<button ref={primary} type="button" className="button-primary" onClick={() => (last ? finish() : setAt(at + 1))}>
{last ? 'Start learning' : at === 0 ? 'Show me around' : 'Next'}
</button>
</div>
</div>
</div>
);
}