Phases 28-31 archived to milestones/v2.2-phases/ Requirements and roadmap snapshots archived to milestones/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9.4 KiB
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
- Client:
ImageUpload.tsx→ file validation →apiUpload("/api/images", file) - Server:
routes/images.ts→ generates UUID filename →uploadImage(buffer, filename, contentType)viastorage.service.ts - Storage: S3-compatible (Garage/R2/MinIO) via
@aws-sdk/client-s3 - 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, nullableitems.imageSourceUrl— text, nullableglobalItems.imageUrl— text, nullable (external URL)globalItems.imageSourceUrl— text, nullablethreadCandidates.imageFilename— text, nullablethreadCandidates.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/imagesPOST and/api/images/from-url) - Return
dominantColorin the response alongsidefilename - For globalItems with external
imageUrl: extract on first access or via backfill script (fetch + process)
3. Schema Changes Required
New Fields
items table:
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:
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:
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:
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
croppedAreaPixelsandcroppedArea(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
<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
<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:
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
dominantColorset (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/itemsandPUT /api/items/:id— acceptdominantColor,cropZoom,cropX,cropY- Same for
POST /api/threads/:id/candidatesandPUT /api/threads/:id/candidates/:id - GlobalItems: similar updates
Zod Schema Updates
Add to item/candidate schemas in src/shared/schemas.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)
src/client/components/ItemCard.tsxsrc/client/components/GlobalItemCard.tsxsrc/client/components/CandidateCard.tsxsrc/client/components/CandidateListItem.tsxsrc/client/components/ImageUpload.tsxsrc/client/components/ComparisonTable.tsxsrc/client/components/LinkToGlobalItem.tsxsrc/client/components/CatalogSearchOverlay.tsx(2 instances)src/client/routes/items/$itemId.tsxsrc/client/routes/global-items/$globalItemId.tsxsrc/client/routes/global-items/index.tsxsrc/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
- All 15 image surfaces use
GearImagecomponent (grep for component name) - No remaining
object-coveron gear images (grep, excluding avatar) dominantColorfield exists on items, globalItems, threadCandidates tables- Upload endpoints return
dominantColorin response - Backfill script processes existing images without errors
- Zoom+pan editor appears in ImageUpload and item detail edit mode