@neuctra/ui-mcp 0.4.7 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuctra/ui-mcp",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "MCP server exposing the @neuctra/ui component registry (props, defaults, examples, theme tokens) so AI coding tools generate accurate Neuctra UI code, plus SEO/AEO/GEO tools (per-page meta, woven JSON-LD, audits).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,8 @@
32
32
  "./data/theme.json": "./data/theme.json",
33
33
  "./data/design-rules.json": "./data/design-rules.json",
34
34
  "./data/seo-guide.json": "./data/seo-guide.json",
35
+ "./data/layouts.json": "./data/layouts.json",
36
+ "./data/variants.json": "./data/variants.json",
35
37
  "./package.json": "./package.json"
36
38
  },
37
39
  "files": [
package/src/load-data.mjs CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  // Disk loading is for Node, Bun, and Deno. Edge callers should pass imported
6
- // data to createServer({ registry, theme, aiDesignRules, seoGuide, layouts }).
6
+ // data to createServer({ registry, theme, aiDesignRules, seoGuide, layouts, variants }).
7
7
  export function loadDataFromDisk() {
8
8
  const directory = path.dirname(fileURLToPath(import.meta.url));
9
9
  const dataDirectory = path.resolve(directory, "..", "data");
@@ -16,5 +16,6 @@ export function loadDataFromDisk() {
16
16
  aiDesignRules: readJson("design-rules.json"),
17
17
  seoGuide: readJson("seo-guide.json"),
18
18
  layouts: readJson("layouts.json"),
19
+ variants: readJson("variants.json"),
19
20
  };
20
21
  }
@@ -3,6 +3,7 @@ import { FRAMEWORKS } from "../../seo.mjs";
3
3
  import { findNeuctraUiComponents } from "../get-neuctra-ui-component/index.mjs";
4
4
  import { getDesignRulesContext } from "../get-design-rules/index.mjs";
5
5
  import { buildSeoGuideContext } from "../neuctra-ui-seo-setup/index.mjs";
6
+ import { selectDesignVariants } from "./variants.mjs";
6
7
 
7
8
  function getLayouts(layoutData) {
8
9
  const grouped = (layoutData?.layoutGroups ?? []).flatMap((group) =>
@@ -57,6 +58,16 @@ function resolveLayout(request, requestedType, layoutData) {
57
58
  };
58
59
  }
59
60
 
61
+ const APP_MOTION_CATEGORIES = new Set(["saas-app", "forms", "tools"]);
62
+ const APP_MOTION_LAYOUTS = new Set(["checkout", "shopping-cart", "order-confirmation", "documentation"]);
63
+
64
+ function getMotionGuidance(motion, layout) {
65
+ if (!motion) return null;
66
+ const { byPageKind, ...guide } = motion;
67
+ const pageKind = APP_MOTION_CATEGORIES.has(layout.category) || APP_MOTION_LAYOUTS.has(layout.id) ? "app" : "marketing";
68
+ return { ...guide, pageKind, forThisPage: byPageKind?.[pageKind] };
69
+ }
70
+
60
71
  function detectTaskSignals(request) {
61
72
  return [
62
73
  ["create", /\b(create|build|make|new|design)\b/i],
@@ -89,7 +100,7 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
89
100
  {
90
101
  title: "Analyze and plan a Neuctra UI page",
91
102
  description:
92
- "Analyze a page request and choose from every layout in the grouped real-world catalog in data/layouts.json, including marketing, SaaS dashboards, commerce, publishing, support, portfolios, communities, events, and system pages. Use pageType=auto to select the closest specific layout from the request, or choose an explicit pageType. Returns the selected recipe, matching @neuctra/ui component specs, relevant design rules, and SEO guidance.",
103
+ "Analyze a page request and choose from every layout in the grouped real-world catalog in data/layouts.json, including marketing, SaaS dashboards, commerce, publishing, support, portfolios, communities, events, and system pages. Use pageType=auto to select the closest specific layout from the request, or choose an explicit pageType. Returns the selected recipe, context-matched design variants (navigation shell, page header, stat cards, dashboard grid), matching @neuctra/ui component specs, relevant design rules, Framer Motion animation presets, and SEO guidance.",
93
104
  inputSchema: {
94
105
  request: z.string().min(8).describe("Describe the page to create and the main user task."),
95
106
  pageType: z.enum(pageTypeOptions).default("auto").describe(`Layout ID from the full catalog (${layouts.map((layout) => layout.id).join(", ")}) or auto to infer the best match from the request.`),
@@ -100,6 +111,12 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
100
111
  primaryAction: z.string().optional(),
101
112
  platform: z.enum(["desktop", "mobile", "responsive"]).default("responsive"),
102
113
  audience: z.string().optional(),
114
+ variants: z.object({
115
+ navigationShell: z.string(),
116
+ pageHeader: z.string(),
117
+ statCard: z.string(),
118
+ dashboardBody: z.string(),
119
+ }).partial().optional().describe("Force a specific variant id per group (see designVariants in the response for ids). Use this to keep an existing app's shell consistent."),
103
120
  }).default({}).describe("Optional design and audience preferences."),
104
121
  },
105
122
  },
@@ -110,12 +127,15 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
110
127
  }
111
128
 
112
129
  const components = getComponentGuidance(data.registry, request, layout);
113
- const design = getDesignRulesContext(data.theme, data.aiDesignRules, layout.designRuleNumbers);
130
+ const designRuleNumbers = layout.designRuleNumbers ? [...new Set([...layout.designRuleNumbers, 57, 62])] : undefined;
131
+ const design = getDesignRulesContext(data.theme, data.aiDesignRules, designRuleNumbers);
132
+ const motion = getMotionGuidance(data.aiDesignRules.motion, layout);
114
133
  const seo = buildSeoGuideContext(data.seoGuide, {
115
134
  framework,
116
135
  ruleIds: layout.seoRuleIds,
117
136
  });
118
137
  const signals = detectTaskSignals(request);
138
+ const designVariants = selectDesignVariants(data.variants, { request, layoutId: layout.id, preferences });
119
139
  const preferencesToClarify = [];
120
140
  if (!preferences.visualDirection) preferencesToClarify.push("visual direction");
121
141
  if (!preferences.density) preferencesToClarify.push("content density");
@@ -146,7 +166,9 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
146
166
  matches: components,
147
167
  instruction: "Use these exact exports and documented props. If a required component is absent from the matches, call get-neuctra-ui-component for that need before inventing a pattern.",
148
168
  },
169
+ ...(designVariants && { designVariants }),
149
170
  designRules: design,
171
+ ...(motion && { motion }),
150
172
  seoGuidance: seo ? {
151
173
  framework: seo.framework,
152
174
  relevantRules: seo.seoRules,
@@ -156,7 +178,9 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
156
178
  implementationInstructions: [
157
179
  "Use the selected layout as a starting structure and adapt its sections to the exact request.",
158
180
  "Keep the primary task visually clear; do not add sections that do not serve the request.",
181
+ ...(designVariants ? ["Build the navigation shell, page header, stat cards and dashboard grid from designVariants.selections. Use the recommended variant unless an alternative clearly fits the brief better; if the project already has an app shell, keep that instead."] : []),
159
182
  "Use Neuctra UI components with the exact props and semantic theme tokens from the supplied context.",
183
+ ...(motion ? [`Add medium-level motion with Framer Motion using the motion presets: ${motion.forThisPage} Respect reduced motion and keep brand effects (gradients, glows, blur, animated backgrounds) to theme tokens only.`] : []),
160
184
  "Apply the SEO steps only when the page is public and indexable; protect private application pages.",
161
185
  "When framework is auto, inspect the project files and choose metadata and rendering APIs for the installed framework and router.",
162
186
  ],
@@ -0,0 +1,102 @@
1
+ // Scores the design variants in data/variants.json against a page request so
2
+ // the page builder recommends a context-appropriate navigation shell, header,
3
+ // stat card and dashboard grid instead of the same default every time.
4
+
5
+ const normalize = (text) => ` ${text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `;
6
+
7
+ // Small deterministic tie-breaker (0..1) so equally-scored variants rotate
8
+ // with the request text instead of always resolving to the first entry.
9
+ function tieBreak(seed) {
10
+ let hash = 2166136261;
11
+ for (let i = 0; i < seed.length; i += 1) {
12
+ hash ^= seed.charCodeAt(i);
13
+ hash = Math.imul(hash, 16777619);
14
+ }
15
+ return ((hash >>> 0) % 1000) / 1000;
16
+ }
17
+
18
+ function resolvePlatform(request, platform) {
19
+ if (platform !== "responsive") return platform;
20
+ return /\bmobile[ -]?(app|first|only)\b|\bpwa\b/i.test(request) ? "mobile" : platform;
21
+ }
22
+
23
+ function scoreVariant(variant, { text, layoutId, density, platform, seed }) {
24
+ let score = 0;
25
+ const reasons = [];
26
+ for (const signal of variant.signals ?? []) {
27
+ const phrase = normalize(signal);
28
+ if (phrase.trim() && text.includes(phrase)) {
29
+ score += phrase.trim().includes(" ") ? 4 : 3;
30
+ reasons.push(`request mentions "${signal}"`);
31
+ }
32
+ }
33
+ if (variant.layouts?.includes(layoutId)) {
34
+ score += 3;
35
+ reasons.push(`suits the ${layoutId} layout`);
36
+ }
37
+ if (density) {
38
+ if (variant.density?.includes(density)) {
39
+ score += 2;
40
+ reasons.push(`fits ${density} density`);
41
+ } else {
42
+ score -= 1;
43
+ }
44
+ }
45
+ const platforms = variant.platforms ?? [];
46
+ if (platform === "mobile") {
47
+ if (platforms.includes("mobile")) {
48
+ score += 4;
49
+ reasons.push("works on mobile-first products");
50
+ } else {
51
+ score -= 3;
52
+ }
53
+ } else if (!platforms.includes(platform) && !platforms.includes("responsive")) {
54
+ score -= 10;
55
+ }
56
+ return { score: score + tieBreak(`${seed}:${variant.id}`), reasons };
57
+ }
58
+
59
+ const summarize = ({ id, name, bestFor, structure }) => ({ id, name, bestFor, structure });
60
+
61
+ export function selectDesignVariants(variantData, { request, layoutId, preferences = {} }) {
62
+ const groups = variantData?.groups ?? {};
63
+ const text = normalize(request);
64
+ const platform = resolvePlatform(request, preferences.platform ?? "responsive");
65
+ const overrides = preferences.variants ?? {};
66
+ const selections = {};
67
+
68
+ for (const [groupId, group] of Object.entries(groups)) {
69
+ if (!group.appliesTo?.includes(layoutId)) continue;
70
+ const ranked = (group.variants ?? [])
71
+ .map((variant) => ({
72
+ variant,
73
+ ...scoreVariant(variant, { text, layoutId, density: preferences.density, platform, seed: `${request}:${groupId}` }),
74
+ }))
75
+ .sort((a, b) => b.score - a.score);
76
+
77
+ const override = overrides[groupId] && ranked.find(({ variant }) => variant.id === overrides[groupId]);
78
+ const chosen = override ?? ranked[0];
79
+ if (!chosen) continue;
80
+
81
+ selections[groupId] = {
82
+ title: group.title,
83
+ recommended: {
84
+ ...chosen.variant,
85
+ why: override
86
+ ? ["explicitly requested in preferences.variants"]
87
+ : chosen.reasons.length
88
+ ? chosen.reasons
89
+ : ["no strong context signal; chosen to avoid repeating the default, confirm it fits the brief"],
90
+ },
91
+ alternatives: ranked.filter((entry) => entry !== chosen).slice(0, 2).map(({ variant }) => summarize(variant)),
92
+ otherOptions: ranked.filter((entry) => entry !== chosen).slice(2).map(({ variant }) => variant.id),
93
+ };
94
+ }
95
+
96
+ if (!Object.keys(selections).length) return null;
97
+ return {
98
+ principles: variantData.principles ?? [],
99
+ effectivePlatform: platform,
100
+ selections,
101
+ };
102
+ }