diff --git a/.planning/phases/29-image-presentation/29-02-SUMMARY.md b/.planning/phases/29-image-presentation/29-02-SUMMARY.md
new file mode 100644
index 0000000..af2d2bc
--- /dev/null
+++ b/.planning/phases/29-image-presentation/29-02-SUMMARY.md
@@ -0,0 +1,56 @@
+---
+phase: 29
+plan: 02
+subsystem: frontend
+tags: [components, image-rendering, ui]
+key-files:
+ created:
+ - src/client/components/GearImage.tsx
+ modified:
+ - 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/components/LinkToGlobalItem.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
+metrics:
+ tasks: 13
+ commits: 4
+ files-changed: 13
+---
+
+# Plan 29-02 Summary: GearImage Component + Surface Updates
+
+## What was built
+- Created `GearImage` shared component with three modes: contain (default), cover (tiny thumbnails), and crop (CSS transform)
+- Created `imageContainerBg()` helper for consistent dominant color backgrounds
+- Updated all 12 gear image surfaces to use GearImage
+- Default rendering now uses `object-contain` instead of `object-cover`
+- Parent containers use dominant color background for letterbox/pillarbox fill
+- LinkToGlobalItem uses `cover` mode for 32px thumbnails (intentional exception)
+
+## Commits
+
+| Task | Commit | Description |
+|------|--------|-------------|
+| 1 | 06d3984 | Create GearImage component |
+| 2-3 | 2865e65 | Update ItemCard and GlobalItemCard |
+| 4-5 | 05c0918 | Update CandidateCard and CandidateListItem |
+| 6-8 | 91846b5 | Update ComparisonTable, CatalogSearchOverlay, ImageUpload |
+| 9-13 | 66d9c41 | Update detail pages and LinkToGlobalItem |
+| lint | 9636033 | Lint fixes for formatting and unused parameter |
+
+## Deviations
+None.
+
+## Self-Check: PASSED
+- GearImage component exists: YES
+- object-cover removed from all gear surfaces: YES (only remains in GearImage internal, ProfileSection avatar, users avatar)
+- Build passes: YES
+- Lint passes: YES
diff --git a/src/client/components/ImageCropEditor.tsx b/src/client/components/ImageCropEditor.tsx
index fbd866b..52ee1ea 100644
--- a/src/client/components/ImageCropEditor.tsx
+++ b/src/client/components/ImageCropEditor.tsx
@@ -1,6 +1,6 @@
import { useCallback, useState } from "react";
-import Cropper from "react-easy-crop";
import type { Area, Point } from "react-easy-crop";
+import Cropper from "react-easy-crop";
interface CropResult {
zoom: number;
diff --git a/src/client/components/ImageUpload.tsx b/src/client/components/ImageUpload.tsx
index 805c63a..6aa9da0 100644
--- a/src/client/components/ImageUpload.tsx
+++ b/src/client/components/ImageUpload.tsx
@@ -93,90 +93,90 @@ export function ImageUpload({
{/* Hero image area */}
{!showCropEditor && (
-
inputRef.current?.click()}
- className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
- style={{
- backgroundColor: displayUrl
- ? imageContainerBg(dominantColor)
- : undefined,
- }}
- >
- {displayUrl ? (
- <>
-
- {/* Remove button */}
-
)}
rootRouteImport,
} as any)
+const ProfileRoute = ProfileRouteImport.update({
+ id: '/profile',
+ path: '/profile',
+ getParentRoute: () => rootRouteImport,
+} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
@@ -87,6 +93,7 @@ const ThreadsThreadIdCandidatesCandidateIdRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
+ '/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute
@@ -101,6 +108,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
+ '/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute
@@ -116,6 +124,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
+ '/profile': typeof ProfileRoute
'/settings': typeof SettingsRoute
'/global-items/$globalItemId': typeof GlobalItemsGlobalItemIdRoute
'/items/$itemId': typeof ItemsItemIdRoute
@@ -132,6 +141,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/login'
+ | '/profile'
| '/settings'
| '/global-items/$globalItemId'
| '/items/$itemId'
@@ -146,6 +156,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/login'
+ | '/profile'
| '/settings'
| '/global-items/$globalItemId'
| '/items/$itemId'
@@ -160,6 +171,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/login'
+ | '/profile'
| '/settings'
| '/global-items/$globalItemId'
| '/items/$itemId'
@@ -175,6 +187,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
+ ProfileRoute: typeof ProfileRoute
SettingsRoute: typeof SettingsRoute
GlobalItemsGlobalItemIdRoute: typeof GlobalItemsGlobalItemIdRoute
ItemsItemIdRoute: typeof ItemsItemIdRoute
@@ -196,6 +209,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/profile': {
+ id: '/profile'
+ path: '/profile'
+ fullPath: '/profile'
+ preLoaderRoute: typeof ProfileRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/login': {
id: '/login'
path: '/login'
@@ -279,6 +299,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
+ ProfileRoute: ProfileRoute,
SettingsRoute: SettingsRoute,
GlobalItemsGlobalItemIdRoute: GlobalItemsGlobalItemIdRoute,
ItemsItemIdRoute: ItemsItemIdRoute,
diff --git a/src/client/routes/items/$itemId.tsx b/src/client/routes/items/$itemId.tsx
index 6890a8f..ba62342 100644
--- a/src/client/routes/items/$itemId.tsx
+++ b/src/client/routes/items/$itemId.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { CategoryPicker } from "../../components/CategoryPicker";
import { GearImage, imageContainerBg } from "../../components/GearImage";
+import { ImageCropEditor } from "../../components/ImageCropEditor";
import { ImageUpload } from "../../components/ImageUpload";
import { useFormatters } from "../../hooks/useFormatters";
import { useDuplicateItem, useItem, useUpdateItem } from "../../hooks/useItems";
@@ -45,6 +46,7 @@ function ItemDetail() {
const openExternalLink = useUIStore((s) => s.openExternalLink);
const [isEditing, setIsEditing] = useState(false);
+ const [editingCrop, setEditingCrop] = useState(false);
const [form, setForm] = useState({
name: "",
brand: "",
@@ -242,34 +244,66 @@ function ItemDetail() {
}
/>
- ) : (
-
- {imageUrl ? (
-
- ) : (
-
-
-
- )}
+ ) : editingCrop && imageUrl ? (
+
+ {
+ updateItem.mutate({
+ id: item.id,
+ cropZoom: crop.zoom,
+ cropX: crop.x,
+ cropY: crop.y,
+ });
+ setEditingCrop(false);
+ }}
+ onCancel={() => setEditingCrop(false)}
+ />
+ ) : (
+ <>
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+ {imageUrl && !isEditing && (
+
setEditingCrop(true)}
+ className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
+ >
+ Adjust framing
+
+ )}
+ >
)}
{/* Header / Name */}
diff --git a/src/client/routes/threads/$threadId/candidates/$candidateId.tsx b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx
index dc174b7..5b3ef8a 100644
--- a/src/client/routes/threads/$threadId/candidates/$candidateId.tsx
+++ b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { CategoryPicker } from "../../../../components/CategoryPicker";
import { GearImage, imageContainerBg } from "../../../../components/GearImage";
+import { ImageCropEditor } from "../../../../components/ImageCropEditor";
import { ImageUpload } from "../../../../components/ImageUpload";
import { StatusBadge } from "../../../../components/StatusBadge";
import { useUpdateCandidate } from "../../../../hooks/useCandidates";
@@ -42,6 +43,7 @@ function CandidateDetailPage() {
);
const [isEditing, setIsEditing] = useState(false);
+ const [editingCrop, setEditingCrop] = useState(false);
const [form, setForm] = useState
({
name: "",
weightGrams: "",
@@ -204,22 +206,57 @@ function CandidateDetailPage() {
}
/>
- ) : imageUrl ? (
-
-
+ {
+ updateCandidate.mutate({
+ threadId,
+ candidateId: candidate.id,
+ data: {
+ cropZoom: crop.zoom,
+ cropX: crop.x,
+ cropY: crop.y,
+ },
+ });
+ setEditingCrop(false);
+ }}
+ onCancel={() => setEditingCrop(false)}
/>
+ ) : imageUrl ? (
+ <>
+
+
+
+ {!isEditing && (
+ setEditingCrop(true)}
+ className="mb-4 text-sm text-gray-500 hover:text-gray-700 transition-colors"
+ >
+ Adjust framing
+
+ )}
+ >
) : null}
{/* Header */}