# UI/UX Audit — Nielsen's 10 Heuristics

**Scope:** Live deployment at `http://100.108.208.56:8082/`, React frontend at `frontend/src/`, complementing the existing docs/repo reviews in this folder.
**Method:** Playwright (system Chromium) navigated 20 routes/viewports; findings triangulated against source code with `file:line` references.
**Screenshots:** `/tmp/opencode/mp-review/screenshots/` (20 PNGs referenced inline).
**Severity scale:** **P0 (blocker)** — broken core flow · **P1 (major)** — wrong or misleading · **P2 (minor)** — polish/aa.

---

## Executive summary

The app looks polished on the surface (Tailwind palette, clean cards, working toasts, working focus rings), but a live walkthrough surfaces **multiple silent failures and three outright broken data-rendering bugs**. The most damaging issues are not visual — they are *unmistakable data inconsistencies* the user is expected to read and act on (`$N/A per serving`, blank `lb Pork Chops` rows, hidden empty meal slots on mobile, snake_case aisle labels). They erode trust faster than a missing button.

**Top 5 to fix first** (P0):

1. **Meal detail ingredients render without quantities** (field-name bug, `MealDetail.tsx:249-250`) — a core function of the page is unreadable.
2. **`$N/A per serving`** displayed literally (`MealDetail.tsx:191`).
3. **Recipe detail ingredients collapse unit and name** (`RecipeDetail.tsx:161` — `2 canBlack Beans`).
4. **`/recommended` returns a blank page** (missing route + no 404 catch-all in `App.tsx`).
5. **Mobile dashboard hides empty meal slots** (`Dashboard.tsx:164,219` — users on phones cannot *plan* meals, only view them).

> **Sprint 1 status (commit `f3e4a44`, deployed by user 2026-06-02):** Items 1, 2, 3, 4, 5 all addressed in the frontend source. Live at `100.108.208.56:8082/`. Verification screenshots in `/tmp/opencode/mp-review/screenshots/fix-sprint1/`.
>
> **Sprint 2 status (commit `ccc70aa`, deploy helper `f5fb755`):** All six P1s plus the S3.3 mobile shopping-list stat-grid fix are addressed in source.
> - **B6** Dashboard `MealCard` title: `truncate` → `line-clamp-2`; image shrinks to 40×40 on `<md` to give title more room.
> - **B7** `MealDetail` hero: title/description no longer overlap; description stripped of spoonacular SEO copy via `lib/utils.cleanDescription`; raw text moved to a "Notes from source" disclosure.
> - **B8** `Pantry` aisle/unit: free-text → canonical `Select` from `PANTRY_AISLES` enum (`types/index.ts`). `Ingredient name` field now marked `*` required. Backend migration `0015_normalize_pantry_aisles.py` normalizes `ingredient.aisle` and `grocery_item.aisle` to canonical labels. Dry-run SQL helper at `backend/scripts/dry_run_aisle_migration.sql`.
> - **B9** `ShoppingList` aisle section headers now human-readable via `AISLE_LABEL` map; falls back to raw key for unknown values.
> - **B10** Mobile pantry table: right-edge white-to-transparent gradient overlay hints at horizontal overflow; container has `role="region"` + descriptive `aria-label`.
> - **B11** Recipes filters: refactored to `pending`/`applied` state with explicit Apply / Reset buttons. `Filters` button shows active-count chip when filters are set. Wrapped in `role="region" aria-label="Filters"`.
> - **S3.3** Shopping list stat cards: now `grid-cols-3` on all viewports with compact mobile sizing.
>
> Deployment commands (run on the deployment host — DB is in a container, no host psql needed):
> ```bash
> cd ~/MealPlanner
> git pull
>
> # Optional: persistent backup of aisle values BEFORE the migration
> docker compose exec -T db psql -U mealplanner -d mealplanner \
>   -f /dev/stdin < backend/scripts/persist_aisle_backup.sql
>
> # Dry-run preview (no writes)
> docker compose exec -T db psql -U mealplanner -d mealplanner \
>   -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql
>
> # Apply the migration
> docker compose exec backend alembic upgrade head
>
> # Rebuild & restart frontend
> docker compose -f docker-compose.yml up -d --build frontend
> ```

> **Sprint 3 status (commit `e90a9d6`, awaiting deploy):** All P2s plus the a11y sweep.
> - **B12** Native `confirm()` deleted for both delete sites. `lib/toast.tsx` (renamed from `.ts` for JSX) gains a new `showToast.undo(message, onUndo, ms=5000)` helper. `Dashboard.handleDelete` captures the full item, deletes, then surfaces an Undo toast that re-fires `generateItem(planId, dayOfWeek, mealType)` to refill the slot. `Pantry.handleRemove` is fully reversible: re-adds via `pantry.add` with the original `ingredient_id`/`quantity`/`unit`. Per-row loading state via new `removeId` state.
> - **B13** `Navigation` link text gets `whitespace-nowrap`; padding reduced to `px-2 sm:px-3` so all 4 links fit on one line down to ~360 px.
> - **S3.4** Confirmed `ErrorBoundary` is already mounted at `App.tsx:42` (verified `components/ErrorBoundary.tsx`).
> - **S3.5** A11y sweep: `<nav aria-label="Primary">`, `aria-current="page"` on the active nav link, `<main id="main-content">` for skip-link targets, `Badge` component extended with optional `icon` and `aria-label` props. Approval-status Badge on the meal card now passes `aria-label="Approval status: approved"` etc.
> - **S3.3** Mobile shopping-list stat cards already done in Sprint 2 (3-col grid with compact mobile sizing).
>
> Deploy:
> ```bash
> cd ~/MealPlanner
> git pull
> docker compose -f docker-compose.yml up -d --build frontend
> ```
>
> **Sprint 4 status (commit `d71b67a`, awaiting deploy):** Two §Future items, both small, both polish.
> - **F7** Global react-query error handler. `lib/toast.tsx` gains `extractErrorMessage(err, fallback)` and `showApiError(err, fallback)` that read FastAPI's `response.data.detail` (string or Pydantic 422 array) and produce a clean user-facing string. `App.tsx` wires `QueryCache({ onError })` and `MutationCache({ onError })` to `showApiError`, so any future mutation that forgets a local handler still surfaces its failure. 10 local try/catch toasts deleted across `Dashboard.tsx`, `Pantry.tsx`, `MealDetail.tsx`. Pre-flight client-side checks (empty name, missing ingredient link) deliberately kept local since they never reach the network. Default-options added: `queries: { retry: 1, refetchOnWindowFocus: false }` — closes the H9 "silent background refetch failure" finding.
> - **F6** Plan-status Badge on the Dashboard header (draft / awaiting_approval / approved / rejected) now passes `aria-label="Plan status: <text>"` so screen readers announce both the category and the value. Matches the per-item approval-status pattern added in Sprint 3. **No other colour-only badges exist in the app** — every other `<Badge>` is either a count or a self-describing tag.
> - **Backend changes:** none. Deploy is frontend-only.
> - **Verification log:** `Review/sprint4-verification.md`.
>
> **Sprint 5 status (commits `d78bd18` + `f740f40`, awaiting deploy):** Two §Future items, one with a critical migration fix.
> - **F5** URL week selector. `?week=YYYY-MM-DD` (Monday's ISO date) is now the canonical way to navigate between weeks. `useSearchParams` reads the URL; if absent or invalid, falls back to `isoMonday()` (so the default URL is empty). Both `Dashboard` and `ShoppingList` get a segmented control (chevron-left | 'This week'/'Current' jump button | chevron-right) in the header. The `queryKey` includes `weekStart` so each week is independently cached; mutations invalidate the right key. Empty state branches on `isCurrentWeek` ('No plan for that week' vs 'No shopping list yet'). Backend `GET /api/meals` and `GET /api/shopping-list` both accept the same `?week_start=` param; when omitted, the original "latest plan" behaviour is preserved.
> - **F2** Keyboard shortcuts. Vim-style 2-key sequences (`g d` Dashboard, `g r` Recipes, `g p` Pantry, `g s` Shopping List) navigate between the 4 main pages. `/` focuses the page's search input (Pantry + Recipes subscribe via a `useFocusSearchOnShortcut(ref)` hook). `?` shows a help banner. Suppressed inside text-entry controls and on modifier-key chords. 1.5s sequence timeout. Implementation lives in `frontend/src/hooks/useKeyboardShortcuts.ts` (the global handler) + `frontend/src/hooks/useFocusSearch.ts` (the focus bus) + `frontend/src/components/ShortcutHelpBanner.tsx` (the dialog).
> - **CRITICAL 0015 cast fix** (also in `d78bd18`): the CASE expression in `0015_normalize_pantry_aisles.py` failed with `text = boolean` on the `varchar(100) aisle` column. Sprint 2's dry-run query used a different path so the bug was not caught during Sprint 2. The fix is an explicit `::varchar(100)` cast on the whole CASE expression + simplified `WHEN '' THEN NULL` branch. **Without this fix, the deployment host's `alembic upgrade head` would have failed**, blocking Sprints 2, 3, 4 from going live. The local dev DB has been migrated successfully as of 2026-06-04.
> - **Backend changes:** `meals.py` and `shopping_list.py` (new query param) + `0015_normalize_pantry_aisles.py` (cast fix).
> - **Verification log:** `Review/sprint5-verification.md`. Deploy is a single batch for Sprints 2-5: backup → migrate → rebuild backend + frontend.
>
> **Sprint 8 status (in progress, approved 2026-06-05; not yet committed):** Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) — both surfaced in the user's 2026-06-05 follow-up. **User policy decision (2026-06-05):** "Hard filter. If it is denied this week twice, it should be considered denied for good." That collapses Sprint 8 to the **C + Z** model with a server-side 2-denial auto-escalation.
> - **T2.1** Migration `0016_denial_decay_and_scope.py` (NEW). Adds `meal_plan_item.denial_expires_at TIMESTAMPTZ NULL` (partial index on non-NULL) and `meal_plan_vote.denial_scope VARCHAR(16) NULL`. No data migration; existing rows keep `denial_expires_at = NULL` (the filter requires `> now()`, so old denied rows are effectively forgotten after 90d).
> - **T2.2** Model: `MealPlanItem.denial_expires_at` + `MealPlanVote.denial_scope`.
> - **T2.3** Schema: `MealPlanItemResponse.denial_expires_at`, `VoteRequest.denial_scope`, `VoteResponse.denial_scope`.
> - **T2.4** Backend helpers in `app/api/meals.py`: `_apply_denial` (single source of truth for the deny path), `_ensure_never_suggest_recipe` (idempotent NeverSuggest insert), `_has_prior_active_soft_denial` (counting query for the 2-denial auto-escalation check). `DENIAL_DECAY_DAYS = 90`.
> - **T2.5** `POST /api/meals/items/{id}/deny?scope=this_week|never_again` (default `this_week`). Returns `{message, item, promoted_to_permanent, scope}`. The auto-promotion check runs server-side.
> - **T2.6** `POST /api/meals/vote/{id}` extended: `vote: "approve" | "deny" | "never_again"`. Returns `denial_scope` + `promoted_to_permanent` so the email confirmation page can show what was applied.
> - **T2.7** Email HTML page (`/api/meals/vote/{id}` GET) renders 3 buttons (Approve / Deny this week / Never again). One-click direct-vote via `?scope=...` for the email's per-button links; consumes the token via `submit_vote` and renders a confirmation page.
> - **T2.8** Email template (`step_email`) renders 3 direct-action links per recipe. The legacy single-link "Vote on this meal" is preserved as a secondary "Open vote page (all 3 options)" link.
> - **T2.9** Planner: `_load_blocklists` returns 3 sets; `soft_denied_recipes` is hard-filtered (per user decision). `rejected_summary` adds a `soft_denied_recipe` diagnostic bucket.
> - **T2.10** Webui: `MealCard` renders 3 buttons (Approve / Deny this week / Never again) for **pending** items. `handleDeny` is scope-aware; toast reflects the server's `promoted_to_permanent` flag. "Never again" is gated by a `window.confirm` to prevent accidental permanent blocks.
> - **Verification log:** `Review/sprint8-verification.md`.
> - **No new dependencies. Migration is required (`alembic upgrade head` runs 0016). Deploy is `git pull` + migration + `docker compose up -d --build backend frontend`.**
>
> **Sprint 7 status (commit `09c7525`, awaiting deploy):** Outside-the-audit hotfix driven by user report. **Thread 1 of three open follow-ups from the user's 2026-06-05 message.** Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) are deferred until S7 is deployed + verified.
> - **T1.1** `runner._current_week_start()` → returns the **upcoming** Monday. Today (Fri 2026-06-05) the function returned Friday 2026-06-05; the user got an email for week-of-2026-06-05, but the webui opened on week-of-2026-06-01. One-line body change in `backend/app/services/orchestrator/runner.py:20-24`. Scheduler cron stays Friday.
> - **T1.2** `isoMonday` → `upcomingMonday` in `frontend/src/lib/utils.ts:44-50`. Same logic as T1.1; rename for intent. Add `formatWeekRange(mondayIso)` helper.
> - **T1.3** New `frontend/src/components/WeekRangeNav.tsx`. Renders the user-requested `[<]  Jun 8 — Jun 14  [>]` pattern with clickable chevrons and a clickable range label (jumps to the upcoming week). Replaces the inline Sprint 5 segmented control on Dashboard and ShoppingList. Includes a `This week` chip when off the upcoming week. Keyboard-accessible.
> - **T1.4** SQL: `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` — guarded `UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';` with a `SELECT COUNT(*)` first. Optionally migrates 2026-05-29 too (commented out; operator uncomments if desired). The user's 3-pending-items plan moves to the new Mon key.
> - **T1.5** "This week" semantic: **upcoming** Mon-Sun. Past weeks accessible via the back chevron. URL persistence (F5) unchanged.
> - **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 10 status (committed 2026-06-05, awaiting deploy):** User-driven — "Deny Forever" button on the Recipes surface (card overlay + RecipeDetail top bar). Surfaces the Sprint 1–3 `NeverSuggest` infrastructure on the webui Recipes page. Backend adds family-facing `POST` + `DELETE /api/never-suggest` endpoints; the existing admin path stays unchanged.
> - **T4.1** `POST /api/never-suggest` (public, webui-facing). Idempotent on `(family, recipe, reason)`. Returns the row joined with `recipe_name`.
> - **T4.2** `DELETE /api/never-suggest/{ns_id}` (public, webui-facing). Row-level ownership check (403 if cross-family).
> - **T4.3** `NeverSuggestRead.recipe_name` + `.ingredient_name` server-side joins. One LEFT OUTER JOIN per kind via `_attach_names()` helper.
> - **T4.4** `mealPlannerApi.neverSuggest.list/add/remove` in `frontend/src/api/index.ts`.
> - **T4.5** New `frontend/src/components/NeverSuggestButton.tsx` (~290 lines). Two variants: `card` (overlay) + `detail` (text buttons in top bar). Popover with `Allergy` (red, `window.confirm`) + `Dislike` (neutral, no confirm). Undo toast via `showToast.undo()` (Sprint 3 B12 pattern, 6s window). Pre-existing block detection shows a "Blocked" state with an "Unblock" path.
> - **T4.6** `Recipes.tsx` overlay. Card has `position: relative`; button is `opacity-0 group-hover:opacity-100 focus:opacity-100`. `e.preventDefault()` + `e.stopPropagation()` — doesn't navigate.
> - **T4.7** `RecipeDetail.tsx` top bar. New "Deny forever" button group to the left of "Add to Plan".
> - **Verification log:** `Review/sprint10-verification.md`. Deploy is `git pull` + `docker compose up -d --build backend frontend` (no migration; the `NeverSuggest` table already exists).
>
> **Sprint 11 status (committed 2026-06-05, awaiting deploy):** Wire the dead "Generate Meal Plan" empty-state CTA on the Dashboard. The button has been rendered with `onClick: () => {}` since Sprint 1; clicking it did nothing. Sprint 11 wires it to two existing endpoints (`POST /api/meals` to create a plan + `POST /api/meals/{id}/fill-empty-slots` to fill it from the recipe library). The handler lives on the client for now; future F8 (Spoonacular) + F9 (Ollama) will swap the `fillEmptySlots` call for an LLM call without changing the DOM. F8 + F9 are separate full backend proposals and remain in the §Future backlog.
> - **T5.1** New `handleGenerateFirstPlan` in `Dashboard.tsx:400-449`. Tracks `generatingFirstPlan` state; swaps the button label to "Generating…" and disables it while in-flight. Handles the "already exists" race (another tab created the plan first) by falling through to `getPlanned(weekStart)` + `fillEmptySlots`.
> - **T5.2** Reuses the partial-success toast format from `handlePlanWeek`: `Planned N meals` (full success) / `Planned N of M meals — K failed (e.g. <reason>)` (partial) / `Plan created — no recipes to add yet` (empty library).
> - **T5.3** `EmptyState.action.disabled?: boolean` — optional new prop on `EmptyState.tsx`. Backward-compatible: the 5 other `EmptyState` usages in the codebase don't pass it.
> - **Verification log:** `Review/sprint11-verification.md` (4-step browser smoke + race test + 2 API curls). Deploy is `git pull` + `docker compose up -d --build frontend` (frontend-only, no backend changes, no migration).
>
> **Sprint 12 status (committed 2026-06-05, awaiting deploy):** F8 Spoonacular search — adds a "Search the web" toggle on `/recipes` that hits Spoonacular's `complexSearch` API. Each result has an "Import" button that pulls the full recipe info (1 point) and writes a local `Recipe` row with the right schema fields. Spoonacular ingredients are upserted via the existing idempotent `POST /api/ingredients` endpoint. **No pre-existing WIP files touched.** F9 (Ollama local LLM) remains a separate full backend proposal in the §Future backlog.
> - **T6.1** `backend/app/api/recipe_search.py` (NEW, ~270 lines). 2 endpoints: `GET /api/recipes/search?q=&limit=` (1.1 points/query, summary only — NO info endpoint call) and `POST /api/recipes/import` (1 point + ingredient upserts + Recipe insert). Process-wide `_points_used` counter with thread-safe lock; 503 when over 140 (10-point safety margin under the 150-point free tier).
> - **T6.2** `backend/app/config.py` — added `SPOONACULAR_API_KEY: Optional[str] = None` to `Settings` (was previously read via `getattr` since `extra="ignore"`). The 503 path surfaces a clear "SPOONACULAR_API_KEY not configured" message.
> - **T6.3** `backend/app/schemas/__init__.py` — added `RecipeSearchHit` and `RecipeImportRequest` Pydantic models. The router is registered in `main.py:62-63` at the `/api/recipes` prefix.
> - **T6.4** Frontend: `frontend/src/api/index.ts` adds `recipes.search` + `recipes.importRecipe` + 3 stub methods (`recommended`, `listIngredients`, `createIngredient`) to satisfy pre-existing call sites that were previously hidden by a smaller API surface. `frontend/src/pages/Recipes.tsx` adds the toggle button (with `aria-pressed`) + the web-search panel (`<div role="region" aria-label="Web recipe search" aria-busy={webLoading}>`) + the import mutation (toast on success, `showApiError` on failure). `frontend/src/types/index.ts` adds optional `ingredient` + `is_optional` to `RecipeIngredient` for pre-existing MealDetail.tsx call sites.
> - **T6.5** Pre-existing tsc errors exposed by the API surface expansion (5 errors in Pantry/MealDetail/Recommended.tsx) — resolved per user decision: added 5 stub API methods + 2 type fields. Documented in `Review/sprint12-verification.md` D-fix section.
> - **Verification log:** `Review/sprint12-verification.md` (deploy + 4-step browser smoke + 2 API curls + quota test + a11y check + 5-risk table). Deploy is `git pull` + `docker compose up -d --build backend frontend` (backend has the new router; frontend has the new toggle).
>
> **Sprint 13 status (committed 2026-06-05, awaiting deploy):** F9-lite — splits the Sprint 11 "Generate Meal Plan" CTA into a 2-step modal: the user picks "Use the recipe library" (default, Sprint 11's flow) or "Ask the LLM" (new). The LLM path POSTs to `/api/llm/plan`; the backend calls `kimi-k2.6:cloud` on `ollama.com`, parses the LLM's JSON picks, creates a fresh plan, fills the LLM's picks, and falls through to the Sprint 6+ `fillEmptySlots` pattern for the slots the LLM didn't cover. **No pre-existing WIP files touched.** F9-full (local Ollama model pull on the host) remains a future sprint.
> - **T7.1** `backend/app/api/llm_plan.py` (NEW, ~280 lines). 1 endpoint (`POST /api/llm/plan` body `{prompt, week_start}`) + 4 helpers (`_ensure_ollama_configured`, `_serialize_library` with a 200-recipe cap, `_ask_llm` mirroring the `llm_matcher._ask_ollama` pattern, `_parse_picks` tolerant of markdown code fences, `_validate_picks` that drops invalid entries). 60s timeout, 422 on empty/oversized prompt, 503 on missing OLLAMA_API_KEY, 400 on duplicate week.
> - **T7.2** `backend/app/schemas/__init__.py` — added `LLMPlanRequest` + `LLMPlanResponse` Pydantic models. The router is registered in `main.py:65-66` at the `/api/llm` prefix.
> - **T7.3** Frontend: `frontend/src/api/index.ts` adds `llm.plan(data)`. `frontend/src/pages/Dashboard.tsx` adds the prompt modal (radio for library vs. LLM + textarea for the LLM path with 500-char counter) + extracted Sprint 11's body into `generateFromLibrary` + added `generateFromLLM`. New state: `showPromptModal`, `promptMode`, `promptText`, `promptBusy`. Click-outside-to-dismiss is disabled while `promptBusy` is true. The textarea `autoFocus`es when LLM mode is selected.
> - **Verification log:** `Review/sprint13-verification.md` (deploy + 3-step browser smoke + 4 API curls + a11y check + 6-risk table). Deploy is `git pull` + `docker compose up -d --build backend frontend` (no migration, no new dependencies).
> - **No new dependencies. No migration. Admin path unchanged.**
>
> **Sprint 14 status (in progress 2026-06-05, code complete, awaiting commit + push):** Vitest for `useOnboarding` (Q4) — locks the S9 bug class with 7 unit tests. Sprint 9 (F1 Onboarding Tour) shipped a hand-rolled ~420-line component; the bug `1562929` shipped a post-deploy fix the same day. Sprint 14 prevents recurrence at `npm test` time.
> - **T7.1** 4 new devDeps: `vitest@^1.6.0`, `happy-dom@^14.7.0`, `@testing-library/react@^14.2.0`, `@testing-library/jest-dom@^6.4.0` + `@types/node@^20` (tsc). Lifts the "no new npm deps" rule for testing-only. Runtime bundle unchanged.
> - **T7.2** `frontend/vitest.config.ts` (NEW) — happy-dom env, setup file, `src/**/*.test.{ts,tsx}` glob. `frontend/vitest-setup.ts` (NEW) — `@testing-library/jest-dom/vitest` matchers. `package.json` scripts: `test` (vitest run, no watch) + `test:watch`.
> - **T7.3** `frontend/src/components/OnboardingTour.test.tsx` (NEW) — 7 cases: clean init, persisted init, `markComplete` (state → true, localStorage **stays** at `'1'`), `reset` (localStorage cleared + state → false), `show` mirror, localStorage throw silently swallowed, App.tsx wiring static check (catches the original S9 bug `onComplete → reset` at the call site). 7/7 pass in ~25 ms.
> - **Verification log:** `Review/sprint14-verification.md`. `npm run build` still green (bundle 503.82 kB unchanged). No migration. No backend change. Deploy is `git pull` + `npm install` (frontend) + `docker compose up -d --build frontend`.
> - **No new runtime dependencies. No migration. Admin path unchanged. Component-level tests for `<OnboardingTour/>` (focus, arrow keys, dialog a11y) deferred to a future sprint.**
>
> **Sprint 15 status (in progress 2026-06-06, code complete, awaiting commit + push):** Content op + Sprint 12 latent-bug fix. (1) **Sprint 12 bug fix:** `backend/app/main.py` reorders the `recipe_search_api.router` mount to BEFORE the WIP's `recipes_api.public_router` so the WIP's `GET /{recipe_id}` no longer shadows `/search` and `/import`. Without this fix, every Sprint 12 frontend query would 422. (2) **Sprint 15 content op:** `scripts/seed_recipes.py` (NEW, ~150 lines) seeds 50 family-friendly recipes from Spoonacular. 18 imported today (Spoonacular free-tier cap is 50 pts/day, not 150; remaining 32 to import on future days via the same script, which is idempotent). DB went from 31 → 49 total recipes (19 Spoonacular + 30 manual). LLM test (Sprint 13 endpoint) for week 2026-07-06 returned `picked_count=0 / filled_count=19 / failed_count=2` — the library fill covered 19 of 21 slots, the LLM (kimi-k2.6:cloud) returned 0 picks.
> - **T8.1** `backend/app/main.py` — moved `recipe_search_api.router` import to line 39 (with the other api imports) and the `include_router` call to before `recipes_api.public_router` mount. Three-line comment explains the why. Verified: `GET /api/recipes/search?q=...` returns 200 with hits; `POST /api/recipes/import` still 201.
> - **T8.2** `scripts/seed_recipes.py` (NEW) — 50-query list (5 cuisines × 10 each), direct `complexSearch` + backend `import`. 1.5 sec sleep. Idempotent (409 logged). Stops cleanly on 402.
> - **T8.3** Follow-up: `_DAILY_LIMIT=140` in `backend/app/api/recipe_search.py:48` should drop to 45 to match the actual 50-pt free tier. Not blocking.
> - **Verification log:** `Review/sprint15-verification.md` (full breakdown of 18 imported, free-tier math, LLM test, risk table).
> - **No new runtime dependencies. No schema changes. No UI changes. Deploy is `git pull` + `docker compose up -d --build backend frontend` (backend picks up the main.py fix; the 18 new recipes are already in the DB).**
>
> **Sprint 15 Round 2 (2026-06-07):** +18 recipes, library at 67 total. `scripts/seed_recipes_round2.py` (NEW, ~120 lines) — 50-query list focused on cuisines the round 1 list didn't cover: Indian (8) + Thai (6) + Chinese regional (6) + Soups & stews (6) + Salads (6) + Sandwiches/wraps (5) + Breakfast (5) + German/European (4) + French (4). Same idempotent behavior (409 on duplicate). 18 imported; 12 queries returned no hits from Spoonacular's free-tier index; 30th query hit 402. LLM test (Sprint 13, week 2026-07-20, prompt "variety, mix of cuisines, family-friendly, no repeats"): `picked_count=0 / filled_count=21 / failed_count=0`. **Library now covers all 21 slots of a week.** Tracking: appended to `Review/sprint15-verification.md`.
>
> **Sprint 15 Round 3 (2026-06-07):** +10 recipes, library at 77 total. Re-ran `scripts/seed_recipes.py` (round 1's script, idempotent) after the 50-pt quota rolled over. Skipped 37 duplicates; 10 new imports (Asian leftovers from round 1's cap-blocked queries + 8 American comfort dishes: Superbowl Chili, Veggie Meatloaf, Crab Mac and Cheese, BBQ Chicken, Classic Pot Roast, Lean Shepherd's Pie, Amazing Chicken Pot Pie, Slow Cooker Beef Stew). LLM test (Sprint 13, week 2026-08-03, prompt "comfort food, no repeats from past 2 weeks"): `picked_count=0 / filled_count=21 / failed_count=0`. **Library at 77, well past the 4-week coverage threshold.** Tracking: appended to `Review/sprint15-verification.md`.
>
> **Sprint 16 status (in progress 2026-06-08, code complete, awaiting commit + push):** Fix Sprint 13 latent bug — every `/api/llm/plan` call has returned `picked_count=0` since 2026-06-05 because `kimi-k2.6:cloud` is a reasoning model that burns the `max_tokens` budget on internal `reasoning` and never produces the JSON answer. The library fill (Sprint 6+) silently took over every time. Discovered by adding a temp debug log and seeing `raw_response=''` with `finish_reason: length`. Two-line fix: `backend/app/config.py:38` switches `OLLAMA_MODEL` from `kimi-k2.6:cloud` to `gpt-oss:20b` (OpenAI's open-source 20B non-reasoning model); `backend/app/api/llm_plan.py:117` bumps `max_tokens` from 800 to 4000 (21 picks × ~100 chars + reasoning = ~2100+ chars; 4000 gives 2x headroom); `backend/.env` (or `docker-compose` env) also updated so the container reads `gpt-oss:20b`. `frontend/src/api/llm.test.ts` (NEW, 4 cases) — Vitest contract test on the LLM response shape. **Live verification:** 5/5 test weeks return `picked_count` 15-21 (was 0 before). **Tracking:** `Review/sprint16-verification.md`. 11/11 tests pass, `npm run build` green. No new runtime deps. No schema change. No UI change.
>
>
> **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>)`.
> - **Backend changes:** `pantry.py` + `meals.py` (new endpoints) + `schemas/__init__.py` (3 new schema types).
> - **Verification log:** `Review/sprint6-verification.md`. No migration. Deploy is `docker compose up -d --build backend frontend`.

---

## Findings mapped to Nielsen's 10 Heuristics

### H1 · Visibility of system status — *Partial*

✅ **Works well**
- Toasts (`react-hot-toast`) for generate/delete are top-right and persist.
- Status badges on the dashboard (e.g. `$206.21` total) update reactively.
- Loading skeletons render on data fetch.

⚠️ **Gaps**
- **Filters (P2).** Active filter count is not shown when the filter panel is collapsed (`Recipes.tsx:99` area, `16-recipes-filters-open.png`). User has no way to know a filter is on.
- **Pantry search (P2).** No "X of N results" indicator.
- **Sync status (P2).** When a meal is being generated, no spinner on the slot itself — only the global toast after success.

**Fix:** Render an `activeFilters.length` chip on the Filters button; add a small "Searching…" indicator inside the Pantry search input.

---

### H2 · Match between system and the real world — *Multiple violations*

🚨 **P1 · Snake-case aisle labels on Shopping List** (`page-shopping-list.png`)
- Sections display `meat_seafood`, `produce`, `pantry`, `dairy`.
- Fix: human-readable map in `ShoppingList.tsx`:
  ```ts
  const AISLE_LABEL: Record<string,string> = {
    meat_seafood: 'Meat & Seafood', produce: 'Produce',
    pantry: 'Pantry', dairy: 'Dairy & Eggs',
  };
  ```

🚨 **P1 · `$N/A per serving`** (`MealDetail.tsx:191`, `14-meal-detail.png`)
- `${item.estimated_cost?.toFixed(2) || 'N/A'}` renders `$N/A` literally because the `$` is outside the conditional.
- Fix:
  ```tsx
  {item.estimated_cost != null
    ? `$${item.estimated_cost.toFixed(2)} per serving`
    : 'No price estimate yet'}
  ```

🚨 **P0 · Ingredients render without quantities on the Meal page** (`MealDetail.tsx:249-252`, `14-meal-detail.png`)
- Code reads `ing.quantity` / `ing.unit` but the backend returns `qty` (per `RecipeDetail.tsx:161` working correctly). Result: `lb Pork Chops, Bone-In` instead of `1 lb Pork Chops, Bone-In`.
- Fix: rename both fields to a single canonical name (recommend `qty` to match backend), or apply a compatibility shim:
  ```tsx
  const qty = ing.qty ?? ing.quantity;
  const unit = ing.unit ?? ing.unit;
  ```
  and update the type definition.

🚨 **P1 · Spoonacular marketing copy leaks into meal description** (`14-meal-detail.png`)
- The meal page description includes: *"Featured In Group could be just the gluten free, dairy free, and ketogenic recipe you've been looking for… users who liked this recipe also liked Baked Chicken In Avocado Boat…"*
- Fix: backend `Meal.description` should be truncated to ~280 chars on import, with a regex strip of the "Featured In Group…" / "users who liked…" boilerplate. Alternatively, render `description.split('. ').slice(0,2).join('. ')+'.'` on the frontend with a `line-clamp-3` parent.

🚨 **P1 · Hero title overlaps description** (`MealDetail.tsx:168-197`, `14-meal-detail.png`)
- Long description text (no `line-clamp`) sits over the absolute-positioned title block, making the title literally unreadable.
- Fix: add `line-clamp-3` and `max-w-2xl` on the description; ensure the title is in normal flow (not absolute) on this view.

---

### H3 · User control and freedom — *Partial*

✅ **Works**
- Back links on Recipe and Meal detail pages.
- Drag-and-drop on dashboard (via `@hello-pangea/dnd`) is reversible.

⚠️ **Gaps**
- **P1 · Native `confirm()` dialogs for delete** — jarring, breaks visual continuity. Replace with an inline "Undo" toast (e.g. `react-hot-toast` with a 5s undo that re-fires the create query). See `Dashboard.tsx` meal delete and `Pantry.tsx` row delete.
- **P2 · No keyboard shortcut to focus search** on Recipes/Pantry/Shopping List. Convention is `/` or `Cmd+K`.
- **P2 · Filters have no Reset button** (`16-recipes-filters-open.png`).

---

### H4 · Consistency and standards — *Multiple violations*

🚨 **P1 · Aisle casing inconsistency in Pantry** (`page-pantry.png`, `Pantry.tsx:200`)
- Rows show `Canned Goods`, `Pantry`, `pantry`, `Produce`, `Freezer` — all derived from free-text input. Aisle should be a fixed enum.
- Fix: replace the free-text input with a `<select>` populated from `['Produce','Meat & Seafood','Dairy & Eggs','Pantry','Frozen','Bakery','Beverages','Spices','Other']`. Migrate existing rows via a one-off script that lowercases + maps.

🚨 **P1 · `qty` vs `quantity` field mismatch** between Recipe and Meal detail (covered under H2). The shared `Ingredient` type should have one canonical field.

⚠️ **P2 · Aisle filter pill on dashboard Shopping List card** is uppercase by Tailwind class; the rest of the app uses sentence case.

⚠️ **P2 · Mixed icon set** — `lucide-react` everywhere except a few hand-rolled SVGs in the dashboard's empty state.

---

### H5 · Error prevention — *Violations*

🚨 **P1 · Add Pantry Item form has no required markers and no validation** (`17-pantry-add-item.png`, `Pantry.tsx:~180-220`)
- "Add" button looks pre-disabled (light blue) but the user has no idea *why*. No `*` indicator on the required Ingredient Name field, no inline error, no disabled-until-valid logic explained.
- Fix: add `<span className="text-danger">*</span>` to required field labels; use `aria-describedby` to attach an inline help text; show an inline error on submit fail (e.g. *duplicate item*).

⚠️ **P2 · Filters apply immediately on change** — user can lose their current result set by accidently nudging "Max time". Add explicit Apply (or debounce 400 ms with a clear "Applying…" indicator).

⚠️ **P2 · Meal generate (Generate button) has no confirmation** for the current week — clicking accidentally overwrites. A `confirm()` for destructive regenerate is acceptable; better: a small "Replace existing?" toggle.

---

### H6 · Recognition rather than recall — *Partial*

✅ **Works**
- Recipe cards show tags (cuisine, diet) and quick stats.
- Status badges (Approved, etc.) are color-coded consistently.

⚠️ **Gaps**
- **P2 · No breadcrumbs** on detail pages. From `/meals/f28…` the user cannot see "Meal Plan › Pork Stir-Fry" without remembering.
- **P2 · No active filter chips** on the Recipes page — when filters are collapsed, user has no visible reminder of what's on (see H1).
- **P2 · Empty Pantry state** has no illustration or "Add your first item" primary CTA; just a blank table.

---

### H7 · Flexibility and efficiency of use — *Weak*

🚨 **P1 · No bulk actions on Shopping List or Pantry** (`page-shopping-list.png`, `page-pantry.png`)
- Adding common items (salt, pepper, oil) is one-by-one. Add a "Multi-select" mode with a header that says `2 selected · [Delete] [Move aisle]`.

⚠️ **P2 · No keyboard shortcuts.**
- `/` focus search
- `g p` go to Pantry
- `g s` go to Shopping List
- `n m` new meal
- A small `useShortcuts` hook in `App.tsx` plus a "?" help modal would cover this.

⚠️ **P2 · Generate button regenerates one slot at a time**. A "Plan whole week" button would be a huge efficiency win for a meal planner.

⚠️ **P2 · No persistent week selector** in the URL — back/forward loses the week you're viewing.

---

### H8 · Aesthetic and minimalist design — *Mostly good, with one outlier*

✅ **Works**
- Palette is restrained (surface, primary, warning, success, danger).
- Card hierarchy is clear on Recipes grid.

🚨 **P1 · Meal detail hero is chaotic** (`14-meal-detail.png`)
- Title, badge, description, and metadata all compete; title is unreadable due to the overlap (H2). Long marketing copy adds noise. Tighten to: title → single-line subtitle (cuisine · 25 min · 4 servings) → 1-2 sentence description → CTA. Move the long marketing body into a "Notes from source" collapsible at the bottom.

⚠️ **P2 · Stat cards on Shopping List stack 3 full-width tiles on mobile** (`mobile-shopping-list.png`) — heavy vertical scroll. Consider a 3-up compact layout (icon + value, label below) for `< sm`.

⚠️ **P2 · Mobile nav wraps "Shopping List" onto a second line** (`04-dashboard-mobile.png`). Add `whitespace-nowrap` to nav links.

---

### H9 · Help users recognize, diagnose, and recover from errors — *Violations*

🚨 **P0 · `/recommended` is a blank page** (`page-recommended.png`, `13-recommended-broken.png`, `mobile-recommended.png`)
- The Navigation links do not point to `/recommended` (they correctly point to `/recipes/recommended`), but the URL is referenced in user-facing strings somewhere (most likely an email link or share URL) and resolves to an empty React Router outlet.
- The "Recommended" link in the Recipes header also has a known link to `/recipes/recommended` which works.
- Fix: add a `*` catch-all route in `App.tsx` rendering a friendly `NotFound` component with a "Back to dashboard" CTA; optionally also alias `/recommended → /recipes/recommended` via `<Navigate replace />`.

🚨 **P1 · No error boundary** — if a single component throws (e.g. an ingredient with `null.qty`), the whole page goes blank. Add a top-level `<ErrorBoundary>` in `App.tsx` that shows "Something went wrong. [Reload] [Report]".

⚠️ **P2 · Recipes with no image show a generic cooking-pot icon silently.** Add a `title="Image not available"` and consider a "Report missing image" link.

⚠️ **P2 · 401/403/500 errors from the API are not surfaced as user-readable toasts.** Hook into the `react-query` `onError` global handler.

---

### H10 · Help and documentation — *Missing*

🚨 **P1 · No onboarding for first-time users** — empty dashboard, empty pantry, empty shopping list with no guidance.
- Add a one-time tour (e.g. `react-joyride`) or just 3 inline hint cards on the dashboard: *1. Add items to your pantry · 2. Generate this week's meals · 3. Review the shopping list*.
- Add a "?" icon in the nav that opens a Help modal with a quick-start, FAQ, and a link to `docs/`.

⚠️ **P2 · No tooltips on advanced filter labels** (Max time, Max spice, Max calories) — units and ranges are not obvious. Use `aria-describedby` + a small "?" popover.

⚠️ **P2 · Print List button is hidden behind the page scroll** on mobile. Make it sticky on `lg:` viewports at minimum.

---

## Additional concrete bugs

| # | Where | Bug | Severity | Fix |
|---|---|---|---|---|
| B1 | `RecipeDetail.tsx:161` | `ing.qty != null && `2 canBlack Beans` (no space) | P0 | Drop `.trim()` or add explicit ` ` before `{ing.name}` |
| B2 | `MealDetail.tsx:249-252` | `ing.quantity` undefined → no quantities shown | P0 | Use `ing.qty ?? ing.quantity` or rename to `qty` |
| B3 | `MealDetail.tsx:191` | `$N/A per serving` | P0 | Conditional on `cost != null` |
| B4 | `App.tsx` (routes) | No `/recommended`, no `*` NotFound | P0 | Add `<Route path="*" element={<NotFound/>}>` + alias `/recommended` |
| B5 | `Dashboard.tsx:164,219` | Empty slots hidden on mobile | P0 | Remove `hidden md:flex` / `hidden md:block` (or replace with `flex` on both) |
| B6 | `Dashboard.tsx:87` | `truncate` cuts meal name to 1-2 chars | P1 | `line-clamp-2` and shrink image on narrow grid |
| B7 | `MealDetail.tsx:168-197` | Title overlaps description | P1 | Remove absolute positioning, add `line-clamp-3` on description |
| B8 | `Pantry.tsx:200` | Free-text aisle | P1 | Convert to `<select>` with canonical list |
| B9 | `ShoppingList.tsx` | snake_case aisle names | P1 | Human-readable map |
| B10 | `Pantry.tsx:236` | `overflow-x-auto` without scroll hint on mobile | P1 | Add a faded right-edge gradient + aria `role="region"` with descriptive label |
| B11 | `Recipes.tsx` | Filters have no Apply/Reset/active count | P1 | Add Reset, Apply, and an `activeCount` chip on the Filters button |
| B12 | various | Native `confirm()` for delete | P2 | Replace with `react-hot-toast` undo pattern |
| B13 | `Navigation.tsx:14-38` | "Shopping List" wraps on mobile | P2 | Add `whitespace-nowrap` |
| B14 | dashboard/Shopping List | Stat cards stack full-width on mobile | P2 | Use 3-col compact layout for `<sm` |

---

## Accessibility (WCAG 2.1 AA quick scan)

- **P1 · `aria-current="page"`** missing on the active nav link. Add it in `Navigation.tsx`.
- **P1 · Recipes filter panel** opens inline but is not announced as a region. Add `role="region"` `aria-label="Filters"`.
- **P1 · Modal/dialogs** (none observed, but recommend `focus-trap-react` whenever added).
- **P2 · Color-only signals** — "Approved" badge relies on green alone; add an icon or `aria-label="Approved"`.
- **P2 · Touch targets** — Generate buttons in empty slots are < 44 px tall on mobile. Bump to `min-h-11`.

---

## Recommended implementation order

A pragmatic 3-sprint plan, each ending in something visible to a user testing the deployment.

**Sprint 1 — Stop the bleeding (P0s, ~3 days)**
1. B1 (recipe ingredients space)
2. B2 (meal ingredient field rename + shim)
3. B3 (`$N/A` fix)
4. B4 (404 + `/recommended` alias)
5. B5 (mobile empty slots visible)

**Sprint 2 — Trust the data (P1s, ~4 days)**
1. B6 (card title line-clamp)
2. B7 (meal hero overlap + description clamp)
3. B8 (pantry aisle select)
4. B9 (shopping list aisle map)
5. B10 (mobile pantry scroll hint)
6. B11 (filters: Apply, Reset, active count)

**Sprint 3 — Polish (P2s + a11y, ~3 days)**
1. Undo-toast replaces `confirm()` (B12)
2. Mobile nav wrap (B13)
3. Stat card responsive layout (B14)
4. Onboarding hints on empty dashboard (H10)
5. Error boundary (H9)
6. A11y sweep (aria-current, regions, 44 px targets)

---

## Appendix · Captured screenshots

| Screenshot | Notes |
|---|---|
| `03-dashboard.png` | Desktop dashboard — full week grid, status badge, $206.21 |
| `04-dashboard-mobile.png` | Mobile dashboard — empty slots **hidden** (B5) |
| `page-recipes.png` | 30 recipe grid, search + filters |
| `mobile-recipes.png` | 2-col on mobile, OK |
| `page-pantry.png` | Mixed-case aisles (B8) |
| `mobile-pantry.png` | Columns cut off silently (B10) |
| `page-shopping-list.png` | snake_case aisles (B9) |
| `mobile-shopping-list.png` | Stat cards stack full-width (B14) |
| `page-recommended.png` | **BLANK** — missing route (B4) |
| `mobile-recommended.png` | Same blank on mobile |
| `10-recipe-detail.png` | `/recipes/recommended` — actually renders fine |
| `11-recipe-detail-real.png` | **Bug:** `2 canBlack Beans` (B1) |
| `12-recipe-detail-mobile.png` | Stacks OK on mobile |
| `13-recommended-broken.png` | Blank `/recommended` |
| `14-meal-detail.png` | **Bugs:** overlap, `$N/A`, missing quantities, SEO copy leak (B2/B3/B7) |
| `15-after-generate-click.png` | Toast works; new meal title clipped to `B..` (B6) |
| `16-recipes-filters-open.png` | Filters inline, no Apply/Reset (B11) |
| `17-pantry-add-item.png` | No required marker, pre-disabled looking Add (B12) |
| `18-focus-state.png` | Focus ring on nav link works ✅ |
