You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A reader picks light or dark with the toolbar's "Change contrast" button, and the choice is saved in the browser. If the operating system then switches to the other appearance while a lecture page is open, the page follows the OS and saves the OS value over the reader's choice. An automatic or scheduled light/dark setting is enough to trigger this. Later page loads use that saved value until the reader toggles again.
The same save happens when the reader has never chosen. An OS switch while a page is open saves the OS value, and on later loads that value outranks the OS preference. A page can then open dark while the OS is light.
This contradicts the theme's own documentation. It describes the toggle as "honouring the system preference until the reader chooses" and says "The choice persists in the browser." (docs/dark-mode.md lines 3-8).
It also differs from the Sphinx lecture sites. The book theme reads localStorage.setContrast before paint and on load, and only the toggle writes it. Its source has no prefers-color-scheme or matchMedia reference (theme-settings.js lines 11-44, layout.html lines 97-111).
Every page of a statically built site that uses the theme is affected. The toggle sits in the fixed toolbar at every screen width (Toolbar.tsx lines 23-28 and lines 72-74), and NavigationAndArticleWrapper.tsx lines 18-20 renders that toolbar. The live lecture-wasm site's JavaScript bundle has the same OS listener and the same myst:theme save (checked 2026-09-11).
Reproduced 2026-09-11 in headless Chromium (Playwright 1.60) against a local static build of the visual-test fixture. The OS appearance was switched with page.emulateMedia({ colorScheme }).
Step
Theme class on <html>
myst:theme in localStorage
Load with the OS light, nothing saved
light
not set
Reader clicks "Change contrast"
dark
dark
OS switches to dark
dark
dark
OS switches back to light
light
light
Page reloaded
light
light
A second run started with nothing saved. Loading with the OS light left myst:theme unset. Switching the OS to dark, without touching the toggle, saved dark. A new page opened with the OS back on light then loaded dark.
Cause
The theme calls upstream useTheme unchanged and does not override the hook inside it.
usePreferredTheme adds a (prefers-color-scheme: light) change listener that calls setTheme with the OS value. Its comment reads "This will modify the saved theme".
same file, lines 61-62
useTheme passes its own setTheme to usePreferredTheme.
same file, lines 64-81
After mount, every theme change is written to localStorage under myst:theme on a static build, whatever caused it.
The toolbar button calls nextTheme (ThemeButton.tsx lines 7-13). nextTheme flips light and dark through the same setTheme, which the local Document hands to ThemeProvider (Document.tsx line 97). Nothing tells a reader's choice apart from an OS change.
On the next page load, BlockingThemeLoader applies the saved value ahead of the OS preference, so the overwritten value sticks.
Following OS changes is deliberate upstream. The hook's doc comment reads "Hook that changes theme to follow changes to system preference". On jupyter-book/myst-theme main the hook file is identical to 1.3.0 (checked 2026-09-11). Its only commit there comes from jupyter-book/myst-theme#445 (merged 2024-09-09), which moved static builds from a cookie to localStorage. A search of jupyter-book/myst-theme issues found none reporting that OS changes overwrite a saved choice.
Proposed change
There are two routes, and a maintainer needs to pick one before work starts.
Option 1: keep the reader's explicit choice (a theme change and an upstream candidate).
Add a local useTheme and call it from the local Document in place of the upstream hook (app/components/Document.tsx lines 85-86). Upstream useTheme calls usePreferredTheme internally, so that hook cannot be replaced on its own. The local hook keeps upstream's starting value: the saved choice if there is one, otherwise the OS preference. It writes myst:theme only when the reader toggles, never on an OS change. While nothing is saved, OS changes may still update the page. Once a choice is saved, they are ignored. The file's header comment also needs updating (lines 23-45). It says the copy was taken only to set lang and dir, and that the file can be deleted once upstream accepts those props.
Add an entry to UPSTREAM-CANDIDATES.yml (status pending, target jupyter-book/myst-theme), shaped like the existing document-lang-dir entry (lines 84-99). The request to upstream: usePreferredTheme should follow the OS only while no explicit choice is saved, and OS-driven changes should not be saved.
Trade-offs: it matches docs/dark-mode.md and the book theme. The cost is a second local change in the Document path to maintain. Also, once a reader toggles, the page stops following the OS, as on the Sphinx sites. The two-state toggle has no way back to following the system short of clearing site data.
Option 2: keep upstream's behaviour and correct docs/dark-mode.md. The docs would say that an OS appearance change while a page is open replaces the reader's choice and is saved. They would also say this saves a value for readers who never chose. Trade-offs: there is no code to maintain and the theme stays on upstream code. But the behaviour still differs from the book theme, and on later loads a saved OS value can disagree with the current OS setting.
The study leaves the choice open. Its row reads "Either override the hook or correct the docs" and marks the issue as an upstream candidate, which is option 1's route. Its housekeeping list also names docs/dark-mode.md lines 3-6 for this row; that docs correction is tracked in #210.
Acceptance criteria
A maintainer records in this issue whether option 1 or option 2 is taken.
If option 1:
In a static build with a saved choice, an OS appearance change while a page is open changes neither the page theme nor myst:theme.
With nothing saved, an OS appearance change does not write myst:theme; only the toolbar toggle does.
The toggle still switches the theme, and the choice survives a reload.
UPSTREAM-CANDIDATES.yml has an entry for the change.
If option 2:
docs/dark-mode.md says that an OS appearance change while a page is open replaces the saved value, including for a reader who never chose.
Either way:
A Playwright test against the static build covers the toggle, an OS change via page.emulateMedia, and a reload, and checks for the behaviour chosen. The static-chrome project runs tests/visual/static.spec.ts against the static build (playwright.config.ts lines 84-91).
docs/dark-mode.md describes the shipped behaviour.
Context
Source: the 2026-09-11 book-theme parity study, row F027 (dark-mode toggle with persistence). Its verdict became partial on recheck, after the first two passes marked it implemented. The study's verification addendum confirmed in the code that upstream usePreferredTheme writes the OS value over the saved choice and that the theme does not override it.
No visual test covers dark mode: tests/visual/*.spec.ts and playwright.config.ts contain no dark-mode or colour-scheme reference. Study row F099 recommends adding a dark snapshot.
Out of scope, from the same row: the storage key changes from setContrast to myst:theme, so a dark choice saved on a Sphinx site is not carried over at cutover. The button also has no pressed state or aria-pressed.
Problem
A reader picks light or dark with the toolbar's "Change contrast" button, and the choice is saved in the browser. If the operating system then switches to the other appearance while a lecture page is open, the page follows the OS and saves the OS value over the reader's choice. An automatic or scheduled light/dark setting is enough to trigger this. Later page loads use that saved value until the reader toggles again.
The same save happens when the reader has never chosen. An OS switch while a page is open saves the OS value, and on later loads that value outranks the OS preference. A page can then open dark while the OS is light.
This contradicts the theme's own documentation. It describes the toggle as "honouring the system preference until the reader chooses" and says "The choice persists in the browser." (docs/dark-mode.md lines 3-8).
It also differs from the Sphinx lecture sites. The book theme reads
localStorage.setContrastbefore paint and on load, and only the toggle writes it. Its source has noprefers-color-schemeormatchMediareference (theme-settings.js lines 11-44, layout.html lines 97-111).Every page of a statically built site that uses the theme is affected. The toggle sits in the fixed toolbar at every screen width (Toolbar.tsx lines 23-28 and lines 72-74), and NavigationAndArticleWrapper.tsx lines 18-20 renders that toolbar. The live lecture-wasm site's JavaScript bundle has the same OS listener and the same
myst:themesave (checked 2026-09-11).Static builds keep the choice in
localStorage. app/root.tsx lines 233-237 passesstaticBuild, and app/components/Document.tsx lines 85-92 calls upstreamuseThemewithuseLocalStorage: staticBuild.Reproduced 2026-09-11 in headless Chromium (Playwright 1.60) against a local static build of the visual-test fixture. The OS appearance was switched with
page.emulateMedia({ colorScheme }).<html>myst:themein localStorageA second run started with nothing saved. Loading with the OS light left
myst:themeunset. Switching the OS to dark, without touching the toggle, saveddark. A new page opened with the OS back on light then loaded dark.Cause
The theme calls upstream
useThemeunchanged and does not override the hook inside it.usePreferredThemeadds a(prefers-color-scheme: light)change listener that callssetThemewith the OS value. Its comment reads "This will modify the saved theme".useThemepasses its ownsetThemetousePreferredTheme.localStorageundermyst:themeon a static build, whatever caused it.nextTheme(ThemeButton.tsx lines 7-13).nextThemeflips light and dark through the samesetTheme, which the localDocumenthands toThemeProvider(Document.tsx line 97). Nothing tells a reader's choice apart from an OS change.BlockingThemeLoaderapplies the saved value ahead of the OS preference, so the overwritten value sticks.Following OS changes is deliberate upstream. The hook's doc comment reads "Hook that changes theme to follow changes to system preference". On jupyter-book/myst-theme main the hook file is identical to 1.3.0 (checked 2026-09-11). Its only commit there comes from jupyter-book/myst-theme#445 (merged 2024-09-09), which moved static builds from a cookie to
localStorage. A search of jupyter-book/myst-theme issues found none reporting that OS changes overwrite a saved choice.Proposed change
There are two routes, and a maintainer needs to pick one before work starts.
Option 1: keep the reader's explicit choice (a theme change and an upstream candidate).
useThemeand call it from the localDocumentin place of the upstream hook (app/components/Document.tsx lines 85-86). UpstreamuseThemecallsusePreferredThemeinternally, so that hook cannot be replaced on its own. The local hook keeps upstream's starting value: the saved choice if there is one, otherwise the OS preference. It writesmyst:themeonly when the reader toggles, never on an OS change. While nothing is saved, OS changes may still update the page. Once a choice is saved, they are ignored. The file's header comment also needs updating (lines 23-45). It says the copy was taken only to setlanganddir, and that the file can be deleted once upstream accepts those props.pending, target jupyter-book/myst-theme), shaped like the existingdocument-lang-direntry (lines 84-99). The request to upstream:usePreferredThemeshould follow the OS only while no explicit choice is saved, and OS-driven changes should not be saved.Trade-offs: it matches docs/dark-mode.md and the book theme. The cost is a second local change in the
Documentpath to maintain. Also, once a reader toggles, the page stops following the OS, as on the Sphinx sites. The two-state toggle has no way back to following the system short of clearing site data.Option 2: keep upstream's behaviour and correct docs/dark-mode.md. The docs would say that an OS appearance change while a page is open replaces the reader's choice and is saved. They would also say this saves a value for readers who never chose. Trade-offs: there is no code to maintain and the theme stays on upstream code. But the behaviour still differs from the book theme, and on later loads a saved OS value can disagree with the current OS setting.
The study leaves the choice open. Its row reads "Either override the hook or correct the docs" and marks the issue as an upstream candidate, which is option 1's route. Its housekeeping list also names docs/dark-mode.md lines 3-6 for this row; that docs correction is tracked in #210.
Acceptance criteria
If option 1:
myst:theme.myst:theme; only the toolbar toggle does.If option 2:
Either way:
page.emulateMedia, and a reload, and checks for the behaviour chosen. Thestatic-chromeproject runs tests/visual/static.spec.ts against the static build (playwright.config.ts lines 84-91).Context
usePreferredThemewrites the OS value over the saved choice and that the theme does not override it.setContrasttomyst:theme, so a dark choice saved on a Sphinx site is not carried over at cutover. The button also has no pressed state oraria-pressed.