# 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 `<img>`). 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)
```