@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,250 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse and edit a `design.manifest.md`. Deliberately dependency-free (no YAML
|
|
3
|
-
* lib — consistent with the zero-dep ethos): frontmatter is flat `key: value`,
|
|
4
|
-
* and edits are surgical (replace one marked block / insert one ADR) so any
|
|
5
|
-
* hand-written prose, ordering, and formatting survive a round-trip.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { DesignDecision, DesignManifest, PatternUsage } from './types.js';
|
|
9
|
-
|
|
10
|
-
const USAGES_HEADING = '## Pattern Usages';
|
|
11
|
-
const DECISIONS_HEADING = '## Design Decisions';
|
|
12
|
-
const USAGES_START =
|
|
13
|
-
'<!-- AUTO-GENERATED pattern usages — managed by sync_design_manifest; do not edit by hand -->';
|
|
14
|
-
const USAGES_END = '<!-- END pattern usages -->';
|
|
15
|
-
|
|
16
|
-
/** Split leading `--- … ---` frontmatter from the body. Returns flat key→value pairs. */
|
|
17
|
-
export function parseFrontmatter(content: string): { data: Record<string, string>; body: string } {
|
|
18
|
-
const data: Record<string, string> = {};
|
|
19
|
-
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
20
|
-
if (!match) return { data, body: content };
|
|
21
|
-
|
|
22
|
-
for (const line of match[1]!.split(/\r?\n/)) {
|
|
23
|
-
const kv = line.match(/^([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
|
|
24
|
-
if (kv) {
|
|
25
|
-
const value = kv[2]!.trim().replace(/^["']|["']$/g, '');
|
|
26
|
-
if (value) data[kv[1]!] = value;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
return { data, body: content.slice(match[0].length) };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Extract the `## …` section body for a given heading (until the next `## ` or EOF). */
|
|
33
|
-
function extractSection(body: string, heading: string): string | null {
|
|
34
|
-
const lines = body.split('\n');
|
|
35
|
-
const start = lines.findIndex((l) => l.trim() === heading);
|
|
36
|
-
if (start === -1) return null;
|
|
37
|
-
let end = lines.length;
|
|
38
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
39
|
-
if (/^## /.test(lines[i]!)) {
|
|
40
|
-
end = i;
|
|
41
|
-
break;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return lines.slice(start + 1, end).join('\n');
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function parseUsages(body: string): PatternUsage[] {
|
|
48
|
-
const section = extractSection(body, USAGES_HEADING);
|
|
49
|
-
if (!section) return [];
|
|
50
|
-
const usages: PatternUsage[] = [];
|
|
51
|
-
for (const m of section.matchAll(/^- `([a-z0-9-]+)`\s+—\s+(.+)$/gm)) {
|
|
52
|
-
usages.push({ pattern: m[1]!, file: m[2]!.trim() });
|
|
53
|
-
}
|
|
54
|
-
return usages;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function parseDecisions(body: string): DesignDecision[] {
|
|
58
|
-
const section = extractSection(body, DECISIONS_HEADING);
|
|
59
|
-
if (!section) return [];
|
|
60
|
-
const decisions: DesignDecision[] = [];
|
|
61
|
-
// Each decision is a `### <date> — <title>` block.
|
|
62
|
-
const blocks = section.split(/^### /m).slice(1);
|
|
63
|
-
for (const block of blocks) {
|
|
64
|
-
const headerLine = block.split('\n', 1)[0]!;
|
|
65
|
-
const header = headerLine.match(/^(\d{4}-\d{2}-\d{2})\s+—\s+(.+)$/);
|
|
66
|
-
if (!header) continue;
|
|
67
|
-
const field = (name: string): string | undefined =>
|
|
68
|
-
block.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`))?.[1]?.trim();
|
|
69
|
-
decisions.push({
|
|
70
|
-
date: header[1]!,
|
|
71
|
-
title: header[2]!.trim(),
|
|
72
|
-
status: field('Status') ?? 'accepted',
|
|
73
|
-
decision: field('Decision') ?? '',
|
|
74
|
-
rationale: field('Rationale')
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
return decisions;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Parse a manifest file into structured form. */
|
|
81
|
-
export function parseManifest(content: string, exists = true): DesignManifest {
|
|
82
|
-
const { data, body } = parseFrontmatter(content);
|
|
83
|
-
return {
|
|
84
|
-
frontmatter: data,
|
|
85
|
-
usages: parseUsages(body),
|
|
86
|
-
decisions: parseDecisions(body),
|
|
87
|
-
exists
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The empty-manifest sentinel returned when no file exists. */
|
|
92
|
-
export function emptyManifest(): DesignManifest {
|
|
93
|
-
return { frontmatter: {}, usages: [], decisions: [], exists: false };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function renderUsagesBlock(usages: PatternUsage[]): string {
|
|
97
|
-
const lines = [USAGES_START];
|
|
98
|
-
if (usages.length === 0) {
|
|
99
|
-
lines.push('', '_No `data-design-pattern` markers found yet._', '');
|
|
100
|
-
} else {
|
|
101
|
-
lines.push('');
|
|
102
|
-
const sorted = [...usages].sort(
|
|
103
|
-
(a, b) => a.pattern.localeCompare(b.pattern) || a.file.localeCompare(b.file)
|
|
104
|
-
);
|
|
105
|
-
for (const u of sorted) lines.push(`- \`${u.pattern}\` — ${u.file}`);
|
|
106
|
-
lines.push('');
|
|
107
|
-
}
|
|
108
|
-
lines.push(USAGES_END);
|
|
109
|
-
return lines.join('\n');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Replace the auto-generated usages block (or insert the section) — everything else is untouched. */
|
|
113
|
-
export function upsertUsagesSection(content: string, usages: PatternUsage[]): string {
|
|
114
|
-
const block = renderUsagesBlock(usages);
|
|
115
|
-
const startIdx = content.indexOf(USAGES_START);
|
|
116
|
-
if (startIdx !== -1) {
|
|
117
|
-
const endIdx = content.indexOf(USAGES_END, startIdx);
|
|
118
|
-
if (endIdx !== -1) {
|
|
119
|
-
const after = endIdx + USAGES_END.length;
|
|
120
|
-
return content.slice(0, startIdx) + block + content.slice(after);
|
|
121
|
-
}
|
|
122
|
-
// Start marker present but end marker lost (hand-edit / merge): replace from the
|
|
123
|
-
// start marker to the next section heading so no orphaned block is left to double-count.
|
|
124
|
-
const nextSection = content.indexOf('\n## ', startIdx);
|
|
125
|
-
const truncateAt = nextSection !== -1 ? nextSection : content.length;
|
|
126
|
-
return content.slice(0, startIdx) + block + content.slice(truncateAt);
|
|
127
|
-
}
|
|
128
|
-
// No marker block yet — insert after the heading, or append a fresh section.
|
|
129
|
-
// NB: a function replacer, not a string, so `$`-sequences in a file path can't
|
|
130
|
-
// be interpreted as replacement patterns (`$'`, `$&`, …).
|
|
131
|
-
if (content.includes(`\n${USAGES_HEADING}`) || content.startsWith(USAGES_HEADING)) {
|
|
132
|
-
return content.replace(
|
|
133
|
-
new RegExp(`(${USAGES_HEADING}\\n)`),
|
|
134
|
-
(_m, heading: string) => `${heading}\n${block}\n`
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
const sep = content.endsWith('\n') ? '\n' : '\n\n';
|
|
138
|
-
return `${content}${sep}${USAGES_HEADING}\n\n${block}\n`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Collapse newlines to spaces — every ADR field is single-line in the Markdown format. */
|
|
142
|
-
function oneLine(s: string): string {
|
|
143
|
-
return s.replace(/[\r\n]+/g, ' ').trim();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Render one ADR block. */
|
|
147
|
-
export function renderDecision(d: DesignDecision): string {
|
|
148
|
-
const lines = [
|
|
149
|
-
`### ${d.date} — ${oneLine(d.title)}`,
|
|
150
|
-
'',
|
|
151
|
-
`**Status:** ${oneLine(d.status)}`,
|
|
152
|
-
'',
|
|
153
|
-
`**Decision:** ${oneLine(d.decision)}`
|
|
154
|
-
];
|
|
155
|
-
if (d.rationale) lines.push('', `**Rationale:** ${oneLine(d.rationale)}`);
|
|
156
|
-
return `${lines.join('\n')}\n`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** Insert a new ADR at the top of the Design Decisions section (newest first). Creates the section if absent. */
|
|
160
|
-
export function appendDecision(content: string, decision: DesignDecision): string {
|
|
161
|
-
const block = renderDecision(decision);
|
|
162
|
-
const m = content.match(/(?:^|\n)## Design Decisions[^\n]*\n/);
|
|
163
|
-
if (m && m.index !== undefined) {
|
|
164
|
-
let pos = m.index + m[0].length; // just past the heading line's newline
|
|
165
|
-
let prefix = '';
|
|
166
|
-
if (content[pos] === '\n')
|
|
167
|
-
pos += 1; // keep an existing blank line, insert after it
|
|
168
|
-
else prefix = '\n'; // no blank line below the heading — add one
|
|
169
|
-
return `${content.slice(0, pos) + prefix + block}\n${content.slice(pos)}`;
|
|
170
|
-
}
|
|
171
|
-
const sep = content.endsWith('\n') ? '\n' : '\n\n';
|
|
172
|
-
return `${content}${sep}${DECISIONS_HEADING}\n\n${block}`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** A starter manifest for a project that has none. */
|
|
176
|
-
export function createManifestTemplate(opts: {
|
|
177
|
-
paradigm?: string;
|
|
178
|
-
theme?: string;
|
|
179
|
-
density?: string;
|
|
180
|
-
projectName?: string;
|
|
181
|
-
}): string {
|
|
182
|
-
const fm = [
|
|
183
|
-
'---',
|
|
184
|
-
`paradigm: ${opts.paradigm ?? 'minimal'}`,
|
|
185
|
-
`theme: ${opts.theme ?? 'default'}`,
|
|
186
|
-
`density: ${opts.density ?? 'comfortable'}`,
|
|
187
|
-
'---'
|
|
188
|
-
].join('\n');
|
|
189
|
-
return [
|
|
190
|
-
fm,
|
|
191
|
-
'',
|
|
192
|
-
`# Design Manifest${opts.projectName ? ` — ${opts.projectName}` : ''}`,
|
|
193
|
-
'',
|
|
194
|
-
'The persistent design intent for this project. Frontmatter records the enforced intake',
|
|
195
|
-
'decisions (paradigm, theme, density). `## Pattern Usages` is regenerated from',
|
|
196
|
-
'`data-design-pattern` markers by `sync_design_manifest`. `## Design Decisions` is an',
|
|
197
|
-
'append-only ADR log written by `record_design_decision`.',
|
|
198
|
-
'',
|
|
199
|
-
USAGES_HEADING,
|
|
200
|
-
'',
|
|
201
|
-
renderUsagesBlock([]),
|
|
202
|
-
'',
|
|
203
|
-
DECISIONS_HEADING,
|
|
204
|
-
''
|
|
205
|
-
].join('\n');
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Human-readable context summary for `get_design_context`. */
|
|
209
|
-
export function formatContext(manifest: DesignManifest): string {
|
|
210
|
-
let md = '# Design Context\n\n';
|
|
211
|
-
|
|
212
|
-
const fm = manifest.frontmatter;
|
|
213
|
-
const keys = Object.keys(fm);
|
|
214
|
-
if (keys.length > 0) {
|
|
215
|
-
md += '## Intake\n\n';
|
|
216
|
-
for (const k of keys) md += `- **${k}:** ${fm[k]}\n`;
|
|
217
|
-
md += '\n';
|
|
218
|
-
if (fm.paradigm) {
|
|
219
|
-
md += `> Stay within the **${fm.paradigm}** paradigm. Call \`get_design_principles(topic="theming")\` for its token profile.\n\n`;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
md += '## Pattern Usages\n\n';
|
|
224
|
-
if (manifest.usages.length === 0) {
|
|
225
|
-
md +=
|
|
226
|
-
'_None recorded._ Add `data-design-pattern="<name>"` to page roots, then run `sync_design_manifest`.\n\n';
|
|
227
|
-
} else {
|
|
228
|
-
const byPattern = new Map<string, string[]>();
|
|
229
|
-
for (const u of manifest.usages) {
|
|
230
|
-
(byPattern.get(u.pattern) ?? byPattern.set(u.pattern, []).get(u.pattern)!).push(u.file);
|
|
231
|
-
}
|
|
232
|
-
for (const [pattern, files] of [...byPattern].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
233
|
-
md += `- \`${pattern}\` (${files.length}): ${files.join(', ')}\n`;
|
|
234
|
-
}
|
|
235
|
-
md += '\n> To change a pattern across the app, migrate every file listed under it.\n\n';
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
md += '## Design Decisions\n\n';
|
|
239
|
-
if (manifest.decisions.length === 0) {
|
|
240
|
-
md +=
|
|
241
|
-
'_None recorded._ Use `record_design_decision` when you deviate from a pattern or principle.\n';
|
|
242
|
-
} else {
|
|
243
|
-
for (const d of manifest.decisions) {
|
|
244
|
-
md += `- **${d.date} — ${d.title}** (${d.status}): ${d.decision}\n`;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return md;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export { DECISIONS_HEADING, USAGES_END, USAGES_HEADING, USAGES_START };
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
5
|
-
import { scanMarkers } from './scan.js';
|
|
6
|
-
|
|
7
|
-
let root: string;
|
|
8
|
-
|
|
9
|
-
beforeAll(async () => {
|
|
10
|
-
root = await mkdtemp(join(tmpdir(), 'uib-scan-'));
|
|
11
|
-
const write = async (rel: string, body: string) => {
|
|
12
|
-
const path = join(root, rel);
|
|
13
|
-
await mkdir(join(path, '..'), { recursive: true });
|
|
14
|
-
await writeFile(path, body, 'utf-8');
|
|
15
|
-
};
|
|
16
|
-
await write('src/routes/dashboard/+page.svelte', '<div data-design-pattern="dashboard">…</div>');
|
|
17
|
-
await write('src/routes/signup/+page.svelte', "<main data-design-pattern='form-page'>…</main>");
|
|
18
|
-
await write('src/lib/Plain.svelte', '<div class="bg-surface-base">no marker</div>');
|
|
19
|
-
// Must be skipped:
|
|
20
|
-
await write('src/node_modules/pkg/Comp.svelte', '<div data-design-pattern="should-skip">…</div>');
|
|
21
|
-
await write('dist/built.svelte', '<div data-design-pattern="should-skip">…</div>');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
afterAll(async () => {
|
|
25
|
-
await rm(root, { recursive: true, force: true });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('scanMarkers', () => {
|
|
29
|
-
it('finds markers and reports project-relative paths', async () => {
|
|
30
|
-
const usages = await scanMarkers(join(root, 'src'), root);
|
|
31
|
-
const patterns = usages.map((u) => u.pattern).sort();
|
|
32
|
-
expect(patterns).toEqual(['dashboard', 'form-page']);
|
|
33
|
-
expect(usages.find((u) => u.pattern === 'dashboard')?.file).toBe(
|
|
34
|
-
'src/routes/dashboard/+page.svelte'
|
|
35
|
-
);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('skips node_modules and build output', async () => {
|
|
39
|
-
const usages = await scanMarkers(root, root);
|
|
40
|
-
expect(usages.some((u) => u.pattern === 'should-skip')).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('handles both single- and double-quoted markers', async () => {
|
|
44
|
-
const usages = await scanMarkers(join(root, 'src'), root);
|
|
45
|
-
expect(usages.some((u) => u.pattern === 'form-page')).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('returns empty for a non-existent directory', async () => {
|
|
49
|
-
expect(await scanMarkers(join(root, 'does-not-exist'))).toEqual([]);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scan a source tree for `data-design-pattern="…"` markers. This is the
|
|
3
|
-
* convention (analogous to the `data-guide` namespace) that makes pattern usage
|
|
4
|
-
* greppable instead of guessable — the answer to DESIGN-SYSTEM-INTELLIGENCE.md's
|
|
5
|
-
* "how does the LLM reliably find which pages follow a pattern?".
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
9
|
-
import { join, relative, sep } from 'node:path';
|
|
10
|
-
import type { PatternUsage } from './types.js';
|
|
11
|
-
|
|
12
|
-
const SCANNED_EXT = /\.(svelte|html|tsx|jsx|astro|vue)$/;
|
|
13
|
-
const SKIP_DIRS = new Set([
|
|
14
|
-
'node_modules',
|
|
15
|
-
'.svelte-kit',
|
|
16
|
-
'.git',
|
|
17
|
-
'dist',
|
|
18
|
-
'build',
|
|
19
|
-
'.next',
|
|
20
|
-
'.turbo',
|
|
21
|
-
'coverage'
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
const MARKER_RE = /data-design-pattern\s*=\s*["'`]([a-z0-9-]+)["'`]/g;
|
|
25
|
-
|
|
26
|
-
/** Recursion cap — guards against symlink loops; real source trees are far shallower. */
|
|
27
|
-
const MAX_DEPTH = 24;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Recursively scan `dir` for marker usages. Files in the returned list are
|
|
31
|
-
* relative to `baseDir` (default `dir`) so the manifest stays portable.
|
|
32
|
-
*/
|
|
33
|
-
export async function scanMarkers(
|
|
34
|
-
dir: string,
|
|
35
|
-
baseDir: string = dir,
|
|
36
|
-
depth = 0
|
|
37
|
-
): Promise<PatternUsage[]> {
|
|
38
|
-
const usages: PatternUsage[] = [];
|
|
39
|
-
if (depth > MAX_DEPTH) return usages;
|
|
40
|
-
|
|
41
|
-
let names: string[];
|
|
42
|
-
try {
|
|
43
|
-
names = await readdir(dir);
|
|
44
|
-
} catch {
|
|
45
|
-
return usages;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const name of names) {
|
|
49
|
-
const full = join(dir, name);
|
|
50
|
-
const info = await stat(full).catch(() => null);
|
|
51
|
-
if (!info) continue;
|
|
52
|
-
|
|
53
|
-
if (info.isDirectory()) {
|
|
54
|
-
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue;
|
|
55
|
-
usages.push(...(await scanMarkers(full, baseDir, depth + 1)));
|
|
56
|
-
} else if (info.isFile() && SCANNED_EXT.test(name)) {
|
|
57
|
-
let content: string;
|
|
58
|
-
try {
|
|
59
|
-
content = await readFile(full, 'utf-8');
|
|
60
|
-
} catch {
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
const seen = new Set<string>();
|
|
64
|
-
for (const m of content.matchAll(MARKER_RE)) {
|
|
65
|
-
const pattern = m[1]!;
|
|
66
|
-
if (seen.has(pattern)) continue; // one entry per (pattern, file)
|
|
67
|
-
seen.add(pattern);
|
|
68
|
-
usages.push({ pattern, file: relative(baseDir, full).split(sep).join('/') });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return usages;
|
|
74
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Types for the design manifest — the per-consumer-project memory of design
|
|
3
|
-
* intent (docs/DESIGN-MCP.md, Option C). The manifest is a Markdown file
|
|
4
|
-
* (`design.manifest.md`) at the consumer's project root with three parts:
|
|
5
|
-
*
|
|
6
|
-
* 1. Frontmatter — the enforced intake decisions (paradigm, theme, density).
|
|
7
|
-
* 2. Pattern Usages — an auto-generated index of `data-design-pattern` markers
|
|
8
|
-
* found in the source (so "which pages follow pattern X" is a grep, not a
|
|
9
|
-
* guess — answering the open question from DESIGN-SYSTEM-INTELLIGENCE.md).
|
|
10
|
-
* 3. Design Decisions — append-only ADRs recording deliberate deviations.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** One `data-design-pattern="…"` marker found in the source tree. */
|
|
14
|
-
export interface PatternUsage {
|
|
15
|
-
/** The pattern name, e.g. "dashboard". */
|
|
16
|
-
pattern: string;
|
|
17
|
-
/** Source file, relative to the project root. */
|
|
18
|
-
file: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** A recorded design decision (ADR). */
|
|
22
|
-
export interface DesignDecision {
|
|
23
|
-
/** ISO date (YYYY-MM-DD). */
|
|
24
|
-
date: string;
|
|
25
|
-
title: string;
|
|
26
|
-
/** accepted | proposed | superseded — free text, defaults to "accepted". */
|
|
27
|
-
status: string;
|
|
28
|
-
decision: string;
|
|
29
|
-
rationale?: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Parsed view of a manifest file. */
|
|
33
|
-
export interface DesignManifest {
|
|
34
|
-
/** Flat key→value frontmatter (paradigm, theme, density, …). */
|
|
35
|
-
frontmatter: Record<string, string>;
|
|
36
|
-
usages: PatternUsage[];
|
|
37
|
-
decisions: DesignDecision[];
|
|
38
|
-
/** Whether a manifest file actually existed (false → defaults returned). */
|
|
39
|
-
exists: boolean;
|
|
40
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { MAX_RUBRIC_SCORE, RUBRIC_CRITERIA, renderRubric } from './rubric.js';
|
|
3
|
-
|
|
4
|
-
describe('rubric criteria', () => {
|
|
5
|
-
it('keeps the eight A/B-test criteria', () => {
|
|
6
|
-
expect(RUBRIC_CRITERIA).toHaveLength(8);
|
|
7
|
-
expect(MAX_RUBRIC_SCORE).toBe(40);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it('has unique ids and complete anchors', () => {
|
|
11
|
-
const ids = new Set(RUBRIC_CRITERIA.map((c) => c.id));
|
|
12
|
-
expect(ids.size).toBe(RUBRIC_CRITERIA.length);
|
|
13
|
-
for (const c of RUBRIC_CRITERIA) {
|
|
14
|
-
expect(c.name).toBeTruthy();
|
|
15
|
-
expect(c.measures).toBeTruthy();
|
|
16
|
-
for (const score of [1, 3, 5] as const) {
|
|
17
|
-
expect(c.anchors[score], `${c.id} anchor ${score}`).toBeTruthy();
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('anchors technical correctness on validate_design', () => {
|
|
23
|
-
const correctness = RUBRIC_CRITERIA.find((c) => c.id === 'correctness');
|
|
24
|
-
expect(correctness?.anchors[5]).toContain('validate_design');
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('renderRubric', () => {
|
|
29
|
-
const md = renderRubric();
|
|
30
|
-
|
|
31
|
-
it('renders every criterion and the total', () => {
|
|
32
|
-
for (const c of RUBRIC_CRITERIA) expect(md).toContain(c.name);
|
|
33
|
-
expect(md).toContain(`/${MAX_RUBRIC_SCORE}`);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('tells the judge to run validate_design first', () => {
|
|
37
|
-
expect(md).toContain('validate_design');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('describes the panel-of-lenses approach for variant selection', () => {
|
|
41
|
-
expect(md.toLowerCase()).toContain('lens');
|
|
42
|
-
});
|
|
43
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The design-quality scoring rubric — the qualitative half of the design loop
|
|
3
|
-
* (docs/DESIGN-MCP.md, step 3). Where `validate_design` answers "is it correct?"
|
|
4
|
-
* deterministically, the rubric answers "is it good?" through a judge.
|
|
5
|
-
*
|
|
6
|
-
* The eight criteria have been validated empirically against design-quality
|
|
7
|
-
* comparisons, scoring each 1–5 and summing to /40. Keeping the same instrument
|
|
8
|
-
* means new evaluations are directly comparable to that baseline. This is the
|
|
9
|
-
* SINGLE SOURCE for the criteria: the
|
|
10
|
-
* `get_design_principles(as="rubric")` tool renders it to Markdown, and the
|
|
11
|
-
* eval-suite (WP5) imports the same constants to score programmatically.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface RubricCriterion {
|
|
15
|
-
id: string;
|
|
16
|
-
/** Display name for the criterion. */
|
|
17
|
-
name: string;
|
|
18
|
-
/** One line on what the criterion measures. */
|
|
19
|
-
measures: string;
|
|
20
|
-
/** Anchored descriptions for scores 1, 3 and 5 (the judge interpolates 2 and 4). */
|
|
21
|
-
anchors: { 1: string; 3: string; 5: string };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const RUBRIC_CRITERIA: readonly RubricCriterion[] = [
|
|
25
|
-
{
|
|
26
|
-
id: 'distinctiveness',
|
|
27
|
-
name: 'Design Language Distinctiveness',
|
|
28
|
-
measures: 'Whether the page has its own visual identity or reads as a generic template.',
|
|
29
|
-
anchors: {
|
|
30
|
-
1: 'The most common layout imaginable — a Tailwind-UI starter with no personality.',
|
|
31
|
-
3: 'A few custom touches (one heading style, one composition) over conventional bones.',
|
|
32
|
-
5: 'A coherent, deliberate identity: custom compositions over default components, a consistent typographic voice, signature moments.'
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
id: 'color',
|
|
37
|
-
name: 'Color Scheme Coherence',
|
|
38
|
-
measures: 'Whether colour carries meaning or merely decorates.',
|
|
39
|
-
anchors: {
|
|
40
|
-
1: 'Decorative colour — a rainbow of intents; intent colours where neutral belongs.',
|
|
41
|
-
3: 'Intent mapping mostly correct, but some decorative or noisy colour remains.',
|
|
42
|
-
5: 'Neutral surfaces dominate (80–90%); intent colour appears only for genuine status, severity, or action.'
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: 'spacing',
|
|
47
|
-
name: 'Spacing Consistency',
|
|
48
|
-
measures: 'Whether spacing expresses hierarchy.',
|
|
49
|
-
anchors: {
|
|
50
|
-
1: 'One uniform rhythm everywhere (e.g. all `space-y-6`).',
|
|
51
|
-
3: 'Some variation, but no clear within-vs-between system.',
|
|
52
|
-
5: 'A clear two-tier rhythm (tight within items, generous between sections), with data-driven variation where it helps.'
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
id: 'radius',
|
|
57
|
-
name: 'Radius & Shape Language',
|
|
58
|
-
measures: 'Whether shape is a deliberate choice.',
|
|
59
|
-
anchors: {
|
|
60
|
-
1: 'Zero radius intent — component defaults only, no shape strategy.',
|
|
61
|
-
3: 'Some radius use, but inconsistent (mixed methods, no hierarchy).',
|
|
62
|
-
5: 'A deliberate radius hierarchy (e.g. hero > standard > compact) applied consistently via `class`/`slotClasses`.'
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
id: 'ux',
|
|
67
|
-
name: 'UX Pattern Originality',
|
|
68
|
-
measures: 'Whether interaction patterns go beyond the textbook.',
|
|
69
|
-
anchors: {
|
|
70
|
-
1: 'Textbook only — divider lists, stacked buttons, defaults throughout.',
|
|
71
|
-
3: 'A few genuine UX touches (a thoughtful empty state, a useful affordance).',
|
|
72
|
-
5: 'Creative, effective patterns that serve the content — original compositions, state-driven layout.'
|
|
73
|
-
}
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
id: 'hierarchy',
|
|
77
|
-
name: 'Visual Hierarchy',
|
|
78
|
-
measures: 'Whether the eye is guided to what matters.',
|
|
79
|
-
anchors: {
|
|
80
|
-
1: 'Everything equally weighted — nothing dominates; labels compete with data.',
|
|
81
|
-
3: 'Some dominance, but flat regions remain.',
|
|
82
|
-
5: 'Each section has one clearly dominant element; metadata is recessed; visual weight tracks importance.'
|
|
83
|
-
}
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
id: 'cohesion',
|
|
87
|
-
name: 'Overall Design Cohesion',
|
|
88
|
-
measures: 'Whether the page reads as one designed artifact.',
|
|
89
|
-
anchors: {
|
|
90
|
-
1: 'Cohesive only through sameness, or parts feel grafted on / disconnected.',
|
|
91
|
-
3: 'Mostly unified, with a section or two that drift.',
|
|
92
|
-
5: 'A single design DNA — consistent radius, typographic voice, and component logic tie the whole page together.'
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
id: 'correctness',
|
|
97
|
-
name: 'Technical Correctness',
|
|
98
|
-
measures: 'Whether the code is valid and uses real component APIs and design tokens.',
|
|
99
|
-
anchors: {
|
|
100
|
-
1: 'Hallucinated tokens, broken dynamic classes, or wrong component APIs — would not render as intended.',
|
|
101
|
-
3: 'Largely correct with a few token or API slips.',
|
|
102
|
-
5: 'Valid semantic tokens, correct Svelte 5 and component APIs, no broken classes. Anchor this with `validate_design` — a passing linter (0 errors/warnings) puts this at 4–5.'
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
];
|
|
106
|
-
|
|
107
|
-
export const MAX_RUBRIC_SCORE = RUBRIC_CRITERIA.length * 5;
|
|
108
|
-
|
|
109
|
-
/** Render the rubric as Markdown for a judge (served by `get_design_principles(as="rubric")`). */
|
|
110
|
-
export function renderRubric(): string {
|
|
111
|
-
let md = '# Design-Quality Rubric\n\n';
|
|
112
|
-
md += `Score a generated UI on each of the ${RUBRIC_CRITERIA.length} criteria from **1 to 5**, then sum to **/${MAX_RUBRIC_SCORE}**. `;
|
|
113
|
-
md +=
|
|
114
|
-
'For every score, cite specific evidence from the code (a class, a component, a layout choice) — a number without a reason is not a judgement.\n\n';
|
|
115
|
-
md += '**Before scoring, run `validate_design` on the code.** It deterministically catches the ';
|
|
116
|
-
md +=
|
|
117
|
-
'correctness failures (hallucinated tokens, broken dynamic classes) that a judge tends to miss, and it anchors the *Technical Correctness* criterion.\n\n';
|
|
118
|
-
|
|
119
|
-
for (const [i, c] of RUBRIC_CRITERIA.entries()) {
|
|
120
|
-
md += `## ${i + 1}. ${c.name}\n\n`;
|
|
121
|
-
md += `*${c.measures}*\n\n`;
|
|
122
|
-
md += `- **1** — ${c.anchors[1]}\n`;
|
|
123
|
-
md += `- **3** — ${c.anchors[3]}\n`;
|
|
124
|
-
md += `- **5** — ${c.anchors[5]}\n\n`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
md += '---\n\n';
|
|
128
|
-
md += '## Using the rubric\n\n';
|
|
129
|
-
md +=
|
|
130
|
-
'- **As a single judge:** score all criteria, sum to /' +
|
|
131
|
-
MAX_RUBRIC_SCORE +
|
|
132
|
-
', and list the two lowest as the concrete revision targets.\n';
|
|
133
|
-
md +=
|
|
134
|
-
'- **As a panel (recommended for variant selection):** run one judge per *lens* — correctness, hierarchy, paradigm-fidelity, distinctiveness — rather than N identical judges. Diversity of lens catches failures redundancy cannot.\n';
|
|
135
|
-
md +=
|
|
136
|
-
'- **For N variants:** score each, pick the winner, then graft the best ideas from the runners-up before a final `validate_design` pass.\n';
|
|
137
|
-
md +=
|
|
138
|
-
'- **Reward deviation within the rules.** A safe, generic page should not outscore a distinctive one that stays inside the paradigm. Penalise AI-slop sameness on *Distinctiveness* and *UX Pattern Originality*.\n';
|
|
139
|
-
return md;
|
|
140
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
import { emptyManifest, formatContext, parseManifest } from '../design-manifest/index.js';
|
|
5
|
-
import { getProjectManifestPath } from '../utils/paths.js';
|
|
6
|
-
|
|
7
|
-
export function registerGetDesignContextTool(server: McpServer): void {
|
|
8
|
-
server.tool(
|
|
9
|
-
'get_design_context',
|
|
10
|
-
"Read this project's design manifest (design.manifest.md): the chosen paradigm / theme / density, which pages use which composition patterns, and the recorded design decisions (ADRs). Call this at the START of any UI task so generated code stays consistent with what the project has already committed to.",
|
|
11
|
-
{
|
|
12
|
-
manifestPath: z
|
|
13
|
-
.string()
|
|
14
|
-
.optional()
|
|
15
|
-
.describe(
|
|
16
|
-
'Path to design.manifest.md. Defaults to ./design.manifest.md in the project root.'
|
|
17
|
-
)
|
|
18
|
-
},
|
|
19
|
-
{ readOnlyHint: true },
|
|
20
|
-
async ({ manifestPath }) => {
|
|
21
|
-
const path = manifestPath ?? getProjectManifestPath();
|
|
22
|
-
|
|
23
|
-
let manifest: ReturnType<typeof emptyManifest>;
|
|
24
|
-
try {
|
|
25
|
-
manifest = parseManifest(await readFile(path, 'utf-8'));
|
|
26
|
-
} catch {
|
|
27
|
-
manifest = emptyManifest();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
let text = formatContext(manifest);
|
|
31
|
-
if (!manifest.exists) {
|
|
32
|
-
text +=
|
|
33
|
-
`\n\n> No manifest found at \`${path}\`. Scaffold one with \`sync_design_manifest\`, ` +
|
|
34
|
-
'or record the first decision with `record_design_decision`.\n';
|
|
35
|
-
}
|
|
36
|
-
text += '\n---\n\n**Next steps:**\n';
|
|
37
|
-
text += '- `get_pattern("<name>")` — the rules behind a pattern listed above\n';
|
|
38
|
-
text += '- `get_design_principles(topic="theming")` — the paradigm token profile\n';
|
|
39
|
-
|
|
40
|
-
return { content: [{ type: 'text' as const, text }] };
|
|
41
|
-
}
|
|
42
|
-
);
|
|
43
|
-
}
|