synthesisui 0.16.84 → 0.16.87
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/anatomy-read.js +9 -0
- package/dist/commands/import.js +69 -4
- package/dist/doctor/call-sites.js +22 -4
- package/dist/doctor/crosswalk.js +63 -1
- package/dist/doctor/signals.js +286 -0
- package/dist/doctor/transcribe.js +32 -1
- package/dist/skill-import.js +60 -6
- package/package.json +1 -1
package/dist/anatomy-read.js
CHANGED
|
@@ -194,6 +194,15 @@ root) {
|
|
|
194
194
|
: [];
|
|
195
195
|
const t = transcribe(classes, declared);
|
|
196
196
|
parts[key] = { base: t.base, dark: t.dark, states: t.states };
|
|
197
|
+
/**
|
|
198
|
+
* A drop on a PART is still a drop. The root path already says these out loud;
|
|
199
|
+
* this one silently swallowed `p-spacing-sm` on a real menu popup, so the part
|
|
200
|
+
* arrived with no padding and the not-expressed file had to discover it from
|
|
201
|
+
* the outside (dono, 01/08).
|
|
202
|
+
*/
|
|
203
|
+
for (const cls of t.unreadable) {
|
|
204
|
+
notes.push(`part \`${key}\`: \`${cls}\` starts like a design decision and reads as nothing - usually a typo in the source, and the element renders without it today`);
|
|
205
|
+
}
|
|
197
206
|
node_.part = key;
|
|
198
207
|
}
|
|
199
208
|
const text = typeof node.text === "string" ? node.text.trim() : "";
|
package/dist/commands/import.js
CHANGED
|
@@ -17,6 +17,7 @@ import { reconcile, scanDefinitions, } from "../doctor/definitions-scan.js";
|
|
|
17
17
|
import { describeConvention, describeRemainder, detectConventions, } from "../doctor/idiom.js";
|
|
18
18
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
19
19
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
20
|
+
import { describeSignals, emptySignals, finishSignals, readSignalsInto, } from "../doctor/signals.js";
|
|
20
21
|
import { buildTable } from "../doctor/tokens.js";
|
|
21
22
|
import { readInlineStyle, rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
22
23
|
import { transcribeVariants } from "../doctor/variant-read.js";
|
|
@@ -272,6 +273,17 @@ export async function takeCensus(root, opts) {
|
|
|
272
273
|
const dirs = new Map();
|
|
273
274
|
/** Parent → child → the files that put one inside the other. */
|
|
274
275
|
const nesting = new Map();
|
|
276
|
+
/**
|
|
277
|
+
* WHAT IS COUNTABLE, COUNTED - so the reader decides instead of searching.
|
|
278
|
+
*
|
|
279
|
+
* The skill's theme section said "go and look" and listed five places to grep; a real run
|
|
280
|
+
* swept for motion and icon libraries too, which it never asked for (dono, 01/08).
|
|
281
|
+
* Searching is a tool call, its whole output lands in the context, and two readers
|
|
282
|
+
* sweeping the same repo bring back different slices. All of this is one more pass over
|
|
283
|
+
* files already open. See `signals.ts`.
|
|
284
|
+
*/
|
|
285
|
+
const signals = emptySignals();
|
|
286
|
+
const importTally = new Map();
|
|
275
287
|
/** Component → what its own definition names and whether it forwards the rest. */
|
|
276
288
|
const propsOf = new Map();
|
|
277
289
|
/**
|
|
@@ -317,6 +329,7 @@ export async function takeCensus(root, opts) {
|
|
|
317
329
|
}
|
|
318
330
|
}
|
|
319
331
|
sources.push({ file: rel, source: src });
|
|
332
|
+
readSignalsInto(signals, importTally, rel, src);
|
|
320
333
|
reports.push(scanSource(rel, src, table));
|
|
321
334
|
// Stories and tests compose components to SHOW them; counting those as the
|
|
322
335
|
// product's own composition is the same lie the colour census told before
|
|
@@ -389,6 +402,15 @@ export async function takeCensus(root, opts) {
|
|
|
389
402
|
* paid for twice already.
|
|
390
403
|
*/
|
|
391
404
|
const v = transcribeVariants(src, declaredValues);
|
|
405
|
+
/**
|
|
406
|
+
* THE RESTING OPTION, from the definition itself. `cva` says it in
|
|
407
|
+
* `defaultVariants`; a lookup-record component says it in the destructuring -
|
|
408
|
+
* `{ variant = "body-md" }` - and reading past that left every style behind a
|
|
409
|
+
* `[data-variant]` selector nothing sets, so a real Text previewed as bare
|
|
410
|
+
* unstyled words (dono, 01/08).
|
|
411
|
+
*/
|
|
412
|
+
const destructured = readDefinitionProps(found[0].name, src).defaults;
|
|
413
|
+
const defaults = { ...destructured, ...v.defaults };
|
|
392
414
|
/**
|
|
393
415
|
* THE CSS MODULE, if this component styles that way.
|
|
394
416
|
*
|
|
@@ -461,9 +483,7 @@ export async function takeCensus(root, opts) {
|
|
|
461
483
|
...(tag ? { rootTag: tag } : {}),
|
|
462
484
|
...(layers.length > 0 ? { layers } : {}),
|
|
463
485
|
...(Object.keys(v.axes).length > 0 ? { declaredAxes: v.axes } : {}),
|
|
464
|
-
...(Object.keys(
|
|
465
|
-
? { defaults: v.defaults }
|
|
466
|
-
: {}),
|
|
486
|
+
...(Object.keys(defaults).length > 0 ? { defaults } : {}),
|
|
467
487
|
};
|
|
468
488
|
}
|
|
469
489
|
// Their variant DEFINITION beats what usage happened to pick, and it is the
|
|
@@ -766,6 +786,44 @@ export async function takeCensus(root, opts) {
|
|
|
766
786
|
console.log("");
|
|
767
787
|
console.log(body(describeFallback(fetched, floorSize())));
|
|
768
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* THE SIGNALS, said as EVIDENCE. "189 dark utilities across 30 files, bound to
|
|
791
|
+
* `[data-theme=dark]`, opening dark with the OS overruled" is something a person can
|
|
792
|
+
* confirm or correct; "this is a dark product" is a claim they take on faith.
|
|
793
|
+
*/
|
|
794
|
+
const signalLines = describeSignals(signals);
|
|
795
|
+
if (signalLines.length > 0) {
|
|
796
|
+
console.log("");
|
|
797
|
+
console.log(section("What was countable, counted"));
|
|
798
|
+
for (const line of signalLines)
|
|
799
|
+
console.log(body(line));
|
|
800
|
+
console.log(body(paint.faint("Do not go looking for any of this - it is measured, and a sweep would bring back a different slice than the next one.")));
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* THE DROPS, SAID OUT LOUD. `p-spacing-sm` on a real menu popup read as nothing and
|
|
804
|
+
* the popup shipped with no padding, invisibly - "if the compiler drops them, the drop
|
|
805
|
+
* is invisible from here" was the not-expressed file calling this exact hole (dono,
|
|
806
|
+
* 01/08). More often than not the class is malformed in THEIR source, which makes this
|
|
807
|
+
* a defect report about their code, not an apology about ours.
|
|
808
|
+
*/
|
|
809
|
+
const unreadableByComponent = Object.entries(looks)
|
|
810
|
+
.filter(([, look]) => (look.unreadable ?? []).length > 0)
|
|
811
|
+
.map(([component, look]) => ({
|
|
812
|
+
component,
|
|
813
|
+
classes: look.unreadable ?? [],
|
|
814
|
+
}));
|
|
815
|
+
if (unreadableByComponent.length > 0) {
|
|
816
|
+
const total = unreadableByComponent.reduce((n, u) => n + u.classes.length, 0);
|
|
817
|
+
console.log("");
|
|
818
|
+
console.log(section("Design classes that read as nothing"));
|
|
819
|
+
console.log(body(`${total} class${total === 1 ? "" : "es"} start like a design decision and resolve to nothing - usually a typo in the source, and the element renders without that decision today. Nothing was guessed on their behalf:`));
|
|
820
|
+
for (const u of unreadableByComponent.slice(0, 8)) {
|
|
821
|
+
console.log(body(paint.faint(` ${u.component}: ${u.classes.slice(0, 4).join(", ")}`)));
|
|
822
|
+
}
|
|
823
|
+
if (unreadableByComponent.length > 8) {
|
|
824
|
+
console.log(body(paint.faint(` and ${unreadableByComponent.length - 8} more components`)));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
769
827
|
const coverageLines = describeCoverage(coverage);
|
|
770
828
|
if (coverageLines.length > 0) {
|
|
771
829
|
console.log("");
|
|
@@ -847,14 +905,19 @@ export async function takeCensus(root, opts) {
|
|
|
847
905
|
}));
|
|
848
906
|
const pkgRaw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
|
|
849
907
|
let name = null;
|
|
908
|
+
/** Their pinned ranges, so a library signal cites the version rather than going stale. */
|
|
909
|
+
let versions = {};
|
|
850
910
|
if (pkgRaw) {
|
|
851
911
|
try {
|
|
852
|
-
|
|
912
|
+
const pkg = JSON.parse(pkgRaw);
|
|
913
|
+
name = pkg.name ?? null;
|
|
914
|
+
versions = { ...pkg.devDependencies, ...pkg.dependencies };
|
|
853
915
|
}
|
|
854
916
|
catch {
|
|
855
917
|
// an unreadable package.json costs the name, not the run
|
|
856
918
|
}
|
|
857
919
|
}
|
|
920
|
+
finishSignals(signals, importTally, versions);
|
|
858
921
|
// Every name their CSS defines, from the same harvest the token table came
|
|
859
922
|
// from - so a reference is judged against what actually exists, not against
|
|
860
923
|
// the subset we managed to read as a ramp.
|
|
@@ -891,6 +954,7 @@ export async function takeCensus(root, opts) {
|
|
|
891
954
|
* printed it, and never turned it into a rule is a feature that does nothing (dono,
|
|
892
955
|
* 01/08).
|
|
893
956
|
*/
|
|
957
|
+
signals,
|
|
894
958
|
...(coverage.components > 0
|
|
895
959
|
? {
|
|
896
960
|
coverage: {
|
|
@@ -1551,6 +1615,7 @@ async function resolveReadParts(census, root) {
|
|
|
1551
1615
|
dark: {},
|
|
1552
1616
|
states: {},
|
|
1553
1617
|
skipped: [],
|
|
1618
|
+
unreadable: [],
|
|
1554
1619
|
literals: [],
|
|
1555
1620
|
fromToken: 0,
|
|
1556
1621
|
fromLiteral: 0,
|
|
@@ -49,12 +49,25 @@ const SPREAD_ONTO = /\{\s*\.\.\.\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
|
49
49
|
*/
|
|
50
50
|
export function readDefinitionProps(name, source) {
|
|
51
51
|
const explicit = [];
|
|
52
|
+
const defaults = {};
|
|
52
53
|
let restName = null;
|
|
54
|
+
/**
|
|
55
|
+
* EXACT NAME FIRST, THEN THE forwardRef SPELLING.
|
|
56
|
+
*
|
|
57
|
+
* `export const Label = forwardRef(LabelComponent)` defines its props on
|
|
58
|
+
* `LabelComponent`, and searching for `Label` alone read past the destructure - so a
|
|
59
|
+
* real Label's `variant = "body-md"` default never travelled (probe, 01/08). A prefix
|
|
60
|
+
* match (`LabelComponent` starts with `Label`) is the deterministic version of that
|
|
61
|
+
* idiom, and the exact name still wins when both exist.
|
|
62
|
+
*/
|
|
53
63
|
DESTRUCTURED.lastIndex = 0;
|
|
54
|
-
|
|
64
|
+
const matches = [...source.matchAll(DESTRUCTURED)];
|
|
65
|
+
const exact = matches.filter((m) => (m[1] ?? m[3]) === name);
|
|
66
|
+
const prefixed = matches.filter((m) => {
|
|
55
67
|
const found = m[1] ?? m[3];
|
|
56
|
-
|
|
57
|
-
|
|
68
|
+
return found !== name && found?.startsWith(name);
|
|
69
|
+
});
|
|
70
|
+
for (const m of exact.length > 0 ? exact : prefixed) {
|
|
58
71
|
const body = m[2] ?? m[4] ?? "";
|
|
59
72
|
for (const raw of body.split(",")) {
|
|
60
73
|
const part = raw.trim();
|
|
@@ -69,6 +82,11 @@ export function readDefinitionProps(name, source) {
|
|
|
69
82
|
if (/^[a-zA-Z_$][\w$]*$/.test(prop) && !explicit.includes(prop)) {
|
|
70
83
|
explicit.push(prop);
|
|
71
84
|
}
|
|
85
|
+
// `variant = "body-md"` - the resting option, said by the definition itself.
|
|
86
|
+
// Only a string literal: a computed default is not in the source.
|
|
87
|
+
const def = /=\s*["']([^"']+)["']\s*$/.exec(part);
|
|
88
|
+
if (def && /^[a-zA-Z_$][\w$]*$/.test(prop))
|
|
89
|
+
defaults[prop] = def[1];
|
|
72
90
|
}
|
|
73
91
|
break;
|
|
74
92
|
}
|
|
@@ -82,7 +100,7 @@ export function readDefinitionProps(name, source) {
|
|
|
82
100
|
}
|
|
83
101
|
}
|
|
84
102
|
}
|
|
85
|
-
return { explicit, forwards };
|
|
103
|
+
return { explicit, defaults, forwards };
|
|
86
104
|
}
|
|
87
105
|
/**
|
|
88
106
|
* WHICH COMPONENTS APPEAR INSIDE THIS ONE, at its call sites.
|
package/dist/doctor/crosswalk.js
CHANGED
|
@@ -196,6 +196,49 @@ export function canonicalName(name) {
|
|
|
196
196
|
// WHOLE word is - a widget card is not a card, it is their own thing.
|
|
197
197
|
return null;
|
|
198
198
|
}
|
|
199
|
+
/** The head of a name, the way `canonicalName` reads it. */
|
|
200
|
+
function headOf(name) {
|
|
201
|
+
return name
|
|
202
|
+
.split(".")[0]
|
|
203
|
+
.toLowerCase()
|
|
204
|
+
.replace(/[^a-z]/g, "");
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The canonical, kept only when the match is an identity or the axes back it up.
|
|
208
|
+
* See the note at the call site - this is the guard, not the policy.
|
|
209
|
+
*/
|
|
210
|
+
export function evidencedCanonical(component) {
|
|
211
|
+
const canonical = canonicalName(component.name);
|
|
212
|
+
if (!canonical)
|
|
213
|
+
return null;
|
|
214
|
+
// The name IS ours, spelled their way. `Text → text`, `Card → card`.
|
|
215
|
+
if (headOf(component.name) === canonical.replace(/-/g, ""))
|
|
216
|
+
return canonical;
|
|
217
|
+
// A synonym hop. Without live axes there is no evidence to weigh - old behaviour.
|
|
218
|
+
const target = live?.axes[canonical];
|
|
219
|
+
if (!target || Object.keys(target).length === 0)
|
|
220
|
+
return canonical;
|
|
221
|
+
// Their side has to DECLARE something for the disagreement to mean anything: a
|
|
222
|
+
// component with no axes offers no witness either way, and the name stands.
|
|
223
|
+
const theirOptions = new Set();
|
|
224
|
+
for (const values of Object.values(component.props)) {
|
|
225
|
+
for (const v of canonicalValues(values))
|
|
226
|
+
theirOptions.add(v);
|
|
227
|
+
}
|
|
228
|
+
if (theirOptions.size < 2)
|
|
229
|
+
return canonical;
|
|
230
|
+
const ourOptions = new Set();
|
|
231
|
+
for (const values of Object.values(target)) {
|
|
232
|
+
for (const v of canonicalValues(values))
|
|
233
|
+
ourOptions.add(v);
|
|
234
|
+
}
|
|
235
|
+
if (ourOptions.size === 0)
|
|
236
|
+
return canonical;
|
|
237
|
+
for (const v of theirOptions)
|
|
238
|
+
if (ourOptions.has(v))
|
|
239
|
+
return canonical;
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
199
242
|
export function canonicalAxes(props) {
|
|
200
243
|
const out = new Set();
|
|
201
244
|
for (const p of Object.keys(props)) {
|
|
@@ -398,7 +441,26 @@ opts) {
|
|
|
398
441
|
: "renders no UI - infrastructure, not a component",
|
|
399
442
|
};
|
|
400
443
|
}
|
|
401
|
-
|
|
444
|
+
/**
|
|
445
|
+
* A SYNONYM HOP NEEDS EVIDENCE; an identity match does not.
|
|
446
|
+
*
|
|
447
|
+
* `label → badge` was decided by the written table on the NAME alone, and their
|
|
448
|
+
* Label is a typography ladder - `variant: display|h1|h2|h3|body-sm|body-md|
|
|
449
|
+
* caption` - with nothing of a badge about it (dono, 01/08, "erros de match").
|
|
450
|
+
* Their `Text → text` is a different kind of claim: the name IS ours, spelled
|
|
451
|
+
* their way, and needs no second witness.
|
|
452
|
+
*
|
|
453
|
+
* So: a canonical reached through a synonym only stands when the axes agree -
|
|
454
|
+
* some shared option value (canonicalised, so `destructive` meets `danger`) with
|
|
455
|
+
* what the LIVE catalogue says that component offers. Their Tag's
|
|
456
|
+
* `color(danger|success|warning|neutral…)` overlaps our badge's intent options
|
|
457
|
+
* and survives; Label's heading ladder overlaps nothing and falls back to being
|
|
458
|
+
* exclusively theirs, which is what it was.
|
|
459
|
+
*
|
|
460
|
+
* Offline the live axes are absent and the old behaviour stands - the fallback
|
|
461
|
+
* message already says the answer is smaller.
|
|
462
|
+
*/
|
|
463
|
+
const canonical = evidencedCanonical(component);
|
|
402
464
|
const twins = mine
|
|
403
465
|
.filter((o) => o.name !== component.name)
|
|
404
466
|
.map((o) => ({
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT IS COUNTABLE, COUNTED - so the reader decides instead of searching.
|
|
3
|
+
*
|
|
4
|
+
* The skill's theme section said "go and look" and listed five places to grep. Watching a
|
|
5
|
+
* real run, the agent swept for `framer-motion`, `lucide-react` and `@base-ui` too - things
|
|
6
|
+
* the skill never asked for - because "go and look" is an open invitation (dono, 01/08).
|
|
7
|
+
*
|
|
8
|
+
* Searching is expensive for three reasons that compound: every grep is a tool call, its
|
|
9
|
+
* whole output lands in the context, and the output is mostly noise - a `grep -rn "dark:"`
|
|
10
|
+
* over 2797 files returns hundreds of lines to answer a question that is one number.
|
|
11
|
+
*
|
|
12
|
+
* And it is worse than expensive: it is NOT DETERMINISTIC. Two readers sweeping the same
|
|
13
|
+
* repo bring back different slices, so the answer changes between runs.
|
|
14
|
+
*
|
|
15
|
+
* THE PRACTICE, and it holds for any system: give a reader the EVIDENCE and ask it to
|
|
16
|
+
* decide; never give it a question and ask it to search. Everything here is one more pass
|
|
17
|
+
* over files the census already walks - free on this side, and it deletes a handful of tool
|
|
18
|
+
* calls from the other.
|
|
19
|
+
*
|
|
20
|
+
* What is left for a person to judge after this is short and named: which grey is the page,
|
|
21
|
+
* what the product IS, and the anatomy of each component. Those are readings, not counts.
|
|
22
|
+
*/
|
|
23
|
+
/** A dark utility in a class name: `dark:bg-x`, and the `dark:` inside a longer chain. */
|
|
24
|
+
const DARK_UTILITY = /(?:^|["'\s:])dark:/g;
|
|
25
|
+
/** How a project says which theme is on. Ordered: the first hit is the one it uses. */
|
|
26
|
+
const THEME_LIBRARY = [
|
|
27
|
+
[/from\s+["']next-themes["']/, "next-themes"],
|
|
28
|
+
[/from\s+["']@radix-ui\/themes["']/, "@radix-ui/themes"],
|
|
29
|
+
[/from\s+["']styled-components["'][\s\S]*ThemeProvider/, "styled-components"],
|
|
30
|
+
[/\buseColorScheme\b/, "react-native / useColorScheme"],
|
|
31
|
+
[/\bThemeProvider\b/, "a ThemeProvider of their own"],
|
|
32
|
+
];
|
|
33
|
+
/** `attribute="data-theme"` on the provider - the selector every dark rule then needs. */
|
|
34
|
+
const THEME_ATTRIBUTE = /attribute\s*=\s*\{?["']([^"']+)["']/;
|
|
35
|
+
/** `defaultTheme="dark"` - which face it opens in, said by the provider itself. */
|
|
36
|
+
const DEFAULT_THEME = /defaultTheme\s*=\s*\{?["'](light|dark|system)["']/;
|
|
37
|
+
/** `enableSystem={false}` - whether the OS gets a vote. */
|
|
38
|
+
const ENABLE_SYSTEM = /enableSystem\s*=\s*\{(true|false)\}/;
|
|
39
|
+
/** What `<html>` carries by default, which is what a dark rule matches against. */
|
|
40
|
+
const HTML_ATTRS = /<html\b([^>]*)>/;
|
|
41
|
+
const PACKAGE_FAMILIES = [
|
|
42
|
+
{
|
|
43
|
+
family: "motion",
|
|
44
|
+
test: /^(framer-motion|motion|@react-spring\/.+|gsap|popmotion|react-transition-group)$/,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
family: "icons",
|
|
48
|
+
test: /^(lucide-react|react-icons|@heroicons\/react|@tabler\/icons-react|@phosphor-icons\/react|react-feather)$/,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
family: "primitives",
|
|
52
|
+
test: /^(@radix-ui\/.+|@base-ui\/.+|@base-ui-components\/.+|@headlessui\/react|@ariakit\/react|@reach\/.+)$/,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
family: "styling",
|
|
56
|
+
test: /^(class-variance-authority|tailwind-variants|clsx|tailwind-merge|styled-components|@emotion\/.+|@chakra-ui\/.+|@mui\/.+|@pandacss\/dev)$/,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
family: "charts",
|
|
60
|
+
test: /^(recharts|victory|@nivo\/.+|chart\.js|react-chartjs-2|apexcharts|d3)$/,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
family: "editors",
|
|
64
|
+
test: /^(@tiptap\/.+|slate|quill|react-quill|@lexical\/.+|lexical)$/,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
export function emptySignals() {
|
|
68
|
+
return {
|
|
69
|
+
theme: {
|
|
70
|
+
darkUtilities: 0,
|
|
71
|
+
darkFiles: 0,
|
|
72
|
+
library: null,
|
|
73
|
+
attribute: null,
|
|
74
|
+
defaultTheme: null,
|
|
75
|
+
enableSystem: null,
|
|
76
|
+
prefersColorScheme: 0,
|
|
77
|
+
htmlCarries: [],
|
|
78
|
+
},
|
|
79
|
+
libraries: [],
|
|
80
|
+
conflicts: [],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const ANY_IMPORT = /(?:from\s+["']([^"'.][^"']*)["']|require\(\s*["']([^"'.][^"']*)["']\s*\))/g;
|
|
84
|
+
/** The package a specifier belongs to: `@tiptap/react` and `motion/react` both keep the
|
|
85
|
+
* part a manifest would name. */
|
|
86
|
+
export function packageOf(specifier) {
|
|
87
|
+
if (specifier.startsWith("@")) {
|
|
88
|
+
const parts = specifier.split("/");
|
|
89
|
+
return parts.slice(0, 2).join("/");
|
|
90
|
+
}
|
|
91
|
+
return specifier.split("/")[0];
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* THE LIBRARY a package belongs to, which is not the same as the package.
|
|
95
|
+
*
|
|
96
|
+
* `@tiptap/react`, `@tiptap/starter-kit` and nineteen extensions are ONE library, and
|
|
97
|
+
* reporting them as twenty-one entries and a conflict is noise pretending to be a finding
|
|
98
|
+
* (probe, 01/08). A scope is a library; an unscoped name is its own.
|
|
99
|
+
*/
|
|
100
|
+
export function libraryOf(specifier) {
|
|
101
|
+
const pkg = packageOf(specifier);
|
|
102
|
+
return pkg.startsWith("@") ? `${pkg.split("/")[0]}/*` : pkg;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fold one file into the signals. Called from the walk the census already does, so nothing
|
|
106
|
+
* here costs a second pass over the disk.
|
|
107
|
+
*/
|
|
108
|
+
export function readSignalsInto(into, imports, file, source) {
|
|
109
|
+
const isCode = /\.(tsx|jsx|ts|js|mjs|cjs|vue|svelte)$/i.test(file);
|
|
110
|
+
const isStyle = /\.(css|scss|sass|less)$/i.test(file);
|
|
111
|
+
if (isStyle) {
|
|
112
|
+
into.theme.prefersColorScheme += (source.match(/prefers-color-scheme/g) ?? []).length;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!isCode)
|
|
116
|
+
return;
|
|
117
|
+
DARK_UTILITY.lastIndex = 0;
|
|
118
|
+
const dark = (source.match(DARK_UTILITY) ?? []).length;
|
|
119
|
+
if (dark > 0) {
|
|
120
|
+
into.theme.darkUtilities += dark;
|
|
121
|
+
into.theme.darkFiles += 1;
|
|
122
|
+
}
|
|
123
|
+
into.theme.prefersColorScheme += (source.match(/prefers-color-scheme/g) ?? []).length;
|
|
124
|
+
// The first library that matches wins, and the order is deliberate: an explicit
|
|
125
|
+
// dependency beats a `ThemeProvider` that could be anybody's.
|
|
126
|
+
if (!into.theme.library) {
|
|
127
|
+
for (const [test, name] of THEME_LIBRARY) {
|
|
128
|
+
if (test.test(source)) {
|
|
129
|
+
into.theme.library = name;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
into.theme.attribute ??= THEME_ATTRIBUTE.exec(source)?.[1] ?? null;
|
|
135
|
+
into.theme.defaultTheme ??= DEFAULT_THEME.exec(source)?.[1] ?? null;
|
|
136
|
+
if (into.theme.enableSystem === null) {
|
|
137
|
+
const enable = ENABLE_SYSTEM.exec(source);
|
|
138
|
+
if (enable)
|
|
139
|
+
into.theme.enableSystem = enable[1] === "true";
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* WHAT `<html>` CARRIES. Every dark rule in the project is written against this
|
|
143
|
+
* ancestor, so reading it is what turns "they have a dark theme" into "their dark rules
|
|
144
|
+
* match `[data-theme=dark]`, which is what ours must match too".
|
|
145
|
+
*/
|
|
146
|
+
const html = HTML_ATTRS.exec(source);
|
|
147
|
+
if (html) {
|
|
148
|
+
for (const m of html[1].matchAll(/([a-zA-Z-]+)\s*=\s*(?:["']([^"']*)["']|\{["']([^"']*)["']\})/g)) {
|
|
149
|
+
const attr = `${m[1]}="${m[2] ?? m[3] ?? ""}"`;
|
|
150
|
+
if (!into.theme.htmlCarries.includes(attr)) {
|
|
151
|
+
into.theme.htmlCarries.push(attr);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
ANY_IMPORT.lastIndex = 0;
|
|
156
|
+
const seen = new Set();
|
|
157
|
+
for (const m of source.matchAll(ANY_IMPORT)) {
|
|
158
|
+
const spec = m[1] ?? m[2];
|
|
159
|
+
if (!spec)
|
|
160
|
+
continue;
|
|
161
|
+
const pkg = packageOf(spec);
|
|
162
|
+
// `motion/react` and `motion` are one package, and a file importing both is one file.
|
|
163
|
+
const key = /^motion(\/|$)/.test(spec) ? spec : pkg;
|
|
164
|
+
if (seen.has(key))
|
|
165
|
+
continue;
|
|
166
|
+
seen.add(key);
|
|
167
|
+
imports.set(key, (imports.get(key) ?? 0) + 1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The libraries, and the conflicts, from the import tally and their manifest.
|
|
172
|
+
*
|
|
173
|
+
* `versions` is `dependencies` merged with `devDependencies`, so the rule can cite the
|
|
174
|
+
* range they pinned rather than going stale the day they upgrade.
|
|
175
|
+
*/
|
|
176
|
+
export function finishSignals(into, imports, versions) {
|
|
177
|
+
const byFamily = new Map();
|
|
178
|
+
for (const [name, files] of imports) {
|
|
179
|
+
const pkg = packageOf(name);
|
|
180
|
+
const family = PACKAGE_FAMILIES.find((f) => f.test.test(pkg) || f.test.test(name));
|
|
181
|
+
if (!family)
|
|
182
|
+
continue;
|
|
183
|
+
const version = versions[pkg] ?? versions[name];
|
|
184
|
+
const signal = {
|
|
185
|
+
family: family.family,
|
|
186
|
+
name,
|
|
187
|
+
files,
|
|
188
|
+
...(version ? { version } : {}),
|
|
189
|
+
};
|
|
190
|
+
byFamily.set(family.family, [
|
|
191
|
+
...(byFamily.get(family.family) ?? []),
|
|
192
|
+
signal,
|
|
193
|
+
]);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* COLLAPSED BY LIBRARY. Twenty-one `@tiptap/*` entries answer nothing a person wanted to
|
|
197
|
+
* know; `@tiptap/* (21 packages, 4 files)` answers it in one line. The file count is the
|
|
198
|
+
* WIDEST of the group rather than the sum: four files import tiptap, not fifty-two.
|
|
199
|
+
*/
|
|
200
|
+
into.libraries = [...byFamily.entries()]
|
|
201
|
+
.flatMap(([family, signals]) => {
|
|
202
|
+
const byLibrary = new Map();
|
|
203
|
+
for (const signal of signals) {
|
|
204
|
+
const key = libraryOf(signal.name);
|
|
205
|
+
byLibrary.set(key, [...(byLibrary.get(key) ?? []), signal]);
|
|
206
|
+
}
|
|
207
|
+
return [...byLibrary.entries()].map(([library, group]) => ({
|
|
208
|
+
family,
|
|
209
|
+
name: group.length > 1 ? library : group[0].name,
|
|
210
|
+
files: Math.max(...group.map((g) => g.files)),
|
|
211
|
+
...(group.length > 1 ? { packages: group.length } : {}),
|
|
212
|
+
...(group[0].version ? { version: group[0].version } : {}),
|
|
213
|
+
}));
|
|
214
|
+
})
|
|
215
|
+
.sort((a, b) => b.files - a.files || a.name.localeCompare(b.name));
|
|
216
|
+
/**
|
|
217
|
+
* TWO IN ONE FAMILY IS A FINDING. `framer-motion` in four files and `motion/react` in
|
|
218
|
+
* three, in one package, is the same library under two names and one of them is dead
|
|
219
|
+
* weight in the bundle. A person should see it; we do not decide it.
|
|
220
|
+
*
|
|
221
|
+
* `styling` and `primitives` are exempt: `clsx` beside `tailwind-merge` is the normal
|
|
222
|
+
* pairing, and two primitive libraries in a large app is a migration in progress, not a
|
|
223
|
+
* mistake.
|
|
224
|
+
*/
|
|
225
|
+
for (const [family, signals] of byFamily) {
|
|
226
|
+
if (family === "styling" || family === "primitives")
|
|
227
|
+
continue;
|
|
228
|
+
// By LIBRARY, not by package: `@tiptap/react` beside `@tiptap/starter-kit` is one
|
|
229
|
+
// library used correctly, and calling it a conflict was noise (probe, 01/08).
|
|
230
|
+
const names = [...new Set(signals.map((x) => libraryOf(x.name)))];
|
|
231
|
+
if (names.length > 1)
|
|
232
|
+
into.conflicts.push({ family, names });
|
|
233
|
+
}
|
|
234
|
+
return into;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The signals as the sentences the reader would otherwise have gone looking for.
|
|
238
|
+
*
|
|
239
|
+
* Written as EVIDENCE rather than as a verdict: "189 dark utilities across 30 files, bound
|
|
240
|
+
* to `[data-theme=dark]`, opening dark with the OS overruled" is something a person can
|
|
241
|
+
* confirm or correct. "This is a dark product" is a claim they have to take on faith.
|
|
242
|
+
*/
|
|
243
|
+
export function describeSignals(s) {
|
|
244
|
+
const out = [];
|
|
245
|
+
const t = s.theme;
|
|
246
|
+
if (t.darkUtilities > 0) {
|
|
247
|
+
out.push(`${t.darkUtilities} dark utilities across ${t.darkFiles} file${t.darkFiles === 1 ? "" : "s"} - so a second scheme is real here, not aspirational.`);
|
|
248
|
+
}
|
|
249
|
+
else if (t.prefersColorScheme > 0) {
|
|
250
|
+
out.push(`no dark utilities, but ${t.prefersColorScheme} \`prefers-color-scheme\` rule${t.prefersColorScheme === 1 ? "" : "s"} - the second scheme lives in CSS rather than in class names.`);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
out.push("no dark utilities and no `prefers-color-scheme` - one scheme, and adding a second is a decision rather than a discovery.");
|
|
254
|
+
}
|
|
255
|
+
if (t.library) {
|
|
256
|
+
const bits = [`switched by ${t.library}`];
|
|
257
|
+
if (t.attribute)
|
|
258
|
+
bits.push(`bound to \`${t.attribute}\``);
|
|
259
|
+
if (t.defaultTheme)
|
|
260
|
+
bits.push(`opening ${t.defaultTheme}`);
|
|
261
|
+
if (t.enableSystem === false)
|
|
262
|
+
bits.push("with the OS overruled");
|
|
263
|
+
if (t.enableSystem === true)
|
|
264
|
+
bits.push("with the OS allowed a vote");
|
|
265
|
+
out.push(`${bits.join(", ")}.`);
|
|
266
|
+
}
|
|
267
|
+
if (t.htmlCarries.length > 0) {
|
|
268
|
+
out.push(`\`<html>\` carries ${t.htmlCarries.map((a) => `\`${a}\``).join(" ")} - which is the ancestor every dark rule of theirs matches, and therefore the one ours must match.`);
|
|
269
|
+
}
|
|
270
|
+
for (const family of ["primitives", "motion", "icons", "charts", "editors"]) {
|
|
271
|
+
const found = s.libraries.filter((l) => l.family === family);
|
|
272
|
+
if (found.length === 0)
|
|
273
|
+
continue;
|
|
274
|
+
out.push(`${family}: ${found.map((l) => `${l.name}${l.version ? ` ${l.version}` : ""} (${l.packages ? `${l.packages} packages, ` : ""}${l.files} file${l.files === 1 ? "" : "s"})`).join(", ")}`);
|
|
275
|
+
}
|
|
276
|
+
for (const c of s.conflicts) {
|
|
277
|
+
// The count is read off the list rather than written into the sentence: it said "TWO"
|
|
278
|
+
// over three names, which is the kind of small lie that costs the next sentence its
|
|
279
|
+
// credibility (probe, 01/08).
|
|
280
|
+
const list = c.names.length === 2
|
|
281
|
+
? c.names.join(" and ")
|
|
282
|
+
: `${c.names.slice(0, -1).join(", ")} and ${c.names[c.names.length - 1]}`;
|
|
283
|
+
out.push(`${c.names.length} ${c.family} LIBRARIES: ${list}. The same job under more than one name, and the extras are weight in the bundle for nothing. Worth your attention; not ours to decide.`);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
@@ -447,6 +447,22 @@ function resolveArbitrary(inner, declared) {
|
|
|
447
447
|
return null;
|
|
448
448
|
const v = /^var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,[^)]*)?\)$/.exec(raw);
|
|
449
449
|
if (!v) {
|
|
450
|
+
/**
|
|
451
|
+
* `[--text-body-s]` IS the var() shorthand - Tailwind v3 defined it that way, so the
|
|
452
|
+
* author wrote a working thing once. v4 dropped the shorthand, which is why their
|
|
453
|
+
* inputs render at the inherited size today; the token they NAMED is still the
|
|
454
|
+
* intent, and a recipe is the rules to rebuild the component, not a photograph of
|
|
455
|
+
* the regression. Their own defect still gets reported - see `unreadable`.
|
|
456
|
+
*
|
|
457
|
+
* A bare `--name` they never declared resolves to nothing under either semantics,
|
|
458
|
+
* and carrying it would emit `font-size: --x`, which is invalid CSS.
|
|
459
|
+
*/
|
|
460
|
+
if (raw.startsWith("--")) {
|
|
461
|
+
const name = raw.split(",")[0].trim();
|
|
462
|
+
return declared.has(name)
|
|
463
|
+
? { value: tokenRefFor(name), token: name }
|
|
464
|
+
: null;
|
|
465
|
+
}
|
|
450
466
|
// A literal in brackets is still a real value somebody typed.
|
|
451
467
|
return /[<>{}@;]/.test(raw) ? null : { value: raw };
|
|
452
468
|
}
|
|
@@ -540,12 +556,18 @@ export function readInlineStyle(source) {
|
|
|
540
556
|
}
|
|
541
557
|
/** Properties React leaves unitless, so a bare number is not pixels. */
|
|
542
558
|
const UNITLESS = /^(opacity|zIndex|flex|flexGrow|flexShrink|order|lineHeight|fontWeight|zoom|columnCount|fillOpacity|strokeOpacity|animationIterationCount)$/;
|
|
559
|
+
/**
|
|
560
|
+
* A prefix this reader claims to understand. A class that starts with one of these and
|
|
561
|
+
* still reads as nothing is a failed DESIGN read, not layout plumbing.
|
|
562
|
+
*/
|
|
563
|
+
const DESIGN_PREFIX = /^(?:p|px|py|pt|pr|pb|pl|m|mx|my|gap|rounded|text|font|bg|border|shadow|w|h|size|min-w|min-h|max-w|max-h|leading|tracking|opacity|fill|stroke)-\S/;
|
|
543
564
|
export function transcribe(classes, declared) {
|
|
544
565
|
const out = {
|
|
545
566
|
base: {},
|
|
546
567
|
states: {},
|
|
547
568
|
dark: {},
|
|
548
569
|
skipped: [],
|
|
570
|
+
unreadable: [],
|
|
549
571
|
literals: [],
|
|
550
572
|
fromToken: 0,
|
|
551
573
|
fromLiteral: 0,
|
|
@@ -585,7 +607,16 @@ export function transcribe(classes, declared) {
|
|
|
585
607
|
out.skipped.push(cls);
|
|
586
608
|
continue;
|
|
587
609
|
}
|
|
588
|
-
if (!decl
|
|
610
|
+
if (!decl) {
|
|
611
|
+
// The prefix says design intent; the rest resolved to nothing. Malformed in their
|
|
612
|
+
// source more often than not - `p-spacing-sm` for `p-sm` - and a silent drop here
|
|
613
|
+
// is how a menu popup ships with no padding and nobody is told.
|
|
614
|
+
if (DESIGN_PREFIX.test(utility) && !out.unreadable.includes(cls)) {
|
|
615
|
+
out.unreadable.push(cls);
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (!target)
|
|
589
620
|
continue;
|
|
590
621
|
/**
|
|
591
622
|
* EVERYTHING IT READS TRAVELS, literals included - as DATA.
|
package/dist/skill-import.js
CHANGED
|
@@ -237,13 +237,19 @@ This is the field that matters most and the one only you can fill. The ladder ca
|
|
|
237
237
|
places no token reader sees. A real library declared nine near-blacks and five light greys in
|
|
238
238
|
one \`:root\` block, and the import handed its near-black dashboard a white page.
|
|
239
239
|
|
|
240
|
-
|
|
240
|
+
**DO NOT GO LOOKING. It is already counted**, under "What was countable, counted" in the
|
|
241
|
+
census output:
|
|
241
242
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
243
|
+
\`\`\`
|
|
244
|
+
782 dark utilities across 79 files - so a second scheme is real here, not aspirational.
|
|
245
|
+
switched by a ThemeProvider of their own, bound to data-theme, opening dark, with the
|
|
246
|
+
OS overruled.
|
|
247
|
+
html carries lang="en" data-theme="dark" - which is the ancestor every dark rule of
|
|
248
|
+
theirs matches, and therefore the one ours must match.
|
|
249
|
+
\`\`\`
|
|
250
|
+
|
|
251
|
+
That is a real run. Your job is to CONFIRM OR CORRECT it, not to reproduce it - and a sweep
|
|
252
|
+
would bring back a different slice than the next one, which is the worse property of the two.
|
|
247
253
|
|
|
248
254
|
\`has: ["dark"]\` on a dark-only product is the right answer. It is better than filling a light
|
|
249
255
|
slot on their behalf, and it makes adding light a deliberate act they take later.
|
|
@@ -537,6 +543,20 @@ extensions are configured at construction, not toggled later
|
|
|
537
543
|
of their manifest and attaches it (\`"^2.1.0 in packages/ui"\`), so the rule does not go stale the
|
|
538
544
|
day they upgrade and the information is not lost either.
|
|
539
545
|
|
|
546
|
+
### If \`recipe_vocabulary\` is missing, the MCP registration is stale
|
|
547
|
+
|
|
548
|
+
A real run reported "recipe_vocabulary and validate_recipe do not exist on this server" and
|
|
549
|
+
sent every anatomy unvalidated - the repo's \`.mcp.json\` pinned \`synthesisui@0.16.78\` while
|
|
550
|
+
the import itself ran unpinned at latest: **two CLI versions in one repo, in one run** (dono,
|
|
551
|
+
01/08). Before concluding a tool does not exist, check the registration:
|
|
552
|
+
|
|
553
|
+
\`\`\`json
|
|
554
|
+
"args": ["synthesisui@latest", "mcp"]
|
|
555
|
+
\`\`\`
|
|
556
|
+
|
|
557
|
+
Tell the person to fix the pin and restart the server. Do not work around a stale server by
|
|
558
|
+
skipping validation - unvalidated is how correct work arrives as nothing.
|
|
559
|
+
|
|
540
560
|
### Ask what a recipe can hold, BEFORE you write one
|
|
541
561
|
|
|
542
562
|
\`\`\`
|
|
@@ -634,6 +654,16 @@ what shows when it is missing a missing asset must never look broken
|
|
|
634
654
|
|
|
635
655
|
### What the CLI now reads for you, so do not spend a turn on it
|
|
636
656
|
|
|
657
|
+
**The rule under all of this: you are given EVIDENCE and asked to DECIDE.** You are never
|
|
658
|
+
given a question and asked to search. A grep is a tool call whose entire output lands in your
|
|
659
|
+
context to answer something that is one number, and two readers sweeping the same repo bring
|
|
660
|
+
back different slices - so the answer changes between runs, which is the part that actually
|
|
661
|
+
costs.
|
|
662
|
+
|
|
663
|
+
Measured on a real run: the census counts the theme signals, the libraries and their
|
|
664
|
+
versions, and the conflicts. The reader that swept for them anyway spent a dozen shell calls
|
|
665
|
+
to arrive at a subset of what was already on screen.
|
|
666
|
+
|
|
637
667
|
Four things are read deterministically from the source. Reporting them back as if you
|
|
638
668
|
found them wastes a turn and risks contradicting the measurement:
|
|
639
669
|
|
|
@@ -642,6 +672,10 @@ found them wastes a turn and risks contradicting the measurement:
|
|
|
642
672
|
- **whether a component forwards the rest of its props**, and which it names
|
|
643
673
|
- **which components appear inside which** at their call sites, as pairs
|
|
644
674
|
- **the folder architecture**, and it will ask you which one has priority
|
|
675
|
+
- **the theme**: how many dark utilities, in how many files, which library switches it, which
|
|
676
|
+
attribute it is bound to, what \`<html>\` carries, and how many \`prefers-color-scheme\` rules
|
|
677
|
+
- **the libraries and their versions** - motion, icons, primitives, charts, editors - with the
|
|
678
|
+
file count for each, and a CONFLICT when one job is done by more than one of them
|
|
645
679
|
- **which of your components are ours**, matched against the LIVE catalogue rather than a
|
|
646
680
|
list baked into the CLI. If a run says it fell back to the built-in names, the match is a
|
|
647
681
|
smaller answer than it should be and the reason is on screen
|
|
@@ -649,6 +683,26 @@ found them wastes a turn and risks contradicting the measurement:
|
|
|
649
683
|
What is still yours: the anatomy tree, the part names, the frontiers, the roles, the
|
|
650
684
|
concept, and the rules that are not about a class name.
|
|
651
685
|
|
|
686
|
+
### A scale's INTENT is a rule, and their docs already wrote it
|
|
687
|
+
|
|
688
|
+
A three-step spacing scale with documented meaning - sm "close UI relationships", md
|
|
689
|
+
"standard internal padding", lg "layout gutters" - carries a law nothing else expresses:
|
|
690
|
+
**this system has exactly three steps, and a fourth is a mistake.** A real import read the
|
|
691
|
+
scale and lost the sentence (dono, 01/08).
|
|
692
|
+
|
|
693
|
+
When their docs (a \`Geometry.mdx\`, a design-tokens page, comments beside the \`@theme\`)
|
|
694
|
+
state what a step is FOR, send it as system-wide rules - \`applies: []\`, \`fact: true\`, the
|
|
695
|
+
doc as evidence:
|
|
696
|
+
|
|
697
|
+
\`\`\`
|
|
698
|
+
this system's spacing has exactly three steps - sm for close relationships inside a
|
|
699
|
+
control, md for standard internal padding, lg for layout gutters. A fourth step is a
|
|
700
|
+
mistake, not a gap (Geometry.mdx)
|
|
701
|
+
\`\`\`
|
|
702
|
+
|
|
703
|
+
\`applies: []\` is what makes it reach CLAUDE.md and every prompt after - the same route the
|
|
704
|
+
architecture rule travels.
|
|
705
|
+
|
|
652
706
|
### How much to send
|
|
653
707
|
|
|
654
708
|
Send an anatomy for **every component with visible structure**, which is nearly all of them. A
|
package/package.json
CHANGED