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>