v0.28.0
2026-05-24
- [x] Remove legacy action-roll code paths (
runActionRoll,roll3DAndAwait,actionRollResolverchannel ondiceStore, and the legacy fallback branch inactionExecutor.rollSimple). Resolved by makingrollSimplefully synchronous: usesrollDetailed(Math.random RNG) and returnsnumberimmediately, with fire-and-forget 3D dice kickoff viauseDiceStorewhen the overlay is mounted. Deletedroll3DAndAwait.ts,actionDiceRunner.ts, removedactionRollResolver/setActionRollResolverfromdiceStore, removedsendMessageWithIdfromActionExecutionContextand all 14 call sites, and cleaned uptestAction/createQueuingContextwhich no longer need thesendMessageWithId: undefinedhack. Existing action code usingawait roll(...)still works — awaiting a non-Promise returns the value. TODO (future): update Import Character and other code generators to stop emittingawaitbeforeroll()calls.
Misc
- [x] Add background removal feature to token art (instead of just portrait art). This is useful for object tokens.
- [x] When a user receives access to a character (via Share This Character), show a notification with a button to view the character.
- [x] When a user receives a character invitation, they don't see the notification on the main menu or character list until a hard refresh.
- [x] dice-box doesn't support the notation "1d20+0". While stupid, we have to work around this. When rolling with dice-box, strip any "+0" from the end of the expression. This is not necessary for dddice or the internal die roller.
- [x] character context loading indicator isn't visible on mobile for some reason
- [x] mobile top level nav z-index is too low; it's being covered by the Me tab
- [x] in mobile Me tab, if a character fails to load, the x button to de-impersonate isn't shown. (Implemented: TokenContextActions now also rendered in the CharacterSubcontext error fallback.) After the new "you don't have access" message is implemented, add a button to de-impersonate in that case as well. (Second part deferred: blocked on the "you do not have access to this character" message TODO above; will be picked up when that is implemented.)
- [x] Clicking the x button in the header right of 'impersonated token' does nothing when there is no such token. It should hide the context container.
- [x] Bug: clicking "focus camera on token" always moves the camera to the original location of the token, even if it is moved. The visual ping is located in the correct position.
- [x] Pressing shift-[0-9] on an Action button is supposed to set the corresponding quickbar slot to that action, but it doesn't work, at least in Character Context.
- [x] Add "Edit this Character" and "Edit this Token" to the ... menu in Character Context
- [x] Remove the X (stop impersonating) button from the Character Context
- [x] When the user clicks X in the Character Context header, nothing happens. Change it so clicking this stops impersonating the token and hides the context container. Also add a tooltip for this button saying "stop impersonating this token".
- [x] Move the zoom artwork, focus token, and ... buttons from the top of Character Context back into the upper right of the portrait, vertically oriented, with grey backgrounds (the same visual treatment as the same buttons in CurrentTokenBar)
- [x] When the user clicks "Build" in Character Context, set the minimum width of the context container to 40rem, so the build mode is usable. If they manually resize, remove the min-width so they can set a preferred width, but the next time they click Build, re-apply the min width. When they exit build mode, remove the min-width to revert to their preferred width.
- [x] Changes needed for the top-level nav on mobile:
- Increase z-index of top-level nav: it's being overlapped by the Palette in Play mode
- Increase the container width to 100vw. Right now the screen is half top level nav and half empty space in a different shade.
- Remove the << button to collapse the nav.
- Ensure tapping the hamburger icon closes the nav when open.
- [x] If the user clicks on a token not on their current layer, and there is no valid token on their current layer, pop up a temporary tooltip saying "this token is on layer [layer name]. Switch to that layer to interact with it."
Mobile
- [x] Remove the CurrentTokenBar for selected tokens in mobile--users aren't finding it helpful.
- [x] Remove the Menu button from the mobile bottom bar--it does nothing now
- [x] In mobile, long-pressing a token opens the context menu. That's good. But tapping outside the menu doesn't close it...they simply have to choose something from the menu.
- [x] In mobile, tapping the hamburger icon opens the top-level nav. This is good, but tapping the hamburger again doesn't close it. They have to select something from the menu to close it. It should be possible to tap the hamburger again to close it without selecting something.
- [x] When impersonating a token, and your initiative turn starts, pop up a temporary notification bubble.
- [x] Bug: on mobile, I'm impersonating a token, but I can't see the End Turn button. Ensure it's located somewhere accessible, not hidden behind the bottom bar or scrolled off the screen.
- [x] On mobile, when I have the Me tab open, and I tap an action that goes into targeting mode, the Me tab should close so I can see the map and target. Right now it stays open and covers the map, so I have to manually close it before I can target.
- [x] If possible, disable browser zoom on mobile. Users often find the view zoomed in and don't realize it, and crucial UI elements aren't visible. They can pinch to zoom but they don't know that.
- [x] On mobile, when you focus the ChatLog text input, the browser zooms in. If possible, prevent this; it's confusing to users.
Dice Rolling
- [x] In the Chat Log, when the user presses the Dice button, instead of a popup, expand the fixed bottom input area (which contains the dice button itself) upwards to show the dice roller buttons (previously in the popup) in a single horizontal line. Clicking the dice button again collapses the roller back into the popup.
- [x] Switch to deterministic dice rolls. (Partial: DDDice path now pre-computes each die's value via
Math.random()and sends it as thevaluefield in the SDK'sIDiceRoll[]; the platform also uses our precomputed values for the chat total instead of trusting the server echo, so the result is locally deterministic even if dddice ignores the directive. The unifiedrollExpressionWithChatUIpipeline and the legacyrunActionRollfallback inactionExecutorare now both fire-and-forget —await roll(...)in an action resolves immediately with the deterministic precompute and the 3D overlay kickoff is wrapped in try/catch so a crash there cannot block or alter the return value. Dice-box determinism is still deferred — see remaining TODO below.) - [x] Bugfix (v0.28.0): Eliminated dual-RNG divergence where chat could show different per-die values than the 3D animation (e.g. user rolls
2d8+12, dddice animates 3 + 4, chat said 11 + 6). Root cause:buildPendingDiceRollTokenpre-rolled values for the chat fallback, and the dddice roller'sbuildDiceRollsindependently pre-rolled its own values for the SDK'svaluefield. Fix: addedprecomputedValues?: number[]toDiceRollContext;DiceExtensionBridgenow forwardspendingChat.data.fallbackRolls, and the dddice extension honors those values (defensively falling back to its own RNG only on length mismatch).ChatLog.handleRollCompletealso now patches messages withentry.data.fallbackRollsinstead of the 3D results, so chat is authoritative even if a roller declines to honorprecomputedValues. - [x] Bugfix (v0.28.0): Extended fire-and-forget to all dice-entry sites. Previously
roll()worked, but chat-input dice expressions, the chatDiceButtons, and theCurrentTokenBar/DiceBagdice clicks bypassedrollExpressionWithChatUIand went straight touseDiceStore.addDie, relying onChatLog.handleRollComplete's legacy fallback to emit the chat message AFTER 3D settled. RefactoredChatLog.roll,CurrentTokenBar.handleDiceClick, andDiceBag.Die.handleClickto route through the unified pipeline. The chat row now appears immediately with a spinner and is patched in place, sharing the same RNG as the 3D animation. - [x] the Dice roller buttons in ChatLog and CurrentTokenBar, as well as dice expressions in chat like '1d20', are currently waiting for 3d dice to finish rolling before displaying. They should roll using Math.random(), passing the pre-rolled values to the die roller (when using the dddice module--otherwise don't call 3d dice), and immediately show the result in chat. (Fixed: replaced
rollExpressionWithChatUIspinner-placeholder flow withrollDiceToChatwhich sends resolved[DICE_ROLL:]tokens immediately. Chat-typed dice expressions also fixed —handleSendMessageWithDicenow uses instant-computeprocessMessageDiceAndMathinstead of the broken animated flow that referenced the removedisRollingselector.)
Token Images
- [x] Add a field to tokens called 'facing logic'. Options:
- Pointer: an arrow shows the facing direction; the token image doesn't rotate. This is the only mode currently. It shall be the default for tokens in 'token' render mode.
- Rotate: the token image rotates to match the facing direction. The user has an option to designate which way the image is facing when unrotated (an image of a car might be rendered in profile with the car facing right, or it might be top-down with the car facing up, etc). This is the new default for tokens in 'object' render mode.
- [x] Add a field to tokens called 'render mode'. Options:
- Token: the image must fit a square of even grid size (1×1, 2×2, etc.). Uses 'contain' logic if the image aspect ratio doesn't match the grid square. Default facing logic is 'pointer'.
- Object: the image can be any size, and can be resized at will including changing aspect ratio. Default facing logic is 'rotate'.
- [x] Add a "Show faction ring" toggle to the Edit Token modal (default on). When off, the faction-colored outline (green/red/yellow for party/hostile/neutral) is hidden on the map without changing the token's underlying faction or combat behavior.
Layers Rework
Reworking Layers so users can customize layer order, add their own layers, etc.
Currently, we have four built-in layers:
- Foundation (not shown in selector)
- Background
- Objects
- Tokens
There are a couple of "invisible" layers like Map Objects and Foreground, which I'm not even sure what they do.
Going forward, we're changing the layer system.
- Users can define any number of layers, and assign them a name and z-index.
- Built-in layers still exist, specifically:
- Foundation
- Background
- Drawings (new layer)
- Objects (merged with Map Objects)
- Tokens
- VBL / FOW
- Each layer has a default token rendering mode:
'object'or'token'.'object'mode (default for Background and Objects): tokens are free-form — they do not need to snap to even grid sizes, can be resized at will including changing aspect ratio, and new token artwork defaults to'unchanged'.'token'mode (default for Tokens): tokens must fit into a square of even grid size (1×1, 2×2, etc.), and new token artwork defaults to'tokenized'.- This property is editable on any layer. The defaults match the current built-in behavior (Background/Objects →
'object'; Tokens →'token'), but the user can override them.
- In the layers menu in the Palette, users see all layers that are valid for tokens or drawings. They also see two new options at the bottom, after a separator:
- New Layer
- Edit Layers
- Edit Layers spawns a modal which shows:
- A list of all layers, including non-token layers, with the lowest z-index (Foundation) being at the bottom of the list and the highest z-index (VBL/FOW) being at the top.
- Each row shows the layer name, and whether tokens and drawings are allowed on that layer, and how many of each are currently present in that layer. Mousing over the number of either count shows a tooltip with a scrollable list of the tokens/drawings on that layer, showing their name and thumbnail.
- Each layer has a handle to drag-and-drop to reorder the layers, which updates their z-index accordingly.
- Each layer has an pencil icon button which allows the user to change the layer name.
- Each layer has a trash icon button which allows the user to delete the layer. If there are any tokens or drawings on that layer, the user is prompted if they want to move the tokens to another layer of their choice, or delete the tokens along with the layer.
- Exceptions to the above:
- Some layers are protected and cannot be deleted:
- Foundation
- VBL / FOW
- Some layers cannot be re-ordered:
- Foundation is always at the bottom
- VBL / FOW is always at the top
- Some layers are protected and cannot be deleted:
- There is a button to create a new layer, which creates a new layer at the top (just below VBL/FOW) with a default name like "New Layer" in a text field that is pre-focused with "New Layer" already selected.
- New Layers are always token layers.
- The New Layer option opens the Edit Layers modal and has the same effect as the New Layer button in that modal.
- Tokens and drawings within the same layer still have a z-order within that layer, which should be tracked separately from the layer's z-index. Both tokens and drawings should have right-click context menus with options:
- Send to Top of Layer
- Send to Bottom of Layer
- Move Up Within Layer
- Move Down Within Layer
- Move to Layer... (opens a submenu with a list of all layers, and selecting one moves the token/drawing to that layer)
- (tokens have most of these already, drawings don't)
- When rendering the map, we calculate z-index thus:
- The layers have a z-index starting with 0 (Foundation), and increasing by 1 for each layer.
- Within a layer, all drawings and tokens are assigned an auto-increment layer z-index starting with 0. If I create 10 tokens and then 10 drawings, the tokens are at layer z-indices 0-9 and the drawings are at layer z-indices 10-19. If I use "move up" or "send to back" etc, these values are swapped around accordingly.
- When rendering, every token and drawing on a layer with z-index N is always above every token and drawing on a layer with z-index < N, regardless of their layer z-index.
- Within a layer, tokens and drawings are rendered in order of their layer z-index, from lowest to highest.
- If a token or drawing is deleted, subtract 1 from the within-layer z-index of all tokens and drawings on the same layer with a higher within-layer z-index, to fill in the gap.
- Layer z-index is never stored on the token/drawing level--it is referred from the layer definition. Only the within-layer z-index is stored on the token/drawing.
- When a map is first loaded, do a sanity check on layer z-indices and token/drawing z-indices, making sure there are no gaps.
- Note: if there isn't already, make a PATCH endpoint for updating multiple tokens at once, so we can efficiently update all the tokens in one request instead of one request per token.
- Similar note: whenever we swap two tokens' z-indices, we should do that in one request, using a transaction which can roll back if both updates don't succeed.
Layers — TODO breakdown
Design decisions captured from spec review (2026-05-19):
- Scope: one v0.27.0 epic, sliced into the commits below.
- Data model: per-map. Layer definitions live in a new
map_layerstable keyed bymapId. (No campaign-level inheritance for now.) - Within-layer ordering: fractional (LexoRank-style) strings, not no-gap integers. Avoids O(N) ripple on delete and lets a swap touch exactly one row instead of two-in-a-transaction. The spec's "no gaps + decrement on delete + PATCH-many" path was rejected as too noisy for large maps.
- VBL / FOW: read-only label entry in the Edit Layers list. Current VBL/FOW pipeline stays intact; the modal just shows it as the always-top sentinel that can't be deleted or reordered. Full unification deferred.
foreground: dead. Only reference is asetCurrentLayer('foreground')command-palette entry inMapSecondLevelNav.tsx. Remove as part of cleanup phase below.mapObjects↔objects: the WebGLmapObjectsvisual layer is just alayerId='mapObjects'filter on thetokenstable. The separatemap_objectstable is dead in the WebGL path (only used for hit-test in right-click delete; rendered only by the legacy KonvaMap.tsxfallback insideWebGLErrorBoundary). Fusion plan: migratetokens.layer_id, update drag flow, drop the dead WebGL layer instance, but keep themap_objectstable around for the Konva fallback (stop writing to it from new code paths).
Commits, in suggested order:
- [x] Phase 1 — Cleanup before adding complexity. Single commit covering: (a)
UPDATE tokens SET layer_id='objects' WHERE layer_id='mapObjects'migration; (b)LibraryMain.handleAddToMapandLibrarySubcontextdrag handler stop emitting'mapObjects'(use'objects'); (c) delete<WebGLTokenLayer mapId={mapId} layerId="mapObjects">fromWebGLMap.tsxand anylayerId === 'mapObjects'z-branch inWebGLTokenLayer.tsx/TokenDebugModal.tsx; (d) delete theforegroundcommand-palette entry inMapSecondLevelNav.tsx; (e) audituseMapObjectscallers — keep the read for right-click hit-test (legacymap_objectstable still has rows from old data) but mark the write path dead. Test: existing maps render unchanged, dragging an object-type library item lands on theobjectslayer. - [x] Phase 2 —
map_layersschema + read API. Addmap_layerstable (id,map_id,name,order_keyLexoRank string,allows_tokensbool,allows_drawingsbool,is_protectedbool,is_builtinbool,builtin_idnullable enum'foundation' | 'background' | 'drawings' | 'objects' | 'tokens' | 'vbl_fow',token_render_modeenum'object' | 'token', timestamps). Default values for built-ins: Background/Objects/Drawings →'object'; Tokens →'token'; Foundation/VBL/FOW →'object'(these layers don't render tokens, but a sane default is required by the schema). Add Drizzle migration. On first map load, ifmap_layersis empty for that map, seed the six built-in rows with evenly-spaced LexoRank keys. AddGET /api/maps/:mapId/layers. Test: seeding produces stable LexoRank order, repeat reads are idempotent. - [x] Phase 3 — Per-row
order_keyontokens+drawings. Schema migration addsorder_keyLexoRank string. Backfill: for each (mapId, layerId) group, assign sequential LexoRanks bycreated_at. Tests cover the backfill being gap-free in fractional space and stable across reruns. - [x] Phase 4 — Mutation API + transactional swap.
POST /api/maps/:mapId/layers(create),PATCH /api/maps/:mapId/layers/:id(rename, reorder via neworder_key),DELETE /api/maps/:mapId/layers/:id(with?moveTo=<layerId>or?cascade=truequery).PATCH /api/tokens/:id/orderandPATCH /api/drawings/:id/orderaccept{ order_key: string }. Operations are single-row writes by design (no transaction needed for swaps thanks to LexoRank). Protected layers reject delete/reorder with 409. - [x] Phase 5 — Renderer: dynamic layer iteration. Replace the hard-coded
<WebGLTokenLayer layerId="background">,<WebGLTokenLayer layerId="objects">, etc. instances inWebGLMap.tsxwith a.map()over the layer list, sorted byorder_key. Drop the layerId→z constants inWebGLTokenLayer.tsx(line ~2306) andTokenDebugModal.tsx(line ~17); passzas a prop derived from the layer's position in sorted order. Split<WebGLDrawingLayer>into per-layer instances filtered bydrawings.layer_id. Within each layer, render entities sorted byorder_key. EachWebGLTokenLayerreceives the layer'stoken_render_modeand enforces it: in'token'mode, tokens snap to even grid sizes and artwork defaults to'tokenized'; in'object'mode, tokens are free-form and artwork defaults to'unchanged'. Test: re-rendering a map with the seeded six layers matches the pre-Layers screenshot pixel-for-pixel. - [x] Phase 6 — Drawings get
layer_id. Schema migration addsdrawings.layer_id(default'drawings'for new rows; existing rows backfilled based on the current global z=0.9 position → seeded'drawings'layer). UpdateDrawingToolbarto default new drawings to the current layer (or to the'drawings'builtin if the current is the always-top tokens layer). Map Generator render route (packages/server-core/src/routes/mapGenerations.ts) pins painter output to the'drawings'builtin layer. - [x] Phase 7 — Per-entity z-order context menus. Add to drawing right-click menu (tokens already have most): Send to Top of Layer / Send to Bottom of Layer / Move Up / Move Down / Move to Layer.... All four reduce to
PATCH /api/drawings/:id/orderwith the appropriate computed LexoRank. Reuse existing token context-menu code; factor the LexoRank-compute helpers intoapps/web/src/utils/layerOrdering.ts. - [x] Phase 8 — Layers Palette menu. Update the Layers section of the Palette/SecondLevelNav: list all token-or-drawing-allowed layers in
order_keyorder, plus the separator + "New Layer" + "Edit Layers" entries. "New Layer" opens the Edit Layers modal pre-focused on a new row. - [x] Phase 9 — Edit Layers modal. New modal: drag-to-reorder list, pencil-rename, trash-delete with reassign-or-cascade prompt, per-row token/drawing count badges + hover-tooltip with thumbnail list, "New Layer" button. Each layer row also shows and allows editing of its token rendering mode (
'object'/'token'), rendered as a compact toggle or select (label: "Rendering"). Honor protected/reorder constraints (Foundation pinned bottom, VBL/FOW pinned top). New layers default to'token'mode (per spec). - [x] Phase 10 — Sanity check on map load. On first read of a map's layers + entities, verify every entity's
layer_idreferences an existing layer. Orphans get reassigned to the'objects'builtin layer with a console warning. (No ripple-decrement needed thanks to LexoRank.) Cover with a regression test. - [x] Phase 11 — Cleanup. Delete the now-fully-dead
map_objectswrite paths (keep the table +useMapObjectsread for the legacy KonvaWebGLErrorBoundaryfallback). DropuseMapObjectswrite methods, thedeleteMapObjectAPIcall site inWebGLMap.tsxright-click handler (replace withdeleteTokenAPIonce we're confident hit-test only finds tokens). Remove this section's TODOs from the changelog.