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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Affordance | Request | Failure the UI must handle |
|---|---|---|
| Tab activation | GET /products/{prod_}/images | Error state with retry |
| Dropzone / Add image | POST …/images (multipart file, altText) | 413 size · 400 ×6 rejections · 409 image_limit_reached · 409 upload_contended · 400 storage_not_configured |
| Delete | DELETE …/images/{pimg_} → 204 | Idempotent — an already-deleted image also answers 204 |
| Drag to reorder | PUT …/images/reorder (complete ordered set) | 409 ordering_mismatch → refetch |
| Set as primary | PUT …/images/{pimg_}/primary | 404 if the image is under another product |
| Save alt text | PATCH …/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 |