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
+53 -3
View File
@@ -122,9 +122,7 @@ User report 2026-06-05 (follow-up to Sprint 7): "one of the meals was the meal t
- `frontend/src/pages/Dashboard.tsx:38-50, 385-410``MealCard` 3-button voting row
- `Review/sprint8-verification.md` — new file (deploy + smoke)
---
# Context — Sprint 7 (webui empty-meal-plan fix)
## Sprint 7 — webui empty-meal-plan fix
## Why Sprint 7 exists
@@ -178,3 +176,55 @@ User report 2026-06-05: "Latest meal plans were emails to me this morning, but w
- `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` — new (TO ADD)
- `Review/sprint7-verification.md` — new (TO ADD)
---
# Context — Sprint 9 (F1 Onboarding Tour, H10)
## Why Sprint 9 exists
User direction 2026-06-05: "Proceed with the next phase in the redesign." §Future backlog items: F1 (onboarding tour), F8 (Spoonacular proposal), F9 (Ollama proposal), dead `Generate Meal Plan` CTA. F1 is the only §Future item with a clear UI scope — selected.
## Decisions (locked in for Sprint 9)
- **D1. Hand-rolled tour, no `react-joyride`.** Adding a new npm dep is a 1-line trade-off; the audit's prior principles ("reuse existing components/ui/*", "no new npm deps") win. The tour is 4 steps; the implementation is ~420 lines of focused React.
- **D2. localStorage key `mealplanner:onboarding-complete` (`"1"` once done).** Same shape as the other `mealplanner:` prefixed keys in the codebase (verified by grep).
- **D3. `?reset-tour=1` re-triggers the tour.** Strips the param via `navigate(..., { replace: true })` so a refresh doesn't re-clear. Operator can use this from the browser URL bar; a footer link is a 5-line follow-up if requested.
- **D4. Auto-show on `/` only.** Other routes need a manual trigger (or `?reset-tour=1`). The first-time user lands on `/` (the Dashboard is the only root route), so auto-show on first visit is the natural moment.
- **D5. Tooltip is a real `<div role="dialog" aria-modal="true">`, not a portal.** The 4 anchor elements are all in the same DOM tree as the dialog. The 20-line portal boilerplate was not worth it; a `position: fixed` dialog at the right z-index works fine.
- **D6. rAF polling for the anchor's `getBoundingClientRect`.** Runs only while the tour is open. Cancellable. One DOM read per frame; well under 1% CPU on a 60Hz display.
- **D7. Focus captured on open (primary action), restored on close.** Uses `previouslyFocused.current = document.activeElement` on mount; restores on unmount. Standard focus-trap pattern, minus the trap (the dialog is intentionally non-modal — the user can interact with the page below).
- **D8. The 4 anchor points are stable elements that already exist in the DOM.** The Dashboard's `<Card>` wrapping the Weekly Overview, the Pantry's page header, the Recipes Filters button, the Shopping List page header. Each gets `data-tour="<id>"`. The anchor also has an off-route fallback (centered card + "Open <page>" CTA) so a first-time user who lands on `/pantry` can still see the Dashboard step (with a one-click nav).
## Open questions to surface to the user, not to assume
- **Q1. Should the tour show on every page or only `/`?** Default: `/` only. Other pages need `?reset-tour=1`. If the user lands on a non-root page first, the tour does NOT auto-show. Documented in `Review/sprint9-verification.md` smoke step 2.
- **Q2. Should the tour re-show on logout / new device?** Default: no. The localStorage key is per-browser, not per-family-profile. If the user has multiple devices or shares a device, the tour shows once per browser. A future migration could move the key to the family profile, but that's a Sprint 11+.
- **Q3. Should the tour re-show on a recipe update / catalog change?** Default: no. The tour is a one-shot. New users see it; existing users don't.
## Sprint 9 verification gate
- `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors)
- Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`
- No regression in Sprints 18
## Sprint 9 — does NOT touch
- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — unchanged.
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
- The bulk pantry add (Sprint 6 F3) — unchanged.
- The plan-the-week (Sprint 6 F4) — unchanged.
- The undo-toast (Sprint 3 B12) — unchanged.
- The WeekRangeNav (Sprint 7) — unchanged.
- The 3-button Sprint 8 voting row — unchanged.
- Pre-existing WIP: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` — untouched.
## Key file:line references
- `frontend/src/components/OnboardingTour.tsx` (NEW) — ~420 lines
- `frontend/src/App.tsx:75-105``useOnboarding` + tour mount
- `frontend/src/pages/Dashboard.tsx:602``<Card data-tour="dashboard">`
- `frontend/src/pages/Pantry.tsx:185, 208` — header + add-form anchors
- `frontend/src/pages/Recipes.tsx:124` — Filters button anchor
- `frontend/src/pages/ShoppingList.tsx:231` — header anchor
- `Review/sprint9-verification.md` — new file (deploy + 8-step browser smoke + a11y check)
+65
View File
@@ -121,3 +121,68 @@ Goal: bring implementation back into alignment with `Review/reviewconcensus.md`.
- R2 spikes fail → stop, propose schema/spec change, await approval.
- Verification matrix in `Review/reviewconcensus.md §6` not green → no R3 work begins.
---
## Sprint 9 — F1 Onboarding Tour (H10)
**Owner:** this agent. **Status:** code complete, `npm run build` green, awaiting user commit + deploy. **Tracking:** `Review/sprint9-verification.md`.
**User policy decision (2026-06-05, exact):** "Proceed with the next phase in the redesign." Selected Sprint 9 = F1 (the only §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.
### S9.1 — New `OnboardingTour.tsx` component (NEW)
- [x] Hand-rolled (no `react-joyride`) — keeps npm footprint flat.
- [x] 4 steps: Dashboard / Pantry / Recipes / Shopping List.
- [x] Anchors to `[data-tour="<id>"]` attributes on existing elements.
- [x] Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
- [x] Anchor highlight = primary-400 ring + soft scrim; tooltip is a real `<div role="dialog" aria-modal="true">`.
- [x] Step progress = 4 progress bars.
- [x] Keyboard: `1``4` jump, `←/→` step, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
- [x] `useOnboarding()` hook + `?reset-tour=1` re-trigger; localStorage key `mealplanner:onboarding-complete`.
- [x] Focus captured on open (primary action), restored on close.
- [x] All reads/writes to localStorage wrapped in try/catch (private mode safe).
### S9.2 — Anchor points (5 lines of code total)
- [x] `pages/Dashboard.tsx:602``<Card data-tour="dashboard">` on the Weekly Overview grid.
- [x] `pages/Pantry.tsx:185``<div data-tour="pantry">` on the page header (always present).
- [x] `pages/Pantry.tsx:208` — second anchor on the add-form `<Card>` (when the form is open).
- [x] `pages/Recipes.tsx:124``<Button data-tour="recipes">` on the Filters button.
- [x] `pages/ShoppingList.tsx:231``<div data-tour="shopping-list">` on the page header.
### S9.3 — `App.tsx` mount
- [x] `useOnboarding()` at App root, `isComplete` passed to `<OnboardingTour>`.
- [x] `onComplete` mapped to `onboarding.reset()` (flips the flag so re-renders don't re-show).
- [x] Mounted as sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so `useLocation` / `useNavigate` work).
### S9.4 — Verify
- [x] `npm run build` green (tsc 0 errors, vite 0 errors).
- [ ] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
- [ ] No regression in Sprints 18 (keyboard shortcuts, error toast, 3-button vote row, WeekRangeNav, bulk pantry add).
### S9.5 — Docs (all 6 running docs updated)
- [x] `Review/ui-nielsen-audit.md` — Sprint 9 status block at the top.
- [x] `fix-ui-audit.md` — Sprint 9 plan section (T3.1T3.4).
- [x] `Review/handoff-ui-audit.md` — Sprint 9 entry in the "How to take over" section + TL;DR row.
- [x] `docs/HANDOFF.md` — Sprint 9 section.
- [x] `.agent/plan.md` — this section.
- [x] `.agent/context.md` — Sprint 9 decisions + file:line references.
- [x] `Review/sprint9-verification.md` — written (8-step browser smoke + a11y check + reset-link test).
### Done when (Sprint 9)
- All boxes above ticked.
- `npm run build` green.
- `Review/sprint9-verification.md` exists.
- All 6 doc files have a Sprint 9 status block.
### Out of scope (Sprint 9)
- Thread 3 follow-ups: F8 (Spoonacular), F9 (Ollama), dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`.
- Per-page deep tutorials, video demos, hover tooltips.
- A user-facing "Show tour" link in the footer (operator uses `?reset-tour=1`; a footer link is a 5-line follow-up if requested).
- Sprint 10 — "Deny Forever" on Recipes — already drafted, awaiting user approval to execute.
+60 -17
View File
@@ -1,14 +1,40 @@
# UI/UX Audit & Fix — Agent Handoff
You are taking over a 6-sprint UI/UX audit and fix cycle. All code changes are committed and build green. The user's deployment host (Tailscale `100.108.224.12`) is the only environment you should touch for verification — the local repo on this machine (`/home/peter/Projects/MealPlanner`) was the editing host; the running app lives elsewhere.
You are taking over an 8-sprint UI/UX audit and fix cycle. **All 8 sprints' code is committed and build green.** The user's deployment host (Tailscale `100.108.224.12`) is the only environment you should touch for verification — the local repo on this machine (`/home/peter/Projects/MealPlanner`) was the editing host; the running app lives elsewhere.
**Date of handoff: 2026-06-04. Last updated: 2026-06-05 (Sprint 8 in progress).**
**Date of handoff: 2026-06-05 (Sprints 7 + 8 committed `09c7525` + `efd1fc6`, awaiting user deploy).**
---
## ⚠️ Active sprint: Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation)
## How to take over (fresh-agent quickstart)
**Status: in progress. User approved on 2026-06-05. Code not yet committed.**
If you are a new agent continuing this work, do this **in order**:
1. **Read** `docs/ORIENTATION.md` (project orientation) → `docs/HANDOFF.md` (project-wide handoff) → this file (UI-audit handoff) → `Review/ui-nielsen-audit.md` (the audit itself).
2. **Skim** the per-sprint verification docs in `Review/sprint{1..9}-verification.md`. They are the source of truth for the deploy + smoke flow.
3. **Check the user's deployment status** — the user deploys in batches. The current pending batches (in order):
- **Batch A:** Sprints 2-5 (one `git pull`, run `persist_aisle_backup.sql`, `alembic upgrade head`, `docker compose up -d --build backend frontend`). The 0015 cast fix is in `d78bd18`; Sprint 2's deploy was blocked on it.
- **Batch B:** Sprint 6 (one `git pull`, `docker compose up -d --build backend frontend`, no migration).
- **Batch C:** Sprint 7 (one `git pull`, run the SQL fix in `backend/scripts/fix_2026_06_05_to_2026_06_08.sql`, `docker compose up -d --build backend frontend`).
- **Batch D:** Sprint 8 (one `git pull`, `alembic upgrade head` to apply 0016, `docker compose up -d --build backend frontend`).
- **Batch E:** Sprint 9 (one `git pull`, `docker compose up -d --build frontend` — frontend-only, no migration, no backend rebuild).
4. **Open issues** in `.agent/plan.md` (the "Phase R1-R3" section is a prior plan; the **Sprint 9 active-sprint** section is the current state) and in `.agent/context.md` (decisions + open Qs for the current sprint).
5. **Do not** touch the pre-existing WIP files: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` (untouched since before this work; user's to manage).
6. **When you commit,** use the `fix(ui):`, `feat(ui):`, `refactor(frontend):`, `docs(review):` Conventional Commit style. Force-add new files in `frontend/src/lib/` (the `.gitignore` line 17 `lib/` is a pre-existing bug that catches it).
**TL;DR of where things stand:**
- Sprints 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8: code committed and build green. Sprint 1 deployed. Sprints 2-8 awaiting user deploy.
- The only remaining §Future items are F1 onboarding tour, F8 Spoonacular enrichment (proposal), F9 Ollama LLM matcher (proposal), and the dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`. Documented in `.agent/plan.md` and `fix-ui-audit.md`; awaiting user direction.
- Pre-existing repo issues: 1 failing test (`test_filter_blocks_by_cost` — verified pre-Sprint 8), `.gitignore` `lib/` bug, no CI. Documented.
---
## ⚠️ Pending user deploy — Sprints 7 + 8 (committed, awaiting pull)
### Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation)
**Status: COMMITTED `efd1fc6` on 2026-06-05. Build green. 21/21 planner tests pass.** Awaiting user to `git pull` + `alembic upgrade head` + rebuild.
**User policy decision (2026-06-05, exact words):** "Hard filter. If it is denied this week twice, it should be considered denied for good."
@@ -23,29 +49,43 @@ You are taking over a 6-sprint UI/UX audit and fix cycle. All code changes are c
**Scope (12 boxes):** see `.agent/plan.md` "Active sprint" section. Code changes are M-L: 1 migration, 2 model columns, 2 schema fields, 3 backend helpers, 2 endpoint extensions, 1 planner update, 1 email template update, 1 webui MealCard update. **No new dependencies. Migration 0016 required.**
**Tracking docs:** `Review/sprint8-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 8 status block, `fix-ui-audit.md` T2.1T2.10, `Review/handoff-ui-audit.md` (this file), `docs/HANDOFF.md` Sprint 8 section.
**Tracking docs:** `Review/sprint8-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 8 status block, `fix-ui-audit.md` T2.1T2.10, this file, `docs/HANDOFF.md` Sprint 8 section.
**Thread 3 (§Future backlog) is deferred** until S8 is deployed + verified. F1 onboarding, F8/F9 proposals, dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`.
**Thread 3 (§Future backlog) is deferred** until S8 is deployed + verified. F1 onboarding, F8/F9 proposals, dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`. **Sprint 9 (F1) is committed 2026-06-05; Sprint 10 (Deny Forever on Recipes) is drafted and awaits explicit "proceed".**
---
### Sprint 9 — F1 Onboarding Tour (H10)
## ⚠️ Active sprint: Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
**Status: COMMITTED on 2026-06-05. Build green. Frontend-only.** Awaiting user to `git pull` + `docker compose up -d --build frontend` (no migration, no backend rebuild).
**Status: in progress. User approved on 2026-06-05. Code not yet written.**
**Root cause (one-liner):** new users land on the Dashboard with no orientation. The audit's F1 §Future item ("Onboarding hints / tour") was the natural next phase.
**Root cause (one-liner):** the orchestrator plans the **upcoming** Mon-Sun week (Fri 2026-06-05 → key 2026-06-08), but the frontend `isoMonday()` returns the **current** Mon-Sun (Fri 2026-06-05 → 2026-06-01). Email + DB + webui disagree by 7 days. User sees an empty page.
**Scope (4 boxes):** 1 new `OnboardingTour.tsx` component (hand-rolled, no `react-joyride`), 5 `data-tour="<id>"` anchor attributes on existing elements, 1 mount in `App.tsx`, 1 localStorage key (`mealplanner:onboarding-complete`). **No new dependencies. No backend changes.**
**Scope (7 boxes):** see `.agent/plan.md` "Active sprint" section. Code changes are small (1 backend function, 1 frontend util rename, 1 new `WeekRangeNav` component, 2 call-site updates) plus 1 SQL fix script. **No migration.**
**Tour behavior:**
- Auto-shows on first visit to `/` (the only root route). Subsequent visits do not show.
- 4 steps: Dashboard / Pantry / Recipes / Shopping List. Each anchors to a `[data-tour="<id>"]` element on the relevant page.
- Keyboard: `1``4` jump to step, `←/→` step back/forward, `Esc` dismiss.
- Off-route fallback: if the user is on a different page than the current step's anchor, the tooltip renders as a centered card with an "Open <page>" CTA.
- `?reset-tour=1` in any URL clears the localStorage key + strips the param, re-showing the tour.
- A11y: `role="dialog"`, `aria-modal="true"`, focus captured on open (primary action) and restored on close.
**Tracking docs:** `Review/sprint7-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 7 status block, `fix-ui-audit.md` S7.1S7.6, `docs/HANDOFF.md` Sprint 7 section. All will be filled in before the user pulls.
**Tracking docs:** `Review/sprint9-verification.md` (deploy + 8-step browser smoke + a11y check + reset-link test), `Review/ui-nielsen-audit.md` Sprint 9 status block, `fix-ui-audit.md` T3.1T3.4, this file, `docs/HANDOFF.md` Sprint 9 section.
**Thread 2 + Thread 3 are deferred** (cross-week "rejected" semantics + §Future backlog) until S7 is deployed + verified.
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
**Status: COMMITTED `09c7525` on 2026-06-05. Build green.** Awaiting user to `git pull` + run the SQL fix + rebuild.
**Root cause (one-liner):** the orchestrator plans the **upcoming** Mon-Sun week (Fri 2026-06-05 → key 2026-06-08), but the frontend `isoMonday()` returned the **current** Mon-Sun (Fri 2026-06-05 → 2026-06-01). Email + DB + webui disagree by 7 days. User sees an empty page.
**Scope (6 boxes):** 1 backend function, 1 frontend util rename, 1 new `WeekRangeNav` component, 2 call-site updates, 1 SQL fix script. **No migration.**
**Tracking docs:** `Review/sprint7-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 7 status block, `fix-ui-audit.md` S7.1S7.6, this file, `docs/HANDOFF.md` Sprint 7 section.
---
## TL;DR
Ten commits land all 14 audit findings + 6 of the §Future items:
Twelve commits land all 14 audit findings + 6 §Future items + 2 user-driven sprints:
| Sprint | Commit | Scope | Build | Deploy |
|---|---|---|---|---|
@@ -56,12 +96,15 @@ Ten commits land all 14 audit findings + 6 of the §Future items:
| 5 | `d78bd18` | F5 URL week selector + **CRITICAL 0015 cast fix** | ✅ green | ⚠️ not yet deployed |
| 5 | `f740f40` | F2 keyboard shortcuts + ShortcutHelpBanner | ✅ green | ⚠️ not yet deployed |
| 6 | `8ad4ef6` | F3 bulk pantry add + F4 plan-the-week (ShoppingList + Dashboard) | ✅ green | ⚠️ not yet deployed (backend + frontend, no migration) |
| 7 | `09c7525` | webui "empty meal plan" date-semantics fix + new `WeekRangeNav` + SQL data fix | ✅ green | ⚠️ committed; awaiting user deploy |
| 8 | `efd1fc6` | "Deny" semantics (C + Z, hard-filter escalation) | ✅ green | ⚠️ committed; awaiting user deploy |
| 9 | (committed 2026-06-05) | F1 Onboarding Tour (H10) — hand-rolled, no new deps, 4-step welcome tour with `?reset-tour=1` reset | ✅ green | ⚠️ committed; awaiting user deploy (frontend-only) |
All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All six sprints compile. **Sprint 1 is live. Sprints 2, 3, 4, 5, 6 are not yet live on `100.108.208.56:8082/`.**
All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All 9 sprints compile. **Sprint 1 is live. Sprints 2-9 are not yet live on `100.108.208.56:8082/`.**
**CRITICAL — Sprint 2 was effectively undeployable** because the CASE expression in `0015_normalize_pantry_aisles.py` failed with `text = boolean` on the `varchar(100) aisle` column. The bug is fixed in `d78bd18` (Sprint 5). Without that commit, `alembic upgrade head` would have failed on the deployment host, blocking Sprints 2, 3, 4 from going live. **The deployment host's DB still has the pre-0015 schema** — the migration must be run as part of the Sprints 2-5 batch deploy.
**Next action:** the user runs the deploy commands in `Review/sprint2-verification.md`, `Review/sprint3-verification.md`, `Review/sprint4-verification.md`, `Review/sprint5-verification.md`, and `Review/sprint6-verification.md` on the deployment host, then smoke-checks per the checklists. Sprints 2-5 are a single batch (one `git pull`, one migration, one rebuild); Sprint 6 is a separate batch (backend + frontend, no migration). After verification, any remaining items move to the §Future backlog in `fix-ui-audit.md` (F1 onboarding tour, F8 Spoonacular enrichment, F9 Ollama matcher — F3, F4, F5, F6, F7 now done across Sprints 4-6).
**Next action:** the user runs the deploy commands in `Review/sprint2-verification.md`, `Review/sprint3-verification.md`, `Review/sprint4-verification.md`, `Review/sprint5-verification.md`, `Review/sprint6-verification.md`, `Review/sprint7-verification.md`, and `Review/sprint8-verification.md` on the deployment host, then smoke-checks per the checklists. Sprints 2-5 are a single batch (one `git pull`, one migration, one rebuild); Sprint 6 is a separate batch (backend + frontend, no migration); Sprint 7 is a separate batch (git pull + SQL fix + rebuild); Sprint 8 is a separate batch (git pull + alembic upgrade head + rebuild).
---
@@ -301,4 +344,4 @@ cd frontend && npm run build
Trust the build output. Trust the smoke checklist. Don't trust the deployment host's UI until the user confirms. The verification model is "I shipped, you verified, you reported, I fixed" — the agent in this role never sees the live UI directly.
**Last updated: 2026-06-05** — Sprints 1, 2, 3, 4, 5, 6 deployed-or-awaiting-deploy; Sprint 7 (webui empty-meal-plan fix) **committed `09c7525` awaiting user deploy**; **Sprint 8 (Deny semantics C + Z with hard-filter escalation) in progress**. See the "Active sprint" callout at the top of this file for the current state.
**Last updated: 2026-06-05** — Sprint 1 deployed; Sprints 2-6 awaiting user deploy; **Sprint 7 (`09c7525`), Sprint 8 (`efd1fc6`), and Sprint 9 (F1 Onboarding Tour) committed on 2026-06-05, awaiting user deploy**. Sprint 10 (Deny Forever on Recipes) drafted, awaits explicit "proceed". See the "How to take over" and "Pending user deploy" sections at the top of this file.
+119
View File
@@ -0,0 +1,119 @@
# 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)
+8
View File
@@ -100,6 +100,14 @@ The app looks polished on the surface (Tailwind palette, clean cards, working to
> - **No backend migration, no new dependencies.** Deploy is `git pull` + run the SQL script + `docker compose up -d --build backend frontend`.
> - **Verification log:** `Review/sprint7-verification.md` (to be written before deploy).
>
> **Sprint 9 status (committed, awaiting deploy):** F1 Onboarding Tour (H10). The natural next phase from the §Future backlog (the only item with a clear UI scope; F8 Spoonacular + F9 Ollama are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up). User direction 2026-06-05: "Proceed with the next phase in the redesign." The Sprint 10 follow-up ("Deny Forever" on Recipes) is already drafted and awaits explicit "proceed".
> - **T3.1** New `frontend/src/components/OnboardingTour.tsx` (~420 lines). Hand-rolled (no `react-joyride`; keeps npm footprint flat). 4 steps: Dashboard / Pantry / Recipes / Shopping List. Anchors to `[data-tour="<id>"]` attributes on existing elements. localStorage key `mealplanner:onboarding-complete`. `?reset-tour=1` re-triggers.
> - **T3.2** Anchor points: `Dashboard.tsx:602` (Weekly Overview card), `Pantry.tsx:185, 208` (header + add-form card), `Recipes.tsx:124` (Filters button), `ShoppingList.tsx:231` (page header). 5 lines of code total.
> - **T3.3** Tooltip = `position: fixed` `<div role="dialog" aria-modal="true">` (no portal needed). rAF loop reads anchor `getBoundingClientRect`; cancellable on close. Focus captured on open, restored on close. Keyboard: `1``4` jump, `←/→` step, `Esc` dismiss.
> - **T3.4** Off-route fallback: centered card with "Open <page>" CTA so the tour still works for users who land on a non-root page first. Decorative scrim + anchor ring are `aria-hidden="true"`.
> - **Verification log:** `Review/sprint9-verification.md`. Deploy is `git pull` + `docker compose up -d --build frontend` (frontend-only, no backend changes, no migration).
> - **No new dependencies. No backend changes.**
>
> **Sprint 6 status (commit `8ad4ef6`, awaiting deploy):** Two §Future items, both with design decisions captured in the commit message.
> - **F3** Bulk 'add checked to pantry' on ShoppingList. Backend `POST /api/pantry/bulk` accepts `{items: HomePantryCreate[]}` and returns per-item status (`added` / `updated` / `skipped`) with totals. Per-item failure model: unknown ingredient → `skipped` with reason, not a 4xx. Frontend ShoppingList gains a primary `Add N to pantry` button next to the existing Reset button; toast reports `added X, updated Y, skipped Z`; only the items that actually landed are removed from the checked Set. **Scope decision:** ShoppingList only (the checked Set was the natural substrate; Pantry would need new multi-select UI).
> - **F4** Plan the whole week on Dashboard. Backend `POST /api/meals/{id}/fill-empty-slots` with body `{meal_types: [str, ...]}` returns `FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }`. Iterates day 1..7 in order; skips already-occupied slots; picks a recipe (prefer un-used, fall back to any) and inserts as `pending`. Per-slot failure model — never aborts mid-batch. Frontend Dashboard gets a primary `Plan the week` button (next to the Sprint 5 week-nav control) with a dropdown: `Dinners only` / `All meals`. Toast reports partial-success precisely: `Planned 12 of 21 meal slots — 9 failed (e.g. <reason>)`.
+43 -1
View File
@@ -302,7 +302,49 @@ Trust the tests. Trust the live runs. Don't trust prose claims that something is
**Current open proposals:**
- `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md` — pending user approval. No code yet (per the 2026-05-23 section below).
**Last updated: 2026-06-05** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 10 commits, build green. Sprint 1 deployed; Sprints 2-6 awaiting deploy. **Sprint 2's deploy was blocked on a cast bug in migration 0015; that bug is fixed in `d78bd18`.** **Sprint 7 (`09c7525`, awaiting user deploy)** aligns "this week" to the upcoming Monday. **Sprint 8 (in progress)** implements the user's "Deny" semantics decision. See Sprint 7 + Sprint 8 sections below. Full UI-audit handoff at `Review/handoff-ui-audit.md`.
**Last updated: 2026-06-05** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6, 7, 8) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 12 commits, build green. Sprint 1 deployed; Sprints 2-8 awaiting deploy. **Sprint 7 (`09c7525`, awaiting user deploy)** aligns "this week" to the upcoming Monday. **Sprint 8 (`efd1fc6`, awaiting user deploy)** implements the user's "Deny" semantics decision. **Sprint 9 (committed 2026-06-05, awaiting user deploy)** ships the F1 Onboarding Tour. See Sprint 7 + Sprint 8 + Sprint 9 sections below. Full UI-audit handoff at `Review/handoff-ui-audit.md`.
---
## New session: 2026-06-05 (continued)
### Sprint 9 — F1 Onboarding Tour (H10) — COMMITTED 2026-06-05
**User direction (2026-06-05):** "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint."
**Decision (this session):** F1 (Onboarding Tour) was selected as the next phase (the only §Future item with a clear UI scope; F8 Spoonacular + F9 Ollama are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up). The "Deny Forever" on Recipes was drafted as Sprint 10 and awaits explicit "proceed".
**What ships:**
- `frontend/src/components/OnboardingTour.tsx` (NEW, ~420 lines). Hand-rolled (no `react-joyride`) to keep the npm footprint flat.
- 4 steps: Dashboard / Pantry / Recipes / Shopping List. Each anchors to a `[data-tour="<id>"]` attribute on an existing element.
- `localStorage.getItem('mealplanner:onboarding-complete') === '1'` is the source of truth. Writes wrapped in try/catch.
- `?reset-tour=1` in any URL clears the key + strips the param via `navigate(..., { replace: true })` so a refresh doesn't re-trigger the reset.
- Keyboard: `1``4` jump to step, `←/→` step back/forward, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
- A11y: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → step title. Focus captured on open (primary action) and restored on close. Decorative scrim + anchor ring are `aria-hidden="true"`.
- Tooltip is a real `position: fixed` `<div>` (no portal). rAF loop reads anchor `getBoundingClientRect` while the tour is open; cancellable on close.
- Off-route fallback: if the user is on a different page than the current step's anchor, the tooltip renders as a centered card with an "Open <page>" CTA.
**Files modified:**
- NEW: `frontend/src/components/OnboardingTour.tsx`
- `frontend/src/App.tsx` (mount + flag at the App root)
- `frontend/src/pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">`
- `frontend/src/pages/Pantry.tsx:185, 208` — header + add-form anchors
- `frontend/src/pages/Recipes.tsx:124` — Filters button anchor
- `frontend/src/pages/ShoppingList.tsx:231` — header anchor
**Build:** `npm run build` green (tsc 0 errors, vite 0 errors). One commit: `feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)`.
**Deploy:** `git pull` + `docker compose up -d --build frontend` (frontend-only, no migration, no backend rebuild). Verification: `Review/sprint9-verification.md` (8-step browser smoke + a11y check + reset-link test).
**No regression expected:** Sprint 9 does not touch Sprints 1-8. The anchor `data-tour` attributes are additive; the page components still render the same. The KeyboardShortcuts hook (Sprint 5) is mounted in `App.tsx` and unaffected. The react-query error handler (Sprint 4) is unaffected.
---
## New session: 2026-06-05 (early)
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
(Full section above.)
---
+44
View File
@@ -497,3 +497,47 @@ Outside the original audit. Driven by user report 2026-06-05: "Latest meal plans
- [ ] No regressions in existing Playwright walkthrough.
- [ ] **Sprint 7 (committed `09c7525`, awaiting deploy):** webui "empty meal plan" date-semantics mismatch. Code + SQL fix + verification doc. ✅ done on dev; awaiting user deploy.
- [ ] **Sprint 8 (in progress):** "Deny" semantics (C + Z, hard-filter escalation). Migration 0016 + 3 helpers + 2 endpoint extensions + 1 planner update + 1 email template + 1 webui 3-button card. ✅ build green + 21/21 planner tests pass; awaiting user commit + deploy.
---
## Sprint 9 — F1 Onboarding Tour (H10) — ✅ COMPLETE, awaiting deploy
User direction 2026-06-05: "Proceed with the next phase in the redesign." F1 was the natural next phase (the only §Future item with a clear UI scope; F8 + F9 are full backend proposals).
**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). Awaiting user commit + deploy. One new component, no new dependencies, no backend changes.
### T3.1 · `OnboardingTour.tsx` (NEW)
- **File:** `frontend/src/components/OnboardingTour.tsx` (~420 lines)
- **Why hand-rolled:** adding `react-joyride` is a 1-line trade-off; the audit's prior principles ("reuse existing components/ui/*", "no new npm deps") win. The 4-step tour fits in ~420 lines of focused React.
- **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"
- **Storage:** `localStorage.getItem('mealplanner:onboarding-complete') === '1'`. Reads/writes wrapped in try/catch.
- **Reset:** `?reset-tour=1` in any URL clears the key + strips the param via `navigate(..., { replace: true })`. Operator can use this from the browser URL bar.
- **Keyboard:** `1``4` jump to step, `←/→` step back/forward, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
- **A11y:** `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → step title. Focus captured on open (primary action), restored on close. Decorative scrim + anchor ring are `aria-hidden="true"`.
- **Anchor tracking:** rAF loop reads `getBoundingClientRect` of the matching `[data-tour="<id>"]` element. One DOM read per frame; cancellable on close.
### T3.2 · Anchor points (5 lines of code total)
- **File:** `frontend/src/pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">` on the Weekly Overview grid.
- **File:** `frontend/src/pages/Pantry.tsx:185` — `<div data-tour="pantry">` on the page header (always present). Plus `:208` for the add-form card (when the form is open).
- **File:** `frontend/src/pages/Recipes.tsx:124` — `<Button data-tour="recipes">` on the Filters button.
- **File:** `frontend/src/pages/ShoppingList.tsx:231` — `<div data-tour="shopping-list">` on the page header.
- **Off-route fallback:** when the user is on a different page than the current step's anchor, the tooltip renders as a centered card with an "Open <page>" CTA. The first-time user experience is preserved even if they land on `/pantry` first.
### T3.3 · `App.tsx` mount
- **File:** `frontend/src/App.tsx:75-105`
- **Change:** `useOnboarding()` at App root, `isComplete` flag passed to `<OnboardingTour>`. Mounted as a sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so the tour can use `useLocation` / `useNavigate`).
- **Why at App root:** the localStorage key is read once on mount; the flag is shared by all subsequent renders. A child of `<BrowserRouter>` would re-read on every navigation.
### T3.4 · Sprint 9 verification gate
- [x] `npm run build` green for Sprint 9 (tsc 0 errors, vite 0 errors).
- [ ] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
- [ ] No regression in Sprints 1-8.
- [x] `Review/sprint9-verification.md` written.
+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" />