Skip to content

v0.29.0

v0.29.0 - Roll Requesting, and Bug Fixes

  • 2026-06-08

Bugs

  • [x] Impersonate doesn't work. If I'm logged in as admin, and impersonate a user, then go to /campaigns, I see my campaigns, not the user's. (Fixed: client-side race condition in useAuthFetch — isAdminMode() returned false until async whoami resolved, so early queries missed the impersonation header. Now reads localStorage directly. Also added !req.auth?.isImpersonating guard to all ADMIN_USER_ID checks in campaigns and campaignSharing routes.)
  • [x] The options in the Map dropdown in PlayTopBar have mostly disappeared. I see the map selector, and Export Map, but New Map, Edit Map, Delete Map, etc have all disappeared.

Roll shorthand

  • [x] Roll syntax '1d20+str' evaluates to '1d20+(actual str property)'
  • [x] When a roll in the form '1d20+str' is processed, and 'str' evaluates to a token property of type ability-score, substitute the ability score modifier (via calculateAbilityModifier) instead of the raw score. (Implemented: DiceRoller.roll() / substituteProperties() now accept the token system's property definitions and do alias-aware, prop-type-aware substitution — a matched ability-score property is replaced with calculateAbilityModifier(value), all others stay raw. actionExecutor.ts threads tokenApiContext.propertyDefinitions through createDetailedRollFunction. 12 tests added in diceRoller.test.ts.) Implementation notes:
  • [x] Bugfix (v0.29.0): the substitution loop's dice-notation filter (match.toLowerCase().startsWith('d')) skipped every property whose name starts with d — dex, dexterity, defense, damage, etc. — leaving expressions like 1d20+dex un-substituted and throwing "Invalid dice expression: dex" → total 0. Tightened to /^d\d+$/i so only true dice tokens (d followed only by digits) are skipped. Regression test added in diceRoller.test.ts (13 tests, was 12).
  • [x] Bugfix (v0.29.0): the ability-modifier substitution check def.propType === 'ability-score' only matched raw fixture data — at seed time seedSystemFixture namespaces prop_type.id by systemId (e.g. system8_ability-score, dnd5e-2014_ability-score), so production propType references never matched and 1d20+dex with DEX 20 substituted the raw 20 instead of +5. Loosened to /(^|_)ability-score$/, so both raw and namespaced shapes trigger the modifier path. Regression test added in diceRoller.test.ts (14 tests).
    • The roll-shorthand substitution lives in apps/web/src/utils/diceRoller.ts (DiceRoller.substituteProperties) and currently treats every property as a raw number, so 1d20+str with STR=18 produces 1d20+18 (=19–38) instead of the intended 1d20+4 (=5–24).
    • Substitution needs prop-type awareness. The cleanest path: thread the property definitions for the token's system into DiceRoller.roll(...) alongside metadata, and when a matched property's propType === 'ability-score', replace with calculateAbilityModifier(value) instead of value. (Consider doing the same enrichment that WebGLTokenLayer already does for bar expressions — see apps/web/src/components/WebGLTokenLayer.tsx ~L522.)
    • Match by canonical name AND aliases (case-insensitive) — same lookup tokenApi.getProperty() already uses.
    • Update callers in actionExecutor.ts (the roll(...) builtin) so they pass the property definitions through.
    • Add tests pinning: 1d20+str with STR=18 → +4 modifier substituted; 10+hp with HP=45 (propType 'number') → +45 raw value still substituted (regression guard); alias-match ('str' vs 'Strength') still resolves; missing/non-numeric value still falls through unchanged.

Request function

  • [x] Implemented (v0.29.0). token.requestRoll(expression) / target.requestRoll(expression) (also targets[i]) ask a token's owner to roll and return a Promise that resolves to the rolled total, or null when the owner denies / never responds (5-min timeout) / the bridge isn't ready. Delivery is a pure socket relay through per-user rooms (requestRoll:request/requestRoll:response in packages/server-core/src/events/rollRequestRelay.ts + index.ts) — no persistence. The requester side wires the sandbox to the live socket via a module-level bridge (apps/web/src/utils/rollRequestBridge.ts, mirroring the prompt() pattern); tokenApi.createTokenApi exposes requestRoll on both token and target. The owner side collects incoming requests in useRollRequestStore and renders RollRequestModal (per-request Roll/Deny + Roll All / Deny All) within the map view; accepting rolls the expression against the target token's stats (ability-modifier-aware substitution → 3D dice + chat via rollRequestedExpression) and relays the total back. RollRequestListeners (mounted in AppRouter) handles both socket legs. Tests: bridge (7), store (5), owner roll executor (4), server relay (4). (Multi-user realtime flow verified by unit tests of the pure logic + reuse of the proven user:-room notification pattern; not exercised end-to-end with two live clients.)
  • [x] Enhanced (v0.29.0): the campaign owner (GM) now also receives the prompt for every roll request in their campaign, even when they don't own the target token. The server enriches the relayed requestRoll:request with a campaignOwnerId (looked up via tokens → maps → campaigns) and fans out to both the token owner's and the GM's per-user rooms (de-duped, with the requester excluded so they don't get prompted for their own request). When either side responds, the requestRoll:response is fanned out to the requester and both co-recipients so whichever side didn't act has its modal closed. The modal labels GM-only entries with a small "GM" badge so the GM knows the token owner may also be acting on it. New pure helpers rollRequestRecipientRooms / rollResponseRecipientRooms in rollRequestRelay.ts (8 tests added). Also fixed in the same pass: a Strict-Mode remount race in RollRequestListeners that left the requester-side bridge detached (the listener no longer gates on useSocketConnection.isConnected — socket.on(...) works pre-connect and emits buffer — and re-joins the per-user room on connect for reconnects); the bridge's "not ready" warning now names exactly which piece is missing (socket / user / target owner) instead of lumping all three.

New function token.requestRoll() requests the target token to roll an expression. Example:

  • token.requestRoll('1d20+str') requests that the target token roll '1d20+str' and return the result.

This is an async function.

  • If awaited, it will either have the roll result, or some default value if the token owner chose not to make the roll.
  • If not awaited, it will return a promise. Actions can be written to simply ignore this promise. For example, the action might return 'If the save succeeds, I inflict 50 damage, but if it fails, I inflict 100 damage', and expect chat viewers to figure it out by watching for the chat messages of the saves later.

The owner of the token will see a modal asking them to make the roll, with a button to make the roll, and a button to deny the request. If the owner owns multiple tokens with requests, the UI shows all of them in a list, as well as a 'roll all' and 'deny all' button.

The primary purpose of this is when an attack has a saving throw or opposed check. If a PC targets 10 tokens with a fireball, right now the GM has to manually select each token in turn and roll their save. With this change, the GM can just click 'roll all' and get all the rolls at once, and the players can see their own rolls happening in real time instead of waiting for the GM to report them.

The rolls from this function are just normal rolls, i.e. an action with code:

return '1d20+7'; // or whatever the property value was

This will trigger 3d dice as normal and will show the result in the chat log.

Example use case:

  • PC casts a targeted area spell that hits 5 monsters.
    • The action code has target.requestRoll('1d20+dex') in the save logic for the spell.
  • The chat log shows the attack roll and the damage roll, and the GM sees 5 applyDamage() prompts to apply damage to the 5 monsters as rolled.
  • GM sees a request to roll 5 saves. They click 'roll all'.
  • The d20s roll, and everyone sees 5 chat messages with the save results.
  • The GM resolves the applyDamage() prompts in turn based on the results.

Roll Requesting in Pre-generated Actions

In various places, this app generates actions for tokens, which have targeting configured. The goal of this change is to update them to use the new requestRoll() function, when enough information exists to know what roll that would be.

Example function:

let damage = roll('8d6');
let dc = roll('1d20+int');
if (target) {
  let saved = await target.requestRoll('1d20+dex');
  let finalDamage = saved ? damage / 2 : damage;
  target.applyDamage(finalDamage);
  return `I did ${finalDamage} fire damage with a fireball, DC ${dc}`;
}
if (targets) {
  await Promise.all(targets.map(async (target) => {
    let saved = await target.requestRoll('1d20+dex');
    let finalDamage = saved ? damage / 2 : damage;
    target.applyDamage(finalDamage);
  }));
  return `My fireball with DC ${dc} hit ${targets.length} targets.`;
}

When to use requestRoll(), and what roll to request:

  • 5e monsters from "Insert Monster" have saving throws defined for most abilities. For example, an ability might say "inflicts 8d6 fire damage, with a Dex save for half".
  • AI-generated monsters likely include similar wording, even when called for System 8 (which chatbots wouldn't know about and would default to 5e logic)
  • Algorithm Generated Monsters for 5e should have this logic defined already.
  • Algorithm Generated Monsters for System 8 should use the same logic as the Character Importer since they are effectively characters.
  • The Character Import feature, for System 8, uses metadata from the system 8 wiki. When a save is allowed, it is worded in the form "inflicts heavy fire damage (Dex half)" or "causes the Stunned condition, Con ends".

Saving throw wordage:

  • Half: typically for damaging attacks, on save they take half damage, on fail they take full (round down)
  • Ends: duration-based spells will allow a save each turn until it passes, at which point the effect ends. RequestRoll() doesn't support ongoing saves like this yet, so this can be ignored.
  • Negates: on save success, the spell's effect doesn't occur at all. On fail, it occurs normally.

TODOs:

  • [x] Algorithm-generated monsters' actions request rolls when appropriate
  • [x] AI-generated monsters' actions request rolls when appropriate
  • [x] 5e Inserted Monsters' actions request rolls when appropriate
  • [x] Imported Characters' actions request rolls when appropriate

Roll() function improvements

  • [x] Add an optional label and supplemental text field to roll(). (Implemented: roll(expr, { label, text, pipeToChat? }) emits a single decorated chat message — label renders as a small uppercase header above the dice line, and text substitutes {result} with the rich [DICE_ROLL:] token (or fallback dice line for complex/diceless expressions). Backward-compatible: roll(expr) and roll(expr, false) are unchanged. Implementation in rollExpressionToChat.ts (new label/text options, HTML-escaped to prevent injection) and actionExecutor.ts (rollSimple normalises the boolean-or-options second arg). 7 tests added in rollExpressionToChat.test.ts + 2 in actionExecutor.test.ts.)
    • Today: roll('1d20+10') rolls a d20 and writes the roll result to chat.
    • Change: roll('1d20+10', { label: 'Con Save', text: 'I roll a {result} against the effect.' }) returns a single chat message with 'Con Save' in some sort of header tag, then says 'I roll a (show the normal chat result for the roll here) against the effect.'

Target Mode Performance

  • [x] In targeting mode, the frame rate of the app drops very low. Analysis TBD
    • Root cause: useTargetingMode called useTargetingStore() with no selector at the top of the hook, subscribing every consumer to the entire targeting store. With 8 call sites (CurrentTokenBar, TargetPanel, PartyPanel, MapSecondLevelNav, FloatingTokenWindow, TokenModal, TokenContext, useImpersonatedTokenActionExecutor) every cursor move during targeting fired setCursorPosition / setHoveredToken on the store, which re-rendered all 8 heavy panels and ran 8 redundant keydown / area-click / validity-update effect closures. Chrome trace evidence: 5.78s targeting session showed the main thread ~70% saturated with performWorkUntilDeadline (1.5s self-time) and frequent 10–50 ms MinorGC events per long task — classic React-rerender object-churn signature.
    • Fix: Split the hook into action vs. effects.
      • useTargetingMode(options) no longer subscribes to the targeting store. It returns just { executeOrTarget }. The callback reads a fresh snapshot via useTargetingStore.getState() at invocation time. Per-consumer re-render cost from the targeting store is now zero.
      • New useTargetingEffects(options) hook owns the keyboard handlers (Escape / Enter / Tab), the targeting-area-click window listener, and the reactive validity / area-preview update. It subscribes via fine-grained selectors (useTargetingStore(s => s.active) etc.) so unrelated store fields don't re-trigger it.
      • New <TargetingEffectsHost /> component (apps/web/src/components/TargetingEffectsHost.tsx) calls useTargetingEffects and is mounted exactly once inside WebGLMap, replacing the 8 redundant copies that previously came along with each consumer.
    • Files: apps/web/src/hooks/useTargetingMode.ts, apps/web/src/components/TargetingEffectsHost.tsx (new), apps/web/src/components/WebGLMap.tsx (mount + import).
    • Tests: Added apps/web/src/hooks/useTargetingMode.subscription.test.ts — a structural regression guard that fails if useTargetingMode regrows a bare useTargetingStore() call, regrows useEffect calls, or stops exporting useTargetingEffects. Existing useTargetingMode.notifications.test.ts, useTargetingMode.targetingInfo.test.ts, targetingStore.test.ts, and TargetingOverlay.test.ts continue to pass unchanged.

Character Context

The Character Context redesign is broken into atomic sub-tasks so each one gets its own focused commit. Spec source: the original 5 bullets are preserved verbatim below sub-task 8 for reference.

  • [x] 1. Split the top-right rail — move "Zoom Artwork" out of TokenContextActions into a new standalone TokenZoomButton that lives in the top-right corner of the portrait (so it scrolls out of view with the portrait). The remaining TokenContextActions becomes a horizontal [...] [Focus] group rendered inside the existing Play/Build local tab strip (where it was previously vertical). Wired in PlayModeView (portrait rail) and CharacterSubcontext (tab strip + impersonation-error fallback).
    • New: apps/web/src/components/CharacterDetail/TokenZoomButton.tsx — owns the carousel modal, keyboard nav (ArrowLeft/ArrowRight), de-duped artwork list, and the token-context-actions-zoom test id.
    • Refactored: apps/web/src/components/CharacterDetail/TokenContextActions.tsx — now a horizontal <Group> (was <Stack>); zoom button + modal removed entirely (focus + "..." menu only).
    • Wiring: apps/web/src/components/CharacterDetail/PlayModeView.tsx portrait corner renders <TokenZoomButton> always (on a token), and <TokenContextActions> only when !hideTokenContextActions. Non-portrait fallback branch also splits into "both" / "zoom only" / null. apps/web/src/components/Contexts/CharacterContext/CharacterSubcontext.tsx impersonation-error fallback renders both as a <Group>; tab-strip continues to render just <TokenContextActions> (now horizontal naturally).
    • Tests: rewrote TokenContextActions.test.ts — drops vertical-Stack / zoom-tooltip assertions, adds horizontal-Group assertion, adds TokenZoomButton source-text contract (testid, carousel, arrow-key handler, circle style), adds PlayModeView rail-wiring assertions. Updated CharacterSubcontext.viewBuildTabs.test.ts to match the new {!hideTokenContextActions && <TokenContextActions>} shape.
    • Known pre-existing failure not addressed here: PlayModeView.tokenActions.test.ts "renders a token-actions Paper guarded by token.actions length" — regex /token\?\.actions\s*&&\s*token\.actions\.length/ no longer matches because v0.29.0 add-action work inserted (token.actions.length > 0 || onAddAction). Out of scope for this commit (Rule 3).
  • [x] 2. Section scroll-spy infrastructure — add stable data-section attributes to every top-level section in PlayModeView (portrait, bio-block, dice-tools, ability-scores, actions, ...) and introduce a useSectionScrollSpy(scrollRootRef) hook that returns the currently-topmost visible section id. No UI change yet; prerequisite for sub-tasks 3 and 5.
    • Sections labelled (in document order): portrait, identity, ability-scores, quick-stats, actions, skills, feats, powers, equipment, vision-light, conditions, auras. Existing test ids (play-mode-portrait-block, play-mode-token-actions-section) preserved alongside the new data-section.
    • Hook: apps/web/src/hooks/useSectionScrollSpy.ts — accepts a RefObject<HTMLElement | null> for the scroll container, queries [data-section] within it, returns the data-section of the last element whose getBoundingClientRect().top has scrolled past the root's top (with 1 px slack for sub-pixel rounding). Throttled with requestAnimationFrame; subscribes to scroll (passive) on the root and resize on the window; cleans up on unmount. Pure DOM, no external deps.
    • Tests: new apps/web/src/hooks/useSectionScrollSpy.test.ts — 20 source-text contracts covering the hook signature, querySelector usage, doc-order break, rAF throttling, scroll/resize subscriptions + teardown, plus a 12-section presence guard for PlayModeView. Behavioural scroll testing deferred to sub-task 3 where the hook gets its first consumer (the breadcrumb shell), since web-app vitest runs in node env.
    • No UI change — useSectionScrollSpy has no callers yet. Sub-task 3 lands the breadcrumb that consumes it.
  • [x] 3. Sticky breadcrumb shell — underneath the Play/Build tab strip, render a fixed (non-scrolling) breadcrumb bar (<character-name> > <section-name>). Pure presentation, driven by useSectionScrollSpy from sub-task 2. No dropdowns yet — those land in sub-tasks 4 and 5.
    • New: apps/web/src/components/Contexts/CharacterContext/CharacterContextBreadcrumb.tsx — exports CharacterContextBreadcrumb + sectionLabel. Renders <character-name> > <section-name> inside a thin bar with a bottom border. The character crumb always renders; the section crumb hides while activeSection is null (top of scroll) or 'portrait' (still inside the portrait section), per spec. Exposes data-active-section on the bar so future code (and tests) can read the live spy result without hooking the hook again.
    • Wiring (CharacterSubcontext.tsx): added viewScrollRef = useRef<HTMLDivElement>(null); restructured the View Tabs.Panel to host an inner <Box ref={viewScrollRef} overflowY="auto"> that owns the scroll (Tabs.Panel itself no longer scrolls). The breadcrumb is rendered between the tab strip <Group> and the View <Tabs.Panel>, and only on activeTab === 'view' (the Build panel uses CharacterDetail which has no data-section attrs). New testid character-context-view-scroll on the inner scroll Box.
    • Tests: new CharacterContextBreadcrumb.test.ts — 14 source-text contracts pinning the props shape, the spy hook wiring, the conditional section render, the sectionLabel mapping (null/portrait → null, known ids → labels, unknown → fallback to raw id), and the CharacterSubcontext integration (import, ref, conditional render on view tab, structural placement between strip and panel, scroll ownership). Regression: existing CharacterSubcontext.viewBuildTabs.test.ts still passes — the regex <Tabs\.Panel value="view">[\s\S]*?<PlayModeView continues to match because the new inner Box is between them.
  • [x] 4. Breadcrumb impersonation dropdown — make the leftmost crumb (<character-name>) a click-to-open dropdown with:
    • "Stop impersonating "
    • "Change impersonation" (stub disabled in this commit, separate TODO below)
    • Separator
    • List of map tokens bound to characters owned by the current user; clicking one swaps impersonation.
    • Implementation: the character crumb is now wrapped in a Mantine <Menu> (zIndex={3000}) with an IconChevronDown next to the name. Items render in this order — Stop impersonating <name> (only when impersonatedToken is set), Change impersonation… (always present, disabled with a tooltip), Menu.Divider (only when otherTokens.length > 0), then one Menu.Item per other impersonatable token. Test ids: character-context-breadcrumb-character (Menu.Target), -stop-impersonating, -change-impersonation, -impersonate-${tokenId}.
    • New helper: pickImpersonatableTokens(tokens, userId) — exported pure function. Filters to layerId === 'tokens' + owner === userId + metadata?.characterId. Mirrors the heuristic UI.tsx already uses for auto-impersonation, with the bound-character filter from the spec. Unit-tested with 4 token-shape cases.
    • State plumbing: the breadcrumb reads impersonatedToken / setImpersonatedToken / unsetImpersonatedToken from useUIStore, and the map-scoped tokens from useTokens(undefined, mapId || '') — same pattern MobileContextBar uses. Stop-impersonating calls unsetImpersonatedToken(); selecting a token calls setImpersonatedToken(t).
    • Targeting-mode flow (the "Change impersonation" → next-clicked-token interaction): explicitly deferred. The menu item ships disabled with the tooltip "Coming soon — pick the next-clicked token as the new impersonation". Tracked as a separate TODO at the bottom of this section.
    • Tests: extended CharacterContextBreadcrumb.test.ts to 23 contracts (was 14) — adds 4 unit cases for pickImpersonatableTokens plus 7 source-text assertions pinning the Menu shape, the conditional Stop item, the disabled Change item with tooltip, the divider gating, the per-token map shape, and the high z-index.
  • [x] 5. Breadcrumb section dropdown — make the right crumb (<section-name>) a click-to-open dropdown with:
    • "Scroll to top of section"
    • If subsections exist (e.g. Actions categories), list them in document order with click-to-scroll.
    • Separator
    • Sibling sections at the same level, click-to-scroll.
    • Implementation: the section crumb is now a <Menu> (zIndex={3000}) wrapping an UnstyledButton with the section label + IconChevronDown. Items: Scroll to top of section (calls helper with active section), Menu.Divider, then one item per other entry of SECTION_ORDER. Test ids: character-context-breadcrumb-section (Menu.Target), -section-scroll-top, -section-jump-${id}. The trigger exposes data-section-id={activeSection} for assertions and future styling.
    • New exports: SECTION_ORDER (frozen array, document order, omits portrait) and scrollSectionIntoView(root, sectionId) (smooth scrollIntoView with block: 'start'; no-ops on missing root or missing target).
    • Subsection list (Actions categories) — deferred to sub-task 8 per the original spec. The current dropdown only renders the scroll-to-top item plus the sibling list.
    • Tests: extended CharacterContextBreadcrumb.test.ts to 33 contracts (was 23) — adds 5 unit cases for SECTION_ORDER + scrollSectionIntoView and 5 source-text assertions pinning the section dropdown shape.
  • [x] 6. Per-character UI preferences hook — useCharacterUIPrefs(characterId) that persists the filter/group/sort selections (and any future per-character UI prefs) to localStorage keyed by character id. Prerequisite for sub-tasks 7 and 8.
    • Storage layer: apps/web/src/utils/characterUIPrefs.ts exposes DEFAULT_CHARACTER_UI_PREFS, loadCharacterUIPrefs(id), saveCharacterUIPrefs(prefs, id), plus the namespaced-key helper. Storage key: purple-cat:character-ui-prefs:${characterId}. Validation accepts only the documented enum values for actionsGroup (category | collapse-by-category | flat) and actionsSort (alphabetical | system); invalid values fall back to defaults. Filter is a string[] with non-string entries dropped. Missing keys are filled from defaults so the format is forward-compat. JSON parse errors and empty/missing characterId all return defaults.
    • Hook: apps/web/src/hooks/useCharacterUIPrefs.ts exports useCharacterUIPrefs(characterId) returning { prefs, setPrefs, resetPrefs }. State is seeded lazily from loadCharacterUIPrefs; a useEffect keyed on characterId re-hydrates when the active character changes (e.g. user switches Character Contexts without remounting). setPrefs(patch) merges with previous state, persists, and updates state in one render. resetPrefs() writes DEFAULT_CHARACTER_UI_PREFS through. Both setters are useCallback-memoized.
    • Type surface: CharacterUIPrefs (interface), ActionsGroupMode, ActionsSortMode, and UseCharacterUIPrefsResult are exported. actionsFilter is readonly string[] with [] meaning "no filter".
    • Tests: 13 jsdom-env runtime contracts in utils/characterUIPrefs.test.ts (defaults, namespacing, round-trip, character isolation, malformed JSON fallback, invalid filter entry filtering, enum-fallback for invalid group/sort values, missing-key forward-compat, save no-op when characterId is missing, overwrite semantics) plus 9 source-text contracts in hooks/useCharacterUIPrefs.test.ts (imports, return shape, signature, lazy state seed, character-id re-hydrate effect, partial-update merge + persist, useCallback memoization, reset-then-save behavior, exported result type).
  • [x] 7. Filter/group/sort in Actions section — on the same sticky bar as the breadcrumb, add a filter/group/sort icon in the Actions section. Filter by category only (the IAction shape has no type field — verified in packages/shared/src/types/vtt.ts:14). Group by category (default) / collapse-by-category / flat. Sort alphabetical / system-default. Persisted via sub-task 6's hook.
    • Pure helper: apps/web/src/components/CharacterDetail/applyActionPrefs.ts — applyActionPrefs(actions, prefs) returns the same { category, label, actions }[] shape groupActionsByCategory already produces. Filter is a list of category ids ([] = no filter); category || 'other' is the bucket. Group category and collapse-by-category share the same data shape (renderer differs); flat returns one synthetic group { category: 'all', label: 'Actions', actions: [...] }. Sort system is identity (preserves authored order); alphabetical sorts each group by name and never mutates input. 14 unit cases cover defaults, filter (single category, "other" bucket, empty filter, exclude-all), group modes (each + identity equivalence between category and collapse), sort modes (within-group + flat + immutability), and a combo (filter+flat+alphabetical).
    • Popover UI: apps/web/src/components/Contexts/CharacterContext/ActionsPrefsControl.tsx — IconAdjustmentsHorizontal ActionIcon opens a <Popover withinPortal zIndex={3000}> with three sections: Filter (Mantine Chip.Group multiple with one chip per available category, ordered using ACTION_CATEGORY_ORDER then unknowns), Group (Radio.Group with the three documented modes + descriptive labels), Sort (Radio.Group with the two modes). Filter section is hidden when no categories are available. Test ids: actions-prefs-control-trigger / -filter / -group / -sort, actions-prefs-filter-chip-${id}, actions-prefs-group-${mode}, actions-prefs-sort-${mode}.
    • Breadcrumb integration: CharacterContextBreadcrumb got an optional actionsExtras?: ReactNode slot that is rendered to the right of the section dropdown only when activeSection === 'actions'. The slot lives in a right-aligned <Box data-testid="character-context-breadcrumb-actions-extras">. Inner Group was wrapped in a flex-1 container with justify="space-between" so the section dropdown stays left-aligned and the extras stay right-aligned.
    • State plumbing: CharacterSubcontext now calls useCharacterUIPrefs(characterId) and passes <ActionsPrefsControl prefs={actionPrefs} onChange={setActionPrefs} availableCategories={actionCategories} /> to the breadcrumb's actionsExtras slot. actionCategories is memoized from liveImpersonatedToken?.actions. PlayModeView additionally receives the prefs via a new optional actionPrefs?: CharacterUIPrefs prop.
    • PlayModeView rendering: Accordion was added to the Mantine import. The Actions Paper section now: (a) computes groups = actionPrefs ? applyActionPrefs(token.actions ?? [], actionPrefs) : groupActionsByCategory(token.actions ?? []), (b) extracts a renderActionRow(action) closure for the per-action Group/Button/Menu JSX so it can be reused, (c) renders an <Accordion multiple variant="contained"> when actionsGroup === 'collapse-by-category' (each Accordion.Item per category, action rows inside Accordion.Panel), and (d) renders the legacy <Stack> layout otherwise — hiding the group label when group.category === 'all' (flat mode). Existing data-testid ids are preserved; new play-mode-actions-accordion testid added for the collapse mode.
    • Tests: 14 unit cases for applyActionPrefs plus 25 source-text contracts in ActionsPrefsControl.test.ts covering the popover shell, filter chips (multi-select binding + Chip ordering), group + sort radio bindings, breadcrumb actionsExtras plumbing, CharacterSubcontext wiring (hook import + memoized categories + props passed through), and PlayModeView's actions-prefs branch (Accordion testid, flat-mode label hide, renderActionRow extraction). Three pre-existing PlayModeView source-text tests had their regexes updated to match the new code shape (Accordion in import, actionPrefs in destructure, applyActionPrefs ? : groupActionsByCategory ternary). One stale errorDeimpersonate regex was updated to allow the v0.29.0 sub-task 1 <Group> wrapper. The pre-existing PlayModeView.tokenActions.test.ts:35 failure is unchanged (out of scope per Rule 3, documented at sub-task 1).
  • [x] 8. Subsection dropdown for Actions categories — extend sub-task 5's section dropdown so that, while the Actions section is active, the subsection list is the Actions categories (in document order) and not just generic subsections. Scrolls with animation when clicked.
    • DOM markers: PlayModeView now stamps data-subsection={group.category} on each Actions group element — both the default <Stack> layout and the collapse-by-category <Accordion.Item>. Existing data-testid="token-actions-group-${category}" ids are unchanged.
    • Helpers: three new exports in CharacterContextBreadcrumb.tsx — findSubsections(scrollRoot, sectionId) (queries [data-section="<id>"] [data-subsection] in document order, drops empty/missing markers, no-ops on missing root/section), scrollSubsectionIntoView(scrollRoot, sectionId, subsectionId) (smooth scrollIntoView with block: 'start', no-ops on any missing link in the chain), and subsectionLabel(sectionId, subsectionId) (maps Actions categories via ACTION_CATEGORY_LABELS plus friendly labels for the synthetic 'all' and 'other' buckets; passes through for non-actions sections today).
    • Section dropdown integration: between the existing "Scroll to top of section" item and the <Menu.Divider />, the dropdown now renders subs = findSubsections(scrollRootRef.current, activeSection) as a list of <Menu.Item>s. The block returns null when subs.length === 0 so non-Actions sections still see the original two-section layout. Each item calls scrollSubsectionIntoView(...) and uses subsectionLabel for its display text. Test ids: character-context-breadcrumb-subsection-${id}.
    • Tests: added 16 contracts to CharacterContextBreadcrumb.test.ts (now 50 total) — 9 unit cases for findSubsections + scrollSubsectionIntoView + subsectionLabel (including null/missing-link no-ops, document-order preservation, empty-marker filtering, and label fallbacks), 5 source-text assertions pinning the subsection-list shape inside the Menu.Dropdown (import, query call, empty-guard, between scroll-to-top and the divider, label call), and 2 source-text assertions for the new data-subsection markers in PlayModeView.
    • Notes: the "siblings" portion of the dropdown (sub-task 5) is unchanged. The query reflects the live filter/group prefs because the DOM does — when flat mode is selected the sole subsection is the synthetic 'all' group, which subsectionLabel renders as just "Actions"; in collapse mode each subsection corresponds to an Accordion.Item and clicking still scrolls correctly (the Accordion's collapsed state does not affect the outer item's scroll target).
  • [x] 9. "Change impersonation" targeting-mode flow (deferred from sub-task 4) — sub-task 4 ships the disabled stub menu item; this TODO covers the actual flow. When clicked, the app enters a "targeting mode" overlay where the next on-map token click becomes the new impersonation. Acceptance: cursor/hover affordance shows targeting is armed, Escape cancels, clicking a non-impersonatable token (no linked character / not owned by user) shows a brief inline error and stays in targeting mode, clicking a valid one calls setImpersonatedToken(t) and exits. Likely shares plumbing with the existing token-targeted-action flow. (Implemented (v0.29.0): the breadcrumb's "Change impersonation…" item now arms the existing impersonate-click mode with requireImpersonatable: true + the user's id. impersonateModeStore gained requireImpersonatable/ownerUserId via enterMode(options?); the keyboard "I" shortcut stays loose so a GM can still impersonate any token. New shared predicate isImpersonatableToken(token, userId) (utils/impersonation.ts, also tolerant of string-shaped metadata, and now backing pickImpersonatableTokens) gates the WebGLMap mouse and touch handlers: a non-impersonatable token raises an inline actionNotificationStore error at the click point and keeps the mode armed; a valid one calls setImpersonatedToken and exits. Escape / right-click cancel (pre-existing); ImpersonateModeBanner shows a stricter instruction in this mode. Tests: 9 predicate unit cases, 4 store cases, 5 WebGLMap source-text contracts, +2 breadcrumb wiring, 2 banner.)
Original spec bullets (reference, preserved verbatim)
  • The 'Zoom Artwork' button should move from its current location to the top right of the portrait art, neither fixed nor sticky, it will scroll out of view when the top of the portrait is scroll out of view.
  • The 'Focus Camera on Token' and 'More Actions' ... button should be visibly in the tab bar along with Play and Build, but aligned right, in a fixed position similar to the tab bar.
  • Underneath the tabs in the tab bar, in a fixed position, there shall be a dynamic breadcrumb based on the scroll position of the context.
    • Leftmost: current impersonated character name. This has a caret indicating dropdown. When clicked, options include:
      • Stop impersonating (character name)
      • Change impersonation: when clicked, enter a sort of "targeting mode" where the next token clicked becomes the impersonated token
      • Below this, a list of tokens on the map which are bound to characters owned by the current user. Clicking one instantly changes impersonation to that character token.
    • Greater than caret
    • Section name, such as Ability Scores, Actions, etc, based on scroll position.
      • At the very top, or within the portrait section, this is empty.
      • Otherwise use the current section name.
      • Caret indicates dropdown.
      • On click, show options:
        • Scroll to top: scrolls with animation so current section's header appears topmost in the scroll pane.
        • If subsections exist (e.g. in Actions), a list of subsection headers in the order they appear (such as Action categories). Clicking one scrolls to it with animation.
        • Separator
        • Show sibling sections: list the other sections at the same level as the current section. Clicking one scrolls to it with animation.
  • filter/group/sort in character context actions
    • In the Actions section, on the same vertical sticky bar as the breadcrumbs, show a filter/sort/group icon. On click, show:
      • Filter options: filter actions by category or type.
      • Group options: group actions by category (default), collapse by category (making each category a collapsible accordion), or no grouping (flat list).
      • Sort options: sort actions alphabetically by name, or in the default order defined by the system or user.
  • character context top right buttons:
    • image art zoom button should be part of the portrait, not fixed
    • ... and focus should be in the tab bar, horizontal

Deferred from 0.28.0

  • [ ] Map Generator — wire deferred backend routes. v0.25.0 shipped the full UI + server-core pipeline against client-side stubs (MapGeneratorClient / SlotMatcherClient / RenderMapClient in apps/web/src/components/UI/MapGenerator/mapGeneratorClient.ts). To finish: (1) POST /api/map-generations calling generateMapDescription + extractSlots + matchSlotsToLibrary, persisting via the Phase 2.3 store; (2) GET /api/map-generations/:id returning the persisted record so the assets step hydrates from server state; (3) POST /api/map-generations/:id/render invoking the painter/objectPlacer/tokenPlacer pipeline with a realtime ProgressReporter emitting per-phase ticks on a channel the modal subscribes to. Then swap the three stub clients to fetch()-based impls (1-line change in each — the contracts are designed for it). See per-phase notes in docs/todo/features/map-generator.md. (DEFERRED: large multi-phase feature; deferred past 0.29.0)

  • [ ] Map Generator — Playwright E2E. Blocked on the routes above and on Playwright not yet being installed in the workspace. Setup task: add @playwright/test, playwright.config.ts, MSW + route mocks, then write e2e/map-generator.spec.ts covering palette → prompt → asset overrides → render → entity removal. See docs/todo/features/map-generator.md § Phase 7.3. (DEFERRED: blocked on routes above; deferred past 0.29.0)

  • [x] Token → character resolution ignores deleted_at, so when a token's linked character has been soft-deleted, the UI (TokenModal / hover card / Character Context) still surfaces the original user_id and shows "owned by " — confusing for users who actually had the character shared with them and have since lost access. Fix: have the token-side character lookup honor deleted_at IS NULL (or treat a deleted target as not-found) and render a "linked character missing/deleted" affordance instead of the original-owner string. Repro: character df6202b5-5333-4531-9289-4f257c72e164 ("Pip") was soft-deleted 2026-05-07; 5 tokens still reference it via metadata.characterId, 2 of them not deleted; the recipient of the original share sees "owned by another user" but the character does not appear in her character list (correct), so the two UIs disagree.

  • [x] In the Character Context, if you don't have access to a character it says 'Failed to fetch character'. In this case, it should say 'you do not have access to this character' with a button to request permission from the owner and/or GM. This request pops up a notification (similar to Bring All To Map) with a button to grant access (one for view and one for view+edit), as well as a Deny button. (Implemented: 403 shows "You do not have access to this character." + Request Access button → POST /api/characters/:id/request-access creates a character_access_requests row and emits characterAccessRequest:created to owner's socket room. Owner sees a sticky notification with Grant View / Grant Edit / Deny buttons. Granting creates a character_members row and emits characterInvite:changed reason=accepted to requester (triggering the existing CharacterAccessGrantedListener). Requester sees "Access request sent" confirmation.)

  • [x] Add a new type of Drawing: Wall. Walls are composed of line segments that run from grid corner to grid corner. They can turn at 90 degree angles, but not diagonally. They are drawn in "pencil mode", where the user clicks to start the wall, moves along the wall's path, then clicks again to end. The wall segments are created by snapping the user's mouse movements to the nearest grid line.

    • Implementation: New 'wall' DrawingTool type in IDrawing.ts with IWallDrawing interface. startDrawing forces corner snapping for wall. updateDrawingPreview snaps to grid corner + constrains to 90° from last confirmed point. addPoint does the same for confirmed vertices. completeDrawing strips the preview point. Toolbar button added (IconWall). Rendering in WebGLDrawingLayer mirrors line (stroke only, no fill). WebGLMap handles wall the same as line (click to add points, right-click to finish). Server Zod schema updated. 22 tests added to drawingStore.test.ts.
  • [x] Add 'slugs' to campaigns, maps, and characters so they can have prettier URLs and filenames (in the case of images)

    • slug is an optional property; if not specified, the id can still be used in the URL as before
    • slug is unique across the campaign for maps and characters
    • slug defaults to a URL-safe version of the name (e.g. "Giant Spider" → "giant-spider"), but can be manually edited by the user
    • edit campaign, edit map, and character detail modals all have an input for slug, which is auto-filled with the default value but can be edited
    • when an image is uploaded for a character or map, the filename is based on character or map slug, such as "giant-spider-1.png" instead of a random string.
  • [x] Foundation texture scaling

    • Add a slider to the Edit Map dialog (min 0.1, default 1, max 10 for now).
    • The slider sets the new foundation texture scale property of the map.
    • When rendering the textures on the foundation layer, a scale of 1 means the texture matches the size of one square. A scale of 2 means the texture is twice the size of one square, so it repeats every 2 squares. A scale of 0.5 means the texture is half the size of one square, so it repeats every 0.5 squares (i.e. 4 times per square).
    • [x] UI to change foundation texture scaling is not visible in the Edit Map UI (Fixed (v0.29.0): the Texture Scale slider existed only in the now-unused EditMapModal; the live Edit Map panel is EditMapSubcontext (opened via MapSecondLevelNav → openSubcontext('editMap')). Added the same Slider (min 0.1 / max 10 / step 0.1, default 1, with marks) to EditMapSubcontext, hydrated from currentMap.foundationTextureScale and included in the updateMap payload; triggerMapTextureRefresh() already re-renders the foundation on save. 4 source-text contracts added to EditMapSubcontext.test.ts.)
  • [ ] human todo: investigate why user's character can't be seen in their characters page, but the GM can see them (shared) and confirms the user owns that character according to the UI

  • [x] Bug: mobile users can't select drawings, even though they can make them.

  • [x] When the user selects a token, show a contextual UI in the bottom left of the map:

    • Title: "Target [token name] with:"
    • Then a scrollable horizontal list of all targeted actions on the current impersonated token.
    • Clicking/tapping one of these fires the action with the selected token as the target, and then exits targeting mode.
    • There's also a "clear target" button to exit targeting mode without firing an action.
    • (Implemented (v0.29.0): new TargetActionBar (components/UI/TargetActionBar/) mounted in UI.tsx next to MapEndTurnButton (same sidenav/subcontext leftOffset, lifted above it). Shows when currentToken is set, differs from the impersonated token, and the impersonated token has ≥1 target.enabled action. Renders "Target <name> with:" + a horizontal ScrollArea of action Buttons + a clear-target ✕. Clicking an action fires it against the selected token via the new useExecuteImpersonatedActionOnTarget(campaignId, mapId) hook — which injects the target through the _targetToken side-channel and calls ActionExecutor.executeAction directly (no click-to-target step), mirroring the now-disabled TargetPanel's inline logic — then clears the selection; the ✕ clears without firing. Pure helpers getTargetedActions / shouldShowTargetActionBar unit-tested (6 cases) + source-text contracts for the component, hook, and mount (5). 11 tests total.)
  • [x] Soft-deleting a character should clean up / unlink dependent tokens. Currently, DELETE /characters/:id just sets deleted_at on the row; any tokens whose metadata.characterId points at it are left dangling, still claiming the character link on whatever maps they live on. Decide on a policy (recommend: keep the tokens but clear metadata.characterId — and any other character-derived fields like portrait/token artwork references that were inherited — so they survive as plain tokens), implement it in the delete handler, and add a one-shot migration / cleanup script for the existing dangling tokens already in prod. Pair this with the 0.28.0 token→character lookup fix so both ends of the relationship stay consistent. See repro under the 0.28.0 Misc TODO for character df6202b5. (Policy: keep tokens but clear metadata.characterId via json_extract query in softDeleteCharacter. Artworks left intact so tokens retain their appearance. One-shot cleanup script: packages/server-core/src/scripts/cleanup-dangling-character-tokens.ts with --dry-run support.)

  • [x] Character Sheet edits currently require a full PUT of the character, including all actions and properties. They should be PATCH calls with the minimum payload necessary for the change. (Fixed: handleCharacterChange in CharacterDetail now builds a Partial<Character> patch with only the changed fields and sends that to updateCharacter, instead of spreading the full character object. The hook already used method: 'PATCH'.)

  • [x] Write a script to remove ALL base64-encoded images from the database. Ensure there are no code paths that can write new base64 images to the DB (e.g. from the token editor). Add validation to the token editor that strips any data: or blob: URLs from artwork entities before sending them to the API, preventing accidental serialization of large base64 images in the database. Any API call that could potentially return base64 data should double check and strip base64 data before sending to the client, just in case. (Implemented: isTransientUrl / stripTransientUrls / stripTransientUrlsFromArtworks utilities in @purple-cat/shared. Client-side stripping in useTokens.updateToken and useCharacters.updateCharacterArtworks. One-shot cleanup script: packages/server-core/src/scripts/strip-base64-images.ts with --dry-run. Server-side response stripping deferred to the Common API middleware TODO.)

  • [x] Don't load unnecessary assets (Partial: added loadList option to useCharacters() so consumers that only need imperative helpers skip GET /api/characters. Updated CharacterSubcontext, CharacterNav, CharacterDetail (embedded), and CampaignList (hideCharacters). Remaining: use case 2 (duplicate token loads) needs token-loading architecture refactor; use cases 3/6 need server-side campaignId filter + fields query param.)

    • [x] Use case 1: user is on /play/:campaignId/:mapId, and the app loads every character in the database. For one, even if all characters were needed, we should filter that load by campaign and map id. But there is no actual need for this data at all.
      • When the Character Context is loaded, we already fire off a load for that character, so pre-loading it isn't even helping.
      • When Import Character or Import Party is clicked, then we should load characters, but still limit the characters loaded to those relevant to the current campaign and map, and limit the fields to those needed to populate the character list.
    • [x] Use case 2: user is on /play/:campaignId/:mapId, and the app fires off one GET /tokens?layerId=x for each layer. But then it fires off GET /tokens?mapId=x. The latter call retrieves all the necessary data, making the per-layer calls unnecessary and redundant. Just call GET /tokens?mapId=x and be done with it.
      • Also: in the same page load, the GET /tokens?mapId call was fired twice, and GET /tokens?mapId=x&layerId=tokens was fired twice.
      • (Fixed (v0.29.0): useTokens(layerId?, mapId?) now keys its react-query query by ['tokens', mapId] (was ['tokens', layerId, mapId]) and fetches only GET /tokens?mapId=, then filters to the requested layer client-side via the new exported filterTokensByLayer(tokens, layerId) (missing layerId ⇒ 'tokens'). Every useTokens('tokens'|<layer>|undefined, mapId) consumer on the same map now shares ONE query/network request instead of one per distinct layerId, which also collapses the duplicate ?mapId vs ?mapId&layerId=tokens calls. Verified server-side that GET /tokens?mapId= returns all layers with the same filtering as the per-layer call (packages/server-core/src/routes/tokens.ts:408), so the client-side filter reproduces the per-layer result exactly. The socket token:created handler no longer drops other-layer tokens (the cache is map-wide now). 7 tests in useTokens.layerDedup.test.ts; existing useTokens.* tests unchanged.)
    • [x] Use case 3: user is on /characters route. App calls GET /characters, retrieving every character in the DB. While this page does show all characters without pagination, it should at least filter by campaign id. Also, on this page, we can only see the character name and image, so the API call should only return those fields, not the full character data including actions and properties. (Resolved (v0.29.0): the two heavy concerns were already eliminated by prior hardening — GET /api/characters returns only access-controlled stub columns (id, name, level, classes, artworks/token image, version + timestamp fields — NOT the heavy actions/properties/origins/attributes JSON; characters.ts:277) and is scoped to the user's owned + shared characters (not "every character in the DB"; 401 without auth). The /characters page itself is an intentional global "all my characters" view (it labels each row with its campaigns rather than filtering to one), so it stays unscoped. The remaining literal ask — a campaign filter — is now implemented as the optional ?campaignId= query param on GET /api/characters + useCharacters({ campaignId }), consumed by use case 6's MapList. The list's extra stub fields (level/classes/timestamps/version) are required by the list UI's labels/sort/version-badge, so they aren't trimmed further. Tests: characters.campaignFilter.test.ts (3) + useCharacters.loadList.test.ts (+3).)
    • [x] Use case 4: user is on /characters/:id. App calls GET /characters/:id, which is fine, but it also calls GET /characters, retrieving every character in the database. This second call is totally unnecessary and insecure. If this data is needed for some modal (I'm not even sure which; I can't think of a use case), call it when needed, and filter by campaign id, and limit the fields to those needed for the use case.
    • [x] Use case 5: user is on /campaigns, where they see a list of campaigns they have access to, and nothing else. But the app fires off GET /characters, retrieving every character in the database, even though no character data is needed on this page at all.
    • [x] Use case 6: user is on /campaigns/:id, where they see the campaign details and a list of maps and characters. The app requests all characters in the DB, even though it only needs a limited number of campaign-specific characters, and only the name, id, and image. (Fixed (v0.29.0): /campaigns/:id renders MapList, which called useCharacters() (all of the user's characters as stub rows) and then filtered to the campaign client-side. It now calls useCharacters({ campaignId }), so GET /api/characters?campaignId= returns only this campaign's characters (server-side filter added in the use-case-3 commit); the client-side filter is kept as a defensive no-op. Fields were already minimal stubs — the heavy actions/properties JSON was never sent. 1 contract added to MapListCampaignPage.test.ts.)
  • [x] Investigation task: I found an 8 MB webp, and it wasn't all that high resolution. It was a campaign background image. All images uploaded by users, loaded from Google, loaded from a URL, etc, should be processed as described below, so find any flows where this isn't true and add TODOs to fix them:

    • Load the image in the browser.
    • While still in browser, use existing libraries to convert it to webp with high quality compression.
      • Upload this image, either via API or direct S3 from the browser.
    • Now process the image into a 512x512 thumbnail, also compressed as webp, and upload that as well.
    • Store the processed webp URLs in the DB (e.g. tokenArtwork, campaign background image, etc)
    • Investigation findings (v0.29.0): The server already processes images through processImageWithLOD (Sharp-based webp conversion + LOD variants) in both S3 and local storage providers. Client-side compressFileForUpload exists but is only used in ImageDropZone.tsx. Many direct uploadImage() calls bypass client-side compression (CampaignEditModal, TokenModal, BackgroundRemoval, ChatSettings, MapSnapshotCapture, BatchTokenModal). However, these still go through the server-side pipeline. The 8MB webp issue may be from a flow that writes image data directly to the DB without going through the upload route (e.g. base64 serialization in metadata — now fixed by the base64 stripping TODO above). Follow-up TODOs: (1) Ensure compressFileForUpload is called before all uploadImage() calls for an additional client-side optimization pass. (2) Verify CampaignEditModal campaign background images go through the standard upload route with proper max dimensions (campaign category = 1024px).
  • [x] Add a button to "add action" to the bottom of the action list in the CharacterSubcontext. (Implemented: PlayModeView accepts a new onAddAction?: () => void prop and renders a violet Add Action button (with IconPlus) at the bottom-right of the actions Paper when the callback is provided; the actions section also renders when the token has no actions yet so the first one can still be added. CharacterSubcontext wires onAddAction to a handleAddAction callback that sets editActionId to the sentinel '__new__', reusing the existing ActionEditor modal contract for opening a blank create form. Inert when no token is impersonated. 9 new tests in PlayModeView.addAction.test.ts pinning the prop, button rendering, and CharacterSubcontext wiring.)