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("")}}`;
}