synthesisui 0.16.46 → 0.16.49

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.
@@ -3,6 +3,7 @@ import { basename, join, relative } from "node:path";
3
3
  import { readToken, resolveRegistry } from "../config.js";
4
4
  import { isNearDuplicate } from "../doctor/color-distance.js";
5
5
  import { emptyTally, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
6
+ import { crosswalk } from "../doctor/crosswalk.js";
6
7
  import { diagnose, scanSource } from "../doctor/scan.js";
7
8
  import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
8
9
  import { buildTable } from "../doctor/tokens.js";
@@ -335,7 +336,18 @@ export async function takeCensus(root) {
335
336
  }
336
337
  }
337
338
  const d = diagnose(reports);
338
- const components = tallyToInventory(tally);
339
+ const inventory = tallyToInventory(tally);
340
+ // The verdict travels WITH the payload: the platform reads one reading rather
341
+ // than computing a second opinion from the same numbers, which is how two
342
+ // implementations of the same judgement start disagreeing.
343
+ const verdicts = new Map(crosswalk(inventory).map((r) => [
344
+ r.component.name,
345
+ { bucket: r.bucket, canonical: r.canonical, because: r.because },
346
+ ]));
347
+ const components = inventory.map((c) => ({
348
+ ...c,
349
+ ...(verdicts.get(c.name) ?? {}),
350
+ }));
339
351
  const pkgRaw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
340
352
  let name = null;
341
353
  if (pkgRaw) {
@@ -440,6 +452,57 @@ function printComponents(c) {
440
452
  if (mine.length > 12) {
441
453
  console.log(body(paint.faint(`(${mine.length - 12} more of yours)`)));
442
454
  }
455
+ printCrosswalk(mine);
456
+ }
457
+ /**
458
+ * The crosswalk, printed and nothing else.
459
+ *
460
+ * Three buckets, each line carrying the evidence that put it there - a name
461
+ * the catalogue knows, or a measured overlap with something they already wrote.
462
+ * Nothing is mapped, adopted or sent: the thresholds below are a first guess
463
+ * and the only way to tune them is to read this against a real codebase, which
464
+ * is exactly how the colour work found its own.
465
+ */
466
+ function printCrosswalk(mine) {
467
+ if (mine.length === 0)
468
+ return;
469
+ const rows = crosswalk(mine);
470
+ const of = (b) => rows.filter((r) => r.bucket === b);
471
+ const exists = of("exists");
472
+ const nearly = of("nearly");
473
+ const exclusive = of("exclusive");
474
+ console.log("");
475
+ console.log(section("Which of these already exist"));
476
+ const show = (title, list, limit) => {
477
+ if (list.length === 0)
478
+ return;
479
+ console.log(body(paint.strong(title)));
480
+ for (const r of list.slice(0, limit)) {
481
+ console.log(body(` ${r.component.name.padEnd(18)} ${paint.faint(`${r.component.files} files`)} ${paint.dim(r.because)}`));
482
+ }
483
+ if (list.length > limit) {
484
+ console.log(body(paint.faint(` (${list.length - limit} more)`)));
485
+ }
486
+ console.log("");
487
+ };
488
+ show(`Already in the catalogue (${exists.length})`, exists, 6);
489
+ show(`The same decision, twice (${nearly.length})`, nearly, 6);
490
+ show(`Only yours (${exclusive.length})`, exclusive, 8);
491
+ // Counted, never listed: four icons and a provider under "only yours" pad the
492
+ // one list worth reading with the least interesting thing in it.
493
+ const icons = of("icon").length;
494
+ const providers = of("provider").length;
495
+ if (icons > 0 || providers > 0) {
496
+ const bits = [
497
+ icons > 0 &&
498
+ `${icons} icon${icons === 1 ? "" : "s"} (a library, not recipes)`,
499
+ providers > 0 &&
500
+ `${providers} provider${providers === 1 ? "" : "s"} (no UI of their own)`,
501
+ ].filter(Boolean);
502
+ console.log(body(paint.faint(`Set aside: ${bits.join(" · ")}`)));
503
+ console.log("");
504
+ }
505
+ console.log(body(paint.dim("Nothing was mapped. This is a reading - the adopting is a decision you make.")));
443
506
  }
444
507
  function printAgentContract() {
445
508
  console.log("");
@@ -59,7 +59,19 @@ function looksLikeType(source, at) {
59
59
  * of copy wearing the shape of a variant axis. An axis is a closed set somebody
60
60
  * designed; a title is whatever that screen happens to say.
61
61
  */
62
- const NOT_A_DECISION = /^(class|className|style|key|ref|id|href|src|alt|type|name|value|placeholder|children|on[A-Z]|data-|aria-|title|label|text|heading|subtitle|caption|description|message|content|tooltip|hint|error|helper)$|^(class|className|on[A-Z]|data-|aria-)/;
62
+ const CONTENT_WORD = "class|className|style|key|ref|id|href|src|alt|type|name|value|placeholder|children|title|label|text|heading|subtitle|caption|description|message|content|tooltip|hint|error|helper";
63
+ /**
64
+ * Whole name, or the TAIL of a camelCase compound.
65
+ *
66
+ * `bodyClassName` and `infoContent` walked straight past a whole-name match
67
+ * (dono, 30/07), and `infoContent` brought four sentences of screen copy into
68
+ * what is supposed to be a list of variant axes. The tail is what says what a
69
+ * prop IS: `infoContent` is content, `bodyClassName` is a class name.
70
+ *
71
+ * Matching the tail rather than anywhere keeps `titleTone` - an axis whose name
72
+ * merely starts with a content word.
73
+ */
74
+ const NOT_A_DECISION = new RegExp(`^(?:${CONTENT_WORD})$|[a-z](?:${CONTENT_WORD.replace(/\b([a-z])/g, (_, c) => `[${c}${c.toUpperCase()}]`)})$|^(?:on[A-Z]|data-|aria-)`);
63
75
  /** `import X, { A, B as C } from "spec"` - who owns each local name. */
64
76
  const IMPORT = /import\s+(?:type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
65
77
  function importedNames(source, internal = []) {
@@ -0,0 +1,339 @@
1
+ /**
2
+ * THE CROSSWALK - does this component already exist, here or in the catalogue?
3
+ *
4
+ * Phase one counted what a project composes. This asks the question that
5
+ * follows and is the whole reason to ask it: which of those are things the
6
+ * design system already has, which are the same thing twice, and which are
7
+ * genuinely theirs.
8
+ *
9
+ * Three signals, strongest first, and only the third needs a person:
10
+ *
11
+ * 1. THE CANONICAL NAME. `Pill`, `Chip` and `Tag` are a badge. Cheap, and it
12
+ * settles most of the list. The table below is curated knowledge, the same
13
+ * kind as the hue lanes the colour report argues with.
14
+ * 2. THE SHAPE OF THE AXES. Two components carrying a closed set on the same
15
+ * kind of axis, with values that overlap, are the same kind of thing -
16
+ * and overlap is measurable rather than felt.
17
+ * 3. JUDGEMENT, when the first two disagree. Reported, never applied.
18
+ *
19
+ * It runs in BOTH directions on purpose. Comparing only against our catalogue
20
+ * would have missed the finding that mattered most on a real dashboard (dono,
21
+ * 30/07): `Loader` (59 uses), `SimpleLoading` (21) and `ShimmerLoader` (158)
22
+ * are three loading components in one app. Redundancy is redundancy whoever
23
+ * owns it.
24
+ */
25
+ /** Their word → the canonical thing it is. */
26
+ const SYNONYM = {
27
+ button: "button",
28
+ btn: "button",
29
+ iconbutton: "button",
30
+ toolbarbutton: "button",
31
+ badge: "badge",
32
+ pill: "badge",
33
+ chip: "badge",
34
+ tag: "badge",
35
+ label: "badge",
36
+ card: "card",
37
+ panel: "card",
38
+ tile: "card",
39
+ input: "input",
40
+ textfield: "input",
41
+ textinput: "input",
42
+ field: "input",
43
+ modal: "modal",
44
+ dialog: "modal",
45
+ modalbox: "modal",
46
+ drawer: "drawer",
47
+ sheet: "drawer",
48
+ spinner: "spinner",
49
+ loader: "spinner",
50
+ loading: "spinner",
51
+ simpleloading: "spinner",
52
+ shimmerloader: "skeleton",
53
+ skeleton: "skeleton",
54
+ shimmer: "skeleton",
55
+ tooltip: "tooltip",
56
+ popover: "popover",
57
+ avatar: "avatar",
58
+ alert: "alert",
59
+ banner: "alert",
60
+ toast: "toast",
61
+ snackbar: "toast",
62
+ tabs: "tabs",
63
+ table: "table",
64
+ datatable: "table",
65
+ select: "select",
66
+ dropdown: "select",
67
+ checkbox: "checkbox",
68
+ radio: "radio",
69
+ toggle: "switch",
70
+ switch: "switch",
71
+ link: "link",
72
+ anchor: "link",
73
+ text: "text",
74
+ typography: "text",
75
+ heading: "heading",
76
+ title: "heading",
77
+ divider: "divider",
78
+ separator: "divider",
79
+ breadcrumb: "breadcrumb",
80
+ progress: "progress",
81
+ slider: "slider",
82
+ };
83
+ /** What the toolkit ships, with the axes each one carries. Curated because the
84
+ * CLI has no catalogue to read offline; kept short and canonical. */
85
+ const CATALOGUE = {
86
+ button: ["intent", "size"],
87
+ badge: ["intent"],
88
+ card: ["intent"],
89
+ input: ["size"],
90
+ modal: [],
91
+ drawer: ["side"],
92
+ spinner: ["size"],
93
+ skeleton: [],
94
+ tooltip: ["side"],
95
+ popover: ["side"],
96
+ avatar: ["size"],
97
+ alert: ["intent"],
98
+ toast: ["intent"],
99
+ tabs: [],
100
+ table: ["density"],
101
+ select: ["size"],
102
+ checkbox: [],
103
+ radio: [],
104
+ switch: ["size"],
105
+ link: ["intent"],
106
+ text: ["size"],
107
+ heading: ["size"],
108
+ divider: [],
109
+ breadcrumb: [],
110
+ progress: [],
111
+ slider: [],
112
+ };
113
+ /** Values that mean the same decision under different spellings. */
114
+ const VALUE_SYNONYM = {
115
+ warn: "warning",
116
+ destructive: "danger",
117
+ error: "danger",
118
+ negative: "danger",
119
+ positive: "success",
120
+ ok: "success",
121
+ info: "info",
122
+ informational: "info",
123
+ primary: "primary",
124
+ main: "primary",
125
+ secondary: "accent",
126
+ accent: "accent",
127
+ neutral: "neutral",
128
+ default: "neutral",
129
+ xs: "xs",
130
+ xsm: "xs",
131
+ small: "sm",
132
+ sm: "sm",
133
+ medium: "md",
134
+ md: "md",
135
+ large: "lg",
136
+ lg: "lg",
137
+ };
138
+ /** Axis names that ask the same question. */
139
+ const AXIS_SYNONYM = {
140
+ variant: "intent",
141
+ intent: "intent",
142
+ tone: "intent",
143
+ color: "intent",
144
+ colour: "intent",
145
+ kind: "intent",
146
+ type: "intent",
147
+ status: "intent",
148
+ severity: "intent",
149
+ size: "size",
150
+ scale: "size",
151
+ density: "density",
152
+ dense: "density",
153
+ compact: "density",
154
+ side: "side",
155
+ placement: "side",
156
+ position: "side",
157
+ };
158
+ export function canonicalName(name) {
159
+ const head = name
160
+ .split(".")[0]
161
+ .toLowerCase()
162
+ .replace(/[^a-z]/g, "");
163
+ if (SYNONYM[head])
164
+ return SYNONYM[head];
165
+ // A compound like `WidgetCard` or `ArrowDownIcon` is only canonical when the
166
+ // WHOLE word is - a widget card is not a card, it is their own thing.
167
+ return null;
168
+ }
169
+ export function canonicalAxes(props) {
170
+ const out = new Set();
171
+ for (const p of Object.keys(props)) {
172
+ const axis = AXIS_SYNONYM[p.toLowerCase()];
173
+ if (axis)
174
+ out.add(axis);
175
+ }
176
+ return out;
177
+ }
178
+ function canonicalValues(values) {
179
+ return new Set(values
180
+ .map((v) => v.trim().toLowerCase())
181
+ .map((v) => VALUE_SYNONYM[v] ?? v)
182
+ // A raw colour or a css length is a value passed through, not a named
183
+ // option somebody designed - it says nothing about what this component IS.
184
+ .filter((v) => v && !/^(#|var\(|rgb|\d)/.test(v)));
185
+ }
186
+ /** How much two components' axis VALUES overlap, 0-1 (Jaccard). */
187
+ export function axisOverlap(a, b) {
188
+ const va = new Set();
189
+ const vb = new Set();
190
+ for (const [p, values] of Object.entries(a)) {
191
+ if (AXIS_SYNONYM[p.toLowerCase()]) {
192
+ for (const v of canonicalValues(values))
193
+ va.add(v);
194
+ }
195
+ }
196
+ for (const [p, values] of Object.entries(b)) {
197
+ if (AXIS_SYNONYM[p.toLowerCase()]) {
198
+ for (const v of canonicalValues(values))
199
+ vb.add(v);
200
+ }
201
+ }
202
+ if (va.size === 0 || vb.size === 0)
203
+ return 0;
204
+ let shared = 0;
205
+ for (const v of va)
206
+ if (vb.has(v))
207
+ shared += 1;
208
+ return shared / new Set([...va, ...vb]).size;
209
+ }
210
+ /**
211
+ * Names that are not components in the sense this crosswalk means.
212
+ *
213
+ * An ICON is a glyph in a set, and a design system models it as a library
214
+ * rather than as a recipe with variant axes - so listing four of them under
215
+ * "only yours" pads the interesting list with the least interesting thing in
216
+ * it (dono, 30/07). A PROVIDER renders no UI at all.
217
+ *
218
+ * Both are reported as counts instead, which says more in one line than four
219
+ * lines of "nothing in the catalogue does this".
220
+ */
221
+ const IS_ICON = /(^Icon|Icon$)/;
222
+ const IS_PROVIDER = /(Provider|Context)$/;
223
+ export function classifyAside(name) {
224
+ if (IS_ICON.test(name))
225
+ return "icon";
226
+ if (IS_PROVIDER.test(name))
227
+ return "provider";
228
+ return null;
229
+ }
230
+ /** Below this, two components share a value or two by accident. */
231
+ export const TWIN_OVERLAP = 0.5;
232
+ /**
233
+ * And a ratio needs a denominator worth trusting.
234
+ *
235
+ * The first real run called an `InformationIcon` the same decision as a
236
+ * spinner, at 100% (dono, 30/07) - both carried exactly one readable option,
237
+ * `sm`, so one shared value out of one union was a perfect score. Same lesson
238
+ * the colour work learned twice: a ratio over a tiny set says nothing.
239
+ */
240
+ export const TWIN_MIN_SHARED = 2;
241
+ function sharedCount(a, b) {
242
+ const va = new Set();
243
+ const vb = new Set();
244
+ for (const [p, values] of Object.entries(a)) {
245
+ if (AXIS_SYNONYM[p.toLowerCase()]) {
246
+ for (const v of canonicalValues(values))
247
+ va.add(v);
248
+ }
249
+ }
250
+ for (const [p, values] of Object.entries(b)) {
251
+ if (AXIS_SYNONYM[p.toLowerCase()]) {
252
+ for (const v of canonicalValues(values))
253
+ vb.add(v);
254
+ }
255
+ }
256
+ let n = 0;
257
+ for (const v of va)
258
+ if (vb.has(v))
259
+ n += 1;
260
+ return n;
261
+ }
262
+ export function crosswalk(components) {
263
+ const mine = components.filter((c) => !c.from);
264
+ // Two of THEIR components reading as the same catalogue entry is the
265
+ // strongest redundancy signal there is, and it needs no threshold: it is not
266
+ // "these look alike", it is "you wrote this twice". The first run filed both
267
+ // of a project's spinners as `exists` and said nothing about them being two.
268
+ const claims = new Map();
269
+ for (const c of mine) {
270
+ const k = canonicalName(c.name);
271
+ if (!k)
272
+ continue;
273
+ claims.set(k, [...(claims.get(k) ?? []), c.name]);
274
+ }
275
+ return mine
276
+ .map((component) => {
277
+ const aside = classifyAside(component.name);
278
+ if (aside) {
279
+ return {
280
+ component,
281
+ canonical: null,
282
+ twins: [],
283
+ bucket: aside,
284
+ because: aside === "icon"
285
+ ? "a glyph, which a system models as a library rather than a recipe"
286
+ : "renders no UI - infrastructure, not a component",
287
+ };
288
+ }
289
+ const canonical = canonicalName(component.name);
290
+ const twins = mine
291
+ .filter((o) => o.name !== component.name)
292
+ .map((o) => ({
293
+ name: o.name,
294
+ overlap: axisOverlap(component.props, o.props),
295
+ }))
296
+ .filter((t) => t.overlap >= TWIN_OVERLAP &&
297
+ sharedCount(component.props, mine.find((m) => m.name === t.name)?.props ?? {}) >= TWIN_MIN_SHARED)
298
+ .sort((a, b) => b.overlap - a.overlap);
299
+ if (canonical && CATALOGUE[canonical]) {
300
+ const alsoClaiming = (claims.get(canonical) ?? []).filter((n) => n !== component.name);
301
+ if (alsoClaiming.length > 0) {
302
+ return {
303
+ component,
304
+ canonical,
305
+ twins,
306
+ bucket: "nearly",
307
+ because: `you have ${alsoClaiming.length + 1} components that all read as ds-${canonical} (${[component.name, ...alsoClaiming].join(", ")})`,
308
+ };
309
+ }
310
+ const axes = [...canonicalAxes(component.props)];
311
+ return {
312
+ component,
313
+ canonical,
314
+ twins,
315
+ bucket: "exists",
316
+ because: axes.length
317
+ ? `reads as ds-${canonical}; its ${axes.join(" and ")} ${axes.length === 1 ? "maps" : "map"} onto ours`
318
+ : `reads as ds-${canonical}`,
319
+ };
320
+ }
321
+ if (twins.length > 0) {
322
+ return {
323
+ component,
324
+ canonical,
325
+ twins,
326
+ bucket: "nearly",
327
+ because: `shares ${Math.round(twins[0].overlap * 100)}% of its options with ${twins[0].name} - one decision, two components`,
328
+ };
329
+ }
330
+ return {
331
+ component,
332
+ canonical,
333
+ twins,
334
+ bucket: "exclusive",
335
+ because: "nothing in the catalogue does this - it is yours",
336
+ };
337
+ })
338
+ .sort((a, b) => b.component.files - a.component.files);
339
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.46",
3
+ "version": "0.16.49",
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": {