@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ezmode Games
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @rafters/color-utils
2
+
3
+ > OKLCH-first color math for the Rafters design system.
4
+
5
+ Every color in Rafters is OKLCH before it is anything else. This package holds
6
+ the math that acts on it: conversion, perceptual analysis, accessibility scoring,
7
+ gamut awareness, harmony and semantic generation, deterministic naming, and the
8
+ builder that assembles a full color value from a single seed. Parsing from CSS
9
+ and formatting back to it exist for interoperability, but the algorithms work in
10
+ OKLCH because that is where the perceptual results hold up.
11
+
12
+ ## Install
13
+
14
+ Published as TypeScript source; consumers need a bundler or tsx/vite. Plain
15
+ `node` cannot import it.
16
+
17
+ ```bash
18
+ pnpm add @rafters/color-utils
19
+ ```
20
+
21
+ Import from another package:
22
+
23
+ ```ts
24
+ import {
25
+ hexToOKLCH,
26
+ oklchToCSS,
27
+ roundOKLCH,
28
+ buildColorValue,
29
+ getGamutTier,
30
+ generateHarmony,
31
+ generateColorName,
32
+ calculateWCAGContrast,
33
+ } from '@rafters/color-utils';
34
+ ```
35
+
36
+ ## What's inside
37
+
38
+ ### Conversion (`conversion.ts`)
39
+
40
+ - `hexToOKLCH(hex)` — parse a hex or CSS color into OKLCH.
41
+ - `tryParseColor(input)` — parse without throwing; returns null on a bad string.
42
+ - `oklchToCSS(oklch, options?)` — format as a CSS `oklch()` string.
43
+ - `roundOKLCH(oklch)` — normalize and round, so equal colors cache as equal.
44
+
45
+ ```ts
46
+ const o = hexToOKLCH('#0ea5a4');
47
+ oklchToCSS(roundOKLCH(o)); // 'oklch(0.654 0.11 194)'
48
+ ```
49
+
50
+ `oklchToCSS` emits `oklch(L C H)`, or `oklch(L C H / A)` when `alpha` is defined
51
+ and is not 1. `options.precision`, when given, is the number of decimal places
52
+ applied to every emitted channel — L, C, H, and A alike. With no options and no
53
+ alpha the output is the plain three-channel string, unrounded.
54
+
55
+ ```ts
56
+ oklchToCSS({ l: 0.7, c: 0.15, h: 250 });
57
+ // 'oklch(0.7 0.15 250)'
58
+
59
+ oklchToCSS({ l: 0.7, c: 0.15, h: 250, alpha: 0.5 });
60
+ // 'oklch(0.7 0.15 250 / 0.5)'
61
+
62
+ oklchToCSS({ l: 0.70001, c: 0.15001, h: 250.001 }, { precision: 2 });
63
+ // 'oklch(0.70 0.15 250.00)'
64
+
65
+ oklchToCSS({ l: 0.70001, c: 0.15001, h: 250.001, alpha: 0.5001 }, { precision: 3 });
66
+ // 'oklch(0.700 0.150 250.001 / 0.500)'
67
+ ```
68
+
69
+ ### Gamut (`gamut.ts`)
70
+
71
+ A three-tier model built on what displays actually support: `srgb` shows
72
+ everywhere, `p3` needs a wide-gamut screen, `out` is displayable nowhere.
73
+
74
+ - `getGamutTier(oklch)` — which tier a color falls in.
75
+ - `isInSRGBGamut(oklch)` / `isInP3Gamut(oklch)` — the two boundary checks.
76
+ - `toNearestGamut(oklch)` — snap an out-of-gamut color into sRGB. Returns
77
+ `{ color, tier }`: the snapped OKLCH and the tier it landed in.
78
+ - `computeGamutBoundaries(hue, steps?)` — the boundary points a picker draws
79
+ against.
80
+
81
+ Boundary checks run through `colorjs.io`, so sRGB and P3 agree with what the
82
+ browser will render.
83
+
84
+ ### Accessibility (`accessibility.ts`)
85
+
86
+ - `calculateWCAGContrast(fg, bg)` — the WCAG contrast ratio.
87
+ - `calculateAPCAContrast(fg, bg)` — the APCA score, for modern contrast work.
88
+ - `generateAccessibilityMetadata(scale)` / `rebakeAccessibility(value)` — precompute
89
+ the pass/fail matrix for a scale, and rebuild it after an edit.
90
+
91
+ ```ts
92
+ calculateWCAGContrast(o, hexToOKLCH('#ffffff'));
93
+ ```
94
+
95
+ ### Analysis (`analysis.ts`)
96
+
97
+ - `isLightColor(color)` — light or dark, with a chroma adjustment.
98
+ - `getColorTemperature(color)` — warm, cool, or neutral from hue and chroma. The
99
+ package's only temperature classifier: everything that needs the verdict calls
100
+ this one.
101
+ - `calculateAtmosphericWeight(color)` / `calculatePerceptualWeight(color)` —
102
+ perceptual weight, for background-to-foreground role and layout balance.
103
+
104
+ ### Manipulation (`manipulation.ts`)
105
+
106
+ - `adjustHue(color, degrees)` — rotate the hue, normalized back into 0-360.
107
+
108
+ ### Harmony and scales (`harmony.ts`)
109
+
110
+ - `generateHarmony(base)` — hue rotations: complementary, triadic, analogous,
111
+ tetradic, split-complementary, monochromatic.
112
+ - `generateOKLCHScale(base)` — an accessible 50 to 950 scale tuned for contrast.
113
+
114
+ ### Semantic selection (`semantic.ts`)
115
+
116
+ Purpose-driven pair finding over a color value. Pairs are found in light mode,
117
+ then inverted as a unit for dark mode so the relationship survives.
118
+
119
+ - `semanticFor(family, { name? })` — open a selection context over one color
120
+ family. The returned context carries `.pair(request)` for a single use,
121
+ `.states(from, dark?)` for the whole state ladder, and `.invert(pair)` for the
122
+ dark-mode counterpart.
123
+ - `statusAnchor(role, seed)` — the one OKLCH anchor for a status role
124
+ (`destructive`, `success`, `warning`, `info`), with chroma derived from the seed.
125
+ - `generateSemanticColorSuggestions(base)` — seed-derived semantic palettes
126
+ (danger, success, warning, info), three variants per role.
127
+ - `STATE_USES`, `SemanticSelectionError`, and the `Pair*` types.
128
+
129
+ ### Color wheel and builder (`color-wheel.ts`, `builder.ts`)
130
+
131
+ - `buildColorValue(oklch, options)` — assemble a full color value (scale,
132
+ harmonies, accessibility) from one OKLCH seed, with pure math and no network.
133
+ - `colorWheel(seed, harmony, options?)` — a complete 11-family semantic system
134
+ from a single seed. `harmony` is required and names the relationship
135
+ (`complementary`, `triadic`, `tetradic`, `analogous`, `split-complementary`).
136
+
137
+ ### Scale positions (`scale-positions.ts`)
138
+
139
+ - `SCALE_POSITIONS` — the canonical labels (`50`, `100`, ... `950`).
140
+ - `POSITION_TO_INDEX` — the inverse lookup. Vocabulary only; selection lives in
141
+ `semantic.ts`.
142
+
143
+ ### Naming (`naming/`)
144
+
145
+ - `generateColorName(oklch)` / `generateColorNameWithMetadata(oklch)` — a stable,
146
+ human-readable name from an OKLCH value, chosen by temperature and perceptual
147
+ weight.
148
+
149
+ ```ts
150
+ generateColorName({ l: 0.65, c: 0.12, h: 230, alpha: 1 }); // 'luminous-true-bay-blue'
151
+ ```
152
+
153
+ The bucket helpers and word lists (`getCBucket`, `getChromaBand`, `HUE_HUBS`,
154
+ `MATERIAL_WORDS`, and the rest) are exported for callers building their own
155
+ naming views.
156
+
157
+ ## Testing
158
+
159
+ ```bash
160
+ pnpm --filter @rafters/color-utils test
161
+ ```
162
+
163
+ ## Notes
164
+
165
+ - OKLCH-first. Conversions exist for interoperability; the math stays in OKLCH.
166
+ - Accessibility scoring is mathematical, and APCA is there for modern contrast
167
+ evaluation alongside WCAG.
168
+ - Published as TypeScript source. It gains nothing from a build step, but a
169
+ consumer needs a bundler or tsx/vite to import it — plain `node` cannot.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@rafters/color-utils",
3
+ "version": "0.2.3",
4
+ "description": "OKLCH color manipulation utilities for Rafters design system",
5
+ "homepage": "https://rafters.studio",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rafters-studio/rafters.git",
10
+ "directory": "packages/color-utils"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "LICENSE"
15
+ ],
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "test:coverage": "vitest run --coverage",
28
+ "typecheck": "tsc --noEmit",
29
+ "clean": "rm -rf .turbo coverage"
30
+ },
31
+ "dependencies": {
32
+ "@rafters/shared": "workspace:*",
33
+ "apca-w3": "^0.1.9",
34
+ "colorjs.io": "^0.5.2",
35
+ "zod": "catalog:"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "catalog:",
39
+ "tsx": "^4.21.0",
40
+ "typescript": "catalog:",
41
+ "vitest": "catalog:",
42
+ "zocker": "catalog:"
43
+ }
44
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Accessibility contrast calculation and compliance checking functions
3
+ */
4
+
5
+ import type { ColorValue, OKLCH } from '@rafters/shared';
6
+ import { APCAcontrast, sRGBtoY } from 'apca-w3';
7
+ import Color from 'colorjs.io';
8
+ import { z } from 'zod';
9
+
10
+ /** White reference color for contrast calculations. */
11
+ export const CONTRAST_WHITE: OKLCH = { l: 1, c: 0, h: 0, alpha: 1 };
12
+
13
+ /** Black reference color for contrast calculations. */
14
+ export const CONTRAST_BLACK: OKLCH = { l: 0, c: 0, h: 0, alpha: 1 };
15
+
16
+ /** WCAG 2.1 minimum contrast ratios, by conformance level and text size. */
17
+ const WCAG_CONTRAST_THRESHOLDS = {
18
+ AA: { normal: 4.5, large: 3.0 },
19
+ AAA: { normal: 7.0, large: 4.5 },
20
+ } as const;
21
+
22
+ /**
23
+ * Breakpoints for ColorValue.accessibility.apca.minFontSize, most demanding
24
+ * first. This is builder.ts's own two-breakpoint ternary (16px at >=60,
25
+ * 24px at >=45, 32px otherwise), pulled out so it is encoded once instead of
26
+ * inline in buildColorValue. It is not a general APCA font-size table -- the
27
+ * finer-grained small/medium/large breakpoints that once lived in the now
28
+ * -deleted, uncalled `meetsAPCAStandard` do not appear here; that function
29
+ * had zero callers repo-wide and its table was never reconciled with this one.
30
+ */
31
+ const APCA_FONT_SIZE_THRESHOLDS: readonly { minFontSize: number; minContrast: number }[] = [
32
+ { minFontSize: 16, minContrast: 60 },
33
+ { minFontSize: 24, minContrast: 45 },
34
+ { minFontSize: 32, minContrast: 0 },
35
+ ];
36
+
37
+ /**
38
+ * Smallest font size (in px) that stays readable at the given APCA contrast,
39
+ * per ColorValue.accessibility.apca.minFontSize's existing breakpoints (see
40
+ * APCA_FONT_SIZE_THRESHOLDS). Contrast sign is irrelevant -- APCA reports
41
+ * polarity, magnitude is what carries legibility.
42
+ */
43
+ export function minFontSizeForAPCA(contrast: number): number {
44
+ const magnitude = Math.abs(contrast);
45
+ for (const { minFontSize, minContrast } of APCA_FONT_SIZE_THRESHOLDS) {
46
+ if (magnitude >= minContrast) return minFontSize;
47
+ }
48
+ // Unreachable: the last row has minContrast 0. Kept for exhaustiveness.
49
+ return 32;
50
+ }
51
+
52
+ /**
53
+ * Convert OKLCH to relative luminance for WCAG calculations
54
+ */
55
+ function getRelativeLuminance(oklch: OKLCH): number {
56
+ const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);
57
+ const srgb = color.to('srgb');
58
+
59
+ // WCAG formula for relative luminance
60
+ const toLinear = (val: number): number => {
61
+ if (val <= 0.03928) {
62
+ return val / 12.92;
63
+ }
64
+ return ((val + 0.055) / 1.055) ** 2.4;
65
+ };
66
+
67
+ const r = toLinear(srgb.r);
68
+ const g = toLinear(srgb.g);
69
+ const b = toLinear(srgb.b);
70
+
71
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
72
+ }
73
+
74
+ /**
75
+ * Calculate WCAG 2.1 contrast ratio between two colors
76
+ */
77
+ export function calculateWCAGContrast(foreground: OKLCH, background: OKLCH): number {
78
+ const l1 = getRelativeLuminance(foreground);
79
+ const l2 = getRelativeLuminance(background);
80
+
81
+ const lighter = Math.max(l1, l2);
82
+ const darker = Math.min(l1, l2);
83
+
84
+ return (lighter + 0.05) / (darker + 0.05);
85
+ }
86
+
87
+ /**
88
+ * Calculate APCA contrast for modern accessibility
89
+ * Uses the official APCA algorithm via apca-w3 library
90
+ */
91
+ export function calculateAPCAContrast(foreground: OKLCH, background: OKLCH): number {
92
+ // Convert OKLCH to RGB arrays for APCA
93
+ const foregroundColor = new Color('oklch', [foreground.l, foreground.c, foreground.h]);
94
+ const backgroundColorObj = new Color('oklch', [background.l, background.c, background.h]);
95
+
96
+ const fgRgb = foregroundColor.to('srgb');
97
+ const bgRgb = backgroundColorObj.to('srgb');
98
+
99
+ // Convert to 0-255 RGB values for apca-w3
100
+ const fgR = Math.max(0, Math.min(255, (fgRgb.r || 0) * 255));
101
+ const fgG = Math.max(0, Math.min(255, (fgRgb.g || 0) * 255));
102
+ const fgB = Math.max(0, Math.min(255, (fgRgb.b || 0) * 255));
103
+ const bgR = Math.max(0, Math.min(255, (bgRgb.r || 0) * 255));
104
+ const bgG = Math.max(0, Math.min(255, (bgRgb.g || 0) * 255));
105
+ const bgB = Math.max(0, Math.min(255, (bgRgb.b || 0) * 255));
106
+
107
+ // Calculate luminance values
108
+ const fgY = sRGBtoY([fgR, fgG, fgB]);
109
+ const bgY = sRGBtoY([bgR, bgG, bgB]);
110
+
111
+ // Use official APCA calculation
112
+ const result = APCAcontrast(fgY, bgY);
113
+ return typeof result === 'number' ? result : 0;
114
+ }
115
+
116
+ /**
117
+ * Check if color pair meets WCAG contrast standards.
118
+ * Module-private: the pass/fail matrices below are its only consumers.
119
+ */
120
+ function meetsWCAGStandard(
121
+ foreground: OKLCH,
122
+ background: OKLCH,
123
+ level: 'AA' | 'AAA',
124
+ textSize: 'normal' | 'large',
125
+ ): boolean {
126
+ const contrast = calculateWCAGContrast(foreground, background);
127
+ return contrast >= WCAG_CONTRAST_THRESHOLDS[level][textSize];
128
+ }
129
+
130
+ /** Pre-computed pairs of scale indices that pass a contrast standard. */
131
+ const ContrastPairsSchema = z.object({
132
+ normal: z.array(z.array(z.number())),
133
+ large: z.array(z.array(z.number())),
134
+ });
135
+
136
+ /** Pre-computed scale indices that pass on a single-color background. */
137
+ const BackgroundComplianceSchema = z.object({
138
+ aa: z.array(z.number()),
139
+ aaa: z.array(z.number()),
140
+ });
141
+
142
+ /**
143
+ * Pre-computed accessibility metadata for a color scale. Indices reference
144
+ * positions in the source scale array.
145
+ */
146
+ export const AccessibilityMetadataSchema = z.object({
147
+ wcagAA: ContrastPairsSchema,
148
+ wcagAAA: ContrastPairsSchema,
149
+ onWhite: BackgroundComplianceSchema,
150
+ onBlack: BackgroundComplianceSchema,
151
+ });
152
+ export type AccessibilityMetadata = z.infer<typeof AccessibilityMetadataSchema>;
153
+
154
+ /**
155
+ * Generate pre-computed accessibility metadata for a color scale
156
+ * Eliminates expensive on-demand contrast calculations by storing all valid pairs as indices
157
+ */
158
+ export function generateAccessibilityMetadata(scale: OKLCH[]): AccessibilityMetadata {
159
+ if (!scale || scale.length === 0) {
160
+ // Return empty metadata for invalid scales
161
+ return {
162
+ wcagAA: { normal: [], large: [] },
163
+ wcagAAA: { normal: [], large: [] },
164
+ onWhite: { aa: [], aaa: [] },
165
+ onBlack: { aa: [], aaa: [] },
166
+ };
167
+ }
168
+
169
+ const metadata: AccessibilityMetadata = {
170
+ wcagAA: { normal: [], large: [] },
171
+ wcagAAA: { normal: [], large: [] },
172
+ onWhite: { aa: [], aaa: [] },
173
+ onBlack: { aa: [], aaa: [] },
174
+ };
175
+
176
+ // Check all pairs within the scale
177
+ for (let i = 0; i < scale.length; i++) {
178
+ for (let j = 0; j < scale.length; j++) {
179
+ if (i === j) continue; // Skip same color pairs
180
+
181
+ const color1 = scale[i];
182
+ const color2 = scale[j];
183
+
184
+ // Skip invalid OKLCH values
185
+ if (!color1 || !color2 || typeof color1.l !== 'number' || typeof color2.l !== 'number') {
186
+ continue;
187
+ }
188
+
189
+ // Check WCAG AA compliance
190
+ if (meetsWCAGStandard(color1, color2, 'AA', 'normal')) {
191
+ metadata.wcagAA.normal.push([i, j]);
192
+ }
193
+ if (meetsWCAGStandard(color1, color2, 'AA', 'large')) {
194
+ metadata.wcagAA.large.push([i, j]);
195
+ }
196
+
197
+ // Check WCAG AAA compliance
198
+ if (meetsWCAGStandard(color1, color2, 'AAA', 'normal')) {
199
+ metadata.wcagAAA.normal.push([i, j]);
200
+ }
201
+ if (meetsWCAGStandard(color1, color2, 'AAA', 'large')) {
202
+ metadata.wcagAAA.large.push([i, j]);
203
+ }
204
+ }
205
+
206
+ // Check against white background
207
+ const colorForWhite = scale[i];
208
+ if (colorForWhite && typeof colorForWhite.l === 'number') {
209
+ if (meetsWCAGStandard(colorForWhite, CONTRAST_WHITE, 'AA', 'normal')) {
210
+ metadata.onWhite.aa.push(i);
211
+ }
212
+ if (meetsWCAGStandard(colorForWhite, CONTRAST_WHITE, 'AAA', 'normal')) {
213
+ metadata.onWhite.aaa.push(i);
214
+ }
215
+ }
216
+
217
+ // Check against black background
218
+ const colorForBlack = scale[i];
219
+ if (colorForBlack && typeof colorForBlack.l === 'number') {
220
+ if (meetsWCAGStandard(colorForBlack, CONTRAST_BLACK, 'AA', 'normal')) {
221
+ metadata.onBlack.aa.push(i);
222
+ }
223
+ if (meetsWCAGStandard(colorForBlack, CONTRAST_BLACK, 'AAA', 'normal')) {
224
+ metadata.onBlack.aaa.push(i);
225
+ }
226
+ }
227
+ }
228
+
229
+ return metadata;
230
+ }
231
+
232
+ /**
233
+ * Assemble the WCAG half of ColorValue.accessibility for a scale: pair
234
+ * matrices plus on-white/on-black ratios and pass flags for a reference
235
+ * color. Shared by buildColorValue (which adds APCA on top) and
236
+ * rebakeAccessibility -- one assembly, two consumers, no drift.
237
+ */
238
+ export function assembleWcagAccessibility(
239
+ scale: readonly OKLCH[],
240
+ reference: OKLCH,
241
+ ): Pick<NonNullable<ColorValue['accessibility']>, 'wcagAA' | 'wcagAAA' | 'onWhite' | 'onBlack'> {
242
+ const meta = generateAccessibilityMetadata([...scale]);
243
+ const onWhiteRatio = calculateWCAGContrast(reference, CONTRAST_WHITE);
244
+ const onBlackRatio = calculateWCAGContrast(reference, CONTRAST_BLACK);
245
+ return {
246
+ wcagAA: meta.wcagAA,
247
+ wcagAAA: meta.wcagAAA,
248
+ onWhite: {
249
+ wcagAA: onWhiteRatio >= WCAG_CONTRAST_THRESHOLDS.AA.normal,
250
+ wcagAAA: onWhiteRatio >= WCAG_CONTRAST_THRESHOLDS.AAA.normal,
251
+ contrastRatio: onWhiteRatio,
252
+ aa: meta.onWhite.aa,
253
+ aaa: meta.onWhite.aaa,
254
+ },
255
+ onBlack: {
256
+ wcagAA: onBlackRatio >= WCAG_CONTRAST_THRESHOLDS.AA.normal,
257
+ wcagAAA: onBlackRatio >= WCAG_CONTRAST_THRESHOLDS.AAA.normal,
258
+ contrastRatio: onBlackRatio,
259
+ aa: meta.onBlack.aa,
260
+ aaa: meta.onBlack.aaa,
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Re-derive accessibility metadata for a ColorValue whose scale changed.
267
+ * A family value supplied without its WCAG pair matrices would starve the
268
+ * contrast/state/invert selection downstream -- this guard re-bakes them.
269
+ * No-op for values without a scale. (Moved from the removed CLI set command;
270
+ * the studio API mutation path is the consumer. #1643)
271
+ */
272
+ export function rebakeAccessibility(value: ColorValue): ColorValue {
273
+ if (!('scale' in value) || !Array.isArray(value.scale)) return value;
274
+ const reference = value.scale[5] ?? value.scale[0];
275
+ if (!reference) return value;
276
+ return { ...value, accessibility: assembleWcagAccessibility(value.scale, reference) };
277
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Color analysis functions for perceptual color properties
3
+ */
4
+
5
+ import type { OKLCH } from '@rafters/shared';
6
+ import { clamp01, normalizeHue, VIVID_CHROMA_CAP } from './internal/math.js';
7
+
8
+ /**
9
+ * Determine if color is light or dark based on perceptual lightness
10
+ * Uses OKLCH lightness with adjustments for chroma
11
+ */
12
+ export function isLightColor(color: OKLCH): boolean {
13
+ // Base threshold at 50% lightness
14
+ let threshold = 0.5;
15
+
16
+ // Adjust threshold based on chroma
17
+ // High chroma colors appear darker due to the Helmholtz-Kohlrausch effect
18
+ if (color.c > 0.15) {
19
+ threshold += color.c * 0.3; // Increase threshold for high chroma
20
+ }
21
+
22
+ // Very low chroma colors are essentially gray, use simple lightness
23
+ if (color.c < 0.05) {
24
+ threshold = 0.5;
25
+ }
26
+
27
+ return color.l > threshold;
28
+ }
29
+
30
+ /**
31
+ * Calculate color temperature (warm/cool/neutral).
32
+ * Based on hue angle and chroma intensity.
33
+ *
34
+ * This is the package's only temperature classifier: everything that needs a
35
+ * warm/cool/neutral verdict (including calculateAtmosphericWeight below) calls
36
+ * it, so two parts of the same ColorValue can never disagree again.
37
+ */
38
+ export function getColorTemperature(color: OKLCH): 'warm' | 'cool' | 'neutral' {
39
+ // Very low chroma colors are neutral regardless of hue
40
+ if (color.c < 0.04) {
41
+ return 'neutral';
42
+ }
43
+
44
+ const hue = normalizeHue(color.h);
45
+
46
+ // Define temperature ranges based on color theory
47
+ // Warm: reds, oranges, yellows, warm purples
48
+ // Cool: greens, cyans, blues, cool purples
49
+
50
+ if (
51
+ (hue >= 0 && hue <= 90) || // Red to yellow
52
+ (hue >= 315 && hue < 360) // Red-purple to red
53
+ ) {
54
+ return 'warm';
55
+ }
56
+
57
+ if (hue >= 150 && hue <= 270) {
58
+ // Green-cyan to blue-purple
59
+ return 'cool';
60
+ }
61
+
62
+ // Boundary zones (yellow-green and purple-red) depend on specific hue
63
+ if (hue > 90 && hue < 150) {
64
+ // Yellow-green to green
65
+ // More yellow = warm, more green = cool
66
+ return hue < 120 ? 'warm' : 'cool';
67
+ }
68
+
69
+ if (hue > 270 && hue < 315) {
70
+ // Purple to red-purple
71
+ // More blue = cool, more red = warm
72
+ return hue > 290 ? 'warm' : 'cool';
73
+ }
74
+
75
+ // Fallback for edge cases
76
+ return 'neutral';
77
+ }
78
+
79
+ /**
80
+ * Colors get cooler and lighter with distance
81
+ * Applied to UI: background colors should be cooler/lighter, foreground warmer/darker
82
+ *
83
+ * Temperature comes from getColorTemperature -- the same verdict that lands in
84
+ * ColorValue.analysis.temperature -- so the two never contradict each other.
85
+ */
86
+ export function calculateAtmosphericWeight(color: OKLCH): {
87
+ distanceWeight: number; // 0 = background, 1 = foreground
88
+ temperature: 'warm' | 'neutral' | 'cool';
89
+ atmosphericRole: 'background' | 'midground' | 'foreground';
90
+ } {
91
+ const temperature = getColorTemperature(color);
92
+
93
+ const lightnessWeight = color.l;
94
+
95
+ let distanceWeight = 0;
96
+
97
+ if (temperature === 'warm') {
98
+ distanceWeight += 0.3;
99
+ } else if (temperature === 'cool') {
100
+ distanceWeight -= 0.2;
101
+ }
102
+
103
+ distanceWeight += (1 - lightnessWeight) * 0.4;
104
+ distanceWeight += color.c * 1.5;
105
+
106
+ distanceWeight = clamp01(distanceWeight);
107
+
108
+ let atmosphericRole: 'background' | 'midground' | 'foreground';
109
+ if (distanceWeight < 0.3) atmosphericRole = 'background';
110
+ else if (distanceWeight < 0.7) atmosphericRole = 'midground';
111
+ else atmosphericRole = 'foreground';
112
+
113
+ return { distanceWeight, temperature, atmosphericRole };
114
+ }
115
+
116
+ /**
117
+ * some colors feel "heavier" than others
118
+ * Used for visual balance in UI layouts
119
+ */
120
+ export function calculatePerceptualWeight(color: OKLCH): {
121
+ weight: number; // 0-1, higher = more visual weight
122
+ density: 'light' | 'medium' | 'heavy';
123
+ balancingRecommendation: string;
124
+ } {
125
+ const hue = color.h;
126
+ let hueWeight = 0.5;
127
+
128
+ if (hue >= 345 || hue <= 15)
129
+ hueWeight = 0.9; // Red - heaviest
130
+ else if (hue <= 45)
131
+ hueWeight = 0.8; // Red-Orange
132
+ else if (hue <= 75)
133
+ hueWeight = 0.6; // Orange-Yellow
134
+ else if (hue <= 105)
135
+ hueWeight = 0.4; // Yellow-Green
136
+ else if (hue <= 165)
137
+ hueWeight = 0.3; // Green - lightest feeling
138
+ else if (hue <= 225)
139
+ hueWeight = 0.2; // Blue - very light feeling
140
+ else if (hue <= 285)
141
+ hueWeight = 0.35; // Blue-Purple
142
+ else hueWeight = 0.5; // Purple-Red
143
+
144
+ const lightnessWeight = 1 - color.l;
145
+ const chromaWeight = Math.min(1, color.c / VIVID_CHROMA_CAP);
146
+
147
+ const weight = lightnessWeight * 0.4 + chromaWeight * 0.35 + hueWeight * 0.25;
148
+
149
+ let density: 'light' | 'medium' | 'heavy';
150
+
151
+ if (weight < 0.3) {
152
+ density = 'light';
153
+ } else if (weight < 0.7) {
154
+ density = 'medium';
155
+ } else {
156
+ density = 'heavy';
157
+ }
158
+
159
+ const balancingRecommendation = 'Balanced weight';
160
+
161
+ return {
162
+ weight,
163
+ density,
164
+ balancingRecommendation,
165
+ };
166
+ }