feat(ui): close 6 P1 audit findings + 1 bonus mobile fix (Sprint 2)

- Dashboard MealCard: title truncate -> line-clamp-2, image shrinks to
  40x40 on <md to give the title room (B6).
- MealDetail: hero reworked to normal flow with stronger gradient;
  description runs through new cleanDescription() helper that strips
  14 spoonacular SEO patterns and trims to the last full sentence.
  Raw description moved to a 'Notes from source' disclosure (B7).
- Pantry: free-text aisle/unit replaced with <Select> populated from
  the new PANTRY_AISLES canonical enum; ingredient name field marked
  required. New PANTRY_AISLES export + PantryAisle type in types (B8).
- backend: alembic 0015_normalize_pantry_aisles maps free-text
  ingredient.aisle and grocery_item.aisle to canonical labels in a
  single transaction; downgrade raises (restore from snapshot).
  backend/scripts/dry_run_aisle_migration.sql is the read-only
  preview helper.
- ShoppingList: human-readable AISLE_LABEL map replaces raw snake_case
  aisle keys; 3-col stat grid with compact mobile sizing (B9 + S3.3).
- Pantry table: role/aria-label region and a right-edge white
  gradient hint at mobile horizontal overflow (B10).
- Recipes: pending/applied filter split, Apply and Reset buttons,
  active-count chip on the Filters button, role=region + aria-label
  on the panel (B11).
- Review/sprint2-verification.md and fix-ui-audit.md updated.

Build: npm run build (tsc + vite) green. tsc emits 0 errors.

Co-located audit + plan docs kept in sync: Review/ui-nielsen-audit.md
gains a Sprint 2 status block; fix-ui-audit.md has implementation
notes for each Sprint 2 task.
This commit is contained in:
2026-06-03 17:36:26 -07:00
parent 36038bb9cb
commit ccc70aaf72
12 changed files with 728 additions and 81 deletions
+55 -23
View File
@@ -9,6 +9,38 @@ import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
const AISLE_LABEL: Record<string, string> = {
produce: 'Produce',
meat: 'Meat & Seafood',
seafood: 'Meat & Seafood',
meat_seafood: 'Meat & Seafood',
chicken: 'Meat & Seafood',
beef: 'Meat & Seafood',
pork: 'Meat & Seafood',
dairy: 'Dairy & Eggs',
eggs: 'Dairy & Eggs',
cheese: 'Dairy & Eggs',
milk: 'Dairy & Eggs',
yogurt: 'Dairy & Eggs',
pantry: 'Pantry',
canned: 'Pantry',
canned_goods: 'Pantry',
dry: 'Pantry',
snacks: 'Pantry',
frozen: 'Frozen',
bakery: 'Bakery',
bread: 'Bakery',
beverages: 'Beverages',
drinks: 'Beverages',
spices: 'Spices',
seasoning: 'Spices',
other: 'Other',
}
function aisleDisplay(key: string): string {
return AISLE_LABEL[key.toLowerCase()] ?? key
}
function ShoppingListSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
@@ -156,46 +188,46 @@ export default function ShoppingListPage() {
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-3 gap-2 sm:gap-4">
<Card className="bg-gradient-to-br from-primary-50 to-primary-100/50 border-primary-200">
<CardBody>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-100 flex items-center justify-center">
<Receipt className="w-5 h-5 text-primary-600" />
<CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-primary-100 flex items-center justify-center flex-shrink-0">
<Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-primary-600" />
</div>
<div>
<div className="text-2xl font-bold text-surface-900">
<div className="min-w-0">
<div className="text-base sm:text-2xl font-bold text-surface-900 truncate">
${shoppingList.total_estimated_cost.toFixed(2)}
</div>
<div className="text-sm text-surface-500">Estimated Total</div>
<div className="text-[10px] sm:text-sm text-surface-500 truncate">Estimated</div>
</div>
</div>
</CardBody>
</Card>
<Card>
<CardBody>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-surface-100 flex items-center justify-center">
<Package className="w-5 h-5 text-surface-600" />
<CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-surface-100 flex items-center justify-center flex-shrink-0">
<Package className="w-4 h-4 sm:w-5 sm:h-5 text-surface-600" />
</div>
<div>
<div className="text-2xl font-bold text-surface-900">{shoppingList.items.length}</div>
<div className="text-sm text-surface-500">Total Items</div>
<div className="min-w-0">
<div className="text-base sm:text-2xl font-bold text-surface-900 truncate">{shoppingList.items.length}</div>
<div className="text-[10px] sm:text-sm text-surface-500 truncate">Items</div>
</div>
</div>
</CardBody>
</Card>
<Card className="bg-gradient-to-br from-success-50 to-success-100/50 border-success-200">
<CardBody>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-success-100 flex items-center justify-center">
<Tag className="w-5 h-5 text-success-600" />
<CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-success-100 flex items-center justify-center flex-shrink-0">
<Tag className="w-4 h-4 sm:w-5 sm:h-5 text-success-600" />
</div>
<div>
<div className="text-2xl font-bold text-success-700">{shoppingList.sale_items_count}</div>
<div className="text-sm text-surface-500">Items on Sale</div>
<div className="min-w-0">
<div className="text-base sm:text-2xl font-bold text-success-700 truncate">{shoppingList.sale_items_count}</div>
<div className="text-[10px] sm:text-sm text-surface-500 truncate">On Sale</div>
</div>
</div>
</CardBody>
@@ -207,7 +239,7 @@ export default function ShoppingListPage() {
{Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
<Card key={aisle} className="overflow-hidden">
<div className="px-5 py-3 border-b border-surface-200 bg-surface-50 flex items-center justify-between">
<h3 className="text-sm font-semibold text-surface-900">{aisle}</h3>
<h3 className="text-sm font-semibold text-surface-900">{aisleDisplay(aisle)}</h3>
<Badge variant="neutral">{items.length} items</Badge>
</div>
<ul className="divide-y divide-surface-200">