Fix automatic readable text contrast for public and admin themes.
Local Build and Deploy / deploy (push) Successful in 55s

Derive admin/public text colors from WCAG contrast, unify ThemeVars CSS emission, and keep navbar overrides in sync with readable vars.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-15 20:41:34 +02:00
1 parent 9d4d409b77
commit d7278c77f9
9 files changed
+205 -176

No files matched your search

+63 -7
View File
@@ -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);
})();
+2 -2
View File
@@ -402,7 +402,7 @@ async function Sidebar({
>
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
<span
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--color-primary-foreground-readable)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
className="flex-none w-10 h-10 rounded-xl grid place-items-center font-extrabold text-base text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] shadow-lg shadow-[var(--admin-accent)]/20"
aria-hidden
>
{initial}
@@ -475,7 +475,7 @@ async function Topbar({
<span className="text-xs text-[var(--admin-text-muted)]">
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--color-primary-foreground-readable)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
<span className="inline-flex items-center gap-1.5 text-[0.6rem] font-bold uppercase tracking-wider text-[var(--admin-accent-foreground)] bg-[var(--admin-accent)] rounded-full px-2.5 py-1">
{t("rankLabel", { rank: staff.rank })}
</span>
</div>
+11 -1
View File
@@ -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,
+1 -6
View File
@@ -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 {
+8
View File
@@ -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);
}, []);
File diff suppressed because it is too large. Load diff
+42
View File
@@ -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", () => {
+38 -22
View File
@@ -31,7 +31,7 @@ export function deriveAdminPalette(
colors: PublicThemeColors,
overrides?: Partial<AdminPalette>,
): 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),
+4 -4
View File
@@ -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("")}}`;
}