v0.40.0
Released October 8, 2026 · All releases
The first composed rune. A rune can now be defined as a Markdoc template that places existing runes, instead of a transform written in code. Composition is proved on two storytelling runes, bond and character, beside the plugin's own versions, so nothing you render changes yet. Separately, sandbox's context attribute now takes effect: context="dark" gets the dark token set instead of the default one.
Action needed for theme authors: a theme can no longer override a rune's sequence, a meta field's metaType, or a layout wrapper attribute that a rune's identity emits. Each is part of what the rune means, not how it looks. Such an override is now dropped and reported; the presentation keys beside it still apply.
Background: the milestone · Composed runes · The identity guard is path-granular
Changes
- A
RuneConfigmay now omitblock(SPEC-145 D2a). A block-less config gets norf-*classes, on its root or on its named children, and neverrf-undefined. Everything else the identity transform does still applies: modifiers render asdata-*attributes, universal attributes and metadata blocks render, anddata-rune-fieldsis stripped.refrakt contractsdescribes a block-less entry by[data-rune]and itsdata-*modifiers, with no BEM selectors.validateThemeConfigandrefrakt plugin validateaccept a missingblockand still reject an empty one.refrakt inspect --auditandscaffold-cssskip block-less runes, because they ship no CSS. No shipped rune drops its block yet, so existing output is unchanged. - A rune can now be defined by a composition template (SPEC-145). A definition is YAML frontmatter declaring the rune's input (
tag,attributes,content,schema,registers,metaFields,blocks) and a Markdoc body that places the content model's fields into slots of existing runes:{% slot name="x" /%}, with fallback content, and{% slot name="xs" each %}…{% /slot %}with$eachbound to the model'semitAttributes.$attrs.<name>and Markdoc's own{% if %}work in the template. The template renders at the rune's transform, after field resolution, so every placed rune runs its own transform unchanged. Slot-placed nodes carrydata-slotin the output; a slot adds no element. The composed root carries the rune's owndata-runeand a generated block-less config, and a node-sourcedregistersvalue is copied into the field bag for Phase 2. - Every bad definition fails at load or construction, naming the rune and what was rejected: a preprocessor tag in the template, a second emit path, an unplaced field, an unknown or repeated slot,
eachon a single value, an undeclared$eachor$attrsfield, a cycle, a peer schema type, a placed rune missing its required parent, and CSS. PluginRunecarries exactly one oftransformortemplate(SPEC-153 D11);transformis now optional on the type.refrakt contractsrecords a composed rune's slot names and expansion. No shipped rune is composed yet, so contracts and structured data are unchanged.- New exports from
@refrakt-md/runes:defineComposedRune,composedPluginRune,pluginRuneSchema,checkComposedCatalog,collectCompositions,compileComposition,compositionFor,parseCompositionDefinition,composedRuneConfig,composedTypeName,checkCompositions,SUBORDINATE_SCHEMA_TYPES,validatePlugin.createContentModelSchematakes atemplateoption. data-owneranddata-slotnow survive a primitive's transform, ahead of composed runes (SPEC-145 D10a). The page pipeline declares both attributes once, when it assembles the Markdoc config, on every node and tag schema. No rune declares them itself, so authoring tools do not offer them. The schema-table resolvers admit a node bydata-ownerplusdata-slot, and a primitive's owndata-nameon that node still resolves for the primitive.releaseOwnedNodesstripsdata-ownerand keepsdata-slot. Nothing sets the markers yet, so rendered output and structured data are unchanged.- New exports from
@refrakt-md/runes:declareSlotMarkers,declareSlotMarkersOnNodes,OWNER_ATTR,SLOT_ATTR. sandbox'scontextattribute now takes effect. The sandbox transform never emitted it, so the design plugin always resolved a sandbox to thedefaultdesign-context token set:context="dark"gotdefault, and an undefined scope gotdefaultwith no warning.contextis now a field in the rune'sdata-rune-fieldsbag. A named scope gets its own token set, an unknown scope gets none and raisesSandbox references design context "…" which is not defined on any page, and a sandbox with nocontextstill getsdefault. The editor's block preview reads the same field, so it matches the build.- A composed rune's template can place a declared meta block with
{% metablock name="…" /%}(SPEC-145 D7). A composed rune has nolayout, so until now a definition could declaremetaFieldsandblocksin full and get nothing rendered. The tag marks where the block goes. The engine fills it from the rune's own config and values, anywhere in its template's subtree, using the same rendererlayoutprojection uses, so the block renders identically and a theme'sblocksoverride reaches it unchanged. - The tag is template vocabulary only: on a page it is an undefined tag. Each of these is rejected when the definition is built, naming the block: a name that matches no declared block, a block placed twice, and a placement inside an
eachslot. A block whose fields all resolve empty emits nothing. - Every attribute a
metaFieldsentry reads is now a modifier of the generated config, so it also renders as adata-*attribute, as on a tree-owning rune.METABLOCK_ATTRandMETABLOCK_OWNER_ATTRare exported from@refrakt-md/transform. No shipped rune is composed, so existing output is unchanged. - The identity guard is path-granular (SPEC-158).
IDENTITY_FIELDSnow holds config paths, andfindReservedFieldsresolves a wildcard segment. Three protections join the existing eight keys, each dropped and reported rather than thrown: - A theme override of
sequenceis ignored — whether an ordinal is information (a playlist's track numbers) is rune identity.sequenceDirectionstays overridable. - A theme override of
metaFields.<field>.metaTypeis ignored while that entry'slabel,sentimentMapandtransformstill merge; an override entry that leavesmetaTypeout keeps the declared one. - A
layoutwrapper'sattrsmay not set an attribute an identity field emits (data-section,data-media,typeof,property,data-sequence,data-meta-type, ordata-{name}for a declared modifier). The attribute is dropped at config assembly and the wrapper's other attributes stand;validateThemeConfigreports it as an error.data-zone-layoutand other presentation attributes are unaffected. mergeRuneConfig,derivedAttributeOwner,IDENTITY_FIELD_ATTRIBUTESandderivedAttributeMessageare now exported. Nothing in the shipped plugins or Lumina is affected.@refrakt-md/planand@refrakt-md/docsnow export./package.json. Theirexportsmaps previously left it out, sorequire.resolve('<pkg>/package.json')threwERR_PACKAGE_PATH_NOT_EXPORTEDand anything reading the installed package (such as plugin fixture discovery) silently found nothing.@refrakt-md/plan'sfilesalso no longer lists astylesdirectory that does not exist.- The plan scanner no longer descends into dot-directories such as
.git. Their contents are not plan content, and a lock file git deleted mid-scan could fail the scan withENOENT.