This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+172
-142
@@ -2,168 +2,198 @@ type Rgb = readonly [number, number, number];
|
||||
|
||||
export type PublicThemeColors = Record<string, string>;
|
||||
export interface ContrastPair {
|
||||
name: string;
|
||||
foreground: string;
|
||||
background: string;
|
||||
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;
|
||||
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,
|
||||
overrides?: Partial<AdminPalette>,
|
||||
colors: PublicThemeColors,
|
||||
overrides?: Partial<AdminPalette>,
|
||||
): AdminPalette {
|
||||
const accent = colors.color_primary ?? "#f59e0b";
|
||||
const defaults: AdminPalette = {
|
||||
canvas: "#0b0f19",
|
||||
surface: "#151b27",
|
||||
surfaceElevated: "#1e2635",
|
||||
text: "#f8fafc",
|
||||
muted: "#94a3b8",
|
||||
border: "#2a3448",
|
||||
accent,
|
||||
accentText: readableColor(accent, ["#0b0f19", "#151b27", "#1e2635"]),
|
||||
accentForeground: readableColor("#ffffff", [accent]),
|
||||
success: "#22c55e",
|
||||
warning: "#eab308",
|
||||
error: "#ef4444",
|
||||
info: "#3b82f6",
|
||||
sidebar: "#080c18",
|
||||
sidebarText: "#f8fafc",
|
||||
overlay: "rgba(2, 6, 23, 0.72)",
|
||||
focusRing: accent,
|
||||
};
|
||||
if (!overrides) return defaults;
|
||||
return {
|
||||
...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,
|
||||
focusRing: accent,
|
||||
};
|
||||
const accent = colors.color_primary ?? "#f59e0b";
|
||||
const defaults: AdminPalette = {
|
||||
canvas: "#0b0f19",
|
||||
surface: "#151b27",
|
||||
surfaceElevated: "#1e2635",
|
||||
text: "#f8fafc",
|
||||
muted: "#94a3b8",
|
||||
border: "#2a3448",
|
||||
accent,
|
||||
accentText: readableColor(accent, ["#0b0f19", "#151b27", "#1e2635"]),
|
||||
accentForeground: readableColor("#ffffff", [accent]),
|
||||
success: "#22c55e",
|
||||
warning: "#eab308",
|
||||
error: "#ef4444",
|
||||
info: "#3b82f6",
|
||||
sidebar: "#080c18",
|
||||
sidebarText: "#f8fafc",
|
||||
overlay: "rgba(2, 6, 23, 0.72)",
|
||||
focusRing: accent,
|
||||
};
|
||||
if (!overrides) return defaults;
|
||||
return {
|
||||
...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,
|
||||
focusRing: accent,
|
||||
};
|
||||
}
|
||||
|
||||
export function derivePublicForegrounds(c: PublicThemeColors) {
|
||||
const value = (key: string, fallback: string) => c[key] ?? fallback;
|
||||
const background = value("color_background", "#f8fafc");
|
||||
const surface = value("color_surface", "#ffffff");
|
||||
const dropdown = value("color_dropdown", surface);
|
||||
const navbar = value("color_navbar", surface);
|
||||
const primary = value("color_primary", "#f59e0b");
|
||||
const accent = value("color_accent", "#10b981");
|
||||
const make = (name: string, preferred: string, bg: string): ContrastPair => ({
|
||||
name,
|
||||
foreground: readableColor(preferred, [bg]),
|
||||
background: bg,
|
||||
});
|
||||
const pairs = [
|
||||
make("body", value("color_text", "#0f172a"), background),
|
||||
make("surface", value("color_text", "#0f172a"), surface),
|
||||
make("muted", value("color_text_muted", "#64748b"), surface),
|
||||
make("subtle", value("color_text_muted", "#64748b"), surface),
|
||||
make("disabled", value("color_text_muted", "#64748b"), surface),
|
||||
make("sidebar", value("color_navbar_text", "#1e293b"), navbar),
|
||||
make("input", value("color_text", "#0f172a"), dropdown),
|
||||
make("link", value("link_color", primary), surface),
|
||||
make("link-hover", value("link_hover_color", primary), surface),
|
||||
make("navbar", value("color_navbar_text", "#1e293b"), navbar),
|
||||
make("dropdown", value("color_text", "#0f172a"), dropdown),
|
||||
make("primary", value("button_text_color", "#1e293b"), primary),
|
||||
make("accent", "#ffffff", accent),
|
||||
make("button-primary", value("button_text_color", "#1e293b"), value("button_primary_color", primary)),
|
||||
make(
|
||||
"button-secondary",
|
||||
value("button_secondary_text_color", "#ffffff"),
|
||||
value("button_secondary_color", "#22c55e"),
|
||||
),
|
||||
make(
|
||||
"button-danger",
|
||||
value("button_danger_text_color", "#ffffff"),
|
||||
value("button_danger_color", "#ef4444"),
|
||||
),
|
||||
make("button-outline", value("button_outline_text_color", "#1a1a2e"), surface),
|
||||
make("pill", value("color_text", "#0f172a"), surface),
|
||||
make("badge", value("button_text_color", "#1e293b"), primary),
|
||||
make("overlay", "#ffffff", "#111827"),
|
||||
...(["success", "warning", "error", "info"] as const).flatMap((status) => {
|
||||
const statusColor = value(`color_${status}`, "#0f172a");
|
||||
return [make(`${status}-text`, statusColor, surface), make(`${status}-solid`, "#ffffff", statusColor)];
|
||||
}),
|
||||
];
|
||||
return { pairs, byName: Object.fromEntries(pairs.map((pair) => [pair.name, pair.foreground])) };
|
||||
const value = (key: string, fallback: string) => c[key] ?? fallback;
|
||||
const background = value("color_background", "#f8fafc");
|
||||
const surface = value("color_surface", "#ffffff");
|
||||
const dropdown = value("color_dropdown", surface);
|
||||
const navbar = value("color_navbar", surface);
|
||||
const primary = value("color_primary", "#f59e0b");
|
||||
const accent = value("color_accent", "#10b981");
|
||||
const make = (name: string, preferred: string, bg: string): ContrastPair => ({
|
||||
name,
|
||||
foreground: readableColor(preferred, [bg]),
|
||||
background: bg,
|
||||
});
|
||||
const pairs = [
|
||||
make("body", value("color_text", "#0f172a"), background),
|
||||
make("surface", value("color_text", "#0f172a"), surface),
|
||||
make("muted", value("color_text_muted", "#64748b"), surface),
|
||||
make("subtle", value("color_text_muted", "#64748b"), surface),
|
||||
make("disabled", value("color_text_muted", "#64748b"), surface),
|
||||
make("sidebar", value("color_navbar_text", "#1e293b"), navbar),
|
||||
make("input", value("color_text", "#0f172a"), dropdown),
|
||||
make("link", value("link_color", primary), surface),
|
||||
make("link-hover", value("link_hover_color", primary), surface),
|
||||
make("navbar", value("color_navbar_text", "#1e293b"), navbar),
|
||||
make("dropdown", value("color_text", "#0f172a"), dropdown),
|
||||
make("primary", value("button_text_color", "#1e293b"), primary),
|
||||
make("accent", "#ffffff", accent),
|
||||
make(
|
||||
"button-primary",
|
||||
value("button_text_color", "#1e293b"),
|
||||
value("button_primary_color", primary),
|
||||
),
|
||||
make(
|
||||
"button-secondary",
|
||||
value("button_secondary_text_color", "#ffffff"),
|
||||
value("button_secondary_color", "#22c55e"),
|
||||
),
|
||||
make(
|
||||
"button-danger",
|
||||
value("button_danger_text_color", "#ffffff"),
|
||||
value("button_danger_color", "#ef4444"),
|
||||
),
|
||||
make(
|
||||
"button-outline",
|
||||
value("button_outline_text_color", "#1a1a2e"),
|
||||
surface,
|
||||
),
|
||||
make("pill", value("color_text", "#0f172a"), surface),
|
||||
make("badge", value("button_text_color", "#1e293b"), primary),
|
||||
make("overlay", "#ffffff", "#111827"),
|
||||
...(["success", "warning", "error", "info"] as const).flatMap((status) => {
|
||||
const statusColor = value(`color_${status}`, "#0f172a");
|
||||
return [
|
||||
make(`${status}-text`, statusColor, surface),
|
||||
make(`${status}-solid`, "#ffffff", statusColor),
|
||||
];
|
||||
}),
|
||||
];
|
||||
return {
|
||||
pairs,
|
||||
byName: Object.fromEntries(
|
||||
pairs.map((pair) => [pair.name, pair.foreground]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function contrastRatio(foreground: string, background: string): number | null {
|
||||
const fg = parseHex(foreground);
|
||||
const bg = parseHex(background);
|
||||
if (!fg || !bg) return null;
|
||||
export function contrastRatio(
|
||||
foreground: string,
|
||||
background: string,
|
||||
): number | null {
|
||||
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);
|
||||
const light = Math.max(relativeLuminance(fg), relativeLuminance(bg));
|
||||
const dark = Math.min(relativeLuminance(fg), relativeLuminance(bg));
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
}
|
||||
|
||||
export function readableColor(preferred: string, backgrounds: readonly string[], minimumRatio = 4.5): string {
|
||||
const preferredRatios = backgrounds.map((background) => contrastRatio(preferred, background));
|
||||
if (preferredRatios.some((ratio) => ratio === null)) return preferred;
|
||||
if (preferredRatios.every((ratio) => (ratio as number) >= minimumRatio)) return preferred;
|
||||
export function readableColor(
|
||||
preferred: string,
|
||||
backgrounds: readonly string[],
|
||||
minimumRatio = 4.5,
|
||||
): string {
|
||||
const preferredRatios = backgrounds.map((background) =>
|
||||
contrastRatio(preferred, background),
|
||||
);
|
||||
if (preferredRatios.some((ratio) => ratio === null)) return preferred;
|
||||
if (preferredRatios.every((ratio) => (ratio as number) >= minimumRatio))
|
||||
return preferred;
|
||||
|
||||
const candidates = ["#000000", "#ffffff"] as const;
|
||||
return candidates
|
||||
.map((color) => ({
|
||||
color,
|
||||
minimum: Math.min(...backgrounds.map((background) => contrastRatio(color, background) ?? 0)),
|
||||
}))
|
||||
.sort((a, b) => b.minimum - a.minimum)[0].color;
|
||||
const candidates = ["#000000", "#ffffff"] as const;
|
||||
return candidates
|
||||
.map((color) => ({
|
||||
color,
|
||||
minimum: Math.min(
|
||||
...backgrounds.map(
|
||||
(background) => contrastRatio(color, background) ?? 0,
|
||||
),
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => b.minimum - a.minimum)[0].color;
|
||||
}
|
||||
|
||||
function parseHex(value: string): Rgb | null {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
|
||||
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),
|
||||
];
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
|
||||
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: Rgb): number {
|
||||
const [red, green, blue] = rgb.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
const [red, green, blue] = rgb.map((channel) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||
}
|
||||
Reference in new issue
Block a user