--- phase: 35-bug-fixes plan: 02 type: execute wave: 1 depends_on: [] files_modified: - src/client/components/GearImage.tsx - src/client/components/ItemCard.tsx - src/client/components/CandidateCard.tsx - src/client/components/GlobalItemCard.tsx autonomous: true requirements: - FIX-03 must_haves: truths: - "All img elements in GearImage have loading='lazy'" - "ItemCard shows a bg-gray-100 animate-pulse skeleton while the image loads" - "CandidateCard shows a bg-gray-100 animate-pulse skeleton while the image loads" - "GlobalItemCard shows a bg-gray-100 animate-pulse skeleton while the image loads" - "Once loaded, the img fades in via opacity-0 to opacity-100 transition-opacity duration-200" - "When imageUrl is null, the no-image placeholder (category icon on bg-gray-50) is unchanged" artifacts: - path: "src/client/components/GearImage.tsx" provides: "Lazy-loading image component" contains: "loading=\"lazy\"" - path: "src/client/components/ItemCard.tsx" provides: "ItemCard with image skeleton" contains: "animate-pulse" - path: "src/client/components/CandidateCard.tsx" provides: "CandidateCard with image skeleton" contains: "animate-pulse" - path: "src/client/components/GlobalItemCard.tsx" provides: "GlobalItemCard with image skeleton" contains: "animate-pulse" key_links: - from: "ItemCard image area" to: "GearImage onLoad callback" via: "loaded useState" pattern: "onLoad.*setLoaded" - from: "skeleton div" to: "loaded state" via: "conditional rendering" pattern: "loaded.*opacity-0.*opacity-100" --- Add lazy loading and image skeleton loading states to all card types that display images. The skeleton prevents layout shift and gives users immediate feedback while presigned S3 URLs resolve. 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/35-bug-fixes/35-CONTEXT.md @.planning/phases/35-bug-fixes/35-UI-SPEC.md From src/client/components/GearImage.tsx (current state): ```typescript // Three render paths — each has an element that needs loading="lazy": // 1. cover mode: {alt} // 2. hasCrop mode: {alt} // 3. default mode: {alt} ``` Image skeleton pattern (from UI-SPEC, matching existing SkeletonGrid in codebase): ```tsx // In each card, inside the aspect-[4/3] container when imageUrl is truthy: const [loaded, setLoaded] = useState(false); // Render when imageUrl is truthy:
{!loaded && (
)} setLoaded(true)} className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`} />
``` NOTE: GearImage needs to accept and forward an `onLoad` prop and a `className` to the underlying `` element. The `className` prop already exists on GearImage — it is forwarded to ``. The `onLoad` prop does NOT currently exist — it must be added to `GearImageProps` and forwarded to each `` element. From src/client/components/ItemCard.tsx (current state, line 188-213): ```tsx
{imageUrl ? ( ) : (
)}
``` 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:** ```typescript 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:** ```typescript 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 `` elements:** Cover path (currently line ~29): ```tsx {alt} ``` hasCrop path (currently line ~43): ```tsx {alt} ``` Default path (currently line ~58): ```tsx {alt} ``` cd /home/jlmak/Projects/jlmak/GearBox && grep -n 'loading="lazy"' src/client/components/GearImage.tsx | wc -l - `grep -c 'loading="lazy"' src/client/components/GearImage.tsx` outputs `3` (all three img elements) - `grep -n "onLoad" src/client/components/GearImage.tsx` shows the prop in interface, destructuring, and all three img elements (at least 5 matches) - `bun run lint` passes with no errors on the modified file GearImage has loading="lazy" on all three img elements and forwards an optional onLoad callback prop. Existing callers pass no onLoad and are unaffected. Task 2: Add image skeleton to ItemCard, CandidateCard, and GlobalItemCard (FIX-03 — part 2) src/client/components/ItemCard.tsx, src/client/components/CandidateCard.tsx, src/client/components/GlobalItemCard.tsx - src/client/components/ItemCard.tsx (read fully — locate image area at lines 188-213) - src/client/components/CandidateCard.tsx (read fully — locate image area at lines 163-188) - src/client/components/GlobalItemCard.tsx (read fully — locate image area at lines 40-73) Apply identical skeleton pattern to all three cards (per D-08, UI-SPEC Image Skeleton Contract): **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: ```typescript const [loaded, setLoaded] = useState(false); ``` **Step 3:** Replace the imageUrl branch of the image area container. **ItemCard** — replace the current `{imageUrl ? ... : ...}` inside `
`: ```tsx {imageUrl ? (
{!loaded && (
)} setLoaded(true)} className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`} />
) : (
)} ``` **CandidateCard** — same pattern as ItemCard, using `name` as the alt: ```tsx {imageUrl ? (
{!loaded && (
)} setLoaded(true)} className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`} />
) : (
)} ``` **GlobalItemCard** — same pattern, alt is `\`${brand} ${model}\``: ```tsx {imageUrl ? (
{!loaded && (
)} setLoaded(true)} className={`transition-opacity duration-200 ${loaded ? "opacity-100" : "opacity-0"}`} />
) : (
{/* keep existing SVG icon placeholder unchanged */}
)} ``` 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 - `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 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. ## 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. | After both tasks complete: 1. Open collection overview page — cards with images must show a gray pulsing placeholder, then fade in the image 2. Open catalog/global-items page — GlobalItemCard items with images must show skeleton then fade in 3. Open a thread page with candidates — CandidateCard images must show skeleton then fade in 4. Cards without images must still show the category icon placeholder (no skeleton, no blank) 5. 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 - 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 After completion, create `.planning/phases/35-bug-fixes/35-02-SUMMARY.md`