@stevegreco/design-system 0.0.3 → 0.0.4

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.
Files changed (2) hide show
  1. package/mcp/index.js +376 -0
  2. package/package.json +6 -2
package/mcp/index.js ADDED
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { readFileSync, statSync, readdirSync } from "fs";
6
+ import { join, dirname } from "path";
7
+ import { fileURLToPath } from "url";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const ROOT = join(__dirname, "..");
11
+
12
+ // Maps CSS properties to the token type and path hints used for scoring.
13
+ // hints are substrings matched against token paths (case-insensitive).
14
+ const CSS_PROPERTY_MAP = {
15
+ "background-color": { type: "color", hints: ["bg", "background"] },
16
+ "background": { type: "color", hints: ["bg", "background"] },
17
+ "color": { type: "color", hints: ["fg", "foreground"] },
18
+ "border-color": { type: "color", hints: ["border"] },
19
+ "outline-color": { type: "color", hints: ["border", "focus"] },
20
+ "fill": { type: "color", hints: ["fg", "foreground"] },
21
+ "stroke": { type: "color", hints: ["border", "fg"] },
22
+ "caret-color": { type: "color", hints: ["fg", "accent"] },
23
+ "border-radius": { type: "dimension", hints: ["radius"] },
24
+ "border-width": { type: "dimension", hints: ["border"] },
25
+ "padding": { type: "dimension", hints: ["padding"] },
26
+ "padding-top": { type: "dimension", hints: ["paddingY", "padding"] },
27
+ "padding-bottom": { type: "dimension", hints: ["paddingY", "padding"] },
28
+ "padding-left": { type: "dimension", hints: ["paddingX", "padding"] },
29
+ "padding-right": { type: "dimension", hints: ["paddingX", "padding"] },
30
+ "gap": { type: "dimension", hints: ["gap", "space"] },
31
+ "margin": { type: "dimension", hints: ["space"] },
32
+ "font-size": { type: "dimension", hints: ["fontSize", "text"] },
33
+ "letter-spacing": { type: "dimension", hints: ["tracking"] },
34
+ "line-height": { type: "number", hints: ["leading"] },
35
+ "font-family": { type: "fontFamily", hints: ["fontFamily", "font"] },
36
+ "font-weight": { type: "fontWeight", hints: ["fontWeight", "weight"] },
37
+ "box-shadow": { type: "shadow", hints: ["shadow"] },
38
+ "transition-duration": { type: "duration", hints: ["duration", "transition"] },
39
+ "animation-duration": { type: "duration", hints: ["duration"] },
40
+ "transition-timing-function":{ type: "cubicBezier", hints: ["ease"] },
41
+ "width": { type: "dimension", hints: ["layout", "space"] },
42
+ "max-width": { type: "dimension", hints: ["layout"] },
43
+ };
44
+
45
+ const TIER_BONUS = { component: 0.3, semantic: 0.2, primitive: 0.1 };
46
+
47
+ const DIST = {
48
+ primitives: join(ROOT, "dist/tokens/primitives.json"),
49
+ semanticLight: join(ROOT, "dist/tokens/semantic.light.json"),
50
+ semanticDark: join(ROOT, "dist/tokens/semantic.dark.json"),
51
+ components: join(ROOT, "dist/tokens/components.json"),
52
+ };
53
+
54
+ function sourceFiles() {
55
+ const base = [
56
+ join(ROOT, "tokens/primitives.tokens.json"),
57
+ join(ROOT, "tokens/semantic/light.tokens.json"),
58
+ join(ROOT, "tokens/semantic/dark.tokens.json"),
59
+ ];
60
+ try {
61
+ const components = readdirSync(join(ROOT, "tokens/components"))
62
+ .filter((f) => f.endsWith(".tokens.json"))
63
+ .map((f) => join(ROOT, "tokens/components", f));
64
+ return [...base, ...components];
65
+ } catch {
66
+ return base;
67
+ }
68
+ }
69
+
70
+ function isStale() {
71
+ try {
72
+ const srcMax = Math.max(
73
+ ...sourceFiles().map((f) => {
74
+ try { return statSync(f).mtimeMs; } catch { return 0; }
75
+ })
76
+ );
77
+ const distMin = Math.min(
78
+ ...Object.values(DIST).map((f) => {
79
+ try { return statSync(f).mtimeMs; } catch { return 0; }
80
+ })
81
+ );
82
+ return srcMax > distMin;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function inferType(value) {
89
+ if (typeof value === "number") return "fontWeight";
90
+ const v = String(value);
91
+ if (/^#[0-9a-f]{3,8}$/i.test(v) || /^(rgb|hsl)a?\(/.test(v)) return "color";
92
+ if (/^-?\d+(\.\d+)?(px|rem|em|%)$/.test(v)) return "dimension";
93
+ if (/^\d+ms$/.test(v)) return "duration";
94
+ if (/^cubic-bezier/.test(v)) return "cubicBezier";
95
+ // shadows: multi-part strings containing px and spaces, or inset keyword
96
+ if (/^inset /.test(v) || (v.includes(" ") && v.includes("px"))) return "shadow";
97
+ // font families: quoted strings or comma-separated lists (not function calls)
98
+ if (v.includes("'") || v.includes('"') || (v.includes(",") && !v.includes("("))) return "fontFamily";
99
+ if (/^-?\d+(\.\d+)?$/.test(v)) return "number";
100
+ return "string";
101
+ }
102
+
103
+ function flatten(obj, parts = []) {
104
+ const tokens = [];
105
+ for (const [key, val] of Object.entries(obj)) {
106
+ const path = [...parts, key];
107
+ if (val !== null && typeof val === "object" && !Array.isArray(val)) {
108
+ tokens.push(...flatten(val, path));
109
+ } else {
110
+ const root = path[0];
111
+ tokens.push({
112
+ path: path.join("."),
113
+ cssVariable: "--" + path.join("-"),
114
+ value: String(val),
115
+ type: inferType(val),
116
+ tier:
117
+ root === "primitives" ? "primitive"
118
+ : root === "semantic" ? "semantic"
119
+ : "component",
120
+ });
121
+ }
122
+ }
123
+ return tokens;
124
+ }
125
+
126
+ function loadTokens(theme = "light") {
127
+ const semanticFile = theme === "dark" ? DIST.semanticDark : DIST.semanticLight;
128
+ const tokens = [];
129
+ for (const file of [DIST.primitives, semanticFile, DIST.components]) {
130
+ try {
131
+ tokens.push(...flatten(JSON.parse(readFileSync(file, "utf-8"))));
132
+ } catch {
133
+ // dist not yet built
134
+ }
135
+ }
136
+ return tokens;
137
+ }
138
+
139
+ const server = new McpServer({
140
+ name: "design-system-tokens",
141
+ version: "1.0.0",
142
+ });
143
+
144
+ server.registerTool(
145
+ "search_tokens",
146
+ {
147
+ description:
148
+ "Search design tokens by keyword. Matches against token paths and resolved values. " +
149
+ "Returns path, CSS variable name, resolved value, type, and tier for each result.",
150
+ inputSchema: z.object({
151
+ query: z.string().describe(
152
+ "Substring to search for in token paths and resolved values (e.g. 'danger', 'border', '#b8472a')"
153
+ ),
154
+ tier: z
155
+ .enum(["primitive", "semantic", "component"])
156
+ .optional()
157
+ .describe("Restrict results to a single tier"),
158
+ type: z
159
+ .string()
160
+ .optional()
161
+ .describe("Restrict results to a token type (e.g. color, dimension, fontFamily, duration)"),
162
+ theme: z
163
+ .enum(["light", "dark"])
164
+ .optional()
165
+ .describe("Theme variant for semantic tokens. Defaults to light."),
166
+ }),
167
+ },
168
+ async ({ query, tier, type, theme = "light" }) => {
169
+ const tokens = loadTokens(theme);
170
+ const q = query.toLowerCase();
171
+
172
+ let results = tokens.filter(
173
+ (t) => t.path.toLowerCase().includes(q) || t.value.toLowerCase().includes(q)
174
+ );
175
+ if (tier) results = results.filter((t) => t.tier === tier);
176
+ if (type) results = results.filter((t) => t.type === type);
177
+
178
+ const stale = isStale() ? "\n\n⚠ Token files may be out of date — run pnpm build" : "";
179
+
180
+ if (results.length === 0) {
181
+ return {
182
+ content: [{ type: "text", text: `No tokens found matching "${query}".${stale}` }],
183
+ };
184
+ }
185
+ return {
186
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) + stale }],
187
+ };
188
+ }
189
+ );
190
+
191
+ server.registerTool(
192
+ "get_token",
193
+ {
194
+ description:
195
+ "Get a single design token by its exact dot-separated path. " +
196
+ "Returns path, CSS variable name, resolved value, type, and tier.",
197
+ inputSchema: z.object({
198
+ path: z
199
+ .string()
200
+ .describe("Exact token path (e.g. component.button.primary.bg)"),
201
+ theme: z
202
+ .enum(["light", "dark"])
203
+ .optional()
204
+ .describe("Theme variant for semantic tokens. Defaults to light."),
205
+ }),
206
+ },
207
+ async ({ path, theme = "light" }) => {
208
+ const tokens = loadTokens(theme);
209
+ const token = tokens.find((t) => t.path === path);
210
+ const stale = isStale() ? "\n\n⚠ Token files may be out of date — run pnpm build" : "";
211
+
212
+ if (!token) {
213
+ return {
214
+ content: [{ type: "text", text: `Token not found: "${path}".${stale}` }],
215
+ isError: true,
216
+ };
217
+ }
218
+ return {
219
+ content: [{ type: "text", text: JSON.stringify(token, null, 2) + stale }],
220
+ };
221
+ }
222
+ );
223
+
224
+ server.registerTool(
225
+ "suggest_token",
226
+ {
227
+ description:
228
+ "Suggest the best design token for a given CSS property and usage context. " +
229
+ "Returns up to 5 ranked suggestions, preferring component tokens over semantic and semantic over primitive.",
230
+ inputSchema: z.object({
231
+ property: z.string().describe(
232
+ "CSS property name (e.g. 'background-color', 'border-radius', 'font-size', 'box-shadow')"
233
+ ),
234
+ context: z.string().optional().describe(
235
+ "Plain-English usage context to narrow results (e.g. 'danger button', 'input focus state', 'card background')"
236
+ ),
237
+ theme: z.enum(["light", "dark"]).optional().describe(
238
+ "Theme variant for semantic tokens. Defaults to light."
239
+ ),
240
+ }),
241
+ },
242
+ async ({ property, context = "", theme = "light" }) => {
243
+ const tokens = loadTokens(theme);
244
+ const stale = isStale() ? "\n\n⚠ Token files may be out of date — run pnpm build" : "";
245
+ const hint = CSS_PROPERTY_MAP[property.toLowerCase()];
246
+ const contextKeywords = context.toLowerCase().split(/\W+/).filter(Boolean);
247
+
248
+ const scored = tokens
249
+ .map((token) => {
250
+ const pathLower = token.path.toLowerCase();
251
+ let score = 0;
252
+
253
+ // Hard filter by token type when the CSS property has a known mapping
254
+ if (hint?.type && token.type !== hint.type) return null;
255
+
256
+ // Score by how well the path matches the CSS property's expected path hints
257
+ for (const h of hint?.hints ?? []) {
258
+ if (pathLower.includes(h.toLowerCase())) score += 2;
259
+ }
260
+
261
+ // Score by context keyword overlap
262
+ for (const kw of contextKeywords) {
263
+ if (pathLower.includes(kw)) score += 1;
264
+ }
265
+
266
+ // If unknown CSS property, fall back to matching the property name itself
267
+ if (!hint) {
268
+ const propKeywords = property.toLowerCase().split(/[-\s]+/);
269
+ for (const kw of propKeywords) {
270
+ if (pathLower.includes(kw)) score += 1;
271
+ }
272
+ }
273
+
274
+ if (score === 0) return null;
275
+
276
+ return { score: score + TIER_BONUS[token.tier], token };
277
+ })
278
+ .filter(Boolean)
279
+ .sort((a, b) => b.score - a.score)
280
+ .slice(0, 5)
281
+ .map(({ token }) => token);
282
+
283
+ if (scored.length === 0) {
284
+ const msg = hint
285
+ ? `No tokens found for "${property}"${context ? ` in context "${context}"` : ""}.`
286
+ : `Unknown CSS property "${property}". Try a standard property like "background-color", "border-radius", or "font-size".`;
287
+ return { content: [{ type: "text", text: msg + stale }] };
288
+ }
289
+
290
+ return {
291
+ content: [{ type: "text", text: JSON.stringify(scored, null, 2) + stale }],
292
+ };
293
+ }
294
+ );
295
+
296
+ // --- Resources ---
297
+
298
+ server.registerResource(
299
+ "primitives",
300
+ "tokens://primitives",
301
+ {
302
+ title: "Primitive Tokens",
303
+ description: "Raw scale-based values: colors, spacing, typography, radius, shadow, motion.",
304
+ mimeType: "application/json",
305
+ },
306
+ async (uri) => ({
307
+ contents: [{ uri: uri.href, text: readFileSync(DIST.primitives, "utf-8") }],
308
+ })
309
+ );
310
+
311
+ server.registerResource(
312
+ "components",
313
+ "tokens://components",
314
+ {
315
+ title: "Component Tokens",
316
+ description: "Component-scoped tokens for button, input, card, badge, and alert.",
317
+ mimeType: "application/json",
318
+ },
319
+ async (uri) => ({
320
+ contents: [{ uri: uri.href, text: readFileSync(DIST.components, "utf-8") }],
321
+ })
322
+ );
323
+
324
+ server.registerResource(
325
+ "semantic",
326
+ new ResourceTemplate("tokens://semantic/{theme}", {
327
+ list: async () => ({
328
+ resources: [
329
+ { uri: "tokens://semantic/light", name: "Semantic Tokens — Light" },
330
+ { uri: "tokens://semantic/dark", name: "Semantic Tokens — Dark" },
331
+ ],
332
+ }),
333
+ }),
334
+ {
335
+ title: "Semantic Tokens",
336
+ description: "Intent-named tokens for background, foreground, border, accent, and status. Pass 'light' or 'dark'.",
337
+ mimeType: "application/json",
338
+ },
339
+ async (uri, { theme }) => {
340
+ const file = theme === "dark" ? DIST.semanticDark : DIST.semanticLight;
341
+ return { contents: [{ uri: uri.href, text: readFileSync(file, "utf-8") }] };
342
+ }
343
+ );
344
+
345
+ // --- Prompts ---
346
+
347
+ server.registerPrompt(
348
+ "find-token",
349
+ {
350
+ title: "Find a Token",
351
+ description: "Describe what you need in plain English and get a suggestion for the right design token to use.",
352
+ argsSchema: {
353
+ description: z.string().describe(
354
+ "Plain-English description of what you need (e.g. 'a background color for a danger alert')"
355
+ ),
356
+ },
357
+ },
358
+ ({ description }) => ({
359
+ messages: [
360
+ {
361
+ role: "user",
362
+ content: {
363
+ type: "text",
364
+ text:
365
+ `I need a design token for: "${description}"\n\n` +
366
+ `Use the search_tokens tool to find the best match. ` +
367
+ `Prefer semantic or component tokens over primitives. ` +
368
+ `Reply with the token path, its CSS variable name, and the resolved value.`,
369
+ },
370
+ },
371
+ ],
372
+ })
373
+ );
374
+
375
+ const transport = new StdioServerTransport();
376
+ await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stevegreco/design-system",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Design tokens distributed as CSS and JSON artifacts.",
5
5
  "type": "module",
6
6
  "main": "./dist/tokens/primitives.json",
@@ -13,8 +13,12 @@
13
13
  "./tokens/semantic-dark": "./dist/tokens/semantic.dark.json",
14
14
  "./tokens/components": "./dist/tokens/components.json"
15
15
  },
16
+ "bin": {
17
+ "design-system-mcp": "mcp/index.js"
18
+ },
16
19
  "files": [
17
- "dist"
20
+ "dist",
21
+ "mcp"
18
22
  ],
19
23
  "keywords": [
20
24
  "design-system",