From 7064c6cdf1d827e0892277a67deecaf4cf821fab Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Apr 2026 19:51:36 +0200 Subject: [PATCH] docs(29): research, validation, and 4 plans for image presentation Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/STATE.md | 10 +- .../29-image-presentation/29-PLAN-01.md | 281 +++++++++ .../29-image-presentation/29-PLAN-02.md | 566 ++++++++++++++++++ .../29-image-presentation/29-PLAN-03.md | 361 +++++++++++ .../29-image-presentation/29-PLAN-04.md | 271 +++++++++ .../29-image-presentation/29-RESEARCH.md | 251 ++++++++ .../29-image-presentation/29-VALIDATION.md | 78 +++ 7 files changed, 1813 insertions(+), 5 deletions(-) create mode 100644 .planning/phases/29-image-presentation/29-PLAN-01.md create mode 100644 .planning/phases/29-image-presentation/29-PLAN-02.md create mode 100644 .planning/phases/29-image-presentation/29-PLAN-03.md create mode 100644 .planning/phases/29-image-presentation/29-PLAN-04.md create mode 100644 .planning/phases/29-image-presentation/29-RESEARCH.md create mode 100644 .planning/phases/29-image-presentation/29-VALIDATION.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 04986ef..fd0a548 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,10 +4,10 @@ milestone: v2.2 milestone_name: User Experience Polish status: executing stopped_at: Phase 29 context gathered -last_updated: "2026-04-12T17:42:16.111Z" -last_activity: 2026-04-12 +last_updated: "2026-04-12T17:51:24.659Z" +last_activity: 2026-04-12 -- Phase 29 planning complete progress: - total_phases: 34 + total_phases: 35 completed_phases: 21 total_plans: 58 completed_plans: 56 @@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-04-09) Phase: 999.1 Plan: Not started -Status: Executing Phase 28 -Last activity: 2026-04-12 +Status: Ready to execute +Last activity: 2026-04-12 -- Phase 29 planning complete Progress: [░░░░░░░░░░] 0% diff --git a/.planning/phases/29-image-presentation/29-PLAN-01.md b/.planning/phases/29-image-presentation/29-PLAN-01.md new file mode 100644 index 0000000..2710f23 --- /dev/null +++ b/.planning/phases/29-image-presentation/29-PLAN-01.md @@ -0,0 +1,281 @@ +--- +phase: 29 +plan: 01 +type: backend +wave: 1 +depends_on: [] +files_modified: + - src/db/schema.ts + - src/shared/schemas.ts + - src/shared/types.ts + - src/server/routes/images.ts + - src/server/services/image.service.ts + - src/server/services/storage.service.ts + - src/server/services/item.service.ts + - src/server/routes/items.ts + - src/server/routes/threads.ts + - src/server/routes/global-items.ts + - package.json +autonomous: true +requirements: [] +--- + + +Add dominant color extraction on image upload and extend the database schema with dominantColor and crop fields across items, globalItems, and threadCandidates tables. Install Sharp for server-side image processing. Update API schemas and services to accept/return the new fields. + + + + +### Task 1: Install Sharp dependency + + +Run `bun add sharp` and `bun add -d @types/sharp` to install the Sharp image processing library and its type definitions. + + +grep '"sharp"' package.json && echo "PASS" || echo "FAIL" + + +- package.json contains `"sharp"` in dependencies +- @types/sharp in devDependencies +- `bun install` completes without errors + + + +### Task 2: Add schema fields to database + + +- src/db/schema.ts + + +Add four new fields to THREE tables in `src/db/schema.ts`: + +**items table** — add after `brand: text("brand")`: +```ts +dominantColor: text("dominant_color"), +cropZoom: doublePrecision("crop_zoom"), +cropX: doublePrecision("crop_x"), +cropY: doublePrecision("crop_y"), +``` + +**globalItems table** — add after `imageSourceUrl: text("image_source_url")`: +```ts +dominantColor: text("dominant_color"), +cropZoom: doublePrecision("crop_zoom"), +cropX: doublePrecision("crop_x"), +cropY: doublePrecision("crop_y"), +``` + +**threadCandidates table** — add after `imageSourceUrl: text("image_source_url")`: +```ts +dominantColor: text("dominant_color"), +cropZoom: doublePrecision("crop_zoom"), +cropX: doublePrecision("crop_x"), +cropY: doublePrecision("crop_y"), +``` + + +grep -c "dominant_color" src/db/schema.ts | grep -q "3" && echo "PASS" || echo "FAIL" + + +- `src/db/schema.ts` contains `dominantColor: text("dominant_color")` in items, globalItems, and threadCandidates tables (3 occurrences) +- `src/db/schema.ts` contains `cropZoom: doublePrecision("crop_zoom")` in all 3 tables +- `src/db/schema.ts` contains `cropX: doublePrecision("crop_x")` in all 3 tables +- `src/db/schema.ts` contains `cropY: doublePrecision("crop_y")` in all 3 tables + + + +### Task 3: [BLOCKING] Push schema changes to database + + +Run `bun run db:generate` to generate the Drizzle migration, then `bun run db:push` to apply it to the database. + + +bun run db:push 2>&1 | tail -5 + + +- Migration generated successfully +- `bun run db:push` completes without errors +- Database contains dominant_color, crop_zoom, crop_x, crop_y columns on items, global_items, and thread_candidates tables + + + +### Task 4: Create dominant color extraction utility + + +- src/server/services/storage.service.ts +- src/server/services/image.service.ts + + +Create a new function `extractDominantColor` in `src/server/services/image.service.ts`: + +```ts +import sharp from "sharp"; + +/** + * Extract the dominant color from an image buffer. + * Resizes to 1x1 pixel for a perceptually weighted average. + * Returns hex string like '#a3b2c1' or null on failure. + */ +export async function extractDominantColor(buffer: Buffer | ArrayBuffer): Promise { + try { + const { data } = await sharp(Buffer.from(buffer)) + .resize(1, 1) + .raw() + .toBuffer({ resolveWithObject: true }); + const r = data[0]; + const g = data[1]; + const b = data[2]; + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } catch { + return null; + } +} +``` + +Keep the existing `fetchImageFromUrl` function. Add the import for sharp at the top. + + +grep "extractDominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL" + + +- `src/server/services/image.service.ts` exports `extractDominantColor` function +- Function accepts `Buffer | ArrayBuffer` and returns `Promise` +- Uses `sharp(buffer).resize(1, 1).raw().toBuffer()` to extract color +- Returns hex string in format `#rrggbb` +- Returns null on error (try/catch) + + + +### Task 5: Integrate dominant color extraction into upload endpoints + + +- src/server/routes/images.ts +- src/server/services/image.service.ts + + +Update `src/server/routes/images.ts` to extract dominant color during upload: + +**POST `/` (direct upload):** +1. After `const buffer = await file.arrayBuffer();` +2. Add: `const dominantColor = await extractDominantColor(buffer);` +3. Change response from `{ filename }` to `{ filename, dominantColor }` + +**POST `/from-url`:** +1. In `src/server/services/image.service.ts`, update `fetchImageFromUrl` to also extract dominant color +2. After `await uploadImage(Buffer.from(buffer), filename, contentType);` +3. Add: `const dominantColor = await extractDominantColor(buffer);` +4. Change return from `{ filename, sourceUrl: url }` to `{ filename, sourceUrl: url, dominantColor }` +5. Update `FetchImageResult` interface to include `dominantColor: string | null` + +Import `extractDominantColor` in images.ts from `../services/image.service`. + + +grep "dominantColor" src/server/routes/images.ts && grep "dominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL" + + +- `POST /api/images` response includes `dominantColor` field (string or null) +- `POST /api/images/from-url` response includes `dominantColor` field +- `FetchImageResult` interface has `dominantColor: string | null` +- Dominant color extraction happens before the response is sent + + + +### Task 6: Update Zod schemas for new fields + + +- src/shared/schemas.ts + + +Update `src/shared/schemas.ts`: + +**createItemSchema** — add after `brand: z.string().optional()`: +```ts +dominantColor: z.string().nullable().optional(), +cropZoom: z.number().nullable().optional(), +cropX: z.number().nullable().optional(), +cropY: z.number().nullable().optional(), +``` + +**createCandidateSchema** — add after `globalItemId: z.number().int().positive().optional()`: +```ts +dominantColor: z.string().nullable().optional(), +cropZoom: z.number().nullable().optional(), +cropX: z.number().nullable().optional(), +cropY: z.number().nullable().optional(), +``` + +**upsertGlobalItemSchema** — add after `tags`: +```ts +dominantColor: z.string().nullable().optional(), +cropZoom: z.number().nullable().optional(), +cropX: z.number().nullable().optional(), +cropY: z.number().nullable().optional(), +``` + +updateItemSchema and updateCandidateSchema already use `.partial()` so they inherit the new fields automatically. + + +grep -c "dominantColor" src/shared/schemas.ts | grep -q "3" && echo "PASS" || echo "FAIL" + + +- `createItemSchema` contains `dominantColor`, `cropZoom`, `cropX`, `cropY` fields +- `createCandidateSchema` contains the same 4 fields +- `upsertGlobalItemSchema` contains the same 4 fields +- All use `z.number().nullable().optional()` for crop fields +- All use `z.string().nullable().optional()` for dominantColor + + + +### Task 7: Update storage service to return dominant color with image URLs + + +- src/server/services/storage.service.ts + + +The `withImageUrl` and `withImageUrls` functions in `src/server/services/storage.service.ts` currently enrich records with `imageUrl`. They already pass through all record fields via spread operator, so `dominantColor`, `cropZoom`, `cropX`, `cropY` will automatically be included in the response when they exist on the record. + +No changes needed to storage.service.ts — the spread operator `{ ...record, imageUrl }` already forwards all fields. + +Verify this by confirming the return type `T & { imageUrl: string | null }` preserves all properties of T. + + +grep "...record" src/server/services/storage.service.ts && echo "PASS" || echo "FAIL" + + +- `withImageUrl` function uses spread operator `{ ...record, imageUrl }` which preserves dominantColor and crop fields from the source record +- No changes needed — verify existing behavior is sufficient + + + + + + +1. `bun run lint` passes +2. `bun test` passes (existing tests not broken) +3. Database has new columns: `SELECT column_name FROM information_schema.columns WHERE table_name = 'items' AND column_name LIKE '%crop%' OR column_name = 'dominant_color';` +4. Upload endpoint returns dominantColor in response body + + + +- Sharp installed and importable +- 3 tables have dominantColor + crop fields +- Image upload extracts and returns dominant color +- Zod schemas accept new fields +- All existing tests pass + + + +| Threat | Severity | Mitigation | +|--------|----------|------------| +| Sharp buffer overflow via malformed image | Medium | Sharp handles this internally with libvips bounds checking; wrapped in try/catch returning null | +| DoS via large image processing | Low | Existing 5MB file size limit applies before Sharp processing | +| Stored XSS via dominantColor field | Low | Value is a hex color string extracted server-side, not user input; rendered as CSS backgroundColor | + + + +- [ ] Sharp dependency installed +- [ ] dominantColor field on items, globalItems, threadCandidates +- [ ] Crop fields (cropZoom, cropX, cropY) on all 3 tables +- [ ] Upload endpoints return dominantColor +- [ ] Schema pushed to database + diff --git a/.planning/phases/29-image-presentation/29-PLAN-02.md b/.planning/phases/29-image-presentation/29-PLAN-02.md new file mode 100644 index 0000000..64efcbd --- /dev/null +++ b/.planning/phases/29-image-presentation/29-PLAN-02.md @@ -0,0 +1,566 @@ +--- +phase: 29 +plan: 02 +type: frontend +wave: 1 +depends_on: [] +files_modified: + - src/client/components/GearImage.tsx + - src/client/components/ItemCard.tsx + - src/client/components/GlobalItemCard.tsx + - src/client/components/CandidateCard.tsx + - src/client/components/CandidateListItem.tsx + - src/client/components/ImageUpload.tsx + - src/client/components/ComparisonTable.tsx + - src/client/components/CatalogSearchOverlay.tsx + - src/client/routes/items/$itemId.tsx + - src/client/routes/global-items/$globalItemId.tsx + - src/client/routes/global-items/index.tsx + - src/client/routes/threads/$threadId/candidates/$candidateId.tsx +autonomous: true +requirements: [] +--- + + +Create the GearImage shared component that renders images with object-contain + dominant color background fill, and replace all inline image elements across 12 surfaces with GearImage. This delivers the core visual change: fit-within framing instead of hard crops. + + + + +### Task 1: Create GearImage component + + +- src/client/components/ItemCard.tsx (current image rendering pattern) +- src/client/components/GlobalItemCard.tsx (current image rendering pattern) + + +Create `src/client/components/GearImage.tsx`: + +```tsx +interface GearImageProps { + src: string; + alt: string; + dominantColor?: string | null; + cropZoom?: number | null; + cropX?: number | null; + cropY?: number | null; + aspectRatio?: string; + className?: string; + cover?: boolean; +} + +export function GearImage({ + src, + alt, + dominantColor, + cropZoom, + cropX, + cropY, + aspectRatio = "4/3", + className = "", + cover = false, +}: GearImageProps) { + const hasCrop = cropZoom != null && cropZoom > 1; + const bgColor = dominantColor || "#f3f4f6"; + + if (cover) { + return ( + {alt} + ); + } + + if (hasCrop) { + return ( +
+ {alt} +
+ ); + } + + return ( +
+ {alt} +
+ ); +} +``` + +Note: The `aspectRatio` in className uses Tailwind arbitrary values. Since the aspect ratio container is typically provided by the parent, the GearImage component renders as a child within the existing aspect-ratio div. Adjust the component to NOT wrap with its own aspect-ratio div when used inside cards (the parent already has `aspect-[4/3]`). Instead, the component should just render the image with the correct object-fit and background color: + +Simplified version (preferred — parent controls aspect ratio): + +```tsx +export function GearImage({ + src, + alt, + dominantColor, + cropZoom, + cropX, + cropY, + className = "", + cover = false, +}: Omit) { + const hasCrop = cropZoom != null && cropZoom > 1; + const bgColor = dominantColor || "#f3f4f6"; + + if (cover) { + return ( + {alt} + ); + } + + if (hasCrop) { + return ( + {alt} + ); + } + + return ( + {alt} + ); +} +``` + +The **parent div** provides aspect ratio, overflow-hidden, and the `style={{ backgroundColor: dominantColor }}`. This matches the existing pattern where the parent `
` wraps the image. + + +test -f src/client/components/GearImage.tsx && grep "object-contain" src/client/components/GearImage.tsx && echo "PASS" || echo "FAIL" + + +- `src/client/components/GearImage.tsx` exists +- Exports `GearImage` component +- Default rendering uses `object-contain` (not `object-cover`) +- When `cover` prop is true, uses `object-cover` +- When crop values exist and cropZoom > 1, uses CSS transform with scale and translate +- Accepts `dominantColor`, `cropZoom`, `cropX`, `cropY` props + + + +### Task 2: Update ItemCard to use GearImage + + +- src/client/components/ItemCard.tsx +- src/client/components/GearImage.tsx + + +In `src/client/components/ItemCard.tsx`: + +1. Add `dominantColor`, `cropZoom`, `cropX`, `cropY` to `ItemCardProps` interface (all `number | null` or `string | null`) +2. Import `GearImage` from `./GearImage` +3. Replace the image div (around line 164-179): + +Current: +```tsx +
+ {imageUrl ? ( + {name} + ) : ( +
+ +
+ )} +
+``` + +New: +```tsx +
+ {imageUrl ? ( + + ) : ( +
+ +
+ )} +
+``` +
+ +grep "GearImage" src/client/components/ItemCard.tsx && ! grep "object-cover" src/client/components/ItemCard.tsx && echo "PASS" || echo "FAIL" + + +- ItemCard imports and uses GearImage component +- No `object-cover` remains in ItemCard.tsx +- `dominantColor` prop is passed to GearImage +- Parent div uses inline `backgroundColor` style from dominantColor +- Empty state (no image) still shows category icon on gray-50 background + +
+ +### Task 3: Update GlobalItemCard to use GearImage + + +- src/client/components/GlobalItemCard.tsx +- src/client/components/GearImage.tsx + + +In `src/client/components/GlobalItemCard.tsx`: + +1. Add `dominantColor?: string | null`, `cropZoom?: number | null`, `cropX?: number | null`, `cropY?: number | null` to `GlobalItemCardProps` +2. Import `GearImage` from `./GearImage` +3. Replace the image rendering (around line 31-54): + +Current: +```tsx +
+ {imageUrl ? ( + {`${brand} + ) : ( +
+ {/* SVG placeholder */} +
+ )} +
+``` + +New: +```tsx +
+ {imageUrl ? ( + + ) : ( +
+ {/* Keep existing SVG placeholder */} +
+ )} +
+``` +
+ +grep "GearImage" src/client/components/GlobalItemCard.tsx && ! grep "object-cover" src/client/components/GlobalItemCard.tsx && echo "PASS" || echo "FAIL" + + +- GlobalItemCard imports and uses GearImage +- No `object-cover` in GlobalItemCard.tsx +- Props include dominantColor, cropZoom, cropX, cropY + +
+ +### Task 4: Update CandidateCard to use GearImage + + +- src/client/components/CandidateCard.tsx +- src/client/components/GearImage.tsx + + +Same pattern as Task 2/3: +1. Add `dominantColor`, `cropZoom`, `cropX`, `cropY` to props interface +2. Import `GearImage` +3. Replace `` with `` inside the existing `aspect-[4/3]` container +4. Update parent div to use inline `backgroundColor` style + + +grep "GearImage" src/client/components/CandidateCard.tsx && ! grep "object-cover" src/client/components/CandidateCard.tsx && echo "PASS" || echo "FAIL" + + +- CandidateCard uses GearImage component +- No `object-cover` remaining +- Dominant color props threaded through + + + +### Task 5: Update CandidateListItem to use GearImage + + +- src/client/components/CandidateListItem.tsx +- src/client/components/GearImage.tsx + + +Same pattern: +1. Add image presentation props to interface +2. Import GearImage +3. Replace `object-cover` image with GearImage +4. Update parent container background color + + +grep "GearImage" src/client/components/CandidateListItem.tsx && ! grep "object-cover" src/client/components/CandidateListItem.tsx && echo "PASS" || echo "FAIL" + + +- CandidateListItem uses GearImage +- No `object-cover` remaining + + + +### Task 6: Update ComparisonTable to use GearImage + + +- src/client/components/ComparisonTable.tsx +- src/client/components/GearImage.tsx + + +Same pattern: replace inline `` with ``. Thread dominantColor and crop props from the data source. + + +grep "GearImage" src/client/components/ComparisonTable.tsx && ! grep "object-cover" src/client/components/ComparisonTable.tsx && echo "PASS" || echo "FAIL" + + +- ComparisonTable uses GearImage +- No `object-cover` remaining + + + +### Task 7: Update CatalogSearchOverlay to use GearImage + + +- src/client/components/CatalogSearchOverlay.tsx +- src/client/components/GearImage.tsx + + +CatalogSearchOverlay has 2 `object-cover` instances (search results and selected item preview). Replace both with GearImage. Thread dominantColor from global item data. + + +grep "GearImage" src/client/components/CatalogSearchOverlay.tsx && ! grep "object-cover" src/client/components/CatalogSearchOverlay.tsx && echo "PASS" || echo "FAIL" + + +- Both image instances in CatalogSearchOverlay use GearImage +- No `object-cover` remaining in the file + + + +### Task 8: Update ImageUpload preview to use GearImage + + +- src/client/components/ImageUpload.tsx +- src/client/components/GearImage.tsx + + +In `src/client/components/ImageUpload.tsx`: + +1. Add `dominantColor?: string | null` to `ImageUploadProps` +2. Import GearImage +3. Replace the preview image (line 76-79): + +Current: +```tsx +Item +``` + +New: +```tsx + +``` + +4. Update the parent container to use dominant color background: +```tsx +style={{ backgroundColor: displayUrl ? (dominantColor || "#f3f4f6") : undefined }} +``` + + +grep "GearImage" src/client/components/ImageUpload.tsx && ! grep "object-cover" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL" + + +- ImageUpload uses GearImage for preview +- No `object-cover` remaining +- Accepts dominantColor prop + + + +### Task 9: Update item detail page + + +- src/client/routes/items/$itemId.tsx +- src/client/components/GearImage.tsx + + +In `src/client/routes/items/$itemId.tsx`: + +1. Import GearImage +2. Replace the `object-cover` image (around line 245-250) with GearImage +3. Update the parent `aspect-[4/3]` div to use dominant color background via inline style +4. Thread dominantColor, cropZoom, cropX, cropY from the item data + + +grep "GearImage" src/client/routes/items/\$itemId.tsx && ! grep "object-cover" src/client/routes/items/\$itemId.tsx && echo "PASS" || echo "FAIL" + + +- Item detail page uses GearImage +- No `object-cover` in the file +- Dominant color and crop fields used from item data + + + +### Task 10: Update global item detail page + + +- src/client/routes/global-items/$globalItemId.tsx +- src/client/components/GearImage.tsx + + +In `src/client/routes/global-items/$globalItemId.tsx`: + +1. Import GearImage +2. Replace the `object-cover` image (around line 65-70) with GearImage +3. This page uses `aspect-[16/9]` — keep that ratio on the parent container +4. Update background color to use dominant color + + +grep "GearImage" src/client/routes/global-items/\$globalItemId.tsx && ! grep "object-cover" src/client/routes/global-items/\$globalItemId.tsx && echo "PASS" || echo "FAIL" + + +- Global item detail uses GearImage +- No `object-cover` remaining +- Aspect ratio 16/9 preserved + + + +### Task 11: Update global items index page + + +- src/client/routes/global-items/index.tsx +- src/client/components/GearImage.tsx + + +In `src/client/routes/global-items/index.tsx`: + +1. Import GearImage +2. Replace `object-cover` image with GearImage +3. Thread dominantColor from global item data + + +grep "GearImage" src/client/routes/global-items/index.tsx && ! grep "object-cover" src/client/routes/global-items/index.tsx && echo "PASS" || echo "FAIL" + + +- Global items index uses GearImage +- No `object-cover` remaining + + + +### Task 12: Update candidate detail page + + +- src/client/routes/threads/$threadId/candidates/$candidateId.tsx +- src/client/components/GearImage.tsx + + +In `src/client/routes/threads/$threadId/candidates/$candidateId.tsx`: + +1. Import GearImage +2. Replace `object-cover` image with GearImage +3. This page uses `aspect-[16/9]` — keep that ratio +4. Thread dominantColor and crop fields from candidate data + + +grep "GearImage" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && ! grep "object-cover" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && echo "PASS" || echo "FAIL" + + +- Candidate detail uses GearImage +- No `object-cover` remaining +- Aspect ratio 16/9 preserved + + + +### Task 13: Update LinkToGlobalItem with cover mode + + +- src/client/components/LinkToGlobalItem.tsx +- src/client/components/GearImage.tsx + + +In `src/client/components/LinkToGlobalItem.tsx`: + +The 32x32px thumbnail is too small for letterbox treatment. Use GearImage with `cover={true}` prop to keep `object-cover` for this tiny thumbnail: + +Replace: +```tsx + +``` + +With: +```tsx + +``` + + +grep "GearImage" src/client/components/LinkToGlobalItem.tsx && echo "PASS" || echo "FAIL" + + +- LinkToGlobalItem uses GearImage with `cover` prop +- Small thumbnail renders with object-cover (intentional exception for tiny images) + + + + + + +1. `bun run lint` passes +2. `bun run build` passes (TypeScript compilation) +3. `grep -r "object-cover" src/client/ --include="*.tsx"` returns ONLY: + - `GearImage.tsx` (internal cover mode) + - `ProfileSection.tsx` (user avatar — out of scope) + - `routes/users/$userId.tsx` (user avatar — out of scope) +4. All 12 surfaces render images with `object-contain` by default + + + +- GearImage component exists and is used by all 12 gear image surfaces +- Default image display uses object-contain (fit-within) +- Dominant color background fills letterbox/pillarbox space +- Cropped images display with CSS transform +- LinkToGlobalItem uses cover mode for 32px thumbnails +- No regression in empty state (placeholder icons still show) + + + +| Threat | Severity | Mitigation | +|--------|----------|------------| +| XSS via dominantColor in style attribute | Low | dominantColor is server-extracted hex string, not user input; React escapes style values | +| Layout shift from object-contain | Low | Container maintains fixed aspect ratio; image loads within same bounds | + + + +- [ ] GearImage component created at src/client/components/GearImage.tsx +- [ ] All 12 image surfaces use GearImage (except ProfileSection/user avatar) +- [ ] Default rendering uses object-contain, not object-cover +- [ ] Dominant color background on image containers +- [ ] LinkToGlobalItem uses cover mode for tiny thumbnails + diff --git a/.planning/phases/29-image-presentation/29-PLAN-03.md b/.planning/phases/29-image-presentation/29-PLAN-03.md new file mode 100644 index 0000000..0f96004 --- /dev/null +++ b/.planning/phases/29-image-presentation/29-PLAN-03.md @@ -0,0 +1,361 @@ +--- +phase: 29 +plan: 03 +type: fullstack +wave: 2 +depends_on: [01, 02] +files_modified: + - src/client/components/ImageCropEditor.tsx + - src/client/components/ImageUpload.tsx + - src/client/routes/items/$itemId.tsx + - src/client/routes/global-items/$globalItemId.tsx + - src/client/routes/threads/$threadId/candidates/$candidateId.tsx + - src/client/hooks/useItems.ts + - package.json +autonomous: true +requirements: [] +--- + + +Implement the zoom+pan image framing editor using react-easy-crop. Users can adjust image framing during upload (ImageUpload) and from detail pages (item, global item, candidate). Crop settings (zoom, x, y) persist to the database via existing CRUD endpoints. + + + + +### Task 1: Install react-easy-crop + + +Run `bun add react-easy-crop` to install the crop editor library. + + +grep '"react-easy-crop"' package.json && echo "PASS" || echo "FAIL" + + +- package.json contains `"react-easy-crop"` in dependencies + + + +### Task 2: Create ImageCropEditor component + + +- src/client/components/ImageUpload.tsx +- src/client/components/GearImage.tsx + + +Create `src/client/components/ImageCropEditor.tsx`: + +```tsx +import { useCallback, useState } from "react"; +import Cropper from "react-easy-crop"; +import type { Area, Point } from "react-easy-crop"; + +interface CropResult { + zoom: number; + x: number; + y: number; +} + +interface ImageCropEditorProps { + imageUrl: string; + dominantColor?: string | null; + initialZoom?: number; + initialX?: number; + initialY?: number; + aspect?: number; + onSave: (result: CropResult) => void; + onCancel: () => void; +} + +export function ImageCropEditor({ + imageUrl, + dominantColor, + initialZoom = 1, + initialX = 0, + initialY = 0, + aspect = 4 / 3, + onSave, + onCancel, +}: ImageCropEditorProps) { + const [crop, setCrop] = useState({ x: initialX, y: initialY }); + const [zoom, setZoom] = useState(initialZoom); + + const onCropComplete = useCallback((_croppedArea: Area, _croppedAreaPixels: Area) => { + // We use the crop/zoom state directly, not the callback values + }, []); + + function handleSave() { + onSave({ + zoom, + x: crop.x, + y: crop.y, + }); + } + + return ( +
+ {/* Crop area */} +
+ +
+ + {/* Zoom slider */} +
+ + + + + + + setZoom(Number(e.target.value))} + className="flex-1 h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer accent-gray-900" + /> + + + + + +
+ + {/* Action buttons */} +
+ + +
+
+ ); +} +``` + +The component: +- Uses react-easy-crop `Cropper` with `objectFit="contain"` so images fit within the frame +- Min zoom 1.0 (fit-within), max zoom 3.0 +- Zoom slider between zoom-out and zoom-in icons +- "Cancel" (ghost) and "Save framing" (primary) buttons +- Returns `{ zoom, x, y }` on save +- Background color uses dominant color from the image +
+ +test -f src/client/components/ImageCropEditor.tsx && grep "react-easy-crop" src/client/components/ImageCropEditor.tsx && grep "Save framing" src/client/components/ImageCropEditor.tsx && echo "PASS" || echo "FAIL" + + +- `src/client/components/ImageCropEditor.tsx` exists +- Imports `Cropper` from `react-easy-crop` +- `objectFit="contain"` set on Cropper +- Min zoom 1, max zoom 3 +- Zoom slider with range input +- "Cancel" button calls `onCancel` +- "Save framing" button calls `onSave` with `{ zoom, x, y }` +- Dominant color used as background + +
+ +### Task 3: Add crop editor to ImageUpload + + +- src/client/components/ImageUpload.tsx +- src/client/components/ImageCropEditor.tsx + + +Update `src/client/components/ImageUpload.tsx`: + +1. Add `onCropChange?: (crop: { zoom: number; x: number; y: number }) => void` to `ImageUploadProps` +2. Add `cropZoom?: number | null`, `cropX?: number | null`, `cropY?: number | null` to props +3. Add state: `const [showCropEditor, setShowCropEditor] = useState(false);` +4. After successful upload (`onChange(result.filename)`), set `setShowCropEditor(true)` +5. When crop editor is visible, replace the image preview area with the `ImageCropEditor` component +6. On save: call `onCropChange?.({ zoom, x, y })` and `setShowCropEditor(false)` +7. On cancel: `setShowCropEditor(false)` +8. Import `ImageCropEditor` + +The crop editor appears inline in the same container where the preview image normally shows, replacing the static preview temporarily. + + +grep "ImageCropEditor" src/client/components/ImageUpload.tsx && grep "showCropEditor" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL" + + +- ImageUpload imports and conditionally renders ImageCropEditor +- Editor appears after successful upload +- `onCropChange` callback fires with zoom/x/y values +- Editor can be dismissed via Cancel +- Save triggers crop change callback + + + +### Task 4: Add "Adjust framing" to item detail page + + +- src/client/routes/items/$itemId.tsx +- src/client/components/ImageCropEditor.tsx +- src/client/hooks/useItems.ts + + +In `src/client/routes/items/$itemId.tsx`: + +1. Import `ImageCropEditor` +2. Add state: `const [editingCrop, setEditingCrop] = useState(false)` +3. Below the image area (after the `aspect-[4/3]` div), add an "Adjust framing" button: +```tsx +{item.imageUrl && ( + +)} +``` +4. When `editingCrop` is true, replace the GearImage area with `ImageCropEditor`: +```tsx +{editingCrop ? ( + { + await updateItem({ id: item.id, cropZoom: crop.zoom, cropX: crop.x, cropY: crop.y }); + setEditingCrop(false); + }} + onCancel={() => setEditingCrop(false)} + /> +) : ( + /* existing GearImage rendering */ +)} +``` +5. Use the existing `useUpdateItem` mutation to persist crop values + + +grep "Adjust framing" src/client/routes/items/\$itemId.tsx && grep "ImageCropEditor" src/client/routes/items/\$itemId.tsx && echo "PASS" || echo "FAIL" + + +- Item detail page shows "Adjust framing" button when image exists +- Clicking button shows ImageCropEditor inline +- Save persists crop values via updateItem mutation +- Cancel returns to normal image view + + + +### Task 5: Add "Adjust framing" to global item detail page + + +- src/client/routes/global-items/$globalItemId.tsx +- src/client/components/ImageCropEditor.tsx + + +Same pattern as Task 4 but for global item detail: + +1. Import ImageCropEditor and useState +2. Add "Adjust framing" button below image +3. Toggle between GearImage and ImageCropEditor +4. Use `aspect={16/9}` to match the global item detail page aspect ratio +5. Use the appropriate mutation to persist crop values for global items + + +grep "Adjust framing" src/client/routes/global-items/\$globalItemId.tsx && grep "ImageCropEditor" src/client/routes/global-items/\$globalItemId.tsx && echo "PASS" || echo "FAIL" + + +- Global item detail shows "Adjust framing" button +- ImageCropEditor uses aspect 16/9 +- Crop values persist via mutation + + + +### Task 6: Add "Adjust framing" to candidate detail page + + +- src/client/routes/threads/$threadId/candidates/$candidateId.tsx +- src/client/components/ImageCropEditor.tsx + + +Same pattern as Task 4/5 but for candidate detail: + +1. Import ImageCropEditor and useState +2. Add "Adjust framing" button below image +3. Toggle between GearImage and ImageCropEditor +4. Use `aspect={16/9}` to match the candidate detail page aspect ratio +5. Use candidate update mutation to persist crop values + + +grep "Adjust framing" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && grep "ImageCropEditor" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && echo "PASS" || echo "FAIL" + + +- Candidate detail shows "Adjust framing" button +- ImageCropEditor uses aspect 16/9 +- Crop values persist via candidate update mutation + + + +
+ + +1. `bun run lint` passes +2. `bun run build` passes +3. ImageCropEditor component renders react-easy-crop Cropper +4. "Adjust framing" button appears on all 3 detail pages when image exists +5. Crop values round-trip: set in editor → save → reload page → image renders with saved crop + + + +- react-easy-crop installed +- ImageCropEditor component created with zoom slider and save/cancel actions +- ImageUpload shows crop editor after upload +- Item, global item, and candidate detail pages have "Adjust framing" button +- Crop values persist through CRUD endpoints +- Crop values render correctly via GearImage component + + + +| Threat | Severity | Mitigation | +|--------|----------|------------| +| Crop values outside expected range | Low | Server-side validation via Zod schema (nullable number) | +| react-easy-crop supply chain | Low | MIT license, 1M+ weekly downloads, actively maintained | + + + +- [ ] react-easy-crop installed +- [ ] ImageCropEditor component with zoom slider +- [ ] Crop editor in ImageUpload (post-upload) +- [ ] "Adjust framing" on item detail page +- [ ] "Adjust framing" on global item detail page +- [ ] "Adjust framing" on candidate detail page +- [ ] Crop values persist to database + diff --git a/.planning/phases/29-image-presentation/29-PLAN-04.md b/.planning/phases/29-image-presentation/29-PLAN-04.md new file mode 100644 index 0000000..516655b --- /dev/null +++ b/.planning/phases/29-image-presentation/29-PLAN-04.md @@ -0,0 +1,271 @@ +--- +phase: 29 +plan: 04 +type: backend +wave: 2 +depends_on: [01] +files_modified: + - scripts/backfill-dominant-colors.ts +autonomous: true +requirements: [] +--- + + +Create a one-time backfill script that processes all existing images in the database to extract and store their dominant color. Handles items, globalItems, and threadCandidates with imageFilename, plus globalItems with external imageUrl. + + + + +### Task 1: Create backfill script + + +- src/db/schema.ts +- src/server/services/storage.service.ts +- src/server/services/image.service.ts + + +Create `scripts/backfill-dominant-colors.ts`: + +```ts +/** + * Backfill dominant colors for all existing images. + * Run with: bun run scripts/backfill-dominant-colors.ts + * + * Idempotent — skips records that already have dominantColor set. + * Processes in batches of 10 concurrent requests. + */ + +import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { isNull } from "drizzle-orm"; +import postgres from "postgres"; +import sharp from "sharp"; +import * as schema from "../src/db/schema"; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) throw new Error("DATABASE_URL required"); + +const client = postgres(DATABASE_URL); +const db = drizzle(client, { schema }); + +const s3 = new S3Client({ + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION ?? "us-east-1", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY!, + secretAccessKey: process.env.S3_SECRET_KEY!, + }, + forcePathStyle: true, +}); +const bucket = process.env.S3_BUCKET ?? "gearbox-images"; + +async function extractColor(buffer: Buffer): Promise { + try { + const { data } = await sharp(buffer).resize(1, 1).raw().toBuffer({ resolveWithObject: true }); + return `#${data[0].toString(16).padStart(2, "0")}${data[1].toString(16).padStart(2, "0")}${data[2].toString(16).padStart(2, "0")}`; + } catch { + return null; + } +} + +async function fetchFromS3(filename: string): Promise { + try { + const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: filename })); + const bytes = await response.Body?.transformToByteArray(); + return bytes ? Buffer.from(bytes) : null; + } catch { + return null; + } +} + +async function fetchFromUrl(url: string): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(10000) }); + if (!response.ok) return null; + return Buffer.from(await response.arrayBuffer()); + } catch { + return null; + } +} + +async function processBatch( + items: T[], + getBuffer: (item: T) => Promise, + updateFn: (id: number, color: string) => Promise, + label: string, +) { + const BATCH_SIZE = 10; + let processed = 0; + let updated = 0; + let failed = 0; + + for (let i = 0; i < items.length; i += BATCH_SIZE) { + const batch = items.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map(async (item) => { + const buffer = await getBuffer(item); + if (!buffer) { failed++; return; } + const color = await extractColor(buffer); + if (!color) { failed++; return; } + await updateFn(item.id, color); + updated++; + }) + ); + processed += batch.length; + console.log(` ${label}: ${processed}/${items.length} processed, ${updated} updated, ${failed} failed`); + } +} + +async function main() { + console.log("=== Backfill Dominant Colors ===\n"); + + // Items with imageFilename but no dominantColor + const { eq, and, isNotNull } = await import("drizzle-orm"); + + const itemsToProcess = await db + .select({ id: schema.items.id, imageFilename: schema.items.imageFilename }) + .from(schema.items) + .where(and(isNotNull(schema.items.imageFilename), isNull(schema.items.dominantColor))); + + console.log(`Items: ${itemsToProcess.length} need processing`); + await processBatch( + itemsToProcess as { id: number; imageFilename: string }[], + (item) => fetchFromS3(item.imageFilename), + async (id, color) => { + const { eq } = await import("drizzle-orm"); + await db.update(schema.items).set({ dominantColor: color }).where(eq(schema.items.id, id)); + }, + "Items", + ); + + // GlobalItems with imageSourceUrl (external URLs stored in S3) + const globalWithFile = await db + .select({ id: schema.globalItems.id, imageSourceUrl: schema.globalItems.imageSourceUrl }) + .from(schema.globalItems) + .where(and(isNotNull(schema.globalItems.imageSourceUrl), isNull(schema.globalItems.dominantColor))); + + console.log(`\nGlobal Items (with source URL): ${globalWithFile.length} need processing`); + await processBatch( + globalWithFile as { id: number; imageSourceUrl: string }[], + (item) => fetchFromUrl(item.imageSourceUrl), + async (id, color) => { + const { eq } = await import("drizzle-orm"); + await db.update(schema.globalItems).set({ dominantColor: color }).where(eq(schema.globalItems.id, id)); + }, + "Global Items", + ); + + // GlobalItems with imageUrl (direct URLs) + const globalWithUrl = await db + .select({ id: schema.globalItems.id, imageUrl: schema.globalItems.imageUrl }) + .from(schema.globalItems) + .where(and(isNotNull(schema.globalItems.imageUrl), isNull(schema.globalItems.dominantColor))); + + console.log(`\nGlobal Items (with image URL): ${globalWithUrl.length} need processing`); + await processBatch( + globalWithUrl as { id: number; imageUrl: string }[], + (item) => fetchFromUrl(item.imageUrl), + async (id, color) => { + const { eq } = await import("drizzle-orm"); + await db.update(schema.globalItems).set({ dominantColor: color }).where(eq(schema.globalItems.id, id)); + }, + "Global Items (URL)", + ); + + // Thread candidates + const candidatesToProcess = await db + .select({ id: schema.threadCandidates.id, imageFilename: schema.threadCandidates.imageFilename }) + .from(schema.threadCandidates) + .where(and(isNotNull(schema.threadCandidates.imageFilename), isNull(schema.threadCandidates.dominantColor))); + + console.log(`\nCandidates: ${candidatesToProcess.length} need processing`); + await processBatch( + candidatesToProcess as { id: number; imageFilename: string }[], + (item) => fetchFromS3(item.imageFilename), + async (id, color) => { + const { eq } = await import("drizzle-orm"); + await db.update(schema.threadCandidates).set({ dominantColor: color }).where(eq(schema.threadCandidates.id, id)); + }, + "Candidates", + ); + + console.log("\n=== Backfill Complete ==="); + process.exit(0); +} + +main().catch((err) => { + console.error("Backfill failed:", err); + process.exit(1); +}); +``` + +Note: The exact import patterns for drizzle-orm may need adjustment based on the project's existing database connection setup. Check `src/db/` for the actual connection pattern used and replicate it in the script. + + +test -f scripts/backfill-dominant-colors.ts && grep "extractColor" scripts/backfill-dominant-colors.ts && grep "processBatch" scripts/backfill-dominant-colors.ts && echo "PASS" || echo "FAIL" + + +- `scripts/backfill-dominant-colors.ts` exists +- Script queries items, globalItems, threadCandidates with images but no dominantColor +- Processes in batches of 10 concurrent +- Extracts dominant color via Sharp resize(1,1) +- Updates database records with extracted color +- Skips records that already have dominantColor (idempotent) +- Logs progress: `Items: 45/123 processed, 42 updated, 3 failed` +- Handles errors gracefully (skips failed images, logs them) +- Exits with 0 on success, 1 on fatal error + + + +### Task 2: Add npm script for backfill + + +- package.json + + +Add to `scripts` section in `package.json`: +```json +"backfill:colors": "bun run scripts/backfill-dominant-colors.ts" +``` + + +grep "backfill:colors" package.json && echo "PASS" || echo "FAIL" + + +- package.json contains `"backfill:colors"` script +- Script points to `scripts/backfill-dominant-colors.ts` + + + + + + +1. `bun run lint` passes (script follows project conventions) +2. Script is syntactically valid: `bun run scripts/backfill-dominant-colors.ts --help` or `bun check scripts/backfill-dominant-colors.ts` +3. Script handles missing S3 credentials gracefully (error message, not crash) + + + +- Backfill script exists and processes all 3 tables +- Script is idempotent (safe to re-run) +- Batch processing limits concurrency to 10 +- Progress logging shows processing status +- npm script shortcut available + + + +| Threat | Severity | Mitigation | +|--------|----------|------------| +| S3 credential exposure in script | Low | Uses env vars from process.env, no hardcoded credentials | +| SSRF via globalItems imageUrl | Medium | Script only processes URLs already stored in the database (previously validated on ingestion); fetch has 10s timeout | +| Database overload from bulk updates | Low | Batch size of 10 limits concurrent DB writes | + + + +- [ ] Backfill script at scripts/backfill-dominant-colors.ts +- [ ] Processes items, globalItems, threadCandidates +- [ ] Idempotent (skips existing dominantColor) +- [ ] Batch processing with concurrency limit +- [ ] Progress logging +- [ ] npm script shortcut + diff --git a/.planning/phases/29-image-presentation/29-RESEARCH.md b/.planning/phases/29-image-presentation/29-RESEARCH.md new file mode 100644 index 0000000..54e7584 --- /dev/null +++ b/.planning/phases/29-image-presentation/29-RESEARCH.md @@ -0,0 +1,251 @@ +# 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 diff --git a/.planning/phases/29-image-presentation/29-VALIDATION.md b/.planning/phases/29-image-presentation/29-VALIDATION.md new file mode 100644 index 0000000..0f68567 --- /dev/null +++ b/.planning/phases/29-image-presentation/29-VALIDATION.md @@ -0,0 +1,78 @@ +--- +phase: 29 +slug: image-presentation +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-04-12 +--- + +# Phase 29 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Bun test runner + Playwright | +| **Config file** | `bunfig.toml` / `playwright.config.ts` | +| **Quick run command** | `bun test` | +| **Full suite command** | `bun test && bun run test:e2e` | +| **Estimated runtime** | ~30 seconds (unit) + ~60 seconds (E2E) | + +--- + +## Sampling Rate + +- **After every task commit:** Run `bun test` +- **After every plan wave:** Run `bun test && bun run test:e2e` +- **Before `/gsd-verify-work`:** Full suite must be green +- **Max feedback latency:** 30 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 29-01-01 | 01 | 1 | D-01,D-02,D-03 | — | N/A | unit | `bun test tests/services/image.service.test.ts` | ❌ W0 | ⬜ pending | +| 29-01-02 | 01 | 1 | D-04 | — | N/A | integration | `bun test tests/services/image.service.test.ts` | ❌ W0 | ⬜ pending | +| 29-02-01 | 02 | 1 | D-01,D-06 | — | N/A | grep | `grep -r "GearImage" src/client/` | N/A | ⬜ pending | +| 29-03-01 | 03 | 2 | D-07,D-08,D-09 | — | N/A | unit+E2E | `bun test && bun run test:e2e` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] Test stubs for dominant color extraction service +- [ ] Test stubs for crop field persistence + +*Existing test infrastructure (Bun test runner, Playwright) covers framework needs.* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Dominant color background looks correct | D-02 | Visual quality subjective | Upload 5 varied images, verify background colors feel intentional | +| Zoom+pan editor is intuitive | D-07 | UX quality subjective | Open editor, zoom in/out, pan, verify coordinates save and render | +| Letterbox/pillarbox appearance | D-01 | Visual consistency check | View tall and wide images on cards and detail pages | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 30s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending