diff --git a/CLAUDE.md b/CLAUDE.md
index 5cb81d332..712968331 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -366,6 +366,10 @@ The render pipeline has a **profile checkpoint** between `MetadataMergeStage` an
Q2 emits HTML **directly from the Pandoc AST** and has **no post-Pandoc DOM-mutation stage**. Quarto 1, by contrast, leans heavily on DOM postprocessors (functions that parse the rendered HTML into a `Document` and mutate it — e.g. reveal's `applyStretch`, which hoists a stretched `` to be a direct child of its ``). **When porting such a feature, re-express it as an AST transform** (operate on `Block`/`Inline` so the writer emits the right HTML the first time) rather than adding a DOM postprocessor. A DOM-postprocessor stage is a large new architectural surface with no current consumer; do not introduce one without an extremely strong, explicitly-discussed reason. Worked example: revealjs auto-stretch unwraps a `Paragraph[Image]` into a `Plain[Image]` in `crates/quarto-core/src/revealjs/auto_stretch.rs` to get `section > img.r-stretch`, instead of mutating the DOM after the fact (bd-zkstclhl).
+### Transform pipeline phases — format-agnostic semantics before format-specific presentation
+
+The single transform pipeline (`build_transform_pipeline` in `crates/quarto-core/src/pipeline.rs`) runs every output format, splicing format-specific transforms in at chosen positions. It must visit transforms in **non-decreasing phase rank**: `Normalization` → `Crossref` → `Navigation` → `Finalization`. The hard rule: **a format-specific *presentation* transform that consumes semantic structure (a float, caption, number, or resolved `@ref`) must run in `Finalization`, after `crossref-render` — never earlier.** Each `AstTransform` declares its phase via `fn phase(&self) -> TransformPhase` (defaults to `Unclassified`; every pipeline member overrides it). The invariant is enforced by `test_build_transform_pipeline_phase_ordering`, which is format-neutral (loops over format strings, no `is_revealjs` branch). When adding a new format-specific late transform, add it via a named seam like `reveal_finalization_transforms`/`footer_render_stage`, not an inline `if is_revealjs { … }` at an arbitrary position. This is why a revealjs presentation transform (auto-stretch) once silently broke crossrefs by running before they were numbered (bd-w0c6d38k). Full contract + author rule + the preview-pipeline shape contract: `claude-notes/designs/transform-pipeline-phases.md`.
+
## hub-client Commit Instructions
**IMPORTANT**: When making commits that include changes to `hub-client/`, you MUST also update `hub-client/changelog.md`.
diff --git a/claude-notes/designs/transform-pipeline-phases.md b/claude-notes/designs/transform-pipeline-phases.md
new file mode 100644
index 000000000..346534b6f
--- /dev/null
+++ b/claude-notes/designs/transform-pipeline-phases.md
@@ -0,0 +1,203 @@
+# Transform pipeline phases & the format-agnostic-first invariant
+
+**Status:** Implemented under bd-w0c6d38k (2026-06-18). `TransformPhase` and the
+defaulted `phase()` method live on `AstTransform` (`crates/quarto-core/src/transform.rs`);
+all render-pipeline transforms are classified; the invariant is enforced by
+`test_build_transform_pipeline_phase_ordering` in `pipeline.rs`. The first
+consumer of the seam is the revealjs auto-stretch move (auto-stretch now runs in
+`Finalization`, after `crossref-render`).
+
+**Audience:** anyone adding or reordering an `AstTransform` in
+`build_transform_pipeline` (`crates/quarto-core/src/pipeline.rs`), and especially
+anyone adding a new output format (`dashboard`, `typst`, `pdf`, …) with its own
+format-specific transforms.
+
+---
+
+## Why this exists
+
+Q2 renders every output format through **one** transform pipeline
+(`build_transform_pipeline`, `pipeline.rs:1078`), with format-specific transforms
+spliced in at chosen positions (today via inline `if is_revealjs { … }`). The
+pipeline mixes two fundamentally different kinds of work:
+
+1. **Format-agnostic semantic normalization** — establishing *what the document
+ means*: callouts, theorems, proofs, floats (Figure/Table), equations, and the
+ crossref index/resolve that numbers them and rewrites `@refs`.
+2. **Format-specific presentation** — reshaping the resolved document for *one*
+ output: revealjs slide construction, single-image auto-stretch, etc.
+
+These have a hard dependency direction: **presentation depends on semantics, not
+the other way around.** A revealjs transform that hoists a `Figure` to a bare
+`` is only correct *after* that `Figure` has been recognized, numbered, and
+resolved as a crossref float.
+
+When that direction is violated, the failure is silent and format-specific. The
+motivating incident (bd-w0c6d38k): `RevealAutoStretchTransform` (`pipeline.rs:1148`)
+ran *before* `FloatRefTargetSugarTransform` (`:1166`) and the crossref phase
+(`:1170`). On a single-image slide it hoisted `{#fig-1}` (a Pandoc
+`Figure`) into a bare `Plain[Image]` before the float was ever registered — so
+`@fig-1` rendered unresolved and a sibling figure was mis-numbered. Every test
+passed; only `format: revealjs` was affected, and only for that figure syntax.
+
+---
+
+## The phase model
+
+Every transform belongs to exactly one phase. Phases are **totally ordered**;
+their order is the contract.
+
+| rank | `TransformPhase` | purpose | examples |
+|------|------------------|---------|----------|
+| 1 | `Normalization` | format-agnostic semantic sugar **and** format-specific *structural scaffolding* that does not consume crossref semantics | `callout`, `theorem-sugar`, `proof-sugar`, `float-ref-target-sugar`, `equation-label`, `metadata-normalize`, `footnotes`, `example-embed`; **`reveal-columns`, `reveal-slides`, `reveal-footnotes`, `reveal-footer-alias`** |
+| 2 | `Crossref` | assign section-scoped numbers; rewrite `@refs` to custom nodes | `crossref-index`, `crossref-resolve` |
+| 3 | `Navigation` | TOC / navbar / sidebar / page-nav / footer / listings generate + render | `toc-*`, `navbar-*`, `sidebar-*`, `footer-generate`, `listing-*` |
+| 4 | `Finalization` | render custom nodes to writer-visible shapes, then format presentation that *consumes* those shapes, then resource/attribution baking | `link-rewrite`, `appendix-structure`, **`crossref-render`**, **`reveal-auto-stretch`** (after the move), `code-block-render`, `resource-collector`, `attribution-render` |
+
+Two subtleties this model encodes:
+
+- **Crossref spans two phases.** Index + resolve happen in `Crossref` (rank 2);
+ the conversion of crossref custom nodes into real `Figure`/`Div`/`Span`/`Link`
+ shapes happens in `crossref-render` (`pipeline.rs:1259`), which is `Finalization`
+ (rank 4). This is *why* a presentation transform that needs the final `Figure`
+ (auto-stretch) belongs in `Finalization`, after `crossref-render` — not in
+ `Crossref`.
+- **Not all format-specific transforms are late.** `reveal-slides`/`reveal-columns`
+ build the `` scaffolding from raw blocks and metadata; they do **not**
+ read or mutate floats/captions/numbers, so they correctly live in
+ `Normalization`. The distinction is *what a transform consumes*, not merely that
+ it is format-specific (see the author rule below).
+
+---
+
+## The invariant
+
+> **Format-agnostic semantic structure is fully established before any transform
+> that consumes it.** Concretely, in the built pipeline the transforms' phase
+> ranks are **non-decreasing by position** (a transform never precedes one of a
+> lower rank).
+
+Because `crossref-render` is `Finalization` and a presentation transform that
+hoists a crossref float is also `Finalization`, the rank order alone forbids the
+bug: a `Finalization` transform cannot sit among `Normalization` transforms ahead
+of the `Crossref` phase.
+
+### Author rule (which phase do I pick?)
+
+When adding a format-specific transform, ask: **does it read or mutate a structure
+produced by the Crossref phase — a float, a caption, a number, or a resolved
+`@ref`?**
+
+- **Yes** → phase `Finalization`, and it must run *after* `crossref-render`. (This
+ is auto-stretch.)
+- **No, it only builds format scaffolding from raw blocks/metadata** → phase
+ `Normalization` is acceptable. (This is `reveal-slides`.)
+
+If unsure, choose `Finalization`. Running a presentation transform too late is at
+worst a missed optimization; running it too early silently corrupts semantics.
+
+---
+
+## Enforcement
+
+### `phase()` on the `AstTransform` trait
+
+`AstTransform` (`crates/quarto-core/src/transform.rs:69`) gains a method:
+
+```rust
+fn phase(&self) -> TransformPhase { TransformPhase::Unclassified }
+```
+
+It **defaults to `Unclassified`** rather than being required-with-no-default. The
+reason is honesty: a handful of `AstTransform` impls are *not* part of the ordered
+multi-format render pipeline — engine-stage transforms (`JupyterTransform`), the
+stage-level `AttributionGenerateTransform`, and in-module test doubles. Forcing
+them to fabricate a render-pipeline phase would be a lie. Instead, `Unclassified`
+is the honest default for "not a member of the ordered pipeline," and **every
+transform that actually appears in `build_transform_pipeline` overrides it** with
+a real phase. The anti-drift guarantee is preserved by the test, not the compiler:
+the ordering test **fails if any transform in the built pipeline reports
+`Unclassified`** (exhaustiveness), so a new pipeline transform that forgets to
+classify itself is caught loudly. This mirrors the existing deny-list-name
+validator (`pipeline.rs:1344`), which is likewise a test, not a compiler check.
+
+Cost: one extra vtable slot per transform *type* (a fn pointer in static rodata);
+**zero per-instance memory and zero render-time cost** — `phase()` is called only
+by the ordering test, never on the render hot path. (`is_format_specific()` may be
+added later as a sibling method if a real consumer appears; the ordering invariant
+needs only `phase()`.)
+
+`TransformPhase` derives `PartialOrd`/`Ord` from declaration order; `Unclassified`
+is declared **last** so a leaked value sorts high, though the exhaustiveness check
+rejects it before the monotonicity comparison runs.
+
+### The ordering-invariant test
+
+A test over the **real** `build_transform_pipeline` (the analogue of the existing
+*analysis*-pipeline test, `pipeline.rs:1851`) that:
+
+1. builds the pipeline for **each supported format string** (`html`, `revealjs`,
+ and `dashboard`/`typst`/`pdf` as they land — a list, not an `is_revealjs`
+ branch, so new formats are covered without new assertions);
+2. reads each transform's `phase()`;
+3. asserts ranks are **non-decreasing by position**;
+4. on failure, names the offending transform and its neighbours.
+
+This test **fails on today's code** (auto-stretch is `Finalization`-rank sitting
+before the `Crossref` phase) and **passes after auto-stretch moves** past
+`crossref-render` — so it is simultaneously the red/green TDD test for the
+structural fix and the permanent guardrail. It is format-neutral by construction:
+it never mentions `revealjs`, only phases.
+
+---
+
+## The preview-pipeline shape contract (the anti-recurrence rule)
+
+The bug had a deeper enabler worth stating as its own rule.
+
+The preview pipeline (`build_q2_preview_transform_pipeline`, `pipeline.rs:1387`) is
+**the render pipeline minus a deny-list** (`Q2_PREVIEW_TRANSFORM_EXCLUDED`,
+`:1348`), which removes `"crossref-render"` (`:1374`) so crossref custom nodes
+survive for React. That subtraction means a crossref float has **different
+concrete AST shapes in the two pipelines** after the crossref phase: a real
+`Figure` in native render, a `CustomNode("FloatRefTarget")` in preview. Faced with
+that divergence, the original author placed auto-stretch *before* the sugar
+transforms — the only point where both pipelines still show a plain `Figure` — and
+accepted not stretching crossref figures. That early placement is what created the
+ordering inversion.
+
+**Rule:** *removing (or adding) a transform in the preview deny-list must not
+create a post-crossref AST-shape divergence that forces a presentation transform
+to run earlier in the render pipeline.* If preview and render must differ in
+post-crossref shape, the presentation transform must handle **both** shapes (e.g.
+recognize the custom-node form), or the preview must be brought back onto the
+common shape — never the reverse, where the render pipeline contorts to match
+preview's reduced shape. Tracking the preview's own crossref story is bd-zecehtnc.
+
+---
+
+## Worked example: the auto-stretch inversion
+
+| | before (buggy) | after (fixed) |
+|---|---|---|
+| `reveal-auto-stretch` declared phase | `Finalization` | `Finalization` |
+| its position | `pipeline.rs:1148`, **before** `Crossref` (`:1170`) | after `crossref-render` (`:1259`) |
+| `{#fig-1}` at that point | a plain `Figure` → hoisted to bare ``; never registered as a float | a fully-rendered `Figure(id=fig-1)` with caption "Figure 1: …" → hoisted to `section > img.r-stretch` + `
` |
+| ordering test | **fails** (rank 4 before rank 2) | **passes** (ranks non-decreasing) |
+| `@fig-1` in output | unresolved `?fig-1?` | `Figure 1` |
+
+---
+
+## References
+
+- Pipeline: `crates/quarto-core/src/pipeline.rs` — `build_transform_pipeline`
+ (`:1078`), phase headers (`NORMALIZATION` `:1090`, `CROSSREF` `:1169`,
+ `NAVIGATION` `:1173`, `FINALIZATION` `:1249`), `crossref-render` (`:1259`),
+ `Q2_PREVIEW_TRANSFORM_EXCLUDED` (`:1348`), preview builder (`:1387`), existing
+ analysis-pipeline ordering test (`:1851`), `footer_render_stage` format seam
+ (`:1070`).
+- Trait: `crates/quarto-core/src/transform.rs:69` (`AstTransform`).
+- Auto-stretch transform + its ordering rationale: `crates/quarto-core/src/revealjs/auto_stretch.rs`.
+- Plan: `claude-notes/plans/2026-06-18-revealjs-crossref.md` (bd-w0c6d38k);
+ follow-ups bd-4ly7ne01 (bare-table desugar), bd-zecehtnc (preview crossref).
+- Crossref design epic: `claude-notes/plans/2026-04-15-crossref-design.md` (bd-jsbg).
diff --git a/claude-notes/plans/2026-06-18-revealjs-crossref.md b/claude-notes/plans/2026-06-18-revealjs-crossref.md
new file mode 100644
index 000000000..a83de6978
--- /dev/null
+++ b/claude-notes/plans/2026-06-18-revealjs-crossref.md
@@ -0,0 +1,490 @@
+# Cross-references in `format: revealjs`
+
+**Braid strand:** bd-w0c6d38k (related: bd-jsbg crossref epic, bd-zkstclhl reveal auto-stretch)
+**Sub-strands:** bd-4ly7ne01 (Bug B: bare-table desugar, format-agnostic),
+bd-zecehtnc (WASM/interactive-preview crossref support: `q2 preview` + hub-client)
+**Created:** 2026-06-18
+**Status:** Design — decisions taken 2026-06-18 (see "Decisions"), iterating before implementation
+
+---
+
+## Overview
+
+Audit and fix cross-reference **computation, resolution, and rendering** under
+`format: revealjs`, so that all three crossref categories work:
+
+- **floats** — Figure, Table
+- **blocks** — Theorem, Lemma, … (and Proof-likes)
+- **inlines** — Equation
+
+The trigger was `examples/presentations/14-crossrefs/slides.qmd`, where `@fig-1`
+renders unresolved (`?fig-1?`) and `@fig-2` is mis-numbered.
+
+This is partly a *pipeline-ordering / format-composition* problem: a
+revealjs-only AST transform (`RevealAutoStretchTransform`) runs **before** the
+crossref phase and destroys crossref float targets. The footer-rendering work
+(`footer_render_stage`, bd-n2w0sxgd) is the precedent for reasoning about which
+transforms run under which format and in what order.
+
+---
+
+## Reproduction (observed 2026-06-18)
+
+### A. revealjs is broken; HTML is fine
+
+`cargo run --bin q2 -- render examples/presentations/14-crossrefs/slides.qmd`
+emits `Warning: unresolved crossref @fig-1` and produces:
+
+```html
+
+See ?fig-1? and
+ Figure 1.
+
+
+
+
…
Figure 1: Another figure.
+```
+
+The **identical content rendered as `format: html`** resolves correctly:
+`Figure 1` / `Figure 2`, both `
`, caption `Figure 2: …`. So the
+crossref machinery is correct; the revealjs path breaks it.
+
+### B. Per-category audit in revealjs
+
+Test file `/tmp/xref-test/all.qmd` (one of each category):
+
+| ref | form | revealjs result | HTML result | verdict |
+|-----|------|-----------------|-------------|---------|
+| `@fig-1` | `{#fig-1}` (implicit Figure) | **unresolved `?fig-1?`** | `Figure 1` ✓ | **revealjs bug (A)** |
+| `@fig-plot` | `::: {#fig-plot}` (div) | `Figure 1` ✓ | `Figure 1` ✓ | works |
+| `@tbl-data` | `: cap {#tbl-data}` (pipe-table caption) | **unresolved `?tbl-data?`** | **unresolved `?tbl-data?`** | **general gap (B)** |
+| `@thm-main` | `::: {#thm-main}` | `Theorem 1` ✓ | `Theorem 1` ✓ | works |
+| `@eq-key` | `$$…$$ {#eq-key}` | `Equation 1` ✓ | `Equation 1` ✓ | works |
+
+So in revealjs today: **blocks (theorem) and inlines (equation) already work**;
+**div-form figure floats work**; the breakage is (A) attribute-form figures and
+(B) bare-table floats. (B) is not revealjs-specific.
+
+### C. Div-form floats are the robust canonical path (verified both formats)
+
+The `::: {#-…}` div form works correctly in **both HTML and revealjs**
+(`/tmp/xref-div/*.qmd`, 2026-06-18):
+
+| ref | div content | HTML | revealjs |
+|-----|-------------|------|----------|
+| `@tbl-1` | image-of-table (`` + caption) | `Table 1`, `
` ✓ |
+
+Notably the single-image `::: {#tbl-1}` slide is rendered as `
`
+and is correctly **not** stretched — auto-stretch leaves plain Divs alone (it
+only targets top-level `Paragraph[Image]`/`Figure`). This confirms the intended
+architecture: **the div/float form is canonical and uniform; the other syntaxes
+are sugar that should desugar into (or be classified like) it.** Both bugs are
+exactly the sugar forms that fail to reach that canonical path:
+
+- Bug A: `{#fig-1}` *is* classified as a float in HTML, but revealjs
+ auto-stretch destroys the `Figure` **before** classification.
+- Bug B: `: cap {#tbl-data}` (bare `Block::Table`) **never desugars** into the
+ float form at all.
+
+---
+
+## Root-cause analysis
+
+### Bug A — auto-stretch runs before the crossref phase (revealjs-specific)
+
+`build_transform_pipeline` (`crates/quarto-core/src/pipeline.rs`) order:
+
+```
+NORMALIZATION
+ …
+ RevealSlidesTransform (is_revealjs) ~1127
+ RevealFootnotesTransform (is_revealjs) ~1143
+ RevealAutoStretchTransform (is_revealjs) ~1148 ← TOO EARLY
+ ExampleEmbedTransform ~1159
+ TheoremSugarTransform / ProofSugarTransform ~1164
+ FloatRefTargetSugarTransform (Div/Figure → float) ~1166
+ EquationLabelTransform ~1167
+CROSSREF PHASE
+ CrossrefIndexTransform (number floats) 1170
+ CrossrefResolveTransform (resolve @refs) 1171
+…
+FINALIZATION
+ CrossrefRenderTransform (CustomNode → Figure) 1259
+```
+
+`{#fig-1}` parses to a Pandoc `Block::Figure` (implicit
+figure: a captioned image alone in a paragraph). On a single-image slide,
+`RevealAutoStretchTransform` (1148) hits its `Block::Figure` branch and
+**hoists** it: replaces the `Figure` with `Plain[Image]` (id transferred onto
+the ``) + a caption paragraph — see
+`crates/quarto-core/src/revealjs/auto_stretch.rs` `hoist_figure`.
+
+By the time `FloatRefTargetSugarTransform` (1166) runs, there is **no Figure**
+with id `fig-1` left to classify as a float — only a bare `Plain[Image]` that
+happens to carry the id. So:
+
+- `fig-1` is never registered in the crossref index → `@fig-1` unresolved.
+- Only `fig-2` (the `:::`-div form, which auto-stretch ignores because at this
+ point it is still a plain `Block::Div`, not a `Figure`) is counted → it
+ becomes "Figure 1".
+
+The id-transfer in `hoist_figure` was a band-aid for the *HTML anchor* (`#fig-1`
+still points somewhere) but does nothing for crossref *registration/numbering*,
+which needs the float node to survive into the crossref phase.
+
+`auto_stretch.rs` (lines 50–53) already documents this as a known divergence:
+> The *cross-referenceable* figure case (`::: {#fig-…}`) is still un-stretched on
+> single-image slides … the crossref→`Figure` conversion runs later and is
+> excluded from the preview pipeline … a documented divergence … deferred.
+
+### Bug B — bare `Block::Table` with caption-id is never a float (general)
+
+`FloatRefTargetSugarTransform` classifies only `Block::Div` and `Block::Figure`
+(`float_ref_target.rs` `classify_div` / `classify_fig`). It handles the
+`Div(#tbl-…) > Table` form, but a **bare top-level `Block::Table`** whose caption
+carries `{#tbl-data}` (the `: caption {#tbl-data}` pipe-table syntax) is never
+classified — it renders as `
` with no number and `@tbl-data`
+unresolved, in **both HTML and revealjs**. Q1 handles this in
+`parsefiguredivs.lua` (wraps caption-bearing tables into FloatRefTargets).
+
+### The format-composition tension (why we can't just reorder blindly)
+
+Auto-stretch was deliberately placed *early* so it operates on a shape common to
+**both** pipelines:
+
+- **native render** (`build_transform_pipeline`): includes
+ `CrossrefRenderTransform` → crossref floats become real `Figure`s by
+ finalization.
+- **preview** (`build_q2_preview_transform_pipeline` = full pipeline minus
+ `Q2_PREVIEW_TRANSFORM_EXCLUDED`): **excludes `"crossref-render"`** (pipeline.rs
+ line 1374) so the CustomNodes survive for React's type-specific components.
+
+Therefore a crossref float has **no single concrete shape** across both
+pipelines after the crossref phase: it is a `Figure` in native render but a
+`CustomNode("FloatRefTarget")` in preview. That is the real reason the original
+author put auto-stretch up front (where both pipelines still see a plain
+`Figure`/`Paragraph[Image]`), and accepted not stretching crossref figures.
+
+Note: `CrossrefIndexTransform` **is** included in preview — only *render* is
+excluded — so **numbering is correct in preview**; only the final-Figure DOM
+shape is absent there.
+
+---
+
+## Proposed direction (to confirm with user)
+
+The fix has three parts. Parts 1–2 are mechanical; Part 3 is the design
+decision the user flagged.
+
+### Part 1 — Bug B: desugar caption-bearing bare tables into the canonical float Div (format-agnostic)
+
+**Tracked separately as bd-4ly7ne01** (format-agnostic; affects HTML too).
+A bare `Block::Table` whose caption carries a registered ref-type id (`tbl-…`)
+should be **wrapped into a `Div(#tbl-…) > Table`** by a small desugar step, so the
+*existing* uniform `FloatRefTargetSugarTransform` `Div > [Table]` arm handles it
+— matching the "all syntaxes desugar into divs, then process uniformly" model
+rather than adding a special top-level `Table` classification arm. Q1 ref:
+`parsefiguredivs.lua`.
+
+- **Tests first:** qmd fixture in `crossref_fixtures.rs` asserting the index
+ registers `tbl-data` from `: cap {#tbl-data}`; end-to-end HTML render shows
+ `Table 1` + resolved `@tbl-data`.
+
+### Part 2 — Bug A: move revealjs float post-processing after the crossref phase
+
+Move `RevealAutoStretchTransform` so it runs **after `CrossrefRenderTransform`**
+(finalization, after line 1259 / after `ExampleEmbedRenderTransform`). Then for
+native render:
+
+1. `{#fig-1}` → `Figure` survives to `FloatRefTargetSugarTransform` →
+ numbered → resolved → rendered by `CrossrefRenderTransform` to a final
+ `Figure(id=fig-1)` with caption "Figure 1: A figure".
+2. Auto-stretch then hoists *that* Figure to `section > img.r-stretch` + a
+ `
Figure 1: A figure
`, exactly as it already does for
+ plain captioned figures.
+
+Side benefit: the `::: {#fig-…}` div form *also* becomes a real `Figure` by then,
+so auto-stretch can finally stretch div-form crossref figures too — closing the
+"still un-stretched" divergence noted in `auto_stretch.rs` for native render.
+
+Ordering constraints to preserve: auto-stretch must still run **after**
+`RevealFootnotesTransform` (coalesced aside ⇒ >1 block ⇒ skip) — satisfied, since
+that is in NORMALIZATION, far earlier. The reveal `` tree
+(`RevealSlidesTransform`) already exists by finalization.
+
+- **Tests first:** the existing `auto_stretch.rs` unit tests operate on synthetic
+ ASTs and stay valid. Add an **end-to-end** revealjs render test (via
+ `render_document_to_file` / the binary) over `14-crossrefs/slides.qmd`
+ asserting: `@fig-1` resolves to `Figure 1`, `@fig-2` to `Figure 2`, fig-1's
+ `` is a direct child of its ``, and
+ its caption reads "Figure 1: …". This is the regression the unit tests missed.
+
+### Part 3 — Preview (`q2-slides`) behavior for crossref figures
+
+**Decision (2026-06-18): option 3a for this strand, with a committed follow-up
+(bd-zecehtnc).** After Part 2, in **native render** crossref figures number,
+resolve, render, and stretch correctly. In **preview**, `crossref-render` is
+excluded, so the float stays a `CustomNode("FloatRefTarget")`; numbering is still
+correct (index runs in preview) but the final-Figure shape + auto-stretch do not
+apply there. We accept that divergence in *this* strand (it fixes the
+user-visible `q2 render` bug and matches the documented status quo), but the
+WASM-based interactive previews (`q2 preview` **and** hub-client) must support
+crossrefs well — tracked as **bd-zecehtnc**.
+
+Options considered for the eventual preview fix (decided in bd-zecehtnc):
+
+- **3b (teach auto-stretch the CustomNode form):** give auto-stretch a path that
+ also recognizes single-image `CustomNode("FloatRefTarget")` and hoists it,
+ preserving the custom node for React.
+- **3c (include crossref-render in the slides preview):** drop `"crossref-render"`
+ from `Q2_PREVIEW_TRANSFORM_EXCLUDED` for the `q2-slides` path; React then
+ receives `Figure` instead of the custom node — revisit component expectations.
+- React-side rendering of crossref CustomNodes (incl. reveal stretch) is a third
+ candidate. bd-zecehtnc will pick among these.
+
+### Part 4 — formalize "which transforms run under which format, in what order"
+
+Today reveal transforms are `pipeline.push`-ed inline under `if is_revealjs`, at
+fixed positions. The footer work introduced `footer_render_stage(is_revealjs)` as
+"the one place that maps format → render stage … the seam where a format →
+stage-sequence mapping would grow." Part 2 adds a *second* format-gated
+finalization slot (a reveal float-postprocess step after crossref).
+
+The **named helper** = the direct analogue of `footer_render_stage`: one
+documented function owning "which reveal transforms run after crossref, and
+where". Sketch:
+
+```rust
+// returns the reveal-specific finalization transforms (today: just auto-stretch)
+fn reveal_finalization_transforms(is_revealjs: bool) -> Vec> {
+ if is_revealjs { vec![Box::new(RevealAutoStretchTransform::new())] }
+ else { vec![] }
+}
+// spliced in once, right after CrossrefRenderTransform (finalization):
+for t in reveal_finalization_transforms(is_revealjs) { pipeline.push(t); }
+```
+
+It is **not** a pipeline DSL or a format→stage map — just a named, greppable
+function (CLAUDE.md forbids new architectural surface without strong reason).
+If it feels like overkill for one transform, "defer the seam" (inline push at the
+new position) is acceptable. Decision pending; default to the small helper since
+Part 2 does add a second gated slot.
+
+---
+
+## Decisions (2026-06-18)
+
+1. **Preview (Part 3):** option **3a** for this strand (native render fixed; preview
+ numbers/resolves but doesn't stretch). WASM interactive previews (`q2 preview`
+ + hub-client) must support crossrefs well — committed follow-up **bd-zecehtnc**.
+2. **Bug B (Part 1):** **split** into format-agnostic sub-strand **bd-4ly7ne01**
+ (it affects HTML too). Will be done this session or a direct follow-up.
+3. **Part 4 seam:** default to the small named helper
+ (`reveal_finalization_transforms`); finalize during implementation. Still
+ confirming whether to do it now vs. inline-push + defer.
+4. **Structural guardrail (Part 5, NEW):** add a required `phase()` method to the
+ `AstTransform` trait (`transform.rs:69`) returning a `TransformPhase` enum
+ (`Normalization` < `Crossref` < `Navigation` < `Finalization`), and a
+ **format-neutral ordering-invariant test** over the real `build_transform_pipeline`
+ asserting phase ranks are non-decreasing by position. Trait-method approach
+ chosen over a test-only table (negligible cost — one vtable slot per type, zero
+ render-time/per-instance cost; may find other uses). The test is **format-neutral**
+ (loops over supported format strings, no `is_revealjs` branch) so future formats
+ (`dashboard`, `typst`, `pdf`) are covered for free. It **fails on today's code**
+ and **passes after Part 2's reorder** — i.e. it is the red/green TDD test for the
+ structural fix as well as the permanent guardrail.
+5. **Architecture doc:** written to
+ `claude-notes/designs/transform-pipeline-phases.md` (phase model, invariant,
+ author rule, and the **preview-pipeline shape contract** — the anti-recurrence
+ rule). To be linked from `CLAUDE.md` → Architecture Notes **when the fix lands**
+ (so CLAUDE.md never points at an unenforced invariant).
+
+## Remaining checks during implementation
+
+- Are there other reveal-only transforms that mutate float/caption/identity shape
+ and would also need to move after crossref? Audit: `RevealColumns`,
+ `RevealSlides`, `RevealFootnotes`, `RevealFooterAlias`, `RevealFooterLogo` —
+ none appear to touch float identity, but confirm.
+- Confirm `CrossrefRenderTransform` renders figure-floats as `Block::Figure`
+ (so auto-stretch's `Figure` arm catches them) and table-floats as `Div` (left
+ un-stretched, matching div-form behavior verified above).
+
+---
+
+## Work items (provisional — finalize after design sign-off)
+
+- [ ] **Tests first (Part 5):** add `TransformPhase` enum + required `phase()` on
+ `AstTransform`; classify every existing transform; write the format-neutral
+ ordering-invariant test. Confirm it **FAILS** today (auto-stretch inversion).
+- [ ] **Tests first:** end-to-end revealjs render regression over `14-crossrefs`
+ (fig-1/fig-2 number + resolve + r-stretch + caption); confirm it FAILS today.
+- [ ] **Tests first:** caption-bearing bare-table float fixture (HTML), confirm FAILS.
+- [ ] Part 2: move `RevealAutoStretchTransform` to after `CrossrefRenderTransform`
+ (declared phase `Finalization`); confirm the ordering test now **PASSES**.
+- [ ] Part 1 (bd-4ly7ne01): desugar bare `Block::Table` w/ caption-id into
+ `Div(#tbl-…) > Table` so the uniform classifier handles it.
+- [ ] Part 3: implement the chosen preview behavior (default 3a).
+- [ ] Part 4 (optional): extract `reveal_finalization_transforms` seam.
+- [ ] Part 5 doc: link `transform-pipeline-phases.md` from `CLAUDE.md` →
+ Architecture Notes once the invariant is enforced.
+- [ ] Re-render `14-crossrefs/slides.qmd`; inspect HTML; record the end-to-end
+ output in this plan per CLAUDE.md "End-to-end verification".
+- [ ] `cargo nextest run --workspace`; `cargo xtask verify` (WASM leg, since
+ `quarto-core` changes affect `wasm-quarto-hub-client`).
+
+---
+
+## Part 5 sub-plan — `phase()` trait method + ordering-invariant test (IN PROGRESS)
+
+Logically first: it's the red/green TDD test for Part 2. Approach: `phase()`
+defaults to `Unclassified`; every transform in `build_transform_pipeline` overrides
+it; the test rejects `Unclassified` in the pipeline (exhaustiveness) + asserts
+non-decreasing rank by position. Design: `claude-notes/designs/transform-pipeline-phases.md`.
+
+Phase assignments (47 pipeline structs; `JupyterTransform`,
+`AttributionGenerateTransform`, test doubles stay `Unclassified`):
+
+- **Normalization:** Callout, CalloutResolve, ShortcodeResolve, MetadataNormalize,
+ CodeBlockGenerate, WebsiteTitlePrefix, WebsiteFavicon, WebsiteBootstrapIcons,
+ WebsiteCanonicalUrl, RevealColumns, RevealSlides, RevealFooterAlias, TitleBlock,
+ Sectionize, Footnotes, RevealFootnotes, ExampleEmbed, TheoremSugar, ProofSugar,
+ FloatRefTargetSugar, EquationLabel
+- **Crossref:** CrossrefIndex, CrossrefResolve
+- **Navigation:** TocGenerate, NavbarGenerate, SidebarGenerate, PageNavGenerate,
+ FooterGenerate, ListingGenerate, ListingRender, CategoriesSidebar,
+ ListingFeedStage, ListingFeedLink, TocRender, NavbarRender, SidebarRender,
+ PageNavRender, FooterRender, RevealFooterLogo
+- **Finalization:** LinkRewrite, AppendixStructure, CrossrefRender,
+ ExampleEmbedRender, CodeBlockRender, ResourceCollector, TableBootstrapClass,
+ AttributionRender, AttributionViewer, **RevealAutoStretch** (← currently
+ mis-ordered before Crossref; Part 2 moves it after CrossrefRender)
+
+### Part 5 checklist
+
+- [x] 5.1 Add `TransformPhase` enum (`Normalization` < `Crossref` < `Navigation` <
+ `Finalization` < `Unclassified`) + defaulted `phase()` on `AstTransform`
+ (`transform.rs`). Done — enum + defaulted method, doc-commented.
+- [x] 5.2 Override `phase()` on all 21 Normalization transforms.
+- [x] 5.3 Override `phase()` on the 2 Crossref transforms.
+- [x] 5.4 Override `phase()` on all 16 Navigation transforms.
+- [x] 5.5 Override `phase()` on all 10 Finalization transforms (incl.
+ RevealAutoStretch = Finalization). 49 total inserted via script keyed by
+ struct name; `quarto-core` compiles clean.
+- [x] 5.6 Wrote `test_build_transform_pipeline_phase_ordering` in `pipeline.rs`:
+ loops over `["html","revealjs"]`, asserts no `Unclassified` + non-decreasing
+ rank; failure names offender + full order.
+- [x] 5.7 Confirmed **RED**: `[revealjs] phase ordering inversion:
+ reveal-auto-stretch (Finalization) runs before example-embed (Normalization)`.
+ HTML pipeline passes (no auto-stretch). Validates all phase assignments.
+
+### Part 2 checklist (makes 5.7 green)
+
+- [x] 2.1 Added `reveal_finalization_transforms(is_revealjs)` helper (Part 4 seam,
+ analogue of `footer_render_stage`).
+- [x] 2.2 Removed `RevealAutoStretchTransform` from the early `is_revealjs` block;
+ left a NOTE pointing to its new home + the bd-w0c6d38k rationale.
+- [x] 2.3 Spliced the helper after `ExampleEmbedRenderTransform` (post
+ `CrossrefRenderTransform`) and before `CodeBlockRenderTransform`/
+ `ResourceCollectorTransform`.
+- [x] 2.4 Ordering test **GREEN** (was RED).
+- [x] 2.5 e2e regression test `revealjs_crossref_attribute_figure_resolves_and_stretches`
+ added; RED before the reorder, GREEN after. (Caption *number-prefix*
+ assertion intentionally dropped — see bd-uwv2eec2 below; ref numbering +
+ stretch + direct-section-child are asserted. Links use U+00A0.)
+- [x] 2.6 Re-rendered `14-crossrefs/slides.qmd` via `q2 render` — see results below.
+- [x] 2.7 Full `cargo nextest run -p quarto-core` → **2385 passed, 0 failed**. No
+ auto-stretch/snapshot/crossref regressions from the reorder.
+
+### Part 2 end-to-end verification (CLAUDE.md)
+
+Invocation: `cargo run --bin q2 -- render examples/presentations/14-crossrefs/slides.qmd`
+
+- **Before:** `Warning: unresolved crossref @fig-1 …` + `1 warning`.
+- **After:** `Rendered 1 of 1 files … ` — **no warning.** Inspected `slides.html`:
+ - body refs: `Figure 1` and
+ `Figure 2` (correctly numbered; was `?fig-1?` + "Figure 1").
+ - fig-1 (`![A figure]{#fig-1}`): `` direct
+ child of ``, caption `
A figure
`.
+ - fig-2 (`::: {#fig-2}`): now ALSO `r-stretch` (the predicted side benefit — a
+ div-form crossref figure on a single-image slide is stretched now that
+ auto-stretch sees the rendered `Figure`), caption `
Figure 2:
+ Another figure.
`.
+ - `` count = 0 (both hoisted).
+
+### Discovered (filed, out of scope here)
+
+- **bd-uwv2eec2** — attribute-form figure `{#fig-N}` gets **no "Figure N:"
+ caption-number prefix** (the div form does). Pre-existing crossref-render
+ asymmetry; reproduces in plain HTML (`A figure`), so it
+ is **not** caused by the reorder. Fix belongs in `crossref_render.rs`.
+
+## Part 1 sub-plan — bare-table caption desugar (bd-4ly7ne01) — DONE
+
+`: caption {#tbl-…}` parses to a **bare `Block::Table`** with the id on the
+table's own `attr` (verified via `pampa -t native`:
+`Table ("tbl-bare",[],[]) (Caption Nothing [Plain …])`). The uniform
+`classify_div`/`convert_div` path never saw it.
+
+Fix (matches "all syntaxes desugar into divs"): a `maybe_wrap_bare_table_into_div`
+step at the top of `FloatRefTargetSugarTransform::transform_block` wraps such a
+table into `Div(#tbl-…) > Table` — id (and classes/kvs) move onto the Div, the
+Table is left anonymous (no duplicate id) — so the existing `[Block::Table(_)]`
+arm of `convert_div` handles it. Integrating it inside the sugar transform means
+it runs everywhere the transform runs (full pipeline, analysis pipeline, fixtures)
+with no new registration.
+
+### Part 1 checklist
+
+- [x] 1.1 Confirm AST shape (`pampa -t native`): id on bare `Block::Table`.
+- [x] 1.2 Tests first: `fixture_bare_table_caption_target` in `crossref_fixtures.rs`
+ — confirmed **RED** (`idx.get("tbl-bare")` = None).
+- [x] 1.3 Implement `maybe_wrap_bare_table_into_div` in `float_ref_target.rs`.
+- [x] 1.4 Fixture **GREEN**; index entry `("tbl-bare","tbl",vec![],1)`.
+- [x] 1.5 End-to-end: rendered two bare-caption tables + `@tbl-data`/`@tbl-two`
+ in HTML **and** revealjs — both resolve to `Table 1`/`Table 2`, no
+ unresolved placeholders.
+- [x] 1.6 Full `cargo nextest run -p quarto-core` → **2386 passed**.
+
+## Part 3 outcome — interactive-preview crossref (bd-zecehtnc) — CLOSED (semantics done)
+
+Investigation (2026-06-18) found the interactive preview already renders crossrefs
+correctly, and Part 2 improved it:
+
+- The q2-preview React renderer (`ts-packages/preview-renderer/src/q2-preview/`)
+ has dedicated components for every crossref custom node — `custom/FloatRefTarget.tsx`,
+ `CrossrefResolvedRef.tsx`, `Theorem.tsx`, `Proof.tsx`, `Equation.tsx` — registered
+ in `registry.ts` and dispatched by `type_name` (`dispatchers.tsx:207`).
+ `FloatRefTarget` composes "Figure N:" captions; `CrossrefResolvedRef` renders
+ `{kind} {n}`.
+- **Numbering, links, captions, figure-vs-div, theorems/proofs/equations all work**
+ — 53 `custom-components.integration.test.tsx` tests pass.
+- **Part 2 also fixed preview's attribute-form figure crossref**: the
+ `{#fig-1}` figure is no longer destroyed by early auto-stretch, so it
+ becomes a proper numbered `FloatRefTarget` custom node in preview too.
+- **nbsp parity: already correct.** `CrossrefResolvedRef.tsx` already emits a
+ non-breaking space between kind and number (byte-confirmed: `…7d c2 a0 24…`);
+ the "regular space" I first reported was a terminal-rendering artifact. No fix.
+
+Decision (user): **close bd-zecehtnc as semantics-done.** The only remaining gap
+is *visual* revealjs auto-stretch parity for crossref figures in preview (a
+single-image crossref figure fills the slide in `render` but shows at natural size
+in `preview`, because the Rust auto-stretch transform skips `Block::Custom` and
+`FloatRefTarget.tsx` adds no `r-stretch`). Deferred to **bd-hbloemff** (recommended
+approach there: make `RevealAutoStretchTransform` CustomNode-aware + have
+`FloatRefTarget.tsx` honor the mark).
+
+## Reference: key files
+
+- Pipeline order: `crates/quarto-core/src/pipeline.rs:1078-1309`
+ (`build_transform_pipeline`), `:1348-1375` (`Q2_PREVIEW_TRANSFORM_EXCLUDED`),
+ `:1070` (`footer_render_stage` precedent).
+- Auto-stretch: `crates/quarto-core/src/revealjs/auto_stretch.rs` (esp.
+ `hoist_figure`, doc lines 44–67 on the crossref divergence).
+- Float sugar: `crates/quarto-core/src/transforms/float_ref_target.rs`
+ (`classify_div`/`classify_fig`; Table handling at the `[Block::Table(_)]` arm).
+- Crossref transforms: `crates/quarto-core/src/transforms/crossref_{index,resolve,render}.rs`.
+- Crossref design epic: `claude-notes/plans/2026-04-15-crossref-design.md` (bd-jsbg).
+- Q1 references: `external-sources/quarto-cli/src/resources/filters/quarto-pre/parsefiguredivs.lua`
+ (table-caption float wrapping), `.../format-reveal.ts` `applyStretch`.
diff --git a/crates/quarto-core/src/pipeline.rs b/crates/quarto-core/src/pipeline.rs
index 03bc773d1..86a1a4927 100644
--- a/crates/quarto-core/src/pipeline.rs
+++ b/crates/quarto-core/src/pipeline.rs
@@ -1075,6 +1075,32 @@ fn footer_render_stage(is_revealjs: bool) -> Box img.r-stretch`
+/// (bd-w0c6d38k). A new format that needs late, float-aware reshaping (e.g.
+/// `dashboard`, `typst`) adds its transforms here rather than inline.
+///
+/// These are `TransformPhase::Finalization` transforms; the phase-ordering
+/// invariant (`test_build_transform_pipeline_phase_ordering`) keeps them after
+/// the `Crossref` phase.
+fn reveal_finalization_transforms(
+ is_revealjs: bool,
+) -> Vec> {
+ if is_revealjs {
+ vec![Box::new(crate::revealjs::RevealAutoStretchTransform::new())]
+ } else {
+ Vec::new()
+ }
+}
+
pub fn build_transform_pipeline(
shortcode_paths: Vec,
extensions: Vec,
@@ -1139,13 +1165,16 @@ pub fn build_transform_pipeline(
// Per-slide footnote/aside coalescing consumes FootnotesTransform's
// resolved output (refs = `Span#fnrefN`, defs in the trailing
// `Div#footnotes`), so it must run *after* it. Pure AST → benefits
- // render and preview alike. See `revealjs::footnotes`.
+ // render and preview alike. See `revealjs::footnotes`. This is a
+ // `Normalization`-phase transform: it builds slide scaffolding and does
+ // not consume crossref semantics.
pipeline.push(Box::new(crate::revealjs::RevealFootnotesTransform::new()));
- // Auto-stretch single-image slides (add reveal's `.r-stretch`). Runs
- // *after* footnote/aside coalescing so a slide that gained a coalesced
- // aside has >1 body block and is correctly skipped. Default-on, gated
- // by `auto-stretch: false`. See `revealjs::auto_stretch`.
- pipeline.push(Box::new(crate::revealjs::RevealAutoStretchTransform::new()));
+ // NOTE: revealjs auto-stretch is NOT here. It is a `Finalization`-phase
+ // transform — it consumes the *rendered* `Figure` shape that
+ // `CrossrefRenderTransform` produces, so running it this early would
+ // hoist a crossref figure to a bare `` before the float is ever
+ // numbered (bd-w0c6d38k). It is spliced in after `CrossrefRenderTransform`
+ // via `reveal_finalization_transforms` below.
}
// Example-iframe embeds (bd-z1smhvuo / bd-t3cert81). Sugars
// `Div.embed-example-iframe[file=…]` into a `CustomNode("ExampleEmbed")`.
@@ -1265,6 +1294,13 @@ pub fn build_transform_pipeline(
// dispatches on FloatRefTarget/Theorem/Proof), so the node survives to
// here untouched.
pipeline.push(Box::new(ExampleEmbedRenderTransform::new()));
+ // Format-specific presentation that consumes rendered crossref shapes:
+ // revealjs auto-stretch. Runs *after* `CrossrefRenderTransform` (so a
+ // single-image `![cap]{#fig-…}` figure is numbered/resolved/rendered to a
+ // real `Figure` first) and *before* `ResourceCollectorTransform` (so the
+ // hoisted `` is still visible to resource collection). See
+ // `reveal_finalization_transforms` and bd-w0c6d38k.
+ pipeline.extend(reveal_finalization_transforms(is_revealjs));
// bd-1tl09 Phase 0: code-block decoration Render. Consumes the
// typed payload produced by `code-block-generate` in the
// Normalization Phase and emits the outer wrapping markup
@@ -2938,6 +2974,67 @@ mod tests {
);
}
+ /// Format-neutral pipeline phase-ordering invariant (bd-w0c6d38k).
+ ///
+ /// Every transform in `build_transform_pipeline` must (1) declare a real
+ /// phase — not the `Unclassified` default — and (2) appear in non-decreasing
+ /// phase-rank order. Together these forbid a format-specific *presentation*
+ /// transform (e.g. revealjs auto-stretch, a `Finalization` transform) from
+ /// running before the format-agnostic *semantic* structure it consumes (the
+ /// `Crossref` phase) is established.
+ ///
+ /// The test loops over every render format string — there is deliberately
+ /// **no `is_revealjs` branch** — so a new output format (`dashboard`,
+ /// `typst`, `pdf`, …) is covered the moment its transforms are classified,
+ /// without editing this test.
+ ///
+ /// See `claude-notes/designs/transform-pipeline-phases.md`.
+ #[test]
+ fn test_build_transform_pipeline_phase_ordering() {
+ use crate::transform::TransformPhase;
+
+ // Add new render format strings here as they land; the invariant then
+ // covers them automatically.
+ for format in ["html", "revealjs"] {
+ let runtime = make_test_runtime();
+ let pipeline = build_transform_pipeline(vec![], vec![], runtime, format.to_string());
+ let steps: Vec<(&str, TransformPhase)> =
+ pipeline.iter().map(|t| (t.name(), t.phase())).collect();
+
+ // (1) Exhaustiveness: every pipeline member must be classified.
+ let unclassified: Vec<&str> = steps
+ .iter()
+ .filter(|(_, p)| *p == TransformPhase::Unclassified)
+ .map(|(n, _)| *n)
+ .collect();
+ assert!(
+ unclassified.is_empty(),
+ "[{format}] these pipeline transforms have no phase() override \
+ (still TransformPhase::Unclassified) — classify them per \
+ claude-notes/designs/transform-pipeline-phases.md: {unclassified:?}",
+ );
+
+ // (2) Monotonicity: phase ranks must not decrease by position.
+ for win in steps.windows(2) {
+ let (prev_name, prev_phase) = win[0];
+ let (next_name, next_phase) = win[1];
+ assert!(
+ prev_phase <= next_phase,
+ "[{format}] phase ordering inversion: `{prev_name}` ({prev_phase:?}) \
+ runs before `{next_name}` ({next_phase:?}), but {prev_phase:?} \
+ ranks after {next_phase:?}. A transform that consumes semantic \
+ structure must not precede the phase that produces it. \
+ See claude-notes/designs/transform-pipeline-phases.md.\n\
+ Full order: {:?}",
+ steps
+ .iter()
+ .map(|(n, p)| format!("{n}:{p:?}"))
+ .collect::>(),
+ );
+ }
+ }
+ }
+
#[test]
fn q2_preview_pipeline_includes_code_block_decoration_transforms() {
let runtime = make_test_runtime();
diff --git a/crates/quarto-core/src/project/listing/feed/link_inject.rs b/crates/quarto-core/src/project/listing/feed/link_inject.rs
index 643389fc4..77ae61771 100644
--- a/crates/quarto-core/src/project/listing/feed/link_inject.rs
+++ b/crates/quarto-core/src/project/listing/feed/link_inject.rs
@@ -31,7 +31,7 @@ use crate::Result;
use crate::project::listing::ResolvedListing;
use crate::project::website_config::{website_site_url, website_title};
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
use crate::transforms::is_feature_disabled;
/// Pass-2 transform: append a `` to
@@ -60,6 +60,10 @@ impl AstTransform for ListingFeedLinkTransform {
"listing-feed-link"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Navigation
+ }
+
async fn transform(&self, ast: &mut Pandoc, ctx: &mut RenderContext) -> Result<()> {
if is_feature_disabled(&ast.meta, "listing") {
return Ok(());
diff --git a/crates/quarto-core/src/project/listing/feed/stage.rs b/crates/quarto-core/src/project/listing/feed/stage.rs
index de420c05a..c71abb3db 100644
--- a/crates/quarto-core/src/project/listing/feed/stage.rs
+++ b/crates/quarto-core/src/project/listing/feed/stage.rs
@@ -33,7 +33,7 @@ use crate::project::listing::config::{FeedType, ListingFeedOptions};
use crate::project::listing::item::ListingItem;
use crate::project::website_config::website_site_url;
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
use crate::transforms::is_feature_disabled;
use super::binding::{
@@ -94,6 +94,10 @@ impl AstTransform for ListingFeedStageTransform {
"listing-feed-stage"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Navigation
+ }
+
async fn transform(&self, ast: &mut Pandoc, ctx: &mut RenderContext) -> Result<()> {
if is_feature_disabled(&ast.meta, "listing") {
return Ok(());
diff --git a/crates/quarto-core/src/revealjs/auto_stretch.rs b/crates/quarto-core/src/revealjs/auto_stretch.rs
index 2ad19eaee..7af9d9fb6 100644
--- a/crates/quarto-core/src/revealjs/auto_stretch.rs
+++ b/crates/quarto-core/src/revealjs/auto_stretch.rs
@@ -75,7 +75,7 @@ use quarto_source_map::{By, SourceInfo};
use crate::Result;
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
const STRETCH_CLASS: &str = "r-stretch";
@@ -100,6 +100,10 @@ impl AstTransform for RevealAutoStretchTransform {
"reveal-auto-stretch"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Finalization
+ }
+
async fn transform(&self, ast: &mut Pandoc, _ctx: &mut RenderContext) -> Result<()> {
let auto_stretch = ast
.meta
diff --git a/crates/quarto-core/src/revealjs/columns.rs b/crates/quarto-core/src/revealjs/columns.rs
index 887fe7ae0..aad2c6b74 100644
--- a/crates/quarto-core/src/revealjs/columns.rs
+++ b/crates/quarto-core/src/revealjs/columns.rs
@@ -24,7 +24,7 @@ use quarto_pandoc_types::pandoc::Pandoc;
use crate::Result;
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
/// Transform that rewrites `.column` `width` attributes to `flex-basis`.
pub struct RevealColumnsTransform;
@@ -47,6 +47,10 @@ impl AstTransform for RevealColumnsTransform {
"reveal-columns"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Normalization
+ }
+
async fn transform(&self, ast: &mut Pandoc, _ctx: &mut RenderContext) -> Result<()> {
walk_blocks(&mut ast.blocks);
Ok(())
diff --git a/crates/quarto-core/src/revealjs/footer_logo.rs b/crates/quarto-core/src/revealjs/footer_logo.rs
index 243c708f5..cad2ba4d3 100644
--- a/crates/quarto-core/src/revealjs/footer_logo.rs
+++ b/crates/quarto-core/src/revealjs/footer_logo.rs
@@ -51,7 +51,7 @@ use quarto_source_map::{By, SourceInfo};
use crate::Result;
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
// --- config alias: footer: → page-footer: -----------------------------------
@@ -78,6 +78,10 @@ impl AstTransform for RevealFooterAliasTransform {
"reveal-footer-alias"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Normalization
+ }
+
async fn transform(&self, ast: &mut Pandoc, _ctx: &mut RenderContext) -> Result<()> {
// `page-footer:` wins if the user set both (it's the canonical key).
if ast.meta.contains_path(&["page-footer"]) {
@@ -116,6 +120,10 @@ impl AstTransform for RevealFooterLogoTransform {
"reveal-footer-logo"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Navigation
+ }
+
async fn transform(&self, ast: &mut Pandoc, _ctx: &mut RenderContext) -> Result<()> {
if !ast.meta.contains_path(&["rendered", "reveal", "footer"])
&& let Some(html) = footer_slot_html(&ast.meta)
diff --git a/crates/quarto-core/src/revealjs/footnotes.rs b/crates/quarto-core/src/revealjs/footnotes.rs
index 34d896fa5..ac848cf90 100644
--- a/crates/quarto-core/src/revealjs/footnotes.rs
+++ b/crates/quarto-core/src/revealjs/footnotes.rs
@@ -64,7 +64,7 @@ use quarto_source_map::{By, SourceInfo};
use crate::Result;
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
/// Transform that coalesces per-slide footnotes/asides for `format: revealjs`.
pub struct RevealFootnotesTransform;
@@ -87,6 +87,10 @@ impl AstTransform for RevealFootnotesTransform {
"reveal-footnotes"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Normalization
+ }
+
async fn transform(&self, ast: &mut Pandoc, _ctx: &mut RenderContext) -> Result<()> {
// Q1: `slideFootnotes = referenceLocation !== "document"`. Reveal never
// sets `reference-location`, so the default (unset) coalesces; an
diff --git a/crates/quarto-core/src/revealjs/transform.rs b/crates/quarto-core/src/revealjs/transform.rs
index f87ba303c..382a35b3b 100644
--- a/crates/quarto-core/src/revealjs/transform.rs
+++ b/crates/quarto-core/src/revealjs/transform.rs
@@ -26,7 +26,7 @@ use quarto_source_map::{By, SourceInfo};
use crate::Result;
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
use super::slides::{DEFAULT_SLIDE_LEVEL, build_reveal_slides};
@@ -51,6 +51,10 @@ impl AstTransform for RevealSlidesTransform {
"reveal-slides"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Normalization
+ }
+
async fn transform(&self, ast: &mut Pandoc, _ctx: &mut RenderContext) -> Result<()> {
let slide_level = ast
.meta
diff --git a/crates/quarto-core/src/transform.rs b/crates/quarto-core/src/transform.rs
index 78242a062..0932601c5 100644
--- a/crates/quarto-core/src/transform.rs
+++ b/crates/quarto-core/src/transform.rs
@@ -51,6 +51,47 @@
use crate::Result;
use crate::render::RenderContext;
+/// The phase a transform belongs to within the ordered render pipeline.
+///
+/// Phases are **totally ordered**; the declaration order *is* the contract.
+/// The render pipeline must visit transforms in non-decreasing phase rank —
+/// a presentation transform must never run before the semantic structure it
+/// consumes is established. See
+/// `claude-notes/designs/transform-pipeline-phases.md` for the full model,
+/// the author rule (which phase do I pick?), and the preview-pipeline shape
+/// contract. The invariant is enforced by `test_build_transform_pipeline_phase_ordering`
+/// in `pipeline.rs`.
+///
+/// `Unclassified` is the default for `AstTransform`s that are **not** members
+/// of the ordered multi-format render pipeline (engine-stage transforms,
+/// stage-level transforms, test doubles). The ordering test rejects
+/// `Unclassified` for any transform that actually appears in
+/// `build_transform_pipeline`, so every real pipeline member must override
+/// `phase()`. It is declared last so a leaked value sorts high.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub enum TransformPhase {
+ /// Format-agnostic semantic normalization (callout/theorem/proof/float/
+ /// equation sugar, metadata normalize, footnotes, …) **and** format-specific
+ /// *structural scaffolding* that does not consume crossref semantics
+ /// (revealjs slide/column construction, footnote coalescing, metadata aliases).
+ Normalization,
+ /// Crossref numbering and `@ref` resolution (`crossref-index`,
+ /// `crossref-resolve`). Note: crossref *render* (custom node → `Figure`/
+ /// `Div`/`Span`/`Link`) is `Finalization`, not here.
+ Crossref,
+ /// Navigation chrome generate + render (TOC, navbar, sidebar, page-nav,
+ /// footer, listings).
+ Navigation,
+ /// Render custom nodes to writer-visible shapes (`crossref-render`,
+ /// `example-embed-render`, `code-block-render`), format presentation that
+ /// *consumes* those rendered shapes (revealjs auto-stretch), then
+ /// resource/attribution baking.
+ Finalization,
+ /// Not a member of the ordered render pipeline. Default; the ordering test
+ /// rejects this value for transforms that appear in `build_transform_pipeline`.
+ Unclassified,
+}
+
/// Trait for AST transformations.
///
/// Transforms modify the Pandoc AST during the render pipeline.
@@ -72,6 +113,16 @@ pub trait AstTransform: Send + Sync {
/// Used for logging and debugging.
fn name(&self) -> &str;
+ /// The pipeline phase this transform belongs to.
+ ///
+ /// Defaults to [`TransformPhase::Unclassified`]. Every transform that is a
+ /// member of `build_transform_pipeline` **must** override this with a real
+ /// phase; the ordering invariant test fails otherwise. See
+ /// [`TransformPhase`] and `claude-notes/designs/transform-pipeline-phases.md`.
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Unclassified
+ }
+
/// Apply the transformation to the AST.
///
/// # Arguments
diff --git a/crates/quarto-core/src/transforms/appendix.rs b/crates/quarto-core/src/transforms/appendix.rs
index 2a16c0728..2133d682b 100644
--- a/crates/quarto-core/src/transforms/appendix.rs
+++ b/crates/quarto-core/src/transforms/appendix.rs
@@ -56,7 +56,7 @@ use quarto_pandoc_types::ConfigValue;
use crate::Result;
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
use crate::transforms::{AppendixStyle, ReferenceLocation};
/// Transform that consolidates appendix content into a single container.
@@ -119,6 +119,10 @@ impl AstTransform for AppendixStructureTransform {
"appendix-structure"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Finalization
+ }
+
async fn transform(&self, ast: &mut Pandoc, _ctx: &mut RenderContext) -> Result<()> {
let meta = &ast.meta;
let appendix_style = Self::get_appendix_style(meta);
diff --git a/crates/quarto-core/src/transforms/attribution_render.rs b/crates/quarto-core/src/transforms/attribution_render.rs
index c327f2988..5e0e13746 100644
--- a/crates/quarto-core/src/transforms/attribution_render.rs
+++ b/crates/quarto-core/src/transforms/attribution_render.rs
@@ -70,7 +70,7 @@ use crate::attribution::{
attribution_viewer_enabled_from_meta, resolve_byte_range,
};
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
pub struct AttributionRenderTransform;
@@ -92,6 +92,10 @@ impl AstTransform for AttributionRenderTransform {
"attribution-render"
}
+ fn phase(&self) -> TransformPhase {
+ TransformPhase::Finalization
+ }
+
async fn transform(&self, ast: &mut Pandoc, ctx: &mut RenderContext) -> Result<()> {
let Some(data) = ctx.attribution_data.clone() else {
return Ok(());
diff --git a/crates/quarto-core/src/transforms/attribution_viewer.rs b/crates/quarto-core/src/transforms/attribution_viewer.rs
index 0e9203220..4bb79094b 100644
--- a/crates/quarto-core/src/transforms/attribution_viewer.rs
+++ b/crates/quarto-core/src/transforms/attribution_viewer.rs
@@ -50,7 +50,7 @@ use quarto_pandoc_types::pandoc::Pandoc;
use crate::Result;
use crate::attribution::{IdentityMap, VIEWER_CSS, VIEWER_JS};
use crate::render::RenderContext;
-use crate::transform::AstTransform;
+use crate::transform::{AstTransform, TransformPhase};
/// HTML-comment sentinel embedded in the injected `