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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
acb4d97870
commit
d69e896c4c
+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