94 lines
4.1 KiB
TypeScript
94 lines
4.1 KiB
TypeScript
type Rgb = readonly [number, number, number];
|
|
|
|
export type PublicThemeColors = Record<string, string>;
|
|
export interface ContrastPair { name: string; foreground: string; background: string }
|
|
|
|
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])) };
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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),
|
|
];
|
|
}
|
|
|
|
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;
|
|
}
|