From 4ecd80648722c9afdd36db0907d7cfcc93754932 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 20 Oct 2026 16:44:28 +0200 Subject: [PATCH 1/3] Don't enable dark mode from a dark brand file that was ignored A `brand: {dark: ...}` file holding only comments is dropped with a warning, but the raw `brand:` metadata still lists `dark:`. The key-order check in `darkModeDefault` read that metadata, so the page got a color-scheme toggle, the toggle script and `body.quarto-dark` with no dark styles to switch to. Honour `brand:` key order only when a dark brand was actually loaded (`format.render.brand?.dark`). `theme:` key order is unchanged and still wins, so an explicit `theme: {light, dark}` keeps its toggle. `darkModeDefaultMetadata` behaves as before: it runs before the brand is loaded. --- src/format/html/format-html-info.ts | 51 ++++++++----------- .../dark-mode/comment-only-dark-brand.yml | 4 ++ .../dark-mode/default-dark-brand-order.qmd | 15 ++++++ ...dark-mode-comment-only-dark-only-brand.qmd | 21 ++++++++ .../no-toggle-comment-only-dark-brand.qmd | 23 +++++++++ ...gle-theme-dark-with-ignored-dark-brand.qmd | 26 ++++++++++ .../.gitignore | 5 ++ .../_quarto.yml | 16 ++++++ .../dark-brand.yml | 2 + .../index.qmd | 15 ++++++ .../light-brand.yml | 2 + 11 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 tests/docs/smoke-all/dark-mode/comment-only-dark-brand.yml create mode 100644 tests/docs/smoke-all/dark-mode/default-dark-brand-order.qmd create mode 100644 tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd create mode 100644 tests/docs/smoke-all/dark-mode/no-toggle-comment-only-dark-brand.qmd create mode 100644 tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd create mode 100644 tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/.gitignore create mode 100644 tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/_quarto.yml create mode 100644 tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/dark-brand.yml create mode 100644 tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/index.qmd create mode 100644 tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/light-brand.yml diff --git a/src/format/html/format-html-info.ts b/src/format/html/format-html-info.ts index 3626390779b..d973e6b170f 100644 --- a/src/format/html/format-html-info.ts +++ b/src/format/html/format-html-info.ts @@ -43,43 +43,36 @@ export function formatDarkMode(format: Format): boolean | undefined { // a dark theme or brand // 2. later, after brand is loaded, we'll know whether unified brand enabled dark mode // in practice, we only know +// Metadata alone cannot tell whether a brand file was ignored (e.g. it only +// holds comments), so darkModeDefault checks `brand:` key order against the +// loaded brand. export function darkModeDefaultMetadata( metadata: Metadata, ): boolean | undefined { - if (metadata[kTheme] && typeof metadata[kTheme] === "object") { - const keys = Object.keys(metadata[kTheme]); - if (keys.includes("dark")) { - if (keys[0] === "dark") { - return true; - } else { - return false; - } - } + return darkModeFromKeyOrder(metadata[kTheme]) ?? + darkModeFromKeyOrder(metadata[kBrand]); +} + +export function darkModeDefault(format: Format): boolean | undefined { + const fromTheme = darkModeFromKeyOrder(format.metadata?.[kTheme]); + if (fromTheme !== undefined) { + return fromTheme; } - if (metadata[kBrand] && typeof metadata[kBrand] === "object") { - const keys = Object.keys(metadata[kBrand]); - if (keys.includes("dark")) { - if (keys[0] === "dark") { - return true; - } else { - return false; - } - } + if (format.render.brand?.dark) { + // a unified brand has no author preference but it can have dark mode + return darkModeFromKeyOrder(format.metadata?.[kBrand]) ?? false; } return undefined; } -export function darkModeDefault(format: Format): boolean | undefined { - const metadata = format.metadata; - if (metadata !== undefined) { - const dmdm = darkModeDefaultMetadata(metadata); - if (dmdm !== undefined) { - return dmdm; + +// true/false when `dark` is a key (true when it is listed first), +// undefined when there is no `dark` key +function darkModeFromKeyOrder(value: unknown): boolean | undefined { + if (value && typeof value === "object") { + const keys = Object.keys(value); + if (keys.includes("dark")) { + return keys[0] === "dark"; } } - const brand = format.render.brand; - if (brand && brand.dark) { - // unified brand has no author preference but it can have dark mode - return false; - } return undefined; } diff --git a/tests/docs/smoke-all/dark-mode/comment-only-dark-brand.yml b/tests/docs/smoke-all/dark-mode/comment-only-dark-brand.yml new file mode 100644 index 00000000000..9344aa54cac --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/comment-only-dark-brand.yml @@ -0,0 +1,4 @@ +# A dark brand file that only holds comments, e.g. a commented-out template. +# color: +# background: "#222222" +# foreground: "#eeeeee" diff --git a/tests/docs/smoke-all/dark-mode/default-dark-brand-order.qmd b/tests/docs/smoke-all/dark-mode/default-dark-brand-order.qmd new file mode 100644 index 00000000000..84ebe4f95c3 --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/default-dark-brand-order.qmd @@ -0,0 +1,15 @@ +--- +title: "Dark brand listed first defaults to dark" +format: html +brand: + dark: slate-brand.yml + light: united-brand.yml +_quarto: + tests: + html: + ensureHtmlElements: + - ['body.quarto-dark', 'link.quarto-color-alternate'] + - [] +--- + +Both brand files are valid and `dark` is listed first, so the page defaults to dark mode. diff --git a/tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd b/tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd new file mode 100644 index 00000000000..532704c25b5 --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd @@ -0,0 +1,21 @@ +--- +title: "No dark mode when the only dark brand file is ignored" +format: html +brand: + dark: comment-only-dark-brand.yml +_quarto: + tests: + html: + noErrors: true + printsMessage: + level: WARN + regex: 'comment-only-dark-brand\.yml has no content and will be ignored' + ensureHtmlElements: + - ['body.quarto-light'] + - ['body.quarto-dark', 'link.quarto-color-alternate'] + ensureFileRegexMatches: + - [] + - ['quarto-color-scheme-toggle'] +--- + +The dark brand file is the only dark source and holds only comments, so it is ignored and the page has a single light mode. diff --git a/tests/docs/smoke-all/dark-mode/no-toggle-comment-only-dark-brand.qmd b/tests/docs/smoke-all/dark-mode/no-toggle-comment-only-dark-brand.qmd new file mode 100644 index 00000000000..4692d3f9c90 --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/no-toggle-comment-only-dark-brand.qmd @@ -0,0 +1,23 @@ +--- +title: "No color-scheme toggle when the dark brand file is ignored" +format: html +brand: + light: united-brand.yml + dark: comment-only-dark-brand.yml +_quarto: + tests: + html: + noErrors: true + printsMessage: + level: WARN + regex: 'comment-only-dark-brand\.yml has no content and will be ignored' + ensureHtmlElements: + - [] + - ["link.quarto-color-alternate"] + ensureFileRegexMatches: + - [] + - + - 'quarto-color-scheme-toggle' +--- + +A comment-only dark brand file is ignored, so the page has a single light mode and must not emit any toggle markup or toggle script. diff --git a/tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd b/tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd new file mode 100644 index 00000000000..6be5cc4bfe2 --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd @@ -0,0 +1,26 @@ +--- +title: "Theme dark mode kept when the dark brand file is ignored" +format: + html: + theme: + light: flatly + dark: darkly +brand: + light: united-brand.yml + dark: comment-only-dark-brand.yml +_quarto: + tests: + html: + noErrors: true + printsMessage: + level: WARN + regex: 'comment-only-dark-brand\.yml has no content and will be ignored' + ensureHtmlElements: + - ['link.quarto-color-alternate', 'body.quarto-light'] + - [] + ensureFileRegexMatches: + - ['quarto-color-scheme-toggle'] + - [] +--- + +The theme supplies the dark mode, so the toggle stays even though the dark brand file is ignored. diff --git a/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/.gitignore b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/.gitignore new file mode 100644 index 00000000000..0b3bf772477 --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/.gitignore @@ -0,0 +1,5 @@ +/.quarto/ +*.html +*.json +site_libs/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/_quarto.yml b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/_quarto.yml new file mode 100644 index 00000000000..2f896b1572b --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/_quarto.yml @@ -0,0 +1,16 @@ +project: + type: website + output-dir: . + +website: + title: "Website" + navbar: + left: + - href: index.qmd + text: Home + +brand: + light: light-brand.yml + dark: dark-brand.yml + +format: html diff --git a/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/dark-brand.yml b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/dark-brand.yml new file mode 100644 index 00000000000..2eba6aa400c --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/dark-brand.yml @@ -0,0 +1,2 @@ +# color: +# background: "#222222" diff --git a/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/index.qmd b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/index.qmd new file mode 100644 index 00000000000..41878ce48dc --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/index.qmd @@ -0,0 +1,15 @@ +--- +title: "Website" +_quarto: + tests: + html: + noErrors: true + printsMessage: + level: WARN + regex: 'dark-brand\.yml has no content and will be ignored' + ensureHtmlElements: + - ['nav.navbar'] + - ['.quarto-color-scheme-toggle', 'link.quarto-color-alternate'] +--- + +The project dark brand file holds only comments and is ignored, so the navbar has no color-scheme toggle. diff --git a/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/light-brand.yml b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/light-brand.yml new file mode 100644 index 00000000000..6c7e7536edf --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle-ignored-dark-brand/light-brand.yml @@ -0,0 +1,2 @@ +color: + primary: red From 8811be446e1bbaab02dcdabeb1c51cfa0af8625a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 20 Oct 2026 16:44:42 +0200 Subject: [PATCH 2/3] Document the ignored dark brand case and add changelog entry Update the dark mode architecture doc for the new `darkModeDefault` inputs and add a Traps row for a dark brand file that is ignored. --- llm-docs/html-dark-mode-architecture.md | 7 ++++--- news/changelog-1.11.md | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/llm-docs/html-dark-mode-architecture.md b/llm-docs/html-dark-mode-architecture.md index cf52c96b526..03fd96e3e7e 100644 --- a/llm-docs/html-dark-mode-architecture.md +++ b/llm-docs/html-dark-mode-architecture.md @@ -25,7 +25,7 @@ The core model: a page ships **one presentation or two**. When it ships two, the - `false` — dark mode exists; the default is light. - `true` — dark mode exists; the default is dark. -It gates the toggle (`src/project/types/website/website-navigation.ts:356-362`), the before-body script (`src/format/html/format-html.ts:351`, template written at `:530`), and the body class (`format-html-bootstrap.ts:1073-1078`). Its inputs: `darkModeDefaultMetadata()` (`format-html-info.ts:46-70`) reads key order of `theme:` then `brand:`; `darkModeDefault()` (`:71-85`) adds a fallback on `format.render.brand?.dark`. +It gates the toggle (`src/project/types/website/website-navigation.ts:356-362`), the before-body script (`src/format/html/format-html.ts:351`, template written at `:530`), and the body class (`format-html-bootstrap.ts:1073-1078`). Its input is `darkModeDefault()` (`format-html-info.ts:56-66`): it reads `theme:` key order first, then `brand:` key order only when `format.render.brand?.dark` is loaded, and falls back to `false` for a loaded dark brand with no `brand:` key order. `darkModeDefaultMetadata()` (`:49-54`) reads the same key orders from metadata alone, without the loaded-brand check; it exists for `render-contexts.ts`, which calls it before the brand is loaded. ## Configuration surface @@ -53,7 +53,7 @@ Brand SASS layers become one more bundle with a `dark` half (`src/core/sass/bran ### Precedence between `theme.dark` and brand dark -- **Default mode:** `theme:` key order wins over `brand:` key order (`format-html-info.ts:46-70`). A unified `_brand.yml` carries no preference and defaults to light (`:81-82`). +- **Default mode:** `theme:` key order wins over `brand:` key order (`format-html-info.ts:56-66`). A unified `_brand.yml` carries no preference and defaults to light (`:63`). - **Variable values:** SASS `!default` order. Brand normally sits before the theme, so the theme overrides brand. An explicit `brand` marker in the theme list (`theme: {dark: [cyborg, brand]}`) reverses this (`pandoc-html.ts:91-137`, `format-html-scss.ts:216-219`). Pinned by `tests/integration/playwright/tests/html-dark-mode.spec.ts` and `html-dark-mode-defaultlight.spec.ts`. ### `respect-user-color-scheme` @@ -115,6 +115,7 @@ To read the active mode: the `body` class, or the `rel` of `link.quarto-color-sc | Situation | Rendered result | Trap | |---|---|---| | Light-only `_brand.yml` | Full `quarto-color-scheme`/`quarto-color-alternate` link set, all `data-mode="light"`, **no toggle, no before-body script** | An "alternate link exists" probe reports two modes; there is one. Cause: stylesheets key off `project.resolveBrand()` (always split) while the toggle keys off `format.render.brand.dark` (`render-contexts.ts:574-585`). Fixture: `tests/docs/axe-scan/sites/brand-light-only` | +| Dark brand file holding only comments | Warning, file ignored; without a `theme.dark` the page has a single light mode and no toggle, with `theme: {light, dark}` the theme's dark mode stays | `brand:` metadata still lists `dark:`; anything reading its key order must check the loaded brand (`format.render.brand.dark`) | | `theme: {light: cosmo, dark: flatly}` | Alternate link with `data-mode="light"` | A `[data-mode="dark"]` probe reports one mode; there are two | | `theme: darkly` | One link, `data-mode="dark"`, no `quarto-color-scheme` class, `body.quarto-light` | Visually dark page that every declaration-level signal calls light | | `theme: none` / `html+minimal` + brand dark | Untested edge: `brandSassFormatExtras` still contributes a bootstrap bundle (`pandoc-html.ts:284-294`) | Possible alternate stylesheets with no JS to manage them | @@ -127,7 +128,7 @@ To read the active mode: the `body` class, or the `rel` of `link.quarto-color-sc | `kTheme` = `"theme"` | `src/config/constants.ts:635` | | `kBrand` = `"brand"` | `src/config/constants.ts:123` | | `kRespectUserColorScheme` | `src/config/constants.ts:156` | -| `formatDarkMode`, `darkModeDefault`, `darkModeDefaultMetadata` | `src/format/html/format-html-info.ts:33-85` | +| `formatDarkMode`, `darkModeDefault`, `darkModeDefaultMetadata` | `src/format/html/format-html-info.ts:33-78` | | `brandHasDarkMode`, `splitUnifiedBrand` | `src/core/brand/brand.ts:545-590`, `:773-805` | | `cssHasDarkModeSentinel` | `src/core/pandoc/css.ts:45-47` | | `attribForThemeStyle` (link attributes) | `src/command/render/pandoc-html.ts:658-679` | diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 2466935c636..a5de09dd42d 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -68,6 +68,7 @@ All changes included in 1.11: - ([#15118](https://github.com/quarto-dev/quarto-cli/pull/15118)): Fix Google Scholar metadata missing the first and last page of bibliography entries, and giving wrong pages for a citation `page` written with an en-dash or as a page list. - ([#15125](https://github.com/quarto-dev/quarto-cli/pull/15125)): Fix Google Scholar metadata failing the render with a CSL JSON (`.json`) bibliography. - ([#15128](https://github.com/quarto-dev/quarto-cli/pull/15128)): Fix Google Scholar metadata missing references with a YAML bibliography not wrapped in `---` delimiters. +- ([#15131](https://github.com/quarto-dev/quarto-cli/issues/15131)): Fix the light/dark toggle appearing, with no dark styles to switch to, when the `dark` brand file holds only comments and is ignored. ### `pdf` From 1230408d5d71c1d920c7c68a690c1b0eb2ecc964 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 20 Oct 2026 16:55:57 +0200 Subject: [PATCH 3/3] Give each ignored-brand test its own comment-only fixture The empty-brand warning goes through `warnOnce`, which remembers a message for the rest of the process. Smoke-all renders every document of a run in one process, so when the three dark-mode docs shared `comment-only-dark-brand.yml` only the first render printed the warning and the other two failed their `printsMessage` check, depending on render order. A distinct fixture path per document keeps the warning check in each one, which is what shows the dark file was actually ignored. --- .../dark-mode/comment-only-dark-brand-with-theme.yml | 4 ++++ .../docs/smoke-all/dark-mode/comment-only-dark-only-brand.yml | 4 ++++ .../dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd | 4 ++-- .../dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 tests/docs/smoke-all/dark-mode/comment-only-dark-brand-with-theme.yml create mode 100644 tests/docs/smoke-all/dark-mode/comment-only-dark-only-brand.yml diff --git a/tests/docs/smoke-all/dark-mode/comment-only-dark-brand-with-theme.yml b/tests/docs/smoke-all/dark-mode/comment-only-dark-brand-with-theme.yml new file mode 100644 index 00000000000..9344aa54cac --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/comment-only-dark-brand-with-theme.yml @@ -0,0 +1,4 @@ +# A dark brand file that only holds comments, e.g. a commented-out template. +# color: +# background: "#222222" +# foreground: "#eeeeee" diff --git a/tests/docs/smoke-all/dark-mode/comment-only-dark-only-brand.yml b/tests/docs/smoke-all/dark-mode/comment-only-dark-only-brand.yml new file mode 100644 index 00000000000..9344aa54cac --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/comment-only-dark-only-brand.yml @@ -0,0 +1,4 @@ +# A dark brand file that only holds comments, e.g. a commented-out template. +# color: +# background: "#222222" +# foreground: "#eeeeee" diff --git a/tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd b/tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd index 532704c25b5..4b39725697a 100644 --- a/tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd +++ b/tests/docs/smoke-all/dark-mode/no-dark-mode-comment-only-dark-only-brand.qmd @@ -2,14 +2,14 @@ title: "No dark mode when the only dark brand file is ignored" format: html brand: - dark: comment-only-dark-brand.yml + dark: comment-only-dark-only-brand.yml _quarto: tests: html: noErrors: true printsMessage: level: WARN - regex: 'comment-only-dark-brand\.yml has no content and will be ignored' + regex: 'comment-only-dark-only-brand\.yml has no content and will be ignored' ensureHtmlElements: - ['body.quarto-light'] - ['body.quarto-dark', 'link.quarto-color-alternate'] diff --git a/tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd b/tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd index 6be5cc4bfe2..5643ef82fbe 100644 --- a/tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd +++ b/tests/docs/smoke-all/dark-mode/toggle-theme-dark-with-ignored-dark-brand.qmd @@ -7,14 +7,14 @@ format: dark: darkly brand: light: united-brand.yml - dark: comment-only-dark-brand.yml + dark: comment-only-dark-brand-with-theme.yml _quarto: tests: html: noErrors: true printsMessage: level: WARN - regex: 'comment-only-dark-brand\.yml has no content and will be ignored' + regex: 'comment-only-dark-brand-with-theme\.yml has no content and will be ignored' ensureHtmlElements: - ['link.quarto-color-alternate', 'body.quarto-light'] - []