Theme Config API
Theme configuration is declarative. Instead of writing transform logic, you describe what each rune should produce — which BEM block it maps to, what modifiers it reads, how it projects metadata (metaFields/blocks) and assembles its structure (layout) — and the identity transform engine handles the rest.
ThemeConfig
The top-level configuration object passed to createTransform().
interface ThemeConfig {
prefix: string; // BEM prefix, e.g., 'rf' → .rf-hint
tokenPrefix: string; // CSS variable prefix, e.g., '--rf' → --rf-color-text
icons: Record<string, Record<string, string>>; // SVG icons by group and variant
runes: Record<string, RuneConfig>; // Per-rune configuration
}
| Field | Description |
|---|---|
prefix | Prepended to all BEM class names. 'rf' produces .rf-hint, .rf-hint--note, .rf-hint__icon |
tokenPrefix | Convention for CSS custom property naming. Used by documentation and tooling, not the engine itself |
icons | SVG strings organized by group and variant. Structural groups (e.g., hint) are used by StructureEntry.icon config to inject icons into rune headers. The global group is used by the {% icon %} content rune to resolve author-chosen icons by name |
runes | Maps each rune's typeName (PascalCase, e.g. Hint) to its transform configuration. The engine matches the kebab-cased key against the data-rune attribute on the tag |
tints / backgrounds / frames | Named preset registries — colour tints, bg layers, and frame media-surface chrome (SPEC-086). All share the same extends resolution. See Surface chrome for frames. |
RuneConfig
Each entry in runes describes how a single rune type is transformed. All fields except block are optional.
Three fields are rune identity, not theme configuration. block, modifiers and sections say what a rune is, and a rune's universal-attribute applicability is derived from them — reading needs a body role, prominence needs a header-ish one, cover needs a declared media-position modifier. A theme that could rewrite them could change what the same markdown means, so it may not: an override of one is ignored with a build warning, and a variant delta carrying one is a config-load error. Everything else here stays fully theme-owned. See ADR-028.
block
The BEM block name, without the prefix. This is the only required field.
Grid: { block: 'grid' }
// → class="rf-grid"
The engine always produces the block class (.rf-grid) and sets data-rune="grid" on the root element.
Rune identity (ADR-028) — not theme-overridable.
modifiers
Reads values from meta tags or HTML attributes and produces BEM modifier classes plus data attributes.
Rune identity (ADR-028) — not theme-overridable. Which modifiers a rune declares is what gates the cover and content-place axes, so a theme that could add one would grant an attribute the rune's own schema rejects.
modifiers: {
hintType: { source: 'meta', default: 'note' },
align: { source: 'meta', default: 'center' },
}
| Property | Description |
|---|---|
source | 'meta' reads the field value from the rune's data-rune-fields bag (a JSON object on the root). 'attribute' reads from the element's own attributes |
default | Fallback value when the field is absent |
For each modifier with a resolved value, the engine:
- Adds a BEM modifier class:
.rf-hint--note - Sets a data attribute:
data-hint-type="note"(camelCase → kebab-case) - Strips the
data-rune-fieldsbag from the output (it is an internal, pre-engine data channel — never a theming hook; themes target the BEM classes and thedata-*attributes above)
Example: The Hint rune with hintType: 'warning':
<!-- Before transform — field data rides the data-rune-fields bag (camelCase keys) -->
<div data-rune="hint" data-rune-fields='{"hintType":"warning"}'>
<p>Be careful!</p>
</div>
<!-- After transform -->
<div class="rf-hint rf-hint--warning" data-rune="hint" data-hint-type="warning">
<!-- bag consumed, structural elements injected -->
<p>Be careful!</p>
</div>
contextModifiers
Adds a BEM modifier class when the rune is nested inside a specific parent rune. The key is the parent's typeName (matched against data-rune after kebab-casing); the value is the modifier suffix.
// Hint config
contextModifiers: { 'Hero': 'in-hero', 'Feature': 'in-feature' }
// When Hint is inside a Hero:
// class="rf-hint rf-hint--note rf-hint--in-hero"
This enables context-aware styling — a Hint inside a Hero can be more compact, a CTA inside Pricing can be left-aligned.
staticModifiers
BEM modifier classes that are always applied, regardless of meta tag values. Useful for rune variants that share a block but have fixed differences.
// FeaturedTier shares the 'tier' block but always gets --featured
FeaturedTier: { block: 'tier', staticModifiers: ['featured'] }
// → class="rf-tier rf-tier--featured"
metaFields
A pure-data manifest of the rune's meta-bearing fields — domain semantics only (type, label, sentiment, optional rich rendering). Blocks reference these fields by name; the engine resolves them against modifier values and renders them. This is the SPEC-080 replacement for hand-built metadata in structure.
metaFields: {
difficulty: {
metaType: 'category',
label: 'Difficulty',
condition: 'difficulty',
sentimentMap: { easy: 'positive', medium: 'neutral', hard: 'caution' },
},
}
| Field | Description |
|---|---|
metaType | Visual shape — 'status'/'category'/'tag' render as chips; 'id'/'quantity'/'temporal'/'code' render bare. Also drives typography (monospace for id/code, tabular-nums for quantity/temporal) |
label | Human-readable label — the <dt> in a def-list, the in-chip label in a bar |
sentimentMap | Maps the resolved value to a sentiment colour (positive/negative/caution/neutral) |
condition / renderWhenEmpty | Render only when the named modifier is truthy (or merely present) |
href / rating / icon | Rich renderings — link, rating widget, leading icon |
splitOn / transform / tag | Delimited collection → one chip per item; value transform (duration/uppercase/capitalize); element tag override |
See Blocks & layout for the full field reference.
blocks
Named metadata blocks projected from metaFields. Each block is a flat list of fields rendered by a layout primitive — bar (horizontal flex row) or definition-list (<dl> of labelled pairs). Render shape (chip vs bare) is intrinsic to each field's metaType, not the block's layout.
blocks: {
metadata: { fields: ['prepTime', 'cookTime', 'servings', 'difficulty'], layout: 'definition-list' },
eyebrow: { fields: ['plotType', { field: 'structure', align: 'end' }], layout: 'bar' },
}
A field may be given as { field, align } to push it to the row end (bar only). A block becomes an addressable element (.rf-{block}__{name}) placed via layout.
layout
The recursive skeleton (SPEC-081). The transform emits flat data-name slots; layout declares how they are grouped and ordered. Keyed by a container's data-name, or the reserved 'root' for the rune's top-level children.
layout: {
root: ['media', 'content'],
content: { tag: 'div', children: ['eyebrow', 'body', 'footer'] },
preamble: { tag: 'header', children: ['eyebrow', 'headline', 'blurb'] },
}
Each value is a LayoutEntry:
- a bare
string[]orders an existing container's children; { tag, children, attrs? }creates a wrapper element (<tag data-name=key>) and fills it from the flat transform slots.
Projected blocks and transform slots a list doesn't name are appended in transform order — rune content is never dropped. See Blocks & layout for name resolution and worked examples.
variants
Modifier-keyed config deltas (SPEC-091): a rune's structure can vary by a modifier without branching in the transform. The outer key is a declared modifier (the axis), the inner key one of its values, and the payload a partial RuneConfig merged over base — in declaration order — before assembly.
variants: {
'media-position': {
cover: {
layout: {
root: ['cover-band', 'body'],
'cover-band': { tag: 'div', children: ['media', 'preamble'] },
},
},
},
}
Selection rides the modifier system — the modifier's own default picks the active value, so there is no separate condition language and no defaultVariants. A delta may override assembly/decoration fields (layout, structure, styles, contentWrapper, staticModifiers, autoLabel, editHints) but not identity fields (block, modifiers, sections); every axis must be a declared modifier. Both invariants are checked at config load. Requires the rune to be on the flat-slot + base-layout model. See the variants section in Blocks & layout.
structure (legacy)
structure is a legacy field, superseded by metaFields + blocks + layout above. No first-party rune declares it — the engine keeps a back-compat branch only for injecting icons/badges into runes that don't project metadata. New runes should use the block-and-layout model. The StructureEntry API below documents the legacy injection shape.
It injects new HTML elements into a rune's output — headers, icons, badges — keyed by name, each defining a StructureEntry:
structure: {
header: {
tag: 'div',
before: true,
children: [
{ tag: 'span', ref: 'icon', icon: { group: 'hint', variant: 'hintType' } },
{ tag: 'span', ref: 'title', metaText: 'hintType' },
],
},
}
contentWrapper
Wraps all content children (non-structural) in a container element.
// Recipe config
contentWrapper: { tag: 'div', ref: 'content' }
// Output: recipe's structural header comes first, then content is wrapped
// <div class="rf-recipe">
// <div class="rf-recipe__meta">...</div> ← structure (before)
// <div class="rf-recipe__content"> ← contentWrapper
// <ul>ingredients...</ul>
// <ol>steps...</ol>
// </div>
// </div>
The ref value becomes the element's data-name, which the engine converts to a BEM element class (rf-recipe__content).
autoLabel
Maps child element tag names (or property attribute values) to data-name attributes. The engine then adds BEM element classes for these.
// AccordionItem config
autoLabel: { name: 'header' }
// A child with tag name "name" gets data-name="header"
// → class="rf-accordion-item__header"
// Details config
autoLabel: { summary: 'summary' }
// The <summary> child gets data-name="summary"
// → class="rf-details__summary"
styles
Maps modifier values to CSS custom properties or inline style declarations. Useful when CSS needs dynamic values that can't be expressed as BEM classes.
Simple form — sets a CSS custom property:
styles: { columns: '--sb-columns' }
// With columns=3: style="--sb-columns: 3"
Template form — interpolates the value into a CSS property:
styles: {
columns: {
prop: 'grid-template-columns',
template: 'repeat({}, 1fr)'
}
}
// With columns=3: style="grid-template-columns: repeat(3, 1fr)"
Multiple style entries produce semicolon-separated values. Existing inline styles on the tag are preserved.
defaultDensity
Controls how much detail a rune shows by default. The engine emits data-density on the root element.
Accordion: { block: 'accordion', defaultDensity: 'full' }
Details: { block: 'details', defaultDensity: 'compact' }
Breadcrumb: { block: 'breadcrumb', defaultDensity: 'minimal' }
| Value | Behavior |
|---|---|
'full' | All sections visible, generous spacing (default) |
'compact' | Descriptions truncated, secondary metadata hidden |
'minimal' | Title and primary metadata only |
Resolution order: author attribute > rendering context > config default > 'full'. The engine automatically applies compact inside Grid/Bento/Gallery and minimal inside backlog/decision-log contexts.
See Dimensions for the full CSS patterns and density interactions.
sections
Maps structural ref names (data-name values) to standard section roles. The engine emits data-section on matching elements, enabling generic layout styling.
Budget: {
block: 'budget',
sections: { header: 'header', title: 'title', footer: 'footer' },
}
Available roles: 'header', 'preamble', 'title', 'description', 'body', 'footer', 'media'.
Rune identity (ADR-028) — not theme-overridable. This is a join table rather than a posture: the keys are data-name values the rune's own transform emits and the values are a closed engine vocabulary, so a theme owns neither side. All it could do is rewire a pair — and reading, prominence and frame applicability hang off exactly those pairs. Styling the emitted data-section stays entirely the theme's business.
Since v0.31.0 the map is declared in the rune's tag module and referenced here, so createContentModelSchema has the fact at construction. mediaSlots and frameTarget moved with it for the same reason. The engine's read path is unchanged — it still reads config.sections.
// tags/card.ts
export const cardSections = { media: 'media', body: 'body' } as const;
export const card = createContentModelSchema({ sections: cardSections, /* … */ });
// config.ts
Card: { block: 'card', sections: cardSections, /* … */ }
refrakt validate fails a config in which a rune declares a body or heading slot (in layout, structure, autoLabel, blocks or contentWrapper) with no matching role — that omission silently drops reading, dropcap or prominence, which is exactly how the original mismatches went unnoticed. The check is direction-only: it flags a missing role and never a role that is present, so a body role on a non-prose region stays untouched.
sectionRoleExceptions
Records a deliberate decision not to map a declared slot, keyed by slot name and valued by the reason. It silences the check for that slot and keeps the reasoning next to the decision.
AccordionItem: {
block: 'accordion-item',
sections: { body: 'body' },
sectionRoleExceptions: {
header: 'The `header` slot is the <summary> disclosure control, not a page-section header.',
},
}
Use it for a genuine judgement, never to quiet a real omission. Most exceptions so far share one shape: a child rune takes the title role only when its parent does not already hold one — a second title inside the same subtree flattens the hierarchy prominence exists to scale.
See Dimensions for role descriptions and CSS.
mediaSlots
Maps ref names to media treatment types. The engine emits data-media on matching elements.
Figure: { block: 'figure', mediaSlots: { media: 'cover' } }
Available types: 'portrait', 'cover', 'thumbnail', 'hero', 'icon'.
See Dimensions for treatment descriptions and CSS.
checklist
When true, the engine scans <li> text for checkbox markers ([x], [ ], [>], [-]), strips the marker, and emits data-checked on the element.
Work: { block: 'work', checklist: true }
See Dimensions for marker values and CSS.
sequence
Ordered list style. The engine emits data-sequence on <ol> elements within the rune.
Steps: { block: 'steps', sequence: 'connected' }
| Value | Visual treatment |
|---|---|
'numbered' | Counter circles |
'connected' | Vertical line with dots |
'plain' | No visual indicators |
Use sequenceDirection to control orientation:
Timeline: {
block: 'timeline',
sequence: 'connected',
sequenceDirection: { fromModifier: 'direction', default: 'vertical' },
}
See Dimensions for CSS patterns.
parent
Groups a rune under a parent rune in the block editor palette. Advisory only — a rune may declare a typical parent yet still be valid standalone. Does not affect rendering.
AccordionItem: { block: 'accordion-item', parent: 'Accordion' }
frameTarget
SPEC-086 — which surface frame chrome decorates: 'media' (the [data-section="media"] zone) or 'self' (the rune root, for runes whose body is the media, like figure/showcase). Defaults to 'media' when the rune declares a media section; frame on a rune with no resolvable target emits a build warning. See Surface chrome.
Figure: { block: 'figure', frameTarget: 'self' }
requiresParent
A hard nesting requirement (SPEC-084): the rune is only meaningful inside the named parent (PascalCase typeName). Unlike parent (an advisory editor grouping), this is validated — the engine warns when the rune appears without that parent as its nearest ancestor rune.
BreadcrumbItem: { block: 'breadcrumb-item', parent: 'Breadcrumb', requiresParent: 'Breadcrumb' }
defaultWidth
Sets the default page grid width for the rune. The engine emits data-width on the root element.
Hero: { block: 'hero', defaultWidth: 'full' }
| Value | Behavior |
|---|---|
'content' | Standard content width (default) |
'wide' | Wider than content, narrower than full |
'full' | Edge-to-edge |
childDensity
Imposes a density level on all nested runes. Useful for runes that display children in a compact context.
Grid: { block: 'grid', childDensity: 'compact' }
Backlog: { block: 'backlog', childDensity: 'minimal' }
editHints
Declares how named sections are edited in the block editor. Keys are data-name values; values are edit mode hints.
Hint: {
block: 'hint',
editHints: { icon: 'none', title: 'inline' },
}
| Hint | Behavior |
|---|---|
'inline' | Inline text editing |
'link' | Link editing |
'code' | Code editing |
'image' | Image picker |
'icon' | Icon picker |
'none' | Not editable (generated content) |
rootAttributes
Extra attributes set on the root element unconditionally.
Sandbox: { block: 'sandbox', rootAttributes: { 'data-interactive': 'true' } }
projection
Declarative tree reshaping by data-name, applied after assembly. Per SPEC-081 it is the escape hatch for bending a tree a theme does not own (a third-party rune's output); a rune declaring its own structure should use layout instead.
Comparison: {
block: 'comparison',
projection: {
hide: ['meta'], // drop elements by data-name (the only retained op)
},
}
hide— drop elements bydata-name. Retained.group({ tag, members, slot? }) andrelocate({ into, position? }) — deprecated: alayouttag-entry is a group, and you place a slot wherever you name it in thelayouttree. The contract generator emits a deprecation warning for both.
Advanced modifier options
Modifiers support additional fields beyond source and default:
| Property | Description |
|---|---|
noBemClass | When true, skips BEM modifier class generation — only the data attribute is emitted |
valueMap | Maps modifier values before emitting (e.g., { 'GET': 'read', 'POST': 'write' }) |
mapTarget | Custom data attribute name for the mapped value (instead of the default) |
modifiers: {
method: {
source: 'meta',
default: 'GET',
valueMap: { GET: 'read', POST: 'write', PUT: 'write', DELETE: 'danger' },
mapTarget: 'intent',
},
}
// Emits: data-method="GET" data-intent="read"
postTransform
A programmatic escape hatch that runs after all declarative processing. Receives the fully transformed node and resolved modifier values.
postTransform(node, ctx) {
return {
...node,
attributes: {
...node.attributes,
'data-custom': `cols-${ctx.modifiers.columns}`,
},
};
}
The ctx object contains:
| Property | Type | Description |
|---|---|---|
modifiers | Record<string, string> | All resolved modifier values |
parentType | string | undefined | The parent rune's data-rune value (kebab-case), if nested |
fields | Record<string, unknown> | The parsed SPEC-082 data-rune-fields bag — for non-modifier field values (the engine strips the bag attribute before postTransform runs) |
Use declarative config first. postTransform is for edge cases that truly can't be expressed with modifiers, structure, or styles. It makes the config harder to analyze statically — the inspect and audit tools can't introspect programmatic transforms.
StructureEntry
Defines an element injected into a rune's output via the legacy structure config (see structure (legacy)). New runes project metadata with metaFields + blocks instead; this API is documented for the back-compat injection path.
interface StructureEntry {
tag: string; // HTML tag name ('div', 'span', 'a', etc.)
ref?: string; // Sets data-name (overrides the structure key)
before?: boolean; // Insert before content children (default: after)
children?: (string | StructureEntry)[]; // Nested entries or text
// Content
metaText?: string; // Inject resolved modifier value as text
icon?: { group: string; variant: string }; // Icon from config.icons
// Conditional
condition?: string; // Only render if named modifier is truthy
conditionAny?: string[]; // Only render if any named modifier is truthy
// Text transforms
transform?: 'duration' | 'uppercase' | 'capitalize';
textPrefix?: string; // Prepend to metaText value
textSuffix?: string; // Append to metaText value
// Labels
label?: string; // Emits <span data-meta-label>Label</span> child
labelHidden?: boolean; // Label visually hidden but accessible (sr-only)
// Metadata dimensions (see Dimensions page)
metaType?: 'status' | 'category' | 'quantity' | 'temporal' | 'tag' | 'id';
sentimentMap?: Record<string, 'positive' | 'negative' | 'caution' | 'neutral'>;
// Repetition — generate copies of a template element
repeat?: {
count: string; // Modifier name providing the total count
max?: number; // Cap to prevent runaway generation (default 10)
filled?: string; // Modifier name for how many are "filled"
element: StructureEntry; // Template for each generated element
filledElement?: StructureEntry; // Optional template for filled elements
};
// Attributes — literal strings, or references to a modifier / page data
attrs?: Record<string, string | { fromModifier: string } | { fromPageData: string }>;
}
Structural injection example
The Api rune demonstrates most structure features:
Api: {
block: 'api',
contentWrapper: { tag: 'div', ref: 'body' },
modifiers: {
method: { source: 'meta', default: 'GET' },
path: { source: 'meta' },
auth: { source: 'meta' },
},
structure: {
header: {
tag: 'div', before: true,
children: [
{ tag: 'span', ref: 'method', metaText: 'method' },
{ tag: 'code', ref: 'path', metaText: 'path' },
{ tag: 'span', ref: 'auth', metaText: 'auth', condition: 'auth' },
],
},
},
}
This produces:
<div class="rf-api rf-api--GET" data-method="GET" data-rune="api">
<div data-name="header" class="rf-api__header">
<span data-name="method" class="rf-api__method">GET</span>
<code data-name="path" class="rf-api__path">/api/users</code>
<!-- auth span only present if auth modifier has a value -->
</div>
<div data-name="body" class="rf-api__body">
<!-- content children -->
</div>
</div>
Conditional elements
Use condition to only inject an element when a modifier has a truthy value:
{ tag: 'span', ref: 'auth', metaText: 'auth', condition: 'auth' }
// Only renders if the 'auth' modifier is present
Use conditionAny to render when at least one of several modifiers is present:
{
tag: 'div', ref: 'meta', before: true,
conditionAny: ['prepTime', 'cookTime', 'servings', 'difficulty'],
children: [...]
}
// Only renders if any of those modifiers has a value
Dynamic attributes
The attrs field sets attributes on the injected element. Values can be literal strings or references to modifier values:
{
tag: 'a', ref: 'register', condition: 'url',
attrs: { href: { fromModifier: 'url' } },
children: ['Register'],
}
// → <a href="https://example.com" class="rf-event__register">Register</a>
Repeated elements
The repeat field generates multiple copies of a template element — useful for star ratings, progress dots, and similar patterns. count and filled are modifier names.
{
tag: 'div', ref: 'rating',
repeat: {
count: 'maxRating', // modifier holding the total
filled: 'rating', // modifier holding how many are filled
element: { tag: 'span', ref: 'star' },
},
}
// With maxRating=5, rating=3:
// Produces 5 <span> elements, first 3 with data-filled="true"
Each generated element gets data-filled="true" or data-filled="false" based on whether its index is less than the filled count.
Text transforms
Built-in transforms applied to metaText values:
| Transform | Input | Output |
|---|---|---|
duration | PT1H30M | 1h 30m |
uppercase | get | GET |
capitalize | warning | Warning |
{ tag: 'span', ref: 'meta-item', metaText: 'prepTime',
transform: 'duration', textPrefix: 'Prep: ' }
// With prepTime="PT45M": "Prep: 45m"
Labels
The label field emits a separate <span data-meta-label> child element before the value text. This enables independent styling — themes can make labels thin and muted, or hide them entirely.
{ tag: 'span', ref: 'meta-item', metaText: 'prepTime',
label: 'Prep:', metaType: 'temporal' }
Set labelHidden: true to make the label visually hidden but accessible to screen readers (sr-only pattern). Use this for values that are self-explanatory, like ID badges.
Metadata dimensions
Two fields enable generic cross-rune badge styling. When present, the engine emits data-meta-* attributes on the generated element. Themes style these attributes generically instead of writing per-rune badge CSS.
| Field | Attribute emitted | Description |
|---|---|---|
metaType | data-meta-type | Visual shape — 'status', 'category', 'quantity', 'temporal', 'tag', 'id' |
sentimentMap | data-meta-sentiment | Maps modifier values to colors — 'positive', 'negative', 'caution', 'neutral' |
{ tag: 'span', ref: 'badge', metaText: 'difficulty',
metaType: 'category',
sentimentMap: { easy: 'positive', medium: 'neutral', hard: 'caution' } }
When difficulty="easy", the engine emits:
<span data-meta-type="category"
data-meta-sentiment="positive" data-difficulty="easy">
easy
</span>
See Universal Theming Dimensions for the full CSS system.
mergeThemeConfig
Combines the base config with theme-specific overrides.
import { baseConfig } from '@refrakt-md/runes';
import { mergeThemeConfig } from '@refrakt-md/transform';
const myConfig = mergeThemeConfig(baseConfig, {
// Override the BEM prefix (optional)
prefix: 'my',
// Add icon SVGs (merged by group)
icons: {
hint: {
note: '<svg>...</svg>',
warning: '<svg>...</svg>',
},
},
// Override specific rune configs (shallow merge per rune)
runes: {
Hint: {
// This replaces only the 'defaultElevation' field of the Hint config
// All other fields (block, modifiers, blocks, layout) are preserved
defaultElevation: 'raised',
},
},
});
Merge behavior:
| Field | Strategy |
|---|---|
prefix | Override replaces base |
tokenPrefix | Override replaces base |
icons | Shallow merge by group (override groups replace base groups) |
runes | Per-rune merge: most fields replace, but metaFields, blocks, layout, and variants merge by inner key; block, modifiers and sections are identity and cannot be overridden at all |
Per-rune merge is shallow for most fields — overriding structure or styles replaces the entire object, not individual entries. The block-and-layout fields are the exception: metaFields, blocks, layout, and variants merge by inner key, so a theme can re-point a single field, swap one block's primitive, reshape one container, or add one variant without restating the whole map. When overriding a wrapper-creating layout entry, restate its tag (the entry is replaced as a whole).
block, modifiers and sections are rune identity (ADR-028) and are not overridable here. An override carrying one is dropped — the rune's own declaration stands — and a build warning names the rune and the field. Adding a modifier, or rewiring a section role, would change which universal attributes an author may write on that rune, and applicability is a fact about the rune rather than about the theme. A theme still styles the emitted data-section / modifier attributes however it likes; it just does not define them.
Real-world examples
Simple rune: Grid
Minimal config — just a block name. CSS handles everything.
Grid: { block: 'grid' }
Modifier rune: Hint
Reads a modifier, adds context-awareness, injects a header with icon and title.
Hint: {
block: 'hint',
modifiers: { hintType: { source: 'meta', default: 'note' } },
contextModifiers: { 'Hero': 'in-hero', 'Feature': 'in-feature' },
structure: {
header: {
tag: 'div', before: true,
children: [
{ tag: 'span', ref: 'icon', icon: { group: 'hint', variant: 'hintType' } },
{ tag: 'span', ref: 'title', metaText: 'hintType' },
],
},
},
}
Complex rune: Recipe
The current block-and-layout model: a metaFields manifest, a projected blocks metadata def-list, and a layout skeleton that assembles flat slots into a media + content split. (This is the actual @refrakt-md/learning config, trimmed.)
Recipe: {
block: 'recipe',
defaultDensity: 'full',
sequence: 'numbered',
sections: { preamble: 'preamble', headline: 'title', blurb: 'description', media: 'media' },
mediaSlots: { media: 'cover' },
modifiers: {
'media-position': { source: 'meta', default: 'top', noBemClass: true },
prepTime: { source: 'meta', noBemClass: true },
cookTime: { source: 'meta', noBemClass: true },
servings: { source: 'meta', noBemClass: true },
difficulty: { source: 'meta', default: 'medium' },
},
metaFields: {
prepTime: { metaType: 'temporal', label: 'Prep', condition: 'prepTime', transform: 'duration' },
cookTime: { metaType: 'temporal', label: 'Cook', condition: 'cookTime', transform: 'duration' },
servings: { metaType: 'quantity', label: 'Serves', condition: 'servings' },
difficulty: {
metaType: 'category', label: 'Difficulty', condition: 'difficulty',
sentimentMap: { easy: 'positive', medium: 'neutral', hard: 'caution' },
},
},
blocks: {
metadata: { fields: ['prepTime', 'cookTime', 'servings', 'difficulty'], layout: 'definition-list' },
},
layout: {
root: ['media', 'content'],
content: { tag: 'div', children: ['preamble', 'metadata', 'ingredients', 'steps', 'tips'] },
preamble: { tag: 'header', children: ['eyebrow', 'headline', 'blurb'] },
},
}
metaFields declares the facts once; the metadata block projects them as a labelled def-list; layout nests that block in the content column under the header, beside the media. The metaType/sentimentMap fields drive generic badge styling — no rune-specific CSS. See Blocks & layout and Dimensions for the full system.