Skip to content
Merged
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
7 changes: 4 additions & 3 deletions llm-docs/html-dark-mode-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 |
Expand All @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
51 changes: 22 additions & 29 deletions src/format/html/format-html-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# A dark brand file that only holds comments, e.g. a commented-out template.
# color:
# background: "#222222"
# foreground: "#eeeeee"
4 changes: 4 additions & 0 deletions tests/docs/smoke-all/dark-mode/comment-only-dark-brand.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# A dark brand file that only holds comments, e.g. a commented-out template.
# color:
# background: "#222222"
# foreground: "#eeeeee"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# A dark brand file that only holds comments, e.g. a commented-out template.
# color:
# background: "#222222"
# foreground: "#eeeeee"
15 changes: 15 additions & 0 deletions tests/docs/smoke-all/dark-mode/default-dark-brand-order.qmd
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
title: "No dark mode when the only dark brand file is ignored"
format: html
brand:
dark: comment-only-dark-only-brand.yml
_quarto:
tests:
html:
noErrors: true
printsMessage:
level: WARN
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']
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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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-with-theme.yml
_quarto:
tests:
html:
noErrors: true
printsMessage:
level: WARN
regex: 'comment-only-dark-brand-with-theme\.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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/.quarto/
*.html
*.json
site_libs/
**/*.quarto_ipynb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# color:
# background: "#222222"
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
color:
primary: red
Loading