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..08a9fdd65
--- /dev/null
+++ b/claude-notes/plans/2026-08-14-light-dark-theme-epic.md
@@ -0,0 +1,622 @@
+# 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`).
+
+- [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).
+- [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.
+- [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.
+- [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.
+- [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.
+- [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.
+- [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).
+ **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
+ `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.
+
+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`
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/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/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,19 +242,22 @@ $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$
$endfor$
+$if(color-mode-script)$
+$color-mode-script$
+$endif$
$if(rendered.draft-alert-text)$
$rendered.draft-alert-text$
$endif$
@@ -629,8 +690,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 +716,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 +730,56 @@ 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));
}
+ // 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, author_prefers_dark) {
+ (true, false) => "light dark",
+ (true, true) => "dark light",
+ (false, false) => "light",
+ (false, true) => "dark",
+ };
+ ctx.insert(
+ "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
// claude-notes/plans/2026-05-04-includes-feature.md §Resolved questions
@@ -726,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));
}
@@ -756,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}")
}
}
@@ -813,7 +930,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 +954,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 +2594,7 @@ mod tests {
"body
",
&meta,
&[],
- &["libs/kbd/kbd.js".to_string()],
+ &[LinkedResource::plain("libs/kbd/kbd.js")],
)
.unwrap();
diff --git a/crates/quarto-core/src/theme_diagnostic.rs b/crates/quarto-core/src/theme_diagnostic.rs
index 5e34e6d2f..ded4e71a8 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", "Q-14-5"] {
let info = quarto_error_catalog::ERROR_CATALOG.get(code);
assert!(
info.is_some(),
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 36a1b8c27..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
@@ -275,5 +275,23 @@
# 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.
+# 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 aafc6d0b9ffbbe6fb12b50a2fc1c0d845f58b6018476494fea2127609a0bee2a
+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 fab15c8f0..1778bc3e3 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,677 @@ 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!(
+ !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);
+}
+
+/// 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(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#" "#),
+ "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#" ` (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"
+ );
+}
+
+/// 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-3")),
- "light-only map ignores nothing, so it must not warn"
+ .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");
+}
+
+/// 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.
+#[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..05686ca0e 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",
@@ -1147,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-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");
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()?;
@@ -1091,6 +1100,95 @@ 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"
+ );
+ }
+
+ /// 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 6ba82eec1..3900de2c9 100644
--- a/crates/quarto-sass/src/config.rs
+++ b/crates/quarto-sass/src/config.rs
@@ -102,19 +102,88 @@ 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,
+
+ /// 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
+/// (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.
+ 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,
+
+ /// 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
+/// (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
@@ -132,6 +201,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 +219,8 @@ impl ThemeConfig {
suppress_bootstrap: false,
title_block_layer: true,
brand_ref: None,
- dark_theme_ignored: None,
+ dark: None,
+ highlight_style: None,
}
}
@@ -161,7 +236,8 @@ impl ThemeConfig {
suppress_bootstrap: false,
title_block_layer: true,
brand_ref: None,
- dark_theme_ignored: None,
+ dark: None,
+ highlight_style: None,
}
}
@@ -172,11 +248,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
@@ -205,24 +280,37 @@ 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(),
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),
+ highlight_style: None,
+ brand_ref: None,
+ })
+ }
+ None => None,
+ };
cfg
}
None => Self::from_theme_value(value)?,
@@ -236,26 +324,54 @@ 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);
+ // 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);
+ match (&light_ref, light_has_token) {
+ (Some(_), false) if !result.suppress_bootstrap => {
result.themes.push(ThemeSpec::Brand);
result.theme_locations.push(None);
}
- (Some(br), true) => {
- // Token already present at user-specified position.
- result.brand_ref = Some(br);
- }
(None, true) => {
return Err(SassError::InvalidThemeConfig {
message: "`theme:` contains `brand` but no `_brand.yml` was configured \
@@ -264,9 +380,34 @@ impl ThemeConfig {
location: config.get("theme").map(|v| v.source_info.clone()),
});
}
- (None, false) => {}
+ _ => {}
+ }
+ 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
+ // so adaptive-name resolution can consult the variants.
+ parse_highlight_style(config, &mut result)?;
+
Ok(result)
}
@@ -290,7 +431,8 @@ impl ThemeConfig {
suppress_bootstrap: true,
title_block_layer: true,
brand_ref: None,
- dark_theme_ignored: None,
+ dark: None,
+ highlight_style: None,
});
}
let located = extract_theme_specs(value)?;
@@ -305,7 +447,8 @@ impl ThemeConfig {
suppress_bootstrap: false,
title_block_layer: true,
brand_ref: None,
- dark_theme_ignored: None,
+ dark: None,
+ highlight_style: None,
})
}
@@ -365,9 +508,40 @@ impl ThemeConfig {
suppress_bootstrap: self.suppress_bootstrap,
brand,
brand_dir,
+ dark: self.dark,
+ })
+ }
+
+ /// 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: d.brand_ref.clone(),
+ dark: None,
+ highlight_style: d.highlight_style.clone(),
})
}
+ /// 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
@@ -402,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).
@@ -413,7 +590,8 @@ pub fn resolve_brand(
suppress_bootstrap: false,
title_block_layer: true,
brand_ref: Some(brand_ref),
- dark_theme_ignored: None,
+ dark: None,
+ highlight_style: None,
}
.resolve(runtime, base_dir)?;
@@ -478,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"),
+ });
}
+ }
+
+ 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`.
+ // 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
@@ -529,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)));
}
}
@@ -615,6 +824,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
@@ -622,10 +943,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 +964,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 +1655,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 +1706,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 +1732,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 +1760,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 +1776,574 @@ 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_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());
+ }
+
+ // === 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")
+ );
+ }
+
+ // === 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
+ // 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 +2399,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 +2417,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..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
@@ -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,
@@ -64,7 +65,10 @@ 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, HighlightStyle, 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::{
@@ -77,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/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-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/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/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}
+
+:::
+
+::: {.dark-content}
+
+:::
+```
+
+### 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).
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") {
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
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;
-}