Steps to Reproduce
{% character name="Veshra" %}
Prose about Veshra.
{% /character %}
Expected
The paragraph renders inside the character's body slot, as the identical shape does on {% realm %} and {% faction %}.
Actual
The body slot is emitted as an empty <div data-name="body"> and the paragraph is gone. No warning, no error.
Cause
plugins/storytelling/src/tags/character.ts names its greedy prose field header and then never reads it:
fields: [
{ name: 'portrait', match: 'image', optional: true },
{ name: 'header', match: 'heading|paragraph', optional: true, greedy: true },
{ name: 'items', match: 'tag', optional: true, greedy: true },
],
transform() references resolved.portrait, resolved.items and resolved.sections, but never resolved.header. The body slot is then built from the items cursor rather than from any prose:
const body = !hasSections ? sectionNodes.wrap('div') : undefined;
so with no child sections and no child tags it wraps nothing.
Realm and Faction name the same field description and feed it into their body slot, which is why they are unaffected. The fix is to make Character match its siblings.
Impact
Content silently disappears — the failure mode SPEC-125 exists to remove. It also makes Character's new body role (added in WORK-530) inert in practice: the role is correctly declared and data-reading / data-dropcap land on the slot, but there is never any prose in it for a reading register to style. A character's visible prose lives in its {% character-section %} children, which carry their own body role.
Notes
Not fixed in WORK-530 deliberately. That item corrects section-role data; this is a content-model defect whose fix makes prose that currently vanishes start rendering — a visible output change on its own terms, and one that wants its own test and changeset rather than riding along with an audit.
Environment
@refrakt-md/storytelling 0.30.1 (present since the rune was written)
Resolution
Completed: 2026-09-09
Branch: claude/milestone-v0-31-0-e5ihxr PR: refrakt-md/refrakt#594
What was wrong
Two defects, either of which alone would have lost the prose.
- The field was never read. The content model's greedy prose field was named
header and matched heading|paragraph; transform referenced resolved.portrait, resolved.items and resolved.sections, never resolved.header. The prose was resolved and discarded. - The body was built from the wrong cursor, and conditionally.
const body = !hasSections ? sectionNodes.wrap('div') : undefined — the items cursor, and only when the character had no sections. So even had the field been read, lead prose and sections could not coexist.
The fix
plugins/storytelling/src/tags/character.ts now matches its siblings:
- The field is
description, matching paragraph. Dropping heading from the match costs nothing — a heading in the preamble starts a section, so it never matched there. buildStoryContent(...) replaces the bespoke body assembly. It is the helper realm and faction already use, so the three shapes now share one implementation rather than three that agree by inspection. extraDescription is empty for Character: it extracts its portrait from an image field directly, so unlike Realm's extractScene there is no leftover text.refs and children emit body and sections independently instead of one or the other.
plugins/storytelling/src/config.ts — the content column is now ['preamble', 'metadata', 'body', 'sections'], matching Realm and Faction. The old order had sections first, which was invisible while the two were mutually exclusive and would have put lead prose after its own sections once they could coexist.
Tests
plugins/storytelling/test/character-body.test.ts (5) — prose renders; the three sibling runes agree on identical markup (a control, so a regression on any of them reads as a difference between them rather than three assertions that rot separately); lead prose and sections coexist; no prose still emits no body slot; and a portrait alongside prose leaves both intact, since they are adjacent fields in the content model.
Direction-checked: 4 of the 5 fail against the pre-fix code.
One wrinkle worth recording — the first draft of the "no prose, no body slot" case passed against the wrong node. character-section has a body slot of its own, so an unscoped tree search finds a section's body on a character that has none. The test now walks only as far as the next data-rune.
Notes
- The only structure-contract change is the two-line layout reorder; no rune's emitted attributes or classes moved.
- This makes Character's
body section role meaningful in practice. WORK-530 declared it correctly and data-reading / data-dropcap did land on the slot — there was simply never any prose in it, which is why WORK-537 audited Character on intent rather than on output.