Skip to content

Component Implementation Specification

Implementation specification for the 19 components defined in the UI Components document. Each component produces a standard set of deliverables following the conventions established in the canary library (see atoms/grid/boolean/ for the reference pattern). Workflow integration stories are out of scope here and will be addressed separately.

Every new component directory contains the following artifacts:

ArtifactFilePurpose
Component<name>.tsxProduction React component
Unit tests<name>.test.tsxVitest + React Testing Library tests
Stories<name>.stories.tsxStorybook stories (may split into multiple files if large)
Documentation<name>.mdxMDX doc page with Canvas embeds and props tables
Indexindex.tsBarrel re-exports for the component directory

For modified components (Badge, Avatar, item-grid-columns), the existing files are extended rather than replaced.

All components live under src/components/canary/ with subdirectories for primitives, atoms, molecules, and organisms. Each of these may have additional subdirectories (e.g., grid) depending on functional groupings. Each level of the directory hierarchy has a descriptive mdx file that serves as an index for its contents.

All utilities live under src/types/canary/utilities/ with a single mdx documentation file at that directory level (may be split in the future).

The sidebar follows the directory structure. Within each section, the first entry is the index document for that subdirectory (*.mdx), followed by other files in alphabetical order, then subdirectories in alphabetical order.

Component sections (leaf directories) follow this order:

  1. Component documentation
  2. Stories documentation (if present)
  3. Stories in alphabetical order except the Playground story
  4. Playground story last

Every component story file follows this structure:

  1. MDX description (<component>.mdx) — imports stories, provides prose overview, embeds key Canvas blocks, and documents props via HTML <table> elements (no Markdown tables in MDX).
  2. Playground story — fully instrumented with argTypes controls across all props. This is the primary interactive story for stakeholders.
  3. Specialized stories — named exports that each highlight one key aspect of the component’s behavior or visual state.

Story title paths follow: Components/Canary/{Tier}/{ComponentName}.

All stories use mock data only — no backend connections. Upload workflows use delayed Promises simulating presigned-POST success. URL reachability checks use mock responses. Image URLs use placeholder services.

Tests use Vitest + React Testing Library + userEvent following the patterns established in the canary library (see boolean.test.tsx, badge.test.tsx).

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';

Test categories applied per component (where applicable):

  1. Rendering — component mounts without error, renders expected elements in each visual state.
  2. Props and variants — each prop value and variant produces the correct DOM output, classes, or data-* attributes.
  3. User interaction — clicks, hovers, keyboard events trigger the correct callbacks (vi.fn() assertions).
  4. Edge cases — null/undefined props, empty strings, broken URLs, missing optional callbacks.
  5. Accessibility — ARIA attributes, roles, focus management where applicable.
  6. Ref handlesuseImperativeHandle contracts (AG Grid cell editors).

All diagrams in MDX documentation use PlantUML format.

Before implementation begins, the following must be in place.

Gate: All existing checks (lint, build, tests) must pass before making any changes. This is verified at the start of Wave 0.

Dependency hygiene: Update current dependencies to their latest versions unless major breaking changes would require disproportionate effort.

PackagePurposeStatus
react-dropzoneImageDropZone foundationAlready installed (v15)
react-easy-cropImagePreviewEditor crop/zoom/rotateTo install
browser-image-compressionAuto-compression in ImageUploadDialogTo install
heic2anyHEIC-to-JPEG conversion (lazy-loaded)To install
radix-uiUnified Radix packageAlready installed (v1.4)
class-variance-authorityCVA variantsAlready installed (v0.7)

These ShadCN primitives are referenced by components but do not currently exist in the primitives/ directory. They must be added before the components that consume them.

PrimitiveConsumed ByNotes
alert-dialog.tsxImageUploadDialog (Warn state), ImageFormField (remove confirmation)ShadCN AlertDialog — focus trap, no click-outside dismiss
checkbox.tsxCopyrightAcknowledgmentShadCN Checkbox
popover.tsxImageHoverPreviewShadCN Popover — positioning for hover preview
progress.tsxImageUploadDialog (upload progress bar)ShadCN Progress
slider.tsxImagePreviewEditor (zoom control)ShadCN Slider
aspect-ratio.tsxImageDisplay, ImagePreviewEditorShadCN AspectRatio

As part of establishing the utilities/ directory, move the contents of the existing src/types/canary/utils.ts, pagination.ts, and date-time.ts into the new src/types/canary/utilities/ directory and update all import paths.

FileContents
src/types/canary/utilities/image-field-config.tsImageMimeType, ImageFieldStaticConfig, ImageFieldInitConfig, ImageFieldConfig, ImageInput, ImageUploadResult, CropData, PixelCrop
src/types/canary/utilities/get-initials.tsgetInitials(name: string): string — extracted from item-grid-columns.tsx
src/types/canary/utilities/get-cropped-image.tsgetCroppedImage(...) — canvas helper returning Promise<Blob>
src/types/canary/utilities/index.tsBarrel for utilities

A shared mock data module provides consistent sample data across all stories:

File: src/components/canary/__mocks__/image-story-data.ts

// Placeholder image URLs (via picsum.photos, stable seeds)
export const MOCK_ITEM_IMAGE = 'https://picsum.photos/seed/arda-item-1/400/400';
export const MOCK_ITEM_IMAGE_ALT = 'https://picsum.photos/seed/arda-item-2/400/400';
export const MOCK_BROKEN_IMAGE = 'https://picsum.photos/seed/zzz-broken/0/0'; // triggers error
export const MOCK_LARGE_IMAGE = 'https://picsum.photos/seed/arda-large/2048/2048';
export const ITEM_IMAGE_CONFIG: ImageFieldConfig = {
aspectRatio: 1,
acceptedFormats: ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'],
maxFileSizeBytes: 10 * 1024 * 1024,
maxDimension: 2048,
entityTypeDisplayName: 'Item',
propertyDisplayName: 'Product Image',
};
// Simulated presigned-POST upload (happy path, ~1.5s)
export async function mockUpload(file: Blob): Promise<string> {
await new Promise((r) => setTimeout(r, 1500));
return URL.createObjectURL(file);
}
// Simulated URL reachability check (happy path, ~500ms)
export async function mockReachabilityCheck(url: string): Promise<boolean> {
await new Promise((r) => setTimeout(r, 500));
return !url.includes('broken');
}
// Sample items for grid stories
export interface MockItem {
id: string;
name: string;
sku: string;
imageUrl: string | null;
unitCost: number;
}
export const MOCK_ITEMS: MockItem[] = [
{ id: '1', name: 'Hex Bolt M10x30', sku: 'HB-1030', imageUrl: MOCK_ITEM_IMAGE, unitCost: 0.45 },
{ id: '2', name: 'Flat Washer 3/8"', sku: 'FW-0375', imageUrl: MOCK_ITEM_IMAGE_ALT, unitCost: 0.12 },
{ id: '3', name: 'Spring Pin 4x20', sku: 'SP-0420', imageUrl: null, unitCost: 0.28 },
{ id: '4', name: 'Tee Nut 1/4-20', sku: 'TN-2520', imageUrl: MOCK_BROKEN_IMAGE, unitCost: 0.65 },
];

Grid cell adapter location: atoms/grid/image/ (carve-out confirmed). ImageCellDisplay and ImageCellEditor live under atoms/grid/image/ alongside all other grid cell types, maintaining symmetry with boolean, text, number, date, select, memo, and color. The delegation to molecules is documented in the index and MDX but does not change the directory location.

Components are ordered by dependency. Each wave can only begin after its predecessors are complete.

Wave 0a Infrastructure (deps, dependency updates, existing checks green)
|
Wave 0b Structural scaffolding (primitives, types, utilities, directory moves, mocks)
|
Wave 1 Foundation components (no deps on other new components)
| getInitials, getCroppedImage, Badge (error-overlay), Avatar (refactor),
| CopyrightAcknowledgment, ImageDisplay, ImageDropZone, ImagePreviewEditor
|
Wave 2 Composition components (depend on Wave 1)
| ImageHoverPreview, ImageInspectorOverlay, ImageComparisonLayout, ImageFormField
|
Wave 3 Grid atoms + Organism (depend on Waves 1-2)
| ImageCellDisplay, ImageCellEditor, ImageUploadDialog
|
Wave 4 Integration (modify existing components)
item-grid-columns, barrel exports

Wave gate: At the end of each wave, the Storybook must build without errors, all unit tests must pass, and the linter must pass. After a successful gate, all changes are committed locally.


  1. Verify all existing checks pass (lint, Storybook build, unit tests).
  2. Update current dependencies to latest versions (unless major breaking changes require disproportionate effort).
  3. Install new npm dependencies: react-easy-crop, browser-image-compression, heic2any.
  4. Verify checks still pass after dependency changes.
  1. Create src/types/canary/utilities/ directory.
  2. Move src/types/canary/utils.ts (cn() helper), pagination.ts, and date-time.ts into utilities/. Update all import paths across the codebase.
  3. Create src/types/canary/utilities/index.ts barrel.
  4. Create src/components/canary/molecules/form/ directory.
  5. Create src/components/canary/atoms/grid/image/ directory with index.ts.
  6. Scaffold src/components/canary/organisms/shared/image-upload-dialog/ directory.
  1. Create src/types/canary/utilities/image-field-config.ts with ImageMimeType, ImageFieldStaticConfig, ImageFieldInitConfig, ImageFieldConfig, ImageInput, ImageUploadResult, CropData, PixelCrop.
  1. Add alert-dialog.tsx, checkbox.tsx, popover.tsx, progress.tsx, slider.tsx, aspect-ratio.tsx to src/components/canary/primitives/.
  1. Add error-overlay variant to Badge CVA definition in badge-base.tsx.
  1. Extract getInitials from item-grid-columns.tsx to src/types/canary/utilities/get-initials.ts. Update item-grid-columns.tsx to import from the new location.
  1. Create src/types/canary/utilities/get-cropped-image.ts — canvas helper returning Promise<Blob>.
  1. Confirm that the existing skeleton primitive provides the shimmer @keyframes via Tailwind. If not, add it to @theme in globals.css.
  1. Create src/components/canary/__mocks__/image-story-data.ts with shared mock data module.

These stories familiarize stakeholders with the ShadCN primitives used throughout the image components. They are lightweight — one story file per primitive with 2–3 named exports showing the primitive in image-upload context.

Story file: src/components/canary/primitives/skeleton-image.stories.tsx Title: Components/Canary/Primitives/Skeleton (Image Context)

StoryPurpose
ImagePlaceholderSkeleton sized as a 64×64 square image cell — the loading state stakeholders will see in grids
ImagePlaceholderLargeSkeleton sized at 256×256 — the loading state in preview/inspector contexts
ShimmerAnimationSkeleton in a simulated container that toggles loaded/loading every 2s to demonstrate the transition

Story file: src/components/canary/primitives/tabs-image.stories.tsx Title: Components/Canary/Primitives/Tabs (Image Context)

StoryPurpose
CurrentVsNewTwo tabs labeled “Current” / “New” each containing a placeholder image — preview of the mobile comparison UX
WithPlaceholder“Current” tab shows initials placeholder, “New” tab shows an actual image

Story file: src/components/canary/primitives/alert-dialog-image.stories.tsx Title: Components/Canary/Primitives/AlertDialog (Image Context)

StoryPurpose
RemoveConfirmation“Remove Product Image?” dialog with destructive action button — the exact L&F of the image removal prompt
DiscardChanges“Discard unsaved image?” dialog — the Warn state confirmation from ImageUploadDialog

No Storybook story — pure function. Documented in a shared utilities MDX page with an input/output reference table.

MDX file: src/types/canary/utilities/utilities.mdx Title: Components/Canary/Utilities

Contents: Table of getInitials examples ("Hex Bolt""HB", "A""A", "" → fallback symbol). Table of getCroppedImage parameters and return type.

Unit tests: src/types/canary/utilities/get-initials.test.ts

TestAssertion
two-word name"Hex Bolt""HB"
three-word name (takes first two)"Hex Bolt M10""HB"
single-word name"Bolt""B"
single character"A""A"
empty stringreturns fallback symbol
whitespace-only stringreturns fallback symbol
leading/trailing whitespacetrims before splitting
mixed case"hex bolt""HB" (uppercased)

No Storybook story — documented alongside getInitials in the utilities MDX page.

Unit tests: src/types/canary/utilities/get-cropped-image.test.ts

TestAssertion
returns a Blobresult is instanceof Blob
output MIME type matches parameteroutputFormat: 'image/jpeg' → Blob type is image/jpeg
default quality is 0.85Blob size is smaller than uncompressed (smoke test)
handles zero rotationno error, produces valid Blob
handles 90-degree rotationproduces valid Blob (dimensions swapped)

Note: tests use a small in-memory canvas image, not external URLs.

Story file: src/components/canary/atoms/badge/badge.stories.tsx (extend existing) MDX file: src/components/canary/atoms/badge/badge.mdx (extend existing) Title: Components/Canary/Atoms/Badge (unchanged)

New stories added to the existing file:

StoryPurpose
Playground(existing) — add error-overlay to variant select options
ErrorOverlayBadge in error-overlay variant positioned over a bg-muted 64×64 container simulating an image cell. Shows warning icon + “!” indicator
ErrorOverlayInContextSide-by-side: normal image cell vs. failed image cell with error-overlay badge — demonstrates the visual distinction between “no image” and “broken image”
AllVariants(existing) — add error-overlay row to the variant gallery

MDX additions: new “Error Overlay Variant” section with Canvas embed showing ErrorOverlay and ErrorOverlayInContext.

Unit tests: extend src/components/canary/atoms/badge/badge.test.tsx

TestAssertion
renders error-overlay variantdata-variant="error-overlay" attribute present
error-overlay has absolute positioning classclassName contains absolute
error-overlay renders warning iconSVG icon element present
existing variants unchangedexisting tests continue to pass (regression)

Story file: src/components/canary/atoms/avatar/avatar.stories.tsx (extend existing) MDX file: src/components/canary/atoms/avatar/avatar.mdx (extend existing) Title: Components/Canary/Atoms/Avatar (unchanged)

New stories added:

StoryPurpose
InitialsFromNameAvatar rendered with entityName prop — demonstrates getInitials producing fallback initials automatically instead of requiring pre-computed children. Shows: “Hex Bolt M10” → “HB”, “A” → “A”, empty → fallback icon
AllSizesWithImagesm/default/lg side-by-side with a real image
AllSizesWithFallbacksm/default/lg side-by-side with initials
AllSizesWithBadgesm/default/lg with status badge overlay
ImageLoadErrorAvatar with broken image URL — shows fallback transition with Radix delayMs

Existing stories (Playground, Default, WithImage, WithFallback, WithBadge, Sizes, Group) remain unchanged.

Unit tests: extend existing Avatar tests (or add avatar-initials.test.tsx if file is large)

TestAssertion
renders initials from entityName prop"Hex Bolt" → text content "HB"
renders fallback icon for empty namefallback icon element present
existing fallback behavior unchangedpassing children still renders them (regression)
image load error shows fallbackbroken src → fallback content visible

Story file: src/components/canary/atoms/copyright-acknowledgment/copyright-acknowledgment.stories.tsx MDX file: src/components/canary/atoms/copyright-acknowledgment/copyright-acknowledgment.mdx Title: Components/Canary/Atoms/CopyrightAcknowledgment

StoryPurpose
PlaygroundFull controls: acknowledged (boolean toggle), onAcknowledge (action logger). Stakeholders can toggle the checkbox and see the action logged
UncheckedDefault unchecked state with full legal text visible
CheckedChecked state — shows visual confirmation
DisabledUncheckedDisabled + unchecked — demonstrates opacity-50 treatment when parent disables interaction
InDialogContextCopyrightAcknowledgment rendered inside a mock dialog footer alongside a disabled “Confirm” button. Checking the box enables the button — demonstrates the gate behavior stakeholders will see in the upload flow

MDX contents:

  • Overview: purpose, legal text, gate behavior
  • Canvas of Playground
  • “Gate Behavior” section with Canvas of InDialogContext
  • Props table

Unit tests: copyright-acknowledgment.test.tsx

TestAssertion
renders unchecked by defaultcheckbox role present, not checked
renders legal textexpected text content visible
calls onAcknowledge on clickvi.fn() called with true
toggles back to uncheckedsecond click calls with false
disabled state prevents interactionclick does not call onAcknowledge
disabled state applies opacitycontainer has opacity-50 class
checkbox has accessible labelaria-labelledby or associated <label>

Story file: src/components/canary/molecules/image-display/image-display.stories.tsx MDX file: src/components/canary/molecules/image-display/image-display.mdx Title: Components/Canary/Molecules/ImageDisplay

StoryPurpose
PlaygroundControls: imageUrl (text — paste any URL), entityTypeDisplayName (text), propertyDisplayName (text). Container size control (64/128/256px). Stakeholders can try different URLs and see states change
LoadedHappy-path image rendered in a 128×128 container
LoadingSkeleton shimmer in a 128×128 container (image URL set but <img> load simulated as pending)
ErrorStateBroken URL — shows initials placeholder with error-overlay Badge. Demonstrates the distinction from “no image”
NoImageimageUrl: null — initials placeholder without error badge
AllStates2×2 grid showing Loaded, Loading, Error, NoImage side-by-side for quick visual comparison
SizeResponsivenessSame image rendered in 32px, 64px, 128px, 256px containers — demonstrates that initials and error badge scale proportionally
WhiteImageContrastA white/light image on bg-muted container — demonstrates the deliberate no-border design decision (bg-muted provides sufficient contrast)

MDX contents:

  • Overview: foundational rendering molecule, three visual states
  • Canvas of Playground
  • “Visual States” section with Canvas of AllStates
  • “Design Decision: No Border” section with Canvas of WhiteImageContrast
  • Props table (Static/Init/Runtime sections)

Unit tests: image-display.test.tsx

TestAssertion
renders img element when imageUrl provided<img> with correct src
shows skeleton during loadingskeleton element visible before load event
shows loaded state after img loadskeleton removed, img visible
shows error state on img errorinitials placeholder + error badge visible
shows initials placeholder when imageUrl is nullinitials text visible, no error badge
initials derived from entityTypeDisplayName"Item""I"
no border on containercontainer does not have border class (deliberate)
fills parent containerwidth: 100%, height: 100% styles present
applies object-cover to imgimg has object-cover class

Story file: src/components/canary/molecules/image-drop-zone/image-drop-zone.stories.tsx MDX file: src/components/canary/molecules/image-drop-zone/image-drop-zone.mdx Title: Components/Canary/Molecules/ImageDropZone

StoryPurpose
PlaygroundControls: acceptedFormats (multi-select). Actions panel logs onInput and onDismiss events. Stakeholders can drag files, paste images, type URLs
IdleStateDefault idle appearance — dashed border, instructional text, upload button, URL field
DragOverHighlightSimulated drag-over state (border-primary, bg-accent) — uses a decorator that triggers the highlight on story mount for visual inspection
UrlEntryShows the URL text field focused with a sample HTTPS URL typed in — demonstrates the URL input path
ValidationErrorDrop zone after an invalid file type was provided — shows inline text-destructive error message with plain-language explanation
InputClassificationInteractive demo: a panel below the drop zone shows the classified ImageInput result. Stakeholders can try each input method and see the system’s classification in real time

MDX contents:

  • Overview: unified input surface, 6 input methods
  • “Try It” section with Canvas of Playground
  • “Input Methods” section explaining each method with Canvas of InputClassification
  • “Drag Highlight” section with Canvas of DragOverHighlight
  • “Error Handling” section with Canvas of ValidationError
  • Props table

Unit tests: image-drop-zone.test.tsx

TestAssertion
renders drop area with dashed bordercontainer with border-dashed class
renders upload buttonbutton with expected label visible
renders URL text fieldinput element with URL-related placeholder
calls onInput with file data on dropsimulate drop event, vi.fn() called with file input
calls onInput with URL on text submittype URL + submit, callback receives URL input
shows error for invalid file typeprovide wrong MIME type, error message in text-destructive visible
calls onDismiss on dismiss clickdismiss button click calls callback
drag-over adds highlight classessimulate dragenter, container gets border-primary class
drag-leave removes highlightsimulate dragleave, border-primary removed
rejects non-HTTPS URLtype http://..., error message shown

Story file: src/components/canary/molecules/image-preview-editor/image-preview-editor.stories.tsx MDX file: src/components/canary/molecules/image-preview-editor/image-preview-editor.mdx Title: Components/Canary/Molecules/ImagePreviewEditor

StoryPurpose
PlaygroundControls: aspectRatio (number), imageData (select from preset URLs). Actions panel logs onCropChange data. Stakeholders can crop, zoom, rotate, pan, reset
DefaultViewImage loaded at 1:1 aspect ratio with toolbar visible — initial state before any edits
ZoomControlImage pre-zoomed to ~2× with the Slider control visible — demonstrates zoom range and slider interaction
RotateStepsFour side-by-side renders of the same image at 0°, 90°, 180°, 270° — demonstrates 90-degree rotation increments
CropAndPanImage with a visible crop overlay and the pan cursor active — demonstrates repositioning within the locked aspect ratio
ResetBehaviorInteractive: user makes edits (zoom + rotate), clicks Reset, image reverts — demonstrates the reset control
UrlImageLoadingImagePreviewEditor with a slow-loading URL — shows shimmer placeholder with timeout message after 5s

MDX contents:

  • Overview: locked aspect ratio, edit operations (crop, zoom, rotate, pan, reset)
  • Canvas of Playground
  • “Edit Operations” section with Canvas of ZoomControl, RotateSteps
  • “Reset” section with Canvas of ResetBehavior
  • Props table

Unit tests: image-preview-editor.test.tsx

TestAssertion
renders crop area with locked aspect ratiocontainer with correct aspect ratio class
renders toolbar with edit controlszoom slider, rotate button, reset button visible
calls onCropChange when crop state changescallback receives crop data object
rotate button increments by 90 degreesclick rotate, callback receives rotation value
reset button calls onResetclick reset, onReset callback called
shows shimmer during URL image loadskeleton visible when image data is a URL

Note: react-easy-crop internal behavior (drag/pinch) is not unit-tested — those are integration concerns verified via stories.


Story file: src/components/canary/molecules/image-hover-preview/image-hover-preview.stories.tsx MDX file: src/components/canary/molecules/image-hover-preview/image-hover-preview.mdx Title: Components/Canary/Molecules/ImageHoverPreview

StoryPurpose
PlaygroundControls: imageUrl (text), entityTypeDisplayName (text), propertyDisplayName (text). Child is a 64×64 ImageDisplay. Hover to see popover
HoverToPreviewA small thumbnail (64×64) that opens a ~256×256 popover on hover after 500ms delay. Annotated with timing note for stakeholders
NoPreviewOnErrorThumbnail with broken URL — hover does NOT open the popover. Demonstrates the suppression behavior
MultipleInRowThree thumbnails in a horizontal row — hover each one independently. Demonstrates that only one popover is open at a time

MDX contents:

  • Overview: lightweight hover popover, 500ms delay, not a modal
  • Canvas of HoverToPreview
  • “Error Suppression” section with Canvas of NoPreviewOnError
  • Props table

Unit tests: image-hover-preview.test.tsx

TestAssertion
renders children (trigger element)child content visible
does not show popover initiallypopover content not in DOM
shows popover on hover after delaymouseenter + wait, popover content visible
hides popover on mouse leavemouseleave, popover content removed
does not show popover when imageUrl is broken/errorhover over error state, no popover
renders ImageDisplay inside popoverpopover contains img element

Story file: src/components/canary/molecules/image-inspector-overlay/image-inspector-overlay.stories.tsx MDX file: src/components/canary/molecules/image-inspector-overlay/image-inspector-overlay.mdx Title: Components/Canary/Molecules/ImageInspectorOverlay

StoryPurpose
PlaygroundControls: imageUrl (text), open (boolean), onEdit (action/undefined toggle). Button to open the overlay
ViewOnlyOverlay with image at large size, close button, no Edit button (onEdit undefined). Dismiss via Escape, click-outside, or close button
WithEditButtonSame overlay but with Edit button visible (onEdit provided). Clicking Edit logs the action and closes the overlay
DismissMethodsInteractive: instructions panel showing three dismiss methods (Escape, click-outside, close button) with action log confirming each

MDX contents:

  • Overview: read-only full-size image modal, optional edit transition
  • Canvas of Playground
  • “Inspector to Edit Transition” section with Canvas of WithEditButton
  • “Dismiss Methods” section with Canvas of DismissMethods
  • Props table

Unit tests: image-inspector-overlay.test.tsx

TestAssertion
renders nothing when open is falseno dialog in DOM
renders image when open is truedialog with img element visible
calls onClose on Escape keypress Escape, onClose called
calls onClose on close button clickclick close button, onClose called
shows Edit button when onEdit providedbutton with “Edit” label visible
hides Edit button when onEdit undefinedno Edit button in DOM
calls onEdit on Edit button clickclick Edit, onEdit called
image has correct srcimg src matches imageUrl prop

Story file: src/components/canary/molecules/image-comparison-layout/image-comparison-layout.stories.tsx MDX file: src/components/canary/molecules/image-comparison-layout/image-comparison-layout.mdx Title: Components/Canary/Molecules/ImageComparisonLayout

StoryPurpose
PlaygroundControls: existingImageUrl (text — set to null to remove comparison), entityTypeDisplayName (text), propertyDisplayName (text). Children: a static ImagePreviewEditor
DesktopSideBySideWide viewport: existing image (small) alongside new image (large) with “Current”/“New” labels
MobileTabsNarrow viewport (via Storybook parameters.viewport): tabbed view showing “Current” and “New” tabs. Stakeholders use Storybook viewport addon to see both layouts
NoExistingImageexistingImageUrl: null — only the new image preview is shown, no comparison layout
ExistingImageBrokenExisting image URL is broken — shows initials placeholder with error badge in the “Current” panel

MDX contents:

  • Overview: responsive comparison layout, desktop vs. mobile
  • Canvas of DesktopSideBySide
  • “Mobile Layout” note directing stakeholders to use viewport addon
  • “No Existing Image” section with Canvas of NoExistingImage
  • Props table

Unit tests: image-comparison-layout.test.tsx

TestAssertion
renders side-by-side on desktop viewportboth “Current” and “New” labels visible
renders existing image via ImageDisplayexisting image src present
renders children (new image preview)children content visible
hides comparison when existingImageUrl is nullonly children rendered, no “Current” label
shows initials placeholder for broken existing imageinitials + error badge in “Current” panel
labels are correct“Current” and “New” text present

Story file: src/components/canary/molecules/form/image/image-form-field.stories.tsx MDX file: src/components/canary/molecules/form/image/image-form-field.mdx Title: Components/Canary/Molecules/Form/ImageFormField

StoryPurpose
PlaygroundControls: imageUrl (text), disabled (boolean), config (JSON — uses ITEM_IMAGE_CONFIG default). Actions log onChange
WithImageForm field showing a current image with hover affordances (eye, pencil, trash icons)
WithoutImageForm field showing initials placeholder with hover affordances (eye suppressed, pencil visible, trash hidden)
HoverAffordancesAnnotated story: hover over the field to see action icons appear. Labels identify each icon’s function
DisabledField in disabled state — opacity-50, no interaction
RemoveFlowInteractive: field with image → hover → click trash → AlertDialog confirmation → confirm → field shows placeholder. Demonstrates the full remove interaction
EditFlowInteractive: field with image → hover → click pencil → ImageUploadDialog opens (mock). Demonstrates the edit entry point

MDX contents:

  • Overview: form field renderer, unified hover interaction model
  • Canvas of Playground
  • “Hover Affordances” section with Canvas of HoverAffordances
  • “Remove Flow” section with Canvas of RemoveFlow
  • Props table (config, imageUrl, onChange, disabled)

Unit tests: image-form-field.test.tsx

TestAssertion
renders ImageDisplay with current imageimg element with correct src
renders initials placeholder when no imageinitials visible
shows action icons on hovermouseenter, eye/pencil/trash icons appear
hides action icons when not hoveredicons not visible by default
eye icon suppressed when no imagehover, only pencil visible
trash icon hidden when no imagehover, no trash icon
pencil click calls upload dialog triggerclick pencil, expected callback fired
trash click opens remove confirmationclick trash, AlertDialog content visible
confirm remove calls onChange with nullconfirm in AlertDialog, onChange called with null
disabled state shows opacity and blocks interactionopacity-50 class, hover does not show icons
applies config display namesentityTypeDisplayName used for placeholder

Story file: src/components/canary/atoms/grid/image/image.stories.tsx MDX file: src/components/canary/atoms/grid/image/image.mdx Title: Components/Canary/Atoms/Grid/Image

Uses a simplified 4-column AG Grid: Image, Name, SKU, Unit Cost.

StoryPurpose
PlaygroundControls on config fields. 4-row grid with MOCK_ITEMS data. Stakeholders can interact with image cells
GridDisplayStatic grid showing all four visual states across rows: loaded image, alternate image, no image (placeholder), broken image (error badge)
HoverPreviewInstruction: hover over a loaded image cell. After 500ms the ImageHoverPreview popover appears. Hover off to dismiss
HoverActionIconsInstruction: hover over an image cell. Eye and pencil icons appear. Annotated labels show what each icon does
InspectorFromGridInstruction: hover → click eye icon → ImageInspectorOverlay opens showing full-size image. Close with Escape
DoubleClickEditInstruction: double-click an image cell. AG Grid enters edit mode and ImageUploadDialog (mock) opens
KeyboardEditInstruction: navigate to image cell with Tab/arrow keys, press Enter. Edit mode activates
ErrorCellNoInspectorBroken image cell — hover shows action icons but eye icon is suppressed (no URL to inspect). Only pencil icon is available

MDX contents:

  • Overview: AG Grid cell renderer, Display + Editor pair pattern
  • Canvas of GridDisplay
  • “Interactions” section: HoverPreview, InspectorFromGrid, DoubleClickEdit
  • “Keyboard Navigation” section with Canvas of KeyboardEdit
  • Props table (config, value, data)

Unit tests: image-cell-display.test.tsx

TestAssertion
renders ImageDisplay with value as imageUrlimg element with correct src
renders placeholder when value is nullinitials visible
passes config to ImageDisplayentityTypeDisplayName from config used
shows hover affordances on mouseenteraction icon elements appear
hides affordances on mouseleaveicons removed
eye icon suppressed for null/error imagesno eye icon when no valid URL

Note: AG Grid integration (double-click, startEditingCell, keyboard nav) is tested via stories and manual verification, not unit tests, due to AG Grid’s DOM complexity.

The cell editor is an invisible wrapper. Its story is embedded in the ImageCellDisplay grid stories above (DoubleClickEdit, KeyboardEdit).

One additional standalone story demonstrates the factory pattern:

Story file: same as ImageCellDisplay (image.stories.tsx)

StoryPurpose
EditorFactoryDemoCode snippet + rendered grid showing column definition with createImageCellEditor(ITEM_IMAGE_CONFIG). Double-click opens ImageUploadDialog mock. On confirm, cell value updates. On cancel, no change

Unit tests: image-cell-editor.test.tsx

TestAssertion
exposes getValue via refref.current.getValue() returns current value
getValue returns original value on cancelafter cancel, returns initial value
getValue returns new URL on confirmafter simulated confirm, returns new URL
renders invisible placeholdercomponent renders with no visible content
createImageCellEditor factory returns componentfactory call produces a valid React component

Story file: src/components/canary/organisms/shared/image-upload-dialog/image-upload-dialog.stories.tsx MDX file: src/components/canary/organisms/shared/image-upload-dialog/image-upload-dialog.mdx Title: Components/Canary/Organisms/Shared/ImageUploadDialog

StoryPurpose
PlaygroundControls: open (boolean), existingImageUrl (text/null), config (JSON). Actions log onConfirm, onCancel
EmptyImageStateDialog open with no existing image. Shows ImageDropZone in its initial state
ProvidedImageStateDialog open with a mock file already “provided” — shows ImagePreviewEditor with crop controls, CopyrightAcknowledgment unchecked, Confirm button disabled
ComparisonModeDialog open with existingImageUrl set — shows ImageComparisonLayout with existing image alongside new image preview
ValidationErrorDialog showing inline error after invalid file type — plain-language error message in text-destructive, retry affordance
CopyrightGateInteractive: image provided, copyright unchecked → Confirm disabled. Check copyright → Confirm enables. Demonstrates the gate
UploadProgressMock upload in progress — shows progress bar (bg-primary fill on bg-muted track), Confirm button replaced by progress indicator
WarnOnDiscardInteractive: image is staged (ProvidedImage state) → click Cancel → AlertDialog “Discard unsaved image?” appears → “Discard” closes everything, “Go Back” returns to ProvidedImage
FullHappyPathEnd-to-end: open dialog → drag/select file → preview appears → crop/zoom → check copyright → click Confirm → progress bar → dialog closes → onConfirm logged with result

MDX contents:

  • Overview: state machine orchestrator, 4 interaction states
  • State diagram (PlantUML): PlantUML diagram
  • Canvas of EmptyImageState, ProvidedImageState, ComparisonMode
  • “Validation” section with Canvas of ValidationError
  • “Copyright Gate” section with Canvas of CopyrightGate
  • “Warn on Discard” section with Canvas of WarnOnDiscard
  • “Happy Path” section with Canvas of FullHappyPath
  • Props table

Unit tests: image-upload-dialog.test.tsx

TestAssertion
renders nothing when open is falseno dialog in DOM
renders ImageDropZone in EmptyImage statedrop zone visible when open, no image
transitions to ProvidedImage on image inputsimulate file input, preview area visible
shows CopyrightAcknowledgmentcheckbox with legal text visible
Confirm button disabled when copyright uncheckedbutton has disabled attribute
Confirm button enabled when copyright checkedcheck box, button no longer disabled
calls onCancel on cancel click (no staged image)cancel click, onCancel called
shows Warn dialog on cancel with staged imagestage image, cancel, AlertDialog visible
Warn discard calls onCancelclick Discard in Warn, onCancel called
Warn go-back returns to ProvidedImageclick Go Back, back to preview
shows validation error for invalid fileprovide invalid file, error text in text-destructive visible
calls onConfirm with result on confirmcheck copyright, click confirm, onConfirm called
shows progress bar during uploadmock upload in progress, progress element visible
comparison layout shown when existingImageUrl setexisting image panel + new image panel
no comparison layout when existingImageUrl nullonly drop zone / preview shown

No new story file. The existing item-grid stories are updated to use ImageCellDisplay instead of the inline ImageCellRenderer. Visual output is identical — this is a structural replacement, not a new story.

After all components are implemented, src/canary.ts is updated with exports for all new components and types. node tools/update-package-contents.js is run to regenerate package contents documentation.


#ComponentWaveTest FileTestsStoriesMDX
U1getInitials1utilities/get-initials.test.ts8shared
U2getCroppedImage1utilities/get-cropped-image.test.ts5shared
3Badge1badge.test.tsx (extend)+4+3 storiesextend
4Avatar1avatar test (extend)+4+5 storiesextend
5CopyrightAcknowledgment1copyright-acknowledgment.test.tsx75 storiesnew
6ImageDisplay1image-display.test.tsx98 storiesnew
7ImageDropZone1image-drop-zone.test.tsx106 storiesnew
8ImagePreviewEditor1image-preview-editor.test.tsx67 storiesnew
9ImageHoverPreview2image-hover-preview.test.tsx64 storiesnew
10ImageInspectorOverlay2image-inspector-overlay.test.tsx84 storiesnew
11ImageComparisonLayout2image-comparison-layout.test.tsx65 storiesnew
12ImageFormField2image-form-field.test.tsx117 storiesnew
13ImageCellDisplay3image-cell-display.test.tsx68 storiesnew
14ImageCellEditor3image-cell-editor.test.tsx51 storyshared
15ImageUploadDialog3image-upload-dialog.test.tsx159 storiesnew
P-1skeleton (context)03 stories
P-2tabs (context)02 stories
P-3alert-dialog (context)02 stories
Totals15 test files (12 new + 3 extend)~115 tests79 stories12 MDX

#ItemNotes
1HEIC/HEIF browser testingheic2any is lazy-loaded; stories should include a note that HEIC testing requires an actual .heic file (not mockable via URL)
2Popover vs. Tooltip for ImageHoverPreviewStories assume Popover primitive; if Tooltip is chosen instead, positioning behavior may differ slightly

Copyright: (c) Arda Systems 2025-2026, All rights reserved