@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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Type declarations for apca-w3 library
3
+ * apca-w3 is a vanilla JS library with no official TypeScript types
4
+ */
5
+ declare module 'apca-w3' {
6
+ export function APCAcontrast(txtY: number, bgY: number, places?: number): number;
7
+
8
+ export function sRGBtoY(rgb: [number, number, number, number?]): number;
9
+
10
+ export function displayP3toY(rgb: [number, number, number, number?]): number;
11
+
12
+ export function adobeRGBtoY(rgb: [number, number, number, number?]): number;
13
+
14
+ export function alphaBlend(
15
+ rgbaFG: [number, number, number, number?],
16
+ rgbBG: [number, number, number],
17
+ round?: boolean,
18
+ ): [number, number, number];
19
+
20
+ export function fontLookupAPCA(contrast: number, places?: number): number[];
21
+
22
+ export function calcAPCA(
23
+ textColor: [number, number, number, number?],
24
+ bgColor: [number, number, number, number?],
25
+ places?: number,
26
+ round?: boolean,
27
+ ): number;
28
+ }
package/src/builder.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * ColorValue Builder
3
+ *
4
+ * Builds a complete ColorValue from an OKLCH input using pure math.
5
+ * No AI, no network calls - just deterministic color calculations.
6
+ *
7
+ * This is the single source of truth for constructing ColorValue objects
8
+ * with all computed properties (scale, harmonies, accessibility, etc.)
9
+ */
10
+
11
+ import type { ColorValue, OKLCH } from '@rafters/shared';
12
+ import {
13
+ assembleWcagAccessibility,
14
+ calculateAPCAContrast,
15
+ CONTRAST_BLACK,
16
+ CONTRAST_WHITE,
17
+ minFontSizeForAPCA,
18
+ } from './accessibility.js';
19
+ import {
20
+ calculateAtmosphericWeight,
21
+ calculatePerceptualWeight,
22
+ getColorTemperature,
23
+ isLightColor,
24
+ } from './analysis.js';
25
+ import { generateHarmony, generateOKLCHScale } from './harmony.js';
26
+ import { generateColorName } from './naming/index.js';
27
+ import { SCALE_POSITIONS } from './scale-positions.js';
28
+ import { generateSemanticColorSuggestions } from './semantic.js';
29
+
30
+ /**
31
+ * Options for building a ColorValue
32
+ */
33
+ export interface BuildColorValueOptions {
34
+ /**
35
+ * Semantic token assignment (e.g., "primary", "destructive")
36
+ */
37
+ token?: string;
38
+
39
+ /**
40
+ * Scale position reference (e.g., "500", "400")
41
+ */
42
+ value?: string;
43
+
44
+ /**
45
+ * Human notes about the color choice
46
+ */
47
+ use?: string;
48
+
49
+ /**
50
+ * State mappings (e.g., { hover: "blue-900", focus: "blue-700" })
51
+ */
52
+ states?: Record<string, string>;
53
+ }
54
+
55
+ /**
56
+ * Build a complete ColorValue from OKLCH using pure math calculations.
57
+ *
58
+ * This function computes:
59
+ * - 11-position scale (50-950)
60
+ * - Color harmonies (complementary, triadic, analogous, tetradic, monochromatic)
61
+ * - Accessibility metadata (WCAG AA/AAA, APCA, contrast on white/black)
62
+ * - Color analysis (temperature, lightness)
63
+ * - Atmospheric and perceptual weights
64
+ * - Semantic color suggestions (danger, success, warning, info)
65
+ *
66
+ * @param oklch - The base color in OKLCH format
67
+ * @param options - Optional metadata (token, value, use, states)
68
+ * @returns A complete ColorValue with all computed properties
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { buildColorValue } from '@rafters/color-utils';
73
+ *
74
+ * const primary = buildColorValue(
75
+ * { l: 0.5, c: 0.15, h: 240, alpha: 1 },
76
+ * { token: 'primary', use: 'Brand primary color' }
77
+ * );
78
+ *
79
+ * console.log(primary.name); // e.g., "slate-bold-sapphire"
80
+ * console.log(primary.scale.length); // 11
81
+ * console.log(primary.accessibility.onWhite.wcagAA); // true/false
82
+ * ```
83
+ */
84
+ export function buildColorValue(oklch: OKLCH, options: BuildColorValueOptions = {}): ColorValue {
85
+ // Generate the 11-position scale
86
+ const scaleRecord = generateOKLCHScale(oklch);
87
+ const scale = SCALE_POSITIONS.map((pos) => scaleRecord[pos]).filter(
88
+ (v): v is OKLCH => v !== undefined,
89
+ );
90
+
91
+ // Generate harmonies
92
+ const harmony = generateHarmony(oklch);
93
+
94
+ // WCAG accessibility (pair matrices + on-white/on-black) via the shared
95
+ // assembler; APCA is layered on below, builder-only.
96
+ const wcagAccessibility = assembleWcagAccessibility(scale, oklch);
97
+ const apcaOnWhite = calculateAPCAContrast(oklch, CONTRAST_WHITE);
98
+ const apcaOnBlack = calculateAPCAContrast(oklch, CONTRAST_BLACK);
99
+
100
+ // Get color analysis
101
+ const temperature = getColorTemperature(oklch);
102
+ const light = isLightColor(oklch);
103
+
104
+ // Get perceptual weights
105
+ const atmospheric = calculateAtmosphericWeight(oklch);
106
+ const perceptual = calculatePerceptualWeight(oklch);
107
+
108
+ // Get semantic suggestions
109
+ const semanticSuggestions = generateSemanticColorSuggestions(oklch);
110
+
111
+ // Generate the color name
112
+ const name = generateColorName(oklch);
113
+
114
+ // Build the complete ColorValue
115
+ const colorValue: ColorValue = {
116
+ name,
117
+ scale,
118
+ tokenId: `color-${oklch.l.toFixed(3)}-${oklch.c.toFixed(3)}-${Math.round(oklch.h)}`,
119
+
120
+ // Optional metadata from options
121
+ ...(options.token && { token: options.token }),
122
+ ...(options.value && { value: options.value }),
123
+ ...(options.use && { use: options.use }),
124
+ ...(options.states && { states: options.states }),
125
+
126
+ // Harmonies - pure hue rotation arrays
127
+ harmonies: {
128
+ complementary: harmony.complementary,
129
+ triadic: harmony.triadic,
130
+ analogous: harmony.analogous,
131
+ tetradic: harmony.tetradic,
132
+ splitComplementary: harmony.splitComplementary,
133
+ monochromatic: harmony.monochromatic,
134
+ },
135
+
136
+ // Accessibility
137
+ accessibility: {
138
+ ...wcagAccessibility,
139
+ apca: {
140
+ onWhite: apcaOnWhite,
141
+ onBlack: apcaOnBlack,
142
+ minFontSize: minFontSizeForAPCA(apcaOnWhite),
143
+ },
144
+ },
145
+
146
+ // Analysis
147
+ analysis: {
148
+ temperature,
149
+ isLight: light,
150
+ name,
151
+ },
152
+
153
+ // Atmospheric weight
154
+ atmosphericWeight: atmospheric,
155
+
156
+ // Perceptual weight
157
+ perceptualWeight: perceptual,
158
+
159
+ // Semantic suggestions
160
+ semanticSuggestions,
161
+ };
162
+
163
+ return colorValue;
164
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * colorWheel: generate a complete 11-family semantic color system from a single OKLCH seed.
3
+ *
4
+ * Dispatches to harmony-specific functions. Complementary is fully deterministic.
5
+ * All other harmony types delegate to complementaryWheel until their specific
6
+ * semantic role mappings are designed.
7
+ */
8
+
9
+ import type { ColorValue, OKLCH } from '@rafters/shared';
10
+ import { buildColorValue } from './builder.js';
11
+ import { roundOKLCH } from './conversion.js';
12
+ import { clamp, VIVID_CHROMA_CAP } from './internal/math.js';
13
+ import { adjustHue } from './manipulation.js';
14
+ import { statusAnchor } from './semantic.js';
15
+
16
+ export type HarmonyType =
17
+ | 'complementary'
18
+ | 'triadic'
19
+ | 'tetradic'
20
+ | 'analogous'
21
+ | 'split-complementary';
22
+
23
+ export interface ColorWheelOptions {
24
+ chromaDistribution?: 'gaussian' | 'flat';
25
+ }
26
+
27
+ export interface SemanticColorSystem {
28
+ primary: ColorValue;
29
+ secondary: ColorValue;
30
+ tertiary: ColorValue;
31
+ accent: ColorValue;
32
+ highlight: ColorValue;
33
+ neutral: ColorValue;
34
+ muted: ColorValue;
35
+ success: ColorValue;
36
+ warning: ColorValue;
37
+ destructive: ColorValue;
38
+ info: ColorValue;
39
+ }
40
+
41
+ /**
42
+ * Apply gaussian chroma distribution centered at L=0.6 with sigma=0.25.
43
+ * Reduces chroma at light and dark extremes, preserving peak saturation
44
+ * near mid-tones where colors are most perceptually legible.
45
+ *
46
+ * Applied to: secondary, tertiary, highlight, neutral, muted.
47
+ * NOT applied to: primary (exact designer choice), accent (needs full chroma),
48
+ * status colors (success/warning/destructive/info must remain recognizable).
49
+ */
50
+ function applyGaussianChroma(oklch: OKLCH): OKLCH {
51
+ const peakL = 0.6;
52
+ const sigma = 0.25;
53
+ const gaussian = Math.exp(-((oklch.l - peakL) ** 2) / (2 * sigma ** 2));
54
+ return { ...oklch, c: oklch.c * gaussian };
55
+ }
56
+
57
+ /**
58
+ * Derive secondary lightness from the primary's accessibility matrix.
59
+ *
60
+ * Uses primary's wcagAAA.normal pairs to find the scale position farthest from
61
+ * primary in lightness that still passes AAA. That position's OKLCH lightness
62
+ * becomes secondary's lightness, ensuring AAA contrast between primary and secondary.
63
+ *
64
+ * Falls back to the opposite-end heuristic (dark seed -> light secondary, vice versa)
65
+ * if the accessibility matrix yields no AAA pairs.
66
+ */
67
+ function resolveSecondaryLightness(primaryColorValue: ColorValue, primaryOklch: OKLCH): number {
68
+ const aaaPairs = primaryColorValue.accessibility?.wcagAAA?.normal ?? [];
69
+ const scale = primaryColorValue.scale;
70
+
71
+ // Find the pair with the greatest lightness distance from primary
72
+ let bestL: number | null = null;
73
+ let bestDistance = 0;
74
+
75
+ for (const pair of aaaPairs) {
76
+ const idxA = pair[0];
77
+ const idxB = pair[1];
78
+ if (idxA === undefined || idxB === undefined) continue;
79
+
80
+ // Both indices in the pair are from the primary scale.
81
+ // We want the one that is farthest in lightness from primary.
82
+ for (const idx of [idxA, idxB]) {
83
+ const scaleColor = scale[idx];
84
+ if (!scaleColor) continue;
85
+ const dist = Math.abs(scaleColor.l - primaryOklch.l);
86
+ if (dist > bestDistance) {
87
+ bestDistance = dist;
88
+ bestL = scaleColor.l;
89
+ }
90
+ }
91
+ }
92
+
93
+ if (bestL !== null) {
94
+ return bestL;
95
+ }
96
+
97
+ // Fallback: opposite end of the lightness range
98
+ return primaryOklch.l > 0.5 ? 0.25 : 0.75;
99
+ }
100
+
101
+ /**
102
+ * Full complementary color wheel.
103
+ *
104
+ * Roles:
105
+ * primary - the seed as-is
106
+ * secondary - same hue, opposite lightness end (AAA against primary), chroma * 0.33
107
+ * accent - complement (+180), full chroma
108
+ * tertiary - CTA from complement hue, chroma * 1.2 (max 0.30), L clamped 0.45-0.65
109
+ * highlight - secondary math applied to tertiary hue
110
+ * neutral - seed hue, chroma * 0.02, L = 0.50
111
+ * muted - seed hue, chroma * 0.05, L = 0.85
112
+ * success - hue 145, L 0.55, C = min(0.18, seed.c * 0.9)
113
+ * warning - hue 85, L 0.75, C = min(0.18, seed.c * 0.9)
114
+ * destructive - hue 25, L 0.55, C = min(0.20, seed.c)
115
+ * info - hue 230, L 0.58, C = min(0.15, seed.c * 0.85)
116
+ */
117
+ function complementaryWheel(seed: OKLCH, options: ColorWheelOptions): SemanticColorSystem {
118
+ const useGaussian = (options.chromaDistribution ?? 'gaussian') === 'gaussian';
119
+ const alpha = seed.alpha ?? 1;
120
+
121
+ // primary: exact designer seed
122
+ const primaryColorValue = buildColorValue(seed, { token: 'primary' });
123
+
124
+ // secondary: same hue, AAA-verified lightness from primary matrix, chroma * 0.33
125
+ const secondaryL = resolveSecondaryLightness(primaryColorValue, seed);
126
+ const secondaryOklch: OKLCH = { l: secondaryL, c: seed.c * 0.33, h: seed.h, alpha };
127
+ const secondaryFinal = useGaussian ? applyGaussianChroma(secondaryOklch) : secondaryOklch;
128
+ const secondaryColorValue = buildColorValue(roundOKLCH(secondaryFinal), { token: 'secondary' });
129
+
130
+ // accent: pure complement, full chroma -- no gaussian (needs full chroma)
131
+ const accentOklch = adjustHue(seed, 180);
132
+ const accentColorValue = buildColorValue(accentOklch, { token: 'accent' });
133
+
134
+ // tertiary: CTA from complement hue, chroma boosted but capped, mid lightness
135
+ const tertiaryRaw: OKLCH = {
136
+ l: clamp(seed.l, 0.45, 0.65),
137
+ c: Math.min(VIVID_CHROMA_CAP, seed.c * 1.2),
138
+ h: accentOklch.h,
139
+ alpha,
140
+ };
141
+ const tertiaryFinal = useGaussian ? applyGaussianChroma(tertiaryRaw) : tertiaryRaw;
142
+ const tertiaryColorValue = buildColorValue(roundOKLCH(tertiaryFinal), { token: 'tertiary' });
143
+
144
+ // highlight: secondary math applied to tertiary hue
145
+ // AAA lightness from the tertiary's accessibility matrix, same desaturation as secondary.
146
+ const highlightL = resolveSecondaryLightness(tertiaryColorValue, tertiaryFinal);
147
+ const highlightOklch: OKLCH = {
148
+ l: highlightL,
149
+ c: tertiaryFinal.c * 0.33,
150
+ h: tertiaryFinal.h,
151
+ alpha,
152
+ };
153
+ const highlightFinal = useGaussian ? applyGaussianChroma(highlightOklch) : highlightOklch;
154
+ const highlightColorValue = buildColorValue(roundOKLCH(highlightFinal), { token: 'highlight' });
155
+
156
+ // neutral: seed hue, very low chroma, mid lightness
157
+ const neutralOklch: OKLCH = { l: 0.5, c: seed.c * 0.02, h: seed.h, alpha };
158
+ const neutralFinal = useGaussian ? applyGaussianChroma(neutralOklch) : neutralOklch;
159
+ const neutralColorValue = buildColorValue(roundOKLCH(neutralFinal), { token: 'neutral' });
160
+
161
+ // muted: seed hue, slightly more chroma than neutral, high lightness
162
+ const mutedOklch: OKLCH = { l: 0.85, c: seed.c * 0.05, h: seed.h, alpha };
163
+ const mutedFinal = useGaussian ? applyGaussianChroma(mutedOklch) : mutedOklch;
164
+ const mutedColorValue = buildColorValue(roundOKLCH(mutedFinal), { token: 'muted' });
165
+
166
+ // status colors -- no gaussian, must stay recognizable. Anchored by the
167
+ // shared STATUS_ROLE_ANCHORS (semantic.ts) so the wheel and the
168
+ // semanticSuggestions on every ColorValue can never drift apart.
169
+ const successColorValue = buildColorValue(statusAnchor('success', seed), { token: 'success' });
170
+ const warningColorValue = buildColorValue(statusAnchor('warning', seed), { token: 'warning' });
171
+ const destructiveColorValue = buildColorValue(statusAnchor('destructive', seed), {
172
+ token: 'destructive',
173
+ });
174
+ const infoColorValue = buildColorValue(statusAnchor('info', seed), { token: 'info' });
175
+
176
+ return {
177
+ primary: primaryColorValue,
178
+ secondary: secondaryColorValue,
179
+ tertiary: tertiaryColorValue,
180
+ accent: accentColorValue,
181
+ highlight: highlightColorValue,
182
+ neutral: neutralColorValue,
183
+ muted: mutedColorValue,
184
+ success: successColorValue,
185
+ warning: warningColorValue,
186
+ destructive: destructiveColorValue,
187
+ info: infoColorValue,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Generate a complete 11-family semantic color system from a single OKLCH seed.
193
+ *
194
+ * Dispatches to harmony-specific functions. Complementary is fully deterministic.
195
+ * Triadic, tetradic, analogous, and split-complementary delegate to complementaryWheel
196
+ * until their correct semantic role mappings are designed.
197
+ *
198
+ * @param seed - Base color in OKLCH
199
+ * @param harmony - Harmony relationship type
200
+ * @param options - Optional configuration (chromaDistribution: 'gaussian' | 'flat')
201
+ * @returns SemanticColorSystem with 11 fully-built ColorValues
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * import { colorWheel } from '@rafters/color-utils';
206
+ *
207
+ * const system = colorWheel(
208
+ * { l: 0.5, c: 0.15, h: 240, alpha: 1 },
209
+ * 'complementary'
210
+ * );
211
+ *
212
+ * console.log(system.primary.name); // e.g. "slate-bold-sapphire"
213
+ * console.log(system.destructive.accessibility?.onWhite.wcagAAA); // true/false
214
+ * ```
215
+ */
216
+ export function colorWheel(
217
+ seed: OKLCH,
218
+ harmony: HarmonyType,
219
+ options?: ColorWheelOptions,
220
+ ): SemanticColorSystem {
221
+ const opts = options ?? {};
222
+
223
+ switch (harmony) {
224
+ case 'complementary':
225
+ return complementaryWheel(seed, opts);
226
+
227
+ // Stubs: correct semantic role mappings need design research.
228
+ // Delegating to complementary until those mappings are known.
229
+ case 'triadic':
230
+ case 'tetradic':
231
+ case 'analogous':
232
+ case 'split-complementary':
233
+ return complementaryWheel(seed, opts);
234
+ }
235
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Type declarations for colorjs.io library
3
+ * Minimal subset of the API that we use
4
+ */
5
+ declare module 'colorjs.io' {
6
+ interface ToStringOptions {
7
+ format?: string;
8
+ collapse?: boolean;
9
+ }
10
+
11
+ export default class Color {
12
+ // Constructor can take various forms
13
+ constructor(space: string, coords: [number, number, number], alpha?: number);
14
+ constructor(cssString: string);
15
+
16
+ // Color space properties
17
+ coords: [number | undefined, number | undefined, number | undefined];
18
+ alpha: number;
19
+ r: number;
20
+ g: number;
21
+ b: number;
22
+ srgb: { r: number; g: number; b: number };
23
+
24
+ // Gamut methods
25
+ inGamut(space?: string): boolean;
26
+
27
+ // Conversion methods
28
+ to(space: string): Color;
29
+ toGamut(options?: { space?: string; method?: string }): Color;
30
+ toString(options?: ToStringOptions): string;
31
+
32
+ // Static methods
33
+ static get(color: unknown, prop: string): number;
34
+ static set(color: unknown, prop: string, value: number): void;
35
+ }
36
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * OKLCH color conversion functions using colorjs.io
3
+ */
4
+
5
+ import type { OKLCH } from '@rafters/shared';
6
+ import Color from 'colorjs.io';
7
+
8
+ /** Options for formatting an OKLCH color as a CSS `oklch()` string. */
9
+ export interface OklchToCSSOptions {
10
+ /**
11
+ * Decimal places applied to every emitted channel (L, C, H, and A when
12
+ * present). Omitted means no rounding: channels are stringified as-is.
13
+ */
14
+ precision?: number;
15
+ }
16
+
17
+ /**
18
+ * Convert OKLCH to a CSS oklch() function string.
19
+ *
20
+ * Emits `oklch(L C H)`, or `oklch(L C H / A)` when alpha is defined and not 1.
21
+ * With no options and no alpha the output is byte-identical to the original
22
+ * three-channel formatter -- design-tokens callers depend on that exact string.
23
+ */
24
+ export function oklchToCSS(oklch: OKLCH, options?: OklchToCSSOptions): string {
25
+ const precision = options?.precision;
26
+ const format = (value: number): string =>
27
+ precision === undefined ? String(value) : value.toFixed(precision);
28
+
29
+ const base = `${format(oklch.l)} ${format(oklch.c)} ${format(oklch.h)}`;
30
+ const { alpha } = oklch;
31
+ if (alpha === undefined || alpha === 1) {
32
+ return `oklch(${base})`;
33
+ }
34
+ return `oklch(${base} / ${format(alpha)})`;
35
+ }
36
+
37
+ /**
38
+ * Convert hex string to OKLCH color object
39
+ */
40
+ export function hexToOKLCH(hex: string): OKLCH {
41
+ try {
42
+ const color = new Color(hex);
43
+ const oklch = color.to('oklch');
44
+
45
+ // colorjs.io returns boxed `Number` objects (not primitives) for some
46
+ // input formats (oklch() literals notably). Schemas downstream check
47
+ // `typeof === 'number'` and reject boxed Numbers. Coerce to primitives
48
+ // here so every consumer sees plain numbers.
49
+ return {
50
+ l: Number(oklch.coords[0] ?? 0),
51
+ c: Number(oklch.coords[1] ?? 0),
52
+ // Achromatic colors (grays) get NaN hue from colorjs.io, not undefined
53
+ h: Number.isNaN(oklch.coords[2]) ? 0 : Number(oklch.coords[2] ?? 0),
54
+ alpha: Number(oklch.alpha ?? 1),
55
+ };
56
+ } catch (_error) {
57
+ throw new Error(`Invalid hex color: ${hex}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Predicate-form CSS color parser. Returns the parsed OKLCH on success and
63
+ * null on any parse failure (invalid format, missing channels, gibberish).
64
+ *
65
+ * Used by callers that need to test "is this string a color?" without a
66
+ * try/catch around `hexToOKLCH` (which throws). Despite the historical name,
67
+ * `hexToOKLCH` already accepts every CSS color format via colorjs.io --
68
+ * `tryParseColor` is just the null-on-failure wrapper.
69
+ */
70
+ export function tryParseColor(css: string): OKLCH | null {
71
+ try {
72
+ return hexToOKLCH(css);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Round OKLCH values to standard precision for consistency
80
+ * L and C: 3 decimal places (perceptually meaningful differentiation)
81
+ * H: whole degrees, Alpha: 2 decimal places
82
+ * Prevents floating point precision issues and optimizes cache keys
83
+ */
84
+ export function roundOKLCH(oklch: OKLCH): OKLCH {
85
+ return {
86
+ l: Math.round(oklch.l * 1000) / 1000,
87
+ c: Math.round(oklch.c * 1000) / 1000,
88
+ // NaN hue from achromatic colors defaults to 0
89
+ h: Number.isNaN(oklch.h) ? 0 : Math.round(oklch.h),
90
+ alpha: oklch.alpha !== undefined ? Math.round(oklch.alpha * 100) / 100 : 1,
91
+ };
92
+ }
package/src/gamut.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * OKLCH gamut awareness utilities
3
+ *
4
+ * Three-tier gamut model based on real-world display support:
5
+ * - srgb: in sRGB (safe on every screen, also in P3 by inclusion)
6
+ * - p3: in Display P3 but NOT sRGB (modern wide-gamut displays only)
7
+ * - out: outside both gamuts (not displayable anywhere)
8
+ */
9
+
10
+ import type { OKLCH } from '@rafters/shared';
11
+ import Color from 'colorjs.io';
12
+ import { z } from 'zod';
13
+ import { roundOKLCH } from './conversion.js';
14
+ import { MAX_CHROMA } from './internal/math.js';
15
+
16
+ export const GamutTierSchema = z.enum(['srgb', 'p3', 'out']);
17
+ export type GamutTier = z.infer<typeof GamutTierSchema>;
18
+
19
+ export const GamutBoundaryPointSchema = z.object({
20
+ l: z.number(),
21
+ /** Maximum chroma within sRGB gamut */
22
+ maxC_srgb: z.number(),
23
+ /** Maximum chroma within P3 gamut */
24
+ maxC_p3: z.number(),
25
+ });
26
+ export type GamutBoundaryPoint = z.infer<typeof GamutBoundaryPointSchema>;
27
+
28
+ /** Check whether an OKLCH color is within the sRGB gamut. */
29
+ export function isInSRGBGamut(oklch: OKLCH): boolean {
30
+ const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);
31
+ return color.inGamut('srgb');
32
+ }
33
+
34
+ /** Check whether an OKLCH color is within the Display P3 gamut. */
35
+ export function isInP3Gamut(oklch: OKLCH): boolean {
36
+ const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);
37
+ return color.inGamut('p3');
38
+ }
39
+
40
+ /** Classify an OKLCH color by the gamut it fits in. */
41
+ export function getGamutTier(oklch: OKLCH): GamutTier {
42
+ if (isInSRGBGamut(oklch)) return 'srgb';
43
+ if (isInP3Gamut(oklch)) return 'p3';
44
+ return 'out';
45
+ }
46
+
47
+ /**
48
+ * Snap an OKLCH color to the nearest in-gamut color.
49
+ * Returns the snapped color and its tier.
50
+ */
51
+ export function toNearestGamut(oklch: OKLCH): { color: OKLCH; tier: GamutTier } {
52
+ const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);
53
+
54
+ if (color.inGamut('srgb')) {
55
+ return { color: { ...oklch }, tier: 'srgb' };
56
+ }
57
+
58
+ const srgbMapped = color.toGamut({ space: 'srgb' });
59
+ const mappedOklch = srgbMapped.to('oklch');
60
+ return {
61
+ color: {
62
+ l: mappedOklch.coords[0] ?? 0,
63
+ c: mappedOklch.coords[1] ?? 0,
64
+ h: mappedOklch.coords[2] ?? oklch.h,
65
+ alpha: oklch.alpha,
66
+ },
67
+ tier: 'srgb',
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Snap an OKLCH into sRGB and round it -- the shape every generated color is
73
+ * emitted in. Lives here because both halves are gamut/conversion concerns;
74
+ * harmony.ts and semantic.ts are callers, not owners.
75
+ */
76
+ export function clampColor(color: OKLCH): OKLCH {
77
+ return roundOKLCH(toNearestGamut(color).color);
78
+ }
79
+
80
+ const DEFAULT_STEPS = 101;
81
+ const TOLERANCE = 0.001;
82
+
83
+ function findMaxChroma(l: number, h: number, gamutSpace: string): number {
84
+ let lo = 0;
85
+ let hi = MAX_CHROMA;
86
+ if (l <= 0 || l >= 1) return 0;
87
+ const maxColor = new Color('oklch', [l, hi, h]);
88
+ if (maxColor.inGamut(gamutSpace)) return hi;
89
+ while (hi - lo > TOLERANCE) {
90
+ const mid = (lo + hi) / 2;
91
+ const color = new Color('oklch', [l, mid, h]);
92
+ if (color.inGamut(gamutSpace)) {
93
+ lo = mid;
94
+ } else {
95
+ hi = mid;
96
+ }
97
+ }
98
+ return lo;
99
+ }
100
+
101
+ /**
102
+ * Compute both sRGB and P3 gamut boundaries for a given hue.
103
+ * The two boundary curves define three zones:
104
+ * 0 .. maxC_srgb -> srgb
105
+ * maxC_srgb .. maxC_p3 -> p3
106
+ * beyond maxC_p3 -> out
107
+ */
108
+ export function computeGamutBoundaries(hue: number, steps = DEFAULT_STEPS): GamutBoundaryPoint[] {
109
+ const boundaries: GamutBoundaryPoint[] = [];
110
+ for (let i = 0; i < steps; i++) {
111
+ const l = i / (steps - 1);
112
+ const maxC_srgb = findMaxChroma(l, hue, 'srgb');
113
+ const maxC_p3 = findMaxChroma(l, hue, 'p3');
114
+ boundaries.push({ l, maxC_srgb, maxC_p3 });
115
+ }
116
+ return boundaries;
117
+ }