@baukit/ui-tokens 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baukit/ui-tokens",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Dependency-free design-token schema, validator, accessibility checker, and deterministic compiler for web and React Native.",
5
5
  "license": "MIT",
6
6
  "author": "Patrick Koss",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "files": [
37
37
  "dist",
38
+ "src",
38
39
  "README.md",
39
40
  "LICENSE"
40
41
  ],
@@ -0,0 +1,196 @@
1
+ /**
2
+ * CSS color keywords recognized as raw colors. Keywords that double as ordinary
3
+ * English words in prop values are handled by requiring a style-like context.
4
+ */
5
+ const CSS_COLOR_KEYWORDS = new Set([
6
+ 'aliceblue',
7
+ 'antiquewhite',
8
+ 'aqua',
9
+ 'aquamarine',
10
+ 'azure',
11
+ 'beige',
12
+ 'bisque',
13
+ 'black',
14
+ 'blanchedalmond',
15
+ 'blue',
16
+ 'blueviolet',
17
+ 'brown',
18
+ 'burlywood',
19
+ 'cadetblue',
20
+ 'chartreuse',
21
+ 'chocolate',
22
+ 'coral',
23
+ 'cornflowerblue',
24
+ 'cornsilk',
25
+ 'crimson',
26
+ 'cyan',
27
+ 'darkblue',
28
+ 'darkcyan',
29
+ 'darkgoldenrod',
30
+ 'darkgray',
31
+ 'darkgreen',
32
+ 'darkgrey',
33
+ 'darkkhaki',
34
+ 'darkmagenta',
35
+ 'darkolivegreen',
36
+ 'darkorange',
37
+ 'darkorchid',
38
+ 'darkred',
39
+ 'darksalmon',
40
+ 'darkseagreen',
41
+ 'darkslateblue',
42
+ 'darkslategray',
43
+ 'darkslategrey',
44
+ 'darkturquoise',
45
+ 'darkviolet',
46
+ 'deeppink',
47
+ 'deepskyblue',
48
+ 'dimgray',
49
+ 'dimgrey',
50
+ 'dodgerblue',
51
+ 'firebrick',
52
+ 'floralwhite',
53
+ 'forestgreen',
54
+ 'fuchsia',
55
+ 'gainsboro',
56
+ 'ghostwhite',
57
+ 'gold',
58
+ 'goldenrod',
59
+ 'gray',
60
+ 'green',
61
+ 'greenyellow',
62
+ 'grey',
63
+ 'honeydew',
64
+ 'hotpink',
65
+ 'indianred',
66
+ 'indigo',
67
+ 'ivory',
68
+ 'khaki',
69
+ 'lavender',
70
+ 'lavenderblush',
71
+ 'lawngreen',
72
+ 'lemonchiffon',
73
+ 'lightblue',
74
+ 'lightcoral',
75
+ 'lightcyan',
76
+ 'lightgoldenrodyellow',
77
+ 'lightgray',
78
+ 'lightgreen',
79
+ 'lightgrey',
80
+ 'lightpink',
81
+ 'lightsalmon',
82
+ 'lightseagreen',
83
+ 'lightskyblue',
84
+ 'lightslategray',
85
+ 'lightslategrey',
86
+ 'lightsteelblue',
87
+ 'lightyellow',
88
+ 'lime',
89
+ 'limegreen',
90
+ 'linen',
91
+ 'magenta',
92
+ 'maroon',
93
+ 'mediumaquamarine',
94
+ 'mediumblue',
95
+ 'mediumorchid',
96
+ 'mediumpurple',
97
+ 'mediumseagreen',
98
+ 'mediumslateblue',
99
+ 'mediumspringgreen',
100
+ 'mediumturquoise',
101
+ 'mediumvioletred',
102
+ 'midnightblue',
103
+ 'mintcream',
104
+ 'mistyrose',
105
+ 'moccasin',
106
+ 'navajowhite',
107
+ 'navy',
108
+ 'oldlace',
109
+ 'olive',
110
+ 'olivedrab',
111
+ 'orange',
112
+ 'orangered',
113
+ 'orchid',
114
+ 'palegoldenrod',
115
+ 'palegreen',
116
+ 'paleturquoise',
117
+ 'palevioletred',
118
+ 'papayawhip',
119
+ 'peachpuff',
120
+ 'peru',
121
+ 'pink',
122
+ 'plum',
123
+ 'powderblue',
124
+ 'purple',
125
+ 'rebeccapurple',
126
+ 'red',
127
+ 'rosybrown',
128
+ 'royalblue',
129
+ 'saddlebrown',
130
+ 'salmon',
131
+ 'sandybrown',
132
+ 'seagreen',
133
+ 'seashell',
134
+ 'sienna',
135
+ 'silver',
136
+ 'skyblue',
137
+ 'slateblue',
138
+ 'slategray',
139
+ 'slategrey',
140
+ 'snow',
141
+ 'springgreen',
142
+ 'steelblue',
143
+ 'tan',
144
+ 'teal',
145
+ 'thistle',
146
+ 'tomato',
147
+ 'turquoise',
148
+ 'violet',
149
+ 'wheat',
150
+ 'white',
151
+ 'whitesmoke',
152
+ 'yellow',
153
+ 'yellowgreen',
154
+ ]);
155
+
156
+ const HEX_PATTERN = /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/i;
157
+ const FUNCTIONAL_PATTERN = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\s*\(/i;
158
+ const KEYWORD_SPLIT_PATTERN = /[^a-z]+/i;
159
+
160
+ /** Kind of raw color found in a literal, used to pick the report message. */
161
+ export type RawColorKind = 'hex' | 'function' | 'keyword';
162
+
163
+ /** A raw color match with the exact text that triggered it. */
164
+ export interface RawColorMatch {
165
+ readonly kind: RawColorKind;
166
+ readonly text: string;
167
+ }
168
+
169
+ function findKeyword(value: string): string | undefined {
170
+ for (const word of value.split(KEYWORD_SPLIT_PATTERN)) {
171
+ if (word !== '' && CSS_COLOR_KEYWORDS.has(word.toLowerCase())) return word;
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /**
177
+ * Finds the first raw CSS color in a string. Hex and functional notations are
178
+ * unambiguous anywhere; keyword matches are only meaningful in a style-like
179
+ * context, which the caller decides.
180
+ */
181
+ export function findRawColor(value: string, includeKeywords: boolean): RawColorMatch | undefined {
182
+ const hex = HEX_PATTERN.exec(value);
183
+ if (hex) return { kind: 'hex', text: hex[0] };
184
+
185
+ const functional = FUNCTIONAL_PATTERN.exec(value);
186
+ if (functional) return { kind: 'function', text: functional[0].replace(/\s*\($/, '') };
187
+
188
+ if (!includeKeywords) return undefined;
189
+ const keyword = findKeyword(value);
190
+ return keyword === undefined ? undefined : { kind: 'keyword', text: keyword };
191
+ }
192
+
193
+ /** True when the value is a CSS color keyword on its own. */
194
+ export function isCssColorKeyword(value: string): boolean {
195
+ return CSS_COLOR_KEYWORDS.has(value.trim().toLowerCase());
196
+ }
@@ -0,0 +1,107 @@
1
+ import type { DesignTokens, ThemeColor } from './schema.js';
2
+
3
+ interface FlatToken {
4
+ readonly path: string;
5
+ readonly value: number | string;
6
+ }
7
+
8
+ function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
10
+ }
11
+
12
+ function isThemeColor(value: unknown): value is ThemeColor {
13
+ return isRecord(value) && typeof value['light'] === 'string' && typeof value['dark'] === 'string';
14
+ }
15
+
16
+ function flattenColors(
17
+ value: unknown,
18
+ path: readonly string[],
19
+ theme: 'light' | 'dark',
20
+ output: FlatToken[],
21
+ ): void {
22
+ if (isThemeColor(value)) {
23
+ output.push({ path: path.join('-'), value: value[theme] });
24
+ return;
25
+ }
26
+ if (isRecord(value)) {
27
+ for (const key of Object.keys(value).sort()) {
28
+ flattenColors(value[key], [...path, key], theme, output);
29
+ }
30
+ }
31
+ }
32
+
33
+ function flattenScalars(value: unknown, path: readonly string[], output: FlatToken[]): void {
34
+ if (typeof value === 'string' || typeof value === 'number') {
35
+ output.push({ path: path.join('-'), value });
36
+ return;
37
+ }
38
+ if (isRecord(value)) {
39
+ for (const key of Object.keys(value).sort()) {
40
+ flattenScalars(value[key], [...path, key], output);
41
+ }
42
+ }
43
+ }
44
+
45
+ function kebabCase(path: string): string {
46
+ return path.replaceAll(/([a-z\d])([A-Z])/gu, '$1-$2').toLowerCase();
47
+ }
48
+
49
+ function declaration(token: FlatToken): string {
50
+ return ` --bk-${kebabCase(token.path)}: ${String(token.value)};`;
51
+ }
52
+
53
+ function comparePaths(left: FlatToken, right: FlatToken): number {
54
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
55
+ }
56
+
57
+ /** Compiles a deterministic light root and dark-theme override block. */
58
+ export function toCssVariables(tokens: DesignTokens): string {
59
+ const light: FlatToken[] = [];
60
+ const dark: FlatToken[] = [];
61
+ flattenColors(tokens.color, ['color'], 'light', light);
62
+ flattenColors(tokens.color, ['color'], 'dark', dark);
63
+ for (const group of ['typography', 'space', 'radius', 'motion', 'elevation'] as const) {
64
+ flattenScalars(tokens[group], [group], light);
65
+ }
66
+ light.sort(comparePaths);
67
+ dark.sort(comparePaths);
68
+
69
+ return [
70
+ ':root {',
71
+ ...light.map(declaration),
72
+ '}',
73
+ '',
74
+ '[data-theme="dark"] {',
75
+ ...dark.map(declaration),
76
+ '}',
77
+ '',
78
+ ].join('\n');
79
+ }
80
+
81
+ function sortDeep(value: unknown): unknown {
82
+ if (Array.isArray(value)) {
83
+ return value.map((item: unknown) => sortDeep(item));
84
+ }
85
+ if (isRecord(value)) {
86
+ return Object.fromEntries(
87
+ Object.keys(value)
88
+ .sort()
89
+ .map((key) => [key, sortDeep(value[key])]),
90
+ );
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /** Compiles deterministic, nested TypeScript constants for React Native. */
96
+ export function toReactNative(tokens: DesignTokens): string {
97
+ const runtimeTokens = {
98
+ color: tokens.color,
99
+ typography: tokens.typography,
100
+ space: tokens.space,
101
+ radius: tokens.radius,
102
+ motion: tokens.motion,
103
+ elevation: tokens.elevation,
104
+ };
105
+ const json = JSON.stringify(sortDeep(runtimeTokens), undefined, 2);
106
+ return `// Generated by @baukit/ui-tokens. Do not edit.\nexport const tokens = ${json} as const;\n\nexport type Tokens = typeof tokens;\n`;
107
+ }
@@ -0,0 +1,232 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ blendColors,
5
+ checkSemanticContrastMatrix,
6
+ chooseReadableForeground,
7
+ contrastRatio,
8
+ DEFAULT_SEMANTIC_CONTRAST_REQUIREMENTS,
9
+ exampleContrastViolations,
10
+ exampleTokens,
11
+ hexToRgb,
12
+ normalizeHexColor,
13
+ rgbToHex,
14
+ type DesignTokens,
15
+ } from './index.js';
16
+
17
+ describe('normalizeHexColor', () => {
18
+ it.each([
19
+ ['#000', '#000000'],
20
+ ['fff', '#ffffff'],
21
+ ['#AbC', '#aabbcc'],
22
+ ['12EfA0', '#12efa0'],
23
+ ['#ABCDEF', '#abcdef'],
24
+ ])('normalizes %s to %s', (input, expected) => {
25
+ expect(normalizeHexColor(input)).toBe(expected);
26
+ });
27
+
28
+ it.each(['', '#12', '#1234', '#12345', '#1234567', 'red', ' #fff', '#ggg'])(
29
+ 'rejects unsupported input %j',
30
+ (input) => {
31
+ expect(() => normalizeHexColor(input)).toThrow('expected RGB or RRGGBB with an optional #');
32
+ },
33
+ );
34
+ });
35
+
36
+ describe('hex and RGB conversion', () => {
37
+ it('converts both directions at channel boundaries', () => {
38
+ expect(hexToRgb('#00ff7f')).toEqual({ r: 0, g: 255, b: 127 });
39
+ expect(rgbToHex({ r: 0, g: 255, b: 127 })).toBe('#00ff7f');
40
+ });
41
+
42
+ it('uses the same hexadecimal validation when converting to RGB', () => {
43
+ expect(() => hexToRgb('#abcd')).toThrow('Unsupported hexadecimal color');
44
+ });
45
+
46
+ it.each([
47
+ [{ r: -1, g: 0, b: 0 }, 'r'],
48
+ [{ r: 0, g: 256, b: 0 }, 'g'],
49
+ [{ r: 0, g: 0.5, b: 0 }, 'g'],
50
+ [{ r: 0, g: 0, b: Number.NaN }, 'b'],
51
+ [{ r: 0, g: 0, b: Number.POSITIVE_INFINITY }, 'b'],
52
+ ] as const)('rejects invalid RGB channels', (color, channel) => {
53
+ expect(() => rgbToHex(color)).toThrow(`RGB channel ${channel}`);
54
+ });
55
+ });
56
+
57
+ describe('blendColors', () => {
58
+ it('blends opaque colors and rounds channels', () => {
59
+ expect(blendColors('#ffffff', '#000000', 0.5)).toBe('#808080');
60
+ });
61
+
62
+ it('clamps ratios outside the unit interval', () => {
63
+ expect(blendColors('#fff', '#000', -0.1)).toBe('#000000');
64
+ expect(blendColors('#fff', '#000', 1.1)).toBe('#ffffff');
65
+ expect(blendColors('#fff', '#000', 0)).toBe('#000000');
66
+ expect(blendColors('#fff', '#000', 1)).toBe('#ffffff');
67
+ });
68
+
69
+ it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
70
+ 'rejects a non-finite ratio %s',
71
+ (ratio) => {
72
+ expect(() => blendColors('#fff', '#000', ratio)).toThrow('Blend ratio must be finite');
73
+ },
74
+ );
75
+
76
+ it('rejects invalid colors', () => {
77
+ expect(() => blendColors('white', '#000', 0.5)).toThrow('Unsupported hexadecimal color');
78
+ });
79
+ });
80
+
81
+ describe('chooseReadableForeground', () => {
82
+ it('returns the first candidate that meets the requested ratio', () => {
83
+ const result = chooseReadableForeground('#ffffff', ['#777777', '#000000'], 4.5);
84
+
85
+ expect(result.foreground).toBe('#000000');
86
+ expect(result.ratio).toBe(21);
87
+ expect(result.meetsThreshold).toBe(true);
88
+ });
89
+
90
+ it('normalizes and returns the first passing candidate', () => {
91
+ const result = chooseReadableForeground('FFFFFF', ['#000', '#111111'], 4.5);
92
+
93
+ expect(result.foreground).toBe('#000000');
94
+ expect(result.meetsThreshold).toBe(true);
95
+ });
96
+
97
+ it('returns the best candidate and achieved ratio when none meets the threshold', () => {
98
+ const result = chooseReadableForeground('#777777', ['#888888', '#999999'], 7);
99
+
100
+ expect(result.foreground).toBe('#999999');
101
+ expect(result.ratio).toBeCloseTo(1.5718, 4);
102
+ expect(result.meetsThreshold).toBe(false);
103
+ });
104
+
105
+ it('accepts the minimum and maximum possible contrast thresholds', () => {
106
+ expect(chooseReadableForeground('#fff', ['#fff'], 1).meetsThreshold).toBe(true);
107
+ expect(chooseReadableForeground('#fff', ['#000'], 21).meetsThreshold).toBe(true);
108
+ });
109
+
110
+ it('rejects an empty candidate list', () => {
111
+ expect(() => chooseReadableForeground('#fff', [], 4.5)).toThrow(
112
+ 'must contain at least one color',
113
+ );
114
+ });
115
+
116
+ it.each([0.99, 21.01, Number.NaN, Number.POSITIVE_INFINITY])(
117
+ 'rejects an invalid minimum ratio %s',
118
+ (minimumRatio) => {
119
+ expect(() => chooseReadableForeground('#fff', ['#000'], minimumRatio)).toThrow(
120
+ 'Minimum contrast ratio must be finite and between 1 and 21',
121
+ );
122
+ },
123
+ );
124
+
125
+ it('rejects invalid background and candidate colors', () => {
126
+ expect(() => chooseReadableForeground('white', ['#000'], 4.5)).toThrow(
127
+ 'Unsupported hexadecimal color',
128
+ );
129
+ expect(() => chooseReadableForeground('#fff', ['black'], 4.5)).toThrow(
130
+ 'Unsupported hexadecimal color',
131
+ );
132
+ expect(() => chooseReadableForeground('#fff', ['#000', 'black'], 4.5)).toThrow(
133
+ 'Unsupported hexadecimal color',
134
+ );
135
+ });
136
+ });
137
+
138
+ describe('checkSemanticContrastMatrix', () => {
139
+ it('passes the documented default matrix for the example tokens', () => {
140
+ expect(DEFAULT_SEMANTIC_CONTRAST_REQUIREMENTS).toHaveLength(13);
141
+ expect(exampleContrastViolations).toEqual([]);
142
+ });
143
+
144
+ it('reports a failing pair in exactly one theme', () => {
145
+ const tokens: DesignTokens = {
146
+ ...exampleTokens,
147
+ color: {
148
+ ...exampleTokens.color,
149
+ text: {
150
+ ...exampleTokens.color.text,
151
+ primary: { light: '#777777', dark: '#ffffff' },
152
+ },
153
+ },
154
+ };
155
+ const requirement = {
156
+ foregroundRole: 'color.text.primary',
157
+ backgroundRole: 'color.background.primary',
158
+ minimumRatio: 4.5,
159
+ } as const;
160
+
161
+ const violations = checkSemanticContrastMatrix(tokens, [requirement]);
162
+
163
+ expect(violations).toHaveLength(1);
164
+ expect(violations[0]).toMatchObject({
165
+ ...requirement,
166
+ theme: 'light',
167
+ });
168
+ expect(violations[0]?.achievedRatio).toBeCloseTo(contrastRatio('#777', '#fff'), 10);
169
+ });
170
+
171
+ it('reports every failing requirement in both themes', () => {
172
+ const requirements = [
173
+ {
174
+ foregroundRole: 'color.text.primary',
175
+ backgroundRole: 'color.background.primary',
176
+ minimumRatio: 21,
177
+ },
178
+ {
179
+ foregroundRole: 'color.text.muted',
180
+ backgroundRole: 'color.background.primary',
181
+ minimumRatio: 21,
182
+ },
183
+ ] as const;
184
+
185
+ expect(checkSemanticContrastMatrix(exampleTokens, requirements)).toHaveLength(4);
186
+ });
187
+
188
+ it('accepts an empty matrix and the minimum possible ratio', () => {
189
+ expect(checkSemanticContrastMatrix(exampleTokens, [])).toEqual([]);
190
+ expect(
191
+ checkSemanticContrastMatrix(exampleTokens, [
192
+ {
193
+ foregroundRole: 'color.text.primary',
194
+ backgroundRole: 'color.background.primary',
195
+ minimumRatio: 1,
196
+ },
197
+ ]),
198
+ ).toEqual([]);
199
+ });
200
+
201
+ it('rejects missing role paths and invalid minimum ratios', () => {
202
+ expect(() =>
203
+ checkSemanticContrastMatrix(exampleTokens, [
204
+ {
205
+ foregroundRole: 'color.text.missing',
206
+ backgroundRole: 'color.background.primary',
207
+ minimumRatio: 4.5,
208
+ },
209
+ ]),
210
+ ).toThrow('does not reference a color token');
211
+ expect(() =>
212
+ checkSemanticContrastMatrix(exampleTokens, [
213
+ {
214
+ foregroundRole: 'text.primary',
215
+ backgroundRole: 'color.background.primary',
216
+ minimumRatio: 4.5,
217
+ },
218
+ ]),
219
+ ).toThrow('must start with "color."');
220
+ for (const minimumRatio of [0.99, 21.01, Number.NaN, Number.POSITIVE_INFINITY]) {
221
+ expect(() =>
222
+ checkSemanticContrastMatrix(exampleTokens, [
223
+ {
224
+ foregroundRole: 'color.text.primary',
225
+ backgroundRole: 'color.background.primary',
226
+ minimumRatio,
227
+ },
228
+ ]),
229
+ ).toThrow('Minimum contrast ratio must be finite and between 1 and 21');
230
+ }
231
+ });
232
+ });