Files
Meal-Planner/Review/ui-nielsen-audit.md
admin a8debbe96a docs: Sprint 16.1 — _DAILY_LIMIT 140 → 45 follow-up across all 6 running docs
Sprint 16.1 (commit 11cfd46) is a one-line fix that lowers
_DAILY_LIMIT in backend/app/api/recipe_search.py:48 from
140.0 to 45.0. The 140 value was set assuming Spoonacular's
free tier is 150 pts/day; Sprint 15 round 1 proved the real
cap is 50 pts/day. The gate now triggers at 45 (5pt safety
margin), preventing the user from making requests that
would 503 after a 402 upstream roundtrip.

This commit updates the 6 running docs that track sprints:

- .agent/plan.md — Sprint 16.1 section appended to the
  Sprint 16 sections.
- .agent/context.md — Sprint 16.1 decisions + file:line
  references added.
- Review/sprint16-verification.md — Sprint 16.1 section
  appended (one-line change + verification).
- Review/ui-nielsen-audit.md — Sprint 16.1 paragraph added
  to the Sprint 16 status block.
- fix-ui-audit.md — T9.6 added to the Sprint 16 section.
- Review/handoff-ui-audit.md — TL;DR Sprint 16.1 line
  added, Last-updated footer updated.
- docs/HANDOFF.md — Tracking docs reference updated to
  include Sprint 16.1, Last-updated footer updated.

All 6 docs now reflect Sprint 16.1.
2026-06-08 14:12:59 -07:00

426 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 13 `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 16.1 (2026-06-08):** one-line `_DAILY_LIMIT` follow-up. `backend/app/api/recipe_search.py:48` — `_DAILY_LIMIT: float = 45.0` (was 140.0). The 140 cap assumed Spoonacular free tier was 150 pts/day; Sprint 15 round 1 proved the real cap is 50. The gate now triggers at 45 (5pt safety margin), preventing the user from making requests that would 503 after a 402 upstream roundtrip. Documented in `Review/sprint16-verification.md` (Sprint 16.1 section).
>
>
> **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 ✅ |