v0.31.0

Released September 8, 2026 · All releases

Section-role correctness. reading and dropcap now work on runes where they were silently ignored, most visibly card, because eleven runes now declare which of their parts is the body or the header. No build breaks, but those runes gain data-section and can render differently. Which universal attributes apply to a rune is now part of the rune itself, so a theme cannot change it.

Background: · ·

Changes

  • Correct missing section roles on card, bento-cell and accordion-item (SPEC-125 Phase 1)
  • Three runes declared a slot in their layout that their sections map never mapped to a role. Because data-section is what reading, dropcap and prominence gate on, the omission silently dropped those attributes — {% card reading="prose" %} did nothing at all, with no warning.
  • card gains a body role on its body slot. Its content model is literally "body (optional, repeatable any block)", so this is the clearest case of the three — and the one that surfaced the whole problem. reading and dropcap now land.
  • bento-cell gains both a body role and a title role; its title is a sibling slot of its body. reading, dropcap and prominence now land.
  • accordion-item gains a body role on its answer panel, so reading and dropcap now land there too. It deliberately declares no header-ish role: the header slot is the <summary> disclosure control, not a page-section header.
  • card deliberately keeps no title role — its leading heading is nested inside the body slot rather than beside it, so a title role would nest one section inside another and resize every card heading.
  • Rendered output changes, though under Lumina nothing moves. The three runes now emit data-section on those slots. Checked in a browser against the real stylesheet: [data-section="body"]'s declarations (line-height: relaxed, color: text) are already the inherited values, and [data-section="title"]'s type is outranked by .rf-bento-cell__title, so every element's computed style and box size is byte-identical at every density. A theme with its own [data-section] rules may see a change on these three runes; that is the intended, visible half of the correction.
  • Lumina additionally fixes a case this exposed: at density="minimal" the blanket [data-section="body"] { display: none } would have hidden a card's title along with its prose, because card nests the title in its body. Minimal density now keeps a body-nested title and hides only the body's other children — so {% card density="minimal" %} shows title-only rather than an empty surface. Previously it did nothing at all.
  • prominence is now declarable on bento-cell but has no visible effect under Lumina, which pins .rf-bento-cell__title's font size rather than letting it ride --rf-title-size. That is a skin gap, not a config one.
  • Enforce rune identity on theme overrides (ADR-028)
  • block, modifiers and sections are rune identity: they say what a rune is, and universal-attribute applicability derives from them — reading needs a body section role, prominence a header-ish one, cover a declared media-position modifier. Until now the IDENTITY_FIELDS rule guarded only SPEC-091 variant deltas, while the theme-override path shallow-merged without restriction, so a theme could write runes: { Card: { sections: {} } } and silently disable reading on every card in a site.
  • mergeThemeConfig now drops any of the three from a theme override and reports it, naming the rune and the field; the rune's own declaration stands. The rule itself moved to a single shared module (identity-fields.ts, exported as IDENTITY_FIELDS / VARIANT_DELTA_RESERVED_FIELDS) that both merge paths consume, so the two cannot drift.
  • This is a no-op against everything the project ships — Lumina overrides no rune, and none of the nine official plugins collides with a core rune — and a test now guards that. The one capability removed is a theme reaching into modifiers to restate a modifier (e.g. to add a valueMap); that belongs on the rune's own declaration. Themes keep layout, structure, styles, contentWrapper, staticModifiers, autoLabel, editHints, projection and variants unchanged.
  • @refrakt-md/lumina additionally exports its overrides object as luminaOverrides alongside the merged luminaConfig.
  • Declare the schema↔engine join tables in tag modules (SPEC-125 Phase 2)
  • sections, mediaSlots and frameTarget are not theme configuration: their keys are data-names a rune's own transform emits and their values are a closed engine vocabulary, so the theme owns neither side. All 72 declarations across core and the nine plugins now live in the tag module that owns each rune, with ThemeConfig.runes referencing the declaration rather than defining it:
  • This is what makes narrowing possible. createContentModelSchema now has the gating facts at construction, recorded on a new schemaRuneStructures WeakMap alongside the existing schemaContentModels — so a rune's Markdoc schema can later offer only the universal attributes that can affect it, with no config lookup and no import cycle. The direction matters: config → tags holds uniformly across the repo, and having tags import config instead would cycle in core, where createContentModelSchema runs at module scope and would observe coreConfig uninitialised.
  • Pure relocation. No values changed, the engine's read path is untouched (it still reads config.sections, config.mediaSlots, config.frameTarget), transform output is byte-identical, and refrakt contracts --check passes with no regeneration.
  • IDENTITY_FIELDS gains mediaSlots and frameTarget, closing the gap the identity guard deliberately left. frameTarget in particular needs it: frame applicability resolves as config.frameTarget ?? (hasMediaSection(config.sections) ? 'media' : null) and the type has no 'none', so it can only ever grant — an unguarded theme could add frameTarget: 'self' to a rune whose schema rejects frame=, config granting what the schema forbids.
  • New public API on @refrakt-md/runes: schemaRuneStructures, plus the RuneStructure and SectionRole types. createContentModelSchema accepts sections, mediaSlots and frameTarget; all three are optional and nothing reads them yet.
  • Lint slot/role mismatches so section-role drift cannot recur (SPEC-125 Phase 1)
  • The twelve corrections in this milestone were found by an ad-hoc script comparing each rune's declared slots against its sections map. Nothing stopped the same drift returning the next time a rune gained a body slot — and the failure mode is silent: reading, dropcap and prominence are simply dropped.
  • @refrakt-md/transform now exports two new functions. declaredSlots(config) collects every slot a rune declares, from all five config fields that can introduce one (layout, structure, autoLabel, blocks, contentWrapper) — the reconstruction the ad-hoc script had to do by hand. lintSectionRoles(runes) builds on it and flags a rune declaring a body or heading slot with no matching role.
  • validateThemeConfig runs the lint, so refrakt validate and refrakt plugin validate fail on drift with a non-zero exit rather than only warning. Across the 132-rune catalogue the check is quiet: no findings, and no noise from the ~105 genuinely bodyless runes.
  • The check is direction-only — it flags a missing role and never a role that is present. A body role on a non-prose region (datatable's table, showcase's viewport) is an overload of what body means, not a data error, and themes style those roles directly; flagging them would push someone toward removing a correct role.
  • New RuneConfig.sectionRoleExceptions records a deliberate decision not to map a slot, keyed by slot name and valued by the reason, so the reasoning travels with the rune instead of living only in a commit message. Applied to the five runes this milestone deliberately left without a header-ish role — accordion-item, the three storytelling *-section runes, and itinerary-day — each carrying its own justification.
  • Theme and plugin authors upgrading may see refrakt validate fail on a rune that was quietly dropping an attribute. That is the check working: map the role, or record why not.
  • Correct missing section roles on playlist and itinerary-stop (SPEC-125 Phase 1)
  • playlist gains a body role. Its layout places tracks and body as separate slots, and the body slot holds the prose an author writes after the track list — so it is both structurally the body and genuinely prose-bearing. reading and dropcap now land on it. The track list stays unroled: it is structured content the rune reinterprets, and mapping it to body would invent the datatable-shaped overload SPEC-125 Direction 2 exists to avoid.
  • itinerary-stop gains a body role on each stop's note — authored prose, and the stop's only content region.
  • itinerary-day deliberately gains no header-ish role. Its parent itinerary already holds title on its headline, and prominence scales the header of a page-section family rune, so a second title in the same subtree would flatten the hierarchy the axis exists to scale. The contract keeps recording prominence as unavailable on the day, which is the accurate answer.
  • Rendered output changes: playlist and itinerary-stop now emit data-section="body" on those slots. Checked in a browser against the real stylesheet — every element's computed style and box geometry is unchanged. .rf-itinerary-stop__body already sets its own font-size, line-height and color, so the shared [data-section="body"] rules are outranked there; on playlist they apply but are already the values inherited from html. A theme with its own [data-section] rules may see a change on these two runes.
  • Correct missing section roles on the storytelling entity and section runes (SPEC-125 Phase 1)
  • Six runes in two families of three, each family resolving the same way across its siblings.
  • character, realm, faction gain a body role. All three share one shape — a preamble header holding name, a media slot, and a body slot the sections map never mapped — so reading and dropcap were silently dropped on all three. Their header roles were already correct, so prominence already worked; only body was missing.
  • character-section, realm-section, faction-section gain a body role on their prose, and deliberately gain no header-ish role. The entity's own name already holds title; a second title inside the same subtree would flatten the very hierarchy prominence exists to scale, and Lumina pins .rf-{block}__name's type outright, so the role would be inert there in any case. The contract keeps recording prominence as unavailable on these three, which is the honest answer.
  • Rendered output changes: the six runes now emit data-section="body" on those slots. Checked in a browser against the real stylesheet — including the realm case where the new body role sits beside an existing media role — every element's computed style and box geometry is unchanged. [data-section="body"]'s declarations are already the values inherited from html, and none of the six stylesheets sets line-height or color on its body element. A theme with its own [data-section] rules may see a change; that is the intended, visible half of the correction.
  • Note: character's entity-level body slot is currently always empty — prose written directly inside {% character %} is dropped by its content model, unlike realm and faction, which handle the same input correctly. The role is declared correctly here; the content-model defect is tracked separately as BUG-003.

v0.31.0 — Section-role correctness and applicability as identity

v0.31.0complete

v0.31.0 — Section-role correctness and applicability as identity

The first half of SPEC-125, governed by ADR-028: correct the section-role data, and make attribute applicability a rune-structural fact that a theme cannot change.

Nothing here breaks a build. The user-visible outcome is that reading and dropcap start working on runes where they were silently dropped — most obviously card, whose content model is literally "body (optional, repeatable any block)" but whose sections map never declared the role.

The half that does break — narrowing schemas so an inapplicable attribute is rejected rather than ignored — is v0.32.0.

Why this is a minor

Phase 1 changes rendered HTML on ~10 runes: declaring a section role adds data-section, which Lumina styles directly in styles/dimensions/sections.css and styles/dimensions/density.css. No build fails, but existing sites will render those runes differently without doing anything. Pre-1.0, caret ranges only allow patch updates (see RELEASING.md), so a minor is the right compatibility boundary for a visible change.

Shape

Phase 1 — audit and correct the section-role data. Six runes declare a body slot but never map it to the body role — Card, BentoCell, Character, Realm, Faction, Playlist — and six declare a title/header slot with no header-ish role — AccordionItem, BentoCell, CharacterSection, RealmSection, FactionSection, ItineraryDay. The remaining 105 runes with neither are genuinely bodyless and must be left alone.

Each needs a per-rune judgement, and each is a visible output change, so each carries its own justification and test. A lint lands with them so the drift cannot recur.

Phase 2 — lock applicability as rune identity. Two parts:

  • Extend the IDENTITY_FIELDS guard in packages/transform/src/validate.ts — today enforced only against SPEC-091 variant deltas — to the theme override path in mergeRuneConfig. Expected to be a no-op in practice, so it is independently landable and a good first commit.
  • Move the join tables (sections, mediaSlots) and frameTarget into their tag modules, with config referencing them. The engine's read path is unchanged.

Sequencing

Phases 1 and 2 are grouped into one release because they touch the same files. Phase 1 corrects sections values across ~50 rune configs; Phase 2 moves those same declarations into tag modules. Splitting them across releases would mean two passes over the same ~50 places for no benefit.

Within the milestone the identity guard can land first and alone — it depends on nothing and unblocks the argument for everything after it.

Deliberately deferred

Separating the prose capability from the body role is not in this milestone, even though the DataTable and Showcase cases surfaced alongside the Phase 1 audit. Those roles are correct — body is simply overloaded, and Lumina styles it — so the fix is a design change, not a data change, and its central question (default-on vs default-off) is only answerable once schemas have narrowed. It is Phase 4, in v0.32.0.

References

  • SPEC-125 — the spec
  • ADR-028 — applicability is rune identity, not theme configuration

Theme-independent attribute applicability

SPEC-125shipped

Theme-independent attribute applicability

Make a rune's universal-attribute applicability a rune-structural fact, visible at schema-build time, so authoring tools stop promising attributes that cannot do anything.

Governed by ADR-028, which settles the principle: applicability is rune identity, never theme configuration; a theme may decline to style an axis but never to suppress its emission.

runesattributesschemaconfigthemeclilanguage-serverdx

Problem

refrakt reference card prints, today:

Universal attributes (available on every rune): tint, tint-mode, bg, width, reading, dropcap, spacing, inset, elevation, prominence, reveal, stagger, frame, …

On a card, reading, dropcap and prominence do nothing. The CLI states as a fact something false about the rune it is describing. The language server's completeAttributeNames has the same blind spot: it iterates the rune's Markdoc schema attributes with no applicability filter, so the editor autocompletes prominence on runes where the engine will drop it with a warning.

Both are correct from where they stand — schemas are theme-agnostic and applicability currently lives in ThemeConfig.runes, which neither loads.

WORK-527 made the scale visible for the first time: 686 per-rune unavailability entries across 132 runes. 111 runes can never carry data-reading; 92 can never carry data-prominence.

Four of the seven gated axes warn when they are dropped. Three — reading, content-place and the cover scrim* family — are entirely silent.

Goals

  • A rune's Markdoc schema offers only the universal attributes that can affect it.
  • refrakt reference, editor completion and Markdoc validation become correct without loading theme config.
  • Applicability cannot be changed by a theme.
  • Universal-attribute availability is answered by a declared rule, not by which schema constructor a rune happened to use.
  • Every inapplicable attribute an author writes produces feedback — no silent no-ops.
  • Applicability gates are declared capabilities, not structural facts reused as proxies. See The governing rule below.

Non-goals

  • Letting a theme suppress attribute emission. Explicitly rejected in ADR-028.
  • Restricting what a theme may do with layout, structure, styles, contentWrapper, staticModifiers, autoLabel, editHints or projection.
  • Migrating the six hand-written schemas to createContentModelSchema. See Phase 3.
  • Changing any HTML the engine emits. This spec is about what authors may write and what tools promise; transform output is unchanged throughout.

Phases

The ordering is load-bearing: the data audit must land before schemas narrow, or wrong exclusions get frozen into validation errors.

Phase 1 — Audit and correct the section-role data

Applicability is derived from sections, and sections is currently both incomplete and, in places, over-declared. The audit runs in both directions — a role that is missing wrongly denies an attribute, and a role that is present wrongly offers one. Both become hard facts once schemas narrow, so both are worth settling first.

Direction 1 — roles that are missing

Six runes declare a body slot in their layout but never map it to the body role: Card, BentoCell, Character, Realm, Faction, Playlist.

Card is the clearest case. Its config is:

sections: { media: 'media' },
layout: { root: ['media', 'content'],
          content: { tag: 'div', children: ['eyebrow', 'body', 'footer'] } }

The content model is literally "body (optional, repeatable any block)" — body text is the main thing a card contains. sections simply never declares it, so applyBemClasses never sets data-section="body", so data-reading never lands. That is a config gap, not a statement that cards have no body.

Six more declare a title/header slot with no header-ish role: AccordionItem, BentoCell, CharacterSection, RealmSection, FactionSection, ItineraryDay.

The remaining 105 runes have neither a body role nor a body slot and are genuinely bodyless (badges, icons, table cells) — those exclusions are correct and must not be "fixed".

Each of the ~10 runes needs a per-rune judgement: is the missing role an oversight, or is the slot deliberately not a semantic section? Adding a role is not output-neutral — it adds data-section and enables data-reading — so each change needs its own justification and test.

Direction 2 — roles doing double duty

Three runes map a body role from a slot not named body:

RuneMapping
Blogcontent → bodyprose
Showcaseviewport → bodya component preview surface
DataTabletable → bodya table

Under Phase 3 the latter two would have reading and dropcap offered in their schemas, so {% datatable reading="prose" dropcap=true %} would validate and stamp a drop cap onto a table.

The role is not the problem, and removing it is not the fix. DataTable's table genuinely is the rune's main content region, and Lumina styles that role directly — styles/dimensions/sections.css:42 and styles/dimensions/density.css:45 both key on [data-section="body"]. Dropping the role would change how these runes render, for a reason unrelated to rendering.

The real problem is that body carries two meanings that were merged by accident of timing:

  • Structural — "the rune's main content region". Its original purpose, consumed by the dimension stylesheets.
  • Editorial — "prose that a reading register applies to". Added later by SPEC-108, which reused the existing structural declaration as a proxy for a capability it never declared.

The proxy holds for most of the 21 runes with a body role and breaks for a real minority: DataTable, Showcase, Form, Api, Symbol. (Only four runes set defaultReading at all — Sidenote, PullQuote, TextBlock, Lore.)

Separating the two meanings is therefore not a data fix and does not belong in Phase 1. It is a small design change, and it is sequenced into Phase 4 for a reason given there.

Guard against recurrence

A lint or test that flags a missing role (Direction 1) belongs here, so that drift cannot silently return once corrected. Direction 2 is not a lint target — those roles are correct.

The governing rule: declare capabilities, do not infer them

The lesson generalises past reading, and is worth stating because it predicts where the next axis will go wrong. All four applicability gates today:

AxisGateKind
cover, content-placerune declares the media-position / content-place modifierexact
prominencea header-ish roleproxy — sound
frame, substratea media role, or an explicit targetproxy — mostly sound
reading, dropcapa body roleproxy — lossy

The one exact gate is the one whose declaration was made for that purpose. Every proxy gate reuses a structural fact declared for something else, and the lossy one is where the correlation breaks.

Rule. When an axis needs a capability, have runes declare that capability. Infer it from an existing structural fact only when the fact and the capability are genuinely the same thing. A media role really is a frame target — that inference is identity. A body role merely usually contains prose — that is correlation, and correlation drifts.

Applied at SPEC-108 time this would have caught the reading case. It is the test to apply to the next universal axis anyone adds. If a second axis turns out to need it, this rule is a candidate for promotion to its own ADR.

Phase 2 — Lock applicability as rune identity

Extend the IDENTITY_FIELDS guard in packages/transform/src/validate.ts — today enforced only against SPEC-091 variant deltas — to the theme override path in mergeRuneConfig. block, modifiers and sections become non-overridable on every merge path.

This is expected to be a no-op in practice (Lumina overrides none of them), which makes it cheap to land early and independently.

Where the applicability data lives — resolved as (b)

Phase 3 needs the gating facts reachable from schema-build time, and today they live in RuneConfig, which the schema layer never sees. createContentModelSchema merges all ~37 universal attributes unconditionally, at module scope, with no structural input at all:

// packages/runes/src/lib/index.ts:384
Object.assign(attributes, universalAttributes);

Two shapes were considered. The module graph decides between them.

(a) Keep RuneConfig as the source and have the schema layer consume it — rejected. The dependency direction is config → tags, uniformly. Core's config.ts imports four sentinels from tag modules (BREADCRUMB_AUTO_SENTINEL, NAV_AUTO_SENTINEL, PAGINATION_AUTO_SENTINEL, XREF_RUNE_MARKER); no core tag imports config, and no plugin tag imports its config either — 0 of 9.

For a tag module to import config would invert that direction and create a cycle in core. Because createContentModelSchema is called at module scope, the tag would observe coreConfig still uninitialised mid-cycle — a crash, not a subtle bug. The cycle-free variant (narrow later, at catalog/registry assembly) works but must run at every assembly point — the core catalog, each plugin's runes record, the merged tag set — and hands config to every consumer. That is the "teach the consumers to read config" model ADR-028 already rejected, reached by a different route.

(b) Declare the fact in the tag module and let config reference it — adopted.

// tags/card.ts
export const cardSections = { media: 'media', body: 'body' } as const;
export const card = createContentModelSchema({ …, sections: cardSections });
// config.ts — already imports from tags/
Card: { block: 'card', sections: cardSections, … }
  • createContentModelSchema has the fact in hand at construction, so narrowing is local: no config lookup, no cycle, no build-order question.
  • The engine is unchanged — it still reads config.sections exactly as today.
  • No new import direction anywhere. Core already runs this way; plugins would start to, in the direction their code already permits.

Narrowing becomes correct by construction rather than by ordering discipline, which is the whole reason to prefer it over the workable form of (a).

Scope of the move. The join tables plus frameTarget: sections (~50 runes), mediaSlots (a handful), frameTarget (2). Mechanical, but it touches ~50 places across ten packages.

modifiers does not move. For the gates that read it, the schema already knows: cover and content-place gate on the rune declaring a media-position / content-place modifier, and those are author-facing attributes the schema declares anyway (refrakt reference card lists both). That is the exact gate from The governing rule above, and it is already schema-side.

The identity guard remains load-bearing. Under (b), config references the rune's declaration rather than owning it — but sections is still a RuneConfig field, so a theme could shadow it in the merged config. The engine would then use the theme's value while the schema used the rune's: silent divergence, exactly what this spec closes. The guard is required under every option, not just under (a).

The line to draw: join tables vs postures

The question is not "does sections move" but which fields are schema↔engine join tables and which are genuine theme postures. sections turns out to be the clearest case of the former:

Accordion: { sections: { preamble: 'preamble', headline: 'title', blurb: 'description' } }
  • Keys are the rune's own slot names, emitted as data-name by its schema transform. A theme cannot invent one — it does not control what the transform emits.
  • Values are a closed vocabulary the engine owns (header | preamble | title | description | body | footer | media, types.ts:341).

The theme owns neither side. All a theme can do is rewire an existing pair — exactly the capability ADR-028 removes.

Its only emission consumer is applyBemClasses (engine.ts:560), which turns the role into data-section, plus data-reading/data-dropcap on a body role and data-guest-fit on a media role. Lumina's CSS reads the emitted data-section attribute, never the sections config (styles/dimensions/sections.css and ~10 rune stylesheets), so moving the field touches no stylesheet at all. The theme's real relationship to sections is entirely downstream of emission: it styles roles, it does not define them.

Applying the same test to the fields that travel with it:

FieldShapeVerdict
sectionsdata-name → closed engine rolejoin table — move
mediaSlotsdata-name → closed vocabulary (portrait/cover/…)join table — same argument, move with it
guestFit'clip' | 'bleed' containment postureposture — stays; gates nothing
substrateTarget'media' | 'self', defaults to 'self'posture — stays; see below
frameTarget'media' | 'self'the exception — see below

substrateTarget gates nothing

It defaults to 'self', so substrate is applicable on every rune, and it is set on zero runes in the catalog. The only unavailability path — substrateTarget: 'media' on a rune with no media section — is a misconfiguration, not an authored intent, and cannot currently occur. It is a genuine theme posture ("put the pattern on the media well rather than the whole surface") and can stay in RuneConfig without splitting anything. This spec's earlier framing of it as a fuzzy applicability gate was wrong.

frameTarget splits frame applicability across two homes

Frame applicability resolves as config.frameTarget ?? (hasMediaSection(config.sections) ? 'media' : null). The type has no 'none', so frameTarget can only ever grant, never revoke.

It is set on exactly two runes — Figure and Showcase, both 'self', both with no media section. In both cases it grants frame applicability to a rune that would otherwise have none, and it means "frame me, because I am the media" — which reads as a structural fact about the rune, not a decoration.

That creates a concrete hazard if sections moves and frameTarget does not. Frame applicability would have two sources, one of them still theme-overridable, so a theme could add frameTarget: 'self' to a rune whose narrowed schema rejects frame=. Config would grant what the schema forbids — the same divergence this spec exists to close, inverted.

Three resolutions, to be decided in the placement work item:

  • (a) frameTarget moves with sections. Both become rune identity. Cheapest and most consistent; the migration is two runes.
  • (b) Narrow frameTarget so it can only redirect among surfaces the rune already has. Then Figure/Showcase would need a media section to be framed — awkward, since the whole rune is the media.
  • (c) Introduce an explicit "this rune is its own media surface" structural fact and retire frameTarget: 'self'. The most honest modelling; the most churn.

(a) is the decision. (c) is recorded below as a follow-on with an explicit trigger, because the evidence for it today is thin and (a) does not foreclose it.

Why (c) is deferred rather than rejected

The engine has one media model, and it assumes media is a sub-zone holding a guest: sections: { x: 'media' } declares the well, guestFit says how the well contains its guest, mediaSlots sizes it, findMediaZone() walks children for data-section="media", and frameTarget defaults to 'media' when such a zone exists.

Figure and Showcase do not host media — the image is the figure. There is no guest. That fact has no representation in config: the only way to express it is a side effect of frameTarget: 'self', which states the consequence ("frame chrome lands on my root") rather than the cause ("I am the media surface").

(c) would name the cause — e.g. mediaSurface: 'self', mutually exclusive with a media section role — so that every media-aware axis derives from one fact: chrome lands on the media surface, which is either the root or [data-section="media"].

The argument for it is that substrateTarget is already the second instance of the same escape hatch — same 'media' | 'self' type, same question, added independently, and it too never learned about these two runes. Today {% figure substrate="dots" substrate-target="media" %} warns "has no media section" and does nothing, on a rune that plainly has a media surface.

The argument against is that this is two runes and two axes, one of which is set on zero runes in the catalog. That is a duplicated pattern, not a demonstrated cost, and (c) carries a new config field, a vocabulary, its interaction with sections, both facets' resolution logic, docs, contract description and tests.

Trigger for revisiting: if a third media-aware axis needs a 'self' target, stop adding escape hatches and name the fact. Until then (a) holds, and the migration (c) would need is the same two runes either way.

Phase 3 — Narrow schemas and fix the consumers

With correct data and a reachable source, createContentModelSchema merges only the applicable universal attributes.

The rule must be declared, not inherited from a constructor. Six runes use hand-written schemas and therefore carry no universal attributes at all: icon, tint, bg, xref, expand, badge. That split is mostly principled and should be expressed rather than migrated away:

RuneReasonAssessment
xref, badgeinline: truecorrect — block axes are meaningless on an inline span
tint, bgconfigurator runes that supply axis values to their parentcorrect — a {% tint %} with its own tint= is circular
iconeffectively an inline glyphprobably correct; confirm
expandblock-level disclosureprobably wrong; reads as legacy

So the rule needs at least three inputs: inline-ness, configurator-ness, and rune-structural applicability. Migrating these six to createContentModelSchema is not required — four would end up declaring "no universal attributes" anyway.

Consumers then fall out:

  • refrakt reference stops printing "available on every rune" and reports the rune's actual set. No config loading.
  • Language server completion narrows automatically — it reads rune.attributes, which is the schema.
  • reading, content-place and cover scrim* gain the dev warning their four siblings already have, for the case where an attribute is written but the facet never runs. Still needed: schema narrowing catches authored attributes, not values arriving through scoped defaults or embed overrides (ADR-027).

Phase 4 — Separate the prose capability from the body role

Give reading and dropcap a declared gate instead of the lossy body-role proxy, per the governing rule above. The structural role and every stylesheet that keys on it are untouched.

Sequenced after Phase 3, deliberately. The design question is the default, and Phase 3 settles it:

  • Default-on (a body role implies prose unless a rune opts out) keeps the migration tiny, but preserves the lossy proxy as the default — so the DataTable bug simply recurs for the next non-prose rune.
  • Default-off (a rune must declare that it bears prose) is honest, but a forgotten declaration silently disables reading — which is today's failure mode returning.

Default-off is the right answer only once schemas have narrowed: with Phase 3 in place, a forgotten declaration is no longer silent — the attribute is not offered, and refrakt reference says so. Phase 3 converts the risk of default-off from an invisible regression into a discoverable one. Running Phase 4 first would forfeit that.

Shape. Express the gate as a capability declared by the facet and provided by the rune, rather than another hard-coded helper alongside hasBodySection / hasMediaSection / hasPageSectionHeader:

readingAxis.requires = 'prose'
TextBlock: { provides: ['prose'] }

Only one axis needs it initially, so this is close to the narrow fix in cost. It is worth doing as the general shape now because SPEC-124 already gave every facet a declaration point — requires sits beside contract and describeForRune, and flows into the structure contract for free. The alternative, a bespoke proseSection field on RuneConfig, solves the same case and leaves the next axis to invent its own.

The ~21 runes carrying a body role are audited once for whether they bear authored prose. Expected non-providers: DataTable, Showcase, Form, Api, Symbol.

Migration

Narrowing turns {% card reading="prose" %} from a silent no-op into a Markdoc validation error. That is the intended outcome — the author gets no feedback at all today — but it will break existing content.

Phase 1 removes most of the sting: the runes an author is most likely to have written reading on are exactly the six whose roles were missing. What remains is content targeting a genuinely bodyless rune, where the error is correct.

The transitional option from ADR-028's rejected alternatives is available if the break proves too sharp: keep schemas permissive for one minor and have refrakt reference and the editor annotate inapplicable attributes rather than reject them, narrowing in the following minor.

Decided: narrow directly, no transitional minor

Phase 1 has landed, so the impact is now measured rather than estimated. Every {% tag %} in the repo's markdown — 986 files across site/content, plan, the plugins and the create-refrakt templates — was scanned for the seven gated axes and each use attributed to the rune it sits on, then checked against the structure contract's unavailable map:

  • 25 uses in live authored content. 0 would be rejected after narrowing.
  • 5 would be rejected, all inside documentation code fences — illustrative examples, not content that builds.

The transitional minor exists to soften a break that does not materialise here, and it carries a real cost: it preserves the silent no-op as the default experience for another release, which is the thing this spec exists to remove. Narrow directly in Phase 3.

Two caveats the same scan turned up, both now fixed rather than migrated:

  • Three docs taught {% recipe reading="prose" %} as the "editorial-header composition" — the flagship example of this spec and SPEC-108. recipe has no body slot at all (its content is ingredients, steps and tips), so reading was always a no-op there and the composition only ever half-worked. The user docs now use blog, which maps content → body and carries the whole composition; SPEC-108 carries a correction note rather than a rewrite.
  • site/content/runes/bg.md documented {% bg src="…" scrim="bottom" %}, but bg is a hand-written schema that declares no scrim attribute, so that example was already a Markdoc attribute-undefined error with the scrim silently never applying. The scrim* family is universal and belongs on the rune the background sits behind; the doc now shows that form.

Neither is a migration concern — both were broken before narrowing. They are recorded here because the impact scan is what surfaced them, and because they are the same disease this spec treats: documentation promising an attribute the schema does not carry.

Acceptance criteria

  • The ~10 missing-role mismatches are individually assessed and resolved; each change carries its own test and the reasoning is recorded
  • A lint or test flags a missing section role, so Direction 1 drift cannot recur
  • reading and dropcap gate on a declared prose capability, not on the body role; the structural role and every stylesheet keying on [data-section="body"] are unchanged
  • The capability is declared by the facet and provided by the rune, so the next axis needing one does not invent a bespoke field
  • The ~21 body-role runes are audited for whether they bear authored prose; DataTable, Showcase, Form, Api and Symbol no longer offer reading/dropcap
  • IDENTITY_FIELDS is enforced on mergeRuneConfig, not only on variant deltas
  • The join tables (sections, mediaSlots) and frameTarget are declared in their tag modules and referenced from config, so createContentModelSchema has them at construction; the engine's read path is unchanged
  • modifiers stays in RuneConfig — the schema already declares the attributes its gates read
  • frameTarget moves with sections (resolution (a)), so frame applicability has a single source — no path by which theme config can grant an attribute the narrowed schema rejects
  • createContentModelSchema merges only applicable universal attributes
  • Universal-attribute availability is governed by a declared rule that accounts for inline-ness and configurator runes — not by which constructor a schema used
  • refrakt reference <rune> reports the rune's actual universal attributes and no longer claims "available on every rune"
  • Language server completion offers only applicable universal attributes, with no theme-config loading added to the completion path
  • reading, content-place and the cover scrim* family warn when dropped
  • The structure contract's unavailable entries agree with the narrowed schemas — the two derivations must not diverge
  • Transform output is unchanged except where Phase 1 deliberately adds a section role; those changes are enumerated
  • Migration path decided and documented; a breaking change carries a changeset
  • npm run build, the full repo suite, and refrakt contracts --check pass

References

  • ADR-028 — the governing decision
  • SPEC-091 — engine config variants; source of the identity-field rule
  • SPEC-124 — facet registry; gave each axis an exported vocabulary
  • WORK-527 — universal axes in structure contracts; the measurement
  • SPEC-108 — reading register and dropcap
  • SPEC-107 — elevation and prominence
  • ADR-027 — scoped attribute bags; the non-authored input path
  • SPEC-028 — rune output standards

Attribute applicability is rune identity, not theme configuration

ADR-028accepted

Attribute applicability is rune identity, not theme configuration

runesattributesschemaconfigthemearchitecture

Context

Every rune built with createContentModelSchema gets ~37 universal attributes merged into its Markdoc schema — tint, width, reading, dropcap, elevation, prominence, frame*, substrate*, scrim* and the rest (UNIVERSAL_ATTRIBUTE_NAMES in packages/runes/src/attribute-presets.ts). refrakt reference prints them under the heading "Universal attributes (available on every rune)".

That heading is false, and the falsity is not marginal. Several of those attributes are silently or noisily inert depending on the rune's entry in ThemeConfig.runes:

AttributeGated onDiagnostic when inert
prominencesections has a header-ish rolewarns
dropcapresolved reading register is prosewarns
frame*frameTarget, or sections has a media rolewarns
substrate-target="media"sections has a media rolewarns (unreachable today: no rune sets substrateTarget, and it defaults to self)
readingsections has a body rolesilent
content-placerune declares a content-place modifiersilent
scrim* (cover)rune declares a media-position modifiersilent

The measurement that surfaced this is on WORK-527: the structure contract now records 686 per-rune unavailability entries across the 132-rune catalog. 111 runes can never carry data-reading; 92 can never carry data-prominence. None of that was inspectable before.

Three facts make this a decision rather than a bug report:

  • sections lives in ThemeConfig.runes, and mergeRuneConfig shallow-merges it. A theme can write runes: { Card: { sections: {} } } and silently disable reading on every card in a site. Nothing prevents it. (Lumina does not; the capability is unused, not absent.)
  • The project has already classified sections as identity. packages/transform/src/validate.ts enforces IDENTITY_FIELDS = ['block', 'modifiers', 'sections', 'variants'] against SPEC-091 variant deltas, with the rule that a delta "restructures a rune, never redefines it". The same field is unguarded on the theme-override path. This is one position applied to one of two merge paths.
  • The schema layer cannot see any of it. Markdoc schemas are built from rune definitions and know nothing about RuneConfig. That is why refrakt reference prints the blanket claim, and why the language server's completeAttributeNames offers every universal attribute on every rune: from where they stand, applicability is invisible.

Decision

Attribute applicability is a property of the rune, never of the theme. It is derived from rune-structural facts (which sections a rune has, which modifiers it declares), and those facts are identity fields that a theme override may not redefine.

Concretely:

  1. The IDENTITY_FIELDS rule already enforced for variant deltas extends to theme overrides. block, modifiers and sections become non-overridable on every merge path into RuneConfig.
  2. Applicability becomes visible at schema-build time, so a rune's Markdoc schema offers only the universal attributes that can do something on it. Every downstream consumer — refrakt reference, the language server, Markdoc validation — then becomes correct without loading theme config at all.
  3. Whether a rune offers universal attributes is answered by a declared rule, not by which schema constructor it happened to use.

The two senses of "a theme not supporting an attribute"

These were conflated in the discussion that produced this ADR, and separating them is most of the decision:

  • Styling — the theme's CSS does nothing with data-elevation. This is entirely the theme's business and is already fully decoupled: a theme that ignores an axis simply ships no rule for it. Nothing needs building, and nothing here restricts it.
  • Emission — the engine does not put data-elevation on the element.

Emission stays theme-agnostic, and no mechanism will be added for a theme to suppress it. A theme opting out of an axis is a CSS non-decision, not a config switch.

Theme-agnostic is not the same as universal

reading remains inapplicable on a rune with no body text. That is a rune fact and a permanent one. The rule being adopted is "applicability varies by rune, never by theme" — not "every attribute applies everywhere".

Consequences

Content becomes portable in fact, not just in principle. Today the same markdown can mean different things under different themes, because a theme can change which attributes do anything. Markdown is the source of truth in this project; a theme that can redefine what an author is allowed to express breaks that premise. This closes it.

The tooling becomes correct for free. Once schemas encode applicability, refrakt reference, editor completion and Markdoc validation all stop over-promising without any of them learning to read theme config. That is the main practical payoff, and it is why the fix belongs at schema-build time rather than in each consumer.

Silently-ignored attributes become validation errors. {% card reading="prose" %} moves from a silent no-op to a Markdoc error. This is the intended outcome — an author who writes it today gets no feedback at all — but it is a breaking change for existing content and needs a migration path. SPEC-125 owns that.

A data audit becomes a prerequisite. Applicability is only worth freezing into schemas if the underlying data is right, and it is not. Six runes have a body slot but no body section role — Card, BentoCell, Character, Realm, Faction, Playlist — and six have a title/header slot with no header-ish role — AccordionItem, BentoCell, CharacterSection, RealmSection, FactionSection, ItineraryDay. Card is the clearest: its layout is content: { children: ['eyebrow', 'body', 'footer'] } and its content model is literally "body (optional, repeatable any block)", yet sections is { media: 'media' }. A card obviously has body text; the role mapping was simply never declared. Narrowing schemas before fixing this would freeze six wrong exclusions and reject content whose author is right and whose config is wrong. The remaining 105 runes with neither a body role nor a body slot are genuinely bodyless, so the audit is small.

Some theme flexibility is deliberately given up. A theme can no longer restructure a rune's semantic roles. It retains layout, structure, styles, contentWrapper, staticModifiers, autoLabel, editHints and projection — the same split SPEC-091 already draws for variants — so it can hide, reorder, re-wrap and re-decorate, just not redefine what a section is.

Alternatives considered

Leave applicability in theme config and teach the consumers to read it. refrakt reference and the language server would load refrakt.config.json, resolve the merged theme, and annotate or filter accordingly. Rejected on two counts. It does not fix portability — a theme could still change what content means — and it pushes config resolution into every consumer, including an editor completion path that must stay fast and that today needs no theme at all. The structure contract's unavailable map would make this possible; it does not make it right.

Keep schemas permissive; annotate rather than reject. Schemas keep offering every universal attribute, and tooling marks the inapplicable ones as inert. Avoids the breaking change and keeps {% card reading="prose" %} working (harmlessly). Rejected as the primary model because it preserves the silent no-op as the default experience: the author still gets nothing unless they happen to be in an editor that surfaces the annotation. It is, however, a reasonable transitional mode, and SPEC-125 may adopt it as the migration step before schemas narrow.

Move sections out of RuneConfig entirely, onto the rune definition. Conceptually the cleanest expression of "this is rune identity" — the field would live where the schema can already see it, and the guard would be unnecessary because there would be nothing to override. Not decided here: it is a migration across ~50 rune configs with real churn, and the applicability guarantee does not depend on it. SPEC-125 evaluated placement as an implementation question and adopted the move — the deciding factor being the module graph, which runs config → tags uniformly and so forbids the reverse. Note that the guard above is still required either way: config would reference the rune's declaration, but the field remains on RuneConfig, so an unguarded theme override could still shadow it and diverge from the schema.

Placement was decided on a join table vs posture test rather than field by field — sections and mediaSlots map schema-emitted data-names to closed engine vocabularies and are owned by neither the theme nor either endpoint alone, while guestFit and substrateTarget are genuine theme postures that gate nothing. frameTarget is the one field that resists the test and is called out there.

References

  • SPEC-125 — the spec this decision governs
  • SPEC-091 — engine config variants; the source of the identity-field rule
  • SPEC-124 — facet registry; made the universal axes inspectable
  • WORK-527 — the measurement that surfaced this
  • SPEC-108 — reading register; the silent-drop case
  • SPEC-107 — elevation and prominence
  • SPEC-028 — rune output standards