v0.45.0 · Steps and the ladder

All releases

A composition can number its items. Composition templates gain one tag, {% group %}, that arranges what it holds as a row or a ladder, and steps becomes the first composition built on a numbered ladder (, D5).

Changes

  • track declares its artist, running time and date as metaFields with a details block, so the engine renders them instead of the rune building spans (SPEC-156 D7). The row form is unchanged in Lumina, and a new meta="byline" sets the details beneath the track's name. It is a variants entry, which a theme can restyle. playlist takes meta too, for every track it lists.
  • A playlist's list-form tracks are now track items, rendered through the same config as a nested {% track %}, so the two forms produce the same markup. A playlist's tracks are rows of its listing, so they are flush rather than cards. A nested track also shows the playlist's artist, as a list item always did. Chapters and lyrics in a list-form track now take the track's cue-point styling.
  • The engine gains a clock meta-field transform (3:36, 1:02:03). bar and lead block values now carry data-field, as definition-list rows do.
  • For theme authors: rf-track__track-artist, rf-track__track-duration and rf-track__track-meta are gone. Style .rf-track__details > [data-field="artist" | "duration" | "date"], and the byline under .rf-track[data-meta="byline"]. Playlist list items now carry rf-track. The JSON-LD is unchanged. In the RDFa, a list item's byArtist and datePublished now ride hidden metas, as a nested track's always did.
  • Composition templates gain {% group name="…" layout="bar|ladder" %}, an engine wrapper that arranges what it holds as a row or a list of peer items without placing a rune (SPEC-156 D2, D3). A child of a bar group written with align="end" pushes to the row's end. A definition may declare sequence: numbered | connected | plain in its frontmatter: its ladder then renders an <ol> stamped with data-sequence, and numbered items get their ordinal nodes. Only the ladder is stamped, never an ordered list an author writes inside a step (D12, D14). A definition declaring a sequence without a ladder is rejected (sequence-without-ladder), as is a malformed group (group-invalid).
  • Skeleton gains the unsequenced ladder rule ([data-zone-layout="ladder"]: list reset, column flow), and cast's list mode now uses it instead of a rule of its own. Lumina gives a numbered ladder's items the step rhythm the native howto, recipe and steps use.
  • prominence gains a fifth step, marquee, above display: a landing page's opening band (SPEC-183). Each step now sizes the whole header, not just its title, and Lumina publishes the values as custom properties a theme can retune.
    • marquee ramps the title to 13/6 of the resting size (3.25rem from the default).
    • The blurb grows from prominent up: text-lg at prominent and display, text-xl at marquee. The header's gaps widen with it.
    • A flush band at display or marquee takes a band's padding: 3.5rem / 3rem and 7rem / 6.5rem, less on narrow screens. A cover band moves it onto the overlaid content.
    • Lumina draws display and marquee titles as a gradient. It is dropped under forced colours and never reaches a nested rune's title.
    • Large buttons now come with marquee only. A display or prominent section's actions take the default size.
    • A loud header is set wide, and its blurb carries the measure: at display and marquee the header takes the column's width and the blurb stops at 40rem. A flush band at those steps is padded 2rem inline.
  • A numbered sequence's ordinal is a node, not a CSS counter (WORK-583). The engine puts each item's number first in the item, <span data-name="ordinal" data-meta-type="quantity">3</span> with the rune's element class (rf-steps__ordinal), so it can be selected, copied and read in order. An item's own position wins (a track's number=), the rest count on from it, and an <ol start> sets the first. connected and plain sequences are unchanged.
  • For theme authors: style the number with [data-sequence="numbered"] > li > [data-name="ordinal"]. A rule on [data-sequence="numbered"] > li::before no longer matches anything, and the sequence counter is gone. Lumina renders identically.
  • Fixes found reviewing the composed hero on staging.refrakt.md:
    • A {% bg %} or {% tint %} in a composed rune lands on its paint carrier, as the bg and tint attributes already did, rather than on the empty root. A backdrop was positioned against the page and filled the viewport's height rather than the band.
    • A rune carrying a background is its layer's containing block (position: relative, at zero specificity). A plain section with a {% bg %} had the same bug.
    • A full-width composed rune pads its carrier, not its empty root, so the carrier's background, cover well and surface fill the bleed.
    • A stacked end split puts its media after the content, whatever the source order. Every split rune: section, card and the rest.
    • A section header is start-aligned by default and never wider than its column, so a pill eyebrow keeps its width and a long word overflows a centred header evenly.
    • A display or marquee title builds its gradient from its own colours, so a cover band's light-on-dark title stays light.
  • A lead block is set small throughout, the lead value included: a byline is fine print. The lead stays distinguished by weight and colour. It matches the plugin testimonial's byline.
  • On a phone (below 640px of container width) the loud headlines step down so a long word fits the line: prominence="marquee" titles to 2.5rem and display titles to 2rem, and the hero and cta headlines to the same sizes.
  • pullquote no longer nests a quotation in a quotation. When its content is a blockquote, the pull quote is the one <blockquote>: it takes the authored blockquote's paragraphs and its attributes, so a schema property placed on it lands on the pull quote. Lumina's reset for the inner blockquote is removed; the pull quote renders the same.
  • howto, recipe, steps and track no longer hand-roll a counter the sequence dimension already provides (BUG-024). A theme rule on [data-sequence="numbered"] > li now reaches all four; before, it was ignored on howto and recipe. Visible in Lumina: howto and recipe step numbers get the dimension's filled circle (as steps already had), and the three runes' compact items take the dimension's compact indent.
  • steps names its item container (data-name="steps", .rf-steps__steps), so a layout can address it and its styles no longer reach an <ol> an author writes inside a step. playlist names its tracks list through refs, with identical output.
  • A start or end split with no media zone lays out as one column. A rune with media-position and nothing in its media zone used to leave its content in half the row.

The ladder and row arrangement primitives

SPEC-156accepted

The ladder and row arrangement primitives

runesthemelayoutarrangementscompositionvocabularysequence

Summary

ADR-030 governs how an arrangement vocabulary may grow and closes by saying "this decision does not authorise building anything. It constrains a later spec." This is that spec, scoped deliberately narrow: two topologies, ladder and row, because those are the two the evidence supports and the two SPEC-145's composed runes need first.

Revised on 2026-10-27 against the code after v0.42.0. The first draft found both topologies hand-written and missing generically. Re-read, both already exist generically: the row as [data-zone-layout="bar"], and the ladder as the sequence dimension ([data-sequence]). What is missing is aperture: a composition template cannot reach either. So the deliverable shrinks to one template tag, {% group %}, with two arrangement values. The two identity holes the first draft found were closed by SPEC-158 in v0.40.0.

Decision numbers are kept from the first draft, because other specs cite them. D2, D3 and D8 are revised in place; D4 and D5 are recorded as shipped; D10 was settled in the ADR-030 review; D12–D14 are new.

The row exists, once generically

Generic, packages/skeleton/styles/dimensions/metadata.css:

[data-zone-layout="bar"]                    { display: flex; flex-wrap: wrap; align-items: center; }
[data-zone-layout="bar"][data-wrap="false"] { flex-wrap: nowrap; }
[data-zone-layout="bar"] [data-align="end"] { margin-left: auto; }

symbol, bar and api key on it. Rune-specific copies live in packages/skeleton/styles/runes/track.css and playlist.css: the same flex, the same marker box, the same flex: 1; min-width: 0 name cell, and margin-left: auto on the duration, which is the bar's right-push. A track row is a bar, confirmed in CSS rather than by analogy.

Today a bar is reachable from the bar rune, or from blocks[].layout over metaFields-projected fields. It is not reachable over ordinary named slots, which is what a composition template has.

The ladder exists too: it is the sequence dimension

The first draft said the ladder container "exists five times, and generically nowhere." That was wrong. The generic container is the sequence dimension:

  • Values: numbered, connected and plain (RuneConfig.sequence, packages/transform/src/types.ts).
  • Structure, packages/skeleton/styles/dimensions/sequence.css: the list reset, the counter origin, the item gutter and the marker box for numbered; the item geometry and marker box for connected; the reset alone for plain. Look, packages/lumina/styles/dimensions/sequence.css: marker fill, connector lines, type.
  • Stamping: the engine's annotateSequence (packages/transform/src/engine.ts) sets data-sequence on every <ol> inside a rune whose config declares sequence. Seven runes do: howto, recipe, steps, playlist (numbered), timeline, itinerary (connected), and plot through a modifier.

What the per-rune CSS is. Seven skeleton files reset a list for their items: playlist, steps, timeline, itinerary, howto, recipe and cast. Four of them also hand-roll a counter that the dimension already provides, which BUG-024 records: the two land at different specificities, so which one wins differs per rune. That is duplication of a generic contract, not the absence of one.

cast is a weaker case than the first draft said. It was called the strongest single piece of evidence, a ladder no config knows about. Its container has three arrangements, grid, list and carousel (packages/skeleton/styles/runes/cast.css). Only list is a ladder.

So the one genuinely missing generic part is small: a ladder with no sequence, a list of peer items in a column with the marker reset and nothing else. plain is close, but it is stamped only through a rune's sequence config.

What a composition cannot do

A composed steps needs an <ol> of its steps, stamped data-sequence="numbered". It has no way to get one:

  • No sequence. composedRuneConfig generates modifiers, metaFields, blocks and provides (SPEC-169, What the parser accepts). SPEC-169 D6 routes sequence to this spec.
  • No list around an each slot. A template writes Markdoc. It cannot wrap the items an each slot produces in an <ol> with one <li> per item.
  • No row over slots. A template that wants bar geometry must place the bar rune, which carries data-rune="bar", a name-resolution boundary (SPEC-145 D19).

SPEC-169 D4's own steps example already writes the answer:

{% group name="steps" layout="ladder" %}
{% slot name="items" each %}
{% section %}{% slot /%}{% /section %}
{% /slot %}
{% /group %}

Variants of an item's arrangement

The worked case: display a track as a name with a byline underneath rather than as one row.

row form:     [1] Speak to Me · Pink Floyd ————————→ 1:13 · Mar 1973
byline form:  [1] Speak to Me
                  Pink Floyd · 1973 · 1:13

The parts are identical. The item's topology goes row → column, and three sibling parts are grouped. That is rule 4 nesting, not a new topology, and byline is one of the group names rule 6 names (byline, meta, actions, aside).

On the declared path, blocks already does it: a BlockDef is a named group of meta-fields with a layout, and { field: 'duration', align: 'end' } emits data-align="end", which is the right-push track.css hand-writes. Measured on 2026-10-27: Track's config is still { block, parent, defaultElevation, editHints }, with no metaFields and no blocks. Its artist, duration and date are hand-built spans.

Selection follows who wants the change:

Wants itMechanismStatus
The themevariants over layout (SPEC-091)ships
The composition authorSPEC-145 D15, several templatesdecided, not built
The page author, per instancea parent attribute reaching its itemsD8

The grouping-device hazard

An engine-made group (a blocks zone, a created layout wrapper, or the {% group %} of D2) carries data-name and no data-rune. A group made by placing a rune, {% bar %}…{% /bar %}, carries data-rune="bar", which is a name-resolution boundary: findAllByName stops at it, so a schema property sourced from a node inside that group disappears with no diagnostic (SPEC-146 Problem 2). track survives by luck: every property in trackSchema is attribute-sourced. SPEC-145 D19 states the rule for templates; D9 here records it.

Decisions

D1 — scope is ladder and row, and both are consolidations

ADR-030 rule 5a, not 5b. Both topologies have generic implementations already and several hand-written copies. No other topology is in scope: grid and split are plausible consolidations with their own evidence and their own spec.

D2 — a template reaches both through {% group %}, an engine wrapper with an arrangement

Revised 2026-10-27. The first draft said the row needed aperture and the ladder needed a new contract. Both need the same thing: a way for a template to declare an arrangement on an engine-made group. That is ADR-030 rule 6's form, made a template tag:

{% group name="byline" layout="bar" %}…{% /group %}
{% group name="steps" layout="ladder" %}…{% /group %}
  1. It is an engine wrapper. It renders with data-name and no data-rune, so it is safe where the rune publishes schema (D9, SPEC-145 D19). A composed rune has no block (SPEC-145 D2), so a group gets no BEM class: themes see its data-name and data-zone-layout.
  2. name is required, and a convention, not a type: it carries no geometry (D6).
  3. layout takes bar or ladder. Nothing else, until a topology clears ADR-030 rule 5a in its own spec. An unknown value is rejected at schema construction, naming it.
  4. bar renders a <div data-zone-layout="bar"> over whatever the group holds, the existing row contract. A child that should push to the end is written in its own {% group name="…" align="end" %}, which renders data-align="end".
  5. ladder renders a list (D3).
  6. It is a template tag only. Authors do not write {% group %} on a page; the bar rune remains the author-facing row (SPEC-145 D12's placement set is unchanged).

D3 — the ladder is the sequence dimension, plus a reset for the unsequenced case

Revised 2026-10-27. The contract, stated without naming a rune: a list whose direct children are peer items, each free to declare its own inner arrangement, with a shared marker gutter when the rune declares a sequence.

  1. Element. <ol> when the definition declares a sequence (D12), <ul> otherwise, with data-name and data-zone-layout="ladder".
  2. Items. Each iteration of an each slot placed directly in the group, or else each top-level node the group holds, is wrapped in an <li>.
  3. Markers are the sequence dimension's: the ladder carries data-sequence with the definition's value. No second marker contract is written.
  4. The unsequenced ladder gets the one missing rule: [data-zone-layout="ladder"] resets the list and flows its items in a column, about six declarations in skeleton. cast's list mode is its first native consumer.
  5. "Optional separation between items" stays one hook, not modifiers. If it becomes three modifiers, the topology is really two and this decision is wrong.

D4 — sequence is identity: shipped

Closed by SPEC-158 D2 in v0.40.0: sequence is in IDENTITY_FIELDS (packages/transform/src/identity-fields.ts), and sequenceDirection stays presentational.

D5 — layout.attrs cannot write an identity attribute: shipped

Closed by SPEC-158 in v0.40.0: a layout attrs entry that writes an attribute an identity field derives is rejected (identity-fields.ts, cited there as SPEC-156 D5).

D6 — a group's geometry comes from its declared arrangement, never from its name

ADR-030 rule 6, made operative by D2: byline is a group named byline that arranges as a row. Nothing is stamped on a node for being called something, so no reserved-name rule is needed.

D7 — the byline variant needs no new mechanism, only adoption

metaFields, blocks, two layout values and a variants axis all ship. It is blocked only by Track not declaring its meta fields, which is still true on 2026-10-27.

D8 — the per-instance cascade is narrowed to author-written children

Revised 2026-10-27, as SPEC-171 D4 asked. Three cases were found: bento's media-position, playlist's default artist (track.artist ?? artistValue), and playlist's child kind.

SPEC-145 D32 now covers a parent passing a value to children the template places: the template writes it on them. What D32 cannot reach is children the author writes inside a slot: the tracks in a playlist, the cells in a bento. A template places the slot, not the runes inside it. All three cases are of that kind, so the childDefaults mechanism is still needed, scoped to author-written children, and remains a separate spec. SPEC-159 keeps the theme-owned half.

D9 — a composed template groups with an engine wrapper, not a placed rune

Where schema is involved; with no schema table, either device is safe. {% group %} (D2) is the engine wrapper a template writes. SPEC-145 D19 states the same rule.

Revised 2026-10-28. The first draft said carousel and rail named one topology. They do not. ADR-030 never defined rail, but its cited cases (progress, tolerance, and budget, ruled out for having "no proportional bars") point at items placed along a scale by value, not a scrolling strip. Neither name had a contract, and track also collided with the track rune.

Settled in the ADR-030 review: rail and track leave its topology list until a spec proposes them with a contract. carousel stays ADR-018's author-facing layout with SPEC-100's contract, and is not a value of {% group layout %} until a spec places it there.

D11 — stack is not named

It is default block flow. Naming the null case invites stack-tight and stack-loose, which is rule 2's combinatorial explosion arriving by the back door. SPEC-161 D6's block layout is therefore named lead, after the one thing that distinguishes it.

D12 — a composition declares sequence in its definition, not its template

A composed rune's frontmatter takes sequence (numbered | connected | plain), and the generated config carries it.

  • Why the frontmatter. sequence is identity: whether an ordinal is information is a fact about the content (SPEC-158 D2, ADR-030 rule 3). ADR-045 and SPEC-177 let a theme or site replace a composition's template while keeping its frontmatter. A sequence written in the template would travel with the template, so a theme's replacement could delete the step numbers, which is rule 3's founding example.
  • The template marks where. The {% group layout="ladder" %} is where the definition's sequence is stamped.
  • A definition that declares sequence must place a ladder, or it is rejected at schema construction, naming the rune. A sequence with nowhere to go is the silent loss this guards against.
  • SPEC-177's checks apply the same rule to a replacement template: one that drops the ladder group of a definition declaring a sequence is rejected when the site loads.

D13 — ordinals arrive as nodes, and the per-rune counters go first

WORK-583 (ready, from ADR-029) emits a numbered sequence's ordinal as a DOM node instead of a CSS counter: information belongs in the document. Composed ladders should get node ordinals from the start rather than be migrated afterwards, so WORK-583 lands before or with D2. BUG-024's removal of the four vestigial per-rune counters is the "proved by deletion" step this spec asks for, and goes with it.

D14 — a ladder stamps its own list, not every list inside it

annotateSequence recurses through every descendant of a rune declaring sequence and stamps every <ol> it finds. For a ladder that would include an ordered list an author wrote inside a step's body, which would then be numbered as steps. The ladder's data-sequence is set on the ladder element only. Whether native runes already mis-stamp nested author lists is checked during implementation and filed as a bug if they do.

Implementation notes, deliberately not yet work items

  1. WORK-583 and BUG-024 first (D13).
  2. The {% group %} template tag with bar and ladder, and the unsequenced ladder rule (D2, D3).
  3. sequence in the definition's frontmatter, and the ladder requirement (D12).
  4. A composed steps over it, compared with the plugin (SPEC-161 D5).
  5. Track adopts metaFields and blocks (D7), deleting track.css's hand-written push.
  6. The five hand-written list resets that remain after BUG-024 are re-expressed through the ladder or the dimension, with their CSS deleted.

Non-goals

  • grid, split, tree or any topology beyond ladder and row (D1)
  • childDefaults, the per-instance cascade for author-written children (D8)
  • Promoting layout to a universal attribute: every universal today is decoration, so a structural one is a category change that needs its own argument
  • SPEC-145 D15's several templates, which the variants section describes but does not specify
  • Retiring bar as a rune; it stays as the author-facing row
  • {% group %} as an author-facing rune (D2)

Acceptance Criteria

  • A composition template can place {% group name layout="bar" %} and {% group name layout="ladder" %}; both render with data-name and no data-rune, and an unknown layout is rejected at schema construction (D2)
  • A bar group's child written as {% group align="end" %} renders data-align="end" and pushes to the end (D2)
  • A ladder renders <ol> when the definition declares a sequence and <ul> otherwise, with each each iteration or top-level node in an <li> (D3)
  • [data-zone-layout="ladder"] resets the list and flows items in a column in skeleton; cast's list mode uses it and its own reset is deleted (D3)
  • A composition's frontmatter accepts sequence; the generated config carries it, and a definition declaring sequence without a ladder group is rejected, naming the rune (D12)
  • The ladder's data-sequence is set on the ladder element only; an ordered list in an item's body is not stamped (D14)
  • WORK-583 and BUG-024 are done before or with the ladder (D13)
  • A composed steps places a numbered ladder and matches the plugin's output, every difference explained
  • Track declares metaFields and blocks, its byline variant is a variants entry over layout, and track.css's hand-written margin-left: auto is gone (D7)
  • refrakt contracts --check and npm run seo:baseline:check report no unexplained drift, on both contract copies
  • The CSS-coverage test's known-gap sets shrink rather than grow
  • The authoring guide documents {% group %} and states D9's rule

References

  • ADR-030: the rules this spec is the "later spec" for; accepted on 2026-10-28, with D10 settled there.
  • SPEC-158: shipped D4 and D5.
  • SPEC-169: D4's steps example, and D6, which routes sequence here.
  • SPEC-145: D19 (grouping), D32 (passing down), D15 (several templates).
  • SPEC-177 and ADR-045: template replacement, which D12 guards against.
  • WORK-583, ADR-029: ordinals as nodes.
  • BUG-024: the vestigial per-rune counters.
  • SPEC-161: D5 (steps), D6 (lead).
  • SPEC-080: metaFields, blocks, LayoutPrimitive.
  • SPEC-091: variants.
  • SPEC-146: the boundary that makes D9's hazard silent.
  • SPEC-155: the media audit, and the two rune-specific row implementations.
  • SPEC-159, SPEC-171: the cascade, D8.
  • SPEC-100, ADR-018: carousel, which D10 keeps out of this vocabulary.

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).