@neuctra/ui-mcp 0.4.7 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuctra/ui-mcp",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
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) =>
@@ -89,7 +90,7 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
89
90
  {
90
91
  title: "Analyze and plan a Neuctra UI page",
91
92
  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.",
93
+ "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, and SEO guidance.",
93
94
  inputSchema: {
94
95
  request: z.string().min(8).describe("Describe the page to create and the main user task."),
95
96
  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 +101,12 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
100
101
  primaryAction: z.string().optional(),
101
102
  platform: z.enum(["desktop", "mobile", "responsive"]).default("responsive"),
102
103
  audience: z.string().optional(),
104
+ variants: z.object({
105
+ navigationShell: z.string(),
106
+ pageHeader: z.string(),
107
+ statCard: z.string(),
108
+ dashboardBody: z.string(),
109
+ }).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
110
  }).default({}).describe("Optional design and audience preferences."),
104
111
  },
105
112
  },
@@ -116,6 +123,7 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
116
123
  ruleIds: layout.seoRuleIds,
117
124
  });
118
125
  const signals = detectTaskSignals(request);
126
+ const designVariants = selectDesignVariants(data.variants, { request, layoutId: layout.id, preferences });
119
127
  const preferencesToClarify = [];
120
128
  if (!preferences.visualDirection) preferencesToClarify.push("visual direction");
121
129
  if (!preferences.density) preferencesToClarify.push("content density");
@@ -146,6 +154,7 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
146
154
  matches: components,
147
155
  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
156
  },
157
+ ...(designVariants && { designVariants }),
149
158
  designRules: design,
150
159
  seoGuidance: seo ? {
151
160
  framework: seo.framework,
@@ -156,6 +165,7 @@ export function registerNeuctraUiPageBuilderTool(server, data) {
156
165
  implementationInstructions: [
157
166
  "Use the selected layout as a starting structure and adapt its sections to the exact request.",
158
167
  "Keep the primary task visually clear; do not add sections that do not serve the request.",
168
+ ...(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
169
  "Use Neuctra UI components with the exact props and semantic theme tokens from the supplied context.",
160
170
  "Apply the SEO steps only when the page is public and indexable; protect private application pages.",
161
171
  "When framework is auto, inspect the project files and choose metadata and rendering APIs for the installed framework and router.",
@@ -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
+ }