Relationships
Related 16
Branches 3
Summary
SPEC-151 D1 kept the marketing plugin alive on comparison alone, because its cross-column alignment is a computation over the whole child set that no declarative content model can express. That is still true. What has changed since is the route around it: ADR-044 tier 2 and SPEC-162 established that a primitive may own imperative code and the domain rune composes over it.
comparison's one computation is lining up several labelled lists by their labels. That is a generic operation: a pivot of definition lists into a table of entities by attributes. This spec makes it a core primitive, {% crosstab %}, and makes comparison a composition with two templates (SPEC-145 D15), table over crosstab and cards over grid and card. It adds a universal featured intent for "the one option singled out", decides the inclusion value SPEC-166 D5 left open, and records the visible changes.
What comparison does today, measured
From plugins/marketing/src/tags/comparison.ts and plugins/marketing/src/config.ts:
- Input. Each
##heading is a column. In each column, a list item whose text starts with a bold label is a row (- **Learning curve** — Low), and the label aligns it with the same label in other columns.[x]and[ ]at the start of an item mark it supported or not supported,~~strikethrough~~marks it negative, a blockquote is a callout, and an item with no bold label is a secondary row. - Alignment.
convertComparisonChildrencollects every label across all columns in first-seen order, then emits each column's rows in that order, with an empty placeholder where a column lacks a label. - Two layouts, chosen by
layout.buildComparisonTablebuilds a<table>, andbuildComparisonCardsbuilds one card per column. - The table drops content.
buildComparisonTableiterates the master label list only, so secondary rows and callouts never appear in the table layout. Only the cards show them. - "Recommended" is hard-coded English, in both builders, so it cannot be translated.
- Classes are hard-coded
rf-comparison*; the source says the rune was never theme-prefix aware. - The ✓ and ✗ marks are
<span aria-label="Supported">andaria-label="Not supported"with no role. ARIA 1.2 does not allow naming a generic element, so screen readers often ignore the label. collapseis declared and read nowhere. The schema describes it as "rows collapse into an accordion on small screens"; neither the transform nor theComparisonengine config reads it.highlightednames a column by its heading, and the builders add--highlightedclasses to that column's header and cells.- Usage: three instances, all on the rune's own documentation page (
site/content/runes/marketing/comparison.md). Lumina'scomparison.cssis 210 lines.
The same "singled out" idea exists a second time in pricing: tier featured renames the rune to featured-tier (plugins/marketing/src/tags/pricing.ts), which gets a static --featured modifier, and pricing.css draws it with a primary border, a large shadow and a filled button.
Why SPEC-151's verdict changes
SPEC-151 D4 rejected generalising comparison's content model, because that would mean inventing an expression language for one consumer (ADR-036). This spec does not do that. crosstab is a primitive with code, and its content model is simple: a heading and some content, per column. The computation lives in its transform, as tally's does. ADR-036's objection is about content-model vocabulary and does not apply.
Decisions
D1 — {% crosstab %} is a core primitive that aligns definition lists into a table
{% crosstab featured="Astro" %} ### SvelteKit - **Learning curve** — Low - **SSR** — Built-in ### Astro - **Learning curve** — Low - **Islands** — Yes {% /crosstab %}
Input. A column is one of two shapes, and both may be mixed:
- A heading, of any level, and everything up to the next heading. This is plain Markdown, for direct use. It follows ordinary section semantics: a paragraph before a heading belongs to the column above it. So a heading column has no eyebrow, and
featurednames it by its heading text. - A placed
sectionorcard. Its rendered header supplies the column name (the headline) and the eyebrow, and itsfeaturedintent (D2) marks it. The boundary is the rune, so nothing is ambiguous.
A paragraph immediately before a heading could otherwise be either the end of one column or the eyebrow of the next. The two shapes keep that question from arising: an eyebrow exists only where a rune gives the column a start, as card already does ("a leading paragraph immediately followed by a heading", packages/runes/src/tags/card.ts).
{% crosstab %} {% section %} ### SvelteKit - **Learning curve** — Low - **SSR** — Built-in {% /section %} {% section featured=true %} Our pick ### Astro - **Learning curve** — Low - **Islands** — Yes {% /section %} {% /crosstab %}
How it works. In its transform, crosstab wraps each bare list in a deflist node, renders its children, and reads the rendered dt/dd pairs. It never parses term syntax itself: that stays in deflist, which gains the bold-lead term shape with a dash separator in SPEC-161 D3. Everything happens at stage 2, inside this one rune, on its own children, so it is not a pipeline post-process.
Because it reads rendered rows, anything that renders definition-list rows can be a column: an explicit {% deflist %}, a deflist of {% field %} rows (SPEC-145 D28), or an engine metadata block, which renders the same markup. A composition can compare entities by their metadata without re-wrapping it.
Output, for the second example:
<div data-rune="crosstab"> <table> <colgroup><col><col><col data-featured></colgroup> <thead> <tr> <td></td> <th scope="col">SvelteKit</th> <th scope="col" data-featured><span data-name="eyebrow">Our pick</span> Astro</th> </tr> </thead> <tbody> <tr><th scope="row">Learning curve</th><td>Low</td><td data-featured>Low</td></tr> <tr><th scope="row">SSR</th><td>Built-in</td><td data-featured data-empty></td></tr> <tr><th scope="row">Islands</th><td data-empty></td><td data-featured>Yes</td></tr> </tbody> </table> </div>
- Rows are the union of terms across columns, in first-seen order. A column without a term gets an empty cell marked
data-empty, with a visually hidden "Not listed" fromcore.crosstab.empty(SPEC-145 D29). - Column state is carried to every cell of the column. A column's
data-featured(from a placed section or card carrying the D2 intent, or, for a heading column, thefeaturedattribute naming its heading) and itsdata-sentiment(SPEC-163) are stamped on its header cell, each of its cells and its<col>. A row's state is stamped on its row. A theme styles[data-featured]on cells without knowingcrosstabexists. - The eyebrow goes in the header cell, so a screen reader hears "Our pick, Astro", and the emphasis is not only visual.
crosstabrenders no label of its own: the label is content. - Content that is not a row (a paragraph after the list, a blockquote, an actions link) goes into a
<tfoot>row, under its column. Nothing an author wrote is dropped, which fixes the table's loss of callouts and secondary rows. labels="hidden"omits the row-header column visually; theth scope="row"cells stay in the markup, visually hidden, so rows keep their names.- The table sits in a horizontally scrolling wrapper, as today's does.
The name. crosstab is the established reporting and BI term for a text table with dimensions on both axes, which is what this builds. matrix was the first draft's name and was dropped because other runes have stronger claims to it:
- a 2×2 quadrant (priority, effort and impact, likelihood and impact, which is a risk matrix): the most common meaning of "matrix" in business and planning content, and a plausible plugin rune with a different shape (two axes, items placed into cells);
- a mathematical matrix, which is what LaTeX calls a bracketed grid of numbers.
A RACI grid is the one "matrix" with this shape, and would be a composition over crosstab. Names near "compare" were avoided too: core already has compare (code comparison) and marketing has comparison. pivot was rejected because it implies aggregation.
Placement in core. The input is core vocabulary (deflist, headings), and there is a second consumer beside comparison: a "compare plans" table for pricing, where a tier's feature list already has this shape. If a reviewer holds the stricter bar of two plugins, crosstab ships in the marketing plugin first; nothing else here changes.
D2 — featured is a universal intent
Contract: this item is the one singled out among its siblings. It is statable without naming a rune, which is SPEC-159's first gate.
It is not an existing axis:
elevationis depth. A theme may draw featured with elevation, but that is its reading of an intent (SPEC-159 D5), not the meaning.prominenceis a header's type register.sentiment(SPEC-163) is good or bad.
featured is a boolean available on section and so on card, emitting data-featured on the chrome carrier. crosstab carries it into table cells (D1.2). It replaces:
tier'sfeatured-tierrune name and its--featuredstatic modifier, amending SPEC-161 D4, which left the choice between elevation and aprominencevalue open.pricingSchemalistsfeatured-tieronly because of the rename, and drops it.comparison's--highlightedclasses.
D3 — the label is the eyebrow, written as content
"Recommended" is content, not chrome: it needs translating, and authors want "Most popular" or "Our pick". It is the eyebrow of the featured column or card. There is no eyebrow attribute on section or card: one slot with two sources is ambiguous, and the eyebrow stays the paragraph before the heading.
A template that adds the label to one item only does so with a conditional, which SPEC-145 D30 resolves during expansion, so the rune around it sees a paragraph and not an if tag. D5 shows it.
D4 — the inclusion value
This decides SPEC-166 D5's second candidate.
- Values:
included,excluded,partial. - Authoring: a task-list marker at the start of a definition,
[x],[ ]or[~], read by the inclusion kind's reader (SPEC-166 D2), whichdeflistcalls on each definition.[x]and[ ]are whatcomparisonreads today;[~]is new. - Element: per SPEC-166 D1,
<span data-meta-type="inclusion" data-value="included" data-meta-sentiment="positive">, withexcluded→negativeandpartial→caution. It carries visually hidden text fromcore.inclusion.<value>("Included", "Not included", "Partly"), and its mark is a glyph (SPEC-164) namedincluded,excludedorpartial, so the theme draws it. Any text after the marker stays in the definition. - Strikethrough loses its special meaning.
~~text~~renders as Markdown's<s>, as everywhere else. "Not supported" is the inclusion value's job. This is a visible change, recorded in D6. inclusionis added to themetaTypevocabulary, subject to ADR-043's coverage check.
D5 — comparison is a composition with two templates
It stays in the marketing plugin, and authors' content does not change.
--- tag: section description: Options compared side by side, as a table or as cards. attributes: title: { type: string } highlighted: { type: string, description: "Heading of the option to feature." } layout: { type: string, matches: [table, cards], default: table } labels: { type: string, matches: [left, hidden], default: left } verdict: { type: string } content: type: sections sectionHeading: heading emitAttributes: { heading: $heading } preamble: intro: { match: any, optional: true, greedy: true } templates: { by: layout } --- {% fragment name="opening" %} {% if $attrs.title %} ## {% $attrs.title %} {% /if %} {% slot name="intro" /%} {% /fragment %} {% fragment name="item-heading" %} {% if equals($each.heading, $attrs.highlighted) %} {% t key="recommended" %}Recommended{% /t %} {% /if %} ### {% $each.heading %} {% /fragment %} {% fragment name="closing" %} {% if $attrs.verdict %} {% $attrs.verdict %} {% /if %} {% /fragment %} {% template layout="table" %} {% partial file="#opening" /%} {% crosstab labels=$attrs.labels %} {% slot name="sections" each %} {% section featured=equals($each.heading, $attrs.highlighted) %} {% partial file="#item-heading" /%} {% slot /%} {% /section %} {% /slot %} {% /crosstab %} {% partial file="#closing" /%} {% /template %} {% template layout="cards" %} {% partial file="#opening" /%} {% grid cells="each" %} {% slot name="sections" each %} {% card featured=equals($each.heading, $attrs.highlighted) %} {% partial file="#item-heading" /%} {% deflist %} {% slot /%} {% /deflist %} {% /card %} {% /slot %} {% /grid %} {% partial file="#closing" /%} {% /template %}
- Two templates, not a runtime switch. D15 gives each layout its own assembly, its own contract expansion and its own fixture. Each template places every field once.
- What the two share is written once, as fragments (SPEC-168 D8): the title and intro, each item's eyebrow and heading, and the verdict. The
item-headingfragment is used inside aneach, so$eachis bound where it lands. - The two templates differ in two words. Each item is a
sectionin acrosstabor acardin agrid, with the same featured flag, eyebrow and heading. Onlycrosstabreads the sections as columns. - The label is resolved at expansion (SPEC-145 D30). The featured item's content starts with a paragraph and then a heading, so
sectionandcardboth read it as the eyebrow, andcrosstabtakes it from the section's header. The key ismarketing.comparison.recommended. - The cards template places
deflistexplicitly, becausecarddoes not know terms. The table template does not, becausecrosstabwraps bare lists itself. collapseis removed. It was never read. A narrow screen gets the scrolling table, as today; an author who wants cards on every screen chooseslayout="cards".comparison-rowandcomparison-columnare removed with the native transform.
D6 — the migration is compared, and the visible changes are recorded
As SPEC-165 D11 does for the design runes, the three instances on the documentation page are rendered before and after, and the differences are recorded:
| Change | Why |
|---|---|
rf-comparison* classes replaced by crosstab, grid, card and deflist vocabulary | the composition emits shared vocabulary; Lumina's comparison.css is drained (ADR-044) |
| "Recommended" is translatable and replaceable | D3 |
Secondary rows and callouts appear in the table, in <tfoot> | D1.4 |
| ✓ and ✗ become inclusion values with real text alternatives | D4 |
~~text~~ renders as plain strikethrough, without negative styling | D4 |
The featured column is drawn by the theme's [data-featured] rule | D2 |
Third-party themes that style rf-comparison__* lose those selectors. This is the same accepted cost as card moving onto section (ADR-041).
D7 — SPEC-151 D1 and D4 are amended
- D1 ("marketing stays a plugin, on
comparisonalone") no longer holds as stated.comparisonbecomes a composition. Whether the marketing plugin remains, as a home for its compositions, is a packaging question that this spec does not decide. - D4 stands as written: no content-model vocabulary is invented. Its conclusion that
comparisonmust stay native is replaced, because the primitive-plus-composition route did not exist when it was written.
Dependencies
- SPEC-145 D15 (several templates per rune), which is specified but not yet built:
composition.tshas notemplateshandling. - SPEC-145 D29 (
t) and D30 (conditionals resolved at expansion). - SPEC-161 D3:
deflistreads a bold lead with a dash separator as the term. - SPEC-165 D7:
grid cells="each". - SPEC-160:
cardas a section with a surface, and its eyebrow. - SPEC-166: the value contract the inclusion value meets.
Open questions
- An
eachslot expands before the placed rune's content model runs, socrosstabsees the repeated sections.expandruns over the whole template before any placed rune transforms (composition.ts:886), andcardalready sees a---written around slots incharacter's template, so it should hold; the first implementation step is a probe, not an assumption. equals($each.heading, $attrs.highlighted)in an attribute position.substitutereplaces variables inside function parameters (composition.ts:771), so this should evaluate at transform. The probe covers it.- Partial marker syntax.
[~]is proposed. Markdown has no standard for it. - A standalone inclusion rune for prose ("Offline mode ") is not proposed until a case appears.
Acceptance Criteria
{% crosstab %}exists: a column is a heading or a placedsection/card, bare lists are wrapped indeflist, rows are the union of terms in first-seen order, and missing cells aredata-emptywith a visually hidden "Not listed"- A paragraph before a heading in a heading column belongs to the column above it; only a placed
sectionorcardsupplies an eyebrow crosstabreads only rendereddt/ddrows; a column built from{% field %}rows or an engine metadata block aligns the same as one built from a list- A featured column's state is stamped on its header cell, its cells and its
<col>; a column's eyebrow renders inside its header cell - Content in a column that is not a row renders in
<tfoot>under that column; nothing authored is dropped labels="hidden"hides row headers visually and keeps them in the accessibility treefeaturedis a universal intent onsectionandcardemittingdata-featured;tieruses it, andfeatured-tieris removed from the rune names andpricingSchema- The inclusion value reads
[x],[ ]and[~], meets every point of SPEC-166 D1, and is in themetaTypevocabulary comparisonis a composition in the marketing plugin withtableandcardstemplates; the three instances on its documentation page render with the differences in D6 and no others- "Recommended" is the eyebrow of the featured item, keyed
marketing.comparison.recommended, and an author can replace it comparison's native transform,comparison-row,comparison-columnand thecollapseattribute are removed, and Lumina'scomparison.cssis deleted- SPEC-151 D1 and D4 carry a note pointing here
References
- SPEC-151: D1 and D4, amended by D7.
- SPEC-145: D15 templates, D28 field rows, D29
t, D30 conditionals at expansion. - SPEC-161: D3
deflistterm shapes; D4tier, amended by D2. - SPEC-162:
tally, the precedent for a container that reads rendered children. - SPEC-163:
sentiment, carried to cells likefeatured. - SPEC-164: glyphs for the inclusion marks.
- SPEC-166: the value contract; D5's inclusion candidate, decided by D4.
- SPEC-159: the gates an intent passes, and chrome as intent.
- ADR-036: why no content-model vocabulary is invented.
- ADR-044: tier 2, and draining per-rune theme CSS.