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";
|
} from "@/lib/theme-custom-store";
|
||||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||||
|
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||||
|
|
||||||
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||||
@@ -35,12 +36,17 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const mode of ["light", "dark"] as const) {
|
for (const mode of ["light", "dark"] as const) {
|
||||||
|
const bag: Record<string, string> = {};
|
||||||
for (const key of THEME_COLOR_KEYS) {
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
const dbKey = settingKey(key, mode);
|
const dbKey = settingKey(key, mode);
|
||||||
const raw = String(formData.get(dbKey) ?? "")
|
const raw = String(formData.get(dbKey) ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.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 = [
|
const ADMIN_KEYS = [
|
||||||
@@ -50,12 +56,17 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
|||||||
"admin_text_muted",
|
"admin_text_muted",
|
||||||
"admin_border",
|
"admin_border",
|
||||||
"admin_sidebar_bg",
|
"admin_sidebar_bg",
|
||||||
];
|
] as const;
|
||||||
|
const adminBag: Record<string, string> = {};
|
||||||
for (const key of ADMIN_KEYS) {
|
for (const key of ADMIN_KEYS) {
|
||||||
const raw = String(formData.get(key) ?? "")
|
const raw = String(formData.get(key) ?? "")
|
||||||
.normalize("NFC")
|
.normalize("NFC")
|
||||||
.trim();
|
.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") ?? "")
|
const radius = String(formData.get("border_radius") ?? "")
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Light/dark toggle. The actual class lives on <html> (so `.app.dark` selectors
|
* Light/dark toggle. The class lives on <html>; theme-init.js applies the
|
||||||
* and the dark CSS-var block apply); we mirror localStorage so the choice
|
* saved choice before paint. We sync React state after mount so SSR hydration
|
||||||
* survives reloads. The inline boot script in the root layout applies the saved
|
* does not desync from the real DOM class (which forced a double-click before).
|
||||||
* theme before paint to avoid a flash — this component only reflects/toggles it.
|
|
||||||
*/
|
*/
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const [dark, setDark] = useState(
|
const [dark, setDark] = useState(false);
|
||||||
() =>
|
const [ready, setReady] = useState(false);
|
||||||
typeof window !== "undefined" &&
|
|
||||||
document.documentElement.classList.contains("dark"),
|
useEffect(() => {
|
||||||
);
|
setDark(document.documentElement.classList.contains("dark"));
|
||||||
|
setReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
const next = !dark;
|
const next = !document.documentElement.classList.contains("dark");
|
||||||
setDark(next);
|
|
||||||
document.documentElement.classList.toggle("dark", next);
|
document.documentElement.classList.toggle("dark", next);
|
||||||
|
setDark(next);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("theme", next ? "dark" : "light");
|
localStorage.setItem("theme", next ? "dark" : "light");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -33,6 +34,8 @@ export function ThemeSwitcher() {
|
|||||||
className="nav-item cursor-pointer text-lg leading-none px-2"
|
className="nav-item cursor-pointer text-lg leading-none px-2"
|
||||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
title={dark ? "Light mode" : "Dark mode"}
|
title={dark ? "Light mode" : "Dark mode"}
|
||||||
|
suppressHydrationWarning
|
||||||
|
style={{ visibility: ready ? "visible" : "hidden" }}
|
||||||
>
|
>
|
||||||
<span aria-hidden className="text-lg leading-none">
|
<span aria-hidden className="text-lg leading-none">
|
||||||
{dark ? "☀" : "☾"}
|
{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", () => {
|
describe("derivePublicForegrounds", () => {
|
||||||
it.each(
|
it.each(
|
||||||
Object.entries(PRESETS),
|
Object.entries(PRESETS),
|
||||||
|
|||||||
@@ -186,6 +186,36 @@ export function readableColor(
|
|||||||
.sort((a, b) => b.minimum - a.minimum)[0].color;
|
.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 {
|
function parseHex(value: string): Rgb | null {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
const short = /^#([0-9a-f]{3})$/i.exec(trimmed);
|
const short = /^#([0-9a-f]{3})$/i.exec(trimmed);
|
||||||
|
|||||||
Reference in new issue
Block a user