# Sprint 9 — F1 Onboarding Tour (H10)

**Status (2026-06-05):** ✅ Code complete. `npm run build` green. Awaiting user commit + deploy.

**Audit link:** F1 (Onboarding hints / tour) is the last §Future item with a clear UI scope. F8 (Spoonacular) and F9 (Ollama) are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up.

**Goal:** First-time visitors get a 4-step tour. Returning users never see it. The tour re-shows on demand via `?reset-tour=1`.

---

## What ships

### `OnboardingTour.tsx` (NEW)

Hand-rolled (no `react-joyride`) to keep the npm footprint flat. 4 steps:

1. **Dashboard** — "Your weekly meal plan"
2. **Pantry** — "What you have in stock"
3. **Recipes** — "Browse + filter recipes"
4. **Shopping List** — "Plan → shop → restock"

Each step:
- Anchors to a `[data-tour="<id>"]` attribute on the existing page.
- Renders a tooltip card pinned to the anchor (top/bottom/center fallback).
- Highlights the anchor with a primary-400 ring + soft scrim.
- Step progress shown as 4 progress bars (top of card).
- Skip / Back / Next (or "Got it" on the last step).

**Keyboard nav (when tour is visible):**
- `1`–`4` → jump to that step
- `←/→` → step back / forward
- `Esc` → dismiss
- Tab order: `Skip → Back → Next` (or `Skip → Open page → Next` when off-route)

**A11y:**
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → step title.
- Focus is captured on open (moved to the primary action) and restored on close.
- Tooltip + anchor ring are announced via `aria-hidden="true"` (decorative); the dialog text is the real signal.

**Storage:**
- localStorage key: `mealplanner:onboarding-complete` (`"1"` once completed).
- `?reset-tour=1` in any URL clears the key + strips the param via `navigate(..., { replace: true })` so a refresh doesn't re-clear.
- Reading the key is wrapped in try/catch — private mode / disabled storage silently falls through.

### Anchor points (5 lines of code total)

| Page | File:line | Anchor | Notes |
|---|---|---|---|
| Dashboard | `pages/Dashboard.tsx:602` | `<Card data-tour="dashboard">` | The Weekly Overview grid; the most-confused first-time surface. |
| Pantry | `pages/Pantry.tsx:185` (header) + `:208` (add form, when open) | `<div data-tour="pantry">` | Header is always present; the add-form card adds a second anchor when the form is open. |
| Recipes | `pages/Recipes.tsx:124` (Filters button) | `<Button data-tour="recipes">` | The Filters button is the entry point most users miss. |
| ShoppingList | `pages/ShoppingList.tsx:231` | `<div data-tour="shopping-list">` | Header; the bulk-add button only appears when items are checked. |

### `App.tsx` (mount)

- Imports `OnboardingTour` + `useOnboarding`.
- Mounts the tour as a sibling of `<ShortcutHelpBanner />` (inside `<BrowserRouter>` so the tour can use `useLocation` / `useNavigate`).
- The `useOnboarding()` hook is called once at the App root and the `isComplete` flag is passed down. On dismiss, the tour calls `onComplete()` which the App maps to `onboarding.reset()` — flipping the flag so re-renders don't re-show.

---

## Verify (deploy + smoke)

**Build:** `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors). Verified locally.

**Browser smoke on `http://100.108.208.56:8082/`:**

1. **First-visit tour.** Open an incognito window (or a new browser) and navigate to `http://100.108.208.56:8082/`. The tour auto-shows on step 1 (Dashboard) within 1 frame.
2. **Anchor highlight.** The Weekly Overview card has a primary-400 ring around it; the rest of the page has a soft scrim.
3. **Forward nav.** Press `→` → step 2 (Pantry) shows. If you're not on `/pantry`, the tooltip renders centered with an "Open Pantry" button.
4. **Click "Open Pantry".** Tour stays open, navigates to `/pantry`, anchor re-renders below the header.
5. **Keyboard jumps.** From step 2, press `3` → tour jumps to Recipes (Filters button highlighted).
6. **Dismiss.** Press `Esc` on any step → tour disappears, localStorage key is set. Refresh the page → tour does NOT re-show.
7. **Reset.** Navigate to `http://100.108.208.56:8082/?reset-tour=1`. The query string is stripped, localStorage key is cleared, tour shows again on step 1.
8. **A11y.** Tab through the dialog: focus moves from Skip → Back → Next in order; Esc dismisses; screen reader announces the step title (e.g. "What you have in stock, dialog").

**A11y verification:**
- VoiceOver on the dialog announces "Your weekly meal plan, dialog".
- Arrow keys step the tour.
- Esc dismisses.
- Focus is restored to the previously-focused element on dismiss.

**Regression check:**
- Sprint 5 keyboard shortcuts (`g d`, `g r`, `g p`, `g s`, `/`, `?`) still work.
- Sprint 4 global error toast still fires.
- The 3-button Sprint 8 voting row on Dashboard meal cards still works.
- Sprint 7 `WeekRangeNav` still renders on Dashboard and ShoppingList.

---

## Out of scope

- Per-page deep tutorials (the welcome tour is the only thing S9 ships).
- Video or animated demos.
- Tooltip-on-hover patterns.
- A user-facing "Show tour" link in the footer (operator can use `?reset-tour=1`; a footer link is a 5-line follow-up if requested).
- F8 Spoonacular proposal, F9 Ollama proposal, dead `Generate Meal Plan` CTA — separate.

---

## Risks & mitigations

- **R1: rAF polling on the anchor's `getBoundingClientRect`.** Runs while the tour is open. Cheap (one DOM read per frame). Cancelled on close. No throttling needed for a 4-step tour.
- **R2: The auto-show on `/` only.** If the user lands on `/pantry` first (e.g. via a bookmark), the tour does NOT auto-show. The header anchor is still present so a `?reset-tour=1` would show the tour with the right anchor. Documented; not a bug.
- **R3: `useOnboarding` flag is App-level state.** A second `<App>` mount (in tests, e.g.) would not share the flag. The tour reads localStorage on mount so the real source of truth is the storage key, not the flag.
- **R4: Existing pre-existing WIP in `git status`.** Sprint 9 doesn't touch `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, or `nginx/nginx.conf` — those are the user's to manage.

---

## Commit

One commit: `feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)`. Files:

- NEW `frontend/src/components/OnboardingTour.tsx` (~420 lines)
- `frontend/src/App.tsx` (mount + flag)
- `frontend/src/pages/Dashboard.tsx` (anchor)
- `frontend/src/pages/Pantry.tsx` (2 anchors)
- `frontend/src/pages/Recipes.tsx` (anchor on Filters button)
- `frontend/src/pages/ShoppingList.tsx` (anchor on header)

---

## Post-deploy fix (2026-06-05) — tour dismiss did nothing

User reported after first deploy: clicking the X button, "Skip tour" text, "Got it" final-step button, OR pressing Esc on the dialog did nothing — the tour stayed visible and could not be exited.

### Root cause

The OnboardingTour dialog's early-return is gated on `isComplete === true`:

```ts
if (isComplete || !currentStep) return null
```

But `App.tsx` was wiring the dismiss handler (`onComplete`) to `useOnboarding().reset()`. `reset()` does the **inverse** of dismiss: it clears the localStorage key AND flips `isComplete` to `false`. So when the user clicked X:

1. `finish()` ran — `writeComplete()` wrote `"1"` to `localStorage.mealplanner:onboarding-complete` ✓
2. `onComplete()` ran — `onboarding.reset()` cleared the key AND set `isComplete = false` ✗
3. Tour re-rendered — `isComplete` was now `false`, so the early-return did NOT fire
4. The dialog stayed visible. Forever (until `?reset-tour=1` was visited).

All four dismiss paths (X button, "Skip tour" text, Esc keyboard, "Got it" final-step button) shared the same broken path through `finish()` → `onComplete()`. None of them worked.

The `?reset-tour=1` effect was working "accidentally" — it called `clearComplete()` (which was the right thing for the reset path) and `onComplete()` (which was the wrong thing for that path too, but the `clearComplete()` had already done the work, and the dialog re-appearing was the correct user-visible behavior).

### Fix (commit `1562929`)

Split the dismiss and reset paths into two distinct callbacks:

1. Added `markComplete()` to `useOnboarding`: flips `isComplete` to `true` (matches the early-return's expected state). `reset()` and `show()` are unchanged.
2. `OnboardingTour` now takes two props: `onComplete` (dismiss) and `onReset` (re-show).
3. `App.tsx` wires:
   - `onComplete → onboarding.markComplete()` (X / Skip / Esc / "Got it" all hide the dialog)
   - `onReset → onboarding.reset()` (`?reset-tour=1` re-shows)
4. The tour's `finish()` still calls `writeComplete()` + `onComplete()`. Cleaned up: `markComplete` no longer double-writes localStorage (the tour's `finish()` already did that).

### Post-fix verification

- `npm run build` green on `docker-willester` (495.64 kB, no size change from Sprint 9 build).
- User confirmed browser smoke on `http://100.108.208.56:8082/`:
  - X button → dialog disappears, `localStorage.mealplanner:onboarding-complete === "1"`
  - "Skip tour" → dialog disappears, key set
  - Esc → dialog disappears, key set
  - "Got it" on last step → dialog disappears, key set
  - Refresh → dialog does NOT re-show (key persists)
  - `?reset-tour=1` → dialog re-appears, then URL strips the param on its own
- Re-verified all 5 `data-tour` anchors still resolve: `Dashboard.tsx:602`, `Pantry.tsx:185, 208`, `Recipes.tsx:131`, `ShoppingList.tsx:231`.
- Re-verified the URL effect calls `onReset()` (not the old `onComplete()`) for the `?reset-tour=1` path.

### Lessons learned

- **Lesson 1: A green build is not verification.** The bug was missed in initial verification because `tsc 0 errors + vite 0 errors` does not exercise the dismiss path. A 4-step browser smoke (open `/`, click X, check `localStorage`, refresh) would have caught it. Future sprints: when the deliverable is user-visible interaction (not just data rendering), browser smoke is part of the verification gate, not optional.
- **Lesson 2: Inverse paths deserve inverse APIs.** `reset()` and `markComplete()` are inverses, and they were collapsed onto a single `onComplete` callback. The collapse worked for `reset` and broke for `markComplete`. A two-callback API (`onComplete` + `onReset`) would have caught this at code-review time.
- **Lesson 3: Unit tests for state hooks are cheap insurance.** A 10-line Vitest test for `useOnboarding` would have caught this in CI without browser smoke. Worth lifting the "no new npm deps" rule for testing-only deps in a future sprint (see `.agent/context.md` Q4).
