adds color themeing support to the site

This commit is contained in:
kfj001 committed 2026-06-29 21:23:51 +00:00
1 parent bfd6c5bb9e
commit ee79662e5d
37 files changed
+1046 -15

No files matched your search

+324
View File
@@ -0,0 +1,324 @@
// =============================================================================
// Color harmony utilities — pure functions, no Angular dependencies
// =============================================================================
// ── Types ────────────────────────────────────────────────────────────────────
export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'none';
export interface HSL {
h: number; // 0-360
s: number; // 0-100
l: number; // 0-100
}
export interface ColorValidation {
isHarmonious: boolean;
suggestedHex?: string;
deviation: number; // degrees from expected hue
}
// ── Hex ↔ HSL conversion ────────────────────────────────────────────────────
export function hexToHSL(hex: string): HSL {
hex = hex.replace('#', '');
if (hex.length === 3) {
hex = hex.split('').map(c => c + c).join('');
}
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) {
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
} else if (max === g) {
h = ((b - r) / d + 2) * 60;
} else {
h = ((r - g) / d + 4) * 60;
}
return {
h: Math.round(h),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
export function hslToHex(h: number, s: number, l: number): string {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(100, s)) / 100;
l = Math.max(0, Math.min(100, l)) / 100;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
const toHex = (v: number) => {
const hex = Math.round((v + m) * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// ── Variant generation ──────────────────────────────────────────────────────
/** Generate a lighter variant (+20% lightness). */
export function generateLight(hex: string): string {
const { h, s, l } = hexToHSL(hex);
return hslToHex(h, s, Math.min(95, l + 20));
}
/** Generate a darker variant (-20% lightness). */
export function generateDark(hex: string): string {
const { h, s, l } = hexToHSL(hex);
return hslToHex(h, s, Math.max(5, l - 20));
}
/** Generate a muted variant (-20% saturation). */
export function generateMuted(hex: string): string {
const { h, s, l } = hexToHSL(hex);
return hslToHex(h, Math.max(0, s - 20), l);
}
// ── Harmony detection ───────────────────────────────────────────────────────
/**
* Detect the harmony rule between two hues.
* Returns the best-fit rule or 'none' if the colors are too close or unrelated.
*/
export function detectHarmonyRule(primaryHex: string, accentHex: string): HarmonyRule {
const p = hexToHSL(primaryHex);
const a = hexToHSL(accentHex);
// If either color is near-gray (low saturation), harmony doesn't apply
if (p.s < 10 || a.s < 10) return 'none';
const diff = hueDistance(p.h, a.h);
// Tolerance in degrees for each rule
const rules: Array<{ rule: HarmonyRule; target: number; tolerance: number }> = [
{ rule: 'complementary', target: 180, tolerance: 20 },
{ rule: 'split-complementary', target: 150, tolerance: 15 },
{ rule: 'triadic', target: 120, tolerance: 15 },
{ rule: 'analogous', target: 30, tolerance: 15 },
];
for (const { rule, target, tolerance } of rules) {
if (Math.abs(diff - target) <= tolerance) {
return rule;
}
}
return 'none';
}
/** Calculate the shortest distance between two hues (0-180). */
function hueDistance(h1: number, h2: number): number {
const diff = Math.abs(h1 - h2);
return Math.min(diff, 360 - diff);
}
// ── Validation ──────────────────────────────────────────────────────────────
/**
* Check if a color's hue is harmonious given the primary hue and harmony rule.
* Neutrals (saturation < 10) are always considered harmonious.
*/
export function validateColor(
colorHex: string,
primaryHex: string,
rule: HarmonyRule,
tolerance: number = 15,
): ColorValidation {
const color = hexToHSL(colorHex);
const primary = hexToHSL(primaryHex);
// Neutrals are always harmonious
if (color.s < 10) {
return { isHarmonious: true, deviation: 0 };
}
// No rule = can't validate
if (rule === 'none') {
return { isHarmonious: true, deviation: 0 };
}
const expectedHues = getExpectedHues(primary.h, rule);
let minDeviation = 360;
for (const expected of expectedHues) {
const dev = hueDistance(color.h, expected);
if (dev < minDeviation) minDeviation = dev;
}
const isHarmonious = minDeviation <= tolerance;
return {
isHarmonious,
deviation: minDeviation,
suggestedHex: isHarmonious ? undefined : suggestHarmoniousColor(
colorHex, primaryHex, rule,
),
};
}
/** Get the expected hues for a given harmony rule relative to the primary hue. */
function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] {
switch (rule) {
case 'complementary':
return [primaryHue, (primaryHue + 180) % 360];
case 'analogous':
return [primaryHue, (primaryHue + 30) % 360, (primaryHue - 30 + 360) % 360];
case 'triadic':
return [primaryHue, (primaryHue + 120) % 360, (primaryHue + 240) % 360];
case 'split-complementary':
return [primaryHue, (primaryHue + 150) % 360, (primaryHue + 210) % 360];
default:
return [primaryHue];
}
}
/**
* Suggest a harmonious color by adjusting the hue to the nearest expected hue,
* preserving the original saturation and lightness.
*/
export function suggestHarmoniousColor(
colorHex: string,
primaryHex: string,
rule: HarmonyRule,
): string {
const color = hexToHSL(colorHex);
const expectedHues = getExpectedHues(hexToHSL(primaryHex).h, rule);
// Find the closest expected hue
let closestHue = expectedHues[0];
let closestDist = 360;
for (const hue of expectedHues) {
const dist = hueDistance(color.h, hue);
if (dist < closestDist) {
closestDist = dist;
closestHue = hue;
}
}
return hslToHex(closestHue, color.s, color.l);
}
// ── Palette-level operations ────────────────────────────────────────────────
/**
* Color role definitions for the theme palette.
* Each role maps to a StationConfig color field.
*/
export interface ColorRole {
field: string;
label: string;
group: 'primary' | 'accent' | 'neutral' | 'semantic';
}
export const COLOR_ROLES: readonly ColorRole[] = [
{ field: 'color_primary', label: 'Primary', group: 'primary' },
{ field: 'color_primary_light', label: 'Primary Light', group: 'primary' },
{ field: 'color_primary_dark', label: 'Primary Dark', group: 'primary' },
{ field: 'color_primary_muted', label: 'Primary Muted', group: 'primary' },
{ field: 'color_accent', label: 'Accent', group: 'accent' },
{ field: 'color_accent_light', label: 'Accent Light', group: 'accent' },
{ field: 'color_accent_dark', label: 'Accent Dark', group: 'accent' },
{ field: 'color_accent_muted', label: 'Accent Muted', group: 'accent' },
{ field: 'color_background', label: 'Background', group: 'neutral' },
{ field: 'color_text', label: 'Text', group: 'neutral' },
{ field: 'color_success', label: 'Success', group: 'semantic' },
{ field: 'color_danger', label: 'Danger', group: 'semantic' },
{ field: 'color_info', label: 'Info', group: 'semantic' },
] as const;
/**
* Validate an entire color palette against the detected harmony rule.
* Returns a map of field → validation result.
*/
export function validatePalette(
colors: { [key: string]: string },
rule: HarmonyRule,
): { [key: string]: ColorValidation } {
const primary = colors['color_primary'] || '#1a3a5c';
const result: { [key: string]: ColorValidation } = {};
for (const role of COLOR_ROLES) {
const hex = colors[role.field];
if (!hex) {
result[role.field] = { isHarmonious: true, deviation: 0 };
continue;
}
// Neutrals are always harmonious
if (role.group === 'neutral') {
result[role.field] = { isHarmonious: true, deviation: 0 };
continue;
}
// Semantic colors get wider tolerance
const tolerance = role.group === 'semantic' ? 30 : 15;
result[role.field] = validateColor(hex, primary, rule, tolerance);
}
return result;
}
/**
* Auto-fix the entire palette by adjusting non-harmonious colors.
* Returns a new colors record with corrections applied.
*/
export function autoFixPalette(
colors: { [key: string]: string },
rule: HarmonyRule,
): { [key: string]: string } {
const fixed = { ...colors };
const primary = fixed['color_primary'] || '#1a3a5c';
for (const role of COLOR_ROLES) {
if (role.group === 'neutral') continue;
const hex = fixed[role.field];
if (!hex) continue;
const validation = validateColor(hex, primary, rule, role.group === 'semantic' ? 30 : 15);
if (!validation.isHarmonious && validation.suggestedHex) {
fixed[role.field] = validation.suggestedHex;
}
}
return fixed;
}
// ── Harmony rule labels ─────────────────────────────────────────────────────
export const HARMONY_RULE_LABELS: Record<HarmonyRule, string> = {
complementary: 'Complementary (opposite, ~180°)',
analogous: 'Analogous (neighbors, ±30°)',
triadic: 'Triadic (three-way, ±120°)',
'split-complementary': 'Split-Complementary (offset, ±150°)',
none: 'No harmony detected',
};