Fix theme switcher desync and auto-correct unreadable saved colors.
Local Build and Deploy / deploy (push) Successful in 53s

Sync dark-mode state from the DOM after mount so admin text no longer needs a double toggle, and normalize text/button colors against their surfaces on theme save.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 20:54:46 +02:00
1 parent ed56bf0917
commit 0096c55f96
4 files changed
+91 -15

No files matched your search

+14 -3
View File
@@ -15,6 +15,7 @@ import {
} from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
@@ -35,12 +36,17 @@ export async function saveTheme(formData: FormData): Promise<void> {
try {
for (const mode of ["light", "dark"] as const) {
const bag: Record<string, string> = {};
for (const key of THEME_COLOR_KEYS) {
const dbKey = settingKey(key, mode);
const raw = String(formData.get(dbKey) ?? "")
.normalize("NFC")
.trim();
if (raw && COLOR_RE.test(raw)) await writeSetting(dbKey, raw);
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
}
const fixed = ensureReadableThemeColors(bag);
for (const [key, value] of Object.entries(fixed)) {
await writeSetting(settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode), value);
}
}
const ADMIN_KEYS = [
@@ -50,12 +56,17 @@ export async function saveTheme(formData: FormData): Promise<void> {
"admin_text_muted",
"admin_border",
"admin_sidebar_bg",
];
] as const;
const adminBag: Record<string, string> = {};
for (const key of ADMIN_KEYS) {
const raw = String(formData.get(key) ?? "")
.normalize("NFC")
.trim();
if (raw && COLOR_RE.test(raw)) await writeSetting(key, raw);
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
}
const adminFixed = ensureReadableThemeColors(adminBag);
for (const [key, value] of Object.entries(adminFixed)) {
await writeSetting(key, value);
}
const radius = String(formData.get("border_radius") ?? "")
+15 -12
View File
@@ -1,24 +1,25 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
/**
* Light/dark toggle. The actual class lives on <html> (so `.app.dark` selectors
* and the dark CSS-var block apply); we mirror localStorage so the choice
* survives reloads. The inline boot script in the root layout applies the saved
* theme before paint to avoid a flash — this component only reflects/toggles it.
* Light/dark toggle. The class lives on <html>; theme-init.js applies the
* saved choice before paint. We sync React state after mount so SSR hydration
* does not desync from the real DOM class (which forced a double-click before).
*/
export function ThemeSwitcher() {
const [dark, setDark] = useState(
() =>
typeof window !== "undefined" &&
document.documentElement.classList.contains("dark"),
);
const [dark, setDark] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
setDark(document.documentElement.classList.contains("dark"));
setReady(true);
}, []);
function toggle() {
const next = !dark;
setDark(next);
const next = !document.documentElement.classList.contains("dark");
document.documentElement.classList.toggle("dark", next);
setDark(next);
try {
localStorage.setItem("theme", next ? "dark" : "light");
} catch {
@@ -33,6 +34,8 @@ export function ThemeSwitcher() {
className="nav-item cursor-pointer text-lg leading-none px-2"
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
title={dark ? "Light mode" : "Dark mode"}
suppressHydrationWarning
style={{ visibility: ready ? "visible" : "hidden" }}
>
<span aria-hidden className="text-lg leading-none">
{dark ? "☀" : "☾"}
+32
View File
@@ -174,6 +174,38 @@ describe("deriveAdminPalette", () => {
});
});
describe("ensureReadableThemeColors", () => {
it("rewrites saved text colors that fail against their surfaces", async () => {
const { ensureReadableThemeColors } = await import("@/lib/theme-contrast");
const fixed = ensureReadableThemeColors({
color_background: "#0b0f19",
color_surface: "#151b27",
color_text: "#0b0f19",
color_text_muted: "#111827",
color_navbar: "#ffffff",
color_navbar_text: "#ffffff",
button_primary_color: "#f59e0b",
button_text_color: "#f59e0b",
admin_canvas: "#ffffff",
admin_surface: "#f8fafc",
admin_text: "#f8fafc",
admin_text_muted: "#e2e8f0",
});
expect(
contrastRatio(fixed.color_text, fixed.color_surface),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(fixed.color_navbar_text, fixed.color_navbar),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(fixed.button_text_color, fixed.button_primary_color),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(fixed.admin_text, fixed.admin_canvas),
).toBeGreaterThanOrEqual(4.5);
});
});
describe("derivePublicForegrounds", () => {
it.each(
Object.entries(PRESETS),
+30
View File
@@ -186,6 +186,36 @@ export function readableColor(
.sort((a, b) => b.minimum - a.minimum)[0].color;
}
/** Auto-fix foreground hex keys so saved custom themes stay readable. */
export function ensureReadableThemeColors(
colors: Record<string, string>,
): Record<string, string> {
const out = { ...colors };
const fix = (fgKey: string, bgKeys: readonly string[]) => {
const preferred = out[fgKey];
if (!preferred) return;
const backgrounds = bgKeys
.map((key) => out[key])
.filter((value): value is string => Boolean(value));
if (backgrounds.length === 0) return;
out[fgKey] = readableColor(preferred, backgrounds);
};
fix("color_text", ["color_surface", "color_background"]);
fix("color_text_muted", ["color_surface", "color_background"]);
fix("color_navbar_text", ["color_navbar"]);
fix("button_text_color", ["button_primary_color", "color_primary"]);
fix("button_secondary_text_color", ["button_secondary_color"]);
fix("button_danger_text_color", ["button_danger_color"]);
fix("button_outline_text_color", ["color_surface", "color_background"]);
fix("link_color", ["color_surface", "color_background"]);
fix("link_hover_color", ["color_surface", "color_background"]);
fix("admin_text", ["admin_canvas", "admin_surface"]);
fix("admin_text_muted", ["admin_canvas", "admin_surface"]);
return out;
}
function parseHex(value: string): Rgb | null {
const trimmed = value.trim();
const short = /^#([0-9a-f]{3})$/i.exec(trimmed);