Skip to content

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).

  • [x] Characters move under /campaign/:id-or-slug/characters; Campaigns / Maps / Characters gain /new and /delete routes; /help and /library split into subroutes; a pure routeContext.ts drives the new GlobalTopNav. .cat archive 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 (+ useEntityPermissions hook) 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 uses kind="character" and passes syncTokenId (the impersonated token instance) so editing there also syncs the Player-Token table; Impersonated Token Context uses kind="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.
  • [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.
  • [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.
  • [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 openContext in place (or the Impersonated Token context for a character-less token) — no navigation. Verified interactively.
  • [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 in ContextPanel to 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 the MePrompt "select a token" list is filtered to the server-computed impersonatable/Allow-access set.
    • Shared useImpersonationRoster hook 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.
    • BarberPoleLine in 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: DrawingText blob ({ content, fontSize?, color?, align?, valign?, wrap? }) on IDrawing.text, serialized as a JSON string column text on the drawings table (sqlite + pg runtime migrations + generated pg migration + zod). useDrawings parses/serializes it on both create and update paths.
      • Part 2 — display: DrawingTextLayer renders 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 both useFrame AND 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 isEditableTarget contentEditable guard.

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.
    • useArrowKeyTokenMovement resolved 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 Popover ran controlled (opened={showFilterPopover}) but had no onChange — and Mantine v8 routes the close-on-outside-click signal through onChange, not onClose, so the click was swallowed. Added onChange={setShowFilterPopover} to all three shared choosers (Feat, Power, and Equipment, which had the same bug).
  • [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 ActionEditor and the compact TokenActionsEditor) now route the Modal onClose (X / Escape / click-outside) and the Cancel button through a guarded requestClose that shows a "Discard changes?" ConfirmDestructive when 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 optional zIndex prop to ConfirmDestructive so the nested confirm clears each editor modal.
  • [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: getHpStats now honors metadata.hpCalculationType ('manual' | 'calculated', default 'calculated' to preserve behavior); in manual mode maxHp comes from metadata.hpValue (falling back to the calculated max when unset). The Hit Points field shows a Calculated/Manual Select in edit mode plus a manual Max HP input. Unit
      • source-contract tested.
  • [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.extraFeatSlots count: 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.

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.
  • [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 activeOverlay state and bypassed it. Added an effect keyed on activeOverlay that clears setHoveredToken(null) when the Me or Chat overlay opens. Source-contract tested.
  • [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 dedicated Z_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-existing MobileTopNav.zIndex guard resolve var(--z-*) (it parsed only literals and had silently broken after the v0.39.0 CSS-var migration).
  • [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.)
  • [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).
  • [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.
    • DrawingDeleteButton hand-rolled a world→screen transform using raw camera zoom as the px-per-world scale, but the true scale is zoom * viewportHeight / frustum, so the button drifted by a constant multiple of the drawing's coords. Replaced it with the camera-projected rect that DrawingTextProjector already writes to uiStore.drawingTextRects for the selected drawing (it projects the selection even with no text): anchor at the projected top-right corner with position: 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 by getAcStats), not as an attribute. convertCharacterToToken only mapped an "AC"/"Armor Class" attribute (the v0.33.0 fix), which only the monster generator synthesizes — so PC imports set no metadata.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.
  • [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.pendingFocusToken intent: AddToMapModal captures the POST response and records { mapId, id, x, y } before navigating; useMapStatePersistence consumes it on the target map once the grid params sync and the token has loaded, calling focusToken (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 the impersonationRestorePending pattern.
  • [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 code body flowed through the editors into TestActionPanel, whose render path calls actionCode.trim(). Hardened in three places: TestActionPanel now types actionCode? and defaults it to '' (the crash site), and both ActionEditor and TokenActionsEditor coalesce action.code ?? '' when seeding the edit form. Source-contract tested.
  • [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
    • handleConditionToggle awaited 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.