Sheet Kit API
Structured character sheets that play: the core draws a small set of building blocks, your system decides what every button does.
Why a Sheet Kit
Sheets that play, a core that stays small
The declarative sheet-system contract covers character creation. A sheet that is used at the table also needs state and rules: action economy, reactions, per-rest limits, conditions, equipment with effects, dice pools, class mechanics. A Sheet Kit adds both as an optional kit member on a sheet-system plugin — without teaching the Fey-Gate core any game rules.
Three layers (a construction kit)
Layer 1 — building blocks (core, system-neutral): the KitNode kinds below. The core only knows how to draw them and how to send an intent back.
Layer 2 — system description (data): which blocks a system shows, with which labels and limits — the output of view().
Layer 3 — custom logic (optional code): apply() decides what an intent does. It ships inside the trusted plugin module, same trust model as every plugin. Keep the automation here, never in the core.
Three pure functions
The core calls only view(doc, ctx) (what to draw), apply(doc, intent, ctx) (new doc + messages, optional prompt and dice) and toMarkdown(doc, ctx) (read-only markdown-plus projection). All three are pure, so a kit is fully testable without a browser.
export default {
id: 'my-system', name: 'My System', version: '1.0.0', apiVersion: 1,
classes: [], // may stay empty — the kit runs creation itself
kit: {
kitVersion: 1,
createOptions: [{ id: 'wizard', label: 'Step by step' }],
create(optionId, name, ctx) { /* → KitDocument */ },
view(doc, ctx) { /* → KitView */ },
apply(doc, intent, ctx) { /* → KitApplyResult */ },
toMarkdown(doc, ctx) { /* → { markdownSource, fieldValues } */ },
nameOf(doc) { /* → string */ },
migrate(raw) { /* → KitDocument, or throw */ },
importFile(json, ctx) { /* optional: other apps' files → KitDocument[] | null */ },
},
}; KitDocument & Storage
KitDocument
{ systemId, kitVersion: 1, schema, data, updatedAt }. systemId is the plugin id; schema is your own version of data (for migrate); data is opaque to the core and must be plain JSON; updatedAt is set from ctx.now on every change.
One file for every client
A kit sheet is an ordinary CharacterSheet record with an extra kit field. The core regenerates markdownSource and fieldValues from it after every change, so the DM panel, sheet:sync, the folder mirror and older builds keep working. The same JSON file (db/characterSheets/<id>.json, or an exported .fey-sheet.json) can be read and written by other apps — e.g. the Char Manifest Android app. Newest updatedAt wins on folder reconciliation; manual import never silently replaces a newer local copy.
KitContext & Rules
KitContext
lang (KitLang: "de" | "en", toggled per device), random(sides) — the only allowed randomness (crypto-backed in the app, seeded in tests), now (ms), viewer ("owner" may act, "readonly" is the DM's view — hide or omit buttons).
KitIntent
{ id, args? } — what a button, pip, counter, choice, note or prompt sends back to apply. Both id and args are yours to define (e.g. "combat:endTurn", { id: "hp:heal", args: { amount: 4 } }). The core only adds args.delta, args.index, args.value, prompt field values and args.preview as described below.
Contract rules
1. view/apply/toMarkdown are pure: no DOM, network, storage, globals, Date.now() or Math.random().
2. apply never mutates its input — return a new doc.
3. Unknown intents are ignored (return the doc unchanged). Never throw for user input; the core turns a throw into an error message and keeps the old sheet.
4. migrate gets untrusted input (disk, network, other apps): validate, clamp and throw on garbage.
5. Node ids are stable within a view, so open tabs and sections survive re-renders.
Building Blocks (KitNode)
KitView
{ title, subtitle?, header?, body }. header nodes are shown above the body on every tab (hit points, conditions, the action bar).
Layout: section, tabs
section groups children under an optional title/subtitle; collapsible/collapsed make it fold, tone colours the frame. tabs holds KitTabs ({ id, label, children }), active is the initial tab.
Values: stats, track, pips, counter, dice, badges, kv, text
stats — grid of KitStatItem (label, preformatted value, sub, tone, optional intent = tap to roll), columns.track — current/max bar with temp and inline actions (hit points, mana).pips — filled of total; tapping sends intent with args.index.counter — a number with −/+ (args.delta = −1/+1), optional max.dice — a pool of rolled dice values of type die with actions.badges — status chips (KitBadge: label, tone, title tooltip).kv — label/value rows. text — a hint line.
Interaction: actions, list, note, choice
actions — a grid of KitAction buttons (label, sub, intent, tone, disabled, confirm = ask first).list — KitListItems (title, subtitle, text, badges, actions) with an empty text.note — editable text, sends args.value when it loses focus; rows 1 = single line.choice — inline pick (creation wizard, level-up) from KitChoiceOptions (value, label, text, badges, disabled); sends args.value. With multiple: n the value is an array of at most n (the oldest pick drops out).
{ kind: 'track', id: 'hp', label: 'HP', current: 7, max: 12, temp: 2,
actions: [{ id: 'dmg', label: '− Damage', tone: 'bad', intent: { id: 'hp:damage:open' } }] } KitTone
normal, accent, good, warn, bad, muted — mapped to the Fey-Gate design tokens. Never pass colours.
Prompts (KitPrompt)
Asking before applying
Return prompt from apply to show a form: { id, title, text?, fields, submit: { label, intent, tone? }, preview?, cancelLabel? }. On submit the core merges every field value into submit.intent.args and calls apply again. Without cancelLabel the prompt is a pure notice with one button that applies nothing (use it for "already used this round").
KitField kinds
number (min, max, keypad = big phone keypad), toggle, radio (options are KitChoiceOptions), stepper, text (multiline), info (read-only line with tone). Each has a key; value is the default.
Live preview
With preview: true the core calls apply(doc, { ...submit.intent, args: { ...args, ...values, preview: true } }) on every edit and ignores the returned doc. It shows the returned messages as a preview line, and if a prompt with the same id comes back it swaps in its fields (keeping what the user entered) — that is how a form shows or hides options depending on other fields. In preview mode a kit must not change anything.
case 'hp:damage': {
const taken = args.defend ? Math.max(0, args.raw - armor) : args.raw;
if (args.preview) return { doc, messages: ['HP ' + hp + ' → ' + (hp - taken)] };
return { doc: withHp(doc, hp - taken, ctx), report: [{ heading: name + ': −' + taken + ' HP' }] };
} KitApplyResult
What apply returns
doc — the next document (the same object when nothing changed).messages — short toast lines on the player's device.prompt — ask first (see above).rolls — KitRollShowns (label, dice as KitDie { type: "d20", value }, modifier, total, combat): shown as 3D dice; rolls with combat: true are also sent to the table as a dice:roll.report — { heading, body? } lines for the DM's live log (sheet:ability:share), sent only from a player's companion device.
What the Core Does
Behaviour you get for free
"+ From System…" lists the kit's createOptions and calls create. The sheet view draws view(), saves after every applied intent (a user-initiated mutation — never on load), keeps a short in-memory undo, offers a DE/EN toggle, export as .fey-sheet.json and import (one-file format, a bare KitDocument, or anything your importFile recognises). Companion devices send the doc along with sheet:sync (optional kit field); the DM panel renders it read-only with the DM's copy of the kit after migrate(), or falls back to the markdown projection when the plugin is not loaded there.
What the core never does
Interpret data, compute rules, auto-apply anything, or run a kit function outside a user action (except view/toMarkdown, which are pure).
Build a System (also with AI)
Recipe
1. Copy examples/plugins/sheet-kit-example.js (Tiny d6, ~200 lines — every part of the contract).
2. Design data: only what a player chooses or spends (choices, current HP, used limits, inventory). Everything derivable (max HP, attack bonus) is computed in view.
3. Write view with the blocks above, then apply one intent at a time, then toMarkdown.
4. Write migrate defensively.
5. Validate: npm run validate-sheet-kit -- path/to/plugin.js (shape, purity with a fixed random/now, JSON round-trip, no mutation, every visible intent applies without throwing, read-only view has no actions).
6. Drop the file into the campaign plugins/ folder and approve it in Settings.
Prompt for an AI assistant
Give it src/plugins/sheet-kit-types.ts, this page and the example, plus your rules text, and ask: "Write a Fey-Gate sheet-system plugin with a Sheet Kit for <system>. Keep view/apply/toMarkdown pure, use ctx.random for dice, never mutate the input doc, ignore unknown intents. Then run validate-sheet-kit and fix every finding."
Generality check (paper test)
Nimble (built): action pool → pips/badges, reactions → badges + actions, wounds → pips, damage with Defend → prompt with preview.
D&D 5e: abilities/saves/skills → stats, spell slots → pips per level, death saves → two pips, concentration → badges, short/long rest → actions + prompt (hit dice via stepper).
Daggerheart: Hope/Fear → counter, HP/Stress slots → pips, Duality dice (2d12) → rolls with two KitDies, domain cards → list with actions.
PbtA: stats → stats (2d6+stat on tap), moves → list with the 10+/7–9/6- result as messages, harm clock → pips, bonds → note. No new block kind was needed for any of them.
Reference: SheetKit
Members
kitVersion (1), createOptions (KitCreateOption: id, label, text; at least one), create, view, apply, toMarkdown, nameOf, migrate, optional importFile. The loader rejects a kit with another kitVersion, a missing function or no create option. kitVersion bumps only on breaking changes.
Shipped examples
examples/plugins/sheet-kit-example.js — Tiny d6, the readable reference.examples/plugins/nimble-charmanifest.js — the full Nimble 1st edition sheet (all classes, levels 1–20), built from the Char Manifest app's rule engine so app and Fey-Gate behave identically.