@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.
- package/README.md +36 -0
- package/package.json +47 -0
- package/src/index.ts +23 -0
- package/src/linter/heuristics.ts +609 -0
- package/src/linter/index.ts +23 -0
- package/src/linter/linter.test.ts +509 -0
- package/src/linter/linter.ts +96 -0
- package/src/linter/markup-rules.test.ts +109 -0
- package/src/linter/markup-rules.ts +209 -0
- package/src/linter/markup.test.ts +139 -0
- package/src/linter/markup.ts +274 -0
- package/src/linter/rules.ts +354 -0
- package/src/linter/tokens.test.ts +111 -0
- package/src/linter/tokens.ts +230 -0
- package/src/linter/types.ts +119 -0
- package/src/manifest/history.test.ts +65 -0
- package/src/manifest/history.ts +57 -0
- package/src/manifest/index.ts +27 -0
- package/src/manifest/manifest.test.ts +338 -0
- package/src/manifest/manifest.ts +439 -0
- package/src/manifest/scan.test.ts +51 -0
- package/src/manifest/scan.ts +74 -0
- package/src/manifest/types.ts +98 -0
- package/src/rubric/index.ts +10 -0
- package/src/rubric/rubric.test.ts +43 -0
- package/src/rubric/rubric.ts +140 -0
- package/src/search/index.ts +12 -0
- package/src/search/match.ts +115 -0
- package/src/search/search.test.ts +195 -0
- package/src/search/section.ts +47 -0
- package/src/search/types.ts +44 -0
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API of the search module — the component-catalog schema, the discovery
|
|
3
|
+
* ranker, and the `llm.txt` section parser. Shared by the `urbicon` CLI
|
|
4
|
+
* (`find` / `get-component`) and the remote MCP server (`find_components` /
|
|
5
|
+
* `get_component`) so local and remote knowledge agree (DESIGN-MCP-V2 §5,
|
|
6
|
+
* "engine/search + content"). Pure and dependency-free; consumers own the file I/O
|
|
7
|
+
* (locating the bundle via `@urbicon-ui/design-content`, reading it themselves).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { matchComponents } from './match.js';
|
|
11
|
+
export { extractSection, type LlmTxtSection } from './section.js';
|
|
12
|
+
export type { ComponentCatalog, ComponentCatalogEntry, RecipeEntry } from './types.js';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ComponentCatalogEntry } from './types.js';
|
|
2
|
+
|
|
3
|
+
interface ScoredEntry {
|
|
4
|
+
entry: ComponentCatalogEntry;
|
|
5
|
+
score: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rank catalog entries against a free-text query — the component-discovery ranker
|
|
10
|
+
* behind both `find_components` (remote MCP) and `urbicon find` (CLI), so local and
|
|
11
|
+
* remote discovery agree. Pure and dependency-free: each query keyword scores on an
|
|
12
|
+
* exact / substring / fuzzy (Levenshtein ≤ 2) name-or-slug hit, a tag hit, a
|
|
13
|
+
* description hit, and a weak prop-name hit; an explicit `tags` filter adds weight.
|
|
14
|
+
* Keywords shorter than two characters are dropped. Returns the top `limit` entries
|
|
15
|
+
* with a positive score, best first; an empty list when nothing matches.
|
|
16
|
+
*/
|
|
17
|
+
export function matchComponents(
|
|
18
|
+
components: ComponentCatalogEntry[],
|
|
19
|
+
query: string,
|
|
20
|
+
tags?: string[],
|
|
21
|
+
limit = 5
|
|
22
|
+
): ComponentCatalogEntry[] {
|
|
23
|
+
const keywords = query
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.split(/[\s,\-_]+/)
|
|
26
|
+
.filter((w) => w.length > 1);
|
|
27
|
+
|
|
28
|
+
const scored: ScoredEntry[] = components.map((entry) => {
|
|
29
|
+
let score = 0;
|
|
30
|
+
const nameLower = entry.name.toLowerCase();
|
|
31
|
+
const slugLower = entry.slug.toLowerCase();
|
|
32
|
+
const descLower = entry.description.toLowerCase();
|
|
33
|
+
|
|
34
|
+
for (const kw of keywords) {
|
|
35
|
+
// Exact match
|
|
36
|
+
if (nameLower === kw || slugLower === kw) {
|
|
37
|
+
score += 10;
|
|
38
|
+
} else if (nameLower.includes(kw) || slugLower.includes(kw)) {
|
|
39
|
+
score += 7;
|
|
40
|
+
} else {
|
|
41
|
+
// Fuzzy match on name/slug (Levenshtein distance <= 2)
|
|
42
|
+
const nameDist = levenshtein(nameLower, kw);
|
|
43
|
+
const slugDist = levenshtein(slugLower, kw);
|
|
44
|
+
const minDist = Math.min(nameDist, slugDist);
|
|
45
|
+
if (minDist <= 1) {
|
|
46
|
+
score += 6;
|
|
47
|
+
} else if (minDist <= 2) {
|
|
48
|
+
score += 3;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Tag match
|
|
53
|
+
if (entry.tags.some((t) => t.toLowerCase() === kw)) {
|
|
54
|
+
score += 5;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Description match
|
|
58
|
+
if (descLower.includes(kw)) {
|
|
59
|
+
score += 3;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Prop name match
|
|
63
|
+
if (entry.keyProps.some((p) => p.toLowerCase().includes(kw))) {
|
|
64
|
+
score += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (tags && tags.length > 0) {
|
|
69
|
+
const entryTags = entry.tags.map((t) => t.toLowerCase());
|
|
70
|
+
for (const tag of tags) {
|
|
71
|
+
if (entryTags.includes(tag.toLowerCase())) {
|
|
72
|
+
score += 5;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { entry, score };
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return scored
|
|
81
|
+
.filter((s) => s.score > 0)
|
|
82
|
+
.sort((a, b) => b.score - a.score)
|
|
83
|
+
.slice(0, limit)
|
|
84
|
+
.map((s) => s.entry);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function levenshtein(a: string, b: string): number {
|
|
88
|
+
if (a.length === 0) return b.length;
|
|
89
|
+
if (b.length === 0) return a.length;
|
|
90
|
+
|
|
91
|
+
// Early exit for large length differences
|
|
92
|
+
if (Math.abs(a.length - b.length) > 2) return 3;
|
|
93
|
+
|
|
94
|
+
const matrix: number[][] = [];
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i <= a.length; i++) {
|
|
97
|
+
matrix[i] = [i];
|
|
98
|
+
}
|
|
99
|
+
for (let j = 0; j <= b.length; j++) {
|
|
100
|
+
matrix[0]![j] = j;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (let i = 1; i <= a.length; i++) {
|
|
104
|
+
for (let j = 1; j <= b.length; j++) {
|
|
105
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
106
|
+
matrix[i]![j] = Math.min(
|
|
107
|
+
matrix[i - 1]![j]! + 1,
|
|
108
|
+
matrix[i]![j - 1]! + 1,
|
|
109
|
+
matrix[i - 1]![j - 1]! + cost
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return matrix[a.length]![b.length]!;
|
|
115
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { matchComponents } from './match.js';
|
|
3
|
+
import { extractSection } from './section.js';
|
|
4
|
+
import type { ComponentCatalogEntry } from './types.js';
|
|
5
|
+
|
|
6
|
+
function makeEntry(
|
|
7
|
+
overrides: Partial<ComponentCatalogEntry> & Pick<ComponentCatalogEntry, 'name' | 'slug'>
|
|
8
|
+
): ComponentCatalogEntry {
|
|
9
|
+
return {
|
|
10
|
+
package: '@urbicon-ui/blocks',
|
|
11
|
+
group: 'primitives',
|
|
12
|
+
description: '',
|
|
13
|
+
tags: [],
|
|
14
|
+
import: `import { ${overrides.name} } from '@urbicon-ui/blocks';`,
|
|
15
|
+
llmTxtPath: '',
|
|
16
|
+
variants: [],
|
|
17
|
+
keyProps: [],
|
|
18
|
+
keyPropTypes: {},
|
|
19
|
+
slots: [],
|
|
20
|
+
hasExamples: false,
|
|
21
|
+
relatedComponents: [],
|
|
22
|
+
...overrides
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const Button = makeEntry({
|
|
27
|
+
name: 'Button',
|
|
28
|
+
slug: 'button',
|
|
29
|
+
description: 'Click to trigger an action',
|
|
30
|
+
tags: ['action'],
|
|
31
|
+
keyProps: ['intent', 'variant', 'size']
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const Input = makeEntry({
|
|
35
|
+
name: 'Input',
|
|
36
|
+
slug: 'input',
|
|
37
|
+
description: 'Single-line text field',
|
|
38
|
+
tags: ['form'],
|
|
39
|
+
keyProps: ['value', 'error']
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const Dialog = makeEntry({
|
|
43
|
+
name: 'Dialog',
|
|
44
|
+
slug: 'dialog',
|
|
45
|
+
description: 'Modal overlay container',
|
|
46
|
+
tags: ['overlay']
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const catalog = [Button, Input, Dialog];
|
|
50
|
+
|
|
51
|
+
describe('matchComponents', () => {
|
|
52
|
+
it('ranks an exact name match first', () => {
|
|
53
|
+
const results = matchComponents(catalog, 'button');
|
|
54
|
+
expect(results[0]?.name).toBe('Button');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('matches by case-insensitive substring of the name', () => {
|
|
58
|
+
const results = matchComponents(catalog, 'Butt');
|
|
59
|
+
expect(results.some((r) => r.name === 'Button')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('fuzz-matches a single-character typo', () => {
|
|
63
|
+
const results = matchComponents(catalog, 'Buton'); // typo: missing "t"
|
|
64
|
+
expect(results[0]?.name).toBe('Button');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('ignores matches that are too distant', () => {
|
|
68
|
+
const results = matchComponents(catalog, 'xyzzy');
|
|
69
|
+
expect(results).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('scores tag matches when the tag keyword is present in the query', () => {
|
|
73
|
+
const results = matchComponents(catalog, 'form');
|
|
74
|
+
expect(results[0]?.name).toBe('Input');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('filters by the explicit tags argument', () => {
|
|
78
|
+
const results = matchComponents(catalog, 'container', ['overlay']);
|
|
79
|
+
expect(results.some((r) => r.name === 'Dialog')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('respects the limit', () => {
|
|
83
|
+
const many = Array.from({ length: 10 }, (_, i) =>
|
|
84
|
+
makeEntry({ name: `Button${i}`, slug: `button-${i}`, description: 'click' })
|
|
85
|
+
);
|
|
86
|
+
const results = matchComponents(many, 'button', undefined, 3);
|
|
87
|
+
expect(results).toHaveLength(3);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('drops words shorter than two characters before matching', () => {
|
|
91
|
+
// "a" and "x" below are too short and should be filtered;
|
|
92
|
+
// only "button" remains and should drive the match.
|
|
93
|
+
const results = matchComponents(catalog, 'a x button');
|
|
94
|
+
expect(results[0]?.name).toBe('Button');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('uses the prop-name match as a weak signal', () => {
|
|
98
|
+
const results = matchComponents(catalog, 'intent');
|
|
99
|
+
expect(results.some((r) => r.name === 'Button')).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns an empty list when the query has no usable keywords', () => {
|
|
103
|
+
const results = matchComponents(catalog, ', , ');
|
|
104
|
+
expect(results).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Regression for the DateGrid/Planner discovery fix: planning-board queries
|
|
109
|
+
// used to steer toward Calendar (a timed-event scheduler). With Planner in the
|
|
110
|
+
// catalog they must land on Planner instead — driven purely by its catalog
|
|
111
|
+
// description/tags/slug, no hardcoded keyword map.
|
|
112
|
+
describe('matchComponents — Planner discovery', () => {
|
|
113
|
+
const Calendar = makeEntry({
|
|
114
|
+
name: 'Calendar',
|
|
115
|
+
slug: 'calendar',
|
|
116
|
+
description: 'Event display and date selection with month, week and day views.',
|
|
117
|
+
tags: ['display'],
|
|
118
|
+
relatedComponents: ['DatePicker']
|
|
119
|
+
});
|
|
120
|
+
const Planner = makeEntry({
|
|
121
|
+
name: 'Planner',
|
|
122
|
+
slug: 'planner',
|
|
123
|
+
description:
|
|
124
|
+
'Date-indexed planning board — a week, month or custom-range grid whose cells hold your domain content (meals, shifts, bookings, content slots) via a generic cell snippet.',
|
|
125
|
+
tags: ['display', 'layout'],
|
|
126
|
+
keyProps: ['items', 'getDate', 'view', 'cell'],
|
|
127
|
+
relatedComponents: ['Calendar', 'DatePicker']
|
|
128
|
+
});
|
|
129
|
+
const dateCatalog = [Calendar, Planner];
|
|
130
|
+
|
|
131
|
+
for (const query of ['planner', 'meal planner', 'weekly plan', 'shift schedule', 'week board']) {
|
|
132
|
+
it(`ranks Planner first for "${query}"`, () => {
|
|
133
|
+
const results = matchComponents(dateCatalog, query);
|
|
134
|
+
expect(results[0]?.name).toBe('Planner');
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it('still ranks Calendar first for an event/appointment query', () => {
|
|
139
|
+
const results = matchComponents(dateCatalog, 'event calendar');
|
|
140
|
+
expect(results[0]?.name).toBe('Calendar');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('extractSection', () => {
|
|
145
|
+
const llm = [
|
|
146
|
+
'# Button',
|
|
147
|
+
'',
|
|
148
|
+
'Click to trigger an action.',
|
|
149
|
+
'',
|
|
150
|
+
'### Examples',
|
|
151
|
+
'',
|
|
152
|
+
'```svelte',
|
|
153
|
+
'<Button intent="primary">Save</Button>',
|
|
154
|
+
'```',
|
|
155
|
+
'',
|
|
156
|
+
'### API',
|
|
157
|
+
'',
|
|
158
|
+
'| Prop | Type | Default |',
|
|
159
|
+
'| --- | --- | --- |',
|
|
160
|
+
'| intent | string | primary |',
|
|
161
|
+
'',
|
|
162
|
+
'### Slots (slotClasses keys)',
|
|
163
|
+
'',
|
|
164
|
+
'`base`, `label`'
|
|
165
|
+
].join('\n');
|
|
166
|
+
|
|
167
|
+
it('returns everything before the first ### as the overview', () => {
|
|
168
|
+
const overview = extractSection(llm, 'overview');
|
|
169
|
+
expect(overview).toContain('# Button');
|
|
170
|
+
expect(overview).toContain('Click to trigger an action.');
|
|
171
|
+
expect(overview).not.toContain('### Examples');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('extracts a named section up to the next heading', () => {
|
|
175
|
+
const api = extractSection(llm, 'api');
|
|
176
|
+
expect(api).toContain('### API');
|
|
177
|
+
expect(api).toContain('| intent | string | primary |');
|
|
178
|
+
expect(api).not.toContain('### Slots');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('maps the parenthesised slots heading to the slots section', () => {
|
|
182
|
+
const slots = extractSection(llm, 'slots');
|
|
183
|
+
expect(slots).toContain('`base`, `label`');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns null for a section that is absent', () => {
|
|
187
|
+
expect(extractSection(llm, 'variants')).toBe(null);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('treats content with no headings as all-overview', () => {
|
|
191
|
+
expect(extractSection('Just a description, no sections.', 'overview')).toBe(
|
|
192
|
+
'Just a description, no sections.'
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice a named section out of a component's `llm.txt` — the `### …` blocks that
|
|
3
|
+
* `docs-gen` emits (Examples, Variants, API, Slots), plus a synthetic `overview`
|
|
4
|
+
* (everything before the first `###`). Pure string logic so `get_component` (remote)
|
|
5
|
+
* and `urbicon get-component` (CLI) extract identically; each consumer owns the I/O
|
|
6
|
+
* (locating and reading the file). Returns `null` when the section is absent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type LlmTxtSection = 'overview' | 'examples' | 'variants' | 'api' | 'slots';
|
|
10
|
+
|
|
11
|
+
const SECTION_HEADING_MAP: Record<string, LlmTxtSection> = {
|
|
12
|
+
examples: 'examples',
|
|
13
|
+
variants: 'variants',
|
|
14
|
+
api: 'api',
|
|
15
|
+
'slots (slotclasses keys)': 'slots'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function extractSection(content: string, section: LlmTxtSection): string | null {
|
|
19
|
+
if (section === 'overview') {
|
|
20
|
+
const firstH3 = content.indexOf('\n### ');
|
|
21
|
+
if (firstH3 === -1) return content.trim();
|
|
22
|
+
return content.slice(0, firstH3).trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lines = content.split('\n');
|
|
26
|
+
let capturing = false;
|
|
27
|
+
const result: string[] = [];
|
|
28
|
+
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (line.startsWith('### ')) {
|
|
31
|
+
const heading = line.slice(4).trim().toLowerCase();
|
|
32
|
+
const mapped = SECTION_HEADING_MAP[heading];
|
|
33
|
+
if (mapped === section) {
|
|
34
|
+
capturing = true;
|
|
35
|
+
result.push(line);
|
|
36
|
+
continue;
|
|
37
|
+
} else if (capturing) {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (capturing) {
|
|
42
|
+
result.push(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return result.length > 0 ? result.join('\n').trim() : null;
|
|
47
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The component-catalog schema — the shape of the version-pinned
|
|
3
|
+
* `component-catalog.json` that `@urbicon-ui/design-content` ships and `docs-gen`
|
|
4
|
+
* assembles. It lives in the engine (not the content package) so the search logic
|
|
5
|
+
* and every consumer — the `urbicon` CLI's `find`/`get-component`, the remote MCP
|
|
6
|
+
* server's `find_components`/`get_component` — share one authoritative type, while
|
|
7
|
+
* `design-content` stays a pure path locator (DESIGN-MCP-V2 §5, "engine/search + content").
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ComponentCatalogEntry {
|
|
11
|
+
name: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
package: string;
|
|
14
|
+
group: 'primitives' | 'components' | 'core' | 'auth';
|
|
15
|
+
description: string;
|
|
16
|
+
tags: string[];
|
|
17
|
+
import: string;
|
|
18
|
+
llmTxtPath: string;
|
|
19
|
+
variants: { name: string; values: string[]; default?: string }[];
|
|
20
|
+
keyProps: string[];
|
|
21
|
+
keyPropTypes: Record<string, string>;
|
|
22
|
+
slots: string[];
|
|
23
|
+
hasExamples: boolean;
|
|
24
|
+
relatedComponents: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RecipeEntry {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
description: string;
|
|
31
|
+
components: string[];
|
|
32
|
+
code: string;
|
|
33
|
+
features: string[];
|
|
34
|
+
/** Layer-4 composition pattern this recipe is an instance of (e.g. "dashboard"). Cross-links to `get_pattern`. */
|
|
35
|
+
pattern?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ComponentCatalog {
|
|
39
|
+
generated: string;
|
|
40
|
+
version: string;
|
|
41
|
+
components: ComponentCatalogEntry[];
|
|
42
|
+
recipes: RecipeEntry[];
|
|
43
|
+
tags: string[];
|
|
44
|
+
}
|