@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 +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/contrast.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import type { DesignTokens, ThemeColor } from './schema.js';
|
|
2
|
+
|
|
3
|
+
export type ThemeName = 'light' | 'dark';
|
|
4
|
+
|
|
5
|
+
export interface RgbColor {
|
|
6
|
+
readonly r: number;
|
|
7
|
+
readonly g: number;
|
|
8
|
+
readonly b: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ReadableForegroundResult {
|
|
12
|
+
readonly foreground: string;
|
|
13
|
+
readonly ratio: number;
|
|
14
|
+
readonly meetsThreshold: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SemanticContrastRequirement {
|
|
18
|
+
readonly foregroundRole: string;
|
|
19
|
+
readonly backgroundRole: string;
|
|
20
|
+
readonly minimumRatio: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SemanticContrastViolation {
|
|
24
|
+
readonly foregroundRole: string;
|
|
25
|
+
readonly backgroundRole: string;
|
|
26
|
+
readonly theme: ThemeName;
|
|
27
|
+
readonly achievedRatio: number;
|
|
28
|
+
readonly minimumRatio: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ContrastViolation {
|
|
32
|
+
readonly foreground: string;
|
|
33
|
+
readonly background: string;
|
|
34
|
+
readonly theme: ThemeName;
|
|
35
|
+
readonly ratio: number;
|
|
36
|
+
readonly requiredRatio: 3 | 4.5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default role paths and contrast rules for product palettes. Products can
|
|
41
|
+
* extend this list or replace it when their semantic token model differs.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_SEMANTIC_CONTRAST_REQUIREMENTS: readonly SemanticContrastRequirement[] = [
|
|
44
|
+
{
|
|
45
|
+
foregroundRole: 'color.text.primary',
|
|
46
|
+
backgroundRole: 'color.background.primary',
|
|
47
|
+
minimumRatio: 4.5,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
foregroundRole: 'color.text.primary',
|
|
51
|
+
backgroundRole: 'color.background.elevated',
|
|
52
|
+
minimumRatio: 4.5,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
foregroundRole: 'color.text.muted',
|
|
56
|
+
backgroundRole: 'color.background.primary',
|
|
57
|
+
minimumRatio: 4.5,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
foregroundRole: 'color.text.muted',
|
|
61
|
+
backgroundRole: 'color.background.elevated',
|
|
62
|
+
minimumRatio: 4.5,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
foregroundRole: 'color.border.primary',
|
|
66
|
+
backgroundRole: 'color.background.primary',
|
|
67
|
+
minimumRatio: 3,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
foregroundRole: 'color.border.primary',
|
|
71
|
+
backgroundRole: 'color.background.elevated',
|
|
72
|
+
minimumRatio: 3,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
foregroundRole: 'color.focus.ring',
|
|
76
|
+
backgroundRole: 'color.background.primary',
|
|
77
|
+
minimumRatio: 3,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
foregroundRole: 'color.focus.ring',
|
|
81
|
+
backgroundRole: 'color.background.elevated',
|
|
82
|
+
minimumRatio: 3,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
foregroundRole: 'color.action.onPrimary',
|
|
86
|
+
backgroundRole: 'color.action.primary',
|
|
87
|
+
minimumRatio: 4.5,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
foregroundRole: 'color.action.onSecondary',
|
|
91
|
+
backgroundRole: 'color.action.secondary',
|
|
92
|
+
minimumRatio: 4.5,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
foregroundRole: 'color.status.onSuccess',
|
|
96
|
+
backgroundRole: 'color.status.success',
|
|
97
|
+
minimumRatio: 4.5,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
foregroundRole: 'color.status.onWarning',
|
|
101
|
+
backgroundRole: 'color.status.warning',
|
|
102
|
+
minimumRatio: 4.5,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
foregroundRole: 'color.status.onDanger',
|
|
106
|
+
backgroundRole: 'color.status.danger',
|
|
107
|
+
minimumRatio: 4.5,
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/** Normalizes an RGB or RRGGBB hexadecimal color to lowercase #rrggbb. */
|
|
112
|
+
export function normalizeHexColor(color: string): string {
|
|
113
|
+
const match = /^#?([\dA-Fa-f]{3}|[\dA-Fa-f]{6})$/u.exec(color);
|
|
114
|
+
if (match?.[1] === undefined) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
`Unsupported hexadecimal color ${JSON.stringify(color)}; expected RGB or RRGGBB with an optional #.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const digits = match[1];
|
|
120
|
+
const expanded =
|
|
121
|
+
digits.length === 3
|
|
122
|
+
? `${digits.charAt(0)}${digits.charAt(0)}${digits.charAt(1)}${digits.charAt(1)}${digits.charAt(2)}${digits.charAt(2)}`
|
|
123
|
+
: digits;
|
|
124
|
+
return `#${expanded.toLowerCase()}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Converts a supported hexadecimal color to integer sRGB channels. */
|
|
128
|
+
export function hexToRgb(color: string): RgbColor {
|
|
129
|
+
const normalized = normalizeHexColor(color);
|
|
130
|
+
return {
|
|
131
|
+
r: Number.parseInt(normalized.slice(1, 3), 16),
|
|
132
|
+
g: Number.parseInt(normalized.slice(3, 5), 16),
|
|
133
|
+
b: Number.parseInt(normalized.slice(5, 7), 16),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function validateRgbChannel(name: keyof RgbColor, value: number): void {
|
|
138
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
|
139
|
+
throw new RangeError(
|
|
140
|
+
`RGB channel ${name} must be an integer between 0 and 255; received ${String(value)}.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Converts integer sRGB channels to a lowercase six-digit hexadecimal color. */
|
|
146
|
+
export function rgbToHex(color: RgbColor): string {
|
|
147
|
+
validateRgbChannel('r', color.r);
|
|
148
|
+
validateRgbChannel('g', color.g);
|
|
149
|
+
validateRgbChannel('b', color.b);
|
|
150
|
+
return `#${[color.r, color.g, color.b]
|
|
151
|
+
.map((value) => value.toString(16).padStart(2, '0'))
|
|
152
|
+
.join('')}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Blends the first opaque color over the second with a ratio clamped to [0, 1]. */
|
|
156
|
+
export function blendColors(
|
|
157
|
+
foreground: string,
|
|
158
|
+
background: string,
|
|
159
|
+
foregroundRatio: number,
|
|
160
|
+
): string {
|
|
161
|
+
if (!Number.isFinite(foregroundRatio)) {
|
|
162
|
+
throw new RangeError(`Blend ratio must be finite; received ${String(foregroundRatio)}.`);
|
|
163
|
+
}
|
|
164
|
+
const ratio = Math.max(0, Math.min(1, foregroundRatio));
|
|
165
|
+
const foregroundRgb = hexToRgb(foreground);
|
|
166
|
+
const backgroundRgb = hexToRgb(background);
|
|
167
|
+
return rgbToHex({
|
|
168
|
+
r: Math.round(foregroundRgb.r * ratio + backgroundRgb.r * (1 - ratio)),
|
|
169
|
+
g: Math.round(foregroundRgb.g * ratio + backgroundRgb.g * (1 - ratio)),
|
|
170
|
+
b: Math.round(foregroundRgb.b * ratio + backgroundRgb.b * (1 - ratio)),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function channel(hex: string, offset: number): number {
|
|
175
|
+
const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
|
|
176
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** WCAG 2.x relative luminance for an opaque hexadecimal sRGB color. */
|
|
180
|
+
export function relativeLuminance(color: string): number {
|
|
181
|
+
const hex = normalizeHexColor(color).slice(1);
|
|
182
|
+
return 0.2126 * channel(hex, 0) + 0.7152 * channel(hex, 2) + 0.0722 * channel(hex, 4);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** WCAG contrast ratio, always between 1 and 21 inclusive. */
|
|
186
|
+
export function contrastRatio(foreground: string, background: string): number {
|
|
187
|
+
const foregroundLuminance = relativeLuminance(foreground);
|
|
188
|
+
const backgroundLuminance = relativeLuminance(background);
|
|
189
|
+
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
|
190
|
+
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
|
191
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateMinimumRatio(minimumRatio: number): void {
|
|
195
|
+
if (!Number.isFinite(minimumRatio) || minimumRatio < 1 || minimumRatio > 21) {
|
|
196
|
+
throw new RangeError(
|
|
197
|
+
`Minimum contrast ratio must be finite and between 1 and 21; received ${String(minimumRatio)}.`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Returns the first candidate that meets the threshold. If none does, returns
|
|
204
|
+
* the candidate with the highest ratio and sets `meetsThreshold` to false.
|
|
205
|
+
*/
|
|
206
|
+
export function chooseReadableForeground(
|
|
207
|
+
background: string,
|
|
208
|
+
candidates: readonly string[],
|
|
209
|
+
minimumRatio: number,
|
|
210
|
+
): ReadableForegroundResult {
|
|
211
|
+
validateMinimumRatio(minimumRatio);
|
|
212
|
+
const normalizedBackground = normalizeHexColor(background);
|
|
213
|
+
const normalizedCandidates = candidates.map((candidate) => normalizeHexColor(candidate));
|
|
214
|
+
const firstCandidate = normalizedCandidates[0];
|
|
215
|
+
if (firstCandidate === undefined) {
|
|
216
|
+
throw new RangeError('Readable foreground candidates must contain at least one color.');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const evaluate = (foreground: string): ReadableForegroundResult => {
|
|
220
|
+
const ratio = contrastRatio(foreground, normalizedBackground);
|
|
221
|
+
return { foreground, ratio, meetsThreshold: ratio >= minimumRatio };
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
let best = evaluate(firstCandidate);
|
|
225
|
+
if (best.meetsThreshold) return best;
|
|
226
|
+
for (const candidate of normalizedCandidates.slice(1)) {
|
|
227
|
+
const result = evaluate(candidate);
|
|
228
|
+
if (result.meetsThreshold) return result;
|
|
229
|
+
if (result.ratio > best.ratio) best = result;
|
|
230
|
+
}
|
|
231
|
+
return best;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function colorAt(tokens: DesignTokens, path: string): ThemeColor {
|
|
235
|
+
const segments = path.split('.');
|
|
236
|
+
if (segments.shift() !== 'color' || segments.length === 0) {
|
|
237
|
+
throw new TypeError(`Semantic color role ${JSON.stringify(path)} must start with "color.".`);
|
|
238
|
+
}
|
|
239
|
+
let current: unknown = tokens.color;
|
|
240
|
+
for (const segment of segments) {
|
|
241
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
|
242
|
+
throw new TypeError(
|
|
243
|
+
`Semantic color role ${JSON.stringify(path)} does not reference a color token.`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
current = (current as Record<string, unknown>)[segment];
|
|
247
|
+
}
|
|
248
|
+
if (
|
|
249
|
+
current === null ||
|
|
250
|
+
typeof current !== 'object' ||
|
|
251
|
+
Array.isArray(current) ||
|
|
252
|
+
typeof (current as Record<string, unknown>)['light'] !== 'string' ||
|
|
253
|
+
typeof (current as Record<string, unknown>)['dark'] !== 'string'
|
|
254
|
+
) {
|
|
255
|
+
throw new TypeError(
|
|
256
|
+
`Semantic color role ${JSON.stringify(path)} does not reference a color token.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return current as ThemeColor;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Checks every declared pair in both themes and returns only failures. */
|
|
263
|
+
export function checkContrast(tokens: DesignTokens): ContrastViolation[] {
|
|
264
|
+
const violations: ContrastViolation[] = [];
|
|
265
|
+
for (const pair of tokens.contrastPairs) {
|
|
266
|
+
const foreground = colorAt(tokens, pair.foreground);
|
|
267
|
+
const background = colorAt(tokens, pair.background);
|
|
268
|
+
const requiredRatio = pair.largeText === true ? 3 : 4.5;
|
|
269
|
+
for (const theme of ['light', 'dark'] as const) {
|
|
270
|
+
const ratio = contrastRatio(foreground[theme], background[theme]);
|
|
271
|
+
if (ratio < requiredRatio) {
|
|
272
|
+
violations.push({
|
|
273
|
+
foreground: pair.foreground,
|
|
274
|
+
background: pair.background,
|
|
275
|
+
theme,
|
|
276
|
+
ratio,
|
|
277
|
+
requiredRatio,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return violations;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Checks every semantic requirement in both themes and returns every failure. */
|
|
286
|
+
export function checkSemanticContrastMatrix(
|
|
287
|
+
tokens: DesignTokens,
|
|
288
|
+
requirements: readonly SemanticContrastRequirement[],
|
|
289
|
+
): SemanticContrastViolation[] {
|
|
290
|
+
const violations: SemanticContrastViolation[] = [];
|
|
291
|
+
for (const requirement of requirements) {
|
|
292
|
+
validateMinimumRatio(requirement.minimumRatio);
|
|
293
|
+
const foreground = colorAt(tokens, requirement.foregroundRole);
|
|
294
|
+
const background = colorAt(tokens, requirement.backgroundRole);
|
|
295
|
+
for (const theme of ['light', 'dark'] as const) {
|
|
296
|
+
const achievedRatio = contrastRatio(foreground[theme], background[theme]);
|
|
297
|
+
if (achievedRatio < requirement.minimumRatio) {
|
|
298
|
+
violations.push({
|
|
299
|
+
foregroundRole: requirement.foregroundRole,
|
|
300
|
+
backgroundRole: requirement.backgroundRole,
|
|
301
|
+
theme,
|
|
302
|
+
achievedRatio,
|
|
303
|
+
minimumRatio: requirement.minimumRatio,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return violations;
|
|
309
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { RuleTester } from 'eslint';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { configs, noRawColor, rules } from './eslint.js';
|
|
5
|
+
|
|
6
|
+
RuleTester.describe = describe;
|
|
7
|
+
RuleTester.it = it;
|
|
8
|
+
RuleTester.itOnly = it.only;
|
|
9
|
+
|
|
10
|
+
const ruleTester = new RuleTester({
|
|
11
|
+
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const jsx = new RuleTester({
|
|
15
|
+
languageOptions: {
|
|
16
|
+
ecmaVersion: 2022,
|
|
17
|
+
sourceType: 'module',
|
|
18
|
+
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
ruleTester.run('no-raw-color', noRawColor, {
|
|
23
|
+
valid: [
|
|
24
|
+
{ name: 'token reference', code: 'const style = { color: theme.textPrimary };' },
|
|
25
|
+
{ name: 'allowed default keyword', code: 'const style = { backgroundColor: "transparent" };' },
|
|
26
|
+
{ name: 'currentColor', code: 'const style = { fill: "currentColor" };' },
|
|
27
|
+
{
|
|
28
|
+
name: 'keyword outside a style context',
|
|
29
|
+
code: 'const route = "red"; const id = "black-list";',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'non-color string in a style context',
|
|
33
|
+
code: 'const style = { color: theme.get("primary") };',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'allow-listed literal',
|
|
37
|
+
code: 'const style = { color: "#123456" };',
|
|
38
|
+
options: [{ allowedValues: ['#123456'] }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'token definition file',
|
|
42
|
+
code: 'export const palette = { accent: "#0b57d0" };',
|
|
43
|
+
filename: '/repo/src/theme/tokens.ts',
|
|
44
|
+
options: [{ allowedFiles: ['src/theme/**'] }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'keyword reporting disabled',
|
|
48
|
+
code: 'const style = { color: "red" };',
|
|
49
|
+
options: [{ reportKeywords: false }],
|
|
50
|
+
},
|
|
51
|
+
{ name: 'hash that is not a color', code: 'const anchor = "#section-heading";' },
|
|
52
|
+
{ name: 'plain sentence', code: 'const copy = "the red car is fast";' },
|
|
53
|
+
],
|
|
54
|
+
invalid: [
|
|
55
|
+
{
|
|
56
|
+
name: 'six-digit hex anywhere',
|
|
57
|
+
code: 'const accent = "#0b57d0";',
|
|
58
|
+
errors: [{ messageId: 'rawHex', data: { value: '#0b57d0' } }],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'three-digit hex',
|
|
62
|
+
code: 'const accent = "#fff";',
|
|
63
|
+
errors: [{ messageId: 'rawHex' }],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'eight-digit hex with alpha',
|
|
67
|
+
code: 'const overlay = "#0b57d080";',
|
|
68
|
+
errors: [{ messageId: 'rawHex' }],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'rgb function',
|
|
72
|
+
code: 'const style = { color: "rgb(11, 87, 208)" };',
|
|
73
|
+
errors: [{ messageId: 'rawFunction', data: { value: 'rgb' } }],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'rgba function',
|
|
77
|
+
code: 'const style = { backgroundColor: "rgba(0, 0, 0, 0.5)" };',
|
|
78
|
+
errors: [{ messageId: 'rawFunction' }],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'hsl function',
|
|
82
|
+
code: 'const style = { borderColor: "hsl(210 100% 50%)" };',
|
|
83
|
+
errors: [{ messageId: 'rawFunction' }],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'hsla function',
|
|
87
|
+
code: 'const style = { shadowColor: "hsla(210, 100%, 50%, 0.4)" };',
|
|
88
|
+
errors: [{ messageId: 'rawFunction' }],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'keyword in a style property',
|
|
92
|
+
code: 'const style = { color: "rebeccapurple" };',
|
|
93
|
+
errors: [{ messageId: 'rawKeyword', data: { value: 'rebeccapurple' } }],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'keyword nested under a style property',
|
|
97
|
+
code: 'const style = { colors: { danger: "crimson" } };',
|
|
98
|
+
errors: [{ messageId: 'rawKeyword' }],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: 'keyword inside a StyleSheet.create call',
|
|
102
|
+
code: 'const styles = StyleSheet.create({ card: { backgroundColor: "white" } });',
|
|
103
|
+
errors: [{ messageId: 'rawKeyword' }],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'template literal in a css tag',
|
|
107
|
+
code: 'const card = css`color: #0b57d0;`;',
|
|
108
|
+
errors: [{ messageId: 'rawHex' }],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'template literal with an expression around a hex',
|
|
112
|
+
code: 'const shade = `${base} #ff0000`;',
|
|
113
|
+
errors: [{ messageId: 'rawHex' }],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'style-named variable holding a keyword',
|
|
117
|
+
code: 'const cardStyle = { borderColor: "silver" };',
|
|
118
|
+
errors: [{ messageId: 'rawKeyword' }],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'file outside the allow-list',
|
|
122
|
+
code: 'const accent = "#0b57d0";',
|
|
123
|
+
filename: '/repo/src/components/card.ts',
|
|
124
|
+
options: [{ allowedFiles: ['src/theme/**'] }],
|
|
125
|
+
errors: [{ messageId: 'rawHex' }],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'custom style property name',
|
|
129
|
+
code: 'const props = { chartFill: "teal" };',
|
|
130
|
+
options: [{ additionalStyleProperties: ['chartFill'] }],
|
|
131
|
+
errors: [{ messageId: 'rawKeyword' }],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
jsx.run('no-raw-color in JSX', noRawColor, {
|
|
137
|
+
valid: [
|
|
138
|
+
{ name: 'token expression', code: 'const el = <View style={{ color: theme.accent }} />;' },
|
|
139
|
+
{ name: 'non-style attribute keyword', code: 'const el = <View testID="red" />;' },
|
|
140
|
+
],
|
|
141
|
+
invalid: [
|
|
142
|
+
{
|
|
143
|
+
name: 'inline style attribute',
|
|
144
|
+
code: 'const el = <View style={{ backgroundColor: "#0b57d0" }} />;',
|
|
145
|
+
errors: [{ messageId: 'rawHex' }],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'color attribute keyword',
|
|
149
|
+
code: 'const el = <Icon color="tomato" />;',
|
|
150
|
+
errors: [{ messageId: 'rawKeyword' }],
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('plugin exports', () => {
|
|
156
|
+
it('exposes the rule and a recommended flat config', () => {
|
|
157
|
+
expect(rules['no-raw-color']).toBe(noRawColor);
|
|
158
|
+
expect(configs.recommended.rules).toEqual({ '@baukit/ui-tokens/no-raw-color': 'error' });
|
|
159
|
+
expect(configs.recommended.plugins['@baukit/ui-tokens'].rules).toBe(rules);
|
|
160
|
+
});
|
|
161
|
+
});
|