Phases 28-31 archived to milestones/v2.2-phases/ Requirements and roadmap snapshots archived to milestones/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 29 | 02 | frontend | 1 |
|
true |
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`: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 (
<img
src={src}
alt={alt}
className={`w-full h-full object-cover ${className}`}
/>
);
}
if (hasCrop) {
return (
<div
className={`aspect-[${aspectRatio}] overflow-hidden ${className}`}
style={{ backgroundColor: bgColor }}
>
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
style={{
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
transformOrigin: "center center",
}}
/>
</div>
);
}
return (
<div
className={`aspect-[${aspectRatio}] overflow-hidden ${className}`}
style={{ backgroundColor: bgColor }}
>
<img
src={src}
alt={alt}
className="w-full h-full object-contain"
/>
</div>
);
}
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):
export function GearImage({
src,
alt,
dominantColor,
cropZoom,
cropX,
cropY,
className = "",
cover = false,
}: Omit<GearImageProps, 'aspectRatio'>) {
const hasCrop = cropZoom != null && cropZoom > 1;
const bgColor = dominantColor || "#f3f4f6";
if (cover) {
return (
<img src={src} alt={alt} className={`w-full h-full object-cover ${className}`} />
);
}
if (hasCrop) {
return (
<img
src={src}
alt={alt}
className={`w-full h-full object-cover ${className}`}
style={{
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
transformOrigin: "center center",
}}
/>
);
}
return (
<img src={src} alt={alt} className={`w-full h-full object-contain ${className}`} />
);
}
The parent div provides aspect ratio, overflow-hidden, and the style={{ backgroundColor: dominantColor }}. This matches the existing pattern where the parent <div className="aspect-[4/3] bg-gray-50"> wraps the image.
test -f src/client/components/GearImage.tsx && grep "object-contain" src/client/components/GearImage.tsx && echo "PASS" || echo "FAIL"
<acceptance_criteria>
src/client/components/GearImage.tsxexists- Exports
GearImagecomponent - Default rendering uses
object-contain(notobject-cover) - When
coverprop is true, usesobject-cover - When crop values exist and cropZoom > 1, uses CSS transform with scale and translate
- Accepts
dominantColor,cropZoom,cropX,cropYprops </acceptance_criteria>
Task 2: Update ItemCard to use GearImage
- src/client/components/ItemCard.tsx - src/client/components/GearImage.tsx In `src/client/components/ItemCard.tsx`:- Add
dominantColor,cropZoom,cropX,cropYtoItemCardPropsinterface (allnumber | nullorstring | null) - Import
GearImagefrom./GearImage - Replace the image div (around line 164-179):
Current:
<div className="aspect-[4/3] bg-gray-50">
{imageUrl ? (
<img src={imageUrl} alt={name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" />
</div>
)}
</div>
New:
<div
className="aspect-[4/3] overflow-hidden"
style={{ backgroundColor: imageUrl ? (dominantColor || "#f3f4f6") : undefined }}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={name}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
/>
) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" />
</div>
)}
</div>
Task 3: Update GlobalItemCard to use GearImage
- src/client/components/GlobalItemCard.tsx - src/client/components/GearImage.tsx In `src/client/components/GlobalItemCard.tsx`:- Add
dominantColor?: string | null,cropZoom?: number | null,cropX?: number | null,cropY?: number | nulltoGlobalItemCardProps - Import
GearImagefrom./GearImage - Replace the image rendering (around line 31-54):
Current:
<div className="aspect-[4/3] bg-gray-50">
{imageUrl ? (
<img src={imageUrl} alt={`${brand} ${model}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
{/* SVG placeholder */}
</div>
)}
</div>
New:
<div
className="aspect-[4/3] overflow-hidden"
style={{ backgroundColor: imageUrl ? (dominantColor || "#f3f4f6") : undefined }}
>
{imageUrl ? (
<GearImage
src={imageUrl}
alt={`${brand} ${model}`}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
/>
) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
{/* Keep existing SVG placeholder */}
</div>
)}
</div>
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 `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` remainingTask 6: Update ComparisonTable to use GearImage
- src/client/components/ComparisonTable.tsx - src/client/components/GearImage.tsx Same pattern: replace inline `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 fileTask 8: Update ImageUpload preview to use GearImage
- src/client/components/ImageUpload.tsx - src/client/components/GearImage.tsx In `src/client/components/ImageUpload.tsx`:- Add
dominantColor?: string | nulltoImageUploadProps - Import GearImage
- Replace the preview image (line 76-79):
Current:
<img src={displayUrl} alt="Item" className="w-full h-full object-cover" />
New:
<GearImage src={displayUrl} alt="Item" dominantColor={dominantColor} />
- Update the parent container to use dominant color background:
style={{ backgroundColor: displayUrl ? (dominantColor || "#f3f4f6") : undefined }}
Task 9: Update item detail page
- src/client/routes/items/$itemId.tsx - src/client/components/GearImage.tsx In `src/client/routes/items/$itemId.tsx`:- Import GearImage
- Replace the
object-coverimage (around line 245-250) with GearImage - Update the parent
aspect-[4/3]div to use dominant color background via inline style - 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" <acceptance_criteria>
- Item detail page uses GearImage
- No
object-coverin the file - Dominant color and crop fields used from item data </acceptance_criteria>
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`:- Import GearImage
- Replace the
object-coverimage (around line 65-70) with GearImage - This page uses
aspect-[16/9]— keep that ratio on the parent container - 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" <acceptance_criteria>
- Global item detail uses GearImage
- No
object-coverremaining - Aspect ratio 16/9 preserved </acceptance_criteria>
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`:- Import GearImage
- Replace
object-coverimage with GearImage - 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" <acceptance_criteria>
- Global items index uses GearImage
- No
object-coverremaining </acceptance_criteria>
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`:- Import GearImage
- Replace
object-coverimage with GearImage - This page uses
aspect-[16/9]— keep that ratio - 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" <acceptance_criteria>
- Candidate detail uses GearImage
- No
object-coverremaining - Aspect ratio 16/9 preserved </acceptance_criteria>
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:
<img className="w-8 h-8 rounded object-cover shrink-0" ... />
With:
<GearImage src={...} alt={...} cover className="w-8 h-8 rounded shrink-0" />
<success_criteria>
- 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) </success_criteria>
<threat_model>
| 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 |
| </threat_model> |
<must_haves>
- 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 </must_haves>