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:
@@ -231,7 +231,12 @@ Plans:
|
|||||||
3. Catalog and collection images appear without noticeable delay across all image-bearing pages
|
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
|
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
|
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
|
**UI hint**: yes
|
||||||
|
|
||||||
@@ -313,7 +318,7 @@ Plans:
|
|||||||
| 32. Setup Sharing System | v2.3 | 4/4 | Complete | 2026-04-15 |
|
| 32. Setup Sharing System | v2.3 | 4/4 | Complete | 2026-04-15 |
|
||||||
| 33. Currency System | v2.3 | 6/6 | Complete | 2026-04-13 |
|
| 33. Currency System | v2.3 | 6/6 | Complete | 2026-04-13 |
|
||||||
| 34. i18n Foundation | v2.3 | 8/8 | Complete | 2026-04-18 |
|
| 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 | - |
|
| 36. Admin Role & Panel Foundation | v2.4 | 0/TBD | Not started | - |
|
||||||
| 37. Admin — Global Item Management | 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 | - |
|
| 38. Admin — Tag Management | v2.4 | 0/TBD | Not started | - |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ gsd_state_version: 1.0
|
|||||||
milestone: v2.4
|
milestone: v2.4
|
||||||
milestone_name: Admin Foundation
|
milestone_name: Admin Foundation
|
||||||
status: in_progress
|
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_updated: "2026-04-19T00:00:00.000Z"
|
||||||
last_activity: 2026-04-19
|
last_activity: 2026-04-19
|
||||||
progress:
|
progress:
|
||||||
@@ -91,5 +91,5 @@ Items carried forward from v2.3:
|
|||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-19
|
Last session: 2026-04-19
|
||||||
Stopped at: Phase 35 context gathered — ready to plan
|
Stopped at: Phase 35 UI-SPEC approved — ready to plan
|
||||||
Resume file: .planning/phases/35-bug-fixes/35-CONTEXT.md
|
Resume file: .planning/phases/35-bug-fixes/35-UI-SPEC.md
|
||||||
|
|||||||
324
.planning/phases/35-bug-fixes/35-01-PLAN.md
Normal file
324
.planning/phases/35-bug-fixes/35-01-PLAN.md
Normal 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>
|
||||||
373
.planning/phases/35-bug-fixes/35-02-PLAN.md
Normal file
373
.planning/phases/35-bug-fixes/35-02-PLAN.md
Normal 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>
|
||||||
224
.planning/phases/35-bug-fixes/35-03-PLAN.md
Normal file
224
.planning/phases/35-bug-fixes/35-03-PLAN.md
Normal 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>
|
||||||
Reference in New Issue
Block a user