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?.isImpersonatingguard to allADMIN_USER_IDchecks 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 (viacalculateAbilityModifier) 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 matchedability-scoreproperty is replaced withcalculateAbilityModifier(value), all others stay raw.actionExecutor.tsthreadstokenApiContext.propertyDefinitionsthroughcreateDetailedRollFunction. 12 tests added indiceRoller.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 withd—dex,dexterity,defense,damage, etc. — leaving expressions like1d20+dexun-substituted and throwing "Invalid dice expression: dex" → total 0. Tightened to/^d\d+$/iso only true dice tokens (dfollowed only by digits) are skipped. Regression test added indiceRoller.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 timeseedSystemFixturenamespacesprop_type.idby systemId (e.g.system8_ability-score,dnd5e-2014_ability-score), so productionpropTypereferences never matched and1d20+dexwith 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 indiceRoller.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, so1d20+strwith STR=18 produces1d20+18(=19–38) instead of the intended1d20+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'spropType === 'ability-score', replace withcalculateAbilityModifier(value)instead ofvalue. (Consider doing the same enrichment thatWebGLTokenLayeralready does for bar expressions — seeapps/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(theroll(...)builtin) so they pass the property definitions through. - Add tests pinning:
1d20+strwith STR=18 →+4modifier substituted;10+hpwith HP=45 (propType'number') →+45raw value still substituted (regression guard); alias-match ('str'vs'Strength') still resolves; missing/non-numeric value still falls through unchanged.
- The roll-shorthand substitution lives in
Request function
- [x] Implemented (v0.29.0).
token.requestRoll(expression)/target.requestRoll(expression)(alsotargets[i]) ask a token's owner to roll and return a Promise that resolves to the rolled total, ornullwhen 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:responseinpackages/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 theprompt()pattern);tokenApi.createTokenApiexposesrequestRollon bothtokenandtarget. The owner side collects incoming requests inuseRollRequestStoreand rendersRollRequestModal(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 viarollRequestedExpression) and relays the total back.RollRequestListeners(mounted inAppRouter) 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 provenuser:-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:requestwith acampaignOwnerId(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, therequestRoll:responseis 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 helpersrollRequestRecipientRooms/rollResponseRecipientRoomsinrollRequestRelay.ts(8 tests added). Also fixed in the same pass: a Strict-Mode remount race inRollRequestListenersthat left the requester-side bridge detached (the listener no longer gates onuseSocketConnection.isConnected—socket.on(...)works pre-connect and emits buffer — and re-joins the per-user room onconnectfor 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 action code has
- 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 —labelrenders as a small uppercase header above the dice line, andtextsubstitutes{result}with the rich[DICE_ROLL:]token (or fallback dice line for complex/diceless expressions). Backward-compatible:roll(expr)androll(expr, false)are unchanged. Implementation inrollExpressionToChat.ts(newlabel/textoptions, HTML-escaped to prevent injection) andactionExecutor.ts(rollSimple normalises the boolean-or-options second arg). 7 tests added inrollExpressionToChat.test.ts+ 2 inactionExecutor.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:
useTargetingModecalleduseTargetingStore()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 firedsetCursorPosition/setHoveredTokenon the store, which re-rendered all 8 heavy panels and ran 8 redundantkeydown/ area-click / validity-update effect closures. Chrome trace evidence: 5.78s targeting session showed the main thread ~70% saturated withperformWorkUntilDeadline(1.5s self-time) and frequent 10–50 msMinorGCevents 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 viauseTargetingStore.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), thetargeting-area-clickwindow 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) callsuseTargetingEffectsand is mounted exactly once insideWebGLMap, 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 ifuseTargetingModeregrows a bareuseTargetingStore()call, regrowsuseEffectcalls, or stops exportinguseTargetingEffects. ExistinguseTargetingMode.notifications.test.ts,useTargetingMode.targetingInfo.test.ts,targetingStore.test.ts, andTargetingOverlay.test.tscontinue to pass unchanged.
- Root cause:
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
TokenContextActionsinto a new standaloneTokenZoomButtonthat lives in the top-right corner of the portrait (so it scrolls out of view with the portrait). The remainingTokenContextActionsbecomes a horizontal[...][Focus]group rendered inside the existing Play/Build local tab strip (where it was previously vertical). Wired inPlayModeView(portrait rail) andCharacterSubcontext(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 thetoken-context-actions-zoomtest 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.tsxportrait 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.tsximpersonation-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, addsTokenZoomButtonsource-text contract (testid, carousel, arrow-key handler, circle style), addsPlayModeViewrail-wiring assertions. UpdatedCharacterSubcontext.viewBuildTabs.test.tsto 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).
- New:
- [x] 2. Section scroll-spy infrastructure — add stable
data-sectionattributes to every top-level section inPlayModeView(portrait,bio-block,dice-tools,ability-scores,actions, ...) and introduce auseSectionScrollSpy(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 newdata-section. - Hook:
apps/web/src/hooks/useSectionScrollSpy.ts— accepts aRefObject<HTMLElement | null>for the scroll container, queries[data-section]within it, returns thedata-sectionof the last element whosegetBoundingClientRect().tophas scrolled past the root's top (with 1 px slack for sub-pixel rounding). Throttled withrequestAnimationFrame; subscribes toscroll(passive) on the root andresizeon 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 forPlayModeView. Behavioural scroll testing deferred to sub-task 3 where the hook gets its first consumer (the breadcrumb shell), since web-app vitest runs innodeenv. - No UI change —
useSectionScrollSpyhas no callers yet. Sub-task 3 lands the breadcrumb that consumes it.
- Sections labelled (in document order):
- [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 byuseSectionScrollSpyfrom sub-task 2. No dropdowns yet — those land in sub-tasks 4 and 5.- New:
apps/web/src/components/Contexts/CharacterContext/CharacterContextBreadcrumb.tsx— exportsCharacterContextBreadcrumb+sectionLabel. Renders<character-name> > <section-name>inside a thin bar with a bottom border. The character crumb always renders; the section crumb hides whileactiveSectionisnull(top of scroll) or'portrait'(still inside the portrait section), per spec. Exposesdata-active-sectionon the bar so future code (and tests) can read the live spy result without hooking the hook again. - Wiring (
CharacterSubcontext.tsx): addedviewScrollRef = useRef<HTMLDivElement>(null); restructured the ViewTabs.Panelto 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 onactiveTab === 'view'(the Build panel usesCharacterDetailwhich has nodata-sectionattrs). New testidcharacter-context-view-scrollon 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, thesectionLabelmapping (null/portrait→ null, known ids → labels, unknown → fallback to raw id), and theCharacterSubcontextintegration (import, ref, conditional render on view tab, structural placement between strip and panel, scroll ownership). Regression: existingCharacterSubcontext.viewBuildTabs.test.tsstill passes — the regex<Tabs\.Panel value="view">[\s\S]*?<PlayModeViewcontinues to match because the new inner Box is between them.
- New:
- [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 anIconChevronDownnext to the name. Items render in this order —Stop impersonating <name>(only whenimpersonatedTokenis set),Change impersonation…(always present, disabled with a tooltip),Menu.Divider(only whenotherTokens.length > 0), then oneMenu.Itemper 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 tolayerId === 'tokens'+owner === userId+metadata?.characterId. Mirrors the heuristicUI.tsxalready 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 / unsetImpersonatedTokenfromuseUIStore, and the map-scopedtokensfromuseTokens(undefined, mapId || '')— same patternMobileContextBaruses. Stop-impersonating callsunsetImpersonatedToken(); selecting a token callssetImpersonatedToken(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.tsto 23 contracts (was 14) — adds 4 unit cases forpickImpersonatableTokensplus 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.
- "Stop impersonating
- [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 anUnstyledButtonwith the section label +IconChevronDown. Items:Scroll to top of section(calls helper with active section),Menu.Divider, then one item per other entry ofSECTION_ORDER. Test ids:character-context-breadcrumb-section(Menu.Target),-section-scroll-top,-section-jump-${id}. The trigger exposesdata-section-id={activeSection}for assertions and future styling. - New exports:
SECTION_ORDER(frozen array, document order, omitsportrait) andscrollSectionIntoView(root, sectionId)(smoothscrollIntoViewwithblock: '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.tsto 33 contracts (was 23) — adds 5 unit cases forSECTION_ORDER+scrollSectionIntoViewand 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.tsexposesDEFAULT_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 foractionsGroup(category|collapse-by-category|flat) andactionsSort(alphabetical|system); invalid values fall back to defaults. Filter is astring[]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.tsexportsuseCharacterUIPrefs(characterId)returning{ prefs, setPrefs, resetPrefs }. State is seeded lazily fromloadCharacterUIPrefs; auseEffectkeyed oncharacterIdre-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()writesDEFAULT_CHARACTER_UI_PREFSthrough. Both setters areuseCallback-memoized. - Type surface:
CharacterUIPrefs(interface),ActionsGroupMode,ActionsSortMode, andUseCharacterUIPrefsResultare exported.actionsFilterisreadonly 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 inhooks/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).
- Storage layer:
- [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
IActionshape has notypefield — verified inpackages/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 }[]shapegroupActionsByCategoryalready produces. Filter is a list of category ids ([]= no filter);category || 'other'is the bucket. Groupcategoryandcollapse-by-categoryshare the same data shape (renderer differs);flatreturns one synthetic group{ category: 'all', label: 'Actions', actions: [...] }. Sortsystemis identity (preserves authored order);alphabeticalsorts each group bynameand 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—IconAdjustmentsHorizontalActionIcon opens a<Popover withinPortal zIndex={3000}>with three sections: Filter (MantineChip.Group multiplewith one chip per available category, ordered usingACTION_CATEGORY_ORDERthen 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:
CharacterContextBreadcrumbgot an optionalactionsExtras?: ReactNodeslot that is rendered to the right of the section dropdown only whenactiveSection === '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 withjustify="space-between"so the section dropdown stays left-aligned and the extras stay right-aligned. - State plumbing:
CharacterSubcontextnow callsuseCharacterUIPrefs(characterId)and passes<ActionsPrefsControl prefs={actionPrefs} onChange={setActionPrefs} availableCategories={actionCategories} />to the breadcrumb'sactionsExtrasslot.actionCategoriesis memoized fromliveImpersonatedToken?.actions. PlayModeView additionally receives the prefs via a new optionalactionPrefs?: CharacterUIPrefsprop. - PlayModeView rendering:
Accordionwas added to the Mantine import. The Actions Paper section now: (a) computesgroups = actionPrefs ? applyActionPrefs(token.actions ?? [], actionPrefs) : groupActionsByCategory(token.actions ?? []), (b) extracts arenderActionRow(action)closure for the per-action Group/Button/Menu JSX so it can be reused, (c) renders an<Accordion multiple variant="contained">whenactionsGroup === 'collapse-by-category'(eachAccordion.Itemper category, action rows insideAccordion.Panel), and (d) renders the legacy<Stack>layout otherwise — hiding the group label whengroup.category === 'all'(flat mode). Existingdata-testidids are preserved; newplay-mode-actions-accordiontestid added for the collapse mode. - Tests: 14 unit cases for
applyActionPrefsplus 25 source-text contracts inActionsPrefsControl.test.tscovering the popover shell, filter chips (multi-select binding + Chip ordering), group + sort radio bindings, breadcrumbactionsExtrasplumbing, 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,actionPrefsin destructure,applyActionPrefs ? : groupActionsByCategoryternary). 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).
- Pure helper:
- [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:
PlayModeViewnow stampsdata-subsection={group.category}on each Actions group element — both the default<Stack>layout and the collapse-by-category<Accordion.Item>. Existingdata-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)(smoothscrollIntoViewwithblock: 'start', no-ops on any missing link in the chain), andsubsectionLabel(sectionId, subsectionId)(maps Actions categories viaACTION_CATEGORY_LABELSplus 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 renderssubs = findSubsections(scrollRootRef.current, activeSection)as a list of<Menu.Item>s. The block returnsnullwhensubs.length === 0so non-Actions sections still see the original two-section layout. Each item callsscrollSubsectionIntoView(...)and usessubsectionLabelfor its display text. Test ids:character-context-breadcrumb-subsection-${id}. - Tests: added 16 contracts to
CharacterContextBreadcrumb.test.ts(now 50 total) — 9 unit cases forfindSubsections+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 newdata-subsectionmarkers inPlayModeView. - 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
flatmode is selected the sole subsection is the synthetic'all'group, whichsubsectionLabelrenders 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).
- DOM markers:
- [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 withrequireImpersonatable: true+ the user's id.impersonateModeStoregainedrequireImpersonatable/ownerUserIdviaenterMode(options?); the keyboard "I" shortcut stays loose so a GM can still impersonate any token. New shared predicateisImpersonatableToken(token, userId)(utils/impersonation.ts, also tolerant of string-shapedmetadata, and now backingpickImpersonatableTokens) gates the WebGLMap mouse and touch handlers: a non-impersonatable token raises an inlineactionNotificationStoreerror at the click point and keeps the mode armed; a valid one callssetImpersonatedTokenand exits. Escape / right-click cancel (pre-existing);ImpersonateModeBannershows 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.
- Leftmost: current impersonated character name. This has a caret indicating dropdown. When clicked, options include:
- 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.
- In the Actions section, on the same vertical sticky bar as the breadcrumbs, show a filter/sort/group icon. On click, show:
- 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/RenderMapClientinapps/web/src/components/UI/MapGenerator/mapGeneratorClient.ts). To finish: (1)POST /api/map-generationscallinggenerateMapDescription+extractSlots+matchSlotsToLibrary, persisting via the Phase 2.3 store; (2)GET /api/map-generations/:idreturning the persisted record so the assets step hydrates from server state; (3)POST /api/map-generations/:id/renderinvoking the painter/objectPlacer/tokenPlacer pipeline with a realtimeProgressReporteremitting per-phase ticks on a channel the modal subscribes to. Then swap the three stub clients tofetch()-based impls (1-line change in each — the contracts are designed for it). See per-phase notes indocs/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 writee2e/map-generator.spec.tscovering palette → prompt → asset overrides → render → entity removal. Seedocs/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 originaluser_idand 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: characterdf6202b5-5333-4531-9289-4f257c72e164("Pip") was soft-deleted 2026-05-07; 5 tokens still reference it viametadata.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-accesscreates acharacter_access_requestsrow and emitscharacterAccessRequest:createdto owner's socket room. Owner sees a sticky notification with Grant View / Grant Edit / Deny buttons. Granting creates acharacter_membersrow and emitscharacterInvite:changedreason=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 inIDrawing.tswithIWallDrawinginterface.startDrawingforces corner snapping for wall.updateDrawingPreviewsnaps to grid corner + constrains to 90° from last confirmed point.addPointdoes the same for confirmed vertices.completeDrawingstrips the preview point. Toolbar button added (IconWall). Rendering inWebGLDrawingLayermirrors line (stroke only, no fill).WebGLMaphandles wall the same as line (click to add points, right-click to finish). Server Zod schema updated. 22 tests added todrawingStore.test.ts.
- Implementation: New
-
[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 isEditMapSubcontext(opened viaMapSecondLevelNav→openSubcontext('editMap')). Added the sameSlider(min 0.1 / max 10 / step 0.1, default 1, with marks) toEditMapSubcontext, hydrated fromcurrentMap.foundationTextureScaleand included in theupdateMappayload;triggerMapTextureRefresh()already re-renders the foundation on save. 4 source-text contracts added toEditMapSubcontext.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 inUI.tsxnext toMapEndTurnButton(same sidenav/subcontextleftOffset, lifted above it). Shows whencurrentTokenis set, differs from the impersonated token, and the impersonated token has ≥1target.enabledaction. Renders "Target <name> with:" + a horizontalScrollAreaof actionButtons + a clear-target✕. Clicking an action fires it against the selected token via the newuseExecuteImpersonatedActionOnTarget(campaignId, mapId)hook — which injects the target through the_targetTokenside-channel and callsActionExecutor.executeActiondirectly (no click-to-target step), mirroring the now-disabled TargetPanel's inline logic — then clears the selection; the ✕ clears without firing. Pure helpersgetTargetedActions/shouldShowTargetActionBarunit-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/:idjust setsdeleted_aton the row; any tokens whosemetadata.characterIdpoints 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 clearmetadata.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 characterdf6202b5. (Policy: keep tokens but clearmetadata.characterIdviajson_extractquery insoftDeleteCharacter. Artworks left intact so tokens retain their appearance. One-shot cleanup script:packages/server-core/src/scripts/cleanup-dangling-character-tokens.tswith--dry-runsupport.) -
[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:
handleCharacterChangein CharacterDetail now builds aPartial<Character>patch with only the changed fields and sends that toupdateCharacter, instead of spreading the full character object. The hook already usedmethod: '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:orblob: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/stripTransientUrlsFromArtworksutilities in@purple-cat/shared. Client-side stripping inuseTokens.updateTokenanduseCharacters.updateCharacterArtworks. One-shot cleanup script:packages/server-core/src/scripts/strip-base64-images.tswith--dry-run. Server-side response stripping deferred to the Common API middleware TODO.) -
[x] Don't load unnecessary assets (Partial: added
loadListoption touseCharacters()so consumers that only need imperative helpers skipGET /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-sidecampaignIdfilter +fieldsquery 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 onlyGET /tokens?mapId=, then filters to the requested layer client-side via the new exportedfilterTokensByLayer(tokens, layerId)(missinglayerId⇒'tokens'). EveryuseTokens('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?mapIdvs?mapId&layerId=tokenscalls. Verified server-side thatGET /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 sockettoken:createdhandler no longer drops other-layer tokens (the cache is map-wide now). 7 tests inuseTokens.layerDedup.test.ts; existinguseTokens.*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/charactersreturns only access-controlled stub columns (id, name, level, classes, artworks/token image, version + timestamp fields — NOT the heavyactions/properties/origins/attributesJSON;characters.ts:277) and is scoped to the user's owned + shared characters (not "every character in the DB"; 401 without auth). The/characterspage 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 onGET /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/:idrendersMapList, which calleduseCharacters()(all of the user's characters as stub rows) and then filtered to the campaign client-side. It now callsuseCharacters({ campaignId }), soGET /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 heavyactions/propertiesJSON was never sent. 1 contract added toMapListCampaignPage.test.ts.)
- [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.
-
[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-sidecompressFileForUploadexists but is only used inImageDropZone.tsx. Many directuploadImage()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) EnsurecompressFileForUploadis called before alluploadImage()calls for an additional client-side optimization pass. (2) VerifyCampaignEditModalcampaign 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:
PlayModeViewaccepts a newonAddAction?: () => voidprop and renders a violetAdd Actionbutton (withIconPlus) 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.CharacterSubcontextwiresonAddActionto ahandleAddActioncallback that setseditActionIdto the sentinel'__new__', reusing the existingActionEditormodal contract for opening a blank create form. Inert when no token is impersonated. 9 new tests inPlayModeView.addAction.test.tspinning the prop, button rendering, and CharacterSubcontext wiring.)