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"); + } 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} +![](logo-light.png) +::: + +::: {.dark-content} +![](logo-dark.png) +::: +``` + +### Syntax highlighting + +The `highlight-style:` option selects the code-highlighting palette. +The adaptive `a11y` style pairs naturally with a light/dark theme — +each variant gets the matching palette: + +``` yaml +format: + html: + theme: + light: cosmo + dark: darkly +highlight-style: a11y +``` + +You can also select palettes explicitly, per variant: + +``` yaml +highlight-style: + light: a11y + dark: a11y +``` + +Currently available palettes: `default`, `a11y` (adaptive), +`a11y-light`, and `a11y-dark`. With a single dark theme (e.g. +`theme: darkly`), adaptive styles resolve to their dark variant +automatically. + +### Light and dark brands + +Projects using [brand.yml](https://posit-dev.github.io/brand-yml/) can +provide a brand per scheme: + +``` yaml +brand: + light: brand-light.yml + dark: brand-dark.yml +``` + +A dark brand enables dark mode on its own — even with a single +`theme:` entry, the page gets both variants and the toggle. As with +themes, the variant listed first in the `brand:` map is the default +(unless a `theme:` light/dark map decides otherwise). 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; -}