Skip to content

Commit 6ecfc03

Browse files
claude: fix dark mode sentinel for saturated brand colours (closes #14084)
The dark/light sentinel used HWB blackness() which returns 0% for colours like blue (#0000FF), incorrectly classifying them as light. For HTML dual-theme builds, inject literal /*! dark */ and /*! light */ comments from the TS pipeline which knows the theme at build time. For HTML single-theme and RevealJS, use oklch perceptual lightness (color.channel($bg, "lightness", $space: oklch) < 50%) which correctly identifies perceptually dark colours regardless of saturation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d36fe54 commit 6ecfc03

10 files changed

Lines changed: 227 additions & 12 deletions

File tree

news/changelog-1.9.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ All changes included in 1.9:
5353
- ([#13883](https://github.com/quarto-dev/quarto-cli/issues/13883)): Fix unequal top/bottom spacing in simple untitled callouts.
5454
- ([#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.
5555
- ([#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`).
56+
- ([#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.
5657

5758
### `typst`
5859

@@ -102,6 +103,7 @@ All changes included in 1.9:
102103
### `revealjs`
103104

104105
- ([#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)
106+
- ([#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.
105107

106108
### `ipynb`
107109

src/command/render/pandoc-html.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,14 @@ export async function resolveSassBundles(
168168
bundle.key = bundle.key + "-dark";
169169
return bundle;
170170
});
171+
172+
// Inject dark/light sentinel comments directly, rather than relying
173+
// on the SCSS blackness() heuristic which fails for perceptually-dark
174+
// colours with low HWB blackness (e.g. blue #0000FF).
175+
// See https://github.com/quarto-dev/quarto-cli/issues/14084
176+
darkBundles.push(darkModeSentinelBundle("dark"));
177+
bundles.push(darkModeSentinelBundle("light"));
178+
171179
const darkTarget = {
172180
name: `${dependency}-dark.min.css`,
173181
bundles: darkBundles as any,
@@ -195,6 +203,10 @@ export async function resolveSassBundles(
195203
}
196204

197205
hasDarkStyles = true;
206+
} else {
207+
// Single-theme: detect dark/light via WCAG relative luminance,
208+
// which correctly handles saturated colours (unlike HWB blackness).
209+
bundles.push(darkModeSentinelBundle("detect"));
198210
}
199211

200212
for (const target of targets) {
@@ -654,6 +666,43 @@ async function processCssIntoExtras(
654666
const kVariablesRegex =
655667
/\/\*\! quarto-variables-start \*\/([\S\s]*)\/\*\! quarto-variables-end \*\//g;
656668

669+
// Creates a SassBundle that injects the dark/light mode sentinel comment
670+
// into compiled CSS. For dual-theme builds the mode is known at build time;
671+
// for single-theme builds we detect via oklch perceptual lightness.
672+
function darkModeSentinelBundle(
673+
mode: "dark" | "light" | "detect",
674+
): SassBundle {
675+
let uses = "";
676+
let defaults = "";
677+
let sentinelRules: string;
678+
679+
if (mode === "detect") {
680+
uses = '@use "sass:color";';
681+
defaults = "$body-bg: #fff !default;\n";
682+
sentinelRules = [
683+
'@if (color.channel($body-bg, "lightness", $space: oklch) < 50%) {',
684+
" /*! dark */",
685+
"} @else {",
686+
" /*! light */",
687+
"}",
688+
].join("\n");
689+
} else {
690+
sentinelRules = `/*! ${mode} */`;
691+
}
692+
693+
return {
694+
dependency: "quarto-dark-sentinel",
695+
key: `quarto-${mode}-sentinel`,
696+
quarto: {
697+
uses,
698+
defaults,
699+
functions: "",
700+
mixins: "",
701+
rules: sentinelRules,
702+
},
703+
};
704+
}
705+
657706
// Attributes for the style tag
658707
function attribForThemeStyle(
659708
style: "dark" | "light" | "default",

src/resources/formats/html/bootstrap/_bootstrap-rules.scss

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1934,13 +1934,9 @@ code a:any-link {
19341934
text-decoration-color: $gray-600;
19351935
}
19361936

1937-
// This is a sentinel value that renderers can use to determine
1938-
// whether the theme is dark or light
1939-
@if (quarto-color.blackness($body-bg) > $code-block-theme-dark-threshhold) {
1940-
/*! dark */
1941-
} @else {
1942-
/*! light */
1943-
}
1937+
// The dark/light sentinel comment is now injected by the TypeScript
1938+
// compilation pipeline (see pandoc-html.ts) which knows at build time
1939+
// whether the target stylesheet is dark or light.
19441940

19451941
// observable UI element tweaks to support light-mode vs dark-mode
19461942
div.observablehq table thead tr th {

src/resources/formats/revealjs/quarto.scss

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -666,11 +666,10 @@ $panel-sidebar-padding: 0.5em;
666666
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#{colorToRGB($linkColor)}" class="bi bi-brush" viewBox="0 0 16 16"><path d="M15.825.12a.5.5 0 0 1 .132.584c-1.53 3.43-4.743 8.17-7.095 10.64a6.067 6.067 0 0 1-2.373 1.534c-.018.227-.06.538-.16.868-.201.659-.667 1.479-1.708 1.74a8.118 8.118 0 0 1-3.078.132 3.659 3.659 0 0 1-.562-.135 1.382 1.382 0 0 1-.466-.247.714.714 0 0 1-.204-.288.622.622 0 0 1 .004-.443c.095-.245.316-.38.461-.452.394-.197.625-.453.867-.826.095-.144.184-.297.287-.472l.117-.198c.151-.255.326-.54.546-.848.528-.739 1.201-.925 1.746-.896.126.007.243.025.348.048.062-.172.142-.38.238-.608.261-.619.658-1.419 1.187-2.069 2.176-2.67 6.18-6.206 9.117-8.104a.5.5 0 0 1 .596.04zM4.705 11.912a1.23 1.23 0 0 0-.419-.1c-.246-.013-.573.05-.879.479-.197.275-.355.532-.5.777l-.105.177c-.106.181-.213.362-.32.528a3.39 3.39 0 0 1-.76.861c.69.112 1.736.111 2.657-.12.559-.139.843-.569.993-1.06a3.122 3.122 0 0 0 .126-.75l-.793-.792zm1.44.026c.12-.04.277-.1.458-.183a5.068 5.068 0 0 0 1.535-1.1c1.9-1.996 4.412-5.57 6.052-8.631-2.59 1.927-5.566 4.66-7.302 6.792-.442.543-.795 1.243-1.042 1.826-.121.288-.214.54-.275.72v.001l.575.575zm-4.973 3.04.007-.005a.031.031 0 0 1-.007.004zm3.582-3.043.002.001h-.002z"/></svg>');
667667
}
668668

669-
// This is a sentinel value that renderers can use to determine
670-
// whether the theme is dark or light
671-
@if (
672-
quarto-color.blackness($backgroundColor) > $code-block-theme-dark-threshhold
673-
) {
669+
// Sentinel: detect dark/light via oklch perceptual lightness.
670+
// Unlike HWB blackness(), this correctly handles perceptually-dark colours
671+
// with low blackness (e.g. blue #0000FF). See #14084.
672+
@if (quarto-color.channel($backgroundColor, "lightness", $space: oklch) < 50%) {
674673
/*! dark */
675674
} @else {
676675
/*! light */
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
title: "Issue 14084 - dual theme with blue dark background"
3+
brand:
4+
color:
5+
background:
6+
light: "white"
7+
dark: "blue"
8+
foreground:
9+
light: "black"
10+
dark: "yellow"
11+
format:
12+
html:
13+
theme:
14+
light:
15+
- cosmo
16+
- brand
17+
dark:
18+
- darkly
19+
- brand
20+
---
21+
22+
## Light and dark content
23+
24+
[This text is visible only in light mode]{.light-content}
25+
26+
[This text is visible only in dark mode]{.dark-content}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
title: "Issue 14084 - single dark theme with blue background"
3+
brand:
4+
color:
5+
background: "blue"
6+
foreground: "yellow"
7+
format:
8+
html:
9+
theme:
10+
- darkly
11+
- brand
12+
---
13+
14+
## Single dark theme
15+
16+
This page uses a single dark theme (darkly) with a blue background set via brand.
17+
The body should have class `quarto-dark`.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
title: "Issue 14084 - RevealJS dark theme with blue background"
3+
brand:
4+
color:
5+
background: "blue"
6+
foreground: "yellow"
7+
format:
8+
revealjs:
9+
theme: [dark, brand]
10+
---
11+
12+
## Dark theme with blue background
13+
14+
[This is light content]{.light-content}
15+
16+
[This is dark content]{.dark-content}
17+
18+
Body should have class `quarto-dark` because the theme is dark,
19+
even though blue (#0000FF) has 0% HWB blackness.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
title: "Issue 14084 - RevealJS dark theme baseline"
3+
format:
4+
revealjs:
5+
theme: dark
6+
---
7+
8+
## Dark theme baseline
9+
10+
[This is light content]{.light-content}
11+
12+
[This is dark content]{.dark-content}
13+
14+
Body should have class `quarto-dark` for the built-in dark theme.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// Tests for https://github.com/quarto-dev/quarto-cli/issues/14084
4+
// Brand colour overrides with perceptually-dark but low-HWB-blackness
5+
// colours (like blue #0000FF) break the dark mode sentinel, causing
6+
// data-mode="light" on the dark stylesheet and preventing the
7+
// quarto-dark body class from being applied.
8+
9+
test.describe('Issue 14084: blue background dark mode sentinel', () => {
10+
11+
test('Dual theme with explicit light/dark: toggle applies quarto-dark', async ({ page }) => {
12+
await page.goto('./html/dark-brand/issue-14084-dual.html');
13+
14+
// Should start in light mode
15+
const body = page.locator('body').first();
16+
await expect(body).toHaveClass(/quarto-light/);
17+
18+
// Light content should be visible, dark content hidden
19+
await expect(page.locator('span.light-content').first()).toBeVisible();
20+
await expect(page.locator('span.dark-content').first()).toBeHidden();
21+
22+
// The dark stylesheet link should have data-mode="dark"
23+
const darkLink = page.locator('link#quarto-bootstrap.quarto-color-alternate');
24+
await expect(darkLink).toHaveAttribute('data-mode', 'dark');
25+
26+
// Toggle to dark mode
27+
await page.locator('a.quarto-color-scheme-toggle').click();
28+
29+
// Body should now have quarto-dark
30+
await expect(body).toHaveClass(/quarto-dark/);
31+
32+
// Dark content should be visible, light content hidden
33+
await expect(page.locator('span.dark-content').first()).toBeVisible();
34+
await expect(page.locator('span.light-content').first()).toBeHidden();
35+
});
36+
37+
test('Single dark theme with brand: dark stylesheet has correct data-mode', async ({ page }) => {
38+
// theme: [darkly, brand] with brand background: blue
39+
// Brand auto-creates a light+dark pair (no toggle button, but dual stylesheets).
40+
// The key test: the dark stylesheet link must have data-mode="dark"
41+
// despite blue having 0% HWB blackness.
42+
await page.goto('./html/dark-brand/issue-14084-single.html');
43+
44+
// The dark stylesheet link should have data-mode="dark"
45+
const darkLink = page.locator('link#quarto-bootstrap.quarto-color-alternate');
46+
await expect(darkLink).toHaveAttribute('data-mode', 'dark');
47+
48+
// The light stylesheet link should have data-mode="light"
49+
const lightLink = page.locator('link#quarto-bootstrap.quarto-color-scheme:not(.quarto-color-alternate):not(.quarto-color-scheme-extra)');
50+
await expect(lightLink).toHaveAttribute('data-mode', 'light');
51+
});
52+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// Tests for https://github.com/quarto-dev/quarto-cli/issues/14084
4+
// RevealJS: brand colour overrides with perceptually-dark but low-HWB-blackness
5+
// colours (like blue #0000FF) should still produce the correct dark sentinel,
6+
// resulting in body.quarto-dark and dark syntax highlighting.
7+
8+
test.describe('Issue 14084: RevealJS blue background dark mode sentinel', () => {
9+
10+
test('Dark theme with blue brand background: body has quarto-dark', async ({ page }) => {
11+
await page.goto('./revealjs/dark-brand/issue-14084-dark-blue.html');
12+
13+
// Body should have quarto-dark class
14+
const body = page.locator('body');
15+
await expect(body).toHaveClass(/quarto-dark/);
16+
17+
// Dark content should be visible, light content hidden
18+
await expect(page.locator('span.dark-content').first()).toBeVisible();
19+
await expect(page.locator('span.light-content').first()).toBeHidden();
20+
21+
// Syntax highlighting should use the dark stylesheet
22+
const highlightLink = page.locator('link#quarto-text-highlighting-styles');
23+
await expect(highlightLink).toHaveAttribute('href', /quarto-syntax-highlighting-dark/);
24+
});
25+
26+
test('Dark theme baseline: body has quarto-dark', async ({ page }) => {
27+
await page.goto('./revealjs/dark-brand/issue-14084-dark-default.html');
28+
29+
// Body should have quarto-dark class
30+
const body = page.locator('body');
31+
await expect(body).toHaveClass(/quarto-dark/);
32+
33+
// Dark content should be visible, light content hidden
34+
await expect(page.locator('span.dark-content').first()).toBeVisible();
35+
await expect(page.locator('span.light-content').first()).toBeHidden();
36+
37+
// Syntax highlighting should use the dark stylesheet
38+
const highlightLink = page.locator('link#quarto-text-highlighting-styles');
39+
await expect(highlightLink).toHaveAttribute('href', /quarto-syntax-highlighting-dark/);
40+
});
41+
});

0 commit comments

Comments
 (0)