better color control
This commit is contained in:
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user