@spotkit/mwala 0.0.0-stage → 0.0.1
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 +17 -0
- package/README.md +93 -2
- package/dist/index.cjs +380 -0
- package/dist/index.d.cts +112 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +328 -0
- package/package.json +37 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MeloSpot
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Mwala
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Color intelligence, accessibility and design foundations for the web.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @spotkit/mwala
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
generateScale,
|
|
16
|
+
checkContrast,
|
|
17
|
+
createTheme,
|
|
18
|
+
themeToCss,
|
|
19
|
+
} from '@spotkit/mwala';
|
|
20
|
+
|
|
21
|
+
const palette = generateScale('#D74D0F');
|
|
22
|
+
console.log(palette[500]);
|
|
23
|
+
|
|
24
|
+
const contrast = checkContrast('#171717', '#FFFFFF');
|
|
25
|
+
console.log(contrast.ratio, contrast.passesAA);
|
|
26
|
+
|
|
27
|
+
const theme = createTheme({ primary: '#D74D0F', mode: 'light' });
|
|
28
|
+
console.log(themeToCss(theme));
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
### Color conversion
|
|
34
|
+
- `normalizeHex(hex)` — normalize `#RGB` or `#RRGGBB` to lowercase six-digit hex.
|
|
35
|
+
- `hexToRgb(hex)`, `hexToRgba(hex)`, `rgbToHex(rgb)`
|
|
36
|
+
- `hexToHsl(hex)`, `hslToHex(hsl)`, `rgbToHsl(rgb)`, `hslToRgb(hsl)`
|
|
37
|
+
- `adjustLightness(hex, amount)` — adjust HSL lightness by a percentage-point amount.
|
|
38
|
+
- `mix(a, b, amount)` — mix two colors in sRGB.
|
|
39
|
+
- `composite(foreground, background)` — composite a translucent color over an opaque one.
|
|
40
|
+
|
|
41
|
+
### Palettes
|
|
42
|
+
- `generateScale(seed)` — generate a hue-preserving 50–950 palette scale.
|
|
43
|
+
- `generateHarmony(seed, harmony)` — generate `complementary`, `analogous`, `triadic`, or `split-complementary` colors.
|
|
44
|
+
|
|
45
|
+
### Contrast
|
|
46
|
+
- `relativeLuminance(hex)`
|
|
47
|
+
- `contrastRatio(foreground, background)`
|
|
48
|
+
- `checkContrast(foreground, background)` — returns the ratio and WCAG AA/AAA pass flags. `passesAA` uses 4.5:1; `passesAALarge` uses 3:1; `passesAAA` uses 7:1.
|
|
49
|
+
- `worstContrast(foreground, backgrounds)` — the lowest ratio against any background, useful for gradients and layered surfaces.
|
|
50
|
+
- `meetsContrast(foreground, backgrounds, standard?)` — `standard` is `AA` (4.5:1), `AA-large` (3:1) or `AAA` (7:1).
|
|
51
|
+
|
|
52
|
+
### Contrast suggestions
|
|
53
|
+
- `ensureContrast(foreground, backgrounds, options?)` — return the closest color that passes. By default it keeps hue and saturation and only moves lightness, so the suggestion still looks like the user's color. Pass `preserveHue: false` to allow a black/white fallback.
|
|
54
|
+
- `suggestReadable(backgrounds, candidates?, options?)` — suggest a passing color from the user's existing palette, preferring the hue-preserving version of `preferred`.
|
|
55
|
+
- Both return `{ color, ratio, passes, changed }`.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { ensureContrast, suggestReadable } from '@spotkit/mwala';
|
|
59
|
+
|
|
60
|
+
// A gold title on white becomes a readable dark gold, not black.
|
|
61
|
+
const title = ensureContrast('#FFD700', ['#FFFFFF']);
|
|
62
|
+
console.log(title.color, title.ratio); // e.g. #8a7400 4.59
|
|
63
|
+
|
|
64
|
+
// Pass the user's palette too. With preserveHue: false, a palette colour
|
|
65
|
+
// can win when it is closer than black or white.
|
|
66
|
+
const text = suggestReadable(['#FFFFFF'], ['#6B4E00', '#EEEEEE'], {
|
|
67
|
+
preferred: '#EEEEEE',
|
|
68
|
+
preserveHue: false,
|
|
69
|
+
});
|
|
70
|
+
console.log(text.color); // #6b4e00
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Themes
|
|
74
|
+
- `createTheme({ primary, mode })` — create a semantic light or dark theme.
|
|
75
|
+
- `readableForeground(background)` — choose black or white by contrast ratio.
|
|
76
|
+
- `readableOn(...backgrounds)` — choose black or white for several backgrounds at once.
|
|
77
|
+
- `themeToCssVariables(theme, prefix?)` and `themeToCss(theme, selector?, prefix?)`.
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm install
|
|
83
|
+
npm test
|
|
84
|
+
npm run typecheck
|
|
85
|
+
npm run build
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Status
|
|
89
|
+
|
|
90
|
+
Early development (`0.0.1`). The palette scale is intentionally simple and deterministic; perceptual color spaces (OKLCH), gamut mapping, color-blindness simulation, and advanced theme generation are potential future additions.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
CONTRAST_MINIMUM: () => CONTRAST_MINIMUM,
|
|
24
|
+
adjustLightness: () => adjustLightness,
|
|
25
|
+
checkContrast: () => checkContrast,
|
|
26
|
+
composite: () => composite,
|
|
27
|
+
contrastRatio: () => contrastRatio,
|
|
28
|
+
createTheme: () => createTheme,
|
|
29
|
+
ensureContrast: () => ensureContrast,
|
|
30
|
+
generateHarmony: () => generateHarmony,
|
|
31
|
+
generateScale: () => generateScale,
|
|
32
|
+
hexToHsl: () => hexToHsl,
|
|
33
|
+
hexToRgb: () => hexToRgb,
|
|
34
|
+
hexToRgba: () => hexToRgba,
|
|
35
|
+
hslToHex: () => hslToHex,
|
|
36
|
+
hslToRgb: () => hslToRgb,
|
|
37
|
+
meetsContrast: () => meetsContrast,
|
|
38
|
+
mix: () => mix,
|
|
39
|
+
normalizeHex: () => normalizeHex,
|
|
40
|
+
readableForeground: () => readableForeground,
|
|
41
|
+
readableOn: () => readableOn,
|
|
42
|
+
relativeLuminance: () => relativeLuminance,
|
|
43
|
+
rgbToHex: () => rgbToHex,
|
|
44
|
+
rgbToHsl: () => rgbToHsl,
|
|
45
|
+
suggestReadable: () => suggestReadable,
|
|
46
|
+
themeToCss: () => themeToCss,
|
|
47
|
+
themeToCssVariables: () => themeToCssVariables,
|
|
48
|
+
worstContrast: () => worstContrast
|
|
49
|
+
});
|
|
50
|
+
module.exports = __toCommonJS(index_exports);
|
|
51
|
+
|
|
52
|
+
// src/color/parse.ts
|
|
53
|
+
function normalizeHex(input) {
|
|
54
|
+
const value = input.trim().replace(/^#/, "");
|
|
55
|
+
if (/^[\da-fA-F]{3}$/.test(value)) {
|
|
56
|
+
return `#${[...value].map((char) => char + char).join("").toLowerCase()}`;
|
|
57
|
+
}
|
|
58
|
+
if (/^[\da-fA-F]{6}$/.test(value)) return `#${value.toLowerCase()}`;
|
|
59
|
+
throw new Error(`Invalid hex color: "${input}". Expected #RGB or #RRGGBB.`);
|
|
60
|
+
}
|
|
61
|
+
function hexToRgba(hex) {
|
|
62
|
+
const value = hex.trim().replace(/^#/, "");
|
|
63
|
+
const expanded = /^[\da-f]{3,4}$/i.test(value) ? [...value].map((char) => char + char).join("") : value;
|
|
64
|
+
if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(expanded)) {
|
|
65
|
+
throw new Error(`Invalid hex color: "${hex}". Expected #RGB, #RGBA, #RRGGBB or #RRGGBBAA.`);
|
|
66
|
+
}
|
|
67
|
+
const int = Number.parseInt(expanded.slice(0, 6), 16);
|
|
68
|
+
return {
|
|
69
|
+
r: int >> 16 & 255,
|
|
70
|
+
g: int >> 8 & 255,
|
|
71
|
+
b: int & 255,
|
|
72
|
+
a: expanded.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function hexToRgb(hex) {
|
|
76
|
+
const { r, g, b } = hexToRgba(normalizeHex(hex));
|
|
77
|
+
return { r, g, b };
|
|
78
|
+
}
|
|
79
|
+
function rgbToHex({ r, g, b }) {
|
|
80
|
+
const channels = [r, g, b];
|
|
81
|
+
if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) {
|
|
82
|
+
throw new Error("RGB channels must be finite numbers between 0 and 255.");
|
|
83
|
+
}
|
|
84
|
+
return `#${channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/color/convert.ts
|
|
88
|
+
function rgbToHsl({ r, g, b }) {
|
|
89
|
+
const red = r / 255;
|
|
90
|
+
const green = g / 255;
|
|
91
|
+
const blue = b / 255;
|
|
92
|
+
const max = Math.max(red, green, blue);
|
|
93
|
+
const min = Math.min(red, green, blue);
|
|
94
|
+
const delta = max - min;
|
|
95
|
+
let h = 0;
|
|
96
|
+
const l = (max + min) / 2;
|
|
97
|
+
let s = 0;
|
|
98
|
+
if (delta !== 0) {
|
|
99
|
+
s = delta / (1 - Math.abs(2 * l - 1));
|
|
100
|
+
switch (max) {
|
|
101
|
+
case red:
|
|
102
|
+
h = (green - blue) / delta % 6;
|
|
103
|
+
break;
|
|
104
|
+
case green:
|
|
105
|
+
h = (blue - red) / delta + 2;
|
|
106
|
+
break;
|
|
107
|
+
default:
|
|
108
|
+
h = (red - green) / delta + 4;
|
|
109
|
+
}
|
|
110
|
+
h *= 60;
|
|
111
|
+
if (h < 0) h += 360;
|
|
112
|
+
}
|
|
113
|
+
return { h, s: s * 100, l: l * 100 };
|
|
114
|
+
}
|
|
115
|
+
function hslToRgb({ h, s, l }) {
|
|
116
|
+
if (![h, s, l].every(Number.isFinite) || s < 0 || s > 100 || l < 0 || l > 100) {
|
|
117
|
+
throw new Error("HSL requires finite hue and saturation/lightness between 0 and 100.");
|
|
118
|
+
}
|
|
119
|
+
const hue = (h % 360 + 360) % 360;
|
|
120
|
+
const saturation = s / 100;
|
|
121
|
+
const lightness = l / 100;
|
|
122
|
+
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
123
|
+
const x = chroma * (1 - Math.abs(hue / 60 % 2 - 1));
|
|
124
|
+
const m = lightness - chroma / 2;
|
|
125
|
+
let channels;
|
|
126
|
+
if (hue < 60) channels = [chroma, x, 0];
|
|
127
|
+
else if (hue < 120) channels = [x, chroma, 0];
|
|
128
|
+
else if (hue < 180) channels = [0, chroma, x];
|
|
129
|
+
else if (hue < 240) channels = [0, x, chroma];
|
|
130
|
+
else if (hue < 300) channels = [x, 0, chroma];
|
|
131
|
+
else channels = [chroma, 0, x];
|
|
132
|
+
return {
|
|
133
|
+
r: Math.round((channels[0] + m) * 255),
|
|
134
|
+
g: Math.round((channels[1] + m) * 255),
|
|
135
|
+
b: Math.round((channels[2] + m) * 255)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function hexToHsl(hex) {
|
|
139
|
+
return rgbToHsl(hexToRgb(hex));
|
|
140
|
+
}
|
|
141
|
+
function hslToHex(hsl) {
|
|
142
|
+
return rgbToHex(hslToRgb(hsl));
|
|
143
|
+
}
|
|
144
|
+
function adjustLightness(hex, amount2) {
|
|
145
|
+
if (!Number.isFinite(amount2) || amount2 < -100 || amount2 > 100) {
|
|
146
|
+
throw new Error("Lightness adjustment must be between -100 and 100.");
|
|
147
|
+
}
|
|
148
|
+
const hsl = hexToHsl(normalizeHex(hex));
|
|
149
|
+
return hslToHex({ ...hsl, l: Math.min(100, Math.max(0, hsl.l + amount2)) });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/color/mix.ts
|
|
153
|
+
function amount(value) {
|
|
154
|
+
if (!Number.isFinite(value)) throw new Error("Mix amount must be a finite number.");
|
|
155
|
+
return Math.min(1, Math.max(0, value));
|
|
156
|
+
}
|
|
157
|
+
function mix(a, b, weight) {
|
|
158
|
+
const t = amount(weight);
|
|
159
|
+
const first = hexToRgba(a);
|
|
160
|
+
const second = hexToRgba(b);
|
|
161
|
+
return rgbToHex({
|
|
162
|
+
r: first.r + (second.r - first.r) * t,
|
|
163
|
+
g: first.g + (second.g - first.g) * t,
|
|
164
|
+
b: first.b + (second.b - first.b) * t
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function composite(foreground, background) {
|
|
168
|
+
const fg = hexToRgba(foreground);
|
|
169
|
+
const bg = hexToRgba(background);
|
|
170
|
+
return rgbToHex({
|
|
171
|
+
r: fg.r * fg.a + bg.r * (1 - fg.a),
|
|
172
|
+
g: fg.g * fg.a + bg.g * (1 - fg.a),
|
|
173
|
+
b: fg.b * fg.a + bg.b * (1 - fg.a)
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/contrast/wcag.ts
|
|
178
|
+
function linearize(channel) {
|
|
179
|
+
const value = channel / 255;
|
|
180
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
181
|
+
}
|
|
182
|
+
function relativeLuminance(hex) {
|
|
183
|
+
const { r, g, b } = hexToRgb(normalizeHex(hex));
|
|
184
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
185
|
+
}
|
|
186
|
+
function contrastRatio(foreground, background) {
|
|
187
|
+
const first = relativeLuminance(foreground);
|
|
188
|
+
const second = relativeLuminance(background);
|
|
189
|
+
const lighter = Math.max(first, second);
|
|
190
|
+
const darker = Math.min(first, second);
|
|
191
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
192
|
+
}
|
|
193
|
+
function checkContrast(foreground, background) {
|
|
194
|
+
const ratio = contrastRatio(foreground, background);
|
|
195
|
+
return {
|
|
196
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
197
|
+
level: ratio >= 7 ? "AAA" : ratio >= 4.5 ? "AA" : ratio >= 3 ? "AA-large" : "fail",
|
|
198
|
+
passesAA: ratio >= 4.5,
|
|
199
|
+
passesAALarge: ratio >= 3,
|
|
200
|
+
passesAAA: ratio >= 7
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/contrast/advise.ts
|
|
205
|
+
var CONTRAST_MINIMUM = {
|
|
206
|
+
"AA-large": 3,
|
|
207
|
+
AA: 4.5,
|
|
208
|
+
AAA: 7
|
|
209
|
+
};
|
|
210
|
+
function firstBackground(backgrounds) {
|
|
211
|
+
const first = backgrounds[0];
|
|
212
|
+
if (first === void 0) throw new Error("At least one background is required.");
|
|
213
|
+
return first;
|
|
214
|
+
}
|
|
215
|
+
function worstContrast(foreground, backgrounds) {
|
|
216
|
+
const fg = normalizeHex(foreground);
|
|
217
|
+
let worst = { ratio: Number.POSITIVE_INFINITY, background: normalizeHex(firstBackground(backgrounds)) };
|
|
218
|
+
for (const background of backgrounds) {
|
|
219
|
+
const normalized = normalizeHex(background);
|
|
220
|
+
const ratio = contrastRatio(fg, normalized);
|
|
221
|
+
if (ratio < worst.ratio) worst = { ratio, background: normalized };
|
|
222
|
+
}
|
|
223
|
+
return worst;
|
|
224
|
+
}
|
|
225
|
+
function meetsContrast(foreground, backgrounds, standard = "AA") {
|
|
226
|
+
return worstContrast(foreground, backgrounds).ratio >= CONTRAST_MINIMUM[standard];
|
|
227
|
+
}
|
|
228
|
+
function distance(a, b) {
|
|
229
|
+
const first = hexToRgb(a);
|
|
230
|
+
const second = hexToRgb(b);
|
|
231
|
+
return (first.r - second.r) ** 2 + (first.g - second.g) ** 2 + (first.b - second.b) ** 2;
|
|
232
|
+
}
|
|
233
|
+
function ensureContrast(foreground, backgrounds, options = {}) {
|
|
234
|
+
const standard = options.standard ?? "AA";
|
|
235
|
+
const minimum = CONTRAST_MINIMUM[standard];
|
|
236
|
+
const base = normalizeHex(foreground);
|
|
237
|
+
const current = worstContrast(base, backgrounds);
|
|
238
|
+
if (current.ratio >= minimum) {
|
|
239
|
+
return { color: base, ratio: current.ratio, passes: true, changed: false };
|
|
240
|
+
}
|
|
241
|
+
if (options.preserveHue !== false) {
|
|
242
|
+
const { h, s, l } = hexToHsl(base);
|
|
243
|
+
for (let step = 1; step <= 100; step += 1) {
|
|
244
|
+
const shades = [
|
|
245
|
+
hslToHex({ h, s, l: Math.max(0, l - step) }),
|
|
246
|
+
hslToHex({ h, s, l: Math.min(100, l + step) })
|
|
247
|
+
];
|
|
248
|
+
let best2;
|
|
249
|
+
for (const shade of shades) {
|
|
250
|
+
const ratio = worstContrast(shade, backgrounds).ratio;
|
|
251
|
+
if (ratio >= minimum && (best2 === void 0 || ratio > best2.ratio)) {
|
|
252
|
+
best2 = { color: shade, ratio };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (best2 !== void 0) return { ...best2, passes: true, changed: best2.color !== base };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const black = { color: "#000000", ratio: worstContrast("#000000", backgrounds).ratio };
|
|
259
|
+
const white = { color: "#ffffff", ratio: worstContrast("#ffffff", backgrounds).ratio };
|
|
260
|
+
const best = black.ratio >= white.ratio ? black : white;
|
|
261
|
+
return { ...best, passes: best.ratio >= minimum, changed: true };
|
|
262
|
+
}
|
|
263
|
+
function suggestReadable(backgrounds, candidates = [], options = {}) {
|
|
264
|
+
const standard = options.standard ?? "AA";
|
|
265
|
+
const minimum = CONTRAST_MINIMUM[standard];
|
|
266
|
+
const preferredInput = options.preferred ?? candidates[0];
|
|
267
|
+
const preferred = preferredInput === void 0 ? void 0 : normalizeHex(preferredInput);
|
|
268
|
+
if (preferred !== void 0 && meetsContrast(preferred, backgrounds, standard)) {
|
|
269
|
+
return { color: preferred, ratio: worstContrast(preferred, backgrounds).ratio, passes: true, changed: false };
|
|
270
|
+
}
|
|
271
|
+
const adjusted = preferred === void 0 ? void 0 : ensureContrast(preferred, backgrounds, options);
|
|
272
|
+
const passing = /* @__PURE__ */ new Map();
|
|
273
|
+
if (adjusted !== void 0 && adjusted.passes) {
|
|
274
|
+
passing.set(adjusted.color, { color: adjusted.color, ratio: adjusted.ratio });
|
|
275
|
+
}
|
|
276
|
+
for (const candidate of candidates) {
|
|
277
|
+
const color = normalizeHex(candidate);
|
|
278
|
+
const ratio = worstContrast(color, backgrounds).ratio;
|
|
279
|
+
if (ratio >= minimum) passing.set(color, { color, ratio });
|
|
280
|
+
}
|
|
281
|
+
if (passing.size > 0) {
|
|
282
|
+
const best = [...passing.values()].sort((a, b) => {
|
|
283
|
+
if (preferred === void 0) return b.ratio - a.ratio;
|
|
284
|
+
return distance(a.color, preferred) - distance(b.color, preferred);
|
|
285
|
+
})[0];
|
|
286
|
+
return { ...best, passes: true, changed: best.color !== preferred };
|
|
287
|
+
}
|
|
288
|
+
return ensureContrast(preferred ?? "#111111", backgrounds, options);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/palette/generate.ts
|
|
292
|
+
var STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
|
293
|
+
var LIGHTNESS = [97, 94, 88, 80, 69, 55, 45, 36, 27, 18, 12];
|
|
294
|
+
function generateScale(seed) {
|
|
295
|
+
const { h, s } = hexToHsl(normalizeHex(seed));
|
|
296
|
+
return Object.fromEntries(
|
|
297
|
+
STEPS.map((step, index) => [step, hslToHex({ h, s: Math.min(100, s * (0.72 + LIGHTNESS[index] / 350)), l: LIGHTNESS[index] })])
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
function generateHarmony(seed, harmony) {
|
|
301
|
+
const base = hexToHsl(normalizeHex(seed));
|
|
302
|
+
const offsets = {
|
|
303
|
+
complementary: [0, 180],
|
|
304
|
+
analogous: [-30, 0, 30],
|
|
305
|
+
triadic: [0, 120, 240],
|
|
306
|
+
"split-complementary": [0, 150, 210]
|
|
307
|
+
};
|
|
308
|
+
return offsets[harmony].map((offset) => hslToHex({ ...base, h: (base.h + offset + 360) % 360 }));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// src/theme/theme.ts
|
|
312
|
+
function createTheme({ primary, mode = "light" }) {
|
|
313
|
+
const brand = normalizeHex(primary);
|
|
314
|
+
if (mode === "light") {
|
|
315
|
+
return {
|
|
316
|
+
primary: brand,
|
|
317
|
+
background: "#ffffff",
|
|
318
|
+
foreground: "#171717",
|
|
319
|
+
muted: "#737373",
|
|
320
|
+
border: "#e5e5e5",
|
|
321
|
+
accent: adjustLightness(brand, 38)
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
primary: brand,
|
|
326
|
+
background: "#171717",
|
|
327
|
+
foreground: "#fafafa",
|
|
328
|
+
muted: "#a3a3a3",
|
|
329
|
+
border: "#404040",
|
|
330
|
+
accent: adjustLightness(brand, 18)
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function readableForeground(background) {
|
|
334
|
+
return readableOn(background);
|
|
335
|
+
}
|
|
336
|
+
function readableOn(...backgrounds) {
|
|
337
|
+
const worst = (text) => Math.min(...backgrounds.map((background) => checkContrast(text, background).ratio));
|
|
338
|
+
return worst("#000000") >= worst("#ffffff") ? "#000000" : "#ffffff";
|
|
339
|
+
}
|
|
340
|
+
function themeToCssVariables(theme, prefix = "color") {
|
|
341
|
+
return Object.fromEntries(
|
|
342
|
+
Object.entries(theme).map(([key, value]) => [`--${prefix}-${key}`, value])
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
function themeToCss(theme, selector = ":root", prefix = "color") {
|
|
346
|
+
const variables = themeToCssVariables(theme, prefix);
|
|
347
|
+
const declarations = Object.entries(variables).map(([key, value]) => ` ${key}: ${value};`).join("\n");
|
|
348
|
+
return `${selector} {
|
|
349
|
+
${declarations}
|
|
350
|
+
}`;
|
|
351
|
+
}
|
|
352
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
353
|
+
0 && (module.exports = {
|
|
354
|
+
CONTRAST_MINIMUM,
|
|
355
|
+
adjustLightness,
|
|
356
|
+
checkContrast,
|
|
357
|
+
composite,
|
|
358
|
+
contrastRatio,
|
|
359
|
+
createTheme,
|
|
360
|
+
ensureContrast,
|
|
361
|
+
generateHarmony,
|
|
362
|
+
generateScale,
|
|
363
|
+
hexToHsl,
|
|
364
|
+
hexToRgb,
|
|
365
|
+
hexToRgba,
|
|
366
|
+
hslToHex,
|
|
367
|
+
hslToRgb,
|
|
368
|
+
meetsContrast,
|
|
369
|
+
mix,
|
|
370
|
+
normalizeHex,
|
|
371
|
+
readableForeground,
|
|
372
|
+
readableOn,
|
|
373
|
+
relativeLuminance,
|
|
374
|
+
rgbToHex,
|
|
375
|
+
rgbToHsl,
|
|
376
|
+
suggestReadable,
|
|
377
|
+
themeToCss,
|
|
378
|
+
themeToCssVariables,
|
|
379
|
+
worstContrast
|
|
380
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Normalize a 3- or 6-digit hex color to #rrggbb. */
|
|
2
|
+
declare function normalizeHex(input: string): string;
|
|
3
|
+
interface RGB {
|
|
4
|
+
r: number;
|
|
5
|
+
g: number;
|
|
6
|
+
b: number;
|
|
7
|
+
}
|
|
8
|
+
interface RGBA extends RGB {
|
|
9
|
+
/** 0 (transparent) to 1 (opaque). */
|
|
10
|
+
a: number;
|
|
11
|
+
}
|
|
12
|
+
/** Parse #RGB, #RGBA, #RRGGBB or #RRGGBBAA into channels and alpha. */
|
|
13
|
+
declare function hexToRgba(hex: string): RGBA;
|
|
14
|
+
declare function hexToRgb(hex: string): RGB;
|
|
15
|
+
declare function rgbToHex({ r, g, b }: RGB): string;
|
|
16
|
+
|
|
17
|
+
interface HSL {
|
|
18
|
+
h: number;
|
|
19
|
+
s: number;
|
|
20
|
+
l: number;
|
|
21
|
+
}
|
|
22
|
+
declare function rgbToHsl({ r, g, b }: RGB): HSL;
|
|
23
|
+
declare function hslToRgb({ h, s, l }: HSL): RGB;
|
|
24
|
+
declare function hexToHsl(hex: string): HSL;
|
|
25
|
+
declare function hslToHex(hsl: HSL): string;
|
|
26
|
+
declare function adjustLightness(hex: string, amount: number): string;
|
|
27
|
+
|
|
28
|
+
/** Mix two colors in sRGB. 0 returns `a`, 1 returns `b`. Alpha is ignored. */
|
|
29
|
+
declare function mix(a: string, b: string, weight: number): string;
|
|
30
|
+
/** Composite a translucent foreground over an opaque background. */
|
|
31
|
+
declare function composite(foreground: string, background: string): string;
|
|
32
|
+
|
|
33
|
+
/** WCAG 2.x relative luminance, from 0 (black) to 1 (white). */
|
|
34
|
+
declare function relativeLuminance(hex: string): number;
|
|
35
|
+
/** WCAG contrast ratio, from 1:1 to 21:1. */
|
|
36
|
+
declare function contrastRatio(foreground: string, background: string): number;
|
|
37
|
+
type ContrastLevel = 'fail' | 'AA-large' | 'AA' | 'AAA';
|
|
38
|
+
interface ContrastResult {
|
|
39
|
+
ratio: number;
|
|
40
|
+
level: ContrastLevel;
|
|
41
|
+
passesAA: boolean;
|
|
42
|
+
passesAALarge: boolean;
|
|
43
|
+
passesAAA: boolean;
|
|
44
|
+
}
|
|
45
|
+
declare function checkContrast(foreground: string, background: string): ContrastResult;
|
|
46
|
+
|
|
47
|
+
type ContrastStandard = 'AA' | 'AA-large' | 'AAA';
|
|
48
|
+
/** WCAG 2.x minimum ratios: 4.5:1 for body text, 3:1 for large text, 7:1 for AAA. */
|
|
49
|
+
declare const CONTRAST_MINIMUM: Record<ContrastStandard, number>;
|
|
50
|
+
interface ContrastOptions {
|
|
51
|
+
standard?: ContrastStandard;
|
|
52
|
+
/** Keep hue and saturation and move lightness only. Defaults to true. */
|
|
53
|
+
preserveHue?: boolean;
|
|
54
|
+
}
|
|
55
|
+
interface SuggestOptions extends ContrastOptions {
|
|
56
|
+
/** The colour the user already picked. Defaults to the first candidate. */
|
|
57
|
+
preferred?: string;
|
|
58
|
+
}
|
|
59
|
+
interface ContrastSuggestion {
|
|
60
|
+
color: string;
|
|
61
|
+
ratio: number;
|
|
62
|
+
passes: boolean;
|
|
63
|
+
changed: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** The lowest contrast ratio between `foreground` and any of the backgrounds. */
|
|
66
|
+
declare function worstContrast(foreground: string, backgrounds: string[]): {
|
|
67
|
+
ratio: number;
|
|
68
|
+
background: string;
|
|
69
|
+
};
|
|
70
|
+
declare function meetsContrast(foreground: string, backgrounds: string[], standard?: ContrastStandard): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Return the closest colour to `foreground` that meets the standard on every
|
|
73
|
+
* background. By default it keeps the hue and saturation and only moves
|
|
74
|
+
* lightness, so the result still looks like the user's colour.
|
|
75
|
+
*/
|
|
76
|
+
declare function ensureContrast(foreground: string, backgrounds: string[], options?: ContrastOptions): ContrastSuggestion;
|
|
77
|
+
/**
|
|
78
|
+
* Suggest a readable colour from the colours the user already has. The
|
|
79
|
+
* hue-preserving version of `preferred` wins unless a palette candidate is
|
|
80
|
+
* closer to it.
|
|
81
|
+
*/
|
|
82
|
+
declare function suggestReadable(backgrounds: string[], candidates?: string[], options?: SuggestOptions): ContrastSuggestion;
|
|
83
|
+
|
|
84
|
+
type PaletteScale = Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>;
|
|
85
|
+
/** Generate a Tailwind-style 50–950 scale while preserving the source hue and saturation. */
|
|
86
|
+
declare function generateScale(seed: string): PaletteScale;
|
|
87
|
+
type Harmony = 'complementary' | 'analogous' | 'triadic' | 'split-complementary';
|
|
88
|
+
/** Generate companion colors using classic hue relationships. */
|
|
89
|
+
declare function generateHarmony(seed: string, harmony: Harmony): string[];
|
|
90
|
+
|
|
91
|
+
interface ColorTheme {
|
|
92
|
+
primary: string;
|
|
93
|
+
background: string;
|
|
94
|
+
foreground: string;
|
|
95
|
+
muted: string;
|
|
96
|
+
border: string;
|
|
97
|
+
accent: string;
|
|
98
|
+
}
|
|
99
|
+
interface ThemeOptions {
|
|
100
|
+
primary: string;
|
|
101
|
+
mode?: 'light' | 'dark';
|
|
102
|
+
}
|
|
103
|
+
/** Create a small semantic theme from a primary brand color. */
|
|
104
|
+
declare function createTheme({ primary, mode }: ThemeOptions): ColorTheme;
|
|
105
|
+
/** Choose black or white text based on the stronger WCAG contrast ratio. */
|
|
106
|
+
declare function readableForeground(background: string): '#000000' | '#ffffff';
|
|
107
|
+
/** Choose black or white, whichever reads better on every given background. */
|
|
108
|
+
declare function readableOn(...backgrounds: string[]): '#000000' | '#ffffff';
|
|
109
|
+
declare function themeToCssVariables(theme: ColorTheme, prefix?: string): Record<string, string>;
|
|
110
|
+
declare function themeToCss(theme: ColorTheme, selector?: string, prefix?: string): string;
|
|
111
|
+
|
|
112
|
+
export { CONTRAST_MINIMUM, type ColorTheme, type ContrastLevel, type ContrastOptions, type ContrastResult, type ContrastStandard, type ContrastSuggestion, type HSL, type Harmony, type PaletteScale, type RGB, type RGBA, type SuggestOptions, type ThemeOptions, adjustLightness, checkContrast, composite, contrastRatio, createTheme, ensureContrast, generateHarmony, generateScale, hexToHsl, hexToRgb, hexToRgba, hslToHex, hslToRgb, meetsContrast, mix, normalizeHex, readableForeground, readableOn, relativeLuminance, rgbToHex, rgbToHsl, suggestReadable, themeToCss, themeToCssVariables, worstContrast };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Normalize a 3- or 6-digit hex color to #rrggbb. */
|
|
2
|
+
declare function normalizeHex(input: string): string;
|
|
3
|
+
interface RGB {
|
|
4
|
+
r: number;
|
|
5
|
+
g: number;
|
|
6
|
+
b: number;
|
|
7
|
+
}
|
|
8
|
+
interface RGBA extends RGB {
|
|
9
|
+
/** 0 (transparent) to 1 (opaque). */
|
|
10
|
+
a: number;
|
|
11
|
+
}
|
|
12
|
+
/** Parse #RGB, #RGBA, #RRGGBB or #RRGGBBAA into channels and alpha. */
|
|
13
|
+
declare function hexToRgba(hex: string): RGBA;
|
|
14
|
+
declare function hexToRgb(hex: string): RGB;
|
|
15
|
+
declare function rgbToHex({ r, g, b }: RGB): string;
|
|
16
|
+
|
|
17
|
+
interface HSL {
|
|
18
|
+
h: number;
|
|
19
|
+
s: number;
|
|
20
|
+
l: number;
|
|
21
|
+
}
|
|
22
|
+
declare function rgbToHsl({ r, g, b }: RGB): HSL;
|
|
23
|
+
declare function hslToRgb({ h, s, l }: HSL): RGB;
|
|
24
|
+
declare function hexToHsl(hex: string): HSL;
|
|
25
|
+
declare function hslToHex(hsl: HSL): string;
|
|
26
|
+
declare function adjustLightness(hex: string, amount: number): string;
|
|
27
|
+
|
|
28
|
+
/** Mix two colors in sRGB. 0 returns `a`, 1 returns `b`. Alpha is ignored. */
|
|
29
|
+
declare function mix(a: string, b: string, weight: number): string;
|
|
30
|
+
/** Composite a translucent foreground over an opaque background. */
|
|
31
|
+
declare function composite(foreground: string, background: string): string;
|
|
32
|
+
|
|
33
|
+
/** WCAG 2.x relative luminance, from 0 (black) to 1 (white). */
|
|
34
|
+
declare function relativeLuminance(hex: string): number;
|
|
35
|
+
/** WCAG contrast ratio, from 1:1 to 21:1. */
|
|
36
|
+
declare function contrastRatio(foreground: string, background: string): number;
|
|
37
|
+
type ContrastLevel = 'fail' | 'AA-large' | 'AA' | 'AAA';
|
|
38
|
+
interface ContrastResult {
|
|
39
|
+
ratio: number;
|
|
40
|
+
level: ContrastLevel;
|
|
41
|
+
passesAA: boolean;
|
|
42
|
+
passesAALarge: boolean;
|
|
43
|
+
passesAAA: boolean;
|
|
44
|
+
}
|
|
45
|
+
declare function checkContrast(foreground: string, background: string): ContrastResult;
|
|
46
|
+
|
|
47
|
+
type ContrastStandard = 'AA' | 'AA-large' | 'AAA';
|
|
48
|
+
/** WCAG 2.x minimum ratios: 4.5:1 for body text, 3:1 for large text, 7:1 for AAA. */
|
|
49
|
+
declare const CONTRAST_MINIMUM: Record<ContrastStandard, number>;
|
|
50
|
+
interface ContrastOptions {
|
|
51
|
+
standard?: ContrastStandard;
|
|
52
|
+
/** Keep hue and saturation and move lightness only. Defaults to true. */
|
|
53
|
+
preserveHue?: boolean;
|
|
54
|
+
}
|
|
55
|
+
interface SuggestOptions extends ContrastOptions {
|
|
56
|
+
/** The colour the user already picked. Defaults to the first candidate. */
|
|
57
|
+
preferred?: string;
|
|
58
|
+
}
|
|
59
|
+
interface ContrastSuggestion {
|
|
60
|
+
color: string;
|
|
61
|
+
ratio: number;
|
|
62
|
+
passes: boolean;
|
|
63
|
+
changed: boolean;
|
|
64
|
+
}
|
|
65
|
+
/** The lowest contrast ratio between `foreground` and any of the backgrounds. */
|
|
66
|
+
declare function worstContrast(foreground: string, backgrounds: string[]): {
|
|
67
|
+
ratio: number;
|
|
68
|
+
background: string;
|
|
69
|
+
};
|
|
70
|
+
declare function meetsContrast(foreground: string, backgrounds: string[], standard?: ContrastStandard): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Return the closest colour to `foreground` that meets the standard on every
|
|
73
|
+
* background. By default it keeps the hue and saturation and only moves
|
|
74
|
+
* lightness, so the result still looks like the user's colour.
|
|
75
|
+
*/
|
|
76
|
+
declare function ensureContrast(foreground: string, backgrounds: string[], options?: ContrastOptions): ContrastSuggestion;
|
|
77
|
+
/**
|
|
78
|
+
* Suggest a readable colour from the colours the user already has. The
|
|
79
|
+
* hue-preserving version of `preferred` wins unless a palette candidate is
|
|
80
|
+
* closer to it.
|
|
81
|
+
*/
|
|
82
|
+
declare function suggestReadable(backgrounds: string[], candidates?: string[], options?: SuggestOptions): ContrastSuggestion;
|
|
83
|
+
|
|
84
|
+
type PaletteScale = Record<50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950, string>;
|
|
85
|
+
/** Generate a Tailwind-style 50–950 scale while preserving the source hue and saturation. */
|
|
86
|
+
declare function generateScale(seed: string): PaletteScale;
|
|
87
|
+
type Harmony = 'complementary' | 'analogous' | 'triadic' | 'split-complementary';
|
|
88
|
+
/** Generate companion colors using classic hue relationships. */
|
|
89
|
+
declare function generateHarmony(seed: string, harmony: Harmony): string[];
|
|
90
|
+
|
|
91
|
+
interface ColorTheme {
|
|
92
|
+
primary: string;
|
|
93
|
+
background: string;
|
|
94
|
+
foreground: string;
|
|
95
|
+
muted: string;
|
|
96
|
+
border: string;
|
|
97
|
+
accent: string;
|
|
98
|
+
}
|
|
99
|
+
interface ThemeOptions {
|
|
100
|
+
primary: string;
|
|
101
|
+
mode?: 'light' | 'dark';
|
|
102
|
+
}
|
|
103
|
+
/** Create a small semantic theme from a primary brand color. */
|
|
104
|
+
declare function createTheme({ primary, mode }: ThemeOptions): ColorTheme;
|
|
105
|
+
/** Choose black or white text based on the stronger WCAG contrast ratio. */
|
|
106
|
+
declare function readableForeground(background: string): '#000000' | '#ffffff';
|
|
107
|
+
/** Choose black or white, whichever reads better on every given background. */
|
|
108
|
+
declare function readableOn(...backgrounds: string[]): '#000000' | '#ffffff';
|
|
109
|
+
declare function themeToCssVariables(theme: ColorTheme, prefix?: string): Record<string, string>;
|
|
110
|
+
declare function themeToCss(theme: ColorTheme, selector?: string, prefix?: string): string;
|
|
111
|
+
|
|
112
|
+
export { CONTRAST_MINIMUM, type ColorTheme, type ContrastLevel, type ContrastOptions, type ContrastResult, type ContrastStandard, type ContrastSuggestion, type HSL, type Harmony, type PaletteScale, type RGB, type RGBA, type SuggestOptions, type ThemeOptions, adjustLightness, checkContrast, composite, contrastRatio, createTheme, ensureContrast, generateHarmony, generateScale, hexToHsl, hexToRgb, hexToRgba, hslToHex, hslToRgb, meetsContrast, mix, normalizeHex, readableForeground, readableOn, relativeLuminance, rgbToHex, rgbToHsl, suggestReadable, themeToCss, themeToCssVariables, worstContrast };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// src/color/parse.ts
|
|
2
|
+
function normalizeHex(input) {
|
|
3
|
+
const value = input.trim().replace(/^#/, "");
|
|
4
|
+
if (/^[\da-fA-F]{3}$/.test(value)) {
|
|
5
|
+
return `#${[...value].map((char) => char + char).join("").toLowerCase()}`;
|
|
6
|
+
}
|
|
7
|
+
if (/^[\da-fA-F]{6}$/.test(value)) return `#${value.toLowerCase()}`;
|
|
8
|
+
throw new Error(`Invalid hex color: "${input}". Expected #RGB or #RRGGBB.`);
|
|
9
|
+
}
|
|
10
|
+
function hexToRgba(hex) {
|
|
11
|
+
const value = hex.trim().replace(/^#/, "");
|
|
12
|
+
const expanded = /^[\da-f]{3,4}$/i.test(value) ? [...value].map((char) => char + char).join("") : value;
|
|
13
|
+
if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(expanded)) {
|
|
14
|
+
throw new Error(`Invalid hex color: "${hex}". Expected #RGB, #RGBA, #RRGGBB or #RRGGBBAA.`);
|
|
15
|
+
}
|
|
16
|
+
const int = Number.parseInt(expanded.slice(0, 6), 16);
|
|
17
|
+
return {
|
|
18
|
+
r: int >> 16 & 255,
|
|
19
|
+
g: int >> 8 & 255,
|
|
20
|
+
b: int & 255,
|
|
21
|
+
a: expanded.length === 8 ? Number.parseInt(expanded.slice(6, 8), 16) / 255 : 1
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function hexToRgb(hex) {
|
|
25
|
+
const { r, g, b } = hexToRgba(normalizeHex(hex));
|
|
26
|
+
return { r, g, b };
|
|
27
|
+
}
|
|
28
|
+
function rgbToHex({ r, g, b }) {
|
|
29
|
+
const channels = [r, g, b];
|
|
30
|
+
if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) {
|
|
31
|
+
throw new Error("RGB channels must be finite numbers between 0 and 255.");
|
|
32
|
+
}
|
|
33
|
+
return `#${channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/color/convert.ts
|
|
37
|
+
function rgbToHsl({ r, g, b }) {
|
|
38
|
+
const red = r / 255;
|
|
39
|
+
const green = g / 255;
|
|
40
|
+
const blue = b / 255;
|
|
41
|
+
const max = Math.max(red, green, blue);
|
|
42
|
+
const min = Math.min(red, green, blue);
|
|
43
|
+
const delta = max - min;
|
|
44
|
+
let h = 0;
|
|
45
|
+
const l = (max + min) / 2;
|
|
46
|
+
let s = 0;
|
|
47
|
+
if (delta !== 0) {
|
|
48
|
+
s = delta / (1 - Math.abs(2 * l - 1));
|
|
49
|
+
switch (max) {
|
|
50
|
+
case red:
|
|
51
|
+
h = (green - blue) / delta % 6;
|
|
52
|
+
break;
|
|
53
|
+
case green:
|
|
54
|
+
h = (blue - red) / delta + 2;
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
h = (red - green) / delta + 4;
|
|
58
|
+
}
|
|
59
|
+
h *= 60;
|
|
60
|
+
if (h < 0) h += 360;
|
|
61
|
+
}
|
|
62
|
+
return { h, s: s * 100, l: l * 100 };
|
|
63
|
+
}
|
|
64
|
+
function hslToRgb({ h, s, l }) {
|
|
65
|
+
if (![h, s, l].every(Number.isFinite) || s < 0 || s > 100 || l < 0 || l > 100) {
|
|
66
|
+
throw new Error("HSL requires finite hue and saturation/lightness between 0 and 100.");
|
|
67
|
+
}
|
|
68
|
+
const hue = (h % 360 + 360) % 360;
|
|
69
|
+
const saturation = s / 100;
|
|
70
|
+
const lightness = l / 100;
|
|
71
|
+
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
72
|
+
const x = chroma * (1 - Math.abs(hue / 60 % 2 - 1));
|
|
73
|
+
const m = lightness - chroma / 2;
|
|
74
|
+
let channels;
|
|
75
|
+
if (hue < 60) channels = [chroma, x, 0];
|
|
76
|
+
else if (hue < 120) channels = [x, chroma, 0];
|
|
77
|
+
else if (hue < 180) channels = [0, chroma, x];
|
|
78
|
+
else if (hue < 240) channels = [0, x, chroma];
|
|
79
|
+
else if (hue < 300) channels = [x, 0, chroma];
|
|
80
|
+
else channels = [chroma, 0, x];
|
|
81
|
+
return {
|
|
82
|
+
r: Math.round((channels[0] + m) * 255),
|
|
83
|
+
g: Math.round((channels[1] + m) * 255),
|
|
84
|
+
b: Math.round((channels[2] + m) * 255)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function hexToHsl(hex) {
|
|
88
|
+
return rgbToHsl(hexToRgb(hex));
|
|
89
|
+
}
|
|
90
|
+
function hslToHex(hsl) {
|
|
91
|
+
return rgbToHex(hslToRgb(hsl));
|
|
92
|
+
}
|
|
93
|
+
function adjustLightness(hex, amount2) {
|
|
94
|
+
if (!Number.isFinite(amount2) || amount2 < -100 || amount2 > 100) {
|
|
95
|
+
throw new Error("Lightness adjustment must be between -100 and 100.");
|
|
96
|
+
}
|
|
97
|
+
const hsl = hexToHsl(normalizeHex(hex));
|
|
98
|
+
return hslToHex({ ...hsl, l: Math.min(100, Math.max(0, hsl.l + amount2)) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/color/mix.ts
|
|
102
|
+
function amount(value) {
|
|
103
|
+
if (!Number.isFinite(value)) throw new Error("Mix amount must be a finite number.");
|
|
104
|
+
return Math.min(1, Math.max(0, value));
|
|
105
|
+
}
|
|
106
|
+
function mix(a, b, weight) {
|
|
107
|
+
const t = amount(weight);
|
|
108
|
+
const first = hexToRgba(a);
|
|
109
|
+
const second = hexToRgba(b);
|
|
110
|
+
return rgbToHex({
|
|
111
|
+
r: first.r + (second.r - first.r) * t,
|
|
112
|
+
g: first.g + (second.g - first.g) * t,
|
|
113
|
+
b: first.b + (second.b - first.b) * t
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function composite(foreground, background) {
|
|
117
|
+
const fg = hexToRgba(foreground);
|
|
118
|
+
const bg = hexToRgba(background);
|
|
119
|
+
return rgbToHex({
|
|
120
|
+
r: fg.r * fg.a + bg.r * (1 - fg.a),
|
|
121
|
+
g: fg.g * fg.a + bg.g * (1 - fg.a),
|
|
122
|
+
b: fg.b * fg.a + bg.b * (1 - fg.a)
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/contrast/wcag.ts
|
|
127
|
+
function linearize(channel) {
|
|
128
|
+
const value = channel / 255;
|
|
129
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
130
|
+
}
|
|
131
|
+
function relativeLuminance(hex) {
|
|
132
|
+
const { r, g, b } = hexToRgb(normalizeHex(hex));
|
|
133
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
134
|
+
}
|
|
135
|
+
function contrastRatio(foreground, background) {
|
|
136
|
+
const first = relativeLuminance(foreground);
|
|
137
|
+
const second = relativeLuminance(background);
|
|
138
|
+
const lighter = Math.max(first, second);
|
|
139
|
+
const darker = Math.min(first, second);
|
|
140
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
141
|
+
}
|
|
142
|
+
function checkContrast(foreground, background) {
|
|
143
|
+
const ratio = contrastRatio(foreground, background);
|
|
144
|
+
return {
|
|
145
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
146
|
+
level: ratio >= 7 ? "AAA" : ratio >= 4.5 ? "AA" : ratio >= 3 ? "AA-large" : "fail",
|
|
147
|
+
passesAA: ratio >= 4.5,
|
|
148
|
+
passesAALarge: ratio >= 3,
|
|
149
|
+
passesAAA: ratio >= 7
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/contrast/advise.ts
|
|
154
|
+
var CONTRAST_MINIMUM = {
|
|
155
|
+
"AA-large": 3,
|
|
156
|
+
AA: 4.5,
|
|
157
|
+
AAA: 7
|
|
158
|
+
};
|
|
159
|
+
function firstBackground(backgrounds) {
|
|
160
|
+
const first = backgrounds[0];
|
|
161
|
+
if (first === void 0) throw new Error("At least one background is required.");
|
|
162
|
+
return first;
|
|
163
|
+
}
|
|
164
|
+
function worstContrast(foreground, backgrounds) {
|
|
165
|
+
const fg = normalizeHex(foreground);
|
|
166
|
+
let worst = { ratio: Number.POSITIVE_INFINITY, background: normalizeHex(firstBackground(backgrounds)) };
|
|
167
|
+
for (const background of backgrounds) {
|
|
168
|
+
const normalized = normalizeHex(background);
|
|
169
|
+
const ratio = contrastRatio(fg, normalized);
|
|
170
|
+
if (ratio < worst.ratio) worst = { ratio, background: normalized };
|
|
171
|
+
}
|
|
172
|
+
return worst;
|
|
173
|
+
}
|
|
174
|
+
function meetsContrast(foreground, backgrounds, standard = "AA") {
|
|
175
|
+
return worstContrast(foreground, backgrounds).ratio >= CONTRAST_MINIMUM[standard];
|
|
176
|
+
}
|
|
177
|
+
function distance(a, b) {
|
|
178
|
+
const first = hexToRgb(a);
|
|
179
|
+
const second = hexToRgb(b);
|
|
180
|
+
return (first.r - second.r) ** 2 + (first.g - second.g) ** 2 + (first.b - second.b) ** 2;
|
|
181
|
+
}
|
|
182
|
+
function ensureContrast(foreground, backgrounds, options = {}) {
|
|
183
|
+
const standard = options.standard ?? "AA";
|
|
184
|
+
const minimum = CONTRAST_MINIMUM[standard];
|
|
185
|
+
const base = normalizeHex(foreground);
|
|
186
|
+
const current = worstContrast(base, backgrounds);
|
|
187
|
+
if (current.ratio >= minimum) {
|
|
188
|
+
return { color: base, ratio: current.ratio, passes: true, changed: false };
|
|
189
|
+
}
|
|
190
|
+
if (options.preserveHue !== false) {
|
|
191
|
+
const { h, s, l } = hexToHsl(base);
|
|
192
|
+
for (let step = 1; step <= 100; step += 1) {
|
|
193
|
+
const shades = [
|
|
194
|
+
hslToHex({ h, s, l: Math.max(0, l - step) }),
|
|
195
|
+
hslToHex({ h, s, l: Math.min(100, l + step) })
|
|
196
|
+
];
|
|
197
|
+
let best2;
|
|
198
|
+
for (const shade of shades) {
|
|
199
|
+
const ratio = worstContrast(shade, backgrounds).ratio;
|
|
200
|
+
if (ratio >= minimum && (best2 === void 0 || ratio > best2.ratio)) {
|
|
201
|
+
best2 = { color: shade, ratio };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (best2 !== void 0) return { ...best2, passes: true, changed: best2.color !== base };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const black = { color: "#000000", ratio: worstContrast("#000000", backgrounds).ratio };
|
|
208
|
+
const white = { color: "#ffffff", ratio: worstContrast("#ffffff", backgrounds).ratio };
|
|
209
|
+
const best = black.ratio >= white.ratio ? black : white;
|
|
210
|
+
return { ...best, passes: best.ratio >= minimum, changed: true };
|
|
211
|
+
}
|
|
212
|
+
function suggestReadable(backgrounds, candidates = [], options = {}) {
|
|
213
|
+
const standard = options.standard ?? "AA";
|
|
214
|
+
const minimum = CONTRAST_MINIMUM[standard];
|
|
215
|
+
const preferredInput = options.preferred ?? candidates[0];
|
|
216
|
+
const preferred = preferredInput === void 0 ? void 0 : normalizeHex(preferredInput);
|
|
217
|
+
if (preferred !== void 0 && meetsContrast(preferred, backgrounds, standard)) {
|
|
218
|
+
return { color: preferred, ratio: worstContrast(preferred, backgrounds).ratio, passes: true, changed: false };
|
|
219
|
+
}
|
|
220
|
+
const adjusted = preferred === void 0 ? void 0 : ensureContrast(preferred, backgrounds, options);
|
|
221
|
+
const passing = /* @__PURE__ */ new Map();
|
|
222
|
+
if (adjusted !== void 0 && adjusted.passes) {
|
|
223
|
+
passing.set(adjusted.color, { color: adjusted.color, ratio: adjusted.ratio });
|
|
224
|
+
}
|
|
225
|
+
for (const candidate of candidates) {
|
|
226
|
+
const color = normalizeHex(candidate);
|
|
227
|
+
const ratio = worstContrast(color, backgrounds).ratio;
|
|
228
|
+
if (ratio >= minimum) passing.set(color, { color, ratio });
|
|
229
|
+
}
|
|
230
|
+
if (passing.size > 0) {
|
|
231
|
+
const best = [...passing.values()].sort((a, b) => {
|
|
232
|
+
if (preferred === void 0) return b.ratio - a.ratio;
|
|
233
|
+
return distance(a.color, preferred) - distance(b.color, preferred);
|
|
234
|
+
})[0];
|
|
235
|
+
return { ...best, passes: true, changed: best.color !== preferred };
|
|
236
|
+
}
|
|
237
|
+
return ensureContrast(preferred ?? "#111111", backgrounds, options);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/palette/generate.ts
|
|
241
|
+
var STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
|
242
|
+
var LIGHTNESS = [97, 94, 88, 80, 69, 55, 45, 36, 27, 18, 12];
|
|
243
|
+
function generateScale(seed) {
|
|
244
|
+
const { h, s } = hexToHsl(normalizeHex(seed));
|
|
245
|
+
return Object.fromEntries(
|
|
246
|
+
STEPS.map((step, index) => [step, hslToHex({ h, s: Math.min(100, s * (0.72 + LIGHTNESS[index] / 350)), l: LIGHTNESS[index] })])
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
function generateHarmony(seed, harmony) {
|
|
250
|
+
const base = hexToHsl(normalizeHex(seed));
|
|
251
|
+
const offsets = {
|
|
252
|
+
complementary: [0, 180],
|
|
253
|
+
analogous: [-30, 0, 30],
|
|
254
|
+
triadic: [0, 120, 240],
|
|
255
|
+
"split-complementary": [0, 150, 210]
|
|
256
|
+
};
|
|
257
|
+
return offsets[harmony].map((offset) => hslToHex({ ...base, h: (base.h + offset + 360) % 360 }));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/theme/theme.ts
|
|
261
|
+
function createTheme({ primary, mode = "light" }) {
|
|
262
|
+
const brand = normalizeHex(primary);
|
|
263
|
+
if (mode === "light") {
|
|
264
|
+
return {
|
|
265
|
+
primary: brand,
|
|
266
|
+
background: "#ffffff",
|
|
267
|
+
foreground: "#171717",
|
|
268
|
+
muted: "#737373",
|
|
269
|
+
border: "#e5e5e5",
|
|
270
|
+
accent: adjustLightness(brand, 38)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
primary: brand,
|
|
275
|
+
background: "#171717",
|
|
276
|
+
foreground: "#fafafa",
|
|
277
|
+
muted: "#a3a3a3",
|
|
278
|
+
border: "#404040",
|
|
279
|
+
accent: adjustLightness(brand, 18)
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function readableForeground(background) {
|
|
283
|
+
return readableOn(background);
|
|
284
|
+
}
|
|
285
|
+
function readableOn(...backgrounds) {
|
|
286
|
+
const worst = (text) => Math.min(...backgrounds.map((background) => checkContrast(text, background).ratio));
|
|
287
|
+
return worst("#000000") >= worst("#ffffff") ? "#000000" : "#ffffff";
|
|
288
|
+
}
|
|
289
|
+
function themeToCssVariables(theme, prefix = "color") {
|
|
290
|
+
return Object.fromEntries(
|
|
291
|
+
Object.entries(theme).map(([key, value]) => [`--${prefix}-${key}`, value])
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
function themeToCss(theme, selector = ":root", prefix = "color") {
|
|
295
|
+
const variables = themeToCssVariables(theme, prefix);
|
|
296
|
+
const declarations = Object.entries(variables).map(([key, value]) => ` ${key}: ${value};`).join("\n");
|
|
297
|
+
return `${selector} {
|
|
298
|
+
${declarations}
|
|
299
|
+
}`;
|
|
300
|
+
}
|
|
301
|
+
export {
|
|
302
|
+
CONTRAST_MINIMUM,
|
|
303
|
+
adjustLightness,
|
|
304
|
+
checkContrast,
|
|
305
|
+
composite,
|
|
306
|
+
contrastRatio,
|
|
307
|
+
createTheme,
|
|
308
|
+
ensureContrast,
|
|
309
|
+
generateHarmony,
|
|
310
|
+
generateScale,
|
|
311
|
+
hexToHsl,
|
|
312
|
+
hexToRgb,
|
|
313
|
+
hexToRgba,
|
|
314
|
+
hslToHex,
|
|
315
|
+
hslToRgb,
|
|
316
|
+
meetsContrast,
|
|
317
|
+
mix,
|
|
318
|
+
normalizeHex,
|
|
319
|
+
readableForeground,
|
|
320
|
+
readableOn,
|
|
321
|
+
relativeLuminance,
|
|
322
|
+
rgbToHex,
|
|
323
|
+
rgbToHsl,
|
|
324
|
+
suggestReadable,
|
|
325
|
+
themeToCss,
|
|
326
|
+
themeToCssVariables,
|
|
327
|
+
worstContrast
|
|
328
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spotkit/mwala",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Color intelligence, accessibility and design foundations for the web.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"require": {
|
|
18
|
+
"types": "./dist/index.d.cts",
|
|
19
|
+
"default": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
26
|
+
"prepack": "npm run build",
|
|
27
|
+
"prepublishOnly": "npm run build",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch"
|
|
32
|
+
},
|
|
33
|
+
"keywords": ["color", "palette", "contrast", "accessibility", "theme", "design-system", "typescript", "mwala", "spotkit"],
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"tsup": "^8.3.5",
|
|
36
|
+
"typescript": "^5.7.2",
|
|
37
|
+
"vitest": "^2.1.8"
|
|
38
|
+
}
|
|
39
|
+
}
|