Files
GearBox/.planning/phases/35-bug-fixes/35-02-PLAN.md

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
src/client/components/GearImage.tsx
src/client/components/ItemCard.tsx
src/client/components/CandidateCard.tsx
src/client/components/GlobalItemCard.tsx
true
FIX-03
truths artifacts key_links
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
path provides contains
src/client/components/GearImage.tsx Lazy-loading image component loading="lazy"
path provides contains
src/client/components/ItemCard.tsx ItemCard with image skeleton animate-pulse
path provides contains
src/client/components/CandidateCard.tsx CandidateCard with image skeleton animate-pulse
path provides contains
src/client/components/GlobalItemCard.tsx GlobalItemCard with image skeleton animate-pulse
from to via pattern
ItemCard image area GearImage onLoad callback loaded useState onLoad.*setLoaded
from to via pattern
skeleton div loaded state conditional rendering 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.

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

From 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}`}
/>
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:

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

<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>
After completion, create `.planning/phases/35-bug-fixes/35-02-SUMMARY.md`