Feature/q2 preview command#214
Merged
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Basics of
q2 previeware there:A lot of bugs and unimplemented features, but I want to start working on q2's website on
mainwith it.