Files
GearBox/.planning/milestones/v2.2-phases/29-image-presentation/29-02-PLAN.md
Jean-Luc Makiola 2853477a75
All checks were successful
CI / ci (push) Successful in 1m15s
CI / e2e (push) Has been skipped
CI / deploy (push) Has been skipped
chore: archive v2.2 User Experience Polish milestone
Phases 28-31 archived to milestones/v2.2-phases/
Requirements and roadmap snapshots archived to milestones/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:35 +02:00

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements
phase plan type wave depends_on files_modified autonomous requirements
29 02 frontend 1
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
true
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.

Task 1: Create GearImage component

- src/client/components/ItemCard.tsx (current image rendering pattern) - src/client/components/GlobalItemCard.tsx (current image rendering pattern) Create `src/client/components/GearImage.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):

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. test -f src/client/components/GearImage.tsx && grep "object-contain" src/client/components/GearImage.tsx && echo "PASS" || echo "FAIL" <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 2: Update ItemCard to use GearImage

- src/client/components/ItemCard.tsx - src/client/components/GearImage.tsx 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:

<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:

<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>
grep "GearImage" src/client/components/ItemCard.tsx && ! grep "object-cover" src/client/components/ItemCard.tsx && echo "PASS" || echo "FAIL" - 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

Task 3: Update GlobalItemCard to use GearImage

- src/client/components/GlobalItemCard.tsx - src/client/components/GearImage.tsx 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:

<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:

<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>
grep "GearImage" src/client/components/GlobalItemCard.tsx && ! grep "object-cover" src/client/components/GlobalItemCard.tsx && echo "PASS" || echo "FAIL" - GlobalItemCard imports and uses GearImage - No `object-cover` in GlobalItemCard.tsx - Props include dominantColor, cropZoom, cropX, cropY

Task 4: Update CandidateCard to use GearImage

- src/client/components/CandidateCard.tsx - src/client/components/GearImage.tsx Same pattern as Task 2/3: 1. Add `dominantColor`, `cropZoom`, `cropX`, `cropY` to props interface 2. Import `GearImage` 3. Replace `` with `` inside the existing `aspect-[4/3]` container 4. Update parent div to use inline `backgroundColor` style grep "GearImage" src/client/components/CandidateCard.tsx && ! grep "object-cover" src/client/components/CandidateCard.tsx && echo "PASS" || echo "FAIL" - CandidateCard uses GearImage component - No `object-cover` remaining - Dominant color props threaded through

Task 5: Update CandidateListItem to use GearImage

- src/client/components/CandidateListItem.tsx - src/client/components/GearImage.tsx 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 grep "GearImage" src/client/components/CandidateListItem.tsx && ! grep "object-cover" src/client/components/CandidateListItem.tsx && echo "PASS" || echo "FAIL" - CandidateListItem uses GearImage - No `object-cover` remaining

Task 6: Update ComparisonTable to use GearImage

- src/client/components/ComparisonTable.tsx - src/client/components/GearImage.tsx Same pattern: replace inline `` with ``. Thread dominantColor and crop props from the data source. grep "GearImage" src/client/components/ComparisonTable.tsx && ! grep "object-cover" src/client/components/ComparisonTable.tsx && echo "PASS" || echo "FAIL" - ComparisonTable uses GearImage - No `object-cover` remaining

Task 7: Update CatalogSearchOverlay to use GearImage

- src/client/components/CatalogSearchOverlay.tsx - src/client/components/GearImage.tsx CatalogSearchOverlay has 2 `object-cover` instances (search results and selected item preview). Replace both with GearImage. Thread dominantColor from global item data. grep "GearImage" src/client/components/CatalogSearchOverlay.tsx && ! grep "object-cover" src/client/components/CatalogSearchOverlay.tsx && echo "PASS" || echo "FAIL" - Both image instances in CatalogSearchOverlay use GearImage - No `object-cover` remaining in the file

Task 8: Update ImageUpload preview to use GearImage

- src/client/components/ImageUpload.tsx - src/client/components/GearImage.tsx 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:

<img src={displayUrl} alt="Item" className="w-full h-full object-cover" />

New:

<GearImage src={displayUrl} alt="Item" dominantColor={dominantColor} />
  1. Update the parent container to use dominant color background:
style={{ backgroundColor: displayUrl ? (dominantColor || "#f3f4f6") : undefined }}
grep "GearImage" src/client/components/ImageUpload.tsx && ! grep "object-cover" src/client/components/ImageUpload.tsx && echo "PASS" || echo "FAIL" - ImageUpload uses GearImage for preview - No `object-cover` remaining - Accepts dominantColor prop

Task 9: Update item detail page

- src/client/routes/items/$itemId.tsx - src/client/components/GearImage.tsx 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 grep "GearImage" src/client/routes/items/$itemId.tsx && ! grep "object-cover" src/client/routes/items/$itemId.tsx && echo "PASS" || echo "FAIL" <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 10: Update global item detail page

- src/client/routes/global-items/$globalItemId.tsx - src/client/components/GearImage.tsx 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 grep "GearImage" src/client/routes/global-items/$globalItemId.tsx && ! grep "object-cover" src/client/routes/global-items/$globalItemId.tsx && echo "PASS" || echo "FAIL" <acceptance_criteria>
  • Global item detail uses GearImage
  • No object-cover remaining
  • Aspect ratio 16/9 preserved </acceptance_criteria>

Task 11: Update global items index page

- src/client/routes/global-items/index.tsx - src/client/components/GearImage.tsx 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 grep "GearImage" src/client/routes/global-items/index.tsx && ! grep "object-cover" src/client/routes/global-items/index.tsx && echo "PASS" || echo "FAIL" <acceptance_criteria>
  • Global items index uses GearImage
  • No object-cover remaining </acceptance_criteria>

Task 12: Update candidate detail page

- src/client/routes/threads/$threadId/candidates/$candidateId.tsx - src/client/components/GearImage.tsx 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 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" <acceptance_criteria>
  • Candidate detail uses GearImage
  • No object-cover remaining
  • Aspect ratio 16/9 preserved </acceptance_criteria>

Task 13: Update LinkToGlobalItem with cover mode

- src/client/components/LinkToGlobalItem.tsx - src/client/components/GearImage.tsx 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:

<img className="w-8 h-8 rounded object-cover shrink-0" ... />

With:

<GearImage src={...} alt={...} cover className="w-8 h-8 rounded shrink-0" />
grep "GearImage" src/client/components/LinkToGlobalItem.tsx && echo "PASS" || echo "FAIL" - LinkToGlobalItem uses GearImage with `cover` prop - Small thumbnail renders with object-cover (intentional exception for tiny images) 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

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