@rafters/color-utils 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/harmony.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Harmony generation for design systems using pure OKLCH hue rotation.
3
+ *
4
+ * Rule: harmonies are PURE HUE ROTATIONS. L and C are never mutated.
5
+ * Only H changes. Every output goes through toNearestGamut() then roundOKLCH().
6
+ */
7
+
8
+ import type { ColorHarmonies, OKLCH } from '@rafters/shared';
9
+ import { roundOKLCH } from './conversion.js';
10
+ import { clampColor } from './gamut.js';
11
+ import { adjustHue } from './manipulation.js';
12
+ import { POSITION_TO_INDEX, SCALE_POSITIONS } from './scale-positions.js';
13
+
14
+ /**
15
+ * Generate pure OKLCH harmony arrays from a base color.
16
+ *
17
+ * Counts (per the spec):
18
+ * - complementary: 1 color (+180)
19
+ * - triadic: 3 colors (base, +120, +240)
20
+ * - analogous: 6 colors (-45, -30, -15, +15, +30, +45)
21
+ * - tetradic: 4 colors (base, +90, +180, +270)
22
+ * - splitComplementary: 3 colors (base, +150, +210)
23
+ * - monochromatic: 6 colors (same hue, L steps at 0.15/0.30/0.45/0.60/0.75/0.90,
24
+ * chroma reduced at extremes)
25
+ *
26
+ * L and C are preserved exactly from the base color in all hue-rotation harmonies.
27
+ */
28
+ export function generateHarmony(baseColor: OKLCH): ColorHarmonies {
29
+ const base = clampColor(baseColor);
30
+
31
+ // complementary: 1 color at +180
32
+ const complementary = clampColor(adjustHue(baseColor, 180));
33
+
34
+ // triadic: base, +120, +240
35
+ const triadic: OKLCH[] = [
36
+ base,
37
+ clampColor(adjustHue(baseColor, 120)),
38
+ clampColor(adjustHue(baseColor, 240)),
39
+ ];
40
+
41
+ // analogous: -45, -30, -15, +15, +30, +45
42
+ const analogous: OKLCH[] = [
43
+ clampColor(adjustHue(baseColor, -45)),
44
+ clampColor(adjustHue(baseColor, -30)),
45
+ clampColor(adjustHue(baseColor, -15)),
46
+ clampColor(adjustHue(baseColor, 15)),
47
+ clampColor(adjustHue(baseColor, 30)),
48
+ clampColor(adjustHue(baseColor, 45)),
49
+ ];
50
+
51
+ // tetradic: base, +90, +180, +270
52
+ const tetradic: OKLCH[] = [
53
+ base,
54
+ clampColor(adjustHue(baseColor, 90)),
55
+ clampColor(adjustHue(baseColor, 180)),
56
+ clampColor(adjustHue(baseColor, 270)),
57
+ ];
58
+
59
+ // splitComplementary: base, +150, +210
60
+ const splitComplementary: OKLCH[] = [
61
+ base,
62
+ clampColor(adjustHue(baseColor, 150)),
63
+ clampColor(adjustHue(baseColor, 210)),
64
+ ];
65
+
66
+ // monochromatic: 6 steps at fixed L values, same H, chroma reduced at extremes
67
+ const MONO_LIGHTNESS = [0.15, 0.3, 0.45, 0.6, 0.75, 0.9] as const;
68
+ const monochromatic: OKLCH[] = MONO_LIGHTNESS.map((l) => {
69
+ // Reduce chroma at extreme ends to avoid gamut clipping artifacts
70
+ const c = l <= 0.15 || l >= 0.9 ? baseColor.c * 0.5 : baseColor.c;
71
+ return clampColor({ ...baseColor, l, c });
72
+ });
73
+
74
+ return {
75
+ complementary,
76
+ triadic,
77
+ analogous,
78
+ tetradic,
79
+ splitComplementary,
80
+ monochromatic,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Validate if a color is suitable for scale generation
86
+ * Too light or too dark colors don't generate useful scales
87
+ */
88
+ function validateScaleGeneration(baseColor: OKLCH): {
89
+ isValid: boolean;
90
+ reason?: string;
91
+ suggestedLightness?: number;
92
+ } {
93
+ const l = baseColor.l;
94
+
95
+ if (l > 0.85) {
96
+ return {
97
+ isValid: false,
98
+ reason: 'Color too light for scale generation',
99
+ suggestedLightness: 0.7,
100
+ };
101
+ }
102
+
103
+ if (l < 0.15) {
104
+ return {
105
+ isValid: false,
106
+ reason: 'Color too dark for scale generation',
107
+ suggestedLightness: 0.4,
108
+ };
109
+ }
110
+
111
+ return { isValid: true };
112
+ }
113
+
114
+ /**
115
+ * Generate lightness progression using mathematical functions.
116
+ * Base color positioned at 600, with 6 lighter steps above + 4 darker + 950.
117
+ * Power curve (t^0.8) for tints creates natural spacing weighted toward white.
118
+ * Linear progression for shades keeps darkening predictable.
119
+ */
120
+ function generateLightnessProgression(baseLightness: number): Record<string, number> {
121
+ const MAX_LIGHT = 0.95;
122
+ const MIN_DARK = 0.05;
123
+
124
+ const positions = SCALE_POSITIONS;
125
+ const baseIndex = POSITION_TO_INDEX['600'] ?? 6;
126
+
127
+ const lightness: Record<string, number> = {};
128
+
129
+ for (let i = 0; i < baseIndex; i++) {
130
+ const stepsFromBase = baseIndex - i;
131
+ const totalLighterSteps = baseIndex;
132
+
133
+ const t = (stepsFromBase / totalLighterSteps) ** 0.8;
134
+ const calculatedL = baseLightness + (MAX_LIGHT - baseLightness) * t;
135
+
136
+ const pos = positions[i];
137
+ if (pos !== undefined) lightness[pos] = Math.min(MAX_LIGHT, calculatedL);
138
+ }
139
+
140
+ lightness['600'] = baseLightness;
141
+
142
+ for (let i = baseIndex + 1; i < positions.length; i++) {
143
+ const stepsFromBase = i - baseIndex;
144
+ const totalDarkerSteps = positions.length - 1 - baseIndex;
145
+
146
+ const t = stepsFromBase / totalDarkerSteps;
147
+ const darkenAmount = (baseLightness - MIN_DARK) * t;
148
+ const calculatedL = Math.max(MIN_DARK, baseLightness - darkenAmount);
149
+
150
+ const pos = positions[i];
151
+ if (pos !== undefined) lightness[pos] = calculatedL;
152
+ }
153
+
154
+ return lightness;
155
+ }
156
+
157
+ /**
158
+ * Generate OKLCH color scale from base color.
159
+ * Creates 50-950 scale with mathematical lightness progression anchored at 600.
160
+ * Chroma reduced only at extreme lightness to avoid perceptual artifacts.
161
+ */
162
+ export function generateOKLCHScale(baseColor: OKLCH): Record<string, OKLCH> {
163
+ const validation = validateScaleGeneration(baseColor);
164
+
165
+ if (!validation.isValid) {
166
+ const adjustedColor = validation.suggestedLightness
167
+ ? { ...baseColor, l: validation.suggestedLightness }
168
+ : baseColor;
169
+ return generateOKLCHScale(adjustedColor);
170
+ }
171
+
172
+ const lightnessSteps = generateLightnessProgression(baseColor.l);
173
+
174
+ const scale: Record<string, OKLCH> = {};
175
+
176
+ for (const [step, lightness] of Object.entries(lightnessSteps)) {
177
+ let adjustedChroma = baseColor.c;
178
+
179
+ if (lightness > 0.9) {
180
+ adjustedChroma *= 0.3;
181
+ } else if (lightness < 0.15) {
182
+ adjustedChroma *= 0.6;
183
+ }
184
+
185
+ scale[step] = roundOKLCH({
186
+ l: lightness,
187
+ c: adjustedChroma,
188
+ h: baseColor.h,
189
+ alpha: baseColor.alpha,
190
+ });
191
+ }
192
+
193
+ return scale;
194
+ }
package/src/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @rafters/color-utils
3
+ * OKLCH color manipulation utilities for Rafters design system
4
+ */
5
+
6
+ // Accessibility
7
+ export {
8
+ type AccessibilityMetadata,
9
+ calculateAPCAContrast,
10
+ calculateWCAGContrast,
11
+ generateAccessibilityMetadata,
12
+ rebakeAccessibility,
13
+ } from './accessibility.js';
14
+
15
+ // Analysis
16
+ export {
17
+ calculateAtmosphericWeight,
18
+ calculatePerceptualWeight,
19
+ getColorTemperature,
20
+ isLightColor,
21
+ } from './analysis.js';
22
+
23
+ // Builder
24
+ export { type BuildColorValueOptions, buildColorValue } from './builder.js';
25
+
26
+ // Color Wheel
27
+ export {
28
+ type ColorWheelOptions,
29
+ colorWheel,
30
+ type HarmonyType,
31
+ type SemanticColorSystem,
32
+ } from './color-wheel.js';
33
+
34
+ // Conversion
35
+ export {
36
+ hexToOKLCH,
37
+ type OklchToCSSOptions,
38
+ oklchToCSS,
39
+ roundOKLCH,
40
+ tryParseColor,
41
+ } from './conversion.js';
42
+
43
+ // Gamut
44
+ export {
45
+ computeGamutBoundaries,
46
+ type GamutBoundaryPoint,
47
+ type GamutTier,
48
+ getGamutTier,
49
+ isInP3Gamut,
50
+ isInSRGBGamut,
51
+ toNearestGamut,
52
+ } from './gamut.js';
53
+
54
+ // Harmony
55
+ export { generateHarmony, generateOKLCHScale } from './harmony.js';
56
+
57
+ // Manipulation
58
+ export { adjustHue } from './manipulation.js';
59
+ // Naming
60
+ export {
61
+ BLUE_HUB,
62
+ C_BUCKET_COUNT,
63
+ type ChromaBand,
64
+ GREEN_HUB,
65
+ generateColorName,
66
+ generateColorNameWithMetadata,
67
+ getAllBuckets,
68
+ getCBucket,
69
+ getChromaBand,
70
+ getExpandedMaterialWord,
71
+ getHBucket,
72
+ getLBucket,
73
+ getLightnessBand,
74
+ getSubIndex,
75
+ H_BUCKET_COUNT,
76
+ HUE_HUBS,
77
+ type HueCell,
78
+ type HueHub,
79
+ type HueMatrix,
80
+ hasExpandedHub,
81
+ INTENSITY_WORDS,
82
+ type IntensityWord,
83
+ L_BUCKET_COUNT,
84
+ type LightnessBand,
85
+ LUMINOSITY_WORDS,
86
+ type LuminosityWord,
87
+ MATERIAL_WORDS,
88
+ type MaterialWord,
89
+ RED_HUB,
90
+ TOTAL_COMBINATIONS,
91
+ } from './naming/index.js';
92
+ // Scale positions (vocabulary only; selection lives in semantic.ts)
93
+ export { POSITION_TO_INDEX, SCALE_POSITIONS } from './scale-positions.js';
94
+
95
+ // Semantic selection (purpose-driven pair finding + seed-derived suggestions, #1636)
96
+ export {
97
+ generateSemanticColorSuggestions,
98
+ type Pair,
99
+ type PairLeg,
100
+ type PairRequest,
101
+ type PairStandard,
102
+ type PairTier,
103
+ type PairUse,
104
+ type SemanticContext,
105
+ SemanticSelectionError,
106
+ STATE_USES,
107
+ type StateUse,
108
+ semanticFor,
109
+ statusAnchor,
110
+ } from './semantic.js';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Internal numeric helpers shared across the package.
3
+ *
4
+ * These are the idioms that were repeated inline in half a dozen modules:
5
+ * clamping a channel, normalizing a hue angle, and the two chroma ceilings.
6
+ * Not part of the public surface -- nothing here is re-exported from index.ts.
7
+ */
8
+
9
+ /** Clamp a value into [min, max]. */
10
+ export function clamp(value: number, min: number, max: number): number {
11
+ return Math.max(min, Math.min(max, value));
12
+ }
13
+
14
+ /** Clamp a value into [0, 1] -- the range every OKLCH lightness and alpha lives in. */
15
+ export function clamp01(value: number): number {
16
+ return clamp(value, 0, 1);
17
+ }
18
+
19
+ /** Normalize a hue angle into [0, 360), handling negative and over-wrapped input. */
20
+ export function normalizeHue(hue: number): number {
21
+ return ((hue % 360) + 360) % 360;
22
+ }
23
+
24
+ /**
25
+ * Upper bound for a chroma search: no OKLCH color in any display gamut we
26
+ * care about exceeds this, so binary searches and bucket clamps start here.
27
+ */
28
+ export const MAX_CHROMA = 0.4;
29
+
30
+ /**
31
+ * Chroma ceiling for "as vivid as a generated color may get". Deliberately
32
+ * lower than MAX_CHROMA and deliberately a separate constant: this is a design
33
+ * cap on generated output, not a bound on what OKLCH can represent.
34
+ */
35
+ export const VIVID_CHROMA_CAP = 0.3;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * OKLCH color manipulation functions
3
+ */
4
+
5
+ import type { OKLCH } from '@rafters/shared';
6
+ import { normalizeHue } from './internal/math.js';
7
+
8
+ /**
9
+ * Adjust the hue of a color by degrees
10
+ */
11
+ export function adjustHue(color: OKLCH, degrees: number): OKLCH {
12
+ return {
13
+ ...color,
14
+ h: normalizeHue(color.h + degrees),
15
+ };
16
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Deterministic Color Name Generator
3
+ *
4
+ * Generates unique, semantically meaningful color names from OKLCH values.
5
+ * Uses temperature and perceptual weight to select appropriate word variants.
6
+ *
7
+ * Format: {luminosity}-{intensity}-{material}
8
+ * Examples: "deep-bold-cobalt", "faint-soft-sage", "shadow-whisper"
9
+ *
10
+ * Two-tier system:
11
+ * 1. Expanded hubs: Rich semantic word banks for Red, Green, Blue (more coming)
12
+ * - 5 lightness bands × 4 chroma bands × 10 words = 200 words per temp variant
13
+ * - Sub-selection uses fractional L/C for maximum uniqueness
14
+ * 2. Fallback: Original word banks for hues without expanded hubs
15
+ */
16
+
17
+ import type { OKLCH } from '@rafters/shared';
18
+ import { calculatePerceptualWeight, getColorTemperature } from '../analysis.js';
19
+ import { getExpandedMaterialWord, hasExpandedHub } from './hue-hubs.js';
20
+ import { getCBucket, getHBucket, getLBucket } from './quantize.js';
21
+ import { INTENSITY_WORDS, LUMINOSITY_WORDS, MATERIAL_WORDS } from './word-banks.js';
22
+
23
+ // Threshold for considering a color achromatic (no hue word)
24
+ const ACHROMATIC_THRESHOLD = 0.02;
25
+
26
+ /**
27
+ * Generate a deterministic color name from OKLCH values
28
+ *
29
+ * The name is composed of up to three hyphenated words:
30
+ * - Luminosity: describes the lightness (shadow, deep, balanced, pale, brilliant, etc.)
31
+ * - Intensity: describes the chroma/saturation (whisper, bold, fierce, etc.)
32
+ * - Material: describes the hue (ember, sapphire, sage, etc.)
33
+ *
34
+ * Achromatic colors (chroma < 0.02) omit the material word.
35
+ *
36
+ * @param oklch - The color in OKLCH format
37
+ * @returns A hyphenated color name like "luminous-clear-sapphire"
38
+ */
39
+ export function generateColorName(oklch: OKLCH): string {
40
+ // Get computed properties for semantic word selection
41
+ const temperature = getColorTemperature(oklch);
42
+ const { density } = calculatePerceptualWeight(oklch);
43
+
44
+ // Get bucket indices (guaranteed to be within valid ranges by quantize functions)
45
+ const lBucket = getLBucket(oklch.l);
46
+ const cBucket = getCBucket(oklch.c);
47
+ const hBucket = getHBucket(oklch.h);
48
+
49
+ // Select words deterministically
50
+ // Type assertions are safe because bucket functions guarantee valid indices
51
+ const luminosity = LUMINOSITY_WORDS[lBucket] as string;
52
+ const intensityBank = INTENSITY_WORDS[density];
53
+ const intensity = intensityBank[cBucket] as string;
54
+
55
+ // Achromatic colors skip the hue/material word
56
+ if (oklch.c < ACHROMATIC_THRESHOLD) {
57
+ return `${luminosity}-${intensity}`;
58
+ }
59
+
60
+ // Try expanded hub first (rich semantic word banks with sub-selection)
61
+ // Falls back to original word banks for hues without expanded hubs
62
+ let material: string;
63
+ if (hasExpandedHub(hBucket)) {
64
+ material = getExpandedMaterialWord(
65
+ hBucket,
66
+ lBucket,
67
+ cBucket,
68
+ temperature,
69
+ oklch.l,
70
+ oklch.c,
71
+ ) as string;
72
+ } else {
73
+ // Fallback to original word banks
74
+ const materialBank = MATERIAL_WORDS[temperature];
75
+ material = materialBank[hBucket] as string;
76
+ }
77
+
78
+ return `${luminosity}-${intensity}-${material}`;
79
+ }
80
+
81
+ /**
82
+ * Generate a color name with metadata about the generation
83
+ *
84
+ * Useful for debugging and understanding why a particular name was chosen.
85
+ */
86
+ export function generateColorNameWithMetadata(oklch: OKLCH): {
87
+ name: string;
88
+ components: {
89
+ luminosity: string;
90
+ intensity: string;
91
+ material: string | null;
92
+ };
93
+ buckets: {
94
+ l: number;
95
+ c: number;
96
+ h: number;
97
+ };
98
+ modifiers: {
99
+ temperature: 'warm' | 'cool' | 'neutral';
100
+ density: 'light' | 'medium' | 'heavy';
101
+ isAchromatic: boolean;
102
+ usedExpandedHub: boolean;
103
+ };
104
+ } {
105
+ const temperature = getColorTemperature(oklch);
106
+ const { density } = calculatePerceptualWeight(oklch);
107
+
108
+ const lBucket = getLBucket(oklch.l);
109
+ const cBucket = getCBucket(oklch.c);
110
+ const hBucket = getHBucket(oklch.h);
111
+
112
+ // Type assertions are safe because bucket functions guarantee valid indices
113
+ const luminosity = LUMINOSITY_WORDS[lBucket] as string;
114
+ const intensityBank = INTENSITY_WORDS[density];
115
+ const intensity = intensityBank[cBucket] as string;
116
+ const isAchromatic = oklch.c < ACHROMATIC_THRESHOLD;
117
+
118
+ // Get material word (expanded hub or fallback)
119
+ let material: string | null = null;
120
+ let usedExpandedHub = false;
121
+ if (!isAchromatic) {
122
+ if (hasExpandedHub(hBucket)) {
123
+ material = getExpandedMaterialWord(
124
+ hBucket,
125
+ lBucket,
126
+ cBucket,
127
+ temperature,
128
+ oklch.l,
129
+ oklch.c,
130
+ ) as string;
131
+ usedExpandedHub = true;
132
+ } else {
133
+ const materialBank = MATERIAL_WORDS[temperature];
134
+ material = materialBank[hBucket] as string;
135
+ }
136
+ }
137
+
138
+ const name = isAchromatic
139
+ ? `${luminosity}-${intensity}`
140
+ : `${luminosity}-${intensity}-${material}`;
141
+
142
+ return {
143
+ name,
144
+ components: {
145
+ luminosity,
146
+ intensity,
147
+ material,
148
+ },
149
+ buckets: {
150
+ l: lBucket,
151
+ c: cBucket,
152
+ h: hBucket,
153
+ },
154
+ modifiers: {
155
+ temperature,
156
+ density,
157
+ isAchromatic,
158
+ usedExpandedHub,
159
+ },
160
+ };
161
+ }