v0.72.0 — Image Manipulation, phases 3–4
v0.70.0 made an image operation a property of an image rather than of a screen. It changed almost nothing a user can see, and that was the deliberate cost of putting the data model first. This is the release that pays it back: one dialogue, and a Retouch button on every surface that shows an image the user owns.
The grid in § Where an image can be used is the target. Ten surfaces, five capabilities, and today the Token modal has nearly all of them only because every capability was added to it first and to nothing else — the Library, the campaign card and the user avatar have none. After this release the answer to "can I remove the background here?" stops depending on which modal you opened.
Spec: 17-image-manipulation §6–§7. 14 tasks, one commit each, in order. Phase 3 builds the dialogue; Phase 4 is one task per surface, each small and independently shippable.
Phase 3 is where a cut-short release should stop. A dialogue nobody can open is useless, but half the surfaces wired to a working dialogue is a real improvement.
Decisions, settled 2026-08-27
§11 had two open for this release. Both are answered; D4, D5 and D7 open with v0.73.0.
- D1 — the sliding modal, confirming what the spec assumed, so §6 needs no revision.
ArtworkModalShell+ the pureartworkModalNavalready implement it, are already shared by two owners, and are already unit-tested. The alternatives each cost something concrete rather than being merely unfamiliar: a drawer adds a second overlay layer with its own z-index argument and its own Escape handling, on a codebase whoseCLAUDE.mdcarries a standing note about Mantine popups hiding behind things; and stacked modals are precisely whatArtworkModalShellexists to avoid — its contract test asserts there is exactly one<Modal>and that the sub-panes contain none, so picking them would mean deleting a guard rather than satisfying one. - D8 — ephemeral mode for images that are not
ArtworkEntitys. A library item or a user avatar has novariants[]to write into, and giving them artwork records is a schema change plus a migration in both dialects that drags 15-library-overhaul's storage model into this release. Retouch instead runs on a bare URL and produces one replacement image with no history. The obligation that creates is honesty, not consistency: it must say it is not keeping a history rather than showing an empty variant strip that reads as broken. One button with two behaviours is acceptable only when the second announces itself.
Phase 3 — The Retouch dialogue
- [x] 3.1 — The Retouch shell. One component per
§2.3,
built on
ArtworkModalShelland the pureartworkModalNavmodel (D1). Nav stays a pure function with unit tests, as it is today. Remember the half-unit slide arithmetic:translateX(%)resolves against the element's own width, so a modal-relative offset must be re-expressed as a fraction of the track. - [x] 3.2 — The variant strip. Original plus variants, transparency on a checkerboard,
a provenance label per variant, select-to-preview. Thumbnails are background-image
divs, not
<img>: a native image-drag bypasses the wrapper'sonDragStart, the bug fixed in v0.33.0 and again in v0.47.0, and the reason the campaign strip has no inner<img>at all. - [x] 3.3 — The operation list. Every registered operation with its cost badge;
unavailable ones disabled with the reason from
isAvailable. v0.70.0's registry already returns unavailable operations rather than filtering them out, and carries a sentence rather than a boolean, precisely so this list can be built. - [x] 3.4 — Run, with progress and cancellation. A 30-second in-browser upscale needs a
progress bar and a working cancel.
ImageOperationInputalready declaresonProgressandsignal, and genai's upscale operation already reports both halves of its work (model load, then tiles). - [x] 3.5 — Disposition of the primary result. Use for this slot / use for every slot on this artwork / keep as a variant only. Nothing is written until the user chooses. "Use for every slot" is the honest name for what background removal used to do silently, and naming it is what makes v0.47.0's concern — a portrait edit changing the token — stop being a reason to hide the feature.
- [x] 3.6 — The z-index check. Any Select or popover in this dialogue needs a
comboboxProps.zIndexabove the modal layer. This has its own line inCLAUDE.mdbecause it recurs. It also builds the dialogue's first dropdown: controls for the inputs an operation declares (direction,prompt), without which extend reaches Retouch as a bare Run button that always grows in every direction — strictly less than the bespoke modal task 4.4 replaces. The guard enumerates the directory rather than asserting per component, because the failure mode is a new overlay added later.
Phase 4 — Every image surface
- [x] 4.1 — Token modal. Replace the portrait-slot background-removal drawer and
TokenArtworkEditor's inline panel with Retouch. Net deletion. - [x] 4.2 — Character sheet Artwork modal. Retouch on the source art, which is where processing honestly belongs (v0.47.0's reasoning, now with the per-slot variant selection from v0.70.0 task 1.2 that makes it safe).
- [x] 4.3 — Campaign Artwork tab. Card and page background. It already uses
ArtworkModalShell, so this is a prop rather than a port. - [x] 4.4 — Map background token. Replace
BackgroundTokenBar's bespoke "Extend This Image" modal with Retouch. Extend arrives as one operation among several, and upscaling reaches an existing battlemap for the first time. - [x] 4.5 — Map objects. The objects layer, through the same token modal path, with the
object-appropriate compression category preserved (
mapObject, nottoken— the 512px downscale bug has recurred five times). Observed and deliberately not fixed here: a shape-changing operation does not resize an object token in proportion the way it resizes a background one (4.4). The background case is load-bearing — grid alignment depends on it — and the object case is a judgement about framing that the user already made.widthMultiplierreaches the caller either way, so this is a wiring decision rather than a missing capability; it belongs with phase 7's auto-framing rather than smuggled into a task about a compression category. - [x] 4.6 — Monster and encounter generators. Both already host the token drawers; both get Retouch on generated art.
- [x] 4.7 — Library items, in ephemeral mode (D8). A library item has a real
asset_urlsince v0.66.0, so there is a full-resolution image to manipulate — what it has no room for is a variant list. Retouch runs, produces one replacement, and says it is not keeping a history. - [x] 4.8 — A contract test that enumerates the surfaces. Every surface that renders an image the user owns either hosts Retouch or is on a declared exception list. This is the test that stops the feature re-scattering, and the exception list is where the user avatar is named until it earns a real answer.
Reported during the release
- [x] The server would not fetch its own images. Server-side background removal failed
locally on any image the app had just uploaded. Not the third party being unable to
reach
localhost— this route never gives a URL to a provider, it fetches the bytes and posts them — but our own SSRF blocklist refusing our own origin. Both AI removers have been untestable in development for as long as they have existed, failing with an error that named the wrong component. The exemption is the request's own host and port, solocalhost:3001is us andlocalhost:9200is not. - [x] The OpenAI remover had never worked.
POST /v1/images/editsrequiresmodeland the route never sent one, so every call it has ever made returned 400 "Missing required parameter: 'model'". Nothing said so, because the route reported the status code alone and discarded OpenAI's own sentence — the user sawOpenAI API error: 400while the answer sat in a server log they cannot read. Fixed with the model (defaultgpt-image-1, overridable),background: transparent+output_format: pngwithout which the result is a subject on a white square rather than a cutout, the provider's message forwarded, and the same opaque-result refusal the Gemini remover makes. - [x] A retouched variant could not be used as token or portrait art. The reported
symptom: you can make a cutout, see it in the Retouch strip and switch to it there,
and then not get it onto anything. The cause is bigger than the dialogue —
nothing in the application reads a slot-variant binding. v0.70.0 built
tokenVariantId,portraitVariantIdandartworkImageForSlot, and acrossapps/webthose two fields are only ever cleared, while the resolver has one caller: a preview inside one drawer. So "Use for the token" wrote a field no renderer looks at, and every test of it passed, because a unit test can only check the write it was pointed at. Making the binding real needs a variant selector on the token preview, the portrait preview, the detail pages and the campaign editor — a worthwhile feature, and not this one. Instead a result can now be saved as new, independent source art, assigned by the strip-and-drag machinery that already exists and already works. The two slot choices are gone rather than left lying, and a guard pins the count ofartworkImageForSlotreaders so the day that number rises is the day the binding is worth reconsidering. The variant model itself stays: it is what the strip and its provenance are built on. - [x] Every preview of a cutout was blank. Removing a background looked like it
"returned nothing": the run succeeded, the variant was made, the disposition pane
offered to use it, and the picture was nowhere. The checkerboard constant was a
backgroundshorthand — it carried50% / 12px 12px— interpolated intobackground-image, which takes images and nothing else. Chromium rejects the whole declaration, including the picture, and only when a second layer exists — which is exactly whenhasAlphais true, which is exactly when the image is a cutout. An opaque image rendered fine, which is why nothing looked wrong until a removal succeeded. Now one helper returns the whole parallel set ofbackground-*properties, and its grammar is tested rather than its spelling: a source-text guard can confirm the checkerboard is mentioned, but not that the value it is mentioned in is valid. It also quotes theurl(), whichcampaignImagePlacementalready did and this code did not — a second latent bug, on any filename containing a bracket. - [x] A failed run stopped offering the other providers. The alternatives rendered only on success, so an error left the user able to re-run the model that had just failed and nothing else — when a failure is precisely the moment to be offered another. They now show on failure too, and the one that failed is excluded rather than the default, which needed remembering the attempt: a failed run leaves no result to read it from.
- [x] A fourth background remover: Gemini. For people whose only image key is Gemini,
who previously had the browser model, BiRefNet and a button that answers 501.
Offered only when the user has their own Gemini key — a row that can only 501 is the
unexplained disabled control this release exists to remove — via a new
requiresUserKeyon the catalogue entry, which nothing else sets. The result is checked for actual transparency before it is stored. Gemini's image models are generative, and asked to remove a background they will often paint a white one instead: an opaque PNG that looks right in a thumbnail, is not a cutout, and shows as a white rectangle the moment it is composited over a map. An opaque result is refused with a message saying so.