@urbicon-ui/design-engine 6.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,439 @@
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 {
9
+ DesignDecision,
10
+ DesignManifest,
11
+ PatternUsage,
12
+ ProductIntent,
13
+ ValidationHistoryEntry
14
+ } from './types.js';
15
+
16
+ const INTENT_HEADING = '## Product Intent';
17
+ const TOKEN_OVERRIDES_HEADING = '## Token Overrides';
18
+ const USAGES_HEADING = '## Pattern Usages';
19
+ const DECISIONS_HEADING = '## Design Decisions';
20
+ /**
21
+ * Stable detection prefix for the auto-generated usages block. The human-readable
22
+ * tail of the marker (below) may change without breaking `upsertUsagesSection` or
23
+ * stranding manifests written by an older version — detection keys off this prefix,
24
+ * not the full string.
25
+ */
26
+ const USAGES_MARKER_PREFIX = '<!-- AUTO-GENERATED pattern usages';
27
+ const USAGES_START = `${USAGES_MARKER_PREFIX} — regenerated from data-design-pattern markers; do not edit by hand -->`;
28
+ const USAGES_END = '<!-- END pattern usages -->';
29
+
30
+ /** Split leading `--- … ---` frontmatter from the body. Returns flat key→value pairs. */
31
+ export function parseFrontmatter(content: string): { data: Record<string, string>; body: string } {
32
+ const data: Record<string, string> = {};
33
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
34
+ if (!match) return { data, body: content };
35
+
36
+ for (const line of match[1]!.split(/\r?\n/)) {
37
+ const kv = line.match(/^([a-zA-Z][\w-]*)\s*:\s*(.*)$/);
38
+ if (kv) {
39
+ const value = kv[2]!.trim().replace(/^["']|["']$/g, '');
40
+ if (value) data[kv[1]!] = value;
41
+ }
42
+ }
43
+ return { data, body: content.slice(match[0].length) };
44
+ }
45
+
46
+ /** Extract the `## …` section body for a given heading (until the next `## ` or EOF). */
47
+ function extractSection(body: string, heading: string): string | null {
48
+ const lines = body.split('\n');
49
+ const start = lines.findIndex((l) => l.trim() === heading);
50
+ if (start === -1) return null;
51
+ let end = lines.length;
52
+ for (let i = start + 1; i < lines.length; i++) {
53
+ if (/^## /.test(lines[i]!)) {
54
+ end = i;
55
+ break;
56
+ }
57
+ }
58
+ return lines.slice(start + 1, end).join('\n');
59
+ }
60
+
61
+ function parseUsages(body: string): PatternUsage[] {
62
+ const section = extractSection(body, USAGES_HEADING);
63
+ if (!section) return [];
64
+ const usages: PatternUsage[] = [];
65
+ for (const m of section.matchAll(/^- `([a-z0-9-]+)`\s+—\s+(.+)$/gm)) {
66
+ usages.push({ pattern: m[1]!, file: m[2]!.trim() });
67
+ }
68
+ return usages;
69
+ }
70
+
71
+ function parseDecisions(body: string): DesignDecision[] {
72
+ const section = extractSection(body, DECISIONS_HEADING);
73
+ if (!section) return [];
74
+ const decisions: DesignDecision[] = [];
75
+ // Each decision is a `### <date> — <title>` block.
76
+ const blocks = section.split(/^### /m).slice(1);
77
+ for (const block of blocks) {
78
+ const headerLine = block.split('\n', 1)[0]!;
79
+ const header = headerLine.match(/^(\d{4}-\d{2}-\d{2})\s+—\s+(.+)$/);
80
+ if (!header) continue;
81
+ const field = (name: string): string | undefined =>
82
+ block.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+)`))?.[1]?.trim();
83
+ decisions.push({
84
+ date: header[1]!,
85
+ title: header[2]!.trim(),
86
+ status: field('Status') ?? 'accepted',
87
+ decision: field('Decision') ?? '',
88
+ rationale: field('Rationale')
89
+ });
90
+ }
91
+ return decisions;
92
+ }
93
+
94
+ /** The empty product-intent shape (arrays never undefined). */
95
+ function emptyIntent(): ProductIntent {
96
+ return { voice: [], references: [], antiReferences: [] };
97
+ }
98
+
99
+ /**
100
+ * Parse the `## Product Intent` section. Two field grammars coexist (tolerantly):
101
+ * an inline `**Label:** value` (audience, voice) and a labelled list — `**Label:**`
102
+ * followed by `- bullets` and/or an inline comma value (references, anti-references).
103
+ */
104
+ function parseIntent(body: string): ProductIntent {
105
+ const section = extractSection(body, INTENT_HEADING);
106
+ if (section === null) return emptyIntent();
107
+ const lines = section.split('\n');
108
+
109
+ const inlineField = (label: string): string | undefined => {
110
+ const re = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.+)$`);
111
+ for (const l of lines) {
112
+ const m = l.match(re);
113
+ if (m) return m[1]!.trim();
114
+ }
115
+ return undefined;
116
+ };
117
+
118
+ // Items under a `**Label:**`: an inline comma list on the label line and/or the
119
+ // bullet lines that follow it, up to the next labelled field. Blank/prose lines
120
+ // in between are skipped, not treated as terminators.
121
+ const listField = (label: string): string[] => {
122
+ const items: string[] = [];
123
+ const labelRe = new RegExp(`^\\*\\*${label}:\\*\\*\\s*(.*)$`);
124
+ let capturing = false;
125
+ for (const l of lines) {
126
+ if (!capturing) {
127
+ const m = l.match(labelRe);
128
+ if (m) {
129
+ capturing = true;
130
+ const inline = m[1]!.trim();
131
+ if (inline) items.push(...splitList(inline));
132
+ }
133
+ continue;
134
+ }
135
+ if (/^\*\*[^*]+:\*\*/.test(l)) break; // next labelled field ends this one
136
+ const bullet = l.match(/^\s*[-*]\s+(.+)$/);
137
+ if (bullet) items.push(bullet[1]!.trim());
138
+ }
139
+ return items;
140
+ };
141
+
142
+ // Voice shares the list grammar (inline comma list and/or bullets) — a user who
143
+ // mirrors the bullet style of the reference lists still gets parsed, not silently dropped.
144
+ return {
145
+ audience: inlineField('Audience'),
146
+ voice: listField('Voice'),
147
+ references: listField('References'),
148
+ antiReferences: listField('Anti-references')
149
+ };
150
+ }
151
+
152
+ /** Split a comma-separated inline value into trimmed, non-empty parts. */
153
+ function splitList(value: string): string[] {
154
+ return value
155
+ .split(',')
156
+ .map((part) => part.trim())
157
+ .filter(Boolean);
158
+ }
159
+
160
+ /**
161
+ * Parse the `## Token Overrides` section: the backtick-quoted cores in its bullet
162
+ * list (a trailing `— note` is ignored). Deduplicated, order preserved. Cores only
163
+ * (`surface-brand`); a full utility like `bg-surface-brand` parses but is inert at
164
+ * the linter (matching the `extraTokens` contract), so we keep it verbatim.
165
+ */
166
+ function parseTokenOverrides(body: string): string[] {
167
+ const section = extractSection(body, TOKEN_OVERRIDES_HEADING);
168
+ if (!section) return [];
169
+ const cores: string[] = [];
170
+ const seen = new Set<string>();
171
+ for (const m of section.matchAll(/^\s*[-*]\s+`([a-z][a-z0-9-]*)`/gm)) {
172
+ const core = m[1]!;
173
+ if (!seen.has(core)) {
174
+ seen.add(core);
175
+ cores.push(core);
176
+ }
177
+ }
178
+ return cores;
179
+ }
180
+
181
+ /** Parse a manifest file into structured form. */
182
+ export function parseManifest(content: string, exists = true): DesignManifest {
183
+ const { data, body } = parseFrontmatter(content);
184
+ return {
185
+ frontmatter: data,
186
+ intent: parseIntent(body),
187
+ tokenOverrides: parseTokenOverrides(body),
188
+ usages: parseUsages(body),
189
+ decisions: parseDecisions(body),
190
+ exists
191
+ };
192
+ }
193
+
194
+ /** The empty-manifest sentinel returned when no file exists. */
195
+ export function emptyManifest(): DesignManifest {
196
+ return {
197
+ frontmatter: {},
198
+ intent: emptyIntent(),
199
+ tokenOverrides: [],
200
+ usages: [],
201
+ decisions: [],
202
+ exists: false
203
+ };
204
+ }
205
+
206
+ function renderUsagesBlock(usages: PatternUsage[]): string {
207
+ const lines = [USAGES_START];
208
+ if (usages.length === 0) {
209
+ lines.push('', '_No `data-design-pattern` markers found yet._', '');
210
+ } else {
211
+ lines.push('');
212
+ const sorted = [...usages].sort(
213
+ (a, b) => a.pattern.localeCompare(b.pattern) || a.file.localeCompare(b.file)
214
+ );
215
+ for (const u of sorted) lines.push(`- \`${u.pattern}\` — ${u.file}`);
216
+ lines.push('');
217
+ }
218
+ lines.push(USAGES_END);
219
+ return lines.join('\n');
220
+ }
221
+
222
+ /** Replace the auto-generated usages block (or insert the section) — everything else is untouched. */
223
+ export function upsertUsagesSection(content: string, usages: PatternUsage[]): string {
224
+ const block = renderUsagesBlock(usages);
225
+ // Detect by the stable prefix, not the full marker, so a manifest written by an
226
+ // older version (different marker tail) is still found and replaced, not doubled.
227
+ const startIdx = content.indexOf(USAGES_MARKER_PREFIX);
228
+ if (startIdx !== -1) {
229
+ const endIdx = content.indexOf(USAGES_END, startIdx);
230
+ if (endIdx !== -1) {
231
+ const after = endIdx + USAGES_END.length;
232
+ return content.slice(0, startIdx) + block + content.slice(after);
233
+ }
234
+ // Start marker present but end marker lost (hand-edit / merge): replace from the
235
+ // start marker to the next section heading so no orphaned block is left to double-count.
236
+ const nextSection = content.indexOf('\n## ', startIdx);
237
+ const truncateAt = nextSection !== -1 ? nextSection : content.length;
238
+ return content.slice(0, startIdx) + block + content.slice(truncateAt);
239
+ }
240
+ // No marker block yet — insert after the heading, or append a fresh section.
241
+ // NB: a function replacer, not a string, so `$`-sequences in a file path can't
242
+ // be interpreted as replacement patterns (`$'`, `$&`, …).
243
+ if (content.includes(`\n${USAGES_HEADING}`) || content.startsWith(USAGES_HEADING)) {
244
+ return content.replace(
245
+ new RegExp(`(${USAGES_HEADING}\\n)`),
246
+ (_m, heading: string) => `${heading}\n${block}\n`
247
+ );
248
+ }
249
+ const sep = content.endsWith('\n') ? '\n' : '\n\n';
250
+ return `${content}${sep}${USAGES_HEADING}\n\n${block}\n`;
251
+ }
252
+
253
+ /** Collapse newlines to spaces — every ADR field is single-line in the Markdown format. */
254
+ function oneLine(s: string): string {
255
+ return s.replace(/[\r\n]+/g, ' ').trim();
256
+ }
257
+
258
+ /** Render one ADR block. */
259
+ export function renderDecision(d: DesignDecision): string {
260
+ const lines = [
261
+ `### ${d.date} — ${oneLine(d.title)}`,
262
+ '',
263
+ `**Status:** ${oneLine(d.status)}`,
264
+ '',
265
+ `**Decision:** ${oneLine(d.decision)}`
266
+ ];
267
+ if (d.rationale) lines.push('', `**Rationale:** ${oneLine(d.rationale)}`);
268
+ return `${lines.join('\n')}\n`;
269
+ }
270
+
271
+ /** Insert a new ADR at the top of the Design Decisions section (newest first). Creates the section if absent. */
272
+ export function appendDecision(content: string, decision: DesignDecision): string {
273
+ const block = renderDecision(decision);
274
+ const m = content.match(/(?:^|\n)## Design Decisions[^\n]*\n/);
275
+ if (m && m.index !== undefined) {
276
+ let pos = m.index + m[0].length; // just past the heading line's newline
277
+ let prefix = '';
278
+ if (content[pos] === '\n')
279
+ pos += 1; // keep an existing blank line, insert after it
280
+ else prefix = '\n'; // no blank line below the heading — add one
281
+ return `${content.slice(0, pos) + prefix + block}\n${content.slice(pos)}`;
282
+ }
283
+ const sep = content.endsWith('\n') ? '\n' : '\n\n';
284
+ return `${content}${sep}${DECISIONS_HEADING}\n\n${block}`;
285
+ }
286
+
287
+ /** A starter manifest for a project that has none. */
288
+ export function createManifestTemplate(opts: {
289
+ paradigm?: string;
290
+ theme?: string;
291
+ density?: string;
292
+ projectName?: string;
293
+ }): string {
294
+ const fm = [
295
+ '---',
296
+ `paradigm: ${opts.paradigm ?? 'minimal'}`,
297
+ `theme: ${opts.theme ?? 'default'}`,
298
+ `density: ${opts.density ?? 'comfortable'}`,
299
+ '---'
300
+ ].join('\n');
301
+ return [
302
+ fm,
303
+ '',
304
+ `# Design Manifest${opts.projectName ? ` — ${opts.projectName}` : ''}`,
305
+ '',
306
+ 'The persistent design intent for this project. Frontmatter records the enforced intake',
307
+ 'decisions (paradigm, theme, density). `## Product Intent` is the target identity.',
308
+ '`## Token Overrides` lists project-specific tokens `urbicon validate` should accept.',
309
+ '`## Pattern Usages` is regenerated from `data-design-pattern` markers by',
310
+ '`urbicon sync-manifest`. `## Design Decisions` is an append-only ADR log written by',
311
+ '`urbicon record-decision`.',
312
+ '',
313
+ INTENT_HEADING,
314
+ '',
315
+ '<!-- The identity this project designs toward — read at the start of every design task.',
316
+ ' Fill each field; an empty field is simply "not set yet". Voice = a few adjectives. -->',
317
+ '',
318
+ '**Audience:**',
319
+ '',
320
+ '**Voice:**',
321
+ '',
322
+ '**References:**',
323
+ '',
324
+ '**Anti-references:**',
325
+ '',
326
+ TOKEN_OVERRIDES_HEADING,
327
+ '',
328
+ '<!-- Project-specific semantic token cores defined on top of Urbicon’s. Listed here as a',
329
+ ' bullet of `core` (the part after the utility prefix — `surface-brand`, not',
330
+ ' `bg-surface-brand`), they are treated as valid by `urbicon validate`. -->',
331
+ '',
332
+ '_None yet._',
333
+ '',
334
+ USAGES_HEADING,
335
+ '',
336
+ renderUsagesBlock([]),
337
+ '',
338
+ DECISIONS_HEADING,
339
+ ''
340
+ ].join('\n');
341
+ }
342
+
343
+ /** Whether a product intent carries any content at all. */
344
+ function intentIsEmpty(intent: ProductIntent): boolean {
345
+ return (
346
+ !intent.audience &&
347
+ intent.voice.length === 0 &&
348
+ intent.references.length === 0 &&
349
+ intent.antiReferences.length === 0
350
+ );
351
+ }
352
+
353
+ /** Render the recent-validation drift block from the sidecar history (newest entries). */
354
+ function formatDrift(history: ValidationHistoryEntry[]): string {
355
+ const recent = history.slice(-5);
356
+ const last = recent[recent.length - 1]!;
357
+ let md = '## Validation Drift\n\n';
358
+ md +=
359
+ `Last run (${last.date}): correctness ${last.correctness}/100 · slop ${last.slop}/100 — ` +
360
+ `${last.files} file(s), ${last.errors} error(s), ${last.warnings} warning(s).\n`;
361
+ if (recent.length > 1) {
362
+ md += `\nRecent correctness: ${recent.map((e) => e.correctness).join(' → ')}\n`;
363
+ md += `Recent slop-floor: ${recent.map((e) => e.slop).join(' → ')}\n`;
364
+ }
365
+ return md;
366
+ }
367
+
368
+ /**
369
+ * Human-readable context summary for `urbicon context`. Pass the sidecar
370
+ * validation history (when present) to append a drift block; omit it for the
371
+ * pure-manifest summary.
372
+ */
373
+ export function formatContext(
374
+ manifest: DesignManifest,
375
+ history: ValidationHistoryEntry[] = []
376
+ ): string {
377
+ let md = '# Design Context\n\n';
378
+
379
+ const fm = manifest.frontmatter;
380
+ const keys = Object.keys(fm);
381
+ if (keys.length > 0) {
382
+ md += '## Intake\n\n';
383
+ for (const k of keys) md += `- **${k}:** ${fm[k]}\n`;
384
+ md += '\n';
385
+ if (fm.paradigm) {
386
+ md += `> Stay within the **${fm.paradigm}** paradigm. Call \`get_design_principles(topic="theming")\` for its token profile.\n\n`;
387
+ }
388
+ }
389
+
390
+ md += '## Product Intent\n\n';
391
+ const intent = manifest.intent;
392
+ if (intentIsEmpty(intent)) {
393
+ md +=
394
+ '_Not set._ Define audience, voice, references and anti-references so design stays consistent with a target identity, not merely generic.\n\n';
395
+ } else {
396
+ if (intent.audience) md += `- **Audience:** ${intent.audience}\n`;
397
+ if (intent.voice.length > 0) md += `- **Voice:** ${intent.voice.join(', ')}\n`;
398
+ if (intent.references.length > 0) md += `- **References:** ${intent.references.join('; ')}\n`;
399
+ if (intent.antiReferences.length > 0)
400
+ md += `- **Anti-references:** ${intent.antiReferences.join('; ')}\n`;
401
+ md += '\n';
402
+ }
403
+
404
+ if (manifest.tokenOverrides.length > 0) {
405
+ md += '## Token Overrides\n\n';
406
+ md += `${manifest.tokenOverrides.map((c) => `\`${c}\``).join(', ')}\n\n`;
407
+ md += '> Treated as valid by `urbicon validate` (passed as extra tokens for this project).\n\n';
408
+ }
409
+
410
+ md += '## Pattern Usages\n\n';
411
+ if (manifest.usages.length === 0) {
412
+ md +=
413
+ '_None recorded._ Add `data-design-pattern="<name>"` to page roots, then run `urbicon sync-manifest`.\n\n';
414
+ } else {
415
+ const byPattern = new Map<string, string[]>();
416
+ for (const u of manifest.usages) {
417
+ (byPattern.get(u.pattern) ?? byPattern.set(u.pattern, []).get(u.pattern)!).push(u.file);
418
+ }
419
+ for (const [pattern, files] of [...byPattern].sort((a, b) => a[0].localeCompare(b[0]))) {
420
+ md += `- \`${pattern}\` (${files.length}): ${files.join(', ')}\n`;
421
+ }
422
+ md += '\n> To change a pattern across the app, migrate every file listed under it.\n\n';
423
+ }
424
+
425
+ md += '## Design Decisions\n\n';
426
+ if (manifest.decisions.length === 0) {
427
+ md +=
428
+ '_None recorded._ Use `urbicon record-decision` when you deviate from a pattern or principle.\n';
429
+ } else {
430
+ for (const d of manifest.decisions) {
431
+ md += `- **${d.date} — ${d.title}** (${d.status}): ${d.decision}\n`;
432
+ }
433
+ }
434
+
435
+ if (history.length > 0) md += `\n${formatDrift(history)}`;
436
+ return md;
437
+ }
438
+
439
+ export { DECISIONS_HEADING, USAGES_END, USAGES_HEADING, USAGES_START };
@@ -0,0 +1,51 @@
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
+ });
@@ -0,0 +1,74 @@
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
+ }
@@ -0,0 +1,98 @@
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, plus a sidecar ndjson
5
+ * log. Its parts (DESIGN-MCP-V2 §7):
6
+ *
7
+ * 1. Frontmatter — the enforced intake decisions (paradigm, theme, density).
8
+ * 2. Product Intent — the target identity (audience, voice, references,
9
+ * anti-references): the difference between "consistent" and merely "generic".
10
+ * 3. Token Overrides — project-specific semantic token cores, fed to
11
+ * `urbicon validate` so they are not flagged as hallucinated (the local,
12
+ * manifest-sourced counterpart to the remote `validate_design(extraTokens)`).
13
+ * 4. Pattern Usages — an auto-generated index of `data-design-pattern` markers
14
+ * found in the source (so "which pages follow pattern X" is a grep, not a
15
+ * guess — answering the open question from DESIGN-SYSTEM-INTELLIGENCE.md).
16
+ * 5. Design Decisions — append-only ADRs recording deliberate deviations.
17
+ *
18
+ * The Validation History ({@link ValidationHistoryEntry}) lives in a sidecar
19
+ * `*.history.ndjson` file, not in the Markdown — append-only, machine-written,
20
+ * so it never disturbs the human-edited manifest.
21
+ */
22
+
23
+ /** One `data-design-pattern="…"` marker found in the source tree. */
24
+ export interface PatternUsage {
25
+ /** The pattern name, e.g. "dashboard". */
26
+ pattern: string;
27
+ /** Source file, relative to the project root. */
28
+ file: string;
29
+ }
30
+
31
+ /** A recorded design decision (ADR). */
32
+ export interface DesignDecision {
33
+ /** ISO date (YYYY-MM-DD). */
34
+ date: string;
35
+ title: string;
36
+ /** accepted | proposed | superseded — free text, defaults to "accepted". */
37
+ status: string;
38
+ decision: string;
39
+ rationale?: string;
40
+ }
41
+
42
+ /**
43
+ * The target identity a project designs toward — the "missing half" of design
44
+ * memory (DESIGN-MCP-V2 §7). Without it, "consistent change" has no anchor and
45
+ * generation drifts to generic-pretty. Arrays default to `[]` (never undefined);
46
+ * an entirely empty intent means the `## Product Intent` section is absent or blank.
47
+ */
48
+ export interface ProductIntent {
49
+ /** Who uses this — their context, constraints, expertise. */
50
+ audience?: string;
51
+ /** The voice as a few adjectives (canonically three), e.g. `['calm','precise','trustworthy']`. */
52
+ voice: string[];
53
+ /** Products/sites whose feel to move toward. */
54
+ references: string[];
55
+ /** The generic defaults to avoid (e.g. "Bootstrap admin", "rainbow SaaS dashboard"). */
56
+ antiReferences: string[];
57
+ }
58
+
59
+ /**
60
+ * One validation run, appended to the sidecar `*.history.ndjson` so design drift
61
+ * is measurable over time (DESIGN-MCP-V2 §7). One entry per `urbicon validate
62
+ * --record` invocation; scores are the mean across the files in that run, counts
63
+ * are summed. ndjson (not a Markdown table) keeps it append-only and machine-owned.
64
+ */
65
+ export interface ValidationHistoryEntry {
66
+ /** ISO 8601 timestamp of the run (full precision, for a time series). */
67
+ date: string;
68
+ /** Files linted in this run. */
69
+ files: number;
70
+ /** Summed `error`-severity findings across all files. */
71
+ errors: number;
72
+ /** Summed `warning`-severity findings. */
73
+ warnings: number;
74
+ /** Summed `info`-severity (slop-floor) findings. */
75
+ infos: number;
76
+ /** Mean correctness score across files (0–100, rounded). The drift signal. */
77
+ correctness: number;
78
+ /** Mean slop-floor score across files (0–100, rounded). */
79
+ slop: number;
80
+ }
81
+
82
+ /** Parsed view of a manifest file. */
83
+ export interface DesignManifest {
84
+ /** Flat key→value frontmatter (paradigm, theme, density, …). */
85
+ frontmatter: Record<string, string>;
86
+ /** Target identity (audience, voice, references, anti-references). Empty when unset. */
87
+ intent: ProductIntent;
88
+ /**
89
+ * Project-specific semantic token cores declared valid for this project (e.g.
90
+ * `surface-brand`). Cores only — the part after the utility prefix, matching the
91
+ * linter whitelist — not full utilities (`bg-surface-brand`) nor CSS variables.
92
+ */
93
+ tokenOverrides: string[];
94
+ usages: PatternUsage[];
95
+ decisions: DesignDecision[];
96
+ /** Whether a manifest file actually existed (false → defaults returned). */
97
+ exists: boolean;
98
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Public API of the design-quality rubric — the qualitative half of the design loop
3
+ * (docs/DESIGN-MCP.md, step 3). Consumed by the `get_design_principles(as="rubric")`
4
+ * MCP tool and by the eval-suite, which import the same constants to score programmatically.
5
+ *
6
+ * See ./rubric.ts for the criteria and the rationale behind them.
7
+ */
8
+
9
+ export type { RubricCriterion } from './rubric.js';
10
+ export { MAX_RUBRIC_SCORE, RUBRIC_CRITERIA, renderRubric } from './rubric.js';