v0.47.0 · Quotations and the content model

All releases

A content model reads groups, nested blocks and a trailing part, and reports what is missing (); section's header grammar is declared with it. On that, quotations get roles a theme styles, instead of looks an author names (): a quotation, a featured quotation (pullquote), an excerpt and an epigraph, each with one shared attribution.

Changes

  • Two more SPEC-186 content-model constructs.
    • Nested models (D2). A field that matches one block node (blockquote, or a child rune) may declare model: a sequence read over that node's children.
    • The field still takes the whole node. Its members resolve under dotted names (quote.attribution), to any depth.
    • A composition places the node whole or its members by dotted name, not both (nested-placed-twice). A nested model on any other field is rejected (content-nested-invalid).
    • A child the nested model does not read is reported content-unmatched, naming the field.
    • content-missing (D4). A required field, group or nested member that took no node is now reported as content-missing, naming the field and the rune. This includes a required field reached after the content ran out, which used to pass silently. It is a warning in this release.
    • Behaviour change: a child that a required field skips is no longer dropped. It stays for the next field and is reported content-unmatched only if nothing takes it, so a finding names the real problem. A sweep of every site and fixture found no instance.
  • An attribution inside a blockquote is read as its last line (SPEC-185 D6).
    • The inside form. A blockquote whose last paragraph is — **Name**, Source is attributed: the leading dash marks it, so a quote ending in bold text is not misread. A plain quotation so attributed renders as <figure data-quote="quotation"> with its <figcaption>, the name in <cite>.
    • pullquote and the epigraph accept either form, the paragraph after the quote or its last line, and render one <figcaption>. With both, the paragraph after the quote is the caption.
    • Item models take prefix: the text a paragraph record must open with. A composition declares the inside form on its blockquote field as a nested model's position: last paragraph with prefix: '^\s*[—–]\s*$'. insideAttributionItemModel and splitInsideAttribution are exported from the attribution module.
    • An itemModel in a composition now rejects keys other than fields and prefix.
  • An attributed blockquote opening a page or a section is an epigraph (SPEC-185 D5).
    • In a section. section's header grammar gains an optional epigraph after the blurb: a blockquote with a **Name** — Source paragraph after it, read together. Every rune composed over section reads it the same way; header="headline" reads none.
    • At the start of a page. A blockquote opening a page's content, or directly after its # title, with an attribution after it, is the page's epigraph.
    • Only with an attribution. Without one the blockquote stays a quotation, and a lone attribution after a headline stays the blurb. A heading inside a body does not open an epigraph.
    • An epigraph renders as <figure data-quote="epigraph"> with the <figcaption>, and Lumina styles the role: quieter than the text, set toward the end of the line, centred under a centred header.
    • The attribution reader is shared by every role (attributionItemModel, readAttribution, renderAttribution, renderAttributedQuote in lib/attribution.ts).
  • pullquote is the featured quotation, with its attribution (SPEC-185 D2, D4, D6, D9).
    • The markup. A pull quote renders as <figure data-quote="featured"> holding the authored <blockquote>, never a second one around it. With excerpt=true it is a pull quote proper: an <aside aria-hidden="true" data-quote="excerpt"> holding the same figure, so a screen reader does not read the passage twice.
    • The attribution. A **Name** — Source paragraph after the quote is read with it and rendered as a <figcaption>, the name in <cite>. The source is optional.
    • quote is an alias of pullquote.
    • prominence applies to a featured quotation or an excerpt. A rune opens it by declaring the featured-quotation capability in provides; Lumina ramps the quote's size from its resting size, as it ramps a header's title.
    • variant is deprecated, with a warning: its values named looks. accent maps to tint="subtle" and prominence="prominent"; default and editorial map to nothing. align stays.
    • Lumina styles the role, [data-quote="featured"] and [data-quote="excerpt"], with the quotation glyph above the quote at its size: the same glyph a plain quotation carries, now shared as --rf-glyph-quote. pullquote.css keeps placement only (alignment, floats, a tinted panel).
  • A quotation carries its role, and a theme styles the role, not the blockquote element (SPEC-185 D3).
    • The marker. The core blockquote node marks a plain > as data-quote="quotation". A rune that reinterprets > takes the marker off what it renders (withoutQuoteRole): a recipe's tips, a testimonial's quote, a pull quote. QUOTE_ROLES (quotation, featured, excerpt, epigraph) joins the theme vocabulary.
    • Lumina. The global blockquote rule becomes [data-quote="quotation"]. A quotation in the ui reading register is set quieter. An unmarked blockquote gets a minimal zero-specificity fallback. The testimonial's glyph reset is gone, with nothing left to undo.
    • Visible changes:
    • a recipe tip no longer shows the quotation glyph over its text;
    • a pull quote shows its own large glyph instead of the quotation icon's.
  • For theme authors: a rule on blockquote now reaches tips and pull quotes as well as quotations. Style the role with [data-quote="quotation"].
  • A content model can say "these together" and "this one last" (SPEC-186 D1, D3).
    • Groups. A field may be a group of member fields that match as one: in order, when every required member matches, or not at all, leaving the nodes for the field after it. Members may be optional or groups themselves. Each resolves to its own name, and the group's name resolves to true when it matched.
    • position: last. A field with position: last reserves the container's last node when it matches that node, and the fields before it read the rest. A greedy body therefore no longer swallows a trailing attribution line.
    • Load-time checks. Composition definitions accept both and reject misuse when they load: content-group-invalid, content-field-duplicate and content-position-invalid.
    • Reference. refrakt reference and inspect show groups and the anchored field.
  • section's header grammar is now its declared content model (SPEC-186 D5). The eyebrow, headline and blurb are an optional group, so a paragraph with no heading after it is body (ADR-046). header="headline" and header="none" are conditional models, and a split section reads its media, content and footer zones through a delimited model. The hand-written reader (sectionHeaderParts) is removed, and the composition audit (slot-header-role) reads the same declared model, so rendering, the audit and the editor cannot disagree. Rendering is unchanged.
  • The staged composed testimonial places a featured quotation (SPEC-185 D7).
    • The testimonial places {% pullquote %} for its quote. The pull quote reads the **Name** — Role paragraph and renders it as its <figcaption>, so the testimonial's fills and byline meta block go. author and role stay for the attribute form and are passed to the pull quote.
    • pullquote takes author and source: the attribution written as attributes, which wins over a paragraph. A placed attribution paragraph keeps its slot marker on the <figcaption> it becomes.
    • A schema source can name a placed part with a dot. attribution.name is the node named name inside the node placed for attribution. When two sources map to one entity property, the first that resolves is published. The testimonial's Review / Person / Rating graph is unchanged.

A content model reads groups, nested blocks and trailing parts, and reports what is missing

SPEC-186accepted

A content model reads groups, nested blocks and trailing parts, and reports what is missing

A declarative content model is a flat sequence of fields, each matching one node type, in order. That is enough for most runes, and not enough for four things this repository already does by hand or cannot do at all:

runescontent-modelcompositionresolversectionreinterpretation
  1. Parts that only count together. An eyebrow is a paragraph only when a headline follows it (ADR-046); an epigraph is a blockquote only with its attribution (SPEC-185 D5). Written as independent optional fields, either part can be read alone.
  2. Parts inside a matched block. An attribution written as a blockquote's last line (> — **Ada Lovelace**, Analyst) is inside the node a blockquote field matched, and no field can read inside it.
  3. A trailing part after a run. A body of paragraphs followed by an attribution paragraph: a greedy body takes the attribution too.
  4. A required part that never came. Nothing reports it.

This spec adds a construct for each, in the vocabulary content models already use, and proves them on the one native rune whose real grammar is hand-written: section.

Why

section declares a content model and does not use it. Its schema declares a sequence (eyebrow?, headline?, blurb?, body*), and its transform's first step is to put those fields back together in source order and re-read them with the hand-written sectionHeaderParts. The comment there says why: the sequence "would read a paragraph with no heading after it as an eyebrow". The grammar compositions are audited against (schemaHeaderRoles) is that same hand-written reader. So the header that every composition over section relies on (hero, cta, testimonial, and card when it is composed) is code, not declaration, because the model cannot say "these together or not at all".

Compositions can only use what is declarative. A native rune can fall back to code; a composition cannot. If the epigraph and the inside-the-quote attribution of SPEC-185 exist only in hand-written readers, compositions can never offer them, and the same Markdown means one thing in a native rune and another in a composed one.

What the resolver does with a required field today, for the record. resolveSequence walks fields in order and children in order:

  • A field that matches takes its node (or, greedy, the run of matching nodes).
  • An optional field that does not match is skipped; the child waits for the next field.
  • A required field that does not match drops the child and reports it as content-unmatched, then resolution continues with the next field. So Prose. followed by **Ada** — Analyst, under a required quote and an optional attribution, reports Prose. and then reads the attribution, with no quote: the report names the wrong problem, and the result is wrong.
  • A required field reached after the children have run out is skipped silently.

Decisions

D1 — an optional group matches all of its required members, or nothing

A field may be a group: an ordered set of member fields that matches as one.

epigraph:
  optional: true
  group:
    quote:       { match: blockquote }
    attribution: { match: paragraph, itemModel: { fields: [ { name: name, match: strong } ] } }
  1. All or nothing. A group matches when every one of its required members matches, in order, from the current position. Then it takes those nodes. Otherwise it takes nothing, and every node it looked at is left for the field after it. This is SPEC-182 D3's rule ("match only when the required parts are there") applied to sibling nodes instead of the parts of one paragraph.
  2. Members may be optional, as fields are. An optional member that does not match is skipped inside the group without failing it: an eyebrow before a required headline.
  3. A group may be required or optional, as a field is, and may contain groups.
  4. Members resolve to their own names, as if written at the group's level: a composition places {% slot name="quote" /%}, not a path. Member names are unique across the whole model, and a duplicate is rejected at load. The group's own name resolves to whether it matched, for conditions.
  5. No backtracking across fields. The group tries its members once, from where it stands. A group that fails does not make an earlier field give anything back.

D2 — a block field reads its node's children with a nested model

A field matching a block node (blockquote, a list item's block content) may declare model, a content model applied to that node's children:

quote:
  match: blockquote
  model:
    type: sequence
    fields:
      body:        { match: paragraph, greedy: true }
      attribution:
        match: paragraph
        optional: true
        position: last
        itemModel:
          fields:
            - { name: dash, match: text, pattern: '^\s*[—–-]\s*$' }
            - { name: name, match: strong }
            - { name: source, match: text, pattern: '^\s*,?\s*(.+)$', optional: true }
  1. The field still matches the whole node, as today. The nested model reads its children into members named quote.body and quote.attribution: dotted, because a nested model is the node's own, and its members may repeat names used elsewhere (body). Nested models may nest again, to any depth (a.b.c).
  2. A composition places a member by its dotted name ({% slot name="quote.attribution" /%}) or the whole node by the field's name, not both (SPEC-145 D11's placed-once rule extends to members).
  3. The nested model is any content model, so a group (D1) or a condition can sit inside it.
  4. An unmatched child inside the node is reported as content-unmatched, naming the field.

D3 — a field can claim the container's last node

position: last on a field: before the sequence is walked, the last node of the container being read is set aside for that field if the field matches it. Every field before it, greedy or not, then reads the nodes before it; the anchored field takes the node it reserved.

  • body (greedy paragraphs) and attribution (position: last, a paragraph with a dash and a strong): the body takes every paragraph but a final attribution. With no attribution line, the last node is not reserved and the body keeps everything.
  • "Last" is the last node of the container being read: the blockquote's children for a nested model (D2), the zone or the rune's children otherwise. A node after the attribution line (an image inside the quote) means it is not last, and it is not read as one, which is the authored convention.
  • One node. A trailing run is not anchored; no case needs it, and a mechanism for it can be added when one does (ADR-030 rule 6: vocabulary is cheap, mechanism expensive).
  • At most one anchored field per container, and it must be the last field of its model. A model with two, or with fields after it, is rejected at load.
  • Stated on the field that takes, which is the field the rule is about: an attribution is the last line. (Decided 2026-11-01 over yields: true on the greedy field that gives, which needed a field of lookahead and an "otherwise matches nothing" clause.)

D4 — a required field that matched nothing is reported

  1. content-missing is reported for a required field (or required group, or required member of a matched group) that took no node: whether the children ran out first or the field was skipped. It names the field and the rune, at the rune's line.
  2. A child skipped by a required field is no longer dropped. It stays for the next field, as it does after an optional one, and is reported as content-unmatched only if nothing takes it. The report then names the real problem (the missing field), and nothing after it is misread on the strength of a field that is not there.
  3. Rollout: content-missing is a warning for one release, then an error. Before it lands, a sweep of site/content, the SEO fixtures and the composed fixtures lists every instance, and each is fixed or its field made optional where the rune genuinely accepts the absence.

D5 — section's header grammar is declared, and the hand-written reader goes

section's content model becomes its grammar:

type: sequence
fields:
  header:
    optional: true
    group:
      eyebrow:  { match: paragraph, optional: true }
      headline: { match: heading }
      blurb:    { match: paragraph, optional: true }
  body: { match: any, optional: true, greedy: true }
  • A paragraph with no heading after it fails the header group (its required headline is missing) and falls to body: ADR-046, declared.
  • The media zones stay where they are. section splits its media and footer zones on --- before it reads its header (sectionZones). The header model applies to the content zone, which a delimited model already expresses; the spec's work item confirms the zone split reads the same through it.
  • The header attribute's modes (headline, none) become conditional models (when), which the content model already supports.
  • sectionHeaderParts and the reader registered in schemaHeaderRoles are removed. Rendering, the composition audit and the editor read the header from the one declaration, so they cannot disagree. Every existing section, hero, cta and testimonial test passes unchanged: that is the gate.
  • SPEC-185 D5's epigraph is then one more optional group inside header, after the blurb.

D6 — compositions declare the same constructs

A composition definition's content accepts group, model and position: last in the same YAML, with the same load-time checks: unknown keys, a group with no required member, duplicate member names, a nested model on a non-block field, position: last on more than one field of a model or on a field that is not its last. Each is reported at definition load, naming it, and the composed-runes guide's Errors table gains an entry for each new code.

Not in scope

  • The attribution and epigraph themselves. SPEC-185 defines them; this spec provides what they are declared with.
  • Backtracking or general lookahead. D1 and D3 each look at most one field ahead of where they stand. A model that needs more is a model to restructure.
  • Inline-level groups. itemModel already reads a paragraph's inline parts in order.

Open questions

  • (Settled 2026-11-01: dotted names for a nested model's members, flat names for a group's, D1.4 and D2.1. A dot means "inside that node"; no dot means "one of this rune's own parts". Nesting may go to any depth, a.b.c.)
  • (Settled 2026-11-01: position: last on the field that takes, D3.)

Acceptance Criteria

  • A content model accepts an optional or required group that matches all of its required members in order, or takes nothing and leaves its nodes for the next field
  • A block field accepts a nested model over its node's children, resolved to dotted member names, with unmatched children reported
  • A field with position: last reserves the container's last node when it matches it, the fields before it reading the rest, and reserves nothing otherwise
  • A required field, group or member that took no node is reported content-missing, as a warning for one release, and a child it skips is no longer dropped
  • The sweep of site/content and the fixtures is done, and each content-missing it found is fixed or its field made optional
  • section's header is read from its declared model, and sectionHeaderParts and its header-role reader are removed, with every section, hero, cta and testimonial test passing unchanged
  • Composition definitions accept group, model and position: last, with load-time errors for each misuse documented in the composed-runes guide

References

  • ADR-046: the header grammar D5 declares.
  • SPEC-182: paragraph item models, and D3's match-only-when-present rule D1 generalises.
  • SPEC-185: the epigraph (D5) and attribution (D6) that depend on D1–D3.
  • SPEC-145: D11, placed-once, extended to members.
  • SPEC-169: the content models a composition takes.

Quotations have roles, and a theme styles the role

SPEC-185accepted

Quotations have roles, and a theme styles the role

A quotation in refrakt is styled by what element it is and by what look its author named. Lumina decorates every <blockquote>, rule and glyph, wherever it appears. pullquote offers three named looks (default, accent, editorial), and v0.45.0 added a fourth, plain, to take decoration off again so a composed testimonial could look as its plugin did. That is backwards, and it does not travel: every look is Lumina's, named by the author.

This spec names what a quotation is, four roles chosen by the author through syntax or position, and leaves what each looks like to the theme. It applies SPEC-159's rule ("name the intent; the theme owns the look") to quotations, and withdraws the look-names.

runespullquoteblockquotethemeintentaccessibilitytestimonialcomposition

Why

The look-names fail as polaroid did. SPEC-159 rejected comic and polaroid because a look name is "a specification wearing the costume of a name": a theme cannot render editorial differently without failing to do it. An editorial theme looks editorial by default; an author on a docs site should not be choosing it per quote.

plain exposed the problem. The composed testimonial needed its quote undecorated. Instead of the quote's role deciding that, the author (the composition) asked a decorated rune to remove its decoration, so the testimonial looks right only because of how Lumina happens to be styled.

> is not always a quotation, so the element is the wrong thing to style. recipe reads > as tips, comparison as callouts, conversation as dialogue, and event, organization and symbol accept it as body content. A theme that styles blockquote as a quotation puts a rule and a glyph on a recipe tip, and each of those runes undoes it.

The variety an editorial theme wants comes from elsewhere. A theme wanting different quotations in different parts of a page is a real need, and every case of it has a home that is not an author's look-name: where the quote sits (the region, the container, the reading register, which the theme already sees), what it is (a role, below), and how loud it is (a scale, prominence, D9). A remaining wish for pure variety is a composite intent (SPEC-159 Part 3), named for what the quote is to the reader and defined by a theme or a pack.

Decisions

D1 — four roles of quotation

RoleWhat it isAuthored asHTML
quotationsomeone else's words cited in support of the text; the text is the subject> in running text<blockquote>, or <figure> with a <figcaption> when attributed
featured quotationa statement presented for its own sake; who said it is part of it{% pullquote %}<figure><blockquote>…</blockquote><figcaption>…</figcaption></figure>
pull quotean excerpt of this text, repeated large for a skimming eye{% pullquote excerpt %}<aside aria-hidden="true"> holding the same structure
epigrapha short attributed quotation that opens a work or a section> with an attribution, by position (D5)<figure> with a <figcaption>

The roles are the author's choice. What each looks like is the theme's.

D2 — decoration belongs to the theme; the look-names are withdrawn

  • pullquote's variant is deprecated: default, accent and editorial name looks. It is accepted with a deprecation warning for a release, then removed.
    • editorial maps to nothing: on an editorial theme a featured quotation looks editorial already.
    • accent's tinted background is the universal tint, which every rune already takes; its extra size is loudness, which is prominence (D9).
  • plain is withdrawn before it is released. It was added in v0.45.0 and is unpublished, so this is a changeset edit (as SPEC-182 D7 withdrew the testimonial codemod).
  • align stays. It is placement, not decoration.
  • A theme retunes a role; it does not redefine it (SPEC-159's bound). Every theme renders a featured quotation as a set-apart statement with its attribution; how it is set apart is its own.

D3 — a theme styles roles, not the blockquote element

  1. Each role carries a marker, data-quote="quotation" | "featured" | "excerpt" | "epigraph", on the quotation's outermost element: the <figure> when it is attributed, the bare <blockquote> when it is not, the <aside> for an excerpt. A theme styles the role through it, with its blockquote and figcaption as descendants, and never guesses which wrapper holds it.
    • Its own attribute, one per vocabulary as data-reading, data-media and data-meta-type are. Not data-section, which names a part of a rune's own structure: a > in running text is part of none. Not a general content-role attribute: how text speaks (dialogue, lyrics, a caption; SPEC-159's voice role) is a different question from what kind of quotation it is, and keeping them apart lets one element carry both.
    • Independent of rune names. An epigraph comes from section's header grammar or the page start, a quotation from the core node, and a later composition may make a featured quotation without pullquote, so the theme contract names roles, not runes.
  2. The quotation role comes from the core blockquote node. A plain > that no rune consumes has no rune to mark it, so the node emits data-quote="quotation".
  3. A rune that reinterprets > produces its own elements and never carries the marker: tips, callouts and dialogue are not quotations, and no longer need to undo a quotation's style.
  4. A composition does not style what it reinterprets; it places the rune that is that thing. A composed recipe's tips are notes, so its template places them in a hint (type="note", or a tip type if recipe's conversion shows one is needed), and the theme's hint treatment applies, as for any hint. The same holds for comparison's callouts. Neither keeps tip or callout CSS of its own once it is composed: that is the rule every composition already follows (SPEC-145 D2), stated here because > is where the temptation lies.
  5. Lumina's global blockquote rule becomes its [data-quote="quotation"] rule. A blockquote with no marker (third-party content transformed outside refrakt's nodes) gets a minimal, unopinionated fallback at zero specificity, so it is never unstyled to the point of illegibility.
  6. The reading register is context a theme may use. A quotation in prose and one in ui (a card body) may be styled differently: Lumina keeps its rule and glyph in prose and sets a quotation quieter in ui. The role is the same; the context differs (SPEC-108).

One rune, because the two cases look the same and differ in how they are read. It keeps its name and gains quote as an alias, the name for its default case; renaming is not decided (open question 2).

  • By default it is a featured quotation, read by assistive technology, as every current use of it (this site's examples quote other people) already is.
  • excerpt marks a true pull quote: the words already appear in the text, so it renders as an <aside aria-hidden="true"> and a screen reader does not read the passage twice.
  • Its structure is <figure><blockquote>…</blockquote><figcaption>…</figcaption></figure>. The authored blockquote's content is the <blockquote>'s; it is never nested in a second one. An attribution, when present, is the <figcaption>.
  • It reads its attribution from a paragraph after the quote, **Name** — Source, with the item-model reading SPEC-182 gave compositions (D6 below).

D5 — an epigraph is recognised by position and attribution

A blockquote is an epigraph when both hold:

  1. It stands in the epigraph position: after a section's headline (and its blurb, when there is one) and before the body; or at the very start of a page's content, or directly after its # title (many layouts take the title from frontmatter, so the content may have none).
  2. It carries an attribution (D6), in either position.

Without an attribution it stays a quotation. That keeps the ordinary opening quotation from being misread: epigraphs almost always name their source, and requiring one is SPEC-182 D3's rule ("match only when the required parts are there") applied to position. No new rune.

  • It is part of section's header grammar (ADR-046), as one more optional group after the blurb (SPEC-186 D1, D5). section reads its header from the content a composition places in it, so every rune composed over section reads epigraphs the same way: hero, cta, testimonial, and card once it is composed. That is the intended reach: reinterpretation means the same Markdown means the same thing in every rune. A card's epigraph need not look like an essay's; the theme styles the role by context (D3.6).
  • The quote and its attribution are read as one unit, optional together and required within: a lone attribution after a headline is not an epigraph, and stays the blurb it is today.
  • A heading inside a body does not open an epigraph. It is the extension most likely to misread ordinary content (a source quoted under a docs heading), and the cheapest to add later.
  • A composition that never wants one sets the placed section's header="headline".

D6 — attribution is one shared part

Every role that carries a source reads it the same way and renders it the same way:

  • Read from either of two positions, with one item model (**Name** required, then the source after a dash or comma, as SPEC-182 D6 fixed for testimonials):
    • after the quotation, a **Name** — Source paragraph directly following it. Read wherever a rune owns the sequence: pullquote, a composition's model, section's epigraph position. The quote and the attribution are one group (SPEC-186 D1), so an attribution is never read without a quote.
    • inside it, as the blockquote's last paragraph, — **Name**, Source: the leading dash marks it, so a quote ending in bold text is not misread. Read by a nested model on the blockquote field (SPEC-186 D2, D3), so it works in a composition as it does in a native rune, and by the core blockquote node, which gives a plain quotation an attribution with no rune.
    • The after form can ship first. It needs only SPEC-182's paragraph model and SPEC-186 D1; the inside form waits for SPEC-186 D2–D3.
  • Rendered as <figcaption>, the name in <cite>.
  • A quotation (role 1) with an attribution becomes a <figure>; without one it stays a bare <blockquote>.

The staged testimonial places {% pullquote %} for its quote, with no variant: a testimonial's quote is a featured quotation, and its look in a card comes from the theme and the ui register, not from the composition. The testimonial keeps what is its own meaning: the rating, and the Review / Person / Rating schema.

The pullquote owns the attribution. It reads the **Name** — Role paragraph (D6) and renders the <figcaption>, so the testimonial no longer has a byline of its own:

  • The testimonial's attribution field and its fills (SPEC-182 D6) go: the paragraph is the pullquote's content now, placed with the quote.
  • Its byline meta block goes with them. The <figcaption> is the byline.
  • Its schema reads the attribution from the pullquote: author and role resolve to the <figcaption>'s name and role nodes, as reviewBody already resolves to the pullquote's quote across the placed rune. The Review / Person graph is unchanged, which the SEO baseline checks.
  • author and role stay declared on the testimonial for the attribute form (SPEC-182 D4), and fill the pullquote's attribution when written, so both forms render one <figcaption>.

This amends SPEC-182 D6: the paragraph reading it introduced is kept, and moves from the testimonial to the pullquote, where every role reads attribution the same way.

D8 — what happens to the interim fixes

  • plain (#746, unreleased) is withdrawn under D2.
  • The unwrap in #747 (no blockquote nested in a blockquote) is right and is folded into D4, where the root becomes <figure>, so pullquote's markup changes once rather than twice.

prominence scales a page-section header, and SPEC-183 made it a register for the whole header. A featured quotation is the centrepiece of its block in the same way, so it takes the same axis rather than a new one:

  • The gate widens. The prominence facet applies to a rune with a header role, or one marked as a featured quotation or an excerpt (D3). On a plain quotation (role 1) it stays unavailable.
  • The steps keep their meaning. quiet and normal are a quotation's own size, prominent and display a set-apart one, marquee a page's centrepiece. The theme maps each step to the quote's type size, as it maps them to a header's; Lumina ramps from the quote's resting size, as it ramps a title (WORK-538).
  • accent's extra size (D2) is prominence="prominent".
  • A testimonial's quote is normal in a card; a quote set as its own band is display or marquee.

Not in scope

  • Callouts, tips and dialogue. They are not quotations (D3.3); their own runes style them.
  • Inline quotations (<q>). A separate question.
  • A theme other than Lumina. This spec sets the contract a second theme must meet; it does not write one.

Open questions

  1. (Settled 2026-11-01: prominence, D9.)
  2. Renaming. D4 adds quote as an alias. Whether quote later becomes the name and pullquote the alias is left until the alias has been in use.
  3. (Settled 2026-11-01: section's header grammar, so every rune composed over it, and the page start; not headings inside a body. D5.)
  4. (Settled 2026-11-01: the pullquote owns it, D7.)
  5. (Settled 2026-11-01: data-quote with quotation | featured | excerpt | epigraph, on the outermost element, D3.1.)

Acceptance Criteria

  • The core blockquote node marks a quotation data-quote="quotation"; runes that reinterpret > carry no marker
  • Lumina styles the quotation role, not the bare element, with a minimal fallback for an unmarked blockquote, and a quieter quotation in the ui register
  • pullquote renders <figure><blockquote>…</blockquote>[<figcaption>…</figcaption>]</figure>, never a nested blockquote, marked data-quote="featured"
  • {% pullquote excerpt %} renders as <aside aria-hidden="true">, marked data-quote="excerpt"
  • An attribution paragraph is read the same way for every role and rendered as <figcaption> with <cite>
  • A blockquote after a page or section title with an attribution is an epigraph, data-quote="epigraph"; without an attribution it stays a quotation
  • pullquote's variant warns as deprecated, and plain is withdrawn before release
  • pullquote accepts the quote alias, and takes prominence (D9), which ramps the quote's size
  • The staged testimonial places {% pullquote %} with no variant, the pullquote renders its attribution, and the testimonial's Review / Person graph is unchanged, read from the pullquote
  • A composed rune that reinterprets > places the rune that is that thing (a hint for tips) and ships no style for it
  • The theme-authoring docs list the four roles as part of the theme contract, and the pullquote and blockquote docs describe roles, not looks

References

  • SPEC-159: intent, not look; "retune, don't redefine"; composite intents.
  • SPEC-108: reading registers, the context D3.5 uses.
  • SPEC-182: paragraph reading (D4, D6) and match-only-when-present (D5); D6 amended by D7.
  • SPEC-145: D2, a composition has no style of its own (D3.4).
  • SPEC-183: prominence as a register (D9).
  • SPEC-186: the content-model constructs D5 and D6 are declared with: groups, nested models, position: last.
  • ADR-046: header grammar by position (D5).
  • ADR-030: variants are modifiers; semantics are rune-locked.