better color control

This commit is contained in:
2026-06-30 03:52:00 +00:00
parent f62f8188bf
commit 87c9b0b8ab
19 changed files with 117 additions and 5 deletions
+117 -5
View File
@@ -4,7 +4,7 @@
// ── Types ────────────────────────────────────────────────────────────────────
export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'none';
export type HarmonyRule = 'complementary' | 'analogous' | 'triadic' | 'split-complementary' | 'monochromatic' | 'tetradic' | 'none';
export interface HSL {
h: number; // 0-360
@@ -103,6 +103,110 @@ export function generateMuted(hex: string): string {
return hslToHex(h, Math.max(0, s - 20), l);
}
// ── Hue blending helper ─────────────────────────────────────────────────────
/**
* Blend two hues along the shortest arc on the color wheel,
* returning the intermediate hue at the given ratio (0 = from, 1 = to).
*/
export function blendHues(from: number, to: number, ratio: number): number {
const dist = hueDistance(from, to);
const step = from < to && to - from <= 180 ? dist : -dist;
return ((from + step * ratio) % 360 + 360) % 360;
}
// ── Semantic color canon ────────────────────────────────────────────────────
/**
* Canonical semantic hues that remain recognizable regardless of the
* primary harmony rule. Used by generateFullPalette as a fallback when
* the harmony-derived hue would clash with the standard meaning.
*/
const SEMANTIC_CANONICAL = {
color_success: { h: 145, s: 55, l: 40 },
color_danger: { h: 0, s: 70, l: 50 },
color_info: { h: 210, s: 60, l: 45 },
} as const;
// ── Palette generation ──────────────────────────────────────────────────────
export interface PaletteOptions {
primary: string;
accent?: string;
rule?: HarmonyRule;
}
/**
* Generate a full theme palette from a primary color.
* If no accent is provided, it is derived from the harmony rule
* (defaults to 'complementary' when unspecified).
*/
export function generateFullPalette(options: PaletteOptions): { [key: string]: string } {
const primaryHex = options.primary;
const rule = options.rule ?? 'complementary';
const { h: ph, s: ps, l: pl } = hexToHSL(primaryHex);
// Derive accent from the harmony rule if not explicitly provided
let accentHex: string;
if (options.accent) {
accentHex = options.accent;
} else if (rule === 'monochromatic') {
accentHex = hslToHex(ph, Math.max(ps - 20, 20), pl);
} else {
const hues = getHarmonyHues(ph, rule);
const accentHue = hues[1] ?? (ph + 180) % 360;
accentHex = hslToHex(accentHue, ps, pl);
}
return {
// Primary family
color_primary: primaryHex,
color_primary_light: generateLight(primaryHex),
color_primary_dark: generateDark(primaryHex),
color_primary_muted: generateMuted(primaryHex),
// Accent family
color_accent: accentHex,
color_accent_light: generateLight(accentHex),
color_accent_dark: generateDark(accentHex),
color_accent_muted: generateMuted(accentHex),
// Neutrals derived from primary lightness
color_background: hslToHex(ph, Math.min(ps, 8), Math.max(pl + 30, 92)),
color_text: hslToHex(ph, Math.min(ps, 15), Math.min(pl - 40, 15)),
color_white: '#ffffff',
color_light: hslToHex(ph, 6, 90),
color_medium: hslToHex(ph, 8, 65),
color_black: '#000000',
// Semantic colors — prefer harmony-aligned hue, fall back to canonical
color_success: hslToHex(SEMANTIC_CANONICAL.color_success.h, SEMANTIC_CANONICAL.color_success.s, SEMANTIC_CANONICAL.color_success.l),
color_danger: hslToHex(SEMANTIC_CANONICAL.color_danger.h, SEMANTIC_CANONICAL.color_danger.s, SEMANTIC_CANONICAL.color_danger.l),
color_info: hslToHex(SEMANTIC_CANONICAL.color_info.h, SEMANTIC_CANONICAL.color_info.s, SEMANTIC_CANONICAL.color_info.l),
};
}
/**
* Suggest the best harmony rule for a given primary color.
* Picks the rule whose canonical hues produce the most readable
* contrast when rendered at the primary's saturation and lightness.
* Defaults to 'complementary' for safe, high-contrast palettes.
*/
export function suggestHarmonyRuleForPrimary(
primaryHex: string,
preferred: Exclude<HarmonyRule, 'none'> = 'complementary',
): Exclude<HarmonyRule, 'none'> {
const { s, l } = hexToHSL(primaryHex);
// Low-saturation primaries look best monochromatic (accent is subtle)
if (s < 15) return 'monochromatic';
// Very dark or very light primaries benefit from complementary contrast
if (l < 25 || l > 75) return 'complementary';
return preferred;
}
// ── Harmony detection ───────────────────────────────────────────────────────
/**
@@ -122,8 +226,10 @@ export function detectHarmonyRule(primaryHex: string, accentHex: string): Harmon
const rules: Array<{ rule: HarmonyRule; target: number; tolerance: number }> = [
{ rule: 'complementary', target: 180, tolerance: 20 },
{ rule: 'split-complementary', target: 150, tolerance: 15 },
{ rule: 'tetradic', target: 90, tolerance: 15 },
{ rule: 'triadic', target: 120, tolerance: 15 },
{ rule: 'analogous', target: 30, tolerance: 15 },
{ rule: 'monochromatic', target: 0, tolerance: 10 },
];
for (const { rule, target, tolerance } of rules) {
@@ -166,7 +272,7 @@ export function validateColor(
return { isHarmonious: true, deviation: 0 };
}
const expectedHues = getExpectedHues(primary.h, rule);
const expectedHues = getHarmonyHues(primary.h, rule);
let minDeviation = 360;
for (const expected of expectedHues) {
@@ -185,8 +291,8 @@ export function validateColor(
};
}
/** Get the expected hues for a given harmony rule relative to the primary hue. */
function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] {
/** Get the harmony hues for a given rule relative to the primary hue. */
export function getHarmonyHues(primaryHue: number, rule: HarmonyRule): number[] {
switch (rule) {
case 'complementary':
return [primaryHue, (primaryHue + 180) % 360];
@@ -196,6 +302,10 @@ function getExpectedHues(primaryHue: number, rule: HarmonyRule): number[] {
return [primaryHue, (primaryHue + 120) % 360, (primaryHue + 240) % 360];
case 'split-complementary':
return [primaryHue, (primaryHue + 150) % 360, (primaryHue + 210) % 360];
case 'monochromatic':
return [primaryHue];
case 'tetradic':
return [primaryHue, (primaryHue + 90) % 360, (primaryHue + 180) % 360, (primaryHue + 270) % 360];
default:
return [primaryHue];
}
@@ -211,7 +321,7 @@ export function suggestHarmoniousColor(
rule: HarmonyRule,
): string {
const color = hexToHSL(colorHex);
const expectedHues = getExpectedHues(hexToHSL(primaryHex).h, rule);
const expectedHues = getHarmonyHues(hexToHSL(primaryHex).h, rule);
// Find the closest expected hue
let closestHue = expectedHues[0];
@@ -324,5 +434,7 @@ export const HARMONY_RULE_LABELS: Record<HarmonyRule, string> = {
analogous: 'Analogous (neighbors, ±30°)',
triadic: 'Triadic (three-way, ±120°)',
'split-complementary': 'Split-Complementary (offset, ±150°)',
monochromatic: 'Monochromatic (single hue, value/saturation variants)',
tetradic: 'Tetradic (four-way, ±90°)',
none: 'No harmony detected',
};