docs(35): create phase 35 bug-fix plans (3 plans, wave 1 parallel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 19:35:31 +02:00
parent d216c80892
commit 44392e8583
5 changed files with 931 additions and 5 deletions

View File

@@ -231,7 +231,12 @@ Plans:
3. Catalog and collection images appear without noticeable delay across all image-bearing pages
4. Clicking the sign-in button on an auth prompt navigates the user directly to the Logto login page
5. Every clickable or interactive element in the app (buttons, links, cards, badges) shows a pointer cursor on hover
**Plans**: TBD
**Plans**: 3 plans
Plans:
- [ ] 35-01-PLAN.md — Thread modal fix, ItemWithCategory type extension, login auto-redirect (FIX-01, FIX-02, FIX-04)
- [ ] 35-02-PLAN.md — Lazy loading + image skeleton states on GearImage and all card components (FIX-03)
- [ ] 35-03-PLAN.md — Cursor-pointer audit across ItemCard, FabMenu, BottomTabBar (FIX-05)
**UI hint**: yes
@@ -313,7 +318,7 @@ Plans:
| 32. Setup Sharing System | v2.3 | 4/4 | Complete | 2026-04-15 |
| 33. Currency System | v2.3 | 6/6 | Complete | 2026-04-13 |
| 34. i18n Foundation | v2.3 | 8/8 | Complete | 2026-04-18 |
| 35. Bug Fixes | v2.4 | 0/TBD | Not started | - |
| 35. Bug Fixes | v2.4 | 0/3 | Not started | - |
| 36. Admin Role & Panel Foundation | v2.4 | 0/TBD | Not started | - |
| 37. Admin — Global Item Management | v2.4 | 0/TBD | Not started | - |
| 38. Admin — Tag Management | v2.4 | 0/TBD | Not started | - |

View File

@@ -3,7 +3,7 @@ gsd_state_version: 1.0
milestone: v2.4
milestone_name: Admin Foundation
status: in_progress
stopped_at: Phase 35 — Bug Fixes (context gathered, ready to plan)
stopped_at: Phase 35 — Bug Fixes (UI-SPEC approved, ready to plan)
last_updated: "2026-04-19T00:00:00.000Z"
last_activity: 2026-04-19
progress:
@@ -91,5 +91,5 @@ Items carried forward from v2.3:
## Session Continuity
Last session: 2026-04-19
Stopped at: Phase 35 context gathered — ready to plan
Resume file: .planning/phases/35-bug-fixes/35-CONTEXT.md
Stopped at: Phase 35 UI-SPEC approved — ready to plan
Resume file: .planning/phases/35-bug-fixes/35-UI-SPEC.md

View File

@@ -0,0 +1,324 @@
---
phase: 35-bug-fixes
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/client/routes/threads/$threadId/index.tsx
- src/client/hooks/useItems.ts
- src/client/routes/login.tsx
autonomous: true
requirements:
- FIX-01
- FIX-02
- FIX-04
must_haves:
truths:
- "Clicking Add Candidate on the thread page opens CatalogSearchOverlay in thread mode"
- "The AddCandidateModal component and addCandidateOpen state are deleted from the thread route file"
- "ItemWithCategory includes imageUrl, dominantColor, cropZoom, cropX, cropY, priceCurrency fields"
- "Navigating to /login immediately redirects to the server /login route with no intermediate UI"
artifacts:
- path: "src/client/routes/threads/$threadId/index.tsx"
provides: "Thread detail page — Add Candidate button calls openCatalogSearch('thread')"
contains: "openCatalogSearch"
- path: "src/client/hooks/useItems.ts"
provides: "ItemWithCategory interface with image fields"
contains: "imageUrl: string | null"
- path: "src/client/routes/login.tsx"
provides: "Auto-redirect login page"
contains: "window.location.href = \"/login\""
key_links:
- from: "thread detail toolbar button"
to: "useUIStore.openCatalogSearch('thread')"
via: "onClick handler"
pattern: "openCatalogSearch\\(\"thread\"\\)"
- from: "LoginPage useEffect"
to: "window.location.href = \"/login\""
via: "useEffect with empty deps"
pattern: "useEffect.*window\\.location\\.href"
---
<objective>
Three self-contained type/wiring fixes that resolve wrong-modal, missing-image, and login-redirect bugs from the v2.3 backlog.
Purpose: Clear the modal confusion on thread pages (FIX-01), surface item images that the server already returns but the TypeScript type hides (FIX-02), and skip the redundant intermediate login UI (FIX-04).
Output: Updated thread route, useItems hook, and login route.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/35-bug-fixes/35-CONTEXT.md
@.planning/phases/35-bug-fixes/35-UI-SPEC.md
</context>
<interfaces>
<!-- Key contracts the executor needs. Extracted from codebase. -->
From src/client/stores/uiStore.ts:
```typescript
// Catalog search actions
openCatalogSearch: (mode: "collection" | "thread") => void;
closeCatalogSearch: () => void;
catalogSearchOpen: boolean;
catalogSearchMode: "collection" | "thread" | null;
// Session thread tracking (used by CatalogSearchOverlay to scope to a thread)
catalogSessionThreadId: number | null;
setCatalogSessionThreadId: (id: number | null) => void;
```
From src/client/routes/threads/$threadId/index.tsx (current state):
- Line 44: `const [addCandidateOpen, setAddCandidateOpen] = useState(false);`
- Line 144: `onClick={() => setAddCandidateOpen(true)}` — this is the broken Add Candidate button
- Lines 307-313: `{addCandidateOpen && <AddCandidateModal ... />}` — the modal to remove
- Lines 317-639: Full `AddCandidateModal` component and its interfaces/constants — all to delete
From src/client/hooks/useItems.ts (current state):
- `ItemWithCategory` interface (lines 27-43) is missing these fields the server already returns:
- `imageUrl: string | null`
- `dominantColor: string | null`
- `cropZoom: number | null`
- `cropX: number | null`
- `cropY: number | null`
- `priceCurrency: string | null`
From src/client/routes/login.tsx (current state):
- Renders full card UI with a sign-in button that calls `window.location.href = "/login"`
- Has `useAuth` hook check and a `useNavigate` for already-authenticated users
- Both the auth check and full UI need to be removed — replace with immediate useEffect redirect
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Wire Add Candidate button and delete AddCandidateModal (FIX-01)</name>
<files>src/client/routes/threads/$threadId/index.tsx</files>
<read_first>
- src/client/routes/threads/$threadId/index.tsx (read the full file — understand current modal state, imports, and FAB wiring pattern)
- src/client/stores/uiStore.ts (confirm openCatalogSearch and setCatalogSessionThreadId signatures)
</read_first>
<action>
Make two changes to src/client/routes/threads/$threadId/index.tsx:
**1. Wire the toolbar button (per D-01, D-03):**
Replace the `openCatalogSearch` and `setCatalogSessionThreadId` Zustand selectors in the component — add these two lines to the existing `useUIStore` selectors at the top of `ThreadDetailPage`:
```typescript
const openCatalogSearch = useUIStore((s) => s.openCatalogSearch);
const setCatalogSessionThreadId = useUIStore((s) => s.setCatalogSessionThreadId);
```
Delete the `addCandidateOpen` state (line 44):
```typescript
// DELETE THIS LINE:
const [addCandidateOpen, setAddCandidateOpen] = useState(false);
```
Change the toolbar button's onClick from `() => setAddCandidateOpen(true)` to:
```typescript
onClick={() => {
setCatalogSessionThreadId(threadId);
openCatalogSearch("thread");
}}
```
Remove the cursor-default: the button already has class string — ensure `cursor-pointer` is present (the button has no explicit cursor class currently, so browsers default to pointer for `<button>` — leave as-is, no change needed here).
**2. Delete all dead code (per D-02):**
Remove from the JSX:
```tsx
{addCandidateOpen && (
<AddCandidateModal
threadId={threadId}
onClose={() => setAddCandidateOpen(false)}
/>
)}
```
Delete the entire block from line ~317 to end of file:
- `interface AddCandidateModalProps { ... }`
- `interface ModalFormData { ... }`
- `const INITIAL_MODAL_FORM: ModalFormData = { ... }`
- `function AddCandidateModal({ ... }) { ... }` (the entire function, ~300 lines)
Remove any imports that were only used by `AddCandidateModal` and are no longer needed:
- `useCreateCandidate` from `../../../hooks/useCandidates` — check if used elsewhere in the file; if only in `AddCandidateModal`, remove it
- `useCurrency` from `../../../hooks/useCurrency` — check if used elsewhere; if only in modal, remove it
- `ImageUpload` from `../../../components/ImageUpload` — check if used elsewhere; if only in modal, remove it
Keep all other imports (`CategoryPicker`, `ComparisonTable`, etc.) since they are used in the main page body.
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | grep -E "threads/\\\$threadId|error" | head -20</automated>
</verify>
<acceptance_criteria>
- `grep -n "addCandidateOpen" src/client/routes/threads/\$threadId/index.tsx` returns no matches
- `grep -n "AddCandidateModal" src/client/routes/threads/\$threadId/index.tsx` returns no matches
- `grep -n "openCatalogSearch" src/client/routes/threads/\$threadId/index.tsx` shows at least one match
- `grep -n "setCatalogSessionThreadId" src/client/routes/threads/\$threadId/index.tsx` shows at least one match
- `bun run lint` passes with no errors on the modified file
</acceptance_criteria>
<done>Thread detail page Add Candidate button calls openCatalogSearch("thread") with the current threadId set as catalogSessionThreadId. The AddCandidateModal and all associated dead code (interfaces, constants, component function) are deleted.</done>
</task>
<task type="auto">
<name>Task 2: Extend ItemWithCategory interface with image fields (FIX-02)</name>
<files>src/client/hooks/useItems.ts</files>
<read_first>
- src/client/hooks/useItems.ts (read fully — see current ItemWithCategory interface at lines 27-43)
</read_first>
<action>
Add the six missing fields to the `ItemWithCategory` interface in `src/client/hooks/useItems.ts` (per D-04).
Current interface ends at line 43. Add these fields before the closing `}`:
```typescript
imageUrl: string | null;
dominantColor: string | null;
cropZoom: number | null;
cropX: number | null;
cropY: number | null;
priceCurrency: string | null;
```
The updated `ItemWithCategory` interface should be:
```typescript
interface ItemWithCategory {
id: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
quantity: number;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
globalItemId: number | null;
brand: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
imageUrl: string | null;
dominantColor: string | null;
cropZoom: number | null;
cropX: number | null;
cropY: number | null;
priceCurrency: string | null;
}
```
No server-side changes needed (per D-05) — GET /api/items already returns these fields via withImageUrls().
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | grep -E "useItems|error" | head -10</automated>
</verify>
<acceptance_criteria>
- `grep -n "imageUrl: string | null" src/client/hooks/useItems.ts` returns a match inside `ItemWithCategory`
- `grep -n "dominantColor: string | null" src/client/hooks/useItems.ts` returns a match
- `grep -n "cropZoom: number | null" src/client/hooks/useItems.ts` returns a match
- `grep -n "cropX: number | null" src/client/hooks/useItems.ts` returns a match
- `grep -n "cropY: number | null" src/client/hooks/useItems.ts` returns a match
- `grep -n "priceCurrency: string | null" src/client/hooks/useItems.ts` returns a match
- `bun run lint` passes with no errors on the modified file
</acceptance_criteria>
<done>ItemWithCategory includes all six image and currency fields. TypeScript no longer reports missing properties when collection overview cards pass imageUrl/dominantColor/crop values to ItemCard.</done>
</task>
<task type="auto">
<name>Task 3: Replace login page UI with immediate useEffect redirect (FIX-04)</name>
<files>src/client/routes/login.tsx</files>
<read_first>
- src/client/routes/login.tsx (read fully — understand current imports, auth check, and full card UI)
</read_first>
<action>
Replace the entire content of `src/client/routes/login.tsx` with the following (per D-09, UI-SPEC Auth Redirect Contract):
```typescript
import { createFileRoute } from "@tanstack/react-router";
import { useEffect } from "react";
export const Route = createFileRoute("/login")({
component: LoginPage,
});
function LoginPage() {
useEffect(() => {
window.location.href = "/login";
}, []);
return (
<div className="flex items-center justify-center h-screen">
<p className="text-sm text-gray-500">Signing in...</p>
</div>
);
}
```
Remove all now-unused imports: `useNavigate` from `@tanstack/react-router`, `useTranslation` from `react-i18next`, `useAuth` from `../hooks/useAuth`.
The `/login` server route handles the Logto OIDC redirect. If the user is already authenticated, the server redirects back to `/`. No client-side auth check is needed (per D-09).
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun run lint 2>&1 | grep -E "login|error" | head -10</automated>
</verify>
<acceptance_criteria>
- `grep -n "window.location.href" src/client/routes/login.tsx` returns exactly one match inside `useEffect`
- `grep -n "useAuth" src/client/routes/login.tsx` returns no matches
- `grep -n "useNavigate" src/client/routes/login.tsx` returns no matches
- `grep -n "useTranslation" src/client/routes/login.tsx` returns no matches
- `grep -n "SignIn\|signInToGearBox\|redirectDescription" src/client/routes/login.tsx` returns no matches (full UI removed)
- File line count is under 25 lines: `wc -l src/client/routes/login.tsx` outputs a number ≤ 25
- `bun run lint` passes with no errors
</acceptance_criteria>
<done>LoginPage renders only a minimal "Signing in..." indicator and immediately redirects via useEffect to the server /login route. No intermediate card UI, no auth check, no translation keys.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client→server /login | Browser navigates to server-controlled route; server issues Logto OIDC redirect |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-35-01 | Spoofing | login route redirect | accept | Server /login route is Hono-controlled; client just triggers the navigation. No sensitive data exposed client-side. |
| T-35-02 | Information Disclosure | ItemWithCategory type | accept | Type extension only exposes fields already returned by the API to authenticated users. No new data surface. |
</threat_model>
<verification>
After all three tasks complete:
1. Navigate to a thread detail page — clicking "Add Candidate" must open CatalogSearchOverlay (not the old modal form)
2. Confirm no AddCandidateModal UI appears anywhere on thread pages
3. Collection overview cards with images must display images (imageUrl field now typed correctly)
4. Navigate to /login (client-side) — page must immediately redirect to Logto, showing only the brief "Signing in..." text
Run: `bun run lint` — zero errors
Run: `bun test` — all existing tests pass
</verification>
<success_criteria>
- Add Candidate toolbar button on thread page opens CatalogSearchOverlay in thread mode
- AddCandidateModal component is fully deleted (no dead code remaining)
- ItemWithCategory has imageUrl, dominantColor, cropZoom, cropX, cropY, priceCurrency fields
- LoginPage is ≤ 25 lines, redirects immediately via useEffect, renders no form UI
- bun run lint passes with zero errors
</success_criteria>
<output>
After completion, create `.planning/phases/35-bug-fixes/35-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,373 @@
---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/35-bug-fixes/35-CONTEXT.md
@.planning/phases/35-bug-fixes/35-UI-SPEC.md
</context>
<interfaces>
<!-- Key contracts the executor needs. Extracted from codebase. -->
From src/client/components/GearImage.tsx (current state):
```typescript
// 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):
```tsx
// 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):
```tsx
<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.
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Add loading="lazy" and onLoad prop to GearImage (FIX-03 — part 1)</name>
<files>src/client/components/GearImage.tsx</files>
<read_first>
- src/client/components/GearImage.tsx (read fully — understand all three render paths)
</read_first>
<action>
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 `<img>` elements:**
Cover path (currently line ~29):
```tsx
<img
src={src}
alt={alt}
loading="lazy"
onLoad={onLoad}
className={`w-full h-full object-cover ${className}`}
/>
```
hasCrop path (currently line ~43):
```tsx
<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):
```tsx
<img
src={src}
alt={alt}
loading="lazy"
onLoad={onLoad}
className={`w-full h-full object-contain ${className}`}
/>
```
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -n 'loading="lazy"' src/client/components/GearImage.tsx | wc -l</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>GearImage has loading="lazy" on all three img elements and forwards an optional onLoad callback prop. Existing callers pass no onLoad and are unaffected.</done>
</task>
<task type="auto">
<name>Task 2: Add image skeleton to ItemCard, CandidateCard, and GlobalItemCard (FIX-03 — part 2)</name>
<files>
src/client/components/ItemCard.tsx,
src/client/components/CandidateCard.tsx,
src/client/components/GlobalItemCard.tsx
</files>
<read_first>
- 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)
</read_first>
<action>
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 `<div className="aspect-[4/3] overflow-hidden" ...>`:
```tsx
{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:
```tsx
{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}\``:
```tsx
{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.
</action>
<verify>
<automated>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</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
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
</verification>
<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>
<output>
After completion, create `.planning/phases/35-bug-fixes/35-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,224 @@
---
phase: 35-bug-fixes
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- src/client/components/ItemCard.tsx
- src/client/components/FabMenu.tsx
- src/client/components/BottomTabBar.tsx
autonomous: true
requirements:
- FIX-05
must_haves:
truths:
- "ItemCard outer button shows cursor-pointer when linkTo is not null"
- "ItemCard outer button shows cursor-default when linkTo === null (existing correct behavior, preserved)"
- "FabMenu menu item buttons explicitly have cursor-pointer"
- "FabMenu main FAB button explicitly has cursor-pointer"
- "BottomTabBar anonymous tab buttons have cursor-pointer"
artifacts:
- path: "src/client/components/ItemCard.tsx"
provides: "ItemCard with correct conditional cursor"
contains: "cursor-pointer"
- path: "src/client/components/FabMenu.tsx"
provides: "FabMenu buttons with explicit cursor-pointer"
contains: "cursor-pointer"
- path: "src/client/components/BottomTabBar.tsx"
provides: "BottomTabBar buttons with cursor-pointer"
contains: "cursor-pointer"
key_links:
- from: "ItemCard outer button"
to: "cursor-pointer class"
via: "linkTo !== null conditional class"
pattern: "cursor-pointer.*hover:border-gray-200"
---
<objective>
Audit and fix cursor-pointer coverage across interactive elements. The Tailwind utility cursor-pointer must be explicitly applied to all clickable elements that currently lack it.
Purpose: Resolve FIX-05 — the pointer cursor must appear on hover over every interactive element to meet basic UX expectations.
Output: Updated ItemCard, FabMenu, BottomTabBar with explicit cursor-pointer.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/35-bug-fixes/35-CONTEXT.md
@.planning/phases/35-bug-fixes/35-UI-SPEC.md
</context>
<interfaces>
<!-- Key contracts the executor needs. Extracted from codebase. -->
Current cursor state by component (from codebase audit):
ItemCard (src/client/components/ItemCard.tsx, line 76):
```tsx
// Current — missing cursor-pointer in the navigable case:
className={`relative w-full text-left bg-white rounded-xl border border-gray-100 transition-all overflow-hidden group ${
linkTo === null
? "cursor-default"
: "hover:border-gray-200 hover:shadow-sm" // ← cursor-pointer MISSING here
}`}
// Target — add cursor-pointer to the navigable case:
className={`relative w-full text-left bg-white rounded-xl border border-gray-100 transition-all overflow-hidden group ${
linkTo === null
? "cursor-default"
: "cursor-pointer hover:border-gray-200 hover:shadow-sm"
}`}
```
ItemCard action span buttons (lines 106, 138, 170): already have cursor-pointer — DO NOT CHANGE.
ClassificationBadge: already has cursor-pointer — DO NOT CHANGE.
CandidateCard action spans: already have cursor-pointer — DO NOT CHANGE.
FabMenu (src/client/components/FabMenu.tsx):
- Line 85: menu item `motion.button` className — `"flex items-center gap-3 bg-white shadow-lg rounded-full px-4 py-3 hover:bg-gray-50 transition-colors"` — missing cursor-pointer
- Line 108: main FAB `motion.button` className — `"fixed bottom-6 right-6 z-20 w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg hover:shadow-xl transition-colors flex items-center justify-center"` — missing cursor-pointer
BottomTabBar (src/client/components/BottomTabBar.tsx):
- Lines 68, 87, 97: `<button type="button">` wrappers — no explicit cursor-pointer class
- Link elements (lines 50, 60, 79): Links get pointer from browser default — add cursor-pointer explicitly for consistency
Already correct (no changes needed):
- StatusBadge: has cursor-pointer
- CategoryPicker: has cursor-pointer
- PublicSetupCard: has cursor-pointer
- CategoryFilterDropdown: has cursor-pointer
- CatalogSearchOverlay interactive items: has cursor-pointer where needed
- ImageUpload: has cursor-pointer
- ProfileSection avatar: has cursor-pointer
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Add cursor-pointer to ItemCard navigable case (FIX-05)</name>
<files>src/client/components/ItemCard.tsx</files>
<read_first>
- src/client/components/ItemCard.tsx (read the outer button element at line 73-77 to confirm the current conditional class string)
</read_first>
<action>
In src/client/components/ItemCard.tsx, update the outer `<button>` element's className conditional string (per D-11, D-12, UI-SPEC Cursor Contract).
Find the className on the outer button (line ~76):
```tsx
className={`relative w-full text-left bg-white rounded-xl border border-gray-100 transition-all overflow-hidden group ${linkTo === null ? "cursor-default" : "hover:border-gray-200 hover:shadow-sm"}`}
```
Change to:
```tsx
className={`relative w-full text-left bg-white rounded-xl border border-gray-100 transition-all overflow-hidden group ${linkTo === null ? "cursor-default" : "cursor-pointer hover:border-gray-200 hover:shadow-sm"}`}
```
The only change is adding `cursor-pointer ` before `hover:border-gray-200` in the non-null branch.
Do NOT change:
- The `cursor-default` branch (correct behavior when `linkTo === null`)
- Any action span buttons on the card (lines 106, 138, 170 — already have cursor-pointer)
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -n "cursor-pointer hover:border-gray-200" src/client/components/ItemCard.tsx</automated>
</verify>
<acceptance_criteria>
- `grep -n "cursor-pointer hover:border-gray-200" src/client/components/ItemCard.tsx` returns exactly one match on the outer button className
- `grep -n "cursor-default" src/client/components/ItemCard.tsx` still returns one match (the linkTo === null branch is preserved)
- `bun run lint` passes with no errors
</acceptance_criteria>
<done>ItemCard outer button shows cursor-pointer when linkTo is not null, and cursor-default when linkTo === null. Both branches are correctly covered.</done>
</task>
<task type="auto">
<name>Task 2: Add cursor-pointer to FabMenu and BottomTabBar buttons (FIX-05)</name>
<files>
src/client/components/FabMenu.tsx,
src/client/components/BottomTabBar.tsx
</files>
<read_first>
- src/client/components/FabMenu.tsx (read fully — locate motion.button at lines 82-99 and 106-114)
- src/client/components/BottomTabBar.tsx (read fully — locate button elements at lines 68, 87, 97)
</read_first>
<action>
**FabMenu changes** (per D-12, UI-SPEC Cursor Contract §FAB menu items):
1. Menu item buttons (motion.button, currently line ~85) — add `cursor-pointer` to className:
Current: `"flex items-center gap-3 bg-white shadow-lg rounded-full px-4 py-3 hover:bg-gray-50 transition-colors"`
Target: `"flex items-center gap-3 bg-white shadow-lg rounded-full px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer"`
2. Main FAB button (motion.button, currently line ~108) — add `cursor-pointer` to className:
Current: `"fixed bottom-6 right-6 z-20 w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg hover:shadow-xl transition-colors flex items-center justify-center"`
Target: `"fixed bottom-6 right-6 z-20 w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg hover:shadow-xl transition-colors flex items-center justify-center cursor-pointer"`
**BottomTabBar changes** (per D-12, UI-SPEC Cursor Contract §All role="button" elements):
For all three `<button type="button">` elements (anonymous user collection tab at line ~68, anonymous setups tab at ~87, search tab at ~97), add `cursor-pointer` to each button element:
Line ~68:
```tsx
<button type="button" onClick={openAuthPrompt} className="cursor-pointer">
```
Line ~87:
```tsx
<button type="button" onClick={openAuthPrompt} className="cursor-pointer">
```
Line ~97:
```tsx
<button type="button" onClick={() => openCatalogSearch("collection")} className="cursor-pointer">
```
</action>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "cursor-pointer" src/client/components/FabMenu.tsx src/client/components/BottomTabBar.tsx</automated>
</verify>
<acceptance_criteria>
- `grep -c "cursor-pointer" src/client/components/FabMenu.tsx` outputs `2` (menu items button + main FAB button)
- `grep -c "cursor-pointer" src/client/components/BottomTabBar.tsx` outputs `3` (one per anonymous button)
- `bun run lint` passes with no errors across both files
</acceptance_criteria>
<done>FabMenu menu item buttons and main FAB button have explicit cursor-pointer. BottomTabBar's three button elements each have cursor-pointer. All known interactive elements now have correct cursor behavior.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| none | Pure CSS/class changes — no trust boundary implications |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-35-04 | none | cursor-pointer audit | accept | CSS-only change. No logic, data flow, or auth boundary touched. No threat surface. |
</threat_model>
<verification>
After both tasks complete:
1. Open collection overview — hover over an item card (with linkTo set): cursor must be pointer
2. Hover over a card in a setup (linkTo === null): cursor must be default (not pointer) — preserved
3. Open FAB menu — hover over menu items and FAB button: cursor must be pointer
4. On mobile viewport (or DevTools mobile mode), hover/tap BottomTabBar anonymous tabs: buttons must show pointer
Run: `bun run lint` — zero errors
Run: `bun test` — all existing tests pass
</verification>
<success_criteria>
- ItemCard outer button has cursor-pointer in the non-null linkTo branch, cursor-default in the null branch
- FabMenu has cursor-pointer on both motion.button elements (menu items + FAB)
- BottomTabBar has cursor-pointer on all three button elements
- No previously-correct cursor-pointer usage is removed
- bun run lint passes with zero errors
</success_criteria>
<output>
After completion, create `.planning/phases/35-bug-fixes/35-03-SUMMARY.md`
</output>