Fix automatic readable text contrast for public and admin themes.
Local Build and Deploy / deploy (push) Successful in 55s
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:
1 parent
9d4d409b77
commit
d7278c77f9
9 files changed
+205
-176
No files matched your search
@@ -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 theme = null;
|
||||||
let defaultDark = false;
|
let defaultDark = false;
|
||||||
let navbarColor = null;
|
let navbarColor = null;
|
||||||
@@ -12,11 +74,5 @@
|
|||||||
} catch (_e) {}
|
} catch (_e) {}
|
||||||
if (theme === "dark" || (!theme && defaultDark))
|
if (theme === "dark" || (!theme && defaultDark))
|
||||||
document.documentElement.classList.add("dark");
|
document.documentElement.classList.add("dark");
|
||||||
if (navbarColor)
|
applyNavbarColors(navbarColor, navbarTextColor);
|
||||||
document.documentElement.style.setProperty("--color-navbar", navbarColor);
|
|
||||||
if (navbarTextColor)
|
|
||||||
document.documentElement.style.setProperty(
|
|
||||||
"--color-navbar-text",
|
|
||||||
navbarTextColor,
|
|
||||||
);
|
|
||||||
})();
|
})();
|
||||||
@@ -402,7 +402,7 @@ async function Sidebar({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
|
<div className="flex items-center gap-3 px-4 py-5 border-b border-[var(--admin-border)]">
|
||||||
<span
|
<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
|
aria-hidden
|
||||||
>
|
>
|
||||||
{initial}
|
{initial}
|
||||||
@@ -475,7 +475,7 @@ async function Topbar({
|
|||||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||||
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
|
<strong className="text-[var(--admin-text)]">{staff.username}</strong>
|
||||||
</span>
|
</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 })}
|
{t("rankLabel", { rank: staff.rank })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,7 +73,17 @@ export function ColorField({
|
|||||||
type="color"
|
type="color"
|
||||||
name={name}
|
name={name}
|
||||||
value={val}
|
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={{
|
style={{
|
||||||
width: 42,
|
width: 42,
|
||||||
height: 36,
|
height: 36,
|
||||||
|
|||||||
+1
-6
@@ -86,12 +86,7 @@
|
|||||||
--border-subtle: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
|
--border-subtle: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode colors are generated by ThemeVars from the selected preset. */
|
/* Dark mode colors + readable contrast vars are generated by ThemeVars. */
|
||||||
html.dark {
|
|
||||||
--color-text-readable: var(--color-text);
|
|
||||||
--color-text-muted-readable: var(--color-text-muted);
|
|
||||||
--color-navbar-text-readable: var(--color-navbar-text);
|
|
||||||
}
|
|
||||||
html.dark input,
|
html.dark input,
|
||||||
html.dark select,
|
html.dark select,
|
||||||
html.dark textarea {
|
html.dark textarea {
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export function NavbarColorPicker() {
|
|||||||
if (!c) return;
|
if (!c) return;
|
||||||
document.documentElement.style.setProperty("--color-navbar", c.value);
|
document.documentElement.style.setProperty("--color-navbar", c.value);
|
||||||
document.documentElement.style.setProperty("--color-navbar-text", c.text);
|
document.documentElement.style.setProperty("--color-navbar-text", c.text);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--color-navbar-text-readable",
|
||||||
|
c.text,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("navbarColor", c.value);
|
localStorage.setItem("navbarColor", c.value);
|
||||||
localStorage.setItem("navbarTextColor", c.text);
|
localStorage.setItem("navbarTextColor", c.text);
|
||||||
@@ -58,6 +62,10 @@ export function NavbarColorPicker() {
|
|||||||
"--color-navbar-text",
|
"--color-navbar-text",
|
||||||
savedText,
|
savedText,
|
||||||
);
|
);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--color-navbar-text-readable",
|
||||||
|
savedText,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
+36
-134
@@ -1,10 +1,5 @@
|
|||||||
import { siteSettings } from "@/lib/services/site-settings";
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
import {
|
import { themePaletteCss } from "@/lib/theme-css";
|
||||||
deriveAdminPalette,
|
|
||||||
derivePublicForegrounds,
|
|
||||||
readableColor,
|
|
||||||
} from "@/lib/theme-contrast";
|
|
||||||
import { adminPaletteCss, themePaletteCss } from "@/lib/theme-css";
|
|
||||||
import {
|
import {
|
||||||
FONTS,
|
FONTS,
|
||||||
PRESETS,
|
PRESETS,
|
||||||
@@ -12,9 +7,8 @@ import {
|
|||||||
type ThemePalette,
|
type ThemePalette,
|
||||||
} from "@/lib/theme-presets";
|
} from "@/lib/theme-presets";
|
||||||
|
|
||||||
// Injects the DB-driven CSS custom properties into :root, exactly like
|
// Injects the DB-driven CSS custom properties into :root / html.dark.
|
||||||
// AtomCMS's app.blade.php. Falls back to the atom defaults when no DB. Covers
|
// Readable (WCAG) text colors are derived centrally in themePaletteCss.
|
||||||
// colours, typography, buttons, links + an optional raw custom-CSS block.
|
|
||||||
export async function ThemeVars() {
|
export async function ThemeVars() {
|
||||||
const g = (k: string, d: string) => siteSettings.get(k, d);
|
const g = (k: string, d: string) => siteSettings.get(k, d);
|
||||||
const [
|
const [
|
||||||
@@ -103,68 +97,20 @@ export async function ThemeVars() {
|
|||||||
const px = (v: string | null, d: string) =>
|
const px = (v: string | null, d: string) =>
|
||||||
/^\d{1,3}$/.test(v ?? "") ? (v as string) : d;
|
/^\d{1,3}$/.test(v ?? "") ? (v as string) : d;
|
||||||
|
|
||||||
const safeBackground = safe(background, "#f8fafc");
|
const lightPalette = {
|
||||||
const safeSurface = safe(surface, "#ffffff");
|
color_primary: safe(primary, "#f59e0b"),
|
||||||
const publicBackgrounds = [safeBackground, safeSurface];
|
color_background: safe(background, "#f8fafc"),
|
||||||
const readableText = readableColor(safe(text, "#0f172a"), publicBackgrounds);
|
color_surface: safe(surface, "#ffffff"),
|
||||||
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,
|
|
||||||
color_dropdown: safe(dropdown, "#ffffff"),
|
color_dropdown: safe(dropdown, "#ffffff"),
|
||||||
color_navbar: safe(navbar, "#ffffff"),
|
color_navbar: safe(navbar, "#ffffff"),
|
||||||
color_navbar_text: safe(navbarText, "#1e293b"),
|
color_navbar_text: safe(navbarText, "#1e293b"),
|
||||||
color_text: safe(text, "#0f172a"),
|
color_text: safe(text, "#0f172a"),
|
||||||
color_text_muted: safe(textMuted, "#64748b"),
|
color_text_muted: safe(textMuted, "#64748b"),
|
||||||
color_primary: safe(primary, "#f59e0b"),
|
|
||||||
color_accent: safe(accent, "#10b981"),
|
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_primary_color: safe(buttonColor, "#f59e0b"),
|
||||||
button_text_color: safe(buttonText, "#1e293b"),
|
button_text_color: safe(buttonText, "#1e293b"),
|
||||||
button_secondary_color: safe(buttonSecondary, "#22c55e"),
|
button_secondary_color: safe(buttonSecondary, "#22c55e"),
|
||||||
@@ -175,7 +121,19 @@ export async function ThemeVars() {
|
|||||||
button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"),
|
button_outline_text_color: safe(buttonOutlineText, "#1a1a2e"),
|
||||||
link_color: safe(linkColor, "#eeb425"),
|
link_color: safe(linkColor, "#eeb425"),
|
||||||
link_hover_color: safe(linkHover, "#cf9d15"),
|
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;
|
const font = FONTS[fontKey ?? "nunito"] ?? FONTS.nunito;
|
||||||
|
|
||||||
@@ -193,75 +151,19 @@ export async function ThemeVars() {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
) as ThemePalette;
|
) 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 darkCss = themePaletteCss("html.dark", darkPalette, adminOverrides);
|
||||||
|
// Append typography inside :root block.
|
||||||
const lightAdmin = deriveAdminPalette(
|
const css = `${lightCss.slice(0, -1)}${typography};}${darkCss}`;
|
||||||
{
|
|
||||||
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}`;
|
|
||||||
|
|
||||||
const googleHref = font.google
|
const googleHref = font.google
|
||||||
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
|
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
|
||||||
|
|||||||
@@ -130,6 +130,48 @@ describe("deriveAdminPalette", () => {
|
|||||||
expect(admin.canvas).not.toBe("#ff00ff");
|
expect(admin.canvas).not.toBe("#ff00ff");
|
||||||
expect(admin.surface).not.toBe("#00ff00");
|
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", () => {
|
describe("derivePublicForegrounds", () => {
|
||||||
|
|||||||
+38
-22
@@ -31,7 +31,7 @@ export function deriveAdminPalette(
|
|||||||
colors: PublicThemeColors,
|
colors: PublicThemeColors,
|
||||||
overrides?: Partial<AdminPalette>,
|
overrides?: Partial<AdminPalette>,
|
||||||
): AdminPalette {
|
): AdminPalette {
|
||||||
const accent = colors.color_primary ?? "#f59e0b";
|
const accent = overrides?.accent ?? colors.color_primary ?? "#f59e0b";
|
||||||
const defaults: AdminPalette = {
|
const defaults: AdminPalette = {
|
||||||
canvas: "#0b0f19",
|
canvas: "#0b0f19",
|
||||||
surface: "#151b27",
|
surface: "#151b27",
|
||||||
@@ -40,8 +40,8 @@ export function deriveAdminPalette(
|
|||||||
muted: "#94a3b8",
|
muted: "#94a3b8",
|
||||||
border: "#2a3448",
|
border: "#2a3448",
|
||||||
accent,
|
accent,
|
||||||
accentText: readableColor(accent, ["#0b0f19", "#151b27", "#1e2635"]),
|
accentText: "#f59e0b",
|
||||||
accentForeground: readableColor("#ffffff", [accent]),
|
accentForeground: "#ffffff",
|
||||||
success: "#22c55e",
|
success: "#22c55e",
|
||||||
warning: "#eab308",
|
warning: "#eab308",
|
||||||
error: "#ef4444",
|
error: "#ef4444",
|
||||||
@@ -51,29 +51,35 @@ export function deriveAdminPalette(
|
|||||||
overlay: "rgba(2, 6, 23, 0.72)",
|
overlay: "rgba(2, 6, 23, 0.72)",
|
||||||
focusRing: accent,
|
focusRing: accent,
|
||||||
};
|
};
|
||||||
if (!overrides) return defaults;
|
|
||||||
return {
|
const merged: AdminPalette = {
|
||||||
...defaults,
|
...defaults,
|
||||||
...overrides,
|
...overrides,
|
||||||
accent,
|
accent,
|
||||||
accentText: readableColor(accent, [
|
overlay: overrides?.overlay ?? defaults.overlay,
|
||||||
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,
|
|
||||||
focusRing: accent,
|
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) {
|
export function derivePublicForegrounds(c: PublicThemeColors) {
|
||||||
@@ -181,7 +187,17 @@ export function readableColor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseHex(value: string): Rgb | null {
|
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;
|
if (!match) return null;
|
||||||
return [
|
return [
|
||||||
Number.parseInt(match[1].slice(0, 2), 16),
|
Number.parseInt(match[1].slice(0, 2), 16),
|
||||||
|
|||||||
@@ -40,12 +40,14 @@ export function adminPaletteCss(admin: AdminPalette): string {
|
|||||||
`--admin-sidebar-background:${admin.sidebar};`,
|
`--admin-sidebar-background:${admin.sidebar};`,
|
||||||
`--admin-sidebar-text-readable:${admin.sidebarText};`,
|
`--admin-sidebar-text-readable:${admin.sidebarText};`,
|
||||||
`--admin-sidebar-muted-readable:color-mix(in srgb, var(--admin-sidebar-text-readable) 60%, transparent);`,
|
`--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-canvas:${admin.canvas};`,
|
||||||
`--admin-surface:${admin.surface};`,
|
`--admin-surface:${admin.surface};`,
|
||||||
`--admin-surface-elevated:${admin.surfaceElevated};`,
|
`--admin-surface-elevated:${admin.surfaceElevated};`,
|
||||||
`--admin-text:${admin.text};`,
|
`--admin-text:${admin.text};`,
|
||||||
|
`--admin-text-readable:${admin.text};`,
|
||||||
`--admin-text-muted:${admin.muted};`,
|
`--admin-text-muted:${admin.muted};`,
|
||||||
|
`--admin-text-muted-readable:${admin.muted};`,
|
||||||
`--admin-border:${admin.border};`,
|
`--admin-border:${admin.border};`,
|
||||||
`--admin-accent:${admin.accent};`,
|
`--admin-accent:${admin.accent};`,
|
||||||
`--admin-accent-text:${admin.accentText};`,
|
`--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}-subtle:color-mix(in srgb, ${palette[`color_${status}`]} 14%, ${palette.color_surface});`,
|
||||||
`--color-${status}-border:color-mix(in srgb, ${palette[`color_${status}`]} 38%, transparent);`,
|
`--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("")}}`;
|
return `${selector}{${declarations.join("")}}`;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user