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/:idis not a like-for-like swap.CharacterDetailis 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 ownScrollArea. - [ ] 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, andModuleProjectionSheet(now a bare pass-through).createCharacterViewloses its last 2 call sites withCharacterDetailandCharacterPanel— see thecharacterView.ts:468item 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 ahiddenflag on the CS-1.5 panel declaration, which needs avtt-moduleschange 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"),deriveSheetSectionsskips hidden panels, and System 8 declares the flag invtt-modules(system8 0.6.2 — see the publish note below). The panel stays IN the schema rather than being deleted:getDerivedStatsflattens 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.jsonpins system8 0.6.2. 0.6.1 was tagged and its publish run FAILED at the build step, so it never reached the registry:tscrejectedhidden: truebecausepublish.ymlchecks 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'slink: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 localdeclare moduleblock 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
PlayModeViewbranch 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/sharedto zero system literals. 0.44.0 gotserver-coreto zero and ratcheted the guard 247 → 232, but 17 remain inshared: 16 old-sheet-coupled (dataTableApi5e fetchers,feats.serviceswitch(systemId),characterViewisSystem8) plus 1 legitimatesystemId === 'custom'. They close out with the items below, not on their own. - [x]
[0.45.0]PlayModeView.tsx:122isSystem8branch — NEW, found closing out 0.44.0. The Play-Mode sheet hardcodescharacter.systemId === 'system8'to decide whether to render the Proficiency tile (:553,:555). Largely resolved by replacement: 0.45.0's new sheet replacesPlayModeView, 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 (projectionToCharacterViewyieldsproficiencyBonus === 0), and to confirmPlayModeViewhas no consumers left. Distinct from thecharacterView.ts:468branch below, which is harder because it has no safe default. DONE v0.48.0. The verification half needed no work —projectionToCharacterView.test.tsalready 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 fromschema.derivedStatPanelswith no branch at all. The consumer half was false:CharacterSheetModalwas still renderingPlayModeView. It renders the new sheet now, which also drops acreateCharacterViewcall site and the system fetch that fed it. A guard walksapps/web/srcand assertsPlayModeViewhas 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 inderivePowerActions.tsstill live inpackages/sharedwith 15+/3 importers, all of them the out-of-scopeui-componentssheet + oldCharacterDetail. The module already has its own copies; the shared ones go dead only when those consumers retire.derivePowerActions.tsalso has a server-core consumer (characters.tsPPA-3). - [ ] ⏸ COLLAPSES INTO 1e.3 — see below.
[0.46.0]characterView.ts:468isSystem8branch (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 onSystemModule(e.g.combatRules.attacksUseProficiencyBonus) threaded intocreateCharacterView. The catch is that no safe default exists — whatevercreateCharacterViewassumes 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.CharacterContextandModuleProjectionSheetdropped theirs in 1e.2,CharacterSheetModaldropped its in the PlayModeView close-out above, anduseModuleProjectionnever called it — it is the module path. What remains isCharacterDetailandCharacterPanel, 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. WhethercreateCharacterViewitself is then deleted or kept as dead API is 1e.3's call. - [x]
[independent]Older/parse-character-sheetroute (ai.ts:435, used byCharacterSheetImport) keeps its hardcoded D&D prompt — migrating it to the 3.4 provider needs asystemIdthreaded 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.buildTokenParsePromptsits besidebuildCharacterParsePromptin 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 fullCharacter), so only the leaking part — the module-supplied attribute list — is shared.raceis gone in favour of the platform's ownorigin. Both client callers send the CAMPAIGN's system, omitting the key rather than sendingundefinedso the route's zodcustomdefault 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/EquipmentListoff the per-system fetchers and ontolistSystemTraits/getSystemTraitChoices. - [ ] Delete the
switch (systemId)dispatch inpackages/shared/src/services/feats.service.tsand the per-system fetchers inservices/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/AddEquipmentModalare no longer old-sheet components — the NEW sheet's Build mode uses all three (N9), so rewiring them benefits the live sheet immediately.EquipmentListis still old-sheet and moves with 1e.3. Bullet 2 stays fully blocked: the per-system fetchers cannot be deleted whileEquipmentListandCharacterDetailstill call them. Not attempted in v0.48.0, deliberately.AddFeatModalbranches onisDnd5ethrough its fetch, its record type (Dnd5eFeatRecordvs the System 8 shape), its columns and its detail pane — so "rewire it ontolistSystemTraits" 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 fourSystem8*Editorcomponents, which physically live incomponents/UI/MonsterGenerator/. The character sheet imports none of them (greppedui-components/src+CharacterDetailfor 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-5contentProvider/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 ofservices/templates.service.tsthey need. The module'sgenerateCharacteris 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 theisSystem8Campaigngate. - [ ] M-5: re-home the four
System8*Editorcomponents (System8AbilityEditor,System8ClassLevelEditor,System8ManualFeatsEditor,System8ManualPowersEditor) — all four live inapps/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). RewireMonsterGenerator.tsx,encounterGenerator, andGenerateEncounterontoCharacter → projectToken. Open design question first: bespoke monster attack code (spell saves, damage types) does not surviveprojectToken's generic attack generation — decide whether it layers back on as custom actions (à la System 8'stokenFromGeneratedCharacter). 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.InsertMonsterwas 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
systemReferenceblock on the module's client export: either{ kind: 'external', url }(System 8) or{ kind: 'content', groups: [{ id, label }] }(5e). Needs avtt-moduleschange in BOTH modules and a Module API minor bump. Test: the CC-1 guard'sSystemContext.tsxbaseline drops from 5 toward 0 — this is the acceptance criterion, not a side effect. - [ ] R2 — Split
SystemContexton the declaration, not onsystemId. Keep the existing iframe as theexternalbranch verbatim; render the new reader forcontent. A system that declares neither keeps today's "No system selected" card. DeleteSYSTEM_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:spellsis 339 entities andmonsters330, so the entity list needs the sameparseListParamspaging the other lists use — rendering 339 rows into the sidebar on every group click is the obvious failure. - [ ] R4 — The article view.
metadata.bodythrough the existingrenderActionDescriptionpipeline. Strip the leading# Titleheading (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 needoverflow-x: autorather 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 jobrewriteWikiLinksdoes 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.
Characterhas NOequipmentfield: equipment is its own table with its own REST resource, keyed bycharacterId. ASystemModule'sgetTraits(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-sideprojectTokenthat 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:deriveSheetSectionshandles achannel: '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 JSONx-ai-keysheader (or request body) of{ [provider]: key }— and resolve each provider against it. Keep the singularx-ai-key/x-ai-provider+ env-var fallback inresolveImageProviderKeyfor back-compat. Add tests for multi-key enumeration. - [x] Client: have
useImageGenAuthFetchsend ALL of the user's image-capable user-level keys (extend/replacepickImageGenProviderHeadersinapps/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-modelsthrough the platform-providedauthFetch. (If a per-provider enumeration via/api/ai/image-models/:provideris preferred instead, that WOULD need a genai release + amodules.hosted.jsonbump.) DONE v0.48.0, and confirmed: no genai release, nomodules.hosted.jsonbump.x-ai-keyscarries a JSON{ provider: key }object;useImageGenAuthFetchiterates whatevergetImageGenAIHeaders()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/:provideralternative. The singularx-ai-keypair 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/partiesand/characters/factions, already parsed and already bookmarkable, and they are character sub-pages by the spec's own framing ("anywhere in the /characters route")./adminhad 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
.cathalf needed a split:apps/webhas no zip library,server-corehasadm-zipbut no YAML parser, andparseCharacterYaml— which knows what a character YAML means — is client-side. SoPOST /api/characters/import-catunwraps the archive and returns the raw YAML for the same client-side parse a dropped.ymlgets, 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/....
- Options:
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/undeleteand/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()throwsInvalidStateErrorwhen the previous session has not finished tearing down — a real race, sinceonendfires before the object is ready — and that throw was caught and silently ignored as "already started". It is the opposite: nothing started, soonstartnever fired, soonendnever fired, so nothing ever scheduled another restart. Dead service,isListeningfalse, 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 successfulonstart, and by a fresh user-initiated start so toggling the mic off and on is not punished), and fatal-error detection —onendfires 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-2024simply continues to seed none. System-default conditions collide across systems —dnd5e-2024has NEVER had any.conditions.idis 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-2014gets 16,system815 (it loses 6 to 5e), anddnd5e-2024gets 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 thanid. It needs a schema migration plus a rewrite of the condition ids tokens already reference (IToken.conditionsis an array of condition ids — the dev DB already contains a mix of barefrightened-style ids and namespacedsystem8_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.campaignIdwas 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 whentoken.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 !== mapIdkeeps 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, andUI.tsx's auto-impersonate then pickeduserTokens[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.idisstring | 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 alongsidemaxWidthso a narrow viewport fills rather than being pinned to 40rem it does not have, andmarginInline: autocentres 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'suseCharacters()list read and thehandleNewCharacterthat 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.
leftNavModelwas 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 oldSideNavhad 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/usersgot a menu of the other four admin pages under a blank heading — the item itself having been filtered out ofitems. 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/systemsvs/admin/system-healthis 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 onecmd+Phandler. 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.cameraPositionrather 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.
AppShellgates both context panels ononMapRoute, 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 asidebarPagekey. Rendered raw like its neighbours: the LARGE sidebar is 640px andSettingsPagealready 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/fibercomputespointerEvents = eventSource ? 'none' : 'auto'for the div it wraps the canvas in, and with noeventSourceprop that is a hard-coded'auto'on a fullscreen element atCONTEXT_MENU. R3F spreads the caller'sstyleafter 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
Talready had — open the chat context if it is closed, then dispatchfocus-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.isEditableTargetsuppresses 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. NewisModalOpen()closes that, and it is checked for Enter only;Tis 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 toapps/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 renderscomponents/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 takingcurrentColorthe way the tabler die did, which killed thefilledexpanded 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.rightSectionneededrightSectionPointerEvents="all"— Mantine parks that slot atpointer-events: nonebecause 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, andmantineTheme.tsdeliberately remapsblue → walnut(design.md §14.4, guarded byaccentColors.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.
/notificationsis now a real route, and it renders thePendingCharacterInvitationsPanelalready 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 ownimageUrldrawn by us, with a generic icon when Clerk has none, and Clerk remains the session authority —openUserProfile()andsignOut()are still its calls, just made from our menu. Log Out redirects to/, which IS the login page:App.tsxwraps everything in<Show when="signed-in">with aRedirectToSignInfallback. 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 existingopenSettings(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 carriedp="sm", which drew a 12px.itemframe 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 onSheetHeader's ownpy={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 ownpy={4}on both sides. Scoped by context type (isCharacter, joining theisChatopt-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.FloatingContextWindowpads its own body and is a different structure (aScrollAreawrapper), 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 isvariant="light"— a 10% tint ofviolet.4over the card — soActionMenu'sfilledicon rendered the SOLID colour, a bright tan block fused to a dark brown button. Same token, opposite result. The primitive was lifted out ofPlayModeView, whose inline copy has always beenlight; 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 toActionButtonRow, 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 setsheight: 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
nameprop went with it rather than being left unused —CharacterSheetwas the only thing supplying one. That reaches one other surface,CharacterSheetModal, whose own title is the generic "Character Sheet"; it is unreachable in practice (openCharacterSheetModalhas 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 becausesyncCharacterCollectiondiffs 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 toreorderInList(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.KeyboardSensoris 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:
AssociationsSectionhas 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 andAssociationsSectionitself 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 Rimegives 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.
- In play mode, it simply reads:
- [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
bodyHtmlit is — everything in the modal goes throughrenderActionDescription's Markdown-then-DOMPurify pipeline, which also means thecontentsfallback needs no separate transformer: the same pipeline renders Markdown. The interesting part is why this was missing.PlayModeViewhas done exactly this fetch —.html→.json, resolve against the system origin, via the system-data proxy — since play mode shipped. Nothing importsPlayModeView. 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 intotoWikiJsonUrl+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 plainfetch, notauthFetch. 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:urlis null until a modal opens, so a character with thirty powers issues no requests until one is opened, and the projection's ownbodyHtmlskips 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:
TraitListRowgave the labelflex: 1so 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 thattextAlign: '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. OnlyTraitSectionneeded the wiring;PlayModeViewrenders 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: Defenseortype:\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 carriedsubcategoriesof their own:createWikiActiondoes 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.groupPowerActionsByCategorynow walks per-trait rather than over the flattened list, so it has the parent in hand, and falls back to that power'stype. The action's OWN subcategories still win where present, so the future wiki update that putstypeon 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 intonormalizePowerType, becausecharacterToTokenhad 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".
- [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