Skip to content

Commit 0306429

Browse files
authored
Merge pull request #303 from quarto-dev/feature/revealjs-preview-render-parity
revealjs preview↔render parity: footer/logo, section attrs, resources: VFS sync
2 parents f648b2a + 6534e04 commit 0306429

4 files changed

Lines changed: 709 additions & 7 deletions

File tree

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
# revealjs `q2 preview``q2 render` parity audit
2+
3+
**Created:** 2026-06-17
4+
**Epic:** bd-67yja58s (format: revealjs) · GA gate: bd-v053sk3s (Phase 1P)
5+
**Related strands:** bd-vv8jft5n (section-id + config parity), bd-qn8yi1su
6+
(automated golden parity test), bd-ocxnva1f (user Lua filters footer/logo)
7+
**Audit strand:** bd-zaa4m4bd
8+
**Footer/logo strand:** bd-n2w0sxgd (F3)
9+
10+
## Overview
11+
12+
`q2 preview`'s React `RevealDeck` (`ts-packages/preview-renderer/src/q2-preview/
13+
RevealDeck.tsx`) is supposed to produce the same DOM as `q2 render`'s native
14+
reveal scaffold (`crates/quarto-core/src/revealjs/assemble.rs`) for the same
15+
deck. This audit compares the two pipelines across `examples/presentations/*`
16+
and records every divergence so we can close them before the revealjs epic
17+
lands (the GA gate is bd-v053sk3s, "q2 preview shows an in-parity Tier-1
18+
slideshow").
19+
20+
The investigation that seeded this plan compared `10-footer-logo` and
21+
`02-sections` empirically in Chrome (render served via `python -m http.server`
22+
over `slides.html`; preview via `q2 preview … --no-browser`), and cataloged the
23+
render-side `<section>` signatures for all 11 examples. Two divergence classes
24+
are **systemic** (every deck); one is **deck-chrome-specific** (only
25+
`10-footer-logo` today, but applies to any deck with `footer:`/`logo:`).
26+
27+
## Findings
28+
29+
### F1 — Section `id` dropped in preview (SYSTEMIC, partly tracked: bd-vv8jft5n)
30+
31+
Render emits an `id` on every `<section>` (derived from heading text, or absent
32+
for `---`-only slides). Preview emits bare `<section>` with no `id`.
33+
34+
| | render | preview |
35+
|---|---|---|
36+
| title slide | `<section id="title-slide" …>` | `<section …>` (no id) |
37+
| `## First topic` | `<section id="first-topic" …>` | `<section …>` (no id) |
38+
| `# Part One` stack | `<section id="part-one" class="section stack">` | `<section class="stack">` |
39+
40+
**Impact:** breaks in-deck `#/id` deep links, cross-document anchors
41+
(`other.html#first-topic`), and any cross-reference whose target is a slide.
42+
bd-vv8jft5n already names this ("pass section ids onto `<Slide>`") but frames it
43+
as "minor" because hash-nav is off in preview — cross-doc anchors make it not
44+
minor.
45+
46+
### F2 — Section semantic classes dropped in preview (SYSTEMIC, tracked: bd-vv8jft5n)
47+
48+
Render puts `section` on every `<section>`, and `section title-slide center` on
49+
the title slide. Preview emits only the classes reveal.js itself adds at runtime
50+
(`present` / `future` / `stack`). The Quarto-authored classes are gone.
51+
52+
| logical slide | render `class` | preview `class` |
53+
|---|---|---|
54+
| title slide | `section title-slide center present` | `present` |
55+
| content slide | `section future` | `future` |
56+
| section divider stack | `section stack present` | `stack present` |
57+
58+
**Impact:**
59+
- `center` drives reveal's vertical centering of the **title slide** — its
60+
absence visibly shifts the title-slide layout.
61+
- `title-slide` is a theme-CSS hook (Quarto reveal theme keys off it).
62+
- `section` is the generic slide-content hook.
63+
- Any author-supplied per-slide class (`## Slide {.smaller}`,
64+
`{background-color="…"}`, etc.) rides the same `Attr` and is **also** dropped
65+
— so F2 is broader than the three built-in classes; it is "the section `Attr`
66+
is never forwarded onto `<Slide>`/`<Stack>` at all."
67+
68+
F1 and F2 share one root cause and should be fixed together (likely folded into
69+
bd-vv8jft5n with its scope widened from "section-id" to "section `Attr`").
70+
71+
### F3 — Deck-level footer / logo absent in preview (`10-footer-logo`, tracked: bd-n2w0sxgd)
72+
73+
Render (`assemble.rs::render_revealjs_document`) places, as **direct children of
74+
`.reveal` outside `.slides`**:
75+
76+
```html
77+
<div class="reveal has-logo">
78+
<div class="slides"> … </div>
79+
<img class="slide-logo" src="logo.svg">
80+
<div class="footer footer-default">© 2026 … <a href="">example.com</a></div>
81+
</div>
82+
```
83+
84+
Preview emits none of these: no `.footer`, no `.slide-logo`, and `.reveal` lacks
85+
the `has-logo` class.
86+
87+
**The data is already available to preview.** The reveal footer/logo render
88+
stage (`RevealFooterLogoTransform`, name `reveal-footer-logo`, selected by
89+
`pipeline.rs::footer_render_stage(is_revealjs=true)`) runs in the q2-preview
90+
pipeline — it is **not** in `Q2_PREVIEW_TRANSFORM_EXCLUDED` — so the preview's
91+
`ast.meta` carries `rendered.reveal.footer` and `rendered.reveal.logo`.
92+
`RevealDeck` simply never reads those slots, and the `<Deck>` wrapper never adds
93+
`has-logo` to `.reveal`. The fix is purely in `RevealDeck.tsx`: read the two
94+
meta slots and inject the markup outside `<Deck>`'s slides + add `has-logo`.
95+
96+
**Impact:** decks with `footer:`/`logo:` show no footer and no logo in preview —
97+
a complete feature miss, not a style nudge. Tracked by **bd-n2w0sxgd**;
98+
`bd-ocxnva1f` covers *Lua filters* manipulating footer/logo, a different
99+
concern from this baseline "preview doesn't render them at all" gap.
100+
101+
**Status (2026-06-17): DOM fixed and styled.** `RevealDeck` now emits the
102+
chrome. `RevealChrome` reads `rendered.reveal.{footer,logo}` and injects the
103+
markup into the `.reveal` element (outside `.slides`) via `useReveal()`
104+
`getRevealElement()`, plus `has-logo`. Verified in Chrome on `10-footer-logo`:
105+
`.reveal.has-logo`, `img.slide-logo` + `.footer.footer-default` are direct
106+
children of `.reveal`, not in `.slides`, footer link preserved, footer
107+
`position: fixed; bottom: 18px` and logo `position: fixed; bottom: 0; right:
108+
12px` (theme styling applies — see F4). Tests: `RevealDeck.integration.test.tsx`.
109+
**Caveat:** the logo *image* still 404s — see F5.
110+
111+
### F4 — (RETRACTED) "Preview reveal theme omits the chrome CSS layer" — was a stale-WASM artifact (bd-d0f9xoa6, closed)
112+
113+
Initially filed when the footer/logo measured `position: static` and the theme
114+
blob lacked `.reveal .footer`. **Not reproducible on a clean build.** That
115+
reading came from a binary whose embedded WASM theme artifact was stale (built
116+
via `cargo xtask build-q2-preview-spa` *without* a WASM rebuild — the
117+
stale-WASM-in-`q2 preview` trap in CLAUDE.md). After `cargo xtask verify`
118+
rebuilt the WASM, the runtime-delivered theme **does** contain the chrome rules
119+
and footer/logo are correctly `position: fixed`, stable across reloads and
120+
500 ms–5 s time samples. Lesson: always rebuild WASM (`npm run build:wasm` /
121+
`cargo xtask verify`) before judging a preview *styling* gap.
122+
123+
### F5 — Logo image 404s in preview: asset walker misses meta-driven raw-HTML images (tracked: bd-k5rxujiy)
124+
125+
With F3's DOM in place, the logo `<img class="slide-logo" src="logo.svg">` fails
126+
to load in preview (`naturalWidth: 0`). Network: `GET /logo.svg`**200
127+
`text/html`** (the SPA shell) — `logo.svg` is not in the preview's served VFS
128+
asset set, so the catch-all returns `index.html`.
129+
130+
**Root cause (confirmed):** the parent-side asset walker
131+
(`ts-packages/preview-renderer/src/q2-preview/assetWalker.ts::collectImagePaths`)
132+
collects **only AST `Image` nodes** (`obj.t === 'Image'`). The logo comes from
133+
metadata (`logo: logo.svg`), rendered by `RevealFooterLogoTransform` into
134+
`rendered.reveal.logo` as a **raw HTML `<img>` string** that `RevealChrome`
135+
injects via `dangerouslySetInnerHTML`. It is never an `Image` node, so (1) no
136+
blob URL is minted for it, and (2) the raw `<img>` bypasses the `<Image>` React
137+
component that rewrites `src` against the manifest. `ResourceCollectorTransform`
138+
is likewise a no-op in `vfs_root` mode (`resource_collector.rs:96`) by design —
139+
the walker is the producer in preview. Native render works only because
140+
`logo.svg` sits next to `slides.html` on disk.
141+
142+
#### The fix is two independent layers
143+
144+
The problem decomposes into "are the bytes available?" and "does the element
145+
that wants them actually get them?". These are separable and must *both* hold.
146+
147+
**Layer 1 — get the declared bytes into the preview VFS.** Honor a document's
148+
front-matter `resources:` in single-file preview, the same declaration used for
149+
publishing. `crates/quarto-preview/src/config.rs::resolve_single_file_deps`
150+
(bd-9cyza5vy) already runs the real parse + include stages and collects image
151+
deps via `collect_referenced_asset_urls(blocks)`; document `resources:` plugs in
152+
right beside that, reusing the existing publish machinery —
153+
`project_resources::extract_resource_patterns(&doc.ast.meta, ["resources"])` +
154+
`expand_patterns(canonical_root, deck_dir_anchor, …, DocumentMetadata, Page)`
155+
(the same `expand_patterns` already used in that file for *project* `resources:`)
156+
— folding the resolved files into `SingleFileDeps.binary_files`. Properties:
157+
reuses publish semantics (declare once → publish + preview), handles globs, is
158+
format-agnostic (helps any single-file doc referencing assets the AST walker
159+
can't see: `logo:`, raw HTML, shortcodes, CSS `url()`), and is on-policy
160+
(`config.rs:122` already treats `resources:` as the sync upload trust boundary).
161+
**This is the cheap, clearly-worthwhile piece and is implemented under
162+
bd-k5rxujiy.** Bytes-in-VFS is *necessary but not sufficient.*
163+
164+
**Layer 2 — serve those bytes to the raw `<img>`.** The logo is a raw
165+
`<img src="logo.svg">` injected by `RevealChrome`; it fetches `/logo.svg`, hits
166+
the SPA catch-all, and never passes through the `<Image>` component that swaps in
167+
blob URLs. Two ways to close it:
168+
169+
- **(a) per-asset rewrite (targeted, no infra changes).** Two small steps, both
170+
in `preview-renderer` TS:
171+
1. *Walker collects the non-Image paths.* `assetWalker.ts` already builds
172+
`manifest: { origPath → blobUrl }` by reading VFS bytes via
173+
`vfsReadBinaryFile` and minting blob URLs. Extend `collectImagePaths` (or
174+
add a sibling collector) to also gather `meta.logo` and/or `meta.resources`
175+
path strings, so the manifest gains `{ "logo.svg": "blob:…" }`. (Keyed by
176+
the **same** string that appears in the injected HTML — both come from the
177+
`logo:` value the Rust transform emits verbatim, so they match; a test
178+
should pin that.)
179+
2. *Injecting slot rewrites against the manifest.* `RevealChrome` builds a
180+
detached `<div>` from the raw HTML before appending. Add a pass:
181+
`wrapper.querySelectorAll('[src],[href]')`, and for each, look the current
182+
attr value up in the manifest (via `AssetManifestContext`, the same source
183+
the `<Image>` component already uses) and rewrite to the blob URL when
184+
matched; external/`data:`/`blob:` pass through. Best factored as a reusable
185+
`rewriteAssetRefs(root, lookupAssetUrl)` so footer images / navbar logos get
186+
it too. **Stays entirely inside `preview-renderer` — no hub-client, sync
187+
server, or quarto-hub.com involvement.**
188+
- **(b) service-worker VFS-by-path server (general, future-proof).** Serve
189+
`/logo.svg` straight from the VFS so *any* path reference resolves with no
190+
rewriting (`resources:` becomes the allow-list that gates it). This is the
191+
bd-msp0 epic — it **touches hub-client and the quarto-hub.com deployment**, so
192+
it is deferred until we're ready for that surface.
193+
194+
**Decision:** Layer 1 lands now (bd-k5rxujiy). For Layer 2 we go with **(a)**
195+
the per-asset rewrite, contained to `preview-renderer`*or* defer Layer 2
196+
entirely, because **(b)** the service worker pulls in hub-client + the
197+
quarto-hub.com deployment, which we are not ready to take on yet.
198+
199+
## Root cause (F1 + F2)
200+
201+
`RevealDeck.tsx` maps each section `Div` onto `@revealjs/react`'s
202+
`<Slide>` / `<Stack>` but passes only the Div's **children** (`div.c[1]`). The
203+
Div's `Attr` (`div.c[0]` = `[id, classes, kvs]`) is read **only** to flip the
204+
incremental scope (`SlideBody` inspects `sectionClasses` for
205+
`incremental`/`nonincremental`); it is never emitted onto the `<section>`.
206+
`@revealjs/react`'s `<Slide>`/`<Stack>` accept `id`/`className` props (they pass
207+
through to the rendered `<section>`), so the fix is to forward them. The native
208+
contract is `assemble.rs` (and the `RevealSlidesTransform` that produced the
209+
`Div(.section)` tree shared by both paths).
210+
211+
## Render-side catalog (all 11 examples)
212+
213+
Every example renders `id` + `section` class on every `<section>`; title slides
214+
get `section title-slide center`. Only `10` carries deck chrome. Feature markup
215+
present per example (candidates for feature-specific preview divergence still to
216+
verify):
217+
218+
| example | special markup to verify in preview |
219+
|---|---|
220+
| 01-creating-slides | title slide (`center`) |
221+
| 02-sections | vertical `stack`s ✅ compared |
222+
| 03-fragments | `.fragment` spans + `fragment-index` |
223+
| 04-incremental-lists | incremental lists → `.fragment` |
224+
| 05-columns | `.columns`/`.column` flex layout |
225+
| 06-speaker-notes | `.notes` (does preview load the Notes plugin?) |
226+
| 07-asides | `.aside` |
227+
| 08-footnotes | per-slide footnote coalescing + `.aside` |
228+
| 09-themes | `theme:` selection (theme parity in_progress: bd-y259zb57) |
229+
| 10-footer-logo | footer + logo + `has-logo` ✅ compared (F3) |
230+
| 11-auto-stretch | `.r-stretch` auto-stretch |
231+
232+
## Work items
233+
234+
### Phase A — fix the two systemic + one chrome divergence (TDD per /preview-parity)
235+
236+
- [x] **F1+F2: forward section `Attr` onto `<Slide>`/`<Stack>`** (bd-vv8jft5n).
237+
DONE 2026-06-17 — `sectionAttrProps(div)` forwards `id` + joined classes;
238+
`renderTopSection` spreads it onto each leaf/inner `<Slide>` and the `<Stack>`.
239+
Verified in Chrome on `10-footer-logo`: title slide now
240+
`id="title-slide" class="section title-slide center present"` and reveal
241+
vertically centers it (`section top: 288.5px`, h1 `text-align: center`); other
242+
sections gain ids + `section`. Tests in `RevealDeck.integration.test.tsx`.
243+
**Known gap:** `@revealjs/react`'s `<Stack>` only accepts `className` (drops
244+
`id`), so a `# Section`-divider stack's own `id` can't round-trip through the
245+
component yet (inner vertical slides still get theirs). `kvs` (e.g.
246+
`data-background-color`) not yet forwarded — defer to authoring features
247+
(bd-bea550b0). Both noted on bd-vv8jft5n.
248+
- [x] ~~**F4: include the Quarto reveal chrome CSS layer in the preview
249+
theme**~~ — RETRACTED (bd-d0f9xoa6 closed); was a stale-WASM artifact, the
250+
theme is fine on a clean build.
251+
- [x] **F5 Layer 1: honor document `resources:` in single-file preview VFS**
252+
(bd-k5rxujiy). DONE 2026-06-17 — `resolve_single_file_deps` expands
253+
`meta.resources` into `binary_files` (reuses `extract_resource_patterns` +
254+
`expand_patterns`; globs supported). Tests `single_file_deps_includes_declared_resources`
255+
+ `_glob`; E2E A/B via real `q2 preview` (`binary_count` 1 vs 0). Bytes-in-VFS
256+
only — the logo still 404s until Layer 2.
257+
- [ ] **F5 Layer 2: serve the bytes to the raw `<img>`** — option (a) per-asset
258+
rewrite in `preview-renderer` (walker collects `meta.logo`/`meta.resources`;
259+
`RevealChrome` rewrites `src`/`href` via `AssetManifestContext`), OR defer
260+
until the (b) service-worker path-server (bd-msp0, touches hub-client +
261+
quarto-hub.com) is on the table.
262+
- [x] **F3: render deck footer/logo + `has-logo` in `RevealDeck`** (bd-n2w0sxgd).
263+
DONE 2026-06-17 — `RevealChrome` + `revealChromeFromMeta` in `RevealDeck.tsx`,
264+
7-case `RevealDeck.integration.test.tsx`, verified in Chrome. DOM-only;
265+
styling blocked on F4.
266+
- [ ] **F4: include the Quarto reveal chrome CSS layer in the preview theme**
267+
(bd-d0f9xoa6). Footer/logo emit but render `position: static` — the preview
268+
theme lacks `.reveal .footer` / `.slide-logo`. Decide under theme-parity
269+
(bd-y259zb57): compile `quarto-revealjs.scss` into the preview theme, or move
270+
the theme-independent chrome rules into the static `quarto-reveal.css`.
271+
272+
### Phase B — full per-example audit (the "other examples" sweep)
273+
274+
For each example NOT yet compared (01, 03, 04, 05, 06, 07, 08, 09, 11): render →
275+
serve → `q2 preview` → Chrome DOM diff (the workflow in this plan's Overview).
276+
Record any new divergence class as a finding here and file a focused strand.
277+
Known suspects: `06-speaker-notes` (Notes plugin), `05-columns` (flex styles),
278+
`09-themes` (theme delivery — already in_progress under bd-y259zb57).
279+
280+
### Phase C — lock it down
281+
282+
- [ ] Land **bd-qn8yi1su**: automated render↔preview golden parity test (the
283+
slides analogue of `/preview-render-parity`) covering the Tier-1 set, so Phase
284+
2 authoring features extend it per feature instead of re-auditing by hand.
285+
286+
## Verification recipe (used for this audit)
287+
288+
```bash
289+
cargo build --bin q2 --release
290+
./target/release/q2 render examples/presentations/<ex>/slides.qmd
291+
(cd examples/presentations/<ex> && python3 -m http.server 8765 &) # render
292+
./target/release/q2 preview examples/presentations/<ex>/slides.qmd --port 8766 --no-browser &
293+
# Chrome: compare http://127.0.0.1:8765/slides.html vs http://127.0.0.1:8766/?page=slides.qmd
294+
# preview DOM is inside an <iframe> — query iframe.contentDocument.
295+
```
296+
297+
## References
298+
299+
- Native reveal scaffold: `crates/quarto-core/src/revealjs/assemble.rs`
300+
(`render_revealjs_document`, `footer_logo_html`, `has-logo`).
301+
- Footer/logo render stage: `crates/quarto-core/src/revealjs/footer_logo.rs`
302+
(`RevealFooterLogoTransform``rendered.reveal.{footer,logo}`).
303+
- Preview deck: `ts-packages/preview-renderer/src/q2-preview/RevealDeck.tsx`.
304+
- Pipeline seam: `crates/quarto-core/src/pipeline.rs`
305+
(`footer_render_stage`, `Q2_PREVIEW_TRANSFORM_EXCLUDED`,
306+
`build_q2_preview_pipeline_stages`).
307+
- Epic plan: `claude-notes/plans/2026-06-08-revealjs-presentations.md` (Phase 1P).
308+
- Skill: `/preview-render-parity`.

0 commit comments

Comments
 (0)