v0.31.0
v0.31.0 - Targeting Experience
- 2026-06-10
Fixtures
- [x] Change the fixture for the system property definition "range", so there is a render function in the form
value => value + ' / ' + maxValue. This implies changing render function to pull in the maxValue, as the second argument so other render functions aren't affected. (Therangeprop type in the D&D fixtures now carriesrenderFunction: "(value, maxValue) => value + ' / ' + maxValue"(2024 inherits 2014's propTypes); fixture versions bumped 3→4 so existing DBs re-seed. Render functions are invoked as(value, maxValue)— maxValue is the${key}_maxmetadata value — via the newrenderPropertyValueutil inapps/web/src/utils/propTypes.ts(compiled-function cache; null fallback on compile/runtime failure), wired intoTokenHoverLabel.resolvePropertyDisplay: a prop type's render function wins when present, range props use it only when both halves exist, and everything else falls back to the previouscurrent/max/String()display. Single-arg functions (ability-score) simply ignore the extra argument. Two fixes rode along: the hover label's barepropType === 'range'check never matched seeded namespaced types (dnd5e-2014_range) — replaced withisRangePropType; andusePropTypesmigrated to React Query (['propTypes', campaignId], reference-data staleTime) so the hover label's per-hover mount doesn't refetch (same pattern as the v0.30.0 hook migrations). Tests: 4 fixture (shared), 6renderPropertyValueunit, 3 RQ-migration contracts, hover-label contracts updated.) - [x] Change the fixtures for 5e, 5.5, and System 8 so that all bundled properties have 'show on hover' set to true. (Done for the in-repo systems: every property definition in the D&D 2014/2024 fixtures now ships
showOnHover: true(fixture versions → 5 to force a re-seed; existing campaigns pick it up immediately because the campaign property-definitions route merges system rows at read time). Root-cause fix included:buildNamespacedTraitRowsinseedSystems.tsdroppedshowOnHoverwhen building seed rows, so fixture-supplied flags never reached the DB at all — now preserved (defaulting false). The parallel legacyDND5E_PROPERTY_DEFINITIONSlist in sharedtypes/system.tswas aligned too. System 8's fixture lives in@purple-cat-games/system8(vtt-modules repo,modules/system8/src/fixtures.ts, fixture v2 with only HP/AC flagged) — needs a module release +modules.hosted.jsonbump here, which also applies to the previous item'srangerenderFunction; flagged as a follow-up task in that repo. Tests: 2 fixture assertions (shared) + 1 seed-row preservation test (server-core).)
Character Context
- [x] Improve performance of this UI by deferring Build Mode calls and other data loads not needed for the initial Play mode render. (Umbrella item — broken down into the atomic TODOs nested below, one commit each; check this off when all of them are done. All nine landed in v0.31.0 — re-measure against
temp-network-dump-character-context.harwhen convenient. Source: HAR analysis 2026-06-10 oftemp-network-dump-character-context.har— ~2 s from impersonation to settled, in 4 sequential request phases, ~95% of transferred bytes deferrable or trimmable. Caveat when re-measuring: roughly half the duplicate calls in the trace are dev-only React StrictMode double-mounts of rawfetch-in-useEffectwithout abort/dedup; prod builds see fewer requests but every item below still wastes time/bytes in prod. Related server-side caching work lives under "### System Data".)- [x] Defer the Build tab:
CharacterSubcontext.tsxrenders<CharacterDetail characterIdOverride>inside a MantineTabs.Panel, and Mantine keeps panels mounted by default — so the entire Build-mode data load (secondGET /characters/:id, ledger, character factions, versions, global factions, AI models, and the advancement.json proxy — the trace's slowest call at ~400 ms) fires during the initial Play-mode render. PasskeepMounted={false}on the Tabs so CharacterDetail mounts on first Build-tab activation. Note: CharacterDetail will then refetch on every tab re-entry until its character fetch is cache-backed (next item) — acceptable interim behavior. (Done as specified —keepMounted={false}on the View/Build<Tabs>; the Play panel also unmounts while Build is active, which is fine (PlayModeView holds no exclusive state; impersonation lives in stores). 2 contracts inCharacterSubcontext.deferBuildTab.test.ts.) - [x] Deduplicate the character fetch:
CharacterSubcontext.tsx(rawfetchinfetchCharacter, ~line 246) andCharacterDetail.tsx(loadData→getCharacter, ~line 405) independently fetch the sameGET /api/characters/:id— with StrictMode doubling each raw effect, 4 × 40 KB per load. Back both with one React Query key (e.g.['character', id]) so they share a cache entry and get in-flight dedup + StrictMode safety for free. Character edits must stay fresh: keep a short/zero staleTime but rely on mutation-drivensetQueryData/invalidateQueries, mirroring the v0.30.0 hook migrations. (Newhooks/useCharacter.ts:characterQueryKey(id),CHARACTER_STALE_TIME(30 s — absorbs StrictMode double-mounts and Play↔Build re-entry, short enough that out-of-band edits self-heal), a typedCharacterFetchError(403/404 not retried), and the single queryFn;fixCharacterArtworkUrlsmoved there to avoid a circular import.CharacterSubcontextnow subscribes viauseCharacter()(403/404 messages derive from the typed error; view-building is a separate effect over the cached row).useCharacters.getCharacterroutes throughqueryClient.fetchQueryon the same key — every imperative caller (CharacterDetail, CharacterPanel, CharacterNav, CharacterList) dedups automatically — with{ fresh: true }forcing a network read for the out-of-band refresh paths (campaign/party link/unlink, ownership transfer). Cache coherence: full-row mutations (updateCharacter,replaceCharacterSheet, artwork/token-image helpers) merge their response into['character', id]viamergeCharacterCache; element-level routes (per-action, per-collection) invalidate it;deleteCharacterremoves it. Tests: 6 contracts inuseCharacter.reactQuery.test.ts;noAccessMessagecontracts flipped to the typed-error shape;processedUrlMigrationrepointed at the moved normalizer.) - [x] Stop the equipment triple-load:
EquipmentSection.tsx(~line 200) still callsfetchEquipment()(=invalidateQueries) on mount — a leftover from beforeuseEquipment's v0.30.0 React Query migration that forces refetches even when the cache is fresh (1 useQuery fetch + 2 StrictMode-doubled invalidations = 3 GETs per load). Remove the mount-time effect; useQuery already fetches on mount. Equipment is legitimately part of Play mode (PlayModeView renders EquipmentSection) and only needscharacterId, so its single fetch can run in parallel with the character fetch. (Done as specified — the effect (and the component's onlyuseEffect/fetchEquipmentuse) is gone;useEquipment's hook API is unchanged. 2 contracts inEquipmentSection.noMountRefetch.test.ts.) - [x] Give socket-synced map caches a real
staleTime:useTokens,useChatMessages,useDrawings, anduseVBLShapesall setstaleTime: 0, so any new subscriber mounting anywhere refetches the map world — opening Character Context refetched all four (~340 KB), andtokens?mapId=loaded 3× total (197 KB each) though Character Context needs none of them over the network (it reads the impersonated token from the cache the map already populated). These caches are kept live by socket events (setQueryDataon socket messages), so refetch-on-mount buys nothing; give them a meaningful staleTime (theREFERENCE_DATA_STALE_TIMEpattern, or a shorter live-data tier) and invalidate explicitly on socket reconnect to cover missed-event gaps. (NewSOCKET_SYNCED_STALE_TIMEtier (5 min) inlib/queryClient.tsapplied to all four hooks, plusinvalidateSocketSyncedQueries()covering the four key families. Reconnect coverage lives inuseSocketConnection'sconnecthandler gated on a module-levelhasEverConnectedflag — Socket.IO v4 re-emitsconnecton every reconnection (the existingsocket.on('reconnect')is v2-era manager-level and never fires), and the flag never resets so a close-then-reopen cycle also invalidates. Contracts: 7 insocketSyncedStaleTime.test.ts;queryClient.staleTimetokens-tier contract flipped;useTokens.layerDedupslice boundary repointed.) - [x] Send-only chat hook:
CharacterSubcontext.tsx(~line 106) destructures onlysendMessagefromuseChatMessages(campaignId), but the hook subscribes to the full['chatMessages', campaignId]list query — a 65 KB refetch on mount. Add a mutation-only variant (e.g.useSendChatMessage) that doesn't mount the list query, and use it here. (The optimistic send body was extracted into acreateSendMessagefactory both hooks share — identical pending/sent/error cache writes — with the full hook layering on its instance concerns (onSent: markLocallySent,onError: setError);useSendChatMessagemounts no useQuery and registers no socket handlers. Only CharacterSubcontext migrated deliberately: 13 other send-only consumers (WebGLMap, TokenModal, action executors, …) still use the full hook, whose redundant socket handlers are what keep the chat cache live when ChatLog is closed — bulk-migrating them would reopen the missed-events gap the previous item closed. 3 contracts inuseSendChatMessage.test.ts; theChatLog.attributioncontract follows the send body into the factory.) - [x] Defer the AI-models fetch:
AIStatusBannerfetches/api/ai/models/<provider>in a mount effect (AIStatusBanner.tsx~line 62), andCharacterDetailrenders it unconditionally via theCharacterOptimizationpanel (~line 1427) — so Build mode pings the AI backend even when the optimizer is never used. Fetch on first interaction with the panel (or at minimum cache via React Query with reference-data staleTime so repeat mounts are free). (Both halves done: the models list moved to React Query (['aiModels', provider, userApiKey], reference-data staleTime — the user's key is in the query key since it changes what the provider reports; failed/empty responses resolve to an empty list exactly like the old ignore-errors effect), and a newdeferModelsFetchprop gates the query off entirely;CharacterOptimizationpassesdeferModelsFetch={!hasInteracted}flipped by pointer-down/focus capture on the panel Paper, so Build mode never touches the AI backend until the optimizer is actually poked. Other banner hosts (MonsterGenerator, GenerateEncounter, CharacterSheetImport) keep fetch-on-mount semantics but now share the cache across re-opens. 4 contracts inAIStatusBanner.deferredModels.test.ts.) - [x] Server trim — character artwork base64:
GET /api/characters/:idreturns ~40 KB of which ~35 KB isartworks[].processedUrl, an inline base64 webp data-URI, while sibling fields (imageUrl,originalUrl) are already file URLs under/images/.... Persist the processed/tokenized image as a file alongside the original and return its URL instead; audit consumers that may assume a data-URI (resolveCharacterChatAvatar, token creation from character, artwork panels) and handle legacy rows that only have the inline blob. (Newutils/artworkBlobs.tsin server-core:migrateInlineArtworkBlobsdecodes eachdata:URI (any of the four URL fields), stores the bytes via the storage provider (category: artwork,skipResize— the blob is already the final processed webp) and swaps in the stored URL; unrecoverableblob:URLs drop; a failed upload leaves the field untouched (fat payload beats data loss).GET /:idmigrates-and-persists once per legacy row (falling back to response-only stripping if storage is down); the list endpoint only strips inline blobs from the response (no writes on the hot path — the row heals on its next single GET); PUT/PATCH migrates incomingartworksso old clients that still send data-URIs get files persisted instead (modern clients strip client-side since v0.29.0). Consumer audit: clients never actually saw these blobs —fixCharacterArtworkUrlsstrips transientprocessedUrlon every load — soresolveCharacterChatAvatar/token-creation/artwork panels treat the field as an optional URL already; migrated file URLs are strictly an upgrade (avatars regain a pre-tokenized image). 11 behavioral tests (utils/artworkBlobs.test.ts, injecteduploadImpl); thecharacters.deletedAtcross-repo contract was repointed at the RQ error shape from the dedup item.) - [x] Server trim — ledger pagination:
GET /api/characters/:characterId/ledger(packages/server-core/src/routes/ledger.ts~line 61) returns ALL entries including fulloldValue/newValueJSON blobs (72 entries / 84 KB in the trace, and it grows unboundedly with every character edit). Addlimit/offset(default ~50) and omitoldValue/newValuefrom the list response; the ledger UI shows summary lines, so fetch a single entry's blobs on row expand via a get-one endpoint or?fields=full. (List:limit(default 50, cap 200) +offset, slim column projection (everything except the two blobs), category filter preserved. New get-oneGET .../ledger/:entryIdreturns the full row (scoped to the character, 404 otherwise) for whenever a row-expand UI lands —LedgerSectiontoday renders summary lines only andoldValue/newValuewere already optional on the shared type, so no client change is needed;useLedgerkeeps working against the slimmer payload. 5 contracts inledger.pagination.test.ts.) - [x] Server trim — token actions bloat in the map list:
GET /api/tokens?mapId=weighed 197 KB for 8 tokens, ~170 KB of itactions[]— one character-linked token carried 56 actions at ~2.6 KB each because every action embeds its full wiki-HTMLdescription(breadcrumb<nav>markup included) plus rollcode. Fix at ingestion (strip breadcrumb/nav chrome from descriptions when actions are derived) and/or slimactions[].descriptionout of the map-list response with lazy per-token fetch when an actions UI opens — mind consumers that readactionsstraight from the list cache (action bars, hover UI, ActionEditor). (Took the ingestion + read-side-strip half of the "and/or": new sharedstripWikiNavChrome(utils/wikiHtml.ts) removes<nav>…</nav>blocks;derivePowerActionsapplies it when buildingdescription(rows heal permanently on re-derive), and the tokens routes apply it response-only on the map list and single GET (slimTokenActionDescriptions, referential-identity-preserving when nothing matches). Deliberately NOT removingdescriptionfrom the list payload: action bars, hover UI and ActionEditor all read actions straight from the['tokens', mapId]cache, and a lazy per-token detail fetch would need new cache plumbing in all of them — the descriptions stay present, just without the wiki chrome (which also fixes the stray breadcrumb link-trail rendering inside action tooltips). 5 unit tests (wikiHtml.test.ts) + 1 derive integration + 3 route contracts (tokens.slimActions.test.ts).)
- [x] Defer the Build tab:
- [x] Change double click behavior on map tokens to impersonate the double-clicked token's character. If the token's character is already impersonated, double-clicking should revert to the previous behavior of Edit Token. (WebGLTokenLayer's token-layer
onDoubleClick: ifuiStore.impersonatedTokenis the double-clicked token →openTokenModalEdit(the v0.22.0 behavior); otherwise impersonate viasetImpersonatedToken, first deselecting the token if it was the current selection — mirroring the context menu's Impersonate Token action in WebGLMap. Still token-layer-only and inert when the layer isn't active. 3 contracts inWebGLTokenLayer.dblClickImpersonate.test.ts; the v0.22.0CurrentTokenBar.dblclickmap-token contract was reworded to pin the edit modal as the already-impersonated path (portrait half unchanged).)
System Data
- [x] Server: cache system-wiki calls (
/api/system-data/proxyand/api/system-data/system8/featsinpackages/server-core/src/routes/systemDataProxy.ts) with a fairly wide window (~24 hours). Today every request round-trips to the external wiki with no cache at all — the advancement.json proxy was the single slowest call (~400 ms) in the Character Context load. (New genericutils/ttlCache.ts(TTL expiry, insertion-order eviction at 500 entries, in-flight dedup so concurrent misses share one upstream fetch, failures never cached) shared by both endpoints, keyed on the sanitized/whitelisted URL — the cache sits after the host whitelist so it can never store or serve a disallowed host. Upstream non-2xx still mirrors the wiki's status via a typedUpstreamError.?refresh=1busts a single entry (handy while editing the wiki); responses carryX-Cache: HIT|MISS. In-memory by design — restarts re-warm, and version-metadata busting is the Human TODO below. 6 behavioral cache tests + 5 route contracts.) - [x] Human TODO: add system version metadata to every system wiki call/response (system8.purplecat.wiki et al.) so the server-side cache can detect new system versions smartly and bust its own cache as needed, rather than relying purely on the TTL.
Targeting Experience
- [x] Remove the CurrentTokenBar that appears when a token is clicked. (UI.tsx no longer renders (or imports)
CurrentTokenBar— both clicked-token paths are gone: the right-aligned bar when not impersonating and the right-half bar while impersonating. Everything else inCurrentTokenBarContainersurvives: the player "no tokens"/"select your character" prompts, the player/GM auto-impersonation effects, and theBackgroundTokenBarfor clicked background tokens (its grid/VBL controls have no hover-UI home; it now also handles the impersonating+mobile case uniformly). TheCurrentTokenBarcomponent file itself is kept for now — the upcoming hover/multi-select work may reuse its pieces (e.g. Heal/Harm mode), andBackgroundTokenBar/useRefreshTokenFromCharacterlive in the same folder. 4 new contracts inUI.removeClickedTokenBar.test.ts;UI.hideImpersonatedBar/UI.mobileImpersonatecontracts flipped to pin the removal. Drive-by: repaired the two baseline-redUI.autoOpenCharacterContextcontracts that had drifted (window 1200→2000; deps array gainedcloseSubcontext/leftSubcontextsin v0.24.0).) - [x] When hovering over a token, show several elements: (New
TokenHoverUIreplacesTokenHoverLabel. WebGLTokenLayer's pointerover now also projects the token's corner, so the overlay gets the token's screen-space half-extents (uiStore.hoverTokenHalfSize) and anchors all three regions to the token's edges: name (lg, bold) + faction above; the showOnHover properties to the right as "name: value" lines inpropertyDefinitionsorder, values through the prop type's renderFunction via the sharedutils/hoverProperties.tsresolver; and the three icon buttons below. Hover persistence: pointerout now callsscheduleHoveredTokenClear()(200ms grace) instead of clearing, and the button region (whosepaddingTopis the connecting bridge) pins the hover viauiStore.setHoverUiPinned— the above/right pills arepointerEvents: none, so moving up/right still ends the hover. Buttons: Heal/Harm runs the exact H-key prompt flow, extracted in WebGLMap aspromptHealHarmForTokenand bridged throughhealHarmStore.openHealHarmPrompt; Target lists the impersonated token's actions and applies the chosen one per its targeting config — untargeted/area →useImpersonatedTokenActionExecutor.executeAction(area enters area targeting), multi-target → same +targetingStore.addSelectedTarget(hovered)pre-selects the hovered token, single-target →executeActionOnTargetside-channel injection; Request Roll (die icon, menu) offers "1d20", a custom-expression input, and — interpretation: since the owner-side popup only knows how to roll an expression — the impersonated token's actions whose code rolls a literal expression (firstroll('…')/requestRoll('…')literal, extracted byutils/actionRollExpression.ts; actions without one are omitted), dispatched viadispatchRollRequestwith the action name as the roll label. Menus renderwithinPortal={false}inside the pinned wrapper so browsing them doesn't drop the hover. Buttons hide for non-token layers and during Heal/Harm mode; Target disables without an impersonated token. Tests: 11 hoverProperties + 6 actionRollExpression + 7 uiStore hover-clear behavioral, 19 TokenHoverUI/wiring contracts.)- Above the token: the token's name (large), \n, faction (small)
- To the right of the token: a list of the token's properties (with "show on hover" set to true), in the order defined by the campaign's
propertyDefinitions.- Properties should show as
${name}: ${value}, with the value passed through the property's render function, if present (otherwise use String()).
- Properties should show as
- Below the token: small icon action buttons:
- Heal/Harm: represented by a heart icon, enters Heal/Harm mode for the token, similar to selecting it and pressing H.
- Target: represented by a target icon, pops up a list of the impersonated token's actions, and when one is clicked, applies the action to the hovered token as the target. If untargeted, just fire off the Action normally. If single-target, fire off the Action with the hovered token as the target. If multi-target, enter normal targeting mode with the hovered token pre-selected as the first target. If area effect, enter normal targeting mode.
- Request Roll: represented by a die icon, pops up a list of the impersonated token's actions, and when chosen, fires off the existing requestRoll() flow for that action. As already implemented, the token owner will see a popup requesting that roll, and can accept or deny the request.
- In addition to the token's actions, add a "1d20" option that just rolls a d20 with no modifiers, and a "custom" option which is a text input that allows the user to enter an arbitrary roll expression to be rolled and sent to chat (for instance, "1d20+str").
- The above token and right side of token areas are not interactive, just informational. Moving the mouse upward or rightward does not persist the hover, but ends it. However, the immediate area connecting the token to the below-token action buttons should persist the hover, so the user can move the mouse from the token to the buttons without losing the hover state.
- [x] When multiple tokens are selected, show the hover UI for all tokens in the normal location for each token, with the exception of the below-token action buttons. (The hover UI's informational half was extracted into
TokenInfoOverlay(name/faction above + showOnHover properties right);MultiSelectTokenOverlaysrenders one per selected token, anchored to rects projected per-frame by the new in-CanvasSelectedTokenProjector(uiStore.selectedTokenScreenRects, change-detected so a static camera costs no re-renders; tracks pan/zoom live). The single-token hover UI yields to it at 2+ selected. The bottom-centerMultiSelectActionBar(size-lg buttons) carries the three actions: Heal/Harm sends the whole selection through the shared prompt flow —promptHealHarmForTokensnow takes a token array and applies the one entered amount to every target sequentially (H key passes[token]); Target lists the impersonated token's actions withgetMultiTargetDisableReasonrules — single-target disabled, multi-target disabled whenminTargetsexceeds the selection (else executes with the selected tokens via the newexecuteActionOnTargets_targetTokensside-channel), area enters area targeting, untargeted fires normally; Request Roll offers the same 1d20/custom/requestable-action choices, dispatched to every selected token's owner. Drawings/background images in a rubber-band selection are excluded from actions (layerId === 'tokens'). Tests: 5getMultiTargetDisableReasonbehavioral + 11 bar/wiring contracts; TargetActionBar's_targetTokencontract updated for the shared context builder.)- On the bottom center of the screen, show a UI that has the three below-token action buttons (a bit larger), which work thus:
- Heal/Harm: enters Heal/Harm mode for all selected tokens. One input value applies to all targets.
- Target: pops up impersonated token's actions as normal. If an action is single-target, disable that action. If multi-target, and the number of targets required exceeds the number of selected tokens, disable that action; otherwise execute the action with selected tokens. If area effect, just enter normal area targeting mode.
- Request Roll: pops up same choices as single-token mode, but the request goes to every token selected.
- On the bottom center of the screen, show a UI that has the three below-token action buttons (a bit larger), which work thus:
- [x] Follow-up: the vision/lighting badges (vision type / light source / lighting condition) that already rendered on token hover overlapped the new below-token action buttons. (TokenVisionBadges now sits centered BENEATH the action-button region — token half-height + the exported
ACTION_BUTTONS_REGION_PX— and tucks directly under the token when the buttons are hidden (multi-select, Heal/Harm mode). They staypointerEvents: nonelike the name/properties pills, so moving the mouse onto them does not persist the hover. 1 placement contract added inTokenVisionBadges.test.ts.) - [x] Bug: the current special UI in the left sidebar when multiple tokens are selected is aligned to 0vw, but its left edge should be aligned to the right edge of the global nav. (Root cause: the desktop Multi-Select panel's base offset was the v0.24.0-era
effectiveSideNavWidth = 0("nav reserves no space"), but v0.27.0 partially rolled that back — the global nav is again an always-visible icon column (SIDENAV_COLLAPSED_WIDTHwide, 100vh tall) that content is margined past, so the fixed-position panel atleft: 0slid underneath it. The panel'sleftnow bases onSIDENAV_COLLAPSED_WIDTH(both branches — alone, and added to the open left-subcontext width). Desktop-only branch, so mobile is unaffected.UI.multiSelectLeftOffsetcontracts updated to pin the rail-width base and reject the 0 base. Note:MapEndTurnButton(andTargetActionBar, which mirrors its offset) shared the same stale 0 base — fixed in a follow-up:effectiveSideNavWidthis nowisMobile ? 0 : SIDENAV_COLLAPSED_WIDTH, so both clear the rail on desktop while mobile (which renders no nav column) keeps its 0 base; pinned byUI.endTurnLeftOffsetcontracts.)
In-House 3D Dice
- [x] Replace @3d-dice/dice-box with refurbished in-house deterministic 3D dice as the built-in roller. (New
apps/web/src/components/UI/InHouseDice/subsystem. Deterministic by construction: the result comes from the chat-authoritative RNG (precomputedValues, now seeded by every roll path), the whole throw is pre-simulated headlessly with cannon-es in a Web Worker (zero-copy transferable keyframes; sync in-thread fallback on no-Worker/crash/2s-timeout), and a polyhedral-symmetry rotation places the predetermined value on the face that physically landed up — the silhouette is identical under a symmetry, so the replayed tumble IS the real physics with correct labels from frame zero. The number-placement defects of the old MapDicePhysics are fixed at the geometry level: per-face UVs centre the face CENTROID (not the bounding box), label-up axes are consistent (apex-oriented d10 kites), digits are never mirrored, the d10 is a true planar-kite trapezohedron, faces use standard opposite-face numbering (antipodal sum N+1), the d4 is apex-read with corner numbers, 6/9/60/90 are underlined, and d100 rolls as a percentile pair (tens 00–90 + ones 0–9, 00+0 ≡ 100). Cross-client sync:dice:rollnow carries the per-die values (sanitised, length-capped at the server relay), so every client's dice land on the SAME result as the roller's chat message — each observer just sees their own locally-simulated tumble. Performance: assets prewarm at overlay mount during idle (geometries, canvas label textures, GPU texture upload viainitTexture, shadercompileAsync, worker bundle), the replay drives itself with a dedicated rAF +invalidate()(the shared R3F frame loop starved a second canvas), and the visible-tab delta clamp (1/30s) turns main-thread hitches into imperceptible slow-motion instead of keyframe skips that read as a die "changing direction". Removed: the dice-box overlay/themes/config/assets and the@3d-dice/dice-boxdependency, the dice-theme picker in Chat Settings, thetheme/forcedThemeroll-sync plumbing, and the oldMapDicePhysics/@react-three/cannonremnants;dddice-rolleris no longer loaded bymodules.local.json(the module registers its roller unconditionally and was silently capturing every roll). TheDiceRollerExtensionmodule API is unchanged — an active module roller still takes precedence over the built-in dice. 130+ new unit tests (geometry invariants, symmetry-is-exact proofs, sim determinism, worker parity/fallback, replay plans) plus refreshed source contracts.) - [x] The chat color of the impersonate token rolling the dice, or the chat user rolling from the chat log, should be the base color of the 3d dice. (The dice store gains a per-roll
baseColor(cleared with the roll);rollExpressionToChatacceptsdiceColorand every roll origin passes its persona color — chat-log tray / CurrentTokenBar dice / DiceBag → the user's settings chatColor; actionroll()→ the acting token's chatColor; Character/ImpersonatedToken Context quick rolls → the persona's color; owner-acceptedrequestRoll→ the rolling token's color.getDieMaterialstakes an optional base color with a per-color material cache (key|#rrggbb) — same canonical label layout painted over the custom base, label color picked by luminance so numbers stay readable on light chat colors; the first roll in a new color pays the one-time canvas paint, exactly like the default palette does at prewarm. The overlay snapshots the color per replay plan (no mid-tumble recolor). Cross-client:dice:rollcarriesbaseColor, observers normalize on receive (strict lowercase-hex normal form doubles as wire sanitization) and the server relay forwards only regex-validated hex. Non-hex/empty colors fall back to the default per-die palette. Tests: 5diceColorunit + 7 end-to-end wiring contracts (diceBaseColor.test.ts).)
Token Context Menu
- [x] Reorganize the token right-click context menu (
apps/web/src/components/UI/MapContextMenu/MapContextMenu.tsx, token items start ~line 660) into logical groups separated by dividers, ordered by in-session frequency with destructive actions isolated at the bottom. Conditional items (Link to Character, Add to Initiative, Dismount) keep their group slot when shown; toggle pairs (Mount/Dismount, Hide/Show) occupy a fixed position so muscle memory holds. Proposed order:- Group 1 — Identity: Edit Token; View Character Sheet (or Link to Character when unlinked)
- Group 2 — Play: Actions; Conditions; Impersonate Token; Add to Initiative; Mount (or Dismount)
- Group 3 — State & appearance: Hide from Players (or Show to Players); Change Size; Set Facing; Change Faction
- Group 4 — Canvas placement: Arrange; Move to Layer
- Group 5 — Clipboard: Copy; Duplicate; Copy to Map
- Group 6 — Destructive (divider above): Delete Token
- Rationale: today the 16 items render as one undifferentiated list — clipboard ops split the two identity items (Copy/Duplicate/Copy to Map sit between Edit Token and View Character Sheet), the size/facing/faction state changers are non-contiguous, and Actions (the most-used item mid-session) is 15th, directly adjacent to Delete Token (destructive misclick hazard). (Done exactly as proposed — the JSX blocks were moved verbatim (every visibility predicate intact, so conditional items keep their slot and Mount/Dismount + Hide/Show stay fixed-position toggles), with
── Group ──section comments, fourisToken-gated dividers between groups, and an ungated divider above Delete (non-token entities show Edit | divider | Delete; everything between is token-only so their menu gains no stray dividers). 3 contracts inMapContextMenu.tokenGroups.test.tspin the full marker order, the divider counts, and Actions↔Delete separation.)
Bugs
- [x] In the Build Mode of Character Context (and possibly the Character Sheet), the Chat Color color selector doesn't change its value when a color is clicked. (Root cause — same class as the v0.30.0
actionsomission:handleCharacterChange's minimal-PATCH builders in CharacterDetail droppedchatColor, so the controlledColorInput(echoingcharacterView.character.chatColor) never saw the new value and nothing persisted. Both views share this path, so one fix covers the Build tab and the standalone Character Sheet.chatColoris now in BOTH builders (local-state patch for the input echo + networkscalarPatch); rode-along fix: the sheet passedchatColor: value || undefined, which made clearing the field unsaveable ('' collapsed to "untouched") — it now passes the value verbatim, and '' clears the override back to the user-level color (all consumers treat '' as unset via||/?? ''fallbacks). 2 contracts inCharacterDetail.chatColorPatch.test.ts; the v0.29.0patchSaveslice window widened for the new lines.)
Code Cleanup
- [x] Rename any "subcontext" components to "context" as the distinction between Context and Subcontext no longer exists. Example context components: CharacterSubcontext, System sidebar, Library sidebar, ChatLog (or perhaps its container). Agent: split into individual TODOs for each component, so the renaming is atomic and easily reviewable. (Split per instruction into the per-component items below — one commit each. Inventory: nine
Contexts/<X>Context/<X>Subcontext.tsxsidebar components plus the two Layout infra components (SubcontextPanel,FloatingSubcontextWindow). ChatLog itself keeps its name — its containerChatSubcontextis the rename target, matching the "or perhaps its container" hedge. The store/type-level identifiers (openSubcontextet al.) get a final item of their own since they cut across ~30 call sites rather than one component.)- [x] Rename
CampaignSubcontext→CampaignContext(component + file + barrel + importers + contracts). (git mv + global symbol rename; 5 referencing files; no behavior change.) - [x] Rename
MapSubcontext→MapContext. (git mv + symbol rename guarded with a lookbehind soEditMapSubcontextwas untouched; 4 referencing files.) - [x] Rename
SystemSubcontext→SystemContext. (git mv + symbol rename; 4 referencing files.) - [x] Rename
LibrarySubcontext→LibraryContext. (git mv + symbol rename; 4 referencing files.) - [x] Rename
EditMapSubcontext→EditMapContext. (git mv of component + its contract file; the server-coremapHiddenFromPlayerscross-repo contract repointed too.) - [x] Rename
ChatSubcontext→ChatContext(ChatLog's sidebar container). (git mv + symbol rename; 6 referencing files incl. MobileContextBar and the chatGear contract.) - [x] Rename
TokenSubcontext→TokenContext. Note: the name then shadows the unrelated token detail panelcomponents/UI/TokenContext/TokenContext.tsx(path-based imports keep them distinct); renaming that legacy panel is left as a future cleanup. (The sidebar component already imported the legacy panel under theTokenContextContentalias, so the shadowing is contained to that one file; lookbehind keptImpersonatedTokenSubcontextuntouched.) - [x] Rename
ImpersonatedTokenSubcontext→ImpersonatedTokenContext. (git mv of component + its 2 contract files; 10 referencing files.) - [x] Rename
CharacterSubcontext→CharacterContext(largest: 9 contract files plus a server-core cross-repo contract reference it). (git mv of the component + all 9CharacterSubcontext.*.test.tscontract files; ~30 referencing files updated incl. PlayModeView, MobileContextBar, hooks, and the server-corecharacters.deletedAtcontract.CharacterContextBreadcrumbwas already distinct and untouched.) - [x] Rename
SubcontextPanel→ContextPanel(component + module.css + its 7 contract files + importers). (git mv of the component, the CSS module, and 6 contract files (+ContextPanelChatHeader); references updated across ~20 files incl. the shareddesignTokenscross-package contract. Drive-by: repaired the two baseline-red contracts in this area —voiceInputstill expected theConnectionStatusDotthat v0.20.0 removed from the chat header (flipped to not.toContain per the v0.20.0 note), andchatGear's 800-char window no longer reached<ChatSettingsafter the VoiceInput block landed before it (widened to 1500).) - [x] Rename
FloatingSubcontextWindow→FloatingContextWindow. (git mv + symbol rename; undocked-window host, no behavior change.) - [x] Identifier sweep: rename the layoutStore actions (
openSubcontext/closeSubcontext/toggleSubcontext/…),navigation.tstypes (SubcontextSide,leftSubcontexts/rightSubcontexts, the'subcontext'presentation literal), and remaining comments/CSS classes. Cross-cutting (~30 call sites + many contract tests assert these names) — kept last so the component renames stay reviewable. (Store actions →openContext/closeContext/closeAllContexts/toggleContext/setContextCollapsed/maximizeContext/restoreContext/undockContext/dockContext/getContextByType; state keys →leftContexts/rightContexts/maximizedContext; types →ContextSide, presentation'subcontext'→'sidebar',supportsSubcontext→supportsSidebar, width constants →SIDEBAR_*;useHasSubcontexts→useHasSidebarContexts; zerosubcontexttokens remain in web src. The layout store persists — a zustandversion: 1+migratemaps pre-rename persisted layouts (old keys + old presentation literal) so users keep their open panels across the upgrade; the old key names live on only inside that migration. Two collateral fixes: LibraryContext's legacySubcontextProps/subcontextId(unused prop) collapsed intoLibraryContextProps, and the sed exposed a name-shadowing hazard inMainContent.maximizedContext— the store id is nowmaximizedContextId, distinct from the looked-up context. ~55 files; suites green (web baseline unchanged), isolated TS error count unchanged at 226.)
- [x] Rename