@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.
- package/README.md +7 -3
- package/css/webspire-components.css +55 -0
- package/data/registry.json +12210 -719
- package/dist/registration.js +283 -3
- package/dist/search.d.ts +26 -0
- package/dist/search.js +217 -4
- package/dist/search.test.d.ts +1 -0
- package/dist/search.test.js +351 -0
- package/dist/types.d.ts +68 -0
- package/package.json +11 -8
package/dist/registration.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { loadFonts, searchSnippets } from './registry.js';
|
|
3
|
-
import { CONTENT_NEED_KEYS, CONTENT_NEED_LABEL, composePage, DOMAIN_KEYS, DOMAIN_LABEL, normalizeContentNeed, recommendFonts, searchCanvasEffects, searchMotionRecipes, searchPatterns, TONE_KEYS, TONE_LABEL, UX_GOAL_KEYS, UX_GOAL_LABEL, } from './search.js';
|
|
3
|
+
import { augmentPage, CONTENT_NEED_KEYS, CONTENT_NEED_LABEL, composePage, DOMAIN_KEYS, DOMAIN_LABEL, normalizeContentNeed, recommendFonts, searchCanvasEffects, searchMotionRecipes, searchPatterns, TONE_KEYS, TONE_LABEL, UX_GOAL_KEYS, UX_GOAL_LABEL, } from './search.js';
|
|
4
4
|
function formatSnippetBrief(s) {
|
|
5
5
|
return [
|
|
6
6
|
`**${s.title}** (${s.id})`,
|
|
@@ -388,9 +388,36 @@ export function registerToolsWithProvider(server, getRegistry) {
|
|
|
388
388
|
const sections = result.patterns
|
|
389
389
|
.map((p, i) => {
|
|
390
390
|
const role = p.ai?.compositionRole ?? 'supporting';
|
|
391
|
-
return `${i + 1}. **${p.title}** (
|
|
391
|
+
return `${i + 1}. **${p.title}** (\`${p.id}\`) — role: ${role}\n ${p.summary}`;
|
|
392
392
|
})
|
|
393
393
|
.join('\n\n');
|
|
394
|
+
// Alternatives per section
|
|
395
|
+
const alternativesText = Object.keys(result.alternatives).length > 0
|
|
396
|
+
? `\n\n## Alternatives (swap any section)\n${Object.entries(result.alternatives)
|
|
397
|
+
.map(([primaryId, alts]) => {
|
|
398
|
+
const altList = alts.map((a) => `\`${a.id}\``).join(', ');
|
|
399
|
+
return `- For **${primaryId}**: ${altList}`;
|
|
400
|
+
})
|
|
401
|
+
.join('\n')}`
|
|
402
|
+
: '';
|
|
403
|
+
// Bold picks
|
|
404
|
+
const boldPicksText = result.boldPicks.length > 0
|
|
405
|
+
? `\n\n## Bold Picks 💡\nCreative alternatives that weren't selected but fit the mood:\n${result.boldPicks
|
|
406
|
+
.map((p) => {
|
|
407
|
+
const flags = [
|
|
408
|
+
p.tier === 'enhanced' ? 'enhanced' : null,
|
|
409
|
+
p.capabilities?.animated ? 'animated' : null,
|
|
410
|
+
]
|
|
411
|
+
.filter(Boolean)
|
|
412
|
+
.join(', ');
|
|
413
|
+
return `- **${p.title}** (\`${p.id}\`)${flags ? ` [${flags}]` : ''} — ${p.summary}`;
|
|
414
|
+
})
|
|
415
|
+
.join('\n')}`
|
|
416
|
+
: '';
|
|
417
|
+
// Missing from library
|
|
418
|
+
const missingText = result.missingFromLibrary.length > 0
|
|
419
|
+
? `\n\n## Ideas Not Yet in Library\nContent elements that would fit but don't exist as patterns yet:\n${result.missingFromLibrary.map((idea) => `- ${idea}`).join('\n')}`
|
|
420
|
+
: '';
|
|
394
421
|
const reasoningText = result.reasoning.length > 0
|
|
395
422
|
? `\n\n## Selection Reasoning\n${result.reasoning.join('\n')}`
|
|
396
423
|
: '';
|
|
@@ -412,7 +439,107 @@ export function registerToolsWithProvider(server, getRegistry) {
|
|
|
412
439
|
content: [
|
|
413
440
|
{
|
|
414
441
|
type: 'text',
|
|
415
|
-
text: `# Composed Page: ${domain} / ${tone}\n\nUX Goals: ${ux_goals.join(', ')}${content_needs?.length ? `\nContent Needs: ${content_needs.join(', ')}` : ''}\n\n## Recommended Sections\n\n${sections}${snippetsText}${fontsText}${warningsText}${reasoningText}\n\nUse \`get_pattern(id)\` to get full HTML for each section.`,
|
|
442
|
+
text: `# Composed Page: ${domain} / ${tone}\n\nUX Goals: ${ux_goals.join(', ')}${content_needs?.length ? `\nContent Needs: ${content_needs.join(', ')}` : ''}\n\n## Recommended Sections\n\n${sections}${alternativesText}${boldPicksText}${missingText}${snippetsText}${fontsText}${warningsText}${reasoningText}\n\nUse \`get_pattern(id)\` to get full HTML for each section.`,
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
server.tool('augment_page', 'Suggest additions for a page that already has some sections. Unlike compose_page (greenfield), augment_page works out which composition roles are already covered and only fills the gaps. Use this when you have an existing page and want to improve or complete it.', {
|
|
448
|
+
existing_sections: z
|
|
449
|
+
.array(z.string())
|
|
450
|
+
.describe('Family names of sections already present on the page, e.g. ["navbar", "hero", "services"]. Use the family name, not the pattern ID.'),
|
|
451
|
+
domain: z.enum(DOMAIN_KEYS).describe(`Business domain: ${DOMAIN_LABEL}`),
|
|
452
|
+
tone: z.enum(TONE_KEYS).describe(`Desired tone: ${TONE_LABEL}`),
|
|
453
|
+
ux_goals: z.array(z.enum(UX_GOAL_KEYS)).describe(`UX goals: ${UX_GOAL_LABEL}`),
|
|
454
|
+
content_needs: z
|
|
455
|
+
.array(z.enum(CONTENT_NEED_KEYS))
|
|
456
|
+
.optional()
|
|
457
|
+
.describe(`Optional explicit content requirements: ${CONTENT_NEED_LABEL}`),
|
|
458
|
+
max_sections: z
|
|
459
|
+
.number()
|
|
460
|
+
.optional()
|
|
461
|
+
.default(6)
|
|
462
|
+
.describe('Maximum number of additional sections to suggest (default: 6)'),
|
|
463
|
+
}, async ({ existing_sections, domain, tone, ux_goals, content_needs, max_sections }) => {
|
|
464
|
+
const registry = await getRegistry();
|
|
465
|
+
const allPatterns = registry.patterns ?? [];
|
|
466
|
+
const result = augmentPage(allPatterns, registry.snippets, {
|
|
467
|
+
existingSections: existing_sections,
|
|
468
|
+
domain,
|
|
469
|
+
tone,
|
|
470
|
+
uxGoals: ux_goals,
|
|
471
|
+
contentNeeds: content_needs
|
|
472
|
+
?.map(normalizeContentNeed)
|
|
473
|
+
.filter((need) => need !== null),
|
|
474
|
+
maxSections: max_sections,
|
|
475
|
+
});
|
|
476
|
+
// Already covered
|
|
477
|
+
const coveredText = result.alreadyCovered.length > 0
|
|
478
|
+
? `## Already Covered\n${result.alreadyCovered
|
|
479
|
+
.map((f) => `- \`${f}\``)
|
|
480
|
+
.join('\n')}\nCovered roles: ${result.coveredRoles.length > 0 ? result.coveredRoles.join(', ') : 'none detected'}`
|
|
481
|
+
: '## Already Covered\n(none specified)';
|
|
482
|
+
const uncoveredText = result.uncoveredRoles.length > 0
|
|
483
|
+
? `\n\n## Gaps Found\nMissing roles: **${result.uncoveredRoles.join(', ')}**`
|
|
484
|
+
: '\n\n## Gaps Found\nAll composition roles appear covered — the suggestions below are enhancements.';
|
|
485
|
+
// Suggestions
|
|
486
|
+
const suggestions = result.suggestions;
|
|
487
|
+
if (suggestions.patterns.length === 0) {
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: 'text',
|
|
492
|
+
text: `# Page Augmentation: ${domain} / ${tone}\n\n${coveredText}${uncoveredText}\n\nNo additional patterns found. The page appears complete for these goals.`,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const suggestedSections = suggestions.patterns
|
|
498
|
+
.map((p, i) => {
|
|
499
|
+
const role = p.ai?.compositionRole ?? 'supporting';
|
|
500
|
+
return `${i + 1}. **${p.title}** (\`${p.id}\`) — role: ${role}\n ${p.summary}`;
|
|
501
|
+
})
|
|
502
|
+
.join('\n\n');
|
|
503
|
+
const altText = Object.keys(suggestions.alternatives).length > 0
|
|
504
|
+
? `\n\n## Alternatives\n${Object.entries(suggestions.alternatives)
|
|
505
|
+
.map(([id, alts]) => `- For **${id}**: ${alts.map((a) => `\`${a.id}\``).join(', ')}`)
|
|
506
|
+
.join('\n')}`
|
|
507
|
+
: '';
|
|
508
|
+
const boldText = suggestions.boldPicks.length > 0
|
|
509
|
+
? `\n\n## Bold Picks 💡\n${suggestions.boldPicks
|
|
510
|
+
.map((p) => {
|
|
511
|
+
const flags = [
|
|
512
|
+
p.tier === 'enhanced' ? 'enhanced' : null,
|
|
513
|
+
p.capabilities?.animated ? 'animated' : null,
|
|
514
|
+
]
|
|
515
|
+
.filter(Boolean)
|
|
516
|
+
.join(', ');
|
|
517
|
+
return `- **${p.title}** (\`${p.id}\`)${flags ? ` [${flags}]` : ''} — ${p.summary}`;
|
|
518
|
+
})
|
|
519
|
+
.join('\n')}`
|
|
520
|
+
: '';
|
|
521
|
+
const missingText = suggestions.missingFromLibrary.length > 0
|
|
522
|
+
? `\n\n## Ideas Not Yet in Library\n${suggestions.missingFromLibrary.map((idea) => `- ${idea}`).join('\n')}`
|
|
523
|
+
: '';
|
|
524
|
+
const snippetsText = suggestions.snippets.length > 0
|
|
525
|
+
? `\n\n## Recommended Snippets\n${suggestions.snippets
|
|
526
|
+
.map(({ snippet, reason }, i) => `${i + 1}. **${snippet.title}** (${snippet.id}) — ${reason}`)
|
|
527
|
+
.join('\n')}\n\nUse \`get_snippet(id)\` to retrieve the CSS.`
|
|
528
|
+
: '';
|
|
529
|
+
let fontsText = '';
|
|
530
|
+
const fontsDataLoaded = await loadFonts();
|
|
531
|
+
if (fontsDataLoaded) {
|
|
532
|
+
const fontRec = recommendFonts(fontsDataLoaded, domain, tone);
|
|
533
|
+
fontsText = `\n\n## Recommended Fonts\n- **Heading:** ${fontRec.heading.name} (\`${fontRec.heading.npm}\`)\n- **Body:** ${fontRec.body.name} (\`${fontRec.body.npm}\`)`;
|
|
534
|
+
}
|
|
535
|
+
const warningsText = suggestions.warnings.length > 0
|
|
536
|
+
? `\n\n## Warnings\n${suggestions.warnings.join('\n')}`
|
|
537
|
+
: '';
|
|
538
|
+
return {
|
|
539
|
+
content: [
|
|
540
|
+
{
|
|
541
|
+
type: 'text',
|
|
542
|
+
text: `# Page Augmentation: ${domain} / ${tone}\n\nUX Goals: ${ux_goals.join(', ')}\n\n${coveredText}${uncoveredText}\n\n## Suggested Additions\n\n${suggestedSections}${altText}${boldText}${missingText}${snippetsText}${fontsText}${warningsText}\n\nUse \`get_pattern(id)\` to get full HTML for any section.`,
|
|
416
543
|
},
|
|
417
544
|
],
|
|
418
545
|
};
|
|
@@ -1013,6 +1140,159 @@ mountCanvas(document.getElementById('ws-canvas'), effect, { animate: ${effect.an
|
|
|
1013
1140
|
content: [{ type: 'text', text }],
|
|
1014
1141
|
};
|
|
1015
1142
|
});
|
|
1143
|
+
// --- Design Skill Tools ---
|
|
1144
|
+
function formatSkillBrief(s) {
|
|
1145
|
+
return [
|
|
1146
|
+
`**${s.title}** (${s.id})`,
|
|
1147
|
+
` ${s.summary}`,
|
|
1148
|
+
` Tone: ${s.tone} | Domains: ${s.domains.join(', ')}`,
|
|
1149
|
+
s.tags.length > 0 ? ` Tags: ${s.tags.join(', ')}` : '',
|
|
1150
|
+
]
|
|
1151
|
+
.filter(Boolean)
|
|
1152
|
+
.join('\n');
|
|
1153
|
+
}
|
|
1154
|
+
function formatSkillFull(s) {
|
|
1155
|
+
const colors = Object.entries(s.colors)
|
|
1156
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
1157
|
+
.join('\n');
|
|
1158
|
+
return [
|
|
1159
|
+
`# Design Skill: ${s.title}`,
|
|
1160
|
+
'',
|
|
1161
|
+
s.summary,
|
|
1162
|
+
'',
|
|
1163
|
+
'## Identity',
|
|
1164
|
+
`ID: ${s.id}`,
|
|
1165
|
+
`Tone: ${s.tone}`,
|
|
1166
|
+
`Domains: ${s.domains.join(', ')}`,
|
|
1167
|
+
s.uxGoals.length > 0 ? `UX Goals: ${s.uxGoals.join(', ')}` : '',
|
|
1168
|
+
'',
|
|
1169
|
+
`## Color Palette\n${colors}`,
|
|
1170
|
+
'',
|
|
1171
|
+
`## Typography`,
|
|
1172
|
+
`Heading: ${s.typography.heading} (weight ${s.typography.weight.heading})`,
|
|
1173
|
+
`Body: ${s.typography.body} (weight ${s.typography.weight.body})`,
|
|
1174
|
+
`Mono: ${s.typography.mono}`,
|
|
1175
|
+
'',
|
|
1176
|
+
s.relatedPatterns.length > 0 ? `## Related Patterns\n${s.relatedPatterns.join(', ')}` : '',
|
|
1177
|
+
s.suggestedTemplate ? `## Suggested Template\n${s.suggestedTemplate}` : '',
|
|
1178
|
+
'',
|
|
1179
|
+
'## Design Rules',
|
|
1180
|
+
s.body,
|
|
1181
|
+
]
|
|
1182
|
+
.filter((line) => line !== undefined)
|
|
1183
|
+
.join('\n');
|
|
1184
|
+
}
|
|
1185
|
+
server.tool('list_design_skills', 'List all available Design Skills (persistent design system rules for AI agents). Each skill defines color palette, typography, spacing, and component rules for a specific visual style.', {}, async () => {
|
|
1186
|
+
const registry = await getRegistry();
|
|
1187
|
+
const skills = registry.skills ?? [];
|
|
1188
|
+
if (skills.length === 0) {
|
|
1189
|
+
return {
|
|
1190
|
+
content: [
|
|
1191
|
+
{
|
|
1192
|
+
type: 'text',
|
|
1193
|
+
text: 'No design skills available in registry.',
|
|
1194
|
+
},
|
|
1195
|
+
],
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
content: [
|
|
1200
|
+
{
|
|
1201
|
+
type: 'text',
|
|
1202
|
+
text: `${skills.length} design skills available:\n\n${skills.map(formatSkillBrief).join('\n\n')}\n\nUse \`get_design_skill(id)\` for full design rules.`,
|
|
1203
|
+
},
|
|
1204
|
+
],
|
|
1205
|
+
};
|
|
1206
|
+
});
|
|
1207
|
+
server.tool('get_design_skill', "Get the full design rules for a specific Design Skill. Returns color palette, typography, spacing rules, component guidelines, and do/don't rules in markdown format.", {
|
|
1208
|
+
id: z.string().describe('Skill ID, e.g. "modern", "corporate", "glassmorphism", "minimal"'),
|
|
1209
|
+
}, async ({ id }) => {
|
|
1210
|
+
const registry = await getRegistry();
|
|
1211
|
+
const skills = registry.skills ?? [];
|
|
1212
|
+
const skill = skills.find((s) => s.id === id);
|
|
1213
|
+
if (!skill) {
|
|
1214
|
+
const available = skills.map((s) => s.id).join(', ');
|
|
1215
|
+
return {
|
|
1216
|
+
content: [
|
|
1217
|
+
{
|
|
1218
|
+
type: 'text',
|
|
1219
|
+
text: `Design skill "${id}" not found. Available: ${available}`,
|
|
1220
|
+
},
|
|
1221
|
+
],
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
content: [{ type: 'text', text: formatSkillFull(skill) }],
|
|
1226
|
+
};
|
|
1227
|
+
});
|
|
1228
|
+
server.tool('recommend_design_skill', 'Recommend a Design Skill based on business domain, desired tone, or UX goals. Returns the best matching skill with its design rules.', {
|
|
1229
|
+
domain: z
|
|
1230
|
+
.string()
|
|
1231
|
+
.optional()
|
|
1232
|
+
.describe('Business domain, e.g. "saas", "agency", "legal", "healthcare", "finance", "ecommerce"'),
|
|
1233
|
+
tone: z
|
|
1234
|
+
.string()
|
|
1235
|
+
.optional()
|
|
1236
|
+
.describe('Desired visual tone, e.g. "modern", "serious", "premium", "playful", "editorial", "minimal"'),
|
|
1237
|
+
ux_goal: z
|
|
1238
|
+
.string()
|
|
1239
|
+
.optional()
|
|
1240
|
+
.describe('Primary UX goal, e.g. "build_trust", "drive_signup", "showcase_work", "drive_contact"'),
|
|
1241
|
+
}, async ({ domain, tone, ux_goal }) => {
|
|
1242
|
+
const registry = await getRegistry();
|
|
1243
|
+
const skills = registry.skills ?? [];
|
|
1244
|
+
if (skills.length === 0) {
|
|
1245
|
+
return {
|
|
1246
|
+
content: [
|
|
1247
|
+
{
|
|
1248
|
+
type: 'text',
|
|
1249
|
+
text: 'No design skills available in registry.',
|
|
1250
|
+
},
|
|
1251
|
+
],
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
// Score each skill by match quality
|
|
1255
|
+
const scored = skills.map((s) => {
|
|
1256
|
+
let score = 0;
|
|
1257
|
+
if (tone && s.tone === tone)
|
|
1258
|
+
score += 10;
|
|
1259
|
+
if (tone && s.tags.includes(tone))
|
|
1260
|
+
score += 5;
|
|
1261
|
+
if (domain && s.domains.includes(domain))
|
|
1262
|
+
score += 8;
|
|
1263
|
+
if (ux_goal && s.uxGoals.includes(ux_goal))
|
|
1264
|
+
score += 6;
|
|
1265
|
+
return { skill: s, score };
|
|
1266
|
+
});
|
|
1267
|
+
scored.sort((a, b) => b.score - a.score);
|
|
1268
|
+
const best = scored[0];
|
|
1269
|
+
if (best.score === 0) {
|
|
1270
|
+
// No match — return all skills as suggestions
|
|
1271
|
+
return {
|
|
1272
|
+
content: [
|
|
1273
|
+
{
|
|
1274
|
+
type: 'text',
|
|
1275
|
+
text: `No exact match found. Available skills:\n\n${skills.map(formatSkillBrief).join('\n\n')}\n\nUse \`get_design_skill(id)\` for full rules.`,
|
|
1276
|
+
},
|
|
1277
|
+
],
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
const reason = [];
|
|
1281
|
+
if (tone && best.skill.tone === tone)
|
|
1282
|
+
reason.push(`tone matches "${tone}"`);
|
|
1283
|
+
if (domain && best.skill.domains.includes(domain))
|
|
1284
|
+
reason.push(`domain "${domain}" supported`);
|
|
1285
|
+
if (ux_goal && best.skill.uxGoals.includes(ux_goal))
|
|
1286
|
+
reason.push(`UX goal "${ux_goal}" supported`);
|
|
1287
|
+
return {
|
|
1288
|
+
content: [
|
|
1289
|
+
{
|
|
1290
|
+
type: 'text',
|
|
1291
|
+
text: `## Recommended: ${best.skill.title} (${best.skill.id})\n\n**Why:** ${reason.join(', ')}\n\n${formatSkillFull(best.skill)}`,
|
|
1292
|
+
},
|
|
1293
|
+
],
|
|
1294
|
+
};
|
|
1295
|
+
});
|
|
1016
1296
|
}
|
|
1017
1297
|
export function registerResourcesWithProvider(server, getRegistry) {
|
|
1018
1298
|
server.resource('categories', 'webspire://categories', {
|
package/dist/search.d.ts
CHANGED
|
@@ -47,11 +47,37 @@ export interface RecommendedSnippet {
|
|
|
47
47
|
}
|
|
48
48
|
export interface ComposedPage {
|
|
49
49
|
patterns: PatternEntry[];
|
|
50
|
+
/** Per primary pattern: top alternatives with same role, different family */
|
|
51
|
+
alternatives: Record<string, PatternEntry[]>;
|
|
52
|
+
/** Creative wildcards — high-scoring patterns not selected because role was full */
|
|
53
|
+
boldPicks: PatternEntry[];
|
|
50
54
|
snippets: RecommendedSnippet[];
|
|
51
55
|
reasoning: string[];
|
|
52
56
|
warnings: string[];
|
|
57
|
+
/** Content ideas that would fit but don't yet exist as patterns in the library */
|
|
58
|
+
missingFromLibrary: string[];
|
|
59
|
+
}
|
|
60
|
+
export interface AugmentPageOptions extends ComposePageOptions {
|
|
61
|
+
/** Family names of sections already present on the page, e.g. ["navbar", "hero"] */
|
|
62
|
+
existingSections: string[];
|
|
63
|
+
}
|
|
64
|
+
export interface AugmentedPage {
|
|
65
|
+
/** The family names that were passed in as already present */
|
|
66
|
+
alreadyCovered: string[];
|
|
67
|
+
/** Which compositionRoles those families cover */
|
|
68
|
+
coveredRoles: string[];
|
|
69
|
+
/** Which compositionRoles still have open slots */
|
|
70
|
+
uncoveredRoles: string[];
|
|
71
|
+
/** Pattern/snippet suggestions for the gaps */
|
|
72
|
+
suggestions: ComposedPage;
|
|
53
73
|
}
|
|
54
74
|
export declare function composePage(patterns: PatternEntry[], snippets: SnippetEntry[], options: ComposePageOptions): ComposedPage;
|
|
75
|
+
/**
|
|
76
|
+
* Suggests additions for a page that already has some sections.
|
|
77
|
+
* Unlike composePage (greenfield), augmentPage works out which roles
|
|
78
|
+
* are already covered and only fills the gaps.
|
|
79
|
+
*/
|
|
80
|
+
export declare function augmentPage(patterns: PatternEntry[], snippets: SnippetEntry[], options: AugmentPageOptions): AugmentedPage;
|
|
55
81
|
export declare function recommendFonts(fontsData: FontsData, domain: string, tone: string): FontRecommendation;
|
|
56
82
|
export interface CanvasSearchOptions {
|
|
57
83
|
query: string;
|
package/dist/search.js
CHANGED
|
@@ -225,10 +225,12 @@ export function searchPatterns(patterns, options) {
|
|
|
225
225
|
if (tier)
|
|
226
226
|
filtered = filtered.filter((p) => p.tier === tier);
|
|
227
227
|
const scored = filtered
|
|
228
|
-
.map((pattern) =>
|
|
229
|
-
pattern,
|
|
230
|
-
|
|
231
|
-
|
|
228
|
+
.map((pattern) => {
|
|
229
|
+
let score = scorePattern(pattern, stems, expanded, options);
|
|
230
|
+
if (pattern.qualityTier === 'recommended')
|
|
231
|
+
score += 15;
|
|
232
|
+
return { pattern, score };
|
|
233
|
+
})
|
|
232
234
|
.filter(({ pattern, score }) => {
|
|
233
235
|
if (score <= 0)
|
|
234
236
|
return false;
|
|
@@ -837,11 +839,222 @@ export function composePage(patterns, snippets, options) {
|
|
|
837
839
|
}
|
|
838
840
|
}
|
|
839
841
|
const recommendedSnippets = recommendSnippetsForComposition(snippets, result, options);
|
|
842
|
+
// Step 6: Compute alternatives (per selected pattern: top-3 same-role, different family)
|
|
843
|
+
const selectedFamilies = new Set(result.map((p) => p.family));
|
|
844
|
+
const alternatives = {};
|
|
845
|
+
for (const selectedPattern of result) {
|
|
846
|
+
const role = selectedPattern.ai?.compositionRole ?? 'supporting';
|
|
847
|
+
const alts = scored
|
|
848
|
+
.filter(({ pattern }) => (pattern.ai?.compositionRole ?? 'supporting') === role &&
|
|
849
|
+
pattern.family !== selectedPattern.family &&
|
|
850
|
+
!selectedFamilies.has(pattern.family))
|
|
851
|
+
.slice(0, 3)
|
|
852
|
+
.map(({ pattern }) => pattern);
|
|
853
|
+
if (alts.length > 0) {
|
|
854
|
+
alternatives[selectedPattern.id] = alts;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Step 7: Bold picks — high-scoring patterns that weren't selected, preferring animated or enhanced
|
|
858
|
+
const boldPicks = scored
|
|
859
|
+
.filter(({ pattern, score }) => !selectedFamilies.has(pattern.family) &&
|
|
860
|
+
score >= 5 &&
|
|
861
|
+
(pattern.capabilities?.animated === true || pattern.tier === 'enhanced'))
|
|
862
|
+
.slice(0, 2)
|
|
863
|
+
.map(({ pattern }) => pattern);
|
|
864
|
+
// Step 8: Missing from library — content ideas that don't yet exist as patterns
|
|
865
|
+
const missingFromLibrary = computeMissingFromLibrary(domain, tone, uxGoals, result);
|
|
840
866
|
return {
|
|
841
867
|
patterns: result,
|
|
868
|
+
alternatives,
|
|
869
|
+
boldPicks,
|
|
842
870
|
snippets: recommendedSnippets,
|
|
843
871
|
reasoning,
|
|
844
872
|
warnings,
|
|
873
|
+
missingFromLibrary,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
// --- Missing From Library ---
|
|
877
|
+
/**
|
|
878
|
+
* Domain-specific content ideas that would make sense on this type of site
|
|
879
|
+
* but don't yet exist as patterns in the WebSpire library.
|
|
880
|
+
* These are intentionally opinionated — the goal is to inspire, not be exhaustive.
|
|
881
|
+
*/
|
|
882
|
+
const DOMAIN_CONTENT_SUGGESTIONS = {
|
|
883
|
+
agency: [
|
|
884
|
+
'Awards & Recognition strip (Awwwards, FWA, Cannes badges)',
|
|
885
|
+
'Client project counter with animated numbers',
|
|
886
|
+
'Scroll-driven process / methodology diagram',
|
|
887
|
+
'"Work in progress" sneak-peek teaser section',
|
|
888
|
+
'Behind-the-scenes studio culture section',
|
|
889
|
+
],
|
|
890
|
+
saas: [
|
|
891
|
+
'Live product demo with real-time preview embed',
|
|
892
|
+
'GitHub stars + contributor avatar stack',
|
|
893
|
+
'API code snippet preview with language tabs',
|
|
894
|
+
'Customer ROI / time-saved calculator widget',
|
|
895
|
+
'System status / uptime badge row',
|
|
896
|
+
],
|
|
897
|
+
ecommerce: [
|
|
898
|
+
'Recently-viewed products strip',
|
|
899
|
+
'Bundle & save suggestion row',
|
|
900
|
+
'Loyalty points explainer section',
|
|
901
|
+
'Real-time low-stock indicator',
|
|
902
|
+
'Size guide / fit advisor modal trigger',
|
|
903
|
+
],
|
|
904
|
+
consulting: [
|
|
905
|
+
'Client portfolio outcome timeline',
|
|
906
|
+
'Methodology / engagement model visual',
|
|
907
|
+
'Thought leadership article highlight strip',
|
|
908
|
+
'Industry specialization selector',
|
|
909
|
+
],
|
|
910
|
+
healthcare: [
|
|
911
|
+
'Symptom checker / self-assessment widget',
|
|
912
|
+
'Insurance accepted / coverage explainer',
|
|
913
|
+
'Staff credentials strip with specialty badges',
|
|
914
|
+
'Emergency contact / triage priority bar',
|
|
915
|
+
],
|
|
916
|
+
education: [
|
|
917
|
+
'Course prerequisite map / learning path',
|
|
918
|
+
'Student outcome stats (placement rate, salary)',
|
|
919
|
+
'Enrollment deadline countdown banner',
|
|
920
|
+
'Cohort community / alumni network section',
|
|
921
|
+
],
|
|
922
|
+
finance: [
|
|
923
|
+
'Interest rate / mortgage calculator widget',
|
|
924
|
+
'Risk tolerance questionnaire flow',
|
|
925
|
+
'Regulatory disclaimer bar (compact, collapsible)',
|
|
926
|
+
'Portfolio allocation visual explainer',
|
|
927
|
+
],
|
|
928
|
+
legal: [
|
|
929
|
+
'Practice area decision tree',
|
|
930
|
+
'Case outcome / win-rate stats bar',
|
|
931
|
+
'Attorney matching questionnaire',
|
|
932
|
+
'Jurisdiction / location selector',
|
|
933
|
+
],
|
|
934
|
+
nonprofit: [
|
|
935
|
+
'Live impact counter (meals served, trees planted)',
|
|
936
|
+
'Volunteer availability / shift calendar',
|
|
937
|
+
'Recurring vs one-time donation toggle',
|
|
938
|
+
'Partner organization acknowledgement wall',
|
|
939
|
+
],
|
|
940
|
+
gastronomy: [
|
|
941
|
+
'Menu item of the day / featured dish widget',
|
|
942
|
+
'Real-time table availability checker',
|
|
943
|
+
'Chef profile with signature dish spotlight',
|
|
944
|
+
'Dietary / allergy filter strip',
|
|
945
|
+
],
|
|
946
|
+
realestate: [
|
|
947
|
+
'Mortgage / affordability calculator embed',
|
|
948
|
+
'Neighborhood walkability / transit score widget',
|
|
949
|
+
'Virtual tour launcher',
|
|
950
|
+
'Price history sparkline chart',
|
|
951
|
+
],
|
|
952
|
+
personal: [
|
|
953
|
+
'Reading list / bookshelf section',
|
|
954
|
+
'Life stats / fun-facts counter strip',
|
|
955
|
+
"Now page (what I'm working on / listening to)",
|
|
956
|
+
'Newsletter with personalized "why subscribe" hook',
|
|
957
|
+
],
|
|
958
|
+
};
|
|
959
|
+
const UX_GOAL_CONTENT_SUGGESTIONS = {
|
|
960
|
+
build_trust: 'Third-party review aggregate (G2, Trustpilot rating block)',
|
|
961
|
+
drive_contact: 'Smart contact form with department routing',
|
|
962
|
+
drive_signup: 'Progressive disclosure signup — minimal first step, details later',
|
|
963
|
+
drive_purchase: 'Social proof at checkout: "X people bought this today"',
|
|
964
|
+
explain_offer: 'Interactive feature comparison with toggle/filter',
|
|
965
|
+
highlight_expertise: 'Case study timeline with measurable outcomes',
|
|
966
|
+
showcase_work: 'Full-bleed project filmstrip with scroll-scrubbing',
|
|
967
|
+
reduce_friction: 'Inline FAQ that answers objections near the CTA',
|
|
968
|
+
tell_story: 'Scroll-driven narrative with chapter indicators',
|
|
969
|
+
demonstrate_value: 'Before/after slider or split-screen comparison',
|
|
970
|
+
};
|
|
971
|
+
function computeMissingFromLibrary(domain, tone, uxGoals, selectedPatterns) {
|
|
972
|
+
const ideas = [];
|
|
973
|
+
// 2 domain-specific ideas
|
|
974
|
+
const domainIdeas = DOMAIN_CONTENT_SUGGESTIONS[domain] ?? [];
|
|
975
|
+
ideas.push(...domainIdeas.slice(0, 2));
|
|
976
|
+
// 1 idea per uncovered UX goal (max 2)
|
|
977
|
+
const coveredGoals = new Set();
|
|
978
|
+
for (const p of selectedPatterns) {
|
|
979
|
+
for (const g of p.semantics?.uxGoals ?? [])
|
|
980
|
+
coveredGoals.add(g);
|
|
981
|
+
}
|
|
982
|
+
let goalIdeasAdded = 0;
|
|
983
|
+
for (const goal of uxGoals) {
|
|
984
|
+
if (!coveredGoals.has(goal) && UX_GOAL_CONTENT_SUGGESTIONS[goal] && goalIdeasAdded < 2) {
|
|
985
|
+
ideas.push(UX_GOAL_CONTENT_SUGGESTIONS[goal]);
|
|
986
|
+
goalIdeasAdded++;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// Tone-specific wildcard
|
|
990
|
+
if (tone === 'editorial' && !ideas.some((i) => i.includes('scroll'))) {
|
|
991
|
+
ideas.push('Scroll-progress reading indicator for long-form content');
|
|
992
|
+
}
|
|
993
|
+
else if (tone === 'premium' && !ideas.some((i) => i.includes('3D'))) {
|
|
994
|
+
ideas.push('3D product viewer / depth-of-field showcase element');
|
|
995
|
+
}
|
|
996
|
+
else if (tone === 'playful') {
|
|
997
|
+
ideas.push('Easter egg / hidden interaction for delighted discovery');
|
|
998
|
+
}
|
|
999
|
+
return ideas.slice(0, 4);
|
|
1000
|
+
}
|
|
1001
|
+
// --- Augment Existing Page ---
|
|
1002
|
+
/**
|
|
1003
|
+
* Suggests additions for a page that already has some sections.
|
|
1004
|
+
* Unlike composePage (greenfield), augmentPage works out which roles
|
|
1005
|
+
* are already covered and only fills the gaps.
|
|
1006
|
+
*/
|
|
1007
|
+
export function augmentPage(patterns, snippets, options) {
|
|
1008
|
+
const { existingSections, ...composeOptions } = options;
|
|
1009
|
+
// Determine which compositionRoles are already covered
|
|
1010
|
+
const coveredRoleCounts = new Map();
|
|
1011
|
+
for (const family of existingSections) {
|
|
1012
|
+
// Check ROLE_FAMILY_BONUSES for authoritative role lookup
|
|
1013
|
+
let found = false;
|
|
1014
|
+
for (const [role, families] of Object.entries(ROLE_FAMILY_BONUSES)) {
|
|
1015
|
+
if (Object.hasOwn(families, family)) {
|
|
1016
|
+
coveredRoleCounts.set(role, (coveredRoleCounts.get(role) ?? 0) + 1);
|
|
1017
|
+
found = true;
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
// Fallback: look up a matching pattern's compositionRole
|
|
1022
|
+
if (!found) {
|
|
1023
|
+
const match = patterns.find((p) => p.family === family);
|
|
1024
|
+
if (match?.ai?.compositionRole) {
|
|
1025
|
+
const role = match.ai.compositionRole;
|
|
1026
|
+
coveredRoleCounts.set(role, (coveredRoleCounts.get(role) ?? 0) + 1);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// Compute remaining slots per role
|
|
1031
|
+
const remainingSlots = {};
|
|
1032
|
+
for (const [role, limit] of Object.entries(ROLE_LIMITS)) {
|
|
1033
|
+
const used = coveredRoleCounts.get(role) ?? 0;
|
|
1034
|
+
remainingSlots[role] = Math.max(0, limit - used);
|
|
1035
|
+
}
|
|
1036
|
+
const coveredRoles = [...coveredRoleCounts.keys()].filter((r) => (coveredRoleCounts.get(r) ?? 0) >= (ROLE_LIMITS[r] ?? 1));
|
|
1037
|
+
const allRoles = Object.keys(ROLE_ORDER);
|
|
1038
|
+
const uncoveredRoles = allRoles.filter((r) => (remainingSlots[r] ?? 0) > 0);
|
|
1039
|
+
// Filter patterns: exclude already-present families and fully-covered roles
|
|
1040
|
+
const availablePatterns = patterns.filter((p) => {
|
|
1041
|
+
if (existingSections.includes(p.family))
|
|
1042
|
+
return false;
|
|
1043
|
+
const role = p.ai?.compositionRole ?? 'supporting';
|
|
1044
|
+
return (remainingSlots[role] ?? 0) > 0;
|
|
1045
|
+
});
|
|
1046
|
+
// Adjust maxSections to remaining open slots total
|
|
1047
|
+
const totalRemainingSlots = Object.values(remainingSlots).reduce((a, b) => a + b, 0);
|
|
1048
|
+
const adjustedMax = Math.min(composeOptions.maxSections ?? 8, totalRemainingSlots);
|
|
1049
|
+
const suggestions = composePage(availablePatterns, snippets, {
|
|
1050
|
+
...composeOptions,
|
|
1051
|
+
maxSections: adjustedMax,
|
|
1052
|
+
});
|
|
1053
|
+
return {
|
|
1054
|
+
alreadyCovered: existingSections,
|
|
1055
|
+
coveredRoles,
|
|
1056
|
+
uncoveredRoles,
|
|
1057
|
+
suggestions,
|
|
845
1058
|
};
|
|
846
1059
|
}
|
|
847
1060
|
// --- Font Recommendation ---
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|