v0.42.0 - Navigation and Routing Overhaul
The global navigation and routing rework (Style Guide 2.1), plus Token Ownership permissions, in-drawing text and a barber-pole selection outline for Drawings, and a large batch of bug fixes.
Main article: Global Navigation and Routing Overhaul (see also its design and todo).
Navigation and Routing Overhaul
- [x] Characters move under
/campaign/:id-or-slug/characters; Campaigns / Maps / Characters gain/newand/deleteroutes;/helpand/librarysplit into subroutes; a purerouteContext.tsdrives the newGlobalTopNav..catarchive export (yml + native images) backs the/delete"download archive" alternative.
Token Ownership
- [x] UI to allow/deny access to a given token for each player added to the bottom of Character Context and Impersonated Token Context: list of all players in the campaign with a toggle for allow/deny.
- Built
PermissionsPanel(+useEntityPermissionshook) rendered at the bottom of both contexts. Lists the campaign's players (owner + members) with an allow/deny Switch; GM + owner are shown locked (always allowed). Character Context useskind="character"and passessyncTokenId(the impersonated token instance) so editing there also syncs the Player-Token table; Impersonated Token Context useskind="token". Verify the layout interactively (apps/web has no jsdom, so this is source-contract-tested only). - Deny in this case is the absence of an Allow. This is a 2-state permission, not 3-state.
- Store permissions in a Player-Character associative table, with type "allow". (In the future, we could have more states like Deny and Inherit )
- In Impersonated Token context, this UI edits the Player-Token associative table (same schema, just Tokens instead of Characters)
- In Character Context, this UI edits the Player-Character associative table, and syncs the Player-Token table for this instance of the Character.
- Built
- [x] When a Character is created, the default permissions are: GM and Owner allow, others denied (absent permission)
- Implemented via the permission MODEL rather than stored rows: the campaign GM (campaigns.owner) and the
entity owner (characters.userId) are IMPLICITLY allowed by
canImpersonateCharacter, and everyone else is denied by the absence of an allow row. So a freshly created character needs no inserts to satisfy this.
- Implemented via the permission MODEL rather than stored rows: the campaign GM (campaigns.owner) and the
entity owner (characters.userId) are IMPLICITLY allowed by
- [x] When a Token is created, the default permissions are: GM and Owner allow, others denied.
- Same implicit GM/owner allow via
canImpersonateToken. If the Token is a Character (metadata.characterId), the POST /api/tokens handler copies the character's allow rows onto the token (syncCharacterPermissionsToToken), so an imported character token inherits the character's permissions.
- Same implicit GM/owner allow via
- [x] When attempting to impersonate a character without access, the UI already disallows this, and has a button to request access. Add to this UI a button to return to the previous impersonated character.
- Added a "Return to {name}" button to the Character Context 403 error branch, shown when the user is
impersonating a DIFFERENT character than the denied one (returning to the same denied character would just
re-show the error). It swaps the sidebar back to the impersonated character via
openContextin place (or the Impersonated Token context for a character-less token) — no navigation. Verified interactively.
- Added a "Return to {name}" button to the Character Context 403 error branch, shown when the user is
impersonating a DIFFERENT character than the denied one (returning to the same denied character would just
re-show the error). It swaps the sidebar back to the impersonated character via
- [x] New UI element to the left of the Character Context: a vertical tab strip showing all characters/tokens that the current user owns. The Character Context UI becomes the tab content, with the tabs switching impersonation between owned characters/tokens.
- Each tab contains the token art for the character/token, and shows their name on hover
- This UI is not present in mobile. Instead, the Character name in the top sticky header of the Me tab becomes a dropdown; the options include all other owned Characters and Tokens.
- (Mobile) Existing "select a character" UI in the Me tab when no token is impersonated should only show characters and tokens that the user has Allow access to.
- Desktop:
ImpersonationTabStrip— a vertical rail of the effective user's owned tokens, wired inContextPanelto the LEFT of both the Character and Impersonated Token contexts (so it persists when switching between a character-linked and a character-less token). Token art (linked character's portrait for character tokens, token art otherwise), name on hover, active ring on the impersonated token, click to switch; hidden below two owned tokens. - Mobile: the Me-overlay header name is now
MeHeaderSwitcher, a dropdown of the OTHER owned tokens (art + name) that switches impersonation; and theMePrompt"select a token" list is filtered to the server-computed impersonatable/Allow-access set. - Shared
useImpersonationRosterhook backs both (owned tokens + character-first art resolution). All source-contract tested (apps/web has no jsdom); verified interactively on desktop and a mobile viewport.
Drawings
- [x] When the user clicks a drawing, it becomes selected, showing a delete button and changing the options in the drawing palette. However, the visual indication of selection is very subtle and can be easily missed. Increase the width of the outline, and make it a barber-pole pattern or red and white, and animate the barber pole to rotate around the drawing (the same way the selected token animation works). For non-circular drawings, the outline should follow the path of the drawing.
BarberPoleLinein WebGLDrawingLayer replaces the static selection outline: builds the stroke as quads with a per-vertex cumulative-distance attribute and a red/white dash shader that marches along the path (animated via useFrame), so it follows any shape's geometry — circle, rectangle, line, pencil, cone. Wider than the stroke (max(strokeWidth+6, 8)). Mirrors the selected-token marching-ants shader. Verified interactively on a circle and a rectangle (dashes follow the path + animate; only the selected drawing shows it).
- [x] When a drawing is selected, show a cursor in the geometric center. The user can type any desired text into the drawing. Text options are added to the drawing palette for:
- Font size: default 1 rem, but shown in point sizes
- Font color: default black
- Alignment: default horizontal and vertical center, but show a 3x3 grid of alignment options (top/center/bottom x left/center/right)
- Word wrap: default on, with the width constrained to the drawing's width minus 1rem of padding. When off, there is no max width for the text.
- Dev notes: ensure keyboard shortcuts for the map are not intercepted while editing text in a drawing. While a drawing is selected, all key inputs are interpreted as text input.
- Pressing Escape, clicking anywhere off the drawing, closing the drawing tools, or leaving the current map should all deselect the drawing and exit text editing mode.
- Implemented in five commits:
- Part 1 — data model + persistence:
DrawingTextblob ({ content, fontSize?, color?, align?, valign?, wrap? }) onIDrawing.text, serialized as a JSON string columntexton the drawings table (sqlite + pg runtime migrations + generated pg migration + zod).useDrawingsparses/serializes it on both create and update paths. - Part 2 — display:
DrawingTextLayerrenders each drawing's text as a camera-anchored DOM overlay.DrawingTextProjector(inside the Canvas) projects each shape's world bounds → screen rects via the live camera. WebGL is a demand frameloop that idles, so the projector runs bothuseFrameAND a React-effect fallback keyed on selection/drawings/camera-position — otherwise a programmatic select never repaints. - Part 3 — inline editor: the selected drawing hosts a focused
contentEditable(caret to end on mount) writing to the store immediately and to the API debounced 400ms, flushing any pending save on unmount/deselect. - Part 4 — style palette: a "Text style" popover in the toolbar (shown only when a drawing is selected) with font-size steppers (shown in pt), colour input, a 3x3 valign×align grid, and a word-wrap switch. Patches merge onto the current text read from the store (not a stale render closure) so rapid clicks don't clobber each other.
- Part 5 — exit editing: editing ends on deselect. Escape blurs + deselects, closing the toolbar clears the selection,
empty-space click uses the existing select-mode map click, and leaving the map deselects. Map hotkeys stay suppressed
during editing via the shared
isEditableTargetcontentEditable guard.
- Part 1 — data model + persistence:
Misc
- [x] Shift plus arrow key should set the facing for your impersonated token, but it does nothing. It was previously implemented only for selected token, which is no longer a thing.
useArrowKeyTokenMovementresolved its target from selectedTokens → currentToken only, so after a map click (which impersonates rather than selects) there was no target. Inserted the impersonated token into the chain (matching the Delete / copy-paste shortcuts), which restores both Shift/Alt+arrow facing and the plain-arrow nudge.
- [x] In the Drawing sub-palette, add a trash icon when a drawing is selected to delete that drawing. (Same for VBL palette)
- Added a trash ActionIcon to the DrawingToolbar (enabled only when a drawing is selected) that deletes via the API + local store, mirroring the on-canvas delete button. The VBL palette already had an equivalent trash button.
- [x] When the user opens the Filters popup in the Feat or Power chooser dialog, clicking outside the Filters popup doesn't close it, but it should.
- The Filters
Popoverran controlled (opened={showFilterPopover}) but had noonChange— and Mantine v8 routes the close-on-outside-click signal throughonChange, notonClose, so the click was swallowed. AddedonChange={setShowFilterPopover}to all three shared choosers (Feat, Power, and Equipment, which had the same bug).
- The Filters
- [x] In the Action editor, any action that closes the editor and therefore abandons changes should trigger a confirmation popup if there are unsaved changes, asking the user to confirm that they want to discard their changes.
- Both editors (the full
ActionEditorand the compactTokenActionsEditor) now route the ModalonClose(X / Escape / click-outside) and the Cancel button through a guardedrequestClosethat shows a "Discard changes?"ConfirmDestructivewhen the form is dirty (the save path still closes directly). ActionEditor detects dirt via a whole-form snapshot compare; TokenActionsEditor compares name/code. Added an optionalzIndexprop toConfirmDestructiveso the nested confirm clears each editor modal.
- Both editors (the full
- [x] In the Character Sheet, for the Hit Points field, add a dropdown similar to the AC field, with options for Calculated (current behavior) and Manual (allows any numeric input for HP)
- Mirrored the AC pattern:
getHpStatsnow honorsmetadata.hpCalculationType('manual' | 'calculated', default 'calculated' to preserve behavior); in manual mode maxHp comes frommetadata.hpValue(falling back to the calculated max when unset). The Hit Points field shows a Calculated/ManualSelectin edit mode plus a manual Max HP input. Unit- source-contract tested.
- Mirrored the AC pattern:
- [x] In the Feats section, add an element allowing the addition of an arbitrary number of Feat slots.
- Feat slots were fully derived from level + advancement metadata. Added user-added extra slots backed by a
character.metadata.extraFeatSlotscount: they render as level-0 slots at indices past the starting ones (so they reuse the existing assignment / drag / overload plumbing). An "Add Feat Slot" button (build mode) increments the count; the last extra slot can be removed only while empty, so a placed feat is never orphaned. Source-contract tested.
- Feat slots were fully derived from level + advancement metadata. Added user-added extra slots backed by a
Bugs
- [x] When shifting from Combat Log to Chat Log, the Chat Log is scrolled all the way up but should be scrolled all the way down (or wherever their previous scroll position)
- The chat scroll container is unmounted while Combat Log / Initiative are shown, so returning to Chat/Transcript
remounts it at scrollTop 0; neither existing scroll effect fires on a view switch (initial-scroll already ran and
the message count didn't grow). Added a
chatTab-keyed effect that scrolls to the bottom when the chat/transcript view becomes active (halfway when initiative is active, matching the auto-scroll effect). Source-contract tested.
- The chat scroll container is unmounted while Combat Log / Initiative are shown, so returning to Chat/Transcript
remounts it at scrollTop 0; neither existing scroll effect fires on a view switch (initial-scroll already ran and
the message count didn't grow). Added a
- [x] In mobile, tapping a token brings up the hover UI. Tapping off removes it. Opening the Me or Chat tab should also close hover UI.
- The desktop side panels already clear the hover via a leftContexts/rightContexts effect in UI.tsx, but the mobile
bottom-tab overlays use MobileContextBar's local
activeOverlaystate and bypassed it. Added an effect keyed onactiveOverlaythat clearssetHoveredToken(null)when the Me or Chat overlay opens. Source-contract tested.
- The desktop side panels already clear the hover via a leftContexts/rightContexts effect in UI.tsx, but the mobile
bottom-tab overlays use MobileContextBar's local
- [x] In mobile, the Me and Chat tabs are above global nav (hamburger menu). Global nav should be on a higher layer.
- The mobile global-nav
<Drawer>(SideNav.MobileNavDrawer) set no zIndex, falling back to Mantine's default (200), below the mobile bottom bar (800) and overlay (700). Added a dedicatedZ_INDEX.MOBILE_NAV_DRAWER(960) — above the top nav bar it opens from (950), below context menus (1000) — regenerated the--z-*CSS vars, and passed it on the Drawer. Also made the pre-existingMobileTopNav.zIndexguard resolvevar(--z-*)(it parsed only literals and had silently broken after the v0.39.0 CSS-var migration).
- The mobile global-nav
- [x] On mobile, the X button to delete the selected drawing is either not rendered or too small to see. Ensure it is large enough to see and tap.
- The button was a fixed 24px. It now sizes from
useIsMobile()— a 44px tap target (26px glyph) on mobile, 24px on desktop. (The "not rendered" half was largely a symptom of the positioning bug above, now fixed.)
- The button was a fixed 24px. It now sizes from
- [x] Trash icon in palette does nothing. It should enter delete mode, indicating to the user that everything they click on or brush over gets deleted. Similar overlay to targeting mode
- Delete mode was cursor-only (no feedback) and deleted only tokens, on a single click. Now:
DeleteModeBanner(mirrors the targeting banner) shows while the trash tool is active, with a "Done" exit.- The drawing under the pointer highlights (hover detection now also runs in delete mode).
- Click/tap deletes the topmost token OR drawing under the pointer (mobile tap handles drawings too, not just tokens).
- Holding + dragging "brushes": each token/drawing the pointer passes is deleted (deduped per stroke).
- Exit via right-click, Escape, or the banner's Done button (an empty click no longer exits, since brushing needs empty-space drags).
- Delete mode was cursor-only (no feedback) and deleted only tokens, on a single click. Now:
- [x] The x button to delete drawings is not in the right position. Its coordinates appear to be a multiple of the drawing's coordinates--moving the drawing moves the x button by a different amount.
DrawingDeleteButtonhand-rolled a world→screen transform using raw camerazoomas the px-per-world scale, but the true scale iszoom * viewportHeight / frustum, so the button drifted by a constant multiple of the drawing's coords. Replaced it with the camera-projected rect thatDrawingTextProjectoralready writes touiStore.drawingTextRectsfor the selected drawing (it projects the selection even with no text): anchor at the projected top-right corner withposition: fixed, matching the text overlay. Also fixes wall drawings, which the old type switch silently dropped.
- [x] The AC of characters is not being transferred to their tokens during the Import Character process. They simply have no AC set, even on refresh. This is peculiar to the AC property. It happens for D&D and System 8.
- Root cause: for real PCs (both D&D 5e and System 8), AC lives in
character.metadata(resolved bygetAcStats), not as an attribute.convertCharacterToTokenonly mapped an "AC"/"Armor Class" attribute (the v0.33.0 fix), which only the monster generator synthesizes — so PC imports set nometadata.ac. Peculiar to AC because HP has its own Constitution-based fallback. Added a metadata fallback: when no AC attribute set it,metadata.ac = getAcStats(character).ac(manual value, or armor+dex when calculated), matching what the character sheet shows. Monster path unaffected (its AC attribute still wins). Covered by new unit tests.
- Root cause: for real PCs (both D&D 5e and System 8), AC lives in
- [x] When the user presses 'Add to Map' on the Character Sheet, it correctly adds a token of that character to the desired map and moves to the /play route for that map. However, the camera position is incorrect--it should be centered on the newly added token.
- The Add-to-Map flow only navigated; it never focused the new token, and even discarded the created token's id. Added a
uiStore.pendingFocusTokenintent:AddToMapModalcaptures the POST response and records{ mapId, id, x, y }before navigating;useMapStatePersistenceconsumes it on the target map once the grid params sync and the token has loaded, callingfocusToken(grid→world center + ping) and clearing the intent. The saved-camera restore is skipped when a focus is pending for that map, so there's no restore→focus jump. Mirrors theimpersonationRestorePendingpattern.
- The Add-to-Map flow only navigated; it never focused the new token, and even discarded the created token's id. Added a
- [x] Clicking 'edit' in the context menu of an action in the Character Context crashes the app with
ErrorBoundary caught an error in Router: TypeError: Cannot read properties of undefined (reading 'trim') at TestActionPanel (TestActionPanel.tsx:241:35). This is happening in production so it wasn't introduced by the current dev version.- Root cause: a legacy action saved with no
codebody flowed through the editors intoTestActionPanel, whose render path callsactionCode.trim(). Hardened in three places:TestActionPanelnow typesactionCode?and defaults it to''(the crash site), and bothActionEditorandTokenActionsEditorcoalesceaction.code ?? ''when seeding the edit form. Source-contract tested.
- Root cause: a legacy action saved with no
- [x] ctrl-c (or cmd-c) should copy the current impersonated token. ctrl-p should paste a duplicate.
- ctrl-c now prefers the impersonated token as the copy source; ctrl-p is a paste alias alongside ctrl-v (preventDefault suppresses browser print). Reuses the existing tokenClipboard machinery.
- [x] the delete key should delete the currently impersonated token, with a confirmation popup (along with a 'don't ask me again' checkbox that sets a user preference to skip the confirmation in the future).
- Delete now targets a multi-selection, then the impersonated token, then currentToken. Added a "Don't ask me again" checkbox to the confirm dialog, backed by a persisted useDeletePrefsStore; when set, Delete removes immediately.
- [x] when selecting multiple tokens and changing conditions in the multi-token select context, sometimes the first or last token selected isn't changed
handleConditionToggleawaited one per-token write per iteration with no try/catch, so a single rejected request threw out of the sequential loop and skipped every remaining token. Each write is now guarded independently.
- [x] reducing a token to 0 HP in system 8 and 5e automatically applies Dying. However, healing to 1 HP or higher doesn't automatically remove Dying. It should.
- Dying was auto-added server-side (maybeAddDyingCondition) but only removed in one narrow client path. Added the symmetric server-side maybeRemoveDyingCondition (HP rises from <=0 to >0), wired into updateTokenHandler beside the add — centralized, so it covers every HP path for both systems.
- [x] auras are measured in feet when they should be measured in squares, with size from 1 to 100.
- The renderer already treated radius as squares (radius * gridSize); the editors just mislabeled it as feet. Relabeled every aura radius input to "squares" (range 1–100, step 1) and changed the "ft" displays to "sq" across all surfaces.
- [x] aura colors aren't being used as set--they're always white. Aura color is any valid CSS color specification.
- THREE.Color only parses a limited CSS subset (→ white for anything else, and it drops alpha). Added resolveAuraColor: the browser normalizes any valid CSS color via a canvas context, then we parse it into rgb + opacity for the material.
- [x] aura color picker is behind the aura dialog in z-index and unreachable.
- The aura ColorInputs had no popoverProps, so the picker fell to the default z-index while the aura modals sit as high as MODAL_OVERLAY (3000). Gave every aura ColorInput a portaled popover at Z_INDEX.TOP.