docs(29): research, validation, and 4 plans for image presentation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,10 @@ milestone: v2.2
|
|||||||
milestone_name: User Experience Polish
|
milestone_name: User Experience Polish
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: Phase 29 context gathered
|
stopped_at: Phase 29 context gathered
|
||||||
last_updated: "2026-04-12T17:42:16.111Z"
|
last_updated: "2026-04-12T17:51:24.659Z"
|
||||||
last_activity: 2026-04-12
|
last_activity: 2026-04-12 -- Phase 29 planning complete
|
||||||
progress:
|
progress:
|
||||||
total_phases: 34
|
total_phases: 35
|
||||||
completed_phases: 21
|
completed_phases: 21
|
||||||
total_plans: 58
|
total_plans: 58
|
||||||
completed_plans: 56
|
completed_plans: 56
|
||||||
@@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-04-09)
|
|||||||
|
|
||||||
Phase: 999.1
|
Phase: 999.1
|
||||||
Plan: Not started
|
Plan: Not started
|
||||||
Status: Executing Phase 28
|
Status: Ready to execute
|
||||||
Last activity: 2026-04-12
|
Last activity: 2026-04-12 -- Phase 29 planning complete
|
||||||
|
|
||||||
Progress: [░░░░░░░░░░] 0%
|
Progress: [░░░░░░░░░░] 0%
|
||||||
|
|
||||||
|
|||||||
281
.planning/phases/29-image-presentation/29-PLAN-01.md
Normal file
281
.planning/phases/29-image-presentation/29-PLAN-01.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
---
|
||||||
|
phase: 29
|
||||||
|
plan: 01
|
||||||
|
type: backend
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/db/schema.ts
|
||||||
|
- src/shared/schemas.ts
|
||||||
|
- src/shared/types.ts
|
||||||
|
- src/server/routes/images.ts
|
||||||
|
- src/server/services/image.service.ts
|
||||||
|
- src/server/services/storage.service.ts
|
||||||
|
- src/server/services/item.service.ts
|
||||||
|
- src/server/routes/items.ts
|
||||||
|
- src/server/routes/threads.ts
|
||||||
|
- src/server/routes/global-items.ts
|
||||||
|
- package.json
|
||||||
|
autonomous: true
|
||||||
|
requirements: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add dominant color extraction on image upload and extend the database schema with dominantColor and crop fields across items, globalItems, and threadCandidates tables. Install Sharp for server-side image processing. Update API schemas and services to accept/return the new fields.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
### Task 1: Install Sharp dependency
|
||||||
|
<task type="command">
|
||||||
|
<action>
|
||||||
|
Run `bun add sharp` and `bun add -d @types/sharp` to install the Sharp image processing library and its type definitions.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep '"sharp"' package.json && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- package.json contains `"sharp"` in dependencies
|
||||||
|
- @types/sharp in devDependencies
|
||||||
|
- `bun install` completes without errors
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 2: Add schema fields to database
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Add four new fields to THREE tables in `src/db/schema.ts`:
|
||||||
|
|
||||||
|
**items table** — add after `brand: text("brand")`:
|
||||||
|
```ts
|
||||||
|
dominantColor: text("dominant_color"),
|
||||||
|
cropZoom: doublePrecision("crop_zoom"),
|
||||||
|
cropX: doublePrecision("crop_x"),
|
||||||
|
cropY: doublePrecision("crop_y"),
|
||||||
|
```
|
||||||
|
|
||||||
|
**globalItems table** — add after `imageSourceUrl: text("image_source_url")`:
|
||||||
|
```ts
|
||||||
|
dominantColor: text("dominant_color"),
|
||||||
|
cropZoom: doublePrecision("crop_zoom"),
|
||||||
|
cropX: doublePrecision("crop_x"),
|
||||||
|
cropY: doublePrecision("crop_y"),
|
||||||
|
```
|
||||||
|
|
||||||
|
**threadCandidates table** — add after `imageSourceUrl: text("image_source_url")`:
|
||||||
|
```ts
|
||||||
|
dominantColor: text("dominant_color"),
|
||||||
|
cropZoom: doublePrecision("crop_zoom"),
|
||||||
|
cropX: doublePrecision("crop_x"),
|
||||||
|
cropY: doublePrecision("crop_y"),
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "dominant_color" src/db/schema.ts | grep -q "3" && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/db/schema.ts` contains `dominantColor: text("dominant_color")` in items, globalItems, and threadCandidates tables (3 occurrences)
|
||||||
|
- `src/db/schema.ts` contains `cropZoom: doublePrecision("crop_zoom")` in all 3 tables
|
||||||
|
- `src/db/schema.ts` contains `cropX: doublePrecision("crop_x")` in all 3 tables
|
||||||
|
- `src/db/schema.ts` contains `cropY: doublePrecision("crop_y")` in all 3 tables
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 3: [BLOCKING] Push schema changes to database
|
||||||
|
<task type="command">
|
||||||
|
<action>
|
||||||
|
Run `bun run db:generate` to generate the Drizzle migration, then `bun run db:push` to apply it to the database.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun run db:push 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Migration generated successfully
|
||||||
|
- `bun run db:push` completes without errors
|
||||||
|
- Database contains dominant_color, crop_zoom, crop_x, crop_y columns on items, global_items, and thread_candidates tables
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 4: Create dominant color extraction utility
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/server/services/storage.service.ts
|
||||||
|
- src/server/services/image.service.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create a new function `extractDominantColor` in `src/server/services/image.service.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the dominant color from an image buffer.
|
||||||
|
* Resizes to 1x1 pixel for a perceptually weighted average.
|
||||||
|
* Returns hex string like '#a3b2c1' or null on failure.
|
||||||
|
*/
|
||||||
|
export async function extractDominantColor(buffer: Buffer | ArrayBuffer): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { data } = await sharp(Buffer.from(buffer))
|
||||||
|
.resize(1, 1)
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
const r = data[0];
|
||||||
|
const g = data[1];
|
||||||
|
const b = data[2];
|
||||||
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the existing `fetchImageFromUrl` function. Add the import for sharp at the top.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "extractDominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/server/services/image.service.ts` exports `extractDominantColor` function
|
||||||
|
- Function accepts `Buffer | ArrayBuffer` and returns `Promise<string | null>`
|
||||||
|
- Uses `sharp(buffer).resize(1, 1).raw().toBuffer()` to extract color
|
||||||
|
- Returns hex string in format `#rrggbb`
|
||||||
|
- Returns null on error (try/catch)
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 5: Integrate dominant color extraction into upload endpoints
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/server/routes/images.ts
|
||||||
|
- src/server/services/image.service.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Update `src/server/routes/images.ts` to extract dominant color during upload:
|
||||||
|
|
||||||
|
**POST `/` (direct upload):**
|
||||||
|
1. After `const buffer = await file.arrayBuffer();`
|
||||||
|
2. Add: `const dominantColor = await extractDominantColor(buffer);`
|
||||||
|
3. Change response from `{ filename }` to `{ filename, dominantColor }`
|
||||||
|
|
||||||
|
**POST `/from-url`:**
|
||||||
|
1. In `src/server/services/image.service.ts`, update `fetchImageFromUrl` to also extract dominant color
|
||||||
|
2. After `await uploadImage(Buffer.from(buffer), filename, contentType);`
|
||||||
|
3. Add: `const dominantColor = await extractDominantColor(buffer);`
|
||||||
|
4. Change return from `{ filename, sourceUrl: url }` to `{ filename, sourceUrl: url, dominantColor }`
|
||||||
|
5. Update `FetchImageResult` interface to include `dominantColor: string | null`
|
||||||
|
|
||||||
|
Import `extractDominantColor` in images.ts from `../services/image.service`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "dominantColor" src/server/routes/images.ts && grep "dominantColor" src/server/services/image.service.ts && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `POST /api/images` response includes `dominantColor` field (string or null)
|
||||||
|
- `POST /api/images/from-url` response includes `dominantColor` field
|
||||||
|
- `FetchImageResult` interface has `dominantColor: string | null`
|
||||||
|
- Dominant color extraction happens before the response is sent
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 6: Update Zod schemas for new fields
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/shared/schemas.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Update `src/shared/schemas.ts`:
|
||||||
|
|
||||||
|
**createItemSchema** — add after `brand: z.string().optional()`:
|
||||||
|
```ts
|
||||||
|
dominantColor: z.string().nullable().optional(),
|
||||||
|
cropZoom: z.number().nullable().optional(),
|
||||||
|
cropX: z.number().nullable().optional(),
|
||||||
|
cropY: z.number().nullable().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
**createCandidateSchema** — add after `globalItemId: z.number().int().positive().optional()`:
|
||||||
|
```ts
|
||||||
|
dominantColor: z.string().nullable().optional(),
|
||||||
|
cropZoom: z.number().nullable().optional(),
|
||||||
|
cropX: z.number().nullable().optional(),
|
||||||
|
cropY: z.number().nullable().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
**upsertGlobalItemSchema** — add after `tags`:
|
||||||
|
```ts
|
||||||
|
dominantColor: z.string().nullable().optional(),
|
||||||
|
cropZoom: z.number().nullable().optional(),
|
||||||
|
cropX: z.number().nullable().optional(),
|
||||||
|
cropY: z.number().nullable().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
updateItemSchema and updateCandidateSchema already use `.partial()` so they inherit the new fields automatically.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "dominantColor" src/shared/schemas.ts | grep -q "3" && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `createItemSchema` contains `dominantColor`, `cropZoom`, `cropX`, `cropY` fields
|
||||||
|
- `createCandidateSchema` contains the same 4 fields
|
||||||
|
- `upsertGlobalItemSchema` contains the same 4 fields
|
||||||
|
- All use `z.number().nullable().optional()` for crop fields
|
||||||
|
- All use `z.string().nullable().optional()` for dominantColor
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 7: Update storage service to return dominant color with image URLs
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/server/services/storage.service.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
The `withImageUrl` and `withImageUrls` functions in `src/server/services/storage.service.ts` currently enrich records with `imageUrl`. They already pass through all record fields via spread operator, so `dominantColor`, `cropZoom`, `cropX`, `cropY` will automatically be included in the response when they exist on the record.
|
||||||
|
|
||||||
|
No changes needed to storage.service.ts — the spread operator `{ ...record, imageUrl }` already forwards all fields.
|
||||||
|
|
||||||
|
Verify this by confirming the return type `T & { imageUrl: string | null }` preserves all properties of T.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "...record" src/server/services/storage.service.ts && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `withImageUrl` function uses spread operator `{ ...record, imageUrl }` which preserves dominantColor and crop fields from the source record
|
||||||
|
- No changes needed — verify existing behavior is sufficient
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes
|
||||||
|
2. `bun test` passes (existing tests not broken)
|
||||||
|
3. Database has new columns: `SELECT column_name FROM information_schema.columns WHERE table_name = 'items' AND column_name LIKE '%crop%' OR column_name = 'dominant_color';`
|
||||||
|
4. Upload endpoint returns dominantColor in response body
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Sharp installed and importable
|
||||||
|
- 3 tables have dominantColor + crop fields
|
||||||
|
- Image upload extracts and returns dominant color
|
||||||
|
- Zod schemas accept new fields
|
||||||
|
- All existing tests pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
| Threat | Severity | Mitigation |
|
||||||
|
|--------|----------|------------|
|
||||||
|
| Sharp buffer overflow via malformed image | Medium | Sharp handles this internally with libvips bounds checking; wrapped in try/catch returning null |
|
||||||
|
| DoS via large image processing | Low | Existing 5MB file size limit applies before Sharp processing |
|
||||||
|
| Stored XSS via dominantColor field | Low | Value is a hex color string extracted server-side, not user input; rendered as CSS backgroundColor |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<must_haves>
|
||||||
|
- [ ] Sharp dependency installed
|
||||||
|
- [ ] dominantColor field on items, globalItems, threadCandidates
|
||||||
|
- [ ] Crop fields (cropZoom, cropX, cropY) on all 3 tables
|
||||||
|
- [ ] Upload endpoints return dominantColor
|
||||||
|
- [ ] Schema pushed to database
|
||||||
|
</must_haves>
|
||||||
566
.planning/phases/29-image-presentation/29-PLAN-02.md
Normal file
566
.planning/phases/29-image-presentation/29-PLAN-02.md
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
---
|
||||||
|
phase: 29
|
||||||
|
plan: 02
|
||||||
|
type: frontend
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
- src/client/components/ItemCard.tsx
|
||||||
|
- src/client/components/GlobalItemCard.tsx
|
||||||
|
- src/client/components/CandidateCard.tsx
|
||||||
|
- src/client/components/CandidateListItem.tsx
|
||||||
|
- src/client/components/ImageUpload.tsx
|
||||||
|
- src/client/components/ComparisonTable.tsx
|
||||||
|
- src/client/components/CatalogSearchOverlay.tsx
|
||||||
|
- src/client/routes/items/$itemId.tsx
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
- src/client/routes/global-items/index.tsx
|
||||||
|
- src/client/routes/threads/$threadId/candidates/$candidateId.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the GearImage shared component that renders images with object-contain + dominant color background fill, and replace all inline image elements across 12 surfaces with GearImage. This delivers the core visual change: fit-within framing instead of hard crops.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
### Task 1: Create GearImage component
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/ItemCard.tsx (current image rendering pattern)
|
||||||
|
- src/client/components/GlobalItemCard.tsx (current image rendering pattern)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create `src/client/components/GearImage.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface GearImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
dominantColor?: string | null;
|
||||||
|
cropZoom?: number | null;
|
||||||
|
cropX?: number | null;
|
||||||
|
cropY?: number | null;
|
||||||
|
aspectRatio?: string;
|
||||||
|
className?: string;
|
||||||
|
cover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GearImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
dominantColor,
|
||||||
|
cropZoom,
|
||||||
|
cropX,
|
||||||
|
cropY,
|
||||||
|
aspectRatio = "4/3",
|
||||||
|
className = "",
|
||||||
|
cover = false,
|
||||||
|
}: GearImageProps) {
|
||||||
|
const hasCrop = cropZoom != null && cropZoom > 1;
|
||||||
|
const bgColor = dominantColor || "#f3f4f6";
|
||||||
|
|
||||||
|
if (cover) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`w-full h-full object-cover ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCrop) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`aspect-[${aspectRatio}] overflow-hidden ${className}`}
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`aspect-[${aspectRatio}] overflow-hidden ${className}`}
|
||||||
|
style={{ backgroundColor: bgColor }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `aspectRatio` in className uses Tailwind arbitrary values. Since the aspect ratio container is typically provided by the parent, the GearImage component renders as a child within the existing aspect-ratio div. Adjust the component to NOT wrap with its own aspect-ratio div when used inside cards (the parent already has `aspect-[4/3]`). Instead, the component should just render the image with the correct object-fit and background color:
|
||||||
|
|
||||||
|
Simplified version (preferred — parent controls aspect ratio):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function GearImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
dominantColor,
|
||||||
|
cropZoom,
|
||||||
|
cropX,
|
||||||
|
cropY,
|
||||||
|
className = "",
|
||||||
|
cover = false,
|
||||||
|
}: Omit<GearImageProps, 'aspectRatio'>) {
|
||||||
|
const hasCrop = cropZoom != null && cropZoom > 1;
|
||||||
|
const bgColor = dominantColor || "#f3f4f6";
|
||||||
|
|
||||||
|
if (cover) {
|
||||||
|
return (
|
||||||
|
<img src={src} alt={alt} className={`w-full h-full object-cover ${className}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCrop) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={`w-full h-full object-cover ${className}`}
|
||||||
|
style={{
|
||||||
|
transform: `scale(${cropZoom}) translate(${cropX ?? 0}%, ${cropY ?? 0}%)`,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img src={src} alt={alt} className={`w-full h-full object-contain ${className}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The **parent div** provides aspect ratio, overflow-hidden, and the `style={{ backgroundColor: dominantColor }}`. This matches the existing pattern where the parent `<div className="aspect-[4/3] bg-gray-50">` wraps the image.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/client/components/GearImage.tsx && grep "object-contain" src/client/components/GearImage.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/client/components/GearImage.tsx` exists
|
||||||
|
- Exports `GearImage` component
|
||||||
|
- Default rendering uses `object-contain` (not `object-cover`)
|
||||||
|
- When `cover` prop is true, uses `object-cover`
|
||||||
|
- When crop values exist and cropZoom > 1, uses CSS transform with scale and translate
|
||||||
|
- Accepts `dominantColor`, `cropZoom`, `cropX`, `cropY` props
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 2: Update ItemCard to use GearImage
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/ItemCard.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/components/ItemCard.tsx`:
|
||||||
|
|
||||||
|
1. Add `dominantColor`, `cropZoom`, `cropX`, `cropY` to `ItemCardProps` interface (all `number | null` or `string | null`)
|
||||||
|
2. Import `GearImage` from `./GearImage`
|
||||||
|
3. Replace the image div (around line 164-179):
|
||||||
|
|
||||||
|
Current:
|
||||||
|
```tsx
|
||||||
|
<div className="aspect-[4/3] bg-gray-50">
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt={name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<LucideIcon name={categoryIcon} size={36} className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
<div
|
||||||
|
className="aspect-[4/3] overflow-hidden"
|
||||||
|
style={{ backgroundColor: imageUrl ? (dominantColor || "#f3f4f6") : 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>
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/ItemCard.tsx && ! grep "object-cover" src/client/components/ItemCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- ItemCard imports and uses GearImage component
|
||||||
|
- No `object-cover` remains in ItemCard.tsx
|
||||||
|
- `dominantColor` prop is passed to GearImage
|
||||||
|
- Parent div uses inline `backgroundColor` style from dominantColor
|
||||||
|
- Empty state (no image) still shows category icon on gray-50 background
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 3: Update GlobalItemCard to use GearImage
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/GlobalItemCard.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/components/GlobalItemCard.tsx`:
|
||||||
|
|
||||||
|
1. Add `dominantColor?: string | null`, `cropZoom?: number | null`, `cropX?: number | null`, `cropY?: number | null` to `GlobalItemCardProps`
|
||||||
|
2. Import `GearImage` from `./GearImage`
|
||||||
|
3. Replace the image rendering (around line 31-54):
|
||||||
|
|
||||||
|
Current:
|
||||||
|
```tsx
|
||||||
|
<div className="aspect-[4/3] bg-gray-50">
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt={`${brand} ${model}`} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
{/* SVG placeholder */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
<div
|
||||||
|
className="aspect-[4/3] overflow-hidden"
|
||||||
|
style={{ backgroundColor: imageUrl ? (dominantColor || "#f3f4f6") : undefined }}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<GearImage
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`${brand} ${model}`}
|
||||||
|
dominantColor={dominantColor}
|
||||||
|
cropZoom={cropZoom}
|
||||||
|
cropX={cropX}
|
||||||
|
cropY={cropY}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-50 flex flex-col items-center justify-center">
|
||||||
|
{/* Keep existing SVG placeholder */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/GlobalItemCard.tsx && ! grep "object-cover" src/client/components/GlobalItemCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- GlobalItemCard imports and uses GearImage
|
||||||
|
- No `object-cover` in GlobalItemCard.tsx
|
||||||
|
- Props include dominantColor, cropZoom, cropX, cropY
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 4: Update CandidateCard to use GearImage
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/CandidateCard.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Same pattern as Task 2/3:
|
||||||
|
1. Add `dominantColor`, `cropZoom`, `cropX`, `cropY` to props interface
|
||||||
|
2. Import `GearImage`
|
||||||
|
3. Replace `<img className="w-full h-full object-cover">` with `<GearImage>` inside the existing `aspect-[4/3]` container
|
||||||
|
4. Update parent div to use inline `backgroundColor` style
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/CandidateCard.tsx && ! grep "object-cover" src/client/components/CandidateCard.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- CandidateCard uses GearImage component
|
||||||
|
- No `object-cover` remaining
|
||||||
|
- Dominant color props threaded through
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 5: Update CandidateListItem to use GearImage
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/CandidateListItem.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Same pattern:
|
||||||
|
1. Add image presentation props to interface
|
||||||
|
2. Import GearImage
|
||||||
|
3. Replace `object-cover` image with GearImage
|
||||||
|
4. Update parent container background color
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/CandidateListItem.tsx && ! grep "object-cover" src/client/components/CandidateListItem.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- CandidateListItem uses GearImage
|
||||||
|
- No `object-cover` remaining
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 6: Update ComparisonTable to use GearImage
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/ComparisonTable.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Same pattern: replace inline `<img className="w-full h-full object-cover">` with `<GearImage>`. Thread dominantColor and crop props from the data source.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/ComparisonTable.tsx && ! grep "object-cover" src/client/components/ComparisonTable.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- ComparisonTable uses GearImage
|
||||||
|
- No `object-cover` remaining
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 7: Update CatalogSearchOverlay to use GearImage
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/CatalogSearchOverlay.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
CatalogSearchOverlay has 2 `object-cover` instances (search results and selected item preview). Replace both with GearImage. Thread dominantColor from global item data.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/CatalogSearchOverlay.tsx && ! grep "object-cover" src/client/components/CatalogSearchOverlay.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Both image instances in CatalogSearchOverlay use GearImage
|
||||||
|
- No `object-cover` remaining in the file
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 8: Update ImageUpload preview to use GearImage
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/ImageUpload.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/components/ImageUpload.tsx`:
|
||||||
|
|
||||||
|
1. Add `dominantColor?: string | null` to `ImageUploadProps`
|
||||||
|
2. Import GearImage
|
||||||
|
3. Replace the preview image (line 76-79):
|
||||||
|
|
||||||
|
Current:
|
||||||
|
```tsx
|
||||||
|
<img src={displayUrl} alt="Item" className="w-full h-full object-cover" />
|
||||||
|
```
|
||||||
|
|
||||||
|
New:
|
||||||
|
```tsx
|
||||||
|
<GearImage src={displayUrl} alt="Item" dominantColor={dominantColor} />
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Update the parent container to use dominant color background:
|
||||||
|
```tsx
|
||||||
|
style={{ backgroundColor: displayUrl ? (dominantColor || "#f3f4f6") : undefined }}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/ImageUpload.tsx && ! grep "object-cover" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- ImageUpload uses GearImage for preview
|
||||||
|
- No `object-cover` remaining
|
||||||
|
- Accepts dominantColor prop
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 9: Update item detail page
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/items/$itemId.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/routes/items/$itemId.tsx`:
|
||||||
|
|
||||||
|
1. Import GearImage
|
||||||
|
2. Replace the `object-cover` image (around line 245-250) with GearImage
|
||||||
|
3. Update the parent `aspect-[4/3]` div to use dominant color background via inline style
|
||||||
|
4. Thread dominantColor, cropZoom, cropX, cropY from the item data
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/routes/items/\$itemId.tsx && ! grep "object-cover" src/client/routes/items/\$itemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Item detail page uses GearImage
|
||||||
|
- No `object-cover` in the file
|
||||||
|
- Dominant color and crop fields used from item data
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 10: Update global item detail page
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/routes/global-items/$globalItemId.tsx`:
|
||||||
|
|
||||||
|
1. Import GearImage
|
||||||
|
2. Replace the `object-cover` image (around line 65-70) with GearImage
|
||||||
|
3. This page uses `aspect-[16/9]` — keep that ratio on the parent container
|
||||||
|
4. Update background color to use dominant color
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/routes/global-items/\$globalItemId.tsx && ! grep "object-cover" src/client/routes/global-items/\$globalItemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Global item detail uses GearImage
|
||||||
|
- No `object-cover` remaining
|
||||||
|
- Aspect ratio 16/9 preserved
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 11: Update global items index page
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/global-items/index.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/routes/global-items/index.tsx`:
|
||||||
|
|
||||||
|
1. Import GearImage
|
||||||
|
2. Replace `object-cover` image with GearImage
|
||||||
|
3. Thread dominantColor from global item data
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/routes/global-items/index.tsx && ! grep "object-cover" src/client/routes/global-items/index.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Global items index uses GearImage
|
||||||
|
- No `object-cover` remaining
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 12: Update candidate detail page
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/threads/$threadId/candidates/$candidateId.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/routes/threads/$threadId/candidates/$candidateId.tsx`:
|
||||||
|
|
||||||
|
1. Import GearImage
|
||||||
|
2. Replace `object-cover` image with GearImage
|
||||||
|
3. This page uses `aspect-[16/9]` — keep that ratio
|
||||||
|
4. Thread dominantColor and crop fields from candidate data
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && ! grep "object-cover" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Candidate detail uses GearImage
|
||||||
|
- No `object-cover` remaining
|
||||||
|
- Aspect ratio 16/9 preserved
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 13: Update LinkToGlobalItem with cover mode
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/LinkToGlobalItem.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/components/LinkToGlobalItem.tsx`:
|
||||||
|
|
||||||
|
The 32x32px thumbnail is too small for letterbox treatment. Use GearImage with `cover={true}` prop to keep `object-cover` for this tiny thumbnail:
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```tsx
|
||||||
|
<img className="w-8 h-8 rounded object-cover shrink-0" ... />
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
```tsx
|
||||||
|
<GearImage src={...} alt={...} cover className="w-8 h-8 rounded shrink-0" />
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "GearImage" src/client/components/LinkToGlobalItem.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- LinkToGlobalItem uses GearImage with `cover` prop
|
||||||
|
- Small thumbnail renders with object-cover (intentional exception for tiny images)
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes
|
||||||
|
2. `bun run build` passes (TypeScript compilation)
|
||||||
|
3. `grep -r "object-cover" src/client/ --include="*.tsx"` returns ONLY:
|
||||||
|
- `GearImage.tsx` (internal cover mode)
|
||||||
|
- `ProfileSection.tsx` (user avatar — out of scope)
|
||||||
|
- `routes/users/$userId.tsx` (user avatar — out of scope)
|
||||||
|
4. All 12 surfaces render images with `object-contain` by default
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- GearImage component exists and is used by all 12 gear image surfaces
|
||||||
|
- Default image display uses object-contain (fit-within)
|
||||||
|
- Dominant color background fills letterbox/pillarbox space
|
||||||
|
- Cropped images display with CSS transform
|
||||||
|
- LinkToGlobalItem uses cover mode for 32px thumbnails
|
||||||
|
- No regression in empty state (placeholder icons still show)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
| Threat | Severity | Mitigation |
|
||||||
|
|--------|----------|------------|
|
||||||
|
| XSS via dominantColor in style attribute | Low | dominantColor is server-extracted hex string, not user input; React escapes style values |
|
||||||
|
| Layout shift from object-contain | Low | Container maintains fixed aspect ratio; image loads within same bounds |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<must_haves>
|
||||||
|
- [ ] GearImage component created at src/client/components/GearImage.tsx
|
||||||
|
- [ ] All 12 image surfaces use GearImage (except ProfileSection/user avatar)
|
||||||
|
- [ ] Default rendering uses object-contain, not object-cover
|
||||||
|
- [ ] Dominant color background on image containers
|
||||||
|
- [ ] LinkToGlobalItem uses cover mode for tiny thumbnails
|
||||||
|
</must_haves>
|
||||||
361
.planning/phases/29-image-presentation/29-PLAN-03.md
Normal file
361
.planning/phases/29-image-presentation/29-PLAN-03.md
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
---
|
||||||
|
phase: 29
|
||||||
|
plan: 03
|
||||||
|
type: fullstack
|
||||||
|
wave: 2
|
||||||
|
depends_on: [01, 02]
|
||||||
|
files_modified:
|
||||||
|
- src/client/components/ImageCropEditor.tsx
|
||||||
|
- src/client/components/ImageUpload.tsx
|
||||||
|
- src/client/routes/items/$itemId.tsx
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
- src/client/routes/threads/$threadId/candidates/$candidateId.tsx
|
||||||
|
- src/client/hooks/useItems.ts
|
||||||
|
- package.json
|
||||||
|
autonomous: true
|
||||||
|
requirements: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement the zoom+pan image framing editor using react-easy-crop. Users can adjust image framing during upload (ImageUpload) and from detail pages (item, global item, candidate). Crop settings (zoom, x, y) persist to the database via existing CRUD endpoints.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
### Task 1: Install react-easy-crop
|
||||||
|
<task type="command">
|
||||||
|
<action>
|
||||||
|
Run `bun add react-easy-crop` to install the crop editor library.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep '"react-easy-crop"' package.json && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- package.json contains `"react-easy-crop"` in dependencies
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 2: Create ImageCropEditor component
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/ImageUpload.tsx
|
||||||
|
- src/client/components/GearImage.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create `src/client/components/ImageCropEditor.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import Cropper from "react-easy-crop";
|
||||||
|
import type { Area, Point } from "react-easy-crop";
|
||||||
|
|
||||||
|
interface CropResult {
|
||||||
|
zoom: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageCropEditorProps {
|
||||||
|
imageUrl: string;
|
||||||
|
dominantColor?: string | null;
|
||||||
|
initialZoom?: number;
|
||||||
|
initialX?: number;
|
||||||
|
initialY?: number;
|
||||||
|
aspect?: number;
|
||||||
|
onSave: (result: CropResult) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageCropEditor({
|
||||||
|
imageUrl,
|
||||||
|
dominantColor,
|
||||||
|
initialZoom = 1,
|
||||||
|
initialX = 0,
|
||||||
|
initialY = 0,
|
||||||
|
aspect = 4 / 3,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: ImageCropEditorProps) {
|
||||||
|
const [crop, setCrop] = useState<Point>({ x: initialX, y: initialY });
|
||||||
|
const [zoom, setZoom] = useState(initialZoom);
|
||||||
|
|
||||||
|
const onCropComplete = useCallback((_croppedArea: Area, _croppedAreaPixels: Area) => {
|
||||||
|
// We use the crop/zoom state directly, not the callback values
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
onSave({
|
||||||
|
zoom,
|
||||||
|
x: crop.x,
|
||||||
|
y: crop.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Crop area */}
|
||||||
|
<div className="relative w-full" style={{ aspectRatio: `${aspect}` }}>
|
||||||
|
<Cropper
|
||||||
|
image={imageUrl}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={aspect}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={onCropComplete}
|
||||||
|
minZoom={1}
|
||||||
|
maxZoom={3}
|
||||||
|
style={{
|
||||||
|
containerStyle: {
|
||||||
|
backgroundColor: dominantColor || "#f3f4f6",
|
||||||
|
borderRadius: "0.75rem",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
objectFit="contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom slider */}
|
||||||
|
<div className="flex items-center gap-3 px-1">
|
||||||
|
<label htmlFor="crop-zoom" className="sr-only">Zoom</label>
|
||||||
|
<svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
<path d="M8 11h6" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
id="crop-zoom"
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1.5 bg-gray-200 rounded-full appearance-none cursor-pointer accent-gray-900"
|
||||||
|
/>
|
||||||
|
<svg className="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
<path d="M8 11h6M11 8v6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 text-sm font-semibold text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Save framing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The component:
|
||||||
|
- Uses react-easy-crop `Cropper` with `objectFit="contain"` so images fit within the frame
|
||||||
|
- Min zoom 1.0 (fit-within), max zoom 3.0
|
||||||
|
- Zoom slider between zoom-out and zoom-in icons
|
||||||
|
- "Cancel" (ghost) and "Save framing" (primary) buttons
|
||||||
|
- Returns `{ zoom, x, y }` on save
|
||||||
|
- Background color uses dominant color from the image
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/client/components/ImageCropEditor.tsx && grep "react-easy-crop" src/client/components/ImageCropEditor.tsx && grep "Save framing" src/client/components/ImageCropEditor.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `src/client/components/ImageCropEditor.tsx` exists
|
||||||
|
- Imports `Cropper` from `react-easy-crop`
|
||||||
|
- `objectFit="contain"` set on Cropper
|
||||||
|
- Min zoom 1, max zoom 3
|
||||||
|
- Zoom slider with range input
|
||||||
|
- "Cancel" button calls `onCancel`
|
||||||
|
- "Save framing" button calls `onSave` with `{ zoom, x, y }`
|
||||||
|
- Dominant color used as background
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 3: Add crop editor to ImageUpload
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/components/ImageUpload.tsx
|
||||||
|
- src/client/components/ImageCropEditor.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Update `src/client/components/ImageUpload.tsx`:
|
||||||
|
|
||||||
|
1. Add `onCropChange?: (crop: { zoom: number; x: number; y: number }) => void` to `ImageUploadProps`
|
||||||
|
2. Add `cropZoom?: number | null`, `cropX?: number | null`, `cropY?: number | null` to props
|
||||||
|
3. Add state: `const [showCropEditor, setShowCropEditor] = useState(false);`
|
||||||
|
4. After successful upload (`onChange(result.filename)`), set `setShowCropEditor(true)`
|
||||||
|
5. When crop editor is visible, replace the image preview area with the `ImageCropEditor` component
|
||||||
|
6. On save: call `onCropChange?.({ zoom, x, y })` and `setShowCropEditor(false)`
|
||||||
|
7. On cancel: `setShowCropEditor(false)`
|
||||||
|
8. Import `ImageCropEditor`
|
||||||
|
|
||||||
|
The crop editor appears inline in the same container where the preview image normally shows, replacing the static preview temporarily.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "ImageCropEditor" src/client/components/ImageUpload.tsx && grep "showCropEditor" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- ImageUpload imports and conditionally renders ImageCropEditor
|
||||||
|
- Editor appears after successful upload
|
||||||
|
- `onCropChange` callback fires with zoom/x/y values
|
||||||
|
- Editor can be dismissed via Cancel
|
||||||
|
- Save triggers crop change callback
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 4: Add "Adjust framing" to item detail page
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/items/$itemId.tsx
|
||||||
|
- src/client/components/ImageCropEditor.tsx
|
||||||
|
- src/client/hooks/useItems.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
In `src/client/routes/items/$itemId.tsx`:
|
||||||
|
|
||||||
|
1. Import `ImageCropEditor`
|
||||||
|
2. Add state: `const [editingCrop, setEditingCrop] = useState(false)`
|
||||||
|
3. Below the image area (after the `aspect-[4/3]` div), add an "Adjust framing" button:
|
||||||
|
```tsx
|
||||||
|
{item.imageUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingCrop(true)}
|
||||||
|
className="mt-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Adjust framing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
4. When `editingCrop` is true, replace the GearImage area with `ImageCropEditor`:
|
||||||
|
```tsx
|
||||||
|
{editingCrop ? (
|
||||||
|
<ImageCropEditor
|
||||||
|
imageUrl={item.imageUrl}
|
||||||
|
dominantColor={item.dominantColor}
|
||||||
|
initialZoom={item.cropZoom ?? 1}
|
||||||
|
initialX={item.cropX ?? 0}
|
||||||
|
initialY={item.cropY ?? 0}
|
||||||
|
aspect={4 / 3}
|
||||||
|
onSave={async (crop) => {
|
||||||
|
await updateItem({ id: item.id, cropZoom: crop.zoom, cropX: crop.x, cropY: crop.y });
|
||||||
|
setEditingCrop(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingCrop(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
/* existing GearImage rendering */
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
5. Use the existing `useUpdateItem` mutation to persist crop values
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "Adjust framing" src/client/routes/items/\$itemId.tsx && grep "ImageCropEditor" src/client/routes/items/\$itemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Item detail page shows "Adjust framing" button when image exists
|
||||||
|
- Clicking button shows ImageCropEditor inline
|
||||||
|
- Save persists crop values via updateItem mutation
|
||||||
|
- Cancel returns to normal image view
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 5: Add "Adjust framing" to global item detail page
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/global-items/$globalItemId.tsx
|
||||||
|
- src/client/components/ImageCropEditor.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Same pattern as Task 4 but for global item detail:
|
||||||
|
|
||||||
|
1. Import ImageCropEditor and useState
|
||||||
|
2. Add "Adjust framing" button below image
|
||||||
|
3. Toggle between GearImage and ImageCropEditor
|
||||||
|
4. Use `aspect={16/9}` to match the global item detail page aspect ratio
|
||||||
|
5. Use the appropriate mutation to persist crop values for global items
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "Adjust framing" src/client/routes/global-items/\$globalItemId.tsx && grep "ImageCropEditor" src/client/routes/global-items/\$globalItemId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Global item detail shows "Adjust framing" button
|
||||||
|
- ImageCropEditor uses aspect 16/9
|
||||||
|
- Crop values persist via mutation
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 6: Add "Adjust framing" to candidate detail page
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/client/routes/threads/$threadId/candidates/$candidateId.tsx
|
||||||
|
- src/client/components/ImageCropEditor.tsx
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Same pattern as Task 4/5 but for candidate detail:
|
||||||
|
|
||||||
|
1. Import ImageCropEditor and useState
|
||||||
|
2. Add "Adjust framing" button below image
|
||||||
|
3. Toggle between GearImage and ImageCropEditor
|
||||||
|
4. Use `aspect={16/9}` to match the candidate detail page aspect ratio
|
||||||
|
5. Use candidate update mutation to persist crop values
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "Adjust framing" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && grep "ImageCropEditor" src/client/routes/threads/\$threadId/candidates/\$candidateId.tsx && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Candidate detail shows "Adjust framing" button
|
||||||
|
- ImageCropEditor uses aspect 16/9
|
||||||
|
- Crop values persist via candidate update mutation
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes
|
||||||
|
2. `bun run build` passes
|
||||||
|
3. ImageCropEditor component renders react-easy-crop Cropper
|
||||||
|
4. "Adjust framing" button appears on all 3 detail pages when image exists
|
||||||
|
5. Crop values round-trip: set in editor → save → reload page → image renders with saved crop
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- react-easy-crop installed
|
||||||
|
- ImageCropEditor component created with zoom slider and save/cancel actions
|
||||||
|
- ImageUpload shows crop editor after upload
|
||||||
|
- Item, global item, and candidate detail pages have "Adjust framing" button
|
||||||
|
- Crop values persist through CRUD endpoints
|
||||||
|
- Crop values render correctly via GearImage component
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
| Threat | Severity | Mitigation |
|
||||||
|
|--------|----------|------------|
|
||||||
|
| Crop values outside expected range | Low | Server-side validation via Zod schema (nullable number) |
|
||||||
|
| react-easy-crop supply chain | Low | MIT license, 1M+ weekly downloads, actively maintained |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<must_haves>
|
||||||
|
- [ ] react-easy-crop installed
|
||||||
|
- [ ] ImageCropEditor component with zoom slider
|
||||||
|
- [ ] Crop editor in ImageUpload (post-upload)
|
||||||
|
- [ ] "Adjust framing" on item detail page
|
||||||
|
- [ ] "Adjust framing" on global item detail page
|
||||||
|
- [ ] "Adjust framing" on candidate detail page
|
||||||
|
- [ ] Crop values persist to database
|
||||||
|
</must_haves>
|
||||||
271
.planning/phases/29-image-presentation/29-PLAN-04.md
Normal file
271
.planning/phases/29-image-presentation/29-PLAN-04.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
---
|
||||||
|
phase: 29
|
||||||
|
plan: 04
|
||||||
|
type: backend
|
||||||
|
wave: 2
|
||||||
|
depends_on: [01]
|
||||||
|
files_modified:
|
||||||
|
- scripts/backfill-dominant-colors.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: []
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create a one-time backfill script that processes all existing images in the database to extract and store their dominant color. Handles items, globalItems, and threadCandidates with imageFilename, plus globalItems with external imageUrl.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
### Task 1: Create backfill script
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts
|
||||||
|
- src/server/services/storage.service.ts
|
||||||
|
- src/server/services/image.service.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Create `scripts/backfill-dominant-colors.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Backfill dominant colors for all existing images.
|
||||||
|
* Run with: bun run scripts/backfill-dominant-colors.ts
|
||||||
|
*
|
||||||
|
* Idempotent — skips records that already have dominantColor set.
|
||||||
|
* Processes in batches of 10 concurrent requests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
|
import { isNull } from "drizzle-orm";
|
||||||
|
import postgres from "postgres";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import * as schema from "../src/db/schema";
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
if (!DATABASE_URL) throw new Error("DATABASE_URL required");
|
||||||
|
|
||||||
|
const client = postgres(DATABASE_URL);
|
||||||
|
const db = drizzle(client, { schema });
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
region: process.env.S3_REGION ?? "us-east-1",
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
|
||||||
|
|
||||||
|
async function extractColor(buffer: Buffer): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { data } = await sharp(buffer).resize(1, 1).raw().toBuffer({ resolveWithObject: true });
|
||||||
|
return `#${data[0].toString(16).padStart(2, "0")}${data[1].toString(16).padStart(2, "0")}${data[2].toString(16).padStart(2, "0")}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromS3(filename: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: filename }));
|
||||||
|
const bytes = await response.Body?.transformToByteArray();
|
||||||
|
return bytes ? Buffer.from(bytes) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromUrl(url: string): Promise<Buffer | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processBatch<T extends { id: number }>(
|
||||||
|
items: T[],
|
||||||
|
getBuffer: (item: T) => Promise<Buffer | null>,
|
||||||
|
updateFn: (id: number, color: string) => Promise<void>,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
const BATCH_SIZE = 10;
|
||||||
|
let processed = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
||||||
|
const batch = items.slice(i, i + BATCH_SIZE);
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(async (item) => {
|
||||||
|
const buffer = await getBuffer(item);
|
||||||
|
if (!buffer) { failed++; return; }
|
||||||
|
const color = await extractColor(buffer);
|
||||||
|
if (!color) { failed++; return; }
|
||||||
|
await updateFn(item.id, color);
|
||||||
|
updated++;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
processed += batch.length;
|
||||||
|
console.log(` ${label}: ${processed}/${items.length} processed, ${updated} updated, ${failed} failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("=== Backfill Dominant Colors ===\n");
|
||||||
|
|
||||||
|
// Items with imageFilename but no dominantColor
|
||||||
|
const { eq, and, isNotNull } = await import("drizzle-orm");
|
||||||
|
|
||||||
|
const itemsToProcess = await db
|
||||||
|
.select({ id: schema.items.id, imageFilename: schema.items.imageFilename })
|
||||||
|
.from(schema.items)
|
||||||
|
.where(and(isNotNull(schema.items.imageFilename), isNull(schema.items.dominantColor)));
|
||||||
|
|
||||||
|
console.log(`Items: ${itemsToProcess.length} need processing`);
|
||||||
|
await processBatch(
|
||||||
|
itemsToProcess as { id: number; imageFilename: string }[],
|
||||||
|
(item) => fetchFromS3(item.imageFilename),
|
||||||
|
async (id, color) => {
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
await db.update(schema.items).set({ dominantColor: color }).where(eq(schema.items.id, id));
|
||||||
|
},
|
||||||
|
"Items",
|
||||||
|
);
|
||||||
|
|
||||||
|
// GlobalItems with imageSourceUrl (external URLs stored in S3)
|
||||||
|
const globalWithFile = await db
|
||||||
|
.select({ id: schema.globalItems.id, imageSourceUrl: schema.globalItems.imageSourceUrl })
|
||||||
|
.from(schema.globalItems)
|
||||||
|
.where(and(isNotNull(schema.globalItems.imageSourceUrl), isNull(schema.globalItems.dominantColor)));
|
||||||
|
|
||||||
|
console.log(`\nGlobal Items (with source URL): ${globalWithFile.length} need processing`);
|
||||||
|
await processBatch(
|
||||||
|
globalWithFile as { id: number; imageSourceUrl: string }[],
|
||||||
|
(item) => fetchFromUrl(item.imageSourceUrl),
|
||||||
|
async (id, color) => {
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
await db.update(schema.globalItems).set({ dominantColor: color }).where(eq(schema.globalItems.id, id));
|
||||||
|
},
|
||||||
|
"Global Items",
|
||||||
|
);
|
||||||
|
|
||||||
|
// GlobalItems with imageUrl (direct URLs)
|
||||||
|
const globalWithUrl = await db
|
||||||
|
.select({ id: schema.globalItems.id, imageUrl: schema.globalItems.imageUrl })
|
||||||
|
.from(schema.globalItems)
|
||||||
|
.where(and(isNotNull(schema.globalItems.imageUrl), isNull(schema.globalItems.dominantColor)));
|
||||||
|
|
||||||
|
console.log(`\nGlobal Items (with image URL): ${globalWithUrl.length} need processing`);
|
||||||
|
await processBatch(
|
||||||
|
globalWithUrl as { id: number; imageUrl: string }[],
|
||||||
|
(item) => fetchFromUrl(item.imageUrl),
|
||||||
|
async (id, color) => {
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
await db.update(schema.globalItems).set({ dominantColor: color }).where(eq(schema.globalItems.id, id));
|
||||||
|
},
|
||||||
|
"Global Items (URL)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Thread candidates
|
||||||
|
const candidatesToProcess = await db
|
||||||
|
.select({ id: schema.threadCandidates.id, imageFilename: schema.threadCandidates.imageFilename })
|
||||||
|
.from(schema.threadCandidates)
|
||||||
|
.where(and(isNotNull(schema.threadCandidates.imageFilename), isNull(schema.threadCandidates.dominantColor)));
|
||||||
|
|
||||||
|
console.log(`\nCandidates: ${candidatesToProcess.length} need processing`);
|
||||||
|
await processBatch(
|
||||||
|
candidatesToProcess as { id: number; imageFilename: string }[],
|
||||||
|
(item) => fetchFromS3(item.imageFilename),
|
||||||
|
async (id, color) => {
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
await db.update(schema.threadCandidates).set({ dominantColor: color }).where(eq(schema.threadCandidates.id, id));
|
||||||
|
},
|
||||||
|
"Candidates",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n=== Backfill Complete ===");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Backfill failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The exact import patterns for drizzle-orm may need adjustment based on the project's existing database connection setup. Check `src/db/` for the actual connection pattern used and replicate it in the script.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f scripts/backfill-dominant-colors.ts && grep "extractColor" scripts/backfill-dominant-colors.ts && grep "processBatch" scripts/backfill-dominant-colors.ts && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `scripts/backfill-dominant-colors.ts` exists
|
||||||
|
- Script queries items, globalItems, threadCandidates with images but no dominantColor
|
||||||
|
- Processes in batches of 10 concurrent
|
||||||
|
- Extracts dominant color via Sharp resize(1,1)
|
||||||
|
- Updates database records with extracted color
|
||||||
|
- Skips records that already have dominantColor (idempotent)
|
||||||
|
- Logs progress: `Items: 45/123 processed, 42 updated, 3 failed`
|
||||||
|
- Handles errors gracefully (skips failed images, logs them)
|
||||||
|
- Exits with 0 on success, 1 on fatal error
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
### Task 2: Add npm script for backfill
|
||||||
|
<task type="code">
|
||||||
|
<read_first>
|
||||||
|
- package.json
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Add to `scripts` section in `package.json`:
|
||||||
|
```json
|
||||||
|
"backfill:colors": "bun run scripts/backfill-dominant-colors.ts"
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep "backfill:colors" package.json && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- package.json contains `"backfill:colors"` script
|
||||||
|
- Script points to `scripts/backfill-dominant-colors.ts`
|
||||||
|
</acceptance_criteria>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `bun run lint` passes (script follows project conventions)
|
||||||
|
2. Script is syntactically valid: `bun run scripts/backfill-dominant-colors.ts --help` or `bun check scripts/backfill-dominant-colors.ts`
|
||||||
|
3. Script handles missing S3 credentials gracefully (error message, not crash)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Backfill script exists and processes all 3 tables
|
||||||
|
- Script is idempotent (safe to re-run)
|
||||||
|
- Batch processing limits concurrency to 10
|
||||||
|
- Progress logging shows processing status
|
||||||
|
- npm script shortcut available
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
| Threat | Severity | Mitigation |
|
||||||
|
|--------|----------|------------|
|
||||||
|
| S3 credential exposure in script | Low | Uses env vars from process.env, no hardcoded credentials |
|
||||||
|
| SSRF via globalItems imageUrl | Medium | Script only processes URLs already stored in the database (previously validated on ingestion); fetch has 10s timeout |
|
||||||
|
| Database overload from bulk updates | Low | Batch size of 10 limits concurrent DB writes |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<must_haves>
|
||||||
|
- [ ] Backfill script at scripts/backfill-dominant-colors.ts
|
||||||
|
- [ ] Processes items, globalItems, threadCandidates
|
||||||
|
- [ ] Idempotent (skips existing dominantColor)
|
||||||
|
- [ ] Batch processing with concurrency limit
|
||||||
|
- [ ] Progress logging
|
||||||
|
- [ ] npm script shortcut
|
||||||
|
</must_haves>
|
||||||
251
.planning/phases/29-image-presentation/29-RESEARCH.md
Normal file
251
.planning/phases/29-image-presentation/29-RESEARCH.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# Phase 29: Image Presentation - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-12
|
||||||
|
**Status:** Complete
|
||||||
|
|
||||||
|
## 1. Current Image Architecture
|
||||||
|
|
||||||
|
### Display Pattern
|
||||||
|
Every image surface uses the same CSS pattern:
|
||||||
|
```
|
||||||
|
<div class="aspect-[4/3] overflow-hidden">
|
||||||
|
<img class="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
15 total `object-cover` usages across the client (excluding the user avatar which uses `rounded-full`):
|
||||||
|
- **Cards:** ItemCard, GlobalItemCard, CandidateCard, CandidateListItem
|
||||||
|
- **Detail pages:** items/$itemId, global-items/$globalItemId, threads/$threadId/candidates/$candidateId
|
||||||
|
- **Overlays/search:** CatalogSearchOverlay (2 instances), ComparisonTable, LinkToGlobalItem
|
||||||
|
- **Upload preview:** ImageUpload
|
||||||
|
- **Global items index:** global-items/index.tsx
|
||||||
|
|
||||||
|
### Upload Pipeline
|
||||||
|
1. Client: `ImageUpload.tsx` → file validation → `apiUpload("/api/images", file)`
|
||||||
|
2. Server: `routes/images.ts` → generates UUID filename → `uploadImage(buffer, filename, contentType)` via `storage.service.ts`
|
||||||
|
3. Storage: S3-compatible (Garage/R2/MinIO) via `@aws-sdk/client-s3`
|
||||||
|
4. Retrieval: `getImageUrl()` returns presigned URLs; `withImageUrl()`/`withImageUrls()` enriches records
|
||||||
|
|
||||||
|
**No image processing exists today** — images are uploaded raw and served as-is. No Sharp, no node-vibrant, no server-side manipulation.
|
||||||
|
|
||||||
|
### Database Schema (PostgreSQL via Drizzle)
|
||||||
|
- `items.imageFilename` — text, nullable
|
||||||
|
- `items.imageSourceUrl` — text, nullable
|
||||||
|
- `globalItems.imageUrl` — text, nullable (external URL)
|
||||||
|
- `globalItems.imageSourceUrl` — text, nullable
|
||||||
|
- `threadCandidates.imageFilename` — text, nullable
|
||||||
|
- `threadCandidates.imageSourceUrl` — text, nullable
|
||||||
|
|
||||||
|
No fields for dominant color or crop positioning exist today.
|
||||||
|
|
||||||
|
## 2. Dominant Color Extraction
|
||||||
|
|
||||||
|
### Recommended: Sharp
|
||||||
|
- Already the de facto standard for Bun/Node image processing
|
||||||
|
- `sharp(buffer).stats()` returns per-channel mean/dominant values
|
||||||
|
- Can extract dominant color via `sharp(buffer).resize(1,1).raw().toBuffer()` (resize to 1x1 pixel = weighted average)
|
||||||
|
- Alternative: use `sharp(buffer).stats()` to get channel means, convert to hex
|
||||||
|
- Lightweight — no additional binary deps beyond what Sharp bundles
|
||||||
|
- Bun compatibility: Sharp works via Node-API
|
||||||
|
|
||||||
|
### Alternative: node-vibrant / color-thief-node
|
||||||
|
- Heavier, purpose-built for palette extraction
|
||||||
|
- Returns multiple palette swatches (Vibrant, Muted, DarkVibrant, etc.)
|
||||||
|
- Overkill for a single dominant color fill background
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
Use **Sharp** — single dependency handles both dominant color extraction and any future image processing needs. Resize to 1x1 pixel for a perceptually weighted average color.
|
||||||
|
|
||||||
|
### Implementation Notes
|
||||||
|
- Extract dominant color in the upload handler (both `/api/images` POST and `/api/images/from-url`)
|
||||||
|
- Return `dominantColor` in the response alongside `filename`
|
||||||
|
- For globalItems with external `imageUrl`: extract on first access or via backfill script (fetch + process)
|
||||||
|
|
||||||
|
## 3. Schema Changes Required
|
||||||
|
|
||||||
|
### New Fields
|
||||||
|
|
||||||
|
**items table:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE items ADD COLUMN dominant_color text;
|
||||||
|
ALTER TABLE items ADD COLUMN crop_zoom double precision;
|
||||||
|
ALTER TABLE items ADD COLUMN crop_x double precision;
|
||||||
|
ALTER TABLE items ADD COLUMN crop_y double precision;
|
||||||
|
```
|
||||||
|
|
||||||
|
**global_items table:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE global_items ADD COLUMN dominant_color text;
|
||||||
|
ALTER TABLE global_items ADD COLUMN crop_zoom double precision;
|
||||||
|
ALTER TABLE global_items ADD COLUMN crop_x double precision;
|
||||||
|
ALTER TABLE global_items ADD COLUMN crop_y double precision;
|
||||||
|
```
|
||||||
|
|
||||||
|
**thread_candidates table:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE thread_candidates ADD COLUMN dominant_color text;
|
||||||
|
ALTER TABLE thread_candidates ADD COLUMN crop_zoom double precision;
|
||||||
|
ALTER TABLE thread_candidates ADD COLUMN crop_x double precision;
|
||||||
|
ALTER TABLE thread_candidates ADD COLUMN crop_y double precision;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle Schema
|
||||||
|
Add to each table in `src/db/schema.ts`:
|
||||||
|
```ts
|
||||||
|
dominantColor: text("dominant_color"),
|
||||||
|
cropZoom: doublePrecision("crop_zoom"),
|
||||||
|
cropX: doublePrecision("crop_x"),
|
||||||
|
cropY: doublePrecision("crop_y"),
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply via: `bun run db:generate` then `bun run db:push`
|
||||||
|
|
||||||
|
## 4. Zoom+Pan Editor
|
||||||
|
|
||||||
|
### Library Options
|
||||||
|
|
||||||
|
| Library | Size | Touch | Maintained | Notes |
|
||||||
|
|---------|------|-------|------------|-------|
|
||||||
|
| react-easy-crop | ~15KB | Yes | Active | Battle-tested, used by many production apps. Returns crop area coordinates. MIT. |
|
||||||
|
| react-zoom-pan-pinch | ~25KB | Yes | Active | More general-purpose (maps, images, diagrams). Heavier. |
|
||||||
|
| Custom (pointer events + CSS transform) | 0KB | Manual | N/A | Full control but significant effort for touch/gesture handling |
|
||||||
|
|
||||||
|
### Recommendation: react-easy-crop
|
||||||
|
- Provides crop area with zoom, rotation, position
|
||||||
|
- Returns `croppedAreaPixels` and `croppedArea` (percentage-based)
|
||||||
|
- We need percentage-based output for CSS rendering (so images display correctly at any container size)
|
||||||
|
- Output: `{ x, y, zoom }` where x/y are percentage offsets
|
||||||
|
|
||||||
|
### Storage Model
|
||||||
|
Store 3 values per image:
|
||||||
|
- `cropZoom: number` — zoom level (1.0 = fit, >1 = zoomed in)
|
||||||
|
- `cropX: number` — horizontal offset as percentage (-50 to 50)
|
||||||
|
- `cropY: number` — vertical offset as percentage (-50 to 50)
|
||||||
|
|
||||||
|
When crop settings are `null`, default to `object-contain` with dominant color fill.
|
||||||
|
When crop settings are present, use CSS `transform: scale(cropZoom) translate(cropX%, cropY%)` with `overflow: hidden`.
|
||||||
|
|
||||||
|
## 5. CSS Rendering Strategy
|
||||||
|
|
||||||
|
### Default (no crop): Contain + Dominant Color
|
||||||
|
```tsx
|
||||||
|
<div
|
||||||
|
className="aspect-[4/3] overflow-hidden rounded-xl"
|
||||||
|
style={{ backgroundColor: dominantColor || '#f3f4f6' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Crop: Transform
|
||||||
|
```tsx
|
||||||
|
<div className="aspect-[4/3] overflow-hidden rounded-xl"
|
||||||
|
style={{ backgroundColor: dominantColor || '#f3f4f6' }}>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${cropZoom}) translate(${cropX}%, ${cropY}%)`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Component
|
||||||
|
Extract a reusable `<GearImage>` component that encapsulates this logic:
|
||||||
|
```tsx
|
||||||
|
interface GearImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
dominantColor?: string | null;
|
||||||
|
cropZoom?: number | null;
|
||||||
|
cropX?: number | null;
|
||||||
|
cropY?: number | null;
|
||||||
|
aspectRatio?: string; // default "4/3"
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All 15 image surfaces replace their inline `<img>` with `<GearImage>`.
|
||||||
|
|
||||||
|
## 6. Backfill Migration
|
||||||
|
|
||||||
|
### Strategy: One-time Script
|
||||||
|
- Script reads all images from S3 (items + globalItems + candidates with imageFilename)
|
||||||
|
- Downloads each, runs Sharp 1x1 resize, extracts dominant color
|
||||||
|
- Updates the DB record with `dominantColor`
|
||||||
|
- For globalItems with external `imageUrl`: fetch from URL, extract, update
|
||||||
|
- Run as: `bun run scripts/backfill-dominant-colors.ts`
|
||||||
|
|
||||||
|
### Considerations
|
||||||
|
- Rate limit S3 reads (batch of 10 concurrent)
|
||||||
|
- Skip records that already have `dominantColor` set (idempotent)
|
||||||
|
- Log progress: `Processing 45/123 images...`
|
||||||
|
- Handle errors gracefully (skip failed images, log them)
|
||||||
|
|
||||||
|
## 7. API Changes
|
||||||
|
|
||||||
|
### Upload Response Changes
|
||||||
|
Current: `{ filename }` or `{ filename, sourceUrl }`
|
||||||
|
New: `{ filename, dominantColor }` or `{ filename, sourceUrl, dominantColor }`
|
||||||
|
|
||||||
|
### Item/Candidate CRUD
|
||||||
|
- `POST /api/items` and `PUT /api/items/:id` — accept `dominantColor`, `cropZoom`, `cropX`, `cropY`
|
||||||
|
- Same for `POST /api/threads/:id/candidates` and `PUT /api/threads/:id/candidates/:id`
|
||||||
|
- GlobalItems: similar updates
|
||||||
|
|
||||||
|
### Zod Schema Updates
|
||||||
|
Add to item/candidate schemas in `src/shared/schemas.ts`:
|
||||||
|
```ts
|
||||||
|
dominantColor: z.string().nullable().optional(),
|
||||||
|
cropZoom: z.number().nullable().optional(),
|
||||||
|
cropX: z.number().nullable().optional(),
|
||||||
|
cropY: z.number().nullable().optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Scope of Component Changes
|
||||||
|
|
||||||
|
### Full List (15 surfaces)
|
||||||
|
1. `src/client/components/ItemCard.tsx`
|
||||||
|
2. `src/client/components/GlobalItemCard.tsx`
|
||||||
|
3. `src/client/components/CandidateCard.tsx`
|
||||||
|
4. `src/client/components/CandidateListItem.tsx`
|
||||||
|
5. `src/client/components/ImageUpload.tsx`
|
||||||
|
6. `src/client/components/ComparisonTable.tsx`
|
||||||
|
7. `src/client/components/LinkToGlobalItem.tsx`
|
||||||
|
8. `src/client/components/CatalogSearchOverlay.tsx` (2 instances)
|
||||||
|
9. `src/client/routes/items/$itemId.tsx`
|
||||||
|
10. `src/client/routes/global-items/$globalItemId.tsx`
|
||||||
|
11. `src/client/routes/global-items/index.tsx`
|
||||||
|
12. `src/client/routes/threads/$threadId/candidates/$candidateId.tsx`
|
||||||
|
|
||||||
|
### ProfileSection.tsx excluded
|
||||||
|
The `object-cover` in `ProfileSection.tsx` is for user avatars (circular), not gear images. Out of scope.
|
||||||
|
|
||||||
|
## 9. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Sharp installation issues on Bun | Build failure | Sharp has Bun-compatible prebuilt binaries; test early |
|
||||||
|
| Backfill takes long for large catalogs | Blocks deployment | Make it idempotent, run post-deploy as async script |
|
||||||
|
| Zoom+pan UX complexity | Scope creep | Use react-easy-crop as-is, minimal customization |
|
||||||
|
| Dominant color looks wrong for some images | Visual jank | Fallback to neutral gray when extraction fails |
|
||||||
|
| Performance: CSS transforms on many cards | Scroll jank | Transform is GPU-accelerated; no perf concern for static transforms |
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Testable Claims
|
||||||
|
1. All 15 image surfaces use `GearImage` component (grep for component name)
|
||||||
|
2. No remaining `object-cover` on gear images (grep, excluding avatar)
|
||||||
|
3. `dominantColor` field exists on items, globalItems, threadCandidates tables
|
||||||
|
4. Upload endpoints return `dominantColor` in response
|
||||||
|
5. Backfill script processes existing images without errors
|
||||||
|
6. Zoom+pan editor appears in ImageUpload and item detail edit mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RESEARCH COMPLETE
|
||||||
78
.planning/phases/29-image-presentation/29-VALIDATION.md
Normal file
78
.planning/phases/29-image-presentation/29-VALIDATION.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
phase: 29
|
||||||
|
slug: image-presentation
|
||||||
|
status: draft
|
||||||
|
nyquist_compliant: false
|
||||||
|
wave_0_complete: false
|
||||||
|
created: 2026-04-12
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 29 — Validation Strategy
|
||||||
|
|
||||||
|
> Per-phase validation contract for feedback sampling during execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Framework** | Bun test runner + Playwright |
|
||||||
|
| **Config file** | `bunfig.toml` / `playwright.config.ts` |
|
||||||
|
| **Quick run command** | `bun test` |
|
||||||
|
| **Full suite command** | `bun test && bun run test:e2e` |
|
||||||
|
| **Estimated runtime** | ~30 seconds (unit) + ~60 seconds (E2E) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sampling Rate
|
||||||
|
|
||||||
|
- **After every task commit:** Run `bun test`
|
||||||
|
- **After every plan wave:** Run `bun test && bun run test:e2e`
|
||||||
|
- **Before `/gsd-verify-work`:** Full suite must be green
|
||||||
|
- **Max feedback latency:** 30 seconds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-Task Verification Map
|
||||||
|
|
||||||
|
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||||
|
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||||
|
| 29-01-01 | 01 | 1 | D-01,D-02,D-03 | — | N/A | unit | `bun test tests/services/image.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 29-01-02 | 01 | 1 | D-04 | — | N/A | integration | `bun test tests/services/image.service.test.ts` | ❌ W0 | ⬜ pending |
|
||||||
|
| 29-02-01 | 02 | 1 | D-01,D-06 | — | N/A | grep | `grep -r "GearImage" src/client/` | N/A | ⬜ pending |
|
||||||
|
| 29-03-01 | 03 | 2 | D-07,D-08,D-09 | — | N/A | unit+E2E | `bun test && bun run test:e2e` | ❌ W0 | ⬜ pending |
|
||||||
|
|
||||||
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 0 Requirements
|
||||||
|
|
||||||
|
- [ ] Test stubs for dominant color extraction service
|
||||||
|
- [ ] Test stubs for crop field persistence
|
||||||
|
|
||||||
|
*Existing test infrastructure (Bun test runner, Playwright) covers framework needs.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual-Only Verifications
|
||||||
|
|
||||||
|
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||||
|
|----------|-------------|------------|-------------------|
|
||||||
|
| Dominant color background looks correct | D-02 | Visual quality subjective | Upload 5 varied images, verify background colors feel intentional |
|
||||||
|
| Zoom+pan editor is intuitive | D-07 | UX quality subjective | Open editor, zoom in/out, pan, verify coordinates save and render |
|
||||||
|
| Letterbox/pillarbox appearance | D-01 | Visual consistency check | View tall and wide images on cards and detail pages |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Sign-Off
|
||||||
|
|
||||||
|
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||||
|
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||||
|
- [ ] Wave 0 covers all MISSING references
|
||||||
|
- [ ] No watch-mode flags
|
||||||
|
- [ ] Feedback latency < 30s
|
||||||
|
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
Reference in New Issue
Block a user