@urbicon-ui/mcp-server 6.1.5 → 6.1.6

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.
Files changed (48) hide show
  1. package/README.md +44 -41
  2. package/package.json +3 -1
  3. package/src/data/catalog-loader.test.ts +1 -1
  4. package/src/data/catalog-loader.ts +12 -37
  5. package/src/data/component-loader.ts +5 -40
  6. package/src/data/design-system-loader.test.ts +1 -1
  7. package/src/data/design-system-loader.ts +1 -1
  8. package/src/data/icon-loader.test.ts +25 -80
  9. package/src/data/icon-loader.ts +8 -68
  10. package/src/data/template-loader.ts +1 -1
  11. package/src/data/verb-loader.ts +29 -0
  12. package/src/eval/eval.test.ts +16 -9
  13. package/src/eval/score.ts +26 -10
  14. package/src/index.ts +7 -14
  15. package/src/prompts/design-prompts.test.ts +56 -28
  16. package/src/prompts/design-prompts.ts +135 -104
  17. package/src/server.test.ts +16 -7
  18. package/src/server.ts +4 -7
  19. package/src/tools/find-components.ts +1 -1
  20. package/src/tools/get-design-principles.ts +1 -1
  21. package/src/tools/get-recipe.ts +6 -4
  22. package/src/tools/suggest-implementation.ts +2 -3
  23. package/src/tools/validate-design.ts +17 -9
  24. package/src/data/recipe-loader.test.ts +0 -49
  25. package/src/data/recipe-loader.ts +0 -131
  26. package/src/design-linter/heuristics.ts +0 -162
  27. package/src/design-linter/index.ts +0 -14
  28. package/src/design-linter/linter.test.ts +0 -257
  29. package/src/design-linter/linter.ts +0 -62
  30. package/src/design-linter/rules.ts +0 -348
  31. package/src/design-linter/tokens.test.ts +0 -80
  32. package/src/design-linter/tokens.ts +0 -203
  33. package/src/design-linter/types.ts +0 -66
  34. package/src/design-manifest/index.ts +0 -20
  35. package/src/design-manifest/manifest.test.ts +0 -175
  36. package/src/design-manifest/manifest.ts +0 -250
  37. package/src/design-manifest/scan.test.ts +0 -51
  38. package/src/design-manifest/scan.ts +0 -74
  39. package/src/design-manifest/types.ts +0 -40
  40. package/src/design-rubric/rubric.test.ts +0 -43
  41. package/src/design-rubric/rubric.ts +0 -140
  42. package/src/tools/get-design-context.ts +0 -43
  43. package/src/tools/record-design-decision.ts +0 -99
  44. package/src/tools/sync-design-manifest.ts +0 -92
  45. package/src/utils/paths.test.ts +0 -101
  46. package/src/utils/paths.ts +0 -78
  47. package/src/utils/search.test.ts +0 -141
  48. package/src/utils/search.ts +0 -106
@@ -1,257 +0,0 @@
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('heuristics', () => {
160
- it('flags an intent rainbow of ≥4 chromatic background hues', () => {
161
- const code =
162
- '<div class="bg-primary"></div><div class="bg-success"></div><div class="bg-warning"></div><div class="bg-danger"></div>';
163
- expect(has(lintDesign(code).findings, 'intent-rainbow')).toBe(true);
164
- });
165
- it('does NOT count neutral backgrounds toward the rainbow', () => {
166
- const code =
167
- '<div class="bg-neutral-100"></div><div class="bg-neutral-200"></div><div class="bg-surface-base"></div><div class="bg-surface-elevated"></div>';
168
- expect(has(lintDesign(code).findings, 'intent-rainbow')).toBe(false);
169
- });
170
- it('flags uniform spacing (one rhythm tier)', () => {
171
- const code =
172
- '<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>';
173
- expect(has(lintDesign(code).findings, 'spacing-uniform')).toBe(true);
174
- });
175
- it('does NOT flag two-tier spacing', () => {
176
- const code =
177
- '<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>';
178
- expect(has(lintDesign(code).findings, 'spacing-uniform')).toBe(false);
179
- });
180
- it('flags identical Cards (no visual-weight variation)', () => {
181
- const card = '<Card variant="elevated" padding="md">x</Card>';
182
- expect(has(lintDesign(card.repeat(4)).findings, 'card-monotony')).toBe(true);
183
- });
184
- it('does NOT flag differentiated Cards', () => {
185
- const code =
186
- '<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>';
187
- expect(has(lintDesign(code).findings, 'card-monotony')).toBe(false);
188
- });
189
- it('nudges when surfaces exist but no radius strategy does', () => {
190
- const code = '<Card>a</Card><Card>b</Card><Card>c</Card>';
191
- expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(true);
192
- });
193
- it('does NOT nudge once a radius override is present', () => {
194
- const code =
195
- '<Card class="rounded-xl">a</Card><Card class="rounded-xl">b</Card><Card class="rounded-xl">c</Card>';
196
- expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(false);
197
- });
198
- it('does NOT treat bordered table rows / dividers as surfaces (no false radius nudge)', () => {
199
- const code =
200
- '<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>';
201
- expect(has(lintDesign(code).findings, 'no-radius-strategy')).toBe(false);
202
- });
203
- it('can be skipped via skipHeuristics', () => {
204
- const code = '<Card variant="elevated" padding="md">x</Card>'.repeat(4);
205
- const { findings } = lintDesign(code, { skipHeuristics: true });
206
- expect(findings.every((f) => f.kind === 'deterministic')).toBe(true);
207
- });
208
- });
209
-
210
- describe('comment masking', () => {
211
- it('ignores violations inside HTML and block comments', () => {
212
- const code =
213
- '<!-- class="focus:ring-2 bg-blue-500" --><div class="bg-surface-base">/* z-50 */</div>';
214
- const { findings } = lintDesign(code);
215
- expect(findings).toHaveLength(0);
216
- });
217
- it('keeps line numbers correct after masking', () => {
218
- const masked = maskComments('a\n<!--\nx\n-->\nfocus:ring');
219
- expect(masked.split('\n')).toHaveLength(5);
220
- });
221
- });
222
-
223
- describe('scoring', () => {
224
- it('scores clean code 100', () => {
225
- const { score } = lintDesign('<div class="bg-surface-base text-text-primary">clean</div>');
226
- expect(score).toBe(100);
227
- });
228
- it('deducts per finding and floors at 0', () => {
229
- const oneError = lintDesign('<div class="bg-blue-500">');
230
- expect(oneError.score).toBe(90);
231
- // Per-line dedupe collapses identical hits on one line, so spread distinct hits across lines.
232
- const many = lintDesign(
233
- Array.from({ length: 12 }, () => '<div class="bg-blue-500">').join('\n')
234
- );
235
- expect(many.score).toBe(0);
236
- });
237
- it('reports severity counts', () => {
238
- const { counts } = lintDesign('<div class="bg-blue-500 bg-status-x">');
239
- expect(counts.error).toBeGreaterThanOrEqual(1);
240
- expect(counts.warning).toBeGreaterThanOrEqual(1);
241
- });
242
- });
243
-
244
- describe('rule metadata', () => {
245
- it('every finding carries a fix hint', () => {
246
- const code =
247
- '<button class="bg-blue-500 dark:bg-red-500 focus:ring z-50 gap-{x} bg-status-bad">';
248
- for (const f of lintDesign(code).findings) {
249
- expect(f.fix.length).toBeGreaterThan(0);
250
- expect(f.ruleId.length).toBeGreaterThan(0);
251
- }
252
- });
253
- it('produces a stable id ordering for the same input', () => {
254
- const code = '<div class="z-50 bg-blue-500">';
255
- expect(ids(lintDesign(code).findings)).toEqual(ids(lintDesign(code).findings));
256
- });
257
- });
@@ -1,62 +0,0 @@
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 type { Finding, LintOptions, LintReport, Severity } from './types.js';
11
-
12
- /** Per-severity score deduction. Errors dominate (they are real defects). Centralised for tuning. */
13
- export const SCORE_WEIGHTS: Record<Severity, number> = {
14
- error: 10,
15
- warning: 5,
16
- info: 2
17
- };
18
-
19
- /**
20
- * Blank out comment bodies while preserving newlines (so line numbers stay
21
- * correct) — keeps the rules from firing on `focus:` in a `<!-- … -->` note or a
22
- * `/* … *\/` block. Line comments are intentionally left alone: masking `//`
23
- * safely (without eating `https://`) is not worth the complexity for v1.
24
- */
25
- export function maskComments(code: string): string {
26
- const blankKeepNewlines = (s: string) => s.replace(/[^\n]/g, ' ');
27
- return code
28
- .replace(/<!--[\s\S]*?-->/g, blankKeepNewlines)
29
- .replace(/\/\*[\s\S]*?\*\//g, blankKeepNewlines);
30
- }
31
-
32
- const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
33
-
34
- /** Lint one code unit. Returns findings, a score, and severity counts. */
35
- export function lintDesign(code: string, opts: LintOptions = {}): LintReport {
36
- const masked = maskComments(code);
37
- const lines = masked.split('\n');
38
-
39
- const findings: Finding[] = [];
40
- for (const rule of RULES) {
41
- findings.push(...rule.check(lines, masked));
42
- }
43
- if (!opts.skipHeuristics) {
44
- findings.push(...runHeuristics(masked));
45
- }
46
-
47
- findings.sort((a, b) => {
48
- const lineDiff = (a.line ?? Infinity) - (b.line ?? Infinity);
49
- if (lineDiff !== 0) return lineDiff;
50
- return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
51
- });
52
-
53
- const counts = { error: 0, warning: 0, info: 0 };
54
- let deduction = 0;
55
- for (const f of findings) {
56
- counts[f.severity]++;
57
- deduction += SCORE_WEIGHTS[f.severity];
58
- }
59
- const score = Math.max(0, 100 - deduction);
60
-
61
- return { findings, score, counts, filename: opts.filename };
62
- }
@@ -1,348 +0,0 @@
1
- /**
2
- * Deterministic design rules. Each is a fact about the code — a regex/string
3
- * match with no judgement — so it can carry `error`/`warning` severity and be
4
- * covered by regression tests. The prose source is the Anti-Patterns section of
5
- * `design-system/principles.md` plus the documented known failure modes
6
- * (token hallucination, dynamic Tailwind classes).
7
- */
8
-
9
- import {
10
- INTENT_NAMES,
11
- INTENT_PREFIXES,
12
- KNOWN_BAD_NAMESPACES,
13
- KNOWN_FOREIGN_CORES,
14
- SEMANTIC_NAMESPACES,
15
- VALID_TOKEN_CORES
16
- } from './tokens.js';
17
- import type { Finding, Rule } from './types.js';
18
-
19
- const SHADCN_FIX =
20
- 'This is shadcn/ui vocabulary, not Urbicon UI. Use surface tokens (`bg-surface-base`/`-elevated`), text tokens (`text-text-primary`/`-secondary`), or intents (`bg-primary`, `text-success`).';
21
-
22
- /** shadcn/ui-family cores (bare set + `-foreground` suffix + `fg`/`fg-`). */
23
- function isForeignVocab(core: string): boolean {
24
- return (
25
- KNOWN_FOREIGN_CORES.has(core) ||
26
- core.endsWith('-foreground') ||
27
- core === 'fg' ||
28
- core.startsWith('fg-')
29
- );
30
- }
31
-
32
- /** Tailwind's default chromatic palette names — none are Urbicon UI tokens. `neutral` is ours, so it is excluded. */
33
- const RAW_PALETTE =
34
- 'slate|gray|zinc|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose';
35
-
36
- /** Colour-bearing Tailwind prefixes (longest first so `border-l` wins over `border`). */
37
- const COLOR_PREFIXES = [
38
- 'ring-offset',
39
- 'border-x',
40
- 'border-y',
41
- 'border-t',
42
- 'border-r',
43
- 'border-b',
44
- 'border-l',
45
- 'border-s',
46
- 'border-e',
47
- 'bg',
48
- 'text',
49
- 'border',
50
- 'ring',
51
- 'divide',
52
- 'outline',
53
- 'decoration',
54
- 'fill',
55
- 'stroke',
56
- 'from',
57
- 'via',
58
- 'to',
59
- 'accent',
60
- 'caret',
61
- 'placeholder'
62
- ];
63
-
64
- /** Tailwind utility roots that take a scale/value — used to catch broken dynamic interpolation. */
65
- const DYNAMIC_UTILITY_ROOTS = [
66
- 'gap',
67
- 'gap-x',
68
- 'gap-y',
69
- 'space-x',
70
- 'space-y',
71
- 'p',
72
- 'px',
73
- 'py',
74
- 'pt',
75
- 'pb',
76
- 'pl',
77
- 'pr',
78
- 'm',
79
- 'mx',
80
- 'my',
81
- 'mt',
82
- 'mb',
83
- 'ml',
84
- 'mr',
85
- 'w',
86
- 'h',
87
- 'min-w',
88
- 'min-h',
89
- 'max-w',
90
- 'max-h',
91
- 'size',
92
- 'text',
93
- 'bg',
94
- 'border',
95
- 'rounded',
96
- 'grid-cols',
97
- 'grid-rows',
98
- 'col-span',
99
- 'row-span',
100
- 'top',
101
- 'left',
102
- 'right',
103
- 'bottom',
104
- 'inset',
105
- 'z',
106
- 'leading',
107
- 'tracking',
108
- 'opacity',
109
- 'scale',
110
- 'rotate',
111
- 'translate-x',
112
- 'translate-y',
113
- 'duration',
114
- 'delay'
115
- ];
116
-
117
- function dedupeByLine(findings: Finding[]): Finding[] {
118
- const seen = new Set<string>();
119
- const out: Finding[] = [];
120
- for (const f of findings) {
121
- const key = `${f.ruleId}:${f.line}:${f.match}`;
122
- if (seen.has(key)) continue;
123
- seen.add(key);
124
- out.push(f);
125
- }
126
- return out;
127
- }
128
-
129
- const rawTailwindColor: Rule = {
130
- id: 'raw-tailwind-color',
131
- severity: 'error',
132
- description: 'Raw Tailwind palette colour (e.g. `bg-blue-500`) instead of a semantic token.',
133
- check(lines) {
134
- // Trailing `(?![a-z0-9-])` (not `\b`) so an optional `/NN` opacity suffix stays in the match.
135
- const re = new RegExp(
136
- `\\b(?:${COLOR_PREFIXES.join('|')})-(?:${RAW_PALETTE})-(?:50|100|200|300|400|500|600|700|800|900|950)(?:\\/\\d{1,3})?(?![a-z0-9-])`,
137
- 'g'
138
- );
139
- const findings: Finding[] = [];
140
- lines.forEach((line, i) => {
141
- for (const m of line.matchAll(re)) {
142
- findings.push({
143
- ruleId: this.id,
144
- severity: this.severity,
145
- kind: 'deterministic',
146
- message: `Raw Tailwind colour \`${m[0]}\` bypasses the token system (no dark-mode adaptation, no theming).`,
147
- fix: 'Use a semantic token: `bg-surface-*`, `text-text-*`, `border-border-*`, or an intent (`bg-primary`, `text-success`).',
148
- line: i + 1,
149
- match: m[0]
150
- });
151
- }
152
- });
153
- return dedupeByLine(findings);
154
- }
155
- };
156
-
157
- const darkModeOverride: Rule = {
158
- id: 'dark-mode-override',
159
- severity: 'error',
160
- description: 'Manual `dark:` override instead of automatic `light-dark()` semantic tokens.',
161
- check(lines) {
162
- // `!` covers the Tailwind important modifier (`dark:!bg-…`).
163
- const re = /\bdark:[a-z[!]/g;
164
- const findings: Finding[] = [];
165
- lines.forEach((line, i) => {
166
- for (const m of line.matchAll(re)) {
167
- findings.push({
168
- ruleId: this.id,
169
- severity: this.severity,
170
- kind: 'deterministic',
171
- message:
172
- 'Manual `dark:` override. Dark mode resolves automatically via `light-dark()` semantic tokens.',
173
- fix: 'Remove the `dark:` variant and rely on semantic tokens (`bg-surface-elevated` etc.), which already switch.',
174
- line: i + 1,
175
- match: m[0].slice(0, -1)
176
- });
177
- }
178
- });
179
- return dedupeByLine(findings);
180
- }
181
- };
182
-
183
- const focusNotVisible: Rule = {
184
- id: 'focus-not-visible',
185
- severity: 'error',
186
- description: 'Plain `focus:` ring instead of keyboard-only `focus-visible:`.',
187
- check(lines) {
188
- // `focus:` followed by a utility start, but never `focus-visible:`/`focus-within:`
189
- // (those contain `focus-`, not `focus:`). Catches `group-focus:` / `peer-focus:` too.
190
- const re = /\bfocus:(?=[a-z[])/g;
191
- const findings: Finding[] = [];
192
- lines.forEach((line, i) => {
193
- for (const _ of line.matchAll(re)) {
194
- findings.push({
195
- ruleId: this.id,
196
- severity: this.severity,
197
- kind: 'deterministic',
198
- message:
199
- '`focus:` shows a focus ring on mouse clicks too. Keyboard-only rings are the house style.',
200
- fix: 'Use `focus-visible:` instead of `focus:`.',
201
- line: i + 1,
202
- match: 'focus:'
203
- });
204
- }
205
- });
206
- return dedupeByLine(findings);
207
- }
208
- };
209
-
210
- const hardcodedZIndex: Rule = {
211
- id: 'hardcoded-z-index',
212
- severity: 'error',
213
- description: 'Hardcoded z-index instead of a `z-[var(--z-*)]` token.',
214
- check(lines) {
215
- // `z-10`, `z-50`, `z-[999]` — but not `z-[var(--z-modal)]` or `z-auto`.
216
- // Trailing `(?![\w-])` (not `\b`) so the bracket form terminates correctly after `]`.
217
- const re = /\bz-(?:\d{1,4}|\[\d{1,4}\])(?![\w-])/g;
218
- const findings: Finding[] = [];
219
- lines.forEach((line, i) => {
220
- for (const m of line.matchAll(re)) {
221
- findings.push({
222
- ruleId: this.id,
223
- severity: this.severity,
224
- kind: 'deterministic',
225
- message: `Hardcoded z-index \`${m[0]}\` collides with the layering scale and can sit behind/above the wrong overlay.`,
226
- fix: 'Use a z-index token: `z-[var(--z-modal)]`, `z-[var(--z-dropdown)]`, `z-[var(--z-tooltip)]`, …',
227
- line: i + 1,
228
- match: m[0]
229
- });
230
- }
231
- });
232
- return dedupeByLine(findings);
233
- }
234
- };
235
-
236
- const dynamicClassInterpolation: Rule = {
237
- id: 'dynamic-class-interpolation',
238
- severity: 'error',
239
- description:
240
- 'String-interpolated Tailwind class fragment (e.g. `gap-{x}`) — never compiled by Tailwind.',
241
- check(lines) {
242
- // A Tailwind utility root immediately glued to a `{` or `${` interpolation.
243
- // Tailwind needs static class names; `gap-${x}` produces no CSS at all.
244
- const re = new RegExp(`\\b(?:${DYNAMIC_UTILITY_ROOTS.join('|')})-(\\$\\{|\\{)`, 'g');
245
- const findings: Finding[] = [];
246
- lines.forEach((line, i) => {
247
- for (const m of line.matchAll(re)) {
248
- findings.push({
249
- ruleId: this.id,
250
- severity: this.severity,
251
- kind: 'deterministic',
252
- message: `Interpolated class fragment \`${m[0]}…\` — Tailwind only compiles static class names, so this utility is never generated.`,
253
- fix: "Switch the whole class string per state: `class={isHero ? 'gap-4' : 'gap-3'}` — keep each utility a complete literal.",
254
- line: i + 1,
255
- match: m[0]
256
- });
257
- }
258
- });
259
- return dedupeByLine(findings);
260
- }
261
- };
262
-
263
- /**
264
- * Token hallucination: a colour utility whose core *looks* like an Urbicon UI
265
- * semantic token (right namespace / intent prefix) but is not in the validated
266
- * whitelist. Narrow by construction — only fires on our own namespaces, so it
267
- * never flags `bg-transparent`, `bg-cover`, or arbitrary `bg-[#fff]`.
268
- */
269
- const tokenHallucination: Rule = {
270
- id: 'token-hallucination',
271
- severity: 'warning',
272
- description:
273
- 'Colour utility referencing a non-existent semantic token (e.g. `bg-status-danger`).',
274
- check(lines) {
275
- const prefixAlt = COLOR_PREFIXES.join('|');
276
- // capture: prefix, then the core up to a class boundary / opacity / end
277
- const re = new RegExp(`\\b(${prefixAlt})-([a-z][a-z0-9-]*)(?:\\/\\d{1,3})?\\b`, 'g');
278
- const findings: Finding[] = [];
279
-
280
- lines.forEach((line, i) => {
281
- for (const m of line.matchAll(re)) {
282
- const core = m[2]!;
283
- if (!looksSemantic(core)) continue;
284
- if (VALID_TOKEN_CORES.has(core)) continue;
285
-
286
- findings.push({
287
- ruleId: this.id,
288
- severity: this.severity,
289
- kind: 'deterministic',
290
- message: `\`${m[1]}-${core}\` is not a real token — likely hallucinated.`,
291
- fix: suggestForBadCore(core),
292
- line: i + 1,
293
- match: m[0] // full token incl. any `/NN` opacity suffix
294
- });
295
- }
296
- });
297
- return dedupeByLine(findings);
298
- }
299
- };
300
-
301
- /** Does this utility core sit in one of our semantic namespaces / intent families? */
302
- function looksSemantic(core: string): boolean {
303
- // Font-size cores (`text-sm`, `text-2xl`) share the `text-` namespace but are not colour tokens.
304
- if (/^text-(?:xs|sm|base|lg|\d?xl)$/.test(core)) return false;
305
- if (isForeignVocab(core)) return true; // shadcn/ui vocabulary — always foreign
306
- if (SEMANTIC_NAMESPACES.some((ns) => core.startsWith(ns))) return true;
307
- if (core.startsWith('status-')) return true; // owned-looking, never valid → flag
308
- // intent-with-suffix: `primary-muted`, `success-foo` (bare `primary` is valid and caught by the whitelist)
309
- for (const intent of INTENT_PREFIXES) {
310
- if (core.startsWith(`${intent}-`)) return true;
311
- }
312
- if (core.endsWith('-fg')) return true; // `-foreground` is already covered by isForeignVocab above
313
- return false;
314
- }
315
-
316
- function suggestForBadCore(core: string): string {
317
- // The `-foreground` family is the most common shadcn pattern — give the precise replacement first.
318
- if (
319
- core === 'foreground' ||
320
- core.endsWith('-foreground') ||
321
- core === 'fg' ||
322
- core.endsWith('-fg')
323
- ) {
324
- return 'Use `text-on-primary` / `text-on-surface` for foreground-on-intent text, or `text-text-primary`/`-secondary` for general text.';
325
- }
326
- if (isForeignVocab(core)) return SHADCN_FIX;
327
- for (const [bad, hint] of Object.entries(KNOWN_BAD_NAMESPACES)) {
328
- if (bad.endsWith('-') ? core.startsWith(bad) : core.endsWith(bad)) return hint;
329
- }
330
- if (core.startsWith('surface-'))
331
- return 'Valid surfaces: surface-base/quiet/subtle/elevated/overlay/hover/active/selected/inverted.';
332
- if (core.startsWith('feedback-'))
333
- return 'Valid feedback tokens: feedback-{info,success,warning,error}[-subtle].';
334
- const intent = INTENT_NAMES.find((n) => core.startsWith(`${n}-`));
335
- if (intent)
336
- return `Valid \`${intent}\` variants: ${intent}, ${intent}-hover, ${intent}-active, ${intent}-subtle, ${intent}-emphasis, or a scale step ${intent}-50…${intent}-950.`;
337
- return 'Check `get_css_reference()` for the exact token name.';
338
- }
339
-
340
- /** All deterministic rules, in report order. */
341
- export const RULES: Rule[] = [
342
- rawTailwindColor,
343
- darkModeOverride,
344
- focusNotVisible,
345
- hardcodedZIndex,
346
- dynamicClassInterpolation,
347
- tokenHallucination
348
- ];