From b4558aef865b311fe7087f75f805dcbd5b82b42f Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 15:06:06 -0500 Subject: [PATCH 01/10] light-dark epic: design doc (bd-0pic6) Design settled 2026-08-14: Q1-compatible rel-swap runtime with the color-scheme improvement (D1a), FOUC avoidance as the hard constraint, epic children A1-E created in braid, integration branch feature/light-dark-theme. Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 claude-notes/plans/2026-08-14-light-dark-theme-epic.md diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md new file mode 100644 index 000000000..d06f77354 --- /dev/null +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -0,0 +1,470 @@ +# Light/dark theme support epic (bd-0pic6) + +**Created**: 2026-08-14 +**Status**: DESIGN SETTLED (2026-08-14) — all open questions resolved with +Carlos; epic structure created in braid; awaiting go-ahead to start execution +**Umbrella strand**: bd-0pic6 (epic; children bd-… created 2026-08-14, see +"Proposed epic structure") +**Supersedes/absorbs**: the deferred "Phase 6b.6 Light/Dark Support" placeholder in +`2026-01-23-phase6b-custom-scss.md`; the `SassBundle.dark` scaffolding sketched in +`2026-01-13-sass-compilation.md` §7.3. +**Builds on**: `2026-08-08-theme-light-dark-interim.md` (bd-o76p01wb, PR #475 — map +parses, light half applied, Q-14-3 warning on `dark:`). + +## Goal + +Full Q1-parity dark mode for Q2, prioritized so that `format: html` documents and +website projects (concretely: `external-sources/quarto-web`) work first: + +```yaml +format: + html: + respect-user-color-scheme: true + theme: + light: [cosmo, theme.scss] + dark: [cosmo, theme-dark.scss] +highlight-style: a11y +``` + +renders a site with both CSS variants compiled, a working navbar toggle, +`prefers-color-scheme` respected, persistence via localStorage, correct body +classes (`quarto-light`/`quarto-dark`), and light/dark-aware syntax highlighting. +Later phases extend the same seam to brand pairs, `q2 preview`/hub-client, and +revealjs. + +## Reference: how Q1 (1.11) does it + +(From a close read of `external-sources/quarto-cli` at 1.11.1. File refs below are +into that tree.) + +### Compilation model + +- `theme: {light, dark}` normalizes to `Themes {light: string[], dark?: string[]}` + (`src/format/html/format-html-scss.ts:375-455`). **YAML key order is semantic**: + `defaultDark = keys[0] === "dark"` (also `format-html-info.ts:46-70`, which + falls back to the `brand:` map's key order). +- One `SassBundle` per dependency with a nested `dark?: {user, quarto?, framework?, + default: bool}` variant. At compile time (`src/command/render/pandoc-html.ts:140-198`) + the bundle list is split into **full, separate CSS compiles**: + - author-default-dark → emit order: `light, dark` + - author-default-light → emit order: `light, dark, light-copy` — the trailing + copy (class `quarto-color-scheme-extra`) exists so that first paint and + JS-disabled browsers land on the default variant ("last enabled sheet wins"). + It is a cache hit, not a real second compile. + - Per-layer fallback: a bundle with no dark opinion contributes its light + layers to the dark compile (`bundle.dark?.user || bundle.user`). +- Link attributes: all variants are emitted `rel="stylesheet"` with classes + `quarto-color-scheme` (light) / `quarto-color-scheme quarto-color-alternate` + (dark) / `quarto-color-scheme-extra` (trailing copy), `data-mode="light|dark"`, + and a shared `id="quarto-bootstrap"` (dupe ids are intentional; JS queries + "first not-disabled"). **There is no `rel="alternate stylesheet"` anywhere** — + that mechanism was never used. + +### Runtime model + +- A **synchronous inline script placed as the first child of ``** + (`quarto-html-before-body.ejs`, moved there by `format-html-bootstrap.ts:521-527`) + flips `link.rel` between `"stylesheet"` and `"disabled-stylesheet"`, toggles + `body.quarto-light`/`body.quarto-dark` from the active sheet's `data-mode`, + and immediately disables the trailing light copies when the author default is + light. Running before first paint is what prevents FOUC (Q1 #1325). +- Dark is **layered on top of light** — enabling dark never disables the light + sheet; the dark CSS only needs to override. +- Persistence: localStorage key `quarto-color-scheme` with values + `"default"`/`"alternate"` (**alternate ≠ dark**; it means "not the author's + default variant"). `file://` falls back to a JS variable. +- `respect-user-color-scheme: true` (default false): initial state comes from + `matchMedia('(prefers-color-scheme: dark)')`, with a `change` listener; an + explicit localStorage choice always wins over the media query. +- Toggle widget: navbar/sidebar tool (`navdarktoggle.ejs` — an `` calling + `window.quartoToggleColorScheme()`), floating `top-right` fallback for plain + documents. Icon on/off state is pure CSS keyed on `.alternate`. +- Component adaptation: (a) CSS custom properties recompiled per variant (mermaid + `--mermaid-*`, Bootstrap `--bs-*`); (b) `body.quarto-light .dark-content + {display:none}` content-swap rules (`_quarto-rules.scss:766-774`); (c) giscus + gets an explicit postMessage; (d) a `resize` event is dispatched on toggle. + +### highlight-style + +- `highlight-style` accepts scalar, `{light, dark}` map, and **adaptive** single + names (`a11y`, `arrow`, `atom-one`, `ayu`, `breeze`, `github`, `gruvbox`, + `monochrome`) that resolve `-light.theme` / `-dark.theme` + (`src/quarto-core/text-highlighting.ts:34-118`). +- Two highlighting CSS files are emitted with the same class/ordering scheme as + the theme sheets (`id="quarto-text-highlighting-styles"`). +- A theme-darkness sentinel comment `/*! dark */` (emitted by SCSS when + `blackness($body-bg) > threshold`) auto-selects the dark highlight variant for + single dark themes and drives `data-mode`. +- The variant's highlight theme also feeds `$code-block-bg`/`$code-block-color` + etc. *into* the theme SCSS compile (`resolveTextHighlightingLayer`). + +### brand + +- `brand:` resolves to a light/dark `Brand` pair; a unified `_brand.yml` with any + `{light:, dark:}`-valued field is split into two `Brand`s + (`core/brand/brand.ts:635-805`). Brand layers ride as a `key: "brand"` bundle + spliced at the `"brand"` marker position in each variant's theme list. + +## Q2 current state (what exists, what's missing) + +### Exists and is directly reusable + +| Piece | Where | Notes | +|---|---|---| +| Map parsing (light half) | `quarto-sass/src/config.rs` `light_dark_pair`/`LightDarkPair` | Interim: drops the dark value, keeps its `SourceInfo` for Q-14-3. The fork point. | +| Pure compile path | `process_theme_specs` → `assemble_theme_scss` → `compile_with_doc_vars` | `&ThemeConfig + &ThemeContext → String`; can simply run twice. | +| Path rebasing for map leaves | `quarto-core/src/project/mod.rs` `FRAGMENT_PATH_PATTERNS` | Already rebases every string leaf under `theme`, incl. map form. | +| Body-class seam | `quarto-core/src/template.rs:759` `append_color_mode_class()` | Hardcodes `quarto-light`; bd-mtzry comment says it grows a `mode` arg. | +| Toggle CSS | `resources/scss/bootstrap/_bootstrap-rules.scss:2087-2224` | Full `.quarto-color-scheme-toggle` styling (navbar/sidebar/top-right/`.alternate`) already ported. | +| Content-swap CSS | `resources/scss/bootstrap/dist/scss/_light-dark.scss` | Vendored; only light half live (bd-l1rx9yzh). | +| `BuiltInTheme::is_dark()` | `quarto-sass/src/themes.rs:133` | Unused oracle for the darkness sentinel. | +| Dead scaffolding | `quarto-sass/src/types.rs` `SassBundle{,Dark}` | Ported from TS, wired nowhere. Either wire or delete. | +| Brand light/dark data | `quarto-brand/src/types.rs:153-178, 472-504` | `{light, dark}` fields and `LogoEntry::LightDark` parse; picking a side deferred (bd-v5z8w). | +| Editor color scheme | `hub-client/src/components/ThemeContext.tsx` | auto/dark/light provider for hub-client chrome; obvious signal source for preview iframe. | +| Map key order | `quarto-pandoc-types/src/config_value.rs:218` `Map(Vec)` | Insertion order preserved ⇒ Q1's default-dark rule is implementable. (Verify merge doesn't reorder.) | + +### Gaps (the actual work) + +1. **`ThemeConfig` has no dark variant** — `LightDarkPair.dark_ignored` keeps only + a location. Same for `extract_brand_ref` (silent TODO). +2. **Single-artifact assumption**: exactly one `css:theme:` artifact, assumed by + `pass2_renderer.rs:869,1135`, `wasm-quarto-hub-client/src/lib.rs:1579` + (`extract_theme_fingerprint`), pipeline tests, `preview_render_css_parity.rs`. +3. **Template can't emit per-link attributes**: `$for(css)$$endfor$` — no way to add class/id/data-mode. (`SassBundle.attribs` + was TS's answer; unused here.) +4. **No toggle JS**, no before-body injection point, no localStorage/persistence, + no `respect-user-color-scheme` reader (zero hits in the workspace). +5. **No `highlight-style` reader at all**; highlight colors are one static SCSS + layer (`resources/scss/html/templates/highlight.scss`, solarized-ish `hl-*` + classes) loaded unconditionally into every compile. Q1's `.theme` JSONs target + Pandoc's short classes (`.kw`, `.st`), not Q2's tree-sitter `hl-*` classes — + not directly reusable. +6. **Navbar model has no `tools:`** (`quarto-navigation/src/navbar.rs:104`) — no + place to render the toggle; `navbar_to_html`'s right-hand `
    `/search slot is + the insertion point. (bd-fod3 tracks `tools:` generally.) +7. **`DEFAULT_CSS_CACHE` is a single-slot `OnceLock`**; `cache_key` has no variant + discriminator (bd-8oqw wants a structured `CompileInputs` anyway). +8. **`ThemeContext` holds exactly one brand** — a dark compile with a dark brand + needs a second context (or a `brand_dark` field). +9. **Preview transport is single-slot**: renderer writes one `styles.css` to the + VFS; `Q2PreviewIframe.tsx` posts one `UPDATE_THEME` cssUrl; iframe `applyTheme` + maintains exactly one ``. +10. **`BootstrapJsStage` is native-only** — hub-client preview never gets + Bootstrap JS; the toggle JS must be dependency-free plain DOM JS. +11. **Dark half of content-swap rules not emitted** (bd-l1rx9yzh) — quarto-web's + footer logo and `include-dark.lua` filter depend on it. + +### What quarto-web specifically needs (acceptance target for the first phase) + +- `theme: {light: [cosmo, theme.scss], dark: [cosmo, theme-dark.scss]}` — same + Bootswatch base both sides; dark achieved purely by the user layer. The primary + case for dual compile. +- `respect-user-color-scheme: true`. +- `highlight-style: a11y` (top-level, adaptive name → a11y-light/a11y-dark). +- `body.quarto-light` / `body.quarto-dark` rules in its own `styles.css`/`index.css` + and `.light-content`/`.dark-content` content swapping (Posit logo, include-dark + filter output). +- Its `theme-dark.scss` uses Bootstrap functions (`shade-color`) and vars + (`$mono-background-color`), i.e. the dark compile must be a full + framework+quarto+user assembly, not a bolt-on. +- Prerelease profile uses a light-only map (no `dark:` key) — must keep rendering + without warning (already covered by interim D6 tests). +- Adjacent but separate strand: `css: styles.css` files are not copied into + `_site/` by website projects (bd-r1y48cx0) — quarto-web's `body.quarto-dark` + rules live there, so that bug masks part of this feature's effect. + +## Proposed design + +### D1 — adopt Q1's runtime mechanism essentially verbatim + +Compile full separate CSS variants; emit ordered `` tags +with Q1's exact classes/ids/`data-mode` (including the trailing default-copy +trick); port the before-body toggle script (de-EJS'd); localStorage sentinel with +the same key and `default`/`alternate` semantics; `body.quarto-light/quarto-dark` +classes. + +Rationale: quarto-web's own CSS (and the broader Q1 ecosystem's custom CSS) +targets `body.quarto-light`/`body.quarto-dark` and the toggle classes; Q1 spent +several 1.7 iterations converging on the no-FOUC/no-JS-safe ordering, and the +toggle CSS is already ported. A "modern" alternative (CSS `light-dark()`, +`color-scheme`, media-query-only) cannot express arbitrary author-supplied theme +pairs compiled from different SCSS, and breaks localStorage override semantics. +Divergences we deliberately keep from Q2's cleaner architecture: links come from +the artifact system rather than DOM postprocessing (no DOM postprocessor rule), +and the script is a static asset + tiny inline config rather than an EJS +template. + +**Constraint hierarchy (Carlos, 2026-08-14).** Q1's odd-looking triple-link +ordering exists because Q1 tried to serve *two* goals at once: some +light-vs-dark support with JS disabled (degrading to a single stylesheet — the +author default) *and* no FOUC. For Q2 the priorities are explicit: **avoiding +FOUC is a hard constraint; no-JS degradation is a nice-to-have** in this first +pass. We keep the emit-default-last ordering (it serves both goals at zero +cost), but when a future trade-off pits no-JS support against simplicity or +FOUC avoidance, no-JS loses. We are not obligated to mimic every idiosyncrasy +of the Q1 design. + +### D1a — improvement over Q1: set `color-scheme` (approved direction 2026-08-14) + +Q1 never sets the CSS `color-scheme` property, which is why dark Q1 pages have +light scrollbars/native popups and why its toggle JS carries the Safari +scrollbar-recolor hack (quarto-cli #1455). Q2 does better, cheaply: + +- **Each compiled variant emits its own scheme**: `:root { color-scheme: light }` + / `dark`, driven by the same darkness determination D3 computes for + `data-mode`. The rel-swap toggle then flips the scheme automatically — no JS + bookkeeping (optionally one belt-and-braces `documentElement.style.colorScheme` + sync in the toggle). +- **`` in the template head**, baked at render time: + author-default value normally; `light dark` when + `respect-user-color-scheme: true` (correct pre-CSS canvas paint, reduces + flash-into-dark). Must NOT be `light dark` when the author default is fixed — + otherwise author-light + OS-dark flashes dark before CSS loads. +- **Free bonus independent of the pair feature**: a single dark theme + (`theme: darkly`) gets `color-scheme: dark` from its compile via the darkness + sentinel — correct scrollbars/controls for existing dark-theme users. +- **Enables `light-dark()`** in user CSS as the documented Q2 idiom (one rule + instead of a `body.quarto-light`/`body.quarto-dark` pair; body classes stay + for Q1 compat). We do not port the Safari scrollbar hack. +- Scope: lands inside A2 (SCSS emission) + A3 (meta tag) + A4 (JS sync line); + verification includes eyeballing Bootstrap form controls vs UA defaults. + +### D2 — data model: `ThemeConfig` grows a dark variant + +`LightDarkPair` carries `dark: Option<&ConfigValue>` instead of dropping it. +`ThemeConfig` gains: + +```rust +pub struct DarkTheme { + pub themes: Vec, + pub theme_locations: Vec>, + pub suppress_bootstrap: bool, // {dark: none} — mirrors light-half handling + pub is_default: bool, // YAML key order: dark listed first +} +pub struct ThemeConfig { + /* existing light fields unchanged */ + pub dark: Option, // replaces dark_theme_ignored +} +``` + +- `from_config_value` parses both halves through the shared `from_theme_value` + helper; brand auto-injection applies per-variant (dark brand once D7 lands; + until then the light brand feeds both, matching Q1's per-layer fallback). +- Q-14-3 ("dark not yet supported") is **removed** when the dark half takes + effect; the interim tests in `theme_light_dark.rs` invert (assert dark marker + present in the dark artifact, no warning). +- `bootstrap_js.rs` predicate updates trivially (any variant non-suppressed ⇒ + ship JS). +- Semantics table (extends interim D3): `{light: none, dark: darkly}` — light + variant is unstyled default? Q1 gates dark mode on `formatHasBootstrap`; we + mirror: `none` on either half of the pair is an edge to define in tests + (proposal: `theme: none`-style suppression applies per-variant; toggle emitted + only when both variants produce stylesheets). + +### D3 — dual compile in `CompileThemeCssStage` + +Call the existing pure pipeline twice. Dark compile = same built-in layers + +dark spec list (+ dark doc-vars where variant-dependent, e.g. future +`$code-block-bg` from highlight style). Artifacts: + +- keys `css:theme:` (light) and `css:theme-dark:` (dark) — the dark key + sorts after the light key, matching the required link order for + author-default-light; for author-default-dark we need explicit order control + (see D4). +- paths: websites `quarto/quarto-theme-.css` + `quarto/quarto-theme-dark-.css`; + single-doc `styles.css` + `styles-dark.css`. +- cache: add a variant discriminator to `cache_key` (fold into bd-8oqw's + `CompileInputs` refactor if convenient); `DEFAULT_CSS_CACHE` becomes two-slot + or keyed. +- update every "exactly one `css:theme:*`" consumer (gap #2 list). +- darkness sentinel: instead of grepping compiled CSS like Q1, compute + `is_dark` from `BuiltInTheme::is_dark()` + (later) the highlight-style + variant; if that proves insufficient for custom SCSS (e.g. `[cosmo, + theme-dark.scss]` is "dark" only by its `$body-bg`), fall back to porting the + `/*! dark */` SCSS sentinel — the vendored `_bootstrap-rules.scss` may already + contain it (verify). + +### D4 — link emission with attributes + +Extend the artifact→template channel to structured entries: `Artifact` gains an +optional `attribs: Vec<(String, String)>` (the revival of the dead +`SassBundle.attribs` idea, but on the artifact); `collect_artifact_urls` returns +`{href, attrs}` objects; templates render `` +(doctemplate supports map access; fall back to a pre-rendered attribute string +if not). When no dark variant exists, attrs are empty and output is +byte-identical to today (no snapshot churn outside the feature). + +The author-default-light "trailing light copy" is a third artifact entry +referencing the same CSS path with the `quarto-color-scheme-extra` class — no +recompile, just a second link. Explicit ordering: give theme links an explicit +sort-stable key scheme (`css:theme:0:`, `css:theme:1:-dark`, …) or an +`order` field, rather than relying on lexicographic accident. + +### D5 — toggle runtime + +- **Static JS asset** `quarto-color-mode.js` (port of `quarto-html-before-body.ejs` + logic, no EJS): rel-swap enable/disable, body-class sync, localStorage + sentinel, `respect-user-color-scheme` media-query path, giscus hook omitted + until Q2 has comments, `resize` dispatch. Config (authorPrefersDark, + respectUserColorScheme) passed via `data-*` attributes on its own script tag. +- **Placement**: must run before first paint ⇒ injected at the top of `` + via the template (new template slot or `include_before`), not via the sorted + `$for(scripts)$` head loop. Plain DOM JS, no Bootstrap dependency ⇒ works in + hub-client preview later. +- **Body class at render time**: `append_color_mode_class(mode)` grows its mode + argument (bd-mtzry); default class = author default (respecting + `respect-user-color-scheme` means the JS may flip it before paint, same as Q1). +- **Toggle widget**: website navbar/sidebar — render into `navbar_to_html`'s + right-hand slot when the format has a dark variant (interim: hardcoded + emission, folded into bd-fod3's `tools:` support when that lands); plain + documents get the floating `top-right` fallback (small DOMContentLoaded block + in the same JS asset). CSS already shipped. +- **`respect-user-color-scheme`** reader added to the html format config. +- **Dark half of `_light-dark.scss`** content-swap rules activated + (bd-l1rx9yzh joins this epic). + +### D6 — highlight-style reader + variant palettes + +Two-stage scope: + +1. **Epic phase (needed for quarto-web)**: add a `highlight-style` config reader + (scalar + `{light, dark}` map + adaptive-name resolution). Represent each + style as an SCSS layer in `resources/scss/html/highlight-styles/.scss` + targeting Q2's `hl-*` classes. Selection replaces the currently-unconditional + `load_highlight_layer` per compile variant. Ship a small curated set first: + the current default (solarized) + `a11y` light/dark (hand-translated from + Q1's `a11y.theme`/`a11y-dark.theme` JSON via a Pandoc-token → tree-sitter + capture mapping table). Unknown style names ⇒ structured warning + default. +2. **Follow-up strand**: a general `.theme`-JSON → `hl-*` SCSS translator (build + time or xtask codegen) to cover Q1's full style catalog; plus Q1's + feedback loop of highlight-derived `$code-block-bg` into the theme compile. + +This keeps bd-0pic6 from swallowing a full highlighting-theme subsystem while +still making `highlight-style: a11y` work on quarto-web. + +### D7 — brand light/dark seam (bd-v5z8w) + +After the theme seam exists: `extract_brand_ref` carries both halves +(`BrandRef` pair); `ThemeConfig::resolve` resolves two `Brand`s; dark compile +gets a `ThemeContext` with the dark brand (add `with_brand` on a second context +— cheaper than widening `ThemeContext`). Unified `_brand.yml` splitting +(Q1's `splitUnifiedBrand`) ports into `quarto-brand`. Default-dark falls back to +the `brand:` map's key order when `theme:` doesn't decide (Q1 rule). Logo +`LightDarkPair` consumers (favicon/navbar) pick per-variant via the +content-swap classes. + +### D8 — preview / hub-client + +- Second theme slot end-to-end: renderer writes `styles-dark.css` next to + `styles.css` in the VFS; `UPDATE_THEME` message and `applyTheme` grow a + variant field (two ``); fingerprint plumbing + carries a pair. +- The iframe's initial mode follows the same toggle JS if the rendered document + ships it; additionally, hub-client can post its editor `ColorScheme` + (ThemeContext.tsx) into the iframe so preview follows the editor chrome. + Exact policy (document toggle vs editor scheme precedence) is a design point + for that phase, informed by phase-1 lessons. +- `q2 preview` native serves rendered output unchanged — it inherits phase-1 + behavior for free, but the embedded SPA path needs the D8 transport work. +- Grass-vs-dart-sass divergence exposure doubles (bd-izs62xci) — parity test + extends to the dark artifact. + +### D9 — explicitly out of this epic (tracked separately) + +- revealjs light/dark (bd-904h9kmt) — waits for this seam, then Stage-D design. +- mermaid `$mermaid-*`/`--mermaid-*` bridge (bd-sehm2rha/bd-nj25kgbu) — dual + compile makes the vars per-variant automatically once that lands. +- giscus/comments (no comments support in Q2 yet). +- `website.tools:` general support (bd-fod3) — we hardcode only the dark toggle. +- Typst/`brand-mode` for non-HTML formats. +- bd-r1y48cx0 (`css:` files not copied into `_site/`) — independent bug, but + quarto-web verification depends on it; schedule alongside phase A. + +## Proposed epic structure + +**Created in braid 2026-08-14.** bd-0pic6 is the epic parent (retitled). The +A-lane is a `blocks` chain (A1→A2→A3→A4→A5) and is the time-sensitive +`format: html` + website lane; B and C block on A2; D and E block on A5. +Integration branch: `feature/light-dark-theme` (created off `main`). + +- [ ] **A1 — data model** (D2): `bd-ld-a1-data-model-a12bhj1g`. `DarkTheme` in + `ThemeConfig`, both halves parsed, Q-14-3 retired, semantics matrix tests + (incl. `none` per-variant, key-order default-dark, order preservation through + config merge). +- [ ] **A2 — dual compile + artifacts** (D3, D1a): `bd-ld-a2-dual-compile-ds10l5wa`. + Second compile, keys/paths/cache, darkness sentinel, `color-scheme` emission + per variant, update single-artifact consumers, parity tests. (related: + bd-8oqw) +- [ ] **A3 — link emission** (D4, D1a): `bd-ld-a3-link-emission-ruw9kw4v`. + Artifact attribs, template changes, ordering, trailing-copy emission, meta + color-scheme tag. Byte-identical output when no dark variant. +- [ ] **A4 — toggle runtime** (D5): `bd-ld-a4-toggle-runtime-0t9i2rvs`. JS asset + + before-body injection, body classes, localStorage, + `respect-user-color-scheme`, hardcoded navbar toggle + floating fallback, + `_light-dark.scss` dark half (related: bd-l1rx9yzh). +- [ ] **A5 — quarto-web end-to-end**: `bd-ld-a5-quarto-web-e2e-bzg4o5lc`. + Render `external-sources/quarto-web` with the real binary, browser-verify + toggle/persistence/prefers-color-scheme, document gaps found. (related: + bd-r1y48cx0) +- [ ] **B — highlight-style** (D6 stage 1): `bd-ld-b-highlight-style-jnb036fz`. + Reader + a11y light/dark + variant selection; follow-up strand to be filed + for the general `.theme` translator. +- [ ] **C — brand seam** (D7): `bd-ld-c-brand-seam-wef8ww3n`. Absorbs bd-v5z8w; + unified-brand split. +- [ ] **D — preview/hub-client** (D8): `bd-ld-d-preview-hub-t4oxv0hf`. + VFS/iframe dual transport, editor-scheme integration (related: bd-nxe8). + Uses lessons from A. +- [ ] **E — cleanup**: `bd-ld-e-cleanup-qxidnkng`. Delete-or-wire + `SassBundle{,Dark}` scaffolding, docs (`docs/` user-facing dark-mode page, + `light-dark()` idiom, migration notes), audit `bd-36vmz7nk`/`bd-qmpygp02` + fallback posture for the dark compile path. + +Follow-up already filed: `bd-ld-toggle-into-tools-hpae7m9r` — fold the +hardcoded toggle into `tools:` when bd-fod3 lands. + +Each child follows TDD (tests-first, red confirmed) per CLAUDE.md; A2/A3 +particularly need end-to-end CLI tests (the CodeHighlightStage incident pattern: +in-process tests can pass while the real pipeline bypasses a stage). + +## Open questions (for iteration with Carlos) + +1. ~~**Q1-verbatim runtime** (D1) — divergences?~~ **Resolved 2026-08-14**: + Carlos wants the feasibility headroom spent on improvements; `color-scheme` + adopted as D1a. Core rel-swap/triple-link/localStorage mechanics stay + Q1-compatible. (Other candidate improvements can still be raised during + iteration.) +2. ~~**highlight-style scope** (D6)~~ **Resolved 2026-08-14**: curated-set-first + confirmed; `a11y` + default is enough for phase B. The full theme set may be + needed soon but is a follow-up strand, not this epic. +3. ~~**Artifact attribs vs header-includes** (D4)~~ **Resolved 2026-08-14**: + extend `Artifact` + template as proposed. +4. ~~**Toggle placement** (D5)~~ **Resolved 2026-08-14**: hardcode the navbar + toggle emission now; file a follow-up strand (linked to bd-fod3) to fold it + into general `tools:` support. +5. ~~**Single-doc dark artifact naming**~~ **Resolved 2026-08-14**: + `styles-dark.css` alongside `styles.css`. +6. ~~**Epic mechanics**~~ **Resolved 2026-08-14**: retitle bd-0pic6 as the epic + parent, create A1–E children with parent-child deps, integration branch + `feature/light-dark-theme`. + +## References + +- Interim: `claude-notes/plans/2026-08-08-theme-light-dark-interim.md` (PR #475) +- Brand: `claude-notes/plans/2026-05-20-brand-yml-support.md` +- Sass port: `claude-notes/plans/2026-01-13-sass-compilation.md` §6.2/§7.3 +- Custom SCSS: `claude-notes/plans/2026-01-23-phase6b-custom-scss.md` (6b.6) +- Highlighting: `claude-notes/plans/2026-04-19-syntax-highlighting-design.md` +- Strands: bd-0pic6 (umbrella), bd-v5z8w (brand pairs), bd-904h9kmt (reveal), + bd-l1rx9yzh (content-swap CSS), bd-fod3 (`tools:`), bd-nxe8 (hub-client chrome + scheme), bd-r1y48cx0 (css copy bug), bd-8oqw (CompileInputs), bd-mtzry (body + class seam), bd-izs62xci (sass compiler split) +- Q1 key files (external-sources/quarto-cli @1.11.1): + `src/format/html/format-html-scss.ts`, `format-html-info.ts`, + `src/command/render/pandoc-html.ts`, + `src/resources/formats/html/templates/quarto-html-before-body.ejs`, + `src/quarto-core/text-highlighting.ts`, `src/core/brand/brand.ts`, + `src/core/sass/brand.ts` +- quarto-web config: `external-sources/quarto-web/_quarto.yml:682-707`, + `theme.scss`, `theme-dark.scss`, `filters/include-dark.lua` From b3bf221a8195cf46627782daccbc002b5bf4ea2b Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 15:19:34 -0500 Subject: [PATCH 02/10] light-dark A1: ThemeConfig grows a parsed dark variant (bd-ld-a1-data-model-a12bhj1g) The theme: {light, dark} map now parses BOTH halves. DarkThemeConfig {themes, theme_locations, suppress_bootstrap, is_default, key_location} replaces dark_theme_ignored on ThemeConfig; the dark half goes through the same from_theme_value parsing as the light half (none sentinel, unknown-theme errors, PandocInlines frontmatter form). is_default implements Q1's key-order rule (dark written first = dark is the author default); two new quarto-config materialize tests guard the key-order preservation that rule depends on. Brand token auto-injection now applies per variant, and an explicit brand token in the dark list keeps its position; naming brand in either half without brand: errors. ResolvedThemeConfig carries the dark variant through resolve(). Zero behavior change by design: Q-14-3 still fires (now keyed off dark.key_location) and only the light half compiles; both retire in A2 (dual compile). bootstrap_js predicate also updates in A2. TDD: 10 new/extended quarto-sass unit tests confirmed red before the implementation; full workspace suite green (12,125 tests). Part of the light/dark epic (bd-0pic6); plan: claude-notes/plans/2026-08-14-light-dark-theme-epic.md Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 18 +- crates/quarto-config/src/materialize.rs | 54 +++ .../src/stage/stages/compile_theme_css.rs | 22 +- crates/quarto-sass/src/config.rs | 403 ++++++++++++++---- crates/quarto-sass/src/lib.rs | 4 +- 5 files changed, 415 insertions(+), 86 deletions(-) diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index d06f77354..f9a8b9028 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -389,10 +389,20 @@ A-lane is a `blocks` chain (A1→A2→A3→A4→A5) and is the time-sensitive `format: html` + website lane; B and C block on A2; D and E block on A5. Integration branch: `feature/light-dark-theme` (created off `main`). -- [ ] **A1 — data model** (D2): `bd-ld-a1-data-model-a12bhj1g`. `DarkTheme` in - `ThemeConfig`, both halves parsed, Q-14-3 retired, semantics matrix tests - (incl. `none` per-variant, key-order default-dark, order preservation through - config merge). +- [x] **A1 — data model** (D2): `bd-ld-a1-data-model-a12bhj1g`. **Done + 2026-08-14.** `DarkThemeConfig {themes, theme_locations, suppress_bootstrap, + is_default, key_location}` on `ThemeConfig::dark` (replacing + `dark_theme_ignored`); both halves parsed via `from_theme_value`; per-variant + `none` semantics; brand token auto-injected into both halves (explicit token + position honored per-variant; brand-token-without-brand errors for either + half); key-order `is_default` rule + two quarto-config materialize tests + guarding key-order preservation through the merge; `ResolvedThemeConfig` + carries `dark`. TDD: 10 new/extended unit tests confirmed red first. + **Deliberate deferrals to A2** (so A1 has zero behavior change): Q-14-3 + still fires (now keyed off `dark.key_location`; retire when the dark half + actually compiles), `bootstrap_js` predicate unchanged (updates when dark + CSS ships), interim integration tests in `theme_light_dark.rs` unchanged + (they invert in A2). - [ ] **A2 — dual compile + artifacts** (D3, D1a): `bd-ld-a2-dual-compile-ds10l5wa`. Second compile, keys/paths/cache, darkness sentinel, `color-scheme` emission per variant, update single-artifact consumers, parity tests. (related: diff --git a/crates/quarto-config/src/materialize.rs b/crates/quarto-config/src/materialize.rs index f6125860e..da9228258 100644 --- a/crates/quarto-config/src/materialize.rs +++ b/crates/quarto-config/src/materialize.rs @@ -312,6 +312,60 @@ mod tests { assert_eq!(theme.as_yaml().unwrap().as_str(), Some("cosmo")); } + #[test] + fn test_materialize_preserves_single_layer_map_key_order() { + // YAML map key order is *semantic* for `theme: {dark:…, light:…}`: + // quarto-sass's light/dark parsing treats "dark written first" + // as "dark is the author-default variant" (Q1's key-order rule, + // `DarkThemeConfig::is_default`). Materialization must not + // reorder a single layer's entries. + let config = map(vec![( + "theme", + map(vec![("dark", scalar("darkly")), ("light", scalar("cosmo"))]), + )]); + let merged = MergedConfig::new(vec![&config]); + + let result = merged.materialize().unwrap(); + let theme = result.get("theme").unwrap(); + let ConfigValueKind::Map(entries) = &theme.value else { + panic!("theme should materialize as a map"); + }; + let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect(); + assert_eq!(keys, vec!["dark", "light"], "entry order must be preserved"); + } + + #[test] + fn test_materialize_map_key_order_is_first_seen_across_layers() { + // When several layers contribute to the same map, keys appear + // in first-seen order (earlier layer first), with later layers + // only overriding values. Consequence for the light/dark rule: + // a document overriding just the `dark:` half of a project's + // `{light:…, dark:…}` map does NOT flip the author default. + let project = map(vec![( + "theme", + map(vec![("light", scalar("cosmo")), ("dark", scalar("darkly"))]), + )]); + let document = map(vec![("theme", map(vec![("dark", scalar("slate"))]))]); + let merged = MergedConfig::new(vec![&project, &document]); + + let result = merged.materialize().unwrap(); + let theme = result.get("theme").unwrap(); + let ConfigValueKind::Map(entries) = &theme.value else { + panic!("theme should materialize as a map"); + }; + let keys: Vec<&str> = entries.iter().map(|e| e.key.as_str()).collect(); + assert_eq!( + keys, + vec!["light", "dark"], + "first-seen order: light stays first even though the doc layer only wrote dark" + ); + // ... while the value itself is overridden by the later layer. + assert_eq!( + theme.get("dark").unwrap().as_yaml().unwrap().as_str(), + Some("slate") + ); + } + #[test] fn test_materialize_merged_layers() { let layer1 = map(vec![("a", scalar("1")), ("b", scalar("2"))]); diff --git a/crates/quarto-core/src/stage/stages/compile_theme_css.rs b/crates/quarto-core/src/stage/stages/compile_theme_css.rs index 38f48c4ed..092de26be 100644 --- a/crates/quarto-core/src/stage/stages/compile_theme_css.rs +++ b/crates/quarto-core/src/stage/stages/compile_theme_css.rs @@ -378,14 +378,20 @@ impl PipelineStage for CompileThemeCssStage { }; // Interim light/dark degradation (bd-o76p01wb): the parser - // accepted a `theme: {light: …, dark: …}` map but only the - // light half is honored. Make the degradation loud — one - // Q-14-3 *warning* per document, anchored at the ignored - // `dark:` key. Emitted here (not in `BootstrapJsStage`, which - // parses the same config) so each document warns exactly once; - // the CLI's source-location coalescer collapses repeats across - // documents that share the offending config file. - if let Some(dark_loc) = &theme_config.dark_theme_ignored { + // accepts and fully parses the `theme: {light: …, dark: …}` + // map, but this stage compiles only the light half until dual + // compilation lands (bd-ld-a2-dual-compile-ds10l5wa, which + // retires this warning). Make the degradation loud — one + // Q-14-3 *warning* per document, anchored at the `dark:` key. + // Emitted here (not in `BootstrapJsStage`, which parses the + // same config) so each document warns exactly once; the CLI's + // source-location coalescer collapses repeats across documents + // that share the offending config file. + if let Some(dark_loc) = theme_config + .dark + .as_ref() + .and_then(|d| d.key_location.as_ref()) + { ctx.add_diagnostic( quarto_error_reporting::DiagnosticMessageBuilder::warning( "Dark theme variant not yet supported", diff --git a/crates/quarto-sass/src/config.rs b/crates/quarto-sass/src/config.rs index 6ba82eec1..a6237d101 100644 --- a/crates/quarto-sass/src/config.rs +++ b/crates/quarto-sass/src/config.rs @@ -102,19 +102,50 @@ pub struct ThemeConfig { /// [`ThemeConfig::resolve`]. pub brand_ref: Option, - /// Set when the configuration used the `theme: {light: …, dark: …}` - /// map form **and** a `dark:` entry was present: the interim - /// behavior (bd-o76p01wb) honors only the `light:` half, and this - /// field carries the source location of the ignored `dark:` key so - /// the caller can emit a user-visible warning (Q-14-3) pointing at - /// it. `None` when the theme was not a map or the map had no - /// `dark:` entry (a light-only map is fully honored — nothing is - /// ignored, so nothing to warn about). + /// The dark half of a `theme: {light: …, dark: …}` map, parsed and + /// ready for a dark-variant compilation. `None` when the theme was + /// not a map or the map had no `dark:` entry (a light-only map is + /// an honored, if redundant, spelling of the plain form). /// - /// quarto-sass itself stays diagnostics-free: this is data, not a - /// side channel. `CompileThemeCssStage` turns it into the actual - /// diagnostic. - pub dark_theme_ignored: Option, + /// The fields of [`ThemeConfig`] itself always describe the light + /// variant; options that apply to the whole configuration + /// (`minified`, `title_block_layer`, `brand_ref`) are not + /// duplicated here. + pub dark: Option, +} + +/// The parsed `dark:` half of a `theme: {light: …, dark: …}` pair +/// (bd-0pic6 light/dark epic, phase A1). +/// +/// Mirrors the variant-specific subset of [`ThemeConfig`]: its own +/// spec list, per-entry locations, and `none`-sentinel flag. +#[derive(Debug, Clone, Default)] +pub struct DarkThemeConfig { + /// Theme specifications for the dark variant. Empty means default + /// Bootstrap (no Bootswatch customization) — e.g. `{dark: none}` + /// sets `suppress_bootstrap` instead. + pub themes: Vec, + + /// Source location of each entry in `themes`, parallel by index + /// (same contract as [`ThemeConfig::theme_locations`]). + pub theme_locations: Vec>, + + /// The dark half used the `none` sentinel (`{…, dark: none}`): + /// no Bootstrap output for the dark variant. + pub suppress_bootstrap: bool, + + /// Whether dark is the *author-default* variant: true when the + /// `dark:` key is written before `light:` in the map (or is the + /// only key). This is Q1's key-order rule + /// (`format-html-info.ts::darkModeDefaultMetadata`: the first key + /// of the map decides). Drives the emitted stylesheet order and + /// the toggle's initial state. + pub is_default: bool, + + /// Location of the `dark:` key itself, for diagnostics that need + /// to point at the dark half as a whole (e.g. the interim Q-14-3 + /// warning while dual compilation is not yet wired). + pub key_location: Option, } /// Resolved form of [`ThemeConfig`] with the brand file loaded and @@ -132,6 +163,11 @@ pub struct ResolvedThemeConfig { /// Directory the brand file was loaded from (for resolving /// relative `@font-face` URLs). `None` when brand was inline. pub brand_dir: Option, + /// The dark variant, carried through unchanged from + /// [`ThemeConfig::dark`]. (The brand is resolved once and shared + /// by both variants until the brand light/dark seam lands — + /// bd-ld-c-brand-seam-wef8ww3n.) + pub dark: Option, } impl ThemeConfig { @@ -145,7 +181,7 @@ impl ThemeConfig { suppress_bootstrap: false, title_block_layer: true, brand_ref: None, - dark_theme_ignored: None, + dark: None, } } @@ -161,7 +197,7 @@ impl ThemeConfig { suppress_bootstrap: false, title_block_layer: true, brand_ref: None, - dark_theme_ignored: None, + dark: None, } } @@ -172,11 +208,10 @@ impl ThemeConfig { /// - String: single theme name or path (e.g., `"cosmo"`, `"custom.scss"`) /// - Array: multiple themes to layer (e.g., `["cosmo", "custom.scss"]`) /// - Map with only `light:`/`dark:` keys (Q1's dual-theme form): - /// the `light:` half is parsed like a top-level theme value; the - /// `dark:` half is **ignored** for now, its location recorded on - /// [`ThemeConfig::dark_theme_ignored`] so the caller can warn - /// (Q-14-3). Interim behavior per bd-o76p01wb; full dual-theme - /// support is bd-0pic6. + /// each half is parsed like a top-level theme value; the light + /// half fills the top-level fields, the dark half becomes + /// [`ThemeConfig::dark`] (with the key-order rule deciding + /// [`DarkThemeConfig::is_default`]). /// - Null/absent: use default Bootstrap theme /// /// # Arguments @@ -211,18 +246,29 @@ impl ThemeConfig { None => Self::default_bootstrap(), Some(value) => match light_dark_pair(value) { Some(pair) => { - // The pair form is top-level only: the light half - // goes through the same value parsing as a plain + // The pair form is top-level only: each half goes + // through the same value parsing as a plain // `theme:` (string / array / null / `none` // sentinel), where a nested map is invalid. let mut cfg = match pair.light { Some(light_value) => Self::from_theme_value(light_value)?, // Only `dark:` configured → default Bootstrap - // for the rendered (light) page; the warning - // still fires so the degradation is visible. + // for the light variant. None => Self::default_bootstrap(), }; - cfg.dark_theme_ignored = pair.dark_ignored; + cfg.dark = match pair.dark { + Some((dark_value, key_source)) => { + let dark_cfg = Self::from_theme_value(dark_value)?; + Some(DarkThemeConfig { + themes: dark_cfg.themes, + theme_locations: dark_cfg.theme_locations, + suppress_bootstrap: dark_cfg.suppress_bootstrap, + is_default: pair.dark_first, + key_location: Some(key_source), + }) + } + None => None, + }; cfg } None => Self::from_theme_value(value)?, @@ -236,27 +282,42 @@ impl ThemeConfig { // the layer's presence in the bundle. result.title_block_layer = title_block_layer_enabled(config); - // `theme: none` is mutually exclusive with brand — Q1 would - // produce no Bootstrap output anyway, so we mirror that by - // dropping the brand_ref. The user intent ("don't generate - // Bootstrap CSS") wins. - if result.suppress_bootstrap { + // `theme: none` is mutually exclusive with brand, per variant — + // Q1 would produce no Bootstrap output anyway, so we mirror + // that by dropping the brand_ref when *every* configured + // variant suppresses Bootstrap. The user intent ("don't + // generate Bootstrap CSS") wins. + let all_variants_suppressed = + result.suppress_bootstrap && result.dark.as_ref().is_none_or(|d| d.suppress_bootstrap); + if all_variants_suppressed { return Ok(result); } - let has_brand_token = result.themes.iter().any(ThemeSpec::is_brand); - match (brand_ref, has_brand_token) { - (Some(br), false) => { - // Auto-inject brand at the end of the theme list. - result.brand_ref = Some(br); - result.themes.push(ThemeSpec::Brand); - result.theme_locations.push(None); - } - (Some(br), true) => { - // Token already present at user-specified position. + let light_has_token = result.themes.iter().any(ThemeSpec::is_brand); + let dark_has_token = result + .dark + .as_ref() + .is_some_and(|d| d.themes.iter().any(ThemeSpec::is_brand)); + match brand_ref { + Some(br) => { result.brand_ref = Some(br); + // Auto-inject the position marker at the end of each + // variant's list that doesn't already name it (and + // isn't suppressed). Q1 splices brand into light and + // dark independently. + if !result.suppress_bootstrap && !light_has_token { + result.themes.push(ThemeSpec::Brand); + result.theme_locations.push(None); + } + if let Some(dark) = result.dark.as_mut() + && !dark.suppress_bootstrap + && !dark_has_token + { + dark.themes.push(ThemeSpec::Brand); + dark.theme_locations.push(None); + } } - (None, true) => { + None if light_has_token || dark_has_token => { return Err(SassError::InvalidThemeConfig { message: "`theme:` contains `brand` but no `_brand.yml` was configured \ via the `brand:` key" @@ -264,7 +325,7 @@ impl ThemeConfig { location: config.get("theme").map(|v| v.source_info.clone()), }); } - (None, false) => {} + None => {} } Ok(result) @@ -290,7 +351,7 @@ impl ThemeConfig { suppress_bootstrap: true, title_block_layer: true, brand_ref: None, - dark_theme_ignored: None, + dark: None, }); } let located = extract_theme_specs(value)?; @@ -305,7 +366,7 @@ impl ThemeConfig { suppress_bootstrap: false, title_block_layer: true, brand_ref: None, - dark_theme_ignored: None, + dark: None, }) } @@ -365,6 +426,7 @@ impl ThemeConfig { suppress_bootstrap: self.suppress_bootstrap, brand, brand_dir, + dark: self.dark, }) } @@ -413,7 +475,7 @@ pub fn resolve_brand( suppress_bootstrap: false, title_block_layer: true, brand_ref: Some(brand_ref), - dark_theme_ignored: None, + dark: None, } .resolve(runtime, base_dir)?; @@ -622,10 +684,13 @@ fn brand_err(e: quarto_brand::BrandError) -> SassError { struct LightDarkPair<'a> { /// The `light:` entry's value, if present. light: Option<&'a ConfigValue>, - /// Location of the `dark:` key, if present — recorded on - /// [`ThemeConfig::dark_theme_ignored`] so the caller's Q-14-3 - /// warning points at the ignored entry. - dark_ignored: Option, + /// The `dark:` entry's value and its key's location, if present. + dark: Option<(&'a ConfigValue, SourceInfo)>, + /// Whether `dark:` is the map's first key (or its only key) — + /// Q1's key-order rule for the author-default variant + /// (`format-html-info.ts::darkModeDefaultMetadata`). Meaningful + /// only when `dark` is `Some`. + dark_first: bool, } /// Detect Q1's dual-theme pair form: a **non-empty** map whose keys @@ -640,10 +705,11 @@ fn light_dark_pair(value: &ConfigValue) -> Option> { } Some(LightDarkPair { light: entries.iter().find(|e| e.key == "light").map(|e| &e.value), - dark_ignored: entries + dark: entries .iter() .find(|e| e.key == "dark") - .map(|e| e.key_source.clone()), + .map(|e| (&e.value, e.key_source.clone())), + dark_first: entries.first().is_some_and(|e| e.key == "dark"), }) } @@ -1330,14 +1396,14 @@ mod tests { } } - // === Light/dark theme map tests (bd-o76p01wb interim) === + // === Light/dark theme map tests (bd-0pic6 epic, phase A1) === // - // Q1's `theme: {light: […], dark: […]}` map form. Interim - // behavior: the `light:` half is honored (string or array), the - // `dark:` half is ignored with its location recorded on - // `ThemeConfig::dark_theme_ignored` so the pipeline can warn - // (Q-14-3). Maps with keys other than `light`/`dark` stay - // Q-14-1 errors. + // Q1's `theme: {light: […], dark: […]}` map form. Both halves are + // parsed: the light half fills the top-level fields, the dark half + // becomes `ThemeConfig::dark` (specs, per-entry locations, `none` + // sentinel, key-order `is_default`, and the `dark:` key's own + // location for diagnostics). Maps with keys other than + // `light`/`dark` stay Q-14-1 errors. /// Compact scalar ConfigValue builder for map-form tests. fn scalar_value(s: &str) -> ConfigValue { @@ -1381,10 +1447,11 @@ mod tests { } #[test] - fn test_theme_map_light_dark_uses_light_half() { - // The canonical posit-docs shape: both halves are lists. Only - // the light list becomes theme specs; the dark entry's key - // location is recorded for the warning. + fn test_theme_map_light_dark_parses_both_halves() { + // The canonical shape: both halves are lists. The light list + // becomes the top-level specs; the dark list is parsed into + // `ThemeConfig::dark` with per-entry locations and the dark + // key's own location for diagnostics. let dark_key_source = SourceInfo::original(quarto_source_map::FileId(9), 40, 44); let theme_value = map_value(vec![ map_entry("light", array_value(&["custom.scss", "cosmo"])), @@ -1406,11 +1473,22 @@ mod tests { ); assert!(theme_config.themes[1].is_builtin()); assert!(!theme_config.suppress_bootstrap); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); assert_eq!( - theme_config.dark_theme_ignored.as_ref(), + dark.key_location.as_ref(), Some(&dark_key_source), - "dark_theme_ignored should carry the dark entry's key source", + "dark.key_location should carry the dark entry's key source", + ); + assert_eq!(dark.themes.len(), 1, "dark half spec list parsed"); + assert!(dark.themes[0].is_custom()); + assert_eq!( + dark.themes[0].as_custom().and_then(|p| p.to_str()), + Some("dark.scss") ); + assert_eq!(dark.theme_locations.len(), 1); + assert!(dark.theme_locations[0].is_some()); + assert!(!dark.suppress_bootstrap); + assert!(!dark.is_default, "light listed first ⇒ light is default"); } #[test] @@ -1423,7 +1501,7 @@ mod tests { assert_eq!(theme_config.themes.len(), 1); assert!(theme_config.themes[0].is_builtin()); - assert!(theme_config.dark_theme_ignored.is_none()); + assert!(theme_config.dark.is_none()); } #[test] @@ -1439,21 +1517,196 @@ mod tests { assert_eq!(theme_config.themes.len(), 2); assert!(theme_config.themes[0].is_custom()); assert!(theme_config.themes[1].is_builtin()); - assert!(theme_config.dark_theme_ignored.is_none()); + assert!(theme_config.dark.is_none()); } #[test] - fn test_theme_map_dark_only_defaults_with_warning() { - // Only `dark:` configured → default Bootstrap for the (light) - // rendered page, plus the warning. Bootstrap is NOT - // suppressed — the page still gets default styling. + fn test_theme_map_dark_only_defaults_light_and_is_default_dark() { + // Only `dark:` configured → the light variant is default + // Bootstrap (NOT suppressed — the page still gets default + // styling), the dark variant carries the spec, and dark is the + // author default (it is the first — only — key). let theme_value = map_value(vec![map_entry("dark", scalar_value("darkly"))]); let theme_config = ThemeConfig::from_config_value(&config_with_theme_value(theme_value)).unwrap(); assert!(theme_config.themes.is_empty()); assert!(!theme_config.suppress_bootstrap); - assert!(theme_config.dark_theme_ignored.is_some()); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); + assert_eq!(dark.themes.len(), 1); + assert!(dark.themes[0].is_builtin()); + assert!(dark.is_default, "dark-only map ⇒ dark is the default"); + } + + #[test] + fn test_theme_map_dark_first_is_default() { + // Q1's key-order rule: `{dark: …, light: …}` (dark written + // first) makes dark the author-default variant. + let theme_value = map_value(vec![ + map_entry("dark", scalar_value("darkly")), + map_entry("light", scalar_value("cosmo")), + ]); + let theme_config = + ThemeConfig::from_config_value(&config_with_theme_value(theme_value)).unwrap(); + + assert_eq!(theme_config.themes.len(), 1); + assert!(theme_config.themes[0].is_builtin()); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); + assert!(dark.is_default, "dark listed first ⇒ dark is default"); + assert_eq!(dark.themes.len(), 1); + assert!(dark.themes[0].is_builtin()); + } + + #[test] + fn test_theme_map_dark_none_suppresses_dark_bootstrap() { + // The `none` sentinel is honored inside the dark half, per + // variant: the light variant compiles normally, the dark + // variant suppresses Bootstrap. + let theme_value = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("none")), + ]); + let theme_config = + ThemeConfig::from_config_value(&config_with_theme_value(theme_value)).unwrap(); + + assert!(!theme_config.suppress_bootstrap); + assert_eq!(theme_config.themes.len(), 1); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); + assert!(dark.suppress_bootstrap); + assert!(dark.themes.is_empty()); + } + + #[test] + fn test_theme_map_light_none_dark_still_parsed() { + // `{light: none, dark: darkly}`: suppression is per-variant. + // The light half suppresses Bootstrap; the dark half still + // carries its spec for the dark compile. + let theme_value = map_value(vec![ + map_entry("light", scalar_value("none")), + map_entry("dark", scalar_value("darkly")), + ]); + let theme_config = + ThemeConfig::from_config_value(&config_with_theme_value(theme_value)).unwrap(); + + assert!(theme_config.suppress_bootstrap); + assert!(theme_config.themes.is_empty()); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); + assert!(!dark.suppress_bootstrap); + assert_eq!(dark.themes.len(), 1); + assert!(dark.themes[0].is_builtin()); + } + + #[test] + fn test_theme_map_nested_map_in_dark_errors() { + // The pair form is top-level only; a nested map inside `dark:` + // is invalid, same as inside `light:`. + let inner = map_value(vec![map_entry("dark", scalar_value("darkly"))]); + let theme_value = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", inner), + ]); + match ThemeConfig::from_config_value(&config_with_theme_value(theme_value)) { + Err(SassError::InvalidThemeConfig { .. }) => {} + other => panic!("expected InvalidThemeConfig error, got: {:?}", other), + } + } + + #[test] + fn test_theme_map_unknown_dark_theme_errors() { + // The dark half goes through the same spec parsing as the + // light half — unknown names error rather than being ignored. + let theme_value = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("nosuchtheme")), + ]); + match ThemeConfig::from_config_value(&config_with_theme_value(theme_value)) { + Err(SassError::UnknownTheme { name, .. }) => assert_eq!(name, "nosuchtheme"), + other => panic!("expected UnknownTheme error, got: {:?}", other), + } + } + + #[test] + fn test_theme_map_dark_pandoc_inlines_scalar() { + // Frontmatter path: pampa parses `dark: darkly` as + // PandocInlines. The dark half must go through the same text + // extraction as the light half. + use quarto_pandoc_types::inline::{Inline, Str}; + let str_node = Inline::Str(Str { + text: "darkly".to_string(), + source_info: SourceInfo::for_test(), + }); + let dark_value = ConfigValue::new_inlines(vec![str_node], SourceInfo::for_test()); + let theme_value = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", dark_value), + ]); + + let theme_config = + ThemeConfig::from_config_value(&config_with_theme_value(theme_value)).unwrap(); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); + assert_eq!(dark.themes.len(), 1); + assert!(dark.themes[0].is_builtin()); + } + + #[test] + fn test_theme_map_dark_brand_token_without_brand_errors() { + // Naming `brand` in the dark list without a `brand:` key is an + // error, same as in the light list. + let theme_value = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", array_value(&["darkly", "brand"])), + ]); + match ThemeConfig::from_config_value(&config_with_theme_value(theme_value)) { + Err(SassError::InvalidThemeConfig { message, .. }) => { + assert!(message.contains("brand"), "message: {message}"); + } + other => panic!("expected InvalidThemeConfig error, got: {:?}", other), + } + } + + #[test] + fn test_theme_map_explicit_brand_token_position_in_dark() { + // An explicit `brand` token in the dark list controls the + // brand layers' position in the dark variant; the light list + // (without a token) still gets the auto-injected marker at the + // end. + let theme_value = map_value(vec![ + map_entry("light", array_value(&["cosmo"])), + map_entry("dark", array_value(&["brand", "darkly"])), + ]); + let config = map_value(vec![ + map_entry("theme", theme_value), + map_entry("brand", scalar_value("_brand.yml")), + ]); + + let theme_config = ThemeConfig::from_config_value(&config).unwrap(); + assert!(theme_config.brand_ref.is_some()); + assert_eq!(theme_config.themes.len(), 2); + assert!(theme_config.themes[0].is_builtin()); + assert!(theme_config.themes[1].is_brand(), "auto-injected in light"); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); + assert_eq!(dark.themes.len(), 2); + assert!(dark.themes[0].is_brand(), "explicit token keeps position"); + assert!(dark.themes[1].is_builtin()); + } + + #[test] + fn test_resolve_carries_dark_through() { + // ThemeConfig::resolve must not drop the dark half — the + // compile stage consumes the resolved form. + let theme_value = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let theme_config = + ThemeConfig::from_config_value(&config_with_theme_value(theme_value)).unwrap(); + let runtime = quarto_system_runtime::NativeRuntime::new(); + let resolved = theme_config + .resolve(&runtime, Path::new(".")) + .expect("resolve without brand does no I/O"); + let dark = resolved.dark.as_ref().expect("dark half carried through"); + assert_eq!(dark.themes.len(), 1); + assert!(dark.themes[0].is_builtin()); } #[test] @@ -1509,9 +1762,10 @@ mod tests { } #[test] - fn test_theme_map_with_brand_auto_injects_into_light() { + fn test_theme_map_with_brand_auto_injects_into_both_halves() { // brand auto-inject composes with the map form: the Brand - // token is appended to the honored (light) spec list. + // token is appended to each variant's spec list (Q1 splices + // brand into light and dark independently). let theme_value = map_value(vec![ map_entry("light", array_value(&["cosmo"])), map_entry("dark", array_value(&["darkly"])), @@ -1526,7 +1780,10 @@ mod tests { assert!(theme_config.themes[0].is_builtin()); assert!(theme_config.themes[1].is_brand()); assert!(theme_config.brand_ref.is_some()); - assert!(theme_config.dark_theme_ignored.is_some()); + let dark = theme_config.dark.as_ref().expect("dark half parsed"); + assert_eq!(dark.themes.len(), 2); + assert!(dark.themes[0].is_builtin()); + assert!(dark.themes[1].is_brand(), "auto-injected in dark too"); } #[test] diff --git a/crates/quarto-sass/src/lib.rs b/crates/quarto-sass/src/lib.rs index 6d9ed00b7..89e187f33 100644 --- a/crates/quarto-sass/src/lib.rs +++ b/crates/quarto-sass/src/lib.rs @@ -64,7 +64,9 @@ pub use compile::{ assemble_theme_scss, compile_css_from_config, compile_default_css, compile_reveal_theme_css, compile_theme_css, compile_with_doc_vars, }; -pub use config::{ResolvedThemeConfig, ThemeConfig, resolve_brand, resolve_brand_layers}; +pub use config::{ + DarkThemeConfig, ResolvedThemeConfig, ThemeConfig, resolve_brand, resolve_brand_layers, +}; pub use error::SassError; pub use layer::{merge_layers, parse_layer, parse_layer_from_parts}; pub use resources::{ From 85bc5703e00836dc7a6ff1356eafb84051a48335 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 15:38:09 -0500 Subject: [PATCH 03/10] light-dark A2: compile both theme variants + color-scheme emission (bd-ld-a2-dual-compile-ds10l5wa) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompileThemeCssStage now compiles the dark half of a theme: {light, dark} map through the same pure pipeline, via the ThemeConfig::dark_variant() projection, into a css:theme-dark: artifact (quarto/quarto-theme-dark-.css; styles-dark.css for single-doc renders). The stage body is refactored into a per-variant variant_css() helper preserving the exact suppress/fast-path/themed behavior per variant. The css:theme-dark: key prefix deliberately does not extend css:theme:, so every existing light-only prefix consumer keeps working unchanged. D1a: the darkness-sentinel block in _bootstrap-rules.scss now also declares :root{color-scheme:light|dark}, so UA-drawn chrome follows theme darkness — including for single dark themes (theme: darkly) and for custom-SCSS dark halves detected via $body-bg blackness (the quarto-web shape). Verified to survive grass minification. Q-14-3 (interim dark-half-ignored warning) is fully retired: emission, catalog entry, docs page, and test references removed. BootstrapJsStage now ships JS iff any variant ships Bootstrap (ThemeConfig::ships_bootstrap), so {light: none, dark: darkly} keeps its JS. Interim state note: both variants are linked as plain stylesheets with the dark link sorting first, so the light variant wins the cascade and page appearance is unchanged until A3 (link attributes) and A4 (toggle) land. Golden-hash baseline re-captured for the one-rule color-scheme delta (entry documents the verification). TDD: 5 stage/integration tests confirmed red first; 12,135 workspace tests green. Part of the light/dark epic (bd-0pic6); plan: claude-notes/plans/2026-08-14-light-dark-theme-epic.md Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 27 +- .../src/stage/stages/bootstrap_js.rs | 47 +- .../src/stage/stages/compile_theme_css.rs | 496 ++++++++++-------- crates/quarto-core/src/theme_diagnostic.rs | 8 +- .../expected_hashes.txt | 11 +- .../tests/integration/theme_light_dark.rs | 239 ++++++--- .../quarto-error-catalog/error_catalog.json | 7 - crates/quarto-sass/src/config.rs | 115 +++- docs/errors/theme/Q-14-3.qmd | 67 --- docs/errors/theme/Q-14-4.qmd | 2 - .../scss/bootstrap/_bootstrap-rules.scss | 13 +- 11 files changed, 666 insertions(+), 366 deletions(-) delete mode 100644 docs/errors/theme/Q-14-3.qmd diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index f9a8b9028..7971faf48 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -403,10 +403,29 @@ Integration branch: `feature/light-dark-theme` (created off `main`). actually compiles), `bootstrap_js` predicate unchanged (updates when dark CSS ships), interim integration tests in `theme_light_dark.rs` unchanged (they invert in A2). -- [ ] **A2 — dual compile + artifacts** (D3, D1a): `bd-ld-a2-dual-compile-ds10l5wa`. - Second compile, keys/paths/cache, darkness sentinel, `color-scheme` emission - per variant, update single-artifact consumers, parity tests. (related: - bd-8oqw) +- [x] **A2 — dual compile + artifacts** (D3, D1a): `bd-ld-a2-dual-compile-ds10l5wa`. + **Done 2026-08-14.** `CompileThemeCssStage` refactored to a per-variant + `variant_css()` helper (suppress → fast path → themed path, identical + behavior per variant); dark half compiles via `ThemeConfig::dark_variant()` + projection into `css:theme-dark:` / `quarto/quarto-theme-dark-.css` + (single-doc: `styles-dark.css`). Key prefix `css:theme-dark:` deliberately + does NOT match the `css:theme:` prefix, so every existing light-only + consumer (preview transport, wasm `extract_theme_fingerprint`, tests) + needed **zero changes**. D1a landed as one SCSS change: the existing + darkness-sentinel block in `_bootstrap-rules.scss` now also emits + `:root{color-scheme:light|dark}` — verified surviving grass minification; + single dark themes (darkly) get it for free; quarto-web's cosmo-based + dark half gets `dark` via its `$body-bg`. Q-14-3 fully retired (emission, + catalog entry, docs page, tests). `bootstrap_js` predicate now + `!ships_bootstrap()` (per-variant). **Discoveries vs the original plan**: + no cache-key variant discriminator needed (the key hashes spec identities; + identical inputs correctly share output); no `DEFAULT_CSS_CACHE` two-slot + needed yet (default compile is variant-independent until highlight-style + doc-vars differ per variant — phase B may revisit); interim link order + (dark sorts before light → light wins cascade) keeps pages visually + unchanged until A3. Golden-hash baseline re-captured (documented delta: + the one color-scheme rule). E2E: real `q2 render` of a quarto-web-shaped + project inspected. 12,135 workspace tests green. - [ ] **A3 — link emission** (D4, D1a): `bd-ld-a3-link-emission-ruw9kw4v`. Artifact attribs, template changes, ordering, trailing-copy emission, meta color-scheme tag. Byte-identical output when no dark variant. diff --git a/crates/quarto-core/src/stage/stages/bootstrap_js.rs b/crates/quarto-core/src/stage/stages/bootstrap_js.rs index 120fc9bd3..17fb95604 100644 --- a/crates/quarto-core/src/stage/stages/bootstrap_js.rs +++ b/crates/quarto-core/src/stage/stages/bootstrap_js.rs @@ -150,7 +150,10 @@ impl PipelineStage for BootstrapJsStage { // `ApplyTemplateStage` uses to pick the minimal template // that has no Bootstrap-aware `` to inject into. let suppress_by_theme = match ThemeConfig::from_config_value(&doc.ast.meta) { - Ok(c) => c.suppress_bootstrap, + // Per-variant since bd-0pic6 A2: `{light: none, dark: + // darkly}` still compiles a Bootstrap-based dark variant, + // so JS ships iff ANY variant ships Bootstrap. + Ok(c) => !c.ships_bootstrap(), Err(e) => { trace_event!( ctx, @@ -538,6 +541,48 @@ mod tests { ); } + /// `theme: {light: none, dark: darkly}` → the dark variant uses + /// Bootstrap even though the light variant opted out, so JS must + /// ship (bd-0pic6 A2: suppression is per-variant; JS ships iff + /// any variant ships Bootstrap). + #[tokio::test] + async fn light_none_dark_theme_still_registers_bootstrap_js() { + let runtime = Arc::new(MockRuntime); + let mut ctx = make_stage_context(runtime, true); + + let stage = BootstrapJsStage::new(); + stage + .run( + make_doc_ast(meta_with_light_dark_theme("none", "darkly")), + &mut ctx, + ) + .await + .unwrap(); + + assert!( + ctx.artifacts.contains("js:bootstrap"), + "a Bootstrap-using dark variant must ship Bootstrap JS even when light: none" + ); + } + + /// `theme: {light: none, dark: none}` → no variant uses Bootstrap. + #[tokio::test] + async fn both_variants_none_skip_bootstrap_js() { + let runtime = Arc::new(MockRuntime); + let mut ctx = make_stage_context(runtime, true); + + let stage = BootstrapJsStage::new(); + stage + .run( + make_doc_ast(meta_with_light_dark_theme("none", "none")), + &mut ctx, + ) + .await + .unwrap(); + + assert!(!ctx.artifacts.contains("js:bootstrap")); + } + /// `theme: pandoc` → user wants raw Pandoc HTML; no Bootstrap JS. #[tokio::test] async fn theme_pandoc_skips_bootstrap_js() { diff --git a/crates/quarto-core/src/stage/stages/compile_theme_css.rs b/crates/quarto-core/src/stage/stages/compile_theme_css.rs index 092de26be..cc07b2e74 100644 --- a/crates/quarto-core/src/stage/stages/compile_theme_css.rs +++ b/crates/quarto-core/src/stage/stages/compile_theme_css.rs @@ -377,131 +377,43 @@ impl PipelineStage for CompileThemeCssStage { } }; - // Interim light/dark degradation (bd-o76p01wb): the parser - // accepts and fully parses the `theme: {light: …, dark: …}` - // map, but this stage compiles only the light half until dual - // compilation lands (bd-ld-a2-dual-compile-ds10l5wa, which - // retires this warning). Make the degradation loud — one - // Q-14-3 *warning* per document, anchored at the `dark:` key. - // Emitted here (not in `BootstrapJsStage`, which parses the - // same config) so each document warns exactly once; the CLI's - // source-location coalescer collapses repeats across documents - // that share the offending config file. - if let Some(dark_loc) = theme_config - .dark - .as_ref() - .and_then(|d| d.key_location.as_ref()) - { - ctx.add_diagnostic( - quarto_error_reporting::DiagnosticMessageBuilder::warning( - "Dark theme variant not yet supported", - ) - .with_code("Q-14-3") - .problem( - "`theme:` uses the `light:`/`dark:` map form; only the `light:` \ - themes are applied. The `dark:` entry is ignored and no \ - dark-mode toggle is emitted.", - ) - .add_hint( - "Remove the `dark:` entry to silence this warning, or keep it — \ - it will take effect when dual light/dark theme support lands.", - ) - .with_location(dark_loc.clone()) - .build(), - ); - } - - // `theme: none` → ship the static lightweight DEFAULT_CSS without - // compiling Bootstrap. This is the explicit opt-out path. - if theme_config.suppress_bootstrap { - trace_event!( - ctx, - EventLevel::Debug, - "theme: none set, using static DEFAULT_CSS" - ); - store_default_css(ctx); - return Ok(PipelineData::DocumentAst(doc)); - } - - // Ensure the sass namespace matches the current SCSS resources - // generation. When this returns `false` the cache layer is - // unavailable and we compile without caching. - let cache_ok = ensure_sass_cache_ready(ctx.runtime.as_ref()).await; - - // Build the per-document SCSS variables layer (Phase 2 of bd-k8y0). - // Today this is just `$sidebar-border` from `website.sidebar.style`, - // but the same hook is the home for future `$sidebar-bg`, - // `$navbar-bg`, etc. injections — see the plan and `derive_doc_scss_layer`. + // ── Variant-aware compilation (bd-0pic6, phase A2) ───────── + // The light variant always compiles. A `theme: {light: …, + // dark: …}` map additionally compiles the dark half — through + // the exact same pure pipeline, via the projected + // `dark_variant()` config — into its own `css:theme-dark:*` + // artifact. The interim Q-14-3 warning (bd-o76p01wb) is + // retired: nothing is ignored anymore. + + // Per-document SCSS variables layer (Phase 2 of bd-k8y0), + // shared by both variants. Today this is just + // `$sidebar-border` from `website.sidebar.style`; the same + // hook is the home for future `$sidebar-bg`, `$navbar-bg`, + // etc. injections — see `derive_doc_scss_layer`. let doc_vars = derive_doc_scss_layer(&doc.ast.meta); - // Fast path: no themes, no doc-derived variables, and the - // default layer set (title-block layer included). Use the - // shared, cached default-CSS bundle. This preserves byte-identity - // with prior behavior for plain documents (no website / no sidebar). - // `title-block-style: plain|none` docs take the fingerprinted - // path below so their layer-less bundle gets its own cache key. - if !theme_config.has_themes() && doc_vars.is_empty() && theme_config.title_block_layer { - // Try the runtime cache first (cross-session persistence). - if cache_ok - && let Ok(Some(cached)) = cache_get_lru( - ctx.runtime.as_ref(), - SASS_CACHE_NAMESPACE, - default_cache_key(theme_config.minified), - ) - .await - && let Ok(css) = String::from_utf8(cached) - { - trace_event!(ctx, EventLevel::Debug, "cache hit for default CSS"); - store_css(ctx, css); - return Ok(PipelineData::DocumentAst(doc)); - } - - trace_event!( - ctx, - EventLevel::Debug, - "no theme / no doc-vars, compiling default Bootstrap + Quarto layer" - ); - match compile_default(ctx, theme_config.minified).await { - Ok(css) => { - if cache_ok { - let _ = cache_set_lru( - ctx.runtime.as_ref(), - SASS_CACHE_NAMESPACE, - default_cache_key(theme_config.minified), - css.as_bytes(), - SASS_CACHE_BUDGET_BYTES, - ) - .await; - } - store_css(ctx, css); - } - Err(e) => { - trace_event!( - ctx, - EventLevel::Warn, - "default Bootstrap compilation failed: {}, using static DEFAULT_CSS", - e - ); - store_default_css(ctx); - } - } - return Ok(PipelineData::DocumentAst(doc)); - } + // Ensure the sass namespace matches the current SCSS + // resources generation — only worth the roundtrip when some + // variant actually compiles. `false` → compile without + // caching. + let cache_ok = if theme_config.ships_bootstrap() { + ensure_sass_cache_ready(ctx.runtime.as_ref()).await + } else { + false + }; - // Themed and/or doc-vars-bearing path: compute fingerprinted cache - // key (factors in theme identities + doc-vars), check the runtime - // cache, then compile via `compile_with_doc_vars` on miss. let document_dir = doc .path .parent() .map_or_else(|| PathBuf::from("."), |p| p.to_path_buf()); - // Resolve the brand (if any) before building the theme context. - // I/O happens here. Failures are user-facing configuration - // errors (missing `_brand.yml`, invalid YAML, unknown brand - // shape) — propagate them rather than silently shipping - // DEFAULT_CSS, same reasoning as the `from_config_value` - // error path above. + // Resolve the brand (if any) once — it is shared by both + // variants until the brand light/dark seam lands + // (bd-ld-c-brand-seam-wef8ww3n). I/O happens here. Failures + // are user-facing configuration errors (missing `_brand.yml`, + // invalid YAML, unknown brand shape) — propagate them rather + // than silently shipping DEFAULT_CSS, same reasoning as the + // `from_config_value` error path above. let resolved = theme_config .clone() .resolve(ctx.runtime.as_ref(), &ctx.project.dir) @@ -509,7 +421,11 @@ impl PipelineStage for CompileThemeCssStage { PipelineError::stage_error(self.name(), format!("brand resolution: {e}")) })?; - let mut theme_context = ThemeContext::new(document_dir, ctx.runtime.as_ref()); + // The ThemeContext borrows a local Arc clone of the runtime + // (not `ctx`) so `ctx` stays mutably borrowable inside + // `variant_css`. + let runtime = ctx.runtime.clone(); + let mut theme_context = ThemeContext::new(document_dir, runtime.as_ref()); if let Some(brand) = resolved.brand.as_ref() { let brand_dir = resolved .brand_dir @@ -518,112 +434,201 @@ impl PipelineStage for CompileThemeCssStage { theme_context = theme_context.with_brand(brand, brand_dir); } - // Up-front validation: every custom theme entry must resolve - // to a real file (bd-of20unsb). Before this check, a dangling - // entry surfaced only as a compile failure swallowed by the - // DEFAULT_CSS fallback below — silently dropping the *entire* - // theme list, valid entries included. A dangling entry is a - // user-facing configuration error, so it gets the same - // structured hard-error treatment as the `from_config_value` - // path above: Q-14-4, with a span at the offending `theme:` - // entry. - for (i, spec) in theme_config.themes.iter().enumerate() { - let Some(path) = spec.as_custom() else { - continue; - }; - let resolved_path = theme_context.resolve_path(path); - let exists = ctx - .runtime - .path_exists(&resolved_path, Some(PathKind::File)) - .unwrap_or(false); - if !exists { - let err = quarto_sass::SassError::CustomThemeNotFound { - path: resolved_path, - location: theme_config.theme_locations.get(i).cloned().flatten(), - }; - let pe = crate::theme_diagnostic::sass_error_to_parse_error( - &err, - &theme_error_candidates(ctx), - ); - return Err(PipelineError::Structured(pe)); - } + let light_css = + variant_css(ctx, &theme_config, &theme_context, &doc_vars, cache_ok).await?; + store_css(ctx, light_css); + + if let Some(dark_cfg) = theme_config.dark_variant() { + let dark_css = variant_css(ctx, &dark_cfg, &theme_context, &doc_vars, cache_ok).await?; + store_dark_css(ctx, dark_css); } - let key = match cache_key( - &theme_config, - &theme_context, - ctx.runtime.as_ref(), - &doc_vars, - ) { - Ok(k) => k, - Err(e) => { - trace_event!( - ctx, - EventLevel::Warn, - "failed to compute cache key: {}, compiling without cache", - e - ); - // Fall through with no cache key — will compile without caching - String::new() - } - }; + Ok(PipelineData::DocumentAst(doc)) + } +} - // Check cache (best-effort — errors are non-fatal). +/// Produce the compiled CSS for ONE variant (the top-level light +/// config, or the projection `ThemeConfig::dark_variant()` of the +/// dark half). Pure with respect to artifacts — the caller stores the +/// result under the variant's key — but reads/writes the runtime CSS +/// cache and emits trace events. +/// +/// Error contract mirrors the pre-A2 single-variant behavior: +/// dangling custom themes are structured Q-14-4 errors; compile +/// failures degrade to `DEFAULT_CSS` with a warning trace. +async fn variant_css( + ctx: &mut StageContext, + variant_config: &ThemeConfig, + theme_context: &ThemeContext<'_>, + doc_vars: &SassLayer, + cache_ok: bool, +) -> Result { + // `theme: none` (for this variant) → the static lightweight + // DEFAULT_CSS without compiling Bootstrap. Explicit opt-out. + if variant_config.suppress_bootstrap { + trace_event!( + ctx, + EventLevel::Debug, + "theme: none set, using static DEFAULT_CSS" + ); + return Ok(DEFAULT_CSS.to_string()); + } + + // Fast path: no themes, no doc-derived variables, and the + // default layer set (title-block layer included). Use the + // shared, cached default-CSS bundle. This preserves byte-identity + // with prior behavior for plain documents (no website / no sidebar). + // `title-block-style: plain|none` docs take the fingerprinted + // path below so their layer-less bundle gets its own cache key. + if !variant_config.has_themes() && doc_vars.is_empty() && variant_config.title_block_layer { + // Try the runtime cache first (cross-session persistence). if cache_ok - && !key.is_empty() - && let Ok(Some(cached)) = - cache_get_lru(ctx.runtime.as_ref(), SASS_CACHE_NAMESPACE, &key).await + && let Ok(Some(cached)) = cache_get_lru( + ctx.runtime.as_ref(), + SASS_CACHE_NAMESPACE, + default_cache_key(variant_config.minified), + ) + .await && let Ok(css) = String::from_utf8(cached) { - trace_event!( - ctx, - EventLevel::Debug, - "cache hit for theme CSS (key={})", - key - ); - store_css(ctx, css); - return Ok(PipelineData::DocumentAst(doc)); + trace_event!(ctx, EventLevel::Debug, "cache hit for default CSS"); + return Ok(css); } trace_event!( ctx, EventLevel::Debug, - "compiling theme CSS ({} themes, doc_vars={} bytes, key={})", - theme_config.themes.len(), - doc_vars.defaults.len(), - key + "no theme / no doc-vars, compiling default Bootstrap + Quarto layer" ); - - let css = - compile_with_doc_vars_via_runtime(ctx, &theme_config, &theme_context, &doc_vars).await; - - match css { + return match compile_default(ctx, variant_config.minified).await { Ok(css) => { - // Store in cache (best-effort, skip if no key or cache unavailable). - if cache_ok && !key.is_empty() { + if cache_ok { let _ = cache_set_lru( ctx.runtime.as_ref(), SASS_CACHE_NAMESPACE, - &key, + default_cache_key(variant_config.minified), css.as_bytes(), SASS_CACHE_BUDGET_BYTES, ) .await; } - store_css(ctx, css); + Ok(css) } Err(e) => { trace_event!( ctx, EventLevel::Warn, - "theme CSS compilation failed: {}, using default CSS", + "default Bootstrap compilation failed: {}, using static DEFAULT_CSS", e ); - store_default_css(ctx); + Ok(DEFAULT_CSS.to_string()) } + }; + } + + // Themed and/or doc-vars-bearing path: compute fingerprinted cache + // key (factors in theme identities + doc-vars), check the runtime + // cache, then compile via `compile_with_doc_vars` on miss. + // + // Up-front validation: every custom theme entry must resolve + // to a real file (bd-of20unsb). Before this check, a dangling + // entry surfaced only as a compile failure swallowed by the + // DEFAULT_CSS fallback below — silently dropping the *entire* + // theme list, valid entries included. A dangling entry is a + // user-facing configuration error, so it gets the same + // structured hard-error treatment as the `from_config_value` + // path in `run`: Q-14-4, with a span at the offending `theme:` + // entry (the dark half's entries carry their own locations). + for (i, spec) in variant_config.themes.iter().enumerate() { + let Some(path) = spec.as_custom() else { + continue; + }; + let resolved_path = theme_context.resolve_path(path); + let exists = ctx + .runtime + .path_exists(&resolved_path, Some(PathKind::File)) + .unwrap_or(false); + if !exists { + let err = quarto_sass::SassError::CustomThemeNotFound { + path: resolved_path, + location: variant_config.theme_locations.get(i).cloned().flatten(), + }; + let pe = crate::theme_diagnostic::sass_error_to_parse_error( + &err, + &theme_error_candidates(ctx), + ); + return Err(PipelineError::Structured(pe)); + } + } + + let key = match cache_key( + variant_config, + theme_context, + ctx.runtime.as_ref(), + doc_vars, + ) { + Ok(k) => k, + Err(e) => { + trace_event!( + ctx, + EventLevel::Warn, + "failed to compute cache key: {}, compiling without cache", + e + ); + // Fall through with no cache key — will compile without caching + String::new() } + }; - Ok(PipelineData::DocumentAst(doc)) + // Check cache (best-effort — errors are non-fatal). + if cache_ok + && !key.is_empty() + && let Ok(Some(cached)) = + cache_get_lru(ctx.runtime.as_ref(), SASS_CACHE_NAMESPACE, &key).await + && let Ok(css) = String::from_utf8(cached) + { + trace_event!( + ctx, + EventLevel::Debug, + "cache hit for theme CSS (key={})", + key + ); + return Ok(css); + } + + trace_event!( + ctx, + EventLevel::Debug, + "compiling theme CSS ({} themes, doc_vars={} bytes, key={})", + variant_config.themes.len(), + doc_vars.defaults.len(), + key + ); + + match compile_with_doc_vars_via_runtime(ctx, variant_config, theme_context, doc_vars).await { + Ok(css) => { + // Store in cache (best-effort, skip if no key or cache unavailable). + if cache_ok && !key.is_empty() { + let _ = cache_set_lru( + ctx.runtime.as_ref(), + SASS_CACHE_NAMESPACE, + &key, + css.as_bytes(), + SASS_CACHE_BUDGET_BYTES, + ) + .await; + } + Ok(css) + } + Err(e) => { + trace_event!( + ctx, + EventLevel::Warn, + "theme CSS compilation failed: {}, using default CSS", + e + ); + Ok(DEFAULT_CSS.to_string()) + } } } @@ -720,8 +725,20 @@ fn theme_artifact_key_and_path(fingerprint: &str, single_doc: bool) -> (String, (key, path) } -fn store_default_css(ctx: &mut StageContext) { - store_css(ctx, DEFAULT_CSS.to_string()); +/// Dark-variant analog of [`theme_artifact_key_and_path`]. The key +/// prefix is `css:theme-dark:` — deliberately NOT an extension of +/// `css:theme:` (`'-' != ':'`), so every existing +/// `get_by_prefix("css:theme:")` consumer (preview transport, wasm +/// `extract_theme_fingerprint`, test assertions) keeps selecting the +/// light variant untouched. +fn dark_theme_artifact_key_and_path(fingerprint: &str, single_doc: bool) -> (String, PathBuf) { + let key = format!("css:theme-dark:{}", fingerprint); + let path = if single_doc { + PathBuf::from("styles-dark.css") + } else { + PathBuf::from(format!("quarto/quarto-theme-dark-{}.css", fingerprint)) + }; + (key, path) } fn store_css(ctx: &mut StageContext, css: String) { @@ -735,6 +752,17 @@ fn store_css(ctx: &mut StageContext, css: String) { ); } +fn store_dark_css(ctx: &mut StageContext, css: String) { + let fingerprint = theme_fingerprint(&css); + let (key, path) = dark_theme_artifact_key_and_path(&fingerprint, ctx.project.is_single_file); + ctx.artifacts.store( + key, + Artifact::from_string(css, "text/css") + .with_path(path) + .with_scope(ArtifactScope::Project), + ); +} + /// Compile assembled SCSS to CSS. /// /// Uses `compile_scss_with_embedded` on native (sync, via grass) and @@ -948,6 +976,23 @@ mod tests { String::from_utf8(artifact.content.clone()).expect("CSS should be valid UTF-8") } + /// The dark-variant artifact (`css:theme-dark:*`). Note the key + /// prefix deliberately does NOT match `css:theme:` — every + /// existing light-only consumer keeps selecting the light variant + /// untouched. + fn get_dark_css_artifact(ctx: &StageContext) -> String { + let entries: Vec<_> = ctx.artifacts.get_by_prefix("css:theme-dark:"); + assert_eq!( + entries.len(), + 1, + "expected exactly one css:theme-dark:* artifact, found {}", + entries.len() + ); + let artifact = entries[0].1; + assert_eq!(artifact.scope, ArtifactScope::Project); + String::from_utf8(artifact.content.clone()).expect("CSS should be valid UTF-8") + } + // ── Mock runtime ───────────────────────────────────────────────── struct MockRuntime; @@ -1163,11 +1208,11 @@ mod tests { } #[tokio::test] - async fn light_dark_theme_map_compiles_light_and_warns_q_14_3() { - // bd-o76p01wb interim: the Q1 `theme: {light: […], dark: […]}` - // map form must not fail the render. The light half compiles; - // the ignored dark half surfaces as exactly one Q-14-3 - // *warning* on the stage context. + async fn light_dark_theme_map_compiles_both_variants() { + // bd-0pic6 phase A2: the Q1 `theme: {light: […], dark: […]}` + // map form compiles BOTH variants — the light half into the + // `css:theme:*` artifact, the dark half into `css:theme-dark:*`. + // The interim Q-14-3 warning is retired: nothing is ignored. let runtime: Arc = Arc::new(quarto_system_runtime::NativeRuntime::new()); let mut ctx = make_stage_context(runtime); @@ -1189,22 +1234,47 @@ mod tests { css.contains(".btn"), "light half must produce real Bootstrap CSS" ); + assert!( + css.contains("color-scheme:light") || css.contains("color-scheme: light"), + "cosmo-based light variant must declare color-scheme light (D1a)" + ); - let q14_3: Vec<_> = ctx - .diagnostics - .iter() - .filter(|d| d.code.as_deref() == Some("Q-14-3")) - .collect(); - assert_eq!( - q14_3.len(), - 1, - "expected exactly one Q-14-3 warning, diagnostics: {:?}", + let dark_css = get_dark_css_artifact(&ctx); + assert!( + dark_css.contains(".btn"), + "dark half must produce real Bootstrap CSS" + ); + assert!( + dark_css.contains("color-scheme:dark") || dark_css.contains("color-scheme: dark"), + "darkly-based dark variant must declare color-scheme dark (D1a)" + ); + assert_ne!(css, dark_css, "the two variants must differ"); + + assert!( + !ctx.diagnostics + .iter() + .any(|d| d.code.as_deref() == Some("Q-14-3")), + "Q-14-3 is retired: the dark half compiles, nothing is ignored; \ + diagnostics: {:?}", ctx.diagnostics ); - assert_eq!( - q14_3[0].kind, - quarto_error_reporting::DiagnosticKind::Warning, - "Q-14-3 must be warning severity, not error" + } + + #[tokio::test] + async fn plain_theme_produces_no_dark_artifact() { + // A non-map theme must not grow a dark artifact. + let runtime: Arc = + Arc::new(quarto_system_runtime::NativeRuntime::new()); + let mut ctx = make_stage_context(runtime); + let stage = CompileThemeCssStage::new(); + + let input = make_doc_ast(meta_with_theme("cosmo")); + stage.run(input, &mut ctx).await.unwrap(); + + let _ = get_css_artifact(&ctx); // exactly one light artifact + assert!( + ctx.artifacts.get_by_prefix("css:theme-dark:").is_empty(), + "plain theme must not produce a css:theme-dark:* artifact" ); } diff --git a/crates/quarto-core/src/theme_diagnostic.rs b/crates/quarto-core/src/theme_diagnostic.rs index 5e34e6d2f..f1eb33c58 100644 --- a/crates/quarto-core/src/theme_diagnostic.rs +++ b/crates/quarto-core/src/theme_diagnostic.rs @@ -291,10 +291,10 @@ mod tests { // catalog, under the 'theme' subsystem. // Query the catalog data directly (the codes live in // `quarto-error-catalog` now, not in `quarto-error-reporting`). - // Q-14-3 (dark-theme-variant-ignored warning, bd-o76p01wb) is - // emitted by CompileThemeCssStage rather than this converter, - // but it lives in the same subsystem and must be registered. - for code in ["Q-14-1", "Q-14-2", "Q-14-3", "Q-14-4"] { + // (Q-14-3, the interim dark-theme-ignored warning from + // bd-o76p01wb, was retired when dual light/dark compilation + // landed — bd-0pic6 phase A2.) + for code in ["Q-14-1", "Q-14-2", "Q-14-4"] { let info = quarto_error_catalog::ERROR_CATALOG.get(code); assert!( info.is_some(), diff --git a/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt b/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt index 36a1b8c27..c368de3fd 100644 --- a/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt +++ b/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt @@ -275,5 +275,14 @@ # doc.html hash unchanged: the title-block partial's breadcrumb slot is # an $if$ that emits nothing when `rendered.navigation.breadcrumbs` is # absent, which it always is in a single-doc render (no sidebar). +# Re-captured 2026-08-14 (bd-ld-a2-dual-compile-ds10l5wa, light/dark +# epic D1a): styles.css hash updated because the theme-darkness +# sentinel block in `_bootstrap-rules.scss` now also declares the +# matching `color-scheme` (`/*! light */` gains +# `:root{color-scheme:light}`, the dark branch symmetrically), so +# UA-drawn chrome (scrollbars, form-control internals) follows the +# theme's darkness. Verified the only delta is that one rule: the +# pre-existing `[data-bs-theme=dark]{color-scheme:dark;…}` block is +# Bootstrap 5.3 core, unchanged. doc.html hash unchanged: CSS-only. doc.html c85d97d01136c864f9bd54ce0c5a15dd649c029a8f44aa3608819282199e3e57 -doc_files/styles.css aafc6d0b9ffbbe6fb12b50a2fc1c0d845f58b6018476494fea2127609a0bee2a +doc_files/styles.css 4409ed42c2ff93c1f504b4433315bced8ae1ba63faaa6be562105c4f850261bd diff --git a/crates/quarto-core/tests/integration/theme_light_dark.rs b/crates/quarto-core/tests/integration/theme_light_dark.rs index fab15c8f0..4d9d8cffa 100644 --- a/crates/quarto-core/tests/integration/theme_light_dark.rs +++ b/crates/quarto-core/tests/integration/theme_light_dark.rs @@ -1,10 +1,12 @@ //! End-to-end render tests for the Q1 light/dark theme map form -//! (bd-o76p01wb interim behavior). +//! (bd-0pic6 epic, phase A2: dual compilation). //! -//! `theme: {light: […], dark: […]}` must render using only the -//! `light:` half and emit a single Q-14-3 warning that the `dark:` -//! half is ignored. Full dual-theme support (compile both variants + -//! toggle) is tracked separately (bd-0pic6). +//! `theme: {light: […], dark: […]}` must compile BOTH variants: the +//! light half into the primary theme CSS, the dark half into a +//! separate `-dark` CSS artifact. No Q-14-3 warning is emitted (the +//! interim degradation from bd-o76p01wb is retired). Each compiled +//! variant carries a `color-scheme` declaration derived from its +//! `$body-bg` darkness (plan D1a). //! //! Drives the real `render_to_file` API (see CLAUDE.md "End-to-end //! verification") against tempdir-based projects, then inspects both @@ -12,13 +14,12 @@ #![cfg(not(target_arch = "wasm32"))] -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tempfile::TempDir; use quarto_core::render_to_file::{RenderToFileOptions, render_to_file}; -use quarto_error_reporting::DiagnosticKind; use quarto_system_runtime::{NativeRuntime, SystemRuntime}; fn write(path: &Path, contents: &str) { @@ -28,29 +29,43 @@ fn write(path: &Path, contents: &str) { std::fs::write(path, contents).unwrap(); } -fn read_css(resources_dir: &Path) -> String { - let mut combined = String::new(); - for entry in walkdir::WalkDir::new(resources_dir) +/// Collect `(path, contents)` for every CSS file under the resources +/// dir, so assertions can distinguish which *file* carries a marker, +/// not just whether it appears anywhere. +fn css_files(resources_dir: &Path) -> Vec<(PathBuf, String)> { + walkdir::WalkDir::new(resources_dir) .follow_links(false) .into_iter() .filter_map(Result::ok) - { - let p = entry.path(); - if p.extension().and_then(|e| e.to_str()) == Some("css") { - combined.push_str(&std::fs::read_to_string(p).unwrap()); - combined.push('\n'); - } - } - combined + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("css")) + .map(|e| { + let p = e.path().to_path_buf(); + let contents = std::fs::read_to_string(&p).unwrap(); + (p, contents) + }) + .collect() } const LIGHT_SCSS: &str = "/*-- scss:rules --*/\n.q-light-marker { color: #123456; }\n"; const DARK_SCSS: &str = "/*-- scss:rules --*/\n.q-dark-marker { color: #654321; }\n"; +fn assert_no_q14_3(diagnostics: &[quarto_error_reporting::DiagnosticMessage]) { + assert!( + !diagnostics + .iter() + .any(|d| d.code.as_deref() == Some("Q-14-3")), + "Q-14-3 is retired: the dark half now compiles, nothing is ignored; \ + diagnostics: {:?}", + diagnostics + ); +} + /// Project-config path: the map form in `_quarto.yml` -/// (`format.html.theme`), the way the posit-docs extension ships it. +/// (`format.html.theme`). Both halves compile; the light and dark +/// variants land in separate CSS files with matching `color-scheme` +/// declarations. #[test] -fn project_theme_light_dark_map_renders_light_half_with_q_14_3_warning() { +fn project_theme_light_dark_map_compiles_both_variants() { let dir = TempDir::new().unwrap(); let root = dir.path(); write( @@ -74,42 +89,49 @@ fn project_theme_light_dark_map_renders_light_half_with_q_14_3_warning() { }, runtime, ) - .expect("light/dark theme map must render, not fail with Q-14-1"); + .expect("light/dark theme map must render"); - // Only the light half is compiled into the page CSS. - let css = read_css(&result.resources_dir); + let files = css_files(&result.resources_dir); + let light_file = files + .iter() + .find(|(_, css)| css.contains(".q-light-marker")) + .expect("some CSS file must carry the light half's custom rule"); + let dark_file = files + .iter() + .find(|(_, css)| css.contains(".q-dark-marker")) + .expect("some CSS file must carry the dark half's custom rule (dark now compiles)"); + assert_ne!( + light_file.0, dark_file.0, + "light and dark variants must be separate CSS files" + ); assert!( - css.contains(".q-light-marker"), - "light half's custom SCSS must be in the compiled CSS" + !light_file.1.contains(".q-dark-marker"), + "light variant must not contain dark rules" ); assert!( - !css.contains(".q-dark-marker"), - "dark half must be ignored in the interim behavior" + !dark_file.1.contains(".q-light-marker"), + "dark variant must not contain light rules" ); - // The degradation is loud: exactly one Q-14-3 warning. - let diagnostics = &result.render_output.diagnostics; - let q14_3: Vec<_> = diagnostics - .iter() - .filter(|d| d.code.as_deref() == Some("Q-14-3")) - .collect(); - assert_eq!( - q14_3.len(), - 1, - "expected exactly one Q-14-3 warning, diagnostics: {:?}", - diagnostics + // D1a: each variant declares its own color-scheme, derived from + // $body-bg darkness (cosmo → light, darkly → dark). + assert!( + light_file.1.contains("color-scheme:light") || light_file.1.contains("color-scheme: light"), + "light variant CSS must declare color-scheme light" ); - assert_eq!(q14_3[0].kind, DiagnosticKind::Warning); assert!( - q14_3[0].location.is_some(), - "Q-14-3 should carry a source location pointing at the dark entry" + dark_file.1.contains("color-scheme:dark") || dark_file.1.contains("color-scheme: dark"), + "dark variant CSS must declare color-scheme dark" ); + + assert_no_q14_3(&result.render_output.diagnostics); } /// Document-frontmatter path: the map form in the document's own -/// `format.html.theme` (single-file render, no `_quarto.yml`). +/// `format.html.theme` (single-file render, no `_quarto.yml`). The +/// single-doc dark artifact is `styles-dark.css` next to `styles.css`. #[test] -fn frontmatter_theme_light_dark_map_renders_light_half_with_q_14_3_warning() { +fn frontmatter_theme_light_dark_map_compiles_both_variants() { let dir = TempDir::new().unwrap(); let root = dir.path(); write(&root.join("light-marker.scss"), LIGHT_SCSS); @@ -131,23 +153,27 @@ fn frontmatter_theme_light_dark_map_renders_light_half_with_q_14_3_warning() { ) .expect("frontmatter light/dark theme map must render"); - let css = read_css(&result.resources_dir); - assert!(css.contains(".q-light-marker")); - assert!(!css.contains(".q-dark-marker")); - - let q14_3: Vec<_> = result - .render_output - .diagnostics + let files = css_files(&result.resources_dir); + let light_file = files .iter() - .filter(|d| d.code.as_deref() == Some("Q-14-3")) - .collect(); - assert_eq!(q14_3.len(), 1, "expected exactly one Q-14-3 warning"); + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles.css")) + .expect("single-doc light variant is styles.css"); + let dark_file = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles-dark.css")) + .expect("single-doc dark variant is styles-dark.css"); + assert!(light_file.1.contains(".q-light-marker")); + assert!(!light_file.1.contains(".q-dark-marker")); + assert!(dark_file.1.contains(".q-dark-marker")); + assert!(!dark_file.1.contains(".q-light-marker")); + + assert_no_q14_3(&result.render_output.diagnostics); } -/// A light-only map is a fully-honored (if redundant) spelling — no -/// warning (D6 in the plan). +/// A light-only map is a fully-honored (if redundant) spelling of the +/// plain form — no dark artifact is produced, no warning. #[test] -fn light_only_theme_map_renders_without_warning() { +fn light_only_theme_map_renders_without_dark_artifact() { let dir = TempDir::new().unwrap(); let root = dir.path(); write( @@ -172,15 +198,100 @@ fn light_only_theme_map_renders_without_warning() { ) .expect("light-only theme map must render"); - let css = read_css(&result.resources_dir); - assert!(css.contains(".q-light-marker")); + let files = css_files(&result.resources_dir); + assert!( + files.iter().any(|(_, css)| css.contains(".q-light-marker")), + "light half's custom SCSS must be in the compiled CSS" + ); + assert!( + !files.iter().any(|(p, _)| { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name.contains("-dark") + }), + "a light-only map must not produce a dark CSS artifact; files: {:?}", + files.iter().map(|(p, _)| p).collect::>() + ); + assert_no_q14_3(&result.render_output.diagnostics); +} + +/// Dark-only map: the light variant falls back to default Bootstrap, +/// the dark variant compiles the configured themes. No warning. +#[test] +fn dark_only_theme_map_compiles_dark_variant_with_default_light() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write(&root.join("dark-marker.scss"), DARK_SCSS); + write( + &root.join("doc.qmd"), + "---\ntitle: Dark Only\nformat:\n html:\n theme:\n dark: [darkly, dark-marker.scss]\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("dark-only theme map must render"); + + let files = css_files(&result.resources_dir); + let light_file = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles.css")) + .expect("light variant (default Bootstrap) is styles.css"); + let dark_file = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles-dark.css")) + .expect("dark variant is styles-dark.css"); assert!( - !result - .render_output - .diagnostics - .iter() - .any(|d| d.code.as_deref() == Some("Q-14-3")), - "light-only map ignores nothing, so it must not warn" + !light_file.1.contains(".q-dark-marker"), + "default-Bootstrap light variant must not carry dark rules" + ); + assert!(dark_file.1.contains(".q-dark-marker")); + assert!( + dark_file.1.contains("color-scheme:dark") || dark_file.1.contains("color-scheme: dark"), + "darkly-based dark variant must declare color-scheme dark" + ); + + assert_no_q14_3(&result.render_output.diagnostics); +} + +/// D1a bonus: a *single* dark theme (no light/dark pair at all) gets +/// `color-scheme: dark` from the darkness sentinel, so existing +/// dark-theme users get correct native scrollbars/controls. +#[test] +fn single_dark_theme_declares_dark_color_scheme() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: Darkly\nformat:\n html:\n theme: darkly\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("single dark theme must render"); + + let files = css_files(&result.resources_dir); + let styles = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles.css")) + .expect("styles.css present"); + assert!( + styles.1.contains("color-scheme:dark") || styles.1.contains("color-scheme: dark"), + "single dark theme must declare color-scheme dark" ); } diff --git a/crates/quarto-error-catalog/error_catalog.json b/crates/quarto-error-catalog/error_catalog.json index f91820664..0062f989b 100644 --- a/crates/quarto-error-catalog/error_catalog.json +++ b/crates/quarto-error-catalog/error_catalog.json @@ -1133,13 +1133,6 @@ "docs_url": "https://quarto.org/docs/errors/theme/Q-14-2", "since_version": "99.9.9" }, - "Q-14-3": { - "subsystem": "theme", - "title": "Dark theme variant not yet supported", - "message_template": "The `theme:` configuration uses the `light:`/`dark:` map form. This release renders only the `light:` themes; the `dark:` entry is ignored and no dark-mode toggle is emitted. The document still renders, styled by the light themes. Remove the `dark:` entry to silence this warning, or keep it — it will take effect when dual light/dark theme support lands.", - "docs_url": "https://quarto.org/docs/errors/theme/Q-14-3", - "since_version": "99.9.9" - }, "Q-14-4": { "subsystem": "theme", "title": "Theme file not found", diff --git a/crates/quarto-sass/src/config.rs b/crates/quarto-sass/src/config.rs index a6237d101..8a9029aa0 100644 --- a/crates/quarto-sass/src/config.rs +++ b/crates/quarto-sass/src/config.rs @@ -143,8 +143,7 @@ pub struct DarkThemeConfig { pub is_default: bool, /// Location of the `dark:` key itself, for diagnostics that need - /// to point at the dark half as a whole (e.g. the interim Q-14-3 - /// warning while dual compilation is not yet wired). + /// to point at the dark half as a whole. pub key_location: Option, } @@ -430,6 +429,35 @@ impl ThemeConfig { }) } + /// Project the dark half into a standalone, light-shaped + /// [`ThemeConfig`] so the entire pure compile pipeline + /// (`process_theme_specs` → `assemble_theme_scss` → + /// `compile_with_doc_vars`) can run unchanged for the dark + /// variant. Whole-config options (`minified`, + /// `title_block_layer`, `brand_ref`) carry over; the projection + /// has no nested dark half of its own. + /// + /// Returns `None` when no dark half is configured. + pub fn dark_variant(&self) -> Option { + self.dark.as_ref().map(|d| ThemeConfig { + themes: d.themes.clone(), + theme_locations: d.theme_locations.clone(), + minified: self.minified, + suppress_bootstrap: d.suppress_bootstrap, + title_block_layer: self.title_block_layer, + brand_ref: self.brand_ref.clone(), + dark: None, + }) + } + + /// Whether any configured variant ships Bootstrap. `theme: none` + /// suppression is per-variant (`{light: none, dark: darkly}` + /// still needs Bootstrap CSS + JS for the dark variant), so the + /// Bootstrap-JS decision must consider both halves. + pub fn ships_bootstrap(&self) -> bool { + !self.suppress_bootstrap || self.dark.as_ref().is_some_and(|d| !d.suppress_bootstrap) + } + /// Check if this config specifies any themes. /// /// Returns `false` if the config uses the default Bootstrap theme @@ -1690,6 +1718,89 @@ mod tests { assert!(dark.themes[1].is_builtin()); } + #[test] + fn test_dark_variant_projects_standalone_config() { + // `dark_variant()` projects the dark half into a standalone, + // light-shaped ThemeConfig so the pure compile pipeline can + // run unchanged for the dark variant. + let theme_value = map_value(vec![ + map_entry("light", array_value(&["cosmo", "light.scss"])), + map_entry("dark", array_value(&["darkly", "dark.scss"])), + ]); + let config = map_value(vec![ + map_entry("theme", theme_value), + map_entry("brand", scalar_value("_brand.yml")), + ]); + let theme_config = ThemeConfig::from_config_value(&config).unwrap(); + + let dark_cfg = theme_config + .dark_variant() + .expect("dark half present ⇒ dark variant config"); + // dark specs (incl. the auto-injected brand token) become the + // top-level list of the projected config. + assert_eq!(dark_cfg.themes.len(), 3); + assert!(dark_cfg.themes[0].is_builtin()); + assert!(dark_cfg.themes[1].is_custom()); + assert!(dark_cfg.themes[2].is_brand()); + assert_eq!(dark_cfg.theme_locations.len(), 3); + // Whole-config options carry over; the projection has no + // nested dark half of its own. + assert_eq!(dark_cfg.minified, theme_config.minified); + assert_eq!(dark_cfg.title_block_layer, theme_config.title_block_layer); + assert!(dark_cfg.brand_ref.is_some()); + assert!(dark_cfg.dark.is_none()); + assert!(!dark_cfg.suppress_bootstrap); + + // No dark half ⇒ no projection. + let light_only = + ThemeConfig::from_config_value(&config_with_theme_value(scalar_value("cosmo"))) + .unwrap(); + assert!(light_only.dark_variant().is_none()); + } + + #[test] + fn test_dark_variant_carries_dark_none_suppression() { + let theme_value = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("none")), + ]); + let theme_config = + ThemeConfig::from_config_value(&config_with_theme_value(theme_value)).unwrap(); + let dark_cfg = theme_config.dark_variant().unwrap(); + assert!(dark_cfg.suppress_bootstrap); + assert!(dark_cfg.themes.is_empty()); + } + + #[test] + fn test_ships_bootstrap_considers_both_variants() { + // Bootstrap ships iff ANY variant ships it. + let plain = ThemeConfig::from_config_value(&config_with_theme_value(scalar_value("cosmo"))) + .unwrap(); + assert!(plain.ships_bootstrap()); + + let none = + ThemeConfig::from_config_value(&config_with_theme_value(scalar_value("none"))).unwrap(); + assert!(!none.ships_bootstrap()); + + // {light: none, dark: darkly}: the dark variant still needs + // Bootstrap JS. + let light_none_dark = map_value(vec![ + map_entry("light", scalar_value("none")), + map_entry("dark", scalar_value("darkly")), + ]); + let cfg = + ThemeConfig::from_config_value(&config_with_theme_value(light_none_dark)).unwrap(); + assert!(cfg.ships_bootstrap()); + + // Both halves none → nothing ships. + let both_none = map_value(vec![ + map_entry("light", scalar_value("none")), + map_entry("dark", scalar_value("none")), + ]); + let cfg = ThemeConfig::from_config_value(&config_with_theme_value(both_none)).unwrap(); + assert!(!cfg.ships_bootstrap()); + } + #[test] fn test_resolve_carries_dark_through() { // ThemeConfig::resolve must not drop the dark half — the diff --git a/docs/errors/theme/Q-14-3.qmd b/docs/errors/theme/Q-14-3.qmd deleted file mode 100644 index 7c761b944..000000000 --- a/docs/errors/theme/Q-14-3.qmd +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "Dark theme variant not yet supported" -description: "The `theme:` configuration uses the `light:`/`dark:` map form; this release renders the light themes and ignores the dark entry." -code: Q-14-3 -subsystem: theme -status: stub -since: "99.9.9" -categories: - - theme ---- - -# `Q-14-3` — Dark theme variant not yet supported - -> The `theme:` configuration uses the `light:`/`dark:` map form; this -> release renders the light themes and ignores the dark entry. - -## What this means - -Quarto lets `theme:` take a map with `light:` and `dark:` halves, so one -document can ship both a light and a dark stylesheet plus a toggle -between them: - -```yaml -format: - html: - theme: - light: cosmo - dark: darkly -``` - -This release understands the map form but implements only half of it. -The `light:` themes are compiled and applied; the `dark:` entry is -parsed, accepted, and then ignored, and no dark-mode toggle is emitted. - -Your document still renders. It is styled by the light themes exactly -as if you had written `theme: cosmo`. - -## Why this happens - -- **A project carried over from Quarto 1**, where the light/dark map is - fully supported. -- **Following documentation or an example** that shows the map form. - -There is nothing wrong with the configuration itself — the warning -reports a gap in this Quarto version, not a mistake in your file. - -## How to fix - -You have two reasonable options, and which one is right depends on -whether you expect to keep the project on this version. - -**Keep the `dark:` entry.** The configuration is valid and will start -working when dual light/dark support lands, at which point the warning -disappears on its own. Nothing you write today will need revisiting. - -**Collapse to the single-theme form** to silence the warning now: - -```yaml -format: - html: - theme: cosmo -``` - -Do not switch the halves in the hope of getting the dark theme instead -— the `light:` key is what this release reads, so -`light: darkly` is how you render dark styling today, at the cost of -losing the light variant when the toggle ships. diff --git a/docs/errors/theme/Q-14-4.qmd b/docs/errors/theme/Q-14-4.qmd index a24acc4b9..f382ea96f 100644 --- a/docs/errors/theme/Q-14-4.qmd +++ b/docs/errors/theme/Q-14-4.qmd @@ -57,5 +57,3 @@ Common causes: - `Q-14-2` — unknown theme name: the value has no `.scss`/`.css` extension and is not a built-in theme name either. -- `Q-14-3` — dark theme variant not yet supported: the - `light:`/`dark:` map form renders only the `light:` half. diff --git a/resources/scss/bootstrap/_bootstrap-rules.scss b/resources/scss/bootstrap/_bootstrap-rules.scss index 36e5b993e..b82e8b7f4 100644 --- a/resources/scss/bootstrap/_bootstrap-rules.scss +++ b/resources/scss/bootstrap/_bootstrap-rules.scss @@ -2384,11 +2384,22 @@ code a:hover { } // This is a sentinel value that renderers can use to determine -// whether the theme is dark or light +// whether the theme is dark or light. Alongside the sentinel we +// declare the matching `color-scheme` so UA-drawn chrome (scrollbars, +// form-control internals, picker popups) follows the theme's darkness +// — an improvement over Quarto 1, which never sets it (light/dark +// epic bd-0pic6, design D1a). `light-dark()` in user CSS resolves +// against this declaration. @if (quarto-color.blackness($body-bg) > $code-block-theme-dark-threshhold) { /*! dark */ + :root { + color-scheme: dark; + } } @else { /*! light */ + :root { + color-scheme: light; + } } // observable UI element tweaks to support light-mode vs dark-mode From 96251a2e00345579d31136e152b781cc611f7fca Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 15:50:01 -0500 Subject: [PATCH 04/10] light-dark A3: attributed stylesheet links + color-scheme meta (bd-ld-a3-link-emission-ruw9kw4v) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifact gains link_attribs (order-preserving Vec of HTML attributes for the emitted tag) and link_order (emission sort priority); collect_artifact_urls sorts by (link_order, key) and returns structured LinkedResource entries. Attribute-free entries still render as plain template strings, so custom templates writing $css$ keep working and single-variant output is byte-identical (golden-hash test unchanged). Attributed entries render as maps consumed by new single-line $if(css.href)$ branches in both built-in templates. The theme pair is stored with Q1's exact link contract: light (class=quarto-color-scheme, id=quarto-bootstrap), dark (quarto-color-scheme quarto-color-alternate), and — for author-default-light — a trailing re-link of the same light file with class=quarto-color-scheme-extra so pre-toggle paint and no-JS browsers land on the default variant (the FOUC hard constraint; class replaces so the future toggle's selectors skip it). data-mode comes from each sheet's compiled /*! dark */ sentinel rather than its slot, handling custom-SCSS dark halves. D1a: the full template head now emits when a dark variant exists — author default first, both schemes when respect-user-color-scheme: true (first reader for that key; A4 reuses it for the toggle runtime). TDD: 3 integration tests red first; E2E via real q2 render inspected (link trio + meta byte-shape verified). 12,139 workspace tests green. Part of the light/dark epic (bd-0pic6); plan: claude-notes/plans/2026-08-14-light-dark-theme-epic.md Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 22 ++- crates/quarto-core/src/artifact.rs | 40 +++++ .../src/stage/stages/apply_template.rs | 27 ++- .../src/stage/stages/compile_theme_css.rs | 111 +++++++++++- crates/quarto-core/src/template.rs | 111 ++++++++++-- .../tests/integration/theme_light_dark.rs | 158 ++++++++++++++++++ 6 files changed, 436 insertions(+), 33 deletions(-) diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index 7971faf48..d358001e8 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -426,9 +426,25 @@ Integration branch: `feature/light-dark-theme` (created off `main`). unchanged until A3. Golden-hash baseline re-captured (documented delta: the one color-scheme rule). E2E: real `q2 render` of a quarto-web-shaped project inspected. 12,135 workspace tests green. -- [ ] **A3 — link emission** (D4, D1a): `bd-ld-a3-link-emission-ruw9kw4v`. - Artifact attribs, template changes, ordering, trailing-copy emission, meta - color-scheme tag. Byte-identical output when no dark variant. +- [x] **A3 — link emission** (D4, D1a): `bd-ld-a3-link-emission-ruw9kw4v`. + **Done 2026-08-14.** `Artifact` gained typed `link_attribs: + Vec<(String,String)>` + `link_order: i32`; `collect_artifact_urls` returns + `LinkedResource` sorted by `(link_order, key)` (all order-0 ⇒ pre-existing + order preserved byte-identically, guarded by the golden-hash test); + attributed entries render as `TemplateValue::Map` with `$if(css.href)$` + single-line branches in both built-in templates (plain-string entries keep + custom-template compat — matches Q1, whose `$css$` never carried theme + links). Theme trio: light (`quarto-color-scheme`, order 10), dark + (`quarto-color-scheme quarto-color-alternate`, order 20), and for + author-default-light a trailing re-link of the SAME light file + (`quarto-color-scheme-extra`, order 30 — class replaces so toggle + selectors skip it; needed for the FOUC hard constraint). `data-mode` from + each sheet's compiled `/*! dark */` sentinel (`css_is_dark`), not its + slot. `` emitted in the full template head when + a dark variant exists — author default first, both schemes under + `respect-user-color-scheme: true` (reader introduced here, reused by A4). + E2E: real render inspected (trio + meta exactly Q1-shaped). 12,139 tests + green. - [ ] **A4 — toggle runtime** (D5): `bd-ld-a4-toggle-runtime-0t9i2rvs`. JS asset + before-body injection, body classes, localStorage, `respect-user-color-scheme`, hardcoded navbar toggle + floating fallback, diff --git a/crates/quarto-core/src/artifact.rs b/crates/quarto-core/src/artifact.rs index 3fd56d29b..03aaf24d2 100644 --- a/crates/quarto-core/src/artifact.rs +++ b/crates/quarto-core/src/artifact.rs @@ -76,6 +76,21 @@ pub struct Artifact { /// for producers — flipping a producer to `Project` is the /// explicit signal that its output is shareable across pages. pub scope: ArtifactScope, + + /// Extra HTML attributes for the `` / ` +$if(scripts.src)$$else$$endif$ $endfor$ $for(header-includes)$ $header-includes$ @@ -164,6 +219,9 @@ const FULL_HTML_TEMPLATE: &str = r#" +$if(color-scheme-meta)$ + +$endif$ $for(author-meta)$ @@ -184,13 +242,13 @@ $if(pagetitle)$ $pagetitle$ $endif$ $for(css)$ - +$if(css.href)$$else$$endif$ $endfor$ $if(math)$ $math$ $endif$ $for(scripts)$ - +$if(scripts.src)$$else$$endif$ $endfor$ $for(header-includes)$ $header-includes$ @@ -629,8 +687,8 @@ pub fn render_with_compiled_template( template: &Template, body: &str, meta: &ConfigValue, - css_paths: &[String], - script_paths: &[String], + css_paths: &[LinkedResource], + script_paths: &[LinkedResource], ) -> Result<(String, Vec)> { let mut ctx = TemplateContext::new(); ctx.insert("body", TemplateValue::String(body.to_string())); @@ -655,10 +713,8 @@ pub fn render_with_compiled_template( ); // Build combined CSS list: default resources first, then user-specified - let mut css_list: Vec = css_paths - .iter() - .map(|p| TemplateValue::String(p.clone())) - .collect(); + let mut css_list: Vec = + css_paths.iter().map(|r| r.template_value("href")).collect(); // Add any user-specified CSS from metadata if let Some(user_css) = extract_css_from_meta(meta) { @@ -671,11 +727,36 @@ pub fn render_with_compiled_template( if !script_paths.is_empty() { let scripts_list: Vec = script_paths .iter() - .map(|p| TemplateValue::String(p.clone())) + .map(|r| r.template_value("src")) .collect(); ctx.insert("scripts", TemplateValue::List(scripts_list)); } + // Pre-CSS paint hint for light/dark theme pairs (bd-0pic6 D1a): + // when a dark variant exists, tell the UA which scheme(s) the page + // supports before any stylesheet loads. The author-default scheme + // comes first; `respect-user-color-scheme: true` offers both so + // the UA picks per `prefers-color-scheme`. Only the full template + // emits the tag; setting the variable elsewhere is inert. + if let Ok(theme_config) = quarto_sass::ThemeConfig::from_config_value(meta) + && let Some(dark) = &theme_config.dark + { + let respect = meta + .get("respect-user-color-scheme") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let content = match (respect, dark.is_default) { + (true, false) => "light dark", + (true, true) => "dark light", + (false, false) => "light", + (false, true) => "dark", + }; + ctx.insert( + "color-scheme-meta", + TemplateValue::String(content.to_string()), + ); + } + // Wire `rendered.includes.{header, before-body, after-body}` into the // Pandoc-native template variable names (kept stable per // claude-notes/plans/2026-05-04-includes-feature.md §Resolved questions @@ -813,7 +894,8 @@ pub fn render_with_resources( css_paths: &[String], ) -> Result<(String, Vec)> { let template = default_html_template()?; - render_with_compiled_template(&template, body, meta, css_paths, &[]) + let css: Vec = css_paths.iter().map(LinkedResource::plain).collect(); + render_with_compiled_template(&template, body, meta, &css, &[]) } /// Render a document with format-based template selection. @@ -836,7 +918,8 @@ pub fn render_with_format( // `author-meta`). let mut meta = meta.clone(); crate::transforms::normalize_authors_meta(&mut meta); - render_with_compiled_template(&template, body, &meta, css_paths, &[]) + let css: Vec = css_paths.iter().map(LinkedResource::plain).collect(); + render_with_compiled_template(&template, body, &meta, &css, &[]) } /// Compile the appropriate built-in template (minimal or full) with a custom @@ -2475,7 +2558,7 @@ mod tests { "

    body

    ", &meta, &[], - &["libs/kbd/kbd.js".to_string()], + &[LinkedResource::plain("libs/kbd/kbd.js")], ) .unwrap(); diff --git a/crates/quarto-core/tests/integration/theme_light_dark.rs b/crates/quarto-core/tests/integration/theme_light_dark.rs index 4d9d8cffa..db02166e6 100644 --- a/crates/quarto-core/tests/integration/theme_light_dark.rs +++ b/crates/quarto-core/tests/integration/theme_light_dark.rs @@ -261,6 +261,164 @@ fn dark_only_theme_map_compiles_dark_variant_with_default_light() { assert_no_q14_3(&result.render_output.diagnostics); } +/// A3: the emitted `` tags carry Q1's classes/id/data-mode, in +/// the FOUC-safe order (light, dark, trailing light copy for an +/// author-default-light pair), plus the `` +/// pre-CSS paint hint (D1a). +#[test] +fn light_dark_map_emits_attributed_links_and_meta() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write(&root.join("light-marker.scss"), LIGHT_SCSS); + write(&root.join("dark-marker.scss"), DARK_SCSS); + write( + &root.join("doc.qmd"), + "---\ntitle: Attributed Links\nformat:\n html:\n theme:\n light: [cosmo, light-marker.scss]\n dark: [darkly, dark-marker.scss]\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + + // Light link: primary color-scheme sheet, mode from the compiled + // CSS's darkness sentinel. + let light = html + .find(r#""#) + .expect("attributed light link"); + // Dark link: alternate sheet. + let dark = html + .find(r#""#) + .expect("attributed dark link"); + // Trailing light copy (author default is light): re-links the SAME + // file so no-JS/first-paint lands on the default variant. + let extra = html + .find(r#""#) + .expect("trailing light-copy link"); + assert!( + light < dark && dark < extra, + "link order must be light ({light}), dark ({dark}), extra ({extra})" + ); + + // D1a: pre-CSS paint hint matches the author default. + assert!( + html.contains(r#""#), + "author-default-light pair must emit the light color-scheme meta" + ); +} + +/// A3: author-default-dark (dark listed first) — two links only +/// (light, dark; the enabled-last dark wins pre-JS), dark meta. +#[test] +fn dark_first_map_emits_dark_default_links_and_meta() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: Dark Default\nformat:\n html:\n theme:\n dark: darkly\n light: cosmo\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + + let light = html + .find(r#"class="quarto-color-scheme" id="quarto-bootstrap" data-mode="light""#) + .expect("light link present"); + let dark = html + .find(r#"class="quarto-color-scheme quarto-color-alternate" id="quarto-bootstrap" data-mode="dark""#) + .expect("dark link present"); + assert!(light < dark, "light link first, dark (default) last"); + assert!( + !html.contains("quarto-color-scheme-extra"), + "author-default-dark must not emit the trailing copy" + ); + assert!( + html.contains(r#""#), + "author-default-dark pair must emit the dark color-scheme meta" + ); +} + +/// A3 + D1a: `respect-user-color-scheme: true` makes the pre-CSS +/// paint hint offer both schemes so the UA picks per +/// `prefers-color-scheme` (author-default first). +#[test] +fn respect_user_color_scheme_emits_dual_meta() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: Respect\nformat:\n html:\n respect-user-color-scheme: true\n theme:\n light: cosmo\n dark: darkly\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + assert!( + html.contains(r#""#), + "respect-user-color-scheme must offer both schemes, author default first" + ); +} + +/// A3 back-compat: without a dark variant, links stay exactly as +/// before — no classes, no id, no data-mode, no meta tag. (The +/// phase5 golden-hash baseline guards this at the byte level; this +/// assertion documents it at the feature level.) +#[test] +fn single_variant_links_stay_plain() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: Plain\nformat:\n html:\n theme: cosmo\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + assert!( + html.contains(r#""#), + "single-variant link must stay attribute-free" + ); + assert!(!html.contains("quarto-color-scheme")); + assert!(!html.contains(r#" Date: Fri, 14 Aug 2026 16:07:58 -0500 Subject: [PATCH 05/10] light-dark A4: color-mode toggle runtime (bd-ld-a4-toggle-runtime-0t9i2rvs) Adds quarto-color-mode.js, a de-EJS'd port of Q1's before-body toggle script (rel-swap between stylesheet/disabled-stylesheet, body quarto-light/quarto-dark sync from the active sheet's data-mode, root color-scheme sync, localStorage quarto-color-scheme persistence with Q1's key and default/alternate values, respect-user-color-scheme via prefers-color-scheme with explicit-choice-wins, floating top-right fallback toggle folded in from Q1's after-body script). Injected inline as the first child of so initial variant selection runs before first paint (the FOUC hard constraint); configured via data attributes. Deliberate divergences: no Safari scrollbar hack (color-scheme supersedes it), no giscus (no comments support yet). append_color_mode_class grows its default_dark argument (bd-mtzry): author-default-dark maps bake body.quarto-dark. Navbar gains a dark_mode_toggle flag set by NavbarGenerateTransform from the theme config (Q1's formatDarkMode trigger) and rendered as the quarto-navbar-tools slot; folds into general tools: support later (bd-ld-toggle-into-tools-hpae7m9r). bd-l1rx9yzh resolves as a side effect: both .light-content/.dark-content swap rules were already compiled, and the body-class flip now makes them live. Fixes a latent bug found during browser verification: colorToRGBA() was never ported into _bootstrap-functions.scss, so the toggle icons' SVG data-URI fills contained the literal call text (silently invalid inside string interpolation) and the icon was invisible. Ported from Q1's _quarto-functions.scss with a regression test; golden-hash baseline re-captured for the fill delta. TDD: 4 integration tests red first. Browser-verified end-to-end with chrome-devtools MCP: toggle both directions, persistence across reload (restored before paint), content swapping, icon states, no console errors. 12,145 workspace tests green. Part of the light/dark epic (bd-0pic6); plan: claude-notes/plans/2026-08-14-light-dark-theme-epic.md Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 30 ++- .../resources/js/quarto-color-mode.js | 220 ++++++++++++++++++ crates/quarto-core/src/template.rs | 88 ++++--- .../src/transforms/navbar_generate.rs | 9 + .../expected_hashes.txt | 11 +- .../tests/integration/theme_light_dark.rs | 189 ++++++++++++++- crates/quarto-navigation/src/navbar.rs | 17 ++ crates/quarto-navigation/src/render_html.rs | 16 ++ crates/quarto-sass/src/compile.rs | 25 ++ .../scss/bootstrap/_bootstrap-functions.scss | 10 + 10 files changed, 582 insertions(+), 33 deletions(-) create mode 100644 crates/quarto-core/resources/js/quarto-color-mode.js diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index d358001e8..b37d6d769 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -445,10 +445,32 @@ Integration branch: `feature/light-dark-theme` (created off `main`). `respect-user-color-scheme: true` (reader introduced here, reused by A4). E2E: real render inspected (trio + meta exactly Q1-shaped). 12,139 tests green. -- [ ] **A4 — toggle runtime** (D5): `bd-ld-a4-toggle-runtime-0t9i2rvs`. JS asset - + before-body injection, body classes, localStorage, - `respect-user-color-scheme`, hardcoded navbar toggle + floating fallback, - `_light-dark.scss` dark half (related: bd-l1rx9yzh). +- [x] **A4 — toggle runtime** (D5): `bd-ld-a4-toggle-runtime-0t9i2rvs`. + **Done 2026-08-14.** `quarto-color-mode.js` (de-EJS'd port of Q1's + before-body script + after-body floating-toggle fallback, config via + `data-*` attrs on its own tag; divergences documented in the file header: + no Safari scrollbar hack — `color-scheme` supersedes it — and no giscus) + injected INLINE as the first child of `` via the + `color-mode-script` template variable. Same localStorage key/values as Q1 + (`quarto-color-scheme` = `default`/`alternate`) so preferences carry over. + `append_color_mode_class` grew its `default_dark` arg (bd-mtzry resolved); + `respect-user-color-scheme` wired into the runtime. Navbar toggle: + `Navbar.dark_mode_toggle` (set by `NavbarGenerateTransform` from the theme + config, round-trips via `dark-mode-toggle` in the stored config map), + rendered as Q1's `quarto-navbar-tools` slot markup. bd-l1rx9yzh resolved + as a side effect: both content-swap halves were already compiled; the body + class flip makes them live. **Bug found by browser verification, fixed + with a regression test**: `colorToRGBA()` was never ported to + `_bootstrap-functions.scss`, so the toggle icons' SVG fills contained the + literal call text (silently invalid — string interpolation doesn't error + on unknown functions) and the icon was invisible. Golden hash re-captured + for that fix. **Browser-verified end-to-end** (chrome-devtools MCP against + a served website fixture): initial light state with dark+extra sheets + disabled pre-paint; toggle → dark (rel-swap, body class, root + color-scheme, localStorage `alternate`, icon `.alternate` state, + `.light-content`/`.dark-content` swap); reload restores dark before + paint; toggle back to light restores everything (`default` stored); no + console errors. 12,145 workspace tests green. - [ ] **A5 — quarto-web end-to-end**: `bd-ld-a5-quarto-web-e2e-bzg4o5lc`. Render `external-sources/quarto-web` with the real binary, browser-verify toggle/persistence/prefers-color-scheme, document gaps found. (related: diff --git a/crates/quarto-core/resources/js/quarto-color-mode.js b/crates/quarto-core/resources/js/quarto-color-mode.js new file mode 100644 index 000000000..fad3b76ac --- /dev/null +++ b/crates/quarto-core/resources/js/quarto-color-mode.js @@ -0,0 +1,220 @@ +// Quarto color-mode runtime (light/dark theme toggle). +// +// Ported from Quarto 1's quarto-html-before-body.ejs (de-EJS'd: +// configuration arrives via data attributes on this script's own tag +// instead of template interpolation). Injected INLINE as the first +// child of so it runs synchronously before first paint — the +// initial variant selection must happen before any content renders +// (FOUC avoidance is the hard constraint; see +// claude-notes/plans/2026-08-14-light-dark-theme-epic.md D5). +// +// Mechanism: all theme stylesheets are emitted rel="stylesheet" in +// FOUC-safe order (light, dark, and for author-default-light a +// trailing light copy). This script flips link.rel between +// "stylesheet" and "disabled-stylesheet" and keeps body.quarto-light / +// body.quarto-dark plus the root color-scheme in sync with the active +// sheet's data-mode. "alternate" refers to the .quarto-color-alternate +// (dark) sheets; the persisted sentinel value "alternate"/"default" in +// localStorage["quarto-color-scheme"] matches Quarto 1's key and +// values, so preferences carry over between Q1 and Q2 sites on the +// same origin. +// +// Deliberate divergences from Q1 (documented in the plan): +// - No Safari scrollbar-recolor hack: the compiled CSS declares +// :root{color-scheme:...} per variant (D1a), which is the standard +// fix the hack approximated. +// - No giscus handling (Q2 has no comments support yet). +// - The floating top-right fallback toggle (Q1's after-body script) +// is folded into this file's DOMContentLoaded handler. +(function () { + const script = document.currentScript; + const authorPrefersDark = script.dataset.authorPrefersDark === "true"; + const respectUserColorScheme = + script.dataset.respectUserColorScheme === "true"; + + const isFileUrl = () => window.location.protocol === "file:"; + + const toggleBodyColorMode = (bsSheetEl) => { + const mode = bsSheetEl.getAttribute("data-mode"); + const bodyEl = window.document.querySelector("body"); + if (mode === "dark") { + bodyEl.classList.add("quarto-dark"); + bodyEl.classList.remove("quarto-light"); + } else { + bodyEl.classList.add("quarto-light"); + bodyEl.classList.remove("quarto-dark"); + } + // Belt-and-braces for UA-drawn chrome: the enabled stylesheet + // declares the same value, but setting it on the root applies it + // even in the instant before that sheet's rules take effect. + document.documentElement.style.colorScheme = + mode === "dark" ? "dark" : "light"; + }; + + const toggleBodyColorPrimary = () => { + const bsSheetEl = window.document.querySelector( + "link#quarto-bootstrap:not([rel=disabled-stylesheet])" + ); + if (bsSheetEl) { + toggleBodyColorMode(bsSheetEl); + } + }; + + const disableStylesheet = (stylesheets) => { + for (let i = 0; i < stylesheets.length; i++) { + stylesheets[i].rel = "disabled-stylesheet"; + } + }; + + const enableStylesheet = (stylesheets) => { + for (let i = 0; i < stylesheets.length; i++) { + // Guard against re-setting rel to its current value — some + // browsers re-fetch/re-apply on assignment, causing a flash. + if (stylesheets[i].rel !== "stylesheet") { + stylesheets[i].rel = "stylesheet"; + } + } + }; + + // Suppress CSS transitions on elements that would otherwise animate + // during the swap (margin-sidebar links animate color). + const manageTransitions = (selector, allowTransitions) => { + const els = window.document.querySelectorAll(selector); + for (let i = 0; i < els.length; i++) { + els[i].style.transition = allowTransitions ? null : "none"; + } + }; + + const setColorSchemeToggle = (alternate) => { + const toggles = window.document.querySelectorAll( + ".quarto-color-scheme-toggle" + ); + for (let i = 0; i < toggles.length; i++) { + if (alternate) { + toggles[i].classList.add("alternate"); + } else { + toggles[i].classList.remove("alternate"); + } + } + }; + + const toggleColorMode = (alternate) => { + // The trailing default copies (.quarto-color-scheme-extra) are + // deliberately NOT matched by either selector: once this runtime + // owns the swap they stay disabled. + const primaryStylesheets = document.querySelectorAll( + "link.quarto-color-scheme:not(.quarto-color-alternate)" + ); + const alternateStylesheets = document.querySelectorAll( + "link.quarto-color-scheme.quarto-color-alternate" + ); + manageTransitions("#quarto-margin-sidebar .nav-link", false); + if (alternate) { + // Note: dark is layered on top of light — the primary sheets are + // not disabled, the dark CSS only needs to override. + enableStylesheet(alternateStylesheets); + for (const sheetNode of alternateStylesheets) { + if (sheetNode.id === "quarto-bootstrap") { + toggleBodyColorMode(sheetNode); + } + } + } else { + disableStylesheet(alternateStylesheets); + enableStylesheet(primaryStylesheets); + toggleBodyColorPrimary(); + } + manageTransitions("#quarto-margin-sidebar .nav-link", true); + setColorSchemeToggle(alternate); + }; + + // file:// URLs have no reliable localStorage — fall back to a + // page-lifetime variable (Q1 behavior). + let localAlternateSentinel; + + const setStyleSentinel = (alternate) => { + const value = alternate ? "alternate" : "default"; + if (!isFileUrl()) { + window.localStorage.setItem("quarto-color-scheme", value); + } else { + localAlternateSentinel = value; + } + }; + + const getColorSchemeSentinel = () => { + if (!isFileUrl()) { + const storageValue = window.localStorage.getItem("quarto-color-scheme"); + return storageValue != null ? storageValue : localAlternateSentinel; + } + return localAlternateSentinel; + }; + + const hasAlternateSentinel = () => getColorSchemeSentinel() === "alternate"; + + // The effective initial darkness: the author's choice, overridden by + // the OS preference when respect-user-color-scheme is on. + let darkModeDefault = authorPrefersDark; + let queryPrefersDark = null; + if (respectUserColorScheme && window.matchMedia) { + queryPrefersDark = window.matchMedia("(prefers-color-scheme: dark)"); + darkModeDefault = queryPrefersDark.matches; + } + + // Author default light → the trailing default copies exist purely + // for pre-JS paint; hand control to the swapper. + if (!authorPrefersDark) { + disableStylesheet( + document.querySelectorAll("link.quarto-color-scheme-extra") + ); + } + + // "alternate" means the dark (.quarto-color-alternate) sheets are + // active. No stored preference → start from the effective default. + localAlternateSentinel = darkModeDefault ? "alternate" : "default"; + + window.quartoToggleColorScheme = () => { + const toAlternate = !hasAlternateSentinel(); + toggleColorMode(toAlternate); + setStyleSentinel(toAlternate); + // Nudge anything that re-layouts on theme change (plots, OJS). + window.dispatchEvent(new Event("resize")); + }; + + if (queryPrefersDark) { + queryPrefersDark.addEventListener("change", (e) => { + // An explicit user choice (persisted sentinel) always wins over + // the OS preference. + if ( + !isFileUrl() && + window.localStorage.getItem("quarto-color-scheme") !== null + ) { + return; + } + toggleColorMode(e.matches); + localAlternateSentinel = e.matches ? "alternate" : "default"; + }); + } + + // Apply the initial state synchronously, before first paint. + toggleColorMode(hasAlternateSentinel()); + + // Documents without a navbar/sidebar toggle get a floating one + // (Q1's after-body fallback). + window.document.addEventListener("DOMContentLoaded", () => { + let toggle = window.document.querySelector(".quarto-color-scheme-toggle"); + if (!toggle) { + toggle = window.document.createElement("a"); + toggle.href = ""; + toggle.setAttribute( + "onclick", + "window.quartoToggleColorScheme(); return false;" + ); + toggle.className = "top-right quarto-color-scheme-toggle"; + toggle.title = "Toggle dark mode"; + const icon = window.document.createElement("i"); + icon.className = "bi"; + toggle.appendChild(icon); + window.document.body.appendChild(toggle); + } + setColorSchemeToggle(hasAlternateSentinel()); + }); +})(); diff --git a/crates/quarto-core/src/template.rs b/crates/quarto-core/src/template.rs index 6513e5979..81128345e 100644 --- a/crates/quarto-core/src/template.rs +++ b/crates/quarto-core/src/template.rs @@ -255,6 +255,9 @@ $header-includes$ $endfor$ +$if(color-mode-script)$ +$color-mode-script$ +$endif$ $if(rendered.draft-alert-text)$
    $rendered.draft-alert-text$
    $endif$ @@ -732,20 +735,30 @@ pub fn render_with_compiled_template( ctx.insert("scripts", TemplateValue::List(scripts_list)); } - // Pre-CSS paint hint for light/dark theme pairs (bd-0pic6 D1a): - // when a dark variant exists, tell the UA which scheme(s) the page - // supports before any stylesheet loads. The author-default scheme - // comes first; `respect-user-color-scheme: true` offers both so - // the UA picks per `prefers-color-scheme`. Only the full template - // emits the tag; setting the variable elsewhere is inert. - if let Ok(theme_config) = quarto_sass::ThemeConfig::from_config_value(meta) + // Light/dark theme pair wiring (bd-0pic6 D1a + A4). When a dark + // variant exists: + // + // - `color-scheme-meta` → `` pre-CSS + // paint hint: the author-default scheme first; + // `respect-user-color-scheme: true` offers both so the UA picks + // per `prefers-color-scheme`. + // - `color-mode-script` → the inline color-mode runtime, injected + // as the FIRST child of `` so the initial variant + // selection happens synchronously before first paint (the FOUC + // hard constraint). Configured via data attributes. + // + // Only the full template references these variables; setting them + // elsewhere is inert. + let dark_theme_default = if let Ok(theme_config) = + quarto_sass::ThemeConfig::from_config_value(meta) && let Some(dark) = &theme_config.dark { + let author_prefers_dark = dark.is_default; let respect = meta .get("respect-user-color-scheme") .and_then(|v| v.as_bool()) .unwrap_or(false); - let content = match (respect, dark.is_default) { + let content = match (respect, author_prefers_dark) { (true, false) => "light dark", (true, true) => "dark light", (false, false) => "light", @@ -755,7 +768,17 @@ pub fn render_with_compiled_template( "color-scheme-meta", TemplateValue::String(content.to_string()), ); - } + ctx.insert( + "color-mode-script", + TemplateValue::String(format!( + "", + COLOR_MODE_JS + )), + ); + Some(author_prefers_dark) + } else { + None + }; // Wire `rendered.includes.{header, before-body, after-body}` into the // Pandoc-native template variable names (kept stable per @@ -807,9 +830,12 @@ pub fn render_with_compiled_template( (None, false) => "fullcontent".to_string(), }; // bd-mtzry: append the color-mode class so theme-conditional CSS - // can key off `body.quarto-light` (matches Q1 default). Dark-mode - // theme support lands separately; for now we always emit `quarto-light`. - let body_classes = append_color_mode_class(&structural); + // can key off `body.quarto-light` / `body.quarto-dark`. The + // baked class matches the AUTHOR default (Q1 bakes the same + // way); under `respect-user-color-scheme` the inline runtime + // may flip it before first paint. + let body_classes = + append_color_mode_class(&structural, dark_theme_default.unwrap_or(false)); ctx.insert("body-classes", TemplateValue::String(body_classes)); } @@ -837,27 +863,37 @@ pub fn render_with_compiled_template( /// objects, and engine-contributed `PandocIncludes`. If the array is empty /// or absent (resolve stage didn't run), the template variable is not set /// — `$for(template_var)$` then produces no output. -/// Append the active color-mode class (today always `quarto-light`) -/// to a structural body-class string. Empty input → `"quarto-light"`; -/// non-empty input → `" quarto-light"`. Idempotent: a -/// structural that already contains `quarto-light` is returned as-is. +/// The inline color-mode runtime (bd-0pic6 A4), embedded at build +/// time and injected into `` via the `color-mode-script` +/// template variable when a dark theme variant exists. +const COLOR_MODE_JS: &str = include_str!("../resources/js/quarto-color-mode.js"); + +/// Append the active color-mode class (`quarto-light`, or +/// `quarto-dark` when the author-default variant is dark — Q1's +/// key-order rule) to a structural body-class string. Empty input → +/// the bare class; non-empty input → `" "`. +/// Idempotent: a structural that already contains either color-mode +/// class is returned as-is. /// -/// bd-mtzry. Light/dark theme detection is not yet wired into the -/// pipeline (the `theme:` key today is a single Bootswatch name); when -/// it lands, this helper grows a `mode` argument and the call site -/// decides which class to emit. Until then `quarto-light` matches -/// Quarto 1's default body class for documents with no dark theme set. -fn append_color_mode_class(structural: &str) -> String { - const LIGHT: &str = "quarto-light"; +/// bd-mtzry. The baked class reflects the AUTHOR default; the inline +/// color-mode runtime (A4) re-syncs it from the active stylesheet's +/// `data-mode` before first paint when a stored preference or +/// `respect-user-color-scheme` overrides the default. +fn append_color_mode_class(structural: &str, default_dark: bool) -> String { + let class = if default_dark { + "quarto-dark" + } else { + "quarto-light" + }; let already = structural .split_whitespace() - .any(|tok| tok == LIGHT || tok == "quarto-dark"); + .any(|tok| tok == "quarto-light" || tok == "quarto-dark"); if already { structural.to_string() } else if structural.is_empty() { - LIGHT.to_string() + class.to_string() } else { - format!("{structural} {LIGHT}") + format!("{structural} {class}") } } diff --git a/crates/quarto-core/src/transforms/navbar_generate.rs b/crates/quarto-core/src/transforms/navbar_generate.rs index fcc44a839..91e901578 100644 --- a/crates/quarto-core/src/transforms/navbar_generate.rs +++ b/crates/quarto-core/src/transforms/navbar_generate.rs @@ -79,6 +79,15 @@ impl AstTransform for NavbarGenerateTransform { return Ok(()); }; + // Dark-mode toggle: emitted whenever the format has a dark + // theme variant (bd-0pic6 A4). Derived from the THEME config, + // not `navbar:` YAML — same trigger as Q1's + // `formatDarkMode(format) !== undefined` navbar/sidebar + // darkToggle. Parse errors mean the theme stage will fail the + // render anyway; treat as "no toggle" here. + navbar.dark_mode_toggle = + quarto_sass::ThemeConfig::from_config_value(&ast.meta).is_ok_and(|c| c.dark.is_some()); + // bd-qor9a — resolve each href against the YAML file it was // authored in. Frontmatter-rooted hrefs become project-root- // relative; `_quarto.yml`-rooted ones (the common case for diff --git a/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt b/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt index c368de3fd..2eee66779 100644 --- a/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt +++ b/crates/quarto-core/tests/fixtures/phase5-single-doc-baseline/expected_hashes.txt @@ -284,5 +284,14 @@ # theme's darkness. Verified the only delta is that one rule: the # pre-existing `[data-bs-theme=dark]{color-scheme:dark;…}` block is # Bootstrap 5.3 core, unchanged. doc.html hash unchanged: CSS-only. +# Re-captured 2026-08-14 (bd-ld-a4-toggle-runtime-0t9i2rvs): styles.css +# hash updated because `colorToRGBA()` was ported into +# `_bootstrap-functions.scss` (from Q1's `_quarto-functions.scss`). +# The color-scheme toggle icons' SVG data URIs previously interpolated +# the literal text `colorToRGBA(#dee2e6)` as their fill (a silently +# invalid value — sass does not error on unknown functions inside +# string interpolation), leaving the toggle icon invisible; they now +# carry concrete `fill="rgba(…)"` values. Verified the only delta is +# those six data-URI fills. doc.html hash unchanged: CSS-only. doc.html c85d97d01136c864f9bd54ce0c5a15dd649c029a8f44aa3608819282199e3e57 -doc_files/styles.css 4409ed42c2ff93c1f504b4433315bced8ae1ba63faaa6be562105c4f850261bd +doc_files/styles.css 5228869a7216859350164870ea4a62f40fb59222475530ee59fe796cf6364335 diff --git a/crates/quarto-core/tests/integration/theme_light_dark.rs b/crates/quarto-core/tests/integration/theme_light_dark.rs index db02166e6..4d0101120 100644 --- a/crates/quarto-core/tests/integration/theme_light_dark.rs +++ b/crates/quarto-core/tests/integration/theme_light_dark.rs @@ -347,8 +347,9 @@ fn dark_first_map_emits_dark_default_links_and_meta() { .expect("dark link present"); assert!(light < dark, "light link first, dark (default) last"); assert!( - !html.contains("quarto-color-scheme-extra"), - "author-default-dark must not emit the trailing copy" + !html.contains(r#"class="quarto-color-scheme-extra""#), + "author-default-dark must not emit the trailing-copy link \ + (the inline runtime's source mentioning the class is fine)" ); assert!( html.contains(r#""#), @@ -419,6 +420,190 @@ fn single_variant_links_stay_plain() { assert!(!html.contains(r#"` (before any paintable content — +/// the FOUC hard constraint), configured via data attributes. +#[test] +fn light_dark_map_injects_color_mode_script_at_top_of_body() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: Toggle\nformat:\n html:\n theme:\n light: cosmo\n dark: darkly\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + + let body = html.find(""#) + .expect("inline color-mode script with config data attributes"); + assert!(script > body, "script must be inside body"); + // Nothing paintable between and the script: only the + // body tag itself (and whitespace) may precede it. + let between = &html[body..script]; + let after_tag = &between[between.find('>').unwrap() + 1..]; + assert!( + after_tag.trim().is_empty(), + "color-mode script must be the first thing in , found: {after_tag:?}" + ); + assert!( + html.contains("window.quartoToggleColorScheme"), + "toggle entry point must be defined inline" + ); + // Author default is light → body baked light. + assert!(html.contains(r#"quarto-light""#) || html.contains(r#"quarto-light "#)); +} + +/// A4: author-default-dark bakes `quarto-dark` on `` and tells +/// the runtime the author prefers dark. +#[test] +fn dark_first_map_bakes_dark_body_class() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: Dark Default\nformat:\n html:\n theme:\n dark: darkly\n light: cosmo\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + + let body_tag_end = html + .find("').map(|j| i + j)) + .unwrap(); + let body_tag = &html[html.find(" = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + assert!(html.contains(r#"data-respect-user-color-scheme="true""#)); +} + +/// A4: no dark variant → no runtime script (byte-identity with the +/// pre-feature output is separately guarded by the golden-hash test). +#[test] +fn single_variant_has_no_color_mode_script() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: Plain\nformat:\n html:\n theme: cosmo\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + let html = std::fs::read_to_string(&result.output_path).unwrap(); + assert!(!html.contains("quarto-color-mode")); + assert!(!html.contains("quartoToggleColorScheme")); +} + +/// A4: a website navbar grows the dark-mode toggle (Q1's +/// `quarto-navbar-tools` slot) when a dark variant exists — and only +/// then. +#[test] +fn website_navbar_gets_dark_toggle_only_with_dark_variant() { + let render_site = |theme_yaml: &str| -> String { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("_quarto.yml"), + &format!( + "project:\n type: website\nwebsite:\n navbar:\n left:\n - href: index.qmd\n text: Home\nformat:\n html:\n{theme_yaml}" + ), + ); + write( + &root.join("index.qmd"), + "---\ntitle: Home\n---\n\n# Hello\n", + ); + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("index.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("website page must render"); + std::fs::read_to_string(&result.output_path).unwrap() + }; + + let with_dark = render_site(" theme:\n light: cosmo\n dark: darkly\n"); + assert!( + with_dark.contains(r#"class="quarto-color-scheme-toggle"#), + "navbar must carry the dark-mode toggle when a dark variant exists" + ); + assert!( + with_dark.contains("window.quartoToggleColorScheme(); return false;"), + "toggle anchor must invoke the runtime entry point" + ); + + let without_dark = render_site(" theme: cosmo\n"); + assert!( + !without_dark.contains("quarto-color-scheme-toggle"), + "no dark variant → no toggle" + ); +} + /// D1a bonus: a *single* dark theme (no light/dark pair at all) gets /// `color-scheme: dark` from the darkness sentinel, so existing /// dark-theme users get correct native scrollbars/controls. diff --git a/crates/quarto-navigation/src/navbar.rs b/crates/quarto-navigation/src/navbar.rs index d68a23a6a..df9227e88 100644 --- a/crates/quarto-navigation/src/navbar.rs +++ b/crates/quarto-navigation/src/navbar.rs @@ -126,6 +126,12 @@ pub struct Navbar { pub tools_collapse: bool, pub left: Vec, pub right: Vec, + /// Whether to render the dark-mode toggle in the navbar's tools + /// slot. Not parsed from `navbar:` YAML — set by + /// `NavbarGenerateTransform` when the format has a dark theme + /// variant (bd-0pic6 A4; folds into general `tools:` support when + /// bd-fod3 lands — bd-ld-toggle-into-tools-hpae7m9r). + pub dark_mode_toggle: bool, } impl Navbar { @@ -148,6 +154,7 @@ impl Navbar { tools_collapse: false, left: Vec::new(), right: Vec::new(), + dark_mode_toggle: false, } } @@ -218,6 +225,13 @@ impl Navbar { nav.left = parse_item_list(cv.get("left")); nav.right = parse_item_list(cv.get("right")); + // Internal round-trip field (not authored YAML): written by + // `to_config_value` so the flag survives the + // generate-transform → metadata → render-transform trip. + if let Some(v) = cv.get("dark-mode-toggle").and_then(|v| v.as_bool()) { + nav.dark_mode_toggle = v; + } + nav } @@ -291,6 +305,9 @@ impl Navbar { if !self.right.is_empty() { entries.push(item_list_entry("right", &self.right, &info)); } + if self.dark_mode_toggle { + entries.push(bool_entry("dark-mode-toggle", true, &info)); + } ConfigValue::new_map(entries, info) } diff --git a/crates/quarto-navigation/src/render_html.rs b/crates/quarto-navigation/src/render_html.rs index e819e7183..61cca0e4e 100644 --- a/crates/quarto-navigation/src/render_html.rs +++ b/crates/quarto-navigation/src/render_html.rs @@ -129,6 +129,22 @@ pub fn navbar_to_html( } html.push_str("
\n"); } + + // Navbar tools slot (Q1's `quarto-navbar-tools`). Today it holds + // only the dark-mode toggle, emitted when the format has a dark + // theme variant (bd-0pic6 A4); general `tools:` support folds in + // here later (bd-fod3 / bd-ld-toggle-into-tools-hpae7m9r). Markup + // mirrors Q1's `navdarktoggle.ejs`: the `.alternate` class (synced + // by the color-mode runtime) drives the on/off icon via CSS. + if navbar.dark_mode_toggle { + html.push_str("
\n"); + html.push_str( + " \n", + ); + html.push_str("
\n"); + } html.push_str(" \n"); html.push_str(" \n"); diff --git a/crates/quarto-sass/src/compile.rs b/crates/quarto-sass/src/compile.rs index 93a5d3b5d..be68d89e4 100644 --- a/crates/quarto-sass/src/compile.rs +++ b/crates/quarto-sass/src/compile.rs @@ -1091,6 +1091,31 @@ mod tests { ); } + /// The color-scheme toggle icons are SVG data URIs whose `fill` + /// is produced by the `colorToRGBA()` sass function (ported from + /// Q1's `_quarto-functions.scss`). Because the call sits inside a + /// string interpolation, a missing function does NOT error — sass + /// silently emits the literal call text, producing an invalid SVG + /// fill and an invisible toggle icon (found in the bd-0pic6 A4 + /// browser verification). Guard that the function actually + /// evaluates. + #[test] + fn test_compile_theme_css_evaluates_color_to_rgba_in_toggle_icons() { + let runtime = NativeRuntime::new(); + let themes = vec![ThemeSpec::parse("cosmo").unwrap()]; + let config = ThemeConfig::new(themes, false); + let context = ThemeContext::new(PathBuf::from("/doc"), &runtime); + let css = compile_theme_css(&config, &context).unwrap(); + assert!( + !css.contains("colorToRGBA("), + "colorToRGBA() must be evaluated, not emitted literally" + ); + assert!( + css.contains("fill=rgba(") || css.contains("fill=\"rgba("), + "toggle icon SVG fill must be a concrete rgba() color" + ); + } + #[test] fn test_compile_theme_css_multiple_themes() { let runtime = NativeRuntime::new(); diff --git a/resources/scss/bootstrap/_bootstrap-functions.scss b/resources/scss/bootstrap/_bootstrap-functions.scss index 05d1780c2..e75c193b3 100644 --- a/resources/scss/bootstrap/_bootstrap-functions.scss +++ b/resources/scss/bootstrap/_bootstrap-functions.scss @@ -1,3 +1,13 @@ +// Renders a color as an rgba() string (used to interpolate colors +// into SVG data-URI fills, e.g. the color-scheme toggle icons). +// Ported from Q1's _quarto-functions.scss; without it, the call +// inside a string interpolation silently emits the literal function +// text and the SVG fill is invalid (bd-0pic6 A4). +@function colorToRGBA($color) { + @return "rgba(" + red($color) + ", " + green($color) + ", " + blue($color) + + ", " + alpha($color) + ")"; +} + // Dims a color (either making it more white or more black) @function theme-dim($baseColor, $amount) { @if (tone($baseColor) == "dark") { From 2b1235c32767509ce5cb74c9e13e69357931306a Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 16:15:53 -0500 Subject: [PATCH 06/10] light-dark A5: large-project end-to-end verification (bd-ld-a5-quarto-web-e2e-bzg4o5lc) Plan-doc update recording the A5 verification. Target corrected per Carlos: quarto-web is currently a Quarto 1 project (probe findings recorded in the plan: Q-5-24 alias conflicts incl. an apparent upstream copy-paste bug, Pandoc-style attr order in the footer vs qmd's Q-2-3 rule, plus broad Q1-content gaps); the intended large testbed is the connect-docs project with the posit-docs extension's theme: {light, dark} map. Testbed result: 352/352 files rendered, zero errors, zero Q-14-3. One shared light + one dark fingerprinted artifact deduped across all pages via site_libs; link trio, color-scheme meta, inline runtime, and navbar toggle on every page. Browser-verified: toggle applies the extension's full dark palette (#181c25), persists, and restores. Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index b37d6d769..a92dbb4f3 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -471,10 +471,37 @@ Integration branch: `feature/light-dark-theme` (created off `main`). `.light-content`/`.dark-content` swap); reload restores dark before paint; toggle back to light restores everything (`default` stored); no console errors. 12,145 workspace tests green. -- [ ] **A5 — quarto-web end-to-end**: `bd-ld-a5-quarto-web-e2e-bzg4o5lc`. - Render `external-sources/quarto-web` with the real binary, browser-verify - toggle/persistence/prefers-color-scheme, document gaps found. (related: - bd-r1y48cx0) +- [x] **A5 — large-project end-to-end**: `bd-ld-a5-quarto-web-e2e-bzg4o5lc`. + **Done 2026-08-14.** Rendered the connect-docs testbed + (`~/repos/github/cscheid/q2-connect-docs/docs-quarto-2`, posit-docs + extension theme map) with the real binary: **352 of 352 files, zero + errors, zero Q-14-3** (the interim run printed one warning; now the dark + half compiles). Output verified: exactly one shared light + one dark + fingerprinted artifact in `site_libs/quarto/` (deduped across all 352 + pages), Q1-shaped link trio + `` + + inline runtime + navbar toggle on every page. Browser-verified with + chrome-devtools MCP: toggle → full posit-docs dark palette (body + `#181c25`), root color-scheme dark, localStorage persistence, toggle back + restores light. (Testbed's unrelated known caveat unchanged: the + quarto-openapi Deno-style pre-render was temporarily disabled during the + render — bd-wch2dotq — and restored after.) + **Target corrected 2026-08-14 (Carlos):** quarto-web is currently a Quarto 1 + project and a full q2 render of it is out of scope; the intended large + testbed is `~/repos/github/cscheid/q2-connect-docs/docs-quarto-2` (the + posit-docs extension ships `theme: {light: [theme.scss], dark: + [theme-dark.scss]}` — the same shape, 351 files). quarto-web remains the + *config-shape* reference only. + **quarto-web findings recorded en route** (scratch checkout probed, then + fully restored): (1) two Q-5-24 alias conflicts (`quarto-ast.qmd` aliases + collide with prerelease pages that render there; `placeholder.qmd` claims + `/docs/prerelease/1.5/lipsum.html` which `lipsum.qmd` also claims — the + latter looks like an upstream copy-paste bug); (2) the `_quarto.yml` + page-footer logo images write attrs Pandoc-style (`{fig-alt="…" width=65px + .light-content}`), violating qmd's classes-before-key-values rule (Q-2-3) + and failing every page's profile pass; (3) beyond those, wide Q1-content + gaps (grid tables Q-2-39, shortcode/attr strictness Q-2-9/Q-2-35, missing + relative filter paths) — confirming quarto-web is not a Q2 render target + today. - [ ] **B — highlight-style** (D6 stage 1): `bd-ld-b-highlight-style-jnb036fz`. Reader + a11y light/dark + variant selection; follow-up strand to be filed for the general `.theme` translator. From f3537c98de58a71b3d2c64c8c2c727de7e009427 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 16:32:51 -0500 Subject: [PATCH 07/10] light-dark B: highlight-style reader + a11y palettes (bd-ld-b-highlight-style-jnb036fz) First highlight-style reader in Q2. ThemeConfig parses scalar and {light, dark} forms into per-variant HighlightStyle entries, resolving adaptive names at parse time: a11y becomes a11y-light/a11y-dark. Pair halves resolve by role (the quarto-web shape darkens a cosmo base via custom SCSS, so builtin darkness would mislead); single-variant configs resolve via BuiltInTheme::is_dark (theme: darkly + a11y -> a11y-dark). highlight.scss is split into palette-independent structural rules plus swappable palette files: highlight-default.scss (the original solarized palette, extracted verbatim) and hand-translated highlight-a11y-{light,dark}.scss (from Q1's a11y .theme JSONs onto the tree-sitter hl-* class vocabulary, with palette-level $code-block-bg / $code-block-color defaults that user themes can still override). load_highlight_layer(palette) composes structural + palette; unknown names fall back to default. Correctness guards: every default-CSS shortcut (stage fast path, native/wasm compile_with_doc_vars, native/wasm compile_theme_css) now requires highlight_style.is_none() so a palette never bypasses assembly or poisons the shared DEFAULT_CSS_CACHE, and the stage cache_key gained a palette discriminator. Unknown names warn once per name via new Q-14-5 (catalog entry + docs page). The LSP semantic-token legend parity test now reads the default palette file. Follow-up filed: bd-hl-theme-translator-2mdgh4k6 (general .theme translator for the full Q1 catalog, copy-button color feedback, sentinel-based single-variant resolution). TDD: parse/compile/integration tests red first (incl. the fast-path-bypass case found by writing the no-theme test). E2E via real q2 render inspected (light css #d91e18 / dark css #ffa07a). 12,158 workspace tests green. Part of the light/dark epic (bd-0pic6); plan: claude-notes/plans/2026-08-14-light-dark-theme-epic.md Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 22 +- .../src/stage/stages/compile_theme_css.rs | 68 ++++- crates/quarto-core/src/theme_diagnostic.rs | 2 +- .../tests/integration/theme_light_dark.rs | 170 +++++++++++ .../quarto-error-catalog/error_catalog.json | 7 + crates/quarto-lsp-core/src/types.rs | 12 +- crates/quarto-sass/src/bundle.rs | 67 +++- crates/quarto-sass/src/compile.rs | 95 +++++- crates/quarto-sass/src/config.rs | 287 ++++++++++++++++++ crates/quarto-sass/src/lib.rs | 4 +- .../integration/compile_all_themes_test.rs | 2 +- docs/errors/theme/Q-14-5.qmd | 50 +++ .../html/templates/highlight-a11y-dark.scss | 186 ++++++++++++ .../html/templates/highlight-a11y-light.scss | 186 ++++++++++++ .../html/templates/highlight-default.scss | 150 +++++++++ resources/scss/html/templates/highlight.scss | 149 --------- 16 files changed, 1272 insertions(+), 185 deletions(-) create mode 100644 docs/errors/theme/Q-14-5.qmd create mode 100644 resources/scss/html/templates/highlight-a11y-dark.scss create mode 100644 resources/scss/html/templates/highlight-a11y-light.scss create mode 100644 resources/scss/html/templates/highlight-default.scss diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index a92dbb4f3..17ed16de9 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -502,9 +502,25 @@ Integration branch: `feature/light-dark-theme` (created off `main`). gaps (grid tables Q-2-39, shortcode/attr strictness Q-2-9/Q-2-35, missing relative filter paths) — confirming quarto-web is not a Q2 render target today. -- [ ] **B — highlight-style** (D6 stage 1): `bd-ld-b-highlight-style-jnb036fz`. - Reader + a11y light/dark + variant selection; follow-up strand to be filed - for the general `.theme` translator. +- [x] **B — highlight-style** (D6 stage 1): `bd-ld-b-highlight-style-jnb036fz`. + **Done 2026-08-14.** First `highlight-style` reader in Q2 (in + `ThemeConfig::from_config_value`): scalar + `{light, dark}` map, adaptive + resolution at parse time (`a11y` → `a11y-light`/`a11y-dark`; pair halves + resolve by ROLE — critical for the quarto-web shape where custom SCSS + darkens a cosmo base; single-variant configs resolve by + `BuiltInTheme::is_dark`). `highlight.scss` split into structural rules + + swappable palette files (`highlight-default.scss` + hand-translated + `highlight-a11y-{light,dark}.scss` carrying `$code-block-bg`/`-color` + defaults); `load_highlight_layer(palette)` composes them, unknown → + default. All four default-CSS shortcuts (stage fast path, native/wasm + `compile_with_doc_vars`, native/wasm `compile_theme_css`) guarded so a + highlight style forces direct assembly and never poisons the shared + default cache; stage `cache_key` gained the palette discriminator. + Q-14-5 warning (catalog + docs page) for unknown names, deduped per name. + LSP legend parity test repointed at the palette file. Follow-up filed: + `bd-hl-theme-translator-2mdgh4k6` (general `.theme` translator, full Q1 + catalog, copy-button color feedback, sentinel-based single-variant + resolution). E2E via real binary inspected. 12,158 workspace tests green. - [ ] **C — brand seam** (D7): `bd-ld-c-brand-seam-wef8ww3n`. Absorbs bd-v5z8w; unified-brand split. - [ ] **D — preview/hub-client** (D8): `bd-ld-d-preview-hub-t4oxv0hf`. diff --git a/crates/quarto-core/src/stage/stages/compile_theme_css.rs b/crates/quarto-core/src/stage/stages/compile_theme_css.rs index 5e1c20806..d862331a1 100644 --- a/crates/quarto-core/src/stage/stages/compile_theme_css.rs +++ b/crates/quarto-core/src/stage/stages/compile_theme_css.rs @@ -225,6 +225,15 @@ fn cache_key( hasher.update(doc_vars.defaults.as_bytes()); hasher.update(b"\n"); + // Include the highlight palette (bd-0pic6 phase B): the same theme + // list compiles differently under different `highlight-style` + // values, so the palette name must discriminate cache entries. + hasher.update(b"highlight:"); + if let Some(style) = &theme_config.highlight_style { + hasher.update(style.name.as_bytes()); + } + hasher.update(b"\n"); + // Include minification flag hasher.update(if theme_config.minified { b"1" } else { b"0" }); @@ -385,6 +394,46 @@ impl PipelineStage for CompileThemeCssStage { // artifact. The interim Q-14-3 warning (bd-o76p01wb) is // retired: nothing is ignored anymore. + // Unknown `highlight-style:` names fall back to the default + // palette inside quarto-sass; make the fallback loud with one + // Q-14-5 warning per distinct unknown name (bd-0pic6 phase B — + // same data-then-diagnostic split as the theme codes). + { + let mut warned: Vec<&str> = Vec::new(); + let variant_styles = [ + theme_config.highlight_style.as_ref(), + theme_config + .dark + .as_ref() + .and_then(|d| d.highlight_style.as_ref()), + ]; + for style in variant_styles.into_iter().flatten() { + if quarto_sass::is_known_highlight_palette(&style.name) + || warned.contains(&style.name.as_str()) + { + continue; + } + warned.push(style.name.as_str()); + let mut builder = quarto_error_reporting::DiagnosticMessageBuilder::warning( + "Unknown highlight style", + ) + .with_code("Q-14-5") + .problem(format!( + "`highlight-style: {}` does not name a highlight palette \ + shipped with Quarto; the default palette is used instead.", + style.name + )) + .add_hint(format!( + "Available palettes: {}.", + quarto_sass::KNOWN_HIGHLIGHT_PALETTES.join(", ") + )); + if let Some(loc) = &style.location { + builder = builder.with_location(loc.clone()); + } + ctx.add_diagnostic(builder.build()); + } + } + // Per-document SCSS variables layer (Phase 2 of bd-k8y0), // shared by both variants. Today this is just // `$sidebar-border` from `website.sidebar.style`; the same @@ -478,13 +527,18 @@ async fn variant_css( return Ok(DEFAULT_CSS.to_string()); } - // Fast path: no themes, no doc-derived variables, and the - // default layer set (title-block layer included). Use the - // shared, cached default-CSS bundle. This preserves byte-identity - // with prior behavior for plain documents (no website / no sidebar). - // `title-block-style: plain|none` docs take the fingerprinted - // path below so their layer-less bundle gets its own cache key. - if !variant_config.has_themes() && doc_vars.is_empty() && variant_config.title_block_layer { + // Fast path: no themes, no doc-derived variables, no + // highlight-style, and the default layer set (title-block layer + // included). Use the shared, cached default-CSS bundle. This + // preserves byte-identity with prior behavior for plain documents + // (no website / no sidebar). `title-block-style: plain|none` docs + // and `highlight-style:` docs take the fingerprinted path below so + // their non-default bundles get their own cache keys. + if !variant_config.has_themes() + && doc_vars.is_empty() + && variant_config.title_block_layer + && variant_config.highlight_style.is_none() + { // Try the runtime cache first (cross-session persistence). if cache_ok && let Ok(Some(cached)) = cache_get_lru( diff --git a/crates/quarto-core/src/theme_diagnostic.rs b/crates/quarto-core/src/theme_diagnostic.rs index f1eb33c58..ded4e71a8 100644 --- a/crates/quarto-core/src/theme_diagnostic.rs +++ b/crates/quarto-core/src/theme_diagnostic.rs @@ -294,7 +294,7 @@ mod tests { // (Q-14-3, the interim dark-theme-ignored warning from // bd-o76p01wb, was retired when dual light/dark compilation // landed — bd-0pic6 phase A2.) - for code in ["Q-14-1", "Q-14-2", "Q-14-4"] { + for code in ["Q-14-1", "Q-14-2", "Q-14-4", "Q-14-5"] { let info = quarto_error_catalog::ERROR_CATALOG.get(code); assert!( info.is_some(), diff --git a/crates/quarto-core/tests/integration/theme_light_dark.rs b/crates/quarto-core/tests/integration/theme_light_dark.rs index 4d0101120..8a0edcd3a 100644 --- a/crates/quarto-core/tests/integration/theme_light_dark.rs +++ b/crates/quarto-core/tests/integration/theme_light_dark.rs @@ -604,6 +604,176 @@ fn website_navbar_gets_dark_toggle_only_with_dark_variant() { ); } +/// Phase B: `highlight-style: a11y` selects the variant-matching +/// palette in each compile — a11y-light colors in the light CSS, +/// a11y-dark colors (and its code-block background) in the dark CSS. +#[test] +fn highlight_style_a11y_selects_palette_per_variant() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: HL\nformat:\n html:\n theme:\n light: cosmo\n dark: darkly\nhighlight-style: a11y\n---\n\n```python\nprint('hi')\n```\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + + let files = css_files(&result.resources_dir); + let light = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles.css")) + .expect("light css"); + let dark = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles-dark.css")) + .expect("dark css"); + assert!( + light.1.contains("#d91e18"), + "light variant must use the a11y-light keyword color" + ); + assert!( + !light.1.contains("#859900"), + "light variant must not keep the solarized keyword color" + ); + assert!( + dark.1.contains("#ffa07a"), + "dark variant must use the a11y-dark keyword color" + ); + assert!( + dark.1.contains("#2b2b2b"), + "dark variant must apply a11y-dark's code-block background" + ); + + assert!( + !result + .render_output + .diagnostics + .iter() + .any(|d| d.code.as_deref() == Some("Q-14-5")), + "known adaptive style must not warn" + ); +} + +/// Phase B: a single dark built-in theme resolves the adaptive name +/// to the dark palette (Q1's sentinel-driven behavior, approximated +/// statically via BuiltInTheme::is_dark). +#[test] +fn highlight_style_adaptive_follows_single_dark_theme() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: HL\nformat:\n html:\n theme: darkly\nhighlight-style: a11y\n---\n\n```python\nprint('hi')\n```\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + + let files = css_files(&result.resources_dir); + let styles = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles.css")) + .expect("styles.css"); + assert!( + styles.1.contains("#ffa07a"), + "theme: darkly + a11y must select the a11y-dark palette" + ); +} + +/// Phase B: `highlight-style` must apply even with NO theme configured +/// (the default-Bootstrap fast path must not bypass palette +/// selection). +#[test] +fn highlight_style_applies_without_theme() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: HL\nhighlight-style: a11y\n---\n\n```python\nprint('hi')\n```\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("must render"); + + let files = css_files(&result.resources_dir); + assert!( + files.iter().any(|(_, css)| css.contains("#d91e18")), + "a11y-light palette must apply to the default-Bootstrap compile" + ); +} + +/// Phase B: an unknown highlight-style warns (Q-14-5) and falls back +/// to the default palette. +#[test] +fn unknown_highlight_style_warns_and_uses_default() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("doc.qmd"), + "---\ntitle: HL\nformat:\n html:\n theme: cosmo\nhighlight-style: nosuchstyle\n---\n\n```python\nprint('hi')\n```\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("unknown highlight-style must not fail the render"); + + let files = css_files(&result.resources_dir); + assert!( + files.iter().any(|(_, css)| css.contains("#859900")), + "unknown style must fall back to the default (solarized) palette" + ); + + let q14_5: Vec<_> = result + .render_output + .diagnostics + .iter() + .filter(|d| d.code.as_deref() == Some("Q-14-5")) + .collect(); + assert_eq!( + q14_5.len(), + 1, + "expected exactly one Q-14-5 warning, diagnostics: {:?}", + result.render_output.diagnostics + ); + assert!(q14_5[0].location.is_some(), "warning carries a location"); +} + /// D1a bonus: a *single* dark theme (no light/dark pair at all) gets /// `color-scheme: dark` from the darkness sentinel, so existing /// dark-theme users get correct native scrollbars/controls. diff --git a/crates/quarto-error-catalog/error_catalog.json b/crates/quarto-error-catalog/error_catalog.json index 0062f989b..05686ca0e 100644 --- a/crates/quarto-error-catalog/error_catalog.json +++ b/crates/quarto-error-catalog/error_catalog.json @@ -1140,6 +1140,13 @@ "docs_url": "https://quarto.org/docs/errors/theme/Q-14-4", "since_version": "99.9.9" }, + "Q-14-5": { + "subsystem": "theme", + "title": "Unknown highlight style", + "message_template": "The `highlight-style:` value does not name a syntax-highlight palette shipped with Quarto; the default palette is used instead. Available palettes: default, a11y (adaptive), a11y-light, a11y-dark.", + "docs_url": "https://quarto.org/docs/errors/theme/Q-14-5", + "since_version": "99.9.9" + }, "Q-15-1": { "subsystem": "crossref", "title": "Duplicate Crossref Identifier", diff --git a/crates/quarto-lsp-core/src/types.rs b/crates/quarto-lsp-core/src/types.rs index 2a0011922..e039ced68 100644 --- a/crates/quarto-lsp-core/src/types.rs +++ b/crates/quarto-lsp-core/src/types.rs @@ -623,7 +623,7 @@ pub struct SemanticTokensJson { /// names stay unprefixed; [`capture_to_token_type`] adds the prefix. /// /// The `qmd.code.*` group **must** mirror the `.hl-*` roots in -/// `resources/scss/html/templates/highlight.scss` (24 roots) so editor and +/// `resources/scss/html/templates/highlight-default.scss` (24 roots) so editor and /// render colour the same captures — pinned by the `code_legend_covers_render_css` /// test (Phase 7, Defence 3). pub static QMD_TOKEN_LEGEND: &[&str] = &[ @@ -651,7 +651,7 @@ pub static QMD_TOKEN_LEGEND: &[&str] = &[ "qmd.punctuation.delimiter.fence", "qmd.punctuation.delimiter.frontmatter", "qmd.attribute.specifier", - // --- embedded code (the 24 `hl-*` roots in highlight.scss) --- + // --- embedded code (the 24 `hl-*` roots in highlight-default.scss) --- "qmd.code.attribute", "qmd.code.boolean", "qmd.code.character", @@ -883,9 +883,15 @@ mod tests { // the editor on the `code.*` legend. A root present in one but not the // other is a silent parity break, so lock them together: the set of // `code.` legend roots must equal the set of `.hl-` roots. + // + // The color rules live in the DEFAULT palette file — the + // light/dark epic's phase B (bd-0pic6) split the palette out + // of the structural `highlight.scss` so `highlight-style:` + // can swap it. Every shipped palette styles the same class + // vocabulary, so checking the default one suffices. const SCSS: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../resources/scss/html/templates/highlight.scss" + "/../../resources/scss/html/templates/highlight-default.scss" )); // CSS roots: the first hyphen-segment after `.hl-` in each selector. diff --git a/crates/quarto-sass/src/bundle.rs b/crates/quarto-sass/src/bundle.rs index a891893b5..8fa521693 100644 --- a/crates/quarto-sass/src/bundle.rs +++ b/crates/quarto-sass/src/bundle.rs @@ -207,26 +207,75 @@ pub fn load_title_block_layer() -> Result { parse_layer(content, Some("title-block.scss")) } -/// Load the default syntax-highlight SCSS layer. +/// The syntax-highlight palettes shipped with Quarto 2 (bd-0pic6 +/// phase B). `default` is the solarized-inspired original; the a11y +/// pair is hand-translated from Q1's `.theme` files. The general +/// `.theme`-translator follow-up grows this set. +pub const KNOWN_HIGHLIGHT_PALETTES: &[&str] = &["default", "a11y-light", "a11y-dark"]; + +/// Whether `name` is a shipped highlight palette. +/// [`load_highlight_layer`] falls back to `default` for unknown +/// names; `CompileThemeCssStage` uses this to emit the user-facing +/// warning for that fallback. +pub fn is_known_highlight_palette(name: &str) -> bool { + KNOWN_HIGHLIGHT_PALETTES.contains(&name) +} + +/// Load the syntax-highlight SCSS layer for a palette. /// -/// Reads `highlight.scss` from the embedded templates directory. The -/// layer provides color rules for every `.hl-` class the HTML -/// writer can produce from a tree-sitter grammar's `highlights.scm` -/// query (see `claude-notes/plans/2026-04-19-syntax-highlighting-design.md`). +/// The layer combines two embedded files: +/// - `highlight.scss` — palette-independent structural rules +/// (`pre > code` display, white-space, sourceCode margins); +/// - `highlight-.scss` — the `.hl-` color rules +/// (see `claude-notes/plans/2026-04-19-syntax-highlighting-design.md` +/// for the class vocabulary), plus palette-level `$code-block-bg` / +/// `$code-block-color` defaults for the non-default palettes. +/// +/// `None` and unknown names load the `default` palette (the caller +/// warns for unknown names — quarto-sass stays diagnostics-free). /// /// Included as a user-layer — after Bootstrap / Quarto defaults, before /// user-supplied theme overrides — so user themes can redefine any /// `.hl-*` class without touching markup. -pub fn load_highlight_layer() -> Result { +pub fn load_highlight_layer(palette: Option<&str>) -> Result { use crate::resources::TEMPLATES_RESOURCES; - let content = TEMPLATES_RESOURCES + let palette = match palette { + Some(name) if is_known_highlight_palette(name) => name, + _ => "default", + }; + + let structural = TEMPLATES_RESOURCES .read_str(Path::new("highlight.scss")) .ok_or_else(|| SassError::CompilationFailed { message: "highlight.scss not found in templates resources".to_string(), })?; + let palette_file = format!("highlight-{palette}.scss"); + let palette_content = TEMPLATES_RESOURCES + .read_str(Path::new(&palette_file)) + .ok_or_else(|| SassError::CompilationFailed { + message: format!("{palette_file} not found in templates resources"), + })?; + + let structural_layer = parse_layer(structural, Some("highlight.scss"))?; + let palette_layer = parse_layer(palette_content, Some(&palette_file))?; + Ok(SassLayer { + uses: join_band(&structural_layer.uses, &palette_layer.uses), + defaults: join_band(&structural_layer.defaults, &palette_layer.defaults), + functions: join_band(&structural_layer.functions, &palette_layer.functions), + mixins: join_band(&structural_layer.mixins, &palette_layer.mixins), + rules: join_band(&structural_layer.rules, &palette_layer.rules), + }) +} - parse_layer(content, Some("highlight.scss")) +/// Concatenate two optional layer bands, preserving `structural` +/// first. +fn join_band(a: &str, b: &str) -> String { + match (a.is_empty(), b.is_empty()) { + (true, _) => b.to_string(), + (_, true) => a.to_string(), + (false, false) => format!("{a}\n{b}"), + } } /// Load the default code-copy-button SCSS layer. @@ -452,7 +501,7 @@ pub fn assemble_reveal_scss(theme_layers: &[SassLayer]) -> Result Result { use quarto_system_runtime::sass_native::compile_scss_with_embedded; - if !config.has_themes() { - // No custom themes - use default Bootstrap + if !config.has_themes() && config.highlight_style.is_none() { + // No custom themes and default palette - use default Bootstrap return compile_default_css(context.runtime(), config.minified); } @@ -219,7 +220,11 @@ pub fn compile_with_doc_vars( if config.has_themes() { return compile_theme_css(config, context); } - if config.title_block_layer { + // The shared default bundle is palette-agnostic; a + // `highlight-style:` (bd-0pic6 phase B) needs a direct + // assembly so its palette layer composes (and so the OnceLock + // cache never holds a non-default palette). + if config.title_block_layer && config.highlight_style.is_none() { return compile_default_css(context.runtime(), config.minified); } } @@ -229,7 +234,8 @@ pub fn compile_with_doc_vars( // matching `compile_default_css` and `assemble_theme_scss`, then any // theme layers, then doc_vars LAST so it lands at the top of the // merged-defaults section and wins the `!default` race. - let highlight_layer = load_highlight_layer()?; + let highlight_layer = + load_highlight_layer(config.highlight_style.as_ref().map(|h| h.name.as_str()))?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; let listing_layer = load_listing_layer()?; @@ -369,7 +375,7 @@ pub fn compile_default_css( // Load built-in user layers: title block styling + default syntax- // highlight colors. Both ship with Quarto and are always included. let title_block_layer = load_title_block_layer()?; - let highlight_layer = load_highlight_layer()?; + let highlight_layer = load_highlight_layer(None)?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; let listing_layer = load_listing_layer()?; @@ -467,8 +473,8 @@ pub async fn compile_theme_css( config: &ThemeConfig, context: &ThemeContext<'_>, ) -> Result { - if !config.has_themes() { - // No custom themes - use default Bootstrap + if !config.has_themes() && config.highlight_style.is_none() { + // No custom themes and default palette - use default Bootstrap return compile_default_css(context.runtime(), config.minified).await; } @@ -505,12 +511,15 @@ pub async fn compile_with_doc_vars( if config.has_themes() { return compile_theme_css(config, context).await; } - if config.title_block_layer { + // See the native variant: a `highlight-style:` needs a direct + // assembly so its palette layer composes. + if config.title_block_layer && config.highlight_style.is_none() { return compile_default_css(context.runtime(), config.minified).await; } } - let highlight_layer = load_highlight_layer()?; + let highlight_layer = + load_highlight_layer(config.highlight_style.as_ref().map(|h| h.name.as_str()))?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; let listing_layer = load_listing_layer()?; @@ -608,7 +617,7 @@ pub async fn compile_default_css( // entry would render code blocks with `hl-*` span classes but no // associated colors. let title_block_layer = load_title_block_layer()?; - let highlight_layer = load_highlight_layer()?; + let highlight_layer = load_highlight_layer(None)?; let embed_example_layer = load_embed_example_layer()?; let copy_code_layer = load_copy_code_layer()?; let listing_layer = load_listing_layer()?; @@ -1116,6 +1125,70 @@ mod tests { ); } + /// bd-0pic6 phase B: `highlight-style` selects the `.hl-*` palette + /// composed into the compile. `a11y-light` replaces the default + /// (solarized) palette; unknown names fall back to the default + /// (the stage warns separately). + #[test] + fn test_compile_theme_css_a11y_light_palette() { + let runtime = NativeRuntime::new(); + let mut config = ThemeConfig::new(vec![ThemeSpec::parse("cosmo").unwrap()], false); + config.highlight_style = Some(crate::config::HighlightStyle { + name: "a11y-light".to_string(), + location: None, + }); + let context = ThemeContext::new(PathBuf::from("/doc"), &runtime); + let css = compile_theme_css(&config, &context).unwrap(); + assert!( + css.contains("#d91e18"), + "a11y-light keyword color must be present" + ); + assert!( + !css.contains("#859900"), + "solarized keyword color must be replaced" + ); + // Structural code rules stay regardless of palette. + assert!(css.contains("pre > code")); + } + + #[test] + fn test_compile_theme_css_a11y_dark_palette() { + let runtime = NativeRuntime::new(); + let mut config = ThemeConfig::new(vec![ThemeSpec::parse("cosmo").unwrap()], false); + config.highlight_style = Some(crate::config::HighlightStyle { + name: "a11y-dark".to_string(), + location: None, + }); + let context = ThemeContext::new(PathBuf::from("/doc"), &runtime); + let css = compile_theme_css(&config, &context).unwrap(); + assert!( + css.contains("#ffa07a"), + "a11y-dark keyword color must be present" + ); + // The palette's $code-block-bg default flows into the + // code-block background rule. + assert!( + css.contains("#2b2b2b"), + "a11y-dark code-block background must apply" + ); + } + + #[test] + fn test_compile_theme_css_unknown_palette_falls_back_to_default() { + let runtime = NativeRuntime::new(); + let mut config = ThemeConfig::new(vec![ThemeSpec::parse("cosmo").unwrap()], false); + config.highlight_style = Some(crate::config::HighlightStyle { + name: "nosuchstyle".to_string(), + location: None, + }); + let context = ThemeContext::new(PathBuf::from("/doc"), &runtime); + let css = compile_theme_css(&config, &context).unwrap(); + assert!( + css.contains("#859900"), + "unknown style must fall back to the default (solarized) palette" + ); + } + #[test] fn test_compile_theme_css_multiple_themes() { let runtime = NativeRuntime::new(); diff --git a/crates/quarto-sass/src/config.rs b/crates/quarto-sass/src/config.rs index 8a9029aa0..f323490fe 100644 --- a/crates/quarto-sass/src/config.rs +++ b/crates/quarto-sass/src/config.rs @@ -112,6 +112,15 @@ pub struct ThemeConfig { /// (`minified`, `title_block_layer`, `brand_ref`) are not /// duplicated here. pub dark: Option, + + /// Resolved syntax-highlight palette for THIS (light) variant, + /// from the `highlight-style:` key (bd-0pic6 phase B). Adaptive + /// names are resolved at parse time: a scalar `a11y` becomes + /// `a11y-light` here and `a11y-dark` on [`DarkThemeConfig`]; for a + /// single-variant config the palette follows the built-in themes' + /// darkness (`theme: darkly` + `a11y` → `a11y-dark`). `None` → + /// the default palette. + pub highlight_style: Option, } /// The parsed `dark:` half of a `theme: {light: …, dark: …}` pair @@ -145,6 +154,29 @@ pub struct DarkThemeConfig { /// Location of the `dark:` key itself, for diagnostics that need /// to point at the dark half as a whole. pub key_location: Option, + + /// Resolved syntax-highlight palette for the dark variant (from + /// `highlight-style:`, adaptive names already resolved — see + /// [`ThemeConfig::highlight_style`]). `None` → the default + /// palette. + pub highlight_style: Option, +} + +/// A resolved syntax-highlight palette request for one variant +/// (bd-0pic6 phase B). +/// +/// `name` is the palette identifier after adaptive resolution +/// (`a11y` → `a11y-light` / `a11y-dark` depending on the variant's +/// darkness). The name is NOT validated here — quarto-sass carries it +/// as data; the compile falls back to the default palette for unknown +/// names and `CompileThemeCssStage` emits the user-facing warning +/// (same division of labor as the theme diagnostics). +#[derive(Debug, Clone, PartialEq)] +pub struct HighlightStyle { + pub name: String, + /// Location of the YAML value that produced this name, for + /// diagnostics. + pub location: Option, } /// Resolved form of [`ThemeConfig`] with the brand file loaded and @@ -181,6 +213,7 @@ impl ThemeConfig { title_block_layer: true, brand_ref: None, dark: None, + highlight_style: None, } } @@ -197,6 +230,7 @@ impl ThemeConfig { title_block_layer: true, brand_ref: None, dark: None, + highlight_style: None, } } @@ -264,6 +298,7 @@ impl ThemeConfig { suppress_bootstrap: dark_cfg.suppress_bootstrap, is_default: pair.dark_first, key_location: Some(key_source), + highlight_style: None, }) } None => None, @@ -327,6 +362,10 @@ impl ThemeConfig { None => {} } + // `highlight-style:` (bd-0pic6 phase B) — after theme parsing + // so adaptive-name resolution can consult the variants. + parse_highlight_style(config, &mut result)?; + Ok(result) } @@ -351,6 +390,7 @@ impl ThemeConfig { title_block_layer: true, brand_ref: None, dark: None, + highlight_style: None, }); } let located = extract_theme_specs(value)?; @@ -366,6 +406,7 @@ impl ThemeConfig { title_block_layer: true, brand_ref: None, dark: None, + highlight_style: None, }) } @@ -447,6 +488,7 @@ impl ThemeConfig { title_block_layer: self.title_block_layer, brand_ref: self.brand_ref.clone(), dark: None, + highlight_style: d.highlight_style.clone(), }) } @@ -504,6 +546,7 @@ pub fn resolve_brand( title_block_layer: true, brand_ref: Some(brand_ref), dark: None, + highlight_style: None, } .resolve(runtime, base_dir)?; @@ -705,6 +748,118 @@ fn brand_err(e: quarto_brand::BrandError) -> SassError { } } +/// Adaptive highlight styles: bare names that resolve to a +/// variant-specific palette (Q1 ships `-light.theme` / +/// `-dark.theme` pairs for these). Stage-1 curated set +/// (bd-0pic6 phase B); the general `.theme`-translator follow-up +/// grows this list. +const ADAPTIVE_HIGHLIGHT_STYLES: &[&str] = &["a11y"]; + +/// Resolve an adaptive highlight-style name for a variant's darkness; +/// non-adaptive names pass through unchanged (unknown ones fall back +/// to the default palette at compile time, with a stage-side warning). +fn resolve_adaptive_highlight(name: &str, dark: bool) -> String { + if ADAPTIVE_HIGHLIGHT_STYLES.contains(&name) { + format!("{name}-{}", if dark { "dark" } else { "light" }) + } else { + name.to_string() + } +} + +/// Darkness of a variant judged by its built-in themes (any dark +/// Bootswatch theme ⇒ dark), falling back to `fallback` when the list +/// has no built-ins (custom-SCSS-only variants can't be judged +/// statically — Q1 greps the compiled CSS's darkness sentinel, which +/// isn't available before the compile this decision feeds). +fn builtin_darkness(themes: &[ThemeSpec], fallback: bool) -> bool { + let mut saw_builtin = false; + let mut any_dark = false; + for spec in themes { + if let ThemeSpec::BuiltIn(b) = spec { + saw_builtin = true; + any_dark |= b.is_dark(); + } + } + if saw_builtin { any_dark } else { fallback } +} + +/// Parse the `highlight-style:` key (scalar or `{light:, dark:}` map) +/// into per-variant [`HighlightStyle`] entries on `result` +/// (bd-0pic6 phase B). +/// +/// Adaptive-name resolution: with a theme pair, each half's ROLE +/// decides (the quarto-web shape darkens a cosmo base via custom +/// SCSS, so built-in darkness would mislead); for a single-variant +/// config the built-in themes decide (`theme: darkly` + `a11y` → +/// `a11y-dark`). +fn parse_highlight_style(config: &ConfigValue, result: &mut ThemeConfig) -> Result<(), SassError> { + let Some(value) = config.get("highlight-style") else { + return Ok(()); + }; + if value.is_null() { + return Ok(()); + } + + let has_pair = result.dark.is_some(); + let light_is_dark = if has_pair { + false + } else { + builtin_darkness(&result.themes, false) + }; + + if let Some(pair) = light_dark_pair(value) { + if let Some(light_value) = pair.light { + let Some(name) = config_value_as_text(light_value) else { + return Err(SassError::InvalidThemeConfig { + message: "`highlight-style:` entries must be strings".to_string(), + location: Some(light_value.source_info.clone()), + }); + }; + result.highlight_style = Some(HighlightStyle { + name: resolve_adaptive_highlight(&name, light_is_dark), + location: Some(light_value.source_info.clone()), + }); + } + if let Some((dark_value, _key_source)) = pair.dark { + let Some(name) = config_value_as_text(dark_value) else { + return Err(SassError::InvalidThemeConfig { + message: "`highlight-style:` entries must be strings".to_string(), + location: Some(dark_value.source_info.clone()), + }); + }; + // A dark highlight palette needs a dark theme variant to + // ride on; without one it has no compile to affect. + if let Some(dark_half) = result.dark.as_mut() { + dark_half.highlight_style = Some(HighlightStyle { + name: resolve_adaptive_highlight(&name, true), + location: Some(dark_value.source_info.clone()), + }); + } + } + return Ok(()); + } + + let Some(name) = config_value_as_text(value) else { + return Err(SassError::InvalidThemeConfig { + message: "`highlight-style:` must be a string or a map with only \ + `light:`/`dark:` keys" + .to_string(), + location: Some(value.source_info.clone()), + }); + }; + result.highlight_style = Some(HighlightStyle { + name: resolve_adaptive_highlight(&name, light_is_dark), + location: Some(value.source_info.clone()), + }); + if let Some(dark_half) = result.dark.as_mut() { + dark_half.highlight_style = Some(HighlightStyle { + name: resolve_adaptive_highlight(&name, true), + location: Some(value.source_info.clone()), + }); + } + Ok(()) +} + /// The recognized halves of a `theme: {light: …, dark: …}` map. /// /// Produced by [`light_dark_pair`]; consumed by @@ -1801,6 +1956,138 @@ mod tests { assert!(!cfg.ships_bootstrap()); } + // === highlight-style tests (bd-0pic6 phase B) === + + /// Root config `{ theme: , highlight-style: }`. + fn config_with_theme_and_highlight(theme: ConfigValue, highlight: ConfigValue) -> ConfigValue { + map_value(vec![ + map_entry("theme", theme), + map_entry("highlight-style", highlight), + ]) + } + + #[test] + fn test_highlight_style_scalar_adaptive_resolves_per_variant() { + // Q1's adaptive names: a scalar `a11y` resolves to the + // variant-matching palette on each half of a theme pair. + let theme = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let cfg = ThemeConfig::from_config_value(&config_with_theme_and_highlight( + theme, + scalar_value("a11y"), + )) + .unwrap(); + + assert_eq!( + cfg.highlight_style.as_ref().map(|h| h.name.as_str()), + Some("a11y-light") + ); + let dark = cfg.dark.as_ref().unwrap(); + assert_eq!( + dark.highlight_style.as_ref().map(|h| h.name.as_str()), + Some("a11y-dark") + ); + } + + #[test] + fn test_highlight_style_map_form_per_half() { + let theme = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let highlight = map_value(vec![ + map_entry("light", scalar_value("a11y")), + map_entry("dark", scalar_value("othername")), + ]); + let cfg = + ThemeConfig::from_config_value(&config_with_theme_and_highlight(theme, highlight)) + .unwrap(); + + // The light half of the map resolves adaptively for the light + // variant; the dark half's raw (non-adaptive) name is carried + // as-is (unknown names fall back at compile time + warn). + assert_eq!( + cfg.highlight_style.as_ref().map(|h| h.name.as_str()), + Some("a11y-light") + ); + assert_eq!( + cfg.dark + .as_ref() + .unwrap() + .highlight_style + .as_ref() + .map(|h| h.name.as_str()), + Some("othername") + ); + } + + #[test] + fn test_highlight_style_single_dark_builtin_resolves_dark() { + // No theme pair: the adaptive palette follows the built-in + // theme's darkness (theme: darkly → a11y-dark), Q1's + // sentinel-driven behavior approximated via + // BuiltInTheme::is_dark. + let cfg = ThemeConfig::from_config_value(&config_with_theme_and_highlight( + scalar_value("darkly"), + scalar_value("a11y"), + )) + .unwrap(); + assert_eq!( + cfg.highlight_style.as_ref().map(|h| h.name.as_str()), + Some("a11y-dark") + ); + assert!(cfg.dark.is_none()); + + let cfg = ThemeConfig::from_config_value(&config_with_theme_and_highlight( + scalar_value("cosmo"), + scalar_value("a11y"), + )) + .unwrap(); + assert_eq!( + cfg.highlight_style.as_ref().map(|h| h.name.as_str()), + Some("a11y-light") + ); + } + + #[test] + fn test_highlight_style_absent_is_none() { + let cfg = ThemeConfig::from_config_value(&config_with_theme_value(scalar_value("cosmo"))) + .unwrap(); + assert!(cfg.highlight_style.is_none()); + } + + #[test] + fn test_highlight_style_unknown_name_carried_with_location() { + let cfg = ThemeConfig::from_config_value(&config_with_theme_and_highlight( + scalar_value("cosmo"), + scalar_value("nosuchstyle"), + )) + .unwrap(); + let hl = cfg.highlight_style.as_ref().expect("carried"); + assert_eq!(hl.name, "nosuchstyle"); + assert!(hl.location.is_some(), "location carried for diagnostics"); + } + + #[test] + fn test_dark_variant_carries_highlight_style() { + let theme = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let cfg = ThemeConfig::from_config_value(&config_with_theme_and_highlight( + theme, + scalar_value("a11y"), + )) + .unwrap(); + let dark_cfg = cfg.dark_variant().unwrap(); + assert_eq!( + dark_cfg.highlight_style.as_ref().map(|h| h.name.as_str()), + Some("a11y-dark") + ); + } + #[test] fn test_resolve_carries_dark_through() { // ThemeConfig::resolve must not drop the dark half — the diff --git a/crates/quarto-sass/src/lib.rs b/crates/quarto-sass/src/lib.rs index 89e187f33..7f6caf0df 100644 --- a/crates/quarto-sass/src/lib.rs +++ b/crates/quarto-sass/src/lib.rs @@ -54,6 +54,7 @@ pub const SCSS_RESOURCES_HASH: &str = pub const CSS_BUILD_ID: &str = include_str!(concat!(env!("OUT_DIR"), "/css_build_id.txt")); pub use brand_layer::brand_to_layers; +pub use bundle::{KNOWN_HIGHLIGHT_PALETTES, is_known_highlight_palette}; pub use bundle::{ REVEAL_BUILTIN_THEMES, assemble_bootstrap, assemble_reveal_scss, assemble_scss, assemble_themes, assemble_with_theme, assemble_with_user_layers, load_bootstrap_framework, @@ -65,7 +66,8 @@ pub use compile::{ compile_theme_css, compile_with_doc_vars, }; pub use config::{ - DarkThemeConfig, ResolvedThemeConfig, ThemeConfig, resolve_brand, resolve_brand_layers, + DarkThemeConfig, HighlightStyle, ResolvedThemeConfig, ThemeConfig, resolve_brand, + resolve_brand_layers, }; pub use error::SassError; pub use layer::{merge_layers, parse_layer, parse_layer_from_parts}; diff --git a/crates/quarto-sass/tests/integration/compile_all_themes_test.rs b/crates/quarto-sass/tests/integration/compile_all_themes_test.rs index a36c0fff9..0b1202f90 100644 --- a/crates/quarto-sass/tests/integration/compile_all_themes_test.rs +++ b/crates/quarto-sass/tests/integration/compile_all_themes_test.rs @@ -251,7 +251,7 @@ fn test_compiled_css_resets_source_code_pre_margin() { // passes it as an always-present user layer. Mirror that here. use quarto_sass::bundle::{assemble_with_user_layers, load_highlight_layer}; - let highlight = load_highlight_layer().expect("highlight layer should load"); + let highlight = load_highlight_layer(None).expect("highlight layer should load"); let scss = assemble_with_user_layers(&[highlight]).expect("assembly should succeed"); let load_paths = default_load_paths(); diff --git a/docs/errors/theme/Q-14-5.qmd b/docs/errors/theme/Q-14-5.qmd new file mode 100644 index 000000000..389318ad7 --- /dev/null +++ b/docs/errors/theme/Q-14-5.qmd @@ -0,0 +1,50 @@ +--- +title: "Unknown highlight style" +description: "The `highlight-style:` value does not name a syntax-highlight palette shipped with Quarto; the default palette is used instead." +code: Q-14-5 +subsystem: theme +status: stub +since: "99.9.9" +categories: + - theme +--- + +# `Q-14-5` — Unknown Highlight Style + +> The `highlight-style:` value does not name a syntax-highlight +> palette shipped with Quarto; the default palette is used +> instead. + +## What this means + +The `highlight-style:` option selects the color palette used for +syntax highlighting in code blocks. The configured name is not one +of the palettes shipped with Quarto, so the document renders with +the default palette. This is a warning, not an error — the render +completes. + +## Why this happens + +Common causes: + +- **A typo in the style name.** +- **A Quarto 1 style that has not yet been ported.** Quarto's + highlighting is tree-sitter based, and its palettes are being + ported incrementally. Currently available: `default`, `a11y` + (adaptive — resolves to a light or dark variant per theme), + `a11y-light`, and `a11y-dark`. + +## How to fix + +Pick one of the available palettes, or remove `highlight-style:` +to use the default. The adaptive form pairs naturally with a +light/dark theme configuration: + +```yaml +format: + html: + theme: + light: cosmo + dark: darkly +highlight-style: a11y +``` diff --git a/resources/scss/html/templates/highlight-a11y-dark.scss b/resources/scss/html/templates/highlight-a11y-dark.scss new file mode 100644 index 000000000..fa9d9db5a --- /dev/null +++ b/resources/scss/html/templates/highlight-a11y-dark.scss @@ -0,0 +1,186 @@ +/*-- scss:defaults --*/ + +// a11y-dark's canvas: applied to code blocks via the existing +// $code-block-bg color branch in _bootstrap-rules.scss. `!default` +// so user theme layers can still override. +$code-block-bg: #2b2b2b !default; +$code-block-color: #f8f8f2 !default; + +/*-- scss:rules --*/ + +// a11y-dark syntax-highlight palette (bd-0pic6 phase B). +// +// Hand-translated from Quarto 1's +// `resources/pandoc/highlight-styles/a11y-dark.theme` (Eric Bailey's +// a11y-dark, WCAG AA against #2b2b2b) onto Quarto 2's tree-sitter +// `.hl-*` class vocabulary. Same token mapping as +// `highlight-a11y-light.scss`: +// +// Keyword/ControlFlow → keywords #ffa07a +// String/Char → strings #abe338 +// SpecialChar → escapes #00e0e0 +// DecVal/BaseN → numbers #dcc6e0 +// Constant → constants #ffa07a +// Comment → comments #d4d0ab +// Function → functions #ffd700 +// DataType → types #dcc6e0 +// Variable → variables #f5ab35 +// Attribute → properties #ffd700 +// Operator → operators #00e0e0 +// Normal → punctuation #f8f8f2 + +// -- Keywords ------------------------------------------------------------- + +.hl-keyword, +.hl-keyword-control, +.hl-keyword-operator, +.hl-keyword-directive, +.hl-keyword-function, +.hl-keyword-storage, +.hl-keyword-conditional, +.hl-keyword-repeat { + color: #ffa07a; +} + +// -- Strings / characters / escapes --------------------------------------- + +.hl-string, +.hl-string-regexp, +.hl-string-special, +.hl-string-special-symbol, +.hl-character { + color: #abe338; +} + +.hl-string-escape, +.hl-escape { + color: #00e0e0; +} + +// -- Numbers / booleans / constants -------------------------------------- + +.hl-number, +.hl-constant-numeric { + color: #dcc6e0; +} + +.hl-boolean, +.hl-constant, +.hl-constant-builtin, +.hl-constant-character { + color: #ffa07a; +} + +// -- Comments -------------------------------------------------------------- + +.hl-comment, +.hl-comment-line, +.hl-comment-block, +.hl-comment-unused { + color: #d4d0ab; +} + +.hl-comment-documentation { + color: #d4d0ab; + font-style: italic; +} + +// -- Functions ------------------------------------------------------------- + +.hl-function, +.hl-function-method, +.hl-function-builtin, +.hl-function-special, +.hl-function-macro, +.hl-constructor, +.hl-constructor-builtin { + color: #ffd700; +} + +// -- Types / namespaces / modules ----------------------------------------- + +.hl-type, +.hl-type-builtin, +.hl-type-parameter, +.hl-type-enum, +.hl-namespace, +.hl-module { + color: #dcc6e0; +} + +// -- Variables / properties ----------------------------------------------- + +.hl-variable, +.hl-variable-builtin, +.hl-variable-parameter, +.hl-variable-other, +.hl-variable-mutable, +.hl-variable-member { + color: #f5ab35; +} + +.hl-property, +.hl-property-builtin, +.hl-attribute, +.hl-label { + color: #ffd700; +} + +// -- Operators / punctuation ---------------------------------------------- + +.hl-operator, +.hl-operator-word { + color: #00e0e0; +} + +.hl-punctuation, +.hl-punctuation-bracket, +.hl-punctuation-delimiter, +.hl-punctuation-special { + color: #f8f8f2; +} + +// -- Tags / markup --------------------------------------------------------- + +.hl-tag { + color: #ffa07a; +} + +.hl-markup-heading { + color: #ffd700; + font-weight: 700; +} + +.hl-markup-bold { + font-weight: 700; +} + +.hl-markup-italic { + font-style: italic; +} + +.hl-markup-strikethrough { + text-decoration: line-through; +} + +.hl-markup-link, +.hl-markup-link-url { + color: #ffd700; + text-decoration: underline; +} + +.hl-markup-raw { + color: #abe338; +} + +// -- Special / embedded / errors ------------------------------------------ + +.hl-special, +.hl-embedded { + color: #dcc6e0; +} + +.hl-error { + color: #dcc6e0; + text-decoration: underline wavy; +} diff --git a/resources/scss/html/templates/highlight-a11y-light.scss b/resources/scss/html/templates/highlight-a11y-light.scss new file mode 100644 index 000000000..081529516 --- /dev/null +++ b/resources/scss/html/templates/highlight-a11y-light.scss @@ -0,0 +1,186 @@ +/*-- scss:defaults --*/ + +// a11y-light's canvas: applied to code blocks via the existing +// $code-block-bg color branch in _bootstrap-rules.scss. `!default` +// so user theme layers (which merge ahead of built-in layers in the +// defaults band) can still override. +$code-block-bg: #fefefe !default; +$code-block-color: #545454 !default; + +/*-- scss:rules --*/ + +// a11y-light syntax-highlight palette (bd-0pic6 phase B). +// +// Hand-translated from Quarto 1's +// `resources/pandoc/highlight-styles/a11y-light.theme` (Eric Bailey's +// a11y-light, WCAG AA against #fefefe) onto Quarto 2's tree-sitter +// `.hl-*` class vocabulary. The token mapping (Pandoc token → capture +// group) follows the group structure of `highlight-default.scss`: +// +// Keyword/ControlFlow → keywords #d91e18 +// String/Char → strings #008000 +// SpecialChar → escapes #00769e +// DecVal/BaseN → numbers #7928a1 +// Constant → constants #d91e18 +// Comment → comments #696969 +// Function → functions #06287e +// DataType → types #7928a1 +// Variable/Attribute → variables #a55a00 +// Operator → operators #00769e +// Normal → punctuation #545454 + +// -- Keywords ------------------------------------------------------------- + +.hl-keyword, +.hl-keyword-control, +.hl-keyword-operator, +.hl-keyword-directive, +.hl-keyword-function, +.hl-keyword-storage, +.hl-keyword-conditional, +.hl-keyword-repeat { + color: #d91e18; +} + +// -- Strings / characters / escapes --------------------------------------- + +.hl-string, +.hl-string-regexp, +.hl-string-special, +.hl-string-special-symbol, +.hl-character { + color: #008000; +} + +.hl-string-escape, +.hl-escape { + color: #00769e; +} + +// -- Numbers / booleans / constants -------------------------------------- + +.hl-number, +.hl-constant-numeric { + color: #7928a1; +} + +.hl-boolean, +.hl-constant, +.hl-constant-builtin, +.hl-constant-character { + color: #d91e18; +} + +// -- Comments -------------------------------------------------------------- + +.hl-comment, +.hl-comment-line, +.hl-comment-block, +.hl-comment-unused { + color: #696969; +} + +.hl-comment-documentation { + color: #696969; + font-style: italic; +} + +// -- Functions ------------------------------------------------------------- + +.hl-function, +.hl-function-method, +.hl-function-builtin, +.hl-function-special, +.hl-function-macro, +.hl-constructor, +.hl-constructor-builtin { + color: #06287e; +} + +// -- Types / namespaces / modules ----------------------------------------- + +.hl-type, +.hl-type-builtin, +.hl-type-parameter, +.hl-type-enum, +.hl-namespace, +.hl-module { + color: #7928a1; +} + +// -- Variables / properties ----------------------------------------------- + +.hl-variable, +.hl-variable-builtin, +.hl-variable-parameter, +.hl-variable-other, +.hl-variable-mutable, +.hl-variable-member { + color: #a55a00; +} + +.hl-property, +.hl-property-builtin, +.hl-attribute, +.hl-label { + color: #a55a00; +} + +// -- Operators / punctuation ---------------------------------------------- + +.hl-operator, +.hl-operator-word { + color: #00769e; +} + +.hl-punctuation, +.hl-punctuation-bracket, +.hl-punctuation-delimiter, +.hl-punctuation-special { + color: #545454; +} + +// -- Tags / markup --------------------------------------------------------- + +.hl-tag { + color: #d91e18; +} + +.hl-markup-heading { + color: #06287e; + font-weight: 700; +} + +.hl-markup-bold { + font-weight: 700; +} + +.hl-markup-italic { + font-style: italic; +} + +.hl-markup-strikethrough { + text-decoration: line-through; +} + +.hl-markup-link, +.hl-markup-link-url { + color: #06287e; + text-decoration: underline; +} + +.hl-markup-raw { + color: #008000; +} + +// -- Special / embedded / errors ------------------------------------------ + +.hl-special, +.hl-embedded { + color: #7928a1; +} + +.hl-error { + color: #7928a1; + text-decoration: underline wavy; +} diff --git a/resources/scss/html/templates/highlight-default.scss b/resources/scss/html/templates/highlight-default.scss new file mode 100644 index 000000000..e73ad1465 --- /dev/null +++ b/resources/scss/html/templates/highlight-default.scss @@ -0,0 +1,150 @@ +/*-- scss:rules --*/ + +// -- Keywords ------------------------------------------------------------- + +.hl-keyword, +.hl-keyword-control, +.hl-keyword-operator, +.hl-keyword-directive, +.hl-keyword-function, +.hl-keyword-storage, +.hl-keyword-conditional, +.hl-keyword-repeat { + color: #859900; // solarized green + font-weight: 600; +} + +// -- Strings / characters / escapes --------------------------------------- + +.hl-string, +.hl-string-escape, +.hl-string-regexp, +.hl-string-special, +.hl-string-special-symbol, +.hl-character { + color: #2aa198; // solarized cyan +} + +.hl-string-escape, +.hl-escape { + color: #b58900; // solarized yellow +} + +// -- Numbers / booleans / constants -------------------------------------- + +.hl-number, +.hl-boolean, +.hl-constant, +.hl-constant-builtin, +.hl-constant-character, +.hl-constant-numeric { + color: #d33682; // solarized magenta +} + +// -- Comments -------------------------------------------------------------- + +.hl-comment, +.hl-comment-line, +.hl-comment-block, +.hl-comment-documentation, +.hl-comment-unused { + color: #93a1a1; // solarized base1 + font-style: italic; +} + +// -- Functions ------------------------------------------------------------- + +.hl-function, +.hl-function-method, +.hl-function-builtin, +.hl-function-special, +.hl-function-macro, +.hl-constructor, +.hl-constructor-builtin { + color: #268bd2; // solarized blue +} + +// -- Types / namespaces / modules ----------------------------------------- + +.hl-type, +.hl-type-builtin, +.hl-type-parameter, +.hl-type-enum, +.hl-namespace, +.hl-module { + color: #b58900; // solarized yellow +} + +// -- Variables / properties ----------------------------------------------- + +.hl-variable, +.hl-variable-builtin, +.hl-variable-parameter, +.hl-variable-other, +.hl-variable-mutable, +.hl-variable-member { + color: #657b83; // solarized base00 +} + +.hl-property, +.hl-property-builtin, +.hl-attribute, +.hl-label { + color: #6c71c4; // solarized violet +} + +// -- Operators / punctuation ---------------------------------------------- + +.hl-operator, +.hl-operator-word, +.hl-punctuation, +.hl-punctuation-bracket, +.hl-punctuation-delimiter, +.hl-punctuation-special { + color: #657b83; // solarized base00 +} + +// -- Tags / markup --------------------------------------------------------- + +.hl-tag { + color: #dc322f; // solarized red +} + +.hl-markup-heading { + color: #268bd2; + font-weight: 700; +} + +.hl-markup-bold { + font-weight: 700; +} + +.hl-markup-italic { + font-style: italic; +} + +.hl-markup-strikethrough { + text-decoration: line-through; +} + +.hl-markup-link, +.hl-markup-link-url { + color: #268bd2; + text-decoration: underline; +} + +.hl-markup-raw { + color: #2aa198; +} + +// -- Special / embedded / errors ------------------------------------------ + +.hl-special, +.hl-embedded { + color: #cb4b16; // solarized orange +} + +.hl-error { + color: #dc322f; + text-decoration: underline wavy; +} diff --git a/resources/scss/html/templates/highlight.scss b/resources/scss/html/templates/highlight.scss index 8dfbd501e..0a0e83e49 100644 --- a/resources/scss/html/templates/highlight.scss +++ b/resources/scss/html/templates/highlight.scss @@ -63,152 +63,3 @@ div.sourceCode { pre.sourceCode { margin: 0; } - -// -- Keywords ------------------------------------------------------------- - -.hl-keyword, -.hl-keyword-control, -.hl-keyword-operator, -.hl-keyword-directive, -.hl-keyword-function, -.hl-keyword-storage, -.hl-keyword-conditional, -.hl-keyword-repeat { - color: #859900; // solarized green - font-weight: 600; -} - -// -- Strings / characters / escapes --------------------------------------- - -.hl-string, -.hl-string-escape, -.hl-string-regexp, -.hl-string-special, -.hl-string-special-symbol, -.hl-character { - color: #2aa198; // solarized cyan -} - -.hl-string-escape, -.hl-escape { - color: #b58900; // solarized yellow -} - -// -- Numbers / booleans / constants -------------------------------------- - -.hl-number, -.hl-boolean, -.hl-constant, -.hl-constant-builtin, -.hl-constant-character, -.hl-constant-numeric { - color: #d33682; // solarized magenta -} - -// -- Comments -------------------------------------------------------------- - -.hl-comment, -.hl-comment-line, -.hl-comment-block, -.hl-comment-documentation, -.hl-comment-unused { - color: #93a1a1; // solarized base1 - font-style: italic; -} - -// -- Functions ------------------------------------------------------------- - -.hl-function, -.hl-function-method, -.hl-function-builtin, -.hl-function-special, -.hl-function-macro, -.hl-constructor, -.hl-constructor-builtin { - color: #268bd2; // solarized blue -} - -// -- Types / namespaces / modules ----------------------------------------- - -.hl-type, -.hl-type-builtin, -.hl-type-parameter, -.hl-type-enum, -.hl-namespace, -.hl-module { - color: #b58900; // solarized yellow -} - -// -- Variables / properties ----------------------------------------------- - -.hl-variable, -.hl-variable-builtin, -.hl-variable-parameter, -.hl-variable-other, -.hl-variable-mutable, -.hl-variable-member { - color: #657b83; // solarized base00 -} - -.hl-property, -.hl-property-builtin, -.hl-attribute, -.hl-label { - color: #6c71c4; // solarized violet -} - -// -- Operators / punctuation ---------------------------------------------- - -.hl-operator, -.hl-operator-word, -.hl-punctuation, -.hl-punctuation-bracket, -.hl-punctuation-delimiter, -.hl-punctuation-special { - color: #657b83; // solarized base00 -} - -// -- Tags / markup --------------------------------------------------------- - -.hl-tag { - color: #dc322f; // solarized red -} - -.hl-markup-heading { - color: #268bd2; - font-weight: 700; -} - -.hl-markup-bold { - font-weight: 700; -} - -.hl-markup-italic { - font-style: italic; -} - -.hl-markup-strikethrough { - text-decoration: line-through; -} - -.hl-markup-link, -.hl-markup-link-url { - color: #268bd2; - text-decoration: underline; -} - -.hl-markup-raw { - color: #2aa198; -} - -// -- Special / embedded / errors ------------------------------------------ - -.hl-special, -.hl-embedded { - color: #cb4b16; // solarized orange -} - -.hl-error { - color: #dc322f; - text-decoration: underline wavy; -} From ef774d93ae7bb5dd8657dac932284c05f40fae0c Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 16:40:32 -0500 Subject: [PATCH 08/10] light-dark C: brand light/dark seam (bd-ld-c-brand-seam-wef8ww3n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extract_brand_refs now extracts BOTH halves of a brand: {light:, dark:} pair (removing the silent light-only TODO from the original brand port). DarkThemeConfig carries its own brand_ref, with parse-time fallback to the light brand when the pair has no dark half (Q1's per-layer fallback semantics). A dark brand ENABLES dark mode (Q1's enablesDarkMode): when the brand pair exists without a theme: dark half, the dark variant is synthesized from the light theme list and flows through the entire established machinery — dual compilation, attributed links, color-scheme meta, and the toggle — automatically. The author default falls back to the brand map's key order; a theme-declared pair's own key order wins when both maps exist. A dark-only brand ({dark: b.yml}) synthesizes a default-dark variant with no brand on the light side. CompileThemeCssStage resolves each variant's brand into its own ThemeContext, so the dark compile uses the dark brand and cache keys discriminate via the brand hash already included in cache_key. Single-variant brand consumers (revealjs, favicon fallback) explicitly keep the light half. Scope split: field-level {light, dark} values inside a unified _brand.yml (Q1's splitUnifiedBrand; needs untagged-enum type surgery in quarto-brand) filed as bd-unified-brand-split-ep49amad. This commit delivers the seam bd-v5z8w asked for. TDD: 6 parse tests + 1 integration test red first (the integration red pinpointed exactly the shared-ThemeContext gap). E2E via real q2 render inspected: per-variant --bs-primary-rgb values, dark brand background driving color-scheme:dark. 12,166 workspace tests green. Part of the light/dark epic (bd-0pic6); plan: claude-notes/plans/2026-08-14-light-dark-theme-epic.md Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 21 +- .../src/stage/stages/compile_theme_css.rs | 29 +- .../tests/integration/theme_light_dark.rs | 64 ++++ crates/quarto-sass/src/config.rs | 353 +++++++++++++++--- 4 files changed, 401 insertions(+), 66 deletions(-) diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index 17ed16de9..602d3454a 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -521,8 +521,25 @@ Integration branch: `feature/light-dark-theme` (created off `main`). `bd-hl-theme-translator-2mdgh4k6` (general `.theme` translator, full Q1 catalog, copy-button color feedback, sentinel-based single-variant resolution). E2E via real binary inspected. 12,158 workspace tests green. -- [ ] **C — brand seam** (D7): `bd-ld-c-brand-seam-wef8ww3n`. Absorbs bd-v5z8w; - unified-brand split. +- [x] **C — brand seam** (D7): `bd-ld-c-brand-seam-wef8ww3n`. **Done + 2026-08-14.** `extract_brand_refs` extracts BOTH halves of a + `brand: {light:, dark:}` pair (the silent light-only TODO is gone); + `DarkThemeConfig.brand_ref` carries the dark ref with parse-time fallback + to the light brand (Q1's per-layer fallback). **A dark brand ENABLES dark + mode**: with a plain `theme:`, the dark variant is synthesized from the + light theme list, flowing through all of A2–A4 (dual CSS, attributed + links, toggle) automatically; author default falls back to the brand + map's key order (theme map order wins when both exist). The stage + resolves each variant's brand into its own `ThemeContext`, so cache keys + discriminate via the brand hash already in `cache_key`. Single-variant + brand consumers (reveal, favicon) keep the light half explicitly. + **Scope split**: the unified-`_brand.yml` field-level `{light, dark}` + split (Q1 `splitUnifiedBrand`, needs untagged-enum type surgery in + quarto-brand) moved to follow-up `bd-unified-brand-split-ep49amad` — the + epic's deliverable is the seam, which bd-v5z8w asked for. E2E via real + binary: light css `--bs-primary-rgb: 0,85,170` vs dark `255,170,17`, + dark brand `#181c25` background → `color-scheme:dark`. 12,166 tests + green. - [ ] **D — preview/hub-client** (D8): `bd-ld-d-preview-hub-t4oxv0hf`. VFS/iframe dual transport, editor-scheme integration (related: bd-nxe8). Uses lessons from A. diff --git a/crates/quarto-core/src/stage/stages/compile_theme_css.rs b/crates/quarto-core/src/stage/stages/compile_theme_css.rs index d862331a1..af0a17526 100644 --- a/crates/quarto-core/src/stage/stages/compile_theme_css.rs +++ b/crates/quarto-core/src/stage/stages/compile_theme_css.rs @@ -456,10 +456,11 @@ impl PipelineStage for CompileThemeCssStage { .parent() .map_or_else(|| PathBuf::from("."), |p| p.to_path_buf()); - // Resolve the brand (if any) once — it is shared by both - // variants until the brand light/dark seam lands - // (bd-ld-c-brand-seam-wef8ww3n). I/O happens here. Failures - // are user-facing configuration errors (missing `_brand.yml`, + // Resolve each variant's brand (bd-0pic6 phase C: the dark + // half of a `brand: {light:, dark:}` pair drives the dark + // compile; a single brand is shared by both variants via the + // parse-time fallback). I/O happens here. Failures are + // user-facing configuration errors (missing `_brand.yml`, // invalid YAML, unknown brand shape) — propagate them rather // than silently shipping DEFAULT_CSS, same reasoning as the // `from_config_value` error path above. @@ -470,11 +471,11 @@ impl PipelineStage for CompileThemeCssStage { PipelineError::stage_error(self.name(), format!("brand resolution: {e}")) })?; - // The ThemeContext borrows a local Arc clone of the runtime + // The ThemeContexts borrow a local Arc clone of the runtime // (not `ctx`) so `ctx` stays mutably borrowable inside // `variant_css`. let runtime = ctx.runtime.clone(); - let mut theme_context = ThemeContext::new(document_dir, runtime.as_ref()); + let mut theme_context = ThemeContext::new(document_dir.clone(), runtime.as_ref()); if let Some(brand) = resolved.brand.as_ref() { let brand_dir = resolved .brand_dir @@ -487,7 +488,21 @@ impl PipelineStage for CompileThemeCssStage { variant_css(ctx, &theme_config, &theme_context, &doc_vars, cache_ok).await?; if let Some(dark_cfg) = theme_config.dark_variant() { - let dark_css = variant_css(ctx, &dark_cfg, &theme_context, &doc_vars, cache_ok).await?; + let resolved_dark = dark_cfg + .clone() + .resolve(ctx.runtime.as_ref(), &ctx.project.dir) + .map_err(|e| { + PipelineError::stage_error(self.name(), format!("dark brand resolution: {e}")) + })?; + let mut dark_context = ThemeContext::new(document_dir, runtime.as_ref()); + if let Some(brand) = resolved_dark.brand.as_ref() { + let brand_dir = resolved_dark + .brand_dir + .clone() + .unwrap_or_else(|| ctx.project.dir.clone()); + dark_context = dark_context.with_brand(brand, brand_dir); + } + let dark_css = variant_css(ctx, &dark_cfg, &dark_context, &doc_vars, cache_ok).await?; let dark_is_default = theme_config.dark.as_ref().is_some_and(|d| d.is_default); store_variant_pair(ctx, light_css, dark_css, dark_is_default); } else { diff --git a/crates/quarto-core/tests/integration/theme_light_dark.rs b/crates/quarto-core/tests/integration/theme_light_dark.rs index 8a0edcd3a..1778bc3e3 100644 --- a/crates/quarto-core/tests/integration/theme_light_dark.rs +++ b/crates/quarto-core/tests/integration/theme_light_dark.rs @@ -774,6 +774,70 @@ fn unknown_highlight_style_warns_and_uses_default() { assert!(q14_5[0].location.is_some(), "warning carries a location"); } +/// Phase C: `brand: {light, dark}` — each variant's compile uses its +/// own brand, and a dark brand alone (plain `theme: cosmo`) enables +/// the full dark-mode machinery (dual CSS, attributed links, toggle). +#[test] +fn brand_pair_drives_per_variant_compiles_and_enables_dark_mode() { + let dir = TempDir::new().unwrap(); + let root = dir.path(); + write( + &root.join("brand-light.yml"), + "color:\n primary: \"#0055aa\"\n", + ); + write( + &root.join("brand-dark.yml"), + "color:\n primary: \"#ffaa11\"\n background: \"#181c25\"\n", + ); + write( + &root.join("doc.qmd"), + "---\ntitle: Brand Pair\nformat:\n html:\n theme: cosmo\nbrand:\n light: brand-light.yml\n dark: brand-dark.yml\n---\n\n# Hi\n", + ); + + let runtime: Arc = Arc::new(NativeRuntime::new()); + let result = render_to_file( + &root.join("doc.qmd"), + "html", + &RenderToFileOptions { + quiet: true, + ..Default::default() + }, + runtime, + ) + .expect("brand pair must render"); + + let files = css_files(&result.resources_dir); + let light = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles.css")) + .expect("light css (dark mode enabled by the dark brand)"); + let dark = files + .iter() + .find(|(p, _)| p.file_name().and_then(|n| n.to_str()) == Some("styles-dark.css")) + .expect("dark css synthesized from the dark brand"); + assert!( + light.1.contains("0,85,170") || light.1.contains("#0055aa"), + "light variant must carry the light brand's primary" + ); + assert!( + !light.1.contains("255,170,17") && !light.1.contains("#ffaa11"), + "light variant must not carry the dark brand's primary" + ); + assert!( + dark.1.contains("255,170,17") || dark.1.contains("#ffaa11"), + "dark variant must carry the dark brand's primary" + ); + assert!( + dark.1.contains("color-scheme:dark") || dark.1.contains("color-scheme: dark"), + "dark brand background #181c25 must make the dark variant dark" + ); + + // The full dark-mode machinery engages: attributed links + toggle. + let html = std::fs::read_to_string(&result.output_path).unwrap(); + assert!(html.contains(r#"class="quarto-color-scheme""#)); + assert!(html.contains("quartoToggleColorScheme")); +} + /// D1a bonus: a *single* dark theme (no light/dark pair at all) gets /// `color-scheme: dark` from the darkness sentinel, so existing /// dark-theme users get correct native scrollbars/controls. diff --git a/crates/quarto-sass/src/config.rs b/crates/quarto-sass/src/config.rs index f323490fe..3900de2c9 100644 --- a/crates/quarto-sass/src/config.rs +++ b/crates/quarto-sass/src/config.rs @@ -160,6 +160,13 @@ pub struct DarkThemeConfig { /// [`ThemeConfig::highlight_style`]). `None` → the default /// palette. pub highlight_style: Option, + + /// The dark variant's brand reference (bd-0pic6 phase C): the + /// `dark:` half of a `brand: {light:, dark:}` pair, or a copy of + /// the light brand when the brand has no dark half (Q1's + /// per-layer fallback — a bundle with no dark opinion contributes + /// its light layers to the dark compile). + pub brand_ref: Option, } /// A resolved syntax-highlight palette request for one variant @@ -273,7 +280,7 @@ impl ThemeConfig { pub fn from_config_value(config: &ConfigValue) -> Result { // Look for top-level `theme` (format-flattened by MetadataMergeStage) let theme_value = config.get("theme"); - let brand_ref = extract_brand_ref(config.get("brand"))?; + let brand_refs = extract_brand_refs(config.get("brand"))?; let mut result = match theme_value { None => Self::default_bootstrap(), @@ -299,6 +306,7 @@ impl ThemeConfig { is_default: pair.dark_first, key_location: Some(key_source), highlight_style: None, + brand_ref: None, }) } None => None, @@ -327,31 +335,44 @@ impl ThemeConfig { return Ok(result); } + // A dark brand ENABLES dark mode (Q1's `enablesDarkMode`): + // when `brand: {…, dark: …}` exists without a dark theme + // half, synthesize the dark variant from the light theme list + // — it then flows through dual compilation, link emission, + // and the toggle like a `theme:`-declared pair. Synthesis + // happens BEFORE brand-token injection so each variant gets + // its own marker. The author default falls back to the brand + // map's key order (the theme map's order wins when both maps + // exist, because a theme-declared pair sets `is_default` + // above and this branch is skipped). + if brand_refs.dark.is_some() && result.dark.is_none() && !result.suppress_bootstrap { + result.dark = Some(DarkThemeConfig { + themes: result.themes.clone(), + theme_locations: result.theme_locations.clone(), + suppress_bootstrap: false, + is_default: brand_refs.dark_first, + key_location: None, + highlight_style: None, + brand_ref: None, + }); + } + + // Per-variant brand refs: the dark variant falls back to the + // light brand when the brand has no dark half (Q1's per-layer + // fallback). Auto-inject the position marker at the end of + // each variant's list that doesn't already name it (and isn't + // suppressed); naming `brand` in a variant that has no brand + // is an error. + let light_ref = brand_refs.light; + let dark_ref = brand_refs.dark.or_else(|| light_ref.clone()); + let light_has_token = result.themes.iter().any(ThemeSpec::is_brand); - let dark_has_token = result - .dark - .as_ref() - .is_some_and(|d| d.themes.iter().any(ThemeSpec::is_brand)); - match brand_ref { - Some(br) => { - result.brand_ref = Some(br); - // Auto-inject the position marker at the end of each - // variant's list that doesn't already name it (and - // isn't suppressed). Q1 splices brand into light and - // dark independently. - if !result.suppress_bootstrap && !light_has_token { - result.themes.push(ThemeSpec::Brand); - result.theme_locations.push(None); - } - if let Some(dark) = result.dark.as_mut() - && !dark.suppress_bootstrap - && !dark_has_token - { - dark.themes.push(ThemeSpec::Brand); - dark.theme_locations.push(None); - } + match (&light_ref, light_has_token) { + (Some(_), false) if !result.suppress_bootstrap => { + result.themes.push(ThemeSpec::Brand); + result.theme_locations.push(None); } - None if light_has_token || dark_has_token => { + (None, true) => { return Err(SassError::InvalidThemeConfig { message: "`theme:` contains `brand` but no `_brand.yml` was configured \ via the `brand:` key" @@ -359,7 +380,28 @@ impl ThemeConfig { location: config.get("theme").map(|v| v.source_info.clone()), }); } - None => {} + _ => {} + } + result.brand_ref = light_ref; + + if let Some(dark) = result.dark.as_mut() { + let dark_has_token = dark.themes.iter().any(ThemeSpec::is_brand); + match (&dark_ref, dark_has_token) { + (Some(_), false) if !dark.suppress_bootstrap => { + dark.themes.push(ThemeSpec::Brand); + dark.theme_locations.push(None); + } + (None, true) => { + return Err(SassError::InvalidThemeConfig { + message: "`theme:` contains `brand` but no `_brand.yml` was configured \ + via the `brand:` key" + .to_string(), + location: config.get("theme").map(|v| v.source_info.clone()), + }); + } + _ => {} + } + dark.brand_ref = dark_ref; } // `highlight-style:` (bd-0pic6 phase B) — after theme parsing @@ -486,7 +528,7 @@ impl ThemeConfig { minified: self.minified, suppress_bootstrap: d.suppress_bootstrap, title_block_layer: self.title_block_layer, - brand_ref: self.brand_ref.clone(), + brand_ref: d.brand_ref.clone(), dark: None, highlight_style: d.highlight_style.clone(), }) @@ -534,7 +576,10 @@ pub fn resolve_brand( runtime: &dyn SystemRuntime, base_dir: &Path, ) -> Result, SassError> { - let Some(brand_ref) = extract_brand_ref(config.get("brand"))? else { + // Single-variant consumers (reveal, favicon fallback) use the + // LIGHT brand; per-variant selection is the HTML dual-compile + // path's concern (bd-0pic6 phase C). + let Some(brand_ref) = extract_brand_refs(config.get("brand"))?.light else { return Ok(None); }; // Reuse ThemeConfig's brand resolution (path/inline → typed Brand). @@ -611,47 +656,78 @@ fn config_value_as_text(value: &ConfigValue) -> Option { .or_else(|| value.as_plain_text()) } -/// Extract a [`BrandRef`] from the value at the `brand:` key, if any. +/// The per-variant brand references extracted from the `brand:` key +/// (bd-0pic6 phase C). +struct BrandRefs { + light: Option, + dark: Option, + /// Whether `dark:` is the brand map's first key (or its only + /// key) — Q1's fallback author-default rule when the `theme:` map + /// doesn't decide (`format-html-info.ts::darkModeDefaultMetadata`). + dark_first: bool, +} + +/// Extract the per-variant [`BrandRef`]s from the value at the +/// `brand:` key, if any. /// -/// - String → [`BrandRef::Path`]. -/// - Map → check for `light`/`dark` keys. If present, emit a soft -/// warning (light/dark pairs are deferred to a follow-up) and use -/// the `light` half. Otherwise treat the whole map as an inline -/// brand block. -/// - Null / absent → `None`. -fn extract_brand_ref(value: Option<&ConfigValue>) -> Result, SassError> { - let Some(value) = value else { return Ok(None) }; +/// - String → light [`BrandRef::Path`] (the dark variant falls back +/// to it at the call site). +/// - Map with only `light`/`dark` keys → each half extracted as its +/// own single-brand value (path or inline block). +/// - Any other map → an inline (light) brand block. +/// - Null / absent → neither. +fn extract_brand_refs(value: Option<&ConfigValue>) -> Result { + let none = BrandRefs { + light: None, + dark: None, + dark_first: false, + }; + let Some(value) = value else { return Ok(none) }; if value.is_null() { - return Ok(None); - } - - // Path form. - if let Some(s) = config_value_as_text(value) { - return Ok(Some(BrandRef::Path(PathBuf::from(s)))); + return Ok(none); } - // Light/dark pair, or inline block. if let Some(entries) = value.as_map_entries() { let light = entries.iter().find(|e| e.key == "light"); let dark = entries.iter().find(|e| e.key == "dark"); let other = entries.iter().any(|e| e.key != "light" && e.key != "dark"); if (light.is_some() || dark.is_some()) && !other { - // Treat as a light/dark pair. Light half is used; the dark - // side is deferred — see Phase 8 follow-up. - // TODO(brand light/dark): wire dark variant once Q2 has a - // light/dark seam. - if let Some(light_entry) = light { - return extract_brand_ref(Some(&light_entry.value)); - } - // Only dark configured — silently ignore for now. - return Ok(None); + return Ok(BrandRefs { + light: match light { + Some(entry) => Some(extract_single_brand_ref(&entry.value)?), + None => None, + }, + dark: match dark { + Some(entry) => Some(extract_single_brand_ref(&entry.value)?), + None => None, + }, + dark_first: entries.first().is_some_and(|e| e.key == "dark"), + }); } + } - // Inline brand block: convert the typed ConfigValue back to a - // serde_yaml::Value so we can hand it to serde_yaml::from_value - // in `resolve`. + Ok(BrandRefs { + light: Some(extract_single_brand_ref(value)?), + dark: None, + dark_first: false, + }) +} + +/// Extract one [`BrandRef`] from a single brand value (a path string +/// or an inline brand block) — the halves of a `{light:, dark:}` pair +/// and the plain single-brand form both go through here. +fn extract_single_brand_ref(value: &ConfigValue) -> Result { + // Path form. + if let Some(s) = config_value_as_text(value) { + return Ok(BrandRef::Path(PathBuf::from(s))); + } + + // Inline brand block: convert the typed ConfigValue back to a + // serde_yaml::Value so we can hand it to serde_yaml::from_value + // in `resolve`. + if value.as_map_entries().is_some() { let yaml_value = config_value_to_yaml_value(value)?; - return Ok(Some(BrandRef::Inline(Box::new(yaml_value)))); + return Ok(BrandRef::Inline(Box::new(yaml_value))); } // Scalar(Yaml::Hash) — synthesized in tests, or produced when the @@ -662,7 +738,7 @@ fn extract_brand_ref(value: Option<&ConfigValue>) -> Result, Sa { // Only accept if the yaml_value is a mapping; bail otherwise. if matches!(yaml_value, serde_yaml::Value::Mapping(_)) { - return Ok(Some(BrandRef::Inline(Box::new(yaml_value)))); + return Ok(BrandRef::Inline(Box::new(yaml_value))); } } @@ -2088,6 +2164,169 @@ mod tests { ); } + // === brand light/dark seam tests (bd-0pic6 phase C) === + + #[test] + fn test_brand_pair_sets_per_variant_refs() { + // `brand: {light: a.yml, dark: b.yml}` + a theme pair: each + // variant carries its own BrandRef. + let theme = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let brand = map_value(vec![ + map_entry("light", scalar_value("brand-light.yml")), + map_entry("dark", scalar_value("brand-dark.yml")), + ]); + let config = map_value(vec![map_entry("theme", theme), map_entry("brand", brand)]); + let cfg = ThemeConfig::from_config_value(&config).unwrap(); + + match cfg.brand_ref.as_ref() { + Some(BrandRef::Path(p)) => assert_eq!(p.to_str(), Some("brand-light.yml")), + other => panic!("light brand_ref: {:?}", other), + } + let dark = cfg.dark.as_ref().unwrap(); + match dark.brand_ref.as_ref() { + Some(BrandRef::Path(p)) => assert_eq!(p.to_str(), Some("brand-dark.yml")), + other => panic!("dark brand_ref: {:?}", other), + } + // Brand token auto-injected into both variants. + assert!(cfg.themes.iter().any(ThemeSpec::is_brand)); + assert!(dark.themes.iter().any(ThemeSpec::is_brand)); + } + + #[test] + fn test_single_brand_falls_back_to_light_for_dark_variant() { + // A single `brand: a.yml` with a theme pair: the dark variant + // uses the same brand (Q1's per-layer fallback — a bundle with + // no dark opinion contributes its light layers to the dark + // compile). + let theme = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let config = map_value(vec![ + map_entry("theme", theme), + map_entry("brand", scalar_value("_brand.yml")), + ]); + let cfg = ThemeConfig::from_config_value(&config).unwrap(); + match cfg.dark.as_ref().unwrap().brand_ref.as_ref() { + Some(BrandRef::Path(p)) => assert_eq!(p.to_str(), Some("_brand.yml")), + other => panic!("dark brand_ref fallback: {:?}", other), + } + } + + #[test] + fn test_brand_pair_synthesizes_dark_variant() { + // A dark brand ENABLES dark mode even when `theme:` has no + // dark half (Q1: `enablesDarkMode`): the dark variant is + // synthesized from the light theme list + the dark brand. + let config = map_value(vec![ + map_entry("theme", scalar_value("cosmo")), + map_entry( + "brand", + map_value(vec![ + map_entry("light", scalar_value("brand-light.yml")), + map_entry("dark", scalar_value("brand-dark.yml")), + ]), + ), + ]); + let cfg = ThemeConfig::from_config_value(&config).unwrap(); + + let dark = cfg.dark.as_ref().expect("dark variant synthesized"); + // Same theme list as light (cosmo + auto-injected brand token). + assert_eq!(dark.themes.len(), 2); + assert!(dark.themes[0].is_builtin()); + assert!(dark.themes[1].is_brand()); + assert!(!dark.is_default, "light listed first in the brand map"); + match dark.brand_ref.as_ref() { + Some(BrandRef::Path(p)) => assert_eq!(p.to_str(), Some("brand-dark.yml")), + other => panic!("dark brand_ref: {:?}", other), + } + } + + #[test] + fn test_brand_pair_dark_first_sets_default() { + // Q1's fallback rule: when `theme:` doesn't decide the author + // default, the `brand:` map's key order does. + let config = map_value(vec![ + map_entry("theme", scalar_value("cosmo")), + map_entry( + "brand", + map_value(vec![ + map_entry("dark", scalar_value("brand-dark.yml")), + map_entry("light", scalar_value("brand-light.yml")), + ]), + ), + ]); + let cfg = ThemeConfig::from_config_value(&config).unwrap(); + assert!(cfg.dark.as_ref().unwrap().is_default); + } + + #[test] + fn test_theme_map_order_wins_over_brand_order() { + // When BOTH maps exist, the theme map's key order decides the + // author default (Q1 checks the theme map first). + let theme = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let brand = map_value(vec![ + map_entry("dark", scalar_value("brand-dark.yml")), + map_entry("light", scalar_value("brand-light.yml")), + ]); + let config = map_value(vec![map_entry("theme", theme), map_entry("brand", brand)]); + let cfg = ThemeConfig::from_config_value(&config).unwrap(); + assert!( + !cfg.dark.as_ref().unwrap().is_default, + "theme map wrote light first — brand order must not override" + ); + } + + #[test] + fn test_dark_only_brand_synthesizes_dark_default() { + // `brand: {dark: b.yml}`: no light brand; the synthesized dark + // variant carries the brand and is the author default (dark is + // the map's first — only — key). + let config = map_value(vec![ + map_entry("theme", scalar_value("cosmo")), + map_entry( + "brand", + map_value(vec![map_entry("dark", scalar_value("brand-dark.yml"))]), + ), + ]); + let cfg = ThemeConfig::from_config_value(&config).unwrap(); + + assert!(cfg.brand_ref.is_none(), "light variant has no brand"); + assert!( + !cfg.themes.iter().any(ThemeSpec::is_brand), + "no token in the light list without a light brand" + ); + let dark = cfg.dark.as_ref().expect("dark synthesized"); + assert!(dark.is_default); + assert!(dark.brand_ref.is_some()); + assert!(dark.themes.iter().any(ThemeSpec::is_brand)); + } + + #[test] + fn test_dark_variant_projection_carries_brand_ref() { + let theme = map_value(vec![ + map_entry("light", scalar_value("cosmo")), + map_entry("dark", scalar_value("darkly")), + ]); + let brand = map_value(vec![ + map_entry("light", scalar_value("brand-light.yml")), + map_entry("dark", scalar_value("brand-dark.yml")), + ]); + let config = map_value(vec![map_entry("theme", theme), map_entry("brand", brand)]); + let cfg = ThemeConfig::from_config_value(&config).unwrap(); + let dark_cfg = cfg.dark_variant().unwrap(); + match dark_cfg.brand_ref.as_ref() { + Some(BrandRef::Path(p)) => assert_eq!(p.to_str(), Some("brand-dark.yml")), + other => panic!("projected dark brand_ref: {:?}", other), + } + } + #[test] fn test_resolve_carries_dark_through() { // ThemeConfig::resolve must not drop the dark half — the From c160ec80a6ddc211e6f476e1a2da260c96852735 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 16:46:49 -0500 Subject: [PATCH 09/10] light-dark E: cleanup + user-facing docs (bd-ld-e-cleanup-qxidnkng) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes the never-wired SassBundle/SassBundleDark/SassBundleLayers scaffolding from quarto-sass (ported early from the TS architecture; the epic delivered dark variants through per-variant ThemeConfigs instead — the module doc records the decision). Writes docs/guides/formats/html/themes.qmd (previously a TBD stub): theme basics, the light/dark map form and its key-order default rule, toggle behavior and persistence, respect-user-color-scheme, the color-scheme/light-dark() styling idiom, .light-content/.dark-content, highlight-style (incl. the adaptive a11y pair), and light/dark brands. Anchor ids match the pre-existing links from the brand guide (#dark-mode, #mode-specific-content); rendered with q2 and inspected. Audit notes recorded on bd-36vmz7nk and bd-qmpygp02: the silent DEFAULT_CSS compile-failure fallback now applies per variant, where a failing dark compile ships light CSS under the dark artifact key. 12,158 workspace tests green. Part of the light/dark epic (bd-0pic6); plan: claude-notes/plans/2026-08-14-light-dark-theme-epic.md Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 18 +- crates/quarto-sass/src/lib.rs | 4 +- crates/quarto-sass/src/types.rs | 311 +----------------- docs/guides/formats/html/themes.qmd | 149 ++++++++- 4 files changed, 172 insertions(+), 310 deletions(-) diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index 602d3454a..f973d4de7 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -543,10 +543,20 @@ Integration branch: `feature/light-dark-theme` (created off `main`). - [ ] **D — preview/hub-client** (D8): `bd-ld-d-preview-hub-t4oxv0hf`. VFS/iframe dual transport, editor-scheme integration (related: bd-nxe8). Uses lessons from A. -- [ ] **E — cleanup**: `bd-ld-e-cleanup-qxidnkng`. Delete-or-wire - `SassBundle{,Dark}` scaffolding, docs (`docs/` user-facing dark-mode page, - `light-dark()` idiom, migration notes), audit `bd-36vmz7nk`/`bd-qmpygp02` - fallback posture for the dark compile path. +- [x] **E — cleanup**: `bd-ld-e-cleanup-qxidnkng`. **Done 2026-08-14.** + Deleted the never-wired `SassBundle`/`SassBundleDark`/`SassBundleLayers` + scaffolding (the epic delivered dark variants through per-variant + `ThemeConfig`s instead; module doc records why). Wrote the user-facing + `docs/guides/formats/html/themes.qmd` (was a TBD stub): theme basics, + light/dark map + key-order default, toggle/persistence, + `respect-user-color-scheme`, `color-scheme`/`light-dark()` idiom, + `.light-content`/`.dark-content`, highlight-style, brand pairs — with + anchors matching the pre-existing links from `brand.qmd` + (`#dark-mode`, `#mode-specific-content`); rendered with q2 and + inspected. Audit notes recorded on bd-36vmz7nk / bd-qmpygp02: the + silent DEFAULT_CSS fallback now applies per variant, where a failed + dark compile ships LIGHT css under the dark key — extra weight for the + hard-error posture. Q-14-3 retirement was completed in A2. Follow-up already filed: `bd-ld-toggle-into-tools-hpae7m9r` — fold the hardcoded toggle into `tools:` when bd-fod3 lands. diff --git a/crates/quarto-sass/src/lib.rs b/crates/quarto-sass/src/lib.rs index 7f6caf0df..caaa81c87 100644 --- a/crates/quarto-sass/src/lib.rs +++ b/crates/quarto-sass/src/lib.rs @@ -3,7 +3,7 @@ //! Copyright (c) 2025 Posit, PBC //! //! This crate provides: -//! - Core types (SassLayer, SassBundleLayers, SassBundle) +//! - Core types (SassLayer) //! - Layer parsing from SCSS content with boundary markers //! - Layer merging with correct precedence handling //! - Embedded Bootstrap 5.3.1 SCSS resources @@ -81,4 +81,4 @@ pub use themes::{ load_quarto_customization_layer, load_theme_layer, process_theme_specs, resolve_theme, resolve_theme_spec, }; -pub use types::{SassBundle, SassBundleDark, SassBundleLayers, SassLayer}; +pub use types::SassLayer; diff --git a/crates/quarto-sass/src/types.rs b/crates/quarto-sass/src/types.rs index eb22597db..dc05e4e4f 100644 --- a/crates/quarto-sass/src/types.rs +++ b/crates/quarto-sass/src/types.rs @@ -2,14 +2,16 @@ //! //! Copyright (c) 2025 Posit, PBC //! -//! The type hierarchy is: -//! - SassLayer: Smallest unit, organizes SCSS by purpose (uses, defaults, functions, mixins, rules) -//! - SassBundleLayers: Groups layers by audience (framework, quarto, user) with load paths -//! - SassBundle: Complete bundle with metadata (dependency, dark mode, HTML attributes) +//! The central type is [`SassLayer`]: the smallest unit, organizing +//! SCSS by purpose (uses, defaults, functions, mixins, rules). The +//! pipeline composes `Vec` directly (see `bundle.rs`). +//! +//! (The TS-architecture `SassBundle`/`SassBundleDark` wrapper types +//! were ported early but never wired — the light/dark epic (bd-0pic6) +//! delivered dark variants through per-variant `ThemeConfig`s +//! instead, and the dead scaffolding was removed in its phase E.) use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; /// A single SASS layer with organized sections. /// @@ -68,122 +70,6 @@ impl SassLayer { } } -/// Bundle of layers organized by audience. -/// -/// The layers represent different sources of SCSS: -/// - `framework`: Bootstrap, Reveal.js, or other framework SCSS -/// - `quarto`: Quarto's built-in SCSS -/// - `user`: User-provided customizations (can be multiple layers) -/// -/// The `load_paths` specify directories to search for @use/@import resolution. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SassBundleLayers { - /// Unique identifier for this bundle (used for caching) - pub key: String, - - /// Framework layer (Bootstrap, Reveal.js, etc.) - #[serde(skip_serializing_if = "Option::is_none")] - pub framework: Option, - - /// Quarto's built-in layer - #[serde(skip_serializing_if = "Option::is_none")] - pub quarto: Option, - - /// User customization layers (multiple allowed) - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub user: Vec, - - /// Paths to search for @use/@import resolution - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub load_paths: Vec, -} - -/// Dark mode variant layers. -/// -/// Used when a document has both light and dark themes. -/// The `default` flag indicates whether dark mode is the default. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SassBundleDark { - /// Framework dark mode layer - #[serde(skip_serializing_if = "Option::is_none")] - pub framework: Option, - - /// Quarto dark mode layer - #[serde(skip_serializing_if = "Option::is_none")] - pub quarto: Option, - - /// User dark mode layers - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub user: Vec, - - /// Whether dark mode is the default - #[serde(default)] - pub default: bool, -} - -/// Complete SASS bundle with metadata. -/// -/// This is the top-level type used for SASS compilation. -/// It includes: -/// - All layers from `SassBundleLayers` -/// - Dependency information (which framework is being used) -/// - Optional dark mode variant -/// - HTML attributes to apply to the compiled CSS link -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SassBundle { - /// Unique identifier for this bundle - pub key: String, - - /// Framework layer (Bootstrap, Reveal.js, etc.) - #[serde(skip_serializing_if = "Option::is_none")] - pub framework: Option, - - /// Quarto's built-in layer - #[serde(skip_serializing_if = "Option::is_none")] - pub quarto: Option, - - /// User customization layers - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub user: Vec, - - /// Paths to search for @use/@import resolution - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub load_paths: Vec, - - /// Which framework this bundle depends on (e.g., "bootstrap") - pub dependency: String, - - /// Dark mode variant layers - #[serde(skip_serializing_if = "Option::is_none")] - pub dark: Option, - - /// HTML attributes for the compiled CSS (e.g., {"data-theme": "custom"}) - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub attribs: HashMap, -} - -impl SassBundle { - /// Create a new bundle with a key and dependency - pub fn new(key: impl Into, dependency: impl Into) -> Self { - Self { - key: key.into(), - dependency: dependency.into(), - ..Default::default() - } - } - - /// Convert to SassBundleLayers (loses dependency, dark, and attribs) - pub fn into_layers(self) -> SassBundleLayers { - SassBundleLayers { - key: self.key, - framework: self.framework, - quarto: self.quarto, - user: self.user, - load_paths: self.load_paths, - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -205,15 +91,6 @@ mod tests { assert!(layer.has_content()); } - #[test] - fn test_sass_bundle_new() { - let bundle = SassBundle::new("my-bundle", "bootstrap"); - assert_eq!(bundle.key, "my-bundle"); - assert_eq!(bundle.dependency, "bootstrap"); - assert!(bundle.framework.is_none()); - assert!(bundle.user.is_empty()); - } - #[test] fn test_sass_layer_serde_roundtrip() { let layer = SassLayer { @@ -239,176 +116,4 @@ mod tests { assert_eq!(layer.mixins, ""); assert_eq!(layer.rules, ""); } - - #[test] - fn test_sass_bundle_into_layers() { - let bundle = SassBundle { - key: "test-bundle".to_string(), - dependency: "bootstrap".to_string(), - framework: Some(SassLayer { - defaults: "$fw: 1;".to_string(), - ..Default::default() - }), - quarto: Some(SassLayer { - defaults: "$q: 2;".to_string(), - ..Default::default() - }), - user: vec![SassLayer { - rules: ".user { color: red; }".to_string(), - ..Default::default() - }], - load_paths: vec![PathBuf::from("/path/to/scss")], - dark: Some(SassBundleDark::default()), - attribs: HashMap::from([("data-theme".to_string(), "custom".to_string())]), - }; - - let layers = bundle.into_layers(); - - assert_eq!(layers.key, "test-bundle"); - assert!(layers.framework.is_some()); - assert!(layers.quarto.is_some()); - assert_eq!(layers.user.len(), 1); - assert_eq!(layers.load_paths.len(), 1); - // Note: dependency, dark, and attribs are lost in conversion - } - - #[test] - fn test_sass_bundle_layers_serde_roundtrip() { - let layers = SassBundleLayers { - key: "layers-test".to_string(), - framework: Some(SassLayer { - functions: "@function fw() { @return 1; }".to_string(), - ..Default::default() - }), - quarto: None, - user: vec![ - SassLayer { - defaults: "$user1: 1;".to_string(), - ..Default::default() - }, - SassLayer { - defaults: "$user2: 2;".to_string(), - ..Default::default() - }, - ], - load_paths: vec![PathBuf::from("/scss"), PathBuf::from("/bootstrap")], - }; - - let json = serde_json::to_string(&layers).unwrap(); - let parsed: SassBundleLayers = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.key, "layers-test"); - assert!(parsed.framework.is_some()); - assert!(parsed.quarto.is_none()); - assert_eq!(parsed.user.len(), 2); - assert_eq!(parsed.load_paths.len(), 2); - } - - #[test] - fn test_sass_bundle_dark_serde_roundtrip() { - let dark = SassBundleDark { - framework: Some(SassLayer { - defaults: "$dark-bg: #222;".to_string(), - ..Default::default() - }), - quarto: None, - user: vec![SassLayer { - rules: ".dark { background: black; }".to_string(), - ..Default::default() - }], - default: true, - }; - - let json = serde_json::to_string(&dark).unwrap(); - let parsed: SassBundleDark = serde_json::from_str(&json).unwrap(); - - assert!(parsed.framework.is_some()); - assert!(parsed.quarto.is_none()); - assert_eq!(parsed.user.len(), 1); - assert!(parsed.default); - } - - #[test] - fn test_sass_bundle_full_serde_roundtrip() { - let bundle = SassBundle { - key: "full-bundle".to_string(), - dependency: "bootstrap".to_string(), - framework: Some(SassLayer { - uses: "@use 'sass:color';".to_string(), - defaults: "$primary: blue;".to_string(), - functions: "@function f() { @return 1; }".to_string(), - mixins: "@mixin m() { color: red; }".to_string(), - rules: ".fw { display: block; }".to_string(), - }), - quarto: Some(SassLayer { - defaults: "$quarto-var: 1;".to_string(), - ..Default::default() - }), - user: vec![SassLayer { - rules: ".custom { margin: 0; }".to_string(), - ..Default::default() - }], - load_paths: vec![PathBuf::from("/scss")], - dark: Some(SassBundleDark { - framework: None, - quarto: None, - user: vec![], - default: false, - }), - attribs: HashMap::from([ - ("data-theme".to_string(), "custom".to_string()), - ("id".to_string(), "main-styles".to_string()), - ]), - }; - - let json = serde_json::to_string_pretty(&bundle).unwrap(); - let parsed: SassBundle = serde_json::from_str(&json).unwrap(); - - assert_eq!(parsed.key, "full-bundle"); - assert_eq!(parsed.dependency, "bootstrap"); - assert!(parsed.framework.is_some()); - assert!(parsed.quarto.is_some()); - assert_eq!(parsed.user.len(), 1); - assert_eq!(parsed.load_paths.len(), 1); - assert!(parsed.dark.is_some()); - assert_eq!(parsed.attribs.len(), 2); - } - - #[test] - fn test_serde_skip_empty_fields() { - // Empty bundle should serialize without optional fields - let bundle = SassBundle::new("minimal", "bootstrap"); - let json = serde_json::to_string(&bundle).unwrap(); - - // These fields should be absent due to skip_serializing_if - assert!(!json.contains("framework")); - assert!(!json.contains("quarto")); - assert!(!json.contains("user")); - assert!(!json.contains("load_paths")); - assert!(!json.contains("dark")); - assert!(!json.contains("attribs")); - - // These required fields should be present - assert!(json.contains("key")); - assert!(json.contains("dependency")); - } - - #[test] - fn test_sass_bundle_layers_default() { - let layers = SassBundleLayers::default(); - assert_eq!(layers.key, ""); - assert!(layers.framework.is_none()); - assert!(layers.quarto.is_none()); - assert!(layers.user.is_empty()); - assert!(layers.load_paths.is_empty()); - } - - #[test] - fn test_sass_bundle_dark_default() { - let dark = SassBundleDark::default(); - assert!(dark.framework.is_none()); - assert!(dark.quarto.is_none()); - assert!(dark.user.is_empty()); - assert!(!dark.default); - } } diff --git a/docs/guides/formats/html/themes.qmd b/docs/guides/formats/html/themes.qmd index 96ef74aa3..e08443069 100644 --- a/docs/guides/formats/html/themes.qmd +++ b/docs/guides/formats/html/themes.qmd @@ -2,4 +2,151 @@ title: HTML Themes --- -TBD. +## Overview + +Quarto's HTML output is styled with [Bootstrap](https://getbootstrap.com), +and the `theme:` option selects how it looks. You can use a built-in +theme, layer your own SCSS on top of one, or provide light and dark +variants that readers can switch between. + +## Basic themes + +Pick any built-in [Bootswatch](https://bootswatch.com) theme by name: + +``` yaml +format: + html: + theme: cosmo +``` + +Layer custom SCSS on top of a built-in theme by listing entries in +order — later entries override earlier ones: + +``` yaml +format: + html: + theme: + - cosmo + - custom.scss +``` + +Custom theme files use SCSS with Quarto's layer markers +(`/*-- scss:defaults --*/` for variables, `/*-- scss:rules --*/` for +rules), and can use all of Bootstrap's variables, functions, and +mixins. + +To opt out of Bootstrap entirely, use `theme: none`. + +## Light and dark mode {#dark-mode} + +Provide a theme for each color scheme with the `light:`/`dark:` map +form: + +``` yaml +format: + html: + theme: + light: [cosmo, theme.scss] + dark: [cosmo, theme-dark.scss] +``` + +Quarto compiles a full stylesheet for each variant and adds a toggle +to the page (in the navbar for websites, floating in the top-right +corner otherwise). A reader's choice is remembered in the browser and +applied before the page paints, so there is no flash on navigation. + +The variant listed **first** in the map is the default. To make dark +the default, write the `dark:` entry first: + +``` yaml +format: + html: + theme: + dark: [cosmo, theme-dark.scss] + light: [cosmo, theme.scss] +``` + +To have the initial scheme follow the reader's operating-system +preference instead of the author default, set: + +``` yaml +format: + html: + respect-user-color-scheme: true +``` + +An explicit choice made with the toggle still wins over the system +preference on later visits. + +### Styling per scheme {#mode-specific-content} + +Each compiled variant declares its own +[`color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme), +so native browser chrome — scrollbars, form controls, picker popups — +follows the theme automatically. It also means custom CSS can use the +`light-dark()` function to give one rule both values: + +``` css +.my-callout { + border-color: light-dark(#005599, #66aaff); +} +``` + +For larger structural differences, the `body` element carries a +`quarto-light` or `quarto-dark` class that CSS can key on, and content +marked with the `.light-content` / `.dark-content` classes is shown +only in the matching scheme: + +``` markdown +::: {.light-content} +![](logo-light.png) +::: + +::: {.dark-content} +![](logo-dark.png) +::: +``` + +### Syntax highlighting + +The `highlight-style:` option selects the code-highlighting palette. +The adaptive `a11y` style pairs naturally with a light/dark theme — +each variant gets the matching palette: + +``` yaml +format: + html: + theme: + light: cosmo + dark: darkly +highlight-style: a11y +``` + +You can also select palettes explicitly, per variant: + +``` yaml +highlight-style: + light: a11y + dark: a11y +``` + +Currently available palettes: `default`, `a11y` (adaptive), +`a11y-light`, and `a11y-dark`. With a single dark theme (e.g. +`theme: darkly`), adaptive styles resolve to their dark variant +automatically. + +### Light and dark brands + +Projects using [brand.yml](https://posit-dev.github.io/brand-yml/) can +provide a brand per scheme: + +``` yaml +brand: + light: brand-light.yml + dark: brand-dark.yml +``` + +A dark brand enables dark mode on its own — even with a single +`theme:` entry, the page gets both variants and the toggle. As with +themes, the variant listed first in the `brand:` map is the default +(unless a `theme:` light/dark map decides otherwise). From dd2486dc2a6ee72da15f5c6b996e72d628d401e4 Mon Sep 17 00:00:00 2001 From: Carlos Scheidegger Date: Fri, 14 Aug 2026 16:47:41 -0500 Subject: [PATCH 10/10] light-dark: record phase D deferral + design options in the plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-14-light-dark-theme-epic.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md index f973d4de7..08a9fdd65 100644 --- a/claude-notes/plans/2026-08-14-light-dark-theme-epic.md +++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md @@ -542,7 +542,22 @@ Integration branch: `feature/light-dark-theme` (created off `main`). green. - [ ] **D — preview/hub-client** (D8): `bd-ld-d-preview-hub-t4oxv0hf`. VFS/iframe dual transport, editor-scheme integration (related: bd-nxe8). - Uses lessons from A. + **Deferred to its own session (2026-08-14)** — the last remaining child. + It is the only one touching the TypeScript stack, and it carries a real + UX decision the A-lane surfaced: the preview'd document now ships its own + inline toggle runtime, so the iframe transport could either (a) inject + BOTH variants as ``s carrying the A3 classes/`data-mode`, letting + the document's own runtime + toggle work inside the iframe verbatim + (elegant; needs care with injection-vs-script timing since the parent + posts CSS after the inline script has run), or (b) drive the variant from + hub-client's editor `ColorScheme` (ThemeContext.tsx) with no in-preview + toggle, or a hybrid (document toggle wins, editor scheme as the initial + signal). Recommend (a)+initial-signal hybrid; decide with Carlos at + session start. Mechanical inventory: `pass2_renderer.rs` styles.css VFS + write → add `styles-dark.css`; wasm `extract_theme_fingerprint` → pair; + `Q2PreviewIframe.tsx` `UPDATE_THEME` + `entry.tsx` `applyTheme` → + two-slot; q2-preview SPA equivalents; extend + `preview_render_css_parity.rs` to the dark artifact. - [x] **E — cleanup**: `bd-ld-e-cleanup-qxidnkng`. **Done 2026-08-14.** Deleted the never-wired `SassBundle`/`SassBundleDark`/`SassBundleLayers` scaffolding (the epic delivered dark variants through per-variant