Product Images — Images tab

Created: 2026-08-13 | Last Updated: 2026-08-18

Composition brief for the portal's product Images tab (issue #1100, backend ADR-0127). Every panel below is one state the tab must handle. Visual treatment is illustrative — see the authority note in this file's source.

Panel 1 · Empty — no images yet

Product images

Shown in the catalog, the POS and the online store.
0 of 10

Drag images here or click to upload

PNG, JPEG, WebP, or iPhone HEIC — up to 8 MB

Maximum 10 images per product

One dropzone, no grid. The counter's denominator comes from items.length + remainingSlots — never a hardcoded 10, so a merchant whose cap is raised server-side reads the real number. The size hint mirrors ObjectStorage:MaxUploadBytes; the 413's title is what a user sees when the two disagree.

Panel 2 · Populated — 4 images

Product images

Shown in the catalog, the POS and the online store.
4 of 10
Primary medium
Front of the box, label facing camera
medium
Back of the box with the ingredient panel
medium
Add description for accessibility
medium
Scale shot next to a coffee cup
+ Add another image

Order matters and is the point of this panel: grid first, dropzone last. The card count per row ramps 5 → 4 → 3 → 2 as the viewport narrows; that sequence is the composition. The exact widths it turns at are visual treatment, so the shipped page uses the DS breakpoints (Tailwind lg/md/sm — 1024 / 768 / 640) rather than the round numbers this mockup's own CSS happens to use. Per ADR-0118 decision 1 — clause (c) of the Mockups-vs-DS procedure in .claude/rules/frontend-ui.md, whose lettering this is — that delta is not a finding. Do not "fix" the code to match the media queries above. Element counts per card: one primary badge (on the primary card only), one drag handle, one alt-text line, and four action buttons — reduced to three on the primary card, which has no "Set as primary". Every action button is 44×44 px and carries an aria-label naming both the action and the image's position, since the icon alone identifies neither. Each card renders mediumUrl; the grid is the only place the images appear on this tab, and it comes first — the dropzone sits BELOW it (see the panel ordering above). Every <img> carries explicit width/height and loading="lazy" — which is exactly why nothing may be stacked above the grid: a lazy image below the fold is never even requested. An earlier revision of this note said the grid "sits directly under the dropzone", which is the order this design deliberately abandoned for that reason.

Panel 3 · Reorder in progress
medium · source
Front of the box, label facing camera
medium · drop target
Back of the box with the ingredient panel
medium
Add description for accessibility
medium
Scale shot next to a coffee cup

Source card dimmed, a purple edge marking the insertion point. Reorder submits the complete ordered id list; a 409 ordering_mismatch means someone else changed the set, so the tab refetches instead of retrying the same body. prefers-reduced-motion disables the drag transitions — the reorder still works, it just does not animate. Keyboard reorder is the same dnd-kit sortable: focus the handle, then arrow keys.

Panel 4 · Alt-text inline edit
Primarymedium
37 / 500
medium
Add description for accessibility
medium
Scale shot next to a coffee cup
medium
Lid detail

One card expands; the others stay put. Counter is length / 500 — the cap is FieldLengths.ProductImage.AltText, matched exactly so the field cannot submit something the server truncates. Saving sends the row's rowVersion; a 409 concurrency_conflict keeps the typed text on screen and offers a reload, because discarding what the operator wrote is worse than asking twice.

Panel 5 · Converting / uploading
Primarymedium
Front of the box
medium
Add description for accessibility
Uploading…

Exactly ONE placeholder, and it comes LAST — after every existing card. Not one per file: the uploader awaits each file to completion before starting the next and serialises whole batches, so exactly one file is ever in flight and a second placeholder is unreachable. An earlier version of this panel showed two, before the cards, and described "one placeholder card per file, in the order the files were dropped" — a state the shipped UI cannot enter. HEIC conversion swaps the label on that same card to Converting iPhone photo…, because it happens before any request leaves the browser and takes visibly longer. A spinner, not a percentage: fetch reports no upload progress, so a bar would be fiction.

Known consequence worth watching. Because the placeholder is appended, a drop onto a product that already holds 6–8 images lands it below the fold at 375px (two columns) — the operator sees no feedback in the viewport even though the upload is running. The batch summary toast is what covers that today; a scroll-into-view on the placeholder would close it properly.

Panel 6 · Cap reached — remainingSlots = 0

Product images

10 of 10

Drag images here or click to upload

Disabled — the product is at its image limit

Driven by remainingSlots === 0, not by counting rows against a local constant. The dropzone is disabled rather than hidden, so the affordance stays where the user expects it and the banner explains why it will not take a file.

Panel 7 · Offline
Primarymedium (cached)
Front of the box
medium (cached)
Add description for accessibility

Cached images stay on screen and the write affordances go disabled. Images are online-only in v1 (ADR-0127 decision 13) — nothing here promises an offline queue.

Panel 8 · Delete confirmation

Two lines, two buttons — a binary decision, so ConfirmDialog and not a Drawer. Deleting the primary promotes the next image automatically; the copy does not offer to pick a replacement because the server has already decided.

Panel 9 · Zoom modal

This is the only place ProductImageGallery appears — the hero reads largeUrl and the strip tinyUrl. An earlier revision of this brief also put it inline above the grid at mediumUrl; that panel is gone (see Panel 2), and so is the size prop that served it. The caption is the alt text — visible here rather than only in the alt attribute, because this is the one view where checking the description against the picture is the point. Arrow keys move between images, Escape closes.

Panel 10 · Toasts
The image exceeds the maximum upload size.
Images must be PNG, JPEG or WebP.
Image uploaded

Upload rejections carry the server's title, not a locally invented one. All six 400 rejections share code: "invalid_image", so the code cannot tell them apart — only the title names which rule the file broke. Locally detectable problems (an unsupported extension, a failed HEIC conversion) use the local images.errors.* strings, since no request was made.

Panel 11 · Product list thumbnail — already shipped
tiny
Colombian Dark Roast 12 oz
Ceramic Pour-Over Dripper

Mostly not new work: ListProducts already projected the primary image's tiny URL and both ProductListPage and CategoryDetailPage already rendered it with an ImageOff fallback for a null URL. Shown so the tab's composition is read next to the surface it feeds.

What this PR did change here, since an earlier version of this note claimed it was only two attributes: the two identical inline blocks were extracted into a shared core/components/ProductThumbnail.tsx, which adds explicit width/height and an onError fallback that neither call site had — a NON-null URL whose bytes are gone (an orphaned blob; the delete purge is best-effort) used to leave the browser's broken-image glyph in the 40px box. It also resets that failure when the row's URL changes, because DataTable keys non-selectable rows by array index and so hands one component instance a different product across paging and sorting.

Deliberately NOT reported to Sentry, unlike the Images tab's own surfaces: a list page renders up to pageSize of these at once, so one storage outage would emit a burst of identical events for a single cause. The Images tab is the diagnostic surface.

Contract — what each affordance calls
AffordanceRequestFailure the UI must handle
Tab activationGET /products/{prod_}/imagesError state with retry
Dropzone / Add imagePOST …/images (multipart file, altText)413 size · 400 ×6 rejections · 409 image_limit_reached · 409 upload_contended · 400 storage_not_configured
DeleteDELETE …/images/{pimg_} → 204Idempotent — an already-deleted image also answers 204
Drag to reorderPUT …/images/reorder (complete ordered set)409 ordering_mismatch → refetch
Set as primaryPUT …/images/{pimg_}/primary404 if the image is under another product
Save alt textPATCH …/images/{pimg_}/alt-text (+ rowVersion)409 concurrency_conflict → keep the text, offer reload
Any <img>GET /api/img/{mrch_}/{token}/{tiny|medium|large}Null URL → the ImageOff placeholder, never a broken glyph