synthesisui 0.16.78 → 0.16.80

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.
@@ -30,7 +30,7 @@
30
30
  * of the three outcomes this pipeline has paid for twice.
31
31
  */
32
32
  import { transcribe, } from "./doctor/transcribe.js";
33
- /** The nine forms, closed. A renderer that accepts any tag draws anything. */
33
+ /** The ten forms, closed. A renderer that accepts any tag draws anything. */
34
34
  const FORMS = new Set([
35
35
  "image",
36
36
  "heading",
@@ -42,6 +42,7 @@ const FORMS = new Set([
42
42
  "stack",
43
43
  "component",
44
44
  "external",
45
+ "slot",
45
46
  ]);
46
47
  /** The two that arrange, and the only two that take children. */
47
48
  const CONTAINERS = new Set(["row", "stack"]);
@@ -95,7 +96,11 @@ function formOf(node) {
95
96
  * `declared` is their own tokens, so a `bg-ocean-500` on a nested part resolves
96
97
  * to the name they gave it rather than to a literal.
97
98
  */
98
- export function resolveAnatomy(read, declared, deps) {
99
+ export function resolveAnatomy(read, declared, deps,
100
+ /** Their name → the slug it reaches in this system. See `RefResolver`. */
101
+ resolve,
102
+ /** What the component returns, when the skill said and it is not a plain tag. */
103
+ root) {
99
104
  const parts = {};
100
105
  const composes = [];
101
106
  const external = [];
@@ -147,12 +152,22 @@ export function resolveAnatomy(read, declared, deps) {
147
152
  // on one would be us claiming styles that belong to somebody else.
148
153
  if (FRONTIERS.has(as)) {
149
154
  if (as === "component") {
150
- const ref = String(node.ref ?? "").trim();
151
- if (!ref)
155
+ const name = String(node.ref ?? "").trim();
156
+ if (!name)
152
157
  continue;
153
- if (!composes.includes(ref))
154
- composes.push(ref);
155
- out.push({ as, ref });
158
+ if (!composes.includes(name))
159
+ composes.push(name);
160
+ /**
161
+ * THE CROSSWALK DECIDES WHAT THIS REACHES.
162
+ *
163
+ * `Tag` is bucketed `nearly` with canonical `badge`; kebab-casing it
164
+ * looks for `ds-tag` and finds nothing. Without a resolver we keep the
165
+ * kebab, which is what a system generated by us would want anyway.
166
+ */
167
+ const slug = resolve?.(name) ?? safePartName(name);
168
+ out.push(slug && slug !== safePartName(name)
169
+ ? { as, ref: slug, refName: name }
170
+ : { as, ref: slug || safePartName(name) });
156
171
  continue;
157
172
  }
158
173
  const from = String(node.from ?? "").trim();
@@ -184,6 +199,19 @@ export function resolveAnatomy(read, declared, deps) {
184
199
  const text = typeof node.text === "string" ? node.text.trim() : "";
185
200
  if (text)
186
201
  node_.text = text.slice(0, 120);
202
+ /**
203
+ * A SLOT KEEPS ITS STYLES AND BORROWS ITS CONTENT.
204
+ *
205
+ * Unlike the other two frontiers it is not somebody else's element - it IS
206
+ * theirs, and its border and hover belong on it. Only what goes inside is the
207
+ * caller's, so `expects` says what that is in their words instead of the
208
+ * preview drawing a blank.
209
+ */
210
+ if (as === "slot") {
211
+ const expects = typeof node.expects === "string" ? node.expects.trim() : "";
212
+ if (expects)
213
+ node_.expects = expects.slice(0, 80);
214
+ }
187
215
  // Only the two arrangers descend. A leaf with children is a leaf that was
188
216
  // labelled wrongly, and its children would render nowhere.
189
217
  if (CONTAINERS.has(as) && Array.isArray(node.children)) {
@@ -202,13 +230,40 @@ export function resolveAnatomy(read, declared, deps) {
202
230
  return out;
203
231
  };
204
232
  const tree = walk(top, 0);
233
+ /**
234
+ * THE ROOT, RESOLVED THE SAME WAY.
235
+ *
236
+ * A dotted capitalised name is somebody else's namespace - `Radio.Root`,
237
+ * `BaseDialog.Root` - so there is nothing of theirs to reach and the honest
238
+ * answer is to name the library. Anything else is a component of theirs and goes
239
+ * through the crosswalk exactly like a child frontier does.
240
+ */
241
+ let rootOut;
242
+ const rootRaw = String(root ?? "").trim();
243
+ if (rootRaw && /^[A-Z]/.test(rootRaw)) {
244
+ if (rootRaw.includes(".")) {
245
+ rootOut = { from: rootRaw };
246
+ }
247
+ else {
248
+ const slug = resolve?.(rootRaw) ?? safePartName(rootRaw);
249
+ if (slug)
250
+ rootOut = { ref: slug, name: rootRaw };
251
+ }
252
+ }
205
253
  if (coerced > 0) {
206
254
  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
255
  }
208
256
  if (budget <= 0) {
209
257
  notes.push(`the anatomy was longer than ${MAX_NODES} nodes - the rest is layout, and it is not read`);
210
258
  }
211
- return { parts, tree, composes, external, notes };
259
+ return {
260
+ parts,
261
+ tree,
262
+ composes,
263
+ external,
264
+ notes,
265
+ ...(rootOut ? { root: rootOut } : {}),
266
+ };
212
267
  }
213
268
  /**
214
269
  * 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,12 +1,12 @@
1
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, stat, 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";
7
7
  import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
8
8
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
9
- import { crosswalk, observedRules } from "../doctor/crosswalk.js";
9
+ import { crosswalk, isLibrary, observedRules } from "../doctor/crosswalk.js";
10
10
  import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
11
11
  import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
12
12
  import { diagnose, scanSource } from "../doctor/scan.js";
@@ -207,7 +207,8 @@ function distinctValues(d) {
207
207
  }
208
208
  return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
209
209
  }
210
- export async function takeCensus(root) {
210
+ export async function takeCensus(root, opts) {
211
+ const scopeLabel = opts?.scopeLabel;
211
212
  const css = await harvestOwnCss(root);
212
213
  const table = buildTable({ css, source: "yours" });
213
214
  const schemes = parseSchemeBlocks(css);
@@ -260,6 +261,48 @@ export async function takeCensus(root) {
260
261
  }
261
262
  const d = diagnose(reports);
262
263
  const inventory = tallyToInventory(tally);
264
+ /**
265
+ * THE EVIDENCE PASS - a second walk that touches ONE reading.
266
+ *
267
+ * Only the tally. Not `css`, not `declaredValues`, not `looks`, not `defined`,
268
+ * not `reports`: an app's classes are choices made from a scale, and folding
269
+ * them into the census would put the average back where the declaration goes.
270
+ *
271
+ * Deliberately its own tally so the LIBRARY QUESTION is asked of the scope
272
+ * alone. Fold the app in first and every export has a count, `isLibrary` reads
273
+ * false, and the crosswalk starts substituting the components it was just
274
+ * taught to keep.
275
+ */
276
+ const usageTally = emptyTally();
277
+ const usageSeen = new Set();
278
+ /**
279
+ * THE SCOPE'S OWN PACKAGE NAME IS INTERNAL, always.
280
+ *
281
+ * An app importing the system says `import { Button } from "@acme/ui"`, and the
282
+ * alias reader finds that name by walking the workspace for sibling manifests. It
283
+ * usually does - but when it cannot, every component the app composes files as
284
+ * THIRD-PARTY and the evidence pass counts nothing. The scope is the system by
285
+ * definition, so its name never needs discovering.
286
+ */
287
+ const scopePkg = await readFile(join(root, "package.json"), "utf8")
288
+ .then((raw) => JSON.parse(raw).name ?? null)
289
+ .catch(() => null);
290
+ const shared = scopePkg ? [...internal, scopePkg] : internal;
291
+ for (const u of opts?.usage ?? []) {
292
+ const uInternal = await internalSpecifiers(u.path);
293
+ for await (const file of walk(u.path)) {
294
+ const src = await readFile(file, "utf8").catch(() => "");
295
+ if (!src)
296
+ continue;
297
+ const rel = join(u.label, relative(u.path, file));
298
+ if (/(\.(spec|test|stories)\.[a-z]+$|__tests__\/|(^|\/)\.storybook\/)/.test(rel)) {
299
+ continue;
300
+ }
301
+ usageSeen.add(rel);
302
+ scanComponentsInto(usageTally, rel, src, [...shared, ...uInternal]);
303
+ }
304
+ }
305
+ const usedThere = tallyToInventory(usageTally, 400);
263
306
  /**
264
307
  * THE TWO READINGS BECOME ONE LIST before anything is judged.
265
308
  *
@@ -285,6 +328,9 @@ export async function takeCensus(root) {
285
328
  ...Object.fromEntries(d.flags.map((f) => [f, ["true"]])),
286
329
  },
287
330
  propFiles: {},
331
+ // Their own rung, off the folder. It travels from the DEFINITION because
332
+ // that is the only reading that knows which file the component lives in.
333
+ ...(d.tier ? { tier: d.tier } : {}),
288
334
  }));
289
335
  /**
290
336
  * A composed component that is ALSO defined keeps its usage and gains the
@@ -306,20 +352,104 @@ export async function takeCensus(root) {
306
352
  const declaredBy = new Map(defined.map((d) => [d.name, d]));
307
353
  const enriched = inventory.map((c) => {
308
354
  const d = declaredBy.get(c.name);
309
- if (!d || Object.keys(d.axes).length === 0)
355
+ if (!d)
310
356
  return c;
311
- return { ...c, declaredAxes: d.axes };
357
+ const tier = d.tier ? { tier: d.tier } : {};
358
+ if (Object.keys(d.axes).length === 0) {
359
+ return Object.keys(tier).length > 0 ? { ...c, ...tier } : c;
360
+ }
361
+ return { ...c, declaredAxes: d.axes, ...tier };
312
362
  });
313
- const merged = [...enriched, ...fromTypes];
363
+ const scoped = [...enriched, ...fromTypes];
364
+ /**
365
+ * THE EVIDENCE, FOLDED IN - counts and chosen values, never new components.
366
+ *
367
+ * A component the app has and the system does not is the app's own, and adopting
368
+ * it here would make the system a union of two codebases. It is reported instead:
369
+ * a number somebody can act on beats a component nobody asked for.
370
+ *
371
+ * The counts ADD because they are the same act measured in two places, and the
372
+ * prop values union because both are things somebody typed. `propFiles` is what
373
+ * the laws read, and it is the reason this pass exists: three files agreeing is
374
+ * what makes a habit, and a library has one file per component.
375
+ */
376
+ const own = new Set(scoped.map((c) => c.name));
377
+ const evidence = new Map(usedThere.filter((c) => !c.from).map((c) => [c.name, c]));
378
+ const merged = scoped.map((c) => {
379
+ const e = evidence.get(c.name);
380
+ if (!e)
381
+ return c;
382
+ /**
383
+ * A COMPONENT COMPOSED NOWHERE IN THE SCOPE carries its DECLARATION in `props`,
384
+ * because for a library that is the only reading there is. The moment real usage
385
+ * arrives, that declaration has to move where declarations live - otherwise the
386
+ * two readings merge and `props` stops meaning "what the code passed".
387
+ *
388
+ * It is the same mistake `declaredAxes` exists to prevent, arriving from the
389
+ * other side: unioned, a Button declaring solid|ghost and only ever passed solid
390
+ * reports two options, and the law that all four files agree can never be
391
+ * written.
392
+ */
393
+ const declaredOnly = (c.count ?? 0) === 0;
394
+ const props = declaredOnly ? {} : { ...c.props };
395
+ for (const [prop, values] of Object.entries(e.props)) {
396
+ props[prop] = [...new Set([...(props[prop] ?? []), ...values])];
397
+ }
398
+ const declaredAxes = c.declaredAxes ?? (declaredOnly ? c.props : undefined);
399
+ const propFiles = { ...(c.propFiles ?? {}) };
400
+ for (const [prop, n] of Object.entries(e.propFiles ?? {})) {
401
+ propFiles[prop] = (propFiles[prop] ?? 0) + n;
402
+ }
403
+ return {
404
+ ...c,
405
+ count: (c.count ?? 0) + (e.count ?? 0),
406
+ files: (c.files ?? 0) + (e.files ?? 0),
407
+ props,
408
+ propFiles,
409
+ ...(declaredAxes && Object.keys(declaredAxes).length > 0
410
+ ? { declaredAxes }
411
+ : {}),
412
+ };
413
+ });
414
+ const theirsOnly = usedThere.filter((c) => !c.from && !own.has(c.name));
314
415
  // The verdict travels WITH the payload: the platform reads one reading rather
315
416
  // than computing a second opinion from the same numbers, which is how two
316
417
  // implementations of the same judgement start disagreeing.
317
- const verdicts = new Map(crosswalk(merged).map((r) => [
418
+ /**
419
+ * IS THIS SCOPE A LIBRARY? The answer changes what the crosswalk is FOR.
420
+ *
421
+ * In an app, "your Pill reads as our badge" saves them a duplicate. In a library it
422
+ * would delete their component - and 16 of 37 in a real one were deleted that way
423
+ * (dono, 01/08). Measured, not guessed: a library declares and does not compose
424
+ * itself. See `isLibrary`.
425
+ */
426
+ const library = isLibrary(scoped);
427
+ const verdicts = new Map(crosswalk(merged, { library }).map((r) => [
318
428
  r.component.name,
319
429
  { bucket: r.bucket, canonical: r.canonical, because: r.because },
320
430
  ]));
431
+ if (usedThere.length > 0) {
432
+ console.log("");
433
+ console.log(body(`Read ${usageSeen.size} more file${usageSeen.size === 1 ? "" : "s"} for EVIDENCE only - ${(opts?.usage ?? []).map((u) => paint.strong(u.label)).join(", ")}. What comes from there is how often each component is used and which values get picked, which is what turns a settled choice into a law. The palette, the scales and the convention stay from ${paint.strong(scopeLabel ?? "the system")}: an app's average is not a scale, it is a set of choices made from one.`));
434
+ if (theirsOnly.length > 0) {
435
+ const shown = theirsOnly.slice(0, 6).map((c) => c.name);
436
+ console.log(body(paint.faint(`(${theirsOnly.length} component${theirsOnly.length === 1 ? "" : "s"} live there and not in the system - ${shown.join(", ")}${theirsOnly.length > shown.length ? ", …" : ""}. Left out on purpose: the system is one place, and a union of two codebases is not a system. Point --scope at them if they belong in it.)`)));
437
+ }
438
+ }
439
+ if (library) {
440
+ console.log("");
441
+ console.log(body(`This scope DECLARES its components and composes almost none of them inside itself - which is what a component library looks like from the inside. So every component it exports arrives as YOURS, with its own recipe. Where one reads like something in our catalogue, that is recorded as a note instead of replacing it.`));
442
+ }
443
+ /**
444
+ * THE LAWS READ THE MERGED LIST, not the scope's.
445
+ *
446
+ * `RULE_MIN_FILES` is three, and a library has one file per component - so
447
+ * measured against the scope alone, "every file that passes it agrees" can never
448
+ * be true and the habit rung stays empty forever. The app is where the agreeing
449
+ * happens, which is the whole reason `--usage` exists.
450
+ */
321
451
  const laws = new Map();
322
- for (const r of observedRules(inventory)) {
452
+ for (const r of observedRules(merged)) {
323
453
  laws.set(r.component, [...(laws.get(r.component) ?? []), r.text]);
324
454
  }
325
455
  const components = merged.map((c) => ({
@@ -361,6 +491,9 @@ export async function takeCensus(root) {
361
491
  observed: distinctValues(d),
362
492
  ...(components.length > 0 ? { components } : {}),
363
493
  ...(defined.length > 0 ? { defined } : {}),
494
+ ...((opts?.usage ?? []).length > 0
495
+ ? { usage: (opts?.usage ?? []).map((u) => u.label) }
496
+ : {}),
364
497
  totals: {
365
498
  scanned: d.scanned,
366
499
  values: d.findings.length,
@@ -670,9 +803,22 @@ async function sayIfWorkspace(root) {
670
803
  console.log(body("That is a fine DIAGNOSIS and a poor system: a light app and a dark one"));
671
804
  console.log(body("average into a palette that is neither."));
672
805
  console.log("");
806
+ /**
807
+ * BOTH ROLES IN ONE LINE, because a person reading this has both folders open.
808
+ *
809
+ * `--scope` alone is where the library came back with no laws at all: the system
810
+ * is one place, but the EVIDENCE that a choice is settled is somewhere else, and
811
+ * naming only the first left the second unreachable (dono, 01/08).
812
+ */
813
+ const usageFlags = apps
814
+ .slice(0, 2)
815
+ .map((a) => `--usage ${a}`)
816
+ .join(" ");
673
817
  if (shared.length > 0) {
674
818
  console.log(body("The shape that works, if your vocabulary is shared:"));
675
- console.log(body(` ${paint.strong(`synthesisui import --scope ${shared[0]}`)} ${paint.faint("the system comes from here")}`));
819
+ console.log(body(` ${paint.strong(`synthesisui import --scope ${shared[0]} ${usageFlags}`)}`));
820
+ console.log(body(paint.faint(` --scope is the SYSTEM: the tokens, the components, the way you name a class. One place.`)));
821
+ console.log(body(paint.faint(` --usage is the EVIDENCE: how often each component is used and which values get picked. As many as you have.`)));
676
822
  }
677
823
  else {
678
824
  console.log(body("The shape that works:"));
@@ -924,10 +1070,28 @@ async function resolveReadParts(census, root) {
924
1070
  let edges = 0;
925
1071
  const libs = new Set();
926
1072
  const notes = new Set();
1073
+ /**
1074
+ * WHAT A NAME IN THEIR CODE REACHES IN THE SYSTEM - from the crosswalk.
1075
+ *
1076
+ * The census already decided this for every component it read: `Tag` is
1077
+ * `nearly`, canonical `badge`; `ToolbarButton` is `nearly`, canonical `button`;
1078
+ * `Card` `exists` as `card`. The first version of the frontier kebab-cased the
1079
+ * name and looked for `ds-tag`, which does not exist - so a component whose
1080
+ * recipe was sitting right there drew as a grey chip (dono, 01/08).
1081
+ */
1082
+ const crosswalked = new Map();
1083
+ for (const c of census.components ?? []) {
1084
+ if (c.from)
1085
+ continue; // a package's component is not one of theirs
1086
+ const target = c.canonical ?? (c.bucket === "exclusive" ? c.name : null);
1087
+ if (target)
1088
+ crosswalked.set(c.name, safePartName(target));
1089
+ }
1090
+ const resolveRef = (name) => crosswalked.get(name) ?? null;
927
1091
  for (const [component, entry] of Object.entries(read)) {
928
1092
  const anatomy = entry?.anatomy;
929
1093
  const resolved = Array.isArray(anatomy) && anatomy.length > 0
930
- ? resolveAnatomy(anatomy, declared, deps)
1094
+ ? resolveAnatomy(anatomy, declared, deps, resolveRef, entry?.root)
931
1095
  : Array.isArray(entry?.parts) && entry.parts.length > 0
932
1096
  ? resolveFlatParts(entry.parts, declared)
933
1097
  : null;
@@ -942,6 +1106,7 @@ async function resolveReadParts(census, root) {
942
1106
  ...(resolved.tree.length > 0 ? { tree: resolved.tree } : {}),
943
1107
  ...(resolved.composes.length > 0 ? { composes: resolved.composes } : {}),
944
1108
  ...(resolved.external.length > 0 ? { external: resolved.external } : {}),
1109
+ ...(resolved.root ? { root: resolved.root } : {}),
945
1110
  };
946
1111
  looks[component] = look
947
1112
  ? {
@@ -998,11 +1163,40 @@ export async function runImport(opts) {
998
1163
  * decisions live in one package and the governance belongs at the root, and
999
1164
  * conflating the two meant every re-measure scattered another census.
1000
1165
  */
1001
- const scope = opts.scope
1002
- ?.trim()
1166
+ const tidy = (p) => p
1167
+ .trim()
1003
1168
  .replace(/^\.\/+/, "")
1004
1169
  .replace(/\/+$/, "");
1170
+ const scope = opts.scope ? tidy(opts.scope) : undefined;
1005
1171
  const readFrom = scope ? join(root, scope) : root;
1172
+ /**
1173
+ * THE SECOND ROLE. `--scope` is the SYSTEM - one place, and the only source of
1174
+ * the palette, the scales and the convention. `--usage` is the EVIDENCE - as
1175
+ * many places as they have, and the only source of counts, chosen values and
1176
+ * laws.
1177
+ *
1178
+ * They were one flag, and one flag could only answer one of the two questions.
1179
+ * Pointed at a real library, `usage` came back empty on all 23 components and
1180
+ * the habit rung of the ladder was unreachable by construction: a law needs
1181
+ * three files that agree and a library has one file per component (dono, 01/08).
1182
+ */
1183
+ const usage = [];
1184
+ for (const raw of opts.usage ?? []) {
1185
+ const label = tidy(raw);
1186
+ if (!label || label === scope)
1187
+ continue;
1188
+ const path = join(root, label);
1189
+ const ok = await stat(path)
1190
+ .then((st) => st.isDirectory())
1191
+ .catch(() => false);
1192
+ if (!ok) {
1193
+ console.log(section("Import"));
1194
+ console.log(body(`\`--usage ${label}\` is not a folder under ${paint.strong(root)}. Nothing was read.`));
1195
+ return;
1196
+ }
1197
+ if (!usage.some((u) => u.label === label))
1198
+ usage.push({ path, label });
1199
+ }
1006
1200
  // A census handed to us (an agent annotated it) is sent as-is; the numbers
1007
1201
  // inside were still ours to begin with.
1008
1202
  let census;
@@ -1041,7 +1235,10 @@ export async function runImport(opts) {
1041
1235
  }
1042
1236
  else {
1043
1237
  console.log(section("Reading your project"));
1044
- census = await takeCensus(readFrom);
1238
+ census = await takeCensus(readFrom, {
1239
+ ...(usage.length > 0 ? { usage } : {}),
1240
+ ...(scope ? { scopeLabel: scope } : {}),
1241
+ });
1045
1242
  if (scope)
1046
1243
  census.scope = scope;
1047
1244
  }
@@ -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,118 @@ 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
+ // THEIR OWN RUNG, when their repo has one. An agent about to compose this should
236
+ // know whether it is a primitive or a whole region before it starts.
237
+ if (recipe.tier) {
238
+ out.push(recipe.tier === "atom"
239
+ ? "An atom in your own ladder - nothing else in the system breaks down into it."
240
+ : recipe.tier === "molecule"
241
+ ? "A molecule in your own ladder - built out of your atoms, so reach for those inside it."
242
+ : "An organism in your own ladder - a whole region of a screen, so it is more likely to be composed than to be composed into.");
243
+ }
244
+ // WHAT IT SITS ON. 17 of 23 components in a real library carry no surface of
245
+ // their own, because the surface belongs to what they return.
246
+ const p = recipe.preview;
247
+ if (p?.rootRef) {
248
+ 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.`);
249
+ }
250
+ else if (p?.rootFrom) {
251
+ out.push("", `Sits on: ${p.rootFrom} - a third-party root. Its markup and behaviour are that library's.`);
252
+ }
253
+ // WHAT IT IS MADE OF, and where it ends.
254
+ const composes = [];
255
+ const libs = [];
256
+ const shape = [];
257
+ const walk = (nodes, depth) => {
258
+ for (const raw of nodes) {
259
+ const n = raw;
260
+ const pad = " ".repeat(depth + 1);
261
+ if (n.as === "component" && n.ref) {
262
+ shape.push(`${pad}<${n.ref}>${n.refName && n.refName !== n.ref ? ` (your ${n.refName})` : ""}`);
263
+ if (!composes.includes(n.ref))
264
+ composes.push(n.ref);
265
+ }
266
+ else if (n.as === "external" && n.from) {
267
+ shape.push(`${pad}${n.from} (third party - not ours to render)`);
268
+ if (!libs.includes(n.from))
269
+ libs.push(n.from);
270
+ }
271
+ else {
272
+ shape.push(`${pad}${n.as}${n.part ? ` .${name}-${n.part}` : ""}`);
273
+ }
274
+ if (Array.isArray(n.children))
275
+ walk(n.children, depth + 1);
276
+ }
277
+ };
278
+ if (Array.isArray(p?.parts) && p.parts.length > 0) {
279
+ walk(p.parts, 0);
280
+ out.push("", "Made of:", ...shape);
281
+ }
282
+ else if (recipe.parts && Object.keys(recipe.parts).length > 0) {
283
+ out.push("", `Parts: ${Object.keys(recipe.parts).join(", ")} - compose them inside it.`);
284
+ }
285
+ if (composes.length > 0) {
286
+ 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.`);
287
+ }
288
+ /**
289
+ * WHAT IT NEEDS INSTALLED - the whole reason this tool earns its place.
290
+ *
291
+ * From `requires.json`, filtered by the stack at install time, so the answer is
292
+ * about THIS project. Never installs: a dependency has a licence, a bundle cost
293
+ * and a maintainer attached, so the agent reports and the person decides.
294
+ */
295
+ const needed = requires.filter((r) => (r.applies ?? []).includes(name));
296
+ if (needed.length > 0 || libs.length > 0) {
297
+ const named = [
298
+ ...new Set([...needed.map((r) => r.requires), ...libs]),
299
+ ];
300
+ 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.");
301
+ for (const r of needed) {
302
+ if (r.pinned)
303
+ out.push(` ${r.requires} ${r.pinned}`);
304
+ }
305
+ }
306
+ // THE RULES, scoped. A relation names both, which is what an agent needs in
307
+ // order not to assemble it wrongly.
308
+ const laws = [...(recipe.usage ?? [])];
309
+ if (laws.length > 0) {
310
+ out.push("", "Rules for this component:", ...laws.map((l) => ` ${l}`));
311
+ }
312
+ if (needed.length > 0) {
313
+ out.push(...needed.map((r) => ` ${r.text}`));
314
+ }
315
+ if (laws.length === 0 && needed.length === 0) {
316
+ 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.");
317
+ }
318
+ return out.join("\n");
319
+ }
194
320
  async function addComponent(root, name) {
195
321
  const { table } = await loadSystem(root);
196
322
  if (!table.slug)
@@ -207,6 +333,8 @@ async function callTool(root, name, args) {
207
333
  return text(await findToken(root, String(args.value ?? "")));
208
334
  case "list_components":
209
335
  return text(await listComponents(root));
336
+ case "describe_component":
337
+ return text(await describeComponent(root, String(args.name ?? "")));
210
338
  case "add_component":
211
339
  return text(await addComponent(root, String(args.name ?? "")));
212
340
  case "request_component": {