adds color themeing support to the site
This commit is contained in:
1 parent
bfd6c5bb9e
commit
ee79662e5d
37 files changed
+1046
-15
No files matched your search
@@ -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',
|
||||
};
|
||||
Reference in new issue
Block a user