synthesisui 0.16.71 → 0.16.73
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 +60 -1
- package/dist/doctor/class-style.js +127 -0
- package/dist/doctor/transcribe.js +17 -0
- package/dist/skill-import.js +37 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { basename, dirname, join, relative } from "node:path";
|
|
3
3
|
import { readCredentials, readToken, resolveRegistry, sameRegistry, } from "../config.js";
|
|
4
4
|
import { findBrokenRefs } from "../doctor/broken-refs.js";
|
|
5
|
+
import { describeClassStyle, detectClassStyle, } from "../doctor/class-style.js";
|
|
5
6
|
import { isNearDuplicate, isNeutral, lightness, } from "../doctor/color-distance.js";
|
|
6
7
|
import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
|
|
7
8
|
import { crosswalk, observedRules } from "../doctor/crosswalk.js";
|
|
@@ -10,7 +11,7 @@ import { describeConvention, describeRemainder, detectConventions, } from "../do
|
|
|
10
11
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
11
12
|
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
12
13
|
import { buildTable } from "../doctor/tokens.js";
|
|
13
|
-
import { rootClasses, rootTag, transcribe, } from "../doctor/transcribe.js";
|
|
14
|
+
import { rootClasses, rootTag, transcribe, transcribeParts, } from "../doctor/transcribe.js";
|
|
14
15
|
import { body, paint, section } from "../output.js";
|
|
15
16
|
import { walk, walkAll } from "./doctor.js";
|
|
16
17
|
/**
|
|
@@ -407,12 +408,14 @@ export async function takeCensus(root) {
|
|
|
407
408
|
}
|
|
408
409
|
const brokenRefs = findBrokenRefs(sources, declaredNames);
|
|
409
410
|
const conventions = detectConventions(sources);
|
|
411
|
+
const classStyle = detectClassStyle(sources);
|
|
410
412
|
return {
|
|
411
413
|
census: 1,
|
|
412
414
|
project: { name, stack: await detectStack(root) },
|
|
413
415
|
declared: Object.fromEntries(table.byName),
|
|
414
416
|
...(brokenRefs.length > 0 ? { brokenRefs } : {}),
|
|
415
417
|
...(conventions.length > 0 ? { conventions } : {}),
|
|
418
|
+
classStyle,
|
|
416
419
|
...(Object.keys(looks).length > 0 ? { looks } : {}),
|
|
417
420
|
...(schemes.alt.size > 0
|
|
418
421
|
? { declaredAlt: Object.fromEntries(schemes.alt) }
|
|
@@ -496,6 +499,10 @@ function sayConventions(c) {
|
|
|
496
499
|
if (remainder)
|
|
497
500
|
console.log(body(paint.faint(` ${remainder}`)));
|
|
498
501
|
}
|
|
502
|
+
if (c.classStyle) {
|
|
503
|
+
console.log("");
|
|
504
|
+
console.log(body(describeClassStyle(c.classStyle)));
|
|
505
|
+
}
|
|
499
506
|
console.log("");
|
|
500
507
|
console.log(body(paint.faint("Nothing here is a rule we brought - it is what your own files already do, and the system now knows it. None of the rest has to move today: this is a complement, not a correction.")));
|
|
501
508
|
}
|
|
@@ -951,6 +958,46 @@ function sayReach(c) {
|
|
|
951
958
|
console.log(body(`Your neutrals only reach the ${has} end, so this system gets one scheme. A ${missing} mode would need surfaces you do not declare.`));
|
|
952
959
|
}
|
|
953
960
|
}
|
|
961
|
+
/**
|
|
962
|
+
* Fold the skill's named parts into the transcription, in place.
|
|
963
|
+
*
|
|
964
|
+
* Their declared tokens come from the census itself, so a run that only sends a
|
|
965
|
+
* file it was handed still resolves a `bg-ocean-500` by their own name.
|
|
966
|
+
*/
|
|
967
|
+
function resolveReadParts(census) {
|
|
968
|
+
const read = census.reading?.components;
|
|
969
|
+
if (!read)
|
|
970
|
+
return;
|
|
971
|
+
const declared = new Map(Object.entries(census.declared));
|
|
972
|
+
const looks = census.looks ?? (census.looks = {});
|
|
973
|
+
let named = 0;
|
|
974
|
+
for (const [component, entry] of Object.entries(read)) {
|
|
975
|
+
const parts = entry?.parts;
|
|
976
|
+
if (!Array.isArray(parts) || parts.length === 0)
|
|
977
|
+
continue;
|
|
978
|
+
const resolved = transcribeParts(parts, declared);
|
|
979
|
+
if (Object.keys(resolved).length === 0)
|
|
980
|
+
continue;
|
|
981
|
+
const look = looks[component];
|
|
982
|
+
looks[component] = look
|
|
983
|
+
? { ...look, parts: { ...(look.parts ?? {}), ...resolved } }
|
|
984
|
+
: {
|
|
985
|
+
base: {},
|
|
986
|
+
dark: {},
|
|
987
|
+
states: {},
|
|
988
|
+
skipped: [],
|
|
989
|
+
literals: [],
|
|
990
|
+
fromToken: 0,
|
|
991
|
+
fromLiteral: 0,
|
|
992
|
+
parts: resolved,
|
|
993
|
+
};
|
|
994
|
+
named += Object.keys(resolved).length;
|
|
995
|
+
}
|
|
996
|
+
if (named > 0) {
|
|
997
|
+
console.log("");
|
|
998
|
+
console.log(body(`${named} part${named === 1 ? "" : "s"} your reading named across ${Object.keys(read).length} component${Object.keys(read).length === 1 ? "" : "s"} - each previews as what it IS rather than as a text box`));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
954
1001
|
export async function runImport(opts) {
|
|
955
1002
|
const root = opts.root ?? process.cwd();
|
|
956
1003
|
/**
|
|
@@ -986,6 +1033,18 @@ export async function runImport(opts) {
|
|
|
986
1033
|
console.log(body("That file is not a census this version can send."));
|
|
987
1034
|
return;
|
|
988
1035
|
}
|
|
1036
|
+
/**
|
|
1037
|
+
* THE SKILL NAMED PARTS; THE CLI KNOWS WHAT THEIR CLASSES DO.
|
|
1038
|
+
*
|
|
1039
|
+
* A clean split of who knows what: only the skill can say that a span holds
|
|
1040
|
+
* the title, and only this side can say that `text-ocean-500` is
|
|
1041
|
+
* `{color.ocean.500}`. The platform then turns declarations into roles,
|
|
1042
|
+
* because only it has the two palettes.
|
|
1043
|
+
*
|
|
1044
|
+
* Resolved here rather than at measure time because the reading arrives
|
|
1045
|
+
* AFTER the census was taken - this is the first moment both halves exist.
|
|
1046
|
+
*/
|
|
1047
|
+
resolveReadParts(census);
|
|
989
1048
|
}
|
|
990
1049
|
else {
|
|
991
1050
|
console.log(section("Reading your project"));
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW THIS PROJECT NAMES A CLASS - and whether it names one at all.
|
|
3
|
+
*
|
|
4
|
+
* A compiled `.ds-metric-card` attaches to their markup, and a project whose own
|
|
5
|
+
* convention is `.metric-card__title` should not get a new name in its house for
|
|
6
|
+
* no reason it chose (dono, 01/08). So the convention has to be read, not
|
|
7
|
+
* assumed.
|
|
8
|
+
*
|
|
9
|
+
* Reading a real project first changed what this had to answer. Measured on a
|
|
10
|
+
* 670-stylesheet monorepo: 536 files are `*.module.scss` and 134 are not, and the
|
|
11
|
+
* class names inside them are `root`, `wrapper`, `editorText` - LOCAL names that
|
|
12
|
+
* a bundler hashes into `RootWrapper_root__x7f2a`.
|
|
13
|
+
*
|
|
14
|
+
* Which means there is no global convention there to follow, and our global
|
|
15
|
+
* class cannot collide with anything of theirs. The honest answer for that
|
|
16
|
+
* project is "nothing to change", and saying so is worth more than a concession
|
|
17
|
+
* nobody needed - inventing a problem is its own kind of dishonesty.
|
|
18
|
+
*
|
|
19
|
+
* Three worlds, and only the third asks anything of us:
|
|
20
|
+
*
|
|
21
|
+
* modules classes are local and hashed - ours are safe, and separate
|
|
22
|
+
* utility no component classes at all - ours are the only global ones
|
|
23
|
+
* global they DO name global classes, and we follow their shape
|
|
24
|
+
*/
|
|
25
|
+
const MODULE_FILE = /\.module\.(css|scss|sass|less)$/i;
|
|
26
|
+
const STYLESHEET = /\.(css|scss|sass|less)$/i;
|
|
27
|
+
/** A class selector at the start of a rule: `.metric-card__title {`. */
|
|
28
|
+
const CLASS_DECL = /^\s*\.([a-zA-Z][a-zA-Z0-9_-]*)\s*[,{:]/gm;
|
|
29
|
+
/** A Tailwind-shaped utility in markup - enough to know they exist. */
|
|
30
|
+
const UTILITY = /\b(?:bg|text|border|p|px|py|m|mx|my|gap|flex|grid|rounded|shadow)-[a-z0-9[\]./-]+/g;
|
|
31
|
+
/**
|
|
32
|
+
* Below this many global class declarations, a "convention" is a handful of
|
|
33
|
+
* one-off selectors rather than a house style - and adopting one from noise
|
|
34
|
+
* would rename everything we compile on the strength of three sightings.
|
|
35
|
+
*/
|
|
36
|
+
const MIN_GLOBAL_DECLS = 20;
|
|
37
|
+
/**
|
|
38
|
+
* Prefixes worth honouring have to be shared by most of what they declare.
|
|
39
|
+
* A project with `app-header` and `admin-nav` has two words, not a prefix.
|
|
40
|
+
*/
|
|
41
|
+
const PREFIX_SHARE = 0.6;
|
|
42
|
+
export function detectClassStyle(files) {
|
|
43
|
+
let moduleFiles = 0;
|
|
44
|
+
let globalFiles = 0;
|
|
45
|
+
let utilities = 0;
|
|
46
|
+
const globalDecls = [];
|
|
47
|
+
for (const { file, source } of files) {
|
|
48
|
+
if (MODULE_FILE.test(file)) {
|
|
49
|
+
moduleFiles += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (STYLESHEET.test(file)) {
|
|
53
|
+
globalFiles += 1;
|
|
54
|
+
// Only a non-module stylesheet can hold a class anybody else must match.
|
|
55
|
+
for (const m of source.matchAll(CLASS_DECL))
|
|
56
|
+
globalDecls.push(m[1]);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
utilities += source.match(UTILITY)?.length ?? 0;
|
|
60
|
+
}
|
|
61
|
+
// Their own global classes come first - but only if they have a SHAPE.
|
|
62
|
+
//
|
|
63
|
+
// Counting them was not enough. A real repo returned `global` on the strength
|
|
64
|
+
// of a syntax-highlighting theme: `hll`, `field-name`, `document` cleared the
|
|
65
|
+
// threshold with no convention between them, and we would have renamed every
|
|
66
|
+
// class we compile to match vendor CSS (dono, 01/08). Many global classes with
|
|
67
|
+
// nothing in common is not a house style, it is a stylesheet.
|
|
68
|
+
if (globalDecls.length >= MIN_GLOBAL_DECLS) {
|
|
69
|
+
const shape = shapeOf(globalDecls);
|
|
70
|
+
if (shape) {
|
|
71
|
+
return {
|
|
72
|
+
kind: "global",
|
|
73
|
+
...shape,
|
|
74
|
+
agreed: globalDecls.length,
|
|
75
|
+
samples: [...new Set(globalDecls)].slice(0, 3),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (moduleFiles > globalFiles && moduleFiles > 0) {
|
|
80
|
+
return { kind: "modules", moduleFiles, globalFiles };
|
|
81
|
+
}
|
|
82
|
+
return { kind: "utility", utilities };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The shape their global classes share.
|
|
86
|
+
*
|
|
87
|
+
* BEM's `__` is unmistakable when present, so it is checked first and only
|
|
88
|
+
* accepted on a real share - two `__` selectors in a thousand is somebody's
|
|
89
|
+
* exception, not the house style.
|
|
90
|
+
*/
|
|
91
|
+
function shapeOf(decls) {
|
|
92
|
+
// BEM's `__` is unmistakable when it is the house style, and meaningless as
|
|
93
|
+
// somebody's two exceptions.
|
|
94
|
+
const bem = decls.filter((c) => c.includes("__")).length;
|
|
95
|
+
const isBem = bem / decls.length > 0.2;
|
|
96
|
+
// A prefix is the first delimited segment, when most of them share it. A
|
|
97
|
+
// project with `app-header` and `admin-nav` has two words, not a prefix.
|
|
98
|
+
const heads = new Map();
|
|
99
|
+
for (const c of decls) {
|
|
100
|
+
const head = c.split(/[-_]/)[0];
|
|
101
|
+
if (!head || head === c)
|
|
102
|
+
continue;
|
|
103
|
+
heads.set(head, (heads.get(head) ?? 0) + 1);
|
|
104
|
+
}
|
|
105
|
+
const top = [...heads.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
106
|
+
const hasPrefix = top != null && top[1] / decls.length >= PREFIX_SHARE;
|
|
107
|
+
// Neither established: there is nothing here for us to follow, and adopting
|
|
108
|
+
// "unprefixed, hyphenated" from a grab-bag would rename everything we compile
|
|
109
|
+
// for no reason anybody chose.
|
|
110
|
+
if (!isBem && !hasPrefix)
|
|
111
|
+
return null;
|
|
112
|
+
return {
|
|
113
|
+
prefix: hasPrefix ? `${top[0]}-` : "",
|
|
114
|
+
partSeparator: isBem ? "__" : "-",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** One line for the report, in their vocabulary and without asking for anything. */
|
|
118
|
+
export function describeClassStyle(style) {
|
|
119
|
+
if (style.kind === "modules") {
|
|
120
|
+
return `your components style themselves through CSS Modules (${style.moduleFiles} module stylesheets) - those class names are local and hashed, so ours stay global and cannot collide with yours`;
|
|
121
|
+
}
|
|
122
|
+
if (style.kind === "utility") {
|
|
123
|
+
return "your components style themselves with utilities, so there are no component class names of yours for ours to match";
|
|
124
|
+
}
|
|
125
|
+
const named = style.prefix ? `prefixed \`${style.prefix}\`` : "unprefixed";
|
|
126
|
+
return `your global classes are ${named} and join parts with \`${style.partSeparator}\` (e.g. ${style.samples.join(", ")}) - what we compile will be spelled the same way`;
|
|
127
|
+
}
|
|
@@ -365,3 +365,20 @@ export function rootClasses(source) {
|
|
|
365
365
|
.flatMap((m) => m[1].split(/\s+/))
|
|
366
366
|
.filter((c) => c.length > 0 && !c.includes("${"));
|
|
367
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Transcribe the parts a skill named, using the same reader the root used.
|
|
370
|
+
*
|
|
371
|
+
* `declared` comes from the census itself on the `--census` path, so a run that
|
|
372
|
+
* only sends a file it was handed still resolves their tokens by name.
|
|
373
|
+
*/
|
|
374
|
+
export function transcribeParts(parts, declared) {
|
|
375
|
+
const out = {};
|
|
376
|
+
for (const part of parts) {
|
|
377
|
+
const name = part.name?.trim();
|
|
378
|
+
if (!name || typeof part.classes !== "string")
|
|
379
|
+
continue;
|
|
380
|
+
const t = transcribe(part.classes.split(/\s+/).filter(Boolean), declared);
|
|
381
|
+
out[name] = { base: t.base, dark: t.dark, states: t.states };
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
package/dist/skill-import.js
CHANGED
|
@@ -134,6 +134,15 @@ Open the project and answer the questions below. Then add a \`reading\` object t
|
|
|
134
134
|
"roles": { "canvas": "#050505", "foreground": "#f9fafb", "primary": "#4A90E2" },
|
|
135
135
|
"fonts": { "display": "Inter", "body": "Inter" },
|
|
136
136
|
"concept": "one paragraph on what this product is",
|
|
137
|
+
"components": {
|
|
138
|
+
"MetricCard": {
|
|
139
|
+
"parts": [
|
|
140
|
+
{ "name": "icon", "classes": "size-8 text-ocean-500" },
|
|
141
|
+
{ "name": "label", "classes": "text-xs uppercase text-lightgray-500" },
|
|
142
|
+
{ "name": "value", "classes": "text-2xl font-bold" }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
},
|
|
137
146
|
"by": "claude"
|
|
138
147
|
}
|
|
139
148
|
\`\`\`
|
|
@@ -166,6 +175,34 @@ about which one is the page. Read a screen and see.
|
|
|
166
175
|
**\`fonts\` and \`concept\`** - the voice, and one paragraph on what this product is. The concept
|
|
167
176
|
feeds every recommendation downstream, so a real one beats a generic one by a wide margin.
|
|
168
177
|
|
|
178
|
+
**\`components[Name].parts\` - what each component is MADE OF.** This is the field that decides
|
|
179
|
+
whether a component previews as itself or as a grey box with a sentence in it, and only you can
|
|
180
|
+
fill it.
|
|
181
|
+
|
|
182
|
+
The census reads the ROOT element's classes and stops there, on purpose: descending a fixed
|
|
183
|
+
number of levels picks a layout wrapper as often as a semantic part. **You decide how far down a
|
|
184
|
+
part lives**, because you read the component. Send a name and the classes on it; the CLI turns
|
|
185
|
+
those classes into declarations and the platform turns declarations into roles.
|
|
186
|
+
|
|
187
|
+
**The name is load-bearing, not a label.** The renderer infers a part's role from it:
|
|
188
|
+
|
|
189
|
+
- \`root\`, \`wrapper\`, \`container\`, \`base\`, \`content\` - the element itself, and **skipped**. Do not
|
|
190
|
+
send these; their styles already sit on the component.
|
|
191
|
+
- anything matching \`icon\`, \`dot\`, \`indicator\`, \`bar\`, \`avatar\`, \`media\`, \`swatch\` - renders as a
|
|
192
|
+
bare span, sized and coloured by its own styles
|
|
193
|
+
- anything matching \`button\`, \`action\`, \`cta\` - renders as a real button carrying the label
|
|
194
|
+
- everything else - carries text
|
|
195
|
+
|
|
196
|
+
So a part named \`div2\` previews as a text node reading "Div2". Name what it IS: \`label\`, \`value\`,
|
|
197
|
+
\`delta\`, \`icon\`, \`action\`, \`title\`, \`meta\`.
|
|
198
|
+
|
|
199
|
+
Send them **in the order they appear**, because that is the order they render. Three or four
|
|
200
|
+
named parts is a recognisable component; twelve is a transcription of their DOM, and nobody
|
|
201
|
+
needs the layout divs.
|
|
202
|
+
|
|
203
|
+
If a component genuinely has no parts - a \`Divider\`, a \`Spinner\` - send none. An empty list is
|
|
204
|
+
a real answer.
|
|
205
|
+
|
|
169
206
|
### 3. Walk them through the decisions, one at a time
|
|
170
207
|
|
|
171
208
|
Four decisions are theirs. **Ask them as separate questions with selectable options** - use your
|
package/package.json
CHANGED