synthesisui 0.16.79 → 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"]);
@@ -198,6 +199,19 @@ root) {
198
199
  const text = typeof node.text === "string" ? node.text.trim() : "";
199
200
  if (text)
200
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
+ }
201
215
  // Only the two arrangers descend. A leaf with children is a leaf that was
202
216
  // labelled wrongly, and its children would render nowhere.
203
217
  if (CONTAINERS.has(as) && Array.isArray(node.children)) {
@@ -1,4 +1,4 @@
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
3
  import { resolveAnatomy, resolveFlatParts, safePartName, } from "../anatomy-read.js";
4
4
  import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
@@ -6,7 +6,7 @@ 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 };
362
+ });
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
+ };
312
413
  });
313
- const merged = [...enriched, ...fromTypes];
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:"));
@@ -1017,11 +1163,40 @@ export async function runImport(opts) {
1017
1163
  * decisions live in one package and the governance belongs at the root, and
1018
1164
  * conflating the two meant every re-measure scattered another census.
1019
1165
  */
1020
- const scope = opts.scope
1021
- ?.trim()
1166
+ const tidy = (p) => p
1167
+ .trim()
1022
1168
  .replace(/^\.\/+/, "")
1023
1169
  .replace(/\/+$/, "");
1170
+ const scope = opts.scope ? tidy(opts.scope) : undefined;
1024
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
+ }
1025
1200
  // A census handed to us (an agent annotated it) is sent as-is; the numbers
1026
1201
  // inside were still ours to begin with.
1027
1202
  let census;
@@ -1060,7 +1235,10 @@ export async function runImport(opts) {
1060
1235
  }
1061
1236
  else {
1062
1237
  console.log(section("Reading your project"));
1063
- census = await takeCensus(readFrom);
1238
+ census = await takeCensus(readFrom, {
1239
+ ...(usage.length > 0 ? { usage } : {}),
1240
+ ...(scope ? { scopeLabel: scope } : {}),
1241
+ });
1064
1242
  if (scope)
1065
1243
  census.scope = scope;
1066
1244
  }
@@ -232,6 +232,15 @@ async function describeComponent(root, name) {
232
232
  const out = [
233
233
  `${name}${recipe.description ? ` - ${recipe.description}` : ""}`,
234
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
+ }
235
244
  // WHAT IT SITS ON. 17 of 23 components in a real library carry no surface of
236
245
  // their own, because the surface belongs to what they return.
237
246
  const p = recipe.preview;
@@ -516,6 +516,7 @@ const FORM_TAG = {
516
516
  icon: { tag: "span" },
517
517
  row: { tag: "div" },
518
518
  stack: { tag: "div" },
519
+ slot: { tag: "div" },
519
520
  };
520
521
  /** Only these two arrange; the rest are leaves. */
521
522
  const ARRANGES = new Set(["row", "stack"]);
@@ -555,6 +556,16 @@ function emitTree(nodes, comp, indent) {
555
556
  lines.push(`${indent}{/* ${node.from} renders here - a third party's component, so its markup is theirs */}`);
556
557
  continue;
557
558
  }
559
+ // The caller's content, so the generated code takes it as a prop rather than
560
+ // inventing a sample. A slot that also carries styles keeps its part element
561
+ // around the children; a bare one is just `{children}`.
562
+ if (node.as === "slot") {
563
+ const inner = `{children}${node.expects ? ` {/* ${node.expects} */}` : ""}`;
564
+ lines.push(node.part
565
+ ? `${indent}<${comp}${pascal(node.part)}>${inner}</${comp}${pascal(node.part)}>`
566
+ : `${indent}${inner}`);
567
+ continue;
568
+ }
558
569
  if (!node.part) {
559
570
  // Pure structure with no styles of its own: keep the arrangement, skip the
560
571
  // element - a div that carries nothing is a div nobody needs.
@@ -13,17 +13,30 @@ const LITERAL_PROP = /([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*\{?\s*["']([^"']*)["']\s*\}
13
13
  /** `dense` with no value is a boolean prop set to true, and that IS a literal
14
14
  * decision worth counting. */
15
15
  const BARE_PROP = /(^|\s)([a-z][a-zA-Z0-9_]*)(?=\s|$)/g;
16
+ /**
17
+ * A KEYWORD IS NOT AN IDENTIFIER, and the difference decides whether a `<` opens
18
+ * an element. `return <Card>one</Card>` is a component; `useState<Foo>()` is a
19
+ * type argument. Both are preceded by a word.
20
+ *
21
+ * Prettier writes `return <Foo />;` on one line whenever it fits, so without this
22
+ * every short component written that way read as a generic and vanished from the
23
+ * inventory - which is how a spec for something else caught it.
24
+ */
25
+ const JSX_BEFORE = /\b(return|case|default|await|yield|typeof|in|of|do|else|new)$/;
16
26
  /**
17
27
  * A generic in type position (`useState<Foo>()`, `Array<Item>`) is not an
18
28
  * element. The tell is what precedes the `<`: an identifier or a closing paren
19
- * means a type argument, while JSX follows `(`, `{`, `>`, a newline or nothing.
29
+ * means a type argument, while JSX follows `(`, `{`, `>`, a newline, a keyword or
30
+ * nothing.
20
31
  */
21
32
  function looksLikeType(source, at) {
22
33
  for (let i = at - 1; i >= 0; i--) {
23
34
  const c = source[i];
24
35
  if (c === " " || c === "\t")
25
36
  continue;
26
- return /[A-Za-z0-9_)\]]/.test(c);
37
+ if (!/[A-Za-z0-9_)\]]/.test(c))
38
+ return false;
39
+ return !JSX_BEFORE.test(source.slice(Math.max(0, i - 11), i + 1));
27
40
  }
28
41
  return false;
29
42
  }
@@ -81,6 +94,56 @@ function importedNames(source, internal = []) {
81
94
  }
82
95
  return out;
83
96
  }
97
+ /**
98
+ * Capitalised names a file BINDS ITSELF and does not export.
99
+ *
100
+ * `<Component>` and `<CustomLegend/>` both arrived in a real library's inventory as
101
+ * components of the system (dono, 01/08). Neither is one:
102
+ *
103
+ * function Text({ as: Component = "p" }) // React's polymorphic idiom -
104
+ * // `Component` is a prop rename
105
+ * const CustomLegend = () => (…) // a closure inside LineChart
106
+ *
107
+ * Capitalisation is React's rule for "not an html tag", which is not the same
108
+ * question as "is this a component the system offers". The test that separates them
109
+ * is REACHABILITY: a component of the system is either imported from somewhere or
110
+ * exported to somewhere. A name that only exists inside one function body is that
111
+ * function's private wiring, and giving it a recipe invents a component nobody can
112
+ * import.
113
+ *
114
+ * Local AND exported stays, of course - that is just a component defined where it
115
+ * is used.
116
+ */
117
+ const BOUND = /(?:const|let|var|function|class)\s+([A-Z][A-Za-z0-9_]*)|[{,]\s*\w+\s*:\s*([A-Z][A-Za-z0-9_]*)\s*(?:=[^,}]*)?\s*[,}]/g;
118
+ const EXPORTED_NAME = /export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z][A-Za-z0-9_]*)|export\s+default\s+([A-Z][A-Za-z0-9_]*)|export\s*\{([^}]*)\}/g;
119
+ export function localOnlyNames(source) {
120
+ const exported = new Set();
121
+ EXPORTED_NAME.lastIndex = 0;
122
+ for (const m of source.matchAll(EXPORTED_NAME)) {
123
+ if (m[1])
124
+ exported.add(m[1]);
125
+ if (m[2])
126
+ exported.add(m[2]);
127
+ if (m[3]) {
128
+ for (const raw of m[3].split(",")) {
129
+ const name = raw
130
+ .trim()
131
+ .split(/\s+as\s+/)[0]
132
+ ?.trim();
133
+ if (name && /^[A-Z]/.test(name))
134
+ exported.add(name);
135
+ }
136
+ }
137
+ }
138
+ const out = new Set();
139
+ BOUND.lastIndex = 0;
140
+ for (const m of source.matchAll(BOUND)) {
141
+ const name = m[1] ?? m[2];
142
+ if (name && !exported.has(name))
143
+ out.add(name);
144
+ }
145
+ return out;
146
+ }
84
147
  export function emptyTally() {
85
148
  return new Map();
86
149
  }
@@ -92,11 +155,16 @@ internal = []) {
92
155
  if (!/\.(tsx|jsx|vue|svelte)$/i.test(file))
93
156
  return;
94
157
  const owners = importedNames(source, internal);
158
+ const private_ = localOnlyNames(source);
95
159
  ELEMENT.lastIndex = 0;
96
160
  for (const m of source.matchAll(ELEMENT)) {
97
161
  if (m.index != null && looksLikeType(source, m.index))
98
162
  continue;
99
163
  const name = m[1];
164
+ // Private wiring, not a component of the system. An import wins: a file may
165
+ // shadow nothing and still bind a name it also imports.
166
+ if (!owners.has(name) && private_.has(name.split(".")[0]))
167
+ continue;
100
168
  const body = m[2] ?? "";
101
169
  let hit = tally.get(name);
102
170
  if (!hit) {
@@ -304,8 +304,45 @@ function sharedMeaning(a, b) {
304
304
  n += 1;
305
305
  return n;
306
306
  }
307
- export function crosswalk(components) {
307
+ /**
308
+ * WHEN THE SOURCE IS THE LIBRARY, EVERY COMPONENT IS THEIRS.
309
+ *
310
+ * The crosswalk was designed for an APP that uses components: "your Pill reads as
311
+ * our badge, use ours" is exactly right there, because it stops a duplicate. When the
312
+ * source IS the component library, the premise inverts - their library IS the design
313
+ * system, and mapping their component onto ours is us overwriting theirs with ours.
314
+ * The opposite of entering as a complement.
315
+ *
316
+ * Measured on a real library (dono, 01/08): of 37 components they export, 16 never
317
+ * became theirs. `Label`, `Pill` and `Tag` - three distinct components - collapsed
318
+ * into one `ds-badge` of ours, which is why their Tag previewed as the word "Pill":
319
+ * that is OUR recipe's sample text. `LineChart`, a real organism, simply was not in
320
+ * the system.
321
+ *
322
+ * The code already knew. `describeOrigin` says it in prose - "declared in
323
+ * `packages/ui` and composed nowhere inside it, which is what a component library
324
+ * looks like from the inside" - and then acted as if it were an app.
325
+ *
326
+ * THE SIGNAL, and it is not a guess: a library declares and does not compose itself.
327
+ * When most of what a scope declares is composed nowhere inside it, the scope is a
328
+ * library. The crosswalk still runs and its verdict still travels as INFORMATION -
329
+ * "this reads as our badge" is worth knowing - it just stops replacing anything.
330
+ */
331
+ export function isLibrary(components) {
332
+ const mine = components.filter((c) => !c.from);
333
+ if (mine.length < 4)
334
+ return false;
335
+ const uncomposed = mine.filter((c) => (c.count ?? 0) === 0).length;
336
+ return uncomposed / mine.length >= 0.5;
337
+ }
338
+ export function crosswalk(components,
339
+ /**
340
+ * Their library is the system. Every component keeps its own recipe, and the
341
+ * catalogue match becomes a note rather than a substitution.
342
+ */
343
+ opts) {
308
344
  const mine = components.filter((c) => !c.from);
345
+ const library = opts?.library ?? false;
309
346
  // Two of THEIR components reading as the same catalogue entry is the
310
347
  // strongest redundancy signal there is, and it needs no threshold: it is not
311
348
  // "these look alike", it is "you wrote this twice". The first run filed both
@@ -344,6 +381,24 @@ export function crosswalk(components) {
344
381
  .sort((a, b) => b.overlap - a.overlap);
345
382
  if (canonical && CATALOGUE[canonical]) {
346
383
  const alsoClaiming = (claims.get(canonical) ?? []).filter((n) => n !== component.name);
384
+ /**
385
+ * LIBRARY MODE: the match is a NOTE, not a verdict.
386
+ *
387
+ * Their component stays exclusive and keeps its own recipe; `canonical` still
388
+ * travels so a preview can resolve a frontier and so the v2 can say "these
389
+ * three read alike". What stops happening is their `Label` ceasing to exist.
390
+ */
391
+ if (library) {
392
+ return {
393
+ component,
394
+ canonical,
395
+ twins,
396
+ bucket: "exclusive",
397
+ because: alsoClaiming.length > 0
398
+ ? `yours, and it reads like ds-${canonical} - so do ${alsoClaiming.join(", ")}, which is one decision written ${alsoClaiming.length + 1} times`
399
+ : `yours, and it reads like ds-${canonical} - kept as yours because this library IS the system`,
400
+ };
401
+ }
347
402
  if (alsoClaiming.length > 0) {
348
403
  return {
349
404
  component,
@@ -369,7 +424,9 @@ export function crosswalk(components) {
369
424
  component,
370
425
  canonical,
371
426
  twins,
372
- bucket: "nearly",
427
+ // A twin is a v2 observation either way; in library mode it must not cost
428
+ // the component its own recipe.
429
+ bucket: library ? "exclusive" : "nearly",
373
430
  because: `shares ${Math.round(twins[0].overlap * 100)}% of its options with ${twins[0].name} - one decision, two components`,
374
431
  };
375
432
  }
@@ -20,6 +20,36 @@
20
20
  * actually write and stays silent on the rest: a props type built by `Omit<>` or
21
21
  * spread from another interface yields no axes rather than wrong ones.
22
22
  */
23
+ /**
24
+ * THEIR OWN LADDER, READ OFF THE PATH - never guessed from a name.
25
+ *
26
+ * A real library keeps `atoms/Text/Text.tsx`, `molecules/MetricCard/`,
27
+ * `organisms/DataTable/`: the author saying out loud what composes what, in the
28
+ * vocabulary the whole industry already shares. Twenty-three components arrived in
29
+ * one flat bucket and the information was sitting in the path (dono, 01/08).
30
+ *
31
+ * A path segment, not a substring: `src/atomsphere/` is a folder called
32
+ * atomsphere. Returns undefined for the projects that do not organise themselves
33
+ * this way, which is most of them - and undefined means "no ladder here", not
34
+ * "unclassified".
35
+ */
36
+ const TIER_FOLDER = {
37
+ atoms: "atom",
38
+ atom: "atom",
39
+ primitives: "atom",
40
+ molecules: "molecule",
41
+ molecule: "molecule",
42
+ organisms: "organism",
43
+ organism: "organism",
44
+ };
45
+ export function tierOf(file) {
46
+ for (const segment of file.split(/[/\\]/)) {
47
+ const tier = TIER_FOLDER[segment.toLowerCase()];
48
+ if (tier)
49
+ return tier;
50
+ }
51
+ return undefined;
52
+ }
23
53
  /** `export function X`, `export default function X`, `export const X =`. */
24
54
  const EXPORTED = /export\s+(?:default\s+)?(?:async\s+)?(?:function\s+([A-Z][A-Za-z0-9_]*)|const\s+([A-Z][A-Za-z0-9_]*)\s*[:=])/g;
25
55
  /** `type ButtonProps = { … }` or `interface ButtonProps { … }`, to its closing
@@ -78,11 +108,13 @@ export function scanDefinitions(file, source) {
78
108
  continue;
79
109
  seen.add(name);
80
110
  const props = propsOf.get(name);
111
+ const tier = tierOf(file);
81
112
  out.push({
82
113
  name,
83
114
  file,
84
115
  axes: props?.axes ?? {},
85
116
  flags: props?.flags ?? [],
117
+ ...(tier ? { tier } : {}),
86
118
  });
87
119
  }
88
120
  return out;
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ Usage - deterministic, FREE:
41
41
 
42
42
  Usage - governance (deterministic, FREE):
43
43
  synthesisui adopt [--write] turn the design system you ALREADY have into a contract
44
- synthesisui import [--dry] [--scope <p>] read the app you already have and make it a system
44
+ synthesisui import [--scope <p>] [--usage <p>] read what you already have and make it a system
45
45
  your agent follows - without touching your CSS
46
46
  synthesisui connect wire your agent: the check as an editor hook, the system
47
47
  as MCP tools, and a contract that stops repeating itself
@@ -66,7 +66,8 @@ Options:
66
66
  --version <n> install a specific version (default: latest)
67
67
  --ds <slug> init: bring this DS in right away · generate: target DS (default: installed)
68
68
  --name <name> preferred component name for generate
69
- --scope <path> import: measure only this folder; the census still lands at the root
69
+ --scope <path> import: the SYSTEM - tokens, components, convention. One folder.
70
+ --usage <path> import: the EVIDENCE - counts, chosen values, laws. Repeatable.
70
71
  --scheme <s> dark|light - which end of your ladder is the default
71
72
  --target <t> template/init target: next | general (default: next)
72
73
  --pages-dir <dir> init: folder for generated pages (default: app)
@@ -111,9 +112,30 @@ Examples:
111
112
  synthesisui generate "an upgrade banner with a title, message and a primary CTA"
112
113
  `;
113
114
  /** Extracts simple `--flag value` pairs and the remaining positionals. */
115
+ /**
116
+ * A REPEATED FLAG ACCUMULATES rather than overwriting.
117
+ *
118
+ * `--usage apps/web --usage apps/admin` is the natural way to say two, and keeping
119
+ * only the last would drop one silently - which is the failure mode this codebase
120
+ * refuses everywhere else. Every existing flag is passed once and still arrives as
121
+ * a string, so nothing that reads `typeof flags.x === "string"` changes.
122
+ */
114
123
  function parseFlags(argv) {
115
124
  const positionals = [];
116
125
  const flags = {};
126
+ const put = (key, value) => {
127
+ const had = flags[key];
128
+ if (had === undefined) {
129
+ flags[key] = value;
130
+ return;
131
+ }
132
+ if (typeof value !== "string")
133
+ return;
134
+ flags[key] = [
135
+ ...(Array.isArray(had) ? had : typeof had === "string" ? [had] : []),
136
+ value,
137
+ ];
138
+ };
117
139
  for (let i = 0; i < argv.length; i++) {
118
140
  const arg = argv[i];
119
141
  if (arg === "-h" || arg === "--help") {
@@ -124,17 +146,17 @@ function parseFlags(argv) {
124
146
  const eq = body.indexOf("=");
125
147
  if (eq !== -1) {
126
148
  // `--key=value` form (e.g. --out=templates/page.tsx)
127
- flags[body.slice(0, eq)] = body.slice(eq + 1);
149
+ put(body.slice(0, eq), body.slice(eq + 1));
128
150
  }
129
151
  else {
130
152
  // `--key value` form
131
153
  const next = argv[i + 1];
132
154
  if (next !== undefined && !next.startsWith("--")) {
133
- flags[body] = next;
155
+ put(body, next);
134
156
  i++;
135
157
  }
136
158
  else {
137
- flags[body] = true;
159
+ put(body, true);
138
160
  }
139
161
  }
140
162
  }
@@ -165,6 +187,14 @@ async function main() {
165
187
  // invert a system, so it falls through to being measured and asked.
166
188
  // WHAT to read. `--dir` stays WHERE the project is, in every command.
167
189
  scope: typeof flags.scope === "string" ? flags.scope : undefined,
190
+ // WHERE THE EVIDENCE IS. Repeatable, and comma-separated works too - a
191
+ // person typing one path with a comma in it means two paths.
192
+ usage: [flags.usage]
193
+ .flat()
194
+ .filter((v) => typeof v === "string")
195
+ .flatMap((v) => v.split(","))
196
+ .map((v) => v.trim())
197
+ .filter(Boolean),
168
198
  scheme: flags.scheme === "dark" || flags.scheme === "light"
169
199
  ? flags.scheme
170
200
  : undefined,
@@ -105,22 +105,42 @@ This root holds several projects
105
105
  is the average of all of them. That is a fine DIAGNOSIS and a poor system: a
106
106
  light app and a dark one average into a palette that is neither.
107
107
 
108
- synthesisui import --scope packages/ui the system comes from here
108
+ synthesisui import --scope packages/ui --usage apps/web-dashboard
109
109
  \`\`\`
110
110
 
111
- Then measure again, narrowed:
111
+ Then measure again, narrowed - and **name both roles**:
112
112
 
113
113
  \`\`\`
114
- npx synthesisui import --dry --scope packages/ui
114
+ npx synthesisui import --dry --scope packages/ui --usage apps/web-dashboard
115
115
  \`\`\`
116
116
 
117
+ ### The two roles, and why one flag could not do both
118
+
119
+ \`\`\`
120
+ --scope the SYSTEM tokens, components, the way they name a class ONE folder
121
+ --usage the EVIDENCE how often, which values get picked, the laws as many as they have
122
+ \`\`\`
123
+
124
+ \`--usage\` is repeatable and never contributes a token, a scale or a component. That split is
125
+ not tidiness, it is the two mistakes this pipeline exists to avoid, and each one has been made:
126
+
117
127
  **Do not import the average.** Three apps measured together produce a palette that belongs to
118
128
  none of them, and it will look plausible - a real monorepo reported 36% coverage across 2797
119
129
  files at the root and 47% across 114 in its shared package. The second number is the system;
120
- the first is a diagnosis.
130
+ the first is a diagnosis. Their scale is a DECLARATION; an app's average is a set of choices
131
+ made from one.
132
+
133
+ **Do not import a library alone either.** A law needs three files that agree, and a library has
134
+ ONE file per component - so measured by itself, a real one came back with \`usage\` empty on all
135
+ 23 components and not a single observed law. Not because nothing is settled: because the
136
+ agreeing happens in the app (dono, 01/08). If they have apps, name them.
121
137
 
122
- \`--scope\` narrows what is READ. The census still lands at \`<root>/_synthesisui/census.json\`
123
- and records what it measured, so you always send the same path and there is never a second
138
+ Point \`--usage\` at every app that consumes the system. A component that lives in an app and
139
+ not in the system is reported and left out - the system is one place, and a union of two
140
+ codebases is not a system.
141
+
142
+ The census still lands at \`<root>/_synthesisui/census.json\` and records both what it measured
143
+ and where the evidence came from, so you always send the same path and there is never a second
124
144
  file to pick between. Nothing is sent by \`--dry\`. Read the file.
125
145
 
126
146
  ### 2. Read what arithmetic cannot
@@ -398,7 +418,7 @@ shell and its own parts inside.
398
418
  \`"root": "BaseDialog.Root"\`, \`"root": "Popover.Root"\`
399
419
  - **a plain tag** - leave it out. \`<div>\`, \`<td>\`, \`<button>\` are not frontiers.
400
420
 
401
- ### The nine forms
421
+ ### The ten forms
402
422
 
403
423
  \`as\` says what a node IS, and the renderer draws that. Nothing else is accepted:
404
424
 
@@ -413,10 +433,40 @@ row arranges its children ACROSS
413
433
  stack arranges its children DOWN
414
434
  component their component → needs "ref": the name as their code spells it
415
435
  external a library → needs "from": the package name
436
+ slot the CALLER's content → takes "expects": what goes there, in their words
416
437
  \`\`\`
417
438
 
418
439
  Only \`row\` and \`stack\` take \`children\`. Everything else is a leaf.
419
440
 
441
+ ### \`{children}\` is a node, not an absence
442
+
443
+ Four things decide what a component looks like on screen, and only the first is in its own source:
444
+
445
+ \`\`\`
446
+ its own classes you are reading them
447
+ what the caller passes <tr>{children}</tr> → slot
448
+ its runtime state value={62} drives the width → a part with no static size
449
+ a library drawing it <EditorContent> → external
450
+ \`\`\`
451
+
452
+ A component whose content is not its own had nothing to send, so it sent nothing and previewed as
453
+ a blank box. A real \`DataTableRow\` is sixteen lines - a \`<tr>\` carrying a border, a hover and
454
+ \`{children}\` - and it came back empty, which is not wrong so much as unreadable (dono, 01/08).
455
+
456
+ So send the slot. \`expects\` is what the caller is supposed to put there, in the words their own
457
+ code uses - read the prop type, the JSDoc, or a call site:
458
+
459
+ \`\`\`json
460
+ { "as": "slot", "name": "row", "classes": "border-b hover:bg-ocean-50/30", "expects": "the cells" }
461
+ \`\`\`
462
+
463
+ It keeps its \`name\` and \`classes\` like any other node - the border and the hover are real and
464
+ belong on that element. What it does not do is invent content: the preview says "the cells go
465
+ here", which is exactly what the file says.
466
+
467
+ Good \`expects\`: \`"the cells"\`, \`"one row per item"\`, \`"the form"\`, \`"the page body"\`. Leave it
468
+ out rather than writing \`"children"\` - the field is for what a person would call it.
469
+
420
470
  **\`name\` is the part name**, and it is what carries the styles - flat, lowercase, kebab. Never
421
471
  nested: \`"actions.generate"\` compiles to two CSS classes and is invalid, so name it
422
472
  \`"generate-action"\`. Name what it IS - \`label\`, \`value\`, \`delta\`, \`cover\`, \`toolbar\` - because the
@@ -440,10 +490,22 @@ of. It previews as a block carrying its name, and it becomes a **rule** - \`Arti
440
490
  That edge is a **fact, not a habit**: you saw it in the definition, so it is true once and for
441
491
  all, and it arrives active without waiting for a third sighting.
442
492
 
443
- Two things that are NOT edges, and sending them as such would be wrong:
493
+ Three things that are NOT edges, and sending them as such would be wrong:
444
494
 
445
495
  - \`motion.div\`, \`Dialog.Root\`, \`Radio.Item\` - a library's namespace, not a component of theirs
446
496
  - an HTML element with a capital in a variable name
497
+ - **a name the file binds itself and does not export.** Capitalisation is React's rule for "not an
498
+ html tag", which is a different question from "is this a component of the system". Both of these
499
+ arrived as components of a real library and neither is one (dono, 01/08):
500
+
501
+ \`\`\`tsx
502
+ function Text({ as: Component = "p" }) // a prop rename - the polymorphic idiom
503
+ const CustomLegend = () => (…) // a closure inside LineChart
504
+ \`\`\`
505
+
506
+ The test is reachability: a component of the system is imported from somewhere or exported to
507
+ somewhere. A name that only lives inside one function body is that function's private wiring,
508
+ and giving it a recipe invents a component nobody can import.
447
509
 
448
510
  If the component came from a package, it is \`external\`, not \`component\`.
449
511
 
@@ -497,7 +559,7 @@ spec view has no nesting to draw.
497
559
 
498
560
  ### 3. Walk them through the decisions, one at a time
499
561
 
500
- Four decisions are theirs. **Ask them as separate questions with selectable options** - use your
562
+ Five decisions are theirs. **Ask them as separate questions with selectable options** - use your
501
563
  question tool, one call per decision, so they pick instead of reading a wall and composing a
502
564
  reply. A single block containing everything is a report, and a report gets read, not answered.
503
565
 
@@ -581,6 +643,36 @@ canvas #222326 darkgray-500 - what DashboardLayout paints on <main>
581
643
  Say it and let them object. Offering five near-blacks to pick between is asking someone to
582
644
  re-decide something their code already decided.
583
645
 
646
+ **3e-bis. Our kit, or theirs alone** - ask this ONLY when they already have a component library,
647
+ which is when \`--scope\` pointed at one and the CLI said so. An app with no components of its own
648
+ has nothing to weigh and should never see the question.
649
+
650
+ \`\`\`
651
+ Every system born here comes with 45 components of ours - button, card, input,
652
+ table, the rest of a working toolkit. You already have 37.
653
+
654
+ add ours to yours (recommended) 68 components, and the 23 governed by your
655
+ own rules are the 23 you wrote
656
+ mine alone 37 components, all of them yours. Our
657
+ layouts that need a component you do not
658
+ have stay out too. Add any of ours later
659
+ from the library, one at a time
660
+ \`\`\`
661
+
662
+ Send it as \`"standards": "none"\` in the reading when they choose the second, and leave the field
663
+ out when they choose the first. **Recommend adding ours**, and say why in one line: a component of
664
+ ours costs nothing until something composes it, and having a \`table\` the day somebody needs a
665
+ table is worth more than a shorter list.
666
+
667
+ Two things to be straight about, because both are true and neither is obvious:
668
+
669
+ - **the foundations come from the seed either way.** Their palette, their scales, their type - all
670
+ theirs - but the seven type slots, the easing and the neutral floor arrive from ours, because
671
+ the alternative to a type scale is no components at all rather than their components.
672
+ - **our layouts are written in our recipes.** A landing that composes \`button\`, \`card\` and
673
+ \`badge\` needs those to exist. Choosing *mine alone* keeps the layouts whose recipes they
674
+ happen to have and drops the rest - a layout pointing at a recipe nobody has renders as holes.
675
+
584
676
  **3f. Fonts** - do not ask at all. Report and move on:
585
677
 
586
678
  \`\`\`
@@ -595,7 +687,7 @@ way. If it declares one, there is nothing to decide.
595
687
  carries the weight - \`ocean-500\` tells someone more than a square would. When they run
596
688
  \`npx synthesisui import\` themselves in a terminal, the CLI paints them.
597
689
 
598
- Then, and only after all four are answered:
690
+ Then, and only after all of them are answered:
599
691
 
600
692
  \`\`\`
601
693
  npx synthesisui import --census _synthesisui/census.json --name "<the name>" [--registry <from step 0>]
@@ -603,7 +695,7 @@ npx synthesisui import --census _synthesisui/census.json --name "<the name>" [--
603
695
 
604
696
  **This is the one real write.** Everything before it is on their disk and costs nothing to redo.
605
697
 
606
- Pass every answer explicitly - \`--name\`, \`--scope\` if they changed it, and the reading rewritten
698
+ Pass every answer explicitly - \`--name\`, \`--scope\` and \`--usage\` if they changed either, and the reading rewritten
607
699
  with their primary and their theme. The CLI cannot ask anything when you are the one running it:
608
700
  it has no terminal, so its own prompts are skipped by design. **Whatever you did not ask, nobody
609
701
  asked.** Skip 3a and the first time they learn where their system came from is when they open
@@ -627,6 +719,10 @@ proposal. Say all of this in your own words - do not just print the link:
627
719
  which half was deliberate: nothing was invented, so a component whose classes named no token
628
720
  arrives with structure and no colour. The platform shows that as *Not written yet*, not as a
629
721
  zero.
722
+ - **their own ladder**, if their repo has one. Components under \`atoms/\`, \`molecules/\` and
723
+ \`organisms/\` arrive grouped that way in the vitrine, read off the folder rather than guessed
724
+ from a name. You do not send this and you should not try to: a project that does not organise
725
+ itself this way gets one honest group instead of three invented ones.
630
726
  - **the shape of each component**, if you sent one: its parts nested as they nest in their code,
631
727
  the components of theirs it composes, and the libraries it needs. Each edge is also a rule now,
632
728
  which is what makes it survive being looked at once.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.79",
3
+ "version": "0.16.80",
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": {