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 whenhidden = token.actions.length - (shown across groups) > 0, pluralized ("1 action" / "N actions"). Only appears when a filter actually hides something. 4 source-text contracts inPlayModeView.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 nowfullScreenatZ_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 inTokenZoomButton.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 themap:updatedsocket handler, which WebGLMap renders from) + poketriggerMapTextureRefresh. ApreviewRefkeeps 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 viacloseSubcontext. 7 new source-text contracts inEditMapSubcontext.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.requestRollwhitelists the three fields (action code is user-authored) and ships them asrollOptionson the socket payload (RollRequestOptionsinIRollRequest.ts/ serverRollRequestPayload; the relay's payload spread forwards them verbatim). The owner's accept handler passes them torollRequestedExpression, which renderslabel/textthrough the samerollExpressionToChatdecoration asroll()(sharing its{result}placeholder — originally specced as{roll}, renamed for consistency), or — forpipeToChat: false— computes the total silently viaDiceRoller.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 inrequestRollOptions.test.ts. Verified live (Chrome at localhost:5173): the Playtesting fireball test action withrequestRoll('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), andaiMonsterActionCode.ts(AI monster abilities) — now emitt.requestRoll('1d20+<ability>', { label: '<Ability> save' })(capitalized, e.g. "Dex save"). Character import (characterToToken) doesn't generaterequestRollcalls 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
onRollCompleteslot; the legacyuseDiceRollerhook — 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 finalroll()(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 sharedrollExpressionToChat(immediate chat send, visual-only 3D, persona attribution preserved) anduseDiceRolleris deleted. 4 contracts instrayCompletionRoll.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.imageUrlonly (empty when art lives inartworks/artworkImages) or from moderncharacter.artworksonly (empty when the character's art is in the legacyartworkImages— true for most live characters). New sharedutils/chatAvatar.ts(resolveTokenChatAvatar/resolveCharacterChatAvatar) walks token-art → portrait → first artwork → legacyartworkImages→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 theapplyDamagepending-card avatar. 11 unit tests inchatAvatar.test.ts+ 5 call-site contracts inrollMessageAvatars.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:executeActioninjects it intotokenApiContext.actionInvocation→tokenApi.requestRoll→dispatchRollRequestsocket payload (optionalinvocationonRollRequest/serverRollRequestPayload; the relay's{ ...payload }spread already forwards it verbatim) →handleRequestRollAccepttags itsboundSendwithtagMessageWithInvocation. 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 inrequestRollInvocation.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/:actionIdto 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 byname, no stableid; seepackages/shared/src/types/character.ts:113-269). Either (a) add a stableidto each element (recommended — backfill on read/import) or (b) address by URL-encodedname. 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):
nameToSlugin@purple-cat/sharedimplements the exact transform; slugs are made unique within each collection (-2suffix 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
slugis treated as authoritative (e.g. supplied by the system wiki going forward) —normalizeCollectionSlugspreserves 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(IAurahas a stableid). (Implemented inpackages/server-core/src/routes/tokens.tsmirroring 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 broadcasttokenUpdated. 5 source-text contracts intokens.test.ts.) - [x] Add token condition membership routes:
POST /tokens/:id/conditions(body{ conditionId }) +DELETE /tokens/:id/conditions/:conditionId(conditions are astring[]of ids). (Implemented intokens.ts:AddConditionSchema(non-emptyconditionId); 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 (
ActionEditoralready exposesonCreateAction/onUpdateAction/onDeleteAction):ExpandedTokenContent.tsx:485,CurrentTokenBar.tsx:2066. (Both sites already wired the granular callbacks; the leftoveronActionsChange={() => updateToken(id, { actions })}was a redundant second whole-array PATCH on every edit. MadeActionEditor.onActionsChangeoptional + guarded (?.); ExpandedTokenContent drops it (granular callbacks + live cache refresh), CurrentTokenBar now syncssetCurrentToken({ ...token, actions })locally instead of re-PATCHing. 3 contracts inActionEditor.perActionApi.test.ts.) - [ ] Artworks → existing per-artwork routes:
utils/tokenApi.ts:436. (DEFERRED to its own focused commit — larger than a call-site swap.autoTokenizeArtworkedits one artwork'stokenizeSettingsbut relies on whole-array replace to drop the staleprocessedUrlrender cache; a shallow-mergePATCH /tokens/:id/artworks/:artworkIdcan't clear a field without (a) wideningUpdateArtworkEntitySchema+ArtworkEntity.processedUrlto acceptnulland (b) adding aupdateTokenArtworkhook method threaded ontoTokenApiContext. Sub-tasks: 4a addupdateTokenArtwork(mirrorsupdateTokenAura) + thread onto ctx; 4b widen schema/type for null-clear; 4c migrateautoTokenizeArtwork.) - [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. (AddedaddTokenCondition/removeTokenConditiontouseTokens(POST/DELETE the sub-entity route, optimistic cache update; auto-exposed via theuseUndoableTokensspread). 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 inuseTokens.conditionApi.test.ts.) - [x] Auras → new aura routes:
CurrentTokenBar.tsx:722,TokenContext.tsx:633/:648,MultiTokenContext.tsx:233. (AddedaddTokenAura/updateTokenAura/removeTokenAuratouseTokens(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); localsetCurrentToken/setSelectedTokenssyncs preserved. 7 contracts inuseTokens.auraApi.test.ts.) - [x] DECISION + migrate token-embedded
vblShapes(deprecated; standalone/api/vbl-shapestable 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-shapestable; 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;IActionhas a stableid). (Implemented incharacters.tsusingCharacterActionSchema+ thegetUserCharacterAccess/canEditgate; 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 incharacters.actionApi.test.ts.) - [x] Add
PATCH /characters/:id/artwork/:artworkIdfor single-artwork edits (tokenize settings / crop), so the wholeartworksarray stops being sent. (Implemented incharacters.ts(singular/artwork/:artworkId, consistent with the existing POST/DELETE which already handle both the legacyartworkImagesand modernartworksarrays). Validates withArtworkEntitySchema.partial().omit({ id }), gated bygetUserCharacterAccess/canEdit, merges into the modernartworksentry preserving the id, 404s on a missing artwork, returns the updated character. 4 contracts incharacters.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 genericregisterNameKeyedCollectionRoutes(collection, schema, label)factory incharacters.ts:POST /characters/:id/{collection}+PATCH/DELETE /characters/:id/{collection}/:slug, all gated by a sharedrequireCharacterEditAccess(hasAccess + canEdit) and re-stamping unique slugs on write. Addedslug?to the five element types (shared) + Zod schemas. Slug logic (nameToSlugin shared;normalizeCollectionSlugs/assignCharacterCollectionSlugs/findElementIdxBySluginutils/slugs.ts) is shared with the create/update/import handlers (which now stamp slugs) and a one-shot backfill scriptscripts/backfill-character-element-slugs.ts(--dry-run). Tests: 5nameToSlug(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 wholeartworksarray → per-artwork PATCH. (DEFERRED to its own focused commit, alongside the token artwork tokenize migration above — the character artwork subsystem is intricate: (1) dual legacyartworkImages+ modernartworksarrays where the existing per-item POST writes only to the legacy one; (2) no per-item POST to the modernartworksarray (the "mirror legacy on add" caller needs one); (3) the tokenize editor (handleEditorArtworksChange) updates localcharacter.artworkssynchronously 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. ThePATCH /characters/:id/artwork/:artworkIdroute is ready; sub-tasks: 11a addupdateCharacterArtworkhook helper + last-persisted ref; 11b addPOST /characters/:id/artworksfor 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 atCharacterDetail.tsx:643omitsactions, so character action edits are silently dropped on save today). (AddedcreateCharacterAction/updateCharacterAction/deleteCharacterAction+ asyncCharacterActions(id, old, new)diff helper (by stable id) touseCharacters.CharacterDetailnow routes the editor through ahandleActionsChangethat updates local state and persists viasyncCharacterActions, instead ofhandleCharacterChange({ actions })(which dropped them). 5 contracts inuseCharacters.actionApi.test.ts.) - [x] Route single-item edits in
handleCharacterChange(CharacterDetail.tsx:643-657) forskills/feats/powers/attacks/attributes/classesthrough the sub-entity routes instead of re-sending the whole array. (AddedcreateCharacterCollectionItem/updateCharacterCollectionItem/deleteCharacterCollectionItem+ a slug-diffingsyncCharacterCollectiontouseCharacters.handleCharacterChangenow persists the five name-keyed object collections viasyncCharacterCollection(diffed by slug) and sends a scalar-only PATCH for the rest; local state still reflects everything immediately.classesis astring[](deprecated) with no sub-route, so it rides along in the scalar PATCH. 10 contracts inuseCharacters.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. (AddedreplaceCharacterSheet(id, sheet)touseCharactersusing PUT/characters/:id— a deliberate full-object write that the PATCH-only guardrail below skips.handleSheetImportnow sends only the parsed build fields via PUT (server stamps slugs on the collections) instead of dumping the whole character throughupdateCharacter. 2 contracts inuseCharacters.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, anduseRefreshTokenFromCharacter(apps/web/src/components/UI/CurrentTokenBar/useRefreshTokenFromCharacter.ts:48). Decide whethermetadataobject 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 (excludingupdatedAt/id) or any array sent alongside another field; a lone array (e.g. deprecatedclasses) still passes. Applied in thePATCH /tokens/:idandPATCH /characters/:idhandlers, gated onreq.method === "PATCH"so the PUT bulk path is the exemption.derive-power-actionsis a POST (unaffected); sheet import (#14) and refresh-from-character (newreplaceTokenPUT helper) now use PUT. Decision onmetadata: 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 likecodeHistory/components). 12 unit + contract tests inpatchGuard.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. Addenabled: !!mapIdto theuseQueryinuseTokens(apps/web/src/hooks/useTokens.ts:47). Root cause: withmapIdundefined on first render the query still firesqueryFn, building/api/tokens?with no filter. Add a unit test asserting the hook stays idle (no fetch) whenmapIdis undefined; verify with a fresh HAR that the empty-query call is gone and only?mapId=calls remain. (Addedenabled: !!mapIdto the query config — covers bothuseTokens()(undefined) and themapId || ''callers (empty string is falsy), so no map ⇒ no fetch. 2 source-text contracts inuseTokens.queryGate.test.ts. HAR re-capture is a manual follow-up.) - [x] Server: require a scope on
GET /api/tokens. MakemapId(orcampaignId) mandatory inpackages/server-core/src/routes/tokens.ts:390; return400/empty instead of falling through to the unfilteredisNull(deletedAt)branch (tokens.ts:419). Applylimitat 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/tokensnow 400s without amapId(the scope tokens carry directly — there's nocampaignIdcolumn on tokens; campaign scoping would need a maps join), the unscoped fall-through branch is removed, andlimitis applied via.limit(n)on the query instead of.slice()after fetching every row. OptionallayerIdstill filters on top. 4 source-text contracts intokens.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
usePartiesto React Query (apps/web/src/hooks/useParties.ts:33) with a sharedqueryKey→ ~10 calls collapse to 1. (Replaced theuseState+useEffect+fetchlist withuseQuery({ queryKey: ['parties'] }); create/update/delete/add-character/remove-character nowinvalidateQueries(['parties'])instead of a manualfetchParties(). Same return shape. 3 contracts inuseParties.reactQuery.test.ts.) - [x] Migrate
useSystemsto React Query (apps/web/src/hooks/useSystems.ts:43) → ~8 → 1. (List now fetched viauseQuery({ queryKey: ['systems'] }); the 5 mutations (create/update/delete/copy/restore-defaults)invalidateQueries(['systems']). Localerrorstate retained for mutation failures and combined with the query error. 4 contracts inuseSystems.reactQuery.test.ts.) - [x] Migrate
useCampaignActionsto React Query (apps/web/src/hooks/useCampaignActions.ts:47) → ~6 → 1. (Keyed['campaignActions', campaignId], gatedenabled: !!campaignId; queryFn sorts bysortOrder. Mutations keep the previous instant-UI behavior via optimisticsetQueryData(no refetch). 3 contracts inuseCampaignActions.reactQuery.test.ts.) - [x] Migrate
useEquipmentto 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 (nowsetQueryData+invalidateQueries);fetchEquipment(opts?)retained for callers (itssilentflag is now implicit — RQ background-refetches without dropping data). 5 contracts inuseEquipment.reactQuery.test.ts.) - [x] Audit the
staleTime: 0default (apps/web/src/lib/queryClient.ts) — give slow-changing reference data (systems, parties, actions,modules/*/powers,modules/*/feats) a multi-minutestaleTimeso intra-session re-mounts stop refetching; keep0only for live entities (tokens, chat). (ExportedREFERENCE_DATA_STALE_TIME(5 min) fromqueryClient.tsand applied it touseSystems/useParties/useCampaignActions. Left the global default at0so live entities (tokens — explicitstaleTime: 0, chat) keep refetching eagerly.modules/*/powers+featsare fetched via a module-level cache (useCharacters.getSystem), not React Query, so they're already deduped. 4 contracts inqueryClient.staleTime.test.ts.) - [x] Cache CORS preflights — dev fired 97
OPTIONSround-trips; addAccess-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). (AddedmaxAge: 600to the Expresscors()middleware inindex.ts(thecorspackage emitsAccess-Control-Max-Age: 600). 1 contract incors.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(afieldsparam) and request only render fields on initial map paint, omittingactions; fetch a token'sactionslazily on selection. Same mechanism as thefieldsitem in API Usage Refactor. (Deferred v0.30.0: the serverfieldsparam is mechanical, but the win comes from the client omittingactionson 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 readstoken.actionsbefore the lazy fetch resolves.) - [x] Stop serializing
nullcolumns in the token list response (drop null-valued keys, or use a populated-fields DTO). (Added a purestripNullColumnsutil (packages/server-core/src/utils/stripNullColumns.ts);GET /api/tokensmaps each row through it. Onlynullis stripped (keeps0/''/false/arrays/objects); null vs absent is equivalent for every client read. 4 tests (3 behavioral + 1 source-text) intokens.stripNull.test.ts.) - [x] Collapse duplicate
imageUrl/originalImageUrlwhen equal (send one; let the client default the other), or document why both must ship. (AddedcollapseDuplicateImageUrlto the token-list serialization util;GET /api/tokenscomposes it withstripNullColumns. Safe with no client change — the only consumer,TokenModal, already readstoken.originalImageUrl || token.imageUrl.originalImageUrlis only kept when it genuinely differs (tokenized/cropped tokens). 3 behavioral + 1 source-text test added totokens.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) soammo.wasmand the dice textures/themes load on demand. The CPU trace confirms the cost is not just bytes:DiceBoxOverlaymounts eagerly viaDiceOverlayHost(WebGLMap.tsx:3756→DiceBoxOverlay.tsx:66box.init()), the Ammo physics worker (ammo.wasm.wasm) initializes during load, and a DedicatedWorker burns ~1.35 s inonmessagewith 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 —
blueGreenMetalloads fromcdn.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) fromcdn.jsdelivr.netinto 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/dreitree-shakes. Goal: initial/playchunk < ~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.webpvariant instead oforiginal.*; 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.checkShaderErrorsis left at its defaulttrue, so every shader compile forces a synchronousgetProgramInfoLog()GPU round-trip (~37 ms here, worse on cold GPU caches). Setgl.debug.checkShaderErrors = falsefor 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 gotonCreated={({ gl }) => { gl.debug.checkShaderErrors = import.meta.env.DEV; }}— off in prod, kept on in dev for debugging. 2 source-text contracts inwebgl.shaderErrorChecks.test.ts.) - [ ] Move client-side canvas serialization off the main thread —
toDataURL/toBlobcost ~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) andapps/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
preserveDrawingBufferfor snapshots — the main Canvas runs withpreserveDrawingBuffer: true(apps/web/src/components/WebGLMap.tsx:3599) solely soMapSnapshotCapture(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 droppreserveDrawingBufferfrom the live canvas. (Deferred v0.30.0: rewiringMapSnapshotCaptureto read from an FBO and droppingpreserveDrawingBuffermust be verified against live snapshots in the running app — a regression here silently corrupts map thumbnails.) - [ ] Cut WebGL texture-upload cost —
texSubImage2Dcost ~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 callgetBoundingClientRect()/ readscrollXon everypointermove(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 existingResizeObserver(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.)