@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.
- package/README.md +44 -41
- package/package.json +3 -1
- package/src/data/catalog-loader.test.ts +1 -1
- package/src/data/catalog-loader.ts +12 -37
- package/src/data/component-loader.ts +5 -40
- package/src/data/design-system-loader.test.ts +1 -1
- package/src/data/design-system-loader.ts +1 -1
- package/src/data/icon-loader.test.ts +25 -80
- package/src/data/icon-loader.ts +8 -68
- package/src/data/template-loader.ts +1 -1
- package/src/data/verb-loader.ts +29 -0
- package/src/eval/eval.test.ts +16 -9
- package/src/eval/score.ts +26 -10
- package/src/index.ts +7 -14
- package/src/prompts/design-prompts.test.ts +56 -28
- package/src/prompts/design-prompts.ts +135 -104
- package/src/server.test.ts +16 -7
- package/src/server.ts +4 -7
- package/src/tools/find-components.ts +1 -1
- package/src/tools/get-design-principles.ts +1 -1
- package/src/tools/get-recipe.ts +6 -4
- package/src/tools/suggest-implementation.ts +2 -3
- package/src/tools/validate-design.ts +17 -9
- package/src/data/recipe-loader.test.ts +0 -49
- package/src/data/recipe-loader.ts +0 -131
- package/src/design-linter/heuristics.ts +0 -162
- package/src/design-linter/index.ts +0 -14
- package/src/design-linter/linter.test.ts +0 -257
- package/src/design-linter/linter.ts +0 -62
- package/src/design-linter/rules.ts +0 -348
- package/src/design-linter/tokens.test.ts +0 -80
- package/src/design-linter/tokens.ts +0 -203
- package/src/design-linter/types.ts +0 -66
- package/src/design-manifest/index.ts +0 -20
- package/src/design-manifest/manifest.test.ts +0 -175
- package/src/design-manifest/manifest.ts +0 -250
- package/src/design-manifest/scan.test.ts +0 -51
- package/src/design-manifest/scan.ts +0 -74
- package/src/design-manifest/types.ts +0 -40
- package/src/design-rubric/rubric.test.ts +0 -43
- package/src/design-rubric/rubric.ts +0 -140
- package/src/tools/get-design-context.ts +0 -43
- package/src/tools/record-design-decision.ts +0 -99
- package/src/tools/sync-design-manifest.ts +0 -92
- package/src/utils/paths.test.ts +0 -101
- package/src/utils/paths.ts +0 -78
- package/src/utils/search.test.ts +0 -141
- package/src/utils/search.ts +0 -106
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { Finding, LintReport, Severity } from '@urbicon-ui/design-engine/linter';
|
|
3
|
+
import { lintDesign } from '@urbicon-ui/design-engine/linter';
|
|
2
4
|
import { z } from 'zod';
|
|
3
|
-
import type { Finding, LintReport, Severity } from '../design-linter/index.js';
|
|
4
|
-
import { lintDesign } from '../design-linter/index.js';
|
|
5
5
|
|
|
6
6
|
const SEVERITY_LABEL: Record<Severity, string> = {
|
|
7
7
|
error: 'π΄ Errors',
|
|
8
8
|
warning: 'π Warnings',
|
|
9
|
-
info: 'π΅
|
|
9
|
+
info: 'π΅ Slop-floor'
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
function renderFindings(findings: Finding[], severity: Severity): string {
|
|
@@ -24,17 +24,19 @@ function renderFindings(findings: Finding[], severity: Severity): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function renderReport(report: LintReport): string {
|
|
27
|
-
const {
|
|
27
|
+
const { scores, counts, findings, filename } = report;
|
|
28
28
|
const hardFails = counts.error + counts.warning;
|
|
29
29
|
const verdict = hardFails === 0 ? 'β
PASS' : 'β NEEDS FIXES';
|
|
30
30
|
|
|
31
31
|
let md = `# Design Validation β ${verdict}\n\n`;
|
|
32
32
|
if (filename) md += `> \`${filename}\`\n\n`;
|
|
33
|
-
md += `**
|
|
33
|
+
md += `**Correctness ${scores.correctness}/100 Β· Slop-floor ${scores.slop}/100** Β· ${counts.error} error(s), ${counts.warning} warning(s), ${counts.info} slop note(s)\n\n`;
|
|
34
|
+
md +=
|
|
35
|
+
'Two axes, never mixed: **correctness** is the blocking gate (fix every error/warning to pass); **slop-floor** is advisory β system-agnostic "looks generic" signals to raise distinctiveness.\n\n';
|
|
34
36
|
|
|
35
37
|
if (findings.length === 0) {
|
|
36
38
|
md +=
|
|
37
|
-
'No issues found. Tokens are valid, no `dark:`/`focus:`/hardcoded z-index, and the
|
|
39
|
+
'No issues found. Tokens are valid, no `dark:`/`focus:`/hardcoded z-index, and the slop-floor heuristics are satisfied.\n';
|
|
38
40
|
return md;
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -57,7 +59,7 @@ function renderReport(report: LintReport): string {
|
|
|
57
59
|
export function registerValidateDesignTool(server: McpServer): void {
|
|
58
60
|
server.tool(
|
|
59
61
|
'validate_design',
|
|
60
|
-
'Lint generated Svelte/HTML markup against the Urbicon UI design rules.
|
|
62
|
+
'Lint generated Svelte/HTML markup against the Urbicon UI design rules. Two axes, never mixed: (1) **correctness** β deterministic defects (raw Tailwind colours, `dark:`/`focus:` misuse, hardcoded z-index, broken dynamic classes, hallucinated tokens, foreign-library component APIs like `tone=`/`variant="outline"`, icon-only buttons with no accessible name), the blocking gate; (2) **slop-floor** β system-agnostic "looks generic" heuristics (generic fonts, animated width/height, magic-number sizes, low-contrast text on colour, inline styles, `!important`, placeholder copy, emoji-as-icon, heading-level skips, small touch targets, intent-colour rainbow, uniform spacing/weights, identical Cards), advisory. Returns a correctness score and a slop-floor score (both 0β100; correctness β10/error, β5/warning; slop β10 per signal; floored) plus per-finding fixes. Run in a generate β validate β fix loop after producing UI code. Pass `extraTokens` to whitelist semantic tokens your project defines on top of Urbiconβs so they are not flagged as hallucinated.',
|
|
61
63
|
{
|
|
62
64
|
code: z
|
|
63
65
|
.string()
|
|
@@ -73,11 +75,17 @@ export function registerValidateDesignTool(server: McpServer): void {
|
|
|
73
75
|
.optional()
|
|
74
76
|
.describe(
|
|
75
77
|
'Skip the advisory distribution heuristics; report only deterministic violations. Default: false.'
|
|
78
|
+
),
|
|
79
|
+
extraTokens: z
|
|
80
|
+
.array(z.string())
|
|
81
|
+
.optional()
|
|
82
|
+
.describe(
|
|
83
|
+
'Project-specific semantic token cores to treat as valid for this call, merged into the built-in whitelist so they are not flagged as hallucinated. A "core" is the part after the utility prefix: pass "surface-brand" (for `bg-surface-brand`), not "bg-surface-brand" and not the "--color-surface-brand" CSS variable. Use when your project extends the Urbicon token set or runs a newer library version than this server. This server is stateless and cannot read your CSS, so supply the cores explicitly.'
|
|
76
84
|
)
|
|
77
85
|
},
|
|
78
86
|
{ readOnlyHint: true },
|
|
79
|
-
async ({ code, filename, skipHeuristics }) => {
|
|
80
|
-
const report = lintDesign(code, { filename, skipHeuristics });
|
|
87
|
+
async ({ code, filename, skipHeuristics, extraTokens }) => {
|
|
88
|
+
const report = lintDesign(code, { filename, skipHeuristics, extraTokens });
|
|
81
89
|
return { content: [{ type: 'text' as const, text: renderReport(report) }] };
|
|
82
90
|
}
|
|
83
91
|
);
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import { getRecipeDir } from '../utils/paths.js';
|
|
4
|
-
import { getRecipeById, loadRecipes } from './recipe-loader.js';
|
|
5
|
-
|
|
6
|
-
const recipesAvailable = existsSync(getRecipeDir());
|
|
7
|
-
|
|
8
|
-
describe.skipIf(!recipesAvailable)('recipe-loader pattern annotations', () => {
|
|
9
|
-
it('parses the Layer-4 pattern reference from meta.ts', async () => {
|
|
10
|
-
const dashboard = await getRecipeById('dashboard');
|
|
11
|
-
expect(dashboard?.pattern).toBe('dashboard');
|
|
12
|
-
|
|
13
|
-
const login = await getRecipeById('login');
|
|
14
|
-
expect(login?.pattern).toBe('form-page');
|
|
15
|
-
|
|
16
|
-
const onboarding = await getRecipeById('onboarding-flow');
|
|
17
|
-
expect(onboarding?.pattern).toBe('onboarding-guide');
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('leaves pattern undefined for recipes without one', async () => {
|
|
21
|
-
const profile = await getRecipeById('profile-card');
|
|
22
|
-
// profile-card is a component-level snippet, not a page archetype β no pattern
|
|
23
|
-
expect(profile?.pattern).toBeUndefined();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('loads the meal-planner recipe with its planning-board pattern + Planner code', async () => {
|
|
27
|
-
const mealPlanner = await getRecipeById('meal-planner');
|
|
28
|
-
expect(mealPlanner?.title).toBe('Meal Planner');
|
|
29
|
-
expect(mealPlanner?.pattern).toBe('planning-board');
|
|
30
|
-
expect(mealPlanner?.components).toContain('Planner');
|
|
31
|
-
expect(mealPlanner?.code).toContain('<Planner');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('every annotated pattern names an existing pattern file', async () => {
|
|
35
|
-
const recipes = await loadRecipes();
|
|
36
|
-
const known = new Set([
|
|
37
|
-
'dashboard',
|
|
38
|
-
'form-page',
|
|
39
|
-
'settings-page',
|
|
40
|
-
'tab-navigation',
|
|
41
|
-
'onboarding-guide',
|
|
42
|
-
'planning-board'
|
|
43
|
-
]);
|
|
44
|
-
for (const r of recipes) {
|
|
45
|
-
if (r.pattern)
|
|
46
|
-
expect(known, `recipe "${r.id}" β unknown pattern "${r.pattern}"`).toContain(r.pattern);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
});
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
import { getRecipeDir } from '../utils/paths.js';
|
|
4
|
-
import type { RecipeEntry } from './catalog-loader.js';
|
|
5
|
-
|
|
6
|
-
let cachedRecipes: RecipeEntry[] | null = null;
|
|
7
|
-
|
|
8
|
-
function extractRecipeCode(content: string): string {
|
|
9
|
-
const startMatch = content.match(/const\s+recipeCode\s*=\s*\n?\s*/);
|
|
10
|
-
if (!startMatch) return '';
|
|
11
|
-
|
|
12
|
-
const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
|
|
13
|
-
const rest = content.slice(startIdx);
|
|
14
|
-
|
|
15
|
-
let depth = 0;
|
|
16
|
-
let endIdx = -1;
|
|
17
|
-
|
|
18
|
-
for (let i = 0; i < rest.length; i++) {
|
|
19
|
-
if (rest[i] === '`') {
|
|
20
|
-
depth = depth === 0 ? 1 : 0;
|
|
21
|
-
}
|
|
22
|
-
if (depth === 0 && rest[i] === ';') {
|
|
23
|
-
endIdx = i;
|
|
24
|
-
break;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (endIdx === -1) return '';
|
|
29
|
-
|
|
30
|
-
const raw = rest.slice(0, endIdx);
|
|
31
|
-
const parts = raw.split(/`\s*\+\s*\n?\s*`/);
|
|
32
|
-
return parts.map((p) => p.replace(/^\s*`|`\s*$/g, '')).join('');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function loadRecipes(): Promise<RecipeEntry[]> {
|
|
36
|
-
if (cachedRecipes) return cachedRecipes;
|
|
37
|
-
|
|
38
|
-
const recipeDir = getRecipeDir();
|
|
39
|
-
const entries: RecipeEntry[] = [];
|
|
40
|
-
|
|
41
|
-
let dirs: string[];
|
|
42
|
-
try {
|
|
43
|
-
dirs = await readdir(recipeDir);
|
|
44
|
-
} catch {
|
|
45
|
-
cachedRecipes = [];
|
|
46
|
-
return [];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
for (const dirName of dirs) {
|
|
50
|
-
const dirPath = resolve(recipeDir, dirName);
|
|
51
|
-
const dirStat = await stat(dirPath).catch(() => null);
|
|
52
|
-
if (!dirStat?.isDirectory()) continue;
|
|
53
|
-
|
|
54
|
-
// Read structured metadata from meta.ts
|
|
55
|
-
const metaPath = resolve(dirPath, 'meta.ts');
|
|
56
|
-
let meta: {
|
|
57
|
-
title: string;
|
|
58
|
-
description: string;
|
|
59
|
-
components: string[];
|
|
60
|
-
features: string[];
|
|
61
|
-
pattern: string;
|
|
62
|
-
};
|
|
63
|
-
try {
|
|
64
|
-
const metaContent = await readFile(metaPath, 'utf-8');
|
|
65
|
-
meta = parseRecipeMeta(metaContent);
|
|
66
|
-
} catch {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Read recipeCode from +page.svelte (still embedded there for the live preview)
|
|
71
|
-
const pagePath = resolve(dirPath, '+page.svelte');
|
|
72
|
-
let code = '';
|
|
73
|
-
try {
|
|
74
|
-
const pageContent = await readFile(pagePath, 'utf-8');
|
|
75
|
-
code = extractRecipeCode(pageContent);
|
|
76
|
-
} catch {
|
|
77
|
-
// No page = no code, but metadata is still valid
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
entries.push({
|
|
81
|
-
id: dirName,
|
|
82
|
-
title: meta.title,
|
|
83
|
-
description: meta.description,
|
|
84
|
-
components: meta.components,
|
|
85
|
-
code,
|
|
86
|
-
features: meta.features,
|
|
87
|
-
pattern: meta.pattern || undefined
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
cachedRecipes = entries;
|
|
92
|
-
return entries;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export async function getRecipeById(id: string): Promise<RecipeEntry | null> {
|
|
96
|
-
const recipes = await loadRecipes();
|
|
97
|
-
return recipes.find((r) => r.id === id) ?? null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function parseRecipeMeta(content: string): {
|
|
101
|
-
title: string;
|
|
102
|
-
description: string;
|
|
103
|
-
components: string[];
|
|
104
|
-
features: string[];
|
|
105
|
-
pattern: string;
|
|
106
|
-
} {
|
|
107
|
-
const extractString = (key: string): string => {
|
|
108
|
-
const match = content.match(new RegExp(`${key}:\\s*['"]([^'"]*?)['"]`));
|
|
109
|
-
if (match?.[1]) return match[1];
|
|
110
|
-
// Multi-line string with concatenation
|
|
111
|
-
const multiMatch = content.match(new RegExp(`${key}:\\s*\\n?\\s*['"]([\\s\\S]*?)['"]`, 'm'));
|
|
112
|
-
return multiMatch?.[1]?.replace(/'\s*\+\s*\n?\s*'/g, '') ?? '';
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const extractArray = (key: string): string[] => {
|
|
116
|
-
const match = content.match(new RegExp(`${key}:\\s*\\[([^\\]]+)\\]`, 's'));
|
|
117
|
-
if (!match?.[1]) return [];
|
|
118
|
-
return match[1]
|
|
119
|
-
.split(',')
|
|
120
|
-
.map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
121
|
-
.filter((s) => s.length > 0);
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
title: extractString('title'),
|
|
126
|
-
description: extractString('description'),
|
|
127
|
-
components: extractArray('components'),
|
|
128
|
-
features: extractArray('features'),
|
|
129
|
-
pattern: extractString('pattern')
|
|
130
|
-
};
|
|
131
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Distribution heuristics β stage 2 of the validator. These are *judgements*
|
|
3
|
-
* about how design properties are spread across a page, not facts, so they emit
|
|
4
|
-
* `info`/`heuristic` findings with a light score impact. They operationalise the
|
|
5
|
-
* Design-Quality guidance ("Color = meaning", "Spacing = hierarchy", "Vary
|
|
6
|
-
* visual weight", "Commit to a radius") and target the exact monotony that
|
|
7
|
-
* scored lowest in design-quality evaluation (uniform spacing, rainbow
|
|
8
|
-
* intents, identical Cards, zero radius strategy).
|
|
9
|
-
*
|
|
10
|
-
* Thresholds are named constants so the eval-suite (WP5) can tune them with data
|
|
11
|
-
* instead of guesswork.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { Finding } from './types.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Chromatic intents only β `neutral` is structural greyscale, so neutral
|
|
18
|
-
* backgrounds (common and legitimate) must not count toward a decorative
|
|
19
|
-
* "rainbow". `info` is a distinct blue hue and does count.
|
|
20
|
-
*/
|
|
21
|
-
const CHROMATIC_INTENTS = ['primary', 'secondary', 'success', 'warning', 'danger', 'info'] as const;
|
|
22
|
-
|
|
23
|
-
/** Tunable thresholds. Kept together so evaluation can adjust them in one place. */
|
|
24
|
-
export const HEURISTIC_THRESHOLDS = {
|
|
25
|
-
/** Distinct intent hues used as backgrounds before it reads as a decorative "rainbow". */
|
|
26
|
-
rainbowIntentFamilies: 4,
|
|
27
|
-
/** Minimum spacing utilities before uniformity is judged (avoids nagging tiny snippets). */
|
|
28
|
-
minSpacingUtilities: 6,
|
|
29
|
-
/** Minimum Card instances before monotony is judged. */
|
|
30
|
-
minCards: 4,
|
|
31
|
-
/** Minimum structural surfaces before a missing radius strategy is worth a nudge. */
|
|
32
|
-
minSurfacesForRadius: 3
|
|
33
|
-
} as const;
|
|
34
|
-
|
|
35
|
-
/** All atoms inside class strings, lower-cased, comments already masked upstream. */
|
|
36
|
-
function collectAtoms(code: string): string[] {
|
|
37
|
-
const atoms: string[] = [];
|
|
38
|
-
// class="...", class={...}, slotClasses={{ base: '...' }} β grab quoted runs of utility-ish text.
|
|
39
|
-
const stringRe = /["'`]([^"'`]*?)["'`]/g;
|
|
40
|
-
for (const m of code.matchAll(stringRe)) {
|
|
41
|
-
const body = m[1]!;
|
|
42
|
-
if (!/[a-z]-/.test(body) && !/\b(flex|grid|block|hidden|relative|absolute)\b/.test(body))
|
|
43
|
-
continue;
|
|
44
|
-
for (const atom of body.split(/\s+/)) {
|
|
45
|
-
if (atom) atoms.push(atom);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return atoms;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** "Color = meaning": flag a decorative rainbow of intent backgrounds. */
|
|
52
|
-
function checkIntentRainbow(atoms: string[]): Finding[] {
|
|
53
|
-
const families = new Set<string>();
|
|
54
|
-
const bgIntent = new RegExp(
|
|
55
|
-
`^bg-(${CHROMATIC_INTENTS.join('|')})(?:-(?:hover|active|subtle|emphasis|\\d{2,3}))?(?:\\/\\d{1,3})?$`
|
|
56
|
-
);
|
|
57
|
-
for (const atom of atoms) {
|
|
58
|
-
const m = atom.match(bgIntent);
|
|
59
|
-
if (m) families.add(m[1]!);
|
|
60
|
-
}
|
|
61
|
-
if (families.size >= HEURISTIC_THRESHOLDS.rainbowIntentFamilies) {
|
|
62
|
-
return [
|
|
63
|
-
{
|
|
64
|
-
ruleId: 'intent-rainbow',
|
|
65
|
-
severity: 'info',
|
|
66
|
-
kind: 'heuristic',
|
|
67
|
-
message: `${families.size} different intent hues used as backgrounds (${[...families].join(', ')}). Reads as decoration, not meaning.`,
|
|
68
|
-
fix: 'Let neutral surfaces dominate (80β90%). Reserve intent colour for genuine status/severity/action signals.'
|
|
69
|
-
}
|
|
70
|
-
];
|
|
71
|
-
}
|
|
72
|
-
return [];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** "Spacing = hierarchy": flag a single uniform rhythm tier. */
|
|
76
|
-
function checkSpacingUniformity(atoms: string[]): Finding[] {
|
|
77
|
-
const values = new Set<string>();
|
|
78
|
-
let total = 0;
|
|
79
|
-
const spacingRe = /^(?:gap|gap-x|gap-y|space-x|space-y)-(\d+(?:\.\d+)?|px)$/;
|
|
80
|
-
for (const atom of atoms) {
|
|
81
|
-
const m = atom.match(spacingRe);
|
|
82
|
-
if (m) {
|
|
83
|
-
values.add(m[1]!);
|
|
84
|
-
total++;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
if (total >= HEURISTIC_THRESHOLDS.minSpacingUtilities && values.size <= 1) {
|
|
88
|
-
return [
|
|
89
|
-
{
|
|
90
|
-
ruleId: 'spacing-uniform',
|
|
91
|
-
severity: 'info',
|
|
92
|
-
kind: 'heuristic',
|
|
93
|
-
message: `All ${total} spacing utilities use one value (\`${[...values][0] ?? '?'}\`). No tight-within vs generous-between rhythm.`,
|
|
94
|
-
fix: 'Use two tiers: tight (`gap-2`/`gap-3`) within related items, generous (`gap-8`/`gap-10`) between sections.'
|
|
95
|
-
}
|
|
96
|
-
];
|
|
97
|
-
}
|
|
98
|
-
return [];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** "Vary visual weight": flag many identical Cards (same variant + padding). */
|
|
102
|
-
function checkCardMonotony(code: string): Finding[] {
|
|
103
|
-
const cardRe = /<Card\b([^>]*)>/g;
|
|
104
|
-
const signatures: string[] = [];
|
|
105
|
-
for (const m of code.matchAll(cardRe)) {
|
|
106
|
-
const attrs = m[1]!;
|
|
107
|
-
const variant = attrs.match(/\bvariant=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
|
|
108
|
-
const padding = attrs.match(/\bpadding=(?:"([^"]*)"|'([^']*)'|\{['"]([^'"]*)['"]\})/);
|
|
109
|
-
const v = variant ? (variant[1] ?? variant[2] ?? variant[3]) : 'default';
|
|
110
|
-
const p = padding ? (padding[1] ?? padding[2] ?? padding[3]) : 'default';
|
|
111
|
-
signatures.push(`${v}/${p}`);
|
|
112
|
-
}
|
|
113
|
-
if (signatures.length >= HEURISTIC_THRESHOLDS.minCards) {
|
|
114
|
-
const distinct = new Set(signatures);
|
|
115
|
-
if (distinct.size === 1) {
|
|
116
|
-
return [
|
|
117
|
-
{
|
|
118
|
-
ruleId: 'card-monotony',
|
|
119
|
-
severity: 'info',
|
|
120
|
-
kind: 'heuristic',
|
|
121
|
-
message: `All ${signatures.length} Cards share one look (\`${[...distinct][0]}\`). Visual weight does not vary.`,
|
|
122
|
-
fix: 'Differentiate: prominent content `variant="elevated"`/`padding="lg"`, secondary `variant="outlined"`/`padding="md"`.'
|
|
123
|
-
}
|
|
124
|
-
];
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return [];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** "Commit to a radius": nudge when structural surfaces exist but no radius override does. */
|
|
131
|
-
function checkRadiusStrategy(code: string, atoms: string[]): Finding[] {
|
|
132
|
-
// Only count genuine container surfaces. A bare `border`/`border-b` atom is far
|
|
133
|
-
// more often a table row, list divider, or input frame than a Card-like surface,
|
|
134
|
-
// so including it inflated the count into false positives (review finding #1).
|
|
135
|
-
const surfaceCount =
|
|
136
|
-
(code.match(/<Card\b/g)?.length ?? 0) +
|
|
137
|
-
(code.match(/<(?:Dialog|Drawer|Popover)\b/g)?.length ?? 0);
|
|
138
|
-
const hasRadiusOverride = atoms.some((a) => /^rounded(?:-|$)/.test(a));
|
|
139
|
-
if (surfaceCount >= HEURISTIC_THRESHOLDS.minSurfacesForRadius && !hasRadiusOverride) {
|
|
140
|
-
return [
|
|
141
|
-
{
|
|
142
|
-
ruleId: 'no-radius-strategy',
|
|
143
|
-
severity: 'info',
|
|
144
|
-
kind: 'heuristic',
|
|
145
|
-
message: 'Multiple surfaces but no explicit radius β relying solely on component defaults.',
|
|
146
|
-
fix: 'If the default tier radii do not match your design identity, commit to one philosophy (`rounded-lg`/`rounded-xl`/`rounded-2xl`) applied consistently via `class`/`slotClasses`.'
|
|
147
|
-
}
|
|
148
|
-
];
|
|
149
|
-
}
|
|
150
|
-
return [];
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Run all heuristics over the (comment-masked) code. */
|
|
154
|
-
export function runHeuristics(code: string): Finding[] {
|
|
155
|
-
const atoms = collectAtoms(code);
|
|
156
|
-
return [
|
|
157
|
-
...checkIntentRainbow(atoms),
|
|
158
|
-
...checkSpacingUniformity(atoms),
|
|
159
|
-
...checkCardMonotony(code),
|
|
160
|
-
...checkRadiusStrategy(code, atoms)
|
|
161
|
-
];
|
|
162
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public API of the Urbicon UI design linter.
|
|
3
|
-
*
|
|
4
|
-
* The linter is the deterministic half of the "generate β validate β fix" loop
|
|
5
|
-
* (docs/DESIGN-MCP.md, Option B). It is consumed by the `validate_design` MCP
|
|
6
|
-
* tool and, programmatically, by the eval-suite (WP5) which scores generated
|
|
7
|
-
* pages without an LLM in the loop.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export { HEURISTIC_THRESHOLDS } from './heuristics.js';
|
|
11
|
-
export { lintDesign, maskComments, SCORE_WEIGHTS } from './linter.js';
|
|
12
|
-
export { RULES } from './rules.js';
|
|
13
|
-
export { VALID_TOKEN_CORES } from './tokens.js';
|
|
14
|
-
export type { Finding, FindingKind, LintOptions, LintReport, Rule, Severity } from './types.js';
|