Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
622 changes: 622 additions & 0 deletions claude-notes/plans/2026-08-14-light-dark-theme-epic.md

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions crates/quarto-config/src/materialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]);
Expand Down
220 changes: 220 additions & 0 deletions crates/quarto-core/resources/js/quarto-color-mode.js
Original file line number Diff line number Diff line change
@@ -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 <body> 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());
});
})();
40 changes: 40 additions & 0 deletions crates/quarto-core/src/artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link>` / `<script>` tag this
/// artifact is emitted with by `ApplyTemplateStage` (e.g. the
/// light/dark theme sheets' `class` / `id` / `data-mode` —
/// bd-0pic6 A3). Empty (the default) emits today's plain tag.
/// Order-preserving `Vec` rather than a map: attribute order is
/// part of the deterministic output.
pub link_attribs: Vec<(String, String)>,

/// Sort priority for `<link>` / `<script>` emission:
/// `ApplyTemplateStage` orders entries by `(link_order, key)`.
/// `0` (the default) preserves the pre-existing pure
/// lexicographic-key order; the theme sheets use positive values
/// to pin the FOUC-safe light → dark → default-copy sequence.
pub link_order: i32,
}

impl Artifact {
Expand All @@ -87,6 +102,8 @@ impl Artifact {
path: None,
metadata: HashMap::new(),
scope: ArtifactScope::default(),
link_attribs: Vec::new(),
link_order: 0,
}
}

Expand All @@ -98,6 +115,8 @@ impl Artifact {
path: None,
metadata: HashMap::new(),
scope: ArtifactScope::default(),
link_attribs: Vec::new(),
link_order: 0,
}
}

Expand Down Expand Up @@ -131,6 +150,8 @@ impl Artifact {
path: Some(path),
metadata: HashMap::new(),
scope: ArtifactScope::default(),
link_attribs: Vec::new(),
link_order: 0,
}
}

Expand Down Expand Up @@ -163,6 +184,25 @@ impl Artifact {
self
}

/// Set the extra HTML attributes for the emitted `<link>` /
/// `<script>` tag. See [`Artifact::link_attribs`].
pub fn with_link_attribs(
mut self,
attribs: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
) -> Self {
self.link_attribs = attribs
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect();
self
}

/// Set the emission sort priority. See [`Artifact::link_order`].
pub fn with_link_order(mut self, order: i32) -> Self {
self.link_order = order;
self
}

/// Get content as UTF-8 string (lossy conversion for non-UTF8 data)
pub fn as_string(&self) -> String {
String::from_utf8_lossy(&self.content).into_owned()
Expand Down
Loading
Loading