synthesisui 0.16.78 → 0.16.79
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/dist/anatomy-read.js +48 -7
- package/dist/commands/component.js +38 -1
- package/dist/commands/import.js +21 -2
- package/dist/commands/mcp.js +119 -0
- package/dist/component-codegen.js +41 -15
- package/dist/project-facts.js +58 -2
- package/dist/skill-import.js +108 -6
- package/package.json +1 -1
package/dist/anatomy-read.js
CHANGED
|
@@ -95,7 +95,11 @@ function formOf(node) {
|
|
|
95
95
|
* `declared` is their own tokens, so a `bg-ocean-500` on a nested part resolves
|
|
96
96
|
* to the name they gave it rather than to a literal.
|
|
97
97
|
*/
|
|
98
|
-
export function resolveAnatomy(read, declared, deps
|
|
98
|
+
export function resolveAnatomy(read, declared, deps,
|
|
99
|
+
/** Their name → the slug it reaches in this system. See `RefResolver`. */
|
|
100
|
+
resolve,
|
|
101
|
+
/** What the component returns, when the skill said and it is not a plain tag. */
|
|
102
|
+
root) {
|
|
99
103
|
const parts = {};
|
|
100
104
|
const composes = [];
|
|
101
105
|
const external = [];
|
|
@@ -147,12 +151,22 @@ export function resolveAnatomy(read, declared, deps) {
|
|
|
147
151
|
// on one would be us claiming styles that belong to somebody else.
|
|
148
152
|
if (FRONTIERS.has(as)) {
|
|
149
153
|
if (as === "component") {
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
154
|
+
const name = String(node.ref ?? "").trim();
|
|
155
|
+
if (!name)
|
|
152
156
|
continue;
|
|
153
|
-
if (!composes.includes(
|
|
154
|
-
composes.push(
|
|
155
|
-
|
|
157
|
+
if (!composes.includes(name))
|
|
158
|
+
composes.push(name);
|
|
159
|
+
/**
|
|
160
|
+
* THE CROSSWALK DECIDES WHAT THIS REACHES.
|
|
161
|
+
*
|
|
162
|
+
* `Tag` is bucketed `nearly` with canonical `badge`; kebab-casing it
|
|
163
|
+
* looks for `ds-tag` and finds nothing. Without a resolver we keep the
|
|
164
|
+
* kebab, which is what a system generated by us would want anyway.
|
|
165
|
+
*/
|
|
166
|
+
const slug = resolve?.(name) ?? safePartName(name);
|
|
167
|
+
out.push(slug && slug !== safePartName(name)
|
|
168
|
+
? { as, ref: slug, refName: name }
|
|
169
|
+
: { as, ref: slug || safePartName(name) });
|
|
156
170
|
continue;
|
|
157
171
|
}
|
|
158
172
|
const from = String(node.from ?? "").trim();
|
|
@@ -202,13 +216,40 @@ export function resolveAnatomy(read, declared, deps) {
|
|
|
202
216
|
return out;
|
|
203
217
|
};
|
|
204
218
|
const tree = walk(top, 0);
|
|
219
|
+
/**
|
|
220
|
+
* THE ROOT, RESOLVED THE SAME WAY.
|
|
221
|
+
*
|
|
222
|
+
* A dotted capitalised name is somebody else's namespace - `Radio.Root`,
|
|
223
|
+
* `BaseDialog.Root` - so there is nothing of theirs to reach and the honest
|
|
224
|
+
* answer is to name the library. Anything else is a component of theirs and goes
|
|
225
|
+
* through the crosswalk exactly like a child frontier does.
|
|
226
|
+
*/
|
|
227
|
+
let rootOut;
|
|
228
|
+
const rootRaw = String(root ?? "").trim();
|
|
229
|
+
if (rootRaw && /^[A-Z]/.test(rootRaw)) {
|
|
230
|
+
if (rootRaw.includes(".")) {
|
|
231
|
+
rootOut = { from: rootRaw };
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
const slug = resolve?.(rootRaw) ?? safePartName(rootRaw);
|
|
235
|
+
if (slug)
|
|
236
|
+
rootOut = { ref: slug, name: rootRaw };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
205
239
|
if (coerced > 0) {
|
|
206
240
|
notes.push(`${coerced} node${coerced === 1 ? "" : "s"} named a form that does not exist - kept, drawn as what ${coerced === 1 ? "it holds" : "they hold"}`);
|
|
207
241
|
}
|
|
208
242
|
if (budget <= 0) {
|
|
209
243
|
notes.push(`the anatomy was longer than ${MAX_NODES} nodes - the rest is layout, and it is not read`);
|
|
210
244
|
}
|
|
211
|
-
return {
|
|
245
|
+
return {
|
|
246
|
+
parts,
|
|
247
|
+
tree,
|
|
248
|
+
composes,
|
|
249
|
+
external,
|
|
250
|
+
notes,
|
|
251
|
+
...(rootOut ? { root: rootOut } : {}),
|
|
252
|
+
};
|
|
212
253
|
}
|
|
213
254
|
/**
|
|
214
255
|
* The OLD flat form, folded into the same result shape.
|
|
@@ -5,7 +5,7 @@ import { generateComponentFiles } from "../component-codegen.js";
|
|
|
5
5
|
import { readProjectConfig, resolveRegistry } from "../config.js";
|
|
6
6
|
import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
|
|
7
7
|
import { body, section, snippet } from "../output.js";
|
|
8
|
-
import { reactMajorOf, readInstalledConvention } from "../project-facts.js";
|
|
8
|
+
import { findCollision, reactMajorOf, readInstalledConvention, } from "../project-facts.js";
|
|
9
9
|
import { fetchComponent, RegistryError } from "../registry.js";
|
|
10
10
|
/**
|
|
11
11
|
* Writes the shared `cn.ts` next to the components, built from THIS project's
|
|
@@ -68,6 +68,43 @@ export async function component(slug, name, opts) {
|
|
|
68
68
|
const config = await readProjectConfig(root);
|
|
69
69
|
const wantInteractive = opts.interactive && hasInteractiveTemplate(res.name);
|
|
70
70
|
if (!opts.artifactsOnly && config.target === "next") {
|
|
71
|
+
/**
|
|
72
|
+
* WE DO NOT TAKE A NAME THEY ARE USING.
|
|
73
|
+
*
|
|
74
|
+
* This wrote with a bare `writeFile`, so it overwrote whatever was there - and
|
|
75
|
+
* a project that already has its own `Button` is the normal case. The
|
|
76
|
+
* crosswalk makes it sharper: their `ToolbarButton` resolves to `button`, so
|
|
77
|
+
* asking for one hands back a `Button`, which is a different component.
|
|
78
|
+
*
|
|
79
|
+
* Re-materializing something WE generated is almost always what they meant, so
|
|
80
|
+
* that keeps going. Shadowing a component of theirs stops and asks.
|
|
81
|
+
*/
|
|
82
|
+
const pascalName = res.name
|
|
83
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
86
|
+
.join("");
|
|
87
|
+
const clash = await findCollision(root, config.componentsDir, res.name, pascalName);
|
|
88
|
+
if (!opts.force && (clash.exported || (clash.file && !clash.ours))) {
|
|
89
|
+
console.log(section("This name is already taken in your project"));
|
|
90
|
+
if (clash.exported) {
|
|
91
|
+
console.log(body(`\`${pascalName}\` is already exported from ${clash.exported}.`));
|
|
92
|
+
}
|
|
93
|
+
if (clash.file && !clash.ours) {
|
|
94
|
+
console.log(body(`${clash.file} exists and we did not write it.`));
|
|
95
|
+
}
|
|
96
|
+
console.log("");
|
|
97
|
+
console.log(body("The recipe and the compiled CSS are on disk either way - only the .tsx was not written. Your options:"));
|
|
98
|
+
console.log("");
|
|
99
|
+
console.log(snippet([
|
|
100
|
+
`npx synthesisui component ${slug} ${res.name} --force`,
|
|
101
|
+
`# or bring it under a name of your own:`,
|
|
102
|
+
`npx synthesisui component ${slug} ${res.name} --as my-${res.name}`,
|
|
103
|
+
]));
|
|
104
|
+
console.log("");
|
|
105
|
+
console.log(body("Nothing of yours was touched. Which name it takes is your call, not ours."));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
71
108
|
const compDir = join(root, config.componentsDir, res.name);
|
|
72
109
|
await mkdir(compDir, { recursive: true });
|
|
73
110
|
let filenames;
|
package/dist/commands/import.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
|
-
import { resolveAnatomy, resolveFlatParts, } from "../anatomy-read.js";
|
|
3
|
+
import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
|
|
4
4
|
import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
|
|
5
5
|
import { findBrokenRefs } from "../doctor/broken-refs.js";
|
|
6
6
|
import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
|
|
@@ -924,10 +924,28 @@ async function resolveReadParts(census, root) {
|
|
|
924
924
|
let edges = 0;
|
|
925
925
|
const libs = new Set();
|
|
926
926
|
const notes = new Set();
|
|
927
|
+
/**
|
|
928
|
+
* WHAT A NAME IN THEIR CODE REACHES IN THE SYSTEM - from the crosswalk.
|
|
929
|
+
*
|
|
930
|
+
* The census already decided this for every component it read: `Tag` is
|
|
931
|
+
* `nearly`, canonical `badge`; `ToolbarButton` is `nearly`, canonical `button`;
|
|
932
|
+
* `Card` `exists` as `card`. The first version of the frontier kebab-cased the
|
|
933
|
+
* name and looked for `ds-tag`, which does not exist - so a component whose
|
|
934
|
+
* recipe was sitting right there drew as a grey chip (dono, 01/08).
|
|
935
|
+
*/
|
|
936
|
+
const crosswalked = new Map();
|
|
937
|
+
for (const c of census.components ?? []) {
|
|
938
|
+
if (c.from)
|
|
939
|
+
continue; // a package's component is not one of theirs
|
|
940
|
+
const target = c.canonical ?? (c.bucket === "exclusive" ? c.name : null);
|
|
941
|
+
if (target)
|
|
942
|
+
crosswalked.set(c.name, safePartName(target));
|
|
943
|
+
}
|
|
944
|
+
const resolveRef = (name) => crosswalked.get(name) ?? null;
|
|
927
945
|
for (const [component, entry] of Object.entries(read)) {
|
|
928
946
|
const anatomy = entry?.anatomy;
|
|
929
947
|
const resolved = Array.isArray(anatomy) && anatomy.length > 0
|
|
930
|
-
? resolveAnatomy(anatomy, declared, deps)
|
|
948
|
+
? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root)
|
|
931
949
|
: Array.isArray(entry?.parts) && entry.parts.length > 0
|
|
932
950
|
? resolveFlatParts(entry.parts, declared)
|
|
933
951
|
: null;
|
|
@@ -942,6 +960,7 @@ async function resolveReadParts(census, root) {
|
|
|
942
960
|
...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
|
|
943
961
|
...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
|
|
944
962
|
...(resolved.external.length > 0 ? { external: resolved.external } : {}),
|
|
963
|
+
...(resolved.root ? { root: resolved.root } : {}),
|
|
945
964
|
};
|
|
946
965
|
looks[component] = look
|
|
947
966
|
? {
|
package/dist/commands/mcp.js
CHANGED
|
@@ -65,6 +65,20 @@ const TOOLS = [
|
|
|
65
65
|
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.",
|
|
66
66
|
inputSchema: { type: "object", properties: {} },
|
|
67
67
|
},
|
|
68
|
+
{
|
|
69
|
+
name: "describe_component",
|
|
70
|
+
description: "Everything the system knows about ONE component: what it is made of, what it composes, WHICH LIBRARIES IT NEEDS, and the rules that govern it. Call this before writing code that uses a component - `list_components` gives you a name and a sentence, and a name does not tell you that the editor needs @tiptap/react or that a card is built out of a metric card.",
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
name: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Component name from list_components.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
required: ["name"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
68
82
|
{
|
|
69
83
|
name: "add_component",
|
|
70
84
|
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.",
|
|
@@ -191,6 +205,109 @@ async function listComponents(root) {
|
|
|
191
205
|
...rows.sort(),
|
|
192
206
|
].join("\n");
|
|
193
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* ONE COMPONENT, IN FULL - the round trip that did not exist.
|
|
210
|
+
*
|
|
211
|
+
* `list_components` returns a name and a description, so an agent building with
|
|
212
|
+
* `ds-text-editor` had no way to know it needs `@tiptap/react` before writing the
|
|
213
|
+
* first line. The anatomy, the composition chain and the required libraries are all
|
|
214
|
+
* in the installed contract; nothing was asking for them.
|
|
215
|
+
*
|
|
216
|
+
* It is also the confirmation the skill never had. After an import, reading this
|
|
217
|
+
* back says what the platform UNDERSTOOD - so a reading that came out thin is
|
|
218
|
+
* visible instead of being discovered later in a preview.
|
|
219
|
+
*/
|
|
220
|
+
async function describeComponent(root, name) {
|
|
221
|
+
const { documents, requires } = await loadSystem(root);
|
|
222
|
+
let recipe;
|
|
223
|
+
for (const doc of documents) {
|
|
224
|
+
const d = doc;
|
|
225
|
+
recipe = d.components?.[name] ?? d.blocks?.[name];
|
|
226
|
+
if (recipe)
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
if (!recipe) {
|
|
230
|
+
return `This system has no component called "${name}". Run list_components to see what it does have - and if nothing covers what you need, file request_component rather than writing one from scratch.`;
|
|
231
|
+
}
|
|
232
|
+
const out = [
|
|
233
|
+
`${name}${recipe.description ? ` - ${recipe.description}` : ""}`,
|
|
234
|
+
];
|
|
235
|
+
// WHAT IT SITS ON. 17 of 23 components in a real library carry no surface of
|
|
236
|
+
// their own, because the surface belongs to what they return.
|
|
237
|
+
const p = recipe.preview;
|
|
238
|
+
if (p?.rootRef) {
|
|
239
|
+
out.push("", `Sits on: ${p.rootRef}${p.rootName && p.rootName !== p.rootRef ? ` (your ${p.rootName})` : ""} - its background, border and radius come from there, not from this component.`);
|
|
240
|
+
}
|
|
241
|
+
else if (p?.rootFrom) {
|
|
242
|
+
out.push("", `Sits on: ${p.rootFrom} - a third-party root. Its markup and behaviour are that library's.`);
|
|
243
|
+
}
|
|
244
|
+
// WHAT IT IS MADE OF, and where it ends.
|
|
245
|
+
const composes = [];
|
|
246
|
+
const libs = [];
|
|
247
|
+
const shape = [];
|
|
248
|
+
const walk = (nodes, depth) => {
|
|
249
|
+
for (const raw of nodes) {
|
|
250
|
+
const n = raw;
|
|
251
|
+
const pad = " ".repeat(depth + 1);
|
|
252
|
+
if (n.as === "component" && n.ref) {
|
|
253
|
+
shape.push(`${pad}<${n.ref}>${n.refName && n.refName !== n.ref ? ` (your ${n.refName})` : ""}`);
|
|
254
|
+
if (!composes.includes(n.ref))
|
|
255
|
+
composes.push(n.ref);
|
|
256
|
+
}
|
|
257
|
+
else if (n.as === "external" && n.from) {
|
|
258
|
+
shape.push(`${pad}${n.from} (third party - not ours to render)`);
|
|
259
|
+
if (!libs.includes(n.from))
|
|
260
|
+
libs.push(n.from);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
shape.push(`${pad}${n.as}${n.part ? ` .${name}-${n.part}` : ""}`);
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(n.children))
|
|
266
|
+
walk(n.children, depth + 1);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
if (Array.isArray(p?.parts) && p.parts.length > 0) {
|
|
270
|
+
walk(p.parts, 0);
|
|
271
|
+
out.push("", "Made of:", ...shape);
|
|
272
|
+
}
|
|
273
|
+
else if (recipe.parts && Object.keys(recipe.parts).length > 0) {
|
|
274
|
+
out.push("", `Parts: ${Object.keys(recipe.parts).join(", ")} - compose them inside it.`);
|
|
275
|
+
}
|
|
276
|
+
if (composes.length > 0) {
|
|
277
|
+
out.push("", `Built out of: ${composes.join(", ")}. Change one of those in one place rather than reproducing it here, and call describe_component on it before you do.`);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* WHAT IT NEEDS INSTALLED - the whole reason this tool earns its place.
|
|
281
|
+
*
|
|
282
|
+
* From `requires.json`, filtered by the stack at install time, so the answer is
|
|
283
|
+
* about THIS project. Never installs: a dependency has a licence, a bundle cost
|
|
284
|
+
* and a maintainer attached, so the agent reports and the person decides.
|
|
285
|
+
*/
|
|
286
|
+
const needed = requires.filter((r) => (r.applies ?? []).includes(name));
|
|
287
|
+
if (needed.length > 0 || libs.length > 0) {
|
|
288
|
+
const named = [
|
|
289
|
+
...new Set([...needed.map((r) => r.requires), ...libs]),
|
|
290
|
+
];
|
|
291
|
+
out.push("", `Needs installed: ${named.join(", ")}.`, "Check the manifest before you write against it. If it is missing, say so and ASK - do not install it yourself.");
|
|
292
|
+
for (const r of needed) {
|
|
293
|
+
if (r.pinned)
|
|
294
|
+
out.push(` ${r.requires} ${r.pinned}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// THE RULES, scoped. A relation names both, which is what an agent needs in
|
|
298
|
+
// order not to assemble it wrongly.
|
|
299
|
+
const laws = [...(recipe.usage ?? [])];
|
|
300
|
+
if (laws.length > 0) {
|
|
301
|
+
out.push("", "Rules for this component:", ...laws.map((l) => ` ${l}`));
|
|
302
|
+
}
|
|
303
|
+
if (needed.length > 0) {
|
|
304
|
+
out.push(...needed.map((r) => ` ${r.text}`));
|
|
305
|
+
}
|
|
306
|
+
if (laws.length === 0 && needed.length === 0) {
|
|
307
|
+
out.push("", "No rules govern this component yet. Build with it, and if you find yourself working around it, file request_component with the reasoning.");
|
|
308
|
+
}
|
|
309
|
+
return out.join("\n");
|
|
310
|
+
}
|
|
194
311
|
async function addComponent(root, name) {
|
|
195
312
|
const { table } = await loadSystem(root);
|
|
196
313
|
if (!table.slug)
|
|
@@ -207,6 +324,8 @@ async function callTool(root, name, args) {
|
|
|
207
324
|
return text(await findToken(root, String(args.value ?? "")));
|
|
208
325
|
case "list_components":
|
|
209
326
|
return text(await listComponents(root));
|
|
327
|
+
case "describe_component":
|
|
328
|
+
return text(await describeComponent(root, String(args.name ?? "")));
|
|
210
329
|
case "add_component":
|
|
211
330
|
return text(await addComponent(root, String(args.name ?? "")));
|
|
212
331
|
case "request_component": {
|
|
@@ -207,7 +207,19 @@ const colorKey = (v) => {
|
|
|
207
207
|
return `series-${series}`;
|
|
208
208
|
return null;
|
|
209
209
|
};
|
|
210
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility).
|
|
212
|
+
*
|
|
213
|
+
* A STEP maps to a utility; a ROLE deliberately does not.
|
|
214
|
+
*
|
|
215
|
+
* `typography.scale.<step>` names a step the system actually has, and a step it
|
|
216
|
+
* got from THEIR declaration is bridged into Tailwind's namespace - so `text-h1`
|
|
217
|
+
* exists and is the prettier output. `typography.role.<slot>` is one of our seven
|
|
218
|
+
* slots, and the bridge only ever claims a name they gave us: `text-base` may not
|
|
219
|
+
* exist in an imported system at all. So a role falls through to
|
|
220
|
+
* `var(--ds-typography-role-base-font-size)`, which is always emitted and can
|
|
221
|
+
* never dangle.
|
|
222
|
+
*/
|
|
211
223
|
const scaleKey = (v) => {
|
|
212
224
|
const m = v.match(/^\{typography\.scale\.([a-zA-Z0-9-]+)\.fontSize\}$/);
|
|
213
225
|
return m ? kebab(m[1]) : null;
|
|
@@ -640,11 +652,13 @@ function partTagFor(part, form) {
|
|
|
640
652
|
voidEl: Boolean(mapped.voidEl),
|
|
641
653
|
};
|
|
642
654
|
}
|
|
643
|
-
function emitCssMode(slug, name, recipe, version, props, convention
|
|
655
|
+
function emitCssMode(slug, name, recipe, version, props, convention,
|
|
656
|
+
/** The name it takes in THEIR project. The class stays the system's. */
|
|
657
|
+
localName = name) {
|
|
644
658
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
645
659
|
const el = asElement(tag, attrs, voidEl);
|
|
646
660
|
const axes = axesOf(recipe.variants);
|
|
647
|
-
const comp = pascal(
|
|
661
|
+
const comp = pascal(localName);
|
|
648
662
|
const propNames = axes.map((a) => a.prop);
|
|
649
663
|
const tree = recipe.preview?.parts ?? [];
|
|
650
664
|
// With a shape the root RENDERS its children, so it needs `children` in the
|
|
@@ -713,7 +727,7 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
|
713
727
|
});
|
|
714
728
|
const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
|
|
715
729
|
return `${header(slug, name, version, "css")}
|
|
716
|
-
import "./${
|
|
730
|
+
import "./${localName}.css";
|
|
717
731
|
|
|
718
732
|
import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
|
|
719
733
|
|
|
@@ -727,11 +741,13 @@ ${rootJsx}
|
|
|
727
741
|
}
|
|
728
742
|
${parts.filter(Boolean).join("\n")}`;
|
|
729
743
|
}
|
|
730
|
-
function emitTailwindMode(slug, name, recipe, version, props
|
|
744
|
+
function emitTailwindMode(slug, name, recipe, version, props,
|
|
745
|
+
/** The name it takes in THEIR project. The utilities stay the system's. */
|
|
746
|
+
localName = name) {
|
|
731
747
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
732
748
|
const el = asElement(tag, attrs, voidEl);
|
|
733
749
|
const axes = axesOf(recipe.variants);
|
|
734
|
-
const comp = pascal(
|
|
750
|
+
const comp = pascal(localName);
|
|
735
751
|
const variantConsts = axes
|
|
736
752
|
.filter((a) => !a.boolean)
|
|
737
753
|
.map((a) => {
|
|
@@ -878,27 +894,37 @@ reactMajor = null,
|
|
|
878
894
|
* every caller that predates the convention being the user's - and getting this
|
|
879
895
|
* wrong shipped a component wearing classes its own stylesheet never emits.
|
|
880
896
|
*/
|
|
881
|
-
convention = DEFAULT_CONVENTION
|
|
882
|
-
|
|
897
|
+
convention = DEFAULT_CONVENTION,
|
|
898
|
+
/**
|
|
899
|
+
* THE NAME IT TAKES IN THEIR PROJECT, when it cannot take its own.
|
|
900
|
+
*
|
|
901
|
+
* A project that already exports `Button` should not have to give the name up to
|
|
902
|
+
* install ours - we adapt to what they built. So the FILE and the EXPORT can be
|
|
903
|
+
* renamed while the CLASS stays the design system's: the recipe compiles
|
|
904
|
+
* `.ds-button`, and a `<MySystemButton>` wearing it is styled correctly and
|
|
905
|
+
* shadows nothing of theirs.
|
|
906
|
+
*
|
|
907
|
+
* Absent means the component keeps its own name, which is every caller today.
|
|
908
|
+
*/
|
|
909
|
+
localName = name) {
|
|
883
910
|
const files = [];
|
|
884
911
|
const props = propsTypeName(reactMajor);
|
|
885
912
|
if (styles === "css") {
|
|
886
913
|
files.push({
|
|
887
|
-
filename: `${
|
|
888
|
-
code: `${emitCssMode(slug, name, recipe, version, props, convention)}\n`,
|
|
914
|
+
filename: `${localName}.tsx`,
|
|
915
|
+
code: `${emitCssMode(slug, name, recipe, version, props, convention, localName)}\n`,
|
|
889
916
|
});
|
|
890
|
-
files.push({ filename: `${
|
|
917
|
+
files.push({ filename: `${localName}.css`, code: `${css}\n` });
|
|
891
918
|
}
|
|
892
919
|
else {
|
|
893
920
|
files.push({
|
|
894
|
-
filename: `${
|
|
895
|
-
code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
|
|
921
|
+
filename: `${localName}.tsx`,
|
|
922
|
+
code: `${emitTailwindMode(slug, name, recipe, version, props, localName)}\n`,
|
|
896
923
|
});
|
|
897
924
|
}
|
|
898
925
|
files.push({
|
|
899
926
|
filename: "index.ts",
|
|
900
|
-
code: `export * from "./${
|
|
927
|
+
code: `export * from "./${localName}";\n`,
|
|
901
928
|
});
|
|
902
|
-
void comp;
|
|
903
929
|
return files;
|
|
904
930
|
}
|
package/dist/project-facts.js
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
* handed somebody a React component wearing classes their own stylesheet never
|
|
14
14
|
* emits. It renders completely unstyled, and nothing anywhere reports a problem.
|
|
15
15
|
*/
|
|
16
|
-
import { readFile } from "node:fs/promises";
|
|
17
|
-
import { join } from "node:path";
|
|
16
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
17
|
+
import { join, relative } from "node:path";
|
|
18
18
|
import { DEFAULT_CONVENTION, } from "./component-codegen.js";
|
|
19
19
|
/**
|
|
20
20
|
* Which React the consumer is on, for the ref-carrying prop type.
|
|
@@ -38,6 +38,62 @@ export async function reactMajorOf(root) {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
export async function findCollision(root, componentsDir, name, pascal) {
|
|
42
|
+
const target = join(root, componentsDir, name, `${name}.tsx`);
|
|
43
|
+
const head = await readFile(target, "utf8")
|
|
44
|
+
.then((t) => t.slice(0, 300))
|
|
45
|
+
.catch(() => null);
|
|
46
|
+
const ours = head?.includes("Generated by SynthesisUI") ?? false;
|
|
47
|
+
/**
|
|
48
|
+
* Their own export of that name, searched where a component library lives.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately shallow: `src`, `app`, `components`, `packages` and the
|
|
51
|
+
* configured folder, skipping anything generated. A deep walk of a monorepo to
|
|
52
|
+
* answer one question is not worth the seconds.
|
|
53
|
+
*/
|
|
54
|
+
const pattern = new RegExp(`export\\s+(?:default\\s+)?(?:function|const|class)\\s+${pascal}\\b`);
|
|
55
|
+
const roots = [
|
|
56
|
+
componentsDir,
|
|
57
|
+
"src/components",
|
|
58
|
+
"components",
|
|
59
|
+
"src/ui",
|
|
60
|
+
"packages",
|
|
61
|
+
];
|
|
62
|
+
let exported = null;
|
|
63
|
+
for (const rel of roots) {
|
|
64
|
+
if (exported)
|
|
65
|
+
break;
|
|
66
|
+
for await (const file of walkShallow(join(root, rel), 3)) {
|
|
67
|
+
if (!/\.(tsx|jsx|ts)$/.test(file))
|
|
68
|
+
continue;
|
|
69
|
+
if (file === target)
|
|
70
|
+
continue;
|
|
71
|
+
const source = await readFile(file, "utf8").catch(() => "");
|
|
72
|
+
if (source.includes("Generated by SynthesisUI"))
|
|
73
|
+
continue;
|
|
74
|
+
if (pattern.test(source)) {
|
|
75
|
+
exported = relative(root, file);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { file: head == null ? null : relative(root, target), exported, ours };
|
|
81
|
+
}
|
|
82
|
+
/** Files under `dir`, at most `depth` levels down, skipping the usual noise. */
|
|
83
|
+
async function* walkShallow(dir, depth) {
|
|
84
|
+
if (depth < 0)
|
|
85
|
+
return;
|
|
86
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
89
|
+
continue;
|
|
90
|
+
const full = join(dir, entry.name);
|
|
91
|
+
if (entry.isDirectory())
|
|
92
|
+
yield* walkShallow(full, depth - 1);
|
|
93
|
+
else
|
|
94
|
+
yield full;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
41
97
|
/** The pinned version of an installed system, from its `.lock`. */
|
|
42
98
|
async function pinnedVersion(dir) {
|
|
43
99
|
const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
|
package/dist/skill-import.js
CHANGED
|
@@ -133,6 +133,7 @@ Open the project and answer the questions below. Then add a \`reading\` object t
|
|
|
133
133
|
"themes": { "default": "dark", "has": ["dark"] },
|
|
134
134
|
"roles": { "canvas": "#050505", "foreground": "#f9fafb", "primary": "#4A90E2" },
|
|
135
135
|
"fonts": { "display": "Inter", "body": "Inter" },
|
|
136
|
+
"typeRoles": { "base": "body-m", "display": "h1", "xs": "caption" },
|
|
136
137
|
"concept": "one paragraph on what this product is",
|
|
137
138
|
"rules": [
|
|
138
139
|
{
|
|
@@ -149,6 +150,13 @@ Open the project and answer the questions below. Then add a \`reading\` object t
|
|
|
149
150
|
"kind": "limit",
|
|
150
151
|
"files": 1,
|
|
151
152
|
"evidence": "one dashboard page; may just be how it happened"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"text": "TextEditor's editable region is <EditorContent> - never render children into it directly",
|
|
156
|
+
"applies": ["TextEditor"],
|
|
157
|
+
"kind": "implementation",
|
|
158
|
+
"fact": true,
|
|
159
|
+
"evidence": "read in TextEditor itself: useEditor() feeds a single <EditorContent>"
|
|
152
160
|
}
|
|
153
161
|
],
|
|
154
162
|
"components": {
|
|
@@ -167,6 +175,7 @@ Open the project and answer the questions below. Then add a \`reading\` object t
|
|
|
167
175
|
]
|
|
168
176
|
},
|
|
169
177
|
"ArticleCard": {
|
|
178
|
+
"root": "Card",
|
|
170
179
|
"anatomy": [
|
|
171
180
|
{ "as": "image", "name": "cover", "classes": "aspect-video w-full rounded-t-lg" },
|
|
172
181
|
{ "as": "heading", "name": "title", "classes": "text-lg font-semibold" },
|
|
@@ -184,7 +193,12 @@ Open the project and answer the questions below. Then add a \`reading\` object t
|
|
|
184
193
|
},
|
|
185
194
|
"TextEditor": {
|
|
186
195
|
"anatomy": [
|
|
187
|
-
{
|
|
196
|
+
{
|
|
197
|
+
"as": "row",
|
|
198
|
+
"name": "toolbar",
|
|
199
|
+
"classes": "flex gap-1 border-b p-2",
|
|
200
|
+
"children": [{ "as": "component", "ref": "ToolbarButton" }]
|
|
201
|
+
},
|
|
188
202
|
{ "as": "external", "from": "@tiptap/react" }
|
|
189
203
|
]
|
|
190
204
|
}
|
|
@@ -221,6 +235,26 @@ about which one is the page. Read a screen and see.
|
|
|
221
235
|
**\`fonts\` and \`concept\`** - the voice, and one paragraph on what this product is. The concept
|
|
222
236
|
feeds every recommendation downstream, so a real one beats a generic one by a wide margin.
|
|
223
237
|
|
|
238
|
+
**\`typeRoles\` - which of THEIR type steps plays each of our seven slots.**
|
|
239
|
+
|
|
240
|
+
Their scale arrives under their own names now - \`h1\`, \`body-m\`, \`caption\`, \`overline\` - read
|
|
241
|
+
straight off their \`--text-*\` declarations. What arithmetic cannot know is which of those steps
|
|
242
|
+
is *body copy* and which is *the display size*, and our own components ask for it by slot:
|
|
243
|
+
|
|
244
|
+
\`\`\`json
|
|
245
|
+
"typeRoles": {
|
|
246
|
+
"xs": "caption", "sm": "body-s", "base": "body-m", "lg": "body-l",
|
|
247
|
+
"xl": "h3", "2xl": "h2", "display": "h1"
|
|
248
|
+
}
|
|
249
|
+
\`\`\`
|
|
250
|
+
|
|
251
|
+
Leave it out and we pick by size, which is usually right and occasionally silly - a project whose
|
|
252
|
+
\`overline\` is tiny and whose \`caption\` is tinier gets them the wrong way round. One line from you
|
|
253
|
+
fixes it, and a person can correct it later in the studio either way.
|
|
254
|
+
|
|
255
|
+
**Do not rename their steps to match ours.** \`h1\` stays \`h1\`. The whole point is that editing
|
|
256
|
+
\`body-m\` in the studio moves the text in their app.
|
|
257
|
+
|
|
224
258
|
**\`rules\` - how this company BUILDS, which is half of what they actually made.**
|
|
225
259
|
|
|
226
260
|
A design system that arrives as tokens and recipes is only the vocabulary. The other half is the
|
|
@@ -258,12 +292,39 @@ Use it only when the rule genuinely depends on the environment. Most rules do no
|
|
|
258
292
|
a Sidebar loose"* is true about their design regardless of framework, and pinning it to \`next\`
|
|
259
293
|
would quietly drop it the day they add a second app.
|
|
260
294
|
|
|
261
|
-
**Report \`files\` honestly - it decides whether
|
|
262
|
-
habit and the rule arrives active; one file is a coincidence and it arrives as a candidate,
|
|
295
|
+
**Report \`files\` honestly - it decides whether an OBSERVED rule governs.** Three or more files
|
|
296
|
+
is a habit and the rule arrives active; one file is a coincidence and it arrives as a candidate,
|
|
263
297
|
inactive, waiting for the person to promote it. You do not make that call; you report the
|
|
264
298
|
evidence and a threshold makes it. So a pattern you saw once should say \`"files": 1\` even when
|
|
265
299
|
you are confident - being wrong about a law is worse than being slow about one.
|
|
266
300
|
|
|
301
|
+
**\`fact: true\` - for a rule you read in the DEFINITION, where counting is the wrong question.**
|
|
302
|
+
|
|
303
|
+
Three of these were reported as \`files: 1\` and arrived inactive, which was correct arithmetic on
|
|
304
|
+
the wrong kind of claim (dono, 01/08):
|
|
305
|
+
|
|
306
|
+
\`\`\`
|
|
307
|
+
the editable region is <EditorContent>
|
|
308
|
+
toolbar actions go through editor.chain().focus()
|
|
309
|
+
extensions are configured at construction
|
|
310
|
+
\`\`\`
|
|
311
|
+
|
|
312
|
+
None of those is a coincidence waiting for a second sighting. They are how the component IS
|
|
313
|
+
built, read off its own source, and they are true the moment somebody wrote it. Counting how many
|
|
314
|
+
files agree would leave every construction law in the codebase inactive forever.
|
|
315
|
+
|
|
316
|
+
So the test is **where you read it**, not how sure you feel:
|
|
317
|
+
|
|
318
|
+
\`\`\`
|
|
319
|
+
fact: true you read it inside the component's own definition - its imports, its
|
|
320
|
+
JSX, how its state is wired. True by construction.
|
|
321
|
+
files: N you inferred it from how the component is USED across the project.
|
|
322
|
+
An observation, and the count is what makes it a habit.
|
|
323
|
+
\`\`\`
|
|
324
|
+
|
|
325
|
+
A rule can carry \`fact\` OR \`files\`, never both. If you find yourself wanting both, it is an
|
|
326
|
+
observation - use \`files\`.
|
|
327
|
+
|
|
267
328
|
**Write \`evidence\` as what you actually saw.** "All three app shells wrap them; none renders
|
|
268
329
|
them loose" lets somebody disagree with a fact. "Best practice" lets them disagree only with
|
|
269
330
|
you.
|
|
@@ -302,6 +363,41 @@ an EXTERNAL a third-party library we do not have it and never will → bl
|
|
|
302
363
|
component or a library, stop there, and record the edge. That is why no parameter tells you how
|
|
303
364
|
deep to go: a \`Divider\` is one node deep and a dashboard shell is five, and both are complete.
|
|
304
365
|
|
|
366
|
+
**You decide the depth, and nothing downstream caps it.** The platform follows a
|
|
367
|
+
\`component\` edge into that component's own anatomy, and then into ITS edges, as far as the chain
|
|
368
|
+
goes - \`Chat → Message → TypingIndicator\` renders all three. So a frontier is not a dead end you
|
|
369
|
+
are apologising for; it is how the chain gets walked. Record the edge and stop, and the whole
|
|
370
|
+
depth appears anyway.
|
|
371
|
+
|
|
372
|
+
### The ROOT is a frontier too
|
|
373
|
+
|
|
374
|
+
\`\`\`json
|
|
375
|
+
"components": {
|
|
376
|
+
"ArticleCard": {
|
|
377
|
+
"root": "Card",
|
|
378
|
+
"anatomy": [ … ]
|
|
379
|
+
},
|
|
380
|
+
"Modal": {
|
|
381
|
+
"root": "BaseDialog.Root",
|
|
382
|
+
"anatomy": [ … ]
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
\`\`\`
|
|
386
|
+
|
|
387
|
+
**Say what the component RETURNS when it is not a plain tag.** Measured on a real library
|
|
388
|
+
(dono, 01/08): 14 of 23 components return one of their own - \`ArticleCard\` returns a \`<Card>\`,
|
|
389
|
+
\`Button\` and \`Text\` return a \`<Component>\` - and 17 of 23 have no style of their own at all,
|
|
390
|
+
because the surface belongs to the root.
|
|
391
|
+
|
|
392
|
+
Without \`root\`, an \`ArticleCard\` previewed as floating text: the background, the border, the
|
|
393
|
+
radius and the padding had nowhere to come from. With it, the card wears \`Card\`'s recipe as its
|
|
394
|
+
shell and its own parts inside.
|
|
395
|
+
|
|
396
|
+
- **their component** - the name, as their code spells it: \`"root": "Card"\`
|
|
397
|
+
- **a library** - the dotted namespace, verbatim: \`"root": "Radio.Root"\`,
|
|
398
|
+
\`"root": "BaseDialog.Root"\`, \`"root": "Popover.Root"\`
|
|
399
|
+
- **a plain tag** - leave it out. \`<div>\`, \`<td>\`, \`<button>\` are not frontiers.
|
|
400
|
+
|
|
305
401
|
### The nine forms
|
|
306
402
|
|
|
307
403
|
\`as\` says what a node IS, and the renderer draws that. Nothing else is accepted:
|
|
@@ -379,9 +475,15 @@ Send an anatomy for **every component with visible structure**, which is nearly
|
|
|
379
475
|
\`CircularProgress\` has a track and a fill. Sending none is right only for something genuinely
|
|
380
476
|
undivided - a \`Divider\`, a \`Spacer\`.
|
|
381
477
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
478
|
+
**There is no node budget, and the one I gave you was wrong.** "Three to six nodes is a
|
|
479
|
+
recognisable component" cost a real \`ArticleCard\` most of itself: it has a carousel with arrows,
|
|
480
|
+
three image buttons, a source chip, a refresh, a rich body, a source line and a "Similar
|
|
481
|
+
published content" region with two selects and two buttons - about fourteen regions - and the
|
|
482
|
+
reading came back with six because the guidance said so (dono, 01/08).
|
|
483
|
+
|
|
484
|
+
**One node per region a person can point at.** If they can say "that part", it is a node. What
|
|
485
|
+
you still collapse is a wrapper whose only job is \`flex\` - fold it into the \`row\` it already is,
|
|
486
|
+
because that is not a region, it is plumbing.
|
|
385
487
|
|
|
386
488
|
The cost of sending none is not neutral. A component with no anatomy previews as a grey box with
|
|
387
489
|
a sentence in it, or - if its kind is \`indicator\` - as a small blank shape. A component with a
|
package/package.json
CHANGED