fix(theme): declare the live colour scheme, so Dark Reader stands down
Dark Reader looks for `<meta name="color-scheme">` first. Finding none, it waits for a stylesheet and then samples text and background colours at several points of the screen, plus some further heuristics, to decide whether the page needs darkening. On the dark palette that verdict is wrong: it inverts an app that is already dark, a second time. So the tag now states which scheme is actually live. It ships in the markup as `light dark`, before any script runs; the pre-paint script narrows it to the one in use, and `applyResolvedTheme` keeps it there when the toggle moves — the same two-signal write as the `.dark` class, in both places. `var dark` moved out of the `try` in that script. It has to be readable after a throwing localStorage in order to set the meta, and false is the right value there: it is what `readThemePreference` -> "system" -> `prefersDark` resolves to on the module side, so the two still agree in the one case that cannot be read. Both setters create the tag when it is absent rather than skipping the write. A silent no-op leaves Dark Reader guessing, which is the bug. The drift guard now runs the real script, lifted out of the HTML, instead of matching its text — it asserts class and meta together for a stored dark, a stored light, and no preference at all. Text matching would have passed a script that declared the tag and never set it. Not `<meta name="darkreader-lock">`: that switches the extension off wholesale, including in light mode, where a user who installed it wants it working. Only the dark palette asks it to stand down. Verified in Chrome across stored dark, stored light, system-dark and a live toggle with no reload: one tag, content and computed `color-scheme` agreeing each time. Whether the extension honours it needs a human with Dark Reader installed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
acb4d97870
commit
d69e896c4c
@@ -96,8 +96,13 @@ ship twice — re-introducing one is a regression, not a style choice.
|
||||
reference elsewhere, so this one still needs your attention.
|
||||
- **The pre-paint theme script in `index.html` and `src/ui/theme.ts` must
|
||||
agree.** The script cannot import the module — it has to run before any module
|
||||
loads — so it repeats the storage key and the fallback logic.
|
||||
→ `src/ui/theme.test.ts`
|
||||
loads — so it repeats the storage key and the fallback logic. Both set two
|
||||
things, not one: the `.dark` class and `<meta name="color-scheme">`, which
|
||||
must also match the `color-scheme` declarations in `src/index.css`. The meta
|
||||
tag is what Dark Reader reads to decide the app already has a dark theme;
|
||||
without it, it samples the painted colours and inverts our dark palette a
|
||||
second time. → `src/ui/theme.test.ts`, which runs the real script out of the
|
||||
HTML rather than matching its text.
|
||||
- **Anything derived from the base path is asserted for both a root and a
|
||||
subpath deploy:** the manifest (`src/manifest.test.ts`), the font URLs
|
||||
(`src/fontCss.test.ts`), the icons (`src/icons.test.ts`), and the service
|
||||
|
||||
+15
-2
@@ -11,6 +11,12 @@
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<!-- The live scheme, stated before any script runs. The pre-paint script
|
||||
below narrows it to the one actually in use; src/ui/theme.ts keeps it
|
||||
in step from then on. Dark Reader reads this tag to decide whether the
|
||||
page already has a dark theme, and inverts our own dark palette a
|
||||
second time when it finds nothing. -->
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<!--
|
||||
Applies the saved theme BEFORE the stylesheet paints, so a dark-theme
|
||||
user never sees a flash of the light palette. It cannot import
|
||||
@@ -19,14 +25,21 @@
|
||||
Wrapped because localStorage throws outright in some configurations.
|
||||
-->
|
||||
<script>
|
||||
var dark = false;
|
||||
try {
|
||||
var saved = localStorage.getItem("budget2:theme");
|
||||
var dark =
|
||||
dark =
|
||||
saved === "dark" ||
|
||||
((saved === null || saved === "system") &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
} catch (e) {}
|
||||
if (dark) document.documentElement.classList.add("dark");
|
||||
var scheme = document.querySelector('meta[name="color-scheme"]');
|
||||
if (!scheme) {
|
||||
scheme = document.head.appendChild(document.createElement("meta"));
|
||||
scheme.name = "color-scheme";
|
||||
}
|
||||
scheme.content = dark ? "dark" : "light";
|
||||
</script>
|
||||
<script type="module" src="./frontend.tsx" async></script>
|
||||
</head>
|
||||
|
||||
+90
-5
@@ -10,6 +10,7 @@ import {
|
||||
THEME_KEY,
|
||||
applyResolvedTheme,
|
||||
isThemePreference,
|
||||
prefersDark,
|
||||
readThemePreference,
|
||||
resolveTheme,
|
||||
storeThemePreference,
|
||||
@@ -18,8 +19,27 @@ import {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark");
|
||||
for (const meta of document.querySelectorAll('meta[name="color-scheme"]')) {
|
||||
meta.remove();
|
||||
}
|
||||
});
|
||||
|
||||
function colorSchemeMeta(): string | null {
|
||||
return document.querySelector('meta[name="color-scheme"]')?.getAttribute("content") ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts the inline pre-paint script out of index.html and runs it for real,
|
||||
* rather than matching its text. The module script below it carries
|
||||
* attributes, so a bare `<script>` matches only the one we want.
|
||||
*/
|
||||
async function runPrePaintScript(): Promise<void> {
|
||||
const html = await Bun.file("src/index.html").text();
|
||||
const script = html.match(/<script>([\s\S]*?)<\/script>/);
|
||||
if (!script) throw new Error("src/index.html has no inline pre-paint script");
|
||||
new Function(script[1]!)();
|
||||
}
|
||||
|
||||
describe("resolving a preference to an actual theme", () => {
|
||||
test("an explicit choice ignores the system setting entirely", () => {
|
||||
expect(resolveTheme("light", true)).toBe("light");
|
||||
@@ -76,15 +96,80 @@ describe("applying a theme", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dark Reader looks for `<meta name="color-scheme">` first; without it, it
|
||||
* samples text and background colours once a stylesheet exists and applies its
|
||||
* own inversion on top of ours. Declaring "dark" is what tells it this app is
|
||||
* already dark and to leave the palette alone.
|
||||
*/
|
||||
describe("telling the browser, and Dark Reader, which scheme is live", () => {
|
||||
test("the meta content follows the resolved theme", () => {
|
||||
document.head.appendChild(document.createElement("meta")).setAttribute(
|
||||
"name",
|
||||
"color-scheme",
|
||||
);
|
||||
|
||||
applyResolvedTheme("dark");
|
||||
expect(colorSchemeMeta()).toBe("dark");
|
||||
applyResolvedTheme("light");
|
||||
expect(colorSchemeMeta()).toBe("light");
|
||||
});
|
||||
|
||||
// Silently doing nothing would leave Dark Reader guessing, which is the
|
||||
// whole bug — so this creates the tag rather than skipping the write.
|
||||
test("the tag is created when the document has none", () => {
|
||||
expect(colorSchemeMeta()).toBe(null);
|
||||
applyResolvedTheme("dark");
|
||||
expect(colorSchemeMeta()).toBe("dark");
|
||||
expect(document.querySelectorAll('meta[name="color-scheme"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("re-applying does not add a second tag", () => {
|
||||
applyResolvedTheme("dark");
|
||||
applyResolvedTheme("light");
|
||||
expect(document.querySelectorAll('meta[name="color-scheme"]')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* index.html carries a tiny inline script that applies the stored theme before
|
||||
* the stylesheet paints, so a dark-theme user never sees a white flash. It
|
||||
* cannot import this module, so it repeats the key and the logic — and this is
|
||||
* what stops the two drifting apart.
|
||||
*/
|
||||
test("the no-flash script in index.html agrees with this module", async () => {
|
||||
const html = await Bun.file("src/index.html").text();
|
||||
expect(html).toContain(THEME_KEY);
|
||||
expect(html).toContain("prefers-color-scheme: dark");
|
||||
expect(html).toContain("dark");
|
||||
describe("the no-flash script in index.html agrees with this module", () => {
|
||||
test("it reads the same key and the same system fallback", async () => {
|
||||
const html = await Bun.file("src/index.html").text();
|
||||
expect(html).toContain(THEME_KEY);
|
||||
expect(html).toContain("prefers-color-scheme: dark");
|
||||
expect(html).toContain("dark");
|
||||
});
|
||||
|
||||
// Declared in the markup as well, so the scheme is stated before any script
|
||||
// runs at all.
|
||||
test("the color-scheme meta ships in the markup", async () => {
|
||||
const html = await Bun.file("src/index.html").text();
|
||||
expect(html).toContain('name="color-scheme"');
|
||||
});
|
||||
|
||||
test("a stored dark preference resolves to dark, both signals", async () => {
|
||||
storeThemePreference("dark");
|
||||
await runPrePaintScript();
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||
expect(colorSchemeMeta()).toBe("dark");
|
||||
});
|
||||
|
||||
test("a stored light preference resolves to light, both signals", async () => {
|
||||
storeThemePreference("light");
|
||||
await runPrePaintScript();
|
||||
expect(document.documentElement.classList.contains("dark")).toBe(false);
|
||||
expect(colorSchemeMeta()).toBe("light");
|
||||
});
|
||||
|
||||
// Nothing stored, and happy-dom reports a light system: the same answer
|
||||
// resolveTheme("system", false) gives.
|
||||
test("no stored preference follows the system", async () => {
|
||||
await runPrePaintScript();
|
||||
expect(colorSchemeMeta()).toBe(resolveTheme("system", prefersDark()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,29 @@ export function resolveTheme(
|
||||
*/
|
||||
export function applyResolvedTheme(theme: ResolvedTheme): void {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
applyColorScheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* States the live scheme in `<meta name="color-scheme">`, kept in step with the
|
||||
* `color-scheme` declarations in index.css.
|
||||
*
|
||||
* The meta tag rather than the stylesheet alone, because of Dark Reader: it
|
||||
* reads this tag first, and failing to find one it samples text and background
|
||||
* colours off the painted page and applies its own inversion. On our dark
|
||||
* palette that is a second darkening of an already dark app. Declaring "dark"
|
||||
* is what tells it the app is already there.
|
||||
*
|
||||
* index.html ships the tag and the pre-paint script sets it, so this is
|
||||
* normally an update; it creates the tag if it is missing rather than silently
|
||||
* doing nothing, which would leave Dark Reader guessing again.
|
||||
*/
|
||||
function applyColorScheme(theme: ResolvedTheme): void {
|
||||
let meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.name = "color-scheme";
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = theme;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user