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