@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 +21 -0
- package/README.md +169 -0
- package/package.json +44 -0
- package/src/accessibility.ts +277 -0
- package/src/analysis.ts +166 -0
- package/src/apca-w3.d.ts +28 -0
- package/src/builder.ts +164 -0
- package/src/color-wheel.ts +235 -0
- package/src/colorjs.d.ts +36 -0
- package/src/conversion.ts +92 -0
- package/src/gamut.ts +117 -0
- package/src/harmony.ts +194 -0
- package/src/index.ts +110 -0
- package/src/internal/math.ts +35 -0
- package/src/manipulation.ts +16 -0
- package/src/naming/generator.ts +161 -0
- package/src/naming/hue-hubs.ts +2452 -0
- package/src/naming/index.ts +50 -0
- package/src/naming/quantize.ts +85 -0
- package/src/naming/word-banks.ts +154 -0
- package/src/scale-positions.ts +31 -0
- package/src/semantic.ts +397 -0
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
|
+
}
|
package/src/analysis.ts
ADDED
|
@@ -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
|
+
}
|