# 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: ```
``` 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
``` ### With Crop: Transform ```tsx
``` ### Shared Component Extract a reusable `` 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 `` with ``. ## 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