diff --git a/public/scripts/theme-init.js b/public/scripts/theme-init.js index 9e8f9b04..b492952f 100644 --- a/public/scripts/theme-init.js +++ b/public/scripts/theme-init.js @@ -1,4 +1,66 @@ (() => { + function parseHex(value) { + const trimmed = String(value || "").trim(); + const short = /^#([0-9a-f]{3})$/i.exec(trimmed); + if (short) { + const [r, g, b] = short[1].split(""); + return [ + Number.parseInt(r + r, 16), + Number.parseInt(g + g, 16), + Number.parseInt(b + b, 16), + ]; + } + const match = /^#([0-9a-f]{6})$/i.exec(trimmed); + if (!match) return null; + return [ + Number.parseInt(match[1].slice(0, 2), 16), + Number.parseInt(match[1].slice(2, 4), 16), + Number.parseInt(match[1].slice(4, 6), 16), + ]; + } + + function relativeLuminance(rgb) { + const channels = rgb.map((channel) => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; + } + + function contrastRatio(foreground, background) { + const fg = parseHex(foreground); + const bg = parseHex(background); + if (!fg || !bg) return null; + const light = Math.max(relativeLuminance(fg), relativeLuminance(bg)); + const dark = Math.min(relativeLuminance(fg), relativeLuminance(bg)); + return (light + 0.05) / (dark + 0.05); + } + + function readableColor(preferred, background) { + const preferredRatio = contrastRatio(preferred, background); + if (preferredRatio != null && preferredRatio >= 4.5) return preferred; + const black = contrastRatio("#000000", background) ?? 0; + const white = contrastRatio("#ffffff", background) ?? 0; + return white >= black ? "#ffffff" : "#000000"; + } + + function applyNavbarColors(navbarColor, navbarTextColor) { + const root = document.documentElement.style; + if (navbarColor) root.setProperty("--color-navbar", navbarColor); + const text = + navbarTextColor || + (navbarColor ? readableColor("#1e293b", navbarColor) : null); + if (text) { + root.setProperty("--color-navbar-text", text); + root.setProperty("--color-navbar-text-readable", text); + } else if (navbarColor) { + const readable = readableColor("#ffffff", navbarColor); + root.setProperty("--color-navbar-text-readable", readable); + } + } + let theme = null; let defaultDark = false; let navbarColor = null; @@ -12,11 +74,5 @@ } catch (_e) {} if (theme === "dark" || (!theme && defaultDark)) document.documentElement.classList.add("dark"); - if (navbarColor) - document.documentElement.style.setProperty("--color-navbar", navbarColor); - if (navbarTextColor) - document.documentElement.style.setProperty( - "--color-navbar-text", - navbarTextColor, - ); + applyNavbarColors(navbarColor, navbarTextColor); })(); diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 05727df2..957dafe9 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -402,7 +402,7 @@ async function Sidebar({ >
{initial} @@ -475,7 +475,7 @@ async function Topbar({ {staff.username} - + {t("rankLabel", { rank: staff.rank })}
diff --git a/src/app/admin/theme/color-field.tsx b/src/app/admin/theme/color-field.tsx index d5f13d01..37a54cbc 100644 --- a/src/app/admin/theme/color-field.tsx +++ b/src/app/admin/theme/color-field.tsx @@ -73,7 +73,17 @@ export function ColorField({ type="color" name={name} value={val} - onChange={(e) => setVal(e.target.value)} + onChange={(e) => { + const next = e.target.value; + if (bgValue) { + const nextRatio = contrastRatio(next, bgValue); + if (nextRatio != null && nextRatio < 4.5) { + setVal(readableOn(bgValue)); + return; + } + } + setVal(next); + }} style={{ width: 42, height: 36, diff --git a/src/app/globals.css b/src/app/globals.css index c942587e..5f8f1811 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -86,12 +86,7 @@ --border-subtle: color-mix(in srgb, var(--color-text-muted) 18%, transparent); } -/* Dark mode colors are generated by ThemeVars from the selected preset. */ -html.dark { - --color-text-readable: var(--color-text); - --color-text-muted-readable: var(--color-text-muted); - --color-navbar-text-readable: var(--color-navbar-text); -} +/* Dark mode colors + readable contrast vars are generated by ThemeVars. */ html.dark input, html.dark select, html.dark textarea { diff --git a/src/components/navbar-color-picker.tsx b/src/components/navbar-color-picker.tsx index 645bcadb..fec2c08a 100644 --- a/src/components/navbar-color-picker.tsx +++ b/src/components/navbar-color-picker.tsx @@ -39,6 +39,10 @@ export function NavbarColorPicker() { if (!c) return; document.documentElement.style.setProperty("--color-navbar", c.value); document.documentElement.style.setProperty("--color-navbar-text", c.text); + document.documentElement.style.setProperty( + "--color-navbar-text-readable", + c.text, + ); try { localStorage.setItem("navbarColor", c.value); localStorage.setItem("navbarTextColor", c.text); @@ -58,6 +62,10 @@ export function NavbarColorPicker() { "--color-navbar-text", savedText, ); + document.documentElement.style.setProperty( + "--color-navbar-text-readable", + savedText, + ); } setMounted(true); }, []); diff --git a/src/components/theme-vars.tsx b/src/components/theme-vars.tsx index 5cfc8195..92f31915 100644 --- a/src/components/theme-vars.tsx +++ b/src/components/theme-vars.tsx @@ -1,10 +1,5 @@ import { siteSettings } from "@/lib/services/site-settings"; -import { - deriveAdminPalette, - derivePublicForegrounds, - readableColor, -} from "@/lib/theme-contrast"; -import { adminPaletteCss, themePaletteCss } from "@/lib/theme-css"; +import { themePaletteCss } from "@/lib/theme-css"; import { FONTS, PRESETS, @@ -12,9 +7,8 @@ import { type ThemePalette, } from "@/lib/theme-presets"; -// Injects the DB-driven CSS custom properties into :root, exactly like -// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers -// colours, typography, buttons, links + an optional raw custom-CSS block. +// Injects the DB-driven CSS custom properties into :root / html.dark. +// Readable (WCAG) text colors are derived centrally in themePaletteCss. export async function ThemeVars() { const g = (k: string, d: string) => siteSettings.get(k, d); const [ @@ -103,68 +97,20 @@ export async function ThemeVars() { const px = (v: string | null, d: string) => /^\d{1,3}$/.test(v ?? "") ? (v as string) : d; - const safeBackground = safe(background, "#f8fafc"); - const safeSurface = safe(surface, "#ffffff"); - const publicBackgrounds = [safeBackground, safeSurface]; - const readableText = readableColor(safe(text, "#0f172a"), publicBackgrounds); - const readableMuted = readableColor( - safe(textMuted, "#64748b"), - publicBackgrounds, - ); - const readablePrimary = readableColor( - safe(primary, "#f59e0b"), - publicBackgrounds, - ); - const readableAccent = readableColor( - safe(accent, "#10b981"), - publicBackgrounds, - ); - const readableLink = readableColor( - safe(linkColor, "#eeb425"), - publicBackgrounds, - ); - const readableLinkHover = readableColor( - safe(linkHover, "#cf9d15"), - publicBackgrounds, - ); - const readableButtonText = readableColor(safe(buttonText, "#1e293b"), [ - safe(buttonColor, "#f59e0b"), - ]); - const readableSecondaryText = readableColor( - safe(buttonSecondaryText, "#ffffff"), - [safe(buttonSecondary, "#22c55e")], - ); - const readableDangerText = readableColor(safe(buttonDangerText, "#ffffff"), [ - safe(buttonDanger, "#ef4444"), - ]); - const readableNavbarText = readableColor(safe(navbarText, "#1e293b"), [ - safe(navbar, "#ffffff"), - ]); - const readablePrimaryForeground = readableColor(safe(buttonText, "#1e293b"), [ - safe(primary, "#f59e0b"), - ]); - const readableAccentForeground = readableColor("#ffffff", [ - safe(accent, "#10b981"), - ]); - const adminOverrides = { - ...(adminCanvas ? { canvas: safe(adminCanvas, "") } : {}), - ...(adminSurface ? { surface: safe(adminSurface, "") } : {}), - ...(adminText ? { text: safe(adminText, "") } : {}), - ...(adminTextMuted ? { muted: safe(adminTextMuted, "") } : {}), - ...(adminBorder ? { border: safe(adminBorder, "") } : {}), - ...(adminSidebarBg ? { sidebar: safe(adminSidebarBg, "") } : {}), - }; - - const semantic = derivePublicForegrounds({ - color_background: safeBackground, - color_surface: safeSurface, + const lightPalette = { + color_primary: safe(primary, "#f59e0b"), + color_background: safe(background, "#f8fafc"), + color_surface: safe(surface, "#ffffff"), color_dropdown: safe(dropdown, "#ffffff"), color_navbar: safe(navbar, "#ffffff"), color_navbar_text: safe(navbarText, "#1e293b"), color_text: safe(text, "#0f172a"), color_text_muted: safe(textMuted, "#64748b"), - color_primary: safe(primary, "#f59e0b"), color_accent: safe(accent, "#10b981"), + color_success: safe(success, "#16a34a"), + color_warning: safe(warning, "#eab308"), + color_error: safe(error, "#ef4444"), + color_info: safe(info, "#0ea5e9"), button_primary_color: safe(buttonColor, "#f59e0b"), button_text_color: safe(buttonText, "#1e293b"), button_secondary_color: safe(buttonSecondary, "#22c55e"), @@ -175,7 +121,19 @@ export async function ThemeVars() { button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"), link_color: safe(linkColor, "#eeb425"), link_hover_color: safe(linkHover, "#cf9d15"), - }); + border_color: safe(borderColor, "#eeb425"), + gradient_from: safe(gradientFrom, "#f59e0b"), + gradient_to: safe(gradientTo, "#10b981"), + } satisfies ThemePalette; + + const adminOverrides = { + ...(adminCanvas ? { canvas: safe(adminCanvas, "#0b0f19") } : {}), + ...(adminSurface ? { surface: safe(adminSurface, "#151b27") } : {}), + ...(adminText ? { text: safe(adminText, "#f8fafc") } : {}), + ...(adminTextMuted ? { muted: safe(adminTextMuted, "#94a3b8") } : {}), + ...(adminBorder ? { border: safe(adminBorder, "#2a3448") } : {}), + ...(adminSidebarBg ? { sidebar: safe(adminSidebarBg, "#080c18") } : {}), + }; const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito; @@ -193,75 +151,19 @@ export async function ThemeVars() { ), ), ) as ThemePalette; + + const typography = [ + `--border-radius:${px(borderRadius, "12")}px`, + `--font-family:${font.stack}`, + `--size-heading-h1:${px(h1, "30")}px`, + `--size-heading-h2:${px(h2, "22")}px`, + `--size-heading-h3:${px(h3, "18")}px`, + ].join(";"); + + const lightCss = themePaletteCss(":root", lightPalette, adminOverrides); const darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides); - - const lightAdmin = deriveAdminPalette( - { - color_background: safeBackground, - color_surface: safeSurface, - color_text: safe(text, "#0f172a"), - color_text_muted: safe(textMuted, "#64748b"), - color_primary: safe(primary, "#f59e0b"), - color_accent: safe(accent, "#10b981"), - color_success: safe(success, "#16a34a"), - color_warning: safe(warning, "#eab308"), - color_error: safe(error, "#ef4444"), - color_info: safe(info, "#0ea5e9"), - } as ThemePalette, - adminOverrides, - ); - - const css = `:root{${adminPaletteCss(lightAdmin)} ---color-primary:${safe(primary, "#f59e0b")}; ---color-background:${safeBackground}; ---color-surface:${safeSurface}; ---color-dropdown:${safe(dropdown, "#ffffff")}; ---color-navbar:${safe(navbar, "#ffffff")}; ---color-navbar-text:${safe(navbarText, "#1e293b")}; ---color-text:${safe(text, "#0f172a")}; ---color-text-muted:${safe(textMuted, "#64748b")}; ---color-accent:${safe(accent, "#10b981")}; ---color-success:${safe(success, "#16a34a")}; ---color-warning:${safe(warning, "#eab308")}; ---color-error:${safe(error, "#ef4444")}; ---color-info:${safe(info, "#0ea5e9")}; ---button-color:${safe(buttonColor, "#f59e0b")}; ---button-text-color:${safe(buttonText, "#1e293b")}; ---button-secondary-color:${safe(buttonSecondary, "#22c55e")}; ---button-secondary-text-color:${safe(buttonSecondaryText, "#ffffff")}; ---button-danger-color:${safe(buttonDanger, "#ef4444")}; ---button-danger-text-color:${safe(buttonDangerText, "#ffffff")}; ---button-outline-color:${safe(buttonOutline, "#eeb425")}; ---button-outline-text-color:${safe(buttonOutlineText, "#1a1a2e")}; ---link-color:${safe(linkColor, "#eeb425")}; ---link-hover-color:${safe(linkHover, "#cf9d15")}; ---border-color:${safe(borderColor, "#eeb425")}; ---gradient-from:${safe(gradientFrom, "#f59e0b")}; ---gradient-to:${safe(gradientTo, "#10b981")}; ---color-text-readable:${readableText}; ---color-text-muted-readable:${readableMuted}; ---color-primary-readable:${readablePrimary}; ---color-accent-readable:${readableAccent}; ---color-primary-foreground-readable:${readablePrimaryForeground}; ---color-accent-foreground-readable:${readableAccentForeground}; ---color-navbar-text-readable:${readableNavbarText}; ---button-text-color-readable:${readableButtonText}; ---button-secondary-text-color-readable:${readableSecondaryText}; ---button-danger-text-color-readable:${readableDangerText}; ---link-color-readable:${readableLink}; ---link-hover-color-readable:${readableLinkHover}; ---color-surface-text-readable:${semantic.byName.surface}; ---color-dropdown-text-readable:${semantic.byName.dropdown}; ---button-outline-text-color-readable:${semantic.byName["button-outline"]}; ---color-pill-text-readable:${semantic.byName.pill}; ---color-badge-text-readable:${semantic.byName.badge}; ---color-overlay-text-readable:${semantic.byName.overlay}; ---border-radius:${px(borderRadius, "12")}px; ---font-family:${font.stack}; ---size-heading-h1:${px(h1, "30")}px; ---size-heading-h2:${px(h2, "22")}px; ---size-heading-h3:${px(h3, "18")}px; -}${darkCss}`; + // Append typography inside :root block. + const css = `${lightCss.slice(0, -1)}${typography};}${darkCss}`; const googleHref = font.google ? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap` diff --git a/src/lib/theme-contrast.test.ts b/src/lib/theme-contrast.test.ts index 48436ba3..39d43f89 100644 --- a/src/lib/theme-contrast.test.ts +++ b/src/lib/theme-contrast.test.ts @@ -130,6 +130,48 @@ describe("deriveAdminPalette", () => { expect(admin.canvas).not.toBe("#ff00ff"); expect(admin.surface).not.toBe("#00ff00"); }); + + it("repairs default light text when admin canvas is forced light", () => { + const admin = deriveAdminPalette( + { color_primary: "#f59e0b" }, + { + canvas: "#ffffff", + surface: "#f8fafc", + surfaceElevated: "#f1f5f9", + sidebar: "#ffffff", + }, + ); + expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual( + 4.5, + ); + expect( + contrastRatio(admin.sidebarText, admin.sidebar), + ).toBeGreaterThanOrEqual(4.5); + expect( + contrastRatio(admin.accentForeground, admin.accent), + ).toBeGreaterThanOrEqual(4.5); + }); + + it("repairs hostile admin text overrides on dark canvas", () => { + const admin = deriveAdminPalette( + { color_primary: "#111827" }, + { + canvas: "#0b0f19", + surface: "#151b27", + text: "#0b0f19", + muted: "#111827", + }, + ); + expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual( + 4.5, + ); + expect( + contrastRatio(admin.accentForeground, admin.accent), + ).toBeGreaterThanOrEqual(4.5); + }); }); describe("derivePublicForegrounds", () => { diff --git a/src/lib/theme-contrast.ts b/src/lib/theme-contrast.ts index 359b36dd..4f96af3f 100644 --- a/src/lib/theme-contrast.ts +++ b/src/lib/theme-contrast.ts @@ -31,7 +31,7 @@ export function deriveAdminPalette( colors: PublicThemeColors, overrides?: Partial, ): AdminPalette { - const accent = colors.color_primary ?? "#f59e0b"; + const accent = overrides?.accent ?? colors.color_primary ?? "#f59e0b"; const defaults: AdminPalette = { canvas: "#0b0f19", surface: "#151b27", @@ -40,8 +40,8 @@ export function deriveAdminPalette( muted: "#94a3b8", border: "#2a3448", accent, - accentText: readableColor(accent, ["#0b0f19", "#151b27", "#1e2635"]), - accentForeground: readableColor("#ffffff", [accent]), + accentText: "#f59e0b", + accentForeground: "#ffffff", success: "#22c55e", warning: "#eab308", error: "#ef4444", @@ -51,29 +51,35 @@ export function deriveAdminPalette( overlay: "rgba(2, 6, 23, 0.72)", focusRing: accent, }; - if (!overrides) return defaults; - return { + + const merged: AdminPalette = { ...defaults, ...overrides, accent, - accentText: readableColor(accent, [ - overrides.canvas ?? defaults.canvas, - overrides.surface ?? defaults.surface, - overrides.surfaceElevated ?? defaults.surfaceElevated, - ]), - accentForeground: readableColor("#ffffff", [accent]), - success: overrides.success ?? defaults.success, - warning: overrides.warning ?? defaults.warning, - error: overrides.error ?? defaults.error, - info: overrides.info ?? defaults.info, - sidebarText: readableColor(overrides.text ?? defaults.text, [ - overrides.sidebar ?? defaults.sidebar, - overrides.canvas ?? defaults.canvas, - overrides.surface ?? defaults.surface, - ]), - overlay: defaults.overlay, + overlay: overrides?.overlay ?? defaults.overlay, focusRing: accent, }; + + const surfaces = [ + merged.canvas, + merged.surface, + merged.surfaceElevated, + ] as const; + const preferredText = merged.text; + const preferredMuted = merged.muted; + + return { + ...merged, + text: readableColor(preferredText, surfaces), + muted: readableColor(preferredMuted, [merged.canvas, merged.surface]), + accentText: readableColor(accent, surfaces), + accentForeground: readableColor("#ffffff", [accent]), + sidebarText: readableColor(preferredText, [ + merged.sidebar, + merged.canvas, + merged.surface, + ]), + }; } export function derivePublicForegrounds(c: PublicThemeColors) { @@ -181,7 +187,17 @@ export function readableColor( } function parseHex(value: string): Rgb | null { - const match = /^#([0-9a-f]{6})$/i.exec(value.trim()); + const trimmed = value.trim(); + const short = /^#([0-9a-f]{3})$/i.exec(trimmed); + if (short) { + const [r, g, b] = short[1].split(""); + return [ + Number.parseInt(r + r, 16), + Number.parseInt(g + g, 16), + Number.parseInt(b + b, 16), + ]; + } + const match = /^#([0-9a-f]{6})$/i.exec(trimmed); if (!match) return null; return [ Number.parseInt(match[1].slice(0, 2), 16), diff --git a/src/lib/theme-css.ts b/src/lib/theme-css.ts index 09e55ff5..223c85b9 100644 --- a/src/lib/theme-css.ts +++ b/src/lib/theme-css.ts @@ -40,12 +40,14 @@ export function adminPaletteCss(admin: AdminPalette): string { `--admin-sidebar-background:${admin.sidebar};`, `--admin-sidebar-text-readable:${admin.sidebarText};`, `--admin-sidebar-muted-readable:color-mix(in srgb, var(--admin-sidebar-text-readable) 60%, transparent);`, - `--admin-input-text-readable:${readableColor(admin.text, [admin.canvas, admin.surface])};`, + `--admin-input-text-readable:${admin.text};`, `--admin-canvas:${admin.canvas};`, `--admin-surface:${admin.surface};`, `--admin-surface-elevated:${admin.surfaceElevated};`, `--admin-text:${admin.text};`, + `--admin-text-readable:${admin.text};`, `--admin-text-muted:${admin.muted};`, + `--admin-text-muted-readable:${admin.muted};`, `--admin-border:${admin.border};`, `--admin-accent:${admin.accent};`, `--admin-accent-text:${admin.accentText};`, @@ -104,9 +106,7 @@ export function themePaletteCss( `--color-${status}-subtle:color-mix(in srgb, ${palette[`color_${status}`]} 14%, ${palette.color_surface});`, `--color-${status}-border:color-mix(in srgb, ${palette[`color_${status}`]} 38%, transparent);`, ]), + adminPaletteCss(admin), ); - if (adminOverrides || selector !== ":root") { - declarations.push(adminPaletteCss(admin)); - } return `${selector}{${declarations.join("")}}`; }