v0.44.0 · Hero, cta and testimonial compose

All releases

The first marketing runes become compositions over section, beside the plugin runes they will replace. Before they can, a composed rune has to put its universal attributes somewhere other than its empty root, and state its chrome defaults ( D31–D34, D7, D1).

Changes

  • A composed rune's universal attributes land where they act, not on its empty root (SPEC-145 D14, D24, D31, D33, D34).
    • The paint group goes to a carrier. elevation, substrate, bg with the scrim family, reveal and stagger, and tint move to the rune the template places at its top level. width, spacing and inset stay on the root. A template that places several runes marks one with a bare chrome, which is consumed at expansion. Several top-level runes and no marker is rejected (chrome-ambiguous), as are two markers and a marker with a value. A composition that places no rune warns and drops the paint group.
    • Role-bound axes go to the role's owner. prominence goes to the placed rune with a page-section header, reading and dropcap to the one with a prose body, and frame to the one with media. A composed rune offers one of these only when an owner resolves. A composition placing one section now offers prominence and frame-*, and they land on the section.
    • Placement is a property of the axis. UniversalAxisFacet.placement is root, carrier, header, prose-body or media, and every axis declares one.
    • Resolution looks through nested compositions. The attribute is written on the target as an ordinary attribute, so it renders exactly as if the author had written it there.
    • A definition declares defaults for universal axes, density included. Precedence is the author's value, then a literal in the template, then the definition default. Root-placed defaults become the generated config's defaultWidth, defaultSpacing and defaultInset, and RuneConfig gains defaultSpacing and defaultInset.
    • The tools show where each attribute lands. contracts records each composed axis's placement under composition.placements. inspect prints where every axis lands, and each default with its source. reference lists a composition's defaults.
  • New site option runes.schema: "deny" | "allow" (default deny), SPEC-181 D8.
    • It lets a project definition declare schema. With allow, a definition in runes.dir may declare a schema table, which SPEC-145 D25 otherwise rejects. Use it only when every definition in the directory is reviewed like first-party code. A wrong row renders identically and publishes a false claim as JSON-LD. The D25 rejection now names the option.
    • A preferred project rune also takes the aliases it shares with the plugin rune it shadows. For example, "testimonial": "__project__" settles review as well. An alias another plugin rune claims still needs its own prefer entry.
  • hero and cta can be built as compositions over section and actions (SPEC-160 D7). The plugin runes are unchanged, and replacing them is a later step. The vocabulary those compositions need moves into shared CSS:
    • Button size follows prominence. In actions.css, an actions whose nearest enclosing rune is prominence="display" or "prominent" gets the large hero-size buttons. Anything else gets the default cta size. Each other rune root clears the size, so a card inside a display section keeps default buttons.
    • A section's align positions its own actions row, alongside its header.
    • A cover-mode section is a band. Its height comes from its content and height / aspect, not from the cover layer's portrait default. With no content-place, the content sits mid-band over an evenly darkened scrim. A flush cover section's media has no radius.
    • Fix: a centred or end-aligned section header is aligned again. Lumina's margin: 0 had cancelled the skeleton's auto margins.
    • Fix: media-position="end" keeps a media-first rune's content beside its media, on the first row (grid-auto-flow: dense). Before, section and card put the content on a second row.
    • A composed rune no longer turns an enum attribute it only forwards into a data-* modifier on its root. For example, realm's media-position reaches its section but no longer appears on the wrapper. There it had laid the wrapper out as a split, or as a cover well. The value stays in the field bag.
  • When cta is composed, two leading headings are read as a headline and body, no longer as an eyebrow and a headline (ADR-046).
  • testimonial can be built as a composition over section (SPEC-161 D1). The plugin rune is unchanged; replacing it is a later step.
    • A beside split whose media zone is a portrait takes the portrait's width rather than half the row, unless media-ratio is set.
    • The lead value of a lead block is emphasised by weight only, upright even when it is a <cite>.
  • A theme override aimed at a composed rune is reported and dropped (ADR-042).
    • A composed rune's config is its definition's. composedRuneConfig now marks what it generates with composed: true. mergeThemeConfig reports a theme's runes: entry keyed to such a rune, naming the theme, the rune and the path, through the same sink and once-per-path rule as identity violations, and drops the entry whole.
    • A composed declaration replaces what stood under its key. A theme entry merged before a plugin's composed rune no longer leaks into the generated config field by field.
    • Native runes are unaffected. Their identity fields stay guarded and everything else stays overridable.
    • mergeThemeConfig takes an optional { themeName } and assembleThemeConfig a themeName, used to name the theme in the report.
  • New block layout lead, beside bar and definition-list (SPEC-161 D6). It draws a byline: the fields in a column as data-zone-layout="lead", the first marked data-lead. The skeleton lays it out and Lumina emphasises the lead and sets the rest smaller and muted. Nothing ranks the values, so a compact container hides none of them.
  • A composed rune can read a paragraph into its own values (SPEC-182). A sequence field matching paragraph takes an itemModel, in the vocabulary list items already use, and matches only a paragraph whose required item fields match. fills: { author: $name } names the rune's declared attributes those values supply: they land in the field bag, so meta blocks, $attrs, the schema row and registration read them as they read the attribute. The author's attribute always wins; writing both warns fill-overridden. A filling field counts as placed, and placing it with a slot as well is rejected (fill-placed); a malformed fills is fills-invalid.
  • section accepts guest-fit="clip | bleed" (default clip), stamped as data-guest-fit on its media zone (SPEC-160 D7). The native hero and feature keep guestFit in their config; an attribute lets compositions placing the same section choose different fits. A displaced guest's default frame-displace-mode and the frame-overflow="bleed" check read the resolved value.
  • hero accepts align="start" and align="end", rendering them exactly as left and right. These are section's values, which the composed hero takes (SPEC-181 D7). left and right keep working.
  • testimonial accepts author and role attributes for its byline, as an alternative to the **Name** — Role paragraph. When either is set, the paragraph is not read. When neither is set, the paragraph is parsed as before. The rendered byline and the Review / Person JSON-LD are the same either way. The paragraph stays the documented form (SPEC-182).

Composed runes

SPEC-145accepted

Composed runes

runescompositionmarkdocseohostedarchitecture

Summary

A rune defined not by declaring its own output but by placing its authored content into slots of existing primitive runes, with a Markdoc template. It has no BEM block and ships no CSS: its appearance is whatever the installed theme already gives the primitives it is built from. It does carry its own identity — a data-rune, a schema.org row, and optionally a SPEC-144 registration — so the tree it produces means the domain type, not the primitives.

This is the second of two authoring tiers. SPEC-143 covers a genuinely new atom that needs its own block and styles; this covers a domain type that is structurally a familiar shape, which is most of them.

Background — why this is the pressure valve

A hosted refrakt cannot ship a plugin per domain, so users define runes. The temptation at every expressive gap is a richer language in the definition file; ADR-036 rules that out and names composition as the alternative. This spec is that alternative.

These are not two authoring tiers a user chooses between. They share their whole declaration half — a composed rune's frontmatter declares attributes, content, schema, metaFields, blocks and registers, which is SPEC-143's and SPEC-144's vocabulary in full. They differ only in what emits, and ADR-037 assigns the two emit paths to different audiences:

Declared emit path (SPEC-143)Composed emit path (this spec)
AudienceInternal — first-party runes shedding transformsUsers — the one way to author a rune outside this repo
OutputOwn block, own BEM classesThe primitives' blocks
CSSShips with the themeNone — inherits whatever the theme gives the primitives
Arrangement decided bythe theme, via layoutthe rune author, in the template
ContractDerived from config, as todayDerived by expanding the composition

The arrangement row is why the split lands this way. A declared rune hands arrangement to the theme, which is full ADR-028 portability and exactly what work, character and the rest need. A user running one site with one theme cannot cash that benefit, and pays for it in the CSS column: "a declaratively-defined rune renders unstyled" is the sharpest authoring barrier SPEC-143 identifies, and composition dissolves rather than solves it — there is no new block to style. Hence users get this path, and only this path.

The mechanism mostly exists

Two existing mechanisms supply most of it, and it matters which one does what.

AST substitution is a solved shape. {% include %} (packages/runes/src/tags/include.ts, SPEC-129) clones a file's parsed AST and substitutes variables bindings into it. That is the substitution technique a template needs — cloning a parsed tree and filling named holes — and it is already written.

But the timing is a rune's transform, not preprocess. include splices during preprocess so that data and snippet inside the pasted file get preprocessed; a composition cannot use that timing, because at preprocess the content model has not resolved and no slot has a value (D4). A template renders where transform would have, and calls Markdoc.transform on its result so the primitives inside it are transformed normally — exactly as character.ts already does with resolved.items. No new pipeline stage either way.

Three things are missing.

1. Content slots, not just scalar variables. variables={q: "rune:card"} binds strings. Composition needs to place authored subtrees into named positions — the body a user wrote under {% character %} has to land inside the template's {% card %}. That is substitution over node lists rather than over strings.

2. An outer identity. After the splice the tree is the primitives': it carries data-rune="card", and the cross-page pipeline, extractTitle, breadcrumbs and the editor all see a card. The composition needs a wrapper carrying the composed rune's own data-rune, with the inner runes demoted to implementation detail.

3. Cycle detection. A composes B composes A. Cheap to detect, easy to omit.

SEO — the retyping works, the name resolution does not

An earlier draft of this section claimed SEO was "the solved part" of composition. That was wrong in a way worth recording rather than quietly fixing, because the half-truth is seductive: the retyping mechanism does exist and does fit, but name resolution does not cross a composition boundary, and without that the retyping has nothing to retype. D9, D10 and D11 are what the corrected reading requires.

What does fit — retyping

applySchemaTable (packages/runes/src/lib/index.ts:463-500) retypes children from the parent's row, and says why in its own comments:

Children are retyped by the parent, never by themselves (D9). Markdoc transforms bottom-up, so by now the children carry their own typeof and this rewrites them — and only the parent can supply the property that nests them.

A parent's row.children is keyed by child name, and a SCHEMA_TYPE_EXPLICIT marker distinguishes a type the author stated — which survives — from one that came from the child's fallback row, which the parent may overrule, because "the parent is the better authority on what an unlabelled child is".

That is exactly composition's requirement. A composed character declares Person and child rows keyed by slot name; the card and deflist primitives it is built from contribute structure while the outer rune owns the claim. And the explicit marker means composition cannot silently steal a type an author stated by hand.

The identity problem is the same problem one layer up. The fix for SEO is already "the parent is the authority"; item 2 above is that principle applied to data-rune and cross-page registration rather than to typeof. Extending an existing principle is a much better position than inventing one.

What does not fit — reaching the values

A schema row maps names to schema.org properties, and two different resolvers turn a name into nodes. They behave oppositely, and neither is composition-ready:

ResolverUsed byBoundaryMatches
findAllByName (schema-table.ts:235)properties via stamp, text via findByNameStops at any nested data-runedata-name, data-field (+ kebab)
findChildren (:528)children — retyping child runesFull subtree, no guarddata-name, data-field, and data-rune

findAllByName's own doc comment describes the bug the guard exists to prevent, and it is precisely the bug composition would reintroduce:

The search stops at another rune's node. ADR-008's flat namespace is unique per rune, so the same name means different things in a parent and in a child it contains: character names its title span name, and so does every character-section inside it. Reaching across that boundary published a character whose name was the character plus each of its section headings.

So for properties, the problem is not ambiguity — it is unreachability, and it is confined to properties whose source is a node rather than an attribute. Take a table mapping a node, as castMemberSchema does ({ name: 'name', role: 'jobTitle', portrait: 'image' }), and a template that parks the portrait inside {% card %}:

<article data-rune="member" data-rune-fields='{"name":"Veshra"}'>
  <div data-rune="card">                                  <!-- boundary -->
    <img data-name="portrait" data-owner="member" src="v.jpg">
  </div>
</article>

findAllByName(root, 'portrait') visits the <article> (exempt, it is top), then hits div[data-rune="card"] and returns. Nothing inside the card is ever visited. stamp falls back to the field bag (:309-312), which holds no image because an image is a node and not a scalar. Measured against today's applier, with the same table over both tree shapes:

// composed — portrait slot-placed inside {% card %}
{ "@type": "Person", "name": "Veshra", "@context": "https://schema.org" }

// declared — portrait at the rune's own root
{ "@type": "Person", "image": "v.jpg", "name": "Veshra", "@context": "https://schema.org" }

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

SPEC-160implemented

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.

No heading eyebrow. The eyebrow of a composed hero or cta is section's: a paragraph directly before the headline (ADR-046, which resolves its open question here). The native runes are documented as also reading two leading headings as eyebrow and headline. Only cta does; hero drops the second heading and the blurb (BUG-047). So the visible change is cta's: when it is composed, the first of two leading headings is the headline and the second is body. The changeset says so. No use of the form is known outside the docs' own example.

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.

Decided in SPEC-145 D31–D34. The section is the carrier by default, being the template's single top-level rune (D31). It also owns the header, body and media roles, so every universal attribute except width, spacing and inset lands on it (D33). The defaults are a definition defaults key (D34):

defaults:
  width: full
  elevation: flush
  prominence: display

density needs nothing: section already resolves to full, as hero and cta do.

Button size follows the section's prominence. hero and cta size their buttons differently: hero 0.7rem 1.75rem at --rf-text, cta 0.625rem 1.5rem at --rf-text-sm. WORK-654 moves each size into custom properties that hero.css and cta.css set. Once those files are deleted, the size has to come from something a composition can set. Decided on 2026-10-26: actions takes its size from the enclosing section's prominence, not from a size attribute of its own.

  • display and prominent give the large size, which is today's hero buttons. normal and quiet, and an actions with no prominence around it, give the default size, which is today's cta buttons. The composed hero declares prominence: display (SPEC-145 D34), so it needs nothing more. A cta written with prominence="display" gets hero-sized buttons, which is the intent.
  • It needs no new attribute. The emphasis of a region's header and of its calls to action are one choice, and the composition already declares it.
  • It follows the nearest section only. density.css clears --rf-prominence-size at every rune root, and actions is a rune, so the size cannot ride that variable. The actions.css rules key on the enclosing section's data-prominence instead, and must not reach an actions inside a nested rune: a raised card in a display hero keeps default buttons. The same protection is what density.css gives titles.
  • The custom properties WORK-654 introduces stay as the mechanism. The prominence rules set them, in place of hero.css and cta.css.

guestFit becomes section's guest-fit attribute. The native hero hosts a media guest with guestFit: 'bleed': a rune guest keeps its own chrome and may spill past the zone, rather than being clipped to a framed well. card uses clip. guestFit is a config field, and the engine reads it into data-guest-fit on the [data-section="media"] zone and into frame's default frame-displace-mode. A config field cannot vary between compositions that place the same section, and hero needs bleed where a card-like composition needs clip. So:

  • section gains guest-fit="clip | bleed", default clip, emitted as today's data-guest-fit on its media zone. It is meaningful only with a media zone, so it lands with the split (WORK-652) or immediately after it.
  • frame's default displace mode and its frame-overflow="bleed" check read the resolved attribute, falling back to the config field, which native runes keep.
  • The composed hero writes guest-fit="bleed" on its section. It is section's own attribute, not a universal axis, so it takes no part in D34's defaults.
  • It is named for the data attribute and the existing "clip host" and "bleed host" vocabulary, not media-fit, so one concept keeps one name.

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
  • section takes guest-fit="clip | bleed" (default clip), emitted as data-guest-fit on its media zone, and frame's displace-mode default and bleed check read the resolved value (D7)

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.

Marketing runes on section: what each needs to compose

SPEC-161accepted

Marketing runes on section: what each needs to compose

SPEC-151 audited the marketing plugin against composition when the base primitive was card. ADR-041 moves the base to section, and SPEC-160 gives section the split and adds actions. This spec re-reads the remaining marketing runes on that base: testimonial, feature, pricing with tier, and steps. For each, it says what the composition is built from, what is missing, and what an author gives up. It also settles one question for the business plugin's cast-member (D7), because its name-over-role shape is the same as testimonial's byline.

Two questions were checked by experiment rather than assumed, and the results change the plan (see Measurements).

hero and cta are SPEC-160 D7's, now unblocked: SPEC-145 D31–D34 settle where their universal attributes and defaults land. bento and comparison stay plugin runes for the reasons SPEC-151 gives.

Revised on 2026-10-26 against the code after v0.42.0 and SPEC-145 D31–D34:

runescompositionmarketingprimitivessectionseo
  • D1's variant is no longer a branch: no stylesheet styles it;
  • D5's steps needs SPEC-156's ladder for its numbering;
  • D4's SPEC-146 gate has lifted;
  • D7 depends on SPEC-145 D30;
  • each composition states its chrome defaults with D34;
  • the Order changes as a result.

Measurements

Both were run against main after v0.41.0's changes, with a throwaway composed definition loaded through the same harness as the storytelling compositions (packages/content/test/composed-storytelling.ts). Neither probe was committed.

A composition can already publish a rating

A composed quote-card declared rating and rating-total attributes, a metaFields entry { metaType: rating, rating: { total: rating-total }, condition: rating }, a blocks entry placing it, and placed it with {% metablock name="stars" /%} inside {% section %}. It rendered the engine's shared rating widget: <span data-meta-type="rating"> with four marks data-filled="true" and one false for rating=4. With no rating, the block was omitted.

The same definition declared testimonial's schema table in frontmatter, with its two nested entities: an author Person (author → name, role → jobTitle) and a rating Rating (rating → ratingValue). The JSON-LD came out as { "@type": "Review", "reviewBody": …, "author": { "@type": "Person", "name": "Ada" }, "reviewRating": { "@type": "Rating", "ratingValue": 4 } }, identical at both harvest points: the pre-engine harvest site.ts publishes, and after the identity transform.

So a rating needs no new primitive. The metadata machinery a composition already has (SPEC-145 D7, WORK-630's metablock) renders it, and the schema table publishes it.

deflist cannot take definition's split as it stands

deflist reads one shape: a list item whose first inline is **Term:**, with a colon (packages/runes/src/tags/deflist.ts, extractTermPrefix). A probe rendered three feature-style items through it:

ItemResult
- **Fast** then an indented description paragraphempty <dt>, everything in <dd>, a build warning
- **Typed:** Every rune is checked.<dt>Typed</dt><dd>Every rune is checked.</dd>
- {% icon %} **Light** then two description paragraphsempty <dt>, everything in <dd>, a build warning

feature's definition reads all three. Its term is any leading heading, or paragraph holding an image, an {% icon %} or **strong** text; its description is every paragraph after the first plain one (plugins/marketing/src/tags/feature.ts, isTermParagraph). And deflist renders the definition-list zone layout, a label/value table, where feature renders a grid of items.

Decisions

D1 — testimonial composes once section has a portrait zone, with its author as attributes

Amended by SPEC-182 D6–D7. The byline stays text: a paragraph field takes an item model and fills the rune's author and role, which remain as the alternative form. "What authors give up" below no longer holds, and the paragraph form is not reported as content-unmatched.

Built from: section with a split and media="portrait" (SPEC-160 D2), the rating metablock, and a byline metablock in the lead layout (D6).

Its only missing prerequisite is SPEC-160 D2. Measured part by part:

PartBuilt fromToday
The quotea blockquote placed through a slotavailable
Starsthe rating field shape and {% metablock %}available, measured
Review / Person / Ratinga frontmatter schema table, author from attributesavailable, measured at both harvest points
Author name and rolemetaFields from attributes, in a byline blockavailable as bar; the stacked look needs D6
varianta declared attribute, emitted as data-variant on the root; no branchavailable
The surface and compact spacingdefaults: { elevation: flat, density: compact } (SPEC-145 D34)density needs D34's extension (SPEC-145 D33, D34)
Round avatara media zone marked data-media="portrait"missing: section has no media zone until WORK-652 (v0.43.0)

lead (D6) is what makes the byline look as it does today, name emphasised and role beneath; without it the byline renders in a bar, which is correct but reads as one line. pull-quote's attribution (D2) is not on this path.

---
tag: article
attributes:
  author:       { type: string, description: "The person quoted." }
  role:         { type: string, description: "Their role or organisation." }
  rating:       { type: number, description: "Rating out of rating-total." }
  rating-total: { type: number, default: 5 }
  variant:      { type: string, matches: [card, inline, quote], default: card }
content:
  type: sequence
  fields:
    avatar:       { match: image, optional: true }
    quote:        { match: blockquote }
    avatar-after: { match: image, optional: true }
metaFields:
  rating: { metaType: rating, rating: { total: rating-total }, condition: rating }
  author: { tag: cite, condition: author }
  role:   { condition: role }
blocks:
  rating: { fields: [rating], layout: bar }
  byline: { fields: [author, role], layout: lead }
schema:
  type: Review
  properties: { quote: reviewBody }
  entities:
    author: { type: Person, property: author, properties: { author: name, role: jobTitle } }
    rating: { type: Rating, property: reviewRating, properties: { rating: ratingValue } }
defaults:
  elevation: flat
  density: compact
---

{% section media-position="start" media="portrait" %}
{% slot name="avatar" /%}
{% slot name="avatar-after" /%}

---

{% metablock name="rating" /%}
{% slot name="quote" /%}
{% metablock name="byline" /%}
{% /section %}

The two image fields are because the plugin accepts the avatar before or after the quote; both are placed in the media zone, and an empty one renders nothing. rating becomes marks with metaType: rating when WORK-639 lands.

The author moves from content to attributes. Today the name is the **strong** text of the first paragraph and the role is what follows a dash (testimonial.ts:81-101), which is why SPEC-151 found testimonial gated on SPEC-146: a value read from content placed inside a primitive is not reachable by the schema harvest. As attributes they ride the field bag, and the measurement above shows them publishing. This also fixes a loss the plugin has today: only string children of the name and role survive, so a linked company name loses its link.

What authors give up: the **Ada Lovelace** — Analyst paragraph. The sketch above has no catch-all field, so on a page written that way the paragraph matches nothing and content-unmatched reports it, at error severity since WORK-638. That keeps the migration loud, and it must stay so: adding a body field would quietly render the paragraph and publish no author. The fix is a codemod, since the plugin's own parser already knows the shape and can rewrite the paragraph into author and role attributes once.

variant is not a branch. An earlier draft placed {% card %} for variant="card" and {% section %} otherwise, with {% if %} around the whole invocation. That had two problems:

  • The default lost its avatar. WORK-652 gives media="portrait" to section only, so the card branch, which is the default, could not draw a round portrait.
  • There is nothing to branch on. variant emits rf-testimonial--card, --inline and --quote, and no stylesheet in the repository styles any of them: not Lumina, not skeleton, not the plugin. The surface every testimonial draws comes from defaultElevation: 'flat', and elevation="flat" on any rune draws a complete surface: fill, border, radius and padding (surfaces.css).

So the composition places one section, gets its surface from D34's elevation: flat default, and keeps variant as a declared attribute that the root emits as data-variant. A theme that keyed on the old classes can key on that. An author who wants the borderless look writes elevation="flush". Whether variant is retired outright is SPEC-159's materiality question, and nothing here depends on it.

D2 — pull-quote gains an attribution

pull-quote takes a blockquote and has no attribution. HTML's pattern for a quote with a source is <figure><blockquote/><figcaption/></figure>. pull-quote gains an optional figcaption part, filled from a trailing paragraph inside the rune (or a cite attribute), and renders as a figure when it has one. Without one, its output is unchanged.

This is no longer on testimonial's path. An earlier draft had testimonial place its author through pull-quote. The byline metablock (D1, D6) does that job with machinery compositions already have, and keeps the author as structured fields the schema table reads. The attribution stays worth doing for quotes in general: a pull-quote in an article that names its source.

Open question: whether the attribution should be cite (an attribute, structured) or a trailing paragraph (content, free-form). An attribute is the default because it is reachable; the paragraph form can be added if authors ask for inline links in it.

D3 — feature needs deflist to learn the feature term, not a new content-model form

SPEC-151 recorded feature as needing "definition's split model" and linked it to SPEC-147's proposed segmented content model. The measurement above narrows it: the split is a primitive's job, not the composition's. A core primitive may own imperative code; only a composition must be declarative. So:

  • deflist gains definition's term shapes: a leading heading, or a paragraph holding an image, an icon or a bold lead with no colon, as the term; every following block as the description. The **Term:** form keeps working, and an item that matches no shape keeps today's empty-<dt> fallback and warning.
  • The arrangement (grid, list, carousel) is not deflist's. It comes from SPEC-156's row and ladder arrangements, placed around it. Noted 2026-10-28: a feature list is usually a grid, and SPEC-156 D1 leaves grid to its own spec, so feature waits for that spec as well as for deflist's terms.
  • feature is then section with a split (media-position defaulting to bottom, as today), its header, and the arranged deflist over a definitions slot. Its chrome is hero's: defaults: { width: full, elevation: flush } (SPEC-145 D34) and guest-fit="bleed" on the section (SPEC-160 D7). Today's native feature drops a second leading heading (BUG-047), and composing it fixes that.

This does not replace the segmented model. plot and storyboard still need a content model that splits on a boundary predicate, because their panels are the rune's own structure. It removes feature from that model's critical path.

Open question: whether deflist is the right home, given that its default rendering is a label/value table. The alternative is a separate items primitive sharing deflist's term detection. The deciding question is whether a feature list is a definition list in the HTML sense (term and description), which it is, so deflist with an arrangement is the default.

D4 — tier is a legitimate card; pricing waits on authored prices

A pricing tier is a discrete object, so tier composes over card, not section: the one marketing rune where ADR-041's rule points the other way, and the reason card stays.

  • Featured is emphasis on the card, replacing the featured-tier rune name. It is the universal featured intent (SPEC-167 D2), not elevation or a prominence value.
  • The sign-up link becomes actions (SPEC-160 D6). Today url is harvested from the first <a> in the tier body.
  • The feature list stays a plain list, styled as a checklist by Lumina as it is now.
  • The price is the blocker, as SPEC-151 D2 recorded: parsedPrice and resolvedCurrency are computed from the authored string. The fix is to make them authored: price="19" currency="USD" period="month", published directly as price and priceCurrency, and displayed with core's {% amount %} (SPEC-162 D7; this replaces the separate {% price %} rune first proposed here). Formatting at render is presentation, not derived data, because the schema reads the attributes. This also fixes a defect in the plugin: inferCurrency matches currency symbols only as a prefix, so 29 kr publishes USD.

pricing itself is a section header and an arranged list of tiers. Its Product name and description come from the headline and blurb, which is SPEC-151 D3's pageSectionProperties case. That was gated on SPEC-146 Problem 2, which shipped in v0.39.0 (WORK-610), so the gate has lifted. tier defaults to elevation: flat, density: compact (SPEC-145 D34), as the native tier and featured-tier do.

What authors give up: the ## Pro — $19 heading shorthand, which the plugin parses into a tier. It cannot be expressed declaratively, and keeping it means keeping the plugin rune. That regression is the main reason pricing is last.

D5 — steps composes once the ladder exists

steps is a section with a numbered sequence of steps, and step is a section with a split. It has no schema table (only the Step typeof).

An earlier draft called it the cheapest marketing composition, with nothing new needed. That was wrong on one point: numbering. The native steps numbers its items through sequence: 'numbered' in its config. A composition's generated config has no sequence, and SPEC-169 D6 sends sequence to SPEC-156's ladder, {% group layout="ladder" %}, which SPEC-169 D4's own steps example places. No group primitive or ladder arrangement exists yet, and SPEC-156 is a draft.

The rest is in place:

  • each over a list's items shipped in v0.42.0 (WORK-648), so the conditional content model is not needed (SPEC-169 D4);
  • a step written as a list item with no heading renders as body (ADR-046);
  • the chrome is defaults: { elevation: flush } (SPEC-145 D34). section already resolves to full density, as steps does.

So steps follows SPEC-156, not SPEC-160.

D6 — a lead layout for blocks: a lead value with secondary values beneath

A blocks entry renders its fields through a layout primitive, and there are two (LayoutPrimitive = 'definition-list' | 'bar', packages/transform/src/types.ts): bar, a row, and definition-list, a label/value grid. Neither draws a byline, the shape two runes already hand-roll in their own CSS:

  • testimonial.css: __author-name bold, __author-role small and muted, 0.125rem below it;
  • cast.css: __name semibold, __role small and muted, 0.125rem below it.

Named lead, not stack (renamed 2026-10-27). SPEC-156 D11 keeps stack unnamed, because a column with nothing else to say is ordinary block flow, and naming it invites stack-tight and stack-loose. This layout is named for the one thing that sets it apart from flow: a lead value.

lead is a third layout: the block's fields in a column, the first marked data-lead. The skin emphasises the lead and makes the rest secondary (smaller, muted). Its contract is statable without naming a rune ("a primary value with secondary values beneath it"), and it fits more than people: a title over a date, a product over a price.

"Secondary" here is visual, never data-meta-rank. [data-density="compact"] [data-meta-rank="secondary"] hides an element, and a composed testimonial is compact (D1). If lead marked its non-lead values with that rank, every compact byline would lose the role. Today no code emits data-meta-rank (BUG-023), so nothing is hidden. The lead is marked with data-lead, and the skin styles the rest by :not([data-lead]), so a fix for BUG-023 cannot reach the byline. Checked on 2026-10-26.

The engine change is one layout branch beside bar; the CSS is a few lines in skeleton/styles/dimensions/metadata.css and Lumina's. testimonial's author rules and cast's name and role rules are deleted when those runes compose.

Open question, to come back to: a {% lead %} rune for authored content. bar and deflist ship twice, as a layout the engine fills from declared fields and as a rune that renders the same structure over author-written markdown (SPEC-145 D7). lead could follow suit: each top-level paragraph is an item, the first is the lead, and the output is the same data-zone-layout="lead" element with data-lead, so one set of CSS styles both.

{% lead %}
**Ada Lovelace**

Analyst, Babbage & Co
{% /lead %}

Inline markup would survive (links, bold, an icon), which the field-fed block cannot carry. Uses: an author box under a post, an attribution under a quote in prose, a title over a detail in a grid of cards. It would carry no meaning: no <cite>, no structured data, and no splitting of a single **Name** — Role line, which is the imperative parsing that gated testimonial in the first place. A quote's semantic source belongs to pull-quote's attribution (D2), and a typed author to the field-fed block.

Nothing audited here depends on it, so it is not part of this decision. It is cheap, modelled on packages/runes/src/tags/bar.ts with no new CSS. Revisit it when an author asks for a byline outside a composition, or fold it into the layout's change if it costs as little as expected.

D7 — cast-member puts its name in section's header, not a byline

cast-member could use the D6 byline too, and nothing blocks that. But in a team grid the member is the subject of their own region, so their name is that region's heading, which is what section's header already models:

{% section media-position="top" media="portrait" prominence="quiet" %}
{% slot name="portrait" /%}

---

# {% $attrs.name %}

{% $attrs.role %}
{% slot name="body" /%}
{% /section %}

The name becomes the headline and the role the blurb, emphasised and muted by the header styles that exist, and prominence="quiet" scales it for a grid. The composed character already writes its heading as # {% $attrs.name %}. Person maps name and role from attributes, as SPEC-149 found. The portrait is SPEC-160 D2's, and an image attribute rendered there still needs SPEC-149 D4's scheme resolution.

This depends on SPEC-145 D30. With no role, the paragraph after the headline is the first paragraph of body, so section's grammar reads it as the blurb, and ADR-046's slot-role warning fires. The template must guard the role with {% if $attrs.role %}. Until D30 resolves an expansion-bound conditional during expansion, the if stays a tag, and section does not see the paragraph inside it (SPEC-145 D17). So the role would never be a blurb. cast-member waits on D30.

In testimonial the subject is the quote and the person is its source, so a heading would be wrong there; that is the byline's case. If the two should look identical regardless, cast-member can use the byline instead; the choice is presentational.

What is new, and what it unlocks

CapabilityKindUnlocks
Ratingnone needed: metaFields + metablocktestimonial; any rated thing
pull-quote attribution (D2)extends a core runeany attributed quote
lead block layout (D6)a third layout primitivethe testimonial byline; any lead-over-secondary pair
section portrait zoneSPEC-160 D2testimonial, cast-member, character
deflist feature terms (D3)extends a core runefeature
actionsSPEC-160 D6hero, cta, tier
Authored price + {% amount %} (D4)core rune from SPEC-162tier, pricing
segmented content modelSPEC-147plot, storyboard (no longer feature)
Ladder arrangementSPEC-156steps (its numbering), feature
Chrome defaults, density includedSPEC-145 D34every composition here
Expansion-bound conditionalsSPEC-145 D30cast-member

Order

  1. testimonial (D1, D6): needs SPEC-160 D2 (WORK-652, v0.43.0), lead, the codemod, and density in SPEC-145 D34.
  2. steps (D5): needs SPEC-156's ladder.
  3. feature (D3): needs deflist's terms and SPEC-156's arrangements.
  4. pricing and tier (D4): needs actions (v0.43.0), amount (SPEC-162) and featured (SPEC-167).
  5. cast-member (D7): needs SPEC-145 D30, and can go whenever D30 is built.

Each composition is compared with the plugin's output before it replaces anything, as SPEC-147 D2 set: JSON-LD against the SEO baseline, rendered HTML, and every difference explained. The plugin runes stay until that comparison is reviewed.

Acceptance Criteria

  • testimonial exists as a composition whose JSON-LD matches the plugin's Review graph (reviewBody, Person author, Rating) with the author given as attributes
  • The composed testimonial places one section with no {% if %}, draws its surface from defaults: { elevation: flat, density: compact }, keeps the round avatar for every variant, and emits variant as data-variant on its root
  • A codemod rewrites **Name** — Role testimonial paragraphs into author / role attributes, and is run over this repo's content
  • pull-quote renders an attribution as figcaption inside a figure, and is unchanged without one
  • blocks accept a lead layout with the first field marked data-lead; testimonial's byline renders through it and the hand-rolled author and cast name/role CSS is deleted
  • cast-member composes over section with its name as the header and its role as the blurb, publishing the plugin's Person graph
  • deflist reads definition's term shapes, keeps the **Term:** form, and feature composes over it with matching output
  • tier composes over card with authored price / currency, publishes Offer price and priceCurrency, and 29 kr publishes SEK (or the stated currency)
  • steps exists as a composition matching the plugin's output, numbered by SPEC-156's ladder rather than a sequence config field
  • SPEC-151's open question on testimonial's author sources is closed with a pointer to this spec

References

  • ADR-041 and SPEC-160: section as the base, actions.
  • SPEC-151: the marketing audit this re-reads.
  • SPEC-146: reachability, which pricing's header still waits on.
  • SPEC-147: the segmented model, and the comparison discipline.
  • SPEC-156: the arrangements feature and pricing place their items in.
  • SPEC-159: materiality, which decides whether testimonial's variant is retired.
  • SPEC-145: D30 (cast-member), D31–D34 (carrier, placement, defaults).