v0.46.0 · The theme contract

All releases

A theme renders a vocabulary and configures no rune (); a plugin rune speaks the same vocabulary, and only its novel parts bring their own structure, in tokens (). Both decisions were accepted on 2026-10-28. This milestone builds what makes them checkable.

Changes

  • A composition's narrative field is checked when its definition loads (ADR-047). A content-model field whose match names only the editorial runes (tag:textblock, tag:mediatext) is the rune's narrative field: the story a template can place between the rune's own parts. Two rules are now enforced:
    • One per rune. A definition with two narrative fields is rejected (narrative-field-twice).
    • Never structured data. A schema table that maps the narrative field is rejected (narrative-field-mapped).
  • A field that mixes editorial runes with other matches is not a narrative field. refrakt inspect and refrakt reference name the narrative field. The rune-authoring patterns guide and the composed-runes guide document it.
  • Two checks make ADR-043's and ADR-044's contracts checkable. Both are reports, not gates.
    • refrakt theme coverage reports, for every value of the theme vocabulary, whether a theme's CSS styles it, leaves it to the base stylesheet (@refrakt-md/skeleton), or nothing does. The vocabulary is each meta type, sentiment, zone layout, sequence, section role, media kind and universal-axis value. It is read from the registries that define it (themeVocabulary() in @refrakt-md/runes; META_TYPES, SENTIMENTS, LAYOUT_PRIMITIVES and vocabularyCoverage() in @refrakt-md/transform), so a new value appears without updating anything. Lumina runs it as a test, with its unstyled values recorded as a set that may only shrink.
    • refrakt plugins validate now reports, for a plugin's themed runes, the elements that carry no vocabulary role (ADR-044's tier 3), and the literal colours, sizes and font stacks in its styles/ stylesheets (novelElements(), lintTokenOnly()). A plugin whose stylesheets have none is theme-portable.
    • Baseline: npm run plugins:vocabulary records both for the nine first-party plugins in contracts/plugin-vocabulary.json. This is the baseline Lumina's per-rune CSS drains from.
  • The engine no longer adds rf-badge to field values (ADR-043 decision 3). A value states what it is (data-meta-type, and data-meta-sentiment when its field maps one), and the base stylesheet draws status, category and tag values as pills by that attribute. Every value is now one element: a definition-list <dd> holds a <span data-meta-type> for every kind, where a bare value used to put the type on the <dd> itself. {% badge %} keeps its rf-badge class, and the pill rule covers it. Lumina renders every page unchanged.
  • For theme authors: a rule on .rf-badge now reaches only {% badge %}. Style field pills with [data-meta-type="status" | "category" | "tag"], and a definition-list value with dd > [data-meta-type] rather than dd[data-meta-type].
  • All of metaFields is identity (ADR-043 decision 2). A theme that overrides any part of a rune's meta fields (metaType, label, sentimentMap or transform) has the override reported and dropped, as it already was for metaType alone. A label is copy, a sentiment map says what a value means, and a transform says how it is formatted: all are claims about the content, not its look. No first-party theme overrides them.
  • For third-party themes: move a label, sentiment or format change into the rune's definition (or the plugin), or style the field through [data-field="…"], [data-meta-type="…"] and [data-meta-sentiment="…"]. blocks, which fields are grouped and how they are arranged, stays the theme's.

A theme renders a vocabulary; it configures no rune

ADR-043accepted

A theme renders a vocabulary; it configures no rune

themecompositionmetadatavocabularyconfigidentityarchitecture

Accepted on 2026-10-28, with three amendments, marked Amended where they land:

  1. data-sentiment and data-glyph are not yet in the output; their specs are drafts.
  2. [data-field] stays a theme hook because data-field gets one meaning, "this node carries field X", in SPEC-133.
  3. formats is specified as SPEC-166 D6, not a spec of its own.

The work this decision owes: WORK-670 (all of metaFields is identity), WORK-671 (the engine stops adding rf-badge), WORK-672 (the vocabulary coverage check).

Context

ADR-028 drew the line between a rune and its theme inside each rune's config. Identity fields (sections, schema, metaFields.*.metaType, …) are the rune's, and everything else may be overridden by the theme (packages/transform/src/identity-fields.ts). That fitted a world where every rune was written once, in code, and kept deliberately neutral. The only way to give a rune a look beyond CSS was to change its config, so the theme was given that power.

Composition moves the definition into content. A rune can be defined by a plugin's file or by a site's own runes/ directory, and two sites can define recipe differently. ADR-042 drew the first consequence: a theme cannot override a composed rune's config, because it cannot know which definition it is facing. This decision draws the rest: what a theme is, once it cannot configure runes.

Three facts frame the answer:

  • The reference theme already lives this way. Lumina's config overrides no rune at all. It contributes tints and icons, and the comment on luminaOverrides (packages/lumina/src/config.ts) says so: "it overrides no rune at all". Everything Lumina makes look different, it does in CSS, keyed on the shared vocabulary.
  • The vocabulary is closed and already in the output. Primitive classes, data-section, data-zone-layout, data-meta-type, data-meta-sentiment, data-media, data-sentiment (SPEC-163), data-glyph (SPEC-164) and the universal axes are all closed sets. SPEC-145 D28 makes the value element of a field part of that contract. Amended 2026-10-28: data-sentiment and data-glyph are not in the output yet; SPEC-163 and SPEC-164 are drafts. The rest of the list is.
  • One presentation decision is still made by the engine, not the theme. CHIP_METATYPES in packages/transform/src/engine.ts gives status, category and tag values the rf-badge class, so the engine decides that those values look like pills.

Options Considered

  1. Keep per-rune theme config for native runes indefinitely, and leave composed runes to CSS. Two models side by side, with the boundary depending on how each rune happens to be implemented. A theme author has to know which kind every rune is.
  2. Give themes a new per-composition hook, for example overrides keyed by data-rune that apply when the definition declares matching fields. This is ADR-042's rejected option 1 in a new form: still a guess about a definition the theme cannot see.
  3. The theme renders the vocabulary and configures no rune. Its levers are global and rune-agnostic; everything about a particular rune belongs to the rune's definition.

Decision

Option 3.

  1. A theme owns how each piece of meaning looks, and nothing about which meaning a rune has. What it supplies:

    The theme suppliesThrough
    How each piece of meaning looks: meta types, sentiment, zone layouts, axes, primitive classesCSS, in the structure (skeleton) and skin layers
    Design valuestokens
    Named looks an author can choosepresets: tints, backgrounds, frames
    Drawingsglyph and ornament sets (SPEC-164)
    How each kind of value is formattedformats (decision 4)
    Page chrome and interactive behaviourlayouts, components
    Special casesCSS on [data-rune], [data-field], [data-value]

    Amended 2026-10-28. [data-field] is a hook only if data-field survives into the output. SPEC-133 first narrowed it to a meta about to be consumed. It now has one meaning, "this node carries field X", which covers both the consumed meta and the rendered value element of SPEC-145 D28, so the hook stands. SPEC-133's other uses (a root's role, repeated item markers, the engine's row keys) still go.

    What it does not supply:

    Not the theme'sWhose
    Which fields a rune shows, their order, layout, labels, and sentiment mappingthe rune's definition
    Translations of a rune's textthe definition's owner, overridable by the site (SPEC-145 D29)
    A rune's structurethe definition, or core for a primitive

    (Amended by ADR-045: for a composed rune, the order, layout and structure rows are the definition's by default, and a theme or site may arrange them with a replacement template. Which fields, labels and sentiment mapping stay the definition's.)

  2. All of metaFields is the definition's. SPEC-158 D3 made metaFields.*.metaType identity and left label, sentimentMap and transform presentational, so that a theme had something to override. Seen without that need, all three are claims about content:

    • sentimentMap says that "hard" is cautionary, which is the content author's statement;
    • label is copy, which SPEC-145 D29 translates as such;
    • transform names a kind of formatting, whose style is decision 4's.

    metaFields becomes identity as a whole, for every rune.

  3. The engine emits meaning, not shape. The engine stops adding rf-badge to field values.

    • Pill geometry comes from the base stylesheet (@refrakt-md/skeleton), keyed on [data-meta-type="status"], "category" and "tag", and a theme restyles it like any other rule.
    • {% badge %} keeps its rf-badge class, because a badge is an authored pill. It already emits data-meta-type="tag" by default (packages/runes/src/tags/badge.ts), so the base rule covers it either way.
    • This closes SPEC-145 D28's open question on which values are chips. Under D28 decision 8, a chip and a plain value are already the same element; this removes the last difference the engine imposes.
  4. Formatting style is a theme-wide setting: formats. "15m" or "15 minutes", "Oct 12" or "12 October 2026" is a presentation choice, but today it is fixed per field (transform: 'duration', and the locale-aware formatDurationLocale). Instead:

    • the output always carries the machine value: <time datetime="PT15M">, data-value;
    • the definition says what kind of value it is (metaType: temporal, transform: duration);
    • the theme says how that kind of value is displayed, once, for every rune: formats: { duration: "long", date: "medium", number: "compact" };
    • a site may override the theme's formats, as it overrides tokens.

    It is the one config lever about fields that a theme gains, and it is global, so it never names a rune or a field.

  5. Coverage replaces per-rune guarantees. A theme that cannot configure a rune cannot promise that the rune looks right, and does not need to: it needs to cover the vocabulary. A check reports, for every vocabulary value (each metaType, sentiment, zone layout, axis value and glyph concept), whether the theme styles it or leaves it to the base stylesheet. It extends Lumina's existing selector coverage test (packages/lumina/test/css-coverage.test.ts) from BEM selectors to vocabulary values. A theme that covers the vocabulary renders every composed rune, including runes written after it shipped.

  6. Native runes keep their theme override path as legacy. ADR-028's guard still applies to them, and the runes: section of a theme's config stays valid for them until ADR-042's follow-up settles each one. No new theme lever is added there. Site-level component overrides (overrides, by typeof) are untouched: they replace a component, not a config, and are the site's choice, not the theme's.

Rationale

  • It is where the reference theme already is. Lumina overrides no rune. This decision states that as the model rather than as Lumina's habit.
  • It makes themes portable across definitions. A theme written against the vocabulary renders a site's own recipe, a plugin's recipe, and a rune nobody had written when the theme shipped. Per-rune config could never offer that.
  • It completes ADR-028 rather than reversing it. ADR-028's idea, that identity is not the skin's to change, stands. Only its location moves: from a line inside one config to the line between a definition and a theme.
  • The theme loses fine-grained control over a single rune. CSS on [data-rune], [data-field] and [data-value] covers most of what that control was used for. What a theme can never do again is restructure a rune, which under composition is correct. (Narrowed by ADR-045: a theme may arrange a composed rune's template, never redefine its input.)

Consequences

  • SPEC-158 D3 changes. metaFields is identity in full, not only metaType. IDENTITY_FIELDS names metaFields, and a theme override of any part of it is reported and dropped, for native runes as well as composed ones.
  • SPEC-145 D28's open question is closed by decision 3. D28 gets a pointer here.
  • The engine and the stylesheets change together. The engine stops emitting rf-badge on field values in the same change that adds the base chip rule, so no theme sees a value lose its pill. The contract diff is reviewed.
  • formats needs a spec. It names the value kinds (duration, date, time, number, currency), the styles each accepts, how a style combines with the locale, and how a site overrides it. Amended 2026-10-28: specified as SPEC-166 D6, beside the value kinds it is keyed on, rather than as a spec of its own.
  • The theme-authoring docs are reorganised around the vocabulary. They say:
    • what a theme supplies and what it does not (decision 1);
    • styling meaning before special cases (SPEC-145 D28's reading order);
    • overriding a composed rune's config is not possible, and why.
  • The coverage check becomes part of a theme's definition of done, beside refrakt contracts --check.
  • Themes that override native runes' presentational metaFields keys (label, sentimentMap, transform) break under decision 2. None of the first-party themes does; a third-party theme's override is reported and dropped, with a migration note.

Plugin runes speak the vocabulary; novel parts bring token-only structure

ADR-044accepted

Plugin runes speak the vocabulary; novel parts bring token-only structure

themepluginscompositionvocabularycsstokensarchitecture

Accepted on 2026-10-28 as written. The two checks it calls for are WORK-673, and the plugin-authoring guide's rewrite goes with it.

Context

ADR-043 bounds what a theme does: it renders the shared vocabulary and supplies tokens, and it configures no rune. That bound holds for compositions, which emit only primitives and vocabulary. It does not hold for a native rune from a third-party plugin. Such a rune is TypeScript that emits whatever elements and classes it likes, and no theme can know them.

The first-party code shows the same problem, hidden by the fact that we write the theme too:

  • Lumina ships 92 per-rune stylesheets (packages/lumina/styles/runes/). 47 of them style runes outside core's block list, nearly all plugin runes: bond, recipe, playlist, map, the plan runes, the design runes and the marketing runes.
  • The skeleton ships 82 per-rune structure files (packages/skeleton/styles/runes/), many of them for plugin runes as well.
  • The plugin-authoring guide (site/content/extend/plugin-authoring/authoring.md) has a plugin ship its own styles/ directory. In practice that CSS is written against Lumina's look, because it is the only theme there is.

So today the reference theme knows every first-party rune by name, and a third-party rune is either unstyled under another theme or styled for Lumina only. A theme's domain is unbounded as long as runes may emit structure the theme has to learn.

Options Considered

  1. Themes ship CSS for popular third-party runes. It does not scale, couples theme releases to plugin releases, and leaves every other rune unstyled.
  2. Plugins ship a full skin per known theme. Plugin authors would need to know every theme, which is the same problem from the other side.
  3. Plugin runes speak the vocabulary, and only genuinely novel parts bring their own structure, drawn in theme tokens. The theme's domain stays the vocabulary plus the token set. The plugin covers the remainder, using only those two interfaces.

Decision

Option 3. A plugin rune takes the first of these tiers that fits.

  1. A composition, whenever possible. A domain rune that can be a composition should be one. The theme already covers it, because a composition emits only primitives and vocabulary. Once SPEC-160, SPEC-161 and SPEC-145 D28–D29 land, this covers most of what plugins do: entities, cards, lists of metadata, labelled values.
  2. A native rune that emits the vocabulary, when imperative code is needed (parsing, arithmetic, data fetching).
    • The preferred shape is a primitive plus compositions. The imperative part is a generic primitive, and the domain runes are compositions over it, as SPEC-162 does with amount and tally under ledger and budget, and SPEC-161 D3 states: a primitive may own imperative code; a composition may not.
    • Otherwise, the native rune places core primitives in its output. It builds child nodes of core runes and has them transformed normally, as cast already does by emitting cast-member tags (plugins/business/src/tags/cast.ts). Placed primitives pick up vocabulary changes, engine processing and contracts for free.
    • Stamping vocabulary attributes (data-zone-layout, data-meta-type, data-section) on the rune's own elements is allowed but discouraged. The plugin would have to track the vocabulary by hand.
    • Structured data stays in the field bag. A placed primitive is a name-resolution boundary for structured data (SPEC-145 D19, SPEC-146). A native rune that publishes JSON-LD writes what it publishes into its field bag, not into the tree it then reads back. This is SPEC-145 D28's "the bag is the record".
  3. Genuinely novel visuals bring their own structure CSS, written only in theme tokens. A map, a chart, a waveform or a diagram has parts the vocabulary cannot express, and should not be forced into it. For those parts:
    • the plugin ships the structure layer for its own elements, as the skeleton does for core;
    • that CSS uses only theme tokens: colour, spacing, radius, typography, motion. It has no literal colours or sizes and assumes no particular theme;
    • a theme may add a skin through [data-rune], but is not required to. The rune already looks right in any theme, because it is drawn in that theme's tokens.

The theme's domain is two interfaces: the vocabulary and the token set. Everything else belongs to the plugin, which works through those same two interfaces.

Rationale

  • It is what makes third-party theming possible at all. A theme author can cover a closed vocabulary and a token set. They cannot cover runes that do not exist yet.
  • It reuses the composition boundary instead of inventing a new one. Tier 2's preferred shape is the same primitive/composition split already decided for budget, so a plugin author learns one rule.
  • Tier 3 is honest. Some visuals are new. Forcing a map into vocabulary terms would produce worse output than letting it draw itself. Restricting it to tokens keeps it consistent with whatever theme the site uses.
  • First-party code is held to the same rule, which is the proof that the rule works.

Consequences

  • Two checks make the tiers checkable rather than advice:

    • A vocabulary report per plugin rune. Built from refrakt contracts, it lists the elements in a rune's output that carry no vocabulary: the plugin's own elements, which tier 3 makes the plugin's to style. It is a report, not an error, because tier 3 is legitimate.
    • A token-only lint for plugin CSS. It flags literal colours, sizes and font stacks in a plugin's stylesheets. This one check is what makes tier-3 CSS portable across themes.

    Together with ADR-043's theme coverage check, they cover the whole page: the theme covers the vocabulary, and the plugin covers what it adds.

  • Lumina's per-rune CSS is drained. As first-party plugin runes become compositions, their stylesheets in Lumina shrink to nothing. The structure of novel first-party natives (map, chart, diagram, juxtapose, …) moves to the owning package (the plugin, or the skeleton for core) and is rewritten in tokens only. Lumina ends as vocabulary skin plus presets, with no rune named in it. That end state is the evidence that ADR-043 and this decision hold.

  • The plugin-authoring guide is rewritten around the tiers. It explains which tier to choose and why, the primitive-plus-composition shape, placing core primitives from a native rune, and token-only structure CSS. Its styles/ example becomes a tier-3 example, not a default.

  • The token set becomes a public contract. A plugin's tier-3 CSS depends on token names, so renaming or removing a token is a breaking change for plugins, not only for themes. The token set is documented and versioned as such.

  • Existing third-party plugin CSS keeps working until the lint is turned on. The lint starts as a report; a plugin that passes it can say it is theme-portable.