v0.50.0 — The new character sheet for D&D 5E
The new character sheet for D&D 5E. One sheet, both editions: 2014 and 2024 share the entire sheet vocabulary, so dnd5e-2014 and dnd5e-2024 remain two registered systems — different rules and content — rendered by one schema and one component. Edition deltas live in schema data and content sources, never in a second sheet. buildDnd5eSheetSchema(edition) already works this way; this release makes the rest of the module match.
Deliberately out of scope, each its own later release: validation (the 5e counterpart of v0.49.0's System 8 work), automation / autoFill (CS-7), and equipment projection.
Character Context
- [x] Fix the character context header: keep the "n issues" button inline with the rest of the row so the "…" button doesn't wrap. When the sidebar is too narrow for that, drop the issues button's label (keeping its icon) and hide the "Linked" badge.
Bugs
-
[x] Uploading source art silently did nothing. Adding artwork to a character 400'd with
PATCH cannot change an array (artworks) together with other fieldsand the art never appeared.addArtwork(anddeleteArtwork) returnartworksplus one or both slot ids — exactly the shape the server'scheckPatchPayloadguard rejects. Split into the sequence of PATCHes the API accepts, ordered by direction: the array lands first when a slot is being assigned (or the id names an artwork the server has never seen) and the ids clear first when a slot is being emptied (or the art is gone while two ids still reference it). (14 tests, including one that asserts the original patch really does violate the guard — restated from the guard's own rule rather than matched against its message.) -
[x] Portrait crop presets (Square / 4:3 / 3:4) did nothing. No console error, because nothing failed:
portraitCropwas missing from the server'sArtworkEntitySchema, and zod strips unknown keys — so the crop was dropped on save, the refetch returned the artwork unchanged, and the pane re-rendered exactly as before.tokenizeSettingswas declared, which is why the token crop persisted and this looked portrait-specific. AddedPortraitCropSchema, withaspectleft optional because its absence is what means "no crop", the spec's default.
UX
-
[x] Ability Scores and Combat were missing from every 5e sheet. Not the module, and not the sections: the campaign holds TWO property definitions per attribute — the generic row created with the campaign (
cp_…id, noattributeGroup) and the system fixture's own (dnd5e-2014_strength, grouped). The generic one sorts first,resolveAttributeGroupIdreturned on the first name match, so it returnedundefined, no group was ever "populated", andderiveSheetSectionsdropped every attribute-group section. Silent and total — it read exactly like the module failing to load. A grouped definition now wins over an ungrouped one of the same name, which is right independently of the duplication: a definition with no group can never be the better answer to "which group is this in?". Verified against the live campaign — old rule yields[], new yields["Ability Scores"]. -
[x] Stop seeding duplicate property definitions. Not duplicate seeding — a lossy copy.
POST /campaigns/:id/change-systemcopies system rows into a campaign so the owner can edit them, and each table's copier enumerated the columns it carried. Every one was missing fields: property definitions droppedattributeGroup,abbreviation,aliases,facets,showOnHover; prop types droppedrollable,rollValueFunction,formats; bars droppedpropertyDefinitionId. The copy then SHADOWED the default it came from while carrying less, and since the list endpoints return campaign rows and system defaults together (the campaign row is meant to be an override), every by-name lookup found the lossy one. That is the single cause behind the missing Ability Scores/Combat sections, "Strength" instead of "STR", and ability scores having no modifier and no click-to-roll. The copier now spreads the row and overrides only identity and scope, so a column added later is carried without anyone updating a list.scripts/repair-lossy-trait-copies.tsbackfills rows already at rest — fills only nulls, never overwrites an edit, dry-run by default; run here it repaired 48 columns across 24 rows in two campaigns, and reports zero on a second pass. -
[x] The portrait crop appeared only in its own editor. Cropping, panning and zooming a portrait looked right in the Portrait Art pane and persisted across a reload, but showed up nowhere else — not on the artwork modal's own Portrait preview, not on the sheet, not on
/playor the character context, not in the hover card.cropImageStylehad exactly one consumer: the pane that edits the crop. Every other surface resolved the artwork, took.imageUrloff it, and discarded the artwork, so the framing had nothing to reach them by. NewPortraitImagerenders the crop and the four HTML portrait surfaces go through it — a component rather than a style helper, because a crop is a frame (a box of a given aspect with the image cover-fitted and transformed inside), which a bare<img>cannot express however it is styled. Uncropped portraits render byte-identically to before. Chat avatars and the WebGL token render take a URL string, so a CSS crop cannot reach them; that would mean baking a cropped image, and is out of scope. -
[x] New Campaign needs Create and Cancel buttons. It was a lone
<AsyncTextInput>that saved on blur, so there was no way to say go, no way to say never mind, and clicking anywhere after typing created a campaign.NewCharacterPagewas given an explicit pair in v0.47.0 and its doc comment names this page as the remaining inconsistency; the two are now the same form, down to the ref-based double-submit guard. -
[x] Cap the filename in the source-art card. A long filename widened the whole strip past its panel and pushed the row's Edit/Delete buttons out of view. The
truncatewas already there — the cause was Mantine's ScrollArea wrapping its children in adisplay: tablediv, which shrink-wraps to content, leaving the label'sflex: 1; minWidth: 0with no bound to collapse against. Forced that wrapper to block layout. -
[x] Make "Add source art" a drop zone. The dashed tile already looked like it accepted a drop, and requiring a click through to Upload from device to do that was the confusion. It now takes a dropped image straight to the same
uploadArtworkthe pane uses — extracted so two upload paths cannot mint artwork differently — with a hover state, a spinner, and a guard so dragging one of the strip's own thumbnails is never mistaken for an upload.
Field Commit / Revert (style guide 2.2.0)
Blur-to-save is right and also invisible: edit a name in Build mode, switch to Play without blurring, and the old name is still there. The edit was never wrong, it was never asked for. Style guide 2.2.0 ratifies an inset Commit (primary) / Revert (secondary) pair on any dirty auto-persisting field, with Enter/Escape as the keyboard equivalents. Blur still saves — this is additive.
The shared component already exists, which is why this is three tasks and not thirty:
useAsyncFieldSave + AsyncTextInput / AsyncNumberInput / AsyncTextarea / AsyncSelect
(apps/web/src/components/UI/AsyncField/) is the one place typed auto-persisting inputs go through.
- [x] FC-1 — Build it into the shared field.
useAsyncFieldSavealready owns the value, the committed baseline andcommit(); add adirtyflag andrevert(), and havemergeRightSectionrender the pair while dirty and the existing<FieldSaveIndicator>otherwise (never both). Enter commits, Escape reverts; on a Textarea Escape reverts and Enter still inserts a newline. Test: unit tests on the controller's dirty/commit/revert transitions (it is already fake-timer tested), plus a contract test that no call site hand-rolls its own pair. (Done: 9 controller tests + 8 contract tests, and every existing async-field test still passes unchanged. Addingdirtyexposed a real bug it would otherwise have lied about — a save marked whatever was on screen when it RESOLVED as the new baseline, so keystrokes typed during a save were adopted as "saved" and then skipped on blur as a no-op. Each save now marks the exact value it was handed. Two smaller things the spec implies but does not say: the pair is two buttons in a slot Mantine sizes and disables pointer events on for a decorative icon, so the wrappers widen it and re-enable clicks while it shows; and Revert has topreventDefaultits mousedown, or blur commits the very edit the button exists to discard. Escape only claims the key while dirty, so a clean field still closes its modal. Verified live on the campaign name field: Enter commits, Escape reverts, and the Revert button discards without saving.) - [x] FC-2 — Adopt it on the character sheet. The reported case. Audit which sheet fields still use a
bare Mantine input with a hand-written blur handler rather than an
AsyncFieldwrapper — the name field in Build mode is one — and route them through it. Test: contract test per adopted field. (Done: six fields across three sections — name, slug, character level and per-class level (MetadataSection), the Markdown source (NotesSection), and each currency amount (InventorySection) — plus a ratcheting sweep asserting no other sheet component writes its ownonBlur. Two of those were not blur-savers at all but save-on-CHANGE number fields: typing150gold was three PATCHes, the first of them for 1 gold.saveCharacternow RETURNS its write, so the spinner and the failure toast describe the real request instead of assuming it landed. Two wrapper gaps had to be closed first.reset()had existed since v0.35.0 with no caller, soinitialValuewas read once at mount — the slug field needs it, since the resolver rewrites what was typed, and the value arrives while the save that produced it is still in flight (so the re-sync retries when the field goes clean rather than dropping it). And an idle<FieldSaveIndicator>renders nothing but is still a truthy element, which Mantine reads as "the caller supplied a right section" — that is what had been silently stripping the +/- chevrons off every adoptedNumberInput. Verified live: the pair appears on the sheet's name and level fields, Escape reverts both, and the chevrons return when the field is clean.) - [x] FC-3 — Decide the Select/Switch boundary. Controls that commit on change are never dirty and so
never show the pair (style guide 2.2.0 §Scope note). Confirm nothing on the sheet relies on a
Select's blur to save, which would make it silently dirty-able. Test: covered by FC-1's contract
test.
(Done, and the boundary already held: every commit-on-change control on the sheet — the class
Select/Autocomplete, the attribute-modeSelect, the Build-mode and validation-ruleSwitches, the tokenize and cropSegmentedControls, the three cropSliders, the entityMultiSelectand the chat-colourColorInputononChangeEnd— saves as it is operated. A sweep now says so, and fails if a control of that family ever appears without anonChange/onChangeEnd. InAsyncSelectthe property is structural rather than intended:setValueandcommitfire in one handler, so there is no render between them wheredirty && idlecould hold — asserted, along with its binding no keys, since Enter and Escape belong to typed input. It was also still merging an empty right section, which is how a Select loses its dropdown chevron; it now shares the wrapper rule that omits the key. Preventative rather than a visible fix —AsyncSelecthas no call sites yet.)
D&D 5E Character Sheet — module (vtt-modules, modules/dnd5e, branch version/dnd5e-0.3.0)
-
[x] 5E-1 — Fold the duplicated edition logic into
core/.edition-2014/index.ts(1117 lines) andedition-2024/index.ts(1002 lines) are near-copies — normalise the edition strings and only ~470 lines differ, most of that comment churn plus a wholesale re-declaration of the class table with identical hit dice. Every item below would otherwise be written twice and drift silently. Leave the two edition entrypoints as thin delta declarations (2024: Background-not-Species ability increases, the level-1 Origin Feat, its class list) over sharedcore/implementations. Also give 2024 theapplyTemplateStatsit currently lacks. Test: the existing edition test suites keep passing unchanged; add an equivalence test asserting the two modules agree wherever the rules do. (Done: 2119 lines → 1487, with the two edition files down to 215 each — a module literal andvalidateHpapiece. Newcore/rules.ts,core/abilityScoreConfig.ts,core/suggestions.ts,core/applyTemplateStats.ts, plus a 16-testcore/editionParity.test.tsthat asserts both directions: agreement where the rules are unchanged, and that 2024's actual revisions survive. 2024 gainedapplyTemplateStatsand the missingsupportedMethods/pointBuyBudget/pointBuyRange/startingArray; the package's three spellings of the edition union collapsed to one.validateHpwas deliberately left un-shared — the two implementations diverge in behaviour rather than wording, so reconciling them is a validation decision and waits for the validation release.) -
[x] 5E-2 —
formatAttack(CS-1.7). System 8 declares it; neither 5e edition does, so 5e attacks rendered as a bare name with no to-hit or damage. Format to hit / damage / range the way a 5e stat block reads. Test: module unit tests over a fixture character's attacks. (Done: shared by both editions, 15 tests. The load-bearing three extract the dice expressions out of the ActionprojectTokengenerates and assert the summary quotes the same ones — the "summary must match the roll" invariant, enforced rather than asserted in a comment.) -
[x] 5E-2a — Make 5e attacks roll like 5e. Found while writing 5E-2:
createAttackActionincore/projectToken.tsignores every 5e-shaped field onCharacterAttack—damageDice,damageType,additionalDamage,critRange,critEffect— and rolls System 8's weight/rank model instead (rank = ⌈level/4⌉dice of a light/medium/heavy die, plushitPower × rank). A longsword on a level-8 5e character therefore rolls2d8+7rather than1d8+4.formatAttackcurrently mirrors that faithfully, because a summary that disagrees with the roll is worse than none — so this is what both the roll and the line should become. Changes what dice get rolled for existing 5e characters, so it wants the user's explicit go-ahead. Test: unit tests per attack shape, and 5E-2's roll-vs-summary tests keep both halves in step for free. (Done: 291 module tests, up from 251. Three rules corrected — one damage die instead of⌈level/4⌉of them; proficiency on the attack roll only, never damage;hitPowerflat rather than× rank.damageDiceis preferred when authored, with the legacy weight falling back to one die of its size — not optional, since nothing writesdamageDiceyet.damageAttributehonoured including an explicitnone; a bare-dice rider is rolled and added, prose is shown and never rolled;critRange/rangecarried but never folded into a roll. The arithmetic moved tocore/attackMath.tsshared withformatAttack, making the "summary matches the roll" invariant structural — which reverses 5E-2's note, since what's shared is arithmetic rather than a display string. Added a guard that every generated action actually parses: the code is string-interpolated from user-authored values, where one unescaped quote is a syntax error nobody sees until a player clicks the button.) -
[x] 5E-2b — Register a CS-9 attack editor for 5e. The module registers none, and
attackEditorRegistry.resolveAttackEditorreturnsundefinedfor a system without one — by design, since attack fields are system vocabulary the platform must not guess at. The consequence is that a 5e character on the new sheet gets no Add-attack and no Edit-attack control at all. Whatever field set this offers is the same decision 5E-2a settles, so it follows 5E-2a. Test: source-contract test that both editions resolve an editor. (Done: 11 tests, 302 in the module overall. The field set is the one 5E-2a settled — every control writes somethingattackNumbersreads — and notably there is no damage-weight picker, since offering light/medium/heavy would invite new attacks into the shape we are migrating away from. The weight is still preserved on save, so renaming a legacy attack does not silently re-roll its damage. Proficiency gets a checkbox that System 8's editor has no need of. The preview runs the module's ownformatAttack, so it cannot become a third description of an attack free to disagree with the sheet and the dice. Registered per edition from the client entry only; the first test block runsregister()and asks the registry what the platform asks, because a registration covering one edition would look identical to a working one until someone opened a 2024 character. The package gained system8's React setup —jsx: react-jsx, react + @mantine/core peer/dev deps.) -
[x] 5E-3a — A module seam for power/spell action derivation (CS-6b).
maybeDerivePowerActionsran for every system and did one thing: a System 8 wiki fetch gated onSYSTEM8_BASE_URL. A 5e character's spells reached it, matched nothing, and fell through to the generic d20 roll permanently — while the platform carried one system's content source in a generic code path (a CC-1 leak).SystemModulegainsderivePowerActions; the server asks the character's own module first and keeps System 8's wiki path as the fallback. Server-side becauseprojectTokenruns in the BROWSER and neither 5e's bundled SRD nor System 8's wiki is reachable from there. The merge is deliberate — a power the module cannot decide keeps its generic roll, so the contract says to OMIT rather than return empty actions. (12 tests.) -
[x] 5E-3b — Derive real actions for 5e spells. Fire Bolt rolls
1d20+7and1d10fire; Fireball rolls8d6against a DC 15 Dex save for half; Cure Wounds heals1d8+4. The SRD carries mechanics as prose — nodamage_dice, nodc— so this parses the text, and most of its tests are about what it refuses: dice that are not damage (lasts 1d4 hours), attack spells with no readable damage, saves with no damage (Hold Person), and scaling clauses that are not extra dice. ~Half the SRD is a utility spell with no roll; those are omitted and keep the generic roll, because a confidently wrong action is worse than none. Numbers come from the caster — the same Fireball is DC 15 for a Wizard and DC 12 for a Cleric. Attached to the server export only, by mapping the one shared declaration; the client import graph is verified free ofnode:fs. (34 tests, including a sweep that every derivable spell in both full SRDs produces parseable code.) -
[x] 5E-9 — A 5e trait picker, so spells and feats can be chosen. The Spells and Feats sections were built and their Add buttons wired, but nothing could be added: the sheet falls back to
AddPowerModal/AddFeatModal, which source fromclientConfig.system8ApiPrefix— hardcoded — so a 5e character was offered System 8's powers, or nothing. Those modals are System 8-shaped past their data source too (ranks, level slots, advancement metadata), so retrofitting them meant anis5ebranch in a shared component. Instead the module registers its own picker per subtype, exactly as 5E-2b's attack editor does; a system that registers none keeps today's modals, so System 8 is untouched. Each edition binds to its OWN content provider (2014 → SRD 5.1, 2024 → 5.2.1). Reads throughcontentProvider.listTraits, so no new data path. (6 module tests + platform wiring; the client import graph stays filesystem-free.)
Ability Scores
- [x] Use abbreviations (STR), and show
10 (+0)with the modifier accented. Same duplicate-property-definition cause as the missing sections:findAttributeDefinitionreturned the ungrouped generic row, which carries noabbreviation, so captions fell back to the full name. FixingresolveAttributeGroupIdalone left the section rendering EMPTY, becauseattributesInGroupfilters through this same lookup.10 (+0)needed no new code — the module'sformatAttributehas always returned it — only the accent, viasplitFormattedValue, which separates a trailing parenthetical into the tile's sub-value slot (teal, danger when negative). That split is typography, not system vocabulary: a module returning no parenthetical gets exactly what it returned. - [x] Show the modifier, and make the tile clickable. Two separate causes, both "a copy of the module's rule outranked the module". The
(+0)was missing because CS-1.6's tier 1 lets a campaign-authored prop type beat the module, guarded oncampaignIdbeing null — but SEEDING writes fixture rows into the campaign, so the seededability-scorerow carried a campaignId, passed the guard, and won. Now also gated onisSystemDefault: a seeded row is a snapshot of the module's rule and goes stale the moment the module ships a change, which is the exact reasoning CS-1.6 was introduced on. A human-edited row still wins. The click did nothing because the fixture declaredrollValueFunctionbut notrollable— different questions (AC has the first without the second) — fixed in the fixture, version 8 → 9 so existing installs re-seed. - [x] Let the MODULE own the ability-check roll, including the proficiency prompt. The remaining half of the user's directive. Today the platform composes the expression itself (
resolveRollValue→1d20+N), so "prompt yes/no for proficiency and add the bonus if yes" is 5e arithmetic the platform cannot own. Proposed seam, mirroring CS-6b:SystemModule.buildAttributeCheck(attribute, character)returns anIAction— the module then owns the prompt, the bonus, and the chat line, and the sheet just executes what it is handed (it already hasonExecuteAction). A system that declares none keeps today's bare1d20+N, so System 8 is untouched. Test: module unit tests on the generated action; a contract test that the sheet prefers the module's action over composing its own. (Done, built as proposed. MODULE_API_VERSION 1.10.0 → 1.11.0, additive. The sharper statement of why the old seam could not stretch:rollValueFunctioncan only express a CONSTANT, and 5e's bonus is not one — the same Dexterity tile is1d20+2as a check and1d20+5as a save the character is proficient in, and which is happening lives in the player's head rather than in the record. The decision sits in a pureutils/attributeCheck.tsrather than inline in the sheet, so both halves are unit-testable and a module that throws or returns an action with no code counts as "no opinion" — a tile that silently does nothing is strictly worse than the generic roll it replaced. The module is handed the constructed manifest, since a check folds in derived numbers (the proficiency bonus from level) the raw record never carries. On the module side the checkbox defaults UNCHECKED, reproducing exactly what the tile did before, and the generator is shared withprojectToken's per-ability token button — the tile and the button are the same gesture on the same character, so a build where one prompts and the other does not would be a defect rather than a choice. 10 platform tests + 3 shared contract tests + 19 module tests (386 in the module overall), the module's asserted by RUNNING the generated script against a stubbedprompt/rollrather than matching its text. Module commitf5e3c05onversion/dnd5e-0.3.0; not verified in the browser for the same stale-module reason as the tile drafts.) - [x] Editing an ability score is very unresponsive. NOT what the attached trace shows — see below. The real cause is that
AbilityScoreTile'sNumberInput.onChangecallssaveAttributesdirectly, so every keystroke fires a PATCH; each PATCH bumpsupdatedAt, which re-runs the whole module projection (construct+projectToken, regenerating every action's code string) and re-renders the sheet. Needs debounced writes with optimistic local state — which is FC-1's job, so do them together. (The performance trace is dominated by browser extensions: ablob:extension script at 38.7% and Redux/React DevTools at ~5%. All app-code frames together total 18.2ms of 13,313ms sampled — 0.14%. Re-profile with extensions disabled before trusting any trace of this app.) (Done, on FC-1's machinery:useNumericDraftisuseAsyncFieldSavewith a 400ms debounce, so the value on screen is local and one write goes out when typing stops — blur still commits. It covers BOTH tiles and every component row inside them, not just the headline number, via a shared<DraftNumberInput>(a component rather than a hook call per site, because those rows are a.map()). Two things fell out. A field that commits itself on a timer now shows no Commit / Revert pair — the pair is for an edit that would otherwise sit there unasked-for, and a debounced one would flash it for 400ms while offering a choice already made. And the ability tile's+/-had to learn about drafts: they choose WHICH component moves, which a typed total cannot express, so they read the live draft, write their own components, and then supersede the pending write instead of being silently overwritten by it a moment later. Not verified in the browser — the dev server is in the known stale-module state where no attribute tile renders at all, and clearing that needs a restart.)
D&D 5E Character Sheet — playtest feedback
Raised after playing the sheet with a real 5e character. The first four are the skills list; the fifth is a new feature; the last two are a theme colour and a broken modal.
-
[x] Skills show the ability's abbreviation, not its full name. "Dex" rather than "Dexterity" — the column is narrow and the full word crowds out the skill it belongs to. The abbreviation is the module's to supply (the platform must not shorten a system's words), so this is a projection field plus a platform fallback to the full name. (Done as scoped. The module projects
abilityAbbreviationper skill, reusing the tableattributeCheckalready kept for the chat line rather than a second copy; the platform prefers it and falls back to the full name, so an older module release still renders. The full name stays in the row's tooltip. Module commit5b64f12. 3 platform tests + 2 module (391 in the module).) -
[x] Build mode gets a proficiency toggle per skill. 5E-5 was deliberately scoped read-only and 5E-5a's note says Build mode is where the tier gets set; this is that follow-through. Writes through the module's own edit path, since which tiers exist is
proficiencyLadder's business. (Done. The trained dot BECOMES the control in Build mode, and it cycles rather than offering a dropdown — the platform cannot know how many rungs a ladder has or what they are called, so "the next one" is the only control that works for a five-rung system too. A higher rung reads as a thicker ring, so the tiers above the first are distinguishable at a glance without the platform naming any of them.CharacterSkillgained a generictier?: number— the same ladder index the projection has emitted since 5E-5, flowing back the other way — and the platform writes ONLY that; writingexpertise: truewould be it hardcoding one system's ladder. The module prefers a stored tier, keeps the old booleans as the fallback for records written before the field existed, and now DERIVES them from the tier so the two cannot disagree; an out-of-range index is clamped. A skill the record has never stored is appended, which is the common case — 5e projects all eighteen whether or not the character has an entry. The section's own CC-1 guard caught a comment of mine naming a 5e rung, which is the guard working. 11 platform tests + 4 module (398 in the module). Module commit7be8096.) -
[x] Build mode always shows all skills. You cannot mark proficiency in a skill the list is hiding, so the trained-only filter is a Play-mode idea. The toggle should disappear rather than sit there contradicting the mode. (Done. Build mode OVERRIDES the preference rather than setting it — leaving Build puts the list back the way the player had it instead of leaving it expanded — and the toggle hides with it.)
-
[x] Rolls read as a sentence in chat.
I rolled a Stealth check: 1d20+7, letting the chat log's existingprocessMessageDiceAndMathresolve the expression in place — the same path a typed message takes. Today the sheet posts a bare resolved roll with no indication of what was rolled, so a chat full of d20s says nothing about which check each one was. Needs a label alongside the expression on the sheet's roll contract. (Done. The trick is that the expression is LEFT IN THE TEXT and chat's own processor swaps it for a resolved token, so nothing new computes a roll and the sheet cannot drift from a typed message.rollExpressionToChatdoes have alabeloption that puts a header above a dice row, but the requested format is better and this uses the prose path instead. An unlabelled roll — attacks, actions — keeps the old decorated row. The 3D-dice kickoff had to be extracted from ChatLog rather than copied: it reads the rolled values out of the resolved token so the animation lands on the number chat already published, and ChatLog's own comment says why two copies would hurt — a broken kickoff looks like "dice just don't roll" and is brutal to debug. 9 tests.) -
[x] NEW — a saving-throw row under each ability score. A third row reading
Save: +8, separately clickable, rolling a save rather than a check. Proficient saves use an accent colour; the rest are arithmetically identical to the modifier above them, so they read subdued. Build mode gets a proficiency toggle per save. Save proficiency is character data the module owns — 5e has it, System 8 does not — so the row appears only where the module declares saves. (Done as CS-1.9, a genericProjectedAttribute.rows: a module may hang extra lines under any attribute, and nothing in the platform's renderer knows what a saving throw is — it places the row, runs the click and persists the toggle, while the label, the number, the emphasis and the action are all the module's.Passive: 14would render through the same component. What made this tractable was already in the module: the 5e class table has carriedsavingThrowProficienciessince it was written, so the default derives from the character's classes and needs no new character data, staying correct as they multiclass. Only the OVERRIDE is stored, under a key convention that lives in the CONTRACT rather than either side's utilities since both read it; absent means "derive", deliberately not the same as 0. Multiclassing is generous on purpose — 5e grants only the first class's saves, but a record carries no ordering, and a wrongly-offered save is visible and fixable where a missing one looks like a broken sheet. The save action does not prompt: CS-1.8's check must ask whether a d20 is a check or a save, and clicking a save row has already answered that. 19 platform tests + 14 module (412 in the module). Module commit393e5bc. Follow-up: the row shipped INSIDE the read face, which Build modevisibility: hiddens — so it vanished in exactly the mode whose toggle it carries — andeditModewas never passed down, so the toggle could not have rendered anyway. It is now a sibling of both faces; two contract tests pin the placement and the prop.) -
[x] Ability scores use an error-coded colour in the retro theme. The modifier's accent falls through to something red, which reads as a problem rather than a bonus. Use teal, via an existing accent token if the theme has a suitable one and a new
--vtt-text-accent-positiveif not. (Done — no existing token fit, so the new one it is. The tile had been falling through to--vtt-color-accent, which is the theme's general CHROME accent:#a0522dsienna in retro, which beside a number reads as an error rather than as a bonus.--vtt-text-accent-positiveis defined in all four token files — a token missing from one theme is a value that silently falls back to the component's hardcoded default in exactly that theme, which is the bug this replaces. Teal everywhere, tuned per theme for contrast. 6 tests, including one that asserts retro's is actually teal by channel rather than merely different from the chrome accent.) -
[x] A feat's modal says "No description" when the module has one. Grappler ships a complete Markdown description in both editions and the modal shows none of it. The projection carries whatever the CHARACTER record stored, which for a picked feat is little more than a name — the module's own content is never consulted. (Done — on the second attempt. The first fix was wrong: the trait picker stores its SRD summary as
briefwhere the projection readdescription, so making feats fall back tobrieflooked like the answer. It is a real bug and the fallback is kept, but it does not fix this — the stored record is{ name: "Grappler", slug: "grappler" }and nothing else, verified against the live row. There was never any text to project. The prose is the MODULE's, andgetTraitscannot reach it: the projection is synchronous and the SRD is read server-side (the module's client bundle is deliberately free ofnode:fs). So the sheet asks, through CS-5'scontentProvider.getDetail— the system-blind sibling ofuseWikiTraitDetail, which knows about wiki URLs because that is System 8's shape. Any module with a content provider now gets its own text into the modal. Two details that would each have made it silently return nothing: the lookup uses the trait's OWN slug, since the projection namespaces for uniqueness (grappler-feat) while the provider knows it asgrappler; andmetadata.bodyoutranksdescription, because the list record's description is the one-line summary the picker showed. Lazy — nothing is fetched until a modal opens. 11 platform tests + the 3 module tests from the first attempt. Verified at the network level against the live character:GET /api/modules/dnd5e/feats/grapplerreturns 200 with the full Markdown undermetadata.body. The RENDER is still unverified — rebuilding the module put the dev server back in the stale-module state.)
D&D 5E Character Sheet — playtest feedback, round 2
- [x] The proficiency toggle turned on but never off, and felt sluggish. (Three causes, only one of them
the one I'd have guessed. The write was being silently discarded:
tierwas never declared on the server'sCharacterSkillSchema, and zod strips unknown keys — accepted, dropped, echoed back missing. Turning ON also seededproficient: true(a new entry writes the booleans), so the module's fallback made it look like it had worked; turning OFF wrote onlytier: 0, which vanished, leavingproficient: truebehind and the skill stubbornly on. Exactly theportraitCropfailure from earlier in this release, found by reading the stored row rather than the code. Second: I built a cycle where a toggle was asked for. My reasoning — the platform cannot know how many rungs a ladder has, so "the next one" is the only universal control — was real but the wrong trade, since it made "off" three clicks away with the middle one landing on a rung nobody asked for. It now toggles, still naming no rung: off is the ladder's lowest index, on is the next up. Third: no optimistic state, so the dot waited on character → server → refetch → module re-projection before it moved. It now paints immediately and stops overriding once the projection agrees, which also lets a failed save correct itself. The attached profile is dominated by browser extensions and the profiler's own startup — the sheet's scripting barely registers — so the lag was that round trip, not slow code. 12 tests, 6 of them server-side so a silently-stripped field cannot come back.)
D&D 5E Character Sheet — platform
- [x] 5E-5 — Skills section renderer.
deriveSheetSectionsalready emits akind: 'skills'section whenever the module declares aproficiencyLadder(5e does, System 8 does not), butCharacterSheet.tsxhas no branch for it, so the 5e sheet renders the heading over "Not built yet". Build it: proficient-only by default with a show-all toggle, click to roll, modifiers driven by the module's ladder rather than by any 5e knowledge on the platform. Test: unit tests on the modifier + filter logic. (Done: 16 unit tests on the pure derivation + 9 source-contract tests. The module now projectstieras an INDEX intoproficiencyLadder.tiers, so the platform never sees the word "expertise" — a five-rung ladder renders through the same component, and there is a test for exactly that. The platform reads the projected modifier and never recomputes it; the contract test fails if arithmetic on a modifier reappears in the component. Verified rendering after a dev-server restart.) - [x] 5E-5a — 5e must declare its 18 skills. The Skills section renders correctly and is permanently empty:
createNewCharacterTemplatesetsskills: [], the module projectscharacter.skills ?? [], and 5e's skill list exists nowhere in the module as a declarable list — the names appear only incidentally insideclasses.jsonandrules.jsonprose. So a new 5e character has no skills and nothing ever creates them, which also means the show-all toggle can never appear (hasHiddenSkillsneeds untrained rows to hide). 5e skills are a FIXED list, not a user-authored collection: you do not add Acrobatics, you mark proficiency in it. The module should declare all 18 with their governing ability and project every one, tier 0 when untrained; Build mode then toggles the tier. Read-only was correctly scoped for 5E-5 — this is the gap that scoping assumed away. Test: module unit tests on the declared list + projection; the platform side needs no change, sinceskillRowsalready reads whatever is projected. (Done:DND5E_SKILLSincore/rules.ts, shared by both editions; the projection merges rather than replaces — the character's own entry wins, a skill not in the SRD list survives, a stored skill missing its ability falls back to 5e's, and names match case/whitespace-insensitively. No platform change, as predicted. 7 tests.)- Persisted character data is an array of which skills and tools have proficiency. Absence from this list implies they do not.
- Default mode: only show proficient skills/tools.
- Toggleable alternate mode: show all skills/tools, with untrained ones have a more subdued appearance.
- Each row shows skill/tool name, governing ability, and final modifier. Clicking the row rolls the skill/tool check.
- In edit mode, all skills/tools are always shown, and there is a control to toggle proficiency for each one. The final modifier is updated live as the user toggles proficiency and/or changes ability scores.
- In edit mode, there is an 'Add Skill/Tool' button that allows the user to add new skills or tools to the character's proficiency list.
- This modal populates the choices from the canonical list of skills and tools (each is its own tab), and offers a 'Custom' tab for the user to add a skill/tool that is not in the canonical list. The user can specify the name and governing ability for a custom skill/tool, and whether they are proficient (default: yes).
- [x] 5E-6 — Spell grouping by Class→Level.
powerGrouping.tsofferstrait/category/flat; 5e wants Class→Level (default) or by Level. Drive the available modes from projected trait metadata so the platform stays system-blind. Test: unit tests on the grouping function. (Done:TraitSubtypeSchema.groupings(CS-1.3b) lets a module declare groupings by its own metadata keys, andutils/traitMetadataGroupingexecutes them — nothing in it nameslevel,classor any system's field, proven by a test that groups a System-8-shaped list by rank and school through the same code. 5e declares Class → Level, Level and School. Numeric groups sort numerically (else a caster reads 1, 10, 2); a trait missing the key lands in a residue group sorted last rather than being dropped; a stored preference for a grouping the module no longer offers falls back to the default. The picker storeslevel/schoolfrom the SRD, andclassNameONLY when unambiguous — a 5e spell has several classes and which one a character took it as is a real choice the SRD cannot answer, so it is left unset rather than guessed. 17 tests.) - [x] 5E-7 — Ability-score generation-method picker.
AbilityScoreSchema.generationMethodshas zero consumers today; 5e declares four methods (standard array, point buy, custom array, manual) against System 8's two. Persist the re-roll count per character. Test: unit tests per method. (The one droppable item here if the release runs long — it is the seam where sheet editing starts shading into automation.) (Done, and scoped to exactly the phase-1 half: the picker RECORDS how a character's scores were arrived at, and nothing reads it back. What the method implies — point-buy costs, which swaps a standard array permits — is validation and automation, both out of scope here, so the help line says "recorded for reference" rather than letting the control imply a constraint it does not apply. That also settles the neighbouring +/- question for now: the steppers stay simple up/down. The picker rides with the six scores it describes, appearing in whichever attribute group actually holds abilities — by schema, never by group name — in Build mode only, and only where the module offers more than one method, so System 8 gets no picker at all. An unrecorded method stays null rather than defaulting: a character built before this existed genuinely has no provenance, and a stored method the module no longer offers falls back the same way a stale trait grouping does (5E-6). Changing the method resets the re-roll tally, since the count means "how many times THESE scores were re-rolled". The platform enum gained'rolled'— the method the tally exists for, which 5e's own internal vocabulary already named while its declaration listedcustom-arrayin its place; declaring it made the module'sapplyTemplateStatsswitch non-exhaustive, and it shares the custom-array branch (reassign the six values that exist, re-roll nothing). 19 platform unit tests + 10 contract tests + 2 module tests. Module commit6ed0e59. Not verified in the browser — rebuilding the module put the dev server back in the stale-module state, where no schema-derived section renders.) - [x] 5E-8 — Generality reconciliation. Review every platform change 5E-5..7 forced and fold each back into the module seam, or record why it must stay platform-side. The acceptance criterion for the whole 5e effort is that it adds near-zero platform code; each line it did add is a generality bug until argued otherwise. Test: the CC-1 leakage guard ratchets down, not up. (Done, and the CC-1 guard is green for the first time since v0.46.0 — with nothing added to its baseline to get there. Five offenders, all folded back:
System8AbilityEditorandsystem8ManualValidationeach named a system to fetch or declare their own rules and now take it from the caller (the editor is handed the module its parent already holds; the throwaway validation character carriesmodule.id);MonsterGeneratorloaded the module by literal beside anisSystem8Campaigncheck it had already computed, so the campaign's own id serves; and the two server-core hits were never leakage at all — a comment naming a system id as an example, and atypeof x === "string"triple that reads as asystemId ===branch to the guard's regex. The review itself: every platform file the 5e work added names no system, verified by that same regex. Each is argued rather than assumed.skillSection/SkillsSectionrender whateverproficiencyLaddera module declares, withtieran INDEX into it so the platform never says "expertise" — a five-rung ladder renders through the same component, and there is a test for exactly that.traitMetadataGroupingexecutes module-declared groupings and names no field, proven by grouping System-8-shaped data through it.attributeCheckremoved debt rather than adding it, moving arithmetic the platform could never have owned into the module. The one piece of recorded debt isAbilityScoreMethod: it is a CLOSED platform enum, so a module cannot add a generation method without a platform release — the same criticismNumericAttributeFormatcarries, with the same remedy shape (an id registry) if it ever bites. The rest of the 5e effort (5E-2a, 5E-2b, 5E-3b, 5E-5a, 5E-9) added no platform code at all.)
Layers
Currently, the user can add, edit, and reorder layers, including the default layers of Tokens, Objects, Drawings, and Background. They can see Foundation but cannot edit it. This is expected.
- [x] Add a Grid layer. It can't be deleted or changed, other than that its order can be adjusted like any other layer. It must be above the Foundation, but there are no other restrictions. (Done. The grid used to be drawn from a hardcoded position — "insert it after the background layer" — so it is now a real builtin row and the renderer draws it wherever that row sits. It seeds directly above Background, exactly where it was hardcoded, so nothing about an existing map changes until someone moves it; maps that predate the row are backfilled on read, since the seeder only ever runs on a map with no layers at all. The interesting part was that
isProtectedhad been doing two jobs: it meant "cannot rename / re-flag / delete" AND "cannot reorder", which was fine while the only protected layers were the pinned Foundation and VBL / FOW. The Grid is the first layer that is protected but movable, so the two came apart — pinning is now a property of those two builtins and protection is read separately, in one shared set of predicates the editor, the palette and the renderer all use. The server gained the matching guards, including one nothing had before: a reorder cannot push any layer below the Foundation or above VBL / FOW. Which layers you can be ON is now derived from whether the layer accepts tokens or drawings, rather than an id blocklist — so the Grid is excluded for the same reason the Foundation is, and a future contentless layer is excluded without anyone remembering to list it. 16 tests. Verified against a real pre-existing map through the API: the backfilled Grid landed between Background and Drawings, protected. The map page itself froze the renderer under automation (the known WebGL hazard), so the drawing position rests on the contract tests.) - [x] Reverse the sort order, so the topmost layer in the editor is the topmost on the map (i.e., closest to the camera, obscuring lower layers). This is more intuitive for users. (Done. The model keeps
orderKeyascending — that IS the stacking order, and the renderer iterates it — so only the reading order flips. The list itself is one line; the drag is the part that matters, because a drop computed against the model order while the user is looking at the reversed one moves layers the wrong way, silently, and only in one direction.rankForDisplayDropworks on the displayed array, where "the row above" means the HIGHER key, and there is a test for the neighbouring swap specifically — the direction bug shows up as a layer jumping past its neighbour rather than trading with it. The palette's layer menu reads the same way, since two opposite conventions in one app would be worse than either. 13 tests.) - [x] Change the default Tokens layer to not allow drawings. It can still be toggled on by the user, but is off by default for all new maps. (Done — one field in the seed list, plus the tests that say what it is NOT: the toggle is untouched, and nothing backfills it the way the grid row is backfilled, so a map made yesterday keeps whatever it has. "Default" and "rule" are one character apart in a table like this, and only the second one would need a migration.)
- [x] When the user enters Drawing Mode while they aren't on a layer that allows drawings, automatically switch to the nearest layer that does, preferring a lower layer to a higher one if there are two equally distant layers. Briefly flash the layer control to draw attention to the change. (Half-built already: v0.33.0 shipped the switch, but looking DOWNWARD only — so it silently did nothing whenever the only drawable layer was above you, which is exactly the case the feature exists for. Now nearest in either direction, walking outward a step at a time and testing below before above, so an equal distance resolves downward in one pass. The tie going to the lower layer is also the safer half of it: drawing below what you can see beats drawing over it. The flash is a counter on the UI store rather than a boolean or a timestamp — two switches in a row are two events, and nothing has to be cleared — and the control re-arms on the next frame, since re-applying an animation class that is already there does nothing. Reduced motion gets an outline instead of a pulse, because dropping it would remove the feature rather than soften it.)
- [x] When the user was auto-switched to a different layer due to entering Drawing Mode, and they exit Drawing Mode, automatically switch back to the original layer they were on before the auto-switch. Briefly flash the layer control to draw attention to the change. (The switch-back itself has been there since v0.33.0, including the good part — it only restores if the user is STILL on the auto-picked layer, so a deliberate mid-drawing layer change is respected. All this needed was the flash, which is why it landed in the same commit as the item above rather than its own: the two are one line apart in the same effect.)
Drawings
- [x] Creating, editing, and deleting a Drawing should be undoable/redoable using the undo/redo buttons in the Palette. Currently they do not seem to create history in the undo/redo stack. (Done. They created no history because nothing ever pushed any — the undo store knew about tokens and map fields only. The capture went into
useDrawingsitself rather than auseUndoableDrawingswrapper, because seven components already call that hook and a wrapper would have made each of them opt in one at a time. Every write takes askipUndofor the paths that are not an editing gesture: replaying history (or the buttons fight each other), and the layer-orphan repair, where "undo" would put the orphan back. Drawings are HARD-deleted — there is no soft-delete table for them the way there is for tokens — so undoing a delete re-POSTs the row under its original id, which works because the create route honours a supplied id, and the action carries the whole drawing rather than just the id so a redo can put the same one back. An edit captures only the keys it writes, so undoing one does not revert what someone else changed in between. 12 tests. Typing the drawings query asIDrawing[]on the way past also removed two long-standing implicit-anyerrors and surfaced a real one: the builtin-id set was typed too narrowly to accept the very lookup it exists for.) - [x] When a drawing is selected, the delete or backspace button should delete it. (Done, and the cause is worth recording: the handler was already there and had been since v0.33.0 — it was shadowed. Selecting a drawing mounts the in-shape text editor (v0.42.0) and focuses its
contentEditableimmediately, so from that moment the map's own key handler bails onisEditableTargetlong before it reaches its delete-the-selected-drawing branch. Delete and Backspace were being typed into an invisible empty text box. The rule now lives in the editor, where the focus actually is: empty text → the key deletes the DRAWING; non-empty → it edits the text, and once that is gone the next press deletes the shape, which is how a chip input behaves and means nothing is destroyed under someone mid-sentence. A selection always edits, even from an empty-looking box. It also cancels the debounced text save first — one that landed after the DELETE would resurrect the row. 8 tests.) - [x] When drawing a wall, pop up a UI (similar to delete mode, targeting mode, etc) that explains how to do so. To wit: click once per corner, and double-click or right click to finish. Escape to cancel. (Done, as a fifth mode banner rather than a new idea — it borrows
DeleteModeBanner's geometry, layer and exit button down to the pixel, because a second visual language for "you are in a mode" would be worse than the borrowed one. Worth saying why this tool in particular needed it: every other drawing tool is press-drag-release, and the wall is click-per-corner then a separate gesture to finish — so the natural thing to try is a drag, which lays down a single point and looks broken. The prompt changes once a corner is down: "double-click to finish" means nothing before there is anything to finish, and Escape has nothing to cancel until then. Leaving via Done abandons a half-drawn wall, or the next tool inherits it. 6 tests.) - [x] Split 'snap to grid center' and 'snap to grid corner' into two separate toggles when the drawing tool is a circle, rectangle, or cone. (Done. The reason one toggle could never work: a shape has two ends and they want different KINDS of answer. Where it starts is a point, and snapping a point means moving it. Where a circle or cone ENDS is not a point at all — it is a radius — so "put the edge on the grid" is tangency, which no amount of moving the cursor's point can express. That is why a circle used to snap its centre neatly and then finish wherever the cursor happened to be. The origin toggle is unchanged; the endpoint toggle is new, and its third option is named grid lines rather than corner because that is what unifies the shapes — for a circle the edge meets a line, for a rectangle the far corner sits where two of them cross, and calling both "corner" would describe neither. The spec's own acceptance criterion is a test: snapped origin + lines endpoint gives a circle inscribed exactly in grid squares. The radius rounds to the CLOSEST aligned value in either direction along either axis, never to zero (which is the nearest answer near a corner, and is not a shape), and the tangent point is reported straight out along its axis so the "it isn't in my cursor's square" behaviour reads as deliberate. The second group shows only for the three shape tools: a pencil stroke ends where the pointer lifts, a line's every point is an origin in turn, and a wall already snaps each corner. 24 tests.)
- Toggle one: origin.
- Options: snap to grid center, snap to grid corner, no snap.
- For a circle, this is the center.
- For a cone, this is the point of the cone.
- For a rectangle, this is a corner (exactly which corner depends on which way the user drags the endpoint).
- Toggle two: endpoint.
- Options: snap to grid center, snap to grid lines, no snap.
- For a circle, this is some point on the circumference.
- In 'snap to grid lines' mode, the circle will meet at a single tangent point along the grid line closest to the cursor. It won't be in the cursor's current square unless the cursor has the exact same x or y coordinate as the origin, but this ensures alignment with the grid. If the origin has either snap mode enabled, this should result in a circle perfectly inscribed in a square.
- In 'snap to grid center' mode, the circle will meet at a tangent point exactly in the center of the correct grid square that is along the line closest to the cursor. It won't be in the cursor's current square unless the cursor has the exact same x or y coordinate as the origin, but this ensures alignment with the grid.
- For a cone, this is some point along the circumference of the cone's rounded edge.
- The behavior is very similar to Circle, other than that a cone is only a section of a Circle, but the position of the circular portion should behave exactly like a Circle would.
- For a rectangle, this should hopefully be pretty obvious. The cursor controls the position of the corner opposite the origin, either snapping to the nearest grid center, grid corner, or no snap.
- Toggle one: origin.
Artwork Editor
- In the Artwork Editor of the new character sheet, add an option to paste an image directly (having used 'copy image' on some image in another application).
- [ ] New option added to the Source Art panel listing options.
- [ ] Pressing cmd-V or ctrl-V (win) on the Artwork Editor's landing page pastes the image as new source art.
- [ ] Pressing cmd-V or ctrl-V (win) on the Source Art panel pastes the image as new source art.