docs(29): fix plan file naming convention
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
566
.planning/phases/29-image-presentation/29-02-PLAN.md
Normal file
566
.planning/phases/29-image-presentation/29-02-PLAN.md
Normal file
@@ -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: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<tasks>
|
||||
|
||||
### Task 1: Create GearImage component
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/ItemCard.tsx (current image rendering pattern)
|
||||
- src/client/components/GlobalItemCard.tsx (current image rendering pattern)
|
||||
</read_first>
|
||||
<action>
|
||||
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 (
|
||||
<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):
|
||||
|
||||
```tsx
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f src/client/components/GearImage.tsx && grep "object-contain" src/client/components/GearImage.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 2: Update ItemCard to use GearImage
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/ItemCard.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
<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:
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/ItemCard.tsx && ! grep "object-cover" src/client/components/ItemCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 3: Update GlobalItemCard to use GearImage
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/GlobalItemCard.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
<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:
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/GlobalItemCard.tsx && ! grep "object-cover" src/client/components/GlobalItemCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- GlobalItemCard imports and uses GearImage
|
||||
- No `object-cover` in GlobalItemCard.tsx
|
||||
- Props include dominantColor, cropZoom, cropX, cropY
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 4: Update CandidateCard to use GearImage
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/CandidateCard.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Same pattern as Task 2/3:
|
||||
1. Add `dominantColor`, `cropZoom`, `cropX`, `cropY` to props interface
|
||||
2. Import `GearImage`
|
||||
3. Replace `<img className="w-full h-full object-cover">` with `<GearImage>` inside the existing `aspect-[4/3]` container
|
||||
4. Update parent div to use inline `backgroundColor` style
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/CandidateCard.tsx && ! grep "object-cover" src/client/components/CandidateCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CandidateCard uses GearImage component
|
||||
- No `object-cover` remaining
|
||||
- Dominant color props threaded through
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 5: Update CandidateListItem to use GearImage
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/CandidateListItem.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/CandidateListItem.tsx && ! grep "object-cover" src/client/components/CandidateListItem.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CandidateListItem uses GearImage
|
||||
- No `object-cover` remaining
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 6: Update ComparisonTable to use GearImage
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
Same pattern: replace inline `<img className="w-full h-full object-cover">` with `<GearImage>`. Thread dominantColor and crop props from the data source.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/ComparisonTable.tsx && ! grep "object-cover" src/client/components/ComparisonTable.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ComparisonTable uses GearImage
|
||||
- No `object-cover` remaining
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 7: Update CatalogSearchOverlay to use GearImage
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/CatalogSearchOverlay.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
CatalogSearchOverlay has 2 `object-cover` instances (search results and selected item preview). Replace both with GearImage. Thread dominantColor from global item data.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/CatalogSearchOverlay.tsx && ! grep "object-cover" src/client/components/CatalogSearchOverlay.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Both image instances in CatalogSearchOverlay use GearImage
|
||||
- No `object-cover` remaining in the file
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 8: Update ImageUpload preview to use GearImage
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/ImageUpload.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
<img src={displayUrl} alt="Item" className="w-full h-full object-cover" />
|
||||
```
|
||||
|
||||
New:
|
||||
```tsx
|
||||
<GearImage src={displayUrl} alt="Item" dominantColor={dominantColor} />
|
||||
```
|
||||
|
||||
4. Update the parent container to use dominant color background:
|
||||
```tsx
|
||||
style={{ backgroundColor: displayUrl ? (dominantColor || "#f3f4f6") : undefined }}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/ImageUpload.tsx && ! grep "object-cover" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ImageUpload uses GearImage for preview
|
||||
- No `object-cover` remaining
|
||||
- Accepts dominantColor prop
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 9: Update item detail page
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/items/$itemId.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/routes/items/\$itemId.tsx && ! grep "object-cover" src/client/routes/items/\$itemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Item detail page uses GearImage
|
||||
- No `object-cover` in the file
|
||||
- Dominant color and crop fields used from item data
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 10: Update global item detail page
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/global-items/$globalItemId.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/routes/global-items/\$globalItemId.tsx && ! grep "object-cover" src/client/routes/global-items/\$globalItemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Global item detail uses GearImage
|
||||
- No `object-cover` remaining
|
||||
- Aspect ratio 16/9 preserved
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 11: Update global items index page
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/global-items/index.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/routes/global-items/index.tsx && ! grep "object-cover" src/client/routes/global-items/index.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Global items index uses GearImage
|
||||
- No `object-cover` remaining
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 12: Update candidate detail page
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/routes/threads/$threadId/candidates/$candidateId.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>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"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- Candidate detail uses GearImage
|
||||
- No `object-cover` remaining
|
||||
- Aspect ratio 16/9 preserved
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
### Task 13: Update LinkToGlobalItem with cover mode
|
||||
<task type="code">
|
||||
<read_first>
|
||||
- src/client/components/LinkToGlobalItem.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
</read_first>
|
||||
<action>
|
||||
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
|
||||
<img className="w-8 h-8 rounded object-cover shrink-0" ... />
|
||||
```
|
||||
|
||||
With:
|
||||
```tsx
|
||||
<GearImage src={...} alt={...} cover className="w-8 h-8 rounded shrink-0" />
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep "GearImage" src/client/components/LinkToGlobalItem.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- LinkToGlobalItem uses GearImage with `cover` prop
|
||||
- Small thumbnail renders with object-cover (intentional exception for tiny images)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user