@transtyle/core 0.1.0-alpha.0
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/package.json +25 -0
- package/src/checks.js +74 -0
- package/src/color.js +272 -0
- package/src/css-colors.js +46 -0
- package/src/derive.js +705 -0
- package/src/diagnostics.js +43 -0
- package/src/diff.js +97 -0
- package/src/index.js +187 -0
- package/src/load.js +136 -0
- package/src/nearest.js +39 -0
- package/src/normalize.js +367 -0
- package/src/schema/config.schema.js +92 -0
- package/src/schema/report.schema.js +65 -0
- package/src/schema/validate.js +93 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@transtyle/core",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "Transtyle core: compilation pipeline (load, normalize, derive, resolve, emit, report).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@transtyle/ir": "0.1.0-alpha.0"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": { "access": "public" },
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/transtyle/transtyle.git",
|
|
20
|
+
"directory": "packages/core"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/transtyle/transtyle#readme",
|
|
23
|
+
"bugs": "https://github.com/transtyle/transtyle/issues",
|
|
24
|
+
"license": "MIT"
|
|
25
|
+
}
|
package/src/checks.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Built-in accessibility/consistency checks (docs/specs/validation-and-coverage.md). */
|
|
2
|
+
|
|
3
|
+
import { contrastRatio } from './color.js';
|
|
4
|
+
|
|
5
|
+
const S = 'semantic.color.';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The foreground/background pairs the compiler contrast-checks. Shared with
|
|
9
|
+
* `transtyle diff`'s contrast-regression flag (diff.js) so "which pairs count"
|
|
10
|
+
* has exactly one definition — a pair added here is checked in both places.
|
|
11
|
+
*/
|
|
12
|
+
export const CONTRAST_PAIRS = [
|
|
13
|
+
['text.base', 'elevation.0.surface'],
|
|
14
|
+
['text.base', 'elevation.1.surface'],
|
|
15
|
+
['text.muted', 'elevation.0.surface'],
|
|
16
|
+
['text.muted', 'elevation.1.surface'],
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Minimum ratio for the configured standard (normal text). */
|
|
20
|
+
export function contrastThreshold(config) {
|
|
21
|
+
return config?.check?.contrast?.standard === 'wcag21-aaa' ? 7 : 4.5;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The pair's ratio in one resolved mode map, or null if either slot is absent. */
|
|
25
|
+
export function pairRatio(map, fg, bg) {
|
|
26
|
+
const f = map.get(S + fg)?.value;
|
|
27
|
+
const b = map.get(S + bg)?.value;
|
|
28
|
+
if (!f || !b) return null;
|
|
29
|
+
return contrastRatio(f, b);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runChecks(normalized, config, diagnostics) {
|
|
33
|
+
const min = contrastThreshold(config);
|
|
34
|
+
const standard = config?.check?.contrast?.standard ?? 'wcag21-aa';
|
|
35
|
+
for (const mode of normalized.modeValues) {
|
|
36
|
+
const map = normalized.modes[mode];
|
|
37
|
+
for (const [fg, bg] of CONTRAST_PAIRS) {
|
|
38
|
+
const ratio = pairRatio(map, fg, bg);
|
|
39
|
+
if (ratio === null) continue;
|
|
40
|
+
if (ratio < min) {
|
|
41
|
+
// AL5: on a design system with no dark-mode values authored, the
|
|
42
|
+
// light values simply carry over — so a light-on-light pair is
|
|
43
|
+
// flagged in dark mode and the warning looks like a mystery about
|
|
44
|
+
// colors the user never wrote. When this mode's pair is byte-identical
|
|
45
|
+
// to the default mode's, that carry-over IS the reason, and saying so
|
|
46
|
+
// is the difference between an actionable warning and noise the user
|
|
47
|
+
// learns to ignore.
|
|
48
|
+
//
|
|
49
|
+
// `derivation.autoDark` is NOT consulted here (or anywhere in the
|
|
50
|
+
// pipeline): it's specced (derivation.md) but not implemented, so the
|
|
51
|
+
// hint below must not suggest it as a working remedy — "opt into
|
|
52
|
+
// autoDark" used to be here and was accurate-sounding but false.
|
|
53
|
+
const defaultMap = normalized.modes[normalized.defaultMode];
|
|
54
|
+
const carried =
|
|
55
|
+
mode === normalized.defaultMode
|
|
56
|
+
? []
|
|
57
|
+
: [fg, bg].filter(
|
|
58
|
+
(p) =>
|
|
59
|
+
JSON.stringify(map.get(`${S}${p}`)?.value) ===
|
|
60
|
+
JSON.stringify(defaultMap?.get(`${S}${p}`)?.value),
|
|
61
|
+
);
|
|
62
|
+
diagnostics.warn(
|
|
63
|
+
'TST2101',
|
|
64
|
+
`${fg} vs ${bg} is ${ratio.toFixed(1)}:1 in ${mode} mode (< ${min}:1 ${standard})`,
|
|
65
|
+
carried.length
|
|
66
|
+
? {
|
|
67
|
+
hint: `${carried.join(' and ')} ${carried.length > 1 ? 'are' : 'is'} unchanged from ${normalized.defaultMode} mode — nothing authors a ${mode} value, so ${carried.length > 1 ? 'they' : 'it'} carried over. Author the ${mode} value.`,
|
|
68
|
+
}
|
|
69
|
+
: {},
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/color.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal OKLCH color engine (zero deps).
|
|
3
|
+
* Internal canonical form: { l, c, h, alpha } — docs/architecture/ir.md#values.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NAMED_COLORS } from './css-colors.js';
|
|
7
|
+
|
|
8
|
+
const OKLCH_RE = /^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/i;
|
|
9
|
+
const FUNC_RE = /^(rgba?|hsla?)\(\s*([^)]*)\s*\)$/i;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse any color syntax a real stylesheet is likely to contain
|
|
13
|
+
* (docs/architecture/ir.md#values): `oklch()`, `#hex` (3/4/6/8 digits),
|
|
14
|
+
* `rgb()`/`rgba()`, `hsl()`/`hsla()`, the CSS named colors, and `transparent`.
|
|
15
|
+
* Both the modern space-separated (`rgb(255 0 0 / 50%)`) and legacy comma
|
|
16
|
+
* (`rgba(255, 0, 0, .5)`) forms are accepted. Everything canonicalizes to OKLCH.
|
|
17
|
+
*/
|
|
18
|
+
export function parseColor(str) {
|
|
19
|
+
if (typeof str !== 'string') throw new Error(`Not a color string: ${JSON.stringify(str)}`);
|
|
20
|
+
const s = str.trim();
|
|
21
|
+
|
|
22
|
+
const m = OKLCH_RE.exec(s);
|
|
23
|
+
if (m) {
|
|
24
|
+
const num = (v) => (v.endsWith('%') ? parseFloat(v) / 100 : parseFloat(v));
|
|
25
|
+
return { l: num(m[1]), c: parseFloat(m[2]), h: parseFloat(m[3]), alpha: m[4] ? num(m[4]) : 1 };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s)) {
|
|
29
|
+
const { alpha, ...rgb } = hexToSrgb(s);
|
|
30
|
+
return srgbToOklch(rgb, alpha);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fn = FUNC_RE.exec(s);
|
|
34
|
+
if (fn) {
|
|
35
|
+
const kind = fn[1].toLowerCase();
|
|
36
|
+
const { parts, alpha } = splitComponents(fn[2]);
|
|
37
|
+
if (parts.length < 3) throw new Error(`Malformed ${kind}() color: ${s}`);
|
|
38
|
+
const a = parseAlpha(alpha);
|
|
39
|
+
if (kind.startsWith('rgb')) {
|
|
40
|
+
const ch = (v) => (v === 'none' ? 0 : v.endsWith('%') ? parseFloat(v) / 100 : parseFloat(v) / 255);
|
|
41
|
+
const rgb = { r: ch(parts[0]), g: ch(parts[1]), b: ch(parts[2]) };
|
|
42
|
+
if (Object.values(rgb).some(Number.isNaN)) throw new Error(`Malformed ${kind}() color: ${s}`);
|
|
43
|
+
return srgbToOklch(rgb, a);
|
|
44
|
+
}
|
|
45
|
+
const h = parseHue(parts[0]);
|
|
46
|
+
const pct = (v) => (v === 'none' ? 0 : parseFloat(v) / 100);
|
|
47
|
+
const sat = pct(parts[1]), lig = pct(parts[2]);
|
|
48
|
+
if ([h, sat, lig].some(Number.isNaN)) throw new Error(`Malformed ${kind}() color: ${s}`);
|
|
49
|
+
return srgbToOklch(hslToSrgb(h, sat, lig), a);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lower = s.toLowerCase();
|
|
53
|
+
if (lower === 'transparent') return { l: 0, c: 0, h: 0, alpha: 0 };
|
|
54
|
+
if (NAMED_COLORS[lower]) {
|
|
55
|
+
const { alpha, ...rgb } = hexToSrgb(NAMED_COLORS[lower]);
|
|
56
|
+
return srgbToOklch(rgb, alpha);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
throw new Error(`Unsupported color syntax: ${s} (expected oklch(), #hex, rgb(), hsl(), or a CSS named color)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Split a function body into 3 components + optional alpha, modern or legacy form. */
|
|
63
|
+
function splitComponents(inner) {
|
|
64
|
+
const body = inner.trim();
|
|
65
|
+
const slash = body.indexOf('/');
|
|
66
|
+
if (slash !== -1) {
|
|
67
|
+
return { parts: body.slice(0, slash).trim().split(/\s+/), alpha: body.slice(slash + 1).trim() };
|
|
68
|
+
}
|
|
69
|
+
if (body.includes(',')) {
|
|
70
|
+
const all = body.split(',').map((p) => p.trim());
|
|
71
|
+
return { parts: all.slice(0, 3), alpha: all[3] };
|
|
72
|
+
}
|
|
73
|
+
return { parts: body.split(/\s+/), alpha: undefined };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const parseAlpha = (v) => {
|
|
77
|
+
if (v === undefined || v === '' || v === 'none') return 1;
|
|
78
|
+
const n = v.endsWith('%') ? parseFloat(v) / 100 : parseFloat(v);
|
|
79
|
+
return Number.isNaN(n) ? 1 : Math.min(1, Math.max(0, n));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** CSS <angle> → degrees (deg/rad/grad/turn, or unitless = deg). */
|
|
83
|
+
function parseHue(v) {
|
|
84
|
+
const m = /^(-?[\d.]+)(deg|rad|grad|turn)?$/i.exec(v.trim());
|
|
85
|
+
if (!m) return NaN;
|
|
86
|
+
const n = parseFloat(m[1]);
|
|
87
|
+
switch ((m[2] ?? 'deg').toLowerCase()) {
|
|
88
|
+
case 'rad': return (n * 180) / Math.PI;
|
|
89
|
+
case 'grad': return n * 0.9;
|
|
90
|
+
case 'turn': return n * 360;
|
|
91
|
+
default: return n;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hslToSrgb(hDeg, s, l) {
|
|
96
|
+
const h = ((hDeg % 360) + 360) % 360;
|
|
97
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
98
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
99
|
+
const m = l - c / 2;
|
|
100
|
+
const [r, g, b] =
|
|
101
|
+
h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
|
|
102
|
+
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
|
103
|
+
return { r: r + m, g: g + m, b: b + m };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatColor({ l, c, h, alpha = 1 }) {
|
|
107
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
108
|
+
const L = Math.min(1, Math.max(0, r3(l)));
|
|
109
|
+
let C = Math.max(0, r3(c));
|
|
110
|
+
let H = Math.round(h * 10) / 10;
|
|
111
|
+
if (C < 0.002) { C = 0; H = 0; } // achromatic: drop meaningless hue
|
|
112
|
+
const a = alpha < 1 ? ` / ${r3(alpha)}` : '';
|
|
113
|
+
return `oklch(${L} ${C} ${H}${a})`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Mix a toward b by t (0 = a, 1 = b), in OKLCH with shortest-path hue. */
|
|
117
|
+
export function mix(a, b, t) {
|
|
118
|
+
// Cartesian OKLab interpolation (derivation.md, pinned by exercise F21).
|
|
119
|
+
// Polar hue lerp passes through unrelated hues at moderate ratios (an amber
|
|
120
|
+
// border tint on a blue-cast surface must not travel through cyan).
|
|
121
|
+
const lerp = (x, y) => x + (y - x) * t;
|
|
122
|
+
const rad = Math.PI / 180;
|
|
123
|
+
const A = lerp(a.c * Math.cos(a.h * rad), b.c * Math.cos(b.h * rad));
|
|
124
|
+
const B = lerp(a.c * Math.sin(a.h * rad), b.c * Math.sin(b.h * rad));
|
|
125
|
+
const c = Math.sqrt(A * A + B * B);
|
|
126
|
+
const h = c < 1e-9 ? 0 : (Math.atan2(B, A) / rad + 360) % 360;
|
|
127
|
+
return { l: lerp(a.l, b.l), c, h, alpha: lerp(a.alpha ?? 1, b.alpha ?? 1) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- OKLab <-> linear sRGB (Björn Ottosson's matrices) ----
|
|
131
|
+
|
|
132
|
+
function oklchToLinearSrgb({ l, c, h }) {
|
|
133
|
+
const hr = (h * Math.PI) / 180;
|
|
134
|
+
const a = c * Math.cos(hr);
|
|
135
|
+
const b = c * Math.sin(hr);
|
|
136
|
+
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
137
|
+
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
138
|
+
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
139
|
+
return {
|
|
140
|
+
r: 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
|
141
|
+
g: -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
|
142
|
+
b: -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** #rgb / #rgba / #rrggbb / #rrggbbaa → sRGB (+ alpha). */
|
|
147
|
+
function hexToSrgb(hex) {
|
|
148
|
+
let s = hex.slice(1);
|
|
149
|
+
if (s.length === 3 || s.length === 4) s = s.split('').map((ch) => ch + ch).join('');
|
|
150
|
+
const n = parseInt(s.slice(0, 6), 16);
|
|
151
|
+
const alpha = s.length === 8 ? parseInt(s.slice(6, 8), 16) / 255 : 1;
|
|
152
|
+
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, alpha };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function srgbToLinear(v) {
|
|
156
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function srgbToOklch({ r, g, b }, alpha = 1) {
|
|
160
|
+
const lr = srgbToLinear(r), lg = srgbToLinear(g), lb = srgbToLinear(b);
|
|
161
|
+
const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
162
|
+
const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
163
|
+
const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
164
|
+
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
|
|
165
|
+
const A = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
|
|
166
|
+
const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
|
|
167
|
+
const c = Math.hypot(A, B);
|
|
168
|
+
const h = ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
|
|
169
|
+
return { l: L, c, h, alpha };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const linearToSrgb = (v) => (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055);
|
|
173
|
+
|
|
174
|
+
const inSrgbGamut = ({ r, g, b }) => r >= -0.0005 && r <= 1.0005 && g >= -0.0005 && g <= 1.0005 && b >= -0.0005 && b <= 1.0005;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Reduce chroma at a fixed lightness/hue until the color lands inside the
|
|
178
|
+
* sRGB gamut (binary search; ~20 steps is well under float precision).
|
|
179
|
+
* Keeps `l` and `h` exactly as given — only `c` moves — because rules like
|
|
180
|
+
* F20 (contrast-anchor) pick `l`/`h` for a reason (contrast, brand hue) and
|
|
181
|
+
* should not have those silently perturbed by channel-clipping downstream.
|
|
182
|
+
* A no-op when the color is already in gamut.
|
|
183
|
+
*/
|
|
184
|
+
export function clampChromaToGamut({ l, c, h, alpha = 1 }) {
|
|
185
|
+
if (c <= 0 || inSrgbGamut(oklchToLinearSrgb({ l, c, h }))) return { l, c, h, alpha };
|
|
186
|
+
let lo = 0, hi = c;
|
|
187
|
+
for (let i = 0; i < 20; i++) {
|
|
188
|
+
const mid = (lo + hi) / 2;
|
|
189
|
+
if (inSrgbGamut(oklchToLinearSrgb({ l, c: mid, h }))) lo = mid;
|
|
190
|
+
else hi = mid;
|
|
191
|
+
}
|
|
192
|
+
return { l, c: lo, h, alpha };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* OKLCH → gamma-encoded sRGB. `clamped` is true when the color was outside the
|
|
197
|
+
* sRGB gamut and had to be clamped (coverage class: approximated).
|
|
198
|
+
*/
|
|
199
|
+
export function oklchToSrgb(color) {
|
|
200
|
+
const lin = oklchToLinearSrgb(color);
|
|
201
|
+
let clamped = false;
|
|
202
|
+
const enc = (v) => {
|
|
203
|
+
if (v < -0.005 || v > 1.005) clamped = true;
|
|
204
|
+
return linearToSrgb(Math.min(1, Math.max(0, v)));
|
|
205
|
+
};
|
|
206
|
+
return { r: enc(lin.r), g: enc(lin.g), b: enc(lin.b), clamped };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Format as an HSL channel triplet ("221 83% 53%") — the shadcn tailwind-v3
|
|
211
|
+
* convention (wrapped by components as hsl(var(--x))).
|
|
212
|
+
*/
|
|
213
|
+
export function formatHslTriplet(color) {
|
|
214
|
+
// Near-achromatic: drop the meaningless hue/saturation before conversion,
|
|
215
|
+
// otherwise rounding noise yields absurd triplets like "180 100% 99.9%".
|
|
216
|
+
const input = color.c < 0.002 ? { ...color, c: 0 } : color;
|
|
217
|
+
const { r, g, b, clamped } = oklchToSrgb(input);
|
|
218
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
219
|
+
const l = (max + min) / 2;
|
|
220
|
+
const d = max - min;
|
|
221
|
+
let h = 0, s = 0;
|
|
222
|
+
if (d > 1e-6) {
|
|
223
|
+
s = d / (1 - Math.abs(2 * l - 1));
|
|
224
|
+
if (max === r) h = ((g - b) / d) % 6;
|
|
225
|
+
else if (max === g) h = (b - r) / d + 2;
|
|
226
|
+
else h = (r - g) / d + 4;
|
|
227
|
+
h = (h * 60 + 360) % 360;
|
|
228
|
+
}
|
|
229
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
230
|
+
return { text: `${r1(h)} ${r1(s * 100)}% ${r1(l * 100)}%`, clamped };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Format as #rrggbb hex (canvas-friendly: ECharts). `clamped` mirrors oklchToSrgb.
|
|
235
|
+
*
|
|
236
|
+
* Round-trip note (measured exhaustively, guarded by scripts/check-color.mjs):
|
|
237
|
+
* `formatHex(parseColor(hex))` returns the input for all but 1580 of the
|
|
238
|
+
* 16,777,216 sRGB values (0.0094%), which come back ±1/255. Those all sit in the
|
|
239
|
+
* near-black range, where sRGB's transfer curve is steepest relative to an 8-bit
|
|
240
|
+
* step; it is inherent to canonicalizing through OKLCH in float64. Determinism is
|
|
241
|
+
* unaffected — the same input always yields the same output.
|
|
242
|
+
*/
|
|
243
|
+
export function formatHex(color) {
|
|
244
|
+
const input = color.c < 0.002 ? { ...color, c: 0 } : color;
|
|
245
|
+
const { r, g, b, clamped } = oklchToSrgb(input);
|
|
246
|
+
const h2 = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
|
|
247
|
+
return { text: `#${h2(r)}${h2(g)}${h2(b)}`, clamped };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** WCAG 2.1 relative luminance (via linear sRGB, gamut-clamped). */
|
|
251
|
+
export function relativeLuminance(color) {
|
|
252
|
+
const { r, g, b } = oklchToLinearSrgb(color);
|
|
253
|
+
const cl = (v) => Math.min(1, Math.max(0, v));
|
|
254
|
+
return 0.2126 * cl(r) + 0.7152 * cl(g) + 0.0722 * cl(b);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** WCAG 2.1 contrast ratio between two colors (order-independent). */
|
|
258
|
+
export function contrastRatio(a, b) {
|
|
259
|
+
const ya = relativeLuminance(a), yb = relativeLuminance(b);
|
|
260
|
+
const [hi, lo] = ya >= yb ? [ya, yb] : [yb, ya];
|
|
261
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Pick the candidate with the highest contrast against bg. Returns { color, ratio, index }. */
|
|
265
|
+
export function contrastPick(bg, candidates) {
|
|
266
|
+
let best = null;
|
|
267
|
+
candidates.forEach((cand, index) => {
|
|
268
|
+
const ratio = contrastRatio(bg, cand);
|
|
269
|
+
if (!best || ratio > best.ratio) best = { color: cand, ratio, index };
|
|
270
|
+
});
|
|
271
|
+
return best;
|
|
272
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CSS Color Module Level 4 named colors (plus `transparent`), as sRGB hex.
|
|
3
|
+
*
|
|
4
|
+
* Kept as data in its own module so color.js stays readable. Real products use
|
|
5
|
+
* these verbatim — the P4 hostile-adoption experiment hit a hard stop on
|
|
6
|
+
* Miniflux's literal `red` and `purple` (docs/findings/hostile-adoption.md).
|
|
7
|
+
*/
|
|
8
|
+
export const NAMED_COLORS = {
|
|
9
|
+
aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4',
|
|
10
|
+
azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000',
|
|
11
|
+
blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a',
|
|
12
|
+
burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00', chocolate: '#d2691e',
|
|
13
|
+
coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c',
|
|
14
|
+
cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b',
|
|
15
|
+
darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b',
|
|
16
|
+
darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', darkorchid: '#9932cc',
|
|
17
|
+
darkred: '#8b0000', darksalmon: '#e9967a', darkseagreen: '#8fbc8f', darkslateblue: '#483d8b',
|
|
18
|
+
darkslategray: '#2f4f4f', darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3',
|
|
19
|
+
deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969',
|
|
20
|
+
dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', forestgreen: '#228b22',
|
|
21
|
+
fuchsia: '#ff00ff', gainsboro: '#dcdcdc', ghostwhite: '#f8f8ff', gold: '#ffd700',
|
|
22
|
+
goldenrod: '#daa520', gray: '#808080', green: '#008000', greenyellow: '#adff2f',
|
|
23
|
+
grey: '#808080', honeydew: '#f0fff0', hotpink: '#ff69b4', indianred: '#cd5c5c',
|
|
24
|
+
indigo: '#4b0082', ivory: '#fffff0', khaki: '#f0e68c', lavender: '#e6e6fa',
|
|
25
|
+
lavenderblush: '#fff0f5', lawngreen: '#7cfc00', lemonchiffon: '#fffacd', lightblue: '#add8e6',
|
|
26
|
+
lightcoral: '#f08080', lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3',
|
|
27
|
+
lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', lightsalmon: '#ffa07a',
|
|
28
|
+
lightseagreen: '#20b2aa', lightskyblue: '#87cefa', lightslategray: '#778899', lightslategrey: '#778899',
|
|
29
|
+
lightsteelblue: '#b0c4de', lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32',
|
|
30
|
+
linen: '#faf0e6', magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa',
|
|
31
|
+
mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', mediumseagreen: '#3cb371',
|
|
32
|
+
mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', mediumturquoise: '#48d1cc', mediumvioletred: '#c71585',
|
|
33
|
+
midnightblue: '#191970', mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5',
|
|
34
|
+
navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000',
|
|
35
|
+
olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6',
|
|
36
|
+
palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', palevioletred: '#db7093',
|
|
37
|
+
papayawhip: '#ffefd5', peachpuff: '#ffdab9', peru: '#cd853f', pink: '#ffc0cb',
|
|
38
|
+
plum: '#dda0dd', powderblue: '#b0e0e6', purple: '#800080', rebeccapurple: '#663399',
|
|
39
|
+
red: '#ff0000', rosybrown: '#bc8f8f', royalblue: '#4169e1', saddlebrown: '#8b4513',
|
|
40
|
+
salmon: '#fa8072', sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee',
|
|
41
|
+
sienna: '#a0522d', silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd',
|
|
42
|
+
slategray: '#708090', slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f',
|
|
43
|
+
steelblue: '#4682b4', tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8',
|
|
44
|
+
tomato: '#ff6347', turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3',
|
|
45
|
+
white: '#ffffff', whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32',
|
|
46
|
+
};
|