Skip to content

v0.46.0 — Character Sheet Overhaul: Edit Mode for System 8

v0.45.0 shipped the overhauled sheet read-only. This release gives it a write side: Build mode stops being a label and starts making fields editable, the interim Token Context edit mode retires, and attack authoring gets a module-owned seam.

Main article: Character Sheet Overhaul · task list: todo-v2.md · sheet spec: phase-1-character-sheet.md

Module-side work lives in the sibling vtt-modules repo. v0.46.1 is the manifest bump that carries System 8 0.6.0 and dnd5e 0.2.0 into a deployment.

Phase A — Attack authoring

Injected at the user's request after N9 found Attacks had no Add. Attacks are authored, not chosen from a catalogue, and their shape is system vocabulary: a 3e attack line reads 1d20+7, 20/×2, range 10/20, which means nothing in System 8. So the editor and the play-mode summary both belong to the module.

  • [x] A1 — the seams, as two additive Module API members. Two of the three options considered converge: CS-7 has carried a React component from a module since Module API 1.5.0 (typed any in shared to keep that package React-free, cast at the client registry), so no new API is warranted. CS-9 attackEditor is CS-7's trick for a smaller slot; CS-1.7 formatAttack is CS-1.6 formatAttribute's exact sibling. MODULE_API_VERSION → 1.8.0, additive — every 1.7.0 module keeps working.
  • [x] A2 / A3 / A5 — render module-authored attacks: summary, editor, write path. Done together because all three meet in <AttacksSection>. findAttackForAction matches a projected action back to its authored attack by name — that collection's own identity rule, and the one sameTrait relies on. Both module seams fail soft and identically: no module, no formatter, no matching attack, a formatter returning null/empty, or one that throws all collapse to the attack's bare name. "Edit attack" is a separate menu item from "Edit action", because editing the derivative would be silently undone by the next projection.
  • [x] A4 — System 8 implements both (vtt-modules 27612e0), so the platform seams have a real consumer and Attacks gains its Add button. AttacksList was deliberately not reused — its is5e branch is the leak CS-9 removes.

N9 — Build-mode affordances for the sections that have none

Found during the 0.46.0 acceptance pass: Feats and Powers rendered identically in Play and Build. Only <MetadataSection> and <AttributeGroupSection> responded to editMode. That was a hole in the plan rather than a missed task — 1b.7–1b.11 each shipped "read mode" on purpose, and R5–R11 built the mechanism but wired only two sections to it.

  • [x] Traits — slots and remove. CS-1.3's slotSchedule had been declared and contract-tested since the Character Sheet API landed, with no consumer — so 1b.9's promise needed a reader, not a new module surface. Over-capacity traits keep slots of their own. The storage seam already existed: registerNameKeyedCollectionRoutes is a platform convention over a platform type, so no Module API change and no CS-8 applyEdit.
  • [x] Traits — the remove bug the live test caught. Every trash click silently did nothing. CS-2 requires globally-unique trait slugs, so System 8's projection emits alert-feat while the stored element's slug is alert; comparing them matched nothing and the guard returned without a word. Fixed by matching on name through a shared sameTrait — these are name-keyed collections, so the name is the identity — and the no-match path now warns and notifies instead of failing invisibly.
  • [x] Traits — add, using the old sheet's AddFeatModal / AddPowerModal per the user's call: they already do catalogue search, filters, rank/level validation and slot-aware "add and continue". Two entry points — an empty slot's + fills that scheduled slot; a section-level Add creates a new unleveled slot. Over-capacity is deliberate (GM fiat, or correcting a character when the system's own slot data is wrong); Phase 2 validation flags the excess rather than preventing it.
  • [x] Traits — rearrange, as up/down controls rather than drag: keyboard- and touch-reachable, and it composes with a positional slot model without pulling @dnd-kit into a row that already carries expand, move, remove and add. Reorder cannot go through syncCharacterCollection — a permutation holds the same slugs with the same contents, so that helper issues zero requests and the change silently does not happen. Reordering PATCHes the whole array instead, which the patch guard allows because a lone array key is a legitimate whole-collection write. A modelling limit, flagged rather than faked: the collection is dense, so order is the slot assignment. A trait cannot occupy the level-4 slot while level-2 stands empty.
  • [x] Inventory — Build-mode add, reusing AddEquipmentModal. Equipment is not a character collection: it has its own table and its own REST resource, so this does not go through syncCharacterCollection. The add lives in <InventorySection> rather than <CharacterSheet>, because the section already holds the characterId the equipment API is keyed by. CC-1's guard caught the obvious wiring — the picker branches on systemId, which is banned from CharacterSheet.tsx, so it comes from the campaign instead, which is also the truer source. Not covered: removing or reordering equipment.
  • [x] Lift the Add pickers above the sidebar that opens them. All three opened behind the Character Context: Mantine's default modal zIndex is 200 and the platform's SIDEBAR layer is 600. The three modals gained a zIndex prop (ui-components cannot import the platform's Z_INDEX) and both call sites pass Z_INDEX.MODAL. AddEquipmentModal renders two <Modal>s, so a test counts them against forwarded props to stop the pair drifting. Exactly the case CLAUDE.md's standing z-index rule names.
  • [x] Notes / Options — plain field edits. Build mode subsumed the old sheet's raw/preview toggle rather than porting it: Play is the preview, Build is the source. The trap in both was the empty state — each short-circuits to "No notes." / "No options set.", which is right for reading and exactly wrong for building, so both editors sit above the empty guard and a test pins the ordering. Notes commit on blur; the colour commits on change-END, since dragging through the picker's gradient would otherwise be a request per pixel.
  • [x] The action ⋯ menu, back on every action row. Editing and deleting an action had been unreachable from the overhauled sheet in both contexts — the Character Context lost it at N4, the Impersonated Token Context at 1e.1 — but nothing was ever broken except the control. <ActionButtonRow>'s documented trailing slot now carries a shared <ActionMenu>, and a test asserts none of the three action-rendering sections declares its own <Menu>. One rule from the old inline menu is kept and now has a test: a source: 'campaign' action gets Info but no Edit or Delete, because deleting it from one sheet would take it from every other character using it.

Not built, deliberately: an Add for Attacks was left out of N9 because attacks reach the section through the projection, not from a list the sheet owns — Phase A is what answered it.

Server-side power derivation

  • [x] Derive power actions on the server, for every writer. Powers only got their real actions when a client happened to PATCH a character with updates.powers — the one trigger, in useCharacters. Every other writer produced powers with none: the sub-entity collection routes (which N9's Add uses), imports, generators. The failure is invisible, which is why it survived — CS-6 emits a generic action per power and the sheet falls back to it, so an underived power expands to a convincing placeholder rather than to nothing. Characters looked right and rolled wrong. What kept this client-side was ref resolution, not the derivation: the route's own comment said the server "has no access to the cached system-data proxy result". That comment was stale — systemDataProxy gained a 24h server cache of powers.json, so loadPowerRefs can resolve them server-side. Derivation now fires from the powers collection routes themselves, after both add and update, so it covers every writer including ones not written yet. It runs after the response is sent (deriving is one HTTP request per action document against an external wiki), it never fails the write that followed it, and a wiki outage returning zero actions leaves existing data alone rather than clearing it. The derive loop moved into derivePowersForCharacter so the route and the endpoint share one implementation — two copies would drift on the stat maths, and the same power would then roll differently depending on which path derived it.
  • [x] Let a backfill be { force: true } and nothing else. systemBaseUrl and the full powers[] array are now optional, since the server can resolve refs itself. Supplying them still works and still takes precedence, for a caller deriving a specific subset or pointing at a different wiki.

Phase 1e — Sunset (partial)

  • [x] 1e.1 — Retire the interim Token Context edit mode. v0.36.0's pencil toggle in TokenSheet goes; AdvancedTokenSettings and the token PermissionsPanel are already token sections in the new sheet's derivation. Six test files repointed. The obituary-as-corpse trap struck a third time — two tests searched the context's source for <TokenSheet> / AdvancedTokenSettings and found the comments explaining their removal; comment-stripped, as in R7 and R11. <TokenSheet> now has no consumer at all.

1e.2 (point all routes at the new sheet), 1e.3 (delete the old sheet) and 1e.4 (dead styles) carried into v0.47.0.