Files
GearBox/.planning/milestones/v2.2-phases/29-image-presentation/29-RESEARCH.md
Jean-Luc Makiola 2853477a75
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: archive v2.2 User Experience Polish milestone
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>
2026-04-13 16:00:35 +02:00

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

  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

  • 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:

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 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

<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 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:

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