v0.83.0 — Two ways to make the map bigger
A user pointing at a battlemap and saying "make it bigger" means one of two things, and the app currently does a blurry version of both. Upscaling raises resolution and should leave the footprint alone; resizing changes the footprint and should leave the pixels alone. Today an upscale silently resizes the token it came from, and there is no way to resize a background token at all except by dragging a corner. Two specs, twenty-four tasks, and the reason they ship together is that keeping them distinct is the whole of what makes either one predictable.
The upscaler is not missing. genai 0.7.0 has registered upscaleOperation
since it shipped — Real-ESRGAN over WebGPU with a WASM fallback, at 2×, 3× and
4×. Three things stood between that and a user: production pinned a genai that
predated it (fixed by v0.79.0 — the pin is 0.9.0 and this release inherits it),
the Retouch dialogue has no way to ask for a factor so every run is 2×, and an
upscale returns widthMultiplier / heightMultiplier, which mean "this image
now covers more ground" — true of extend, false of upscale.
All ten decisions across the two specs are resolved (2026-09-09) and taken at their written recommendation. The four that change what gets built: 4× is warned about, not capped — the operator cannot know the user's machine, and a cap computed from a guess forbids runs that would have worked; a centre-anchor resize of an odd-sized snapped token rounds, and says on screen that it did; anchors are defined in unrotated grid space, because the alternative is a bounding-box computation that makes ×2 not exactly ×2; and the ÷2/÷3/÷4 reciprocals are in scope, because they are the same helper with a fractional factor and the only practical undo for a misfired ×4.
Both specs were reconstructed from git history. v0.81.0's migration condensed 26 and 27 from 150 and 145 lines to 30 and 26, and what it dropped was both task lists and all ten decisions — the two things a build reads. Restoring them is S.0 below, and it is the first commit for a reason: the roadmap page is supposed to be where the work lives.
Module release this cut: genai 0.10.0 (the factor input and the
multiplier removal), and a Module API minor bump for the new input kind.
Before either — the specs come back
- [x] S.0 — Restore both specs. The task lists and all ten decisions that v0.81.0's migration dropped go back onto the two roadmap pages, with the decisions recorded as resolved rather than open. One commit, because they were lost in one, and the first commit, because the roadmap page is supposed to be where the work lives.
Riding along — four GitHub issues
Picked up while waiting on genai 0.10.0 to publish. Each was a quick win; the
fifth open issue was not, and is left alone with a note below.
- [x] #69 — CI ran on GitHub's retired Node 20 runtime. Reported against
migration-guardand true of every job in every workflow, so all four files are bumped rather than the one that warned:actions/checkoutv4→v5,actions/setup-nodev4→v5,pnpm/action-setupv2→v4. The bump exposed a second thing: every job pinnedversion: 9while this repository has declaredpnpm@10.23.0since the Node 26 upgrade, so CI resolved the dependency graph with a different major than every developer. The input is gone andaction-setup@v4readspackageManager— which it must, because v4 fails when both are given and disagree. - [x] #68 — the app told users their API keys never leave the browser.
aiConfigStoresaid "never sent to server" whileaiHeadersputs them on outbound requests at five sites, one of which serialises every stored provider key into a single header. The settings panel said only "stored locally in your browser" — true, and read as a promise. Both now describe the real path, and a guard asserts the claim and the code cannot disagree again. The behaviour is unchanged and the product question in the issue is still open: sending a key so the server can proxy the provider call is a reasonable design, it was just never disclosed. - [x] #72 — an existing character arrived on the map neutral. Every creating
flow already picked the right faction; the one that routed through the
Create Token modal was the only one whose choice was thrown away, because
TokenModalInitialDatahad nofactionfield. The compiler had been reporting it the whole time — one of ~190 pre-existingapps/weberrors, which is what a large baseline costs: a real defect is indistinguishable from the noise around it. - [x] #71 — skip the Create Token modal when adding a character. Which is
also what fixes #72 properly: the modal is out of that path, so the
decision reaches
createTokendirectly. The bulk path never opened a modal, so the two flows now differ only in how many tokens they make — and they share one payload builder, because as two literals they had already drifted over the character's chat colour. - [ ] #70 — a background behind the content area on campaign landing pages. Not done, deliberately. The issue proposes "perhaps dark brown with partial transparency and blur (retro theme)" and asks whether to standardise it across list pages. Both halves are design decisions with a visible result, and the second changes every list page in the app. Worth doing; not worth guessing at.
Upsampling — the factor is an input, and an upscale is not a resize
Spec: retouch-upsampling
- [x] U.1 — Confirm the pinned
genaihas the operation. v0.79.0 movedmodules.hosted.jsonto 0.9.0, which is ≥ 0.7.0, so spec 26's D1 is moot. Verify rather than assume — until this is true nothing else in this section is observable outside a dev checkout. - [x] U.2 — Name the version gap where it is checkable. The registry is silent about a module that is present but too old to contribute an operation: the operation is simply absent, exactly like a module that never had one. "Does this deployment have upscaling?" should be answerable without reading a lockfile.
- [x] U.3 — One new
ImageOperationInputKind. A kind for "one of a small closed set of values" (choice), with the options declared by the operation rather than inferred by the host — the host cannot know that 2/3/4 are linear factors and 5 is not offered. The extend operation'sdirectionis the same shape hardcoded, and a second hardcoded kind per operation is how a closed vocabulary stops being renderable. - [x] U.4 —
OperationInputsrenders it. Segmented under a small threshold,Selectabove it — three values want every option visible, and a Mantine dropdown inside the Retouch modal portals to the document root and opens behind the dialogue withoutcomboboxProps.zIndex. That rule has a standing line inCLAUDE.mdbecause it recurs; a segmented control avoids the class entirely. - [x] U.5 —
genaideclares it.upscaleOperation.inputsgains the factor withUPSCALE_FACTORSas the option list, so a fourth factor cannot appear in the UI withoutupscaleToFactorlearning it first. - [x] U.6 — A default that is not silent.
factorFromParamsreturns 2 for anything unrecognised, which is right as a floor and wrong as a way to find out the host sent nothing. The default stays; the dialogue pre-selects it, so what runs is what the user sees selected. - [x] U.7 — Upscale stops returning size multipliers. One line in
genai, and the whole of "an upscale does not move the map". - [x] U.8 — The platform stops trusting them blindly. A guard asserting that
operations which change resolution rather than content area carry no
multipliers, so the next operation to get this wrong fails a test instead
of moving somebody's battlemap.
ImageOperationOutput's doc comment gains the distinction in words. - [x] U.9 — Check every other multiplier consumer.
BackgroundTokenBaris the call site the spec names; the same handler shape may exist onTokenArtworkEditor,PortraitArtPaneandObjectsLayerRetouch. Repeat the search invtt-modulesbefore thegenairelease — spec 26's D5. - [x] U.10 — Predicted output size beside the factor. The dialogue knows the
source dimensions and
w×f × h×fis arithmetic.genai's Big Map selector already writes this sentence; it belongs here too. - [x] U.11 — A warning at the ceiling, not a cap. A 4× chain peaks at a 16× intermediate buffer — on a 4096 px battlemap that is ~1 GB of RGBA before the downscale. Warn at the point of choosing (spec 26 D2/D4).
- [x] U.12 — Say which pass it is on.
upscaleToFactoralready emits aphaseofpass-1/pass-2and nothing displays it. Progress and cancel already work; a 4× run is two passes and should say so.
Background token resize — map geometry, and one coordinate system
Spec: background-token-resize
The whole risk is the coordinate system. IToken.x/y is the bottom-left
corner in grid units and world Y increases upward, documented in exactly one
place — a comment in WebGLTokenLayer.tsx — and every sign error in this
feature will come from it. R.2 is the mitigation: assert the property the user
perceives, not the arithmetic.
- [x] R.1 —
anchoredResize(), pure. In / out:x, y, width, height, a scale factor, an anchor. Nine anchors expressed as a pair of factors in{0, ½, 1}rather than a nine-case switch, so the axes cannot disagree with each other. No React, no store. - [x] R.2 — Tests over all nine anchors, in both axes. Each asserts that the
named point of the footprint has the same world coordinate before and
after. That, rather than checking the new
xagainst a literal, is what catches a flipped Y. - [x] R.3 — The map's corner drag uses it.
WebGLTokenLayer'sse/sw/ne/nwswitch is the same function with four of the nine anchors. The comment block explaining the coordinate system moves onto the helper, where the next reader will look for it. - [x] R.4 — Resize in the background token's bar. Two choices are needed before anything happens, so a popover rather than a menu item. It sits beside Restore Aspect Ratio — the neighbouring size action, and the one users will confuse it with.
- [x] R.5 — A 3×3 anchor picker. Nine buttons laid out as the thing they represent, centre pre-selected, the selection obvious at a glance, and each labelled for screen readers — a grid of unlabelled squares is otherwise unusable without sight.
- [x] R.6 — Factors as buttons. ×2, ×3, ×4 apply immediately on click with the chosen anchor. One click after the anchor is set.
- [x] R.7 — The inverse is offered. ÷2, ÷3, ÷4 in the same control, so a misfire is recoverable without corner-dragging and a shrink is reachable at all — nothing in the app shrinks a background token in one action today.
- [x] R.8 — One PATCH.
x,y,widthandheightin a singleupdateTokenand a single optimisticsetCurrentToken, so no observer ever sees a token that has grown but not moved. - [x] R.9 — Snap-to-grid rounds, visibly. A centre-anchored resize of an odd-sized snapped token rounds the position; the control says that it did rather than leaving the half-square to be discovered.
- [x] R.10 — A floor. A ÷4 of a 1×1 token is a quarter-square token that is
hard to select and easy to lose. The token render mode already forces
max(1, …)whole squares for creature-shaped tokens — follow that precedent rather than inventing one. - [x] R.11 — Mount groups. The server moves a whole mount group when any member's position is PATCHed, and this operation changes position as well as size. Verify the rule's scope before shipping; a background token that somehow has riders must not drag them.
- [x] R.12 — Say "size on the map". Doubling a token's footprint at a fixed
gridSizemeans the image covers twice as many squares — exactly what the ask wants, and exactly what looks wrong to someone who expected the squares to grow instead. Detect Grid Size remains the tool for that other intent, and the label is what keeps them apart.