feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)

Hand-rolled 4-step tour (no react-joyride) anchors to existing
[data-tour="<id>"] attributes. localStorage key
mealplanner:onboarding-complete is the source of truth; ?reset-tour=1
clears the key and re-shows.

Steps: Dashboard / Pantry / Recipes / Shopping List. Keyboard: 1-4 jump,
←/→ step, Esc dismiss. Off-route fallback renders a centered card with
an 'Open <page>' CTA. A11y: role=dialog, aria-modal=true, focus captured
on open and restored on close.

5 lines of code across 4 pages; 1 new component (~420 lines). No new
dependencies. No backend changes. No migration. Frontend-only deploy.

Tracking: Review/sprint9-verification.md (8-step browser smoke + a11y
check + reset-link test).
This commit is contained in:
2026-06-05 11:14:53 -07:00
parent efd1fc695f
commit 6e386baf6e
13 changed files with 839 additions and 25 deletions
+14
View File
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Link, useLocation, useNavigate, Navigate
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ShortcutHelpBanner, SHOW_SHORTCUT_HELP_EVENT } from './components/ShortcutHelpBanner'
import { OnboardingTour, useOnboarding } from './components/OnboardingTour'
import { showApiError } from './lib/toast'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { requestFocusSearch } from './hooks/useFocusSearch'
@@ -73,6 +74,10 @@ function GlobalShortcuts() {
}
function App() {
// Onboarding tour state lives at the App root so the localStorage
// key is read once on mount. The tour itself is mounted inside the
// router (it needs useLocation / useNavigate).
const onboarding = useOnboarding()
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
@@ -94,6 +99,15 @@ function App() {
</Routes>
</main>
<ShortcutHelpBanner />
<OnboardingTour
isComplete={onboarding.isComplete}
onComplete={() => {
// The tour already wrote the localStorage key via
// writeComplete(); flip the App-level flag so it stays
// hidden after the next render.
onboarding.reset()
}}
/>
</div>
</BrowserRouter>
</QueryClientProvider>
+428
View File
@@ -0,0 +1,428 @@
/**
* Onboarding tour — first-visit welcome walkthrough.
*
* Hand-rolled to avoid pulling in `react-joyride` (no new npm dep).
* Anchors to elements via [data-tour="<id>"] attributes. The tour is
* controlled by a single useState pair: `step` (0..N) and a
* `dismissed` boolean persisted in localStorage.
*
* Behaviour:
* - First visit (no localStorage key): auto-shows on Dashboard mount.
* - User clicks "Got it" on the last step → writes the key, hides.
* - User clicks "Skip" on any step → same as "Got it".
* - User presses Escape → same as "Skip".
* - User presses 1..N (when tour is visible) → jumps to that step.
* - User presses ArrowLeft / ArrowRight → steps back / forward.
* - User adds `?reset-tour=1` to any URL → clears the key, shows.
* - Subsequent visits (key is set) → tour is hidden.
*
* Tour re-shows when the user navigates to a new step's anchor page
* (the anchor for step N must be on the current route; otherwise the
* step is rendered as a centered card with a "Go to <page>" button).
*
* Accessibility:
* - role="dialog", aria-modal="true", aria-labelledby points to the
* step title.
* - Focus is moved to the primary action button when the step opens.
* - The previous focused element is restored on dismiss.
* - Tooltip is a real <div> not a portal, so screen readers find it.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { CalendarDays, ShoppingBasket, SlidersHorizontal, Truck, X } from 'lucide-react'
const STORAGE_KEY = 'mealplanner:onboarding-complete'
const RESET_PARAM = 'reset-tour'
export type TourStepId = 'dashboard' | 'pantry' | 'recipes' | 'shopping-list'
interface TourStep {
id: TourStepId
route: string
title: string
body: string
icon: typeof CalendarDays
}
const STEPS: TourStep[] = [
{
id: 'dashboard',
route: '/',
title: 'Your weekly meal plan',
body: 'The dashboard shows the upcoming MonSun week. Approve, deny, or skip meals, and the planner learns what your family likes.',
icon: CalendarDays,
},
{
id: 'pantry',
route: '/pantry',
title: 'What you have in stock',
body: 'Pantry tracks ingredients already at home. The planner uses it to avoid duplicates and the shopping list subtracts from it.',
icon: Truck,
},
{
id: 'recipes',
route: '/recipes',
title: 'Browse + filter recipes',
body: 'Open the Filters panel to narrow by cuisine, protein, dietary tag, prep time, spice, or calories. Search by name or ingredient.',
icon: SlidersHorizontal,
},
{
id: 'shopping-list',
route: '/shopping-list',
title: 'Plan → shop → restock',
body: 'The shopping list is generated from the plan, minus what is in your pantry. Check items and bulk-add them back to pantry when you return from the store.',
icon: ShoppingBasket,
},
]
function readComplete(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === '1'
} catch {
return false
}
}
function writeComplete(): void {
try {
localStorage.setItem(STORAGE_KEY, '1')
} catch {
// localStorage may be disabled (private mode, etc.) — silently
// skip; the tour will just re-show on the next visit.
}
}
function clearComplete(): void {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// see above
}
}
export function useOnboarding(): { reset: () => void; show: () => void; isComplete: boolean } {
const [isComplete, setIsComplete] = useState<boolean>(readComplete)
const reset = useCallback(() => {
clearComplete()
setIsComplete(false)
}, [])
const show = useCallback(() => {
clearComplete()
setIsComplete(false)
}, [])
return { reset, show, isComplete }
}
export function OnboardingTour({
isComplete,
onComplete,
}: {
isComplete: boolean
onComplete: () => void
}) {
const location = useLocation()
const navigate = useNavigate()
const [step, setStep] = useState(0)
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
const dialogRef = useRef<HTMLDivElement | null>(null)
const primaryRef = useRef<HTMLButtonElement | null>(null)
const previouslyFocused = useRef<HTMLElement | null>(null)
const currentStep = STEPS[step]
const isLast = step === STEPS.length - 1
// Handle ?reset-tour=1 (clears the key; tour shows on next render).
useEffect(() => {
const sp = new URLSearchParams(location.search)
if (sp.get(RESET_PARAM) === '1') {
clearComplete()
onComplete()
// Strip the param so a refresh doesn't re-trigger the reset.
sp.delete(RESET_PARAM)
const next = sp.toString()
navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true })
setStep(0)
}
// We intentionally don't depend on `onComplete` (changes per render)
// — the only effect we want is when the URL search changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search])
// Auto-show on first visit (Dashboard route). Don't auto-show on
// every route — only on `/`, which is the app's landing page.
useEffect(() => {
if (isComplete) return
if (location.pathname === '/') {
setStep(0)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isComplete, location.pathname])
// Track the anchor element's position. We poll on a rAF loop while
// the tour is open because the user can resize the window and the
// page can scroll. requestAnimationFrame keeps the tooltip glued to
// the anchor without burning CPU.
useEffect(() => {
if (isComplete) return
if (!currentStep) return
let raf = 0
const tick = () => {
const el = document.querySelector<HTMLElement>(`[data-tour="${currentStep.id}"]`)
if (el) {
setAnchorRect(el.getBoundingClientRect())
} else {
setAnchorRect(null)
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [isComplete, currentStep])
// Capture focus on open, restore on close.
useEffect(() => {
if (isComplete) return
previouslyFocused.current = document.activeElement as HTMLElement | null
// Defer focus to next tick so the dialog is in the DOM.
const id = window.setTimeout(() => primaryRef.current?.focus(), 0)
return () => {
window.clearTimeout(id)
previouslyFocused.current?.focus?.()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isComplete, step])
// Keyboard: 1..N jump, ←/→ step, Esc dismiss.
useEffect(() => {
if (isComplete) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
finish()
return
}
if (e.key === 'ArrowRight') {
e.preventDefault()
if (isLast) {
finish()
} else {
setStep(s => s + 1)
}
return
}
if (e.key === 'ArrowLeft') {
e.preventDefault()
setStep(s => Math.max(0, s - 1))
return
}
// 1..N jump
if (e.key.length === 1 && /^[1-9]$/.test(e.key)) {
const idx = parseInt(e.key, 10) - 1
if (idx >= 0 && idx < STEPS.length) {
e.preventDefault()
setStep(idx)
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isComplete, isLast])
const finish = useCallback(() => {
writeComplete()
onComplete()
}, [onComplete])
const goNext = useCallback(() => {
if (isLast) finish()
else setStep(s => s + 1)
}, [isLast, finish])
const goJumpToAnchor = useCallback(() => {
navigate(currentStep.route)
}, [navigate, currentStep])
if (isComplete || !currentStep) return null
const Icon = currentStep.icon
const isOffRoute = location.pathname !== currentStep.route
// If the user is on the wrong route, render a centered card with a
// "Go to <page>" CTA. The tooltip-pointer layout needs a real anchor
// to point at; without one we'd just be a floating rectangle.
if (isOffRoute || !anchorRect) {
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 animate-fade-in"
>
<div className="bg-white rounded-2xl shadow-2xl border border-surface-200 w-full max-w-md p-6 space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
<Icon className="w-5 h-5 text-primary-600" />
</div>
<h2 id="onboarding-title" className="text-lg font-semibold text-surface-900">
{currentStep.title}
</h2>
</div>
<button
type="button"
onClick={finish}
aria-label="Dismiss tour"
className="p-1 rounded hover:bg-surface-100 text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-surface-600 leading-relaxed">{currentStep.body}</p>
<p className="text-xs text-surface-500">
Step {step + 1} of {STEPS.length} open the highlighted page to see it in context.
</p>
<div className="flex items-center justify-between gap-2 pt-2">
<button
type="button"
onClick={finish}
className="text-xs text-surface-500 hover:text-surface-700 focus:outline-none focus:ring-2 focus:ring-primary-400 rounded px-1"
>
Skip tour
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={goJumpToAnchor}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
Open {currentStep.route === '/' ? 'Dashboard' : currentStep.route.slice(1)}
</button>
<button
ref={primaryRef}
type="button"
onClick={goNext}
className="text-xs font-medium px-3 py-1.5 rounded-lg border border-primary-300 text-primary-700 hover:bg-primary-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
{isLast ? 'Got it' : 'Next'}
</button>
</div>
</div>
</div>
</div>
)
}
// Anchored tooltip: position the card 16px below the anchor (or above
// if it would clip the viewport). Clamp horizontally to keep the card
// on-screen. Mobile: prefer the top of the viewport so the card never
// gets clipped.
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
const ANCHOR_GAP = 16
const CARD_WIDTH = 320
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024
const placeBelow = !isMobile && anchorRect.bottom + ANCHOR_GAP + 200 < viewportH
const top = placeBelow
? anchorRect.bottom + ANCHOR_GAP
: Math.max(16, anchorRect.top - ANCHOR_GAP - 200)
const left = isMobile
? 16
: Math.max(16, Math.min(viewportW - CARD_WIDTH - 16, anchorRect.left + anchorRect.width / 2 - CARD_WIDTH / 2))
return (
<>
{/* Soft scrim. The page is still visible — this is a hint, not a
modal. We don't use a full overlay because the user must be
able to see the page element being explained. */}
<div
className="fixed inset-0 z-40 bg-black/20 pointer-events-none animate-fade-in"
aria-hidden="true"
/>
{/* Anchor highlight ring. Purely decorative; the tooltip is the
real "look here" signal. */}
<div
className="fixed z-40 pointer-events-none rounded-xl ring-4 ring-primary-400 ring-offset-2 ring-offset-white animate-fade-in"
style={{
top: anchorRect.top - 4,
left: anchorRect.left - 4,
width: anchorRect.width + 8,
height: anchorRect.height + 8,
}}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="onboarding-title"
className="fixed z-50 animate-fade-in"
style={{ top, left, width: CARD_WIDTH }}
>
<div className="bg-white rounded-2xl shadow-2xl border border-surface-200 p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center flex-shrink-0">
<Icon className="w-4 h-4 text-primary-600" />
</div>
<h2 id="onboarding-title" className="text-base font-semibold text-surface-900 leading-tight">
{currentStep.title}
</h2>
</div>
<button
type="button"
onClick={finish}
aria-label="Dismiss tour"
className="p-1 rounded hover:bg-surface-100 text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-400 -mt-1 -mr-1"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-surface-600 leading-relaxed">{currentStep.body}</p>
<div className="flex items-center gap-1 pt-1" aria-label="Tour progress">
{STEPS.map((s, i) => (
<span
key={s.id}
className={`h-1.5 flex-1 rounded-full transition-colors ${
i <= step ? 'bg-primary-500' : 'bg-surface-200'
}`}
/>
))}
</div>
<div className="flex items-center justify-between gap-2 pt-1">
<button
type="button"
onClick={finish}
className="text-xs text-surface-500 hover:text-surface-700 focus:outline-none focus:ring-2 focus:ring-primary-400 rounded px-1"
>
Skip tour
</button>
<div className="flex items-center gap-2">
{step > 0 && (
<button
type="button"
onClick={() => setStep(s => s - 1)}
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-surface-300 text-surface-700 hover:bg-surface-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
Back
</button>
)}
<button
ref={primaryRef}
type="button"
onClick={goNext}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400"
>
{isLast ? 'Got it' : 'Next'}
</button>
</div>
</div>
</div>
</div>
</>
)
}
+1 -1
View File
@@ -599,7 +599,7 @@ export default function Dashboard() {
</div>
{/* Weekly Grid — all 7 days visible */}
<Card>
<Card data-tour="dashboard">
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
</CardHeader>
+2 -2
View File
@@ -182,7 +182,7 @@ export default function Pantry() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4" data-tour="pantry">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center">
<Package className="w-5 h-5 text-warning-600" />
@@ -205,7 +205,7 @@ export default function Pantry() {
{/* Add Form */}
{showAddForm && (
<Card className="animate-slide-down">
<Card className="animate-slide-down" data-tour="pantry">
<CardBody>
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
+1
View File
@@ -127,6 +127,7 @@ export default function RecipesPage() {
icon={<SlidersHorizontal className="w-4 h-4" />}
onClick={() => setShowFilters(!showFilters)}
aria-expanded={showFilters}
data-tour="recipes"
>
Filters
{activeCount > 0 && (
+1 -1
View File
@@ -228,7 +228,7 @@ export default function ShoppingListPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4" data-tour="shopping-list">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
<ShoppingCart className="w-5 h-5 text-primary-600" />