Release candidate 0.0.0-rc.2 Source: spec/v0.md

Markset v0 — Core Tag Vocabulary

Status: Release candidate 0.0.0-rc.2, 2026-09-14. The conformance suite passes against the reference implementation, the §9 questions are decided, and the naive-output contract has been verified on GitHub. The candidate period exists to write real documents against the spec; it becomes v0 when that has happened without a change to §2 or §4. Four documents have been written against it so far, and the two most recent forced no change to §2 or §4.

Change policy. Once v0 is declared, changes within v0 are additive only: new diagnostics, new theme tokens, new reserved classes, clarifications that do not change the parse of any existing conformance case. Anything that changes an existing case's ast, valid, or diagnostics is a breaking change and belongs to v1, which will declare itself with a new markset: value. The conformance suite is the arbiter: a proposed change ships with its cases, and the cases decide which kind of change it is. During the candidate period the same rule applies, except that a breaking change may still land in v0 with a new -rc.N tag and a changelog entry.

0. Scope

This document defines the v0 syntax and tag vocabulary: the grammar for attributes and directives, the eight core block constructs, the reserved class names, the theme token schema, and the degradation contract every construct must satisfy.

It is deliberately small. The v0 bar is: can a single implementer build a conformant parser and both renderers in a few weeks? Constructs that fail that bar are listed in §8 with the reason.

Markset is a strict superset of CommonMark. Every valid CommonMark document is a valid Markset document with identical output.

1. Design invariants

These govern every decision below. When a proposed feature conflicts with one, the feature loses.

  1. Semantic, never presentational. Authors name intent (card, metrics). Appearance comes from the theme. No inline CSS, no color values, no pixel dimensions in document source.
  2. Every construct degrades. Each one has a defined CommonMark equivalent and a readable raw-source fallback. This is normative, not aspirational — see §3.
  3. Closed vocabulary. Unknown directive names are a validation error, not silent passthrough. This is what makes documents checkable and makes generated output reliable.
  4. No code execution. Nothing in v0 evaluates anything.
  5. Reuse convergent syntax. Attribute specifiers and directive fences already exist in Pandoc, djot, MyST, and remark-directive. Markset adds a vocabulary on top; it does not add a fourth spelling.

2. Grammar

2.1 Attribute specifier

{#id .class .other key=value key2="quoted value"}

Parse result. The AST node is { "type": "attributes", "id": <string|null>, "classes": [<string>...], "attrs": { <key>: <value> } }. All four fields are always present.

Diagnostics. A malformed item produces one diagnostic, the parser skips to the next whitespace or }, and parsing continues, so one specifier can report several problems. All are errors.

Code Trigger
ATTR_UNTERMINATED No closing } before end of input
ATTR_UNEXPECTED_CHAR Item begins with a character that cannot start an item, such as = or "
ATTR_BAD_IDENTIFIER Id, class, or key name does not match the identifier grammar, including an empty name after # or .
ATTR_EXPECTED_EQUALS A key is not followed by =
ATTR_EMPTY_VALUE key= with no value
ATTR_BAD_VALUE A bare value contains a quote character, or text follows a quoted value's closing quote
ATTR_UNTERMINATED_QUOTE A quoted value has no closing " before end of input. Quoted values may contain }, so this consumes the rest of the input and ATTR_UNTERMINATED is not also reported
ATTR_BAD_ESCAPE A backslash in a quoted value is followed by anything other than " or \
ATTR_RESERVED_KEY The key is id or class

Generated heading ids. A renderer producing HTML should give every heading that has no id of its own a generated one, so a document's sections can be linked without the consumer post-processing the output. An explicit #id always wins, and an id already present anywhere in the document is never generated a second time. The exact spelling is reference output rather than normative (§7), and a generated id is not bound by the identifier grammar above: the reference implementation lowercases the heading's text, drops emphasis and code markers, collapses each run of characters that is not a Unicode letter or digit to one hyphen, falls back to section when nothing is left, and appends -2, -3 and so on to resolve collisions in document order. A card's title and a tab's label are inline content on their construct rather than headings, so they are not given ids; the construct takes one from its own specifier.

2.2 Bracketed span (inline)

[text]{.badge tone=warn}

Applies attributes to an inline run. This is the only inline construct in v0 — there are no inline directives. Anything you would express as :name[text]{} is expressed as a span with a reserved class instead.

Grammar. [, inline content, ], then immediately {, an attribute specifier, }. The text may contain nested brackets and a backslash-escaped ], and may span lines within a paragraph; the specifier must close on the same line it opens, and a } inside a quoted value does not close it. If ] is not immediately followed by {, or the specifier never closes, the brackets are ordinary CommonMark (a link, a reference, or literal text) and no diagnostic is reported. Images take no attribute specifier in v0.

Parse result. { "type": "span", "attributes": <attributes node>, "children": [<inline>...] }.

HTML: <span class="ms-span <classes>" id="..." data-<key>="<value>">. Every key=value becomes a data- attribute; spans have no schema.

Diagnostics. ATTR_ codes from the specifier, reported with document offsets.

2.3 Block directive

:::name{#id .class key=value}
content
:::

Fence lines. A line whose content, after at most three spaces of indentation, begins with three or more : is a fence line. If only whitespace follows the colons it is a closing fence; otherwise it is an opening fence. The name, argument, and attribute specifier follow one another with no whitespace between them, and only whitespace may follow the last part. The name is an identifier (§2.1) and must be one of the block directive names in §4: card, grid, columns, tabs, steps, metrics, figure. The argument is raw source text up to the matching ]; brackets nest, and a backslash-escaped ] does not close it. It is parsed as inline content later.

Line parse result. An opening fence is { "type": "directive-open", "fence": <int>, "name": <string|null>, "argument": <string|null>, "attributes": <attributes node> }; name is null only when the line is malformed, argument is null when no [...] is present, and attributes is always present, empty when no specifier is written. A closing fence is { "type": "directive-close", "fence": <int> }.

Blocks. An opening fence starts a container whose content is parsed as a sequence of blocks, so directives nest and may hold lists, tables, code, or other directives. The container ends at the first closing fence of at least the opening length, or with the enclosing block: a blockquote, a list item, or the document. Directives may interrupt a paragraph, like fenced code. A closing fence inside content closes the innermost open directive regardless of what else is open, so a code block or nested directive that itself contains a ::: line needs a longer outer fence, as with code fences. A malformed opening fence still opens a container, so its content is parsed and reported rather than lost.

Document parse result. { "type": "directive", "name": <string|null>, "argument": [<inline>...] | null, "attributes": <attributes node>, "children": [<block>...] } as a block node in an mdast tree. The argument is parsed as inline content; when it is unterminated it is null.

Diagnostics. Errors from an embedded attribute specifier are reported with their ATTR_ codes. The fence line itself adds:

Code Trigger
DIRECTIVE_MISSING_NAME Opening fence with no name, such as ::: {.foo} or :::[Title]
DIRECTIVE_BAD_NAME Name does not match the identifier grammar
DIRECTIVE_UNKNOWN_NAME Name is not in the closed vocabulary; col is a separator name, not a block name
DIRECTIVE_UNTERMINATED_ARGUMENT [ with no matching ] on the line
DIRECTIVE_TRAILING_CONTENT Anything but whitespace after the last part, including a space before [ or {

The document parser adds two warnings:

Code Trigger
DIRECTIVE_UNCLOSED No closing fence before the enclosing block ended; the directive was closed implicitly
DIRECTIVE_STRAY_FENCE A line of three or more colons with no open directive to close. It is ordinary paragraph text

2.4 Separator directive

::name{attrs}

A line of exactly two : plus a name. It does not open a block; it separates sibling regions inside a parent directive that declares separator children. In v0 only ::col exists, inside :::columns. Encountered anywhere else, it is a validation error.

This exists so that columns doesn't require four levels of fencing to express two columns.

Separator lines. A line whose content, after at most three spaces of indentation, is exactly two : immediately followed by an identifier, an optional attribute specifier, and nothing else but whitespace. A line beginning with :: that is not followed by an identifier is ordinary text, not a separator. Separators take no bracketed argument.

Parse result. { "type": "separator", "name": <string>, "attributes": <attributes node> }.

A separator may interrupt a paragraph. In a document it is a block node with the same shape as the line parse result, minus nothing: { "type": "separator", "name": <string>, "attributes": <attributes node> }.

Diagnostics. DIRECTIVE_UNKNOWN_NAME when the name is not col, DIRECTIVE_TRAILING_CONTENT for anything but whitespace after the last part, and ATTR_ codes from the specifier. A separator whose parent is not a columns directive, including one nested inside a card inside columns, is the error SEPARATOR_OUTSIDE_PARENT, reported by the document parser rather than the line grammar.

2.5 Attribute line

{.lead}
Opening paragraph, larger.

{#pricing .striped}
| Plan | Price |
|------|-------|

A line holding nothing but an attribute specifier attaches its attributes to the block that starts on the very next line. This is djot's block attribute, and it is how the reserved classes in §5 reach paragraphs, headings, lists, tables, code blocks, and blockquotes, none of which have a specifier of their own.

Parse result. The target node gains attributes: <attributes node>; the attribute line itself does not appear in the tree. For a callout the field sits on the callout node.

HTML. id, class, and data-<key> on the block's element: <p class="lead">, <h2 id="pricing">, <ul>, <li>, <table>, <blockquote>, <hr>, and for a fenced code block the <code> element. Inside grid and steps a list item's attributes go on its ms-grid-item or ms-step element, after the ms- class.

Downgrade. Dropped. Naive output. The line merges into a following paragraph as visible text, or stands as a one-line paragraph before any other block; nothing is lost.

3. The degradation contract

Every construct defines two fallbacks. Both are normative and both are covered by the conformance suite.

Downgrade output. The result of running the document through the downgrade renderer, which emits plain CommonMark. This is what you get when publishing to a target that has never heard of Markset. The mapping is specified per-construct in §4. In addition: a bracketed span becomes its text with the attributes dropped; frontmatter is kept; a directive that failed validation contributes its content without the fence; a misplaced ::col contributes nothing. The renderer's serialization choices are fixed so the suite can pin output: - bullets, * for emphasis and strong, fenced code, and GFM tables padded to column width.

Naive output. What a stock CommonMark parser produces from raw Markset source, with no Markset support at all. The spec does not fix the exact output, but requires:

The practical test, and the one worth putting in the README: paste a Markset document into a GitHub comment. It should read as a slightly noisy but complete document, never as syntax soup.

The contract holds because a stock parser reads an opening fence as a paragraph, and bullet lists, ordered lists starting at 1, fenced code, ATX headings, tables, and blockquotes all interrupt a paragraph. Three blocks do not: an ordered list starting at another number, indented code, and a setext heading. Placed directly after an opening fence they merge into it and the block is lost. The parser reports this as the warning DEGRADATION_BLANK_LINE; a blank line after the fence fixes it. A closing fence directly after a list, paragraph, or table joins that block as a lazy line or a final table row, which is visible text and allowed; authors who care about the naive rendering leave a blank line before the closing fence too.

Verified on GitHub, 2026-09-14. examples/showcase.md pasted into a GitHub comment kept every block with its type in source order. Observed noise, all permitted: fence lines and ::col as one-line paragraphs, a closing fence after a table as an extra row, a closing fence after a list as a lazy line in the last item, span attributes as literal text, and, in comments only, frontmatter as a setext heading (repository file views render it as frontmatter). Callouts with a title or fold suffix render as plain blockquotes with the marker visible, because GitHub's own alert syntax is the bare marker (§4.1).

The conformance harness checks the naive contract mechanically on every document case: the sequence of list, table, code, heading, blockquote, and thematic-break blocks in the Markset tree must appear, in order, in a stock CommonMark parse of the same source (GitHub-flavored tables and frontmatter enabled). Cases that document a DEGRADATION_ warning are exempt.

4. Core constructs

Eight block constructs. HTML output uses the ms- class prefix throughout.

HTML conventions. Every construct element carries its ms- class first, then the author's classes in source order; #id becomes the id attribute. Semantic variants are data- attributes. A renderer emits the accessibility information it can derive without the author writing anything — a table header cell takes scope="col", a callout takes role="note" named by its title, a tab panel is named by its label — and emits none that it cannot keep true. Block children are separated by newlines exactly as the CommonMark reference output does, and a non-empty document ends with a newline. Raw HTML in the source is never passed through. A directive that failed validation renders its content with no wrapper.

Normalization. The parser replaces each generic directive with a typed construct node whose type is the construct name, with attributes decoded into typed fields (§7 shows the shape). Every directive-based construct carries id (string or null) and classes (array) from its attribute specifier. An unknown attribute key is the warning DIRECTIVE_UNKNOWN_ATTRIBUTE and is dropped; a value that fails to parse is the error DIRECTIVE_INVALID_ATTRIBUTE and the default is used. Boolean attributes are written key=true or key=false. A directive whose content fails validation is left as a generic directive node so nothing is lost, and the construct's content error is reported. Callouts have no attribute specifier.

4.1 callout

Adopts GitHub/Obsidian alert syntax unchanged rather than introducing a directive. This is the single most widely rendered rich-Markdown construct in existence; competing with it would be pure loss.

> [!WARNING] Breaking change in v3
> The `render()` signature changed.

> [!NOTE]-
> Collapsed by default.

AST: { "type": "callout", "kind": "WARNING", "title": [<inline>...] | null, "fold": "closed" | "open" | null, "children": [<block>...] }.

HTML: <div class="ms-callout" data-type="warning" role="note" aria-labelledby="<title id>"> containing <div class="ms-callout-title" id="<title id>"> and <div class="ms-callout-body">. The title id is the callout's own id with -title appended, or ms-callout-<n>-title numbered per document. A callout is ancillary to the main content, which is what note says, and its title is its name. When a fold indicator is present, emit <details class="ms-callout"> with a <summary class="ms-callout-title"> instead, open for +, so folding works without JavaScript; the folding form takes no role, because on a <details> a role would replace the disclosure semantics, which tell a reader something more useful. A callout without a title shows its type name, title-cased, as the title.

Downgrade: a blockquote whose first paragraph is **Warning:** <title> (the type name, title-cased, followed by the title if any), followed by the body blocks. The fold indicator is dropped.

4.2 card

:::card[Pricing tiers]{tone=info}
Content here.
:::
Attribute Values Default
tone neutral info success warn danger neutral
compact boolean false

Any block content is allowed, including nothing.

AST: { "type": "card", "title": [<inline>...] | null, "tone": "info", "compact": false, "id": null, "classes": [], "children": [<block>...] }.

HTML: <section class="ms-card" data-tone="info"> with an optional <h3 class="ms-card-title"> first. compact=true adds a bare data-compact attribute.

Downgrade: the argument becomes a heading one level below the current section depth, capped at six; the body follows as ordinary blocks. The current section depth is that of the most recent heading in the output, including headings produced by earlier cards and tabs, and zero before any heading. A card without a title contributes only its body.

4.3 grid

A list whose items become cards.

:::grid{cols=3 gap=md}
- **Fast** — sub-50 ms cold start
- **Small** — 4 kB gzipped
- **Typed** — no `any` in the public API
:::
Attribute Values Default
cols 14 2
gap sm md lg md

The content must be exactly one list, ordered or unordered. Any other content, including no content, is the error GRID_CONTENT — this is what guarantees the degradation. Renderers reduce cols responsively at narrow widths; the exact breakpoints are implementation-defined.

AST: { "type": "grid", "cols": 3, "gap": "md", "id": null, "classes": [], "children": [<list>] }. The list is kept as-is; renderers treat its items as the grid items.

HTML: <div class="ms-grid" data-cols="3" data-gap="md"> with one <div class="ms-grid-item"> per list item, holding that item's blocks (a tight item's text is wrapped in <p>). An item's own attributes (§2.5) go on its ms-grid-item.

Downgrade: the list, unchanged.

4.4 columns and ::col

::::columns{ratio="2:1"}
The main argument, at full paragraph length.

::col

:::card{tone=neutral}
A supporting sidebar.
:::
::::
Attribute Values Default
ratio colon-separated integers, one per column equal
gap sm md lg md

The first column is implicit — content before the first ::col belongs to it. If ratio is present, its term count must equal the column count; otherwise it is the error COLUMNS_RATIO_MISMATCH and the ratio is dropped. A columns with no ::col, including an empty one, is the warning COLUMNS_SINGLE. A ::col may carry #id and .class; other keys warn.

AST: { "type": "columns", "ratio": [2, 1] | null, "gap": "md", "id": null, "classes": [], "children": [{ "type": "column", "id": null, "classes": [], "children": [<block>...] }...] }.

HTML: <div class="ms-columns" data-gap="md" style="--ms-ratio: 2fr 1fr"> with <div class="ms-column"> children. The ratio is emitted as a custom property in fr units, ready for grid-template-columns, because it is genuinely per-instance data; figure width (§4.8) is the only other place a style attribute appears.

Downgrade: columns emitted sequentially, separated by nothing. Reading order is the source order, which is why authors must put the primary column first.

4.5 tabs

Headings become tab labels.

:::tabs
### macOS
`brew install markset`

### Windows
`winget install markset`
:::

AST: { "type": "tabs", "active": 1, "id": null, "classes": [], "children": [{ "type": "tab", "depth": 3, "label": [<inline>...], "children": [<block>...] }...] }.

HTML: <div class="ms-tabs"> containing, in order: one <input class="ms-tab-input" type="radio"> per tab sharing a name, the active one checked; a <div class="ms-tablist"> of <label class="ms-tab" id=... for=...> elements; and one <div class="ms-tabpanel" data-index="n" aria-labelledby="<label id>"> per tab. The shared name is the tabs' id when given, else ms-tabs-<n> numbered per document; input ids append -<index> and label ids append -label to the input id.

No tab roles, deliberately. The ARIA tablist pattern requires aria-selected to follow the active tab. That is dynamic state, it cannot be maintained without a script, and a tablist whose aria-selected is frozen at its initial value tells assistive technology something false — worse than radio buttons, which are at least honestly described. Invariant 4 puts a script out of reach, so the roles stay off and each panel is named after its label instead. Inactive panels are hidden with display: none, which removes them from the accessibility tree, so only the visible panel is announced.

Downgrade: the headings and their content, unchanged. This is the cleanest degradation in the vocabulary and the reason tabs use headings rather than a ::tab separator.

4.6 steps

:::steps
1. Install the CLI.
2. Run `markset init`.
3. Edit `markset.toml`.
:::

Content must be exactly one ordered list; anything else is the error STEPS_CONTENT. Nested blocks inside list items are permitted.

AST: { "type": "steps", "id": null, "classes": [], "children": [<list>] }.

HTML: <ol class="ms-steps"> with <li class="ms-step">, keeping the list's start when it is not 1 and rendering tight and loose items as CommonMark does. An item's own attributes (§2.5) go on its ms-step.

Downgrade: the list, unchanged.

4.7 metrics

:::metrics
| Metric  | Value | Δ     |
|---------|-------|-------|
| Revenue | $4.2M | +12%  |
| Churn   | 2.1%  | -0.4% |
:::

Content must be exactly one table with at least two columns; anything else is the error METRICS_CONTENT. Column roles are positional: label, value, and an optional delta. A delta cell beginning with + or - gets a direction attribute; direction=inverse flips which sign reads as positive, for metrics like churn where down is good.

Attribute Values Default
direction normal inverse normal

AST: { "type": "metrics", "direction": "normal", "id": null, "classes": [], "children": [<table>] }.

HTML: <div class="ms-metrics"> with one <div class="ms-metric"> per body row (the header row is not rendered), each holding <div class="ms-metric-label">, <div class="ms-metric-value">, and, when the row has a third cell, <div class="ms-metric-delta">. data-direction="up|down" is set on the metric only when the delta begins with + or -, after applying direction=inverse.

Downgrade: the table, unchanged.

4.8 figure

:::figure[Request lifecycle from ingress to response]{#fig-lifecycle width=60%}
![](lifecycle.svg)
:::

The argument is the caption. width accepts a percentage from 1% to 100% only — no pixels, no absolute units, because the same source has to typeset to print. Content must be exactly one block: a paragraph holding a single image, a table, or a code block. Anything else is the error FIGURE_CONTENT.

AST: { "type": "figure", "caption": [<inline>...] | null, "width": 60 | null, "id": "fig-lifecycle", "classes": [], "children": [<block>] }.

HTML: <figure class="ms-figure" id="fig-lifecycle" style="--ms-width: 60%"> with <figcaption>. An image paragraph is unwrapped so the <img> is a direct child; the width custom property is omitted when width is not set. When the content is a table the caption is emitted as the table's own <caption>, its first child, rather than a sibling <figcaption>: <caption> is the table's accessible name and is announced as one, and a caption beside a table is not. Renderers are expected to place it visually below the table.

Downgrade: the content followed by an italic caption paragraph, or the content alone when there is no caption.

5. Reserved classes

Available on spans (§2.2), on directives through their specifier (§2.3), and on any other block through an attribute line (§2.5). These are the reason v0 needs no directives for typographic variation. A reserved class used outside its "Applies to" column is the warning CLASS_MISAPPLIED.

Class Applies to Meaning
.lead paragraph Opening paragraph, larger
.small span, paragraph De-emphasized fine print
.eyebrow paragraph Short label above a heading
.badge span Inline pill
.muted any Reduced emphasis
.info .success .warn .danger .neutral any Tone, equivalent to tone=

Themes may define additional classes. Unknown classes pass through to HTML without error.

6. Theme tokens

Document-level design intent lives in frontmatter. Token names are normative; how a renderer maps them to output is implementation-defined.

---
markset: 0
theme:
  preset: editorial       # editorial | technical | deck | report
  accent: "#2563eb"
  density: comfortable    # compact | comfortable | spacious
  radius: md              # none | sm | md | lg
  type:
    body: "Source Serif 4"
    heading: "Inter"
    scale: 1.25
---

The markset: key declares the spec version and is the recommended trigger for treating a .md file as Markset. A document with no theme block must still render well; presets carry the load and per-token overrides are the exception.

Grammar. A document that begins with a --- line has YAML frontmatter up to the next --- line. This is the one place Markset departs from CommonMark, where those lines would be a thematic break; it matches GitHub's file view, Jekyll, Hugo, and Pandoc. Contexts without frontmatter support, such as GitHub comments, show the block as a heading or paragraph; nothing is lost. Frontmatter is read as a mapping of block maps, sequences, and scalars; anchors, tags, and flow maps are not interpreted. Keys other than markset and theme belong to the document and are never validated.

Token Values
markset 0
theme.preset editorial technical deck report
theme.accent hex color, #rgb or #rrggbb
theme.density compact comfortable spacious
theme.radius none sm md lg
theme.type.body, theme.type.heading font family name
theme.type.scale number from 1 to 2

Theme stylesheets. Author classes on spans, blocks, and list items are the document's half of a contract whose other half is a stylesheet. No token names that stylesheet: a document that pointed at one would render differently depending on whether the file traveled with it, which is the portability failure Markset exists to avoid. The stylesheet is chosen where the document is rendered. A renderer that produces pages should accept a theme stylesheet and apply it after its default styles; the reference CLI does so with markset html --theme <file>. A document still renders acceptably without its theme, because the constructs are styled by the default stylesheet and unknown classes are inert.

Color scheme. There is no theme token for light or dark, and there will not be one. Which colors a reader sees is not authorial intent, so a document that forced a scheme would be making a decision that belongs to the reader — invariant 1 rules it out for the same reason it rules out a hex value in source. A renderer, or the chrome around a document, may still force one on the reader's behalf: the default stylesheet resolves every color through color-scheme, and data-scheme="light" or data-scheme="dark" on <body> sets it. With no attribute the document follows the reader's system preference. Printing forces light regardless.

Parse result. The frontmatter block stays in the tree as a yaml node with its raw text. When present, the root node also carries frontmatter: { "markset": 0 | null, "theme": <theme> | null }, where <theme> has every token from the table with null for those not set. Absent frontmatter leaves the root without the field.

Diagnostics.

Code Severity Trigger
FRONTMATTER_UNPARSEABLE warning The block is not readable as YAML in the supported subset; markset and theme are treated as absent
DOCUMENT_VERSION_UNSUPPORTED error markset is present with a value other than 0
THEME_UNKNOWN_TOKEN warning A key under theme or theme.type that is not in the table; it is ignored
THEME_INVALID_TOKEN error A token value outside its allowed set; the token is left unset

7. Conformance suite

One JSON file per section under tests/, mirroring the CommonMark spec test layout, with two extra fields. Each file is an array of cases. The normative schema is spec/conformance.schema.json; the harness validates every file against it before running anything.

{
  "section": "grid",
  "markset": ":::grid{cols=2}\n- One\n- Two\n:::\n",
  "html": "<div class=\"ms-grid\" data-cols=\"2\">...</div>\n",
  "downgrade": "- One\n- Two\n",
  "ast": { "type": "root", "children": [
    { "type": "grid", "cols": 2, "gap": "md", "id": null, "classes": [],
      "children": [ { "type": "list", "ordered": false, "start": null, "spread": false, "children": [] } ] }
  ] },
  "valid": true
}

valid: false cases carry a diagnostics array naming the expected error codes. Validation failures are a first-class part of the suite, not an afterthought — a closed vocabulary is only worth having if the errors are specified.

Field semantics:

Diagnostic codes are UPPER_SNAKE. The first segment names the spec area (ATTR_, DIRECTIVE_, GRID_...). Each diagnostic has a severity of error or warning; a document is valid when it has no errors.

What is normative. The suite has two tiers.

Every construct in §4 needs, at minimum: a canonical case, a nested case, an empty-content case, a wrong-content-type case (valid: false), and an unclosed-fence case.

8. Deferred, with reasons

Named here so they don't get relitigated during v0. The test each one was measured against: a need justifies a construct only when it cannot be met by a class, because it requires independent renderers to agree on structure rather than on appearance. A class is portable in syntax and not in meaning, which is the trade a construct exists to make; docs/future-requirements.md carries the worked cases.

9. Decisions on open questions

Recorded 2026-09-14 after the reference implementation reached the end of v0.

  1. Should grid accept nested :::card blocks? No. The one-list rule is what makes grid's downgrade trivially exact and its naive output safe, and both are now checked mechanically. Multi-block layouts use columns.
  2. Synchronized tab groups? Not in v0. Radio inputs cannot synchronize across groups without JavaScript, which invariant 4 rules out. Revisit only alongside an optional scripting layer.
  3. Activation trigger. Declared, not required. The parser treats every input as Markset and never refuses a document for its frontmatter. Editors, build integrations, and directory-scanning tools treat a .md file as Markset only when markset: is present, which protects files that use ::: for something else.
  4. Round-tripping through the downgrade renderer? No. Downgrade is lossy and clean: its output is a fixed point (downgrading it again changes nothing) and parses with no diagnostics. HTML comments would forfeit that, clutter naive rendering, and open an escaping problem. The source file is the round-trip path.
  5. Block attributes. Adopted as the attribute line in §2.5. Heading-trailing attributes in the Pandoc style are not part of v0; they can be added with the cross-reference spec (§8), backward compatibly.
  6. Attributes on one list item. Adopted 2026-09-14 as the leading attribute line of a list item (§2.5), so a single grid item or step can be singled out. The alternative, a specifier trailing the item's first line, was rejected because it would collide with bracketed spans and with ordinary text that ends in braces.
  7. Where a theme stylesheet is named. By the renderer, not the document (§6). A theme.stylesheet token was considered and rejected: a document must not depend on a file traveling with it.