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