v0.30.1

Released September 5, 2026 · All releases

Transform engine facet decomposition. House-cleaning on the most central package in the project. transformRune in packages/transform/src/engine.ts resolves every universal rune axis — tint, density, width, spacing, reading, elevation, prominence, motion, background, frame, substrate, cover — in one ~800-line function whose steps are numbered 1, 1b, 1c … 1h, 2 … 9. This milestone replaces that section with a facet registry (): one module per axis, dependencies declared rather than implied by statement order, and a driver that merges their contributions.

Changes

  • Describe the universal axes in structure contracts (SPEC-124, WORK-527).
  • refrakt contracts is documented as describing "the complete HTML structure the identity transform produces for every rune". It did not: across the 132 runes in the checked-in contract, not one universal axis appeared. A theme author reading it learned nothing about [data-elevation], .rf-card--tinted, .rf-hero--has-bg, [data-reveal] or [data-substrate] — every one of which the engine emits and Lumina styles.
  • Each of the sixteen universal-axis facets now declares its own contract contribution, so the description is derived from the registry rather than hand-listed. Vocabularies come from the facets' existing exports (ELEVATION_VALUES, PROMINENCE_VALUES, TINT_TOKENS, DENSITY_VALUES, READING_REGISTERS) — the contract restates nothing.
  • A top-level universalAxes, in registry order: each axis's inputs and where they are read from, its closed vocabulary where the engine owns one, the data attributes, custom properties and elements it emits, and the condition under which it applies. Selector patterns carry a {block} placeholder.
  • A per-rune universalAxes, carrying only what that rune's config settles — resolved defaults, the surface an axis lands on, the block-substituted selectors it adds (.rf-card--tinted, .rf-card--has-bg), and, under unavailable, the axes its config rules out with the reason.
  • Stating the axis once and the deviations per rune keeps the file honest without repeating a near-identical block 132 times.
  • Additive only. Every existing entry in contracts/structures.json is byte-identical, key order included; the new sections sit alongside. The regenerated file (and Lumina's shipped copy) grow from 86 KB to 185 KB.
  • contract-engine-agreement.test.ts grows from 13 tests to 47, covering the new claims against the engine's actual output — mutation-checked: an engine change that stops emitting data-elevation leaves contracts --check reporting "up to date (132 runes)" while these tests fail.
  • Migrate the background axis to a facet (SPEC-124, WORK-520).
  • The ~200-line step-1f background block moves into packages/transform/src/facets/bg.ts: preset resolution with one level of extends, token-driven gradients, image/video bases, the flat overlay wash, the legibility scrim, and the SPEC-104 sandbox-guest relocation.
  • This is the first facet that builds its own element subtree, so it is the first real use of FacetResult.layers — declared since WORK-517 and unexercised until now. The channel held: the engine's hand-written bg-layer splice is replaced by a generic before-content insertion that any facet can use.
  • It also required one new channel. Relocation turned out to be two halves: layers puts the new subtree in, and FacetResult.absorbs takes the original out. The bg sandbox guest is moved from the host's children into the background layer, and without an explicit way to say so it would render twice. Matched by node identity.
  • bg declares after: ['cover', 'tint'], replacing two more reads of engine internals: cover's scrim reroute and tint's colour-scheme claim now arrive through ctx.axis(). With both tint and bg publishing their scheme claim as facet state, the color-scheme seed is gone entirely.
  • Internal only: no public API changes, and no change to rendered output — the 630 pre-existing transform tests pass unmodified, including the bg-gradient, bg-overlay-scrim and bg-guest suites.
  • One pre-existing quirk is preserved rather than fixed, and is now pinned by a test that documents it: a gradient stop with out-of-range alpha (primary/900) falls back to interpolating the whole stop, emitting var(--rf-color-primary/900) — not a valid custom-property name. Fixing it would be a behaviour change and belongs in its own item.
  • engine.ts is down to 1549 lines from 2223 before the facet work began.
  • Close the contract ↔ engine blind spot, and let the modifier facets declare their own contract sections (SPEC-124, WORK-525).
  • refrakt contracts --check compares the checked-in contract against what the generator produces today — the generator against itself. Nothing has ever compared the generator against the engine, so a change to the engine that the generator does not mirror produced a confidently wrong contract with CI green.
  • contract-engine-agreement.test.ts closes that: it transforms runes and asserts the engine's actual output matches what the contract promised — declared root selectors, data-rune, modifier data attributes and class patterns, valueMap/mapTarget routing, defaults, static modifiers, context modifiers, and the claim implied by absence.
  • Verified by mutation: with the engine changed to stop emitting BEM modifier classes, contracts --check still reports "up to date (132 runes)" while the new test fails.
  • The three config-modifier facets also gained a describe(config, block) that generateStructureContract now consumes, replacing the duplicate derivation in contracts.ts. Contract output is byte-identical across all 132 runes.
  • Internal only: no public API changes.
  • Let facets declare their own author attributes (SPEC-124, WORK-526).
  • transformRune stripped consumed author attributes from pass-through output using a fixed list naming eight facet-owned axes, so adding an axis with a fixed attribute name still meant editing engine.ts — the last coupling contradicting SPEC-124's claim that a new axis is a file plus a registry entry.
  • Facet now carries a static attributes declaration, and the engine strips FACET_ATTRIBUTES — derived once from the registry — alongside the config-modifier facet's dynamic stripAttrs.
  • The declaration is static rather than a FacetResult field because these attributes must be stripped even when the facet resolves to nothing: width="content" is suppressed (no axis, no class) but must not reach the rendered element. A per-instance channel cannot express that.
  • Internal only: no public API changes, and pass-through behaviour is unchanged.
  • One asymmetry is deliberately preserved, and now pinned by a test that says so: reading and dropcap reach the rendered element today, unlike every other axis attribute. It looks like an oversight — both are consumed and re-expressed as data-reading / data-dropcap on the body section — but removing them is a behaviour change and belongs in its own item, not in a mechanical cleanup.
  • Restructure the identity transform's universal-axis resolution as a facet registry (SPEC-124, WORK-517 + WORK-518).
  • transformRune resolved every universal rune axis inline in one ~800-line function with lettered sub-steps (1b…1h). This migrates the first four axes — elevation, prominence, cover and content-place — into packages/transform/src/facets/, where each axis is a module that declares its dependencies and returns its contributions as data for a driver to merge.
  • Internal only: no public API changes, and no change to rendered output. Attribute order, class-string order, inline-style declaration order and warning text are all byte-identical, and the 630 pre-existing transform tests pass unmodified.
  • Recorded because this sits on the code path every rune renders through. If a regression escapes the test suite, this is the changelog entry to bisect to.
  • elevation still passes unknown values through without validation — the closed set is enforced at parse time by the schema's matches, not by the engine.
  • Warn-once diagnostics keep process-wide dedupe scope (matching the module-level *_WARNED sets they will replace). Narrowing that scope is a separate, deliberate decision under WORK-524.
  • Migrate frame chrome (SPEC-086) and substrate fills (SPEC-087) to facets (SPEC-124, WORK-522).
  • Both axes resolve early against the rune tag but apply late, to a surface chosen at resolve time — the rune root, or the [data-section="media"] zone that does not exist until the children are assembled. They are the canonical two-phase facets, and the second real exercise of postAssemble after cover.
  • Internal only: no public API changes, and no change to rendered output. Attribute order, inline-style declaration order and warning text are all byte-identical, and the 630 pre-existing transform tests pass unmodified.
  • Three interface extensions this migration required, none of which the earlier axes needed:
  • FacetContext.theme — facets can now resolve a named preset. frame reads the theme's frame registry; tints and backgrounds are declared alongside it for the tint and background axes that follow.
  • FacetResult.carry — private scratch handed from a facet's resolve to its own postAssemble, for resolved work that is neither emitted nor expressible as a string. The alternative is re-resolving in the second phase, which duplicates work and lets the two phases disagree.
  • postAssemble returns diagnostics instead of void — a media-target chrome that finds no media zone is only detectable against the assembled tree, and routing that through the console directly would bypass the collector's dedupe and make it untestable.
  • engine.ts is down to 1889 lines from 2223 before the facet work began, and the 1g / 1h / 6c sub-steps are gone.
  • Migrate the config-modifier loop to facets and retire the seeding scaffolding (SPEC-124, WORK-523).
  • Steps 1, 1b and 1c of transformRune — the generic config.modifiers loop, context modifiers and static modifiers — move into packages/transform/src/facets/modifiers.ts. This is the last structural migration: no rune axis resolves inline any more.
  • modifiers is registered first, so its axes are visible to every other facet through ctx.axis(). That retires the final two seeded values (media-position and content-place), and with them the whole seeding mechanism: FacetInput.seedAxes, orderFacets's seeded option and the SEEDED_AXES list are all gone. Every after now names a real registered facet, so the registry's dependency graph is complete rather than partly scaffolded.
  • One new channel: FacetResult.stripAttrs, author attribute names a facet consumed, removed from pass-through output. Distinct from consumes, which claims <meta data-field> children. The config-modifier facet needs it because its attribute names come from the rune's own config and so cannot be a fixed list.
  • FacetContext also gains fields — the parsed SPEC-082 data-rune-fields bag, parsed once by the engine rather than per facet.
  • Internal only: no public API changes, and no change to rendered output. The 630 pre-existing transform tests pass unmodified, including the value-mapping, variants, fields and context-modifier suites.
  • engine.ts is down to 1391 lines from 2223 before the facet work began — a 37% reduction. Every numbered step that remains (2–9) is assembly pipeline, which SPEC-124 scopes out explicitly.
  • Known residue, tracked as WORK-526: the engine still strips a fixed list of eight facet-owned attribute names from pass-through output. Generalising that needs a static Facet.attributes declaration, because those must be stripped even when the facet resolves to nothing (width="content"), which a per-instance result channel cannot express. Left out of this change rather than widening the riskiest migration in the milestone.
  • Consolidate engine diagnostics onto the facet collector, scoped per build (SPEC-124, WORK-524).
  • The last four module-level *_WARNED sets in engine.ts — interactive guest in a link, non-eager cover sandbox, layout reference cycle, and requiresParent violations — now emit through the same WarningCollector the facets use. All nine of the original sets are gone.
  • Behaviour change, deliberate. Those sets were module scoped, so a warn-once fired once per process: in a long-lived dev server an author saw a diagnostic once, changed nothing, and never saw it again — not after the edit that failed to fix it, and not on any other page. The collector is now owned by createTransform, so dedupe is scoped to one build. A rebuild re-reports; a single build still reports once however many runes trip the same condition.
  • Nothing else changes: every message is byte-identical, emission still happens at the same point, and the 630 pre-existing transform tests pass unmodified.
  • FacetWarning also gains severity, because requiresParent routes a misplaced structural child (tab, bento-cell, …) to console.error and any other violation to console.warn. Without it that site could not go through the collector at all.
  • Migrate the remaining scalar axes to facets (SPEC-124, WORK-521).
  • density, width, spacing, inset, content-measure, reading, dropcap and motion (reveal + stagger) move out of steps 1e–1f-bis of transformRune into packages/transform/src/facets/.
  • dropcap declares after: ['reading']. The capability gate reads the resolved register, which worked because the two blocks happened to sit five lines apart. It is now a registry constraint the driver enforces.
  • Registry order reproduces the original emission sequence. Class order, modifierValues key insertion order and inline-style declaration order are all part of the output, and all now follow from one ordered list rather than from where statements happen to fall in an 800-line function.
  • That second point fixes the one benign output difference introduced when tint migrated: --tinted is back before --width / --spacing / --inset in the class attribute, where it has always been. Output is byte-identical again.
  • Three axes publish facet state rather than emitted axes, because the engine owns their emission point: reading and dropcap are applied to the body section during child assembly, and data-density is unconditional at a fixed position. density is also the first axis to read config other than its own — it inherits a parent's childDensity — so FacetContext now carries parentConfig, the resolved parent config rather than the rune registry, so a facet cannot reach arbitrarily into the rune graph.
  • Internal only: no public API changes. The 630 pre-existing transform tests pass unmodified, including the density, reading, reveal-stagger and content-measure suites.
  • engine.ts is down to 1463 lines from 2223 before the facet work began — a 34% reduction. Only steps 1, 1b and 1c (the generic config-modifier loop) still resolve inline; they migrate under WORK-523.
  • Migrate the tint axis to a facet (SPEC-124, WORK-519).
  • SPEC-053 tint resolution — named-definition lookup, inline tint-<token> overrides, lockMode, dark-mode tokens, the --tint-* custom properties and the data-tint / data-color-scheme / data-tint-dark markers — moves out of step 1d of transformRune into packages/transform/src/facets/tint.ts, which owns TINT_TOKENS.
  • Tint publishes the resolved colour scheme as facet state rather than as an emitted axis (the attribute is set directly). That lets cover — which now declares after: ['tint'] — tell whether the scheme has already been claimed instead of clobbering it, replacing what was a seeded read of the engine's internal tintDataAttrs bag.
  • Internal only: no public API changes. Rendered output is unchanged except for one benign difference: the --tinted BEM modifier now appears after --width / --spacing / --inset in the class attribute, where it used to come before them. The facet pass runs at a fixed point that sits after those still-inline axes. Class order within a class attribute has no effect on CSS matching or specificity, and no test pinned it — but it is an output difference, so it is recorded here rather than described as byte-identical. It resolves itself when width/spacing/inset migrate under WORK-521.
  • Everything else is byte-identical, including inline-style declaration order (tint registers first, so its --tint-* declarations still lead), and the 630 pre-existing transform tests pass unmodified.

Facet registry for the identity transform's universal axes

SPEC-124shipped

Facet registry for the identity transform's universal axes

transformRune in packages/transform/src/engine.ts resolves every universal rune axis — tint, density, width, spacing, reading, elevation, prominence, motion, background, frame, substrate, cover — in one 800-line function whose steps are numbered 1, 1b, 1c … 1h, 2 … 9. The lettering is the fossil record: each new axis was wedged between two existing ones rather than added alongside them. This spec replaces that section with a facet registry — one module per axis, declared dependencies, and a driver that merges their contributions.

The engine's own comments already call these axes facets ("SPEC-105 motion facet", "the scrim facet", "resolve the frame preset + facets"). The concept exists in the prose and has no representation in the code. So does the decomposition: 18 of the ~50 files in packages/transform/test/ are already named after individual axes (elevation.test.ts, cover.test.ts, bg-gradient.test.ts, …). The tests found the seams years before the source did.

enginetransformarchitecturerefactordx

Problem

engine.ts is 2192 lines — a quarter of the package — and transformRune is ~800 of them. Concretely:

  • Ordering is implied by statement adjacency. dropcap is valid only on a prose reading register; the constraint holds because reading happens five lines earlier. cover publishes isCover at one line and consumes it at seven call sites up to 600 lines away. Nothing declares or enforces any of it.
  • Vocabularies are stranded from their use. ELEVATION_VALUES sat 600 lines from the code that resolves elevation — and was never read at all, a spec-defined closed set that nothing enforced. The engine's HEADER_SECTION_ROLES, CHIP_METATYPES, FRAME_FACET_META and TINT_TOKENS are the same shape.
  • Warn-once is copy-pasted eight times. Eight module-level const X_WARNED = new Set() blocks with identical guard-add-warn bodies. Because they are module scoped, a warning fires once per process: in a long-lived dev server an author sees a diagnostic once and never again, across every later edit and every other page. The dedupe state is also unreachable from tests.
  • Adding an axis means editing a shared 800-line scope, where the new axis can silently interact with any of the eleven already there.

The 630 existing transform tests all run through createTransform — the public entry point — and assert on output attributes and classes. They are agnostic to the engine's internal structure, which makes them a usable characterization suite for exactly this change.

Design

The facet interface

A facet reads a read-only context and returns its contributions as data. The driver merges them into the accumulators transformRune already maintains.

interface Facet {
	readonly name: string;
	readonly after?: readonly string[];
	appliesTo?(ctx: FacetContext): boolean;
	resolve(ctx: FacetContext): FacetResult | null;
	postAssemble?(ctx: FacetContext, children: RendererNode[]): void;
}

interface FacetResult {
	axes?: Record<string, string>;   // emitted as data-* + `config.styles` lookup key
	state?: Record<string, string>;  // cross-facet only, never emitted
	classes?: string[];
	dataAttrs?: Record<string, string>;
	styles?: FacetStyle[];           // ordered [prop, value] pairs
	consumes?: string[];             // meta fields stripped from output
	layers?: FacetLayer[];
	warnings?: FacetWarning[];
}

Four properties of this shape were corrected by prototyping against the hardest case (cover/scrim, SPEC-089) rather than designed up front. They are recorded here because each was wrong in the first draft:

  1. state is separate from axes. Emitted axes become data-* attributes. Publishing "this rune is in cover mode" through axes would invent a data-cover attribute on every cover rune. Emitted output and internal resolution state are different channels that happen to share a reader (ctx.axis()).
  2. styles is an ordered pair list, not a Record. cover deliberately re-declares --cover-scrim-dir after content-place has set it, relying on CSS last-wins — the override two comments describe in prose. A keyed map collapses the two declarations into one and changes what renders, with no test to catch it.
  3. postAssemble is required, not a convenience. Cover's foreground-polarity flip mutates the assembled content overlay, which does not exist when resolve runs. A single axis can contribute at two different pipeline phases, so facet ≠ one contribution point.
  4. Dependencies must be able to name un-migrated producers. media-position comes from the generic config-modifier loop, which stays inline until late in the migration. Without a declared seed surface, no facet can depend on it and incremental migration is impossible — it becomes all-or-nothing.

Declared ordering

after names a facet or a seeded axis; orderFacets topologically sorts, keeps registration order for independents, and throws at import on a cycle, a dangling reference, or a duplicate name. Ordering constraints that were previously invisible become checkable:

export const coverFacet: Facet = {
	name: 'cover',
	after: ['media-position', 'content-place'],
	// …
};

That after encodes the rule the code states only in a comment — "an explicit scrim direction pins the default-scrim gradient, overriding the content-place-derived direction set above" — so it survives someone reordering statements.

Seeded axes — migration scaffolding

FacetInput.seedAxes carries values still resolved inline. Facets read them through the same ctx.axis() channel they will use once the producer migrates, so consumers need no edit when it does: a facet-supplied value shadows the seed of the same name. Seeds are readable but never emitted (the engine's own accumulators already carry them). Each entry is deleted as its producer migrates; the last removal is part of retiring the generic modifier loop.

Warnings as data

Facets return FacetWarning[] instead of calling console.warn. The driver's WarningCollector owns emission and per-key dedupe, giving one dedupe point instead of eight module-level sets, and making diagnostics assertable without a console spy. Emission stays immediate so console ordering is unchanged.

Dedupe scope is deliberately preserved as process-wide for now — matching the *_WARNED sets — and revisited as its own work item, because narrowing it to per-build or per-page is a behaviour change authors will notice.

Vocabularies move with their facet

Each facet owns the closed set it validates against and exports it as the public vocabulary: ELEVATION_VALUES and PROMINENCE_VALUES now live beside the code that reads them. This is the answer to "constants mixed in with logic" for the vocabulary class of constant.

It is not the answer for the other class. SUBSTRATE_CELL (12px/16px/24px), SUBSTRATE_OPACITY, SCRIM_STRENGTH (0.3/0.55/0.8) and BLUR_PRESETS are theme decisions hard-coded into a framework-agnostic engine — the engine breaks the rule every CSS file in the repo must follow. Hoisting them into a shared constants module would entrench that by making it look intentional. They should become token references, which is out of scope here and noted as follow-on work.

Scope

  • The facet interface, driver (ordering, merge, two-phase, warning collector) and registry in packages/transform/src/facets/.
  • Migration of every universal axis currently resolved inline in transformRune, in dependency order.
  • Retirement of seedAxes once the generic config-modifier loop is itself a facet.
  • Consolidation of the eight *_WARNED sets onto the collector, and a decision on dedupe scope.
  • Investigation of whether structure contracts can be derived from facets instead of re-derived independently (see Open questions).

Non-Goals

  • No output changes. Every migration is behaviour-preserving: identical attributes, classes, inline styles, and warning strings.
  • No test rewrites. See Verification.
  • Not the assembly pipeline. Steps 4–6 of transformRune (auto-label, block assembly, BEM application, recursion) are not facets and are untouched.
  • Not the theme-value extraction. Moving SCRIM_STRENGTH and friends to design tokens is real work this spec argues for but does not cover.
  • Not the package split. packages/transform also holds i18n, token stylesheet generation, HTML rendering, adapters and a Vite plugin. That cohesion problem is real and separate.

Verification

The 630 pre-existing transform tests are the safety net, and the governing rule is: not one of them changes. They run through the public entry point and know nothing of the engine's internals, so if a test needs editing to accommodate a migration, the edit is the bug report — behaviour drifted.

Each work item must additionally hold: npm run build, the full repo suite, and refrakt contracts --check (config-derived, so it must stay byte-identical).

New unit tests cover surface that has no counterpart today: facets in isolation without ThemeConfig scaffolding, the driver's new failure modes (cycles, dangling after, duplicate names), the two-phase and seeding channels, and warning dedupe — previously unassertable because the *_WARNED sets were module-level and unresettable.

A caveat worth stating: passing facet unit tests can coexist with broken composition — cover/scrim is precisely a bug that would live in the interaction, not in either facet. Unit tests are additive diagnostics, not a replacement for the integration suite, and are never grounds for relaxing the rule above.

Releasing

Each implementation PR carries a patch changeset. Nothing here changes the public API — facets/ is not exported from the package index — so a patch is the correct semver step, and the milestone is a patch release (v0.30.1).

The changeset is not bookkeeping. This restructures the code path every rune renders through, so a regression that escapes the 630 tests reaches consumers. A changelog entry naming which axes moved in which version is what someone bisecting a rendering regression needs. Refactors of critical paths are worth recording because they carry risk, not exempt from it because they add no API.

Landing with no changesets at all would also strand the spec: with nothing published, SPEC-124 could reach implemented but never shipped, since that status requires a real released-in version.

Acceptance Criteria

  • Every universal axis in transformRune resolves through the facet registry
  • after declares each cross-axis dependency; cycles, dangling references and duplicate names throw at import
  • seedAxes is empty and the type is removed
  • The eight *_WARNED sets are gone; all facet diagnostics flow through the collector, with dedupe scope deliberately chosen and documented
  • Each facet owns and exports the vocabulary it validates against
  • All 630 pre-existing transform tests pass unmodified at every step
  • npm run build, the full repo suite, and refrakt contracts --check pass at every step
  • transformRune no longer contains lettered sub-steps (1b…1h)
  • Each implementation PR carries a patch changeset naming the axes it migrated
  • Theme values still hard-coded in the engine are inventoried and filed as follow-on work

Open questions

  • Can structure contracts derive from facets? contracts.ts independently re-derives what the engine emits, from config — it imports types, helpers and merge, never the engine. So contracts --check validates contracts against themselves, not against engine output, and any engine change contracts does not mirror produces confidently wrong contracts with CI green. A facet describe(config) returning its static contract contribution would collapse the two implementations. This is untested — describe cannot simply call resolve (contracts are config-only, with no instance), so it is a genuinely separate method that could drift the same way. Prototype it on two or three facets before committing.
  • What dedupe scope should warn-once have? Process-wide is the current behaviour and is preserved by default, but it makes diagnostics effectively invisible after the first occurrence in a dev server.
  • Does layers survive the background facet? The channel is declared but unexercised; bg is the first facet that builds its own element tree.

References

  • SPEC-107 — elevation & prominence, the first two axes migrated
  • SPEC-089 — cover layout; the coupling the interface was corrected against
  • SPEC-088 — bg gradients and the scrim vocabulary
  • SPEC-086 — frame presets, whose chrome targets the media zone post-assembly
  • SPEC-087 — substrate fills, same post-assembly targeting
  • SPEC-053 — tint vocabulary
  • SPEC-108 — reading register and the dropcap capability gate
  • SPEC-091 — config variants, resolved before the facet pass runs