Plan
ID:SPEC-028Status:draft

Rune Output Standards

Codify the structural patterns established by the recipe rune as the reference standard for all rune output — schema transforms, engine configs, and identity-transformed HTML.


Overview

The recipe rune (in @refrakt-md/learning) has been refined to model best practices for rune output. This spec captures those patterns as enforceable standards so that existing and future runes produce consistent, well-structured HTML.

The goal is not visual uniformity — runes represent different domains and should look different. The goal is structural consistency: predictable BEM classes, meaningful data attributes, clean semantic markup, and correct use of the engine's declarative config.


Standard 1 — BEM Modifier Classes for Enumerable Values Only

BEM modifier classes (.rf-{block}--{value}) must only be emitted for values drawn from a constrained, enumerable set that a theme would realistically target in CSS.

Rule

  • If the attribute has a matches constraint or a small fixed set of values (e.g. layout, difficulty, status, role), it should produce a BEM modifier class.
  • If the attribute is free-form, numeric, or an unbounded string (e.g. prepTime, servings, tags, aliases, id), it must set noBemClass: true in the engine config.
  • Data attributes (data-*) are always emitted regardless of noBemClass — these are the correct mechanism for free-form value selectors.

Rationale

Classes like .rf-recipe--PT5M or .rf-character--"Gandalf the Grey,Mithrandir" are not useful CSS selectors. They bloat the class list and can't be anticipated by a theme. Data attributes ([data-prep-time="PT5M"]) are the right mechanism for targeting specific values or testing for presence ([data-prep-time]).

Reference

Recipe config (runes/learning/src/config.ts):

modifiers: {
  layout:     { source: 'meta', default: 'stacked' },        // enumerable → BEM class
  difficulty: { source: 'meta', default: 'medium' },          // enumerable → BEM class
  prepTime:   { source: 'meta', noBemClass: true },           // free-form → no BEM class
  servings:   { source: 'meta', noBemClass: true },           // numeric → no BEM class
  ratio:      { source: 'meta', default: '1 1', noBemClass: true }, // layout param → no BEM class
}

Known Violations

RuneModifierReason
Characteraliases, tagsFree-form comma-separated strings
Realmscale, tags, parentFree-form strings
FactionfactionType, tagsFree-form strings
LoretagsFree-form string
PlottagsFree-form string
Beatid, track, followsIdentifiers / free-form

Note: Playlist config already follows this standard correctly — type and layout produce BEM classes (enumerable), while ratio, valign, gap, collapse use noBemClass: true.


Standard 2 — Preamble Groups with Content

For runes that represent page sections (extending PageSection or using eyebrow/headline/blurb), the preamble (header elements) should be nested inside the content wrapper, not emitted as a sibling at the article level.

Rule

  • Wrap eyebrow, headline, and blurb in a <header> with data-name="preamble".
  • Place that header inside the content <div>, before the body content.
  • Meta badges and media are siblings of content at the article level.

Rationale

  • Split layouts need preamble to flow with body content in the same grid column.
  • Semantic grouping is correct — the heading introduces the content that follows it.
  • CSS can still target .rf-{block}__preamble independently via BEM element selectors.
  • Cover/overlay layouts work via display: contents on the content wrapper — no DOM restructuring needed.

Reference Structure

<article class="rf-recipe">
  <div class="rf-recipe__meta">...</div>           <!-- chrome -->
  <div class="rf-recipe__media">...</div>           <!-- media -->
  <div class="rf-recipe__content">                  <!-- content wrapper -->
    <header class="rf-recipe__preamble">            <!-- preamble inside content -->
      <p class="rf-recipe__eyebrow">...</p>
      <h2 class="rf-recipe__headline">...</h2>
      <p class="rf-recipe__blurb">...</p>
    </header>
    <ul class="rf-recipe__ingredients">...</ul>     <!-- body -->
    <ol class="rf-recipe__steps">...</ol>
  </div>
</article>

Known Violations

RuneIssue
PlaylistBuilds a custom <div data-name="header"> with imperative data-name assignment on the title, bypassing the pageSectionProperties + <header> preamble pattern. Description paragraphs are not wrapped as a blurb. Config lacks preamble section — has sections: { header: 'header', title: 'title', media: 'media' } instead of the recipe pattern { meta: 'header', preamble: 'preamble', headline: 'title', blurb: 'description', media: 'media' }.

Standard 3 — Config Must Match Schema Capabilities

If the schema transform emits a structural element (e.g. a scene image, a media zone), the engine config must declare the corresponding sections, mediaSlots, and autoLabel entries so the identity transform annotates it correctly.

Rule

  • Every named ref in createComponentRenderable should have a corresponding autoLabel or sections entry in the config.
  • Every media container should have a mediaSlots entry (e.g. { scene: 'cover' }) so the engine adds data-media and data-section="media".

Known Violations

RuneIssue
FactionSchema extracts a scene image and emits a scene ref, but config lacks mediaSlots: { scene: 'cover' } and sections entry for scene: 'media'
PlaylistSchema emits artistMeta, hasPlayerMeta, and an id meta tag, but config declares no corresponding modifiers — the identity transform won't produce data-artist, data-player, or data-id attributes on the root element. A theme cannot target [data-player="true"] to adjust layout when the player is present.

Standard 3a — Media Zones Must Unwrap Paragraph-Wrapped Images

When Markdoc transforms a Markdown image (![alt](src)), it produces <p><img .../></p> — the image is inline content inside a paragraph node. Media zones must unwrap this to emit a bare <img> inside the media container, not a <p> containing an <img>.

Rule

  • Media zone transforms must extract the <img> tag from its <p> wrapper before placing it in the media container.
  • The resulting HTML should be <div data-name="media"><img .../></div>, not <div data-name="media"><p><img .../></p></div>.
  • Use RenderableNodeCursor's .tag('img') traversal (which digs into children) rather than passing the raw Markdoc.transform() output directly into the media wrapper.

Rationale

  • A paragraph wrapping an image is semantically wrong — an image is not a paragraph.
  • It forces CSS to work around the extra element (.rf-recipe__media img instead of .rf-recipe__media > img), breaking direct child selectors that themes might use.
  • The shared [data-media="cover"] dimension styles target img directly and expect it as a direct child or near-direct descendant.
  • The storytelling runes (realm.ts, faction.ts) already unwrap this manually with a 15-line paragraph-digging loop. pageSectionProperties in common.ts also handles it correctly via cursor.tag('img').limit(1).

Reference

Correct approach (using RenderableNodeCursor):

// Extract bare <img> from media zone — unwraps Markdoc's <p><img/></p>
const mediaImg = mediaCursor.tag('img').limit(1);
const mediaDiv = mediaImg.count() > 0 ? mediaImg.wrap('div') : undefined;

Incorrect (current recipe approach — passes paragraph through):

// Media zone rendered as-is — <p><img/></p> survives into output
const side = new RenderableNodeCursor(
  Markdoc.transform(mediaAstNodes, config) as RenderableTreeNode[],
);
const mediaDiv = side.wrap('div');

Known Violations

RuneIssue
RecipeMedia zone passes Markdoc.transform() output directly into wrapper — <p><img/></p> survives
PlaylistSame pattern — media zone content not unwrapped

Note: Realm and Faction already unwrap correctly, but with duplicated inline code (see Standard 4).


Standard 4 — Avoid Duplicated Transform Logic

When multiple runes in the same package share structural patterns (scene image extraction, layout meta tag emission, content building), extract shared logic into package-level helpers rather than copy-pasting.

Rule

  • Identify repeated patterns across rune transforms within a package.
  • Extract into named helpers (e.g. extractMediaImage(), buildLayoutMetas()).
  • Each rune's transform should read as a composition of helpers plus rune-specific logic.
  • Cross-package patterns (like paragraph-unwrapping for media zones) should be provided as shared utilities in @refrakt-md/runes alongside existing helpers like pageSectionProperties and RenderableNodeCursor.

Known Violations

PackageIssue
storytellingrealm.ts and faction.ts share ~90% identical code: scene image extraction (paragraph → img dig), description rendering, layout meta tag creation, content building, and createComponentRenderable structure
cross-packageThe paragraph → img unwrap pattern appears in realm.ts, faction.ts (inline, 15 lines each) and is missing from recipe.ts and playlist.ts. Should be a single shared utility.

Standard 5 — Minimize Transform Code Paths

A rune's transform() function should produce a single createComponentRenderable call with conditional properties/refs, rather than duplicating the entire call across branches.

Rule

  • Use conditional spreading (...(condition ? { key: value } : {})) to vary properties and refs.
  • One createComponentRenderable call per transform function.

Known Violations

RuneIssue
CharacterTwo full createComponentRenderable calls in hasSections / else branches, differing only in one property and one ref

Standard 6 — Layout Meta Tag Emission Should Be Shared

Runes that extend SplitLayoutModel all emit the same boilerplate for layout meta tags (layout, ratio, valign, gap, collapse) with identical conditional logic. This pattern should be extracted rather than repeated.

Rule

  • Layout meta tag creation (the layout !== 'stacked' guards, gap/collapse conditionals) should be a shared utility, not copy-pasted per rune.
  • The utility should accept the attrs object and return the set of meta tags and their property map entries.

Known Violations

PackageRunes
storytellingrealm.ts, faction.ts — identical layout meta block
mediaplaylist.ts — same pattern, independently written
learningrecipe.ts — same pattern (reference implementation, but should also use the shared utility once extracted)

Standard 7 — Shared Split Layout CSS via Structural Selectors

Runes that follow the standard 3-section structure (meta header, content wrapper, media zone) should not duplicate split layout grid placement CSS. The shared split.css layer should handle this using attribute selectors, so per-rune CSS files contain only domain-specific body content styling.

Rule

  • The shared split.css (packages/lumina/styles/layouts/split.css) should define explicit grid column/row placement for the standard 3-section pattern using [data-section] and [data-name] attribute selectors.
  • Per-rune CSS files must not redefine grid-template-columns, grid-column, grid-row, or mobile collapse rules for split/split-reverse layouts when the standard structure applies.
  • Per-rune CSS files should contain only domain-specific body content styling (e.g. ingredient lists, track lists, step counters).
  • Shared media container patterns (border-radius, overflow, img sizing, split box-shadow) should also move to the dimension layer.
  • Mobile collapse must support two distinct modes depending on the rune category (see below).

Rationale

Recipe and playlist both define ~60 lines of near-identical split layout CSS with the only difference being the BEM prefix (.rf-recipe__ vs .rf-playlist__). Every future rune with split layout + media will copy them again. Since the identity transform already emits data-section="header", data-name="content", and data-section="media" on the standard structural elements, the grid placement can be expressed once with attribute selectors:

/* 3-section split: header + content in primary column, media spans rows */
[data-layout="split"] > [data-section="header"]  { grid-column: 1; grid-row: 1; }
[data-layout="split"] > [data-name="content"]    { grid-column: 1; grid-row: 2; }
[data-layout="split"] > [data-section="media"]   { grid-column: 2; grid-row: 1 / 3; }

[data-layout="split-reverse"] > [data-section="header"]  { grid-column: 2; grid-row: 1; }
[data-layout="split-reverse"] > [data-name="content"]    { grid-column: 2; grid-row: 2; }
[data-layout="split-reverse"] > [data-section="media"]   { grid-column: 1; grid-row: 1 / 3; }

This works for any rune that follows the standard structure — no BEM prefix needed.

Two-Mode Mobile Collapse

When a split layout collapses to a single column on mobile, the media zone's position relative to the preamble differs by rune category. The shared layer must handle both modes.

Content-first media (content runes)

Content runes like recipe, playlist, realm, and faction typically have a cover image or scene photo in their media zone. On mobile, this image should appear above the preamble as a full-bleed card header — it sets the visual context for the content below. Recipe achieves this today with order: -1 on the media zone plus negative-margin full-bleed treatment.

Mobile source order: media → meta → preamble → body

Preamble-first media (marketing runes)

Marketing runes like hero, feature, and step typically have a code block, product screenshot, or interactive demo in their media zone. On mobile, the preamble (headline + CTA) must lead — it's the hook that draws the reader in. The media supports the preamble and should appear after it, following the natural DOM order. These runes already delegate to split.css today, which resets to DOM order on collapse via order: unset.

Mobile source order: meta → preamble → body → media

Implementation approach

The shared split.css should support both modes via a data attribute on the root element (e.g. data-media-position="top" for content-first, with preamble-first as the default). This keeps the behavior declarative and avoids per-rune CSS for collapse ordering:

/* Default collapse: DOM order (preamble first, media after) */
@media (max-width: 640px) {
  [data-layout^="split"][data-collapse] {
    grid-template-columns: 1fr;
  }
  [data-layout^="split"][data-collapse] > * {
    grid-column: auto;
    grid-row: auto;
    order: unset;
  }
}

/* Content-first: hoist media above preamble on collapse */
@media (max-width: 640px) {
  [data-layout^="split"][data-collapse][data-media-position="top"] > [data-section="media"] {
    order: -1;
  }
}

The data-media-position attribute would be emitted by the engine config as a modifier (or directly by the schema transform as a meta tag). Content runes opt in with data-media-position="top"; marketing runes omit it or use the default. The full-bleed card header treatment (negative margins, border-radius reset) also belongs in the shared layer, gated on [data-media-position="top"].

Classification

ModeRunesRationale
Preamble-first (default)Hero, Feature, StepHeadline/CTA is the hook; media (code blocks, product shots) supports it
Content-first (data-media-position="top")Recipe, Playlist, Realm, FactionCover image sets visual context; content follows below

Future runes choose the appropriate mode based on what their media zone typically contains. Most content/editorial runes will want content-first; most marketing/landing-page runes will want preamble-first.

Prerequisite

Standard 2 (preamble inside content) must be applied first. If a rune emits its header/title as a direct child of the article instead of inside the content wrapper, it produces 4 direct children instead of 3, breaking the grid placement. Once all runes follow the standard structure, the CSS converges naturally.

Patterns to Migrate to Shared Layer

PatternCurrent locationTarget
Split grid explicit column/row placementrecipe.css:128–156, playlist.css:141–167split.css
Mobile collapse (reset grid-column/row)recipe.css:172–182, playlist.css:169–183split.css
Content-first media hoist (order: -1)recipe.css:186–189split.css (gated on [data-media-position="top"])
Full-bleed card header (negative margin bleed)recipe.css:190–199split.css (gated on [data-media-position="top"])
Split media box-shadowrecipe.css:158–161, playlist.css:186–189split.css or media.css
Media zone container (border-radius, overflow, img block sizing)recipe.css:104–113, playlist.css:119–130media.css

What Stays Per-Rune

Per-rune CSS files retain only domain-specific styling that no other rune shares:

  • Recipe: ingredient list (surfaced ul with disc markers), step counters (counter-reset: recipe-step), tip blockquotes
  • Playlist: track list layout (flex rows with name/artist/duration), player area, narrow-screen column hiding
  • Realm/Faction: section-specific typography or decorative elements
  • Hero/Feature/Step: marketing-specific decorative treatments (gradient overlays, accent borders, etc.)

Known Violations

RuneDuplicated linesPattern
Recipe~60 linesSplit grid placement, mobile collapse, content-first media hoist, full-bleed header, media container, box-shadow
Playlist~55 linesSplit grid placement, mobile collapse, media container, box-shadow (missing content-first hoist — should have it)
Realm, Faction(to audit)Likely same if they gain CSS

Scope of Changes

This spec covers runes in the following packages:

  • @refrakt-md/learning (recipe, howto) — recipe is the reference implementation
  • @refrakt-md/storytelling (character, realm, faction, lore, plot, beat, bond, storyboard)
  • @refrakt-md/media (playlist, track, audio)

Other community packages should be audited against these standards separately.

Applicability by Rune Tier

Not all standards apply equally to all runes. Runes fall into three structural tiers based on their output complexity:

Tier 1 — Full 3-section split layout (meta + content + media)

These runes extend SplitLayoutModel, have mediaSlots, and support split/split-reverse layouts. All seven standards apply. Standard 7 (shared split CSS) specifically targets this tier.

RunePackageFollows recipe pattern?Issues
RecipelearningReference implementation
HerocoreYes
FeaturecoreYes
StepcoreYes
PlaylistmediaNoCustom header (Std 2), missing modifiers (Std 3), duplicated CSS (Std 7)
RealmstorytellingNoNon-standard sections (Std 2), duplicated transform (Std 4), duplicated CSS (Std 7)
FactionstorytellingNoMissing mediaSlots (Std 3), duplicated transform (Std 4), duplicated CSS (Std 7)

Hero, Feature, and Step in core already follow the standard. Three runes need alignment: Playlist, Realm, and Faction.

Tier 2 — Header + content wrapper, no media zone

These runes have the meta/preamble/content structure but no split layout or media zone. Standards 1–5 apply (BEM hygiene, preamble grouping, config alignment, shared helpers, single code path). Standards 6–7 do not apply since these runes don't use SplitLayoutModel.

RunePackageNotes
HowTolearningStructurally similar to recipe minus media/split
BlogcorePage section with preamble + body
CharacterstorytellingHas portrait mediaSlots but no split layout; could graduate to Tier 1
LorestorytellingSimple header + body
EventplacesDetails header + content wrapper
ApidocsHeader + body wrapper
SymboldocsHeader + preamble + body
OrganizationbusinessPreamble + body sections

Character is the most notable candidate for promotion to Tier 1 — it already has a portrait media slot. Adding SplitLayoutModel would let authors place the portrait beside the character bio in a split layout.

Tier 3 — Sections only, no content wrapper

These runes use sections for identity transform annotation but have simpler structures that don't need content wrappers or split layouts. Standard 1 (BEM hygiene) applies universally. Other standards apply only where relevant.

RunePackage
Accordion, CallToAction, Pricing, Steps, Testimonial, Comparisoncore
Changelogdocs
Typography, Spacing, DesignContext, Mockupdesign
Plot, Beat, Bond, Storyboardstorytelling
Cast, Timelinebusiness
Itineraryplaces
Audiomedia

Summary

StandardTier 1 (7 runes)Tier 2 (8 runes)Tier 3 (~18 runes)
1 — BEM modifier hygieneYesYesYes
2 — Preamble inside contentYesYes
3 — Config matches schemaYesYesYes
4 — No duplicated transformsYesYesYes
5 — Single code pathYesYesYes
6 — Shared layout meta utilityYes
7 — Shared split layout CSSYes
8 — Theme-level shared classesYesYes

Standard 8 — Theme-Level Shared Classes for Structural Grouping

When multiple runes share nearly identical CSS (same structural pattern, same layout, same styling), themes should be able to emit a shared class on those runes — reducing CSS duplication without coupling unrelated rune packages at the config level.

Rule

  • The RuneConfig interface should support an optional sharedClasses?: string[] field.
  • The identity transform engine should prefix each entry with the theme prefix (e.g. ['entity-card'].rf-entity-card) and add them to the root element's class list.
  • Shared classes must be set by the theme layer (via mergeThemeConfig overrides), not by rune package configs. Different themes may want to group runes differently — a storytelling-focused theme might share styles between realm/faction/character, while a minimal theme might not share at all.
  • Shared class CSS files should live alongside the theme's per-rune CSS (e.g. packages/lumina/styles/shared/entity-card.css), not in the rune packages themselves.

Rationale

Realm and faction currently have ~150 lines of near-identical CSS, differing only in the BEM prefix (.rf-realm vs .rf-faction). This duplication is a maintenance burden — every change must be made twice. CSS has no native mixin or @extend mechanism, and adding a preprocessor is an architectural decision beyond the scope of this spec.

The key insight is that which runes share styles is a theme decision, not a rune decision. A rune package defines structure (BEM block, sections, modifiers). A theme decides visual treatment. Two runes might look identical in Lumina but completely different in another theme. Therefore the grouping mechanism belongs in the theme layer.

Engine Change

Minimal — two lines in transformRune():

// After building the BEM class string
const sharedParts = (config.sharedClasses ?? []).map(c => `${prefix}-${c}`);
const bemClass = [block, ...sharedParts, ...modifierClasses, existingClass].filter(Boolean).join(' ');

Theme Usage

Lumina would add shared class overrides via mergeThemeConfig:

export const luminaConfig = mergeThemeConfig(baseConfig, {
  runes: {
    Realm:   { sharedClasses: ['entity-card'] },
    Faction: { sharedClasses: ['entity-card'] },
  },
  // ...existing tints, icons
});

Then write shared CSS:

/* packages/lumina/styles/shared/entity-card.css */
.rf-entity-card { /* ~150 lines of shared layout, typography, section styling */ }
.rf-entity-card__name { ... }
.rf-entity-card__scene { ... }

Per-rune CSS retains only truly unique overrides:

/* realm.css — only realm-specific rules */
.rf-realm__lore-section { border-left: 2px solid var(--rf-color-accent); }

/* faction.css — only faction-specific rules */
.rf-faction__influence { font-variant-numeric: tabular-nums; }

Interaction with Existing Config

  • sharedClasses is additive — it does not replace the rune's own BEM block class.
  • The shared class participates in applyBemClasses for element-level BEM (e.g. .rf-entity-card__scene) only if the shared class file defines those selectors. The engine emits the shared class on the root element only.
  • CSS coverage tests should recognize shared classes as valid selectors for any rune that declares them.
  • Contracts should include shared classes in the rune's class list.

Candidates for Shared Classes in Lumina

Shared ClassRunesShared Lines
entity-cardRealm, Faction (potentially Character)~150 lines

Other groupings may emerge as more runes adopt the standard structure. This standard provides the mechanism; themes decide when to use it.


Non-Goals

  • Changing the identity transform engine itself — these standards work within the existing engine.
  • Mandating specific semantic HTML tags — runes choose <article>, <section>, <div> etc. based on their domain semantics.
  • Prescribing visual design — themes decide appearance; this spec governs structure only.

Relationships