Public Access
Sprint 9 (commit6e386ba) shipped a working OnboardingTour but a broken dismiss path: clicking X / Skip / Esc / "Got it" did nothing. Root cause: useOnboarding().reset() was wired to the dismiss handler at App.tsx, but reset() does the inverse of dismiss — it clears the localStorage key and flips isComplete to FALSE, so the tour re-rendered, the early-return did not fire, and the dialog stayed visible. Fix: commit1562929split the dismiss and reset paths into two distinct callbacks (onComplete and onReset). User confirmed browser smoke passes. This commit updates the 6 running docs that track Sprint 9: - .agent/plan.md — S9.4.1 sub-task (post-deploy fix) added. - .agent/context.md — D9 (root cause + fix) + Q4 (Vitest?) added. - Review/sprint9-verification.md — full post-deploy fix section appended (root cause, fix, post-fix verification, lessons). - Review/handoff-ui-audit.md — Sprint 9 status banner + Last updated footer updated to reference the fix commit. - fix-ui-audit.md — T3.4.1 sub-task added under the T3.4 verification gate. - docs/HANDOFF.md — post-deploy fix paragraph added to the Sprint 9 section. All 6 docs now reflect the post-deploy reality. No code changes.
175 lines
10 KiB
Markdown
175 lines
10 KiB
Markdown
# 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).
|