@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 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
- # Temporary Holding Version
1
+ # Mwala
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ });
@@ -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 };
@@ -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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }