Skip to content

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-kit of 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.