fix(web): apply theme class before first paint so Dark Reader detects dark mode (#7571)

Dark Reader checks html.dark / color-scheme only once during page load,
but the theme class was added by ThemeProvider after React mounted.
Resolve the saved theme in an inline head script and declare
color-scheme: dark on .dark.
This commit is contained in:
RedwindA authored and GitHub committed 2026-10-17 14:03:48 +08:00
1 parent f92c576506
commit b0a98ac49c
3 files changed
+115

No files matched your search

+17
View File
@@ -14,6 +14,23 @@
/>
<meta name="theme-color" content="#fff" />
<!-- Resolve the theme before first paint so the page never flashes light
and one-shot detectors (e.g. Dark Reader) see the real theme.
Keep in sync with ThemeProvider and THEME_STORAGE_KEYS.mode. -->
<script>
;(function () {
var theme = 'system'
try {
theme = localStorage.getItem('newapi:theme:v1:mode') || 'system'
} catch (e) {}
if (theme !== 'dark' && theme !== 'light') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
document.documentElement.classList.add(theme)
})()
</script>
<!--umami-->
<!--Google Analytics-->
</head>
@@ -0,0 +1,95 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { THEME_STORAGE_KEYS } from '@/lib/theme-storage'
// The inline script in index.html must apply the theme class before React
// mounts; Dark Reader checks `html.dark` only once while the page loads.
const html = readFileSync(
resolve(import.meta.dirname, '../../../index.html'),
'utf8'
)
const bootScript = html.match(/<script>([\s\S]*?)<\/script>/)?.[1] ?? ''
function runBootScript() {
new Function(bootScript)()
}
function mockSystemDarkMode(dark: boolean) {
vi.spyOn(window, 'matchMedia').mockImplementation(
(query: string) =>
({
matches: dark && query === '(prefers-color-scheme: dark)',
media: query,
}) as MediaQueryList
)
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
document.documentElement.classList.remove('light', 'dark')
})
describe('index.html theme boot script', () => {
it('applies the saved dark preference to html before React mounts', () => {
mockSystemDarkMode(false)
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'dark')
runBootScript()
expect(document.documentElement).toHaveClass('dark')
expect(document.documentElement).not.toHaveClass('light')
})
it('applies the saved light preference even when the system is dark', () => {
mockSystemDarkMode(true)
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'light')
runBootScript()
expect(document.documentElement).toHaveClass('light')
expect(document.documentElement).not.toHaveClass('dark')
})
it('follows the system dark mode when no preference is saved', () => {
mockSystemDarkMode(true)
runBootScript()
expect(document.documentElement).toHaveClass('dark')
})
it('follows the system light mode when the saved value is invalid', () => {
mockSystemDarkMode(false)
localStorage.setItem(THEME_STORAGE_KEYS.mode, 'sepia')
runBootScript()
expect(document.documentElement).toHaveClass('light')
})
})
+3
View File
@@ -179,6 +179,9 @@ For commercial licensing, please contact support@quantumnous.com
}
.dark {
/* Native controls follow the theme; Dark Reader also reads this to detect
the built-in dark theme. */
color-scheme: dark;
/* OpenAI-like dark mode with a crisp charcoal canvas. */
--background: oklch(0.235 0 0);
--foreground: oklch(0.965 0 0);