Skip to content

Feature/q2 preview command#214

Merged
cscheid merged 144 commits into
mainfrom
feature/q2-preview-command
May 19, 2026
Merged

Feature/q2 preview command#214
cscheid merged 144 commits into
mainfrom
feature/q2-preview-command

Conversation

@cscheid
Copy link
Copy Markdown
Member

@cscheid cscheid commented May 19, 2026

Basics of q2 preview are there:

  • website rendering and fast update
  • engine execution
  • navbar features

A lot of bugs and unimplemented features, but I want to start working on q2's website on main with it.

cscheid and others added 30 commits May 13, 2026 09:31
Architecture: ephemeral local hub-client (samod + file watcher) serves
a pared-down React SPA that renders via the q2-preview format. Engines
run server-side via `engine: replay` and the EngineCapture flows
through automerge to the browser, which replays in WASM. No DOM
rebuild on edit — Bootstrap / MathJax / reveal.js state survives.

Phasing: A (skeleton + standalone SPA serving), B (broadened watcher +
dep-graph invalidation), C (record-on-demand engine + replay), D
(polish), E (stretch). All seven open questions resolved in the
2026-05-11 reviews — see plan §Resolutions from 2026-05-11 review #2.

Two follow-ups filed:
- bd-hfjj (epic, blocks bd-kw93): hub-client decomposition — shared
  preview-pane package so hub-client and the preview SPA stay in
  lockstep by construction.
- bd-56b0 (task, related to bd-kw93): cross-doc dependency channel
  audit. Phase A's always-visible force-refresh button is the user
  escape hatch until this lands.

Branch: feature/q2-preview. No code changes; this is the plan +
beads index update.
Concretizes the bd-hfjj sub-epic of bd-kw93 with package boundaries
(two packages: @quarto/preview-renderer + @quarto/preview-runtime),
SPA location (top-level q2-preview-spa/), MVP scope (React preview
path only; Preview.tsx / slides / debug stay in hub-client), WASM
access (existing symlink + per-consumer alias), and a 7-phase
sequencing that keeps tests green across each move.

This branch holds both this sub-epic plan and the parent epic plan
(via the 03fc123 ancestor commit). Parked off feature/q2-preview
while the other workstream there settles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ces (bd-hfjj Phase 1)

Creates two empty workspace packages mirroring the
ts-packages/quarto-sync-client pattern:

- `@quarto/preview-renderer` — pure React; jsdom-based integration
  config; deps on react, react-dom, morphdom.
- `@quarto/preview-runtime` — WASM + automerge glue; both vitest
  configs declare the `wasm-quarto-hub-client` alias pointing at
  hub-client's existing symlink; deps on @automerge/automerge,
  @automerge/automerge-repo, @quarto/quarto-sync-client,
  @quarto/quarto-automerge-schema, web-tree-sitter.

Each package ships an `export {}` index, a trivial placeholder test
(green on both), and a small jsdom polyfill `test-utils/setup.ts` so
its integration config is self-contained. Both pass `test`,
`typecheck`, and `build`.

Phase 0 pre-flight is also captured in this commit:
- catalogue audit added customRegistry, atomicCustomNodes, and
  utils/sourceInfo to the renderer move list (drift discovered by
  grep against the current tree);
- test-helper placement section records dispositions
  (mockSyncClient/mockWasm → runtime; visibility/setup/userSettings
  stay in hub-client);
- plan status flipped from "draft — awaiting review" to "approved;
  Phases 0–1 complete; Phase 2 next".

Hub-client probe imports (added to main.tsx, compiled cleanly through
both `tsc --noEmit` and `vite build`, then reverted) verified that
both packages resolve via npm's workspace symlinks + tsc's bundler
moduleResolution + vite's `source` condition. No changes to
hub-client landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… Phase 2)

Moves 5 type modules and 7 utility modules out of hub-client into the
new @quarto/preview-renderer workspace, behind sub-path exports:

  ts-packages/preview-renderer/src/types/
    project.ts (+ test), diagnostic.ts, artifactPaths.ts,
    sourceInfo.ts, intelligence.ts

  ts-packages/preview-renderer/src/utils/
    vfsPaths.ts (+ test),
    iframeLinkHandlers.ts (+ integration test),
    componentPath.ts (+ test),
    stripAnsi.ts (+ test),
    customRegistry.ts (+ test),
    atomicCustomNodes.ts,
    sourceInfo.ts (+ test)

`git mv` preserves history. All 64 importers across 44 hub-client
files are rewritten from `../<dir>/types/<m>` / `../<dir>/utils/<m>`
to `@quarto/preview-renderer/types/<m>` / `/utils/<m>`. The package's
exports map uses wildcard sub-paths because `types/diagnostic` and
`types/intelligence` both legitimately export `Diagnostic` /
`DiagnosticDetail` (the former @deprecated), and a single barrel
would force every importer to alias one side.

Two files originally scheduled for Phase 2 were deferred after audit:

- `components/ThemeContext.tsx` — no moving file consumes it, but it
  imports `services/preferences/` (localStorage). The sound move is a
  DI refactor, deferred until the SPA actually needs theme switching.
  Tracked as follow-up `bd-hfjj-fu-theme`.
- `utils/iframePostProcessor.ts` — imports `vfsReadFile` /
  `vfsReadBinaryFile` from `services/wasmRenderer`, which itself
  moves in Phase 5. Moving the postprocessor with wasmRenderer then
  is cleaner than adding a DI seam now. Its remaining internal import
  of `./vfsPaths` is updated to `@quarto/preview-renderer/utils/vfsPaths`.

Hub-client's three vitest configs gain a
`@quarto/preview-renderer` → `ts-packages/preview-renderer/src` alias.
Vitest doesn't honor the package.json `source` condition the same way
Vite's prod build does on fresh clones; the alias matches the
existing sync-client / automerge-schema pattern.

preview-renderer adds `@quarto/quarto-automerge-schema` as a workspace
dep (required by `types/project`), and its own vitest.config.ts gains
the same alias so its tests resolve workspace deps to source.

Hub-client's test:ci, build:all, and `cargo xtask verify
--skip-rust-tests` all pass. Preview-renderer's unit (81 tests across
7 files) and integration (7 tests) suites pass.

Snapshot tests: no snapshot files changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hfjj Phase 3)

Moves the entire components/render/framework/ subtree out of hub-client
into @quarto/preview-renderer:

  ts-packages/preview-renderer/src/framework/
    Ast.tsx, RegistryContext.tsx, dispatch.tsx,
    customNode.ts (+ test), meta.ts (+ test),
    plainText.ts (+ test), types.ts, index.ts

framework is exposed as a single barrel via `./framework` in
package.json's exports map (not a wildcard sub-path). Rationale: the
subtree mixes .tsx and .ts files, and Node's exports map can't
pattern-match both extensions in a single wildcard entry cleanly.
Every symbol re-exports through framework/index.ts already, so
consumers don't need direct sub-file imports.

107 imports across 60 hub-client files rewritten to
`from '@quarto/preview-renderer/framework'`. One dynamic
`await import('./framework')` in parity.integration.test.tsx also
caught and rewritten.

Framework's internal cross-dir imports — Phase 2 had rewritten
Ast/RegistryContext/dispatch to use self-package
`@quarto/preview-renderer/...` paths since their targets had moved
already — are converted to idiomatic relative paths
(`../types/sourceInfo`, `../utils/sourceInfo`, etc.) now that
framework itself lives inside the package.

preview-renderer's test suite now exercises 133 tests across 10 files
(adds customNode/meta/plainText tests). hub-client test:ci, build:all,
and `cargo xtask verify --skip-rust-tests` all green.

Snapshot tests: no snapshot files changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… 5, swapped before Phase 4)

Moves the WASM-bridge / automerge-sync / user-grammar services out of
hub-client into the new @quarto/preview-runtime workspace, plus pulls
iframePostProcessor (deferred from Phase 2) into @quarto/preview-renderer
now that its `vfsReadFile` / `vfsReadBinaryFile` dependency lives in
runtime.

To preview-runtime:
  src/wasmRenderer.ts (+ test)
  src/automergeSync.ts (+ test)
  src/userGrammar/Discovery.ts (renamed; + test)
  src/userGrammar/Cache.ts (renamed; + test)
  src/userGrammar/Highlight.ts (renamed; + WASM-init test)
  src/test-utils/mockSyncClient.ts (test helper)
  src/test-utils/mockWasm.ts (test helper)
  src/wasm-quarto-hub-client.d.ts (mirrored from hub-client so the
    package typechecks standalone)
  src/vite-shims.d.ts (`*.wasm?url` and `/src/wasm-js-bridge/*.js`
    ambient declarations)

To preview-renderer:
  src/utils/iframePostProcessor.ts (+ unit and integration tests)

Phase reordering: the original plan had Phase 4 (q2-preview / iframe
wrappers / overlays) before Phase 5 (services). Swapping is *safer*:
most Phase-4 files import `services/wasmRenderer` or
`utils/iframePostProcessor`, so moving services first means the
remaining Phase-4 movers already point at @quarto/preview-runtime
when their turn comes. Hub-client's full test surface keeps validating
the renderer-side code throughout this swap, so any service regression
surfaces immediately. See "Phase ordering note" in the plan.

Re-decided 2026-05-13: `assetWalker.ts` does NOT move to preview-runtime.
It stays inside `q2-preview/` and moves with it in Phase 4. Rationale:
assetWalker is an AST walker that *uses* VFS, not a VFS service; moving
it to runtime would create a circular preview-runtime → preview-renderer
dependency via `vfsPaths`. The plan's prior signal ("the test moves
alongside since it tests the runtime function") wasn't load-bearing.

Cross-package wiring changes:
  - preview-renderer gains @quarto/preview-runtime as a workspace dep
    (iframePostProcessor imports `vfsReadFile`).
  - preview-runtime gains @quarto/preview-renderer + @quarto/pandoc-types
    + @quarto/quarto-automerge-schema as workspace deps. The
    preview-renderer dep is for the wire-format `Diagnostic` /
    `RenderResponse` re-exports from `types/diagnostic`.
  - preview-runtime/package.json adds sub-path exports
    `./userGrammar/*` and `./test-utils/*`, plus a `test:wasm` script
    (foundation for moving the wasm-test runner later if useful).

WASM build-time path resolution:
  `wasmRenderer.ts`'s lazy `import('../wasm-js-bridge/sass.js')` was
  rewritten to `'/src/wasm-js-bridge/sass.js'` — the same Vite-root
  convention the Rust WASM module already uses via
  `wasm-bindgen raw_module = "/src/wasm-js-bridge/sass.js"`. Every
  consumer (hub-client today, the future q2-preview SPA) must host the
  bridge files at `src/wasm-js-bridge/` and Vite resolves uniformly.
  An ambient `declare module '/src/wasm-js-bridge/*.js'` shim in
  `preview-runtime/src/vite-shims.d.ts` keeps tsc happy.

Hub-client import rewrites:
  Two passes via `/tmp/rewrite-phase5-imports.py`:
    - Pass 1: `<rel>/services/wasmRenderer`, `<rel>/services/automergeSync`,
      `<rel>/utils/iframePostProcessor`, and the renamed userGrammar
      paths.
    - Pass 2: short-form intra-services imports (`./wasmRenderer`,
      `./automergeSync`) that the first pass's pattern didn't cover.
  Manually fixed three categories the regex couldn't:
    - `vi.mock('./wasmRenderer', ...)` and `vi.mock('./automergeSync', ...)`
      in test files (rewritten to mock '@quarto/preview-runtime').
    - Inline `import('../services/automergeSync').ActorIdentity` in
      Editor.tsx.
    - `await import('./framework')` in parity.integration.test.tsx
      (Phase 3 leftover that the framework rewrite caught here).

Hub-client's three vitest configs gain `@quarto/preview-runtime` aliases
to source (same pattern as `@quarto/preview-renderer`).

`Highlight.wasm.test.ts`'s `repoRoot` computation updated from
`../../..` to `../../../..` to account for the deeper directory.

All green:
  - preview-runtime: 60 tests / 6 files, tsc build clean
  - preview-renderer: 133 tests / 10 files (unit) + 8 integration,
    tsc build clean
  - hub-client: 525 unit tests, 173 integration tests, 79 wasm tests,
    `build:all` clean
  - cargo xtask verify --skip-rust-tests: all 9 steps pass

Snapshot tests: no snapshot files changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…renderer (bd-hfjj Phase 4)

The biggest single move in the decomposition — 94 files touched. Lifts
the q2-preview format components, the iframe wrappers, and the
overlays out of hub-client and into @quarto/preview-renderer.

To preview-renderer/src/q2-preview/ (entire subtree):
  AssetManifestContext, NoteNumberingContext, PreviewContext,
  PreviewDocument (+ integration test), assetWalker (+ test),
  dispatchers, entry (+ integration test), index.ts (barrel),
  quartoClasses, registry (+ test), theoremEnvs, utils,
  q2-preview.integration.test, custom-components.integration.test,
  + blocks/, inlines/, custom/ subtrees (each ~15-20 files).

To preview-renderer/src/iframe/:
  Q2PreviewIframe (moved out of q2-preview/, + integration test),
  MorphIframe, DoubleBufferedIframe.

To preview-renderer/src/overlays/:
  PreviewErrorOverlay (+ integration test), PreviewStaticInfoViews.

`PreviewErrorOverlay` was DI-refactored as part of this move. Its
previous internal `usePreference('errorOverlayCollapsed')` call coupled
it to hub-client's localStorage layer; that wasn't viable inside the
shared package. The component now takes optional `collapsed` +
`onToggleCollapsed` props (controlled), with an internal
`useState(true)` fallback for the uncontrolled case. The two hub-client
call sites in `ReactPreview.tsx` and `Preview.tsx` wrap with
`usePreference` and pass the value down — same UX, same persistence,
cleaner boundary. The future q2-preview SPA can pass any state or
omit the props entirely.

Import wiring:

  - Self-package `@quarto/preview-renderer/<X>` imports inside the
    moved subtree were converted to depth-aware relative paths via
    `/tmp/relativize-self-imports.py` (100 rewrites across 55 files).
    Idiomatic and matches the Phase 3 framework treatment.
  - `Q2PreviewIframe.tsx`'s `./assetWalker` import was rewritten to
    `../q2-preview/assetWalker` (it moved out of q2-preview into
    iframe/ as one of the few cross-subtree moves).
  - Hub-client importers of moved files were rewritten via
    `/tmp/rewrite-phase4-imports.py` (9 imports / 6 files in the
    first pass, plus 2 more after the regex was broadened to handle
    paths with intermediate `components/render/` segments).
  - One `vi.mock('./q2-preview/Q2PreviewIframe', ...)` in
    `ReactRenderer.integration.test.tsx` was retargeted to
    `'@quarto/preview-renderer/iframe/Q2PreviewIframe'`.

Hub-client iframe-entry shim:

  `hub-client/q2-preview.html` has a `<script type="module"
  src="/src/components/render/q2-preview/entry.tsx">` tag — Vite
  resolves that path relative to hub-client's project root. The real
  entry now lives in preview-renderer; we keep the original path
  stable by adding a one-line stub file at the hub-client location
  that just re-imports `@quarto/preview-renderer/q2-preview/entry`.
  `parity.integration.test.tsx`'s dynamic
  `import('./q2-preview/entry')` also goes through this stub.

  This required adding a specific `./q2-preview/entry` sub-path
  export to `preview-renderer/package.json` (the q2-preview barrel
  alone wouldn't expose `entry.tsx` since q2-preview/index.ts is the
  barrel, not entry).

preview-renderer public API surface:

  Top-level `src/index.ts` becomes a real barrel exposing
  `Q2PreviewIframe`, `MorphIframe` (+ Handle), `DoubleBufferedIframe`
  (+ Handle), `PreviewErrorOverlay`, `ErrorView` / `FallbackView` /
  `NonQmdPlaceholderView`, plus a `export * from './q2-preview'` for
  the rest of the q2-preview surface (Block, Inline, PreviewDocument,
  previewRegistry, PreviewContext, AssetManifestContext,
  buildAssetManifest, ManifestCacheEntry). Phase 6's q2-preview-spa
  placeholder will use this barrel directly.

Test-config plumbing in `preview-renderer/vitest.integration.config.ts`:
  - Workspace-package aliases: `@quarto/quarto-sync-client`,
    `@quarto/preview-runtime`.
  - `wasm-quarto-hub-client` → hub-client's symlink (JS shim resolves
    at transform; the WASM binary itself isn't invoked).
  - `/src/wasm-js-bridge` → hub-client's bridge dir, so
    `wasmRenderer.ts`'s lazy `import('/src/wasm-js-bridge/sass.js')`
    resolves at Vite's transform step. Scoped to that sub-tree (not
    plain `/src`, which would also intercept test files' own
    absolute paths).

All green:
  - preview-renderer: 156 unit / 13 files + 129 integration / 9 files
  - preview-runtime: 60 tests / 6 files (unchanged)
  - hub-client: 525 unit + 58 integration + 79 wasm tests; build:all
    clean.
  - `cargo xtask verify --skip-rust-tests`: all 9 steps pass.

End-to-end UI smoke: NOT run this session (no browser available in
the worktree). Per CLAUDE.md §End-to-end verification — tests pass,
the real render path was not exercised in a browser. To verify,
`cd hub-client && npm run dev`, open a Quarto project in the
returned URL, and confirm the q2-preview format still renders.

Snapshot tests: no snapshot files changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ation (bd-hfjj Phases 6 + 7)

Closes the bd-hfjj sub-epic. Phases 6 and 7 are mechanical follow-ups
to the big moves in Phases 2–5.

Phase 6 — q2-preview-spa skeleton:

  Creates a new workspace package `q2-preview-spa/` (sibling to
  hub-client/, trace-viewer/) that's the future host of the
  `quarto preview` CLI command from bd-kw93. Today it's a 4-file
  placeholder:

    package.json   — name, react/preview-renderer/preview-runtime deps
    vite.config.ts — react + wasm plugins, source-condition aliases
    tsconfig.{,app,node}.json — trace-viewer-shaped TS project refs
    index.html     — minimal shell
    src/main.tsx   — renders <PreviewErrorOverlay> with a placeholder
                     message, proving the cross-package boundary works.

  Bundle audit (after `npm run build`):
    - Total: 196K, 30 modules.
    - grep'd for editor-only symbols (Monaco, GoogleOAuthProvider,
      FileSidebar, ProjectSelector, automergeSync, createSyncClient,
      monaco-editor): all zero. The §Crate/SPA invariant from the
      epic is enforced by construction — editor code cannot be
      transitively pulled in because the SPA only imports from
      shared packages.

  Caveat: the placeholder uses the *sub-path* overlay import
  (`@quarto/preview-renderer/overlays/PreviewErrorOverlay`) rather
  than the top-level barrel. Importing through the barrel pulls in
  the q2-preview tree, which pulls in `@quarto/preview-runtime` and
  its `/src/wasm-js-bridge/` consumer-provided files. The SPA
  placeholder doesn't yet host those bridge files at its root;
  Phase A of bd-kw93 will revisit when the SPA actually drives a
  render. The current shape proves the cross-package boundary
  works without paying that cost.

Phase 7 — cargo xtask verify integration + cleanup:

  - TOTAL_STEPS bumped 9 → 11. Steps 10 (shared preview-* package
    tests: preview-renderer unit + integration + preview-runtime
    unit) and 11 (q2-preview-spa build) added. Skip flags added:
    `--skip-shared-package-tests`, `--skip-q2-preview-spa-build`.
  - `cargo xtask verify --skip-rust-tests` confirmed green across
    all 11 steps.
  - `hub-client/src/test-utils/index.ts` cleaned up: the mockSyncClient
    and mockWasm re-exports were dead (those moved to preview-runtime
    in Phase 5). The barrel now only carries `@testing-library/react`
    helpers and `visibility.ts` exports.
  - Audit confirmed no empty directories left behind in hub-client/src/;
    the one-file q2-preview/entry.tsx stub there is intentional
    (kept so q2-preview.html and parity.integration.test.tsx keep
    their existing paths).

Also fixes two qmd-render-blocking characters in earlier Phase 4 +
Phase 2 changelog entries (`PreviewErrorOverlay`'s apostrophe and
the `(~50 files)` tilde — both confused Quarto's parser into
"Unclosed Single Quote" / "Unclosed Subscript"; rephrased to plain
text).

Plan flipped from "Phases 0–5 complete; Phase 6 next" to "COMPLETE".
The bd-hfjj sub-epic is closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drafts the engine-less CLI skeleton for `q2 preview` after bd-hfjj
landed the decomposition. Seven sub-tasks (A.0 through A.6) plus an
e2e smoke (A.7):

  A.0  Lift hub-client/src/wasm-js-bridge/ to a new
       @quarto/wasm-js-bridge workspace package; alias `/src/wasm-
       js-bridge` in hub-client + q2-preview-spa + preview-renderer
       tests. Resolves Q-A1 the right way (no duplication, no
       cross-platform symlink fragility).
  A.1  Stub `quarto preview` clap subcommand.
  A.2  New crates/quarto-preview/ shell crate with the
       quarto-trace-server-shaped include_dir! + build.rs
       placeholder.
  A.3  Fill in q2-preview-spa/src/main.tsx so the SPA actually
       drives a render. <PreviewApp> stays local for now.
  A.4  `cargo xtask build-q2-preview-spa` + wire into `build-all`.
  A.5  End-to-end boot test (tempdir lifecycle, samod handshake,
       URL-fragment carrier).
  A.6  Always-visible "force re-render" button (epic resolution #4).
  A.7  Playwright smoke (real-browser flow incl. DOM-stability
       invariant).

Q-A3 source-code audit corrected an inherited claim: the epic and my
initial draft both said the SPA reads `indexDocId`/`wsUrl` from a
server-injected `<meta>` tag "the same trick share links use." Both
halves wrong. hub-client's share links use URL *fragments*
(`hub-client/src/App.tsx:272`, `utils/routing.ts`); there's no
meta-injection in the codebase. trace-server serves `index.html`
unmodified (`crates/quarto-trace-server/src/lib.rs:185-187`). Phase
A now uses the same URL-fragment pattern: the CLI opens
`http://<host>:<port>/#/preview/<indexDocId>`, the SPA reads
`window.location.hash`, the WebSocket URL derives from
`window.location`. No new server-side patterns introduced.

Q-A5 captures a future direction: persistent samod storage inside
`.quarto/preview/samod/` to amortize the "blank screen at boot" cost
on large projects. Phase A measures the cost; future phases act.

TDD: every sub-task is structured tests-first.

Status: approved 2026-05-13 (all open questions resolved); ready to
file beads sub-issues and begin A.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files bd-0xmt (A.0 wasm-js-bridge), bd-yxqt (A.1+A.2 CLI + crate),
bd-o5wd (A.3 SPA main.tsx), bd-501n (A.4 xtask build), bd-mflk (A.5
boot test), bd-b5hf (A.6 force-refresh), bd-vpsy (A.7 Playwright)
as parent-child children of bd-kw93. Plan:
claude-notes/plans/2026-05-13-q2-preview-phase-a.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ckage (bd-0xmt, Phase A.0)

First step of the q2-preview Phase A (bd-kw93). Moves the 4 JS bridge
modules + their .d.ts/.test companions out of hub-client/src/wasm-js-
bridge/ into a new @quarto/wasm-js-bridge workspace package. Each
consumer (hub-client today + q2-preview-spa later) aliases the
Vite-root path `/src/wasm-js-bridge/` to the package's src/ — the
Rust WASM module's `wasm-bindgen raw_module = "/src/wasm-js-bridge/
sass.js"` annotation is unchanged because resolution happens at
consumer build time. One copy in the workspace, no per-consumer
duplication, no symlinks.

Why this is A.0: Phase A.3 (filling in q2-preview-spa/src/main.tsx)
will boot the WASM module from the SPA's Vite root. Without this
move, the SPA would either need its own copy of the bridge files or
a symlink to hub-client's. Doing the lift now lets A.3 inherit a
clean answer.

Package details:
  ts-packages/wasm-js-bridge/
    package.json   — `@quarto/wasm-js-bridge`, private, type module.
                     Deps `ejs`, `sass` (template.js + sass.js).
    src/sass.js, sass.d.ts, cache.js, cache.d.ts, cache.test.ts,
        fetch.js, template.js — git-mv'd from hub-client.
    src/sass.test.ts (new) — pins the four public function names
        (setVfsCallbacks, jsSassAvailable, jsSassCompilerName,
        jsCompileSass). The Rust WASM module + wasmRenderer.ts both
        reference these by name; renaming any silently breaks the
        bridge at runtime. The TypeScript surface is `any`-typed
        because sass.js is JS, so this guard test is the only
        compile-or-test signal we have for the contract.

Consumer wiring:
  - hub-client/vite.config.ts: alias `/src/wasm-js-bridge` →
    `../ts-packages/wasm-js-bridge/src`. The leading `/` was
    previously resolving via Vite project root to a directory that
    no longer exists.
  - hub-client/vitest.{,integration}.config.ts: inherit the alias
    via mergeConfig from vite.config.ts (resolve.alias deep-merges
    cleanly).
  - hub-client/vitest.wasm.config.ts: still has `/src` →
    `hub-client/src` for the WASM tests that need general /src/
    paths; the more-specific `/src/wasm-js-bridge` alias from
    vite.config.ts wins via longest-prefix match. Comment added.
  - q2-preview-spa/vite.config.ts: same `/src/wasm-js-bridge` alias.
    Not strictly required until A.3 fills in main.tsx with real WASM
    init, but landing it now is cheap and means A.3 doesn't need
    config changes.
  - ts-packages/preview-renderer/vitest.integration.config.ts:
    retarget the existing alias from `hub-client/src/wasm-js-bridge`
    (now gone) to `../wasm-js-bridge/src` (within ts-packages).
  - Three hub-client wasm tests (`smokeAll`, `themeCss`,
    `themeFingerprint`) imported the bridge via the relative path
    `../wasm-js-bridge/sass.js`. Rewritten to the Vite-root form
    `/src/wasm-js-bridge/sass.js`, matching how wasmRenderer.ts
    handles it. Now alias-driven, location-independent.

Hub-client cleanup:
  - Removed `ejs` (^3.1.10), `sass` (^1.77.0), `@types/ejs`
    (^3.1.5) from hub-client/package.json. The bridge files were
    the only consumers; with the move complete, these npm deps
    belong to @quarto/wasm-js-bridge instead.

Verification (all 11 cargo xtask verify steps pass):
  - hub-client: 525 unit + 173 integration + 79 wasm tests
    (test:wasm exercises the real sass compile through the bridge —
     the strongest signal that the alias works end-to-end).
  - preview-renderer: 156 unit + 129 integration tests.
  - preview-runtime: 60 tests.
  - q2-preview-spa: builds (placeholder still doesn't touch WASM).
  - hub-client/dist/assets/sass-*.js confirmed to contain the bridge
    code (grep'd for `dart-sass`, `setVfsCallbacks`, jsSassAvailable
    function signature).

Snapshot tests: no snapshot files changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A.0 landed on branch beads/bd-0xmt-wasm-js-bridge-package
(commits ea1bb88 + 043f65e). @quarto/wasm-js-bridge workspace
package now hosts the bridge files; hub-client + q2-preview-spa
alias /src/wasm-js-bridge to the package's src.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 commits decomposing hub-client into shared workspace packages
(@quarto/preview-renderer + @quarto/preview-runtime) + new
q2-preview-spa skeleton. Closes bd-hfjj. Plan + bd-kw93 sub-issues
for Phase A ride along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bridge files moved out of hub-client into a new workspace package;
consumers alias /src/wasm-js-bridge at each Vite root. Unblocks
Phase A.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-pattern worktree workflow: `create-worktree` for parallel /
investigation work (fresh isolation; pays the npm install + cargo
cold-cache cost on purpose); `switch-task` for sequential
implementation work within an epic (reuses the current worktree's
warm caches by branch-switching in place).

`cargo xtask switch-task <bd-id> [--from <branch>]`:
  1. If `--from <branch>` given: `git switch <branch>` + `git pull
     --ff-only` so the topic branch starts from the latest tip
     (siblings' merges land here first).
  2. `git switch -c beads/<id>-<slug>` off the resulting HEAD.
  3. `br update <id> --status in_progress` (skippable via
     `--no-claim`).
  4. Rewrites the `<!-- BEGIN/END WORKTREE CONTEXT -->` block in
     `CLAUDE.local.md` so the next Claude Code session sees the new
     sub-task's metadata (same template as create-worktree).

Reuses create_worktree's BeadsMetadata + derive_slug + build_section
+ update_claude_local_md helpers — the shape is the same; only the
git-side changes (no worktree add, just an in-place branch switch).

Companion changes to `.claude/rules/worktrees.md`:
- Documents the two patterns side-by-side at the top of the file so
  Claude (or any contributor) picks the right command for the shape
  of the work.
- New "Integration-line convention" section formalising that ready
  sub-task work merges into a long-lived `feature/<name>` branch
  (--no-ff so each sub-task is a single merge commit); `switch-task
  --from <branch>` expects to find that integration line already
  current.
- Adds the in-place sub-task pattern to the branch-naming section
  (same `beads/<id>-<slug>` shape, no `.worktrees/` dir).

No change to existing commands; `cargo xtask verify` still 11/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous implementation did `git switch <from>` + `git pull --ff-only`
+ `git switch -c <new>`. Fails when `<from>` is already checked out
in another worktree — which is exactly the common case (main repo on
`feature/<epic>`, in-place sub-task work in a sibling worktree).

New shape: `git fetch origin <from>` (best-effort), then
`git switch -c <new> <from>` — the trailing start-point creates the
new branch at `<from>`'s tip without checking out `<from>` itself.
Worktree-friendly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, not the workspace root

The first end-to-end run of `switch-task` from inside a worktree
clobbered the *main repo's* `CLAUDE.local.md` instead of the
worktree's. Cause: switch_task used `create_worktree::repo_root()`,
which walks up to find `Cargo.toml` with `[workspace]`. For a
worktree of a workspace, that `[workspace]` declaration is shared
across all worktrees, so the walk lands on the main repo regardless
of which worktree the command is run from.

Add a `current_worktree_root()` helper that calls
`git rev-parse --show-toplevel` instead — that returns the
*invoking worktree's* top, which is the right anchor for the
per-worktree CLAUDE.local.md file. Use it in switch_task.

The bug only existed in `switch_task`; `create-worktree` is
unaffected because it explicitly constructs `repo_root.join(".worktrees")
.join(<leaf>)` and uses that for CLAUDE.local.md path, never the
caller's worktree root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d-yxqt, Phase A.1+A.2)

Two halves of the q2-preview phase-A skeleton:

A.1 — CLI surface
  - Replaces the existing `Preview` clap stub (Q1-shaped placeholder
    that returned NotImplemented) with the Q2 shape from
    `claude-notes/plans/2026-05-13-q2-preview-phase-a.md` §A.1.
    Drops the Q1-era flags (--render, --no-serve, --no-navigate,
    --no-watch-inputs, --timeout, --log*, --quiet, --profile) since
    Q2 always serves, always watches, and logs at the binary level.
    Keeps [path] / --port / --host / --no-browser, adds --data-dir
    (samod storage override), --preview-dir (SPA-from-disk override,
    same shape as QUARTO_TRACE_VIEWER_DIR), --no-project (standalone
    mode, Q5 resolution).
  - Tests-first: `crates/quarto/tests/preview_cli.rs` pins the flag
    set via `q2 preview --help` (3 tests). Started red (the
    inherited Q1 flags failed the Phase-A flag-list assertion);
    landed green after the replacement.
  - `commands/preview.rs` grows a `PreviewArgs` struct carrying the
    Phase-A flags. `execute()` is still a NotImplemented stub; the
    fields are `#[allow(dead_code)]`-marked because A.5 (bd-mflk)
    will consume them.

A.2 — quarto-preview crate
  - New `crates/quarto-preview/`. Cargo.toml depends on quarto-hub,
    include_dir, axum, tokio (the deps needed for the future
    hub-layered router). Currently lib.rs is just the SPA shell;
    A.5 layers the hub server on top.
  - `build.rs` mirrors `quarto-trace-server/build.rs` exactly: looks
    for `q2-preview-spa/dist/index.html`; if present, embeds that
    directory via `include_dir!`; otherwise emits an OUT_DIR
    placeholder with the same `<div id="root">` mount point (so the
    smoke test works either way) plus a build warning telling the
    user to run `cargo xtask build-q2-preview-spa` (added in A.4).
  - `src/lib.rs` exposes `PreviewConfig` + `run(config)` + `router(
    &config)`. `router()` is `pub` so the smoke test can exercise
    the SPA-fallback chain without spinning the listener through
    run()'s indefinite serve loop. `run()` itself uses
    `axum::serve(...).with_graceful_shutdown(ctrl_c)`.
  - Smoke tests (`tests/smoke.rs`, 2 tests):
      1. `spa_root_serves_html_with_react_mount` — GET / returns
         200 with the React mount point.
      2. `unknown_path_falls_back_to_index_html` — GET on an
         arbitrary path falls back to index.html (client-side
         routing), matching what `q2-debug.html` /
         `q2-preview.html` rely on in hub-client.
  - Both run green; the bound port comes back via
    `listener.local_addr()` so the tests are port-clash-free.

Verification: `cargo xtask verify --skip-hub-build --skip-hub-tests
--skip-trace-viewer-build --skip-trace-viewer-tests
--skip-shared-package-tests --skip-q2-preview-spa-build
--skip-treesitter-tests` green — Rust-only because the change
touches no Rust code that hub-client transitively consumes (no
quarto-core, no quarto-pandoc-types, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e A.1+A.2)

q2 preview clap surface replaced with the Q2-shaped flags
(--port/--host/--no-browser/--data-dir/--preview-dir/--no-project +
[path]). New crates/quarto-preview/ hosts the SPA via include_dir!
and serves it through axum, including the SPA-fallback chain for
client-side routing. Currently engine-less; A.5 layers the hub
server on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
q2 preview CLI surface + crates/quarto-preview shell crate landed
via merge commit. Ready: bd-o5wd (A.3 — fill in q2-preview-spa
main.tsx), bd-501n (A.4 — xtask build helper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t (bd-o5wd, Phase A.3)

Replaces bd-hfjj Phase 6's placeholder main.tsx (which just rendered
`<PreviewErrorOverlay>` with static text to prove the package
boundary) with the actual SPA bootstrap: WASM init, samod connect,
first-.qmd selection, and `<Q2PreviewIframe>` driven off automerge.

New `src/PreviewApp.tsx`:
  - Three boot states (loading / ready / error) tracked in a single
    state object so the render branches stay declarative.
  - Parses `window.location.hash` for `#/preview/<indexDocId>` per
    Q-A3 of the Phase A plan. The CLI synthesises that fragment when
    opening the browser; the SPA reads it client-side — no server
    HTML rewriting needed.
  - Derives the websocket URL from `window.location` (same host:port
    serves SPA + samod ws), so there's a single source of truth for
    where to connect.
  - On mount: `initWasm()` → `setSyncHandlers()` → `connect(wsUrl,
    indexDocId)`. Files come back from `connect()`'s resolved promise
    AND via the `onFilesChange` handler (the runtime fires both).
  - Active page = first `.qmd` in the project. URL-driven file
    selection is a Phase-D polish item.
  - `onFileContent` bumps a `contentTick` counter; a second
    `useEffect` keyed on `[activeFile, contentTick]` re-runs
    `renderPageInProject()` and feeds the resulting `ast_json` into
    Q2PreviewIframe. That's the "edit re-renders without DOM
    rebuild" promise of the q2-preview format.
  - `setAst` on Q2PreviewIframe is a deliberate no-op for Phase A.
    The iframe expects it because Phase 2 of q2-preview anticipated
    a WYSIWYG round-trip into the .qmd source. Wiring that needs an
    editor surface the SPA doesn't yet have.

New `src/PreviewApp.integration.test.tsx` (3 tests, all green):
  1. Boot path: mocked runtime returns one .qmd; assert
     Q2PreviewIframe receives `astJson` + `currentFilePath` matching
     the mock.
  2. Loading state: `connect()` held in a never-resolving promise;
     assert "Initializing…" UI is visible *before* the iframe.
  3. Connection error path: `connect()` throws; assert the message
     surfaces through `<PreviewErrorOverlay>` and no iframe renders.

Test infra:
  - `vitest.config.ts` + `vitest.integration.config.ts` mirroring
    preview-renderer's pattern: node env for unit, jsdom for
    integration. Workspace-package aliases (preview-renderer,
    preview-runtime, quarto-automerge-schema, quarto-sync-client)
    listed explicitly because vitest doesn't honour the `source`
    exports condition on fresh clones.
  - `src/test-utils/setup.ts`: jest-dom + fake-indexeddb polyfill +
    minimal ResizeObserver shim. Mirrors hub-client's setup
    (deliberate small duplication — see bd-hfjj's catalogue note).

Bookkeeping:
  - q2-preview-spa now has its own `.gitignore` (dist/, node_modules,
    *.tsbuildinfo) — matches `trace-viewer/` and `hub-client/`. The
    prior placeholder dist/ that landed via bd-hfjj Phase 6 is
    untracked accordingly.
  - dev deps grow with jsdom + @testing-library/{react,jest-dom} +
    vitest. `@quarto/quarto-automerge-schema` added as devDep for
    the FileEntry import in the test.

Bundle audit (production build):
  - Editor symbols (Monaco, GoogleOAuthProvider, FileSidebar,
    ProjectSelector, monaco-editor): zero. The §invariant from the
    epic still holds — SPA imports only from shared packages, editor
    code can't transitively leak in.
  - SPA-side code (Q2PreviewIframe, PreviewErrorOverlay,
    renderPageInProject, "Initializing") present. The full WASM +
    sass + automerge chain ships now (~40MB of dist incl. WASM
    binary) — that's the cost of being a real preview host rather
    than a placeholder.

`cargo xtask verify` 11/11 green (including the SPA build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PreviewApp boots through @quarto/preview-runtime — WASM init, samod
connect, first-.qmd selection, <Q2PreviewIframe> renders the active
page and re-renders on content-changed events. Phase A.4 + A.5 still
needed to wire the SPA build into the embedded `quarto preview`
binary and the end-to-end boot test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cscheid and others added 28 commits May 15, 2026 09:59
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`q2 preview` and `q2 hub` were chatty by default: every periodic-sync
tick emitted three INFO lines from quarto-hub even when nothing
changed, and the only way to turn it down was `RUST_LOG`. Move per-
tick / per-event log sites in quarto-hub from `info!` to `debug!`,
fix the periodic-sync gate to ignore `no_changes` (which it summed
into `total_synced()`), and add a global `-v` accumulator on the
`q2` root command and the standalone `hub` binary so visibility is
opt-in.

Verbosity mapping (shared via `quarto_util::verbose_to_filter`):
  0 → quarto=warn        (default; silent terminal)
  1 → quarto=info        (kept startup/lifecycle events)
 2 → quarto=debug,samod=info
  3+ → quarto=trace,samod=debug,tower_http=debug

`RUST_LOG`, when set, still takes precedence — the existing
`try_from_default_env` path is preserved.

Verified end-to-end via `q2 hub` and standalone `hub` (`q2 preview`
inherits automatically once this merges into feature/q2-preview-
command). cargo xtask verify --skip-hub-build green; 8864 workspace
tests pass including new `has_changes_only_counts_real_changes` and
five `verbose_to_filter` table tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demotes per-tick automerge/sync info! lines in quarto-hub to debug!,
adds SyncAllResult::has_changes() to skip the periodic summary when
only no_changes are nonzero, and wires a global -v accumulator on
both the q2 root command and the standalone hub binary (warn floor
by default; RUST_LOG still wins). Mapping lives in
quarto_util::verbose_to_filter.
…4uvv)

samod's TS `repo.find()` rejects bare document IDs with
`Error: Invalid AutomergeUrl`; it requires the `automerge:<id>` scheme
prefix. The text-doc loader (`loadFileDocuments`) prepends it
explicitly when consuming IDs from the `IndexDocument.files` map, but
the new Phase C.4 `getBinaryDocById` path — which reads
`captureDocId` values from the capture sidecar to fetch the gzipped
EngineCapture — was passing the bare ID through. The thrown error was
caught silently by the function's own try/catch and surfaced as
`null`, so every `q2 preview` render of a project with a capture
fell back to the default-registry path: code cells rendered as inert
source even though the server had recorded a valid capture.

`getBinaryDocById` now prefixes the ID exactly like
`loadFileDocuments` does. The shape is also coerced via
`String(docId)` first because automerge's read proxy can return
string-valued fields as a `RawString` proxy with no `.startsWith`
method, depending on how the doc was constructed (observed against
our `create_binary_document` envelope).

Two regression tests in `client.test.ts` lock the rule: bare IDs get
prefixed; already-prefixed IDs are not double-prefixed.

Diagnosed end-to-end on 2026-05-18 against
`~/Desktop/daily-log/2026/05/15/q2-preview-test-website`. This fix
is necessary for any downstream consumer of capture bytes (replay or
the new AST-splice path); the AST-splice work itself is bd-lucp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Phase C.4 design where the WASM preview consumed
recorded captures through `EngineRegistry::with_replay`. ReplayEngine
is a deterministic regression-testing tool (bd-45yw) — its strict
byte-equality miss policy is correct for that consumer but wrong for
preview, where the live QMD is edited every keystroke and never
byte-identical to what was captured. Any prose edit, or even mtime
drift from server-side `listing-item.date-modified`, produced a hard
replay miss and the iframe fell back to raw source.

The preview path now consumes captures *outside* the engine layer
entirely. `CaptureSpliceStage`, inserted between
`PreEngineSugaringStage` and `EngineExecutionStage` in the q2-preview
pipeline, re-parses `capture.input_qmd` and `capture.result.markdown`
into Pandoc ASTs (A1, B1), derives a `(structural_hash, occurrence
index) → output Block` map from the (A1, B1) pair, and splices the
captured engine output into the live pre-engine AST (A2). After the
splice, `EngineExecutionStage` finds no engine cells left and the
WASM fallback-to-markdown is a clean no-op. Cells whose hash doesn't
match a captured entry — content changed in v2, cell added, or A1's
walk diverged — fall through unchanged to today's raw-source path.

The `(hash, occurrence_index)` key disambiguates repeated identical
cells in a doc (e.g. two `cat('hello')` cells); position itself
isn't keyed, so simple reorderings still match.

ReplayEngine and `EngineRegistry::with_replay` are unchanged.

New crate-level modules:

  - `quarto_core::engine::capture_splice` — pure splice algorithm
    (`apply_capture_splice`, `derive_cell_outputs`, `splice_cells`).
    Uses `quarto_ast_reconcile::{compute_block_hash_fresh,
    structural_eq_block}` for the hashing primitives. 9 unit tests
    cover single cell, prose edited around, repeated cells, changed
    body, deleted cell, added cell, prose-only, walk divergence, and
    the plain-language-tag-not-engine-cell distinction.

  - `quarto_core::stage::CaptureSpliceStage` — pipeline stage that
    holds an `Option<EngineCapture>`, parses both sides on each run,
    and applies the splice. Pass-through when no capture; fail-soft
    on corrupt capture (any parse error degrades to pass-through, so
    a malformed capture binary doc never takes the preview down).

Pipeline + renderer plumbing:

  - `build_q2_preview_pipeline_stages(engine_registry, capture)`
    inserts `CaptureSpliceStage` immediately before
    `EngineExecutionStage`. The HTML pipeline doesn't include it —
    `q2 render` natively runs engines or uses `--replay`.

  - `render_qmd_to_preview_ast` gains an `Option<EngineCapture>`
    parameter that threads through to the builder.

  - `RenderToPreviewAstRenderer::with_capture(EngineCapture)` is the
    builder; the WASM project-pipeline path calls it.

WASM entry:

  - `parse_capture_from(capture_gz_json) -> Option<EngineCapture>`
    replaces the old `build_replay_registry_from` —
    deserialization stops at the typed capture instead of
    constructing a ReplayEngine registry.

  - `render_single_doc_to_response` and
    `render_project_active_page_to_response` take
    `Option<EngineCapture>` instead of
    `Option<EngineRegistry>`; both route into the splice path.

This supersedes bd-m0mu (the project-pipeline `engine_registry`
plumbing that was correct only under the now-discarded "preview
uses ReplayEngine" design). bd-4uvv (samod URL prefix in
getBinaryDocById) remains valid and is required regardless — without
it the capture bytes never reach the WASM in the first place.

Verified end-to-end on 2026-05-18 against
`~/Desktop/daily-log/2026/05/15/q2-preview-test-website`. The iframe
now contains:

  <div class="cell">
    <pre><code class="r cell-code">cat("Hello, world")</code></pre>
    <div class="cell-output cell-output-stdout">
      <pre><code>Hello, world</code></pre>
    </div>
  </div>

Live-edit case verified: appending a new prose paragraph to
index.qmd surfaces the edit AND keeps the captured engine output in
the iframe — exactly the case strict byte-equality couldn't handle.

`cargo xtask verify` 12/12 green. `cargo nextest run -p quarto-core`
1924/1924 green (9 new splice unit tests).

Plan: claude-notes/plans/2026-05-18-q2-preview-project-replay-engine.md

Known scope still ahead (per the plan's §Out-of-scope and §Risks): a
pipeline-level integration test for the splice path against the real
orchestrator (today's unit tests cover the algorithm directly, but
not the full ProjectPipeline drive); smarter retargeting for
cell-reorder edge cases; filter/include handling in captured output;
cell-language-vs-engine-name mismatch tightening.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bd-m0mu turned out to be correct only under an architecture we
discarded after design review on 2026-05-18 — the preview path uses
an AST-splice (bd-lucp), not the engine_registry plumbing the m0mu
fix added. m0mu's Rust changes were reverted in the splice commit;
the issue is closed with a comment pointing at the new plan doc.

bd-4uvv (samod URL prefix in getBinaryDocById, P0 bug) was
discovered while diagnosing m0mu and is necessary regardless of
which architecture lands. Fix is on this branch.

bd-lucp (AST-splice consumer for preview captures, P0) carries the
splice architecture; design + implementation also on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bd-4uvv (`94d4e459`): samod TS `repo.find()` requires `automerge:`
URL scheme; getBinaryDocById was passing bare docIds and silently
returning null. Fixed; capture bytes now reach the WASM.

bd-lucp (`f3ec94fd`): preview-time capture consumption rebuilt as an
AST-level cell-targeted splice (keyed by `(structural_hash,
occurrence_index)` to disambiguate repeated identical cells). The
ReplayEngine hard-miss path is no longer on the preview consumer's
flow; ReplayEngine itself is unchanged (still bd-45yw's regression-
testing tool). Supersedes bd-m0mu (which was correct only under the
discarded "preview uses ReplayEngine" architecture). 1924/1924
quarto-core tests green including 9 new splice unit tests; `cargo
xtask verify` 12/12 green; end-to-end verified against
~/Desktop/daily-log/2026/05/15/q2-preview-test-website (iframe DOM
shows the engine output, and survives live prose edits).

Plan: claude-notes/plans/2026-05-18-q2-preview-project-replay-engine.md
Known follow-ups documented in the plan's §Out-of-scope and §Risks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The q2-preview SPA's tab was hard-coded to "Quarto Preview" via
q2-preview.html's static `<title>`. With the live site and the
preview open in sibling tabs, both read the same string. Surface
the active document's title in the tab so the two are
distinguishable.

Format follows the VS Code preview-tab convention:

  meta.title present → "<title> (Quarto Preview)"
  meta.title absent  → "Quarto Preview"  (unchanged)

A `useEffect` in PreviewApp watches `state.astJson`. On each
successful render, it parses the AST, walks `meta.title`, and
flattens via the framework's existing `extractMetaString`
(MetaString / MetaInlines / MetaBlocks). The extraction lives in a
small exported `buildPreviewTabTitle(ast)` helper so the tests
exercise it directly. Parse failure → fallback; no `meta` →
fallback. The effect's dep is just `state.astJson`, so it fires on
every render tick — live edits to the title surface in the tab
immediately.

Three new SPA integration tests pin the contract:
- meta.title present → `"<title> (Quarto Preview)"`,
- meta.title absent → bare "Quarto Preview",
- subsequent renders with a different title update the tab.

The tests reinstall the default `renderPageForPreview` mock at the
top of each case — earlier tests in the suite override the mock
with `mockImplementation` returning hardcoded shapes, and
`vi.clearAllMocks` only clears history (not implementations). The
local reset keeps the title tests immune to ordering.

Verified end-to-end on 2026-05-18 against
~/Desktop/daily-log/2026/05/15/q2-preview-test-website:

  - Initial load: document.title = "Hello, world (Quarto Preview)"
  - Editing `title: Hello, world` → `title: My renamed doc` in
    the on-disk QMD: tab updates to "My renamed doc (Quarto
    Preview)" within the watcher debounce window.

`cargo xtask verify` 12/12 green. 29/29 SPA integration tests
green (was 26; +3 new title tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Browser-tab title now reflects the active doc's meta.title. Live-edit
verified end-to-end against the fixture website.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The q2-preview SPA's tab now reads "<doc-title> (Quarto Preview)"
when the active AST has a `meta.title`; falls back to the bare
"Quarto Preview" otherwise. Live-edit case verified — retitling the
doc in the QMD updates the browser tab on the next render.

Plus: a small bug-immunity fix in the title tests, since earlier
tests in PreviewApp.integration.test.tsx leave `renderPageForPreview`
with a stale `mockImplementation` that survives `vi.clearAllMocks`.

29/29 SPA integration tests green. `cargo xtask verify` 12/12 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
q2 render produced `<span class="hl-function">cat</span>...<span
class="hl-string">"Hello, world"</span>` for the R cell in the
fixture website; q2 preview showed the same cell as plain `<pre><code
class="r cell-code">cat("Hello, world")</code></pre>`. The
preview-side rendering was opting out of `CodeHighlightStage`
unnecessarily.

Two-part fix:

1. **Pipeline**: remove `code-highlight` from `Q2_PREVIEW_STAGE_EXCLUDED`.
   The stage is AST-level (it only writes a `data-hl-spans` attribute
   onto the existing `CodeBlock` / inline `Code` node — no HTML
   emission) and WASM-safe (only `quarto_highlight::user_grammar` is
   native-only; built-in grammars including `r` work everywhere). The
   stage now annotates the AST in both the q2-preview and HTML
   pipelines.

2. **React renderer**: teach `q2-preview/blocks/CodeBlock.tsx` to
   read `data-hl-spans`, decode the JSON triple-array (`[[start_byte,
   end_byte, capture_name], ...]` — the wire format defined by
   `quarto-highlight-encoding`), and emit nested `<span
   class="hl-…">` markup. Capture-to-class mapping is identical to
   the native HTML writer (`crates/pampa/src/writers/html.rs::
   capture_to_class` — `.` → `-`, so `function.builtin` →
   `hl-function-builtin`).

   The byte-offset walk uses `TextEncoder` / `TextDecoder` so non-
   ASCII source bytes (multi-byte utf-8 characters) slice cleanly.
   Mirrors the open / close tie-breakers in the native writer's
   `write_highlighted_body` so nested grammar captures render as
   `<span class="hl-function"><span class="hl-function-builtin">…
   </span></span>`. Empty / missing / malformed `data-hl-spans`
   degrades to the existing plain-`<code>` path. The raw attribute
   is *consumed* — it does not appear as a `data-hl-spans` DOM
   attribute on the emitted `<pre>` / `<code>`.

New tests:

- 4 SPA integration tests in
  `ts-packages/preview-renderer/src/q2-preview/q2-preview.integration.test.tsx`:
  highlight spans present and rendered with the right classes;
  fallback when the attribute is absent; fallback on an empty span
  array; utf-8 byte-offset slicing on non-ASCII source.

- 1 Rust structural test in
  `crates/quarto-core/src/pipeline.rs::tests::q2_preview_pipeline_includes_code_highlight`:
  pins that `code-highlight` lives in the q2-preview pipeline so a
  future cleanup can't silently drop it.

Verified end-to-end on 2026-05-18 against
~/Desktop/daily-log/2026/05/15/q2-preview-test-website. The iframe's
R cell now renders the same `hl-function` / `hl-punctuation-bracket`
/ `hl-string` markup as `q2 render`.

`cargo xtask verify` 12/12 green. preview-renderer tests 150/150 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R / Python / etc. code cells in q2 preview now render with the same
tree-sitter syntax highlighting as q2 render. End-to-end verified
against the fixture website.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeHighlightStage is no longer excluded from the q2-preview pipeline
(it's AST-level, WASM-safe, and just adds a `data-hl-spans` attribute
to the existing `CodeBlock` node), and the React `CodeBlock`
component now reads that attribute and emits the same nested
`<span class="hl-...">` markup the native HTML writer produces. The
fixture website's R cell renders identically across `q2 render` and
`q2 preview`.

4 new SPA integration tests; 1 new Rust pipeline-inclusion test.
`cargo xtask verify` 12/12 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
q2 render produced `<pre class="sourceCode r cell-code"><code>…</code></pre>`
for the example R cell; q2 preview produced
`<pre><code class="r cell-code">…</code></pre>` — classes on the
wrong element, and the `sourceCode` class entirely missing. Quarto
theme CSS keys off `pre.sourceCode` and `pre.sourceCode > code`, so
the divergence broke spacing / indentation of code cells in the
preview.

The React `CodeBlock` component now mirrors the native writer in
`crates/pampa/src/writers/html.rs::Block::CodeBlock` +
`write_code_container_attr`:

- The `Attr` (id, classes, and all non-`data-hl-spans` `data-*`
  kvs) is forwarded to the `<pre>` container.

- The inner `<code>` is bare — no className, no `data-*`. Matches
  the native writer's `write!(ctx, "><code>")` with no attrs.

- When `data-hl-spans` is present (i.e. highlight spans will be
  emitted), `sourceCode` is prepended to the `<pre>` class list,
  identical to `write_code_container_attr`'s
  `combined.push("sourceCode")` at line 489. The prepend is
  idempotent: if the author's own class list already contains
  `sourceCode`, we don't double it.

Two existing tests were rewritten to encode the new contract: the
"language class" case (now asserts `<pre>` carries the class and
`<code>` is bare) and the "highlight spans" case (now asserts
`sourceCode` is on `<pre>` and `<code>` is bare). The other three
CodeBlock tests already pass against the new shape.

Verified end-to-end on 2026-05-18: the React-rendered DOM now
matches `q2 render` byte-for-byte at the class level —
`pre.className === 'sourceCode r cell-code'`, `code.className === ''`
on both pipelines. Only difference remaining is inter-element
whitespace (newlines between block-level siblings), which doesn't
affect CSS selectors or layout.

150/150 SPA integration tests green. `cargo xtask verify` 12/12
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
q2 preview's CodeBlock DOM now matches q2 render — classes on <pre>,
bare <code>, sourceCode prepended when highlighted. Resolves the
visible spacing/indentation drift between the two pipelines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
React `CodeBlock` now mirrors `write_code_container_attr` from
`crates/pampa/src/writers/html.rs`: classes + `data-*` kvs on
`<pre>`, bare `<code>`, `sourceCode` prepended when `data-hl-spans`
is present. The visible spacing / indentation drift between
`q2 preview` and `q2 render` (caused by `pre.sourceCode` CSS rules
not matching the preview DOM) is resolved.

150/150 SPA integration tests green. `cargo xtask verify` 12/12 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-reported symptom: the paragraph immediately preceding the first
section div ("This is an extremely basic website") has 17px
margin-bottom in q2 preview but 34px in q2 render against the
fixture website. Both pipelines load the same compiled Quarto theme
CSS — the only difference is the DOM tag of the next sibling.

The theme has:

    p                                    { margin-bottom: 1rem }
    main.content > p:has(+ section)      { margin-bottom: 2rem }

q2 render's HTML writer (`crates/pampa/src/writers/html.rs::Block::Div`,
lines 1129-1142) maps a Pandoc Div whose class list contains
"section" (the sectionize transform's output) to `<section>`. The
React Div component always emitted `<div>`, so the `:has(+ section)`
selector never matched in preview.

The React `Div` component now mirrors the native writer: when
`classes.includes(SECTION)` (where `SECTION` is the existing
`'section'` constant in `quartoClasses.ts`), render as `<section>`;
otherwise keep `<div>`. Non-`section` Quarto-extension classes
(callouts, columns, …) keep their `<div>` shape — a regression
guard test pins that.

Verified end-to-end on 2026-05-18 against
~/Desktop/daily-log/2026/05/15/q2-preview-test-website: the target
paragraph's `getComputedStyle(p).marginBottom` is now `'34px'` in
preview, matching render. Its next sibling is `<SECTION
class="section level3" id="a-section">` (was `<DIV …>`).

Two new SPA integration tests:
- positive: `<section>` is emitted (and no rogue `<div>` with the
  same classes is present);
- regression: `<div>` is kept for Divs whose class list does NOT
  contain `section`.

152/152 SPA integration tests green. `cargo xtask verify` 12/12 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pandoc Divs with class='section' now render as <section> in q2
preview, matching q2 render. Resolves the 17px-vs-34px paragraph
margin drift between the two pipelines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The React `Div` component now mirrors the native HTML writer's
Div→<section> elevation for Pandoc Divs whose class list contains
"section". Quarto theme rules keyed on `<section>` (e.g.
`main.content > p:has(+ section)`) now match in preview, resolving
the visible paragraph margin drift between `q2 render` and
`q2 preview`.

152/152 SPA integration tests green. `cargo xtask verify` 12/12 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codifies the workflow that emerged from bd-nxslt + bd-y1fs3 + bd-coffj:
diagnose render-vs-preview DOM/style differences via Chrome MCP,
mirror the native HTML writer (crates/pampa/src/writers/html.rs) in
the React preview components, TDD against component / pipeline /
SPA-app test surfaces, verify end-to-end through a real binary.

Captures the conventions that aren't obvious from the code:

- Pampa puts classes on the OUTER code container, not <code>.
- sourceCode is conditionally added when data-hl-spans is present.
- data-hl-spans is consumed, not forwarded — must not leak to DOM.
- Div.attr.classes.contains("section") elevates to <section> tag.
- Q2_PREVIEW_STAGE_EXCLUDED only drops HTML-emission stages —
  AST-level stages belong in the q2-preview pipeline.
- iframe.contentWindow.getComputedStyle, not parent window's.
- vi.clearAllMocks does NOT clear mockImplementation (vitest gotcha).
- Beads parent bd-kw93; topic branches merge --no-ff into
  feature/q2-preview-command.

Scope is narrow on purpose: DOM/style parity only, not preview-server
bugs, not render-side bugs, not theme CSS changes, not capture-splice
work. Each of those has its own design space.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codifies the workflow that emerged from bd-nxslt + bd-y1fs3 + bd-coffj
into a project-level skill at `.claude/skills/preview-render-parity/`.
Auto-invocation triggers on parity / DOM / spacing-difference reports;
explicit `/preview-parity` also works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…p, bd-telo)

Three sibling chrome resolvers each read only one of the two natural
authoring locations: navbar and page-footer read only top-level
`navbar:` / `page-footer:`, while sidebar read only nested
`website.sidebar:`. Authors who used the Q1-compatible `website.navbar:`
form (the more common shape in the wild) got silently no navbar.

Add `quarto_config::resolve_website_value(meta, key)`: looks up both
`meta.<key>` and `meta.website.<key>`, materializes a two-layer merge
with top-level winning on conflicts. Matches the precedence already
encoded in `resolve_website_bool` (document frontmatter lands at the
top level and naturally overrides project chrome). `!prefer` on either
layer escapes the default field-wise merge.

Rewire five call sites onto the helper. Sidebar required all three
readers (membership + generate transform + SCSS doc-vars layer) to
keep the dep-graph view consistent with the rendered output.

End-to-end verified against docs/_quarto.yml — `q2 render docs/`
produces a Bootstrap navbar with logo, brand, two nav items, and
correct active-state marking across pages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in PR #190 (Attribution pipeline) and 13 other commits from main.

Conflict-resolution highlights:
- Module-layout conflicts in hub-client TS files: kept feature's
  @quarto/preview-renderer / @quarto/preview-runtime package-alias
  imports; added main's new attribution surface (props, hooks,
  AttributionWrap dispatcher wraps) wherever the body needed it.
- File-location conflicts (5 attribution files added on main inside
  the renamed framework/ directory): git's rename detection placed
  them at ts-packages/preview-renderer/src/framework/; staged as-is.
- Function-signature collisions in Rust (render_single_doc_to_response,
  render_qmd_to_preview_ast): combined both branches' new optional
  params (capture / engine_registry from bd-lucp + attribution_json
  from PR #190) and updated all call sites including two missed
  test-site calls in pipeline.rs.
- Q2_PREVIEW_TRANSFORM_EXCLUDED: kept main's `attribution-viewer`
  exclusion (correctly CLI-only; React handles attribution via the
  framework hook), dropped its `website-favicon` exclusion (collides
  with the feature branch's Phase F.2 chrome wiring, where
  PreviewDocument's <HeaderIncludesEffect> consumes the favicon).
- PreviewDocument.tsx: combined Phase F.2 chrome slots with PR #190's
  attribution hover wiring. The attribution surface is inert today
  (render_page_for_preview doesn't install an attribution provider
  yet — see bd-zvh2p) but lights up when that gap closes.
- attribution.tsx virtual-module plugin (virtual:quarto-attribution-
  viewer-css): ported the hub-client/vite.config.ts plugin into both
  ts-packages/preview-renderer's vitest configs and q2-preview-spa's
  vite.config.ts so each consuming build/test target can resolve it.
- Two import-path fixes the user's resolution missed:
  ReplayDrawer.tsx imported actorColor from a stale path; PreviewRouter
  / ReactPreview / useAttribution referenced services/automergeSync at
  its hub-client location after the file moved to @quarto/preview-runtime.
- Tree-sitter parser regenerated for issue #206 (pipe-table x ::: fix
  from PR #208) so the grammar tests pass against the new source.

Full verification (`cargo xtask verify --skip-hub-tests`) passes all
12 steps. End-to-end: `q2 render docs/` still produces a Bootstrap
navbar with correct active-state marking (the bd-jjep/bd-telo fix
from earlier on this branch — confirms no regression from the merge).

Briefings used to drive resolution:
- claude-notes/research/2026-05-19-attribution-merge-briefing.md
- claude-notes/research/2026-05-19-PreviewDocument-merge-resolution.md

Follow-up issue filed: bd-zvh2p — extend render_page_for_preview to
accept attribution_json, which lights up the consumer-side wiring
landed in this merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erer

Two PR-#190 integration tests imported the framework + previewRegistry
via relative paths that were valid before the ts-packages reorg:

- q2-debug/attribution.integration.test.tsx — `Ast` from `'../framework'`
- q2-preview/attribution.integration.test.tsx — same, plus
  `previewRegistry` from `'./registry'`

Both paths point at locations the feature branch moved into
ts-packages/preview-renderer. The standalone `tsc --noEmit` runs and
`cargo xtask verify --skip-hub-tests` missed it because
`vitest.integration.config.ts` only picks these files up via
`test:integration` / `test:ci`. CI surfaced it on PR #214 (q2-debug +
q2-preview attribution test files: 2 failed, 6 passed).

Retarget both:
- `Ast` → `@quarto/preview-renderer/framework`
- `previewRegistry` → `@quarto/preview-renderer/q2-preview`

`q2DebugRegistry` stays on its relative `./registry` import — q2-debug
itself didn't move with the framework extraction.

Full `cargo xtask verify` (including `test:integration` + `test:ci`)
now green locally; the hub-client suite reports 66 integration tests
and 79 CI tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…otes

CI surfaced Group A failure on PR #214 (q2-preview/multi-element-doc.qmd
and multi-element-project/index.qmd) with the assertion
`div#quarto-appendix > div#footnotes` not matching the rendered iframe.

Root cause: q2-preview's `Div` block component now renders Divs whose
class list includes `"section"` as `<section>` (bd-coffj, to match the
native HTML writer at `crates/pampa/src/writers/html.rs::Block::Div`).
The footnotes container Div on quarto-core (line ~514 of
`transforms/footnotes.rs`) is constructed with classes
`["footnotes", "section"]`, so the rendered output is

  <div id="quarto-appendix" class="default">
    <section id="footnotes" class="footnotes section" role="doc-endnotes">

— which is exactly what `q2 render` produces too (verified locally).
The fixture's `> div#footnotes` predates the bd-coffj parity fix and
was already inconsistent with the native writer.

Update both fixtures to `> section#footnotes` (with a comment
documenting the parity reason).

Also add a Rust-side regression probe in `pipeline.rs`
(`render_qmd_to_preview_ast_emits_appendix_wrapper_for_footnotes`) so
a future accidental drop of the appendix wrapper from the q2-preview
pipeline gets caught natively, not just by E2E.

Diagnosis path: Rust probe confirmed `quarto-appendix` is in the AST;
inspecting q2-preview's `blocks/Div.tsx` revealed the section-emit
branch; native `q2 render` of the same fixture confirmed `<section>`
is the contracted output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cscheid cscheid merged commit 83ab9d2 into main May 19, 2026
5 checks passed
@cscheid cscheid deleted the feature/q2-preview-command branch May 19, 2026 19:13
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