--- phase: 29 slug: image-presentation status: draft shadcn_initialized: false preset: none created: 2026-04-12 --- # Phase 29 — UI Design Contract > Visual and interaction contract for image presentation changes. Generated by gsd-ui-researcher, verified by gsd-ui-checker. --- ## Design System | Property | Value | |----------|-------| | Tool | none (Tailwind CSS v4 direct) | | Preset | not applicable | | Component library | none (custom components) | | Icon library | Lucide (via custom LucideIcon wrapper) | | Font | System default (inherited) | --- ## Spacing Scale Declared values (must be multiples of 4): | Token | Value | Usage | |-------|-------|-------| | xs | 4px | Icon gaps, inline padding | | sm | 8px | Compact element spacing | | md | 16px | Default element spacing | | lg | 24px | Section padding | | xl | 32px | Layout gaps | | 2xl | 48px | Major section breaks | | 3xl | 64px | Page-level spacing | Exceptions: none --- ## Typography No new typography introduced. All text elements use existing typographic scale from the app. | Role | Size | Weight | Line Height | |------|------|--------|-------------| | Body | 14px | 400 | 1.5 | | Label | 12px | 500 | 1.25 | | Heading | 14px | 600 | 1.25 | --- ## Color No new brand colors introduced. The only new color element is the **dominant color background** which is dynamically extracted per-image. | Role | Value | Usage | |------|-------|-------| | Dominant (60%) | white (#ffffff) | Page background (unchanged) | | Secondary (30%) | gray-50 (#f9fafb) | Card surfaces, fallback image bg | | Accent (10%) | blue-50/green-50 | Weight/price badges (unchanged) | | Dynamic fill | per-image dominant color | Image container background behind `object-contain` images | | Fallback fill | gray-100 (#f3f4f6) | Image container background when dominant color unavailable | Accent reserved for: weight badges (blue-50), price badges (green-50), category badges (gray-50) --- ## Image Container Specifications ### GearImage Component A new shared component replaces all inline `` elements for gear/product images. | Property | Spec | |----------|------| | Component name | `GearImage` | | File location | `src/client/components/GearImage.tsx` | | Default aspect ratio | `4/3` (cards, upload preview) | | Detail page ratio | `16/9` (global item detail, candidate detail) | | Border radius | `rounded-xl` (12px) on detail pages; inherited from parent on cards | | Overflow | `hidden` (always) | ### Default State (no crop) ``` Container: aspect-[4/3], overflow-hidden Background: dominant color OR #f3f4f6 (gray-100 fallback) Image: object-contain, w-full, h-full Result: Full image visible, letterbox/pillarbox fill with dominant color ``` ### Cropped State (user-defined zoom+pan) ``` Container: aspect-[4/3], overflow-hidden Background: dominant color OR #f3f4f6 Image: w-full, h-full, object-cover Transform: scale(cropZoom) translate(cropX%, cropY%) Transform origin: center center Result: User-framed view with cropped overflow hidden ``` ### Empty State (no image) ``` Container: aspect-[4/3], bg-gray-50 Content: Centered LucideIcon (category icon), text-gray-400, size 36px ``` Unchanged from current behavior. ### Transition No CSS transitions on the image itself. Background color applies immediately via inline `style={{ backgroundColor }}`. --- ## Zoom+Pan Editor Specifications ### Editor Trigger Points | Location | Trigger | Behavior | |----------|---------|----------| | ImageUpload component | After image upload completes | Editor overlay appears on the uploaded image | | Item detail page | "Adjust framing" button below image | Editor overlay replaces static image view | | Global item detail page | "Adjust framing" button below image | Same as item detail | | Candidate detail page | "Adjust framing" button below image | Same as item detail | ### Editor UI | Element | Spec | |---------|------| | Library | react-easy-crop | | Crop shape | rect | | Aspect ratio | Matches container (4/3 for cards, 16/9 for detail pages where applicable) | | Min zoom | 1.0 (fit-within, default) | | Max zoom | 3.0 | | Background | Dominant color of the image (or gray-100 fallback) | | Controls | Zoom slider below the crop area | | Save button | "Save framing" — primary action, bottom-right | | Cancel button | "Cancel" — secondary/ghost, bottom-left | | Button spacing | 8px gap between cancel and save | ### Editor Overlay Layout ``` +-------------------------------------------+ | | | [react-easy-crop area] | | (drag to pan, scroll to zoom) | | | +-------------------------------------------+ | [------- zoom slider -------] | +-------------------------------------------+ | Cancel Save framing | +-------------------------------------------+ ``` - Overlay uses `fixed inset-0 z-50 bg-black/60` on mobile, `relative` inline on desktop detail pages - On ImageUpload: overlay within the upload container - On detail pages: replaces the image area inline (no modal) ### Editor Output | Field | Type | Range | Description | |-------|------|-------|-------------| | cropZoom | number | 1.0 - 3.0 | Zoom level (1.0 = fit within) | | cropX | number | -50 to 50 | Horizontal pan offset (percentage) | | cropY | number | -50 to 50 | Vertical pan offset (percentage) | When zoom is 1.0 and x/y are 0: equivalent to default `object-contain` (no crop applied). --- ## Copywriting Contract | Element | Copy | |---------|------| | Adjust framing button | "Adjust framing" | | Editor save CTA | "Save framing" | | Editor cancel | "Cancel" | | Zoom slider label | "Zoom" (sr-only) | | Empty image placeholder | "Click to add photo" (unchanged) | | Backfill progress (admin) | "Processing images... {N}/{total}" | --- ## Surface-by-Surface Spec Each surface adopts the `GearImage` component. All surfaces use 4/3 ratio except where noted. | # | Surface | File | Ratio | Has Editor | Notes | |---|---------|------|-------|------------|-------| | 1 | ItemCard | `components/ItemCard.tsx` | 4/3 | No | Card only, editor on detail page | | 2 | GlobalItemCard | `components/GlobalItemCard.tsx` | 4/3 | No | Card only | | 3 | CandidateCard | `components/CandidateCard.tsx` | 4/3 | No | Card only | | 4 | CandidateListItem | `components/CandidateListItem.tsx` | 4/3 | No | Small thumbnail | | 5 | ImageUpload | `components/ImageUpload.tsx` | 4/3 | Yes | Editor after upload | | 6 | ComparisonTable | `components/ComparisonTable.tsx` | 4/3 | No | Table cell image | | 7 | LinkToGlobalItem | `components/LinkToGlobalItem.tsx` | 1/1 | No | Small 32px thumbnail, keep object-cover for tiny icons | | 8 | CatalogSearchOverlay | `components/CatalogSearchOverlay.tsx` | 4/3 | No | Search result cards (2 instances) | | 9 | Item detail | `routes/items/$itemId.tsx` | 4/3 | Yes | Full editor access | | 10 | Global item detail | `routes/global-items/$globalItemId.tsx` | 16/9 | Yes | Full editor access | | 11 | Global items index | `routes/global-items/index.tsx` | 4/3 | No | List card | | 12 | Candidate detail | `routes/threads/$threadId/candidates/$candidateId.tsx` | 16/9 | Yes | Full editor access | ### LinkToGlobalItem Exception The 32x32px thumbnail in LinkToGlobalItem is too small for letterbox treatment. Keep `object-cover` with `rounded` for this surface. The GearImage component should accept a `cover` prop to force object-cover mode for tiny thumbnails. --- ## Registry Safety | Registry | Blocks Used | Safety Gate | |----------|-------------|-------------| | npm (react-easy-crop) | react-easy-crop | MIT license, 500k+ weekly downloads, active maintenance | No shadcn blocks used in this phase. --- ## Checker Sign-Off - [x] Dimension 1 Copywriting: PASS - [x] Dimension 2 Visuals: PASS - [x] Dimension 3 Color: PASS - [x] Dimension 4 Typography: PASS - [x] Dimension 5 Spacing: PASS - [x] Dimension 6 Registry Safety: PASS **Approval:** approved 2026-04-12