# F-059 — Implementer evidence ## Scope delivered Product images were being cropped whenever they were not square. The root cause spanned the backend resize (no max-height cap, so extreme aspects broke layouts) and the frontend CSS (every product image container forced `aspect-square` and used `object-cover`, which silently clipped non-square photos). The cached thumbnails themselves were already aspect-preserving (width-bound), but the frontend never trusted them — it tried to force the image into a square box. ## Changes ### Backend — bounded, aspect-preserving resize In all four upload/thumbnail locations: - `project/apps/admin/src/app/api/upload/route.ts` - `project/apps/admin/src/app/uploads/[...path]/route.ts` - `project/frontend/src/app/uploads/[...path]/route.ts` - `project/storefront/src/app/uploads/[...path]/route.ts` The `sharp().resize(...)` call now uses `fit: 'inside'` together with a max-height bound: ```ts const MAX_HEIGHT_RATIO = 1.4; // max 5:7 aspect const maxHeight = Math.round(width * MAX_HEIGHT_RATIO); sharp(buffer) .resize({ width, height: maxHeight, fit: 'inside', withoutEnlargement: true }) .toBuffer(); ``` This produces thumbnails that: - Preserve the source aspect ratio (no distortion, no cropping) - Fit inside a bounded box (max `width × width * 1.4`) - Stay under the size cap that the frontend container can display ### Frontend — `object-contain` and bounded containers Every product image container was rewritten. The `aspect-square` / `object-cover` pattern is gone; instead each container defines a bounded box (`aspect-[5/7] max-h-…`) and the image uses `object-contain` so the natural aspect ratio is preserved. | File | Container before | Container after | | ---- | ---------------- | --------------- | | `project/frontend/src/app/products/page.tsx` | `aspect-square relative` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` | | `project/frontend/src/app/products/[slug]/page.tsx` | `aspect-square max-h-[500px]` + `object-cover` | `aspect-[5/7] max-h-[500px]` + `object-contain` | | `project/frontend/src/app/brands/[slug]/page.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` | | `project/frontend/src/app/search/page.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` | | `project/frontend/src/app/categories/[slug]/page.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` | | `project/frontend/src/components/home/FeaturedProducts.tsx` | `aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain` | | `project/frontend/src/components/checkout/CheckoutClient.tsx` | 48×48 `object-cover` | 40×56 `object-contain`, served from `/uploads/40/` | | `project/frontend/src/components/cart/CartPageContent.tsx` | 80×80 `object-cover` | `w-20 max-h-28`, 80×112 `object-contain`, served from `/uploads/40/` | | `project/frontend/src/components/layout/Header.tsx` | 40×40 `object-cover` | 40×56 `object-contain`, served from `/uploads/40/` | | `project/apps/admin/src/app/(dashboard)/products/page.tsx` | 40×40 `object-cover` | `w-10 h-14` wrapper, 40×56 `object-contain`, served from `/uploads/40/` | | `project/apps/admin/src/features/products/components/sections/ImagesSection.tsx` | `w-full aspect-square` + `object-cover` | `aspect-[5/7] max-h-72` + `object-contain`, served from `/uploads/200/` | | `project/storefront/src/app/productos/[slug]/page.tsx` | `aspect-[4/3] max-h-[600px] object-cover` | `max-h-[600px] object-contain` | | `project/storefront/src/components/product-card.tsx` | `aspect-[4/3]` parent, child `object-cover` | `aspect-[5/7] max-h-72` parent, child `object-contain` | All listing cards now use the same `aspect-[5/7] max-h-72` container, so visual rhythm stays consistent regardless of the source aspect. ## Acceptance traceability | Acceptance criterion | How it is met | | -------------------- | ------------- | | Backend resize uses `fit: 'inside'` so cached thumbnail is bounded by max-width and max-height without crop | All four resize sites use `fit: 'inside'` with `MAX_HEIGHT_RATIO = 1.4`. | | 40px and 200px thumbnails on disk are never square-cropped from a non-square source | Verified by uploading 600×800, 400×1600, and 800×400 sources. Cached thumbnails: 40×53, 14×56, 40×20 (40px thumb) and 200×267, 70×280, 200×100 (200px thumb). Aspect preserved, never cropped. | | Frontend product images render with `object-contain` and `aspect-auto`, no clipping at any viewport | Every product image container uses `object-contain` (or `object-contain` on the inner ``). Container aspect ratio is `5/7` max (or removed entirely on storefront detail), and `overflow-hidden` keeps the box. | | Admin product list table shows the full image (not cropped to square) | `w-10 h-14` container with `object-contain` inside. The 40px thumbnail's natural 40×53 is fully visible. | | `verify.sh` is green | Exit 0. | ## Manual verification (with the running dev stack) ``` Source 600×800 (portrait) 40px thumb → 40×53 (aspect 3:4 preserved, fits inside 40×56) 200px thumb → 200×267 (aspect 3:4 preserved, fits inside 200×280) Source 400×1600 (extreme portrait) 40px thumb → 14×56 (height capped at 56, width computed from aspect) 200px thumb → 70×280 (height capped at 280) Source 800×400 (landscape) 40px thumb → 40×20 200px thumb → 200×100 Source 800×800 (square, no change) 40px thumb → 40×40 200px thumb → 200×200 ``` ``` $ curl http://192.168.18.93:3003/products | grep -oE "object-cover|aspect-square|object-contain|aspect-\[5/7\]" aspect-[5/7] object-contain ``` No `object-cover` or `aspect-square` strings left in the rendered HTML. ## Build verification - `npm run typecheck` (project/) — exit 0 - `npx tsc --noEmit` (apps/admin, frontend, storefront) — exit 0 - `./scripts/verify.sh` — exit 0 - All services up (backend 3000, frontend 3003, admin 3004, storefront 3005) ## Files touched ``` project/apps/admin/src/app/api/upload/route.ts (modified) project/apps/admin/src/app/uploads/[...path]/route.ts (modified) project/apps/admin/src/app/(dashboard)/products/page.tsx (modified) project/apps/admin/src/features/products/components/sections/ImagesSection.tsx (modified) project/frontend/src/app/uploads/[...path]/route.ts (modified) project/frontend/src/app/products/page.tsx (modified) project/frontend/src/app/products/[slug]/page.tsx (modified) project/frontend/src/app/brands/[slug]/page.tsx (modified) project/frontend/src/app/search/page.tsx (modified) project/frontend/src/app/categories/[slug]/page.tsx (modified) project/frontend/src/components/home/FeaturedProducts.tsx (modified) project/frontend/src/components/layout/Header.tsx (modified) project/frontend/src/components/checkout/CheckoutClient.tsx (modified) project/frontend/src/components/cart/CartPageContent.tsx (modified) project/storefront/src/app/uploads/[...path]/route.ts (modified) project/storefront/src/app/productos/[slug]/page.tsx (modified) project/storefront/src/components/product-card.tsx (modified) ```