v0.32.0
Released September 10, 2026 · All releases
Narrowed rune schemas. The second half of Theme-independent attribute applicability: a rune's Markdoc schema offers only the universal attributes that can affect it, so refrakt reference, editor completion and Markdoc validation stop over-promising — without any of them loading theme config.
Changes
refrakt referencereports each rune's actual universal attributes (WORK-535)refrakt reference cardprinted all 37 universal attributes under the heading "Universal attributes (available on every rune)". On most runes several of them do nothing — the CLI stated as fact something false about the rune it was describing, which is the symptom that opened SPEC-125. Unlike language-server completion, this did not fall out of the schema narrowing for free: the line was a separate code path printing the static universal set wholesale.- Each rune now reports what it carries, and names the reason for what it does not:
- 51 of the 55 runes in this repo's reference had at least one false claim before.
- Explaining, not just omitting. The alternative was to let an inapplicable attribute vanish silently, which matches the schema and is simpler. But reference output is a teaching surface — it is what
create-refraktwrites intoAGENTS.mdfor coding agents — and "why isreadingontextblockbut not ongrid?" is exactly the question a bare omission leaves unanswered. - The schema stays authoritative. Availability is read off the rune's own attribute list; the applicability rule is consulted only to explain an absence, and an axis the schema carries is never reported unavailable whatever the rule says. A tool that contradicted the rune it describes is the bug being fixed, so the reconciliation is pinned by a test across every core rune rather than left implicit.
--format jsongainsattributes.universalUnavailable({ axis, reason, attributes }[]) alongside the existing narrowedattributes.universal, so machine consumers get the same answer as the human output. The hoisted Universal Attributes section inreference dumpnow reads as the shared vocabulary rather than a blanket promise.- No theme config is loaded to produce any of it.
ReferenceContextcarries schemas, not configs — the right shape, because applicability is rune identity (ADR-028). The posture that explains an inline rune's absences (badgeis inline; a structural reason would be true and beside the point) is recorded on the schema at construction, next to the join tables that answer the same question. - Close the drift between
refrakt.config.json's JSON Schema and the config types, and fixrefrakt config migratestranding site fields - The published schema is hand-maintained and nothing guarded it against the
RefraktConfig/SiteConfiginterfaces, so it had fallen behind in both directions. Editors validating against it were missing autocomplete for real fields, and rejecting two that are valid: - Absent from the schema entirely — the project-level
xrefsandfileRoots, and the site-levelentityRoutes,locale, andstrings. - Actively rejected —
sandbox.dir(only the deprecatedexamplesDiralias was allowed, so the current spelling errored) androuteRules[].entity, which combined withadditionalProperties: falsemade SPEC-092's convention-based entity typing a validation failure. - Advertised but dead — a top-level
tints, dropped fromRefraktConfigin SPEC-053. The normalizer deliberately refuses to mirror it, so a value written there is silently ignored at runtime. packages/transform/test/config-schema.test.tsnow guards this. Rather than mirroring the field list in a literal — which only catches drift if someone remembers to update it — it reads the interfaces out of@refrakt-md/typesand asserts coverage in both directions, so a field added toSiteConfigfails until the schema describes it.refrakt config migratefix. The command kept its own hand-copiedSITE_FIELDSarray, which had drifted from the authoritative one inconfig-normalize.ts. The two describe inverse operations over the same set — the normalizer mirrors site → top level, the migration moves top level → site — so the divergence leftsearch,repoUrl, andrepoBranchbehind at the top level of a migrated config, where nothing reads them once the file is in nested shape. It also movedtints, which the normalizer excludes on purpose.SITE_FIELDSis now exported from@refrakt-md/transform/nodeand consumed by the CLI, so both directions stay in step; the list is already type-checked withsatisfies readonly (keyof SiteConfig)[].- No runtime behaviour changes for configs that were already valid — the schema is used for editor validation, and the top-level
tintsremoval can't invalidate an existing file because the top level does not setadditionalProperties: false. - Make
prominencereach every rune, and ramp it relative to the rune's resting title size (WORK-538) prominencere-points--rf-title-size, which[data-section="title"]reads. Eight rune stylesheets setfont-sizeon their title element directly — and rune CSS is imported afterdimensions/at equal specificity, so the rune rule won and the variable never arrived. On those runes the axis did nothing at all: measured in a browser, asectionheadline moved 20→24→30→40px across the scale while aheroheadline sat at 52px at every value.{% hero prominence="display" %}was a silent no-op, on the rune an author is most likely to write it on.- Two changes fix it.
- The chain is split.
prominencenow publishes--rf-prominence-sizeand the title readsvar(--rf-prominence-size, var(--rf-title-size, …)). One variable used to carry both the density default and the author's override, which meant a rune could not state its own resting size without also silencingprominence. Now it can: the eight runes declare--rf-title-sizeon their root, and the override still wins.density.cssclears--rf-prominence-sizeat every rune root so an ancestor'sprominencecannot leak into a nested rune's title. - The ramp is relative, not an absolute type scale. Fixed steps (
xl/3xl/4xl) only read as a register when a rune rests at the density default — on a hero, which rests above the whole scale,displaymade the title smaller; on a bento cell, which rests below it,quietmade it larger. The steps are now the ratios those fixed sizes had to the2xlresting size (×5/6, ×5/4, ×5/3), so the axis always moves in the direction its name implies. - What changes on screen. No rune's resting size moves — nothing that never asked for
prominenceis restyled. At full density the ramp is byte-identical for the 28 runes where the axis already worked (20/24/30/40) and newly effective on the eight where it was inert. At compact and minimal density the ramp now scales with the density rather than ignoring it: a compact rune'sdisplayis 33.3px rather than 40px. That is a visible change whereverprominenceis set on a nested or compact-density rune, and it is the intended correction — a compact rune's display headline should not match a full-density one. hero's raw3.25remis now a named resting size rather than an inline magic number. It stays a Lumina-local value rather than becoming a--rf-text-5xltoken: the type scale is a typed contract every theme must satisfy (SPEC-048), and one rune's one-off does not belong in it.- A new test derives the title-role selectors from config and fails if any rune stylesheet pins a title
font-sizeoutside the chain, so the axis cannot silently go inert again. - Warn instead of silently dropping
reading,content-placeandscrim-strength(WORK-536) - Of the gated universal axes, four reported when a request was dropped and three said nothing. Each was silent for a different reason:
readingalways resolved and published its register as state; the value only becamedata-readingonce child assembly found an element with section rolebody. On a body-less rune it vanished with no code path aware anything had been asked for. Now warns — but only on an explicitreading=, since every unmarked block in a build resolves to theuidefault and warning there would make the channel worth ignoring.content-placewas gated byappliesToon an axis that a rune without the matching config modifier never sets, so the facet never ran and never got the chance to complain. It now also runs on the bare attribute, purely to warn.scrim-strengthin cover mode was neither honoured nor consumed: cover's scrim meta list omits it, so it was dropped from the styling and leaked its raw<meta>tag into the rendered tree. Now warned and consumed.- Each goes through the facet warning collector with a
dedupeKey, so it reports once per build rather than once per rune instance. - Fixes an over-narrowing regression in the same release. WORK-534 filed the
scrim*family under thecoveraxis, which is gated on a declaredmedia-positionmodifier — so narrowing removedscrim,scrim-type,scrim-strength,scrim-blurandscrim-tonefrom 82 of 83 runes. That was wrong: the background layer reads all five and raises itself on any rune, and cover mode does not enable the scrim, it reroutes it to the media well. Verified against the engine — a bare{ block: 'grid' }config withscrim="bottom"produces a full scrim overlay. The family now belongs to thebgaxis, where the facet that implements it lives, so{% textblock scrim="bottom" %}validates again.coverowns no author-facing attribute and is still reported per rune, likedensityandcontent-place. - This is why the work item's third case changed shape:
scrim*on a rune that cannot enter cover mode is not dropped at all, and the genuine defect runs the other way. - Schema narrowing catches authored attributes at validation time; these runtime diagnostics cover the path it cannot see, where scoped defaults and embed overrides (ADR-027) apply attribute bags to runes that never spelled the attribute out.
- Breaking: rune schemas now declare only the universal attributes that can affect them (WORK-534, SPEC-125 Phase 3)
createContentModelSchemamerged all 37 universal attributes onto every rune unconditionally. Several are structurally inert on most runes:readinganddropcapneed a body section role,prominencea header-ish one, theframe*family a media surface, the coverscrim*family amedia-positionmodifier. Authoring tools read the schema, so they promised every attribute on every rune — and the engine then dropped the inapplicable ones, three of them in total silence.- Schemas are now narrowed at construction: 84 tags lost 1,651 attribute slots between them.
- What breaks. Writing a gated attribute on a rune that cannot honour it moves from a silent no-op to a Markdoc validation error.
{% grid reading="prose" %}used to build and do nothing; it now fails withInvalid attribute: 'reading'. - The break is narrower than it sounds, because the attribute never did anything in the first place — a build that starts failing was already not getting the behaviour it asked for. A scan of all 986 markdown files in this repo found 25 uses of a gated universal attribute in live content and none that a narrowed schema rejects. The five that would be rejected were all inside documentation code fences, and were corrected in 0.31.0. Downstream content the scan cannot see is what this note is for; the fix in every case is to delete the attribute, or to move it to a rune that has the structure it needs.
- 0.31.0 also removed the sharpest edge in advance: the runes an author is most likely to have written
readingon were six whose section roles were missing, and those now honour it rather than rejecting it. - SPEC-125 had deferred the choice between narrowing outright and a transitional minor that annotates instead of rejecting. The numbers above decided it — a transitional release buys nothing and costs another minor of the silent no-op it exists to replace.
- Availability is now a declared rule, not a side effect of which constructor a schema used.
resolveUniversalAttributes()takes three inputs — the rune's posture (auto|inline|configurator|none, a new non-overridableRuneConfig.universalAttributesfield), its own join tables, and the attributes it declares — and answers axis by axis. The six hand-written schemas that never carried universal attributes are each assessed and recorded:badge/xref/iconare inline,tint/bgsupply an axis to their parent rather than carrying one, andexpandis recorded as a legacy gap rather than passing as a decision. - The schema and the structure contract cannot disagree, because they are one derivation: both call
UniversalAxisFacet.describeForRune, and a test compares the narrowed schemas against the contract'sunavailablemap across all paired runes in core and the nine plugins. That agreement is the point — an author told one thing byrefrakt referenceand another by Markdoc validation is the exact failure SPEC-125 exists to remove. - Language-server completion narrows for free, with no theme config loaded on the completion path — applicability is rune identity (ADR-028), answerable from the schema alone.
- Transform output is unchanged. This changes what may be written, not what is emitted.
- Breaking:
readinganddropcapgate on a declared prose capability, not thebodysection role (WORK-537, SPEC-125 Phase 4) - The
bodyrole carried two meanings that were merged by accident of timing. Structurally it means "the rune's main content region", and Lumina keys layout and density off it. SPEC-108 later reused that declaration as a proxy for an editorial fact it never stated — "prose that a reading register applies to". - The proxy holds for most of the 32 body-role runes and breaks for a real minority. A
datatable's body role is on its<table>; ashowcase's is on its viewport. Once schemas narrowed (WORK-534),{% datatable reading="prose" dropcap=true %}would have validated and stamped a drop cap onto a table. - What changes.
readinganddropcapare no longer offered onApi,DataTable,Form,ShowcaseandSymbol— writing either is now a Markdoc validation error, and a value arriving by another route (a scoped default, an embed override) is dropped with a warning naming the reason. The other 27 body-role runes are unaffected:Blog,Card,TextBlock,PullQuote,Sidenote,Lore, the storytelling entities and sections, the plan runes, and the rest all declare prose and keep both attributes. - Removing the role would have been the other fix, and it is the wrong one. A datatable's table genuinely is its main content region, and Lumina styles that role — dropping it would change how the rune renders for a reason unrelated to rendering. Every one of the five keeps its
bodyrole and every[data-section="body"]stylesheet is untouched. - The gate is declared, not inferred. A facet states what it needs (
UniversalAxisFacet.requires) and a rune states what it provides (RuneConfig.provides), so the next axis needing a content capability reuses this instead of inventing another bespoke field.providesis a non-overridable identity field for the same reasonsectionsis: it decides what an author may write. - Default-off — a rune must say it bears prose. That is only safe because schema narrowing landed first: a forgotten declaration is no longer silent, because the attribute is not offered and
refrakt referencenames the reason. - Also fixed:
MusicPlaylist, the schema.org alias ofPlaylist, was a bare{ block }config stub, so{% music-playlist %}rendered with none of the fivedata-sectionattributes its primary emits despite being the same schema and the same output tree. The audit surfaced it through the prose capability; the section drift was the same bug a layer down. It now carries the same join tables. - Also fixed: the facet warning collector deduped on a bare key, so two different diagnostics that both key on the rune name silenced each other —
frameandcontent-placealready collided this way, a warn-once swallowing an unrelated warning for the rest of a build. Keys are now namespaced by warning code. - Documentation: move author-facing pages out of the Extend handbook, and give the entity registry an author-facing home
- The
/docsvs/extendsplit was drawn by subsystem rather than by audience, so several pages describing what you type in a Markdown file lived in a handbook whose own introduction tells content authors to go elsewhere.extend/variables.mdwas the clearest case — its description reads "The author-facing variable surface", and it wasn't in the Extend sidebar at all. - Moved to
/docs/authoring/, with redirect stubs at the old URLs: content variables, partials, page sections (linked from ~19 rune reference pages), nav slug resolution, and rich menubar panels. The i18n overview moved to/docs/configuration/i18n, next to thelocaleandstringsfields it documents. - New: Entities.
collection,aggregate,relationships, andexpandall used to link into the plugin-authoring pipeline reference to explain what an entity is — a page that opens by describing itself as a build-time plugin mechanism. Authors needed the concept, not the implementation. The four runes now link to a page that covers what the registry holds, where entities come from (includingtypein frontmatter, which is how the rune catalog works), and how the query runes relate. - New: Entity routes.
entityRoutesis site configuration, but it was documented only inside the pipeline hook reference — so the field was invisible to anyone reading the configuration docs. It now has a full reference there, and the pipeline page points at it. - Split rather than moved: the tint cascade is now frontmatter semantics for authors and the resolution function and SSR contract for theme developers. Both audiences genuinely need it, at different depths.
- No content was duplicated — each page has exactly one home, and the two handbooks cross-link at their boundaries. Every internal link was verified against the built site.
- Serve every published JSON Schema URL, fixing the 404 that scaffolded projects'
$schemapointed at create-refraktwrites a$schemaURL derived from its own package version (https://refrakt.md/schemas/vX.Y/refrakt.config.schema.json), but the docs site only ever served one hardcoded version —v0.11. Every project scaffolded from v0.12 onward pointed at a URL that did not exist, so new projects got no editor validation or autocomplete at all.- The theme-token schema had it worse:
create-refraktscaffoldshttps://refrakt.md/schemas/vX.Y/theme-tokens.jsonfor preset authoring, and there was no route for it at any URL. Its own$idclaimedhttps://refrakt.md/schemas/theme-tokens.json, which also 404'd. - The versioned route is now generated rather than hardcoded:
schemas/[version]/enumeratesv0.11through the version@refrakt-md/transformships, for both schemas, plus the unversionedschemas/theme-tokens.jsonalias. Because the release workflow builds the site after the version bump, the release that publishes vX.Y now publishes/schemas/vX.Y/as part of the same run — the coupling that was missing between the scaffold and the route. - Each endpoint stamps
$idto match the URL it is served from. The schema file bundled inside this package now carries the unversioned$id(https://refrakt.md/refrakt.config.schema.json) rather than a hardcodedv0.11, so the packaged copy can't go stale between releases. - Versioned URLs are stable addresses, not frozen snapshots — every version serves the latest schema body, which is benign because schema changes are additive.
site/content/docs/configuration/schema.mdnow says so plainly instead of promising per-release freezing. - Fix:
characterdropped prose written directly in its body (BUG-003) - The paragraph never reached the output. The body slot rendered as an empty
<div data-name="body">— no warning, no error, the content simply gone.realmandfaction, which share Character's exact shape, handled the same markup correctly. - Two things were wrong. The content model named its greedy prose field
headerandtransformnever read it, so the prose was resolved and discarded. And the body slot was built from the items cursor, and only when the character had no sections — so even that path could not produce lead prose alongside sections. Character now usesbuildStoryContent, the same helper its two siblings use. - What changes on screen. Prose that currently vanishes starts rendering, in the character's body slot above its sections. A character that has only sections is unaffected — no body slot is emitted, as before. The
bodyslot also moves ahead ofsectionsin the content column, matchingrealmandfaction: the two were previously mutually exclusive, so the order never mattered. - This also makes Character's
bodysection role meaningful in practice. The role was declared correctly in 0.31.0 anddata-reading/data-dropcaplanded on the slot, but there was never any prose in it for a reading register to style.