|
| 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