diff --git a/e2e/tour.spec.ts b/e2e/tour.spec.ts
new file mode 100644
index 0000000..1384e21
--- /dev/null
+++ b/e2e/tour.spec.ts
@@ -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);
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 81235ac..cdf61b4 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -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/',
diff --git a/src/App.tsx b/src/App.tsx
index 5e1318f..e955874 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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';
@@ -127,6 +128,7 @@ export default function App() {
+
);
}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 04edd1b..86c6374 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -115,7 +115,7 @@ export default function Sidebar({ open = false }: Props) {
))}
-
+
Reference
- ðŸ§Which model should I use?
diff --git a/src/components/SiteGuide.css b/src/components/SiteGuide.css
index aa7e1a2..c8997f8 100644
--- a/src/components/SiteGuide.css
+++ b/src/components/SiteGuide.css
@@ -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; }
diff --git a/src/components/SiteGuide.tsx b/src/components/SiteGuide.tsx
index 9d50e67..33af0ee 100644
--- a/src/components/SiteGuide.tsx
+++ b/src/components/SiteGuide.tsx
@@ -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'];
@@ -70,6 +71,7 @@ export default function SiteGuide() {
))}
+
>
) : hits.length > 0 ? (
diff --git a/src/components/Tour.css b/src/components/Tour.css
new file mode 100644
index 0000000..d2945f8
--- /dev/null
+++ b/src/components/Tour.css
@@ -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; } }
diff --git a/src/components/Tour.test.tsx b/src/components/Tour.test.tsx
new file mode 100644
index 0000000..e5136f0
--- /dev/null
+++ b/src/components/Tour.test.tsx
@@ -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();
+ 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();
+ 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();
+ 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');
+ });
+});
diff --git a/src/components/Tour.tsx b/src/components/Tour.tsx
new file mode 100644
index 0000000..529ab8a
--- /dev/null
+++ b/src/components/Tour.tsx
@@ -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(null);
+ const [at, setAt] = useState(0);
+ const [rect, setRect] = useState(null);
+ const card = useRef(null);
+ const primary = useRef(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 (
+ { if (e.key === 'Escape') finish(); }}>
+ {rect ? (
+
+ ) : (
+
+ )}
+
+
{at + 1} of {steps.length}
+
{step.title}
+
{step.text}
+
+ {!last && }
+ {at > 0 && }
+
+
+
+
+ );
+}