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.
@@ -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 ref = String(node.ref ?? "").trim();
151
- if (!ref)
154
+ const name = String(node.ref ?? "").trim();
155
+ if (!name)
152
156
  continue;
153
- if (!composes.includes(ref))
154
- composes.push(ref);
155
- out.push({ as, ref });
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 { parts, tree, composes, external, notes };
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;
@@ -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
  ? {
@@ -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
- /** "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility). */
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(name);
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 "./${name}.css";
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(name);
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
- const comp = pascal(name);
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: `${name}.tsx`,
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: `${name}.css`, code: `${css}\n` });
917
+ files.push({ filename: `${localName}.css`, code: `${css}\n` });
891
918
  }
892
919
  else {
893
920
  files.push({
894
- filename: `${name}.tsx`,
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 "./${name}";\n`,
927
+ code: `export * from "./${localName}";\n`,
901
928
  });
902
- void comp;
903
929
  return files;
904
930
  }
@@ -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(() => "");
@@ -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
- { "as": "row", "name": "toolbar", "classes": "flex gap-1 border-b p-2" },
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 the rule governs.** Three or more files is a
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
- Three to six nodes is a recognisable component. Twelve is a transcription of their DOM, and
383
- nobody needs the layout divs - collapse a wrapper whose only job is \`flex\` into the \`row\` it
384
- already is.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.78",
3
+ "version": "0.16.79",
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": {