Character Sheet Renderer — how a module ships a sheet
Status: v0.105.0 (New character sheet: foundations). The seam a system module renders a character sheet through: the renderer extension point in the Module API, the
@purple-cat/sheet-kitof primitives, the host and its Sheet context. The design is the roadmap epic; this page is the contract as it ships, for a module author. The 5e sheet (v0.106.0) and the System 8 sheet (v0.107.0) are the first two renderers; until a module registers one, the platform's own section-based sheet — the quick view — renders for it.
How to ship a sheet
The shape. Your module ships a React component; the platform ships the
frame it sits in — the header with the Build-mode toggle (gated by the
character's access level, as the quick view's is), History, the ⋯ menu with
zoom and Export PDF, and the scroller — plus the data, the one write path,
the field registry, the recent-changes feed, and your own pickers when the
sheet asks for one. You draw the whole sheet; you write only through
api.edit, so validation, history commits, permissions and the token fan-out
stay the platform's whoever drew the field.
1. Register it from the client entry (Module API 1.39.0)
// src/client/index.ts — React, so the client entry; the server entry stays Node-renderable.
import { moduleRegistry } from '@purple-cat/shared';
import { MySheet } from './MySheet';
moduleRegistry.registerCharacterSheetRenderer({
systemId: 'my-system',
component: MySheet, // React.ComponentType<SheetRendererProps>
pages: [{ id: 'character', label: 'Character' }, { id: 'spells', label: 'Spells' }],
supports: { print: true },
});
and on the server side, in your SystemModule, hasSheetRenderer: true, so
the platform (and the module-version pins) can tell a module that draws its
own sheet from one that takes the platform's without loading the client entry.
The registration is validated at load: a page with no id throws there rather
than at the first render. A module that registers none keeps the platform's
sheet — the quick view — exactly as before.
2. What you are handed: SheetRendererProps
| Prop | What it is |
|---|---|
character |
The record, your construct already applied (the projection's manifest) |
token? |
When opened from a token: its runtime (HP, conditions) |
mode |
'view' (locked: everything reads; rollables roll, action buttons run) · 'build' (unlocked: fields edit in place, complex ones open a field editor) · 'print' (no controls, snapped to pages) |
recentChanges |
RecentChange[] — every runtime write the platform made in the last ~8 s: { path, before, after, at, source } with source one of damage, heal, condition, rest, action, edit, remote. You decide how to show it (pulse the HP box, float "−25"); the platform only promises the user will be shown. Declarative on purpose — no handle into your component (NS-D6). |
focusFieldId? |
Host → sheet: the field to focus and scroll to (a walkthrough step). The kit's primitives handle it when given an id. |
api |
Sheet → platform, below |
kit |
@purple-cat/sheet-kit's exports, handed in so you never import the platform |
equipment? |
1.40.0 — the character's equipment, live: the platform's equipment table's items for it (its own table with its own API, not a trait channel). Change or remove one through api.equipment; add one through api.openPicker('equipment'). Absent on a host older than 1.40.0. |
validationIssues? |
1.48.0 — what is wrong with the character: your module's validate, run by the platform, with muted rules taken out; empty when the character's validation is off or report-only. Mark the section each issue belongs to (the platform's sheet uses a yellow triangle for warnings and a red octagon for errors). Absent on a host older than 1.48.0. |
3. What you call: SheetApi
| Method | Does |
|---|---|
edit(intent) |
The one write path. An intent in the platform's vocabulary; your module's validateEdit may refuse it and its applyEdit lands the CS-8 four; the platform lands the rest. Returns { ok, reason? }. |
roll(expression, label?, { silent? }) |
The chat seam: a labelled roll reads as a sentence in the character's campaign chat, a bare one as a dice frame. null when the character is in no campaign. |
runAction(actionId) |
Runs one of the character's actions — when a token of this character is impersonated on the map (actions run from the map, as the quick view's do); otherwise tells the user. |
sendMessage(html) |
A chat line as the character. |
openPicker('trait' | 'equipment' | 'attack', subtypeId?) |
Opens your module's own editor (registered with registerCharacterSheetEditor, CS-9), mounted by the host; what it adds goes through edit / the equipment API. For trait, subtypeId is the subtype (power, feat); for attack (1.40.0) it may be an existing attack's id, which opens the editor on that attack and replaces it on save. ability-scores (1.40.0) and timed-effect (1.42.0) open the platform's generation modal and timed-effect dialogue; artwork (1.48.0) opens the platform's artwork editor for the character (upload, replace, frame, choose the portrait), whose writes come back in the next character. |
validation? |
1.48.0 — turning a validation rule off from the sheet: muteScopes (where this user may, with labels; empty means offer nothing), ruleName(ruleId), mute(ruleId, scope). The issue leaves validationIssues on the next render; the platform's issue list turns rules back on. |
registerField(id, el, order) |
The field registry (the kit's primitives call it for you). Returns the unregister. |
campaign.configuration(key?) |
v0.102.0's resolved configuration — a key or the whole map, defaults applied. |
treasury.holdings() / treasury.carrying() / treasury.claim(itemId) |
v0.103.0's Treasury: the items held by or earmarked for the character; the party bucket it carries, with its weight; 1.40.0 — Claim takes an earmarked item through the Treasury's one transfer (the Treasure box's Claim line). |
can |
{ edit, control } — the resolved access, for what to draw. |
notify({ title?, message, kind? }) |
The platform's notifications. |
damage.apply(delta, attribute?) / damage.prompt() |
v0.78.0's helpers: move an attribute's current value (negative is damage) through the write path and the feed; open the map's Heal / Harm prompt for the sheet's token (returns whether it could). |
rest(restId, choices?) |
1.40.0 — the platform's rest flow (CS-1.11) behind the API: one of your restTypes by id. The host asks the rest's prompts your choices did not answer (hit dice to spend, Arcane Recovery) in its own dialogue, runs your applyRest, writes the patch (arrays first, then the rest — the PATCH guard), posts the summary to chat and records the change in the feed. Your sheet draws the button; it never re-implements the rest. |
equipment.update(itemId, patch) / equipment.remove(itemId) |
1.40.0 — change an item of the equipment prop (quantity, equipped, a custom name) or remove it, through the platform's equipment API. |
Your own fields (1.40.0, N5.4, NS-D7): declare what you keep under
metadata.<systemId>.* — or under the metadataNamespace your sheet schema
names, when one package serves several systems (5e's two editions share
dnd5e) — in your sheetSchema.metadataFields —
{ path: 'alignment', label: 'Alignment', kind: 'text', section: 'Identity', importHint: 'the alignment, e.g. Chaotic Good' }.
The write path type-checks a set-metadata intent against a declared field
(an undeclared path is still yours to write: declaring earns the label and the
check, it does not permit the write), the History panel files a change to it
under your section with your label, and the importer asks the model for
every field with an importHint and lands the answer under your namespace.
Intents (SheetEditIntent): the Character Sheet API's four —
set-attribute-component, add-trait, remove-trait, adjust-ability —
plus set-field (a simple platform field: name, level, notes, classes…),
set-runtime (attribute:<name> → the attribute's current value),
set-metadata (<systemId>.path.to.value, or <metadataNamespace>.… — your own namespace of the
record, NS-D7: the boxes your system has no platform field for live here,
declared in your sheet schema so validation, history diffs and the import know
them), and patch (an explicit Partial<Character>, the escape hatch). An
intent the platform cannot read is refused before the write path.
4. Build it from the kit
@purple-cat/sheet-kit — its README (packages/sheet-kit/README.md in the
platform repository) lists every export. Two families: layout (Sheet, Page, Region,
Field, Checkbox, Lines, Portrait) and editing (EditableField,
FieldEditor, NumericStepper, Rollable, ActionButton, ValidationMark,
RestButton, ResourcePips). Colours come from skin (the platform's
--vtt-* tokens per skin), bands from zBand; a sheet that uses only the kit
cannot break a skin, a z band or the write path (NS-D1).
Geometry from your PDF. A fillable PDF is a layout file. Read its fields
into a layout.json — { name, page, x, y, w, h } per field, y from the
top — and derive a PageLayout per page (pageLayoutFromFields, or your own
grouping of fields into the regions your sheet actually draws, which keeps the
grid coarse enough to reflow). Page turns it into a CSS grid whose tracks are
the form's bands: at the design width (612 px, the page at 1 px/pt) a region
sits exactly where the printed box does, and below the module's reflowBelow
the grid reflows to one column in reading order (NS-D5). The layout is the
form's; the chrome is yours (NS-D3).
The field editor contract. FieldEditor opens on focus of the field it
wraps, closes on blur (focus leaving both the field and the popover) or
Escape, traps focus while open, and returns focus to the field. In view and
print it does not open. Ability scores are the canonical case: the popover
shows at creation (read-only — generation set and froze it, NS-D10), the
step component (asi / proficiency) on a stepper, other, and the final
value; Regenerate is the only way back to base.
Stable ids and order. Give every field an id (dnd5e.abilities.str)
and an order that is the form's reading order. The host keeps the registry,
moves Tab / Shift-Tab between fields by order, and can focus a field on
request — what the creation walkthrough (v0.108.0) will drive.
5. Modes and print
Read mode (or the kit's useSheetMode()): the kit's controls are inert
outside build except the things a player does at the table — rollables,
action buttons, rests, pips, and a NumericStepper marked always. In
print the kit hides the tabs and every data-sheet-control, Sheet's
media rules snap each Page to a sheet of paper and keep regions whole, and
the host's stylesheet hides the app around the sheet. Export PDF on the ⋯
menu is the browser's print path; declare supports: { print: false } to
disable it.
6. The fixture is the smallest complete example
@purple-cat/sheet-kit/fixture — FixtureSheet for the fixture-system:
two pages, ten regions from a layout.json, every feature above once, and
FixtureSheet.test.tsx running it end to end against a recording API. Read
it before writing yours.
7. Where the sheet appears
The Sheet left-sidebar context ('sheet', the standard sidebar →
floating-window logic), the route /characters/:id/sheet, and three entries:
the character page's ⋯ → Sheet view, the Character context's header
icon, and the token menu's View Character Sheet on /play.
8. The 5e sheet, read as a worked example (v0.106.0)
dnd5e is the first module to ship a renderer, and its files are worth
reading in this order (modules/dnd5e/ in this repository):
| File | What it is |
|---|---|
scripts/extractLayout.mjs |
The geometry, from the fillable PDF: every form field's rectangle, grouped into the 45 boxes the sheet draws, with each page's columns and reading order. The PDF is not committed; the script names the copy to run it against. |
src/sheet/layout.json · geometry.ts |
What it wrote, and the PageLayout per page the kit's Page takes, plus the module's reflow breakpoints. |
src/sheet/metadata.ts |
The boxes the platform has no field for, under metadata.dnd5e.*, declared for the write path, history and the importer (§4 above). |
src/sheet/model.ts |
The view model: every number the sheet shows, from the module's own rules — the same functions the projection and the rests read, so the sheet and the token cannot disagree. Pure, and tested in node. |
src/client/sheet/chrome.ts |
The module's own containers, labels and frames — one stylesheet, every colour a --vtt-* token, a Retro variant and a plain one. |
src/client/sheet/writes.ts |
Every write as an intent through api.edit, in one file, with the platform's PATCH shapes honoured. |
src/client/sheet/{CharacterPage,DetailsPage,SpellsPage}.tsx |
The three pages, box by box. |
src/client/sheet/editors.tsx |
What each field editor's popover shows. |
src/client/sheet/Dnd5eSheet.test.tsx |
The whole sheet rendered under jsdom against the real kit and a recording API — the shape to copy for your own. |
Two traps it hit, which your sheet will hit too.
A form field is not the printed box. A region's rectangle derived from the
PDF's fields is only as big as the fields; on the 5e sheet the skill and save
NAMES are printed artwork, so those boxes came out 40pt wide and the first
playtest showed a column of bare numbers with every name clipped. Where the
printed box is bigger than what is inside it, say so (WIDEN_TO, SPREAD)
and guard it.
A skin can undo your type. The sheet's labels fit their boxes at 6.5px —
until the Retro skin's own rule widened their letter-spacing and pushed
"INITIATIVE" out of its shield. A label that must fit a printed box is worth
measuring in each skin you ship for (el.scrollWidth > el.clientWidth, and
a one-word label whose box is two lines tall). --sk-region-padding is the
other half of that budget.
The phase-3 automation re-read (NS.4)
Character sheet automation
put the sheet's automation (damage helpers, rests, resource spends, derived
actions) in the platform, called by the platform's sheet. Behind the API the
direction is the same and the caller changes: the module's sheet calls
api.damage, api.runAction, api.edit for a rest or a spend, and the
platform's helpers do what they did — the module never re-implements a rule
the platform already carries, and the platform never learns a system's
vocabulary to call it.
The inventory: system-specific code still in apps/web (NS.1)
Where the platform still names a system, what the branch does, and where it
goes when a module owns the sheet. Measured against the systemLiterals
guard's baseline at v0.105.0 (13 apps/web files) plus the branches its regex
does not catch (startsWith('dnd5e'), isSystem8, system5). Files marked
dead are deleted by NS.7 of this release; the rest are the round-2 backlog,
each with the module (or platform seam) that replaces it.
| File | Sites | What it does | Destination |
|---|---|---|---|
components/CharacterDetail/PlayModeView.tsx |
is5e / isSystem8 (2) |
The retired Play Mode's edition and system switches | dead — deleted (NS.7) |
components/CharacterDetail/CharacterDetail.tsx |
systemId !== 'system8' + 3 |
The classic sheet's System 8 advancement-metadata fetch; two systemId passthroughs are not branches |
dead — nothing imports it; deleted (NS.7) |
components/TokenSheet/TokenSheet.tsx |
— | The retired token sheet (no literal, but the same sunset) | dead — deleted (NS.7) |
components/Contexts/CharacterContext/CharacterContextBreadcrumb.tsx, hooks/useSectionScrollSpy.ts |
— | The classic sheet's sticky breadcrumb and its scroll spy; nothing imports either since 1e.2 | dead, out of NS.7's scope — the next sweep |
utils/characterToToken.ts |
isSystem8 (attack proficiency = 0) |
The client-side projection's System 8 attack rule | system8's projectToken (CS-6) already carries the rule server-side; the client copy dies when the last client projection caller goes (the renderer's SheetApi.runAction never projects client-side) |
utils/wikiDocUrl.ts |
DEFAULT_CONTENT_SYSTEM_ID = 'system8' |
The default system whose reference documents a trait detail opens | the module's contentProvider names its own document base; the platform reads it from the module, not a constant — Content API |
utils/system8Records.ts |
(via the default above) | System 8 record shapes for the trait detail | system8 — getDetail (CS-5) already supplies the detail; the shapes go with the renderer (v0.107.0) |
components/Contexts/SystemContext/SystemContext.tsx |
SYSTEM_URLS table (5) |
The System context's default reference URL per system | SystemModule.referenceUrl (a one-line Module API addition) — the module says where its rules live |
components/UI/InsertMonster/InsertMonster.tsx |
FIVE_E_MONSTER_SYSTEMS (4) |
Which systems browse the 5e SRD bestiary | Monster API M-4 — contentProvider.listMonsters(); the list dies when a second system serves a bestiary |
components/UI/MonsterGenerator/MonsterGenerator.tsx |
isSystem8Campaign (+ ~25 dependent identifiers) |
The generator's System 8 form (role, class preference, casting stat, seed) beside the 5e CR form | system8's generator module already supplies templates and generateCharacter; the form should come from SystemModule.generatorFormSchema (declarative, like campaignConfigurationSchema) so the platform draws either without knowing which |
interfaces/ICampaign.ts, utils/characterYaml.ts, utils/diceRoller.ts, utils/propTypes.ts, utils/tokenApi.ts, components/UI/JsCodeEditor/actionApiCompletions.ts |
comments and a typeof check (8) |
Example ids in comments; typeof parsed.systemId !== 'string' reads as a branch to the regex |
not leakage — reworded when touched, as dbSchema was; stays baselined until then |
index.tsx |
setSystem8ProxyUrl |
The legacy System 8 wiki proxy configuration at boot | already deprecated (feats.service logs so); deleted with feats.service.ts when packages/shared's System 8 record services go to the module |
Not in apps/web but in the same round: packages/ui-components's
CharacterSheet.tsx (28), FeatsList (10), AddFeatModal (8),
SpellsList (8) and six more — the pre-overhaul sheet components nothing on
the current sheet uses — and packages/shared's feats.service.ts (10) and
dataTableApi.ts (4). They are the same sunset as PlayModeView a package
over, and are judged the same way when the ui-components package is next
opened; they are outside this release's scope because nothing here reads them.
The guard's baseline ratchets down with NS.7 (PlayModeView 3 → 0,
CharacterDetail 4 → 0) and may never go up: a new system name in apps/web
fails the build.