Skip to content

v0.30.0 - API Cleanup and Performance Optimizations

  • 2026-06-09

Minor Improvements

  • [x] In the Character Context, when actions are filtered out by user settings, show a label at the bottom of Actions in the format "(X actions hidden by filters)" to make it clear that the list is being truncated and not just empty. (Implemented in PlayModeView.tsx: after the grouped/filtered action list, a dimmed italic line renders when hidden = token.actions.length - (shown across groups) > 0, pluralized ("1 action" / "N actions"). Only appears when a filter actually hides something. 4 source-text contracts in PlayModeView.hiddenActionsLabel.test.ts.)
  • [x] The Zoom Artwork button in Character Context has some conflicts with other UI elements. Refactor to be a full-screen modal, above all other elements, that shows the zoomed artwork and a close button. If multiple artworks exist, there are left/right buttons to switch between them; the left/right keys do the same thing. If touch is enabled, user can swipe between images. Esc, close button, and clicking the modal backdrop close the modal. (TokenZoomButton.tsx: the zoom modal is now fullScreen at Z_INDEX.MODAL_OVERLAY (3000, above all non-toast UI) with a dark backdrop, dedicated close button, prev/next + ArrowLeft/ArrowRight, horizontal touch-swipe (50px threshold, vertical-scroll-safe), and Esc / close / backdrop-click dismissal (backdrop = clicks on the dark area, e.target === e.currentTarget). 6 source-text contracts in TokenZoomButton.fullscreenModal.test.ts; the v0.29.0 contracts still pass.)
  • [x] In Edit Map, changing texture and foundation scaling does nothing until the Save button is clicked. Change it so they take effect immediately when the user changes the value. Save will confirm the change, and a Cancel button closes the editor and reverts to the old values if the user doesn't want to keep the change. (EditMapSubcontext.tsx: texture picks and scale-slider moves now patch the React Query ['maps'] cache immediately (the same write path as the map:updated socket handler, which WebGLMap renders from) + poke triggerMapTextureRefresh. A previewRef keeps the last-persisted texture/scale: Save PATCHes and re-baselines it; the new Cancel button — and closing/switching the panel without saving (unmount cleanup) — restores those values to the cache and closes via closeSubcontext. 7 new source-text contracts in EditMapSubcontext.test.ts.)
  • [x] Add optional second argument to requestRoll(), which takes an object with any of these properties:
    • label: string to label the roll in the chat log (e.g. "Fireball save DC"), as implemented already in roll().
    • text: the string to output to chat, with {result} as a placeholder for the rendered roll result (same placeholder as roll()). If not provided, defaults to "{result}".
    • pipeToChat: boolean, default true, if true the roll is rendered to chat (as normal), if false, it is not.
    • (Implemented: requestRoll(expr, { label, text, pipeToChat }) — tokenApi.requestRoll whitelists the three fields (action code is user-authored) and ships them as rollOptions on the socket payload (RollRequestOptions in IRollRequest.ts / server RollRequestPayload; the relay's payload spread forwards them verbatim). The owner's accept handler passes them to rollRequestedExpression, which renders label/text through the same rollExpressionToChat decoration as roll() (sharing its {result} placeholder — originally specced as {roll}, renamed for consistency), or — for pipeToChat: false — computes the total silently via DiceRoller.roll (no chat message, no 3D dice) while still resolving it for the requester. The RollRequestModal shows the label next to the expression so the owner knows what they're rolling for. 11 behavioral tests + 3 plumbing contracts in requestRollOptions.test.ts. Verified live (Chrome at localhost:5173): the Playtesting fireball test action with requestRoll('1d20', { label: 'Dex save', text: 'I rolled {result} to dodge the fireball!' }) showed "Dex save · 1d20" in the owner's prompt and rendered the labeled, templated roll inside the action frame.)
  • [x] In all cases where action code is generated by the platform, such as Import Character and Generate Monster, when requestRoll is used in the generated code, it should use the new second argument to provide a label for the roll such as "Dex save" in the example requestRoll('1d20+dex', { label: 'Dex save' }). (All three generators that emit requestRoll — monsterGenerator.ts (Generate Monster spell attacks), insertedMonster5eActionCode.ts (Insert Monster 5e save abilities), and aiMonsterActionCode.ts (AI monster abilities) — now emit t.requestRoll('1d20+<ability>', { label: '<Ability> save' }) (capitalized, e.g. "Dex save"). Character import (characterToToken) doesn't generate requestRoll calls today, so there was nothing to change there. 6 generated-code assertions updated across the three generator test files.)

Rolling Issues

  • [x] In the below example action, the character is making an extra d20 roll after the explicit rolls for damage and dc. Those two rolls, the final return, and the applyDamage UI correctly appear enclosed within a container with the Action's name; the extra d20 roll is not in that container. Possible hint: if I wait in the requestRoll modal, preventing the action from completing, I can see the extra d20 roll happening asynchronously. (Root cause reproduced in the running app: the dice store has a single onRollComplete slot; the legacy useDiceRoller hook — mounted by the Character/Impersonated-Token contexts — stomped ChatLog's patch-only handler with a pre-v0.28.0 one that POSTED a brand-new [DICE_ROLL:] whenever 3D dice settled. An action's last 3D roll is its final roll() (the d20 dc — clearDice() replaces the 8d6), so it surfaced seconds later as an untagged persona-attributed d20 outside the frame. Fix: both contexts now roll via the shared rollExpressionToChat (immediate chat send, visual-only 3D, persona attribution preserved) and useDiceRoller is deleted. 4 contracts in strayCompletionRoll.test.ts; persona/wiring tests updated to pin the new mechanism.)
  • [x] In the below example action, the avatars for the character do not show; there's a default Avatar silhouette, and the tooltip show's the character's name correctly. The avatars of the tokens targeted by requestRoll also show no avatar. All tokens involved do have character art that should be in the avatar. (Root cause from live data: chat senders resolved avatars from token.imageUrl only (empty when art lives in artworks/artworkImages) or from modern character.artworks only (empty when the character's art is in the legacy artworkImages — true for most live characters). New shared utils/chatAvatar.ts (resolveTokenChatAvatar/resolveCharacterChatAvatar) walks token-art → portrait → first artwork → legacy artworkImages → imageUrl, then falls back to the linked character's art; wired into the impersonated-action executor, WebGLMap's action + requestRoll-accept senders, the Character Context persona, impersonated quick rolls (now also carrying the token chatColor), and the applyDamage pending-card avatar. 11 unit tests in chatAvatar.test.ts + 5 call-site contracts in rollMessageAvatars.test.ts.)
  • [x] In the below example action, the requestRoll chat messages are not contained within the Action container, even though the action awaits their return. (The container is driven by the invisible action-invocation marker on each chat message; the owner-side roll for a target.requestRoll() was posted by WebGLMap's accept handler with no marker, so it always fell outside the frame. The invocation now travels the full round trip: executeAction injects it into tokenApiContext.actionInvocation → tokenApi.requestRoll → dispatchRollRequest socket payload (optional invocation on RollRequest/server RollRequestPayload; the relay's { ...payload } spread already forwards it verbatim) → handleRequestRollAccept tags its boundSend with tagMessageWithInvocation. With the stray-d20 fix above, the response lands adjacent to the action's other tagged messages and groups into the same frame. End-to-end requester-side test + 3 plumbing contracts in requestRollInvocation.test.ts.)

(All three rolling fixes were verified in the running app (Chrome at localhost:5173) by executing the example action below from the Character Context against a targeted token: with the requestRoll modal held open ~9s no stray d20 appeared; after accepting, the requested roll rendered inside the "Fireball (Test) · Shade" frame together with the damage/dc rolls, damage card, and return message; every message showed the correct token/character art avatar. The UI items above (full-screen zoom modal: nav/backdrop-close/counter; Edit Map: live texture-scale preview + Cancel revert-and-close) were verified live the same way. A test action named "Fireball (Test)" was left on the Shade token in the Playtesting campaign for re-testing — delete it whenever.)

Example Action:

let damage = roll('8d6', { label:'fireball damage' });
let dc = roll('1d20+int', { label:'spellcraft check' });

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.`;
}
else 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}`;
}

API Cleanup

Refactor all API calls to use PATCH with minimal payloads instead of full-object PUTs / whole-array sends. Guidelines:

  • Always send only the changed properties.
  • Never send arrays. Instead, use sub-entity routes to change individual items (e.g. PATCH /characters/:id/actions/:actionId to change one action, instead of sending the entire actions array).

Full codebase inventory (which entities are already compliant, every endpoint to create, and every call site to migrate, with file:line refs) is in API Usage Refactor. Campaigns and Maps are already compliant — the work below is Tokens and Characters. The atomic TODOs are ordered so endpoints land before the call sites that consume them.

Decisions (resolve first)

  • [x] DECISION: how to address name-keyed character sub-collections (skills, feats, powers, attacks, attributes — identified by name, no stable id; see packages/shared/src/types/character.ts:113-269). Either (a) add a stable id to each element (recommended — backfill on read/import) or (b) address by URL-encoded name. Blocks the character name-keyed sub-routes below.
    • Decision made: go with a "slug" for each, which is the name, minus any characters not in [A-Za-z ], with spaces replaced with dashes, all characters lowercase. Backfill all current data with these slugs, and create them when discovering new feats/etc from the system wiki.
    • (Resolved (v0.30.0): nameToSlug in @purple-cat/shared implements the exact transform; slugs are made unique within each collection (-2 suffix on collision) at assignment. Because the base slug is a pure function of the name, routes also recompute it on the fly, so the backfill is not a hard prerequisite.)
    • (Refinement: an element's existing slug is treated as authoritative (e.g. supplied by the system wiki going forward) — normalizeCollectionSlugs preserves it as-is and never recomputes/overwrites it; only elements lacking a slug get the computed value, made unique against the authoritative ones.)

Token — sub-entity endpoints to create (packages/server-core/src/routes/tokens.ts)

Reuse existing per-action (/tokens/:id/actions/:actionId) and per-artwork (/tokens/:id/artworks/:artworkId) routes.

  • [x] Add token aura sub-routes: POST /tokens/:id/auras, PATCH /tokens/:id/auras/:auraId, DELETE /tokens/:id/auras/:auraId (IAura has a stable id). (Implemented in packages/server-core/src/routes/tokens.ts mirroring the per-action routes: AuraSchema (id/radius/color), POST appends + 409s on duplicate id, PATCH partial-merges preserving the id + 404s, DELETE filters + 404s; all broadcast tokenUpdated. 5 source-text contracts in tokens.test.ts.)
  • [x] Add token condition membership routes: POST /tokens/:id/conditions (body { conditionId }) + DELETE /tokens/:id/conditions/:conditionId (conditions are a string[] of ids). (Implemented in tokens.ts: AddConditionSchema (non-empty conditionId); POST adds idempotently (already-present → 200 unchanged, else 201), DELETE removes idempotently (absent → 200 unchanged); both skip the DB write + broadcast on a no-op. No PATCH — it's set membership. 4 source-text contracts.)

Token — migrate client call sites off whole-array sends (apps/web/src)

  • [x] Actions → existing per-action routes (ActionEditor already exposes onCreateAction/onUpdateAction/onDeleteAction): ExpandedTokenContent.tsx:485, CurrentTokenBar.tsx:2066. (Both sites already wired the granular callbacks; the leftover onActionsChange={() => updateToken(id, { actions })} was a redundant second whole-array PATCH on every edit. Made ActionEditor.onActionsChange optional + guarded (?.); ExpandedTokenContent drops it (granular callbacks + live cache refresh), CurrentTokenBar now syncs setCurrentToken({ ...token, actions }) locally instead of re-PATCHing. 3 contracts in ActionEditor.perActionApi.test.ts.)
  • [ ] Artworks → existing per-artwork routes: utils/tokenApi.ts:436. (DEFERRED to its own focused commit — larger than a call-site swap. autoTokenizeArtwork edits one artwork's tokenizeSettings but relies on whole-array replace to drop the stale processedUrl render cache; a shallow-merge PATCH /tokens/:id/artworks/:artworkId can't clear a field without (a) widening UpdateArtworkEntitySchema + ArtworkEntity.processedUrl to accept null and (b) adding a updateTokenArtwork hook method threaded onto TokenApiContext. Sub-tasks: 4a add updateTokenArtwork (mirrors updateTokenAura) + thread onto ctx; 4b widen schema/type for null-clear; 4c migrate autoTokenizeArtwork.)
  • [x] Conditions → new condition routes: FloatingTokenWindow.tsx:262, TokenModal.tsx:629, ExpandedTokenContent.tsx:161/:177, CurrentTokenBar.tsx:602/:609/:620, TokenContext.tsx:607, MultiTokenContext.tsx:185. (Added addTokenCondition/removeTokenCondition to useTokens (POST/DELETE the sub-entity route, optimistic cache update; auto-exposed via the useUndoableTokens spread). Migrated all 7 sites + an 8th uncatalogued one (CharacterDetail/LinkedTokenConditions.tsx) to call the single-condition helpers; local-state syncs (setCurrentToken/setTokenConditions/setSelectedTokens) preserved. 10 contracts in useTokens.conditionApi.test.ts.)
  • [x] Auras → new aura routes: CurrentTokenBar.tsx:722, TokenContext.tsx:633/:648, MultiTokenContext.tsx:233. (Added addTokenAura/updateTokenAura/removeTokenAura to useTokens (POST/PATCH/DELETE the sub-entity route, optimistic cache update). Migrated CurrentTokenBar (toggle→remove), TokenContext (add + delete), MultiTokenContext (add-to-each; remove-by-radius+color now deletes each matching aura by id); local setCurrentToken/setSelectedTokens syncs preserved. 7 contracts in useTokens.auraApi.test.ts.)
  • [x] DECISION + migrate token-embedded vblShapes (deprecated; standalone /api/vbl-shapes table exists): either move these editors to the standalone routes or leave the embedded array — do not build a new token-embedded sub-route. Call sites: TokenContext.tsx:872/:894, BackgroundTokenBar.tsx:198, VBLToolbar.tsx:163/:195. (DECISION: leave as-is. These are not the "changed one item, re-sent the array" anti-pattern — they are whole-set operations (VBL auto-detection emits the complete shape set; the VBL toolbar saves all in-progress edits at once), so a per-shape sub-route wouldn't fit the usage. The path is also deprecated in favor of the standalone /api/vbl-shapes table; the correct long-term fix is migrating token VBL onto that table, which is a separate feature beyond this API-payload-cleanup release. No code change.)

Character — sub-entity endpoints to create (packages/server-core/src/routes/characters.ts)

Existing routes: POST /characters/:id/artwork, DELETE /characters/:id/artwork/:artworkId (note: singular, vs tokens' plural — standardize when touching).

  • [x] Add character per-action routes: POST /characters/:id/actions, PATCH /characters/:id/actions/:actionId, DELETE /characters/:id/actions/:actionId (mirror the token implementation; IAction has a stable id). (Implemented in characters.ts using CharacterActionSchema + the getUserCharacterAccess/canEdit gate; POST appends + 409s on dup id, PATCH partial-merges preserving the id + 404s, DELETE filters + 404s; each returns the updated character (no socket broadcast). 5 source-text contracts in characters.actionApi.test.ts.)
  • [x] Add PATCH /characters/:id/artwork/:artworkId for single-artwork edits (tokenize settings / crop), so the whole artworks array stops being sent. (Implemented in characters.ts (singular /artwork/:artworkId, consistent with the existing POST/DELETE which already handle both the legacy artworkImages and modern artworks arrays). Validates with ArtworkEntitySchema.partial().omit({ id }), gated by getUserCharacterAccess/canEdit, merges into the modern artworks entry preserving the id, 404s on a missing artwork, returns the updated character. 4 contracts in characters.artworkApi.test.ts.)
  • [x] Add name-keyed character sub-routes for skills/feats/powers/attacks/attributes (one commit each, or grouped) — after the addressing decision above. (Implemented via a generic registerNameKeyedCollectionRoutes(collection, schema, label) factory in characters.ts: POST /characters/:id/{collection} + PATCH/DELETE /characters/:id/{collection}/:slug, all gated by a shared requireCharacterEditAccess (hasAccess + canEdit) and re-stamping unique slugs on write. Added slug? to the five element types (shared) + Zod schemas. Slug logic (nameToSlug in shared; normalizeCollectionSlugs/assignCharacterCollectionSlugs/findElementIdxBySlug in utils/slugs.ts) is shared with the create/update/import handlers (which now stamp slugs) and a one-shot backfill script scripts/backfill-character-element-slugs.ts (--dry-run). Tests: 5 nameToSlug (shared), 14 slug-helper (slugs.test.ts), 6 route contracts.)

Character — migrate client call sites off whole-array sends (apps/web/src)

  • [ ] updateCharacterArtworks (hooks/useCharacters.ts:396) sends the whole artworks array → per-artwork PATCH. (DEFERRED to its own focused commit, alongside the token artwork tokenize migration above — the character artwork subsystem is intricate: (1) dual legacy artworkImages + modern artworks arrays where the existing per-item POST writes only to the legacy one; (2) no per-item POST to the modern artworks array (the "mirror legacy on add" caller needs one); (3) the tokenize editor (handleEditorArtworksChange) updates local character.artworks synchronously before its 250ms-debounced save, so a naive old-vs-new diff sees no change — a correct per-artwork migration needs a "last-persisted artworks" ref to diff against. The PATCH /characters/:id/artwork/:artworkId route is ready; sub-tasks: 11a add updateCharacterArtwork hook helper + last-persisted ref; 11b add POST /characters/:id/artworks for adds; 11c migrate the editor + set-token-art callers to the ref-based per-artwork diff.)
  • [x] Wire the character action editor to the new per-action routes: CharacterDetail.tsx:1314 (this also fixes a latent bug — the patch builder at CharacterDetail.tsx:643 omits actions, so character action edits are silently dropped on save today). (Added createCharacterAction/updateCharacterAction/deleteCharacterAction + a syncCharacterActions(id, old, new) diff helper (by stable id) to useCharacters. CharacterDetail now routes the editor through a handleActionsChange that updates local state and persists via syncCharacterActions, instead of handleCharacterChange({ actions }) (which dropped them). 5 contracts in useCharacters.actionApi.test.ts.)
  • [x] Route single-item edits in handleCharacterChange (CharacterDetail.tsx:643-657) for skills/feats/powers/attacks/attributes/classes through the sub-entity routes instead of re-sending the whole array. (Added createCharacterCollectionItem/updateCharacterCollectionItem/deleteCharacterCollectionItem + a slug-diffing syncCharacterCollection to useCharacters. handleCharacterChange now persists the five name-keyed object collections via syncCharacterCollection (diffed by slug) and sends a scalar-only PATCH for the rest; local state still reflects everything immediately. classes is a string[] (deprecated) with no sub-route, so it rides along in the scalar PATCH. 10 contracts in useCharacters.collectionApi.test.ts.)
  • [x] handleSheetImport (CharacterDetail.tsx:1190) sends a near-full character on AI sheet import — add a dedicated bulk replace/import endpoint (or explicit exemption) so it isn't rejected by the "too large" check below. (Added replaceCharacterSheet(id, sheet) to useCharacters using PUT /characters/:id — a deliberate full-object write that the PATCH-only guardrail below skips. handleSheetImport now sends only the parsed build fields via PUT (server stamps slugs on the collections) instead of dumping the whole character through updateCharacter. 2 contracts in useCharacters.sheetImport.test.ts.)

Guardrail

  • [x] Add a check to all PATCH endpoints to verify the changes aren't too large. Decide on a reasonable threshold; guidelines:
    • If changing top-level properties, accept up to 10 properties in a single request.
    • Disallow changes on multiple levels, e.g. a top-level Token attribute plus an entire array of Actions. This should be a done by PATCHing the sub-entities and the main entity separately, rather than allowing a single request to change both.
    • This check will throw an error instead of completing.
    • Exempt or special-case the legitimate multi-field writes: POST /characters/:id/derive-power-actions, the bulk sheet-import endpoint above, and useRefreshTokenFromCharacter (apps/web/src/components/UI/CurrentTokenBar/useRefreshTokenFromCharacter.ts:48). Decide whether metadata object blobs (re-sent whole on every token/character property change) count toward the size limit or are exempt.
    • (Implemented: checkPatchPayload (packages/server-core/src/utils/patchGuard.ts) — rejects (400) >10 changed top-level props (excluding updatedAt/id) or any array sent alongside another field; a lone array (e.g. deprecated classes) still passes. Applied in the PATCH /tokens/:id and PATCH /characters/:id handlers, gated on req.method === "PATCH" so the PUT bulk path is the exemption. derive-power-actions is a POST (unaffected); sheet import (#14) and refresh-from-character (new replaceToken PUT helper) now use PUT. Decision on metadata: counts as one top-level property — the guard does NOT recurse into the blob, so metadata-only PATCHes (HP ticks etc.) are unaffected. Applied to the two main-entity handlers where whole-array sends occurred; the sub-entity routes and the already-compliant campaign/map PATCHes are intentionally not guarded (sub-entity items legitimately carry small nested arrays like codeHistory/components). 12 unit + contract tests in patchGuard.test.ts.)

Performance Optimizations

Findings from two /play HAR captures (dev + prod) on a small 8-token map; full analysis with metrics, root-cause traces, and verification steps in docs/ideas/todo/TODO Play Load Performance.md. Ordered biggest-win-first. The dev capture's 936 requests / 660 JS files are a Vite-dev artifact (prod is one 1.38 MB bundle) — the items below target the JSON payload, request dedup, lazy-loading, and main-thread/render cost, all of which reproduce in prod.

v0.30.0 status: the network/payload tier is done — unscoped-fetch gating (client + server), the four React-Query dedup migrations + staleTime, CORS preflight caching, null-column stripping, and the duplicate-URL collapse all shipped with test coverage. The remaining unchecked items below are deferred this pass because they can only be validated by running the app (WebGL/GPU cost, the dice worker, pointer-coordinate correctness, image rendering), inspecting a production bundle, or fetching third-party assets — none of which the test/typecheck/lint tooling here can verify, and blindly committing core-render/interaction refactors would risk regressions that source-text tests can't catch. Each carries a one-line note. Self-hosting the dice theme is hard-blocked on vendoring external CDN files.

Unscoped token fetch — ~84% of page weight (do first)

A single GET /api/tokens? with no mapId returns the entire token table — 51 MB in both dev and prod — for a map needing 8 tokens (~0.2–1.3 MB scoped). It's fetched, parsed on the main thread, and discarded once mapId resolves and the scoped call refires. Confirmed via the HAR _initiator stack (useTokens.ts:25).

  • [x] Client: gate the tokens query on mapId. Add enabled: !!mapId to the useQuery in useTokens (apps/web/src/hooks/useTokens.ts:47). Root cause: with mapId undefined on first render the query still fires queryFn, building /api/tokens? with no filter. Add a unit test asserting the hook stays idle (no fetch) when mapId is undefined; verify with a fresh HAR that the empty-query call is gone and only ?mapId= calls remain. (Added enabled: !!mapId to the query config — covers both useTokens() (undefined) and the mapId || '' callers (empty string is falsy), so no map ⇒ no fetch. 2 source-text contracts in useTokens.queryGate.test.ts. HAR re-capture is a manual follow-up.)
  • [x] Server: require a scope on GET /api/tokens. Make mapId (or campaignId) mandatory in packages/server-core/src/routes/tokens.ts:390; return 400/empty instead of falling through to the unfiltered isNull(deletedAt) branch (tokens.ts:419). Apply limit at the query level (currently read but only .slice()d after fetching every row). Defense-in-depth so no future client bug or script can pull the whole table. (GET /api/tokens now 400s without a mapId (the scope tokens carry directly — there's no campaignId column on tokens; campaign scoping would need a maps join), the unscoped fall-through branch is removed, and limit is applied via .limit(n) on the query instead of .slice() after fetching every row. Optional layerId still filters on top. 4 source-text contracts in tokens.scopeRequired.test.ts; 56 token route tests green.)

Duplicate requests — non-deduped useState+fetch hooks

Each component mounting these hooks fires its own request (no shared cache / in-flight dedup): per load, /api/parties ×10, /api/systems ×8, /actions ×6, /equipment ×6. Not just dev StrictMode — the prod capture independently repeats tokens (scoped) ×3 and actions ×3.

  • [x] Migrate useParties to React Query (apps/web/src/hooks/useParties.ts:33) with a shared queryKey → ~10 calls collapse to 1. (Replaced the useState+useEffect+fetch list with useQuery({ queryKey: ['parties'] }); create/update/delete/add-character/remove-character now invalidateQueries(['parties']) instead of a manual fetchParties(). Same return shape. 3 contracts in useParties.reactQuery.test.ts.)
  • [x] Migrate useSystems to React Query (apps/web/src/hooks/useSystems.ts:43) → ~8 → 1. (List now fetched via useQuery({ queryKey: ['systems'] }); the 5 mutations (create/update/delete/copy/restore-defaults) invalidateQueries(['systems']). Local error state retained for mutation failures and combined with the query error. 4 contracts in useSystems.reactQuery.test.ts.)
  • [x] Migrate useCampaignActions to React Query (apps/web/src/hooks/useCampaignActions.ts:47) → ~6 → 1. (Keyed ['campaignActions', campaignId], gated enabled: !!campaignId; queryFn sorts by sortOrder. Mutations keep the previous instant-UI behavior via optimistic setQueryData (no refetch). 3 contracts in useCampaignActions.reactQuery.test.ts.)
  • [x] Migrate useEquipment to React Query (apps/web/src/hooks/useEquipment.ts:27) → ~6 → 1. (Keyed ['equipment', characterId], gated on it. Preserved the optimistic update-with-reconcile, optimistic reorder, and rollback-on-error (now setQueryData + invalidateQueries); fetchEquipment(opts?) retained for callers (its silent flag is now implicit — RQ background-refetches without dropping data). 5 contracts in useEquipment.reactQuery.test.ts.)
  • [x] Audit the staleTime: 0 default (apps/web/src/lib/queryClient.ts) — give slow-changing reference data (systems, parties, actions, modules/*/powers, modules/*/feats) a multi-minute staleTime so intra-session re-mounts stop refetching; keep 0 only for live entities (tokens, chat). (Exported REFERENCE_DATA_STALE_TIME (5 min) from queryClient.ts and applied it to useSystems/useParties/useCampaignActions. Left the global default at 0 so live entities (tokens — explicit staleTime: 0, chat) keep refetching eagerly. modules/*/powers+feats are fetched via a module-level cache (useCharacters.getSystem), not React Query, so they're already deduped. 4 contracts in queryClient.staleTime.test.ts.)
  • [x] Cache CORS preflights — dev fired 97 OPTIONS round-trips; add Access-Control-Max-Age (e.g. 600s) to the server CORS config. Primarily a local-dev speedup (the prod capture showed 0 preflights — verify whether prod already sets this or proxies same-origin before assuming a prod win). (Added maxAge: 600 to the Express cors() middleware in index.ts (the cors package emits Access-Control-Max-Age: 600). 1 contract in cors.maxAge.test.ts.)

Token payload shape (shrinks the scoped call too)

Even correctly scoped, each token is heavier than first render needs: ~2.5 KB of embedded actions JS per token (only needed on selection), duplicated imageUrl/originalImageUrl, and ~25 always-null columns — all from SELECT * (tokens.ts:397).

  • [ ] Add sparse-fieldset support to GET /api/tokens (a fields param) and request only render fields on initial map paint, omitting actions; fetch a token's actions lazily on selection. Same mechanism as the fields item in API Usage Refactor. (Deferred v0.30.0: the server fields param is mechanical, but the win comes from the client omitting actions on first paint and lazy-loading them on selection — that changes the action-availability flow (actions must be loaded before execution) and needs runtime verification that no path reads token.actions before the lazy fetch resolves.)
  • [x] Stop serializing null columns in the token list response (drop null-valued keys, or use a populated-fields DTO). (Added a pure stripNullColumns util (packages/server-core/src/utils/stripNullColumns.ts); GET /api/tokens maps each row through it. Only null is stripped (keeps 0/''/false/arrays/objects); null vs absent is equivalent for every client read. 4 tests (3 behavioral + 1 source-text) in tokens.stripNull.test.ts.)
  • [x] Collapse duplicate imageUrl/originalImageUrl when equal (send one; let the client default the other), or document why both must ship. (Added collapseDuplicateImageUrl to the token-list serialization util; GET /api/tokens composes it with stripNullColumns. Safe with no client change — the only consumer, TokenModal, already reads token.originalImageUrl || token.imageUrl. originalImageUrl is only kept when it genuinely differs (tokenized/cropped tokens). 3 behavioral + 1 source-text test added to tokens.stripNull.test.ts.)

Lazy-load non-critical subsystems

The prod bundle is a single 1.38 MB chunk loaded before first paint. The 3D-dice subsystem (ammo.wasm 0.3 MB + Three.js + ~0.5 MB of dice themes) isn't needed until the user rolls.

  • [ ] Lazy-load the dice-box subsystem on first roll — dynamic-import() useDiceRoller/DiceBoxOverlay/diceRoller (apps/web/src/hooks/useDiceRoller.ts, apps/web/src/components/UI/DiceBoxOverlay/DiceBoxOverlay.tsx) so ammo.wasm and the dice textures/themes load on demand. The CPU trace confirms the cost is not just bytes: DiceBoxOverlay mounts eagerly via DiceOverlayHost (WebGLMap.tsx:3756 → DiceBoxOverlay.tsx:66 box.init()), the Ammo physics worker (ammo.wasm.wasm) initializes during load, and a DedicatedWorker burns ~1.35 s in onmessage with zero dice rolled. Verify a separate Vite chunk, and that the wasm/theme requests and the worker init both leave the initial load. (Deferred v0.30.0: dynamic-import refactor of a core subsystem whose acceptance criteria are all runtime/build — separate chunk, wasm/worker leaving the initial load — and a mistake silently breaks dice rolling. Needs a running app + a fresh trace to verify.)
  • [ ] Self-host the dice theme — blueGreenMetal loads from cdn.jsdelivr.net (third-party DNS/TLS during initial load); fold it into the lazy dice chunk under /assets/dice-box/themes/. (BLOCKED v0.30.0: requires vendoring the third-party theme files (textures + manifest) from cdn.jsdelivr.net into the repo, which can't be fetched/committed from this environment. Pairs with the dice lazy-load above.)
  • [ ] Run the bundle visualizer (rollup-plugin-visualizer) on a prod build; route-split modal/character-sheet paths, confirm @react-three/drei tree-shakes. Goal: initial /play chunk < ~1 MB. (Deferred v0.30.0: needs a new dev dependency (rollup-plugin-visualizer) installed and a production build run + visually inspected to drive the route-split decisions and confirm the <1 MB goal — an analysis loop this environment can't close.)

Image sizing

Token/character art renders from full-res originals (e.g. cat-icon-2.png 854 KB, a 2550px-wide Reddit image) despite an existing LOD/.webp resize pipeline (apps/web/src/workers/lod.worker.ts).

  • [ ] Render tokens from a sized LOD/image.webp variant instead of original.*; reserve full-res for the artwork editor / zoom. (Deferred v0.30.0: needs the app running to confirm the LOD variant URLs resolve and tokens still render correctly at each zoom — picking the wrong variant URL silently blanks token art.)
  • [ ] Proxy & resize externally-hosted art (i.redd.it, squarespace-cdn, aonprd) through the image pipeline so first paint doesn't pull full-size third-party images. (Deferred v0.30.0: a larger feature — a server-side image proxy/resize endpoint plus client URL rewriting — that needs the running pipeline + real external images to validate caching, CORS, and fallback behavior.)

Main-thread & render cost (from an 8.2 s CPU trace of a hard reload)

The CPU trace corroborates the network findings: the main thread is busy ~3.8 s of the 8.2 s window and idle ~3.8 s waiting on the network (largely the 51 MB tokens blob above), and LCP is an image at ~4 s. Fixing the Tier-1 network waste shrinks the idle; the items below cut the CPU/render cost that remains. (Re-record in Incognito for clean numbers — the trace also caught ~0.4 s of browser-extension content scripts, the same noise as the Redux DevTools note in the idle-render TODO.)

  • [x] Disable Three.js shader-error checking in prod — renderer.debug.checkShaderErrors is left at its default true, so every shader compile forces a synchronous getProgramInfoLog() GPU round-trip (~37 ms here, worse on cold GPU caches). Set gl.debug.checkShaderErrors = false for production builds in the main Canvas (apps/web/src/components/WebGLMap.tsx:3599) and the Minimap (apps/web/src/components/Minimap/Minimap.tsx:118). Cheapest win in this group. (Both Canvases got onCreated={({ gl }) => { gl.debug.checkShaderErrors = import.meta.env.DEV; }} — off in prod, kept on in dev for debugging. 2 source-text contracts in webgl.shaderErrorChecks.test.ts.)
  • [ ] Move client-side canvas serialization off the main thread — toDataURL/toBlob cost ~354 ms on the main thread during load. The on-load callers re-encode a transformed canvas to WebP at render time: character-artwork tokenization (apps/web/src/components/CharacterDetail/CharacterDetail.tsx:542) and apps/web/src/utils/tokenizer.ts:339. Do the encode in a worker (OffscreenCanvas) and/or cache the encoded result so it isn't recomputed on every mount. (Deferred v0.30.0: moving canvas encoding to an OffscreenCanvas worker is a runtime-heavy change (worker plumbing, transferable canvases) whose output must be pixel-verified in the running app.)
  • [ ] Stop forcing preserveDrawingBuffer for snapshots — the main Canvas runs with preserveDrawingBuffer: true (apps/web/src/components/WebGLMap.tsx:3599) solely so MapSnapshotCapture (apps/web/src/components/MapSnapshotCapture.tsx:144) can read the canvas back every 15 s — a per-frame GPU cost on every map. Render snapshots to an FBO/OffscreenCanvas instead and drop preserveDrawingBuffer from the live canvas. (Deferred v0.30.0: rewiring MapSnapshotCapture to read from an FBO and dropping preserveDrawingBuffer must be verified against live snapshots in the running app — a regression here silently corrupts map thumbnails.)
  • [ ] Cut WebGL texture-upload cost — texSubImage2D cost ~349 ms uploading token, token-bar, and foundation textures one at a time (apps/web/src/components/WebGLTokenLayer.tsx:142/911/1844, apps/web/src/components/WebGLMapFoundation.tsx:72). Uploading appropriately-sized token textures (see Image sizing above) directly reduces this; longer-term, atlas the token bars/status icons to batch uploads. (Deferred v0.30.0: the sized-token-texture half overlaps the LOD-variant item above; texture atlasing is a substantial WebGL change requiring GPU-trace verification. Both need the running renderer.)
  • [ ] Cache the canvas rect to stop forced reflow — screenToWorld() and the drag/draw handlers call getBoundingClientRect() / read scrollX on every pointermove (apps/web/src/components/WebGLMap.tsx:708, apps/web/src/components/WebGLTokenLayer.tsx:2522/2821/3058) — ~72 ms of forced synchronous layout. Cache the rect in a ref updated by the existing ResizeObserver (WebGLMap.tsx:695) plus a scroll listener, instead of reading it per event. (Interaction smoothness more than load, but cheap and localized.) (Deferred v0.30.0: caching the rect across 4 pointer-event sites in the interaction core risks coordinate drift on uncaught layout shifts; correctness (drag/draw landing where the cursor is) can only be confirmed with the app running.)