Skip to content

Version: v0.48.0

Staged 2026-08-07 from the former "Rollover from v0.46.0" section. Every item below was re-verified against the tree at v0.47.3 and is still outstanding — nothing here has quietly landed. Spot checks: the CC-1 guard baseline still records leakage in packages/shared (feats.service.ts 10, dataTableApi.ts 4, views/characterView.ts 2); PlayModeView.tsx:127 still carries isSystem8 and CharacterSheetModal still renders it; rank.ts / derivePowerActions.ts / types/system8Advancement.ts are all still in packages/shared; contentApi.ts's listSystemTraits / getSystemTraitChoices still have zero consumers; /parse-character-sheet still opens with a hardcoded "extracts D&D 5e character data" prompt; all eight system8*.ts generation utils are still in apps/web/src/utils; MonsterGenerator.tsx still gates on isSystem8Campaign; tokenFromMonster.ts still exists. The 1e.2 / 1e.3 classic-sheet-retirement items (blocked in v0.47.0) and the former Committed Scope section (Equipment, Multi-provider image-gen keys, Bugs, Misc) were rolled in here on the same date — no duplicates found between the two.

Everything below was deferred deliberately, not missed: the Option-3 scope call in 0.44.0 separated the server / shared / registry / data-store layers and left the system-specific code that lives in the old character sheet, because that code has a deletion date. It gets deleted wholesale by the Character Sheet Overhaul rather than refactored first — see v0.44.0's "Scope decision" for the ~4,100-lines-of-doomed-work argument.

So the ordering constraint is real: retire the old sheet first (1e.2 / 1e.3, immediately below), and most of the rest of this section falls out.

Retire the classic Character Sheet

Carried forward from v0.47.0, where both were blocked. They end in the same place: 1e.2 finishes with "remove the newCharacterSheet feature flag", and 1e.3 deletes the sheet that flag reaches. v0.47.0 also added "Show classic character sheet" to the ... menu — a decision to keep it reachable for one more release — so shipping that menu item while deleting its last route would have been incoherent. They still ship together. Removing that menu item is part of the job.

  • [ ] 1e.2 — Point all routes at the new sheet. /characters/:id is not a like-for-like swap. CharacterDetail is 1,714 lines and still owns surfaces the new sheet has no equivalent for (versions, factions, page-level ledger chrome), so pointing the route at the new sheet without porting those is a silent feature loss. Mobile also needs layout work — its Build panel renders inside its own ScrollArea.
  • [ ] 1e.3 — Delete the old Character Sheet. ⚠︎ Not AddFeatModal / AddPowerModal / AddEquipmentModal — the new sheet's Build mode uses all three. Note the collision with the Content API section below, which rewires those same three modals: whichever lands second inherits the other's surface. ⏸ DEFERRED by the user, 2026-08-07, after 1e.2 shipped. Nothing blocks it technically — 1e.2 left the classic sheet with zero live consumers (no route, no context, no modal) — but the new sheet has not been smoke-tested in a browser yet, and deleting the old one removes the fallback. It is dead code, not broken code, so the cost of waiting is a stale directory. When it resumes, these are now also dead and go with it: PlayModeView + its ~20 test files, CharacterDetail.tsx + its 7, projectionToCharacterView, CharacterContextBreadcrumb, ActionsPrefsControl + useCharacterUIPrefs, TokenSheet, and ModuleProjectionSheet (now a bare pass-through). createCharacterView loses its last 2 call sites with CharacterDetail and CharacterPanel — see the characterView.ts:468 item below, which collapses into this one.
  • [x] Hide the Modifiers section (deferred out of v0.47.0 on complexity, 2026-08-07). "Modifiers" is System 8's CS-1.5 derived-stat panel, rendered by the generic <DerivedPanelSection> that 5e's Saving Throws / Spell DC will also use. Hiding all derived panels over-reaches; hiding one by name puts system vocabulary into platform code; the correct seam is a hidden flag on the CS-1.5 panel declaration, which needs a vtt-modules change and a Module API bump alongside the platform work. DONE v0.48.0, exactly as scoped. DerivedStatPanel.hidden (Module API 1.9, additive — an absent flag means "render it"), deriveSheetSections skips hidden panels, and System 8 declares the flag in vtt-modules (system8 0.6.2 — see the publish note below). The panel stays IN the schema rather than being deleted: getDerivedStats flattens these panels, so removing the declaration would drop Con Modifier from the projection as well as from the sheet. The filtered list is re-indexed, so hiding a panel leaves no gap in the order band. ⚠︎ Needs a publish, and 0.6.1 is a dead version. modules.hosted.json pins system8 0.6.2. 0.6.1 was tagged and its publish run FAILED at the build step, so it never reached the registry: tsc rejected hidden: true because publish.yml checks the platform repo out at its DEFAULT branch, where Module API 1.9 does not exist yet — it is on this unmerged release branch. It built for its author only because the module's link: pointed at a local platform tree already on 1.9. 0.6.2 fixes that with a declaration merge in the module (sheetSchema.ts), so system8 builds against 1.8 and 1.9 alike; the emitted schema is unchanged. Verified against both. Per [[hosted-module-release-flow]], publish 0.6.2 BEFORE deploying this bump. The flag is additive, so an older platform ignores it and 0.6.2 is safe to publish first. Tidy-up owed after platform main carries 1.9: the module's local declare module block can be deleted. It merges with an identical member rather than conflicting, so nothing breaks if it is left — but it should not outlive the skew that caused it.

System-Platform Separation — the old-sheet carry-forward

Re-bucketed to the Overhaul's release schedule. Almost everything here is gated on the old sheet retiring, and under the Play/Edit split the old Build Mode survives 0.45.0 and dies in 0.46.0. So these move with it rather than blocking the Play Mode release. Each item is tagged below; only the PlayModeView branch is genuinely 0.45.0 work, and it turns out to be a verification rather than a fix.

  • [ ] ⏸ BLOCKED on 1e.3 (deferred 2026-08-07). [0.46.0] CC-1 acceptance: packages/shared to zero system literals. 0.44.0 got server-core to zero and ratcheted the guard 247 → 232, but 17 remain in shared: 16 old-sheet-coupled (dataTableApi 5e fetchers, feats.service switch(systemId), characterView isSystem8) plus 1 legitimate systemId === 'custom'. They close out with the items below, not on their own.
  • [x] [0.45.0] PlayModeView.tsx:122 isSystem8 branch — NEW, found closing out 0.44.0. The Play-Mode sheet hardcodes character.systemId === 'system8' to decide whether to render the Proficiency tile (:553, :555). Largely resolved by replacement: 0.45.0's new sheet replaces PlayModeView, and it derives its sections from the module schema with a contract test asserting no system literal appears — so the branch leaves with the file rather than being fixed in it. What remains is to verify the new sheet renders Proficiency correctly for System 8 and omits it for a module that declares no such derived stat (projectionToCharacterView yields proficiencyBonus === 0), and to confirm PlayModeView has no consumers left. Distinct from the characterView.ts:468 branch below, which is harder because it has no safe default. DONE v0.48.0. The verification half needed no work — projectionToCharacterView.test.ts already proves the bonus comes from the module's derived stats and is 0 when it declares none, and the new sheet derives its stat panels from schema.derivedStatPanels with no branch at all. The consumer half was false: CharacterSheetModal was still rendering PlayModeView. It renders the new sheet now, which also drops a createCharacterView call site and the system fetch that fed it. A guard walks apps/web/src and asserts PlayModeView has no importers.
  • [ ] ⏸ BLOCKED on 1e.3 (deferred 2026-08-07). [0.46.0] 3.6 — Shared de-leak (CC-2). system8Advancement.ts, rank.ts, and the wiki-document assumptions in derivePowerActions.ts still live in packages/shared with 15+/3 importers, all of them the out-of-scope ui-components sheet + old CharacterDetail. The module already has its own copies; the shared ones go dead only when those consumers retire. derivePowerActions.ts also has a server-core consumer (characters.ts PPA-3).
  • [ ] ⏸ COLLAPSES INTO 1e.3 — see below. [0.46.0] characterView.ts:468 isSystem8 branch (deferred out of 5.8). Scheduled for 0.46.0 rather than 0.45.0 on a count: of the six call sites that must all pass the new flag, two (CharacterDetail, CharacterPanel) are old-sheet components that die in 0.46.0 — doing this after they go means four call sites instead of six, on a change whose failure mode is silently wrong attack bonuses. Encodes a real System 8 rule — no roll carries a proficiency bonus, because proficiency is baked into the ability scores, so adding it would double-count. Removing the literal means the rule has to come from the system: a declarative flag on SystemModule (e.g. combatRules.attacksUseProficiencyBonus) threaded into createCharacterView. The catch is that no safe default exists — whatever createCharacterView assumes when the flag is absent is wrong for one side — so all 6 call sites must pass it, and only 1 of the 6 (useModuleProjection) currently loads the SystemModule at all. The other five (CharacterContext, CharacterDetail, CharacterPanel, CharacterSheetModal, ModuleProjectionSheet) would each need new async module loading, and missing any one silently produces wrong attack bonuses on a surface that is hard to verify interactively. Do this with the character-sheet work, when those consumers are being touched anyway. Re-measured 2026-08-07: 2 call sites, not 6, and both are old-sheet components. CharacterContext and ModuleProjectionSheet dropped theirs in 1e.2, CharacterSheetModal dropped its in the PlayModeView close-out above, and useModuleProjection never called it — it is the module path. What remains is CharacterDetail and CharacterPanel, both of which 1e.3 deletes. There is therefore no flag to thread and no six-site coordination problem: the function loses its last consumer when the old sheet goes. Whether createCharacterView itself is then deleted or kept as dead API is 1e.3's call.
  • [x] [independent] Older /parse-character-sheet route (ai.ts:435, used by CharacterSheetImport) keeps its hardcoded D&D prompt — migrating it to the 3.4 provider needs a systemId threaded through that import UI. Not guard-caught (no literal). Not gated on the sheet work — it can land in any release. DONE v0.48.0. buildTokenParsePrompt sits beside buildCharacterParsePrompt in shared — a sibling, not a replacement: the two routes read the same input and produce different things (a TOKEN's flat metadata + executable actions vs a full Character), so only the leaking part — the module-supplied attribute list — is shared. race is gone in favour of the platform's own origin. Both client callers send the CAMPAIGN's system, omitting the key rather than sending undefined so the route's zod custom default applies off-map.

Content API — flip the consumers (3.1 / 3.2, deferred halves)

The registry, generic access layer, and both providers shipped in 0.43.0; nothing consumes them yet.

[0.46.0] All three, per the last bullet: these are the edit surfaces (add-feat, add-power, add-equipment), so they move with Edit Mode and the old sheet's retirement, not with Play Mode.

  • [ ] Rewire AddFeatModal / AddPowerModal / AddEquipmentModal / EquipmentList off the per-system fetchers and onto listSystemTraits / getSystemTraitChoices.
  • [ ] Delete the switch (systemId) dispatch in packages/shared/src/services/feats.service.ts and the per-system fetchers in services/dataTableApi.ts.
  • [ ] These feed the old character sheet, so they close out with its retirement and need interactive verification. Re-assessed 2026-08-07, after 1e.2. The gating is now uneven and worth splitting: Bullet 1 is HALF unblocked. AddFeatModal / AddPowerModal / AddEquipmentModal are no longer old-sheet components — the NEW sheet's Build mode uses all three (N9), so rewiring them benefits the live sheet immediately. EquipmentList is still old-sheet and moves with 1e.3. Bullet 2 stays fully blocked: the per-system fetchers cannot be deleted while EquipmentList and CharacterDetail still call them. Not attempted in v0.48.0, deliberately. AddFeatModal branches on isDnd5e through its fetch, its record type (Dnd5eFeatRecord vs the System 8 shape), its columns and its detail pane — so "rewire it onto listSystemTraits" is a rewrite of a 700-line component whose correctness is per-system table rendering. The item itself says it "needs interactive verification", which has not been possible this release. Doing it blind would be the one change here most likely to break silently.

System 8 — generation extraction (folded in from 2.6 / M-5) — DEFERRED, whole section

Deferred with all monster generation (see M-3 above). Measured coupling: every consumer of these modules is monster-generation UI — MonsterGenerator.tsx (1,929 L), tokenFromGeneratedCharacter.ts, and the four System8*Editor components, which physically live in components/UI/MonsterGenerator/. The character sheet imports none of them (grepped ui-components/src + CharacterDetail for all five generation utils: zero hits). So this section blocks nothing in the sheet work, and doing it now would be redone when System 8 monster generation is re-implemented.

This also removes the React-from-modules blocker from 0.44.0: M-5's components are monster-generator UI, not sheet UI, so nothing in this release needs a module to ship React. The longer-term answer is that modules should probably never ship React — the module supplies schema + data (CS-1 sheetSchema, CS-5 contentProvider/getTraitChoices, both already shipped) and the platform renders generic components. A "generic trait chooser fed System 8 feats" needs no new mechanism.

  • [ ] Port the ~1600-line generation subsystem into the module. apps/web/src/utils/system8{CharacterGenerator, AbilityScores,PowerAutoFill,FeatAutoFill,ClassAdvancement,MonsterGenerator,Roles,DerivedStats}.ts (~3.4k lines with tests) plus the parts of services/templates.service.ts they need. The module's generateCharacter is a deliberate partial stub (no power/feat auto-fill), so a blind rewire regresses.
  • [ ] Rewire MonsterGenerator.tsx's three modes onto the module functions, dropping the file-path imports and the isSystem8Campaign gate.
  • [ ] M-5: re-home the four System8*Editor components (System8AbilityEditor, System8ClassLevelEditor, System8ManualFeatsEditor, System8ManualPowersEditor) — all four live in apps/web/src/components/UI/MonsterGenerator/, so they move with the generator, not the sheet.

Monster generation

Deferred as a block in 0.44.0: all monster generation waits until the character sheets work, and System 8 monster generation is going to be re-implemented anyway. Monster insertion is done and stays — it reads the dnd5e module's bundled SRD as of 0.44.0.

  • [ ] DEFERRED to a later version — M-3 monster rewire + delete tokenFromMonster.ts (5.5, deferred half). Rewire MonsterGenerator.tsx, encounterGenerator, and GenerateEncounter onto Character → projectToken. Open design question first: bespoke monster attack code (spell saves, damage types) does not survive projectToken's generic attack generation — decide whether it layers back on as custom actions (à la System 8's tokenFromGeneratedCharacter). Needs a running app; WebGL preview freezes blocked this in 0.43.0. Deferred deliberately: all monster generation waits until the character sheets are working, and System 8 monster generation is going to be re-implemented anyway. InsertMonster was split out of this item and is done — monster insertion stays in the VTT and now reads the module's bundled SRD (see 5.6 above), so only generation remains here.

System UI for 5e

When using System 8, opening the System sidebar reveals the System 8 wiki in an iframe. 5e does not have an equivalent. Since we have the complete SRD text in markdown format, we can provide this same functionality by converting the markdown text into HTML. We'll need a stylesheet to make it look like it belongs in this app, as well as a menu system to navigate the content.

Decomposed 2026-08-07. Three findings changed the shape of this.

1. The markdown is already bundled and already served. The dnd5e module ships data/{2014,2024}/*.json built from 2,564 SRD markdown files, and every record carries its full source markdown at metadata.body — served today by GET /api/modules/dnd5e/:group[/:slug]. 1,486 entities for 2024 (5.2.1) and 1,078 for 2014, across 9 groups. So there is no extraction, no conversion pipeline and no new endpoint in this feature; it is a reader UI over content the app already has.

2. The rendering pipeline exists too. renderActionDescription (marked with gfm: true, then DOMPurify) is the app's one markdown path, already used by Notes and by action descriptions. GFM tables are on, which matters — the SRD is table-heavy.

3. The real work is a seam, not a 5e feature. SystemContext hardcodes a SYSTEM_URLS map (system8 → the wiki, dnd5e* → D&D Beyond) — 5 of the CC-1 guard's remaining literals live in that file. Building "5e reference" as a second hardcoded branch would double the leak. The right shape is a module-declared reference surface with two implementations: System 8 declares a URL and gets the iframe it has now; 5e declares that it serves content and gets the reader below. Any future system picks one without touching the platform.

  • [ ] R1 — Declare the reference surface in the Module API. A systemReference block on the module's client export: either { kind: 'external', url } (System 8) or { kind: 'content', groups: [{ id, label }] } (5e). Needs a vtt-modules change in BOTH modules and a Module API minor bump. Test: the CC-1 guard's SystemContext.tsx baseline drops from 5 toward 0 — this is the acceptance criterion, not a side effect.
  • [ ] R2 — Split SystemContext on the declaration, not on systemId. Keep the existing iframe as the external branch verbatim; render the new reader for content. A system that declares neither keeps today's "No system selected" card. Delete SYSTEM_URLS.
  • [ ] R3 — The navigation menu. Group list from the declaration, entity list per group from GET /api/modules/:id/:group. The sidebar is narrow, so this wants a two-level drill-down (groups → entities → article) with a back affordance, not a tree. Sizing note: spells is 339 entities and monsters 330, so the entity list needs the same parseListParams paging the other lists use — rendering 339 rows into the sidebar on every group click is the obvious failure.
  • [ ] R4 — The article view. metadata.body through the existing renderActionDescription pipeline. Strip the leading # Title heading (every file repeats its own name, which the panel header already shows) and the YAML frontmatter if any survives extraction. Do not add a second markdown path — one sanitiser boundary, per task N6.
  • [ ] R5 — The stylesheet. Scoped to the article container: tables, #####-deep headings (the SRD nests five levels), definition-style bold runs, and blockquote callouts, all on Mantine tokens rather than fixed colours so it works in both themes. The panel is ~360px at its default width, so tables need overflow-x: auto rather than reflow.
  • [ ] R6 — Search across the corpus. The manifest is a flat { name, slug, kind, path } list, so a client-side substring match over names is enough to start and needs no endpoint. Full-text is the Global Search project's job, not this one — say so rather than half-building it.
  • [ ] R7 — Cross-links. SRD bodies reference other entries by name (See also "Playing the Game"). Resolving those to in-app navigation is the difference between a document dump and a reference tool, but it needs a name→slug index and a link-rewriting pass over the rendered HTML — the same job rewriteWikiLinks does for System 8. Do this last; R1–R5 are useful without it.

Deliberately NOT in scope: an offline/PWA cache, printing, bookmarks, or editing. The 2014 vs 2024 edition split is already handled by the module's router, so the reader inherits it for free.

Equipment (Character Sheet)

  • [ ] Equipment is not behind the Character Sheet API. Equipment is genuinely system-specific (5e cares about attunement, weapon properties and encumbrance; System 8 does not), but the System-Platform Separation project never modelled it behind CS-1/CS-2 — neither the system8 nor the dnd5e module declares an equipment trait channel or projects equipment traits. So the projected Play-Mode sheet shows no equipment, and equipment remains rendered only by the classic sheet + its per-system fetchers. Add an equipment channel to the sheet schema and project it from both modules; this is a near-term follow-up, not part of 0.44.0. Not attempted in v0.48.0 — there is a design problem in the way, and it should be answered before any code. Character has NO equipment field: equipment is its own table with its own REST resource, keyed by characterId. A SystemModule's getTraits(character) is pure and synchronous and receives only the character record, so it cannot see the character's equipment at all — projecting equipment traits would need either an async accessor (which breaks CS-2's purity, and with it the server-side projectToken that Module API 1.7 exists to guarantee) or equipment passed in as a second argument (a signature change across every module). Meanwhile the sheet is NOT missing equipment in practice: <InventorySection> reads the REST table directly, and v0.48.0 gave it full management (add / rename / re-count / delete / reorder / nest / image upload). So the gap is architectural consistency, not a broken surface — which is an argument for deciding the seam deliberately rather than backing into it. The platform half already exists: deriveSheetSections handles a channel: 'equipment' trait channel and renders it as the Inventory section. What is missing is the data path.
  • [x] It should be possible to drag equipment in and out of other equipment. Dragging an item onto another item makes the dropped item a sub-item of the target item. Dragging a sub-item to the root makes it it's own item, not a sub-item. DONE v0.48.0 in the new sheet's Inventory (Build mode only). Rules are a pure reparentEquipment, kept separate from the reorder helper so a drop onto a container does not also reshuffle its contents. The refusal that matters is a drop into the item's own descendant — dropping a backpack into the rope it holds detaches both from the tree, and the list silently stops rendering them. The root needs its own drop zone, since every row means "into this item"; it appears only mid-drag and only when it would change something.

Multi-provider image-gen keys

The v0.35.0 GenAI "AI Generated" picker fix (plus the v0.42.1 generation fix) only unlocks ONE user-level image provider per request — the x-ai-provider / x-ai-key headers are singular. A user with user-level keys for two or more image providers (e.g. both Gemini and OpenAI entered in Settings, rather than one coming from a server env var) still only sees the first by priority (openai > grok > gemini). System-level (env) providers are unaffected — they always enumerate. This is platform-side (server-core enumerates each provider against a singular header) and is not 100% genai, so it lives here. To show every user-level provider at once:

  • [x] Server: extend GET /api/ai/image-models (packages/server-core/src/routes/ai.ts) to accept multiple user keys — e.g. a JSON x-ai-keys header (or request body) of { [provider]: key } — and resolve each provider against it. Keep the singular x-ai-key / x-ai-provider + env-var fallback in resolveImageProviderKey for back-compat. Add tests for multi-key enumeration.
  • [x] Client: have useImageGenAuthFetch send ALL of the user's image-capable user-level keys (extend/replace pickImageGenProviderHeaders in apps/web/src/utils/aiHeaders.ts) using the new mechanism, and drop the "first by priority" limitation once the server supports it. Update the unit + source-contract tests.
  • [x] Confirm the genai module needs no change — it already calls /api/ai/image-models through the platform-provided authFetch. (If a per-provider enumeration via /api/ai/image-models/:provider is preferred instead, that WOULD need a genai release + a modules.hosted.json bump.) DONE v0.48.0, and confirmed: no genai release, no modules.hosted.json bump. x-ai-keys carries a JSON { provider: key } object; useImageGenAuthFetch iterates whatever getImageGenAIHeaders() returns rather than naming headers, so the new one reached the module's requests for free — a per-header allowlist there would have cost a release, which is exactly what made this cheaper than the /image-models/:provider alternative. The singular x-ai-key pair is still sent, naming the first provider, purely so a server older than this change does not see no user key and empty the picker.

Restore Left Nav

  • [x] Restore the left nav as follows:
    • Use the pattern shown in this example to create a left nav
    • Left nav is the width given in that example
    • Single level of left nav (except as described below)
    • The Purple Cat logo in the far left of the global top nav should match width with the left nav
    • Icons should be somewhere in the git history from the previous left nav
    • Show tooltips on hover with the full menu item label
    • Nav items:
      • Play (goes to /play with persisted campaign id and map id from last visit to /play routes)
      • Campaigns (goes to /campaigns)
      • Characters (goes to /characters)
      • Library (goes to /library)
      • Systems (goes to /systems)
      • Admin (goes to /admin)
    • When anywhere in the /characters route, show 2nd-level nav with:
      • Characters (goes to /characters)
      • Parties (goes to /parties)
      • Factions (goes to /factions) DONE v0.48.0. Icon rail 60px + second column 240px, the example's widths. The icons are the retired SideNav's own six. The nav sits BELOW the top bar so the logo can own the corner directly above the rail — that is the only arrangement in which "match width with the left nav" reads as a column. The second column occupies layout space rather than overlaying: the first cut let it overlay to avoid the page shifting, and browser verification showed that only looked right because the pages it covered had wide left margins. Three paths differ from the spec, which names routes that do not exist. Parties and Factions are at /characters/parties and /characters/factions, already parsed and already bookmarkable, and they are character sub-pages by the spec's own framing ("anywhere in the /characters route"). /admin had no route at all — it now exists, redirecting to /admin/users.
  • [x] Restore the /characters route. This is a standard list page showing all characters the user has access to, identical to the way characters are shown on the campaign landing page (BEFORE the changes to that page described below). It should have the same Create Character CTA as the campaign landing page (including changes to that page as described below).

Campaign Landing Page

Sections 1–4 DONE v0.48.0. Two latent bugs surfaced on the way: useContextParams never returned showAllMaps / showCampaignMaps / showCampaignCharacters though parsePathToContext has produced all three since v0.42.0 — so /maps rendered the campaign landing page instead of the all-maps list, and /campaign/:id/maps and /campaign/:id/characters both rendered the landing page. Invisible until "Show all" needed somewhere to point.

  • [x] Limit the maps shown on the campaign landing page to a single row, with horizontal panning controls, max 3 rows worth of maps, with a 'show all' that goes to /campaigns/:id/maps. Retain the standard List Page controls for configuring sort order, but remove Grouping from this UI.
  • [x] Limit the characters shown on the campaign landing page to a single row, with horizontal panning controls, max 3 rows worth of characters, with a 'show all' that goes to /campaigns/:id/characters. Retain the standard List Page controls for configuring sort order, but remove Grouping from this UI.
  • [x] In the Characters section, add a dropdown to the Create Character button (same presentation as the one on Create Map on the same page) with options:
    • Import Character (goes to /campaigns/:id/characters/import)
    • Show Deleted Characters (goes to /campaigns/:id/characters/undelete)
  • [x] Implement /campaigns/:id/characters/import (and /characters/import while we're at it)
    • Options:
      • upload a character sheet YAML or CAT file (drag/drop or file picker)
      • paste YAML directly
      • Import via PDF (exists in the codebase, just needs to be adapated to this page)
    • When YAML/CAT is provided and validated, insert the character
      • If on /campaigns/:id/characters/import, insert the character into the current campaign
      • If on /characters/import, insert the character without associating it with a specific campaign
    • After insert, go to the character detail page for that character DONE v0.48.0. The .cat half needed a split: apps/web has no zip library, server-core has adm-zip but no YAML parser, and parseCharacterYaml — which knows what a character YAML means — is client-side. So POST /api/characters/import-cat unwraps the archive and returns the raw YAML for the same client-side parse a dropped .yml gets, rather than defining the character shape a second time server-side. It reports how many characters the archive held, since a campaign export holds many. The campaign comes from the ROUTE with no picker to contradict it. Route note: the existing paths are /campaign/:id/... (singular), not /campaigns/:id/....

List Page Improvements

  • Add a search feature for all list pages.
    • The search input is in the same row as the CTAs for that list page (e.g. Create Map on /maps).
    • The input is centered and takes up about half the width of the row (collapsing as needed on smaller screens).
    • The input is focused on page load.
    • Typing at least three characters into the input filters the list in real time, showing only items that match the search term. The search should be case-insensitive and match any part of the item name. (Debounce the search input so that it only triggers after the user stops typing for 300ms, but it does show a loading state while typing and while loading)
    • The search space is the whole set of items the user has access to, not just the items currently loaded on the page, so this is powered by an API query (with in-memory caching to avoid re-querying the same search term).
    • Remove the Filter button from list pages as this duplicates the search functionality.
    • [x] Added to /campaigns
    • [x] Added to /campaigns/:id/maps
    • [x] Added to /characters
    • [x] Added to /campaigns/:id/characters
    • [x] Added to /characters/parties
    • [x] Added to /characters/factions
  • All list pages should have a secondary CTA (accessed via dropdown from primary CTA) to show deleted items. This should go to /listpageroot/undelete which uses the existing /undelete UI to show deleted items of that type. DONE v0.48.0 — <Undelete only=…> plus routes at /campaigns/undelete, /characters/undelete, /campaign/:id/characters/undelete and /campaign/:id/maps/undelete, each reached from its page's primary-CTA menu.

Search DONE v0.48.0 on all six pages, and one premise did not survive contact. The spec asks for an API-backed search so that "the search space is the whole set of items the user has access to" — but GET /api/characters, /api/campaigns, /api/maps, /api/parties and the faction list are all UNPAGINATED. Each already returns every row the caller may see, and the pages hold it in a React Query cache, so the whole search space is in memory and a ?search= round trip would be strictly slower while matching not one extra row. The requirement is honoured; the mechanism is not. Everything that makes the mechanism swappable is kept — the 300ms debounce, the 3-character minimum, the explicit loading state and the per-term LRU cache all live in useListSearch, with the source behind one function. ⚠︎ If those endpoints ever paginate (the v0.40.0 API Cleanup §6 work would do it), a client-side search silently narrows to the loaded page — a search that quietly stops finding things, with no error. Guarded by a test. Two layout findings from browser verification: an inline flex: 1 on a TextInput root does not survive Mantine Group's flex: 0 0 auto on its children (the input collapsed to 48px), and a zero-basis slot got 31px of a 627px row — so the search slot carries a real flex-basis and the page TITLE is what shrinks.

Bugs

  • [x] Transcript randomly stops recording with no error message DONE v0.48.0 — found a provable dead end in the restart chain. The transcript keeps a continuous session alive by restarting whenever Chrome ends one, which it does constantly. The whole feature rests on onend → scheduleRestart → start() → onstart → … → onend. recognition.start() throws InvalidStateError when the previous session has not finished tearing down — a real race, since onend fires before the object is ready — and that throw was caught and silently ignored as "already started". It is the opposite: nothing started, so onstart never fired, so onend never fired, so nothing ever scheduled another restart. Dead service, isListening false, no error reported. Exactly the symptom. A failed start now schedules another attempt, which needs two things to not become a hot loop: exponential backoff (reset by a successful onstart, and by a fresh user-initiated start so toggling the mic off and on is not punished), and fatal-error detection — onend fires after every error and schedules a restart, so a denied microphone permission previously spun a 500ms retry forever, re-prompting, with only a flickering mic icon to show for it. An UNKNOWN error code is treated as recoverable: browsers add codes, and defaulting to fatal would silently kill the transcript on one we had not heard of. Not verified against the original report — there was no repro attached, and this is a race that needs a long live session to observe. What is certain is that the code path described above terminated the service with no error, which is the reported behaviour.
  • [ ] ⏸ DEFERRED past v0.48.0 (2026-08-08) — needs a schema migration plus a data rewrite of the condition ids tokens already reference, which is not a release-close change. The collision is contained (the 5.3 fixture lift sorts by system id, so the winner is deterministic and matches prior behaviour), so nothing regresses by waiting; dnd5e-2024 simply continues to seed none. System-default conditions collide across systems — dnd5e-2024 has NEVER had any. conditions.id is a GLOBAL primary key, but every system seeds its conditions under bare ids (frightened, prone, …) — unlike prop types / property definitions, which the seeder namespaces as ${systemId}_${id}. Seeding is insert-or-ignore, so the first system to seed claims each shared id and the rest are silently dropped. Measured on a fresh DB: dnd5e-2014 gets 16, system8 15 (it loses 6 to 5e), and dnd5e-2024 gets 0 — all 16 of its ids collide with 2014's. Confirmed against the dev database, so this is long-standing, not new. Surfaced by the 5.3 fixture lift, which changed seeding order and flipped the winner; that lift now sorts fixtures by system id to keep the outcome deterministic and preserve the prior behaviour, so the collision is contained but not fixed. The fix is to key system-default conditions by (id, systemId) rather than id. It needs a schema migration plus a rewrite of the condition ids tokens already reference (IToken.conditions is an array of condition ids — the dev DB already contains a mix of bare frightened-style ids and namespaced system8_weakened-style ones), so it is its own task, not a drive-by. Still its own task; not attempted in v0.48.0. A schema migration plus a rewrite of live condition ids on production tokens is a release's worth of risk on its own, and the collision is contained (the 5.3 lift sorts fixtures deterministically), so nothing gets worse while it waits.
  • [x] Global search (cmd-P) works fine on /play routes, but on others it seems to not have a data source for maps, characters, and campaigns. This feature should work the same everywhere as it is a global feature. DONE v0.48.0 — not a data-source failure. Maps and characters were indexed only when ctx.campaignId was set, and their fetches were skipped outright otherwise, so off a campaign route the palette could offer nothing but campaigns and five nav entries. A deliberate scope that was the wrong call for a feature whose whole promise is finding anything from anywhere. Both fetches are unscoped now — the same two the /maps and /characters list pages already make, React-Query-cached, so on any route that has visited either list this costs nothing. A map result now carries its OWN campaign id. Reusing the one in context was safe while the list was campaign-scoped and would now build /play/<the campaign you are viewing>/<a map from a different one> — a URL that resolves to nothing.
  • [x] When I impersonate a token, then hard reload, I resume impersonating some previous token. The feature to persist my impersonated token id seems to not be updating. DONE v0.48.0 — it was updating; it was writing null. v0.33.0 stopped impersonation leaking across maps by recording an id only when token.mapId === mapId. That predicate treats "belongs to another map" and "carries no mapId at all" as the same thing, and the second is not a cross-map leak — it is a token the UI is quite happily impersonating. UI.tsx's guard for the very same concern says so out loud: imp.mapId && imp.mapId !== mapId keeps a token with no mapId. So the two disagreed, and the save wrote null under a token the user could see themselves impersonating. On the next load there was nothing to restore, and UI.tsx's auto-impersonate then picked userTokens[0] — "some previous token", precisely as reported. The predicate is now one tested function (impersonatedIdForMap) shared by both save paths, matching the UI: reject only a token that explicitly belongs to a DIFFERENT map. The v0.33.0 guarantee is untouched and is now asserted on behaviour rather than on the shape of an inline ternary. Two further holes closed on the way: restoring no longer overwrites a live impersonation (both "have I restored yet" refs are per-MOUNT, so any remount of the hook re-ran the restore and dropped the user back onto the saved token), and a numeric id — IMapObject.id is string | number, and an object can be impersonated — is normalised to a string, where before it was stored as a number and then never matched on restore. Those last two also removed two long-standing TS errors, taking the apps/web baseline 206 → 204.

Character Sheet

  • [x] In the full character sheet (/campaigns/:id/characters/:id), the character sheet should be capped at 40rem width, centered in the available space DONE v0.48.0. The cap goes on the page's frame, not on the sheet, so the sheet keeps owning its own scrolling and its sticky gradient still spans the column. width: 100% sits alongside maxWidth so a narrow viewport fills rather than being pinned to 40rem it does not have, and marginInline: auto centres it — the box is already inside AppShell's left-nav offset, so "available space" is exactly what it measures. Pairs with the sidebar removal below, which had just made the column wider still.
  • [x] In the full character sheet (/campaigns/:id/characters/:id), hide the whole side panel with New Character, Import from YAML, etc DONE v0.48.0 — removed, not hidden, because it was a duplicate panel rather than a capability. Checked each thing it offered before pulling it: switching character and New Character are in the global top nav's Character section, which is on screen on this very route and lists recents plus "More characters…"; Import from YAML is on the characters LIST page, which has had its own since before this page existed; Export YAML / Share / Delete for THIS character are already in the page's own … menu; Home is the top nav's logo. Nothing lost. The mobile Drawer and its fixed hamburger went with it, along with the page's useCharacters() list read and the handleNewCharacter that only the sidebar called — so the route no longer fetches the whole character list to render one character.

Misc

  • [x] Second-level nav on the /admin routes, like /characters, to reach the other admin pages. DONE v0.48.0, and it is a data change plus one gate. leftNavModel was built so "adding a second level to another section is a data change here and nothing else", and that held: Admin gained five sub-items (Users / System Health / Hosted Modules / Systems / Data), restoring sub-tabs the old SideNav had before the rail replaced it. It matters more than a convenience — the top bar's admin menu is hidden this release, so the rail is now the ONLY control pointing at /admin/*. The gate is the one bug found on the way: the second column was chosen from the ROUTE (leftNavSubItems(pathname)) rather than from what the rail is showing, so a non-admin who typed /admin/users got a menu of the other four admin pages under a blank heading — the item itself having been filtered out of items. Every admin route is gated server-side, so this listed things they could not use rather than letting them in, but it is gone either way. /admin/systems vs /admin/system-health is pinned by a test: they share a prefix and the shorter one is the more specific-looking, so longest-first ranking alone would not separate them — segment-boundary matching is what does.
  • [x] Add animated grey loading squares to image cards that are loading (at least until the image starts drawing). DONE v0.48.0. <CardImage> beside <CardSkeletonGrid> — two different waits: the grid skeleton covers the one before any card exists, this one the wait after, when the record has loaded and its artwork has not. The skeleton renders BEHIND the image rather than instead of it, because an <img> that has never mounted has never started loading. Adopted at all five card thumbnails.
  • [ ] Fully implement Mantine input states (UI Overhaul in version +1). Left for version +1 by its own text — not attempted in v0.48.0.
  • [x] Global search (cmd-P) is overloading the Command Palette on the map. Change the Command Palette hotkey to cmd-shift-P. Already true as of v0.48.0 — verified, no change needed. The map's Command Palette binds mod+shift+P (MapSecondLevelNav), and global search binds Cmd/Ctrl+P with an explicit !e.shiftKey. A repo-wide grep finds exactly one cmd+P handler. Pinned by a test so the two cannot drift back onto the same chord.
  • [x] The coordinates of my camera on a given /play route should persist across hard reloads. DONE v0.48.0. Keyed by MAP, not by route: two maps in a campaign are two different views, and restoring one map's coordinates onto another aims at numbers that mean nothing there — so the same change also makes an in-session map switch restore each map's own last view. The restore writes to uiStore.cameraPosition rather than to the Three.js camera, which is what makes it work on both the mount path and a mid-session switch: WebGLMap already has a store→camera sync effect, and this rides it. Saving is a trailing 500ms write — every pan and zoom gesture pushes a new position, and one localStorage write per gesture is more traffic than "where was I looking" is worth. A non-finite or zero-zoom entry is discarded on both read and write; restoring one would park the camera somewhere the user cannot pan back from.
  • [x] When the user clicks Settings in the global top nav, the Settings UI should load in a sidebar, rather than navigating to /settings. DONE v0.48.0, with one qualification the layout forces: there is no sidebar to load into off /play. AppShell gates both context panels on onMapRoute, so everywhere else the button still navigates — the same split Manage Systems and the five admin pages already use, and Settings simply joins them as a sidebarPage key. Rendered raw like its neighbours: the LARGE sidebar is 640px and SettingsPage already caps itself at 600.
  • [x] In the global top nav, add labels to each section (e.g. Campaign: campaign name, Map: map name, Character: character name). The label should be a subtler color and smaller font than the name. DONE v0.48.0. One SectionLabel — size="xs" c="dimmed" against the name's default — used by the Campaign, Map and Character sections. Two sections do NOT get one, for the same reason in both cases: it would be saying the word twice. Library and System already render a literal "Library" / "System" button immediately left of the name they qualify. And the icon-only "…" fallbacks (no campaign / map / character in scope) have no name to label; the campaign section likewise stays unlabelled while its button is showing the placeholder "Campaign" rather than a real one, since "Campaign: Campaign" helps nobody. Built from spans, not divs — this renders inside a <button>, whose content model is phrasing content.
  • [x] When a 3d die roll is in progress, there is an invisible barrier across the entire UI preventing interaction with sidebars, global nav, etc. Ideally this shouldn't exist at all, but if it must, it should be below all the 2d UI components of the /play route UI. DONE v0.48.0 — it doesn't exist at all now, so the re-stacking fallback isn't needed. The barrier was never ours. The overlay's container already sets pointerEvents: 'none' and pointer-events inherits — but @react-three/fiber computes pointerEvents = eventSource ? 'none' : 'auto' for the div it wraps the canvas in, and with no eventSource prop that is a hard-coded 'auto' on a fullscreen element at CONTEXT_MENU. R3F spreads the caller's style after its own value, so setting it on the <Canvas> is what actually wins. Switching it off outright is safe rather than merely re-stacking it: nothing in the overlay has a pointer handler — the dice are decoration. A test pins the R3F behaviour too, so a dependency bump that changes it doesn't quietly leave a stale explanation behind.
  • [x] On the /play route, when no modal is open, and not focused on an input, handle the Enter key by focusing the chat text input. DONE v0.48.0 as a second way into the branch T already had — open the chat context if it is closed, then dispatch focus-chat-input. /play-only comes free: the hook is only mounted by the map. The "no modal open" clause turned out to be doing real work rather than restating the input guard. isEditableTarget suppresses events that ORIGINATE inside an overlay, but a modal can be open with the target still on <body> — focus trap not yet landed, or the user clicked the backdrop — and Enter is the confirm key of every dialog in the app, so a confirmation's "yes, delete it" would have opened chat instead. New isModalOpen() closes that, and it is checked for Enter only; T is a letter and needs none of it. Menus, tooltips and popovers are deliberately not treated as blocking.
  • [x] Replace the dice icon to the left of the chat text input with this SVG DONE v0.48.0, then AMENDED on sight of it: match the d20 inside the panel this button reveals. The first cut used docs/committed/d20-blank.svg, copied to apps/web/public/dice/. Seen next to the dice row it opens, a blue die that opened a row of green ones was the wrong call — so the button now renders components/UI/svg/d20.svg, the row's OWN asset, imported rather than copied: a copy is exactly what would let the button drift from the row again. It also wears .dice-button, so its resting brightness and hover lift match the dice below. Size is unchanged at 22px, and the public copy was deleted rather than left as an orphan. One knock-on survives from the first cut: the artwork carries its own colour instead of taking currentColor the way the tabler die did, which killed the filled expanded state (a die on a solid disc of its own colour is unreadable). The two states differ by light-vs-subtle backing instead, which still reads as on/off with the die legible either way.
  • [x] when there are a non-zero number of characters in the chat text input, show a blue send icon button (icon: the enter key or return key symbol) on the inside right edge of that input. Clicking it or pressing Enter should send the message. DONE v0.48.0. One deliberate departure from the letter of it: the button appears when there is something to SEND, not when there are characters — whitespace-only input reads as non-zero characters but Enter has always refused it, and a button that appears and then does nothing on click is worse than one that stays away. Clicking and Enter are now literally the same call (sendCurrentMessage) rather than two copies that can drift, which also guarantees the visibility test and the click handler agree on what "something to send" means. rightSection needed rightSectionPointerEvents="all" — Mantine parks that slot at pointer-events: none because it is built for adornments, so the button would have been inert without it. "Blue" means the design system's blue, which in the retro skin is not blue. color="blue" is what the dice toggle beside it already uses, and mantineTheme.ts deliberately remaps blue → walnut (design.md §14.4, guarded by accentColors.test.ts) — so live-verified it renders brown in retro and stock blue in modern. Hardcoding a literal blue would have looked right in one skin and fought the palette in the other.
  • [x] Replace the top-right Clerk profile icon with:
    • The same icon, pulled from Clerk (if possible; if not use a generic avatar icon for now) but rendered by us

    • Clicking it opens a dropdown with the following options:

      • Profile (loads Clerk profile)
      • Settings (same functionality as current settings button in the top menu)
      • Notifications (loads the notifications page)
      • Billing (stub that goes nowhere)
      • Logout (logs the user out and goes to the login page)
    • Show a badge or indicator for unread notifications on top of this icon

    • Remove the Settings and Notifications buttons from the top menu, as they are now in this dropdown

      DONE v0.48.0, all six bullets — but one of them turned out to have a page-shaped hole behind it. "Notifications (loads the notifications page)" assumed a notifications page; there wasn't one. Notifications existed only inside the bell dropdown, so removing the bell — which the last bullet asks for — would have taken the only way to accept a character invitation with it. /notifications is now a real route, and it renders the PendingCharacterInvitationsPanel already built for the characters page rather than a second copy of the accept/deny logic. That panel renders nothing when there is nothing pending, which is right inline on another page and wrong as a whole page, so the empty state lives on the page. It is also indexed by global search, like every other destination. The avatar is Clerk's own imageUrl drawn by us, with a generic icon when Clerk has none, and Clerk remains the session authority — openUserProfile() and signOut() are still its calls, just made from our menu. Log Out redirects to /, which IS the login page: App.tsx wraps everything in <Show when="signed-in"> with a RedirectToSignIn fallback. Billing is listed but disabled: a live-looking item that silently does nothing on click is the worse placeholder. Settings was not reimplemented in the menu — the existing openSettings (sidebar on /play, navigate elsewhere) is handed to it, so that behaviour travelled intact. The bell component itself survives; only its global-top-nav instance is gone, and SideNav and the mobile top nav still use it.

  • [x] In the Character Context sidebar, remove the panel padding — the brown frame around the sheet that the four-way gradient does not reach — including the top padding, so the header bar with the name and Build toggle has even spacing above and below. DONE v0.48.0. ContextPanel's content box carried p="sm", which drew a 12px .item frame around all four edges of a surface built to be full-bleed: portrait, four-way gradient, sticky header, with every section already padding itself. The top 12px was the one that showed — stacked on SheetHeader's own py={4} it put 16px above the name/Build/Linked row against 4px below, which is the off-centre look. With the frame gone that bar's spacing is its own py={4} on both sides. Scoped by context type (isCharacter, joining the isChat opt-out that was already there), because every other context is an ordinary padded panel and relies on it; a test pins the opt-out list at exactly those two so a third has to be argued for. Not changed: the UNDOCKED character window. FloatingContextWindow pads its own body and is a different structure (a ScrollArea wrapper), and the request was about the sidebar — say the word and it can match.
  • [x] On an action row, the ⋯ segment should be the same colour as the button it is fused to, with a 1px divider the colour of the background, and should match the button's height. DONE v0.48.0. Naming the same colour was never enough to match: the button is variant="light" — a 10% tint of violet.4 over the card — so ActionMenu's filled icon rendered the SOLID colour, a bright tan block fused to a dark brown button. Same token, opposite result. The primitive was lifted out of PlayModeView, whose inline copy has always been light; the divergence arrived with the extraction, which is why "matches the colour" was already asserted and a mismatch shipped anyway. The contract test now pins the VARIANT too. The other two are geometry, so they went to ActionButtonRow, which already promises to own the whole fused shape rather than splitting it with each caller — but both are about the CHILD the caller passes, which inline styles cannot reach, so the primitive gained its first CSS module. The divider is a 1px MARGIN, not a coloured border: the row appears on the sheet card, in a power's expanded list and in the impersonated-token panel, so any colour named in the CSS would be right on one surface and wrong on the next — letting the surface show through is "the same colour as the background" on all of them at once. The height needed :is(button, a) rather than > *, because Mantine sets height: var(--ai-size) from a single class and a bare universal selector ties it at (0,1,0) and loses on source order; the element name buys the (0,1,1) that wins without !important. Fixed in the primitive, so the fused rows in Attacks and Play Mode get it too — measured after: both 36px, identical backgrounds, 1px apart.
  • [x] In the Character Context, remove the character name from the header, and add 'play' as a label to the left of the play/build toggle in the same style as 'build' on the right. DONE v0.48.0. The name prop went with it rather than being left unused — CharacterSheet was the only thing supplying one. That reaches one other surface, CharacterSheetModal, whose own title is the generic "Character Sheet"; it is unreachable in practice (openCharacterSheetModal has no live callers — the only reference outside the store is a test asserting the map does NOT use it), so nothing a user can open loses a name it was relying on. "Play" is a button, not a bare label. Mantine renders the switch's "Build" text inside its <label>, so that word toggles when clicked; a matching word beside it that did nothing would be a trap. size="xs" is what the switch renders its own label at, so the two match.
  • Cosmetic/minor issues with the new character sheet:
    • [x] Instead of up/down buttons, feats and powers should have a handle to drag and drop them into a new order (visible in build mode only). DONE v0.48.0 with @dnd-kit, the same library the Initiative Tracker already sorts with. The write path is unchanged and stays load-bearing: reordering PATCHes the whole array because syncCharacterCollection diffs by slug, and a permutation holds the same slugs with the same contents — it would issue zero requests and silently not reorder. Two things did change under the surface. moveInList (a swap) gave way to reorderInList (a splice): dragging three places up means the three rows passed each shift by one, whereas swapping with the target would fling it back to where the dragged row started — the same gesture, visibly wrong. And only FILLED slots are sortable items, which is what keeps the collection dense: an empty scheduled slot is not somewhere a trait can land, because there would be nowhere for the gap it left to live. That also makes the old per-button bounds (disabled={slot.index === 0}) structural rather than enforced. KeyboardSensor is not optional politeness — without it the handle would be strictly less accessible than the two buttons it replaced. Focus the handle, then arrow keys move the row.
    • [x] Add campaign and party to the metadata section, underneath the class/level label.
      • In play mode, it simply reads: Campaigns: <campaign name> <campaign name 2>, Party: <party name>
      • In build mode, each input is an autocomplete with multiple selection allowed, and the user can add/remove campaigns and parties from the character. DONE v0.48.0 — mostly a MOVE. The Build-mode half already existed: AssociationsSection has had the multi-selects and the four link/unlink mutations since 1e.2. It was parked in the Options section, Build-mode-only, which is why campaign and party were invisible in Play mode and easy to miss in Build. It now sits under the class/level line inside the same <EditableField> its neighbours use, so it gets the read-face/edit-face swap for free and AssociationsSection itself is unchanged. The Play-mode line is new (associationsSummary), with two deliberate departures from the literal wording asked for. Names within a group are comma-separated, not space-separated: Campaigns: Symphony of Blood Rime gives no way to see where a name ends. And the two groups are separated by a middot, because a comma there would read as a third campaign named "Party: P". Labels are plural-aware, and a character in neither renders no line at all rather than an empty label — belonging to neither is the normal state of a character being built.
    • [x] The Feats and Powers hyperlinks should work as follows:
      • The content of powers.json contains a 'url' field to the full description of the power. It includes a .html extension, but this can be replaced with .json to retrieve the raw data.
      • This json file contains the 'contents' field with the raw MD of the document, and the 'bodyHtml' field with the rendered HTML of the document.
      • If we have an HTML sanitizer handy, use bodyHtml to render the content of the modal. Otherwise, use an MD transformer to render the contents field to html and render that in the modal. DONE v0.48.0. We do have a sanitiser, so bodyHtml it is — everything in the modal goes through renderActionDescription's Markdown-then-DOMPurify pipeline, which also means the contents fallback needs no separate transformer: the same pipeline renders Markdown. The interesting part is why this was missing. PlayModeView has done exactly this fetch — .html → .json, resolve against the system origin, via the system-data proxy — since play mode shipped. Nothing imports PlayModeView. The sheet that replaced it inherited the modal but not the fetch, so feats and powers opened showing whatever one-line brief the projection happened to carry. Lifted into toWikiJsonUrl + useWikiTraitDetail, where it is testable and singular. Carried across with it, because it is the kind of thing that gets re-broken: the request uses plain fetch, not authFetch. The proxy treats an authenticated call as user-scoped, and sending credentials previously broke it for some users — with a symptom ("only the brief shows") that reads as missing wiki data rather than as an auth problem. Lazy: url is null until a modal opens, so a character with thirty powers issues no requests until one is opened, and the projection's own bodyHtml skips the fetch outright.
    • [x] Make the entirety of the row of each feat/power, other than the hit area of the hyperlink, the hit area to expand/collapse the feat or power. DONE v0.48.0. There was no "rest of the row" to click: TraitListRow gave the label flex: 1 so the caret would land at the right end, which made the LINK's hit area the entire row. The stretch moved to a filler between the label and the trailing controls, and that filler is the toggle. It renders whether or not it is clickable, so a row with nothing to expand still lays out identically — this primitive's history is two "identical" layout paths drifting apart, twice, and it now has one. alignSelf: 'stretch' makes the area the row's full height rather than the text line's. A side benefit: the centring bug that textAlign: 'left' was guarding cannot recur, because a shrink-to-fit label has no width to centre its text in. The caret stays — it is the affordance for the wider hit area now, not the only way in. Only TraitSection needed the wiring; PlayModeView renders the same rows but is imported by nothing.
    • [x] When sorting powers by category, all power actions are 'uncategorized'. This is probably in part due to sloppy data in the source. In the latest version of the System8 wiki, the powers have all been standardized to render their types as either a single value or an array of values (e.g. type: Defense or type:\n - Defense\n - Utility). With this new format, ensure power actions are sorted into the correct categories based on the parent power's type. A future System 8 wiki update will apply 'type' directly to actions to to ensure actions are more accurately categorized. DONE v0.48.0 — and it was not the source data. Derived power actions have never carried subcategories of their own: createWikiAction does not set one, and the copies the server-side backfill stores on a power are exactly what the sheet reads. The field the grouping keyed on was empty for every one of them, so "Uncategorized" was not a fallback misfiring, it was the only branch that ever ran. The information was never missing, just somewhere else — on the parent power, which is what the request points at. groupPowerActionsByCategory now walks per-trait rather than over the flattened list, so it has the parent in hand, and falls back to that power's type. The action's OWN subcategories still win where present, so the future wiki update that puts type on actions overrides rather than being ignored — no revisiting needed when it lands. De-duplication by id is preserved: the first power to contribute an action is the one whose type applies. The normalising moved into normalizePowerType, because characterToToken had been doing exactly this inline since powers grew actions while the sheet's grouping did not do it at all — two copies of one rule is how they came to disagree. It reads the single-value and array forms the wiki has standardised on, and still splits the older "Defense and Utility" prose, with a word-boundary guard so "Command" does not become "Comm".