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