Skip to content

Light/dark theme support: dual CSS compilation, toggle runtime, highlight-style, brand pairs (bd-0pic6) - #537

Open
cscheid wants to merge 10 commits into
mainfrom
feature/light-dark-theme
Open

Light/dark theme support: dual CSS compilation, toggle runtime, highlight-style, brand pairs (bd-0pic6)#537
cscheid wants to merge 10 commits into
mainfrom
feature/light-dark-theme

Conversation

@cscheid

@cscheid cscheid commented Aug 14, 2026

Copy link
Copy Markdown
Member

Implements full light/dark dark-mode support for format: html and website projects — phases A1–A5, B, C, and E of the light/dark epic (bd-0pic6). Design doc: claude-notes/plans/2026-08-14-light-dark-theme-epic.md.

What this delivers

format:
  html:
    respect-user-color-scheme: true
    theme:
      light: [cosmo, theme.scss]
      dark: [cosmo, theme-dark.scss]
highlight-style: a11y

renders with both CSS variants compiled, a navbar (or floating) toggle, prefers-color-scheme support, localStorage persistence (Q1-compatible key/values), correct body.quarto-light/quarto-dark classes, .light-content/.dark-content swapping, and variant-matched syntax highlighting — with no FOUC.

Commits (one per epic phase, TDD throughout)

  • A1 — data model: ThemeConfig parses both halves of the theme map; YAML key order decides the author-default variant (merge key-order preservation guarded by new quarto-config tests).
  • A2 — dual compile: the dark half compiles through the same pure pipeline via a dark_variant() projection into css:theme-dark:<fp> artifacts (styles-dark.css for single-doc). The css:theme-dark: key prefix deliberately doesn't extend css:theme:, so every existing light-only prefix consumer needed zero changes. Every compiled theme now emits :root{color-scheme:…} from the vendored darkness sentinel — an improvement over Q1 (correct native scrollbars/form controls; also benefits single dark themes like theme: darkly). Interim Q-14-3 retired (emission, catalog, docs page).
  • A3 — link emission: Artifact gains link_attribs/link_order; the theme trio is emitted with Q1's exact contract (quarto-color-scheme / quarto-color-alternate / trailing quarto-color-scheme-extra copy, shared id="quarto-bootstrap", data-mode from each sheet's compiled darkness) in FOUC-safe order, plus <meta name="color-scheme">. Single-variant output stays byte-identical (golden-hash test unchanged).
  • A4 — toggle runtime: inline before-paint quarto-color-mode.js (de-EJS'd port of Q1's before-body script + floating-fallback), body-class baking incl. default-dark, navbar toggle via Navbar.dark_mode_toggle. Browser verification found and fixed a latent bug: colorToRGBA() was never ported to _bootstrap-functions.scss, leaving toggle icons with a silently-invalid SVG fill (regression test added).
  • A5 — scale verification: connect-docs testbed (posit-docs extension theme map): 352/352 pages, zero errors, one shared light+dark artifact pair deduped via site_libs, toggle browser-verified.
  • B — highlight-style: first reader for the key (scalar, {light, dark} map, adaptive a11y); highlight.scss split into structural rules + swappable palettes with hand-translated a11y-light/a11y-dark; new Q-14-5 warning for unknown names; all default-CSS shortcut paths guarded so palettes can't be bypassed or poison the shared cache.
  • C — brand seam: brand: {light, dark} pairs drive per-variant compiles, and a dark brand alone enables full dark mode (synthesized variant). Unblocks bd-v5z8w.
  • E — cleanup + docs: dead SassBundle scaffolding removed; docs/guides/formats/html/themes.qmd written (was a stub).

Deliberate divergences from Q1 (documented in the plan/design doc)

  • color-scheme emission + light-dark() as the documented styling idiom (Q1 lacks both; supersedes Q1's Safari scrollbar hack, which is not ported).
  • No giscus handling (Q2 has no comments support yet).
  • FOUC avoidance is the hard constraint; no-JS degradation keeps Q1's emit-default-last behavior at zero cost.

Out of scope / follow-ups filed

  • Phase D (preview/hub-client dual-theme transport): bd-ld-d-preview-hub-t4oxv0hf, design options recorded in the plan.
  • General .theme-JSON palette translator: bd-hl-theme-translator-2mdgh4k6.
  • Unified _brand.yml field-level splitting: bd-unified-brand-split-ep49amad.
  • Toggle folding into website.tools:: bd-ld-toggle-into-tools-hpae7m9r.

Verification

  • 12,158 workspace tests green; full cargo xtask verify (including the hub-client/WASM leg) passed twice; WASM leg rebuilt and checked after each phase.
  • Golden-hash baseline re-captured twice with documented single-cause deltas (the color-scheme rule; the colorToRGBA fill fix).
  • Browser-verified end-to-end (chrome-devtools): toggle both directions, persistence across reload restored before paint, content swapping, icon states, no console errors.

🤖 Generated with Claude Code

cscheid and others added 10 commits August 14, 2026 15:06
Design settled 2026-08-14: Q1-compatible rel-swap runtime with the
color-scheme improvement (D1a), FOUC avoidance as the hard constraint,
epic children A1-E created in braid, integration branch
feature/light-dark-theme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-model-a12bhj1g)

The theme: {light, dark} map now parses BOTH halves. DarkThemeConfig
{themes, theme_locations, suppress_bootstrap, is_default, key_location}
replaces dark_theme_ignored on ThemeConfig; the dark half goes through
the same from_theme_value parsing as the light half (none sentinel,
unknown-theme errors, PandocInlines frontmatter form). is_default
implements Q1's key-order rule (dark written first = dark is the author
default); two new quarto-config materialize tests guard the key-order
preservation that rule depends on. Brand token auto-injection now
applies per variant, and an explicit brand token in the dark list keeps
its position; naming brand in either half without brand: errors.
ResolvedThemeConfig carries the dark variant through resolve().

Zero behavior change by design: Q-14-3 still fires (now keyed off
dark.key_location) and only the light half compiles; both retire in A2
(dual compile). bootstrap_js predicate also updates in A2.

TDD: 10 new/extended quarto-sass unit tests confirmed red before the
implementation; full workspace suite green (12,125 tests).

Part of the light/dark epic (bd-0pic6); plan:
claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-ld-a2-dual-compile-ds10l5wa)

CompileThemeCssStage now compiles the dark half of a theme:
{light, dark} map through the same pure pipeline, via the
ThemeConfig::dark_variant() projection, into a css:theme-dark:<fp>
artifact (quarto/quarto-theme-dark-<fp>.css; styles-dark.css for
single-doc renders). The stage body is refactored into a per-variant
variant_css() helper preserving the exact suppress/fast-path/themed
behavior per variant. The css:theme-dark: key prefix deliberately does
not extend css:theme:, so every existing light-only prefix consumer
keeps working unchanged.

D1a: the darkness-sentinel block in _bootstrap-rules.scss now also
declares :root{color-scheme:light|dark}, so UA-drawn chrome follows
theme darkness — including for single dark themes (theme: darkly) and
for custom-SCSS dark halves detected via $body-bg blackness (the
quarto-web shape). Verified to survive grass minification.

Q-14-3 (interim dark-half-ignored warning) is fully retired: emission,
catalog entry, docs page, and test references removed. BootstrapJsStage
now ships JS iff any variant ships Bootstrap
(ThemeConfig::ships_bootstrap), so {light: none, dark: darkly} keeps
its JS.

Interim state note: both variants are linked as plain stylesheets with
the dark link sorting first, so the light variant wins the cascade and
page appearance is unchanged until A3 (link attributes) and A4 (toggle)
land.

Golden-hash baseline re-captured for the one-rule color-scheme delta
(entry documents the verification). TDD: 5 stage/integration tests
confirmed red first; 12,135 workspace tests green.

Part of the light/dark epic (bd-0pic6); plan:
claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-a3-link-emission-ruw9kw4v)

Artifact gains link_attribs (order-preserving Vec of HTML attributes
for the emitted tag) and link_order (emission sort priority);
collect_artifact_urls sorts by (link_order, key) and returns structured
LinkedResource entries. Attribute-free entries still render as plain
template strings, so custom templates writing $css$ keep working and
single-variant output is byte-identical (golden-hash test unchanged).
Attributed entries render as maps consumed by new single-line
$if(css.href)$ branches in both built-in templates.

The theme pair is stored with Q1's exact link contract: light
(class=quarto-color-scheme, id=quarto-bootstrap), dark
(quarto-color-scheme quarto-color-alternate), and — for
author-default-light — a trailing re-link of the same light file with
class=quarto-color-scheme-extra so pre-toggle paint and no-JS browsers
land on the default variant (the FOUC hard constraint; class replaces
so the future toggle's selectors skip it). data-mode comes from each
sheet's compiled /*! dark */ sentinel rather than its slot, handling
custom-SCSS dark halves.

D1a: the full template head now emits <meta name="color-scheme"> when
a dark variant exists — author default first, both schemes when
respect-user-color-scheme: true (first reader for that key; A4 reuses
it for the toggle runtime).

TDD: 3 integration tests red first; E2E via real q2 render inspected
(link trio + meta byte-shape verified). 12,139 workspace tests green.

Part of the light/dark epic (bd-0pic6); plan:
claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…i2rvs)

Adds quarto-color-mode.js, a de-EJS'd port of Q1's before-body toggle
script (rel-swap between stylesheet/disabled-stylesheet, body
quarto-light/quarto-dark sync from the active sheet's data-mode, root
color-scheme sync, localStorage quarto-color-scheme persistence with
Q1's key and default/alternate values, respect-user-color-scheme via
prefers-color-scheme with explicit-choice-wins, floating top-right
fallback toggle folded in from Q1's after-body script). Injected inline
as the first child of <body> so initial variant selection runs before
first paint (the FOUC hard constraint); configured via data attributes.
Deliberate divergences: no Safari scrollbar hack (color-scheme
supersedes it), no giscus (no comments support yet).

append_color_mode_class grows its default_dark argument (bd-mtzry):
author-default-dark maps bake body.quarto-dark. Navbar gains a
dark_mode_toggle flag set by NavbarGenerateTransform from the theme
config (Q1's formatDarkMode trigger) and rendered as the
quarto-navbar-tools slot; folds into general tools: support later
(bd-ld-toggle-into-tools-hpae7m9r). bd-l1rx9yzh resolves as a side
effect: both .light-content/.dark-content swap rules were already
compiled, and the body-class flip now makes them live.

Fixes a latent bug found during browser verification: colorToRGBA()
was never ported into _bootstrap-functions.scss, so the toggle icons'
SVG data-URI fills contained the literal call text (silently invalid
inside string interpolation) and the icon was invisible. Ported from
Q1's _quarto-functions.scss with a regression test; golden-hash
baseline re-captured for the fill delta.

TDD: 4 integration tests red first. Browser-verified end-to-end with
chrome-devtools MCP: toggle both directions, persistence across
reload (restored before paint), content swapping, icon states, no
console errors. 12,145 workspace tests green.

Part of the light/dark epic (bd-0pic6); plan:
claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-web-e2e-bzg4o5lc)

Plan-doc update recording the A5 verification. Target corrected per
Carlos: quarto-web is currently a Quarto 1 project (probe findings
recorded in the plan: Q-5-24 alias conflicts incl. an apparent
upstream copy-paste bug, Pandoc-style attr order in the footer vs
qmd's Q-2-3 rule, plus broad Q1-content gaps); the intended large
testbed is the connect-docs project with the posit-docs extension's
theme: {light, dark} map.

Testbed result: 352/352 files rendered, zero errors, zero Q-14-3.
One shared light + one dark fingerprinted artifact deduped across all
pages via site_libs; link trio, color-scheme meta, inline runtime,
and navbar toggle on every page. Browser-verified: toggle applies the
extension's full dark palette (#181c25), persists, and restores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ht-style-jnb036fz)

First highlight-style reader in Q2. ThemeConfig parses scalar and
{light, dark} forms into per-variant HighlightStyle entries, resolving
adaptive names at parse time: a11y becomes a11y-light/a11y-dark. Pair
halves resolve by role (the quarto-web shape darkens a cosmo base via
custom SCSS, so builtin darkness would mislead); single-variant configs
resolve via BuiltInTheme::is_dark (theme: darkly + a11y -> a11y-dark).

highlight.scss is split into palette-independent structural rules plus
swappable palette files: highlight-default.scss (the original solarized
palette, extracted verbatim) and hand-translated
highlight-a11y-{light,dark}.scss (from Q1's a11y .theme JSONs onto the
tree-sitter hl-* class vocabulary, with palette-level $code-block-bg /
$code-block-color defaults that user themes can still override).
load_highlight_layer(palette) composes structural + palette; unknown
names fall back to default.

Correctness guards: every default-CSS shortcut (stage fast path,
native/wasm compile_with_doc_vars, native/wasm compile_theme_css) now
requires highlight_style.is_none() so a palette never bypasses
assembly or poisons the shared DEFAULT_CSS_CACHE, and the stage
cache_key gained a palette discriminator. Unknown names warn once per
name via new Q-14-5 (catalog entry + docs page). The LSP semantic-token
legend parity test now reads the default palette file.

Follow-up filed: bd-hl-theme-translator-2mdgh4k6 (general .theme
translator for the full Q1 catalog, copy-button color feedback,
sentinel-based single-variant resolution).

TDD: parse/compile/integration tests red first (incl. the
fast-path-bypass case found by writing the no-theme test). E2E via
real q2 render inspected (light css #d91e18 / dark css #ffa07a).
12,158 workspace tests green.

Part of the light/dark epic (bd-0pic6); plan:
claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
extract_brand_refs now extracts BOTH halves of a brand: {light:, dark:}
pair (removing the silent light-only TODO from the original brand
port). DarkThemeConfig carries its own brand_ref, with parse-time
fallback to the light brand when the pair has no dark half (Q1's
per-layer fallback semantics).

A dark brand ENABLES dark mode (Q1's enablesDarkMode): when the brand
pair exists without a theme: dark half, the dark variant is synthesized
from the light theme list and flows through the entire established
machinery — dual compilation, attributed links, color-scheme meta, and
the toggle — automatically. The author default falls back to the brand
map's key order; a theme-declared pair's own key order wins when both
maps exist. A dark-only brand ({dark: b.yml}) synthesizes a
default-dark variant with no brand on the light side.

CompileThemeCssStage resolves each variant's brand into its own
ThemeContext, so the dark compile uses the dark brand and cache keys
discriminate via the brand hash already included in cache_key.
Single-variant brand consumers (revealjs, favicon fallback) explicitly
keep the light half.

Scope split: field-level {light, dark} values inside a unified
_brand.yml (Q1's splitUnifiedBrand; needs untagged-enum type surgery in
quarto-brand) filed as bd-unified-brand-split-ep49amad. This commit
delivers the seam bd-v5z8w asked for.

TDD: 6 parse tests + 1 integration test red first (the integration red
pinpointed exactly the shared-ThemeContext gap). E2E via real q2
render inspected: per-variant --bs-primary-rgb values, dark brand
background driving color-scheme:dark. 12,166 workspace tests green.

Part of the light/dark epic (bd-0pic6); plan:
claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deletes the never-wired SassBundle/SassBundleDark/SassBundleLayers
scaffolding from quarto-sass (ported early from the TS architecture;
the epic delivered dark variants through per-variant ThemeConfigs
instead — the module doc records the decision).

Writes docs/guides/formats/html/themes.qmd (previously a TBD stub):
theme basics, the light/dark map form and its key-order default rule,
toggle behavior and persistence, respect-user-color-scheme, the
color-scheme/light-dark() styling idiom, .light-content/.dark-content,
highlight-style (incl. the adaptive a11y pair), and light/dark brands.
Anchor ids match the pre-existing links from the brand guide
(#dark-mode, #mode-specific-content); rendered with q2 and inspected.

Audit notes recorded on bd-36vmz7nk and bd-qmpygp02: the silent
DEFAULT_CSS compile-failure fallback now applies per variant, where a
failing dark compile ships light CSS under the dark artifact key.

12,158 workspace tests green.

Part of the light/dark epic (bd-0pic6); plan:
claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@posit-snyk-bot

posit-snyk-bot commented Aug 14, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants