Fix theme switcher desync and auto-correct unreadable saved colors.
Local Build and Deploy / deploy (push) Successful in 53s
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:
1 parent
ed56bf0917
commit
0096c55f96
4 files changed
+91
-15
No files matched your search
@@ -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") ?? "")
|
||||
|
||||
@@ -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 ? "☀" : "☾"}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user