From f9aabccc49ee56d7761301319f89e2bb251b2f91 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 13:19:32 -0500 Subject: [PATCH 1/4] Investigate bd-e2kpwy7n: toc-location option (left/right/body) plan skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan skeleton + Q1 mechanism notes + local repro fixture. Verdict: ready to design — gap confirmed at HEAD, Q1 mechanism mapped, q2 insertion points identified (SCSS and banner template hook already ported and inert). Seven design questions pending user alignment. Co-Authored-By: Claude Fable 5 --- claude-notes/plans/2026-08-14-toc-location.md | 220 ++++++++++++++++++ .../q1-mechanism-notes.md | 185 +++++++++++++++ .../repro/.gitignore | 2 + .../repro/README.md | 23 ++ .../repro/_quarto.yml | 4 + .../repro/index.qmd | 13 ++ 6 files changed, 447 insertions(+) create mode 100644 claude-notes/plans/2026-08-14-toc-location.md create mode 100644 claude-notes/plans/toc-location-investigation/q1-mechanism-notes.md create mode 100644 claude-notes/plans/toc-location-investigation/repro/.gitignore create mode 100644 claude-notes/plans/toc-location-investigation/repro/README.md create mode 100644 claude-notes/plans/toc-location-investigation/repro/_quarto.yml create mode 100644 claude-notes/plans/toc-location-investigation/repro/index.qmd diff --git a/claude-notes/plans/2026-08-14-toc-location.md b/claude-notes/plans/2026-08-14-toc-location.md new file mode 100644 index 000000000..a213a985e --- /dev/null +++ b/claude-notes/plans/2026-08-14-toc-location.md @@ -0,0 +1,220 @@ +# toc-location option (left/right/body); unlocks banner toc-left class (bd-e2kpwy7n) + +**Date:** 2026-08-14 +**Braid:** bd-e2kpwy7n +**Branch:** investigated on `main` (worktree `.worktrees/bd-nn2fou8h-execute-visibility`, reused after its strand merged) +**Status:** Investigation — pending design alignment with user. **Do not start implementation until the user gives the go-ahead.** + +User-stated scope note: `external-sources/quarto-cli` is context, not a +contract — what matters is a mechanism that renders TOCs in alternative +locations (`left` first among them), not byte-for-byte Q1 parity. + +## Triage verdict + +**Ready to design.** The gap is confirmed at HEAD, the Q1 mechanism is fully +mapped, the q2 insertion points are identified, and the SCSS + template hooks +are already ported and inert. The remaining work is choosing the q2-native +placement mechanism (design questions below). + +## Issue context + +Q2 has no `toc-location` option — the TOC always renders in the right margin +sidebar (`#quarto-margin-sidebar`). Filed 2026-07-17 as a P2 feature, +follow-up from the title-block parity epic (the banner `toc-left` +header-class hook was ported inert). On 2026-08-14 it gained real-world +impact data from the Posit Connect docs port: `api/index.html` is a 1.8 MB +OpenAPI reference whose only navigation is a 201-entry TOC with +`toc-location: left` — the port's worst page in chrome comparisons, and the +only page site-wide where Q1 emits `#quarto-sidebar` and q2 emits none. + +Committed minimal repro (external): +`/Users/cscheid/repos/github/cscheid/q2-connect-docs/llms-info/repros/toc-location-left/` +(re-verified unimplemented at 0.19.0/0.20.0/0.21.0 and HEAD `3ac596e0`). +Local copy of the same fixture: +`claude-notes/plans/toc-location-investigation/repro/`. + +## Dependency graph + +- **discovered-from**: bd-y71ga2l8 (closed) — title-block parity Phase 7 + (docs + follow-up strands). The title-block work ported the banner + `banner-header-class` template hook verbatim but left it inert because + its only Q1 producer derives `toc-left` from `toc-location`, which q2 + lacked. This strand is the missing producer. +- No incoming `blocks` edges; no children. Priority pressure comes from the + Connect-docs port (origin strand br-toc-location-left-q7hl5jgj in that + repo's skein), not from the q2 graph. + +## What the code looks like today + +The file references in the strand are current at HEAD: + +- `crates/quarto-core/src/template.rs:213-235` — `FULL_HTML_TEMPLATE` + hardcodes the TOC into `
` on the right; + `#quarto-content` classes are baked in (no `toc-left` variable). +- `crates/quarto-core/src/template.rs:337` — the inert banner hook: + `$if(quarto-template-params.banner-header-class)$` on + `#title-block-header`. +- `crates/quarto-core/src/transforms/title_banner.rs:41` — documents the + deliberately-unported `toc-left` producer. +- `crates/quarto-core/src/transforms/toc_render.rs` — renders + `navigation.toc` → `rendered.navigation.toc` (inner `
    ` only; entries + carry `nav-link` + `data-scroll-target` unconditionally). +- `crates/quarto-core/src/transforms/sidebar_render.rs` — renders the + website sidebar → `rendered.navigation.sidebar` (a complete + `nav#quarto-sidebar` fragment via `quarto_navigation::sidebar_to_html`) + and writes `rendered.navigation.body-classes` = `"nav-sidebar + floating|docked"`; skips entirely when `navigation.sidebar` is absent. +- `render_with_compiled_template` (`template.rs:697-733`) — body-class + precedence: user override → sidebar body-classes → empty-when-TOC → + `fullcontent`. +- SCSS is **already ported** in `resources/scss/bootstrap/_bootstrap-rules.scss`: + `.page-columns.toc-left` grids (wide/mid/narrow), `.sidebar.toc-left` + placement, `#quarto-sidebar-toc-left` responsive hiding, sticky rules. +- Preview: `ts-packages/preview-renderer/src/q2-preview/chromeSlots.tsx` + `TocSlot` hardcodes `#quarto-margin-sidebar` too (shape contract with the + template). +- No `toc-location` schema entry anywhere in q2. + +Symptom re-confirmed at HEAD (2026-08-14, `main` after PR #530): +`cargo run --bin q2 -- render .` on the local repro puts `nav#TOC` inside +`#quarto-margin-sidebar` and emits zero `#quarto-sidebar` (or +`#quarto-sidebar-toc-left`) elements — output inspected, see +`toc-location-investigation/repro/README.md`. + +Pre-flight note: `cargo xtask verify --skip-hub-build` at HEAD had exactly +one failure, `quarto-preview::integration +config_endpoint::config_reports_embedded_asset_manifest_hashes` — a stale +local `q2-preview-spa/dist/` (built before `f366cb5d` introduced +`spa-manifest.json`). `cargo xtask build-q2-preview-spa` regenerated the +manifest and the test passes; unrelated to this strand. + +## How Q1 does it (mechanism summary, from external-sources/quarto-cli 1.10.15) + +Full agent report notes below are the source for these line refs. + +- **Option**: `toc-location` enum `["body", "left", "right", "left-body", + "right-body"]`, default `right`, HTML-doc formats only + (`src/resources/schema/document-toc.yml:36-52`). +- **Mechanism**: templates emit a `div#quarto-toc-target` placeholder; a DOM + postprocessor (`format-html-bootstrap.ts:342-412`) moves `nav[role=doc-toc]` + into it (adding `.toc-active`, `nav-link`, `data-scroll-target`, + collapse classes). q2 must re-express this as template/transform logic + (no-DOM-postprocessor rule). +- **Two layout regimes for `left`:** + - *Standalone/article path* (`before-body-article.ejs`): `#quarto-content` + gets class `toc-left`; a `div#quarto-sidebar-toc-left.sidebar.toc-left` + holds the TOC; grid comes from `.page-columns.toc-left` mixins gated on + `body:not(.floating):not(.docked)`. The (empty) `#quarto-margin-sidebar` + is still emitted and later gets `zindex-bottom`. + - *Website path* (`nav-before-body.ejs` + `sidebar.ejs`): the TOC target + goes **inside `nav#quarto-sidebar`** — merged after nav items when a + sidebar exists, or as the sole content of a synthesized + `nav#quarto-sidebar … floating` when no sidebar is configured. Body gets + `floating`/`docked`, and the `body.floating` grid (NOT `toc-left`) + provides the left column. This is the repro's shape. +- **`body`**: no target emitted → the TOC stays where Pandoc put it in + `main`, and gets *none* of the scroll-spy decorations (plain list, no + `.toc-active`). +- **`left-body` / `right-body`**: sidebar TOC plus a plain clone + (`id="TOC-body"`, `.toc-actions` stripped) left in the body. +- **Banner**: `format-html-title.ts:169-175` sets + `banner-header-class: toc-left` when `toc-location === "left"` (exactly — + `left-body` misses it, a latent Q1 inconsistency); the class makes the + relocated banner header use the `toc-left` grid so its `column-body` + aligns. +- **About pages** force `right`; **manuscripts** default to `left`. + +## Proposed phases (draft) + +Skeleton only — contents firm up after the design discussion. + +- **Phase 0 — Test plan (TDD).** Failing tests through + `render_document_to_file` / project render: (a) website repro shape — + `toc-location: left` in a website project yields TOC inside a left + sidebar container and NOT in `#quarto-margin-sidebar`; (b) standalone doc + with `toc-location: left`; (c) `toc-location: body`; (d) default/`right` + unchanged (snapshot-neutral); (e) banner + `toc-location: left` → + `toc-left` on `#title-block-header`; (f) invalid value diagnostics (if we + validate). +- **Phase 1 — Option plumbing.** Read `toc-location` from merged metadata + (`as_plain_text`, per the metadata-as-str lint), normalize, publish for + template + downstream consumers (e.g. + `rendered.navigation.toc-location`). +- **Phase 2 — Placement.** Template/transform changes for `left` + (standalone and website shapes), `body`, keeping `right` as-is; + body-class / `#quarto-content`-class plumbing. +- **Phase 3 — Banner producer.** `TitleBannerTransform` (or the title + pipeline) writes `quarto-template-params.banner-header-class = "toc-left"`; + un-inert the template.rs:337 hook; update the title_banner.rs module docs. +- **Phase 4 — Preview parity.** `TocSlot`/`chromeSlots.tsx` + + `PreviewDocument.tsx` honor the published location (WASM rebuild chain for + verification). +- **Phase 5 — Schema + docs + E2E.** Schema entry (enum), docs page in + `docs/`, end-to-end verification against the Connect-docs repro, snapshot + review. + +## Open design questions for the user + +1. **Value scope.** Q1's enum is `body | left | right | left-body | + right-body`. Proposal: implement `left`, `right`, `body` now; reject or + warn-and-fallback on `*-body` (follow-up strand for the clone behavior). + OK? +2. **One mechanism or two?** Q1 has two `left` layouts: standalone + (`#quarto-sidebar-toc-left` + `.page-columns.toc-left` grid) and website + (TOC merged into `nav#quarto-sidebar`, `body.floating` grid). Both SCSS + regimes are already ported. Do we (a) mirror both shapes (standalone docs + get the toc-left grid, website pages get the sidebar merge), or (b) pick + one mechanism everywhere (e.g. always the `#quarto-sidebar-toc-left` + shape, accepting divergence from Q1 in websites — but then nav-sidebar + + left-TOC coexistence needs its own answer)? Given the driving use case is + a website (Connect docs), (a) seems safer; the repro's exact shape is the + website one. +3. **Where does the website merge happen?** The TOC-inside-`#quarto-sidebar` + case couples two renderers. Options: (a) `SidebarRenderTransform` learns + about the rendered TOC (ordering: it must then run after `TocRender`, or + read `navigation.toc` and render on demand); (b) the template grows a + sidebar-with-toc branch and `sidebar_to_html` exposes a "don't close the + nav yet" seam; (c) a small `TocLocationTransform` that runs late, + relocating rendered fragments in metadata. Preference? +4. **`body` fidelity.** In Q1, a body TOC is a *plain* list (no scroll-spy + classes, no `.toc-active`). q2's `toc_render` bakes `nav-link` + + `data-scroll-target` into every entry. Match Q1's plainness (second + render path), or ship the decorated markup in the body too (simpler; the + classes are inert without the sidebar JS)? +5. **Empty right margin sidebar.** Q1 keeps an empty + `#quarto-margin-sidebar` (`zindex-bottom`) when the TOC moves left. q2 + currently omits the element when it has nothing to show. Keep q2's + omission (cleaner; unknown CSS dependencies?) or emit the empty shell for + parity? +6. **Banner gate.** Copy Q1 exactly (`toc-left` only when the value is + exactly `left`) or also cover `left-body` if/when that lands (fixing + Q1's latent inconsistency)? Trivially decidable later if `*-body` is + deferred per Q1 above. +7. **Preview scope.** Is preview parity (Phase 4) in-scope for this strand, + or a follow-up strand? The preview's `TocSlot` shape contract means + `q2 preview` will keep showing the TOC on the right until it's taught + otherwise. + +## Risks / tradeoffs (draft) + +- `FULL_HTML_TEMPLATE`'s `#quarto-content` class list is baked in; `left` + (standalone) needs a `toc-left` class there, which means a new template + variable and touching the body-class precedence logic + (`template.rs:697-733`) — the subtle `fullcontent`-vs-empty fallback has + bitten before (bd-mgoh); tests must pin all four precedence cases. +- The website-without-sidebar case synthesizes a floating sidebar container + + `body.floating` — interacts with `SidebarRenderTransform`'s skip + conditions and could surprise the `nav-sidebar` body-class consumers. +- Snapshot churn: any template reshuffle around the margin sidebar risks + touching many HTML snapshots; keep `right` byte-stable to keep the diff + reviewable. +- Preview/template shape drift (risk of a `/preview-parity` class of bug) if + Phase 4 is deferred — flag it explicitly in the strand if so. + +## Investigation artifacts + +- `claude-notes/plans/toc-location-investigation/repro/` — minimal website + repro (mirrors the committed external repro). +- `claude-notes/plans/toc-location-investigation/q1-mechanism-notes.md` — + full Q1 exploration report (file:line references into + external-sources/quarto-cli). diff --git a/claude-notes/plans/toc-location-investigation/q1-mechanism-notes.md b/claude-notes/plans/toc-location-investigation/q1-mechanism-notes.md new file mode 100644 index 000000000..afd176cfd --- /dev/null +++ b/claude-notes/plans/toc-location-investigation/q1-mechanism-notes.md @@ -0,0 +1,185 @@ +# Q1 `toc-location` mechanism notes (bd-e2kpwy7n investigation) + +Exploration of `external-sources/quarto-cli` (1.10.15), 2026-08-14. All +paths below are relative to `external-sources/quarto-cli/` unless noted. + +## 1. Declaration / read sites + +- Constant: `src/config/constants.ts:603` — `kTocLocation = "toc-location"`. +- Schema: `src/resources/schema/document-toc.yml:36-52` — + `enum: ["body", "left", "right", "left-body", "right-body"]`, default + `right`, `formats: [$html-doc]`. +- Readers (all six in the tree): + - `src/format/html/format-html-bootstrap.ts:127-129` — standalone/article path + - `src/format/html/format-html-bootstrap.ts:349` — double-TOC (`*-body`) detection + - `src/format/html/format-html-title.ts:171` — banner header class + - `src/project/types/website/website-navigation.ts:252-256, 302` — website path + - `src/project/types/manuscript/manuscript.ts:454-455` — manuscripts default to `left` + - `src/project/types/website/about/website-about.ts:106` — about pages force `right` (and `toc: false`) +- No runtime normalization beyond the YAML enum; the raw string is passed to + EJS and compared with `===`. + +## 2. Placement mechanism + +Two template paths emit a `div#quarto-toc-target` placeholder; one shared +DOM postprocessor moves `nav[role="doc-toc"]` into it. + +### 2a. Standalone / non-website article + +`src/format/html/format-html-bootstrap.ts:126-142` renders +`src/resources/formats/html/templates/before-body-article.ejs`: + +```ejs +<% +const navbarTocLeft = tocLocation === "left" || tocLocation === "left-body"; +const navbarTocRight = tocLocation === "right" || tocLocation === "right-body"; +%> +
    "> +<% if (navbarTocLeft) { %> + +<% } %> + +
    +``` + +`body` produces **no** target at all. `#quarto-margin-sidebar` is always +emitted (it may hold margin content even without a TOC). + +### 2b. Website projects + +`src/project/types/website/website-navigation.ts:252-256, 302, 379` → +`src/resources/projects/website/templates/nav-before-body.ejs:3-4, 111-125`: + +```ejs +const navbarTocLeft = nav['toc-location'] === "left" || nav['toc-location'] === "left-body"; +... + +<% if (nav.sidebar || navbarTocLeft) { %> + <% partial('sidebar.ejs', { sidebar: nav.sidebar, sidebarStyle: nav.sidebarStyle, navbar: !!nav.navbar, toc: navbarTocLeft, ... }) %> +<% } %> + +<% if (nav.layout === "article" || nav.layout === "full") { %> + +<% } %> +``` + +`src/resources/projects/website/templates/sidebar.ejs:1, 95-97`: + +```ejs + +
    +``` + +Asymmetries vs. the standalone path: no `toc-left` class on +`#quarto-content`; the left target lives inside `nav#quarto-sidebar`, not a +separate `#quarto-sidebar-toc-left`. + +- **Nav sidebar + left TOC** → one `nav#quarto-sidebar`, nav items first, + TOC appended after — merged, no second container. +- **No nav sidebar + left TOC (in a website)** → `sidebar.ejs` renders with + `sidebar === undefined`; the wrapper holds only the TOC target and gets + class `floating` (ternary fallback). `website-navigation.ts:538-543` then + latches `floating` onto ``, so the `page-columns-float-*` grids + apply — **not** the `toc-left` grids, which are gated on + `body:not(.floating):not(.docked)`. + +### 2c. The mover (shared DOM postprocessor) + +`src/format/html/format-html-bootstrap.ts:342-412` +(`bootstrapHtmlPostprocessor`): + +- `right`/`left` → `toc.remove(); tocTarget.replaceWith(toc)`; adds + `.toc-active`, `nav-link` classes, `data-scroll-target`, `.collapse` on + nested `ul`s, `data-toc-expanded`. +- `body` → `tocTarget` is null → TOC left in `main`, **no** decorations + (plain static list; smoke test + `tests/docs/smoke-all/issues/3473-toc-side-body/body.qmd`). +- `left-body`/`right-body` → clone with `id="TOC-body"` (`.toc-actions` + stripped) inserted before the original, original moved to the sidebar. + +## 3. Layout / CSS + +SCSS (already ported into q2's `resources/scss/bootstrap/`): + +| What | Q1 file:line | +|---|---| +| `.page-columns.toc-left` wide grid | `_bootstrap-rules.scss:62-70` | +| `.page-columns.toc-left` mid grid | `_bootstrap-rules.scss:145-152` | +| narrow collapse + `nav[role="doc-toc"] { display:none }` | `_bootstrap-rules.scss:219-232` | +| `.sidebar.toc-left` grid placement (`page-start / body-start`) | `_bootstrap-rules.scss:303-306` | +| toc-left margin-element reflow into body column | `_bootstrap-rules.scss:545-557` | +| `#quarto-margin-sidebar` / `#quarto-sidebar-toc-left` hidden below md | `_bootstrap-rules.scss:574-581` | +| sticky sidebar rules | `_bootstrap-rules.scss:1075-1087` | +| sidebar-TOC typography/active/border | `_bootstrap-rules.scss:1201-1330` | +| `page-columns-tocleft-wide/-mid` mixins | `_bootstrap-mixins.scss:1302-1334` | +| grid track vars | `_bootstrap-mixins.scss:846-895` | + +`.sidebar.quarto-banner-title-block-sidebar` (`_bootstrap-rules.scss:1090-1094`) +appears dead — nothing assigns the class. + +## 4. Banner interaction + +`src/format/html/format-html-title.ts:169-175`: when banner (or manuscript) +and `toc-location === "left"` **exactly** (`left-body` misses it — latent Q1 +inconsistency), set `templateParams["banner-header-class"] = "toc-left"`. +Consumed by `templates/banner/title-block.html:2` and +`templates/manuscript/title-block.html:1` — same `$if$` hook q2 already +carries at `crates/quarto-core/src/template.rs:337`. Why needed: Q1 moves +the banner header out of `#quarto-content` +(`format-html-title.ts:262-268`), so it loses the inherited `toc-left` grid +and must carry the class itself for its `column-body` to align. + +## 5. Standalone `left` output shape + +```html +
    + + +
    +
    +``` + +No `collapse`/`sidebar-navigation`/`floating`/`#quarto-sidebar-glass` — +those are website-only. The empty margin sidebar survives +(`bootstrapHtmlFinalizer` only removes it when there is no TOC and no +margin content, `format-html-bootstrap.ts:1030-1032`) and gets +`zindex-bottom` when empty (`:1084-1091`). + +Other consumers of these ids: + +- `format-html-bootstrap.ts:857-865` — "Other Formats/Links" target chain: + `nav[role=doc-toc]` → `#quarto-sidebar-toc-left` → `#quarto-margin-sidebar`. +- `src/resources/formats/html/quarto.js:53-58, 556-572` — runtime toggles: + `#quarto-margin-sidebar` → `quarto-toc-toggle`, `#quarto-sidebar` → + `quarto-sidebarnav-toggle`, `#quarto-sidebar-toc-left` → + `quarto-lefttoc-toggle`. (So in the website left case the TOC gets the + *sidebarnav* toggle, another behavioral difference between the paths.) + +## 6. Gotchas for a q2 implementation + +1. `toc-left` as a grid class only exists on the standalone path; websites + ride `body.floating`/`body.docked` grids. Two layout regimes, one option. +2. `body` = absence of a target = plain undecorated list (no scroll-spy). +3. `*-body` = clone `id="TOC-body"` with `.toc-actions` removed. +4. `banner-header-class: toc-left` gated on `=== "left"` exactly. +5. The article path emits `#quarto-margin-sidebar` unconditionally even for + `left`; downstream code (`getLinkTarget`, `zindex-bottom`, + `fullcontent`/`slimcontent` heuristics at + `format-html-bootstrap.ts:1036-1073`) depends on its presence. diff --git a/claude-notes/plans/toc-location-investigation/repro/.gitignore b/claude-notes/plans/toc-location-investigation/repro/.gitignore new file mode 100644 index 000000000..f6bae5814 --- /dev/null +++ b/claude-notes/plans/toc-location-investigation/repro/.gitignore @@ -0,0 +1,2 @@ +_site/ +.quarto/ diff --git a/claude-notes/plans/toc-location-investigation/repro/README.md b/claude-notes/plans/toc-location-investigation/repro/README.md new file mode 100644 index 000000000..d2afbc3f3 --- /dev/null +++ b/claude-notes/plans/toc-location-investigation/repro/README.md @@ -0,0 +1,23 @@ +# toc-location: left repro (bd-e2kpwy7n) + +Local copy of the committed external repro from the Connect-docs port +(`/Users/cscheid/repos/github/cscheid/q2-connect-docs/llms-info/repros/toc-location-left/`). + +Run: + +```bash +cargo run --bin q2 -- render . +``` + +Observed at HEAD (2026-08-14, main after PR #530): + +- `grep -c 'id="quarto-sidebar"' _site/index.html` → `0` +- `nav#TOC` renders inside `