14 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 35-bug-fixes | 02 | execute | 1 |
|
true |
|
|
Purpose: Resolve FIX-03 — slow image loading UX. Images load lazily (browser-native) and show an animated pulse placeholder until loaded. Output: Updated GearImage, ItemCard, CandidateCard, GlobalItemCard.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/phases/35-bug-fixes/35-CONTEXT.md @.planning/phases/35-bug-fixes/35-UI-SPEC.mdFrom src/client/components/GearImage.tsx (current state):
// Three render paths — each has an <img> element that needs loading="lazy":
// 1. cover mode: <img src={src} alt={alt} className={`w-full h-full object-cover ${className}`} />
// 2. hasCrop mode: <img src={src} alt={alt} className={`w-full h-full object-cover ${className}`} style={{transform...}} />
// 3. default mode: <img src={src} alt={alt} className={`w-full h-full object-contain ${className}`} />
Image skeleton pattern (from UI-SPEC, matching existing SkeletonGrid in codebase):
// In each card, inside the aspect-[4/3] container when imageUrl is truthy:
const [loaded, setLoaded] = useState(false);
// Render when imageUrl is truthy:
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage
src={imageUrl}
alt={name}
// ... other props
onLoad={() => setLoaded(true)}
className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
/>
</div>
NOTE: GearImage needs to accept and forward an onLoad prop and a className to the underlying <img> element. The className prop already exists on GearImage — it is forwarded to <img>. The onLoad prop does NOT currently exist — it must be added to GearImageProps and forwarded to each <img> element.
From src/client/components/ItemCard.tsx (current state, line 188-213):
<div
className="aspect-[4/3] overflow-hidden"
style={{ backgroundColor: imageUrl ? imageContainerBg(dominantColor) : 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>
From src/client/components/CandidateCard.tsx (current state, line 163-188): Same pattern as ItemCard above — imageUrl conditional with GearImage or icon placeholder.
From src/client/components/GlobalItemCard.tsx (current state, line 40-73): Same pattern — imageUrl conditional with GearImage or SVG icon placeholder.
Task 1: Add loading="lazy" and onLoad prop to GearImage (FIX-03 — part 1) src/client/components/GearImage.tsx - src/client/components/GearImage.tsx (read fully — understand all three render paths) Make two changes to src/client/components/GearImage.tsx (per D-07):1. Add onLoad to the props interface:
interface GearImageProps {
src: string;
alt: string;
dominantColor?: string | null;
cropZoom?: number | null;
cropX?: number | null;
cropY?: number | null;
className?: string;
cover?: boolean;
onLoad?: () => void; // ADD THIS
}
2. Destructure onLoad in the function signature:
export function GearImage({
src,
alt,
dominantColor,
cropZoom,
cropX,
cropY,
className = "",
cover = false,
onLoad, // ADD THIS
}: GearImageProps) {
3. Add loading="lazy" and onLoad={onLoad} to ALL THREE <img> elements:
Cover path (currently line ~29):
<img
src={src}
alt={alt}
loading="lazy"
onLoad={onLoad}
className={`w-full h-full object-cover ${className}`}
/>
hasCrop path (currently line ~43):
<img
src={src}
alt={alt}
loading="lazy"
onLoad={onLoad}
className={`w-full h-full object-cover ${className}`}
style={{
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
transformOrigin: "center center",
}}
/>
Default path (currently line ~58):
<img
src={src}
alt={alt}
loading="lazy"
onLoad={onLoad}
className={`w-full h-full object-contain ${className}`}
/>
For each card that has an imageUrl prop:
Step 1: Add useState import if not already present (all three cards already import from react via other hooks — check existing imports and add useState to the destructured import if missing).
Step 2: Add the loaded state at the top of each component function:
const [loaded, setLoaded] = useState(false);
Step 3: Replace the imageUrl branch of the image area container.
ItemCard — replace the current {imageUrl ? ... : ...} inside <div className="aspect-[4/3] overflow-hidden" ...>:
{imageUrl ? (
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage
src={imageUrl}
alt={name}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
onLoad={() => setLoaded(true)}
className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
/>
</div>
) : (
<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>
)}
CandidateCard — same pattern as ItemCard, using name as the alt:
{imageUrl ? (
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage
src={imageUrl}
alt={name}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
onLoad={() => setLoaded(true)}
className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
/>
</div>
) : (
<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>
)}
GlobalItemCard — same pattern, alt is \${brand} ${model}``:
{imageUrl ? (
<div className="relative w-full h-full">
{!loaded && (
<div className="absolute inset-0 bg-gray-100 animate-pulse" />
)}
<GearImage
src={imageUrl}
alt={`${brand} ${model}`}
dominantColor={dominantColor}
cropZoom={cropZoom}
cropX={cropX}
cropY={cropY}
onLoad={() => setLoaded(true)}
className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`}
/>
</div>
) : (
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
{/* keep existing SVG icon placeholder unchanged */}
</div>
)}
Do NOT change the no-image placeholder (icon on bg-gray-50) in any card — it is correct behavior.
cd /home/jlmak/Projects/jlmak/GearBox && grep -l "animate-pulse" src/client/components/ItemCard.tsx src/client/components/CandidateCard.tsx src/client/components/GlobalItemCard.tsx | wc -l
<acceptance_criteria>
- grep -n "animate-pulse" src/client/components/ItemCard.tsx returns at least one match
- grep -n "animate-pulse" src/client/components/CandidateCard.tsx returns at least one match
- grep -n "animate-pulse" src/client/components/GlobalItemCard.tsx returns at least one match
- grep -n "useState" src/client/components/ItemCard.tsx returns at least one match (loaded state)
- grep -n "useState" src/client/components/CandidateCard.tsx returns at least one match
- grep -n "useState" src/client/components/GlobalItemCard.tsx returns at least one match
- grep -n "transition-opacity duration-200" src/client/components/ItemCard.tsx returns at least one match
- grep -n "transition-opacity duration-200" src/client/components/CandidateCard.tsx returns at least one match
- grep -n "transition-opacity duration-200" src/client/components/GlobalItemCard.tsx returns at least one match
- grep -n "onLoad" src/client/components/ItemCard.tsx returns at least one match
- bun run lint passes with no errors across all three files
</acceptance_criteria>
All three card components show a gray animated skeleton (bg-gray-100 animate-pulse) while the image loads, then fade in the image via transition-opacity duration-200 once onLoad fires. No-image placeholders are unchanged.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| browser→S3 presigned URL | img src attributes point to S3 presigned URLs; loading="lazy" defers fetch |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-35-03 | Information Disclosure | GearImage lazy load | accept | loading="lazy" is a browser hint; presigned URLs are already time-limited by S3. No new exposure. |
| </threat_model> |
- Open collection overview page — cards with images must show a gray pulsing placeholder, then fade in the image
- Open catalog/global-items page — GlobalItemCard items with images must show skeleton then fade in
- Open a thread page with candidates — CandidateCard images must show skeleton then fade in
- Cards without images must still show the category icon placeholder (no skeleton, no blank)
- Network throttle to "Slow 3G" in DevTools — skeleton must be clearly visible before image loads
Run: bun run lint — zero errors
Run: bun test — all existing tests pass
<success_criteria>
- GearImage has loading="lazy" on all 3 img elements and accepts optional onLoad prop
- ItemCard, CandidateCard, GlobalItemCard each have a loaded state and show bg-gray-100 animate-pulse skeleton
- Fade-in uses transition-opacity duration-200 on the GearImage className
- No-image placeholder (icon on bg-gray-50) is unchanged in all three cards
- bun run lint passes with zero errors </success_criteria>