@webspire/mcp 0.12.0 → 0.13.1

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,351 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { searchPatterns, searchSnippets } from './search.js';
4
+ // --- Minimal fixture builders ---
5
+ function makeSnippet(overrides) {
6
+ const { meta: metaOverrides, ...rest } = overrides;
7
+ return {
8
+ title: overrides.title ?? 'Untitled',
9
+ description: overrides.description ?? '',
10
+ category: overrides.category ?? 'glass',
11
+ tags: overrides.tags ?? [],
12
+ responsive: overrides.responsive ?? false,
13
+ darkMode: overrides.darkMode ?? false,
14
+ updatedAt: '2025-01-01',
15
+ css: '',
16
+ ...rest,
17
+ meta: {
18
+ lines: 10,
19
+ bytes: 200,
20
+ tailwind: '',
21
+ browser: 'baseline-2024',
22
+ accessibility: {
23
+ prefersReducedMotion: false,
24
+ prefersColorScheme: false,
25
+ supportsCheck: false,
26
+ },
27
+ customProperties: [],
28
+ classes: [],
29
+ useCases: [],
30
+ solves: [],
31
+ usageExample: '',
32
+ ...metaOverrides,
33
+ },
34
+ };
35
+ }
36
+ function makePattern(overrides) {
37
+ return {
38
+ title: overrides.title ?? 'Untitled',
39
+ summary: overrides.summary ?? '',
40
+ description: overrides.description,
41
+ kind: overrides.kind ?? 'section',
42
+ tier: overrides.tier ?? 'base',
43
+ extends: null,
44
+ tags: overrides.tags ?? [],
45
+ semantics: overrides.semantics ?? {
46
+ domains: [],
47
+ tones: [],
48
+ uxGoals: [],
49
+ avoidForTones: [],
50
+ avoidForScenarios: [],
51
+ neighbors: { before: [], after: [], neverWith: [] },
52
+ },
53
+ search: overrides.search ?? { intent: [], keywords: [], useCases: [] },
54
+ frameworks: { html: true, astro: false, webComponent: false },
55
+ tokens: { color: '', radius: '', shadow: '', spacing: '', typography: '' },
56
+ capabilities: {
57
+ responsive: true,
58
+ darkMode: false,
59
+ animated: false,
60
+ interactive: false,
61
+ dependencies: [],
62
+ animationPreset: 'none',
63
+ },
64
+ slots: [],
65
+ props: [],
66
+ files: { html: '', css: null, js: null },
67
+ install: { copyPasteReady: true, ssrSafe: true, tailwindOnly: true, notes: [] },
68
+ governance: {
69
+ status: 'published',
70
+ quality: 'stable',
71
+ owner: 'test',
72
+ updatedAt: '2025-01-01',
73
+ },
74
+ ai: overrides.ai,
75
+ qualityTier: overrides.qualityTier,
76
+ html: '',
77
+ css: null,
78
+ js: null,
79
+ ...overrides,
80
+ };
81
+ }
82
+ // --- Snippet search tests ---
83
+ test('searchSnippets: exact ID match ranks first', () => {
84
+ const snippets = [
85
+ makeSnippet({ id: 'glass/frosted', title: 'Frosted Glass', category: 'glass' }),
86
+ makeSnippet({ id: 'text/gradient', title: 'Gradient Text', category: 'text' }),
87
+ makeSnippet({ id: 'animations/fade-in', title: 'Fade In Animation', category: 'animations' }),
88
+ ];
89
+ const results = searchSnippets(snippets, { query: 'frosted' });
90
+ assert.equal(results.length >= 1, true);
91
+ assert.equal(results[0].id, 'glass/frosted');
92
+ });
93
+ test('searchSnippets: search by title keyword', () => {
94
+ const snippets = [
95
+ makeSnippet({ id: 'glass/frosted', title: 'Frosted Glass Effect', category: 'glass' }),
96
+ makeSnippet({ id: 'text/headline', title: 'Headline Typography', category: 'text' }),
97
+ ];
98
+ const results = searchSnippets(snippets, { query: 'frosted' });
99
+ assert.equal(results.length, 1);
100
+ assert.equal(results[0].id, 'glass/frosted');
101
+ });
102
+ test('searchSnippets: search by tag', () => {
103
+ const snippets = [
104
+ makeSnippet({
105
+ id: 'glass/panel',
106
+ title: 'Glass Panel',
107
+ category: 'glass',
108
+ tags: ['glassmorphism', 'backdrop'],
109
+ }),
110
+ makeSnippet({ id: 'text/clip', title: 'Text Clip', category: 'text', tags: ['typography'] }),
111
+ ];
112
+ const results = searchSnippets(snippets, { query: 'glassmorphism' });
113
+ assert.equal(results.length, 1);
114
+ assert.equal(results[0].id, 'glass/panel');
115
+ });
116
+ test('searchSnippets: search by solves field', () => {
117
+ const snippets = [
118
+ makeSnippet({
119
+ id: 'glass/overlay',
120
+ title: 'Overlay',
121
+ category: 'glass',
122
+ meta: { solves: ['adds depth to modal backgrounds'], useCases: [] },
123
+ }),
124
+ makeSnippet({ id: 'text/label', title: 'Label Style', category: 'text' }),
125
+ ];
126
+ const results = searchSnippets(snippets, { query: 'modal backgrounds' });
127
+ assert.equal(results.length, 1);
128
+ assert.equal(results[0].id, 'glass/overlay');
129
+ });
130
+ test('searchSnippets: search by useCases field', () => {
131
+ const snippets = [
132
+ makeSnippet({
133
+ id: 'scroll/reveal',
134
+ title: 'Scroll Reveal',
135
+ category: 'scroll',
136
+ meta: { useCases: ['animate elements into view on scroll into viewport'], solves: [] },
137
+ }),
138
+ makeSnippet({ id: 'decorative/divider', title: 'Section Divider', category: 'decorative' }),
139
+ ];
140
+ const results = searchSnippets(snippets, { query: 'animate elements viewport' });
141
+ assert.equal(results.length, 1);
142
+ assert.equal(results[0].id, 'scroll/reveal');
143
+ });
144
+ test('searchSnippets: filter by category', () => {
145
+ const snippets = [
146
+ makeSnippet({ id: 'glass/frosted', title: 'Frosted Glass', category: 'glass' }),
147
+ makeSnippet({ id: 'glass/tinted', title: 'Tinted Glass', category: 'glass' }),
148
+ makeSnippet({ id: 'text/gradient', title: 'Gradient Text', category: 'text' }),
149
+ ];
150
+ const results = searchSnippets(snippets, { query: 'glass', category: 'glass' });
151
+ assert.equal(results.every((s) => s.category === 'glass'), true);
152
+ assert.equal(results.some((s) => s.id === 'text/gradient'), false);
153
+ });
154
+ test('searchSnippets: empty query returns empty array', () => {
155
+ const snippets = [
156
+ makeSnippet({ id: 'glass/frosted', title: 'Frosted Glass', category: 'glass' }),
157
+ ];
158
+ const results = searchSnippets(snippets, { query: '' });
159
+ assert.equal(results.length, 0);
160
+ });
161
+ // --- Pattern search tests ---
162
+ test('searchPatterns: search by family name ranks first', () => {
163
+ const patterns = [
164
+ makePattern({
165
+ id: 'hero/base',
166
+ family: 'hero',
167
+ title: 'Hero Base',
168
+ search: { intent: ['full-width hero section'], keywords: ['hero'], useCases: [] },
169
+ }),
170
+ makePattern({
171
+ id: 'pricing/base',
172
+ family: 'pricing',
173
+ title: 'Pricing Base',
174
+ search: { intent: ['pricing table'], keywords: ['pricing'], useCases: [] },
175
+ }),
176
+ makePattern({
177
+ id: 'faq/base',
178
+ family: 'faq',
179
+ title: 'FAQ Base',
180
+ search: { intent: ['questions and answers'], keywords: ['faq'], useCases: [] },
181
+ }),
182
+ ];
183
+ const results = searchPatterns(patterns, { query: 'hero' });
184
+ assert.equal(results.length >= 1, true);
185
+ assert.equal(results[0].family, 'hero');
186
+ });
187
+ test('searchPatterns: filter by tier base only', () => {
188
+ const patterns = [
189
+ makePattern({ id: 'hero/base', family: 'hero', title: 'Hero Base', tier: 'base' }),
190
+ makePattern({
191
+ id: 'hero/animated',
192
+ family: 'hero',
193
+ title: 'Hero Animated',
194
+ tier: 'enhanced',
195
+ search: { intent: ['animated hero'], keywords: ['hero', 'animation'], useCases: [] },
196
+ }),
197
+ makePattern({ id: 'pricing/base', family: 'pricing', title: 'Pricing Base', tier: 'base' }),
198
+ ];
199
+ const results = searchPatterns(patterns, { query: 'hero', tier: 'base' });
200
+ assert.equal(results.every((p) => p.tier === 'base'), true);
201
+ assert.equal(results.some((p) => p.id === 'hero/animated'), false);
202
+ });
203
+ test('searchPatterns: filter by domain', () => {
204
+ const saasSemantics = {
205
+ domains: ['saas'],
206
+ tones: ['modern'],
207
+ uxGoals: ['drive_signup'],
208
+ avoidForTones: [],
209
+ avoidForScenarios: [],
210
+ neighbors: { before: [], after: [], neverWith: [] },
211
+ };
212
+ const otherSemantics = {
213
+ domains: ['legal'],
214
+ tones: ['serious'],
215
+ uxGoals: ['build_trust'],
216
+ avoidForTones: [],
217
+ avoidForScenarios: [],
218
+ neighbors: { before: [], after: [], neverWith: [] },
219
+ };
220
+ const patterns = [
221
+ makePattern({
222
+ id: 'hero/base',
223
+ family: 'hero',
224
+ title: 'Hero',
225
+ semantics: saasSemantics,
226
+ search: { intent: ['hero for saas landing'], keywords: ['hero', 'saas'], useCases: [] },
227
+ }),
228
+ makePattern({
229
+ id: 'legal/base',
230
+ family: 'legal',
231
+ title: 'Legal Page',
232
+ semantics: otherSemantics,
233
+ search: { intent: ['legal information page'], keywords: ['legal'], useCases: [] },
234
+ }),
235
+ ];
236
+ const results = searchPatterns(patterns, { query: 'hero', domain: 'saas' });
237
+ assert.equal(results.length, 1);
238
+ assert.equal(results[0].id, 'hero/base');
239
+ });
240
+ test('searchPatterns: filter by tone', () => {
241
+ const modernSemantics = {
242
+ domains: ['saas'],
243
+ tones: ['modern'],
244
+ uxGoals: [],
245
+ avoidForTones: [],
246
+ avoidForScenarios: [],
247
+ neighbors: { before: [], after: [], neverWith: [] },
248
+ };
249
+ const seriousSemantics = {
250
+ domains: ['legal'],
251
+ tones: ['serious'],
252
+ uxGoals: [],
253
+ avoidForTones: [],
254
+ avoidForScenarios: [],
255
+ neighbors: { before: [], after: [], neverWith: [] },
256
+ };
257
+ const patterns = [
258
+ makePattern({
259
+ id: 'hero/modern',
260
+ family: 'hero',
261
+ title: 'Modern Hero',
262
+ semantics: modernSemantics,
263
+ search: { intent: ['modern hero section'], keywords: ['hero', 'modern'], useCases: [] },
264
+ }),
265
+ makePattern({
266
+ id: 'hero/serious',
267
+ family: 'hero',
268
+ title: 'Serious Hero',
269
+ semantics: seriousSemantics,
270
+ search: { intent: ['hero for law firm'], keywords: ['hero', 'serious'], useCases: [] },
271
+ }),
272
+ ];
273
+ const results = searchPatterns(patterns, { query: 'hero', tone: 'modern' });
274
+ assert.equal(results.length, 1);
275
+ assert.equal(results[0].id, 'hero/modern');
276
+ });
277
+ test('searchPatterns: filter by uxGoal', () => {
278
+ const signupSemantics = {
279
+ domains: ['saas'],
280
+ tones: ['modern'],
281
+ uxGoals: ['drive_signup'],
282
+ avoidForTones: [],
283
+ avoidForScenarios: [],
284
+ neighbors: { before: [], after: [], neverWith: [] },
285
+ };
286
+ const trustSemantics = {
287
+ domains: ['saas'],
288
+ tones: ['modern'],
289
+ uxGoals: ['build_trust'],
290
+ avoidForTones: [],
291
+ avoidForScenarios: [],
292
+ neighbors: { before: [], after: [], neverWith: [] },
293
+ };
294
+ const patterns = [
295
+ makePattern({
296
+ id: 'cta/base',
297
+ family: 'cta',
298
+ title: 'CTA Section',
299
+ semantics: signupSemantics,
300
+ search: { intent: ['call to action for signups'], keywords: ['cta', 'signup'], useCases: [] },
301
+ }),
302
+ makePattern({
303
+ id: 'testimonials/base',
304
+ family: 'testimonials',
305
+ title: 'Testimonials',
306
+ semantics: trustSemantics,
307
+ search: {
308
+ intent: ['social proof section'],
309
+ keywords: ['testimonials', 'trust'],
310
+ useCases: [],
311
+ },
312
+ }),
313
+ ];
314
+ const results = searchPatterns(patterns, { query: 'cta', uxGoal: 'drive_signup' });
315
+ assert.equal(results.length, 1);
316
+ assert.equal(results[0].id, 'cta/base');
317
+ });
318
+ test('searchPatterns: empty query returns empty array', () => {
319
+ const patterns = [
320
+ makePattern({
321
+ id: 'hero/base',
322
+ family: 'hero',
323
+ title: 'Hero',
324
+ search: { intent: [], keywords: ['hero'], useCases: [] },
325
+ }),
326
+ ];
327
+ const results = searchPatterns(patterns, { query: '' });
328
+ assert.equal(results.length, 0);
329
+ });
330
+ test('searchPatterns: recommended qualityTier gets score boost', () => {
331
+ const baseSearch = { intent: ['landing section'], keywords: ['section'], useCases: [] };
332
+ const patterns = [
333
+ makePattern({
334
+ id: 'hero/base',
335
+ family: 'hero',
336
+ title: 'Hero Base',
337
+ qualityTier: 'recommended',
338
+ search: baseSearch,
339
+ }),
340
+ makePattern({
341
+ id: 'hero/plain',
342
+ family: 'hero-plain',
343
+ title: 'Hero Plain',
344
+ qualityTier: 'usable',
345
+ search: baseSearch,
346
+ }),
347
+ ];
348
+ const results = searchPatterns(patterns, { query: 'section' });
349
+ // recommended gets +15 score boost, so hero/base should rank first
350
+ assert.equal(results[0].id, 'hero/base');
351
+ });
package/dist/types.d.ts CHANGED
@@ -32,6 +32,7 @@ export interface SnippetEntry {
32
32
  updatedAt: string;
33
33
  meta: SnippetMeta;
34
34
  css: string;
35
+ polishLayer?: 'motion' | 'surface' | 'depth' | 'interaction' | 'text' | 'decorative' | null;
35
36
  }
36
37
  export interface PatternEntry {
37
38
  id: string;
@@ -114,6 +115,18 @@ export interface PatternEntry {
114
115
  owner: string;
115
116
  updatedAt: string;
116
117
  };
118
+ qualityTier?: 'recommended' | 'usable' | 'experimental';
119
+ quality?: {
120
+ checks: {
121
+ responsive: boolean;
122
+ darkMode: boolean;
123
+ a11y: boolean;
124
+ reducedMotion: boolean;
125
+ tokenized: boolean;
126
+ };
127
+ } | null;
128
+ subtype?: string | null;
129
+ suggestedSnippets?: string[];
117
130
  html: string;
118
131
  css: string | null;
119
132
  js: string | null;
@@ -150,6 +163,11 @@ export interface TemplateEntry {
150
163
  owner: string;
151
164
  updatedAt: string;
152
165
  };
166
+ composition?: {
167
+ patterns: string[];
168
+ snippets: string[];
169
+ tokens: string[];
170
+ } | null;
153
171
  html: string;
154
172
  }
155
173
  export interface FontEntry {
@@ -248,6 +266,54 @@ export interface MotionRecipeEntry {
248
266
  };
249
267
  html: string;
250
268
  }
269
+ export interface SkillEntry {
270
+ id: string;
271
+ title: string;
272
+ summary: string;
273
+ tone: string;
274
+ domains: string[];
275
+ uxGoals: string[];
276
+ tags: string[];
277
+ colors: Record<string, string>;
278
+ typography: {
279
+ heading: string;
280
+ body: string;
281
+ mono: string;
282
+ scale: number[];
283
+ weight: {
284
+ heading: number;
285
+ body: number;
286
+ ui: number;
287
+ };
288
+ };
289
+ spacing: {
290
+ base: number;
291
+ scale: number[];
292
+ };
293
+ radius: Record<string, string>;
294
+ relatedPatterns: string[];
295
+ suggestedTemplate: string | null;
296
+ governance: {
297
+ updatedAt: string;
298
+ };
299
+ /** Full markdown body — the design rules AI agents should follow */
300
+ body: string;
301
+ }
302
+ export interface StarterPathEntry {
303
+ id: string;
304
+ title: string;
305
+ description: string;
306
+ goal: string;
307
+ patterns: string[];
308
+ snippets: string[];
309
+ fonts: string[];
310
+ tokenPreset: string | null;
311
+ tags: string[];
312
+ governance: {
313
+ status: 'draft' | 'review' | 'published' | 'deprecated';
314
+ updatedAt: string;
315
+ };
316
+ }
251
317
  export interface Registry {
252
318
  version: string;
253
319
  generated: string;
@@ -256,6 +322,8 @@ export interface Registry {
256
322
  templates?: TemplateEntry[];
257
323
  canvasEffects?: CanvasEffectEntry[];
258
324
  motionRecipes?: MotionRecipeEntry[];
325
+ skills?: SkillEntry[];
326
+ starterPaths?: StarterPathEntry[];
259
327
  fonts?: FontsData;
260
328
  mountCanvas?: string;
261
329
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webspire/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "MCP server for Webspire — AI-native discovery of CSS snippets, UI patterns, canvas effects, page templates, motion recipes, and font recommendations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,10 @@
17
17
  "scripts": {
18
18
  "build": "tsc && node scripts/postbuild.mjs && node scripts/bundle-registry.mjs",
19
19
  "dev": "tsx src/index.ts",
20
- "clean": "rm -rf dist"
20
+ "clean": "rm -rf dist",
21
+ "test:registry": "node --test src/registry.test.mjs",
22
+ "test:search": "node --import tsx/esm --test src/search.test.ts",
23
+ "test": "node --test src/registry.test.mjs && node --import tsx/esm --test src/search.test.ts"
21
24
  },
22
25
  "files": [
23
26
  "dist",
@@ -25,13 +28,13 @@
25
28
  "css"
26
29
  ],
27
30
  "dependencies": {
28
- "@modelcontextprotocol/sdk": "^1.13.0",
29
- "zod": "^4.2.1"
31
+ "@modelcontextprotocol/sdk": "^1.29.0",
32
+ "zod": "^4.4.3"
30
33
  },
31
34
  "devDependencies": {
32
- "@types/node": "^22.0.0",
33
- "tsx": "^4.21.0",
34
- "typescript": "^5.9.3"
35
+ "@types/node": "^25.8.0",
36
+ "tsx": "^4.22.1",
37
+ "typescript": "^6.0.3"
35
38
  },
36
39
  "keywords": [
37
40
  "mcp",
@@ -54,7 +57,7 @@
54
57
  "engines": {
55
58
  "node": ">=22.12.0"
56
59
  },
57
- "license": "MIT",
60
+ "license": "UNLICENSED",
58
61
  "publishConfig": {
59
62
  "access": "public"
60
63
  }