# Phase 29: Image Presentation - Research

**Researched:** 2026-04-12
**Status:** Complete

## 1. Current Image Architecture

### Display Pattern
Every image surface uses the same CSS pattern:
```
<div class="aspect-[4/3] overflow-hidden">
  <img class="w-full h-full object-cover" />
</div>
```

15 total `object-cover` usages across the client (excluding the user avatar which uses `rounded-full`):
- **Cards:** ItemCard, GlobalItemCard, CandidateCard, CandidateListItem
- **Detail pages:** items/$itemId, global-items/$globalItemId, threads/$threadId/candidates/$candidateId
- **Overlays/search:** CatalogSearchOverlay (2 instances), ComparisonTable, LinkToGlobalItem
- **Upload preview:** ImageUpload
- **Global items index:** global-items/index.tsx

### Upload Pipeline
1. Client: `ImageUpload.tsx` → file validation → `apiUpload("/api/images", file)`
2. Server: `routes/images.ts` → generates UUID filename → `uploadImage(buffer, filename, contentType)` via `storage.service.ts`
3. Storage: S3-compatible (Garage/R2/MinIO) via `@aws-sdk/client-s3`
4. Retrieval: `getImageUrl()` returns presigned URLs; `withImageUrl()`/`withImageUrls()` enriches records

**No image processing exists today** — images are uploaded raw and served as-is. No Sharp, no node-vibrant, no server-side manipulation.

### Database Schema (PostgreSQL via Drizzle)
- `items.imageFilename` — text, nullable
- `items.imageSourceUrl` — text, nullable
- `globalItems.imageUrl` — text, nullable (external URL)
- `globalItems.imageSourceUrl` — text, nullable
- `threadCandidates.imageFilename` — text, nullable
- `threadCandidates.imageSourceUrl` — text, nullable

No fields for dominant color or crop positioning exist today.

## 2. Dominant Color Extraction

### Recommended: Sharp
- Already the de facto standard for Bun/Node image processing
- `sharp(buffer).stats()` returns per-channel mean/dominant values
- Can extract dominant color via `sharp(buffer).resize(1,1).raw().toBuffer()` (resize to 1x1 pixel = weighted average)
- Alternative: use `sharp(buffer).stats()` to get channel means, convert to hex
- Lightweight — no additional binary deps beyond what Sharp bundles
- Bun compatibility: Sharp works via Node-API

### Alternative: node-vibrant / color-thief-node
- Heavier, purpose-built for palette extraction
- Returns multiple palette swatches (Vibrant, Muted, DarkVibrant, etc.)
- Overkill for a single dominant color fill background

### Recommendation
Use **Sharp** — single dependency handles both dominant color extraction and any future image processing needs. Resize to 1x1 pixel for a perceptually weighted average color.

### Implementation Notes
- Extract dominant color in the upload handler (both `/api/images` POST and `/api/images/from-url`)
- Return `dominantColor` in the response alongside `filename`
- For globalItems with external `imageUrl`: extract on first access or via backfill script (fetch + process)

## 3. Schema Changes Required

### New Fields

**items table:**
```sql
ALTER TABLE items ADD COLUMN dominant_color text;
ALTER TABLE items ADD COLUMN crop_zoom double precision;
ALTER TABLE items ADD COLUMN crop_x double precision;
ALTER TABLE items ADD COLUMN crop_y double precision;
```

**global_items table:**
```sql
ALTER TABLE global_items ADD COLUMN dominant_color text;
ALTER TABLE global_items ADD COLUMN crop_zoom double precision;
ALTER TABLE global_items ADD COLUMN crop_x double precision;
ALTER TABLE global_items ADD COLUMN crop_y double precision;
```

**thread_candidates table:**
```sql
ALTER TABLE thread_candidates ADD COLUMN dominant_color text;
ALTER TABLE thread_candidates ADD COLUMN crop_zoom double precision;
ALTER TABLE thread_candidates ADD COLUMN crop_x double precision;
ALTER TABLE thread_candidates ADD COLUMN crop_y double precision;
```

### Drizzle Schema
Add to each table in `src/db/schema.ts`:
```ts
dominantColor: text("dominant_color"),
cropZoom: doublePrecision("crop_zoom"),
cropX: doublePrecision("crop_x"),
cropY: doublePrecision("crop_y"),
```

Apply via: `bun run db:generate` then `bun run db:push`

## 4. Zoom+Pan Editor

### Library Options

| Library | Size | Touch | Maintained | Notes |
|---------|------|-------|------------|-------|
| react-easy-crop | ~15KB | Yes | Active | Battle-tested, used by many production apps. Returns crop area coordinates. MIT. |
| react-zoom-pan-pinch | ~25KB | Yes | Active | More general-purpose (maps, images, diagrams). Heavier. |
| Custom (pointer events + CSS transform) | 0KB | Manual | N/A | Full control but significant effort for touch/gesture handling |

### Recommendation: react-easy-crop
- Provides crop area with zoom, rotation, position
- Returns `croppedAreaPixels` and `croppedArea` (percentage-based)
- We need percentage-based output for CSS rendering (so images display correctly at any container size)
- Output: `{ x, y, zoom }` where x/y are percentage offsets

### Storage Model
Store 3 values per image:
- `cropZoom: number` — zoom level (1.0 = fit, >1 = zoomed in)
- `cropX: number` — horizontal offset as percentage (-50 to 50)
- `cropY: number` — vertical offset as percentage (-50 to 50)

When crop settings are `null`, default to `object-contain` with dominant color fill.
When crop settings are present, use CSS `transform: scale(cropZoom) translate(cropX%, cropY%)` with `overflow: hidden`.

## 5. CSS Rendering Strategy

### Default (no crop): Contain + Dominant Color
```tsx
<div 
  className="aspect-[4/3] overflow-hidden rounded-xl"
  style={{ backgroundColor: dominantColor || '#f3f4f6' }}
>
  <img 
    src={url}
    className="w-full h-full object-contain"
  />
</div>
```

### With Crop: Transform
```tsx
<div className="aspect-[4/3] overflow-hidden rounded-xl"
     style={{ backgroundColor: dominantColor || '#f3f4f6' }}>
  <img 
    src={url}
    className="w-full h-full object-cover"
    style={{
      transform: `scale(${cropZoom}) translate(${cropX}%, ${cropY}%)`,
      transformOrigin: 'center center',
    }}
  />
</div>
```

### Shared Component
Extract a reusable `<GearImage>` component that encapsulates this logic:
```tsx
interface GearImageProps {
  src: string;
  alt: string;
  dominantColor?: string | null;
  cropZoom?: number | null;
  cropX?: number | null;
  cropY?: number | null;
  aspectRatio?: string; // default "4/3"
  className?: string;
}
```

All 15 image surfaces replace their inline `<img>` with `<GearImage>`.

## 6. Backfill Migration

### Strategy: One-time Script
- Script reads all images from S3 (items + globalItems + candidates with imageFilename)
- Downloads each, runs Sharp 1x1 resize, extracts dominant color
- Updates the DB record with `dominantColor`
- For globalItems with external `imageUrl`: fetch from URL, extract, update
- Run as: `bun run scripts/backfill-dominant-colors.ts`

### Considerations
- Rate limit S3 reads (batch of 10 concurrent)
- Skip records that already have `dominantColor` set (idempotent)
- Log progress: `Processing 45/123 images...`
- Handle errors gracefully (skip failed images, log them)

## 7. API Changes

### Upload Response Changes
Current: `{ filename }` or `{ filename, sourceUrl }`
New: `{ filename, dominantColor }` or `{ filename, sourceUrl, dominantColor }`

### Item/Candidate CRUD
- `POST /api/items` and `PUT /api/items/:id` — accept `dominantColor`, `cropZoom`, `cropX`, `cropY`
- Same for `POST /api/threads/:id/candidates` and `PUT /api/threads/:id/candidates/:id`
- GlobalItems: similar updates

### Zod Schema Updates
Add to item/candidate schemas in `src/shared/schemas.ts`:
```ts
dominantColor: z.string().nullable().optional(),
cropZoom: z.number().nullable().optional(),
cropX: z.number().nullable().optional(),
cropY: z.number().nullable().optional(),
```

## 8. Scope of Component Changes

### Full List (15 surfaces)
1. `src/client/components/ItemCard.tsx`
2. `src/client/components/GlobalItemCard.tsx`
3. `src/client/components/CandidateCard.tsx`
4. `src/client/components/CandidateListItem.tsx`
5. `src/client/components/ImageUpload.tsx`
6. `src/client/components/ComparisonTable.tsx`
7. `src/client/components/LinkToGlobalItem.tsx`
8. `src/client/components/CatalogSearchOverlay.tsx` (2 instances)
9. `src/client/routes/items/$itemId.tsx`
10. `src/client/routes/global-items/$globalItemId.tsx`
11. `src/client/routes/global-items/index.tsx`
12. `src/client/routes/threads/$threadId/candidates/$candidateId.tsx`

### ProfileSection.tsx excluded
The `object-cover` in `ProfileSection.tsx` is for user avatars (circular), not gear images. Out of scope.

## 9. Risks & Mitigations

| Risk | Impact | Mitigation |
|------|--------|------------|
| Sharp installation issues on Bun | Build failure | Sharp has Bun-compatible prebuilt binaries; test early |
| Backfill takes long for large catalogs | Blocks deployment | Make it idempotent, run post-deploy as async script |
| Zoom+pan UX complexity | Scope creep | Use react-easy-crop as-is, minimal customization |
| Dominant color looks wrong for some images | Visual jank | Fallback to neutral gray when extraction fails |
| Performance: CSS transforms on many cards | Scroll jank | Transform is GPU-accelerated; no perf concern for static transforms |

## Validation Architecture

### Testable Claims
1. All 15 image surfaces use `GearImage` component (grep for component name)
2. No remaining `object-cover` on gear images (grep, excluding avatar)
3. `dominantColor` field exists on items, globalItems, threadCandidates tables
4. Upload endpoints return `dominantColor` in response
5. Backfill script processes existing images without errors
6. Zoom+pan editor appears in ImageUpload and item detail edit mode

---

## RESEARCH COMPLETE
