diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 030e5e4b..20e7f047 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -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 { try { for (const mode of ["light", "dark"] as const) { + const bag: Record = {}; 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 { "admin_text_muted", "admin_border", "admin_sidebar_bg", - ]; + ] as const; + const adminBag: Record = {}; 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") ?? "") diff --git a/src/components/theme-switcher.tsx b/src/components/theme-switcher.tsx index 7b67efbe..0d8c17d7 100644 --- a/src/components/theme-switcher.tsx +++ b/src/components/theme-switcher.tsx @@ -1,24 +1,25 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; /** - * Light/dark toggle. The actual class lives on (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 ; 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" }} > {dark ? "☀" : "☾"} diff --git a/src/lib/theme-contrast.test.ts b/src/lib/theme-contrast.test.ts index 39d43f89..62fdc481 100644 --- a/src/lib/theme-contrast.test.ts +++ b/src/lib/theme-contrast.test.ts @@ -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), diff --git a/src/lib/theme-contrast.ts b/src/lib/theme-contrast.ts index 4f96af3f..0878c207 100644 --- a/src/lib/theme-contrast.ts +++ b/src/lib/theme-contrast.ts @@ -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, +): Record { + 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);