16 KiB
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):
- Meal detail ingredients render without quantities (field-name bug,
MealDetail.tsx:249-250) — a core function of the page is unreadable. $N/A per servingdisplayed literally (MealDetail.tsx:191).- Recipe detail ingredients collapse unit and name (
RecipeDetail.tsx:161—2 canBlack Beans). /recommendedreturns a blank page (missing route + no 404 catch-all inApp.tsx).- Mobile dashboard hides empty meal slots (
Dashboard.tsx:164,219— users on phones cannot plan meals, only view them).
Sprint 1 status (commit
f3e4a44): Items 1, 2, 3, 4, 5 all addressed in the frontend source. ⚠️ Not yet deployed to the live server at100.108.208.56:8082/— that host is a different machine (Tailscale IP100.108.224.12) than the one I edited on. The user mustgit pullanddocker compose up -d --build frontendon the deployment host. Verification screenshots in/tmp/opencode/mp-review/screenshots/fix-sprint1/.
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.21total) 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:99area,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: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/Aliterally because the$is outside the conditional.- Fix:
{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.unitbut the backend returnsqty(perRecipeDetail.tsx:161working correctly). Result:lb Pork Chops, Bone-Ininstead of1 lb Pork Chops, Bone-In. - Fix: rename both fields to a single canonical name (recommend
qtyto match backend), or apply a compatibility shim:and update the type definition.const qty = ing.qty ?? ing.quantity; const unit = ing.unit ?? ing.unit;
🚨 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.descriptionshould be truncated to ~280 chars on import, with a regex strip of the "Featured In Group…" / "users who liked…" boilerplate. Alternatively, renderdescription.split('. ').slice(0,2).join('. ')+'.'on the frontend with aline-clamp-3parent.
🚨 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-3andmax-w-2xlon 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-toastwith a 5s undo that re-fires the create query). SeeDashboard.tsxmeal delete andPantry.tsxrow delete. - P2 · No keyboard shortcut to focus search on Recipes/Pantry/Shopping List. Convention is
/orCmd+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; usearia-describedbyto 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 searchg pgo to Pantryg sgo to Shopping Listn mnew meal- A small
useShortcutshook inApp.tsxplus 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/recommendedwhich works. - Fix: add a
*catch-all route inApp.tsxrendering a friendlyNotFoundcomponent with a "Back to dashboard" CTA; optionally also alias/recommended → /recipes/recommendedvia<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 inNavigation.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-reactwhenever 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)
- B1 (recipe ingredients space)
- B2 (meal ingredient field rename + shim)
- B3 (
$N/Afix) - B4 (404 +
/recommendedalias) - B5 (mobile empty slots visible)
Sprint 2 — Trust the data (P1s, ~4 days)
- B6 (card title line-clamp)
- B7 (meal hero overlap + description clamp)
- B8 (pantry aisle select)
- B9 (shopping list aisle map)
- B10 (mobile pantry scroll hint)
- B11 (filters: Apply, Reset, active count)
Sprint 3 — Polish (P2s + a11y, ~3 days)
- Undo-toast replaces
confirm()(B12) - Mobile nav wrap (B13)
- Stat card responsive layout (B14)
- Onboarding hints on empty dashboard (H10)
- Error boundary (H9)
- 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 ✅ |