feat: add semantic admin palette
This commit is contained in:
1 parent
d4bcf89449
commit
4ce35e91fb
4 files changed
+97
-2
No files matched your search
@@ -18,6 +18,11 @@ describe("themePaletteCss", () => {
|
||||
"--admin-sidebar-text-readable", "--color-success-text-readable",
|
||||
"--color-success-foreground-readable", "--color-warning-text-readable",
|
||||
"--color-error-text-readable", "--color-info-text-readable",
|
||||
"--admin-canvas", "--admin-surface", "--admin-surface-elevated",
|
||||
"--admin-text", "--admin-text-muted", "--admin-border",
|
||||
"--admin-accent", "--admin-accent-foreground", "--admin-overlay",
|
||||
"--admin-focus-ring", "--admin-success", "--admin-warning",
|
||||
"--admin-error", "--admin-info",
|
||||
]) expect(css).toContain(variable);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contrastRatio, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
||||
import { contrastRatio, deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
||||
import { PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
|
||||
describe("contrastRatio", () => {
|
||||
@@ -76,6 +76,26 @@ describe("readableColor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveAdminPalette", () => {
|
||||
it.each(Object.entries(PRESETS))("keeps admin text readable for %s", (_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
const admin = deriveAdminPalette(palette);
|
||||
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.accentForeground, admin.accent)).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not copy hostile public structural colors into admin surfaces", () => {
|
||||
const admin = deriveAdminPalette({
|
||||
color_background: "#ff00ff", color_surface: "#00ff00", color_primary: "#ffff00",
|
||||
});
|
||||
expect(admin.canvas).not.toBe("#ff00ff");
|
||||
expect(admin.surface).not.toBe("#00ff00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePublicForegrounds", () => {
|
||||
it.each(Object.entries(PRESETS))("makes every semantic pair readable for %s", (_name, preset) => {
|
||||
for (const palette of [preset.light, preset.dark]) {
|
||||
|
||||
@@ -3,6 +3,58 @@ type Rgb = readonly [number, number, number];
|
||||
export type PublicThemeColors = Record<string, string>;
|
||||
export interface ContrastPair { name: string; foreground: string; background: string }
|
||||
|
||||
export interface AdminPalette {
|
||||
canvas: string;
|
||||
surface: string;
|
||||
surfaceElevated: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
border: string;
|
||||
accent: string;
|
||||
accentText: string;
|
||||
accentForeground: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
info: string;
|
||||
sidebar: string;
|
||||
sidebarText: string;
|
||||
overlay: string;
|
||||
focusRing: string;
|
||||
}
|
||||
|
||||
export function deriveAdminPalette(colors: PublicThemeColors): AdminPalette {
|
||||
const publicBackground = colors.color_background ?? "#f8fafc";
|
||||
const blackRatio = contrastRatio("#000000", publicBackground) ?? 21;
|
||||
const whiteRatio = contrastRatio("#ffffff", publicBackground) ?? 1;
|
||||
const dark = whiteRatio > blackRatio;
|
||||
const canvas = dark ? "#0b0f19" : "#f1f5f9";
|
||||
const surface = dark ? "#151b27" : "#ffffff";
|
||||
const surfaceElevated = dark ? "#1e2635" : "#f8fafc";
|
||||
const text = dark ? "#f8fafc" : "#0f172a";
|
||||
const muted = dark ? "#a8b3c7" : "#475569";
|
||||
const accent = colors.color_primary ?? "#f59e0b";
|
||||
return {
|
||||
canvas,
|
||||
surface,
|
||||
surfaceElevated,
|
||||
text: readableColor(text, [canvas, surface, surfaceElevated]),
|
||||
muted: readableColor(muted, [canvas, surface, surfaceElevated]),
|
||||
border: dark ? "#334155" : "#cbd5e1",
|
||||
accent,
|
||||
accentText: readableColor(accent, [canvas, surface, surfaceElevated]),
|
||||
accentForeground: readableColor("#ffffff", [accent]),
|
||||
success: dark ? "#4ade80" : "#15803d",
|
||||
warning: dark ? "#facc15" : "#a16207",
|
||||
error: dark ? "#f87171" : "#b91c1c",
|
||||
info: dark ? "#38bdf8" : "#0369a1",
|
||||
sidebar: dark ? "#101622" : "#172033",
|
||||
sidebarText: "#f8fafc",
|
||||
overlay: "rgba(2, 6, 23, 0.72)",
|
||||
focusRing: accent,
|
||||
};
|
||||
}
|
||||
|
||||
export function derivePublicForegrounds(c: PublicThemeColors) {
|
||||
const value = (key: string, fallback: string) => c[key] ?? fallback;
|
||||
const background = value("color_background", "#f8fafc");
|
||||
|
||||
+19
-1
@@ -1,4 +1,4 @@
|
||||
import { derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
||||
import { deriveAdminPalette, derivePublicForegrounds, readableColor } from "@/lib/theme-contrast";
|
||||
import type { ThemePalette } from "@/lib/theme-presets";
|
||||
|
||||
const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
|
||||
@@ -19,6 +19,7 @@ const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
|
||||
|
||||
export function themePaletteCss(selector: string, palette: ThemePalette): string {
|
||||
const semantic = derivePublicForegrounds(palette);
|
||||
const admin = deriveAdminPalette(palette);
|
||||
const declarations = Object.entries(CSS_VARIABLES).map(
|
||||
([key, cssName]) => `--${cssName}:${palette[key as keyof ThemePalette]};`,
|
||||
);
|
||||
@@ -47,6 +48,23 @@ export function themePaletteCss(selector: string, palette: ThemePalette): string
|
||||
`--admin-sidebar-text-readable:${semantic.byName.sidebar};`,
|
||||
`--admin-sidebar-muted-readable:${semantic.byName.sidebar};`,
|
||||
`--admin-input-text-readable:${semantic.byName.input};`,
|
||||
`--admin-canvas:${admin.canvas};`,
|
||||
`--admin-surface:${admin.surface};`,
|
||||
`--admin-surface-elevated:${admin.surfaceElevated};`,
|
||||
`--admin-text:${admin.text};`,
|
||||
`--admin-text-muted:${admin.muted};`,
|
||||
`--admin-border:${admin.border};`,
|
||||
`--admin-accent:${admin.accent};`,
|
||||
`--admin-accent-text:${admin.accentText};`,
|
||||
`--admin-accent-foreground:${admin.accentForeground};`,
|
||||
`--admin-success:${admin.success};`,
|
||||
`--admin-warning:${admin.warning};`,
|
||||
`--admin-error:${admin.error};`,
|
||||
`--admin-info:${admin.info};`,
|
||||
`--admin-sidebar:${admin.sidebar};`,
|
||||
`--admin-sidebar-text:${admin.sidebarText};`,
|
||||
`--admin-overlay:${admin.overlay};`,
|
||||
`--admin-focus-ring:${admin.focusRing};`,
|
||||
...(["success", "warning", "error", "info"] as const).flatMap((status) => [
|
||||
`--color-${status}-text-readable:${semantic.byName[`${status}-text`]};`,
|
||||
`--color-${status}-foreground-readable:${semantic.byName[`${status}-solid`]};`,
|
||||
|
||||
Reference in new issue
Block a user