v0.43.0 · Section is the composition base

All releases

A composition that wants a titled region with media places {% section %}, not {% card %}. section gains a body role, a header grammar that no longer reads prose as a header, the media split, cover and a media kind. The composed storytelling runes move onto it, and two new primitives arrive: {% actions %} for rows of buttons and a block-level {% link %}. card renders exactly as it does today (, , ).

Changes

  • The eyebrow is a section role (WORK-656). Every rune with an eyebrow (hero, cta, section, card, feature, pricing, reveal, tabs, accordion, steps) now stamps data-section="eyebrow" on it.
    • The eyebrow's type and its linked pill are styled once, by [data-section="eyebrow"] in the sections dimension: the stretched link in the skeleton, the pill in Lumina. The copies in nine runes, two packages each, are gone. A rune keeps only its own spacing, and card keeps a one-step-smaller size.
    • A linked eyebrow in card now renders as a pill, as it already did in the other nine.
    • eyebrow joins the section-role vocabulary (RuneConfig.sections). It does not count as a header role, so it does not offer prominence on its own.
  • refrakt format takes --ignore <path> (repeatable), and the refrakt.format MCP tool takes ignore. Each leaves out a file, or a directory and everything under it, for generated content that its generator rewrites.
  • section's header is now anchored to its headline (ADR-046).
    • Content with no heading changes. A section that does not start with a heading, or with one paragraph and then a heading, has no header: all of its content renders as body. Before this, its first paragraph was lifted into a <header> as the blurb. The site's own sections all start with a heading and render as before, but check third-party content that writes a section with paragraphs only, or an image and paragraphs.
    • Eyebrow and blurb exist only beside the headline. An eyebrow is a paragraph directly before the heading, and a blurb a paragraph directly after it.
    • New header attribute (auto | headline | none, default auto). headline lifts only a leading heading and keeps the paragraph after it in the body. none reads no header.
    • New slot-header-role warning. A composed rune whose slot content a placed section reads as a header role the slot is not named for (for example a description slot read as the blurb) now gets a build warning. It names the rune, the slot, the role and both remedies. It is on by default, can be demoted with validation.disableIds, and changes nothing that renders.
  • New core rune {% actions %}: a row of actions from a plain list of links, with an optional fence as a copyable command line (SPEC-160 D6).
    • Rank is a data attribute. Each link item is <li data-name="action"> with data-rank="primary" on the first link of the row and secondary on the rest. Themes style the rank, not the list position.
    • hero and cta render their buttons through it. Their actions region is now a nested data-rune="actions". It keeps rf-hero__actions / rf-cta__actions beside rf-actions.
    • Button CSS lives in actions.css in Lumina and the skeleton. It is no longer copied into hero.css and cta.css, which now set only --action-padding, --action-font-size and --actions-justify.
    • The primary button's text follows the colour scheme (--rf-color-on-primary). In dark mode it was white on a near-white fill and barely readable. It now has proper contrast, and the cover hero's override for the same problem is gone.
    • Narrower selectors. hero and cta used to style any list or nav inside them as a row of buttons. Only their actions list is styled now.
    • One primary per row. "Primary" was the first item of each list, so a hero written as list, fence, list had two primary buttons. It is now the first link of the whole row.
  • {% link %} now wraps blocks, and linked cards and bento cells are reachable by keyboard and screen readers (SPEC-160 D8).
    • Linked cards and bento cells are real links now. Their whole-card href overlay used to be aria-hidden="true" tabindex="-1", so a keyboard or screen-reader user could not reach it. It is now a focusable link with one tab stop per card, named by the card's title (aria-labelledby), with a visible focus ring in Lumina.
    • {% link %} around blocks makes the whole region a link.
    • With nothing interactive inside, the region is wrapped in a real <a data-link="wrap">.
    • With links or controls inside, a stretched <a data-link="stretch"> is laid over the region instead, so no <a> is nested.
    • The choice is made in the identity transform, so it sees links that postProcess adds.
    • The link is named by aria-label when given, else by the content's title.
    • An empty href leaves the content unlinked.
    • It adds no data-rune, so structured data inside it still publishes.
    • Unchanged: the inline form, {% link href=… %}text{% /link %} in a line of text, renders a plain <a> as before.
    • Shared CSS. The stretched-link positioning moved from card.css and bento.css into a shared dimensions/link.css keyed on data-link, so it works around any rune.
  • The data-field="content-section" attribute is gone from every rune root (SPEC-133). Nothing read it. It came from property: 'contentSection' in 28 transforms, which are now cleaned up.
    • Removed outright: the documents and Page exports of @refrakt-md/runes. The Page document node was never registered by any Markdoc config, and it compared the attribute with the wrong casing, so it never ran. The PageSection config entry and page-section.css in Lumina and the skeleton went with it.
    • Deprecated: property on createComponentRenderable and on an emits declaration is still accepted, so a plugin that passes it keeps type-checking, but it is ignored and renders nothing. It is removed in the release after this one.
  • section gains the media split, cover and a media kind (SPEC-160 D2, ADR-041). A titled region with media no longer needs card, which draws a surface.
    • Split. media-position (top | bottom | start | end | cover), media-ratio, valign and collapse work as on card. With media-position, a section splits on --- into a media zone, the content and an optional footer. The header stays in the content column, and an empty leading zone leaves the media out.
    • Opt-in. Without media-position, a section renders exactly as before, and a --- stays an ordinary rule in its body.
    • Cover. media-position="cover" overlays the content on the media, with content-place, height and aspect, as on card.
    • Media kind. media (portrait | cover | thumbnail | hero | icon) stamps data-media on the media zone, so the media dimension styles it. A portrait is cropped round with no CSS of its own.
    • Card. card's output does not change.
  • section declares its body as the body section role (SPEC-160 D1). reading and dropcap now take effect on a section's body. Before this they were accepted and silently did nothing. The body region now carries data-section="body".

Compose content on section, not card

ADR-041accepted

Compose content on section, not card

runescompositionprimitivescardsectionarchitecture

Context

SPEC-145 D18 made card the canonical primitive a composition places when it wants a media split. The reasoning held up on mechanism: card carries base: SplitLayoutModel, splits media / body / footer on ---, unwraps a bare image, and composing over it deletes helpers recipe hand-rolls. The storytelling compositions follow it: character, realm and faction all place {% card %} and put the portrait in its media zone (packages/content/test/fixtures/composed-storytelling/runes/).

Two observations since then say the choice was made on the wrong axis.

card is a presentation; the runes composed over it are content. Lumina draws a card as a padded, bordered, rounded panel with its own surface fill (packages/lumina/styles/runes/card.css), and the rune renders as a bare <div>. A character, a realm, a recipe or a hero is a titled region of a page that may or may not be drawn as an object. SPEC-159 already reached the same conclusion from the other side: its materiality axis treats "a flat panel vs a card with edges" as an author's intent, not as a rune's identity.

D20 is the symptom. It measured card as "the only split-layout rune without the page-section anatomy": five of the eight SplitLayoutModel runes declare the identical preamble / title / description triple and card declares { media, body }. Its decision was to grow card a placeable title, blurb and meta slot, at the cost of a no-drift migration on every page that already places a card. That is retrofitting a section's anatomy into the panel primitive, because the panel was chosen as the base. D20 is unimplemented.

section already has the anatomy D20 wants to add. It renders a real <section> with property: 'contentSection' and a <header> holding eyebrow, headline and blurb, and declares { preamble, headline: 'title', blurb: 'description' }. What it lacks is the mechanism D18 chose card for: the media zone, the split attributes, the cover variant, and a footer.

The question is which of the two to grow. It is cheap to answer now: the composed runes still sit beside their plugins rather than replacing them, and only three definitions place card.

Options Considered

  1. Keep card as the base and implement D20. Card gains flat title, blurb and meta slots and a preamble layout group. Every composition keeps working. But the base primitive stays a drawn object, so every composed rune inherits a border and a surface it then has to undo, and D14's universal attributes keep painting a second surface around the card (D14 records elevation, substrate and reveal doing exactly that). And every existing {% card %} page takes the migration D20 called "the one part of this that is not cheap".
  2. Make section the base, and give it the split. Section gains the media zone, the split attributes, cover and a footer, through one shared implementation that card also uses. card becomes a section that is drawn as an object. Existing {% section %} pages are protected by making the zoning opt-in. Composed runes get the anatomy and no surface.
  3. A new neutral primitive (for example entity or region) with the anatomy and the split, leaving card and section alone. Avoids touching either rune's existing pages, but adds a third rune whose job overlaps both. An author choosing between section and the new rune would be choosing between two names for a titled region, which is the confusion this decision is meant to remove.

Decision

Option 2. section is the primitive a composition places for a titled region of content, with or without media. card is a section with a surface: the same anatomy and the same split, drawn as a discrete object. SPEC-160 specifies the change.

This supersedes D18's choice of card as the canonical media-split primitive (the argument that composing over a split primitive deletes work stands; the primitive changes) and D20's decision to grow card a preamble. D20's measurements and its scope limit on mediatext stand.

Rationale

  • The base should carry meaning, and the surface should be a choice. A composition that wants a drawn object still gets one, by placing card or by materiality once it exists. A composition that wants a region no longer starts from an object and subtracts.
  • The anatomy is the larger part, and section has it. D20 counts 23 runes declaring preamble, 29 declaring blurb: 'description', 21 declaring headline: 'title'. Moving the split onto the rune that already speaks that vocabulary is smaller than moving the vocabulary onto the rune that has the split, and it does not change card's existing output.
  • It is what the marketing compositions need. SPEC-151 names hero, cta and steps as the first marketing migrations, and D20 notes that hero and steps hit card's missing preamble before anything else. hero and cta already render as <section> with the preamble triple; on a section with a split they are compositions.
  • The timing is cheap. Three definitions, all in test fixtures, place card today.

Consequences

  • section gains opt-in zoning. A --- inside a section is ordinary body content today, and must stay so for every page that does not ask for a media zone (SPEC-160 D2).
  • card's split moves into a shared implementation. card's own output must not drift: refrakt contracts --check and the gallery are the gate, as SPEC-143 D7 set for migrations.
  • The three storytelling definitions are recomposed on section. Their fixtures' expected output changes; the registry and SEO comparisons against the plugin are re-run, not re-recorded silently.
  • Hero and cta lose their rf-hero__* / rf-cta__* element classes when composed, because a composition emits the primitive's classes (a composed character carries rf-card__* and no rf-character class at all). This is true whichever primitive they are built on. SPEC-160 D7 finds that neither needs rune CSS once section, actions and the existing dimensions carry their look, so the break is a migration note for third-party themes.
  • card itself can become a composition over section once materiality and a link primitive exist (SPEC-160 D3, D8).
  • D14's chrome-carrier question gets a better default: the carrier is a section, which has no surface of its own for a universal attribute to double.
  • recipe and howto, which D18 called composable over card, compose over section instead, and recipe's cover-scope="header" is expressible there because section already has the preamble the cover dimension keys on.

Section's header grammar is anchored and explicit

ADR-046accepted

Section's header grammar is anchored and explicit

runessectioncompositioncontent-modelgrammararchitecture

Context

ADR-041 makes section the base that compositions place. The page-section runes (hero, cta, feature, pricing, reveal, tabs, accordion, steps) are expected to become compositions over it in time. Whatever section does with a leading paragraph or heading then becomes the header grammar of that whole family.

Today the family has two grammars, and they disagree:

ContentNative page-section runes (pageSectionProperties, packages/runes/src/tags/common.ts)section (packages/runes/src/tags/section.ts)
Paragraphs, no headingthe first paragraph is the blurbthe first paragraph is the eyebrow
Paragraph, then headingeyebrow, then headlineeyebrow, then headline
Two headingsthe first heading is the eyebrowthe second heading is body
Heading, then prose, then a paragraphthe first paragraph after the heading, anywhere, is the blurbonly a paragraph directly after the heading is the blurb

Composition makes section's grammar read content that was never written as a header. A host runs its content model over the expanded template, so an author's paragraph arriving through a slot is matched by position like anything else:

  • SPEC-169 D4's steps example places each step with {% section %}{% slot /%}{% /section %}. A step's text is a paragraph with no heading before it, so section renders it as an eyebrow: small, uppercase, in the primary colour.
  • A composed testimonial (SPEC-161 D1) that places its quote and attribution slots in a section gets the quote as the eyebrow and the attribution as the blurb.
  • The composed character, realm and faction escape only because their template places {% metablock %} between the heading and the description slot. Without it, the description's first paragraph would become a blurb. Nothing would report it.

A standalone section has the same problem, and WORK-652 makes it more common. With a media split, section becomes the general "region with media" primitive. An image beside two paragraphs then renders the first paragraph as an eyebrow.

Options Considered

(a) Accept positional matching

Record each case as an explained difference. Rejected: every case above is a misreading the author did not ask for and cannot see coming.

(b) Header roles come only from slots named for them

Inside a placed host, a header role is filled only by text the template wrote itself, or by a slot whose name is the role's. Other slot content goes to the body. The slot markers SPEC-145 D10a puts on the AST make this implementable. Rejected, for four reasons:

  • The same markup means different things on a page and in a template. A snippet pasted from a template body into a page changes meaning. SPEC-170 D2 rejected separate template and page parsers for exactly this reason.
  • It depends on invisible markers. Neither inspect nor reference would show why a paragraph is or is not a blurb.
  • It couples input vocabulary to host roles. A composition's field names are what its authors write and read in reference. realm would have to rename description to blurb to get the role, renaming its input for a styling reason.
  • It does nothing for a standalone section.

Its strength, that a composition is correct without its author remembering anything, is kept by the diagnostic in decision 3.

(c) An explicit attribute on section

Chosen, together with an anchored grammar and a diagnostic.

Decision

  1. The grammar is anchored to the headline. Eyebrow and blurb exist only relative to a headline. With no heading there is no header: all content is body. An eyebrow is a paragraph directly before the headline, and a blurb is a paragraph directly after it.

  2. section takes header:

    • auto (the default) reads eyebrow, headline and blurb as in decision 1;
    • headline lifts only the headline, so the paragraph after it stays body;
    • none reads no header at all: the region is all body.

    The attribute applies wherever section is used, standalone or placed by a composition. A composition sets it like any other attribute, for example {% section header="headline" %}.

  3. A slot that lands in a header role it is not named for is reported. At transform time, when content carrying a slot marker fills a header role and the slot's name is not that role's, the build warns, naming the composed rune, the slot, the role and both remedies. For example: "slot description of character is read as section's blurb; name the field blurb, or set header="headline" on the section." The warning changes nothing that renders.

  4. The page-section runes keep their native grammar until each one becomes a composition. A composed page-section rune parses its author's content with its own content model and places the result. It does not inherit pageSectionProperties' rules through section.

Rationale

  • The anchoring alone fixes every misreading found: the steps and testimonial cases and the standalone region with media. None of them has a heading.
  • An attribute is visible and portable. It shows in the source, in inspect and in reference, and means the same thing on a page and in a template.
  • The diagnostic is precise. The slot markers are already on the AST, so the check sees exactly which slot filled which role, with no static guessing about what a slot may contain.
  • Deciding on section now is cheaper than later. Every composition placed on it after this inherits the grammar, and changing it then would move every one of them.

Consequences

  • section's output changes for content with no heading: a leading paragraph that rendered as an eyebrow is now body. The site's existing section examples all have a heading, but the changeset names the change for third-party content.
  • SPEC-169 D4's steps example renders its steps as body.
  • header is a new attribute on section. reference documents it, and the editor offers it.

Open questions

  • A heading used as an eyebrow. hero, cta and feature read two headings as an eyebrow heading and a headline. section's eyebrow is a paragraph only, so a composed hero that places a heading eyebrow would get the wrong headline. This is the one case where (b)'s binding by name is cleaner. It is decided with SPEC-160 D7, when hero and cta become compositions. The options are an eyebrow field that also takes a heading followed by another heading, or a composed hero that turns its heading eyebrow into a paragraph.

References

  • ADR-041: section is the composition base.
  • SPEC-160: D9 records this decision; D7 carries the open question.
  • SPEC-145: D10a (slot markers); the host's positional grammar reads the template's arrangement.
  • SPEC-169: D4's steps example.
  • SPEC-170: D2, one meaning for a snippet wherever it is pasted.
  • WORK-659: the implementation.

Section is the composition base; card is a section with a surface

SPEC-160accepted

Section is the composition base; card is a section with a surface

A composed rune that wants a titled region of content, with or without media, places {% section %}. Today it places {% card %}, because card is the only primitive with a media split (SPEC-145 D18). ADR-041 records why that puts a drawn object at the base of every composition, and decides to move the split onto section instead.

This spec says what section gains, how its existing pages are kept safe, what happens to card, and in what order the compositions move. It also specifies two primitives: actions, which hero and cta need before they can be compositions, and link, which takes href off card.

runescompositionprimitivessectioncardmarketingstorytelling

The two primitives today

sectioncard
Element<section>, property: 'contentSection'<div>
Header anatomyeyebrow, headline, blurb in a <header>eyebrow only; the first heading stays inside the body
Sections declaredpreamble, headline: 'title', blurb: 'description'media, body
body roleno (its body slot is undeclared)yes
Media zone and splitnobase: SplitLayoutModel; --- zones by count: body / media + body / media + body + footer
covernoyes (data-cover-scope="full")
Footernoyes
Whole-region linknohref, a stretched overlay
Surface in Luminanonepadding, radius, border, --rf-color-surface fill

Sources: packages/runes/src/tags/section.ts, packages/runes/src/tags/card.ts, packages/runes/src/config.ts (Section and Card entries), packages/lumina/styles/runes/{section,card}.css.

The split's CSS is already shared: packages/skeleton/styles/layouts/split.css keys on [data-media-position], [data-section="media"] and [data-name="content"], with no rune name. The cover dimension is rune-agnostic the same way (skeleton/styles/dimensions/cover.css). So the missing parts are in section's schema and config, not in its CSS.

Decisions

D1 — section declares its body role

sectionSections gains body: 'body'. Today reading and dropcap do nothing on a section, silently: the case packages/transform/src/section-roles.ts exists to catch. This stands on its own and lands first.

D2 — section gains the split, and zoning is opt-in

section takes base: SplitLayoutModel (media-position, media-ratio, valign, collapse) and the cover variant, through the same helpers card uses (splitMediaBodyFooter, extractMediaImage, buildLayoutMetas in packages/runes/src/tags/common.ts).

Zones are read only when media-position is given. A --- inside a section is ordinary body content today, and every existing page that writes one must render unchanged. So:

  • No media-position: the content model is exactly today's; --- stays in the body.
  • media-position given: the children split on --- by card's rule, a leading media zone, the body, and an optional trailing footer. An empty leading zone means no media, and the media element is omitted (card's empty-zone guard), so a template can emit the delimiter unconditionally as SPEC-145 D17 requires.

A composition opts in by passing the attribute through with a default, for example a composed character declaring media-position with default: top and writing {% section media-position=$attrs["media-position"] %}. The opt-in is the attribute a split already needs, not a new switch.

Inside the body zone, the header anatomy applies as it does today: eyebrow, headline and blurb lead the body, then everything else. With a split, the <header> sits in the content column beside or below the media, where hero, feature and recipe already put it, and cover-scope="header" becomes expressible because preamble exists.

Closed: whether a section with a split should drop the contentSection property. The question is moot. The property is inert: its only consumer, the Page document node, never matched it and is never registered. SPEC-133 removes it from every rune, and WORK-650 does that before this spec's work starts, so section carries it with or without a split until then.

The media zone's kind is an attribute. A rune declares what its media is through mediaSlots in its config, for example { portrait: 'portrait' }, and the engine stamps data-media="portrait" on that element. The shared media dimension then styles it with no rune CSS: skeleton/styles/dimensions/media.css crops a portrait square at --media-portrait-size (5rem, 3rem at compact density), and Lumina rounds it. The vocabulary is portrait | cover | thumbnail | hero | icon (RuneConfig.mediaSlots, packages/transform/src/types.ts).

A composition cannot reach that. Its generated config carries only modifiers, metaFields, blocks and provides (composedRuneConfig, packages/runes/src/lib/composition.ts), and the media zone belongs to the rune it places, not to the composition. This has already cost the composed character its round portrait: the plugin's character renders the portrait zone with data-media="portrait", and the composed one renders a plain media zone inside rf-card__media, with no kind (packages/content/test/composed-character.test.ts, the recorded plugin-vs-composed HTML).

So section takes media, with the same five values, and stamps data-media on its media zone when it is given. With no media, the zone carries no kind, as today. A composition passes it through like the split attributes:

{% section media-position=$attrs["media-position"] media="portrait" %}
{% slot name="portrait" /%}

---
…
{% /section %}

That gives three runes their portrait back from one attribute and the existing dimension:

  • character and testimonial (SPEC-161 D1), which declare a portrait media slot today;
  • cast-member, which hand-rolls the same circle in its own CSS (.rf-cast-member img { border-radius: var(--rf-radius-full) } in lumina/styles/runes/cast.css) instead of using the dimension. As section media-position="top" media="portrait" it needs none. SPEC-149 already found cast and cast-member composable, with Person mapped from attributes.

An avatar rune is not proposed. It would earn its place only for a portrait outside any media zone, inline with text (a byline, a conversation speaker), or to add a fallback the dimension cannot, such as initials when there is no image. No rune audited so far needs either. The placeholder: / icon: image schemes resolve in the image node (packages/runes/src/nodes.ts), so an image authored into the media zone gets them without an avatar rune. An image given as an attribute, as cast-member's image is, still needs SPEC-149 D4: the composition has to render it through the same resolution.

D3 — card's output does not change now; card as a composition is the target

card keeps its attributes, its --- counting, its href overlay, its <div> and its BEM output. Its schema moves onto the shared split helpers where it does not already use them, and refrakt contracts --check must report no change to Card on either contract copy.

The target is card as a composition over section. "card is a section with a surface" is the model this spec adopts. It becomes literal once three things exist, none of which this spec builds for card itself:

  1. The surface, from SPEC-159's materiality axis: a card is section materiality="object" with the split and media-position defaulting to top, which is what card renders today.
  2. The whole-card link, from the link primitive (D8) wrapping the section. href is the one part of card that is behaviour rather than layout, and it moves into a primitive every rune can use instead of staying a card attribute.
  3. An answer to the class question D7 records. A composed card renders rf-section__*, not rf-card__*, and card is the most widely styled rune there is. If the D7 analysis holds for card too (its look is a surface plus shared dimensions, so Lumina needs no card.css of its own), the break is a migration note for third-party themes, not a design problem.

Until then card stays a schema rune with unchanged output. One intermediate step is recorded and not specified here:

  • card adopting section's flat header anatomy (title and blurb as placeable slots), which is SPEC-145 D20's change and its no-drift migration. If card becomes a composition, it gets the anatomy for free and D20 is never implemented, which is the better outcome.

A core rune can ship as a composition; measured, not assumed. A throwaway composed tile (placing section and card) was built with defineComposedRune and registered the way packages/runes/src/index.ts registers core runes: added to the runes catalog, with its generated config under its type name beside baseConfig.runes. Then:

  • checkComposedCatalog (cycles, D9 peer types, D12 required parents) passed with it counted as core, and collectCompositions listed it for contracts;
  • it rendered through the identity transform as data-rune="tile", its title slot landed in section's real <header> as rf-section__headline with property="name", and the CreativeWork name published.

Two constraints on how, both small:

  • The definition is a string in a TypeScript module, not a file in a rune directory. @refrakt-md/runes is imported by the editor's preview, so core must not read the file system at load, which a plugin's runeDir does. defineComposedRune(name, source) takes the source as a string, so core passes it one.
  • The catalog checks run at site assembly. They run inside mergePlugins, which every assembly path calls (the content loader, the SvelteKit plugin, the CLI), so a core composition is checked on every build, not only when a plugin is loaded.

D4 — the storytelling compositions move to section

character, realm and faction replace {% card %} with {% section %}, passing media-position (and, for realm and faction, the other split attributes they already forward). Their fixtures' expected output changes, and is reviewed as a diff:

  • the element classes change from rf-card__* to rf-section__*;
  • the title and description slots become a real <header>, which they were not inside card (the composed character today renders its <h1> inside rf-card__body);
  • the surface disappears, which is the point;
  • the portrait zone regains data-media="portrait" (D2's media attribute), which the composition lost when it placed card.

The comparisons recorded against the plugin are re-run, not re-recorded: JSON-LD against the SEO baseline, RDFa, and the storytelling registry snapshot (SPEC-145's worked character example lists what each one showed). Any movement is explained in the PR.

D5 — recipe and howto compose over section

SPEC-145 D18 called them composable over card, blocked only on SPEC-146. The same holds over section, and recipe's cover-scope="header", its one existing consumer, is expressible there. This spec records the change of base; their migration is its own work.

D6 — an actions primitive

Buttons are a convention today, not a primitive:

  • hero and cta override item with linkItem (packages/runes/src/tags/common.ts), which renders <li> with its text in a <span>, and wrap a fence as a command block;
  • the look is CSS by list position: li:first-child a is the primary fill and the rest are outlined, duplicated between packages/lumina/styles/runes/hero.css and cta.css;
  • no other rune can get buttons without copying both.

{% actions %} is a core rune that takes a list of links, and optionally fences, and renders a row of actions:

  • each link item becomes an action with data-name="action", and its rank as a data attribute: data-rank="primary" for the first, secondary for the rest, so themes style the rank and not the list position;
  • a fence becomes a command (a copyable install line), as in hero today;
  • align follows section's alignment.

A composition places it around a slot, so authors keep writing a plain list:

{% section media-position=$attrs["media-position"] %}
{% slot name="media" /%}

---

{% slot name="headline" /%}
{% slot name="blurb" /%}
{% actions %}{% slot name="actions" /%}{% /actions %}
{% /section %}

Lumina's button CSS moves from hero.css and cta.css into actions.css, and hero and cta (still plugin runes until D7) place their actions through the same renderer so their output and look do not change.

Rank stays positional for now. That matches today's output. An authorable rank (for example a trailing {.primary} annotation or an attribute on actions) is left until a rune needs one.

Settled in the 2026-10-26 review (WORK-654 carries the detail):

  • hero and cta emit actions as a nested rune rather than calling a shared builder, so the composed hero of D7 produces the same actions region they already do;
  • data-rank goes on the <li>, and primary is the first link of the whole region (today it is the first item of each list);
  • both skeleton and Lumina get actions.css; button size comes from custom properties that hero and cta set, because the two copies differ today, until D7 decides where size comes from;
  • the primary action's text uses a scheme-following token instead of white, which removes hero's cover override.

D7 — hero and cta become compositions with no rune CSS of their own

With D2 and D6, hero is a section with a split, cover, the header anatomy and actions; cta is a section with a header and actions. Both are SPEC-151's first marketing migrations.

Neither needs CSS of its own. Every selector in skeleton/styles/runes/{hero,cta}.css and lumina/styles/runes/{hero,cta}.css is one of these, and each already has, or gains in this spec, a home that is not the rune:

What the rune CSS doesWhere it lives instead
Display-size headline and blurbprominence="display", which exists (quiet / normal / prominent / display, SPEC-107)
The pill eyebrow when it holds a linkthe sections dimension (WORK-656); it is copied into nine runes in both skeleton and Lumina today
Buttons by list position (li:first-child a, …)actions (D6)
data-align on the header, blurb and actionssection's align
Media above or below, image fillthe shared split layout (skeleton/styles/layouts/split.css)
cover, the sm–xl heights, aspectthe cover dimension, and height / aspect moving onto section with the split (D2)
Full width, flush elevation, bleeduniversal width and elevation, and guestFit
cta--in-hero, cta--in-pricingcontext tweaks to re-examine, not carry over by default

So the theme question this decision first posed (how a composed rune keeps its rf-hero__* selectors) mostly dissolves. Lumina deletes hero.css and cta.css. What remains is a break for third-party themes that targeted those classes, which ships as a migration note naming the dimension each rule moved to. A composition keeping its own element classes is not needed for these two runes, and this spec does not propose it.

The real prerequisite is defaults. A hero is prominence="display", width="full" and elevation="flush" unless the author says otherwise. The template can hard-code those on its {% section %}, but then the author cannot override them: a universal attribute written on the composed rune ({% hero prominence="prominent" %}) lands on the composition's empty root, not on the section that carries the header. That is SPEC-145 D14's open question, which element a composed rune's universal attributes apply to (its "chrome carrier"). hero and cta move once D14 is decided, with the section as the carrier.

A whole-region link is useful far beyond card: a figure that opens the full image, a section that is a teaser for another page, a testimonial linking to the case study. Today only card and bento-cell can do it, each through its own href.

link already exists, and this extends it. Core registers the Markdown link node as a tag too (packages/runes/src/nodes.ts, exported in tags), so templates can write {% link href=$item.url %} inside collection cells. It renders a plain <a> with no data-rune, and it accepts inline children only (strong, em, s, code, text, tag). D8 lets it take block content, and wrap any block to make it a link.

It has two renderings, chosen by what it wraps, because HTML allows an <a> around flow content only when that content contains nothing interactive:

  • Wrap. When the content has no links, buttons or form controls, the rune renders a real <a> around it. This is the simple, fully accessible case: the whole block is the link's content.
  • Stretch. When the content does contain interactive elements, wrapping would nest them, which is invalid. The rune instead appends a stretched <a data-name="link"> inside the wrapped rune's root, positioned over it, as card does today, so the inner links stay clickable above it.

The choice between the two is made in the identity transform, not in the schema. Links are added after a rune's transform has run: storytelling's postProcess wraps entity names in <a> (plugins/storytelling/src/pipeline.ts), and {% ref %} resolves in the cross-page pipeline. A link that chose "wrap" at transform time would end up around an <a> added later, which is invalid HTML. So the schema emits the region with its href, and the engine picks wrap or stretch. The engine runs per page on the renderable the pipeline returns (packages/create-refrakt/template/src/routes/[...slug]/+page.server.ts), so it sees the final content.

It fixes an accessibility gap in today's overlay. card's stretched link is aria-hidden="true" tabindex="-1" (packages/runes/src/tags/card.ts), so a keyboard or screen-reader user cannot reach the whole-card link at all. The stretched form of link must be focusable and labelled: by an aria-label attribute when given, else by the wrapped content's title slot (aria-labelledby).

It must not become a name-resolution boundary. SPEC-145 D19 records that a placed rune carrying data-rune stops findAllByName, so schema properties sourced inside it vanish. A link around a composed character must not hide the character's name from the harvest. The existing link already renders a plain <a> with no data-rune of its own; the block form must keep that, and a test pins that a schema property inside a link still publishes.

An empty href renders the content unchanged, so a composition writes it unconditionally. That relaxes today's href: { required: true }, for the block form at least:

{% link href=$attrs.href %}
{% section materiality="object" media-position=$attrs["media-position"] %}
…
{% /section %}
{% /link %}

Closed: card's and bento-cell's href are kept as shorthand that renders through link, and are not deprecated. About 20 content files use them, and deprecation would churn every one for no gain. The shorthand is also what a composition writes anyway: once card is a composition (D3), href is an attribute its template forwards to {% link %}, so it never needs retiring. Rendering through link gives both runes the focusable, labelled overlay above, which adds one tab stop per linked card. That is the intended fix, and the changeset names it.

The shorthand always takes the stretched form. An outer <a> around a bento-cell takes the cell out of its grid track (the grid-column / grid-row span is on the cell), and an <a> inside the root breaks the > .rf-bento-cell__content split rules. card has the same child selectors. So href on either rune renders the focusable stretched anchor whatever the content, and the wrap form is for an authored {% link %} (2026-10-26 review).

D9 — section's header grammar is anchored to the headline, and explicit

section reads a leading paragraph as an eyebrow even with no heading after it, so a composition that places prose in it, or a standalone region with media, gets that prose re-roled without a warning. ADR-046 decides:

  • eyebrow and blurb exist only relative to a headline; with no heading, all content is body;
  • section takes header (auto | headline | none);
  • slot content that fills a header role it is not named for raises a build warning.

ADR-046 records the comparison with binding header roles to slot names, which was rejected, and leaves one question for D7: a heading used as an eyebrow, which hero, cta and feature read today.

Order

  1. D1, the body role, then D9, the header grammar.
  2. D2 and D3: the split on section, card unchanged.
  3. D4: the storytelling compositions on section.
  4. D6: actions, with hero and cta rendering through it while still plugin runes.
  5. D8: link, with card's and bento-cell's href rendering through it.
  6. D7: hero and cta as compositions, once SPEC-145 D14's chrome carrier is decided.
  7. card as a composition (D3), once materiality exists.

Steps 1 to 5 depend on nothing outside this spec and can ship in one milestone. They are planned for v0.43.0, after WORK-650 removes contentSection:

  • step 1: WORK-651 and WORK-659;
  • step 2: WORK-652;
  • step 3: WORK-653;
  • step 4: WORK-654;
  • step 5: WORK-655.

WORK-656 also lands in v0.43.0: the eyebrow becomes a section role, and the linked-eyebrow pill moves into the sections dimension.

Not in scope

  • Changing card's output, or making it a composition (D3 records the target and its prerequisites).
  • materiality itself, which is SPEC-159's.
  • mediatext, for the reason SPEC-145 D20 records: it floats text around media, it does not split a row, and it has no header to place.
  • Other marketing runes (feature, pricing, testimonial, steps, bento). Their fit is assessed separately; SPEC-151 is the existing audit.

Acceptance Criteria

  • section declares the body role, and reading / dropcap take effect on its body
  • section accepts the split attributes and cover; with media-position it reads media, body and footer zones by card's rule, and without it every existing section fixture renders unchanged
  • section accepts media (portrait | cover | thumbnail | hero | icon) and stamps data-media on its media zone; the composed character renders its portrait with data-media="portrait" again
  • card's contract entry and gallery output are unchanged on both contract copies
  • character, realm and faction are composed on section; their JSON-LD, RDFa and registry comparisons against the plugin are re-run and any movement explained
  • {% actions %} exists as a core rune with rank as a data attribute, Lumina styles it, and hero and cta render their actions through it with no visual change
  • SPEC-145 D18 and D20 carry a note pointing to ADR-041
  • {% link %} wraps a block as a real <a> when it holds nothing interactive and as a focusable, labelled stretched link when it does, decided in the identity transform; a schema property inside it still publishes; card and bento-cell href render through it
  • section's header grammar is anchored to the headline, header is accepted, and slot content filling a header role it is not named for is reported (D9, ADR-046)
  • SPEC-145 D14's chrome carrier is decided before hero or cta is composed, and the composed hero and cta ship with no rune CSS in Lumina

References

  • ADR-041: the decision this spec implements.
  • SPEC-145: D14 (chrome carrier), D17 (delimiters in templates), D18 and D20 (superseded in part).
  • SPEC-151: the marketing audit naming hero, cta and steps first.
  • SPEC-159: materiality.
  • SPEC-146: the reachability gap recipe, howto and pricing wait on.