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 (
+
+ );
+ }
+
+ if (hasCrop) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+```
+
+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 (
+
+ );
+ }
+
+ if (hasCrop) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+```
+
+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 ? (
+
+ ) : (
+
+
+
+ )}
+
+```
+
+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 ? (
+
+ ) : (
+
+ {/* 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
+
+```
+
+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 (
+