synthesisui 0.16.7 → 0.16.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.
@@ -76,7 +76,7 @@ export async function* walkAll(roots) {
76
76
  * and the recipes `add` put next to them in `design-system.json`. Those
77
77
  * recipes are why the component pass can exist at all - a linter has no idea
78
78
  * what `ds-button` promised. */
79
- async function loadSystem(root) {
79
+ export async function loadSystem(root) {
80
80
  const dsDir = join(root, "_synthesisui", "ds");
81
81
  let slugs;
82
82
  try {
@@ -409,7 +409,12 @@ export async function doctor(opts) {
409
409
  }
410
410
  }
411
411
  else if (asideTotal > 0) {
412
- console.log(body(`set aside ${plural(asideTotal, "value")} a token could never hold (--verbose for why)`));
412
+ // "a token could never hold" was written when every aside was an image
413
+ // renderer or an SVG paint. A literal sitting in a token's OWN fallback -
414
+ // `var(--ds-color-semantic-knob, #ffffff)` - is set aside for the opposite
415
+ // reason: it is already tokenized. The summary said the false half out
416
+ // loud and hid the true half behind a flag.
417
+ console.log(body(`set aside ${plural(asideTotal, "value")} that are not drift (--verbose for why)`));
413
418
  }
414
419
  // 0 of 0 is not a perfect score, it is an empty measurement - printing a
415
420
  // full bar there would be the report's first lie.
@@ -0,0 +1,250 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { relative, resolve } from "node:path";
3
+ import { diagnose, scanSource } from "../doctor/scan.js";
4
+ import { nearestToken, tokenFor } from "../doctor/tokens.js";
5
+ import { component } from "./component.js";
6
+ import { loadSystem, walkAll } from "./doctor.js";
7
+ const send = (msg) => {
8
+ process.stdout.write(`${JSON.stringify(msg)}\n`);
9
+ };
10
+ /** A tool result is always text: readable by the model, and by a person
11
+ * watching the transcript when something goes wrong. */
12
+ const text = (body, isError = false) => ({
13
+ content: [{ type: "text", text: body }],
14
+ ...(isError ? { isError: true } : null),
15
+ });
16
+ /**
17
+ * Everything a command prints, captured.
18
+ *
19
+ * `component()` announces what it fetched, and that line would land in the
20
+ * middle of a JSON-RPC frame. Capturing is not just protection: the output is
21
+ * exactly what the agent needs to read back, so it becomes the result.
22
+ */
23
+ async function capturing(run) {
24
+ const lines = [];
25
+ const real = console.log;
26
+ console.log = (...args) => void lines.push(args.join(" "));
27
+ try {
28
+ const value = await run();
29
+ return { value, out: lines.join("\n") };
30
+ }
31
+ finally {
32
+ console.log = real;
33
+ }
34
+ }
35
+ // ── The tools ────────────────────────────────────────────────────────────────
36
+ const TOOLS = [
37
+ {
38
+ name: "check_file",
39
+ description: "Check one file (or folder) against the installed design system. Returns token coverage, every hardcoded value with the token this project already has for it, and any --ds- name the system does not declare. Run this after writing or editing any UI file, before moving on.",
40
+ inputSchema: {
41
+ type: "object",
42
+ properties: {
43
+ path: {
44
+ type: "string",
45
+ description: "File or folder, relative to the project root.",
46
+ },
47
+ },
48
+ required: ["path"],
49
+ },
50
+ },
51
+ {
52
+ name: "find_token",
53
+ description: "Ask what this design system calls a value. Give it a colour, spacing or radius exactly as you would write it (#2563eb, 12px, 0.75rem) and it answers with the token name, or says no token holds it - in which case do NOT invent one.",
54
+ inputSchema: {
55
+ type: "object",
56
+ properties: {
57
+ value: { type: "string", description: "e.g. #2563eb, 12px, 1rem" },
58
+ },
59
+ required: ["value"],
60
+ },
61
+ },
62
+ {
63
+ name: "list_components",
64
+ description: "The components this design system defines, with what each is for. Look here BEFORE writing any UI element from scratch - if something covers the purpose, materialize it with add_component instead.",
65
+ inputSchema: { type: "object", properties: {} },
66
+ },
67
+ {
68
+ name: "add_component",
69
+ description: "Materialize a component from the design system as real typed code in this project, ready to import and extend. Use it yourself - the person who asked for a feature should never have to know component names.",
70
+ inputSchema: {
71
+ type: "object",
72
+ properties: {
73
+ name: {
74
+ type: "string",
75
+ description: "Component name from list_components.",
76
+ },
77
+ },
78
+ required: ["name"],
79
+ },
80
+ },
81
+ ];
82
+ async function checkFile(root, path) {
83
+ const { table } = await loadSystem(root);
84
+ if (table.byName.size === 0)
85
+ return "No design system installed here, so there is nothing to check against. Run `synthesisui init` or `synthesisui adopt`.";
86
+ const scope = resolve(root, path);
87
+ const reports = [];
88
+ for await (const file of walkAll([scope])) {
89
+ const src = await readFile(file, "utf8").catch(() => "");
90
+ if (src)
91
+ reports.push(scanSource(relative(root, file), src, table));
92
+ }
93
+ if (reports.length === 0)
94
+ return `Nothing readable at ${path}.`;
95
+ const d = diagnose(reports);
96
+ const out = [
97
+ `${table.name ?? table.slug}: ${d.coverage}% of design values come from the system.`,
98
+ `${d.tokenUses} from the system, ${d.findings.length} written by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}.`,
99
+ ];
100
+ if (d.findings.length > 0) {
101
+ out.push("", "Written by hand:");
102
+ for (const f of d.findings.slice(0, 40)) {
103
+ out.push(f.token
104
+ ? ` ${f.file}:${f.line} ${f.literal} - this system calls it ${f.token}`
105
+ : ` ${f.file}:${f.line} ${f.literal} - no token holds this value`);
106
+ }
107
+ out.push("", "Replace the ones that have a token. For a value with none, do NOT invent a token: say which value it is and what you would call it, and let a person decide.");
108
+ }
109
+ const phantoms = d.files.flatMap((f) => (f.phantoms ?? []).map((p) => ` ${f.file}:${p.line} ${p.name}`));
110
+ if (phantoms.length > 0) {
111
+ out.push("", "Names this system does not declare. These look tokenized and apply nothing at all:", ...phantoms);
112
+ }
113
+ if (d.findings.length === 0 && phantoms.length === 0)
114
+ out.push("", "Nothing to fix here.");
115
+ return out.join("\n");
116
+ }
117
+ async function findToken(root, value) {
118
+ const { table } = await loadSystem(root);
119
+ if (table.byName.size === 0)
120
+ return "No design system installed here.";
121
+ const exact = tokenFor(table, value);
122
+ if (exact)
123
+ return `${value} is ${exact} in this system. Use var(${exact}).`;
124
+ const near = nearestToken(table, value);
125
+ if (near)
126
+ return `No token holds ${value}. The closest is ${near.name} at ${near.value}. If that is what you meant, use it - if it genuinely is not, say so rather than inventing a token.`;
127
+ // Same words the managed block uses. A refusal is only useful if it is the
128
+ // same refusal every time.
129
+ return `No token in this system holds ${value}, and nothing is close. Do NOT invent one. Say which value you need and what you would call it, and let a person decide.`;
130
+ }
131
+ async function listComponents(root) {
132
+ const { documents } = await loadSystem(root);
133
+ const rows = [];
134
+ for (const doc of documents) {
135
+ const comps = doc.components;
136
+ for (const [name, recipe] of Object.entries(comps ?? {}))
137
+ rows.push(` ${name}${recipe?.description ? ` - ${recipe.description}` : ""}`);
138
+ }
139
+ if (rows.length === 0)
140
+ return "This system defines no components yet - write what you need with its tokens.";
141
+ return [
142
+ `${rows.length} components. Materialize with add_component before writing one from scratch:`,
143
+ "",
144
+ ...rows.sort(),
145
+ ].join("\n");
146
+ }
147
+ async function addComponent(root, name) {
148
+ const { table } = await loadSystem(root);
149
+ if (!table.slug)
150
+ return "No design system installed here.";
151
+ const { out } = await capturing(() => component(table.slug, name, { dir: root }));
152
+ return `${out}\n\nIt is real code in this project now - import it and extend it rather than writing your own.`;
153
+ }
154
+ // ── The protocol ─────────────────────────────────────────────────────────────
155
+ async function callTool(root, name, args) {
156
+ switch (name) {
157
+ case "check_file":
158
+ return text(await checkFile(root, String(args.path ?? ".")));
159
+ case "find_token":
160
+ return text(await findToken(root, String(args.value ?? "")));
161
+ case "list_components":
162
+ return text(await listComponents(root));
163
+ case "add_component":
164
+ return text(await addComponent(root, String(args.name ?? "")));
165
+ default:
166
+ return text(`No tool named ${name}.`, true);
167
+ }
168
+ }
169
+ /**
170
+ * One request in, one response out - or null for a notification, which MUST
171
+ * NOT be answered. Replying to `notifications/initialized` is the classic way
172
+ * to break a handshake, and it is the kind of thing only a test catches.
173
+ *
174
+ * Split out from the stdin loop so the protocol can be exercised without a
175
+ * pipe: the loop below is framing, this is the protocol.
176
+ */
177
+ export async function handleRequest(root, req) {
178
+ const id = req.id;
179
+ if (id === undefined)
180
+ return null;
181
+ try {
182
+ switch (req.method) {
183
+ case "initialize":
184
+ return {
185
+ jsonrpc: "2.0",
186
+ id,
187
+ result: {
188
+ // Echo the client's version rather than asserting our own: this
189
+ // server speaks a subset every revision of the protocol has had,
190
+ // and refusing a client over a date string helps nobody.
191
+ protocolVersion: req.params?.protocolVersion ?? "2025-06-18",
192
+ capabilities: { tools: {} },
193
+ serverInfo: { name: "synthesisui", version: VERSION },
194
+ },
195
+ };
196
+ case "ping":
197
+ return { jsonrpc: "2.0", id, result: {} };
198
+ case "tools/list":
199
+ return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
200
+ case "tools/call": {
201
+ const name = String(req.params?.name ?? "");
202
+ const args = (req.params?.arguments ?? {});
203
+ return { jsonrpc: "2.0", id, result: await callTool(root, name, args) };
204
+ }
205
+ default:
206
+ return {
207
+ jsonrpc: "2.0",
208
+ id,
209
+ error: { code: -32601, message: `Method not found: ${req.method}` },
210
+ };
211
+ }
212
+ }
213
+ catch (err) {
214
+ // A tool that throws must not take the session with it.
215
+ return {
216
+ jsonrpc: "2.0",
217
+ id,
218
+ result: text(err instanceof Error ? err.message : String(err), true),
219
+ };
220
+ }
221
+ }
222
+ export async function mcp(opts) {
223
+ const root = resolve(opts.dir ?? process.cwd());
224
+ let buffer = "";
225
+ process.stdin.setEncoding("utf8");
226
+ for await (const chunk of process.stdin) {
227
+ buffer += chunk;
228
+ // Newline-delimited JSON. A frame can arrive split across chunks, and two
229
+ // can arrive in one.
230
+ let nl = buffer.indexOf("\n");
231
+ while (nl !== -1) {
232
+ const line = buffer.slice(0, nl).trim();
233
+ buffer = buffer.slice(nl + 1);
234
+ nl = buffer.indexOf("\n");
235
+ if (!line)
236
+ continue;
237
+ let req;
238
+ try {
239
+ req = JSON.parse(line);
240
+ }
241
+ catch {
242
+ continue; // Unparseable frame with no id: nobody to answer.
243
+ }
244
+ const res = await handleRequest(root, req);
245
+ if (res)
246
+ send(res);
247
+ }
248
+ }
249
+ }
250
+ const VERSION = "0.16.9";
@@ -32,11 +32,36 @@ function elementFor(name, recipe) {
32
32
  ? { tag: "button", attrs: ' type="button"' }
33
33
  : undefined);
34
34
  const tag = hit?.tag ?? "div";
35
- return {
36
- tag,
37
- attrs: hit?.attrs ?? "",
38
- voidEl: tag === "input" || tag === "hr",
39
- };
35
+ const attrs = hit?.attrs ?? "";
36
+ const isVoid = tag === "input" || tag === "hr";
37
+ /**
38
+ * A VOID ELEMENT CANNOT HOST THE PARTS WE TELL PEOPLE TO PUT INSIDE IT.
39
+ *
40
+ * `switch` resolved to `<input type="checkbox">`, and the recipe gives it a
41
+ * `thumb` part - so the generated file documented `<Switch><SwitchThumb/>
42
+ * </Switch>`, which React refuses at runtime: input is void and must not
43
+ * have children. Found by an agent building a settings page against it
44
+ * (my-test4, 27/07). The documented usage was impossible.
45
+ *
46
+ * The recipe was right and the tag was wrong. Its own CSS styles `.ds-switch`
47
+ * as an `inline-flex` track with a 16px knob inside a 24px rail - that is a
48
+ * container, described as one, and only the element choice disagreed.
49
+ *
50
+ * `<button role="switch">` is the accessible pattern for exactly this: it
51
+ * takes children, it is focusable and operable by keyboard for free, and the
52
+ * caller supplies `aria-checked`. Promotion happens ONLY when parts exist, so
53
+ * a plain input stays an input.
54
+ */
55
+ if (isVoid && Object.keys(recipe.parts ?? {}).length > 0) {
56
+ // Carry the ARIA role across - it is the part of the input's meaning that
57
+ // survives the tag change, and dropping it would trade a runtime error for
58
+ // a silent accessibility regression.
59
+ const role = / role="[a-z]+"/.exec(attrs)?.[0] ?? "";
60
+ return tag === "input"
61
+ ? { tag: "button", attrs: ` type="button"${role}`, voidEl: false }
62
+ : { tag: "div", attrs: role || ' role="separator"', voidEl: false };
63
+ }
64
+ return { tag, attrs, voidEl: isVoid };
40
65
  }
41
66
  /** Variant axes → typed props. An axis whose options ⊆ {true,false} is a
42
67
  * boolean prop; empty axes (no visual effect) are skipped. */
@@ -272,13 +297,44 @@ const STATE_PREFIX = {
272
297
  function blockToTailwind(block, prefix = "") {
273
298
  return Object.entries(block).flatMap(([prop, value]) => declToTailwind(prop, value).map((cls) => `${prefix}${cls}`));
274
299
  }
275
- function tailwindClassList(recipe) {
300
+ function tailwindClassList(recipe,
301
+ /** CSS properties a variant axis owns - see `variantOwnedProps`. */
302
+ exclude) {
303
+ const base = exclude
304
+ ? Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => !exclude.has(prop)))
305
+ : recipe.base;
276
306
  const classes = [
277
- ...blockToTailwind(recipe.base),
307
+ ...blockToTailwind(base),
308
+ // States keep everything: `hover:` and `disabled:` cannot collide with an
309
+ // unprefixed variant class, so there is nothing to resolve.
278
310
  ...Object.entries(recipe.states ?? {}).flatMap(([state, block]) => STATE_PREFIX[state] ? blockToTailwind(block, STATE_PREFIX[state]) : []),
279
311
  ];
280
312
  return classes.join(" ");
281
313
  }
314
+ /**
315
+ * A VARIANT CANNOT OVERRIDE THE BASE WHEN BOTH ARE PLAIN UTILITIES.
316
+ *
317
+ * `Card` emitted `p-md` in BASE and `[padding:0]` for `padding="none"`. Same
318
+ * specificity, so which one wins is decided by the order Tailwind emits them
319
+ * in the stylesheet - not by the order of the class names, which is what the
320
+ * code looks like it controls. The prop silently did nothing (found by an
321
+ * agent that worked around it in a comment rather than reporting it,
322
+ * my-test4, 27/07).
323
+ *
324
+ * CSS mode never had this: it selects on `[data-padding="none"]`, which
325
+ * outranks the base class honestly. Tailwind mode has no such ladder, so the
326
+ * conflict has to be resolved where it is created - at generation.
327
+ *
328
+ * The property leaves BASE and the base value becomes the axis's default, so
329
+ * exactly one class ever sets it.
330
+ */
331
+ function variantOwnedProps(variants, axis) {
332
+ const props = new Set();
333
+ for (const option of axis.options)
334
+ for (const prop of Object.keys(variants?.[axis.key]?.[option] ?? {}))
335
+ props.add(prop);
336
+ return [...props];
337
+ }
282
338
  // ── Emission ─────────────────────────────────────────────────────────────────
283
339
  function header(slug, name, version, mode) {
284
340
  const setup = mode === "tailwind"
@@ -295,8 +351,14 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
295
351
  /** JSDoc showing how to compose the component with its parts + content, so the
296
352
  * materialized code doesn't read as "a bare shell renders nothing" (dogfood
297
353
  * #5). Built from the recipe's parts. */
298
- function compositionHint(comp, name, recipe) {
354
+ function compositionHint(comp, name, recipe, voidEl = false) {
299
355
  const partNames = Object.keys(recipe.parts ?? {});
356
+ // An `<input>` or `<hr>` takes no children, and telling somebody to put
357
+ // content inside one is an instruction that throws. With parts, the element
358
+ // was promoted above and this branch never runs for a void tag.
359
+ if (voidEl) {
360
+ return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
361
+ }
300
362
  if (partNames.length === 0) {
301
363
  return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
302
364
  }
@@ -317,7 +379,6 @@ function emitCssMode(slug, name, recipe, version) {
317
379
  const comp = pascal(name);
318
380
  const propNames = axes.map((a) => a.prop);
319
381
  const destructure = [...propNames, "className", "...props"].join(", ");
320
- void voidEl; // both void and container elements self-close ({...props} carries children)
321
382
  const rootJsx = ` <${tag}${attrs}\n className={${joinCls([`"ds-${name}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n />`;
322
383
  const parts = Object.entries(recipe.parts ?? {}).map(([partName, part]) => {
323
384
  const partAxes = axesOf(part.variants ?? {});
@@ -345,7 +406,7 @@ import type { ComponentPropsWithoutRef } from "react";
345
406
 
346
407
  type ${comp}Props = ${propsType(axes, tag)};
347
408
 
348
- ${compositionHint(comp, name, recipe)}
409
+ ${compositionHint(comp, name, recipe, voidEl)}
349
410
  export function ${comp}({ ${destructure} }: ${comp}Props) {
350
411
  return (
351
412
  ${rootJsx}
@@ -368,11 +429,17 @@ function emitTailwindMode(slug, name, recipe, version) {
368
429
  const booleanConsts = axes
369
430
  .filter((a) => a.boolean)
370
431
  .map((a) => `const ${a.prop.toUpperCase()} = ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.true ?? {}).join(" "))};`);
432
+ // Every property some axis controls leaves BASE, and the base value becomes
433
+ // that axis's default - so exactly one class ever sets it and the prop
434
+ // actually wins.
435
+ const owned = new Map(axes.map((a) => [a.prop, variantOwnedProps(recipe.variants, a)]));
436
+ const excluded = new Set([...owned.values()].flat());
437
+ const fallbackFor = (a) => JSON.stringify(blockToTailwind(Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => (owned.get(a.prop) ?? []).includes(prop)))).join(" "));
371
438
  const clsParts = [
372
439
  "BASE",
373
440
  ...axes.map((a) => a.boolean
374
- ? `${a.prop} ? ${a.prop.toUpperCase()} : ""`
375
- : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ""`),
441
+ ? `${a.prop} ? ${a.prop.toUpperCase()} : ${fallbackFor(a)}`
442
+ : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
376
443
  "className",
377
444
  ];
378
445
  const destructure = [
@@ -380,17 +447,16 @@ function emitTailwindMode(slug, name, recipe, version) {
380
447
  "className",
381
448
  "...props",
382
449
  ].join(", ");
383
- void voidEl;
384
450
  return `${header(slug, name, version, "tailwind")}
385
451
 
386
452
  import type { ComponentPropsWithoutRef } from "react";
387
453
 
388
- const BASE = ${JSON.stringify(tailwindClassList(recipe))};
454
+ const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
389
455
  ${[...variantConsts, ...booleanConsts].join("\n")}
390
456
 
391
457
  type ${comp}Props = ${propsType(axes, tag)};
392
458
 
393
- ${compositionHint(comp, name, recipe)}
459
+ ${compositionHint(comp, name, recipe, voidEl)}
394
460
  export function ${comp}({ ${destructure} }: ${comp}Props) {
395
461
  return (
396
462
  <${tag}${attrs}
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { generate } from "./commands/generate.js";
9
9
  import { init } from "./commands/init.js";
10
10
  import { list } from "./commands/list.js";
11
11
  import { login } from "./commands/login.js";
12
+ import { mcp } from "./commands/mcp.js";
12
13
  import { refit } from "./commands/refit.js";
13
14
  import { template } from "./commands/template.js";
14
15
  import { upgrade } from "./commands/upgrade.js";
@@ -133,6 +134,11 @@ async function main() {
133
134
  slug: typeof flags.slug === "string" ? flags.slug : undefined,
134
135
  });
135
136
  break;
137
+ // Long-lived: it owns stdin/stdout until the client closes the pipe, so
138
+ // it must not be reached by anything that prints.
139
+ case "mcp":
140
+ await mcp({ dir });
141
+ return;
136
142
  case "doctor":
137
143
  // positional paths scope the READING (the system is still found from the
138
144
  // root): `doctor apps/web packages/ui` in a monorepo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.7",
3
+ "version": "0.16.9",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {