From 6c03397038883c84fd0ef43226bfc40f3f1f003f Mon Sep 17 00:00:00 2001 From: Peter Slijkhuis Date: Wed, 30 Sep 2026 15:42:11 +0000 Subject: [PATCH] Add a first-visit tour that points out each part of the site On a first visit a short tour spotlights the modules, the avatar, streak and points, the reference tools, the Continue button, the Where to? helper and the R status, one step at a time, with Back, Next and Skip tour on every step. Steps whose target is not on screen are left out, so a phone gets the Lessons button instead of the hidden sidebar. It is shown once per browser and can be started again from the helper. --- e2e/tour.spec.ts | 39 +++++++++ playwright.config.ts | 8 +- src/App.tsx | 2 + src/components/Sidebar.tsx | 2 +- src/components/SiteGuide.css | 1 + src/components/SiteGuide.tsx | 2 + src/components/Tour.css | 22 +++++ src/components/Tour.test.tsx | 37 +++++++++ src/components/Tour.tsx | 151 +++++++++++++++++++++++++++++++++++ 9 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 e2e/tour.spec.ts create mode 100644 src/components/Tour.css create mode 100644 src/components/Tour.test.tsx create mode 100644 src/components/Tour.tsx 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 ? ( +