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.
- package/dist/commands/import.js +64 -1
- package/dist/doctor/components-scan.js +13 -1
- package/dist/doctor/crosswalk.js +339 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -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
|
|
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
|
|
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