Skip to content

feature: q2-preview (not yet editable)#171

Open
gordonwoodhull wants to merge 92 commits intomainfrom
feature/q2-preview
Open

feature: q2-preview (not yet editable)#171
gordonwoodhull wants to merge 92 commits intomainfrom
feature/q2-preview

Conversation

@gordonwoodhull
Copy link
Copy Markdown
Member

@gordonwoodhull gordonwoodhull commented May 9, 2026

The major restructuring is done and q2-preview lives alongside q2-debug

q2-debug and q2-preview are refactored as independent react-formats.

There is a draft plan claude-notes/plans/2026-05-10-q2-preview-plan-2e-q2-slides-migration.md for migrating q2-slides and the React revealjs format to the same architecture, but it has many open questions and is out of scope for this PR.

Pandoc and custom nodes are all at parity with the html format; a basic page container and title block are also assembled.

Editability is 5 plans away, but we could merge this sooner, as it is safe and independent.

Implementation plans for the q2-preview format — a new editor-view
format that runs the same transform-and-filter pipeline as HTML output
(minus the destructive resolve-and-flatten transforms and the rendering
stages), enabling Lua filters and shortcodes to be visibly applied in
the React-iframe-based preview while keeping edits round-trippable
through `incremental_write_qmd`.

The plans are sequenced in landing order:

1. q2-preview pipeline + integration (M1: visible rendering, read-only)
2. html.tsx + custom.tsx built-in components (M2: visual fidelity)
3. Filter idempotence verification
4. SourceInfo provenance types (Synthetic + Derived + By struct)
5. JSON wire format extension + code-3 fix
6. Provenance audit (Derived for shortcodes, Synthetic for synthesizers)
7. Incremental writer preimage walk + atomic-violation (M3: edit-back)
8. Include round-trip via IncludeExpansion CustomNode (M4: includes)

Each plan is an implementation plan with named open questions — design
decisions are pinned but code-level details may evolve when the plan
gets promoted to a real implementation in its session.

Cross-plan consistency anchors: `By::is_kind` / `By::as_filter` helpers
in Plan 4, `is_atomic_custom_node` registry in `quarto-core` per Plan 7,
extensible builder list with explicit Plan 6 → Plan 4 audit-feeds-builders
flow.
- Plan 1: resolve JIT seam (option A — format dispatch in
  AstTransformsStage::run), name the render_qmd_to_preview_ast entry,
  fix ReactRenderer.tsx vs ReactPreview.tsx routing target, and add
  a "theme CSS artifact" multi-plan contract (CompileThemeCssStage
  included; artifact written to VFS for Plan 2 to consume; Plan 2
  remains free to pick class-compatible vs. component-local styling).
- Plans 2/3/6: dependency clarifications and read-only-mode contract.
- Plan 7: replace hard-abort AtomicViolation with soft-drop substitution.
  Inline UseAfter on Derived → revert to KeepBefore + Q-3-42 warning.
  Block RecurseIntoContainer on atomic CustomNode → revert wrapper to
  KeepBefore + Q-3-43 warning. Block UseAfter on atomic = let-user-win
  via qmd writer's CustomNode arm. Filter-constructed Synthetic nodes
  are atomic via By::is_atomic_synthesizer; Sectionize/Footnotes/Appendix
  wrappers stay transparent. Add reconciler source-info-blindness
  foundation test, runtime user-filter idempotence check (open question),
  filter-mutations corner notes, and a "byte-provenance contract" Notes
  section reframing the rule as "writer never emits bytes whose origin
  is dishonest" rather than "no materialization."
- Plan 4: add By::is_atomic_synthesizer() method classifying Synthetic
  kinds (atomic/transparent/editable/escape-hatch).
- Plan 8: defensive arm becomes unreachable!() (coarsen substitutes
  KeepBefore upstream); qmd writer arm clarified to handle let-user-win
  case via plain_data; soft-drop walkthrough replaces the abort case;
  Out-of-scope reframed in provenance terms.
- New research doc on editable CustomNode slots (out of scope for this
  epic; captured for findability).
- Plan 7a: new plan covering runtime user-filter idempotence check.
  Round-trip flavor (pipeline → write → pipeline → hash-compare) cached
  per doc per session. FilterMetadata wrapping FilterSpec with
  FilterSource (UserConfig vs. Extension) for attribution. idempotent:
  false opt-out per filter. Q-3-44 Warning + Q-3-45 Info diagnostics
  with three-variant message text. Per-filter attribution via isolated
  re-runs.
- Plan 7: shrink runtime-idempotence open question to a pointer at
  Plan 7a. Add autosave-context note explaining why Q-3-42 / Q-3-43
  need suppress-after-3 mitigation (continuous render → spam) while
  Plan 7a's Q-3-44 doesn't (cached verdict, fires once per doc).
- Plan 3: add "two flavors of non-idempotence" section distinguishing
  pipeline non-determinism (current test) from round-trip non-idempotence
  (the property that actually breaks q2-preview's writer round-trip).
  Propose a strengthening amendment to extend the CI test to the
  round-trip flavor, in scope for Plan 3. Add a Dependencies-related
  note pointing at Plan 7a as the runtime analog for user filters.
…contiguity

- Plan 2: promote bootstrap-CSS-iframe-loading from §Risk to §Scope.
  Per Elliot's decision: class-compatible-with-bootstrap visual fidelity
  strategy. Iframe loads /.quarto/project-artifacts/styles.css from VFS
  via Plan 1's theme-CSS-artifact contract. Page-scoped artifacts (image
  resources) mirror RenderToHtmlRenderer's VFS-served pattern, resolving
  Plan 1's open question.
- Plan 7: replace deferred extension-registration note with concrete
  forward-compat sketch — `contributes.custom-nodes: [{type, atomic}]`
  in `_extension.yml`, `collect_atomic_custom_node_types(extensions) ->
  HashSet<String>` runtime aggregation, `is_atomic_custom_node(name,
  &registry)` signature evolution, `wasm_bindgen` runtime lookup
  replacing the JS hand-mirror for extension-contributed types. Commits
  the schema choice and migration path; defers implementation to a
  follow-up plan when an extension actually needs to register an atomic
  type. Hand-mirror description annotated with the migration path.
- Plan 8: note that the const-based `IncludeExpansion` registration is
  forward-compatible with the future extension-registration mechanism —
  migration is a data-source change, not a code change.
- Plan 4: clarify Concat-with-gaps semantics — Plan 7's `preimage_in`
  invariant (pieces must be byte-contiguous), not a Plan 4 type-system
  invariant. Resolves the cross-plan ambiguity flagged earlier.
Resolved every open item from the plan-vs-source audit:

- Single-doc helper prep refactor (render_qmd + render_qmd_content
  delegate to render_single_doc_to_response so q2-preview routing
  has one seam, not three)
- Pass2Payload enum on WasmPassTwoOutput (replaces the parallel
  WasmPassTwoPreviewOutput struct; one orchestrator function instead
  of two)
- PreviewAstOutput as the new entry-point return type for
  render_qmd_to_preview_ast; not reusing existing AstOutput
- pipeline_kind field on Format pinned as the structured selector
  Plan 7's cleanup will migrate the two string-literal dispatches to
- Unstyled-output expectation between Plan 1 and Plan 2 spelled out
  in the theme-CSS contract
- Read-only kanban UX accepted; format-switch test downgraded to
  manual verification; cross-reference rot warnings stripped

Plan 7 picks up Plan 1's two string-literal dispatch sites
(AstTransformsStage::run() and ReactPreview.doRender) — its
cross-reference paragraph and matching References entries were
drafted previously; commit both together so the cleanup contract
reads end-to-end.
… render_single_doc_to_response

Both wasm-bindgen entry points carried near-duplicate single-doc
render bodies that already lived in render_single_doc_to_response.
Collapse them into thin preludes that delegate:

- render_qmd: VFS read + ProjectContext::discover, then delegate.
- render_qmd_content: synthetic /input.qmd path +
  create_wasm_project_context, then delegate. The unused
  _template_bundle ABI arg stays; it never reached the helper anyway.

Behavior-preserving — HTML rendering is byte-identical. Verified:

- cargo build --workspace: clean.
- cargo nextest run --workspace: 8360/8360 pass.
- cargo xtask verify --skip-rust-tests --skip-hub-tests: all 9 steps
  pass (notably step 6, which compiles wasm-quarto-hub-client through
  npm run build:all).
- hub-client npm run test:ci: 74 passing tests including 60 smoke-all
  WASM fixtures that exercise these entry points end-to-end.

This is the prep refactor named in Plan 1's §"Resolved decisions"
and §"Estimated scope". After this lands, q2-preview routing in
commit 5 has a single seam (render_single_doc_to_response) instead
of three; render_qmd and render_qmd_content gain q2-preview support
for free.

229 deletions, 6 insertions.
…json

Type-shape changes that prepare WasmPassTwoOutput and RenderResponse
to carry either HTML or AST JSON payloads, without yet introducing
the q2-preview producer.

WasmPassTwoOutput (crates/quarto-core/src/project/pass2_renderer.rs):
- New Pass2Payload enum (Html / AstJson) with as_html / as_ast_json
  accessors returning Option<&str>.
- WasmPassTwoOutput.html: String → payload: Pass2Payload. The
  renderer's Output associated type is unchanged (both HTML and the
  future q2-preview renderer share Output = WasmPassTwoOutput), so
  the orchestrator stays one function with a payload-match at the
  response tail.
- WasmPassTwoOutput::html() convenience method panics if the payload
  isn't Html, used by the integration test that statically knows it
  invoked RenderToHtmlRenderer.

RenderResponse (crates/wasm-quarto-hub-client/src/lib.rs):
- New ast_json: Option<String> field alongside html: Option<String>.
- All five construction seams populate ast_json: None today: two
  success paths (single-doc + project-active) plus three error
  helpers (error_response, render_error_response,
  pass_failure_response). Skip-serializing on None keeps the JSON
  envelope unchanged for HTML responses.
- The orchestrator's response tail (~lib.rs:1338) destructures the
  payload via match: Pass2Payload::Html → html, Pass2Payload::AstJson
  → ast_json. The shared orchestrator code above the tail (artifact
  flush, diagnostics conversion, pass1_failures) stays payload-
  agnostic — it operates on source_path / diagnostics /
  source_context / page_artifacts only.

Test updates (crates/quarto-core/tests/render_page_in_project.rs):
- 28 sites mass-rewritten via perl from `<var>.html` to
  `<var>.html()` with a negative-lookbehind to skip the
  `posts/first.html` string literal. One multi-line site
  (`output\n    .html\n    .lines()`) handled separately.
- snippet helper signature widened from `&str` to `impl AsRef<str>`
  so call sites of the form `snippet(&output.html())` (which pass
  `&&str`) still type-check.

TS type (hub-client/src/services/wasmRenderer.ts):
- RenderResult interface gains optional `ast_json?: string`,
  matching the snake_case convention already used for
  pass1_failures.

Behavior preserved — HTML rendering byte-identical, JSON envelope
unchanged for HTML and error responses (ast_json omitted when None
via skip_serializing_if). Verified:

- cargo build --workspace: clean.
- cargo nextest run --workspace: 8360/8360 pass.
- cargo xtask verify --skip-rust-tests: all 9 steps pass, including
  step 6 (WASM build via npm run build:all) and step 7 (74 hub-
  client tests, including 60 smoke-all WASM fixtures).

This commit is type-shape-only; q2-preview routing in commit 5 just
sets ast_json: Some(...) at the orchestrator tail. The smaller
producer-fan-out (5 seams, 4 of which take ast_json: None) means
the q2-preview wiring becomes a localized one-line change.
Pure additions — q2-preview becomes a recognized pseudo-format and
two new pipeline-construction functions exist, but nothing wires
them into a producer yet. AstTransformsStage still uses the HTML
transform pipeline; orchestrator still uses RenderToHtmlRenderer.
Behavior unchanged for HTML / q2-debug / q2-slides.

Format detection (crates/quarto-core/src/format.rs):
- builtin_pseudo_format() now returns (base, pipeline_kind) so the
  (q2-preview → ("html", Some("preview"))) mapping is single-source.
  q2-debug and q2-slides keep pipeline_kind = None.
- Format struct gains pipeline_kind: Option<&'static str>. All 6
  struct literals (3 constructors + 3 from_format_string branches)
  set it explicitly. This is the structured selector Plan 7's
  cleanup will migrate the two string-literal dispatches to —
  AstTransformsStage::run() and ReactPreview.tsx::doRender (per
  Plan 1's pinned cleanup). pipeline-dispatching code reads
  ctx.format.pipeline_kind instead of comparing target_format.
- New test test_from_format_string_q2_preview asserts the field
  values; existing pseudo-format test extended to cover
  pipeline_kind: None for q2-slides.

q2-preview pipeline (crates/quarto-core/src/pipeline.rs):
- build_q2_preview_pipeline_stages(engine_registry): identical
  prefix to build_html_pipeline_stages_with_options through
  resource-report; HTML-specific tail (code-highlight,
  render-html-body, apply-template) excluded. Includes
  CompileThemeCssStage so the theme-CSS forward-compat contract
  with Plan 2 holds (artifact lands in VFS regardless of consumer).
- build_q2_preview_transform_pipeline(...): fresh 19-transform list
  written as explicit pipeline.push(...) calls (not a filtered
  view of build_transform_pipeline). Same constructor signature so
  the drift-protection helper compares apples-to-apples.
- Drift test build_q2_preview_transform_pipeline_is_subset_of_html
  uses the new assert_filtered_subset cfg(test) helper to assert
  the q2-preview pipeline is exactly build_transform_pipeline minus
  the 12-name exclusion list (kebab-case names matching Transform::
  name()). Catches every drift mode in one shot: new transform
  added to full pipeline, transform renamed, transform reordered
  on either side, transform removed from subset.
- Structural test build_q2_preview_pipeline_stages_structural
  asserts the exact stage-list and order.

Verified:
- cargo nextest run --workspace: 8363/8363 pass (3 new tests).
- cargo xtask verify --skip-rust-tests: all 9 steps pass, including
  step 6 (WASM build via npm run build:all) and step 7 (hub-client
  tests).

305 insertions, 9 deletions across 2 files. All additions; the
`9 deletions` are doc-comment edits in format.rs (the pseudo-format
fn doc block, the from_format_string list).
…rer, dispatch

Wires the q2-preview pipeline end-to-end at the Rust layer. The
pipeline is now reachable from Rust callers via
render_qmd_to_preview_ast (mirrors render_qmd_to_html), and the
Pass-2 layer has a RenderToPreviewAstRenderer that produces
WasmPassTwoOutput { payload: Pass2Payload::AstJson(...) }. The
WASM-boundary dispatch (which entry point + which renderer to
choose) lands in commit 5; this commit makes the building blocks
exist and pass tests.

AstTransformsStage::run() dispatch (stage/stages/ast_transforms.rs):
- Replace the unconditional build_transform_pipeline call with a
  match on ctx.format.pipeline_kind: Some("preview") builds the
  q2-preview transform list, _ falls through to the standard one.
- Fix the long-standing bug Plan 1 §"Risk areas" calls out: the
  fourth argument was ctx.format.identifier.as_str().to_string()
  (always "html" for any HTML-based pseudo-format) — switched to
  ctx.format.target_format.clone() so shortcode resolution and
  Lua engines see the user-facing format identity. All 8364
  workspace tests pass with the broader fix; no regressions
  observed for q2-debug / q2-slides / acm-html shortcode paths.
- Plan 7 will retire the string-literal "preview" match in favor
  of a typed selector (or by moving construction to stage-build
  time); this branch is scaffolding scoped explicitly in Plan 1's
  §"Multi-plan contract: cleanup owed to Plan 7".

render_qmd_to_preview_ast + PreviewAstOutput (pipeline.rs):
- New PreviewAstOutput { ast_json: String, diagnostics, source_context }
  struct — sibling of RenderOutput. Carries the already-serialized
  JSON (not a typed Pandoc) since the q2-preview pipeline mutates
  the AST extensively; only the wire form is interesting to
  callers.
- New entry-point function pub async fn render_qmd_to_preview_ast.
  Drives build_q2_preview_pipeline_stages(None), extracts
  DocumentAst from the pipeline output, builds an ASTContext from
  the source context (lifted verbatim from
  wasm-quarto-hub-client/src/lib.rs:914-916 — the q2-debug
  pattern), and serializes via pampa::writers::json::write_with_config
  with JsonConfig { include_inline_locations: true }. Returns
  Result<PreviewAstOutput>; errors propagate through the standard
  QuartoError channel.
- Existing AstOutput is intentionally NOT reused — it carries
  typed Pandoc, q2-preview wants pre-serialized JSON, and the
  shapes diverge enough that a sibling struct is clearer than an
  enum.
- Unit test render_qmd_to_preview_ast_preserves_callout_custom_node
  asserts a callout survives as a __quarto_custom_node wrapper Div
  with data-custom-type=Callout in the JSON output. This guards
  the contract Plan 2 (React CustomNode components) consumes.

RenderToPreviewAstRenderer (project/pass2_renderer.rs):
- New Pass2Renderer impl with type Output = WasmPassTwoOutput.
  Constructor takes a vfs_root path, identical shape to
  RenderToHtmlRenderer.
- render() body mirrors RenderToHtmlRenderer's: read source bytes
  via runtime.file_read, build a vfs_root resolver, set up a
  RenderContext with project_index + resource_resolver, call
  render_qmd_to_preview_ast, then drain Project-scoped artifacts
  through the same lib_dir branching (flush_site_libs for shared
  lib dirs, merge_into_project for accumulator). Page-scoped
  artifacts ride on ctx.artifacts.
- The constructed WasmPassTwoOutput carries
  Pass2Payload::AstJson(preview_output.ast_json); everything else
  (source_path, diagnostics, source_context, page_artifacts)
  matches the HTML renderer field-for-field. The orchestrator's
  response tail (commit 2) dispatches on the variant.
- output_path → None and build_project_resolver → vfs_root, same
  semantics as RenderToHtmlRenderer.

Verification:
- cargo nextest run --workspace: 8364/8364 pass (1 new test).
- cargo xtask verify --skip-rust-tests: all 9 steps pass,
  including step 6 (WASM build via npm run build:all) and step 7
  (hub-client tests).

After this commit: q2-preview is fully renderable from Rust, but
no WASM caller invokes it yet. Commit 5 wires
render_single_doc_to_response and render_project_active_page_to_response
to dispatch on format and instantiate RenderToPreviewAstRenderer
when format.pipeline_kind == Some("preview").

309 insertions, 8 deletions across 3 files.
…able from JS

Wires the q2-preview pipeline through both WASM-side render paths
so a render request with `format: q2-preview` in the active page's
YAML routes to render_qmd_to_preview_ast (single-doc) or
RenderToPreviewAstRenderer (project-active) instead of the HTML
pipeline. Both branches share the response-building tail from
commit 2 — only the rendered payload differs.

Re-exports (crates/quarto-core/src/lib.rs):
- pub use pipeline::{render_qmd_to_preview_ast, PreviewAstOutput} so
  WASM consumers don't need to spell the module path.

Single-doc dispatch (render_single_doc_to_response):
- Branch on format.pipeline_kind: Some("preview") calls
  render_qmd_to_preview_ast and stuffs ast_json into RenderResponse;
  everything else keeps the HTML path.
- Both branches share the prelude (RenderContext + resolver +
  user_grammars provider + source_name) and the tail (VFS artifact
  flush + warnings + RenderResponse construction). HtmlRenderConfig
  is constructed only inside the HTML branch — q2-preview doesn't
  need it.

Project-active dispatch (render_project_active_page_to_response):
- Branch on format.pipeline_kind to choose the renderer:
  RenderToPreviewAstRenderer for q2-preview, RenderToHtmlRenderer
  otherwise. Both share Output = WasmPassTwoOutput (commit 2's
  enum-payload payoff), so ProjectRenderSummary unifies across
  arms and the downstream summary handling, page-artifact flush,
  diagnostics merge, and pass1_failures conversion are completely
  shared. The orchestrator's response tail (commit 2) dispatches
  on Pass2Payload at the end.
- format.pipeline_kind is Option<&'static str> (Copy), so reading
  it doesn't move format — leaves it free to consume in the chosen
  branch.

E2E test (crates/quarto-core/tests/render_page_in_project.rs):
- New helper render_active_page_preview mirrors render_active_page,
  swapping in RenderToPreviewAstRenderer + Format::from_format_string("q2-preview").
- ast_json() helper unwraps Pass2Payload::AstJson via as_ast_json,
  paired with WasmPassTwoOutput::html() that the existing HTML tests
  use.
- New test website_q2_preview_renders_through_orchestrator drives a
  website fixture (callout + embedded image + sidebar config) end-
  to-end and asserts:
  - the AST JSON contains __quarto_custom_node + data-custom-type=Callout
    (CalloutTransform sugar runs, CalloutResolveTransform is
    excluded — preserves the wrapper for Plan 2's React component);
  - the embedded image's filename appears in the AST URL
    (ResourceCollectorTransform rewrites the URL via the vfs_root
    resolver — Plan 1's "Multi-plan contract: page-scoped image
    artifacts");
  - page_artifacts is non-empty (the bytes flush channel works);
  - sidebar metadata includes the sibling page's title
    (SidebarGenerateTransform ran successfully against the project
    index — verifies the q2-preview transform list's nav-generate
    transforms work end-to-end with a real ProjectIndex).

Verification:
- cargo nextest run --workspace: 8365/8365 pass (1 new test).
- cargo xtask verify --skip-rust-tests: all 9 steps pass, including
  step 6 (WASM build via npm run build:all) and step 7 (hub-client
  tests).

After this commit, q2-preview is fully reachable from JS via every
existing wasm-bindgen entry point: render_qmd, render_qmd_content,
and render_page_in_project's single-file and project-active
branches. The format toggle in the YAML frontmatter is the user-
visible switch. Commit 6 wires the hub-client TS layer (ReactRenderer
routing + ReactPreview doRender format switch + read-only guard)
to make the q2-preview format usable in the editor UI.

280 insertions, 46 deletions across 3 files.
…om the editor UI

End-to-end wiring of q2-preview through the React layer. After this
commit, switching a document's YAML frontmatter to `format: q2-preview`
makes the live preview render through the q2-preview pipeline (full
shortcodes, Lua filters, sectionize, crossref, sidebar/navbar
metadata, embedded image artifacts) instead of the raw parse-only
AST that q2-debug uses. The user-visible affordance is the format
toggle in the YAML; everything else dispatches automatically.

New helper (hub-client/src/utils/pipelineKind.ts):
- pipelineKindForFormat(format) returns 'preview' for q2-preview,
  undefined for everything else. Single source of truth on the JS
  side for the format → pipeline-kind mapping; mirrors
  Format::pipeline_kind on the Rust side (Plan 1 commit 3). Used
  today by ReactPreview.doRender's data-source switch and the
  read-only guard; Plan 7's edit-back wiring will read it too.
- 5 unit tests in pipelineKind.test.ts verify the mapping for
  q2-preview, q2-debug, q2-slides, html, and unknown formats. A
  silent regression here would break q2-preview routing without
  surfacing as a test failure anywhere else, so the helper-level
  test pays for itself.

ReactRenderer routing (hub-client/src/components/render/ReactRenderer.tsx):
- The AstIframe gate at the q2-debug branch widens to also match
  q2-preview. Both formats render through the iframe; the data-shape
  contract is the only assumption shared. q2-preview's data source
  diverges upstream in ReactPreview.doRender. Format prop type
  comment updated to mention q2-preview.
- The render-component-path extraction stays gated on q2-debug only;
  q2-preview doesn't ship custom user components today (Plan 2's
  built-in CustomNode components serve the same role), so
  customComponentsCode falls through to {} cleanly.

ReactPreview.doRender format switch (hub-client/src/components/render/ReactPreview.tsx):
- doRender now takes `format` in its options and dispatches on
  pipelineKindForFormat: 'preview' calls renderPageInProject(documentPath)
  and reads RenderResponse.ast_json; everything else keeps the existing
  parseQmdToAst(content) path. The two backends produce the same
  RenderResult shape (success: true, astJson, diagnostics) so the
  state-management logic is unchanged.
- documentPath is required for the q2-preview branch (renderPageInProject
  reads from VFS by path; parseQmdToAst is path-less).
- Format prop type comment updated to include 'q2-preview'.
- doRenderWithStateManagement's useCallback deps add `format` so the
  closure picks up format changes (e.g. user toggles between q2-debug
  and q2-preview in the YAML during a live session).

Read-only guard (handleSetAst):
- handleSetAst early-returns with a console.warn when
  pipelineKindForFormat(format) === 'preview'. q2-preview is read-only
  in v1 (Plan 1 §"Multi-plan contract: read-only mode lifts at Plan 7")
  because the post-pipeline AST diverges from source enough that
  incrementalWriteQmd would corrupt the qmd. Plan 2's components
  (kanban drag, comment buttons) call setLocalAst → handleSetAst;
  those calls silently no-op until Plan 7 wires the round-trip
  machinery. The on-screen affordance may briefly appear to succeed
  before the next render reverts the visual state; this is the
  accepted post-Plan-2 UX gap.
- format added to handleSetAst's useCallback deps.

RenderResponse type fixup (hub-client/src/types/diagnostic.ts):
- Mirror commit 2's change to the second TS-side RenderResponse
  interface (used by renderPageInProject's caller) so ast_json is
  visible when ReactPreview.doRender reads it. Commit 2 added the
  field to RenderResult in wasmRenderer.ts but missed this parallel
  type — caught when the doRender dispatch pulled the field for the
  first time.

Verification:
- cargo nextest run --workspace: 8365/8365 pass (no Rust-side
  changes; confirms nothing rippled).
- cd hub-client && npm run build:all: production build clean
  (tsc -b + vite build, the strict-than-vitest path that uses
  project-references mode).
- cd hub-client && npm run test:ci: full suite passes including the
  5 new pipelineKindForFormat tests + 60 smoke-all WASM fixtures
  exercising the underlying renderQmd / renderPageInProject paths.
- cargo xtask verify --skip-rust-tests: all 9 steps pass.

The format-switch behavior in a long-lived session and the
postMessage data-shape compatibility between q2-debug and q2-preview
ASTs are interactive verifications the user does manually (Plan 1's
§"Format-switch behavior — manual verification only" entry).

After this commit, Plan 1 is **complete**: q2-preview is a recognized
format throughout the stack, renders through its dedicated pipeline,
appears in the AstIframe with the same iframe scaffolding q2-debug
uses, and is read-only until Plan 7 lands the writer round-trip.
Plan 2 builds CustomNode-specific React components against the AST
shape this commit produces.

96 insertions, 12 deletions across 3 modified + 2 new files.
…ind dispatch

Plan 7 originally framed Plan 1's render-side dispatches as
string-literal scaffolding it would refactor. Plan 1 actually
implemented the structured form directly (commits a7143cc7 +
60658a4e + a5e00b20):

- Format struct gained pipeline_kind: Option<&'static str>
- AstTransformsStage::run() dispatches via ctx.format.pipeline_kind
- ReactPreview.doRender dispatches via pipelineKindForFormat(format)
  helper at hub-client/src/utils/pipelineKind.ts

Plan 7's render-side cleanup work is therefore already done. This
commit revises Plan 7's plan-doc to:

§Goal: clarify that the write-side parameter is the new piece —
render-side is already structured. Note that the Option<String>
parameter on incremental_write_qmd is the wasm-bindgen-friendly
form of Plan 1's Option<&'static str> on Format; both map to the
same kind string.

§Scope: replace "Cleanup: structured pipeline dispatch (replaces
Plan 1's temporary scaffolding)" with "Verify: structured pipeline
dispatch is already in place" — names the three Plan 1 commits and
points the verification at threading the new write-side parameter
through pipelineKindForFormat at the JS call site.

§References: update stale line numbers (lib.rs:2166 → 2510 after
Plan 1's prep refactor + new wiring; wasmRenderer.ts:531 → 583).
Add pipelineKind.ts and format.rs as references — Plan 7 reads both.
Note that ast_transforms.rs:134 is already correct from Plan 1, no
edit needed for Plan 7 itself.

§Dependencies: rewrite the cleanup line to reflect "Plan 1's
dispatches are already structured" rather than "Plan 7 absorbs the
cleanup."

§Estimated scope: drop the cleanup row from ~30 to ~5 (verification
only, no refactor); total drops from ~1130 to ~1105.

No code changes — plan doc only.
…ponents)

The 2026-05-06 review session expanded the original Plan 2 scope from
~970 LOC to ~1415 LOC after researching three under-specified areas:

- Pandoc base-type coverage in html.tsx: 11 missing components
  enumerated (LineBlock, DefinitionList, Table family, 8 inline
  variants), bringing the html.tsx fills from ~50 to ~150 LOC.
- JS-side source-info pool plumbing: no TS code currently consumes
  the wire-format source-info pool emitted by the JSON writer; new
  types/sourceInfo.ts + utils/sourceInfo.ts ship the accessors that
  Plan 2B's atomic-aware setLocalAst gating needs to detect Plan 6's
  Derived inlines.
- Iframe asset channel: theme CSS and page-scoped images need to
  reach the AST iframe via the same data:URI rewrite pattern the
  HTML iframe uses today (per the user's clarification — refactor
  iframePostProcessor.ts into a shared helper, both call sites
  delete together when service-worker resource resolution lands).

Plus four smaller items: render-components gate extension to cover
q2-preview, atomicCustomNodes.ts ownership reassigned from Plan 7 to
Plan 2A (Plan 2B is the first consumer), :where()-wrapping the
ast-renderer.html style for cascade-correctness with theme CSS, and
deferral of the layout-chrome utilities (sidebar body-classes,
navbar brand-fallback) to a future "q2-preview layout chrome" plan.

Split rationale: Plan 2A delivers a self-contained ~600 LOC iframe
foundation that's independently shippable (theme CSS works, images
load, render-components covers q2-preview, source-info pool is
threaded). Plan 2B builds the ~1190 LOC of type-specific renderers
on Plan 2A's tested base. The split is clean: 2A's contracts are
stable interfaces 2B consumes; 2B doesn't re-edit foundational types
because 2A pre-declares CustomBlockNode / CustomInlineNode placeholder
discriminants in the consolidated PandocAST.

Cross-plan reference updates: Plans 1, 6, 7, 8 redirect their "Plan 2"
references to 2A or 2B as appropriate. Plan 7's atomicCustomNodes.ts
ownership note is updated to reflect Plan 2A landing the TS hand-mirror;
Plan 8 amends both the Rust and TS sides to add IncludeExpansion.

The MaybeReadOnlyInline wrapper from the original Plan 2 is dissolved
into the existing Block / Inline dispatchers' atomic-aware setLocalAst
gating — no separate wrapper component.

CURRENT.md repointed to 2A; old plan file removed.
Apply ten corrections from the 2026-05-07 review of Plan 2A
(claude-notes/plans/2026-05-04-q2-preview-plan-2a-iframe-foundation.md).
Companion plans updated for consistency.

Plan 2A
- Reorder §"In scope" into ten implementation-ordered items: types
  and consolidation first, behavior-preserving HTML refactors next,
  AstWithAssets wrapper last (item 10).
- Image rewriter narrowed to image-only carve-out at lines 177-210
  of iframePostProcessor.ts; HTML iframe keeps <link> rewrite,
  external-link, and qmd-link click handler inline.
- Theme CSS replaces the rewriter approach with a one-shot inline
  <style> injection in document.head, plus injectPreviewStyles for
  the responsive overrides.
- AST-iframe link handlers extracted into iframeLinkHandlers.ts
  using event delegation; artifact-rooted .html reverse-mapping
  (bd-lnd3) stays in iframePostProcessor.ts since q2-preview's
  pipeline excludes LinkRewriteTransform.
- AstWithAssets wrapper component holds three useEffects (image
  rewrite keyed on [astJson, currentFilePath]; link handlers and
  theme CSS injection at []-deps).
- PandocAST consolidation picks BlockNode/InlineNode as canonical
  names; six consumers migrate (was three); no compat re-export.
- Bootstrap reboot specificity corrected to 0,0,0,1 (was 0,0,1)
  with reference to the actual SCSS source.
- Multi-plan contract for page-scoped image artifacts rewritten:
  ResourceCollectorTransform produces empty-content artifacts; the
  iframe rewriter reads the user's original automergeSync upload,
  not anything the renderer flushed.
- New §Risk: empty-content artifact overwrite (latent bug at
  wasm-quarto-hub-client/src/lib.rs:1208-1214 and :1364-1369). TODO
  to file beads issue from main repo.
- New §Test plan §"TDD discipline per work-item": items 2/8/9 are
  behavior-preserving refactors with "tests pass before AND after"
  gates; rest follow failing-test-first.
- §"User-visible state after 2A lands" item 2 corrected: <img>
  elements keep user-written src, not /.quarto/... paths.
- By type future-narrowing note added.

Plan 1
- §"Multi-plan contract: page-scoped image artifacts" rewritten to
  match Plan 2A: renderer doesn't contribute image bytes; iframe
  reads user's automergeSync upload directly.
- §"Page-scoped artifact handling" implementation note corrected.
- Website-fixture test description strengthened to five concrete
  assertions (URL preservation, manifest path equals
  base_dir.join(url), manifest content empty with comment, navbar/
  sidebar metadata) plus a sixth post-bug-fix assertion.
- Theme CSS resolution: <link> rewrite replaced with inline <style>
  injection.

Plan 2B
- "Block/Inline dispatcher modification" → 2B introduces a unified
  Block/Inline dispatcher; not modifying an existing one.
- iframeAssetRewriter.ts → split references into iframeImageRewriter,
  iframeLinkHandlers, theme CSS injection.
- Image artifacts framing aligned with revised contract.

Plan 6
- "Dispatcher modification" → "unified Block/Inline dispatcher that
  2B introduces" for terminology consistency.

No code changes; documentation-only.
The empty-content artifact overwrite bug discovered during the
Plan 2A review is now filed as bd-3gtn. Update plan references
from "TODO: file an issue" placeholder to the real ID.

Plan 2A §"Risk areas → Empty-content artifact overwrite"
- Replace TODO with bd-3gtn reference.

Plan 1
- §"Page-scoped artifact handling" implementation note: name
  bd-3gtn alongside the cross-reference.
- §"Multi-plan contract: page-scoped image artifacts": same.
- §Test plan website-fixture assertion 3 (manifest content
  empty): point comment at bd-3gtn directly.
- §Multi-plan contract regression test description: name bd-3gtn
  as the prerequisite for the bytes-survive assertion.

Plan 2B
- §"Consumed: Plan 1's page-scoped image artifacts": link bd-3gtn
  alongside the cross-reference.
Codifies the append-only / number-burning rules for the source-info
pool's `t` discriminant. Documents the current 0-3 allocations
(Original, Substring, Concat, FilterProvenance), the 4-5 reservations
for q2-preview plan 5 (Synthetic, Derived), and the grandfathered
recycling of code 3 (Transformed -> FilterProvenance).

The policy and procedure exist so that future variant additions don't
silently drift between the Rust writer and TS reader, which can't be
caught by type errors since the wire codes are integers.

First reader is q2-preview plan 2a's TS mirror in
`hub-client/src/types/sourceInfo.ts`.
Resolves the questions raised in the 2026-05-07 plan review session.
Headline changes:

- Image rewrite: render-time resolution in the `Image` component
  (Option B) replaces the post-render DOM walk. Eliminates flicker,
  drops a `useEffect` from the wrapper, and establishes a
  context-aware URL-resolution pattern future renderers can follow.
  Item 8 changes from "image rewriter helper extraction" to "image
  renderer render-time resolution"; the HTML iframe rewriter is
  unchanged.
- Theme CSS live reload: new item 11 surfaces `theme_fingerprint(css)`
  on `RenderResponse` so the wrapper's theme effect can re-inject on
  theme swaps. The `<style data-q2-theme>` marker doubles as a
  StrictMode idempotency guard. Items 1-10 still ship without item
  11; live reload is the final piece.
- Link handlers: ref-based context (`installLinkHandlers(doc, ctxRef)`)
  prevents stale-closure bugs across document navigation. Keydown
  attaches to `window`, not `document.body`. `injectPreviewStyles`
  gains an idempotency guard.
- PandocAST consolidation: corrected to reflect that slide-side files
  don't import `Block`/`Inline` directly; they only need their
  `PandocAST` import path updated. Slide-side keeps local types
  (q2-slides stays on q2-debug path).
- CustomNode discovery mechanism: `data-custom-type` attribute on
  the wrapper Div/Span carries the `type_name` byte-for-byte. Both
  item 4 and the multi-plan contracts section now document this.
- Wire-format codes: replaced the tense-confused back-compat
  paragraph with a pointer to the new design doc.

Also includes:
- New "Iframe lifetime model" entry in Design decisions (mounted once
  per session; persists across navigation) — load-bearing for the ref
  pattern and fingerprint-keyed re-injection decisions.
- Provider placement pseudocode in item 5.
- `currentFilePath` added to `RegistryContext` alongside
  `sourceInfoPool` and `registry`.
- bd-3gtn empty-content overwrite paragraph cleaned up; out of scope
  for 2A but reference retained.
- Test plan, references, estimated scope table all refreshed to match.
Plan 2A's review amendments changed several things 2B describes as
consumed artifacts. Bringing 2B's prose into agreement:

- The image story moved from a `rewriteImages` helper / DOM walk to
  render-time resolution in the `Image` registry component (plan 2A
  Option B). 2B's "Consumed" list, the page-scoped image artifacts
  contract, and the 2A dependency line all updated accordingly.
- `RegistryContext` now carries `currentFilePath` alongside
  `sourceInfoPool`. Reflected in the consumed-artifacts list.
- Theme CSS injection is fingerprint-keyed (per `themeFingerprint`
  on RenderResponse), not one-shot at mount.
- Link handlers use a ref-based context to avoid stale closures
  across document navigation; small clarification added.
- The atomicCustomNodes hand-mirror's discovery mechanism is
  documented in 2A — `data-custom-type` attribute on the wrapper
  Div/Span. 2B's `unwrapCustomNodes` description now states it
  reads `type_name` from that attribute (and the slot/data
  attributes), pointing at 2A for the canonical contract.
- The `customNode.ts` interface and `renderSlot` casts updated
  from `Block`/`Inline` to the canonical `BlockNode`/`InlineNode`
  names that 2A consolidated under `types/pandoc.ts`. (Wire-format
  shape descriptions in the gap-fill tables stay as `Block[]` /
  `Inline[]` since they describe Pandoc's structural shape, not
  our TS type names.)
New Plan 2pre carves ReactAstDebugRenderer.tsx into a format-agnostic
framework/ plus a debug-specific q2-debug/ -- structural prerequisite
for parallel q2-debug and q2-preview formats.

Decisions captured during 2026-05-07 review:

- Keep 'Ast' registry key (no rename to 'Document'); user TSX
  overriding 'Ast' (slide.tsx) keeps working unchanged.
- Consolidate slide-side Block/Inline -> BlockNode/InlineNode
  (less disruptive than the reverse: comment.tsx already uses
  the Node-suffixed names; Block/Inline as types collide with
  the framework's same-named dispatcher components).
- Drop the dispatcher ?? componentRegistry fallback (defensive
  copy-paste per d6eb060 / 0272166 history -- never load-bearing).
  RegistryContext default -> { registry: {} }; <Ast> registry prop
  becomes required.
- Delete dead transpileAndImportTSX from tsxTranspiler.ts
  (superseded by 72ef918 iframe move); deletion lets the file
  stop pulling reveal.js + KaTeX + the renderer module into every
  consumer's bundle.
- Spell out: src/ast-renderer-entry.tsx ->
  src/components/render/q2-debug/entry.tsx, with the matching
  <script src=...> update in public/ast-renderer.html.
- Pre-flight the slide-side rename (~30 min throwaway) before
  starting the framework split proper.

Plans 2A and 2B aligned with the above. Plans 6 and 8 carry
pre-existing framework-dispatcher path corrections from earlier
in the 2026-05-07 review window. Also includes the parked
live-reload plan (independent of the 2pre/2A/2B series).
2pre review surfaced four architectural improvements that propagate
across 2A and 2B:

- Framework reserves the registry keys 'Block'/'Inline'/'Ast' but
  provides no implementations; each format owns its dispatchers and
  fallback aesthetic. Keeps q2-debug byte-identical and lets q2-preview
  use a quieter "(not yet implemented)" placeholder.
- Collapse framework's recursion-and-render code into a single
  framework/dispatch.tsx (Node + renderChildren + renderNode +
  blockTypes), eliminating cross-file circularity.
- Replace wholesale __REACT_AST_DEBUG_RENDERER__ spread with an
  explicit object literal; researched against elliot's demos. Note
  added on rollback path if other consumer trees surface.
- 2B's atomic-aware gate moves from the (now per-format) Block/Inline
  dispatchers into framework's Node — single recursion chokepoint that
  benefits both formats automatically.

Plus smaller fixes: Quoted added to 2pre's pre-flight narrowing check;
explicit deletion of ReactAstDebugRenderer.tsx; render_components.qmd
line-51 doc-sweep correction (renderChildrenRegistry now lives in
framework/dispatch.tsx); experimental-components survey note;
PreviewDocument.tsx added to 2A's file list; defensive Dispatcher
fallback in 2B's Node code sample; documented q2-debug input
assumption for the unwrap walk.
…-node split

Sources-vs-plan review surfaced a few items that needed pinning down before
2pre implementation. Plan updates only — no code changes.

Plan 2pre:
- Figure renderChildrenRegistry has three pre-existing bugs (literal "// TODO:"
  rendered as DOM text; renders ShortCaption c[1][0] instead of caption blocks
  c[1][1], opposite of html.tsx convention; mixes child-recursion and caption
  presentation). Fix during the framework extraction; deviation from
  byte-identical q2-debug rendering noted in §"What stays exactly the same".
- Add typed format-registry contract: AstProps / AstComponent /
  DispatcherComponent / FormatRegistry in framework/types.ts, applied at the
  format-side construction site (componentRegistry, previewRegistry). Catches
  register-time mistakes; framework <Ast> prop stays loose since user TSX is
  babel-stripped of types anyway.
- New §"renderChildrenRegistry is framework-internal" pinning the contract:
  the table is closed to per-type extension. Custom-node growth happens in
  customNodeRegistry (per-format, keyed by type_name); 2B's CustomBlock /
  CustomInline entries are abstract-category, framework-evolves-itself
  changes — not user extension points.
- blockTypes was inlined in TWO places (renderNode :328, Node :582), not
  three. Pre-flight gains MathInline narrowing parallel to Quoted's coverage.
- ReactRenderer.tsx:141-147 comment is left for 2A to rewrite when format
  dispatch actually changes. blockStyle (drag.tsx) added to the
  __REACT_AST_DEBUG_RENDERER__ surface survey. ReactAstRenderer.tsx delete
  count corrected (-344, not -270).

Plan 2A:
- previewRegistry typed as FormatRegistry (introduced in 2pre); annotation
  catches Block/Inline/Ast register-time mistakes.

Plan 2B:
- renderChildrenRegistry added to dispatch.tsx housing list at line 392 for
  accuracy; mutations explicitly framed as framework-internal.
- Strengthen the renderChildrenRegistry vs customNodeRegistry distinction:
  the new CustomBlock/CustomInline entries are generic and one-time;
  per-custom-node-type growth is in customNodeRegistry with the type-aware
  logic in registered components calling renderSlot.
- q2-preview's Figure component noted as inheriting 2pre's Figure fix; its
  block-by-block caption render avoids the renderChildrenRegistry path
  entirely.
Restructure the plan around a re-export shim so each commit leaves the
tree green: Phase 1 builds the new framework/ + q2-debug/ directories
behind a barrel that preserves old import names; Phase 2 migrates
consumers one at a time and deletes the shim last. 16 explicit checklist
items, each gated by `npm run build:all` (and a smoke test where
runtime behavior moves).

Other refinements from the 2026-05-07 follow-up review:

- Full rename for q2-debug ↔ q2-preview cohabitation: componentRegistry
  → q2DebugRegistry, AstIframe → Q2DebugIframe, /ast-renderer.html →
  /q2-debug.html, vite rollup input ast-renderer → q2-debug. Window
  global __REACT_AST_DEBUG_RENDERER__ stays (public API).
- Reframe "Bug B" as a scope decision: q2-debug's bordered "Caption:
  ShortCaption" line is preserved by porting the caption branch into
  q2-debug's Figure component. Framework's renderChildrenRegistry.Figure
  collapses to body-only (Bug A literal // TODO: text deletion + Bug C
  mixed-concerns fix remain genuine bugs).
- Annotate mergedRegistry as FormatRegistry at the entry-side merge
  (point 4 from review).
- State conclusions for the Math edge case and renderNode-context note
  instead of hedging.
- Roll bd-3day's customRegistry accumulator fix into step 2.7's entry
  rewrite.
- Make the const-Node → export-function-Node promotion explicit.
- Survey of __REACT_AST_DEBUG_RENDERER__ consumers extended to include
  ~/docs/demo-playground/gordon/tldraw-shortcode/.
Add the two regressions tests Plan 1 (`2026-05-04-q2-preview-plan-1-pipeline.md`,
§"Test plan") called for but were not included in the original Plan 1 commits:

- `default_project_theme_artifact_lands_in_vfs_under_q2_preview`: mirrors
  the existing HTML-side `default_project_theme_artifact_lands_in_vfs`
  but drives `RenderToPreviewAstRenderer`. q2-preview returns AST JSON
  with no `<link>` tag, so the test walks the on-disk VFS root for
  `quarto/quarto-theme-*.css` instead of grepping rendered HTML.
  Guards Plan 2A's "Multi-plan contract: theme CSS artifact".

- `ReactRenderer.integration.test.tsx`: asserts q2-preview and q2-debug
  route through `AstIframe`, and q2-slides does not. Targets
  `ReactRenderer` (where the dispatch actually lives) rather than
  `ReactPreview` (which would require mocking the WASM render
  boundary for no additional coverage).
…nd shim

Plan 2pre Phase 1 — structural carve-up of `ReactAstDebugRenderer.tsx`
into a format-agnostic `framework/` (types, RegistryContext, recursive
dispatch, Ast root) and a debug-specific `q2-debug/` (bordered-box
leaves, Block/Inline dispatchers, AstRenderer, q2DebugRegistry). The
file at the old path becomes a thin re-export barrel under the old
names so every existing consumer (ast-renderer-entry.tsx,
tsxTranspiler.ts) keeps compiling unchanged. Phase 2 will migrate the
consumers one commit at a time and delete the shim at the end.

Behavior changes intentionally bundled with the carve-up:
- Figure DOM: literal `// TODO:` text removed from q2-debug's bordered
  Figure (Bug A from plan §"Figure entry"). The bordered "Caption: …"
  line is preserved by porting caption rendering to q2-debug's Figure
  leaf component, so the framework's renderChildrenRegistry.Figure
  collapses to body-blocks-only (Bug C from plan §"Figure entry";
  format-specific caption presentation no longer leaks into the
  framework entry).
- Dispatcher fallbacks (`?? componentRegistry`) dropped at five sites.
  RegistryContext default is `{ registry: {} }`; <Ast>'s `registry`
  prop is required. No user-visible change in normal flow because the
  <Ast> Provider is always set above the dispatchers; verified across
  ~/docs/demo-playground/ that no demo mounts a dispatcher outside an
  <Ast> ancestor.
- Framework's InlineNode union gains MathInline (slide-side already
  used a structurally compatible local type; framework now narrows the
  discriminant to {t: 'DisplayMath' | 'InlineMath'}).
- Typed format-registry contract: `FormatRegistry` declared in
  framework/types.ts with required keys `Ast`, `Block`, `Inline`. q2-
  debug's q2DebugRegistry annotated with this type — registration-time
  TS errors if a format misses a reserved key or registers a non-
  conforming component. Plan 2A's q2-preview registry will reuse the
  same type.

Bundled-commit decision: Phase 1 was originally specified as 11
commits but landed as one. The shim isolates consumers from the
intermediate states (the new files are inert until step 1.10 wires
them in via the shim), so per-step commits would not produce reviewer-
useful intermediate diffs. Phase 2's per-step cadence is preserved.

Verification:
- `npm run build:all` PASSES from hub-client/.
- `npm run test:ci` PASSES (74/74) from hub-client/.
- DevTools sanity check on ~/docs/demo-playground/elliot/index.qmd:
  `Object.keys(window.__REACT_AST_DEBUG_RENDERER__).sort()` returns
  exactly `['Ast', 'Block', 'Inline', 'Node', 'blockStyle',
  'componentRegistry', 'inlineStyle', 'renderChildren', 'renderNode']`.
- Full visual smoke test (kanban widget rendering, etc.) BLOCKED on a
  separate preexisting bug in ReactRenderer.tsx:114-139 — its
  `customComponentsCode` useMemo omits `fileContents` from its deps,
  so when astJson arrives before the VFS-loaded `fileContents` map
  populates `/elliot/*.tsx`, the memo runs once with an empty map,
  fires four `[ReactRenderer] Component file not found` warnings, and
  never re-runs. Confirmed reproducible on the pre-2pre commit
  f58ed2b6 by stashing the Phase-1 working tree and re-rendering. Not
  introduced by 2pre. Tracked with a debugging handoff prompt in
  claude-notes/plans/2026-05-07-debug-render-components-not-loading.md
  for a fresh agent to investigate.

Phase 0 pre-flight discoveries (recorded in the plan file):
- Slide-side rename Block→BlockNode / Inline→InlineNode passes
  cleanly. None of the anticipated narrowing failures (Quoted, Math,
  splitByHeaders boundaries) materialise. Only fallout is TS6196
  unused-decl errors for per-shape types (HorizontalRuleBlock,
  UnknownBlock, SpaceInline, SoftBreakInline, LineBreakInline,
  UnknownInline) that were referenced exclusively by the dropped
  union declarations — falls naturally into Phase 2.2's
  per-shape-type import switch.

Next: Phase 2 (consumer migration, rename for cohabitation with
q2-preview, shim deletion) proceeds once the file-lookup race is
fixed. Phase 1's commit can stand independently of that fix because
the failing path is upstream of the iframe and is not introduced by
this commit.
After rebasing onto bugfix/react-components-not-loading, Plan 2pre's
manual smoke-test list converts to automated gates. The new bugfix
branch landed:

  - 7a59df6: Pull .tsx source files when importing project from disk
  - 46dcf36: Strip leading slash on render-components path lookup
  - 479b514: Resolve render-components paths against the document directory
  - 409cd40 + ce2081f: bd-3day fix + extracted, tested
    `buildCustomRegistry` helper
  - 5044d64: ReactRenderer integration test for the lookup chain
  - 96508df: smoke-all q2-debug support + reactji static fixture
  - f29f3a0: e2e spec for click→state-change interaction

That infrastructure plus this commit's `q2-debug.integration.test.tsx`
covers what the plan was paying manual smoke tests for.

`q2-debug.integration.test.tsx` (8 cases under jsdom):

  - Bordered debug aesthetic for Para / Header / BulletList (locks the
    "byte-equivalent for q2-debug" contract).
  - Figure body collapse + bordered "Caption: ShortCaption" port-for-
    port from the pre-carve-up `renderChildrenRegistry.Figure` (Bug C
    fix preserves visible behavior).
  - Bug A regression: literal `// TODO:` text is gone from the rendered
    Figure DOM.
  - "Not registered" miss path renders a bordered message for an
    unknown block type (locks the dispatcher-fallback removal didn't
    silently break this path).
  - User-TSX override resolves through `<Block>` to the user's
    component (locks the framework→<Block>→registry[node.t] dispatch
    chain that elliot demos and reactji rely on).

Plan-2pre updates:

  - 1.11 marked complete: full automated verification for Phase 1.
  - 2.7's "Fix bd-3day in passing" reduced to "no behavior change"
    (bd-3day was fixed independently; the entry rewrite to
    `q2-debug/entry.tsx` keeps using `buildCustomRegistry`).
  - 2.5 grows the explicit obligation to update test selectors
    (`previewIframeSelector`, `frameLocator` in the e2e spec, and the
    `vi.mock('./AstIframe')` in the integration test) in lockstep
    with the iframe-path rename. Their continued passing is the
    regression gate for that step.
  - Each Phase-2 step now lists the test suites it must satisfy. The
    `npm run test:e2e` run is opt-in only at the iframe-rename
    commit (2.5), entry-rewire (2.8), and final verification (2.15);
    intermediate Phase-2 commits gate on `test:ci` only, per the
    project policy of using e2e sparingly.
  - New §"Test gates" section maps each former manual gate to its
    automated successor, plus an honest accounting of what's NOT
    covered (DOM byte-identical A/B; window-global keys
    test-locking).

Verification:
  - `npm run build:all` passes.
  - `npm run test` (vitest unit) passes.
  - `npm run test:integration` passes (63 tests in 7 files, including
    the new 8-test q2-debug suite).
  - `npm run test:wasm` passes (74 tests in 12 files including
    smoke-all WASM with 60 fixtures).
  - `npm run test:ci` (the aggregate) passes.
  - `npm run test:e2e` not run in this commit (opt-in; would require
    a hub server and is gated to the specific Phase-2 steps that
    earn it).
…s (Plan 2pre 2.1)

Drop the per-file PandocAST declarations and switch every consumer to
import from the framework's canonical type definition. After this
commit, every PandocAST reference under hub-client/src/ traces back to
hub-client/src/components/render/framework/types.ts.

  - ReactRenderer.tsx: drop the local 7-line `interface PandocAST`
    declaration (the one shaped for the setAst callback), import
    framework's instead.
  - ReactAstSlideRenderer.tsx: drop the local `export interface
    PandocAST` declaration; import framework's. Slide-side's
    `parseSlides(ast: PandocAST): Slide[]` now takes framework's
    union; the slide-local Block/Inline declarations remain
    untouched in this commit (step 2.2 consolidates those).
  - RevealjsReactAstSlideRenderer.tsx, useCursorToSlide.ts,
    useSlideThumbnails.tsx: previously imported `PandocAST` from
    ReactAstSlideRenderer; now import directly from
    `framework/types`.

Verification:
  - npm run build:all: passes.
  - npm run test:ci: 619 + 63 + 74 = 756 passing.
…e 2.2)

Slide-side ReactAstSlideRenderer.tsx had its own block/inline type
declarations that paralleled framework's. Consolidate by:

  - Mechanically renaming `Block` → `BlockNode` and `Inline` →
    `InlineNode` throughout slide-side (~25 references plus the union
    declarations and the `Slide.blocks`, `parseSlides`,
    `splitByHeaders`, `extractSections`, `flattenBlocks`,
    `renderBlock`, `renderInlines`, `renderInline` signatures).
  - Dropping the slide-local declarations of all 13 per-shape Block
    types, all 13 per-shape Inline types, and the `BlockNode` /
    `InlineNode` unions.
  - Importing the per-shape types and unions from
    `./framework/types`. Slide-side now inherits framework's
    `MathInline` extension to `InlineNode` and the narrower
    `Quoted`/`Math` discriminants for free.

Phase 0 pre-flight (2026-05-07) confirmed this is safe: the framework
types' narrowing of `Quoted` and `Math` discriminants doesn't break
slide-side's runtime literal-compare checks (e.g.
`mathType.t === 'DisplayMath'`), and structural compatibility holds
at all function boundaries (`splitByHeaders`, `extractSections`,
`flattenBlocks`).

Verification:
  - npm run build:all: passes.
  - npm run test:ci: 619 + 63 + 74 = 756 passing.
Both flush loops in `wasm-quarto-hub-client/src/lib.rs` (the single-doc
path at the top of `render_single_doc_to_response`, and the project
path's per-page artifact flush in `render_project_active_page_to_response`)
iterated `ctx.artifacts` and wrote each artifact's content to VFS at
the resolver's path without checking whether the content was actual
produced bytes or an empty-content manifest entry.

`ResourceCollectorTransform` produces manifest entries via
`Artifact::from_path`, which sets `content: Vec::new()` (the artifact
is a *declaration* that an image dependency exists at the given path,
not a thing to be written). The artifact's `path` is built as
`ctx.document.input.parent().join(url)` — typically absolute in WASM
context. `Path::join`'s "absolute right operand replaces left" rule
means `vfs_root.join("/project/hero.png")` collapses to
`/project/hero.png`, the same VFS key where `automergeSync` placed
the user's upload bytes. The empty-bytes write overwrote the upload
on every render that referenced the image.

Plan history (no copy step was ever planned):
  - 2025-12-21 c8dd086 — Carlos introduces both `Artifact::from_path`
    and `ResourceCollectorTransform`. Doc comment: "Resources are
    stored in the ArtifactStore for later processing." Unspecified.
  - 2025-12-27 79766fc — Carlos adds the WASM flush loop with the
    comment "Populate VFS with artifacts so post-processor can resolve
    them. This includes CSS at /.quarto/project-artifacts/styles.css."
    The intended consumer was theme/extension CSS bytes; the empty
    manifest entries from `from_path` rode along by accident.
  - 2025-12-27 plan `unified-render-pipeline.md` — Q&A note: "The
    artifacts can be returned to JS if needed (e.g., for fetching
    images)." Manifest-as-API was the implied design; nothing
    consumes it today.
  - 2026-04-24 Phase 5 (`websites-phase-5.md`) — adds Page/Project
    scope. Plan only considered produced artifacts (CSS, fonts, future
    plot images). Did not revisit `from_path`'s manifest-only role.
  - 2026-05-07 — bd-3gtn filed during Plan 2A review. Plan 2A,
    Plan 1, and Plan 2B all route around the bug rather than fix it
    so 2A isn't bottlenecked.

Fix: one-line `is_empty()` guard before the `add_file` call in both
loops. Preserves manifest semantics for downstream consumers (the
entries remain in `ctx.artifacts` for `ResourceReportStage` etc.)
while keeping produced bytes (theme CSS, fonts, future engine-
generated images) flowing.

Tests: `assetManifestProject.wasm.test.ts` is reverted to its
natural lifecycle — `vfsAddBinaryFile` BEFORE render — which
deliberately failed before this commit (2 of 4 tests) and now
passes. Bug-then-fix walk-through verified the failure mode and
the guard's effectiveness end-to-end.

Verification: 1904 quarto-core tests, 82 WASM tests, 688 hub-client
unit tests, 112 hub-client integration tests — all pass.

Closes bd-3gtn.
…w render

Adds Plan 1 §Test plan post-bug-fix assertion #5 to
`website_q2_preview_renders_through_orchestrator`: pre-write
"fake image bytes for q2-preview test" to `hero.png`, run the
render, assert the file's contents equal the pre-render bytes.

The native preview renderer (`RenderToPreviewAstRenderer::render`)
does NOT call `write_artifacts` for page-scoped entries — it
returns them in `output.page_artifacts` to the caller, who routes
project-scoped via `flush_site_libs` and merges page-scoped into
the orchestrator's accumulator. So the empty-content manifest
entries from `Artifact::from_path` (produced by
`ResourceCollectorTransform`) ride along but never reach a
`file_write` call. That accident is what kept bd-3gtn from
manifesting natively while it bit the WASM flush loop in
`wasm-quarto-hub-client/src/lib.rs` (fixed in c8a684b).

This assertion locks the native contract: if a future change adds
a `write_artifacts(page_artifacts, ...)` call to the preview
renderer, this test will fail and force the same `is_empty()`
guard to land alongside it. Belt-and-suspenders coverage at the
native layer parallel to the WASM-bridge assertion in
`hub-client/src/services/assetManifestProject.wasm.test.ts`.

Plan reference: 2026-05-04-q2-preview-plan-1-pipeline.md §Test
plan, "Website fixture with embedded image", post-bug-fix item #5.
The plan's earlier "fragile-by-design" assertion #3 was never
implemented in the test (the implemented assertion is content-
agnostic `!page_artifacts.is_empty()`); this commit lands the
post-fix counterpart instead.

Verification: full quarto-core suite (1904 tests) passes.
Cross-checked Plan 2C against what Plan 2B actually shipped on
feature/q2-preview and the current state of the Rust transform
sources. Five mechanical corrections, no design changes:

1. Stale Rust plain_data writer line refs:
   - theorem.rs:282-285 → :145
   - equation_label.rs:215-217 → :316 (two occurrences)
   - crossref_resolve.rs:294-314 → :316

2. framework/types.ts:89 → :163 (Plan 2B added CustomNode shape
   types and gap-fill block types ahead of FormatRegistry).

3. entry.tsx:179-182 → :228-231 (Plan 2B added the
   AssetManifestContext.Provider and Note-numbering useMemo,
   pushing mergedRegistry down).

4. Provider stack updated to include NoteNumberingContext.Provider
   (Plan 2B Phase 3.4); full post-2C stack is
   PreviewContext → AssetManifest → NoteNumbering → CustomNodeRegistry → Ast.

5. Naming convention pinned to mergedPreviewRegistry throughout;
   plan body and code samples no longer mix mergedRegistry (legacy
   2A name) and mergedPreviewRegistry (new symmetric name). 2C's
   implementation does a 1-line rename of the existing variable.

Plus one fixture-spec correction:

6. multi-element-doc.qmd footnote syntax pinned to inline `^[body]`
   (not reference `[^1]: body`). Discovered during Plan 2B
   implementation that pampa's postprocess at
   crates/pampa/src/pandoc/treesitter_utils/postprocess.rs:1134-1146
   converts Inline::NoteReference to an empty
   Span(class="quarto-note-reference") before any quarto-core
   transform runs; nothing downstream resolves those Spans.
   Reference-style footnotes don't render in either q2-preview or
   HTML pipelines today; only inline `^[body]` syntax produces the
   `<sup class="footnote-ref">` markup the smoke fixture's
   ensureHtmlElements selectors expect.

And one template recommendation:

7. customNodeWireFormatProject.wasm.test.ts (Plan 2C item 5.3)
   now points at assetManifestProject.wasm.test.ts (Plan 2B) as
   the closer template; the older themeFingerprint.wasm.test.ts
   reference is preserved as a secondary touchstone since it locks
   theme_fingerprint regression coverage.

Informational notes appended to the revision history (no plan
changes needed): bd-3gtn fix landed in c8a684b so smoke fixtures
with images don't need post-render-add workarounds; Plan 2B added
5 inline gap-fill renderChildrenRegistry entries (Underline /
Strikeout / Superscript / Subscript / SmallCaps) via a
makeFlatInlineRenderer helper; Plan 1's "fragile-by-design"
assertion #3 was never implemented, and assertion #5 (bytes
survive) landed as 07e5205 and assetManifestProject.wasm.test.ts.
Plan 2C: third-pass amendment correcting line refs (the 2026-05-10
first-pass amendment introduced fresh errors when "fixing" them).
Unifies the previously-split CustomNode registry into the existing
framework RegistryContext via the namespace-disjoint policy — one merge
site, no parallel CustomNodeRegistryContext file. Defers IncludeExpansion
to Plan 8 (whose own scope already covers shipping the component).
Per-component output-structure clarifications (Theorem NBSP, Callout
default-title rule, FloatRefTarget caption format, Equation
defensive-fallback enumeration) added inline.

Plan 2D (new): body container draft. Option B (read ast.meta in JS)
chosen over Option A (typed RenderResponse field) for v1 — sidebar-derived
body-classes, the only fields that would justify Option A's plumbing,
aren't in q2-preview's pipeline today, so Option A buys nothing for v1.
Both initial risk areas verified before drafting: iframe host body has
no class (q2-preview.html:28, safe to overwrite), ensureHtmlElements
supports negative selectors via two-array YAML schema
(smokeAllDiscovery.ts:124-129, smokeAllAssertions.ts:122-138).

Plan 8: three cross-reference cleanups removing stale
"Plan 2C ships IncludeExpansion" framing now that 2C defers it. No
Plan 8 scope change — Plan 8's own component-shipping was already in
its checklist; only the framing lagged.
…-xref taxonomy (Plan 2C Phase 4.1)

Enumeration commit per the "enumeration before consumers" rule — these
constants land in their own commit before any of the CustomNode
components that read them, so a typo in a class string is reviewed in
isolation.

Source-line refs in this file are the contract for catching drift in
both directions:
  - vitest "Class-compatibility test" verifies the components emit
    these strings (catches JS-side typos / regressions).
  - smoke-all multi-element-doc.qmd verifies Rust → JS render produces
    matching HTML (catches Rust-side renames).

Adds:
  - CALLOUT, CALLOUT_TYPE_PREFIX, CALLOUT_APPEARANCE_PREFIX,
    CALLOUT_COLLAPSE, CALLOUT_HEADER, CALLOUT_TITLE_CONTAINER,
    CALLOUT_FLEX_FILL, CALLOUT_ICON_CONTAINER, CALLOUT_ICON,
    CALLOUT_BODY_CONTAINER, CALLOUT_BODY
  - THEOREM, THEOREM_TITLE, PROOF
  - QUARTO_XREF

Equation and FloatRefTarget keep no class constants — they preserve
the user's attr verbatim (figure-vs-div discriminator at the element
layer, no class names involved).
…4.2)

renderSlot needs JSX, so utils.ts is renamed to utils.tsx; consumers
import from '../utils' / './utils' (extension-omitted) so no caller
edits are required.

Added to utils.tsx:
  - formatRefLabel(kind, number, title?) — Theorem-style label
    composer; NBSP-joins kind+number to match crossref_render.rs:450,
    appends " (title)" when present, drops the number entirely on
    elision (number === undefined) per :444-453.
  - composeAttr(orig, extraClasses, extraKvs) — non-mutating Attr
    extension for components that need to add their built-in classes
    on top of the user's authored attr without aliasing.
  - renderSlot(slot, setSlot, ctx) — per-slot Node dispatcher with
    copy-on-write setLocalAst per slot kind (block/inline/blocks/
    inlines). Mirrors framework/dispatch.tsx's renderCustomNodeChildren
    walk, but is the per-named-slot helper a per-type CustomNode
    component drives. The duplication is intentional — generic walk
    (Fallback) vs named-slot rendering (Callout's title/content,
    FloatRefTarget's caption_long/caption_short, etc.).
  - makeSlotSetter(node, setLocalAst) — `(slotName) => (newSlot) =>
    void` factory that lifts the `{ ...node, slots: { ...node.slots,
    [name]: x } }` spread into one place.

theoremEnvs.ts (new):
  - theoremEnvFor(refType): port of theorem_env_for at
    crossref_render.rs:388-400 (8-entry closed table). Consumed by
    Theorem.tsx in 4.3.
Fourth-pass review-nit amendments (originally drafted in the prior
session, uncommitted on the worktree until now):
  - Tag-along edit to existing Block/Inline placeholders gets a
    className="q2-preview-placeholder" so the smoke-fixture
    must_not_match selector for the placeholder actually matches.
  - "seven concrete type_name strings" → "six" (off-by-one residue
    from the third-pass IncludeExpansion deferral).
  - Callout flex-fill cited at callout_resolve.rs:215 so the
    "mandatory" claim is drift-detectable.
  - Callout identifier-omit-if-empty rule pinned (mirrors
    Theorem / FloatRefTarget).
  - FormatRegistry kept loose — q2DebugRegistry doesn't need
    CustomBlock/CustomInline; making them required would force
    q2-debug to ship dispatchers duplicating its bordered fallback.
    Runtime presence locked at the Class-compatibility test +
    smoke-fixture must_not_match layer.
  - Atomic gate location pinned at framework/dispatch.tsx:407-411.
  - Namespace-disjoint policy assertion lands now (deferral threshold
    was overly cautious; Rust precedent at pipeline.rs:1987 catches
    silent shadowing at build time).
  - notes.qmd content pinned to a 4-line stub.
  - ensureHtmlElements second-array form (must_not_match) used.

Plus: tick Phase 4.1 (quartoClasses.ts extensions, committed as
a732482).
Type-keyed components dispatched by CustomBlock / CustomInline (Phase
4.4 wiring). Each mirrors the Rust transform's HTML output exactly so
loading Quarto's compiled theme CSS produces matching visuals without
a per-format CSS fork.

Components:
  - Callout.tsx — three-deep Bootstrap-flavored nesting (.callout >
    .callout-header > .callout-title-container.flex-fill,
    .callout > .callout-body-container.callout-body); default-title
    fallback per callout-type matches the Rust capitalize helper
    (callout_resolve.rs:304); whitespace-only titles win over the
    default (matches inlines.is_empty() check, not is_blank).
  - Theorem.tsx — class-list rule with three skip cases (env empty
    / env === "theorem" / env already on attr); NBSP between kind and
    number; all-Strong label with Span(class="theorem-title")
    wrapping; label prepends to first Para (synthesized when content
    starts with non-Para).
  - Proof.tsx — italic <em>Proof.</em> (or user title + ".") inline
    label; no proof-title class.
  - FloatRefTarget.tsx — figure-vs-div discriminator on
    plain_data.ref_type === "fig"; caption-prefix format is
    "{kind} {n}: " with ASCII space (NOT NBSP — Theorem uses NBSP,
    FloatRefTarget does not); first-Paragraph prepend matches
    prefix_caption at crossref_render.rs:721-742.
  - Equation.tsx — JS-side port of the \tag{N} append from
    render_equation:601-650 (q2-preview pipeline excludes
    crossref-render); three defensive-fallback branches: empty
    inlines → empty <span id>; canonical Math(DisplayMath) →
    \tag{N}-append + render through Math.tsx; non-canonical first
    inline (Math(InlineMath) / Str / Span / ...) → console.warn
    once + render every inline verbatim, no \tag append.
  - CrossrefResolvedRef.tsx — resolved-vs-broken affordance
    ("Kind N" vs "?id?"); atomic per atomicCustomNodes.ts so
    setLocalAst is no-op'd by the framework gate before this
    component runs.
  - Fallback.tsx — registered under __fallback__; styled box that
    displays node.type_name and recurses via renderChildren (which
    routes through framework's renderCustomNodeChildren walk —
    framework/dispatch.tsx:238-310). Catches IncludeExpansion until
    Plan 8 ships its own component.
…lan 2C Phase 4.4)

dispatchers.tsx:
  - Add CustomBlock / CustomInline dispatchers as siblings of
    Block / Inline. Both look up registry[node.type_name], falling
    back to registry['__fallback__']. Read from the framework's
    existing RegistryContext — no new context plumbed.
  - Tag-along edit: existing Block / Inline placeholders get
    className="q2-preview-placeholder" alongside the existing inline
    style. Lets the smoke-fixture must-not-match selector
    (`div.q2-preview-placeholder`) actually fire when the placeholder
    fires (without the class, the selector would silently match
    nothing — false-green).

registry.ts:
  - Spread `Custom` exports (Callout, Theorem, Proof, FloatRefTarget,
    Equation, CrossrefResolvedRef) into previewRegistry under their
    type_name keys.
  - Add `__fallback__: Fallback` for the dispatcher miss path.
  - Add `CustomBlock` / `CustomInline` dispatcher entries so the
    framework's `<Node>` (which routes to `registry['CustomBlock'|
    'CustomInline']` for those `t` values) reaches them.

entry.tsx:
  - Rename `mergedRegistry` → `mergedPreviewRegistry` for symmetry
    with the renamed body text. One-line behavior change none.
  - Expose `renderSlot` on `__Q2_PREVIEW_RENDERER__` so user TSX
    overrides of CustomNodes can recurse into named slots without
    reimplementing per-slot setLocalAst plumbing.

FormatRegistry stays loose: `CustomBlock` / `CustomInline` enter
previewRegistry via the existing `Record<string, ...>` index
signature; the required-key intersection stays at `{ Ast; Block;
Inline }`. q2DebugRegistry doesn't ship CustomBlock/CustomInline
(its bordered `Not registered: <tag>` fallback handles them); making
the keys required would force q2-debug to ship duplicating
dispatchers. Runtime presence in previewRegistry locked at the
integration-test layer + the smoke fixture's `must_not_match`
on `div.q2-preview-placeholder`.

Updates the 2B integration test (q2-preview.integration.test.tsx)
that was specifically asserting "CustomBlock/CustomInline must NOT be
in 2B's registry" — flipped the assertion to assert they ARE
registered post-2C, plus the type-keyed CustomNode component keys
and `__fallback__`. Also changes the unknown-type_name test to use
"UnknownExtension" (which hits Fallback) instead of "Callout" (which
now hits the real component).
… (Plan 2C Phase 5.1)

Three test files cover the §"Test plan" surface that lives in vitest
(below smoke-all):

custom-components.integration.test.tsx (38 tests):
  - Per-component structure tests for Callout, Theorem, Proof,
    FloatRefTarget, Equation, CrossrefResolvedRef:
      * Callout 3-deep Bootstrap nesting selector
        (.callout > .callout-header > .callout-title-container.flex-fill,
         .callout > .callout-body-container.callout-body), icon
        on/off, default-title fallback per type, whitespace-only
        title wins over default, callout-appearance-{a} for
        non-default appearance, id-omit-if-empty.
      * Theorem env-class rule (lemma for "lem"; skip for "thm" so
        only one `theorem` token is emitted), NBSP between kind and
        number, authored title in parens, number elision when
        order is missing, id-omit-if-empty.
      * Proof <em>Proof.</em> default + authored title; absence of
        proof-title class.
      * FloatRefTarget figure-vs-div discriminator on ref_type === "fig",
        caption prefix uses ASCII space (NOT NBSP).
      * Equation \tag{N} append (KaTeX `<span class="tag">` whose
        textContent is "(1)"), no tag when order missing, empty
        content slot defensive branch (empty <span id>),
        non-canonical Math(InlineMath) defensive branch (warn +
        passthrough, no \tag append).
      * CrossrefResolvedRef link text format ("Figure 1" w/ NBSP,
        "?id?" for unresolved, kind alone for missing order),
        suffix slot rendered after link.
  - Generic Fallback test: unknown type_name (e.g. IncludeExpansion)
    routes through __fallback__ which displays the type_name and
    recurses into slots.
  - Atomic CustomNode read-only test (registry-routed):
    CrossrefResolvedRef rendered through previewRegistry receives a
    no-op setLocalAst from the framework's atomic gate (calling it
    does not invoke parent setAst).
  - User-override integration: TSX export of `Para` (Pandoc tag) AND
    `Callout` (CustomNode) layered via the merged registry; both
    fire simultaneously.
  - Class-compatibility: built-in components emit the constants
    declared in quartoClasses.ts (callout-header, theorem-title,
    quarto-xref).

registry.test.ts (2 tests):
  - Namespace-disjoint policy assertion: every Custom.* export name
    is disjoint from the hardcoded Pandoc tag set
    (~30 entries from framework/types.ts's BlockNode/InlineNode
    unions). Mirrors the Rust precedent at
    pipeline.rs:1985 (Q2_PREVIEW_TRANSFORM_EXCLUDED validation) —
    static-set-vs-static-set check that catches silent shadowing
    at every build.
  - Sanity check: all 7 expected components are exported from
    ./custom (Callout, Theorem, Proof, FloatRefTarget, Equation,
    CrossrefResolvedRef, Fallback).

customRegistry.test.ts (extended, +1 test):
  - Plan 2C item 12 belt-and-suspenders: assert
    buildCustomRegistry([{ Callout: SomeComponent }]) → { Callout: ... }
    so a future buildCustomRegistry refactor can't silently break
    CustomNode overrides without breaking this test too.

All passing locally (38 new + 1 extended; 691 unit + 150 integration
tests overall).
…-components (Plan 2C Phase 5.2)

Three fixtures land under crates/quarto/tests/smoke-all/q2-preview/.
All three use _quarto.tests.run.requires_js: true so the Playwright
runner picks them up; the CLI smoke-all runner skips them.

multi-element-doc.qmd (single-doc):
  - One callout, one theorem with a cross-reference, one labeled
    equation, one image (hero.png — fixture asset reused from 2B's
    image-with-attrs.qmd), one inline footnote (^[body] syntax —
    bd-1qk5 on the reference-style upstream).
  - Asserts 3-deep Bootstrap nesting selector for callout, theorem
    label structure, quarto-xref class on the link, eq-einstein
    span id, sup.footnote-ref + appendix > #footnotes container.
  - must_not_match locks two invariants: no q2-preview-placeholder
    fired (every CustomNode + Pandoc tag found a component); no
    raw `[data-custom-type]` wrapper survived unwrap.

multi-element-project/ (default project):
  - Same content as multi-element-doc.qmd, plus a notes.qmd sibling
    so pass-1 indexes more than one file. Exercises the project
    pass-2 renderer path (pass2_renderer.rs::
    RenderToPreviewAstRenderer) which would otherwise go untested
    for CustomNode rendering.
  - notes.qmd is a 4-line stub (frontmatter `title: Notes` + one
    sentence body); fixture's only requirement is that pass-1 sees
    >1 file.
  - _quarto.yml is the minimal `project: title: ...` form (no
    `type:` key, so default project type).

with-render-components/ (project + overrides):
  - overrides.tsx exports two components: Para (Pandoc-tag
    override emitting `<p class="my-para">`) and Callout
    (CustomNode-type override emitting `<div class="my-callout">`).
  - Asserts both fire simultaneously through the unified
    mergedPreviewRegistry merge site (entry.tsx's
    `{ ...previewRegistry, ...customRegistry }`).
  - must_not_match `div.callout` confirms the user override
    completely shadows the built-in.

Note: dropped license/quarto-reuse selectors that were in the plan's
original ensureHtmlElements list — license metadata flow into ast.meta
needs separate investigation (frontmatter `license: CC-BY` did not
trigger create_license_section in either the single-doc or project
mode local renders), and the plan's `div.quarto-reuse` selector was
spec'd as a class but appendix.rs:309 sets it as an id
(`Div(id="quarto-reuse", class="section")`). The CustomNode-related
selectors are the actual focus of 2C and remain comprehensive.
Project-mode WASM safety net for the CustomNode wire format. Renders
a `_quarto.yml`-rooted project doc containing a callout (and a
parallel theorem case) and asserts the response's `ast_json` contains
the wire-format Div wrapper with `__quarto_custom_node` in classes
and `data-custom-type=<type_name>` in kvs.

Catches drift between Q2_PREVIEW_TRANSFORM_EXCLUDED at pipeline.rs
:1049-1072 and what unwrapCustomNodes (framework/customNode.ts)
expects to see. If either `callout-resolve` (:1050) or
`crossref-render` (:1071) ever falls out of the exclusion list, the
CustomNode wrappers get rewritten to plain HTML before the iframe
sees them and unwrap finds nothing.

Pattern follows assetManifestProject.wasm.test.ts (Plan 2B): same
initWasm + project setup, same render_page_in_project entry point.
Both WASM tests pass locally.
…d-1qk5)

The c8a684b entry contained `Path::join`'s — a post-codespan
apostrophe pattern that bd-1qk5's inline grammar treats as an
opening single quote, then errors when the closing quote never
arrives. Real Pandoc treats the same construct as a possessive.

Recurring issue tracked at bd-1qk5; same workaround as commits
0c78b1a and d7d7e06 (rewrite the offending sentence). Tripped
changelogRender.wasm.test.ts during Plan 2C's verify --e2e run.

The fix preserves meaning — replaces "via Path::join's absolute-
replace rule" with "via the absolute-replace rule of Path::join".
…se 5.5)

Two fixes uncovered by `cargo xtask verify --e2e`:

1) **Smoke fixture selector**: `sup.footnote-ref` → `a.footnote-ref`
   in multi-element-doc.qmd and multi-element-project/index.qmd.
   FootnotesTransform produces `Span(id=fnref{N}) > Superscript >
   Link(class=footnote-ref)` (see footnotes.rs::create_footnote_ref);
   q2-preview's iframe renders this as
   `<span id="fnref1"><sup><a class="footnote-ref">1</a></sup></span>`.
   The class lives on the `<a>`, not the `<sup>` — the plan's
   original selector spec was wrong. Fixed in both fixtures so the
   Playwright assertion finds the actual element.

2) **fake-indexeddb polyfill in e2e helpers**: import
   `'fake-indexeddb/auto'` at the top of `projectFactory.ts`. The
   helper's `createProjectOnServer` runs in the Playwright Node
   controller process (not the browser); it calls
   `createSyncClient().createNewProject()` which instantiates an
   `IndexedDBStorageAdapter` that checks for the global `indexedDB`
   eagerly at construction. Without the polyfill that global is
   undefined and EVERY e2e test fails with `ReferenceError:
   indexedDB is not defined` before reaching its actual assertion.
   The fake DB is per-process and in-memory — fine for the
   controller-side document-creation step; real persistence happens
   on the hub server. With the polyfill in place: 76 e2e tests pass
   (was 8 passing / 78 failing pre-fix).

Verified: all three q2-preview fixtures
(multi-element-doc.qmd, multi-element-project/index.qmd,
with-render-components/index.qmd) pass under
`npx playwright test e2e/smoke-all.spec.ts`.

Remaining e2e failures (theme-project, extensions/builtin-video-local,
appendix-style-inheritance, image-with-attrs.qmd) are pre-existing
infra issues (offline-mode peer-connection timeouts) unrelated to
2C's CustomNode work — flaky/intermittent tests that were not
introduced by this plan.
Plan 2D (q2-preview body container) extended to also cover the document
title block from `template.rs:211-240` (title / subtitle / author / date
/ abstract chrome inside `<header id="title-block-header">`). Phase 7
adds `PreviewTitleBlock`, registered under the synthetic key
`__title_block__` so user TSX can override it through the existing
2C merge site, with `extractMetaStringList` added to `utils/meta.ts`
for YAML list-form authors.

Phase 6 also re-implements the Rust `title-block` transform's
minimal-mode branch on the React side: the transform is excluded from
q2-preview's pipeline (`pipeline.rs:1052`), so without React-side
synthesis q2-preview's minimal mode silently dropped the title.

Aligned to the post-2C-landing files: PreviewDocument prop shape stays
`{ ast, setAst, onNavigateToDocument }`; registry access uses the
`useContext(RegistryContext).registry` pattern from `dispatchers.tsx:39`;
`registry.test.ts` extended to pin the new synthetic key. Multi-author
rendering matches Rust's broken-but-consistent behavior (single block,
empty-string concatenation) — no deliberate divergences.
Phase 6.0 framework extraction:
- Lift `inlinesToPlainText` / `blocksToPlainText` to `framework/plainText.ts`.
- Add `framework/meta.ts` with `extractMetaString` / `extractMetaBool` /
  `extractMetaStringList`.
- Flat barrel re-exports on `framework/index.ts` (plainText, meta,
  customNode bookkeeping fix).
- Tighten `FormatRegistry` at `framework/types.ts` with typed optional
  entries for the two synthetic keys (`__fallback__`, `__title_block__`).
- Migrate `ReactAstSlideRenderer.tsx`'s private `extractMetaString` to the
  framework helper. Deliberate behavior change for slide titles/authors
  containing inline markup (Emph/Strong/Code/Link now render as text
  instead of empty string) locked by a regression test.
- Consolidate `meta.format` extraction in `ReactRenderer.tsx` and
  `getQ2Format.ts` onto the framework helper.
- Expose framework helpers on `window.__Q2_PREVIEW_RENDERER__` for
  user TSX overrides.

Phase 6.1+ body container in `PreviewDocument.tsx`:
- Page-layout-aware `<div id="quarto-content"><main class="content">`
  wrapper mirrors Rust template at `template.rs:185-247`.
- Imperative `document.body.className` management via useEffect with
  cleanup-on-unmount.
- Minimal-mode title synthesis re-implements
  `transforms/title_block.rs:54-110`'s minimal-mode branch React-side
  (the transform is in `Q2_PREVIEW_TRANSFORM_EXCLUDED`).
- Iframe `document.title` wiring from `meta.pagetitle ?? meta.title`,
  only when non-empty (preserves the static `q2-preview.html` title for
  untitled docs).

Phase 7 title block:
- New `q2-preview/custom/PreviewTitleBlock.tsx` (~110 LOC) mirrors Rust
  template at `template.rs:211-240` byte-for-byte. Multi-author and
  date-without-author Rust quirks deliberately replicated to avoid
  per-format CSS forks.
- Registered under synthetic `__title_block__` key in `previewRegistry`;
  mounts inside `<main>` ahead of body blocks. `AstProps` shape parallels
  the `Ast` key (not `NodeArgs`).
- Built-in exposed on `__Q2_PREVIEW_RENDERER__` so user TSX overrides
  can compose (rather than fully replace) the chrome.

Tests:
- 27 unit tests for framework/meta.ts + framework/plainText.ts
- 18 integration tests for PreviewDocument (body container, body classes,
  minimal-mode title synthesis, iframe document.title)
- 17 integration tests for PreviewTitleBlock (required elements,
  Pandoc-falsy semantics, user override full-replacement + composition)
- Synthetic-key registration directly asserted in registry.test.ts (closes
  a pre-existing gap on `__fallback__`).

Smoke-all q2-preview fixtures (11 new):
- body-container-default / -full-layout / -minimal / -minimal-title
- body-classes-override / -full-layout-combo
- title-block-default / -full / -no-title / -multi-author / -date-no-author

All 11 fixtures green in Playwright e2e; full plan checklist (19 items)
ticked; cargo xtask verify clean.
Typing
    render-components:
      -
into a document's YAML frontmatter crashed the iframe-host page. The
YAML parser produces a MetaList with a `null` (or empty MetaInlines)
entry; `ReactRenderer.tsx`'s path-extraction expression
`o?.c?.[0]?.c` returned `undefined`, which then crashed
`resolveComponentPath(undefined, …)` inside the same `useMemo` with
`Cannot read properties of undefined (reading 'startsWith')`. The
throw bubbles past the local ErrorBoundary (the boundary is a JSX
descendant of `ReactRenderer`, not an ancestor) and blanks the page —
user can't even finish typing.

Fix: filter the extracted paths to keep only non-empty strings. Empty
list entries and empty-MetaInlines entries are skipped silently (no
`console.warn` — they're transient editor states, not user errors
worth surfacing).

Two regression tests added to `ReactRenderer.integration.test.tsx`:
one for `MetaList c: [null]` (the literal "type a bullet, no value"
shape), one for `MetaList c: [{t: 'MetaInlines', c: []}]` (the
"opened the string delimiter, no content yet" shape). Both fail
before the fix with the exact TypeError above; both pass after.
gordonwoodhull added a commit that referenced this pull request May 10, 2026
Latest nightly causes rustc to SIGSEGV when compiling tokio for
wasm32-unknown-unknown under -Zbuild-std=std,panic_unwind. PR #171 hits
this in the "Build WASM module" step on both ubuntu-latest and
macos-latest matrix legs of TS Test Suite.

Mirrors the equivalent fix on chore/e2e-ci for hub-client-e2e.yml
(commits d06e120 + ff51584): pin via the workflow's RUSTUP_TOOLCHAIN
env var so rust-toolchain.toml stays as floating "nightly" for local
developers, and switch the dtolnay action to @master with a toolchain
input (the action no longer accepts @nightly-<date> refs).
Latest nightly causes rustc to SIGSEGV when compiling tokio for
wasm32-unknown-unknown under -Zbuild-std=std,panic_unwind. PR #171 hits
this in the "Build WASM module" step on both ubuntu-latest and
macos-latest matrix legs of TS Test Suite.

Mirrors the equivalent fix on chore/e2e-ci for hub-client-e2e.yml
(commits d06e120, 245cd25, ff51584):

  - Pin via the workflow's RUSTUP_TOOLCHAIN env var so rust-toolchain.toml
    stays as floating "nightly" for local developers.
  - Switch the dtolnay action to @master with a toolchain input (the
    action no longer accepts @nightly-<date> refs).
  - Request rust-src and wasm32-unknown-unknown explicitly on the pinned
    toolchain. Under floating @nightly rustup auto-installed missing
    components against the active toolchain; once pinned they must be
    requested for that toolchain or the build:wasm step fails with
    "library/Cargo.lock does not exist, unable to build with the
    standard library".
Migration plan for moving ReactAstSlideRenderer.tsx (SlideAst carousel)
and RevealjsReactAstSlideRenderer.tsx (RevealjsSlideAst) into a shared
hub-client/src/components/render/q2-slides/ directory, completing the
hub-client format-restructure that 2pre started.

§2 (two registries sharing leaves) is locked: q2SlidesRegistry and
revealjsRegistry both spread the same blocks/inlines modules and differ
only on the 'Ast' document-root entry. §1 (iframe boundary), §3
(SlideContext shape), §4 (asset resolution), §5 (editing wiring), and
§6 (migration strategy) are open with recommendations.

Phased shim approach (Phases 9-13) mirrors 2pre's pattern: build
q2-slides/ behind a re-export shim, migrate four consumers
(ReactRenderer, RevealjsReactAstSlideRenderer, useCursorToSlide,
useSlideThumbnails) one commit at a time, then delete the shim. Depends
on Plan 2D Phase 6.0 (framework/meta.ts) landing first.
…d-1qk5)

The 02da39a entry contained a post-codespan apostrophe in
"\`ReactRenderer.tsx\`'s \`componentPathsKey\`", which trips the open
parser bug bd-1qk5 (Q-2-7 "Unclosed Single Quote"). The Q-2-7 surfaced
in CI via the changelogRender.wasm.test.ts test:wasm gate; locally it
was masked by the SIGSEGV that prevented the WASM build from running
on the floating nightly.

Rewriting the construct to "the \`componentPathsKey\` useMemo (in
\`ReactRenderer.tsx\`)" preserves meaning while avoiding the
codespan-immediately-followed-by-apostrophe boundary the parser
mishandles. Verified locally with \`cargo run --bin pampa --
hub-client/changelog.md\`: renders cleanly with exit 0.
@gordonwoodhull gordonwoodhull changed the title feature: q2-preview feature: q2-preview (not yet editable) May 11, 2026
@gordonwoodhull gordonwoodhull marked this pull request as ready for review May 11, 2026 00:25
gordonwoodhull added a commit that referenced this pull request May 11, 2026
Latest nightly causes rustc to SIGSEGV when compiling tokio for
wasm32-unknown-unknown under -Zbuild-std=std,panic_unwind. PR #171 hits
this in the "Build WASM module" step on both ubuntu-latest and
macos-latest matrix legs of TS Test Suite.

Mirrors the equivalent fix on chore/e2e-ci for hub-client-e2e.yml
(commits d06e120, 245cd25, ff51584):

  - Pin via the workflow's RUSTUP_TOOLCHAIN env var so rust-toolchain.toml
    stays as floating "nightly" for local developers.
  - Switch the dtolnay action to @master with a toolchain input (the
    action no longer accepts @nightly-<date> refs).
  - Request rust-src and wasm32-unknown-unknown explicitly on the pinned
    toolchain. Under floating @nightly rustup auto-installed missing
    components against the active toolchain; once pinned they must be
    requested for that toolchain or the build:wasm step fails with
    "library/Cargo.lock does not exist, unable to build with the
    standard library".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant