better color control
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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