@urbicon-ui/design-engine 6.1.8
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/README.md +36 -0
- package/package.json +47 -0
- package/src/index.ts +23 -0
- package/src/linter/heuristics.ts +609 -0
- package/src/linter/index.ts +23 -0
- package/src/linter/linter.test.ts +509 -0
- package/src/linter/linter.ts +96 -0
- package/src/linter/markup-rules.test.ts +109 -0
- package/src/linter/markup-rules.ts +209 -0
- package/src/linter/markup.test.ts +139 -0
- package/src/linter/markup.ts +274 -0
- package/src/linter/rules.ts +354 -0
- package/src/linter/tokens.test.ts +111 -0
- package/src/linter/tokens.ts +230 -0
- package/src/linter/types.ts +119 -0
- package/src/manifest/history.test.ts +65 -0
- package/src/manifest/history.ts +57 -0
- package/src/manifest/index.ts +27 -0
- package/src/manifest/manifest.test.ts +338 -0
- package/src/manifest/manifest.ts +439 -0
- package/src/manifest/scan.test.ts +51 -0
- package/src/manifest/scan.ts +74 -0
- package/src/manifest/types.ts +98 -0
- package/src/rubric/index.ts +10 -0
- package/src/rubric/rubric.test.ts +43 -0
- package/src/rubric/rubric.ts +140 -0
- package/src/search/index.ts +12 -0
- package/src/search/match.ts +115 -0
- package/src/search/search.test.ts +195 -0
- package/src/search/section.ts +47 -0
- package/src/search/types.ts +44 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { lintDesign, maskComments } from './linter.js';
|
|
3
|
+
import type { Finding } from './types.js';
|
|
4
|
+
|
|
5
|
+
function ids(findings: Finding[]): string[] {
|
|
6
|
+
return findings.map((f) => f.ruleId);
|
|
7
|
+
}
|
|
8
|
+
function has(findings: Finding[], ruleId: string): boolean {
|
|
9
|
+
return findings.some((f) => f.ruleId === ruleId);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('raw-tailwind-color', () => {
|
|
13
|
+
it('flags numbered chromatic palette utilities', () => {
|
|
14
|
+
const { findings } = lintDesign(
|
|
15
|
+
'<div class="bg-blue-500 text-red-600 border-l-amber-400">x</div>'
|
|
16
|
+
);
|
|
17
|
+
expect(findings.filter((f) => f.ruleId === 'raw-tailwind-color')).toHaveLength(3);
|
|
18
|
+
});
|
|
19
|
+
it('flags opacity-suffixed raw colours', () => {
|
|
20
|
+
expect(has(lintDesign('<div class="bg-green-500/40">').findings, 'raw-tailwind-color')).toBe(
|
|
21
|
+
true
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
it('does NOT flag library tokens (intents, neutral, surfaces)', () => {
|
|
25
|
+
const { findings } = lintDesign(
|
|
26
|
+
'<div class="bg-primary-500 bg-neutral-100 bg-surface-base text-success border-border-subtle">'
|
|
27
|
+
);
|
|
28
|
+
expect(has(findings, 'raw-tailwind-color')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('dark-mode-override', () => {
|
|
33
|
+
it('flags dark: variants', () => {
|
|
34
|
+
expect(
|
|
35
|
+
has(
|
|
36
|
+
lintDesign('<div class="bg-white dark:bg-surface-elevated">').findings,
|
|
37
|
+
'dark-mode-override'
|
|
38
|
+
)
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
it('flags the important-modifier form dark:!', () => {
|
|
42
|
+
expect(
|
|
43
|
+
has(lintDesign('<div class="dark:!bg-surface-elevated">').findings, 'dark-mode-override')
|
|
44
|
+
).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('does NOT flag plain semantic tokens', () => {
|
|
47
|
+
expect(
|
|
48
|
+
has(lintDesign('<div class="bg-surface-elevated">').findings, 'dark-mode-override')
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('focus-not-visible', () => {
|
|
54
|
+
it('flags focus: and group-focus:', () => {
|
|
55
|
+
expect(has(lintDesign('<button class="focus:ring-2">').findings, 'focus-not-visible')).toBe(
|
|
56
|
+
true
|
|
57
|
+
);
|
|
58
|
+
expect(
|
|
59
|
+
has(lintDesign('<div class="group-focus:opacity-100">').findings, 'focus-not-visible')
|
|
60
|
+
).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('does NOT flag focus-visible: or focus-within:', () => {
|
|
63
|
+
const { findings } = lintDesign(
|
|
64
|
+
'<button class="focus-visible:ring-2 focus-within:bg-surface-hover">'
|
|
65
|
+
);
|
|
66
|
+
expect(has(findings, 'focus-not-visible')).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('hardcoded-z-index', () => {
|
|
71
|
+
it('flags numeric and bracketed z-index', () => {
|
|
72
|
+
expect(has(lintDesign('<div class="z-10">').findings, 'hardcoded-z-index')).toBe(true);
|
|
73
|
+
expect(has(lintDesign('<div class="z-[999]">').findings, 'hardcoded-z-index')).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it('does NOT flag the z-index token form or z-auto', () => {
|
|
76
|
+
const { findings } = lintDesign('<div class="z-[var(--z-modal)] z-auto">');
|
|
77
|
+
expect(has(findings, 'hardcoded-z-index')).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('dynamic-class-interpolation', () => {
|
|
82
|
+
it('flags interpolated Tailwind utility fragments', () => {
|
|
83
|
+
expect(
|
|
84
|
+
has(
|
|
85
|
+
lintDesign("<div class=\"gap-{isHero ? '4' : '3'}\">").findings,
|
|
86
|
+
'dynamic-class-interpolation'
|
|
87
|
+
)
|
|
88
|
+
).toBe(true);
|
|
89
|
+
expect(
|
|
90
|
+
has(lintDesign('<div class={`py-${pad}`}>').findings, 'dynamic-class-interpolation')
|
|
91
|
+
).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
it('does NOT flag interpolation that is not a Tailwind root (ids, data keys)', () => {
|
|
94
|
+
const { findings } = lintDesign('<label for={`field-${id}`}>');
|
|
95
|
+
expect(has(findings, 'dynamic-class-interpolation')).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('token-hallucination', () => {
|
|
100
|
+
it('flags invented status-* and -fg tokens', () => {
|
|
101
|
+
expect(has(lintDesign('<div class="bg-status-danger">').findings, 'token-hallucination')).toBe(
|
|
102
|
+
true
|
|
103
|
+
);
|
|
104
|
+
expect(has(lintDesign('<div class="text-success-fg">').findings, 'token-hallucination')).toBe(
|
|
105
|
+
true
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
it('flags intent-with-bad-suffix and namespace typos', () => {
|
|
109
|
+
expect(has(lintDesign('<div class="bg-primary-muted">').findings, 'token-hallucination')).toBe(
|
|
110
|
+
true
|
|
111
|
+
);
|
|
112
|
+
expect(has(lintDesign('<div class="bg-surface-raised">').findings, 'token-hallucination')).toBe(
|
|
113
|
+
true
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
it('does NOT flag valid tokens', () => {
|
|
117
|
+
const valid =
|
|
118
|
+
'<div class="bg-surface-subtle text-text-primary bg-primary bg-primary-500 text-success bg-feedback-success-subtle border-border-strong">';
|
|
119
|
+
expect(has(lintDesign(valid).findings, 'token-hallucination')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
it('does NOT flag genuine Tailwind utilities or arbitrary values', () => {
|
|
122
|
+
const { findings } = lintDesign(
|
|
123
|
+
'<div class="bg-transparent bg-[#fff] text-sm bg-cover from-transparent">'
|
|
124
|
+
);
|
|
125
|
+
expect(has(findings, 'token-hallucination')).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
it('does NOT flag font-size cores sharing the text- namespace', () => {
|
|
128
|
+
const { findings } = lintDesign('<div class="bg-text-sm text-text-2xl border-text-base">');
|
|
129
|
+
expect(has(findings, 'token-hallucination')).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
it('keeps the opacity suffix in the reported match', () => {
|
|
132
|
+
const f = lintDesign('<div class="bg-surface-raised/50">').findings.find(
|
|
133
|
+
(x) => x.ruleId === 'token-hallucination'
|
|
134
|
+
);
|
|
135
|
+
expect(f?.match).toBe('bg-surface-raised/50');
|
|
136
|
+
});
|
|
137
|
+
it('flags shadcn/ui vocabulary — the top hallucination source (round-3 finding)', () => {
|
|
138
|
+
const code =
|
|
139
|
+
'<div class="text-foreground bg-accent text-muted-foreground bg-card bg-surface text-fg text-fg-muted border-card-foreground bg-destructive">x</div>';
|
|
140
|
+
const matches = lintDesign(code)
|
|
141
|
+
.findings.filter((f) => f.ruleId === 'token-hallucination')
|
|
142
|
+
.map((f) => f.match);
|
|
143
|
+
for (const t of [
|
|
144
|
+
'text-foreground',
|
|
145
|
+
'bg-accent',
|
|
146
|
+
'text-muted-foreground',
|
|
147
|
+
'bg-card',
|
|
148
|
+
'bg-surface',
|
|
149
|
+
'text-fg',
|
|
150
|
+
'text-fg-muted',
|
|
151
|
+
'border-card-foreground',
|
|
152
|
+
'bg-destructive'
|
|
153
|
+
]) {
|
|
154
|
+
expect(matches, t).toContain(t);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('extraTokens (per-call whitelist)', () => {
|
|
160
|
+
it('whitelists an otherwise-hallucinated core so it is not flagged', () => {
|
|
161
|
+
// `surface-brand` sits in our namespace but is not a built-in token → normally flagged.
|
|
162
|
+
const code = '<div class="bg-surface-brand">';
|
|
163
|
+
expect(has(lintDesign(code).findings, 'token-hallucination')).toBe(true);
|
|
164
|
+
expect(
|
|
165
|
+
has(lintDesign(code, { extraTokens: ['surface-brand'] }).findings, 'token-hallucination')
|
|
166
|
+
).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('whitelists only the supplied cores, still flagging the rest on the same line', () => {
|
|
170
|
+
const code = '<div class="bg-surface-brand bg-surface-imaginary">';
|
|
171
|
+
const matches = lintDesign(code, { extraTokens: ['surface-brand'] })
|
|
172
|
+
.findings.filter((f) => f.ruleId === 'token-hallucination')
|
|
173
|
+
.map((f) => f.match);
|
|
174
|
+
expect(matches).toContain('bg-surface-imaginary');
|
|
175
|
+
expect(matches).not.toContain('bg-surface-brand');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('cannot weaken the raw-palette gate — extraTokens is scoped to hallucination only', () => {
|
|
179
|
+
// A consumer must not be able to whitelist a raw Tailwind palette colour; a
|
|
180
|
+
// different, error-severity rule owns that and does not consult the whitelist.
|
|
181
|
+
const code = '<div class="bg-blue-500">';
|
|
182
|
+
expect(
|
|
183
|
+
has(lintDesign(code, { extraTokens: ['blue-500'] }).findings, 'raw-tailwind-color')
|
|
184
|
+
).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('ignores blank/whitespace entries (nothing whitelisted → still flagged)', () => {
|
|
188
|
+
const code = '<div class="bg-surface-brand">';
|
|
189
|
+
expect(has(lintDesign(code, { extraTokens: [' ', ''] }).findings, 'token-hallucination')).toBe(
|
|
190
|
+
true
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('heuristics', () => {
|
|
196
|
+
it('flags an intent rainbow of ≥4 chromatic background hues', () => {
|
|
197
|
+
const code =
|
|
198
|
+
'<div class="bg-primary"></div><div class="bg-success"></div><div class="bg-warning"></div><div class="bg-danger"></div>';
|
|
199
|
+
expect(has(lintDesign(code).findings, 'intent-rainbow')).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
it('does NOT count neutral backgrounds toward the rainbow', () => {
|
|
202
|
+
const code =
|
|
203
|
+
'<div class="bg-neutral-100"></div><div class="bg-neutral-200"></div><div class="bg-surface-base"></div><div class="bg-surface-elevated"></div>';
|
|
204
|
+
expect(has(lintDesign(code).findings, 'intent-rainbow')).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
it('flags uniform spacing (one rhythm tier)', () => {
|
|
207
|
+
const code =
|
|
208
|
+
'<div class="gap-4"><div class="gap-4"></div><div class="gap-4"></div><div class="gap-4"></div><div class="gap-4"></div><div class="gap-4"></div></div>';
|
|
209
|
+
expect(has(lintDesign(code).findings, 'spacing-uniform')).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
it('does NOT flag two-tier spacing', () => {
|
|
212
|
+
const code =
|
|
213
|
+
'<div class="gap-10"><div class="gap-3"></div><div class="gap-3"></div><div class="gap-10"></div><div class="gap-3"></div><div class="gap-10"></div></div>';
|
|
214
|
+
expect(has(lintDesign(code).findings, 'spacing-uniform')).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
it('flags identical Cards (no visual-weight variation)', () => {
|
|
217
|
+
const card = '<Card variant="elevated" padding="md">x</Card>';
|
|
218
|
+
expect(has(lintDesign(card.repeat(4)).findings, 'card-monotony')).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
it('does NOT flag differentiated Cards', () => {
|
|
221
|
+
const code =
|
|
222
|
+
'<Card variant="elevated" padding="lg">x</Card><Card variant="outlined" padding="md">x</Card><Card variant="outlined" padding="md">x</Card><Card variant="quiet" padding="sm">x</Card>';
|
|
223
|
+
expect(has(lintDesign(code).findings, 'card-monotony')).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
it('nudges when surfaces exist but no radius strategy does', () => {
|
|
226
|
+
const code = '<Card>a</Card><Card>b</Card><Card>c</Card>';
|
|
227
|
+
expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
it('does NOT nudge once a radius override is present', () => {
|
|
230
|
+
const code =
|
|
231
|
+
'<Card class="rounded-xl">a</Card><Card class="rounded-xl">b</Card><Card class="rounded-xl">c</Card>';
|
|
232
|
+
expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
it('does NOT treat bordered table rows / dividers as surfaces (no false radius nudge)', () => {
|
|
235
|
+
const code =
|
|
236
|
+
'<table><tr class="border-b"><td>a</td></tr><tr class="border-b"><td>b</td></tr><tr class="border-b"><td>c</td></tr></table>';
|
|
237
|
+
expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
it('can be skipped via skipHeuristics', () => {
|
|
240
|
+
const code = '<Card variant="elevated" padding="md">x</Card>'.repeat(4);
|
|
241
|
+
const { findings } = lintDesign(code, { skipHeuristics: true });
|
|
242
|
+
expect(findings.every((f) => f.kind === 'deterministic')).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
it('flags uniform font-weights but not a varied scale', () => {
|
|
245
|
+
expect(
|
|
246
|
+
has(lintDesign('<span class="font-bold">x</span>'.repeat(5)).findings, 'font-weight-uniform')
|
|
247
|
+
).toBe(true);
|
|
248
|
+
const varied =
|
|
249
|
+
'<h1 class="font-bold">A</h1><p class="font-normal">b</p><p class="font-normal">c</p><small class="font-medium">d</small><span class="font-semibold">e</span>';
|
|
250
|
+
expect(has(lintDesign(varied).findings, 'font-weight-uniform')).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('slop-floor rules', () => {
|
|
255
|
+
it('generic-font: flags hardcoded stacks, not family tokens', () => {
|
|
256
|
+
expect(has(lintDesign('<div class="font-[\'Arial\']">x</div>').findings, 'generic-font')).toBe(
|
|
257
|
+
true
|
|
258
|
+
);
|
|
259
|
+
expect(
|
|
260
|
+
has(
|
|
261
|
+
lintDesign('<div style="font-family: Helvetica, sans-serif">x</div>').findings,
|
|
262
|
+
'generic-font'
|
|
263
|
+
)
|
|
264
|
+
).toBe(true);
|
|
265
|
+
expect(
|
|
266
|
+
has(lintDesign('<div class="font-sans font-bold">x</div>').findings, 'generic-font')
|
|
267
|
+
).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('arbitrary-color: flags hex/rgb literals, not var() token refs', () => {
|
|
271
|
+
expect(has(lintDesign('<div class="bg-[#3b82f6]">x</div>').findings, 'arbitrary-color')).toBe(
|
|
272
|
+
true
|
|
273
|
+
);
|
|
274
|
+
expect(
|
|
275
|
+
has(lintDesign('<div class="text-[rgb(0,0,0)]">x</div>').findings, 'arbitrary-color')
|
|
276
|
+
).toBe(true);
|
|
277
|
+
expect(
|
|
278
|
+
has(
|
|
279
|
+
lintDesign('<div class="bg-[var(--color-surface-base)]">x</div>').findings,
|
|
280
|
+
'arbitrary-color'
|
|
281
|
+
)
|
|
282
|
+
).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('transition-all: flags the catch-all, not a specific property', () => {
|
|
286
|
+
expect(
|
|
287
|
+
has(lintDesign('<button class="transition-all">x</button>').findings, 'transition-all')
|
|
288
|
+
).toBe(true);
|
|
289
|
+
expect(
|
|
290
|
+
has(lintDesign('<button class="transition-colors">x</button>').findings, 'transition-all')
|
|
291
|
+
).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('animated-dimensions: flags transitioning layout, not transform/opacity', () => {
|
|
295
|
+
expect(
|
|
296
|
+
has(lintDesign('<div class="transition-[width]">x</div>').findings, 'animated-dimensions')
|
|
297
|
+
).toBe(true);
|
|
298
|
+
expect(
|
|
299
|
+
has(
|
|
300
|
+
lintDesign('<div class="transition-[opacity] transition-transform">x</div>').findings,
|
|
301
|
+
'animated-dimensions'
|
|
302
|
+
)
|
|
303
|
+
).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('magic-dimension: flags off-scale px, not scale utils, ch bounds, or hairlines', () => {
|
|
307
|
+
expect(
|
|
308
|
+
has(lintDesign('<div class="w-[317px] h-[42px]">x</div>').findings, 'magic-dimension')
|
|
309
|
+
).toBe(true);
|
|
310
|
+
expect(
|
|
311
|
+
has(lintDesign('<div class="w-64 max-w-[65ch] h-[1px]">x</div>').findings, 'magic-dimension')
|
|
312
|
+
).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('important-modifier: flags `!util-`, not JS negation', () => {
|
|
316
|
+
expect(
|
|
317
|
+
has(lintDesign('<div class="!p-0 !bg-primary">x</div>').findings, 'important-modifier')
|
|
318
|
+
).toBe(true);
|
|
319
|
+
expect(
|
|
320
|
+
has(lintDesign('<div class={!isOpen ? "p-0" : "p-4"}>x</div>').findings, 'important-modifier')
|
|
321
|
+
).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('inline-style: flags static CSS, not a custom property or interpolated value', () => {
|
|
325
|
+
expect(
|
|
326
|
+
has(lintDesign('<div style="padding: 12px; color: red">x</div>').findings, 'inline-style')
|
|
327
|
+
).toBe(true);
|
|
328
|
+
expect(has(lintDesign('<div style="--progress: 40%">x</div>').findings, 'inline-style')).toBe(
|
|
329
|
+
false
|
|
330
|
+
);
|
|
331
|
+
// Dynamic, interpolated values have no static utility equivalent → legitimate.
|
|
332
|
+
expect(
|
|
333
|
+
has(lintDesign('<div style="left: {x}%; width: {w}%">x</div>').findings, 'inline-style')
|
|
334
|
+
).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('gradient-text: flags bg-clip-text', () => {
|
|
338
|
+
expect(
|
|
339
|
+
has(lintDesign('<h1 class="bg-clip-text text-transparent">x</h1>').findings, 'gradient-text')
|
|
340
|
+
).toBe(true);
|
|
341
|
+
expect(has(lintDesign('<h1 class="text-text-primary">x</h1>').findings, 'gradient-text')).toBe(
|
|
342
|
+
false
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('grey-on-intent: flags muted text on an intent bg, not on a neutral surface', () => {
|
|
347
|
+
expect(
|
|
348
|
+
has(
|
|
349
|
+
lintDesign('<div class="bg-primary text-text-tertiary">x</div>').findings,
|
|
350
|
+
'grey-on-intent'
|
|
351
|
+
)
|
|
352
|
+
).toBe(true);
|
|
353
|
+
expect(
|
|
354
|
+
has(lintDesign('<div class="bg-primary text-on-primary">x</div>').findings, 'grey-on-intent')
|
|
355
|
+
).toBe(false);
|
|
356
|
+
expect(
|
|
357
|
+
has(
|
|
358
|
+
lintDesign('<div class="bg-surface-base text-text-tertiary">x</div>').findings,
|
|
359
|
+
'grey-on-intent'
|
|
360
|
+
)
|
|
361
|
+
).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('centered-bodytext: flags a centred <p>, incl. a wrapped opening tag, not a heading', () => {
|
|
365
|
+
expect(
|
|
366
|
+
has(
|
|
367
|
+
lintDesign('<p class="text-center text-text-secondary">body copy</p>').findings,
|
|
368
|
+
'centered-bodytext'
|
|
369
|
+
)
|
|
370
|
+
).toBe(true);
|
|
371
|
+
// The opening tag often wraps across lines under Prettier — must still match.
|
|
372
|
+
expect(
|
|
373
|
+
has(
|
|
374
|
+
lintDesign('<p\n class="text-center text-text-secondary"\n>body</p>').findings,
|
|
375
|
+
'centered-bodytext'
|
|
376
|
+
)
|
|
377
|
+
).toBe(true);
|
|
378
|
+
expect(
|
|
379
|
+
has(lintDesign('<h1 class="text-center">Title</h1>').findings, 'centered-bodytext')
|
|
380
|
+
).toBe(false);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('placeholder-content: flags lorem ipsum, not a real string or input placeholder', () => {
|
|
384
|
+
expect(
|
|
385
|
+
has(lintDesign('<p>Lorem ipsum dolor sit amet</p>').findings, 'placeholder-content')
|
|
386
|
+
).toBe(true);
|
|
387
|
+
expect(
|
|
388
|
+
has(lintDesign('<input placeholder="Email address" />').findings, 'placeholder-content')
|
|
389
|
+
).toBe(false);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('emoji-as-icon: flags pictographic + emoji-default glyphs, not icons or monochrome text', () => {
|
|
393
|
+
expect(has(lintDesign('<button>🚀 Launch</button>').findings, 'emoji-as-icon')).toBe(true);
|
|
394
|
+
// Emoji-presentation-default glyphs render in colour bare → flagged even without VS16.
|
|
395
|
+
expect(has(lintDesign('<span>✅ Saved</span>').findings, 'emoji-as-icon')).toBe(true);
|
|
396
|
+
expect(has(lintDesign('<li>⭐ Featured</li>').findings, 'emoji-as-icon')).toBe(true);
|
|
397
|
+
expect(
|
|
398
|
+
has(lintDesign('<button><RocketIcon /> Launch</button>').findings, 'emoji-as-icon')
|
|
399
|
+
).toBe(false);
|
|
400
|
+
// Bare monochrome glyphs used as text (text-presentation default, no VS16) are not flagged.
|
|
401
|
+
expect(
|
|
402
|
+
has(lintDesign('<span>✓ done · ⚠ heads up · → next</span>').findings, 'emoji-as-icon')
|
|
403
|
+
).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('heading-skip: flags h1→h3, not a sequential or shallower order', () => {
|
|
407
|
+
expect(has(lintDesign('<h1>A</h1><h3>B</h3>').findings, 'heading-skip')).toBe(true);
|
|
408
|
+
expect(has(lintDesign('<h1>A</h1><h2>B</h2><h3>C</h3>').findings, 'heading-skip')).toBe(false);
|
|
409
|
+
expect(has(lintDesign('<h2>A</h2><h1>B</h1>').findings, 'heading-skip')).toBe(false);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('touch-target-small: flags a tiny interactive element, not a ≥44px one or a min/max bound', () => {
|
|
413
|
+
expect(
|
|
414
|
+
has(lintDesign('<button class="h-6 px-2">x</button>').findings, 'touch-target-small')
|
|
415
|
+
).toBe(true);
|
|
416
|
+
expect(
|
|
417
|
+
has(lintDesign('<button class="h-11 px-4">x</button>').findings, 'touch-target-small')
|
|
418
|
+
).toBe(false);
|
|
419
|
+
// `min-h-`/`max-h-` are a floor/cap, not a fixed sub-44 height — `min-h-11` is the fix.
|
|
420
|
+
expect(
|
|
421
|
+
has(lintDesign('<button class="min-h-11 px-4">x</button>').findings, 'touch-target-small')
|
|
422
|
+
).toBe(false);
|
|
423
|
+
expect(has(lintDesign('<a class="max-h-6">x</a>').findings, 'touch-target-small')).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('justified-text: flags text-justify, not text-left', () => {
|
|
427
|
+
expect(
|
|
428
|
+
has(lintDesign('<p class="text-justify">long copy</p>').findings, 'justified-text')
|
|
429
|
+
).toBe(true);
|
|
430
|
+
expect(has(lintDesign('<p class="text-left">long copy</p>').findings, 'justified-text')).toBe(
|
|
431
|
+
false
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('scores slop, never correctness — and fires at most once per repeated sin', () => {
|
|
436
|
+
// Three inline paint styles are one slop verdict (flat SLOP_WEIGHT), not three.
|
|
437
|
+
const code =
|
|
438
|
+
'<div style="color: red">a</div><div style="color: blue">b</div><div style="background: green">c</div>';
|
|
439
|
+
const { findings, scores } = lintDesign(code);
|
|
440
|
+
const inline = findings.filter((f) => f.ruleId === 'inline-style');
|
|
441
|
+
expect(inline).toHaveLength(1);
|
|
442
|
+
expect(inline[0]?.kind).toBe('heuristic');
|
|
443
|
+
expect(scores.correctness).toBe(100); // pure slop, correctness untouched
|
|
444
|
+
expect(scores.slop).toBe(90); // one −10, not −30
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe('comment masking', () => {
|
|
449
|
+
it('ignores violations inside HTML and block comments', () => {
|
|
450
|
+
const code =
|
|
451
|
+
'<!-- class="focus:ring-2 bg-blue-500" --><div class="bg-surface-base">/* z-50 */</div>';
|
|
452
|
+
const { findings } = lintDesign(code);
|
|
453
|
+
expect(findings).toHaveLength(0);
|
|
454
|
+
});
|
|
455
|
+
it('keeps line numbers correct after masking', () => {
|
|
456
|
+
const masked = maskComments('a\n<!--\nx\n-->\nfocus:ring');
|
|
457
|
+
expect(masked.split('\n')).toHaveLength(5);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('scoring (two axes)', () => {
|
|
462
|
+
it('scores clean code 100/100 on both axes', () => {
|
|
463
|
+
const { scores } = lintDesign('<div class="bg-surface-base text-text-primary">clean</div>');
|
|
464
|
+
expect(scores.correctness).toBe(100);
|
|
465
|
+
expect(scores.slop).toBe(100);
|
|
466
|
+
});
|
|
467
|
+
it('deducts correctness per finding and floors at 0', () => {
|
|
468
|
+
const oneError = lintDesign('<div class="bg-blue-500">');
|
|
469
|
+
expect(oneError.scores.correctness).toBe(90);
|
|
470
|
+
// Per-line dedupe collapses identical hits on one line, so spread distinct hits across lines.
|
|
471
|
+
const many = lintDesign(
|
|
472
|
+
Array.from({ length: 12 }, () => '<div class="bg-blue-500">').join('\n')
|
|
473
|
+
);
|
|
474
|
+
expect(many.scores.correctness).toBe(0);
|
|
475
|
+
});
|
|
476
|
+
it('scores slop on its own axis, leaving correctness untouched', () => {
|
|
477
|
+
// An intent rainbow is pure slop — the tokens are all valid, so correctness stays 100.
|
|
478
|
+
const code =
|
|
479
|
+
'<div class="bg-primary"></div><div class="bg-success"></div><div class="bg-warning"></div><div class="bg-danger"></div>';
|
|
480
|
+
const { scores } = lintDesign(code);
|
|
481
|
+
expect(scores.correctness).toBe(100);
|
|
482
|
+
expect(scores.slop).toBeLessThan(100);
|
|
483
|
+
});
|
|
484
|
+
it('does not let a clean slop axis hide a correctness defect (never mixed)', () => {
|
|
485
|
+
const { scores } = lintDesign('<div class="bg-blue-500">solo defect</div>');
|
|
486
|
+
expect(scores.correctness).toBeLessThan(100);
|
|
487
|
+
expect(scores.slop).toBe(100);
|
|
488
|
+
});
|
|
489
|
+
it('reports severity counts', () => {
|
|
490
|
+
const { counts } = lintDesign('<div class="bg-blue-500 bg-status-x">');
|
|
491
|
+
expect(counts.error).toBeGreaterThanOrEqual(1);
|
|
492
|
+
expect(counts.warning).toBeGreaterThanOrEqual(1);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('rule metadata', () => {
|
|
497
|
+
it('every finding carries a fix hint', () => {
|
|
498
|
+
const code =
|
|
499
|
+
'<button class="bg-blue-500 dark:bg-red-500 focus:ring z-50 gap-{x} bg-status-bad">';
|
|
500
|
+
for (const f of lintDesign(code).findings) {
|
|
501
|
+
expect(f.fix.length).toBeGreaterThan(0);
|
|
502
|
+
expect(f.ruleId.length).toBeGreaterThan(0);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
it('produces a stable id ordering for the same input', () => {
|
|
506
|
+
const code = '<div class="z-50 bg-blue-500">';
|
|
507
|
+
expect(ids(lintDesign(code).findings)).toEqual(ids(lintDesign(code).findings));
|
|
508
|
+
});
|
|
509
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design linter engine: masks comments, runs the deterministic rules and the
|
|
3
|
+
* distribution heuristics, and reduces the findings to a 0–100 score. Pure and
|
|
4
|
+
* dependency-free so it is trivially unit-testable (one of the explicit wins of
|
|
5
|
+
* a linter over prose guidance — see docs/DESIGN-MCP.md, Option B).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { runHeuristics } from './heuristics.js';
|
|
9
|
+
import { RULES } from './rules.js';
|
|
10
|
+
import { resolveValidTokenCores } from './tokens.js';
|
|
11
|
+
import type {
|
|
12
|
+
Finding,
|
|
13
|
+
LintContext,
|
|
14
|
+
LintOptions,
|
|
15
|
+
LintReport,
|
|
16
|
+
LintScores,
|
|
17
|
+
Severity
|
|
18
|
+
} from './types.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-severity deduction on the **correctness** axis. Errors dominate (real
|
|
22
|
+
* defects), warnings are softer (likely-but-not-certain). Centralised for tuning.
|
|
23
|
+
*/
|
|
24
|
+
export const SCORE_WEIGHTS: Record<Severity, number> = {
|
|
25
|
+
error: 10,
|
|
26
|
+
warning: 5,
|
|
27
|
+
info: 2
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Flat deduction per slop-floor heuristic on the **slop** axis. Unlike correctness
|
|
32
|
+
* defects (counted per occurrence — every raw colour is its own bug), each slop
|
|
33
|
+
* heuristic fires at most once and is one holistic judgement about the page, so
|
|
34
|
+
* one flat weight regardless of repetition. Tuned so a page tripping ~5 distinct
|
|
35
|
+
* slop signals lands mid-scale (≈50). Kept separate from SCORE_WEIGHTS so the two
|
|
36
|
+
* axes can be tuned independently.
|
|
37
|
+
*/
|
|
38
|
+
export const SLOP_WEIGHT = 10;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Blank out comment bodies while preserving newlines (so line numbers stay
|
|
42
|
+
* correct) — keeps the rules from firing on `focus:` in a `<!-- … -->` note or a
|
|
43
|
+
* `/* … *\/` block. Line comments are intentionally left alone: masking `//`
|
|
44
|
+
* safely (without eating `https://`) is not worth the complexity for v1.
|
|
45
|
+
*/
|
|
46
|
+
export function maskComments(code: string): string {
|
|
47
|
+
const blankKeepNewlines = (s: string) => s.replace(/[^\n]/g, ' ');
|
|
48
|
+
return code
|
|
49
|
+
.replace(/<!--[\s\S]*?-->/g, blankKeepNewlines)
|
|
50
|
+
.replace(/\/\*[\s\S]*?\*\//g, blankKeepNewlines);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
|
|
54
|
+
|
|
55
|
+
/** Lint one code unit. Returns findings, a score, and severity counts. */
|
|
56
|
+
export function lintDesign(code: string, opts: LintOptions = {}): LintReport {
|
|
57
|
+
const masked = maskComments(code);
|
|
58
|
+
const lines = masked.split('\n');
|
|
59
|
+
|
|
60
|
+
// Resolve the effective whitelist once per call (built-in cores + opts.extraTokens),
|
|
61
|
+
// then hand every rule the same context. Rules that need no context ignore it.
|
|
62
|
+
const ctx: LintContext = { validTokenCores: resolveValidTokenCores(opts.extraTokens) };
|
|
63
|
+
|
|
64
|
+
const findings: Finding[] = [];
|
|
65
|
+
for (const rule of RULES) {
|
|
66
|
+
findings.push(...rule.check(lines, masked, ctx));
|
|
67
|
+
}
|
|
68
|
+
if (!opts.skipHeuristics) {
|
|
69
|
+
findings.push(...runHeuristics(masked));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
findings.sort((a, b) => {
|
|
73
|
+
const lineDiff = (a.line ?? Infinity) - (b.line ?? Infinity);
|
|
74
|
+
if (lineDiff !== 0) return lineDiff;
|
|
75
|
+
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Two axes, never mixed (§6): deterministic findings deduct from correctness
|
|
79
|
+
// (per occurrence, weighted by severity), heuristic findings from slop (flat per
|
|
80
|
+
// finding). `kind`, not `severity`, decides the axis — so a future deterministic
|
|
81
|
+
// `info` would still score against correctness, where it belongs.
|
|
82
|
+
const counts = { error: 0, warning: 0, info: 0 };
|
|
83
|
+
let correctnessDeduction = 0;
|
|
84
|
+
let slopDeduction = 0;
|
|
85
|
+
for (const f of findings) {
|
|
86
|
+
counts[f.severity]++;
|
|
87
|
+
if (f.kind === 'heuristic') slopDeduction += SLOP_WEIGHT;
|
|
88
|
+
else correctnessDeduction += SCORE_WEIGHTS[f.severity];
|
|
89
|
+
}
|
|
90
|
+
const scores: LintScores = {
|
|
91
|
+
correctness: Math.max(0, 100 - correctnessDeduction),
|
|
92
|
+
slop: Math.max(0, 100 - slopDeduction)
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return { findings, scores, counts, filename: opts.filename };
|
|
96
|
+
}
|