Skip to content

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. (The range prop type in the D&D fixtures now carries renderFunction: "(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}_max metadata value — via the new renderPropertyValue util in apps/web/src/utils/propTypes.ts (compiled-function cache; null fallback on compile/runtime failure), wired into TokenHoverLabel.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 previous current/max / String() display. Single-arg functions (ability-score) simply ignore the extra argument. Two fixes rode along: the hover label's bare propType === 'range' check never matched seeded namespaced types (dnd5e-2014_range) — replaced with isRangePropType; and usePropTypes migrated 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), 6 renderPropertyValue unit, 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: buildNamespacedTraitRows in seedSystems.ts dropped showOnHover when building seed rows, so fixture-supplied flags never reached the DB at all — now preserved (defaulting false). The parallel legacy DND5E_PROPERTY_DEFINITIONS list in shared types/system.ts was 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.json bump here, which also applies to the previous item's range renderFunction; 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.har when convenient. Source: HAR analysis 2026-06-10 of temp-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 raw fetch-in-useEffect without 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.tsx renders <CharacterDetail characterIdOverride> inside a Mantine Tabs.Panel, and Mantine keeps panels mounted by default — so the entire Build-mode data load (second GET /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. Pass keepMounted={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 in CharacterSubcontext.deferBuildTab.test.ts.)
    • [x] Deduplicate the character fetch: CharacterSubcontext.tsx (raw fetch in fetchCharacter, ~line 246) and CharacterDetail.tsx (loadData → getCharacter, ~line 405) independently fetch the same GET /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-driven setQueryData/invalidateQueries, mirroring the v0.30.0 hook migrations. (New hooks/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 typed CharacterFetchError (403/404 not retried), and the single queryFn; fixCharacterArtworkUrls moved there to avoid a circular import. CharacterSubcontext now subscribes via useCharacter() (403/404 messages derive from the typed error; view-building is a separate effect over the cached row). useCharacters.getCharacter routes through queryClient.fetchQuery on 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] via mergeCharacterCache; element-level routes (per-action, per-collection) invalidate it; deleteCharacter removes it. Tests: 6 contracts in useCharacter.reactQuery.test.ts; noAccessMessage contracts flipped to the typed-error shape; processedUrlMigration repointed at the moved normalizer.)
    • [x] Stop the equipment triple-load: EquipmentSection.tsx (~line 200) still calls fetchEquipment() (= invalidateQueries) on mount — a leftover from before useEquipment'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 needs characterId, so its single fetch can run in parallel with the character fetch. (Done as specified — the effect (and the component's only useEffect/fetchEquipment use) is gone; useEquipment's hook API is unchanged. 2 contracts in EquipmentSection.noMountRefetch.test.ts.)
    • [x] Give socket-synced map caches a real staleTime: useTokens, useChatMessages, useDrawings, and useVBLShapes all set staleTime: 0, so any new subscriber mounting anywhere refetches the map world — opening Character Context refetched all four (~340 KB), and tokens?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 (setQueryData on socket messages), so refetch-on-mount buys nothing; give them a meaningful staleTime (the REFERENCE_DATA_STALE_TIME pattern, or a shorter live-data tier) and invalidate explicitly on socket reconnect to cover missed-event gaps. (New SOCKET_SYNCED_STALE_TIME tier (5 min) in lib/queryClient.ts applied to all four hooks, plus invalidateSocketSyncedQueries() covering the four key families. Reconnect coverage lives in useSocketConnection's connect handler gated on a module-level hasEverConnected flag — Socket.IO v4 re-emits connect on every reconnection (the existing socket.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 in socketSyncedStaleTime.test.ts; queryClient.staleTime tokens-tier contract flipped; useTokens.layerDedup slice boundary repointed.)
    • [x] Send-only chat hook: CharacterSubcontext.tsx (~line 106) destructures only sendMessage from useChatMessages(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 a createSendMessage factory both hooks share — identical pending/sent/error cache writes — with the full hook layering on its instance concerns (onSent: markLocallySent, onError: setError); useSendChatMessage mounts 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 in useSendChatMessage.test.ts; the ChatLog.attribution contract follows the send body into the factory.)
    • [x] Defer the AI-models fetch: AIStatusBanner fetches /api/ai/models/<provider> in a mount effect (AIStatusBanner.tsx ~line 62), and CharacterDetail renders it unconditionally via the CharacterOptimization panel (~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 new deferModelsFetch prop gates the query off entirely; CharacterOptimization passes deferModelsFetch={!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 in AIStatusBanner.deferredModels.test.ts.)
    • [x] Server trim — character artwork base64: GET /api/characters/:id returns ~40 KB of which ~35 KB is artworks[].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. (New utils/artworkBlobs.ts in server-core: migrateInlineArtworkBlobs decodes each data: 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; unrecoverable blob: URLs drop; a failed upload leaves the field untouched (fat payload beats data loss). GET /:id migrates-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 incoming artworks so 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 — fixCharacterArtworkUrls strips transient processedUrl on every load — so resolveCharacterChatAvatar/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, injected uploadImpl); the characters.deletedAt cross-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 full oldValue/newValue JSON blobs (72 entries / 84 KB in the trace, and it grows unboundedly with every character edit). Add limit/offset (default ~50) and omit oldValue/newValue from 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-one GET .../ledger/:entryId returns the full row (scoped to the character, 404 otherwise) for whenever a row-expand UI lands — LedgerSection today renders summary lines only and oldValue/newValue were already optional on the shared type, so no client change is needed; useLedger keeps working against the slimmer payload. 5 contracts in ledger.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 it actions[] — one character-linked token carried 56 actions at ~2.6 KB each because every action embeds its full wiki-HTML description (breadcrumb <nav> markup included) plus roll code. Fix at ingestion (strip breadcrumb/nav chrome from descriptions when actions are derived) and/or slim actions[].description out of the map-list response with lazy per-token fetch when an actions UI opens — mind consumers that read actions straight from the list cache (action bars, hover UI, ActionEditor). (Took the ingestion + read-side-strip half of the "and/or": new shared stripWikiNavChrome (utils/wikiHtml.ts) removes <nav>…</nav> blocks; derivePowerActions applies it when building description (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 removing description from 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] 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: if uiStore.impersonatedToken is the double-clicked token → openTokenModalEdit (the v0.22.0 behavior); otherwise impersonate via setImpersonatedToken, 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 in WebGLTokenLayer.dblClickImpersonate.test.ts; the v0.22.0 CurrentTokenBar.dblclick map-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/proxy and /api/system-data/system8/feats in packages/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 generic utils/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 typed UpstreamError. ?refresh=1 busts a single entry (handy while editing the wiki); responses carry X-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 in CurrentTokenBarContainer survives: the player "no tokens"/"select your character" prompts, the player/GM auto-impersonation effects, and the BackgroundTokenBar for clicked background tokens (its grid/VBL controls have no hover-UI home; it now also handles the impersonating+mobile case uniformly). The CurrentTokenBar component file itself is kept for now — the upcoming hover/multi-select work may reuse its pieces (e.g. Heal/Harm mode), and BackgroundTokenBar/useRefreshTokenFromCharacter live in the same folder. 4 new contracts in UI.removeClickedTokenBar.test.ts; UI.hideImpersonatedBar/UI.mobileImpersonate contracts flipped to pin the removal. Drive-by: repaired the two baseline-red UI.autoOpenCharacterContext contracts that had drifted (window 1200→2000; deps array gained closeSubcontext/leftSubcontexts in v0.24.0).)
  • [x] When hovering over a token, show several elements: (New TokenHoverUI replaces TokenHoverLabel. 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 in propertyDefinitions order, values through the prop type's renderFunction via the shared utils/hoverProperties.ts resolver; and the three icon buttons below. Hover persistence: pointerout now calls scheduleHoveredTokenClear() (200ms grace) instead of clearing, and the button region (whose paddingTop is the connecting bridge) pins the hover via uiStore.setHoverUiPinned — the above/right pills are pointerEvents: none, so moving up/right still ends the hover. Buttons: Heal/Harm runs the exact H-key prompt flow, extracted in WebGLMap as promptHealHarmForToken and bridged through healHarmStore.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 → executeActionOnTarget side-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 (first roll('…')/requestRoll('…') literal, extracted by utils/actionRollExpression.ts; actions without one are omitted), dispatched via dispatchRollRequest with the action name as the roll label. Menus render withinPortal={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()).
    • 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); MultiSelectTokenOverlays renders one per selected token, anchored to rects projected per-frame by the new in-Canvas SelectedTokenProjector (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-center MultiSelectActionBar (size-lg buttons) carries the three actions: Heal/Harm sends the whole selection through the shared prompt flow — promptHealHarmForTokens now 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 with getMultiTargetDisableReason rules — single-target disabled, multi-target disabled when minTargets exceeds the selection (else executes with the selected tokens via the new executeActionOnTargets _targetTokens side-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: 5 getMultiTargetDisableReason behavioral + 11 bar/wiring contracts; TargetActionBar's _targetToken contract 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.
  • [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 stay pointerEvents: none like the name/properties pills, so moving the mouse onto them does not persist the hover. 1 placement contract added in TokenVisionBadges.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_WIDTH wide, 100vh tall) that content is margined past, so the fixed-position panel at left: 0 slid underneath it. The panel's left now bases on SIDENAV_COLLAPSED_WIDTH (both branches — alone, and added to the open left-subcontext width). Desktop-only branch, so mobile is unaffected. UI.multiSelectLeftOffset contracts updated to pin the rail-width base and reject the 0 base. Note: MapEndTurnButton (and TargetActionBar, which mirrors its offset) shared the same stale 0 base — fixed in a follow-up: effectiveSideNavWidth is now isMobile ? 0 : SIDENAV_COLLAPSED_WIDTH, so both clear the rail on desktop while mobile (which renders no nav column) keeps its 0 base; pinned by UI.endTurnLeftOffset contracts.)

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:roll now 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 via initTexture, shader compileAsync, 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-box dependency, the dice-theme picker in Chat Settings, the theme/forcedTheme roll-sync plumbing, and the old MapDicePhysics/@react-three/cannon remnants; dddice-roller is no longer loaded by modules.local.json (the module registers its roller unconditionally and was silently capturing every roll). The DiceRollerExtension module 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); rollExpressionToChat accepts diceColor and every roll origin passes its persona color — chat-log tray / CurrentTokenBar dice / DiceBag → the user's settings chatColor; action roll() → the acting token's chatColor; Character/ImpersonatedToken Context quick rolls → the persona's color; owner-accepted requestRoll → the rolling token's color. getDieMaterials takes 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:roll carries baseColor, 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: 5 diceColor unit + 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, four isToken-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 in MapContextMenu.tokenGroups.test.ts pin 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 actions omission: handleCharacterChange's minimal-PATCH builders in CharacterDetail dropped chatColor, so the controlled ColorInput (echoing characterView.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. chatColor is now in BOTH builders (local-state patch for the input echo + network scalarPatch); rode-along fix: the sheet passed chatColor: 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 in CharacterDetail.chatColorPatch.test.ts; the v0.29.0 patchSave slice 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.tsx sidebar components plus the two Layout infra components (SubcontextPanel, FloatingSubcontextWindow). ChatLog itself keeps its name — its container ChatSubcontext is the rename target, matching the "or perhaps its container" hedge. The store/type-level identifiers (openSubcontext et 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 so EditMapSubcontext was 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-core mapHiddenFromPlayers cross-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 panel components/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 the TokenContextContent alias, so the shadowing is contained to that one file; lookbehind kept ImpersonatedTokenSubcontext untouched.)
    • [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 9 CharacterSubcontext.*.test.ts contract files; ~30 referencing files updated incl. PlayModeView, MobileContextBar, hooks, and the server-core characters.deletedAt contract. CharacterContextBreadcrumb was 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 shared designTokens cross-package contract. Drive-by: repaired the two baseline-red contracts in this area — voiceInput still expected the ConnectionStatusDot that v0.20.0 removed from the chat header (flipped to not.toContain per the v0.20.0 note), and chatGear's 800-char window no longer reached <ChatSettings after 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.ts types (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; zero subcontext tokens remain in web src. The layout store persists — a zustand version: 1 + migrate maps 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 legacy SubcontextProps/subcontextId (unused prop) collapsed into LibraryContextProps, and the sed exposed a name-shadowing hazard in MainContent.maximizedContext — the store id is now maximizedContextId, distinct from the looked-up context. ~55 files; suites green (web baseline unchanged), isolated TS error count unchanged at 226.)