@baukit/ui-tokens 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/color-literals.ts +196 -0
- package/src/compiler.ts +107 -0
- package/src/contrast.test.ts +232 -0
- package/src/contrast.ts +309 -0
- package/src/eslint.test.ts +161 -0
- package/src/eslint.ts +283 -0
- package/src/example.ts +61 -0
- package/src/index.test.ts +270 -0
- package/src/index.ts +6 -0
- package/src/layout.test.ts +124 -0
- package/src/layout.ts +81 -0
- package/src/schema.ts +46 -0
- package/src/validation.ts +237 -0
package/src/eslint.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type { Rule } from 'eslint';
|
|
2
|
+
import type { Node } from 'estree';
|
|
3
|
+
|
|
4
|
+
import { findRawColor, isCssColorKeyword, type RawColorKind } from './color-literals.js';
|
|
5
|
+
|
|
6
|
+
/** Options accepted by the `no-raw-color` rule. */
|
|
7
|
+
export interface NoRawColorOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Glob-like file patterns whose contents may declare raw colors. A token
|
|
10
|
+
* definition file belongs here. `*` matches within a path segment, `**`
|
|
11
|
+
* across segments.
|
|
12
|
+
*/
|
|
13
|
+
readonly allowedFiles?: readonly string[];
|
|
14
|
+
/** Exact literal values that stay allowed everywhere, such as `transparent`. */
|
|
15
|
+
readonly allowedValues?: readonly string[];
|
|
16
|
+
/** Extra property or attribute names treated as style-like. */
|
|
17
|
+
readonly additionalStyleProperties?: readonly string[];
|
|
18
|
+
/** Report CSS color keywords such as `red`. Defaults to `true`. */
|
|
19
|
+
readonly reportKeywords?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_ALLOWED_VALUES = ['transparent', 'currentColor', 'inherit', 'none'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Property and attribute names whose values are styling input. Keyword matching
|
|
26
|
+
* is limited to these so that a route name or a test id is never reported.
|
|
27
|
+
*/
|
|
28
|
+
const STYLE_PROPERTY_NAMES = new Set([
|
|
29
|
+
'backgroundColor',
|
|
30
|
+
'background',
|
|
31
|
+
'borderColor',
|
|
32
|
+
'borderBottomColor',
|
|
33
|
+
'borderLeftColor',
|
|
34
|
+
'borderRightColor',
|
|
35
|
+
'borderTopColor',
|
|
36
|
+
'borderStartColor',
|
|
37
|
+
'borderEndColor',
|
|
38
|
+
'color',
|
|
39
|
+
'fill',
|
|
40
|
+
'stroke',
|
|
41
|
+
'shadowColor',
|
|
42
|
+
'textShadowColor',
|
|
43
|
+
'textDecorationColor',
|
|
44
|
+
'tintColor',
|
|
45
|
+
'placeholderTextColor',
|
|
46
|
+
'selectionColor',
|
|
47
|
+
'underlayColor',
|
|
48
|
+
'outlineColor',
|
|
49
|
+
'caretColor',
|
|
50
|
+
'colors',
|
|
51
|
+
'style',
|
|
52
|
+
'styles',
|
|
53
|
+
'theme',
|
|
54
|
+
'palette',
|
|
55
|
+
'gradient',
|
|
56
|
+
'boxShadow',
|
|
57
|
+
'textShadow',
|
|
58
|
+
'borderTop',
|
|
59
|
+
'borderBottom',
|
|
60
|
+
'borderLeft',
|
|
61
|
+
'borderRight',
|
|
62
|
+
'border',
|
|
63
|
+
'outline',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Tagged-template tags whose contents are CSS. */
|
|
67
|
+
const CSS_TEMPLATE_TAGS = new Set([
|
|
68
|
+
'css',
|
|
69
|
+
'styled',
|
|
70
|
+
'createGlobalStyle',
|
|
71
|
+
'keyframes',
|
|
72
|
+
'injectGlobal',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
function globToRegExp(pattern: string): RegExp {
|
|
76
|
+
let source = '';
|
|
77
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
78
|
+
const character = pattern[index];
|
|
79
|
+
if (character === '*') {
|
|
80
|
+
if (pattern[index + 1] === '*') {
|
|
81
|
+
source += '.*';
|
|
82
|
+
index += 1;
|
|
83
|
+
} else {
|
|
84
|
+
source += '[^/]*';
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (character === '?') {
|
|
89
|
+
source += '[^/]';
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
source += character?.replace(/[.+^${}()|[\]\\]/g, '\\$&') ?? '';
|
|
93
|
+
}
|
|
94
|
+
return new RegExp(`(?:^|/)(?:${source})$`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isAllowedFile(filename: string, patterns: readonly string[]): boolean {
|
|
98
|
+
const normalized = filename.replaceAll('\\', '/');
|
|
99
|
+
return patterns.some((pattern) => globToRegExp(pattern).test(normalized));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface EstreeParent {
|
|
103
|
+
readonly parent?: (Node & EstreeParent) | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type WithParent = Node & EstreeParent;
|
|
107
|
+
|
|
108
|
+
function propertyKeyName(node: WithParent): string | undefined {
|
|
109
|
+
const parent = node.parent;
|
|
110
|
+
if (parent === undefined || parent === null) return undefined;
|
|
111
|
+
if (parent.type === 'Property' && parent.value === (node as Node)) {
|
|
112
|
+
const key = parent.key as WithParent;
|
|
113
|
+
if (key.type === 'Identifier') return key.name;
|
|
114
|
+
if (key.type === 'Literal' && typeof key.value === 'string') return key.value;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Walks up from a literal to decide whether it lands in a styling position: a
|
|
121
|
+
* style-named property or JSX attribute, a CSS tagged template, or a nested
|
|
122
|
+
* object or array under one of those.
|
|
123
|
+
*/
|
|
124
|
+
function isStyleContext(node: WithParent, styleNames: ReadonlySet<string>): boolean {
|
|
125
|
+
let current: WithParent | null | undefined = node;
|
|
126
|
+
let child: WithParent | undefined;
|
|
127
|
+
|
|
128
|
+
while (current !== undefined && current !== null) {
|
|
129
|
+
if (current.type === 'TaggedTemplateExpression') {
|
|
130
|
+
const tag = current.tag as WithParent;
|
|
131
|
+
if (tag.type === 'Identifier' && CSS_TEMPLATE_TAGS.has(tag.name)) return true;
|
|
132
|
+
if (
|
|
133
|
+
tag.type === 'MemberExpression' &&
|
|
134
|
+
tag.object.type === 'Identifier' &&
|
|
135
|
+
CSS_TEMPLATE_TAGS.has(tag.object.name)
|
|
136
|
+
) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (current.type === 'Property' && child !== undefined && current.value === (child as Node)) {
|
|
142
|
+
const name = propertyKeyName(child);
|
|
143
|
+
if (name !== undefined && styleNames.has(name)) return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const jsxName = jsxAttributeName(current);
|
|
147
|
+
if (jsxName !== undefined && styleNames.has(jsxName)) return true;
|
|
148
|
+
|
|
149
|
+
if (current.type === 'VariableDeclarator') {
|
|
150
|
+
const id = current.id as WithParent;
|
|
151
|
+
if (id.type === 'Identifier' && looksLikeStyleName(id.name)) return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (isStyleSheetCall(current)) return true;
|
|
155
|
+
|
|
156
|
+
child = current;
|
|
157
|
+
current = current.parent;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface JsxAttributeLike {
|
|
163
|
+
readonly type: string;
|
|
164
|
+
readonly name?: { readonly name?: string };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function jsxAttributeName(current: WithParent): string | undefined {
|
|
168
|
+
const candidate = current as unknown as JsxAttributeLike;
|
|
169
|
+
return candidate.type === 'JSXAttribute' ? candidate.name?.name : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function looksLikeStyleName(name: string): boolean {
|
|
173
|
+
return /(^|[a-z])(style|styles|theme|palette|colors?)$/i.test(name);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isStyleSheetCall(node: WithParent): boolean {
|
|
177
|
+
if (node.type !== 'CallExpression') return false;
|
|
178
|
+
const callee = node.callee as WithParent;
|
|
179
|
+
return (
|
|
180
|
+
callee.type === 'MemberExpression' &&
|
|
181
|
+
callee.object.type === 'Identifier' &&
|
|
182
|
+
callee.object.name === 'StyleSheet' &&
|
|
183
|
+
callee.property.type === 'Identifier' &&
|
|
184
|
+
callee.property.name === 'create'
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const MESSAGE_IDS: Record<RawColorKind, 'rawHex' | 'rawFunction' | 'rawKeyword'> = {
|
|
189
|
+
hex: 'rawHex',
|
|
190
|
+
function: 'rawFunction',
|
|
191
|
+
keyword: 'rawKeyword',
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Reports CSS colors written directly in application code. Colors belong in the
|
|
196
|
+
* token definition, which the `allowedFiles` option exempts.
|
|
197
|
+
*/
|
|
198
|
+
export const noRawColor: Rule.RuleModule = {
|
|
199
|
+
meta: {
|
|
200
|
+
type: 'problem',
|
|
201
|
+
docs: {
|
|
202
|
+
description: 'Disallow raw CSS colors outside the design-token definition',
|
|
203
|
+
},
|
|
204
|
+
schema: [
|
|
205
|
+
{
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {
|
|
208
|
+
allowedFiles: { type: 'array', items: { type: 'string' } },
|
|
209
|
+
allowedValues: { type: 'array', items: { type: 'string' } },
|
|
210
|
+
additionalStyleProperties: { type: 'array', items: { type: 'string' } },
|
|
211
|
+
reportKeywords: { type: 'boolean' },
|
|
212
|
+
},
|
|
213
|
+
additionalProperties: false,
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
messages: {
|
|
217
|
+
rawHex: 'Raw hex color "{{value}}". Use a design token instead.',
|
|
218
|
+
rawFunction: 'Raw {{value}}() color. Use a design token instead.',
|
|
219
|
+
rawKeyword: 'Raw CSS color keyword "{{value}}". Use a design token instead.',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
create(context: Rule.RuleContext): Rule.RuleListener {
|
|
224
|
+
const options = (context.options[0] ?? {}) as NoRawColorOptions;
|
|
225
|
+
const allowedFiles = options.allowedFiles ?? [];
|
|
226
|
+
const allowedValues = new Set(
|
|
227
|
+
[...DEFAULT_ALLOWED_VALUES, ...(options.allowedValues ?? [])].map((value) =>
|
|
228
|
+
value.toLowerCase(),
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
const styleNames = new Set([
|
|
232
|
+
...STYLE_PROPERTY_NAMES,
|
|
233
|
+
...(options.additionalStyleProperties ?? []),
|
|
234
|
+
]);
|
|
235
|
+
const reportKeywords = options.reportKeywords ?? true;
|
|
236
|
+
|
|
237
|
+
if (isAllowedFile(context.filename, allowedFiles)) return {};
|
|
238
|
+
|
|
239
|
+
const check = (node: WithParent, value: string): void => {
|
|
240
|
+
if (allowedValues.has(value.trim().toLowerCase())) return;
|
|
241
|
+
|
|
242
|
+
const styled = isStyleContext(node, styleNames);
|
|
243
|
+
const keywordsHere = reportKeywords && styled;
|
|
244
|
+
const match = findRawColor(value, keywordsHere);
|
|
245
|
+
if (match === undefined) return;
|
|
246
|
+
if (match.kind === 'keyword' && !isCssColorKeyword(value) && !styled) return;
|
|
247
|
+
|
|
248
|
+
context.report({
|
|
249
|
+
node,
|
|
250
|
+
messageId: MESSAGE_IDS[match.kind],
|
|
251
|
+
data: { value: match.text },
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
Literal(node) {
|
|
257
|
+
if (typeof node.value === 'string') check(node, node.value);
|
|
258
|
+
},
|
|
259
|
+
TemplateLiteral(node) {
|
|
260
|
+
const raw = node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(' ');
|
|
261
|
+
check(node, raw);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/** Rules exported by this plugin, keyed by rule name. */
|
|
268
|
+
export const rules = { 'no-raw-color': noRawColor };
|
|
269
|
+
|
|
270
|
+
const plugin = { rules };
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Ready-made flat config. Spread it into a product's `eslint.config.js` and pass
|
|
274
|
+
* `allowedFiles` so the token definition may keep its color literals.
|
|
275
|
+
*/
|
|
276
|
+
export const configs = {
|
|
277
|
+
recommended: {
|
|
278
|
+
plugins: { '@baukit/ui-tokens': plugin },
|
|
279
|
+
rules: { '@baukit/ui-tokens/no-raw-color': 'error' },
|
|
280
|
+
},
|
|
281
|
+
} as const;
|
|
282
|
+
|
|
283
|
+
export default plugin;
|
package/src/example.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DesignTokens } from './schema.js';
|
|
2
|
+
import { checkSemanticContrastMatrix, DEFAULT_SEMANTIC_CONTRAST_REQUIREMENTS } from './contrast.js';
|
|
3
|
+
|
|
4
|
+
/** A deliberately small fixture that demonstrates the pipeline, not a design system. */
|
|
5
|
+
export const exampleTokens = {
|
|
6
|
+
color: {
|
|
7
|
+
background: {
|
|
8
|
+
primary: { light: '#ffffff', dark: '#111111' },
|
|
9
|
+
elevated: { light: '#f5f5f5', dark: '#222222' },
|
|
10
|
+
accent: { light: '#005fcc', dark: '#66aaff' },
|
|
11
|
+
},
|
|
12
|
+
text: {
|
|
13
|
+
primary: { light: '#111111', dark: '#ffffff' },
|
|
14
|
+
muted: { light: '#595959', dark: '#b3b3b3' },
|
|
15
|
+
onAccent: { light: '#ffffff', dark: '#111111' },
|
|
16
|
+
},
|
|
17
|
+
border: {
|
|
18
|
+
primary: { light: '#767676', dark: '#888888' },
|
|
19
|
+
},
|
|
20
|
+
focus: {
|
|
21
|
+
ring: { light: '#005fcc', dark: '#66aaff' },
|
|
22
|
+
},
|
|
23
|
+
action: {
|
|
24
|
+
primary: { light: '#005fcc', dark: '#66aaff' },
|
|
25
|
+
onPrimary: { light: '#ffffff', dark: '#111111' },
|
|
26
|
+
secondary: { light: '#5f3dc4', dark: '#b197fc' },
|
|
27
|
+
onSecondary: { light: '#ffffff', dark: '#111111' },
|
|
28
|
+
},
|
|
29
|
+
status: {
|
|
30
|
+
success: { light: '#137333', dark: '#65d48b' },
|
|
31
|
+
onSuccess: { light: '#ffffff', dark: '#111111' },
|
|
32
|
+
warning: { light: '#8a4b00', dark: '#ffd166' },
|
|
33
|
+
onWarning: { light: '#ffffff', dark: '#111111' },
|
|
34
|
+
danger: { light: '#b3261e', dark: '#ff8a80' },
|
|
35
|
+
onDanger: { light: '#ffffff', dark: '#111111' },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
typography: {
|
|
39
|
+
family: { body: 'Inter, sans-serif' },
|
|
40
|
+
size: { body: '1rem', title: '2rem' },
|
|
41
|
+
weight: { regular: 400, bold: 700 },
|
|
42
|
+
lineHeight: { body: 1.5, title: 1.2 },
|
|
43
|
+
},
|
|
44
|
+
space: { small: 8, medium: 16 },
|
|
45
|
+
radius: { small: 4, pill: 999 },
|
|
46
|
+
motion: {
|
|
47
|
+
duration: { fast: '120ms', normal: '240ms' },
|
|
48
|
+
easing: { standard: 'cubic-bezier(0.2, 0, 0, 1)' },
|
|
49
|
+
},
|
|
50
|
+
elevation: { raised: '0 2px 8px rgb(0 0 0 / 0.2)' },
|
|
51
|
+
contrastPairs: [
|
|
52
|
+
{ foreground: 'color.text.primary', background: 'color.background.primary' },
|
|
53
|
+
{ foreground: 'color.text.onAccent', background: 'color.background.accent' },
|
|
54
|
+
],
|
|
55
|
+
} as const satisfies DesignTokens;
|
|
56
|
+
|
|
57
|
+
/** Example result for the package's default semantic contrast requirements. */
|
|
58
|
+
export const exampleContrastViolations = checkSemanticContrastMatrix(
|
|
59
|
+
exampleTokens,
|
|
60
|
+
DEFAULT_SEMANTIC_CONTRAST_REQUIREMENTS,
|
|
61
|
+
);
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
checkContrast,
|
|
5
|
+
contrastRatio,
|
|
6
|
+
exampleTokens,
|
|
7
|
+
parseTokens,
|
|
8
|
+
relativeLuminance,
|
|
9
|
+
toCssVariables,
|
|
10
|
+
toReactNative,
|
|
11
|
+
TokenValidationError,
|
|
12
|
+
validateTokens,
|
|
13
|
+
type DesignTokens,
|
|
14
|
+
} from './index.js';
|
|
15
|
+
|
|
16
|
+
const minimalTokens = {
|
|
17
|
+
color: {
|
|
18
|
+
background: { primary: { light: '#fff', dark: '#000' } },
|
|
19
|
+
text: { primary: { light: '#000', dark: '#fff' } },
|
|
20
|
+
},
|
|
21
|
+
typography: {
|
|
22
|
+
family: { body: 'Inter' },
|
|
23
|
+
size: { body: 16 },
|
|
24
|
+
weight: { regular: 400 },
|
|
25
|
+
lineHeight: { body: 1.5 },
|
|
26
|
+
},
|
|
27
|
+
space: { small: 8 },
|
|
28
|
+
radius: { small: 4 },
|
|
29
|
+
motion: { duration: { fast: 120 }, easing: { standard: 'linear' } },
|
|
30
|
+
elevation: { raised: 2 },
|
|
31
|
+
contrastPairs: [{ foreground: 'color.text.primary', background: 'color.background.primary' }],
|
|
32
|
+
} as const satisfies DesignTokens;
|
|
33
|
+
|
|
34
|
+
describe('token validation', () => {
|
|
35
|
+
it('accepts the example token set', () => {
|
|
36
|
+
expect(validateTokens(exampleTokens)).toEqual([]);
|
|
37
|
+
expect(parseTokens(exampleTokens)).toBe(exampleTokens);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('reports path-qualified structural and scalar errors', () => {
|
|
41
|
+
const invalid = {
|
|
42
|
+
...exampleTokens,
|
|
43
|
+
typography: {
|
|
44
|
+
...exampleTokens.typography,
|
|
45
|
+
size: { body: -1 },
|
|
46
|
+
},
|
|
47
|
+
motion: {
|
|
48
|
+
...exampleTokens.motion,
|
|
49
|
+
easing: { 'Bad Name': '' },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
expect(validateTokens(invalid)).toEqual(
|
|
53
|
+
expect.arrayContaining([
|
|
54
|
+
{
|
|
55
|
+
path: '$.typography.size.body',
|
|
56
|
+
message: 'must be a valid positive dimension',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: '$.motion.easing.Bad Name',
|
|
60
|
+
message: 'must start with a lowercase letter and contain only letters or digits',
|
|
61
|
+
},
|
|
62
|
+
{ path: '$.motion.easing.Bad Name', message: 'must be a valid string' },
|
|
63
|
+
]),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects invalid colors and unresolved contrast references with exact paths', () => {
|
|
68
|
+
const invalid = {
|
|
69
|
+
...exampleTokens,
|
|
70
|
+
color: {
|
|
71
|
+
...exampleTokens.color,
|
|
72
|
+
text: { primary: { light: 'black', dark: '#fff' } },
|
|
73
|
+
},
|
|
74
|
+
contrastPairs: [{ foreground: 'color.text.missing', background: 'color.background.primary' }],
|
|
75
|
+
};
|
|
76
|
+
expect(validateTokens(invalid)).toEqual(
|
|
77
|
+
expect.arrayContaining([
|
|
78
|
+
{
|
|
79
|
+
path: '$.color.text.primary.light',
|
|
80
|
+
message: 'must be a #RGB or #RRGGBB hexadecimal color',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
path: '$.contrastPairs[0].foreground',
|
|
84
|
+
message: 'must reference an existing color token path',
|
|
85
|
+
},
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws an aggregate TokenValidationError from parseTokens', () => {
|
|
91
|
+
expect(() => parseTokens(null)).toThrow(TokenValidationError);
|
|
92
|
+
expect(() => parseTokens(null)).toThrow('$: must be an object');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('CSS compiler', () => {
|
|
97
|
+
it('emits exact deterministic CSS', () => {
|
|
98
|
+
expect(toCssVariables(minimalTokens)).toBe(`:root {
|
|
99
|
+
--bk-color-background-primary: #fff;
|
|
100
|
+
--bk-color-text-primary: #000;
|
|
101
|
+
--bk-elevation-raised: 2;
|
|
102
|
+
--bk-motion-duration-fast: 120;
|
|
103
|
+
--bk-motion-easing-standard: linear;
|
|
104
|
+
--bk-radius-small: 4;
|
|
105
|
+
--bk-space-small: 8;
|
|
106
|
+
--bk-typography-family-body: Inter;
|
|
107
|
+
--bk-typography-line-height-body: 1.5;
|
|
108
|
+
--bk-typography-size-body: 16;
|
|
109
|
+
--bk-typography-weight-regular: 400;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
[data-theme="dark"] {
|
|
113
|
+
--bk-color-background-primary: #000;
|
|
114
|
+
--bk-color-text-primary: #fff;
|
|
115
|
+
}
|
|
116
|
+
`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('emits semantic variables and a dark override', () => {
|
|
120
|
+
const css = toCssVariables(exampleTokens);
|
|
121
|
+
expect(css).toContain('--bk-color-background-accent: #005fcc;');
|
|
122
|
+
expect(css).toContain('--bk-typography-line-height-body: 1.5;');
|
|
123
|
+
expect(css).toContain('--bk-color-background-accent: #66aaff;');
|
|
124
|
+
expect(css).toContain('[data-theme="dark"] {');
|
|
125
|
+
expect(css.endsWith('\n')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('is deterministic regardless of object insertion order', () => {
|
|
129
|
+
const reordered = {
|
|
130
|
+
...exampleTokens,
|
|
131
|
+
space: { medium: 16, small: 8 },
|
|
132
|
+
color: {
|
|
133
|
+
...exampleTokens.color,
|
|
134
|
+
text: exampleTokens.color.text,
|
|
135
|
+
background: exampleTokens.color.background,
|
|
136
|
+
},
|
|
137
|
+
} satisfies DesignTokens;
|
|
138
|
+
expect(toCssVariables(reordered)).toBe(toCssVariables(exampleTokens));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('React Native compiler', () => {
|
|
143
|
+
it('emits an exact typed constants module', () => {
|
|
144
|
+
expect(toReactNative(minimalTokens)).toBe(`// Generated by @baukit/ui-tokens. Do not edit.
|
|
145
|
+
export const tokens = {
|
|
146
|
+
"color": {
|
|
147
|
+
"background": {
|
|
148
|
+
"primary": {
|
|
149
|
+
"dark": "#000",
|
|
150
|
+
"light": "#fff"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"text": {
|
|
154
|
+
"primary": {
|
|
155
|
+
"dark": "#fff",
|
|
156
|
+
"light": "#000"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"elevation": {
|
|
161
|
+
"raised": 2
|
|
162
|
+
},
|
|
163
|
+
"motion": {
|
|
164
|
+
"duration": {
|
|
165
|
+
"fast": 120
|
|
166
|
+
},
|
|
167
|
+
"easing": {
|
|
168
|
+
"standard": "linear"
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"radius": {
|
|
172
|
+
"small": 4
|
|
173
|
+
},
|
|
174
|
+
"space": {
|
|
175
|
+
"small": 8
|
|
176
|
+
},
|
|
177
|
+
"typography": {
|
|
178
|
+
"family": {
|
|
179
|
+
"body": "Inter"
|
|
180
|
+
},
|
|
181
|
+
"lineHeight": {
|
|
182
|
+
"body": 1.5
|
|
183
|
+
},
|
|
184
|
+
"size": {
|
|
185
|
+
"body": 16
|
|
186
|
+
},
|
|
187
|
+
"weight": {
|
|
188
|
+
"regular": 400
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} as const;
|
|
192
|
+
|
|
193
|
+
export type Tokens = typeof tokens;
|
|
194
|
+
`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('emits a typed nested constants module without accessibility metadata', () => {
|
|
198
|
+
const output = toReactNative(exampleTokens);
|
|
199
|
+
expect(output).toContain(
|
|
200
|
+
'// Generated by @baukit/ui-tokens. Do not edit.\nexport const tokens = {',
|
|
201
|
+
);
|
|
202
|
+
expect(output).toContain('"primary": {\n "dark": "#111111",');
|
|
203
|
+
expect(output).not.toContain('contrastPairs');
|
|
204
|
+
expect(output).toMatch(/as const;\n\nexport type Tokens = typeof tokens;\n$/u);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('sorts nested objects deterministically', () => {
|
|
208
|
+
const reordered = {
|
|
209
|
+
...exampleTokens,
|
|
210
|
+
radius: { pill: 999, small: 4 },
|
|
211
|
+
} satisfies DesignTokens;
|
|
212
|
+
expect(toReactNative(reordered)).toBe(toReactNative(exampleTokens));
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('WCAG contrast', () => {
|
|
217
|
+
it('calculates the known black/white ratio of 21', () => {
|
|
218
|
+
expect(relativeLuminance('#000000')).toBe(0);
|
|
219
|
+
expect(relativeLuminance('#ffffff')).toBe(1);
|
|
220
|
+
expect(contrastRatio('#000', '#fff')).toBe(21);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('accepts all declared example pairs', () => {
|
|
224
|
+
expect(checkContrast(exampleTokens)).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('reports actual ratios and thresholds for each failing theme', () => {
|
|
228
|
+
const failing: DesignTokens = {
|
|
229
|
+
...exampleTokens,
|
|
230
|
+
color: {
|
|
231
|
+
...exampleTokens.color,
|
|
232
|
+
text: {
|
|
233
|
+
...exampleTokens.color.text,
|
|
234
|
+
weak: { light: '#777777', dark: '#777777' },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
contrastPairs: [{ foreground: 'color.text.weak', background: 'color.background.primary' }],
|
|
238
|
+
};
|
|
239
|
+
const violations = checkContrast(failing);
|
|
240
|
+
expect(violations).toHaveLength(2);
|
|
241
|
+
expect(violations[0]).toMatchObject({
|
|
242
|
+
foreground: 'color.text.weak',
|
|
243
|
+
theme: 'light',
|
|
244
|
+
requiredRatio: 4.5,
|
|
245
|
+
});
|
|
246
|
+
expect(violations[0]?.ratio).toBeCloseTo(4.478, 3);
|
|
247
|
+
expect(violations[1]).toMatchObject({ theme: 'dark', requiredRatio: 4.5 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('uses the 3:1 threshold for declared large text', () => {
|
|
251
|
+
const largeText: DesignTokens = {
|
|
252
|
+
...exampleTokens,
|
|
253
|
+
color: {
|
|
254
|
+
...exampleTokens.color,
|
|
255
|
+
text: {
|
|
256
|
+
...exampleTokens.color.text,
|
|
257
|
+
large: { light: '#767676', dark: '#888888' },
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
contrastPairs: [
|
|
261
|
+
{
|
|
262
|
+
foreground: 'color.text.large',
|
|
263
|
+
background: 'color.background.primary',
|
|
264
|
+
largeText: true,
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
expect(checkContrast(largeText)).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
});
|