@urbicon-ui/mcp-server 6.1.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.
Files changed (130) hide show
  1. package/README.md +161 -0
  2. package/dist/data/catalog-loader.d.ts +37 -0
  3. package/dist/data/catalog-loader.d.ts.map +1 -0
  4. package/dist/data/catalog-loader.js +15 -0
  5. package/dist/data/catalog-loader.js.map +1 -0
  6. package/dist/data/component-loader.d.ts +2 -0
  7. package/dist/data/component-loader.d.ts.map +1 -0
  8. package/dist/data/component-loader.js +17 -0
  9. package/dist/data/component-loader.js.map +1 -0
  10. package/dist/data/recipe-loader.d.ts +4 -0
  11. package/dist/data/recipe-loader.d.ts.map +1 -0
  12. package/dist/data/recipe-loader.js +102 -0
  13. package/dist/data/recipe-loader.js.map +1 -0
  14. package/dist/data/template-loader.d.ts +8 -0
  15. package/dist/data/template-loader.d.ts.map +1 -0
  16. package/dist/data/template-loader.js +33 -0
  17. package/dist/data/template-loader.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +57 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/resources/catalog.d.ts +3 -0
  23. package/dist/resources/catalog.d.ts.map +1 -0
  24. package/dist/resources/catalog.js +20 -0
  25. package/dist/resources/catalog.js.map +1 -0
  26. package/dist/resources/component.d.ts +3 -0
  27. package/dist/resources/component.d.ts.map +1 -0
  28. package/dist/resources/component.js +29 -0
  29. package/dist/resources/component.js.map +1 -0
  30. package/dist/resources/guides.d.ts +3 -0
  31. package/dist/resources/guides.d.ts.map +1 -0
  32. package/dist/resources/guides.js +36 -0
  33. package/dist/resources/guides.js.map +1 -0
  34. package/dist/server.d.ts +3 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js.map +1 -0
  37. package/dist/tools/find-components.d.ts +3 -0
  38. package/dist/tools/find-components.d.ts.map +1 -0
  39. package/dist/tools/find-components.js +21 -0
  40. package/dist/tools/find-components.js.map +1 -0
  41. package/dist/tools/get-recipe.d.ts +3 -0
  42. package/dist/tools/get-recipe.d.ts.map +1 -0
  43. package/dist/tools/get-recipe.js +48 -0
  44. package/dist/tools/get-recipe.js.map +1 -0
  45. package/dist/tools/suggest-implementation.d.ts +3 -0
  46. package/dist/tools/suggest-implementation.d.ts.map +1 -0
  47. package/dist/tools/suggest-implementation.js +178 -0
  48. package/dist/tools/suggest-implementation.js.map +1 -0
  49. package/dist/transports/http.d.ts +2 -0
  50. package/dist/transports/http.d.ts.map +1 -0
  51. package/dist/transports/http.js +77 -0
  52. package/dist/transports/http.js.map +1 -0
  53. package/dist/transports/stdio.d.ts +3 -0
  54. package/dist/transports/stdio.d.ts.map +1 -0
  55. package/dist/transports/stdio.js +6 -0
  56. package/dist/transports/stdio.js.map +1 -0
  57. package/dist/tsconfig.tsbuildinfo +1 -0
  58. package/dist/utils/format-catalog.d.ts +7 -0
  59. package/dist/utils/format-catalog.d.ts.map +1 -0
  60. package/dist/utils/format-catalog.js +93 -0
  61. package/dist/utils/format-catalog.js.map +1 -0
  62. package/dist/utils/paths.d.ts +7 -0
  63. package/dist/utils/paths.d.ts.map +1 -0
  64. package/dist/utils/paths.js +23 -0
  65. package/dist/utils/paths.js.map +1 -0
  66. package/dist/utils/search.d.ts +3 -0
  67. package/dist/utils/search.d.ts.map +1 -0
  68. package/dist/utils/search.js +44 -0
  69. package/dist/utils/search.js.map +1 -0
  70. package/package.json +42 -0
  71. package/src/data/catalog-loader.test.ts +42 -0
  72. package/src/data/catalog-loader.ts +78 -0
  73. package/src/data/component-loader.ts +68 -0
  74. package/src/data/design-system-loader.test.ts +82 -0
  75. package/src/data/design-system-loader.ts +125 -0
  76. package/src/data/icon-loader.test.ts +85 -0
  77. package/src/data/icon-loader.ts +90 -0
  78. package/src/data/recipe-loader.test.ts +49 -0
  79. package/src/data/recipe-loader.ts +131 -0
  80. package/src/data/template-loader.ts +55 -0
  81. package/src/design-linter/heuristics.ts +162 -0
  82. package/src/design-linter/index.ts +14 -0
  83. package/src/design-linter/linter.test.ts +257 -0
  84. package/src/design-linter/linter.ts +62 -0
  85. package/src/design-linter/rules.ts +348 -0
  86. package/src/design-linter/tokens.test.ts +80 -0
  87. package/src/design-linter/tokens.ts +203 -0
  88. package/src/design-linter/types.ts +66 -0
  89. package/src/design-manifest/index.ts +20 -0
  90. package/src/design-manifest/manifest.test.ts +175 -0
  91. package/src/design-manifest/manifest.ts +250 -0
  92. package/src/design-manifest/scan.test.ts +51 -0
  93. package/src/design-manifest/scan.ts +74 -0
  94. package/src/design-manifest/types.ts +40 -0
  95. package/src/design-rubric/rubric.test.ts +43 -0
  96. package/src/design-rubric/rubric.ts +140 -0
  97. package/src/eval/briefs.ts +104 -0
  98. package/src/eval/eval.test.ts +99 -0
  99. package/src/eval/index.ts +11 -0
  100. package/src/eval/score.ts +112 -0
  101. package/src/index.ts +75 -0
  102. package/src/prompts/design-prompts.test.ts +51 -0
  103. package/src/prompts/design-prompts.ts +127 -0
  104. package/src/resources/catalog.ts +23 -0
  105. package/src/resources/guides.ts +60 -0
  106. package/src/server.test.ts +69 -0
  107. package/src/server.ts +48 -0
  108. package/src/tools/find-components.ts +83 -0
  109. package/src/tools/find-icons.ts +77 -0
  110. package/src/tools/get-checklist.ts +139 -0
  111. package/src/tools/get-component.ts +204 -0
  112. package/src/tools/get-css-reference.ts +446 -0
  113. package/src/tools/get-design-context.ts +43 -0
  114. package/src/tools/get-design-principles.ts +72 -0
  115. package/src/tools/get-pattern.ts +69 -0
  116. package/src/tools/get-recipe.ts +80 -0
  117. package/src/tools/record-design-decision.ts +99 -0
  118. package/src/tools/suggest-implementation.ts +251 -0
  119. package/src/tools/sync-design-manifest.ts +92 -0
  120. package/src/tools/validate-design.ts +84 -0
  121. package/src/transports/http.ts +79 -0
  122. package/src/transports/stdio.ts +7 -0
  123. package/src/utils/format-catalog.test.ts +144 -0
  124. package/src/utils/format-catalog.ts +130 -0
  125. package/src/utils/paths.test.ts +101 -0
  126. package/src/utils/paths.ts +78 -0
  127. package/src/utils/search.test.ts +141 -0
  128. package/src/utils/search.ts +106 -0
  129. package/tsconfig.json +27 -0
  130. package/vitest.config.ts +15 -0
@@ -0,0 +1,348 @@
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
+ ];
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { VALID_TOKEN_CORES } from './tokens.js';
6
+
7
+ /**
8
+ * Drift guard: the hardcoded {@link VALID_TOKEN_CORES} is the mcp-server's
9
+ * standalone copy of the design tokens. When the blocks CSS source is present
10
+ * (i.e. running inside the monorepo) we re-derive the token cores from it and
11
+ * assert the two agree exactly. Any token added to / removed from the CSS that
12
+ * is not mirrored here will fail this test — keeping the hallucination detector
13
+ * honest. The mcp-server ships without the CSS, so this can only run in-repo.
14
+ */
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const styleDir = resolve(__dirname, '..', '..', '..', 'blocks', 'src', 'lib', 'style');
18
+ const foundation = resolve(styleDir, 'foundation.css');
19
+ const semantic = resolve(styleDir, 'semantic.css');
20
+ const cssAvailable = existsSync(foundation) && existsSync(semantic);
21
+
22
+ /** Token cores that intentionally live in CSS but are NOT colour-utility cores. */
23
+ const EXCLUDED_PREFIXES = ['shadow-']; // shadows are used via `shadow-[var(--blocks-shadow-*)]`, not `bg-shadow-*`
24
+
25
+ function deriveCoresFromCss(): Set<string> {
26
+ const cores = new Set<string>();
27
+ for (const file of [foundation, semantic]) {
28
+ const css = readFileSync(file, 'utf-8');
29
+ for (const m of css.matchAll(/--color-([a-z0-9-]+)\s*:/g)) {
30
+ const core = m[1]!;
31
+ if (EXCLUDED_PREFIXES.some((p) => core.startsWith(p))) continue;
32
+ cores.add(core);
33
+ }
34
+ }
35
+ return cores;
36
+ }
37
+
38
+ describe.skipIf(!cssAvailable)('token whitelist drift guard', () => {
39
+ it('recognises every colour token defined in the CSS (no forgotten additions)', () => {
40
+ const cssCores = deriveCoresFromCss();
41
+ const missing = [...cssCores].filter((c) => !VALID_TOKEN_CORES.has(c)).sort();
42
+ expect(missing, `CSS tokens missing from VALID_TOKEN_CORES: ${missing.join(', ')}`).toEqual([]);
43
+ });
44
+
45
+ it('contains no phantom tokens absent from the CSS (no invented entries)', () => {
46
+ const cssCores = deriveCoresFromCss();
47
+ const phantom = [...VALID_TOKEN_CORES].filter((c) => !cssCores.has(c)).sort();
48
+ expect(
49
+ phantom,
50
+ `Entries in VALID_TOKEN_CORES with no matching --color-* in CSS: ${phantom.join(', ')}`
51
+ ).toEqual([]);
52
+ });
53
+
54
+ it('each EXCLUDED_PREFIX still matches a real CSS token (exclusion not stale)', () => {
55
+ const css = readFileSync(foundation, 'utf-8') + readFileSync(semantic, 'utf-8');
56
+ for (const prefix of EXCLUDED_PREFIXES) {
57
+ expect(
58
+ css,
59
+ `EXCLUDED_PREFIX "${prefix}" no longer matches any --color-* token — may be stale`
60
+ ).toMatch(new RegExp(`--color-${prefix}`));
61
+ }
62
+ });
63
+ });
64
+
65
+ describe('token whitelist shape', () => {
66
+ it('includes representative tokens from each family', () => {
67
+ for (const core of [
68
+ 'surface-base',
69
+ 'text-primary',
70
+ 'border-subtle',
71
+ 'primary',
72
+ 'primary-500',
73
+ 'feedback-success-subtle',
74
+ 'interactive-hover',
75
+ 'chart-1'
76
+ ]) {
77
+ expect(VALID_TOKEN_CORES.has(core), core).toBe(true);
78
+ }
79
+ });
80
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The authoritative set of Urbicon UI semantic design tokens, expressed as the
3
+ * "core" of a Tailwind colour utility (the part after `bg-`/`text-`/`border-`/…).
4
+ *
5
+ * SOURCE OF TRUTH: `packages/blocks/src/lib/style/{foundation,semantic}.css`
6
+ * (the `--color-*` custom properties). This list is hand-maintained but guarded
7
+ * against drift by `tokens.test.ts`, which re-derives the set from the CSS when
8
+ * run inside the monorepo and fails on any mismatch. The mcp-server ships
9
+ * standalone (no access to the blocks source at runtime), so the data must live
10
+ * here, not be read from disk.
11
+ *
12
+ * Why a whitelist: token hallucination is the single biggest weakness of
13
+ * design-quality guidance — the model invents plausible names like
14
+ * `bg-status-danger` or `text-success-fg`
15
+ * that do not exist. A deterministic whitelist turns that from a guess into a
16
+ * fact: a utility whose core looks semantic but is absent here is flagged.
17
+ */
18
+
19
+ /** Surface background tokens → `bg-surface-*`. */
20
+ const SURFACE_CORES = [
21
+ 'surface-base',
22
+ 'surface-quiet',
23
+ 'surface-subtle',
24
+ 'surface-elevated',
25
+ 'surface-overlay',
26
+ 'surface-interactive',
27
+ 'surface-hover',
28
+ 'surface-active',
29
+ 'surface-disabled',
30
+ 'surface-selected',
31
+ 'surface-inverted'
32
+ ] as const;
33
+
34
+ /** Text colour tokens → `text-text-*` (the `text-` namespace, used after a colour prefix). */
35
+ const TEXT_CORES = [
36
+ 'text-primary',
37
+ 'text-secondary',
38
+ 'text-tertiary',
39
+ 'text-quaternary',
40
+ 'text-disabled',
41
+ 'text-inverted',
42
+ 'text-on-primary',
43
+ 'text-on-dark',
44
+ 'text-on-surface'
45
+ ] as const;
46
+
47
+ /** Border colour tokens → `border-border-*`. */
48
+ const BORDER_CORES = [
49
+ 'border-subtle',
50
+ 'border-default',
51
+ 'border-emphasis',
52
+ 'border-strong',
53
+ 'border-hairline'
54
+ ] as const;
55
+
56
+ /** Intent palettes. Each has a bare token plus the four interaction variants. */
57
+ const INTENT_NAMES = [
58
+ 'primary',
59
+ 'secondary',
60
+ 'success',
61
+ 'warning',
62
+ 'danger',
63
+ 'info',
64
+ 'neutral'
65
+ ] as const;
66
+
67
+ const INTENT_VARIANTS = ['hover', 'active', 'subtle', 'emphasis'] as const;
68
+
69
+ /** Numbered foundation steps present on every intent scale. */
70
+ const SCALE_STEPS = [
71
+ '50',
72
+ '100',
73
+ '200',
74
+ '300',
75
+ '400',
76
+ '500',
77
+ '600',
78
+ '700',
79
+ '800',
80
+ '900',
81
+ '950'
82
+ ] as const;
83
+
84
+ /**
85
+ * `neutral` carries an extended ramp beyond the standard scale (finer steps for
86
+ * the surface ladder). Omitting these would make the hallucination rule flag a
87
+ * real token like `bg-neutral-650` — the drift guard in tokens.test.ts caught
88
+ * exactly that.
89
+ */
90
+ const NEUTRAL_EXTRA_STEPS = ['0', '25', '650', '750', '850'] as const;
91
+
92
+ /** Warm-neutral foundation scale (used by the Organic/Warm paradigm). Scale-only — no semantic variants. */
93
+ const WARM_NEUTRAL_STEPS = SCALE_STEPS;
94
+
95
+ /** Feedback tokens for status messaging → `bg-feedback-*` / `text-feedback-*`. */
96
+ const FEEDBACK_CORES = [
97
+ 'feedback-info',
98
+ 'feedback-info-subtle',
99
+ 'feedback-success',
100
+ 'feedback-success-subtle',
101
+ 'feedback-warning',
102
+ 'feedback-warning-subtle',
103
+ 'feedback-error',
104
+ 'feedback-error-subtle'
105
+ ] as const;
106
+
107
+ /** Interactive overlay tokens. */
108
+ const INTERACTIVE_CORES = [
109
+ 'interactive-hover',
110
+ 'interactive-active',
111
+ 'interactive-focus',
112
+ 'interactive-disabled'
113
+ ] as const;
114
+
115
+ /** Chart series tokens → `text-chart-1` … `text-chart-6`. */
116
+ const CHART_CORES = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'chart-6'] as const;
117
+
118
+ function buildIntentCores(): string[] {
119
+ const cores: string[] = [];
120
+ for (const intent of INTENT_NAMES) {
121
+ cores.push(intent); // bare, e.g. `bg-primary`
122
+ for (const variant of INTENT_VARIANTS) cores.push(`${intent}-${variant}`); // `bg-primary-subtle`
123
+ for (const step of SCALE_STEPS) cores.push(`${intent}-${step}`); // `bg-primary-500`
124
+ }
125
+ return cores;
126
+ }
127
+
128
+ /**
129
+ * Every valid semantic colour-utility core, as a Set for O(1) membership checks.
130
+ * `surface-base`, `text-primary`, `primary`, `primary-500`, `feedback-success`, …
131
+ */
132
+ export const VALID_TOKEN_CORES: ReadonlySet<string> = new Set([
133
+ ...SURFACE_CORES,
134
+ ...TEXT_CORES,
135
+ ...BORDER_CORES,
136
+ ...buildIntentCores(),
137
+ ...NEUTRAL_EXTRA_STEPS.map((s) => `neutral-${s}`),
138
+ ...WARM_NEUTRAL_STEPS.map((s) => `warm-neutral-${s}`),
139
+ ...FEEDBACK_CORES,
140
+ ...INTERACTIVE_CORES,
141
+ ...CHART_CORES
142
+ ]);
143
+
144
+ /**
145
+ * Namespaces that mark a utility core as "intended to be semantic". A core that
146
+ * starts with one of these but is NOT in {@link VALID_TOKEN_CORES} is a
147
+ * hallucinated token. Kept deliberately narrow so we never flag genuine Tailwind
148
+ * utilities (`bg-transparent`, `bg-cover`, arbitrary `bg-[#fff]`).
149
+ */
150
+ export const SEMANTIC_NAMESPACES = [
151
+ 'surface-',
152
+ 'text-',
153
+ 'border-',
154
+ 'feedback-',
155
+ 'interactive-',
156
+ 'chart-'
157
+ ] as const;
158
+
159
+ /** Intent prefixes (`primary-…`, `success-…`) that mark a core as semantic-intent. */
160
+ export const INTENT_PREFIXES: readonly string[] = INTENT_NAMES;
161
+
162
+ /**
163
+ * The shadcn/ui (and Radix) token vocabulary. This is the single most common
164
+ * hallucination source: with no explicit whitelist, models default to the
165
+ * dominant design-system convention in their training data — `text-foreground`,
166
+ * `bg-accent`, `text-muted-foreground`, `bg-card`, … — none of which exist in
167
+ * Urbicon UI. Discovered empirically during design-quality evaluation.
168
+ * These bare cores slip past the namespace/intent heuristics, so they are
169
+ * matched explicitly. (Suffix `-foreground` and the `fg`/`fg-` family are caught
170
+ * by rule in rules.ts.)
171
+ */
172
+ export const KNOWN_FOREIGN_CORES: ReadonlySet<string> = new Set([
173
+ 'foreground',
174
+ 'background',
175
+ 'muted',
176
+ 'accent',
177
+ 'card',
178
+ 'popover',
179
+ 'input',
180
+ 'destructive',
181
+ 'surface', // bare — the real page surface is `surface-base`
182
+ 'muted-foreground',
183
+ 'accent-foreground',
184
+ 'card-foreground',
185
+ 'popover-foreground',
186
+ 'primary-foreground',
187
+ 'secondary-foreground',
188
+ 'destructive-foreground'
189
+ ]);
190
+
191
+ /**
192
+ * Cores that are commonly hallucinated and have a known correct replacement.
193
+ * Drives a precise fix hint instead of a generic "unknown token".
194
+ */
195
+ export const KNOWN_BAD_NAMESPACES: Record<string, string> = {
196
+ // `status-*` is the most frequent invention observed. Map to feedback/intents.
197
+ 'status-':
198
+ 'Use a `feedback-*` token (feedback-success, feedback-error, …) or a bare intent (`success`, `danger`).',
199
+ // `-fg` / `-foreground` suffixes are invented; the system uses `text-on-primary` etc.
200
+ '-fg': 'Use `text-on-primary` / `text-on-surface` for foreground-on-intent text.'
201
+ };
202
+
203
+ export { INTENT_NAMES, INTENT_VARIANTS, SCALE_STEPS };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Types for the Urbicon UI design linter.
3
+ *
4
+ * The linter turns the prose Anti-Patterns and Design-Quality guidance (served
5
+ * today by `get_design_principles`, `suggest_implementation`,
6
+ * `get_implementation_checklist`) into executable checks. Deterministic rules
7
+ * produce `error`/`warning` findings; distribution heuristics produce `info`
8
+ * findings. See design-linter/README context in docs/DESIGN-MCP.md.
9
+ */
10
+
11
+ /** How serious a finding is. Drives the score deduction and the report grouping. */
12
+ export type Severity = 'error' | 'warning' | 'info';
13
+
14
+ /**
15
+ * Whether a finding comes from a deterministic rule (regex/string match — a fact
16
+ * about the code) or a statistical heuristic (a distribution judgement that can
17
+ * have false positives). Surfaced in the report so consumers can weight them.
18
+ */
19
+ export type FindingKind = 'deterministic' | 'heuristic';
20
+
21
+ /** A single linter finding, anchored to a location when one exists. */
22
+ export interface Finding {
23
+ /** Stable rule identifier, e.g. `raw-tailwind-color`. Used for tests and suppression. */
24
+ ruleId: string;
25
+ severity: Severity;
26
+ kind: FindingKind;
27
+ /** Human-readable description of what is wrong. */
28
+ message: string;
29
+ /** Concrete fix hint — what to do instead. */
30
+ fix: string;
31
+ /** 1-based line number, when the finding is anchored to one. */
32
+ line?: number;
33
+ /** The offending snippet (e.g. the class token), when applicable. */
34
+ match?: string;
35
+ }
36
+
37
+ /** A deterministic rule: scans the source and emits findings. */
38
+ export interface Rule {
39
+ id: string;
40
+ severity: Severity;
41
+ /** One-line description of what the rule enforces, shown in `validate_design` rule listings. */
42
+ description: string;
43
+ /**
44
+ * Run the rule over the already-prepared source lines.
45
+ * @param lines source split by `\n`, with comments masked (see linter.ts)
46
+ * @param raw the original source (for rules that need cross-line context)
47
+ */
48
+ check(lines: string[], raw: string): Finding[];
49
+ }
50
+
51
+ /** Aggregate result of linting one code unit. */
52
+ export interface LintReport {
53
+ findings: Finding[];
54
+ /** Deterministic 0–100 design-quality score. 100 = no findings. */
55
+ score: number;
56
+ counts: { error: number; warning: number; info: number };
57
+ /** Optional label (e.g. filename) echoed back in the report. */
58
+ filename?: string;
59
+ }
60
+
61
+ /** Options for a lint run. */
62
+ export interface LintOptions {
63
+ filename?: string;
64
+ /** Skip the distribution heuristics (the `info`-level checks). Default: false. */
65
+ skipHeuristics?: boolean;
66
+ }