diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index ee4e744b22c..e28a239e054 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -53,6 +53,7 @@ All changes included in 1.9: - ([#13883](https://github.com/quarto-dev/quarto-cli/issues/13883)): Fix unequal top/bottom spacing in simple untitled callouts. - ([#13900](https://github.com/quarto-dev/quarto-cli/issues/13900)): Warn when `renderings` cell option contains duplicate names. Previously, duplicate names like `[dark, light, dark, light]` would silently use only the last output for each name. - ([#14065](https://github.com/quarto-dev/quarto-cli/issues/14065)): Fix `SCSSParsingError` when custom SCSS themes contain non-ASCII characters in selectors (e.g., `#présentation`). +- ([#14084](https://github.com/quarto-dev/quarto-cli/issues/14084)): Fix dark mode detection for brand colour overrides with perceptually-dark but low-HWB-blackness colours (e.g. blue `#0000FF`). The dark/light sentinel now uses oklch perceptual lightness instead of HWB blackness, and dual-theme builds inject the sentinel directly from the build pipeline. ### `typst` @@ -102,6 +103,7 @@ All changes included in 1.9: ### `revealjs` - ([#13722](https://github.com/quarto-dev/quarto-cli/issues/13722)): Fix `light-content` / `dark-content` SCSS rules not included in Reveal.js format. (author: @mcanouil) +- ([#14084](https://github.com/quarto-dev/quarto-cli/issues/14084)): Fix dark mode detection for brand colour overrides with perceptually-dark but low-HWB-blackness colours (e.g. blue `#0000FF`). The dark/light sentinel now uses oklch perceptual lightness instead of HWB blackness. ### `ipynb` diff --git a/src/command/render/pandoc-html.ts b/src/command/render/pandoc-html.ts index 5ae46e09007..de945c11595 100644 --- a/src/command/render/pandoc-html.ts +++ b/src/command/render/pandoc-html.ts @@ -168,6 +168,14 @@ export async function resolveSassBundles( bundle.key = bundle.key + "-dark"; return bundle; }); + + // Inject dark/light sentinel comments directly, rather than relying + // on the SCSS blackness() heuristic which fails for perceptually-dark + // colours with low HWB blackness (e.g. blue #0000FF). + // See https://github.com/quarto-dev/quarto-cli/issues/14084 + darkBundles.push(darkModeSentinelBundle("dark")); + bundles.push(darkModeSentinelBundle("light")); + const darkTarget = { name: `${dependency}-dark.min.css`, bundles: darkBundles as any, @@ -195,6 +203,10 @@ export async function resolveSassBundles( } hasDarkStyles = true; + } else { + // Single-theme: detect dark/light via WCAG relative luminance, + // which correctly handles saturated colours (unlike HWB blackness). + bundles.push(darkModeSentinelBundle("detect")); } for (const target of targets) { @@ -654,6 +666,43 @@ async function processCssIntoExtras( const kVariablesRegex = /\/\*\! quarto-variables-start \*\/([\S\s]*)\/\*\! quarto-variables-end \*\//g; +// Creates a SassBundle that injects the dark/light mode sentinel comment +// into compiled CSS. For dual-theme builds the mode is known at build time; +// for single-theme builds we detect via oklch perceptual lightness. +function darkModeSentinelBundle( + mode: "dark" | "light" | "detect", +): SassBundle { + let uses = ""; + let defaults = ""; + let sentinelRules: string; + + if (mode === "detect") { + uses = '@use "sass:color";'; + defaults = "$body-bg: #fff !default;\n"; + sentinelRules = [ + '@if (color.channel($body-bg, "lightness", $space: oklch) < 50%) {', + " /*! dark */", + "} @else {", + " /*! light */", + "}", + ].join("\n"); + } else { + sentinelRules = `/*! ${mode} */`; + } + + return { + dependency: "quarto-dark-sentinel", + key: `quarto-${mode}-sentinel`, + quarto: { + uses, + defaults, + functions: "", + mixins: "", + rules: sentinelRules, + }, + }; +} + // Attributes for the style tag function attribForThemeStyle( style: "dark" | "light" | "default", diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 8c041fd8c71..aaaf3ff4227 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -1934,13 +1934,9 @@ code a:any-link { text-decoration-color: $gray-600; } -// This is a sentinel value that renderers can use to determine -// whether the theme is dark or light -@if (quarto-color.blackness($body-bg) > $code-block-theme-dark-threshhold) { - /*! dark */ -} @else { - /*! light */ -} +// The dark/light sentinel comment is now injected by the TypeScript +// compilation pipeline (see pandoc-html.ts) which knows at build time +// whether the target stylesheet is dark or light. // observable UI element tweaks to support light-mode vs dark-mode div.observablehq table thead tr th { diff --git a/src/resources/formats/revealjs/quarto.scss b/src/resources/formats/revealjs/quarto.scss index a09f78152a5..505b4668674 100644 --- a/src/resources/formats/revealjs/quarto.scss +++ b/src/resources/formats/revealjs/quarto.scss @@ -666,11 +666,10 @@ $panel-sidebar-padding: 0.5em; background-image: url('data:image/svg+xml,'); } -// This is a sentinel value that renderers can use to determine -// whether the theme is dark or light -@if ( - quarto-color.blackness($backgroundColor) > $code-block-theme-dark-threshhold -) { +// Sentinel: detect dark/light via oklch perceptual lightness. +// Unlike HWB blackness(), this correctly handles perceptually-dark colours +// with low blackness (e.g. blue #0000FF). See #14084. +@if (quarto-color.channel($backgroundColor, "lightness", $space: oklch) < 50%) { /*! dark */ } @else { /*! light */ diff --git a/tests/docs/playwright/html/dark-brand/issue-14084-dual.qmd b/tests/docs/playwright/html/dark-brand/issue-14084-dual.qmd new file mode 100644 index 00000000000..7e2a8917687 --- /dev/null +++ b/tests/docs/playwright/html/dark-brand/issue-14084-dual.qmd @@ -0,0 +1,26 @@ +--- +title: "Issue 14084 - dual theme with blue dark background" +brand: + color: + background: + light: "white" + dark: "blue" + foreground: + light: "black" + dark: "yellow" +format: + html: + theme: + light: + - cosmo + - brand + dark: + - darkly + - brand +--- + +## Light and dark content + +[This text is visible only in light mode]{.light-content} + +[This text is visible only in dark mode]{.dark-content} diff --git a/tests/docs/playwright/html/dark-brand/issue-14084-single.qmd b/tests/docs/playwright/html/dark-brand/issue-14084-single.qmd new file mode 100644 index 00000000000..452fd85b96a --- /dev/null +++ b/tests/docs/playwright/html/dark-brand/issue-14084-single.qmd @@ -0,0 +1,17 @@ +--- +title: "Issue 14084 - single dark theme with blue background" +brand: + color: + background: "blue" + foreground: "yellow" +format: + html: + theme: + - darkly + - brand +--- + +## Single dark theme + +This page uses a single dark theme (darkly) with a blue background set via brand. +The body should have class `quarto-dark`. diff --git a/tests/docs/playwright/revealjs/dark-brand/issue-14084-dark-blue.qmd b/tests/docs/playwright/revealjs/dark-brand/issue-14084-dark-blue.qmd new file mode 100644 index 00000000000..75a6af1f438 --- /dev/null +++ b/tests/docs/playwright/revealjs/dark-brand/issue-14084-dark-blue.qmd @@ -0,0 +1,19 @@ +--- +title: "Issue 14084 - RevealJS dark theme with blue background" +brand: + color: + background: "blue" + foreground: "yellow" +format: + revealjs: + theme: [dark, brand] +--- + +## Dark theme with blue background + +[This is light content]{.light-content} + +[This is dark content]{.dark-content} + +Body should have class `quarto-dark` because the theme is dark, +even though blue (#0000FF) has 0% HWB blackness. diff --git a/tests/docs/playwright/revealjs/dark-brand/issue-14084-dark-default.qmd b/tests/docs/playwright/revealjs/dark-brand/issue-14084-dark-default.qmd new file mode 100644 index 00000000000..da77fd69913 --- /dev/null +++ b/tests/docs/playwright/revealjs/dark-brand/issue-14084-dark-default.qmd @@ -0,0 +1,14 @@ +--- +title: "Issue 14084 - RevealJS dark theme baseline" +format: + revealjs: + theme: dark +--- + +## Dark theme baseline + +[This is light content]{.light-content} + +[This is dark content]{.dark-content} + +Body should have class `quarto-dark` for the built-in dark theme. diff --git a/tests/integration/playwright/tests/html-dark-mode-blue-bg.spec.ts b/tests/integration/playwright/tests/html-dark-mode-blue-bg.spec.ts new file mode 100644 index 00000000000..1d9662f300d --- /dev/null +++ b/tests/integration/playwright/tests/html-dark-mode-blue-bg.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +// Tests for https://github.com/quarto-dev/quarto-cli/issues/14084 +// Brand colour overrides with perceptually-dark but low-HWB-blackness +// colours (like blue #0000FF) break the dark mode sentinel, causing +// data-mode="light" on the dark stylesheet and preventing the +// quarto-dark body class from being applied. + +test.describe('Issue 14084: blue background dark mode sentinel', () => { + + test('Dual theme with explicit light/dark: toggle applies quarto-dark', async ({ page }) => { + await page.goto('./html/dark-brand/issue-14084-dual.html'); + + // Should start in light mode + const body = page.locator('body').first(); + await expect(body).toHaveClass(/quarto-light/); + + // Light content should be visible, dark content hidden + await expect(page.locator('span.light-content').first()).toBeVisible(); + await expect(page.locator('span.dark-content').first()).toBeHidden(); + + // The dark stylesheet link should have data-mode="dark" + const darkLink = page.locator('link#quarto-bootstrap.quarto-color-alternate'); + await expect(darkLink).toHaveAttribute('data-mode', 'dark'); + + // Toggle to dark mode + await page.locator('a.quarto-color-scheme-toggle').click(); + + // Body should now have quarto-dark + await expect(body).toHaveClass(/quarto-dark/); + + // Dark content should be visible, light content hidden + await expect(page.locator('span.dark-content').first()).toBeVisible(); + await expect(page.locator('span.light-content').first()).toBeHidden(); + }); + + test('Single dark theme with brand: dark stylesheet has correct data-mode', async ({ page }) => { + // theme: [darkly, brand] with brand background: blue + // Brand auto-creates a light+dark pair (no toggle button, but dual stylesheets). + // The key test: the dark stylesheet link must have data-mode="dark" + // despite blue having 0% HWB blackness. + await page.goto('./html/dark-brand/issue-14084-single.html'); + + // The dark stylesheet link should have data-mode="dark" + const darkLink = page.locator('link#quarto-bootstrap.quarto-color-alternate'); + await expect(darkLink).toHaveAttribute('data-mode', 'dark'); + + // The light stylesheet link should have data-mode="light" + const lightLink = page.locator('link#quarto-bootstrap.quarto-color-scheme:not(.quarto-color-alternate):not(.quarto-color-scheme-extra)'); + await expect(lightLink).toHaveAttribute('data-mode', 'light'); + }); +}); diff --git a/tests/integration/playwright/tests/revealjs-dark-mode-blue-bg.spec.ts b/tests/integration/playwright/tests/revealjs-dark-mode-blue-bg.spec.ts new file mode 100644 index 00000000000..4203a0fa8db --- /dev/null +++ b/tests/integration/playwright/tests/revealjs-dark-mode-blue-bg.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test'; + +// Tests for https://github.com/quarto-dev/quarto-cli/issues/14084 +// RevealJS: brand colour overrides with perceptually-dark but low-HWB-blackness +// colours (like blue #0000FF) should still produce the correct dark sentinel, +// resulting in body.quarto-dark and dark syntax highlighting. + +test.describe('Issue 14084: RevealJS blue background dark mode sentinel', () => { + + test('Dark theme with blue brand background: body has quarto-dark', async ({ page }) => { + await page.goto('./revealjs/dark-brand/issue-14084-dark-blue.html'); + + // Body should have quarto-dark class + const body = page.locator('body'); + await expect(body).toHaveClass(/quarto-dark/); + + // Dark content should be visible, light content hidden + await expect(page.locator('span.dark-content').first()).toBeVisible(); + await expect(page.locator('span.light-content').first()).toBeHidden(); + + // Syntax highlighting should use the dark stylesheet + const highlightLink = page.locator('link#quarto-text-highlighting-styles'); + await expect(highlightLink).toHaveAttribute('href', /quarto-syntax-highlighting-dark/); + }); + + test('Dark theme baseline: body has quarto-dark', async ({ page }) => { + await page.goto('./revealjs/dark-brand/issue-14084-dark-default.html'); + + // Body should have quarto-dark class + const body = page.locator('body'); + await expect(body).toHaveClass(/quarto-dark/); + + // Dark content should be visible, light content hidden + await expect(page.locator('span.dark-content').first()).toBeVisible(); + await expect(page.locator('span.light-content').first()).toBeHidden(); + + // Syntax highlighting should use the dark stylesheet + const highlightLink = page.locator('link#quarto-text-highlighting-styles'); + await expect(highlightLink).toHaveAttribute('href', /quarto-syntax-highlighting-dark/); + }); +});