synthesisui 0.16.34 → 0.16.36
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 +353 -0
- package/dist/index.js +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { readToken, resolveRegistry } from "../config.js";
|
|
4
|
+
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
5
|
+
import { buildTable } from "../doctor/tokens.js";
|
|
6
|
+
import { body, paint, section } from "../output.js";
|
|
7
|
+
import { walk, walkAll } from "./doctor.js";
|
|
8
|
+
/**
|
|
9
|
+
* How many distinct values travel, PER KIND.
|
|
10
|
+
*
|
|
11
|
+
* It was one global cap of 120, and a real app broke it (30/07): 2876 files,
|
|
12
|
+
* 3976 findings, and the payload came back holding exactly 120 - ranked by
|
|
13
|
+
* frequency with every kind competing for the same slots. Two things went wrong
|
|
14
|
+
* there, and the second is the serious one:
|
|
15
|
+
*
|
|
16
|
+
* - radius and spacing lost their slots to colour, so the scale downstream
|
|
17
|
+
* would have been built from a partial set
|
|
18
|
+
* - the NEAR-DUPLICATE colours - a fifth grey used three times, the twin of
|
|
19
|
+
* the brand blue - are by definition low-frequency, which is exactly what a
|
|
20
|
+
* frequency-ranked cut throws away first. The collapse that justifies the
|
|
21
|
+
* whole v2 lives in that tail.
|
|
22
|
+
*
|
|
23
|
+
* So colour gets a deep budget and the dimensional families get their own. The
|
|
24
|
+
* worst case is ~380 entries, about 30KB of json - cheap for the one payload
|
|
25
|
+
* that describes somebody's entire vocabulary.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_PER_KIND = {
|
|
28
|
+
color: 250,
|
|
29
|
+
radius: 60,
|
|
30
|
+
spacing: 60,
|
|
31
|
+
motion: 40,
|
|
32
|
+
font: 30,
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Dependencies as the project actually resolves them - which means reading
|
|
36
|
+
* ANCESTOR package.json files too.
|
|
37
|
+
*
|
|
38
|
+
* Measured on our own repo: `import --dir apps/web` reported "plain css" for a
|
|
39
|
+
* Next + React + Tailwind app, because a workspace hoists those to the root and
|
|
40
|
+
* the leaf package.json lists only its own icons. Three levels up covers every
|
|
41
|
+
* pnpm/npm workspace layout without wandering into someone's home directory.
|
|
42
|
+
*/
|
|
43
|
+
async function resolveDeps(root) {
|
|
44
|
+
const deps = {};
|
|
45
|
+
let dir = root;
|
|
46
|
+
for (let up = 0; up < 4; up++) {
|
|
47
|
+
const raw = await readFile(join(dir, "package.json"), "utf8").catch(() => null);
|
|
48
|
+
if (raw) {
|
|
49
|
+
try {
|
|
50
|
+
const p = JSON.parse(raw);
|
|
51
|
+
// The nearest package.json wins on a version clash; we only ever ask
|
|
52
|
+
// whether a name is present, so first-seen is enough.
|
|
53
|
+
for (const [k, v] of Object.entries({
|
|
54
|
+
...p.dependencies,
|
|
55
|
+
...p.devDependencies,
|
|
56
|
+
})) {
|
|
57
|
+
if (deps[k] == null)
|
|
58
|
+
deps[k] = String(v);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// unreadable manifest costs the detection, not the run
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const parent = join(dir, "..");
|
|
66
|
+
if (parent === dir)
|
|
67
|
+
break;
|
|
68
|
+
dir = parent;
|
|
69
|
+
}
|
|
70
|
+
return deps;
|
|
71
|
+
}
|
|
72
|
+
async function detectStack(root) {
|
|
73
|
+
const stack = [];
|
|
74
|
+
const has = async (f) => (await readFile(join(root, f), "utf8").catch(() => null)) !== null;
|
|
75
|
+
const deps = await resolveDeps(root);
|
|
76
|
+
if (await has("components.json"))
|
|
77
|
+
stack.push("shadcn/ui");
|
|
78
|
+
if (deps.next)
|
|
79
|
+
stack.push("next");
|
|
80
|
+
else if (deps.vite)
|
|
81
|
+
stack.push("vite");
|
|
82
|
+
if (deps.react)
|
|
83
|
+
stack.push("react");
|
|
84
|
+
else if (deps.vue)
|
|
85
|
+
stack.push("vue");
|
|
86
|
+
else if (deps.svelte)
|
|
87
|
+
stack.push("svelte");
|
|
88
|
+
if (deps.tailwindcss)
|
|
89
|
+
stack.push("tailwind");
|
|
90
|
+
if (await has("tokens.json"))
|
|
91
|
+
stack.push("tokens.json");
|
|
92
|
+
// Nothing recognised is itself a finding: plain CSS is a supported entrance,
|
|
93
|
+
// and saying so beats an empty list that reads like a failed detection.
|
|
94
|
+
if (stack.length === 0)
|
|
95
|
+
stack.push("plain css");
|
|
96
|
+
return stack;
|
|
97
|
+
}
|
|
98
|
+
/** Their own vocabulary, read from stylesheets - the same harvest the doctor
|
|
99
|
+
* runs when nothing of ours is installed. */
|
|
100
|
+
async function declaredTokens(root) {
|
|
101
|
+
let css = "";
|
|
102
|
+
for await (const file of walkAll([root])) {
|
|
103
|
+
if (!/\.(css|scss|sass|less)$/i.test(file))
|
|
104
|
+
continue;
|
|
105
|
+
css += `\n${await readFile(file, "utf8").catch(() => "")}`;
|
|
106
|
+
}
|
|
107
|
+
const table = buildTable({ css, source: "yours" });
|
|
108
|
+
return Object.fromEntries(table.byName);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* EVERY distinct design value, commonest first - not just the repeated ones.
|
|
112
|
+
*
|
|
113
|
+
* The doctor's `repeats` list deliberately drops single-use values, because as a
|
|
114
|
+
* to-do list "this appears once" is noise. A CENSUS is the opposite kind of
|
|
115
|
+
* document: it is a vocabulary, and a brand colour that lives in one reusable
|
|
116
|
+
* button is still the brand colour. Caught on a fake v0 app whose primary
|
|
117
|
+
* (#7c3aed) appeared exactly once and vanished from the payload.
|
|
118
|
+
*/
|
|
119
|
+
function distinctValues(d) {
|
|
120
|
+
const by = new Map();
|
|
121
|
+
for (const f of d.findings) {
|
|
122
|
+
const key = `${f.kind}:${f.literal.toLowerCase()}`;
|
|
123
|
+
const hit = by.get(key);
|
|
124
|
+
if (hit) {
|
|
125
|
+
hit.count += 1;
|
|
126
|
+
hit.files.add(f.file);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
by.set(key, {
|
|
130
|
+
kind: f.kind,
|
|
131
|
+
value: f.literal,
|
|
132
|
+
count: 1,
|
|
133
|
+
files: new Set([f.file]),
|
|
134
|
+
token: f.token,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Per-kind budgets, each family ranked inside its own: colour never starves
|
|
138
|
+
// radius, and the low-frequency tail of colour (where near-duplicates live)
|
|
139
|
+
// survives a cut that a global cap would have made first.
|
|
140
|
+
const kept = [];
|
|
141
|
+
const dropped = new Map();
|
|
142
|
+
for (const kind of Object.keys(MAX_PER_KIND)) {
|
|
143
|
+
const family = [...by.values()]
|
|
144
|
+
.filter((v) => v.kind === kind)
|
|
145
|
+
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
146
|
+
const budget = MAX_PER_KIND[kind];
|
|
147
|
+
if (family.length > budget)
|
|
148
|
+
dropped.set(kind, family.length - budget);
|
|
149
|
+
for (const v of family.slice(0, budget)) {
|
|
150
|
+
kept.push({
|
|
151
|
+
kind: v.kind,
|
|
152
|
+
value: v.value,
|
|
153
|
+
count: v.count,
|
|
154
|
+
files: v.files.size,
|
|
155
|
+
token: v.token,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// NO SILENT CAPS: a payload that quietly stops at a budget reads as "this is
|
|
160
|
+
// everything you have", which is the one thing a census must never imply.
|
|
161
|
+
for (const [kind, n] of dropped) {
|
|
162
|
+
console.log(body(paint.faint(`(${n} more ${kind} value${n === 1 ? "" : "s"} exist below the ${MAX_PER_KIND[kind]} we carry - each used less often than the ones above)`)));
|
|
163
|
+
}
|
|
164
|
+
return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
165
|
+
}
|
|
166
|
+
export async function takeCensus(root) {
|
|
167
|
+
const table = buildTable({ css: "", source: "yours" });
|
|
168
|
+
const reports = [];
|
|
169
|
+
for await (const file of walk(root)) {
|
|
170
|
+
const src = await readFile(file, "utf8").catch(() => "");
|
|
171
|
+
if (!src)
|
|
172
|
+
continue;
|
|
173
|
+
reports.push(scanSource(relative(root, file), src, table));
|
|
174
|
+
}
|
|
175
|
+
const d = diagnose(reports);
|
|
176
|
+
const pkgRaw = await readFile(join(root, "package.json"), "utf8").catch(() => null);
|
|
177
|
+
let name = null;
|
|
178
|
+
if (pkgRaw) {
|
|
179
|
+
try {
|
|
180
|
+
name = JSON.parse(pkgRaw).name ?? null;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// an unreadable package.json costs the name, not the run
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
census: 1,
|
|
188
|
+
project: { name, stack: await detectStack(root) },
|
|
189
|
+
declared: await declaredTokens(root),
|
|
190
|
+
observed: distinctValues(d),
|
|
191
|
+
totals: {
|
|
192
|
+
scanned: d.scanned,
|
|
193
|
+
values: d.findings.length,
|
|
194
|
+
named: d.named,
|
|
195
|
+
tokenUses: d.tokenUses,
|
|
196
|
+
coverage: d.coverage,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function summarize(c) {
|
|
201
|
+
const byKind = new Map();
|
|
202
|
+
for (const v of c.observed)
|
|
203
|
+
byKind.set(v.kind, (byKind.get(v.kind) ?? 0) + v.count);
|
|
204
|
+
const kinds = [...byKind.entries()]
|
|
205
|
+
.sort((a, b) => b[1] - a[1])
|
|
206
|
+
.map(([k, n]) => `${n} ${k}`)
|
|
207
|
+
.join(" · ");
|
|
208
|
+
console.log(section("What your project says"));
|
|
209
|
+
console.log(body(`${c.project.stack.join(" · ")}`));
|
|
210
|
+
console.log(body(`${c.totals.scanned} files · ${c.observed.length} distinct design values`));
|
|
211
|
+
if (kinds)
|
|
212
|
+
console.log(body(kinds));
|
|
213
|
+
if (Object.keys(c.declared).length > 0) {
|
|
214
|
+
console.log(body(`${Object.keys(c.declared).length} tokens you already declare - your names travel unchanged`));
|
|
215
|
+
}
|
|
216
|
+
const top = c.observed.slice(0, 5);
|
|
217
|
+
if (top.length > 0) {
|
|
218
|
+
console.log("");
|
|
219
|
+
for (const v of top) {
|
|
220
|
+
console.log(body(`${paint.strong(v.value)} ${paint.faint(`${v.kind}, ${v.count}× in ${v.files} file${v.files === 1 ? "" : "s"}`)}`));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The contract an agent needs, printed where an agent will read it.
|
|
226
|
+
*
|
|
227
|
+
* A skill file cannot help the FIRST import - it is not installed yet, and this
|
|
228
|
+
* is the moment before any relationship exists. Terminal output, though, is
|
|
229
|
+
* already in the agent's context: it ran the command. So the command teaches.
|
|
230
|
+
*
|
|
231
|
+
* What we ask for is strictly JUDGEMENT, never numbers. Which of three greys is
|
|
232
|
+
* the real grey, which family is the display voice, what this product is. The
|
|
233
|
+
* census stays arithmetic, and every value in `roles` must be one the census
|
|
234
|
+
* already found - the endpoint drops anything else.
|
|
235
|
+
*/
|
|
236
|
+
function printAgentContract() {
|
|
237
|
+
console.log("");
|
|
238
|
+
console.log(section("If you are an agent reading this"));
|
|
239
|
+
console.log(body("The census is deterministic and complete. What it cannot do is judge, so"));
|
|
240
|
+
console.log(body("add a `reading` object to the file and send that back to us:"));
|
|
241
|
+
console.log("");
|
|
242
|
+
console.log(body(paint.faint(' "reading": {')));
|
|
243
|
+
console.log(body(paint.faint(' "roles": { "canvas": "#…", "foreground": "#…", "primary": "#…" },')));
|
|
244
|
+
console.log(body(paint.faint(' "fonts": { "display": "…", "body": "…" },')));
|
|
245
|
+
console.log(body(paint.faint(' "concept": "one paragraph on what this product is"')));
|
|
246
|
+
console.log(body(paint.faint(" }")));
|
|
247
|
+
console.log("");
|
|
248
|
+
console.log(body("Every hex in `roles` must be one the census already observed - a value"));
|
|
249
|
+
console.log(body("we did not find in their code is dropped. Then send the file:"));
|
|
250
|
+
console.log("");
|
|
251
|
+
console.log(body(` ${paint.strong("synthesisui import --census _synthesisui/census.json")}`));
|
|
252
|
+
}
|
|
253
|
+
export async function runImport(opts) {
|
|
254
|
+
const root = opts.root ?? process.cwd();
|
|
255
|
+
// A census handed to us (an agent annotated it) is sent as-is; the numbers
|
|
256
|
+
// inside were still ours to begin with.
|
|
257
|
+
let census;
|
|
258
|
+
if (opts.census) {
|
|
259
|
+
const raw = await readFile(opts.census, "utf8").catch(() => null);
|
|
260
|
+
if (!raw) {
|
|
261
|
+
console.log(section("Import"));
|
|
262
|
+
console.log(body(`Cannot read ${opts.census}.`));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
census = JSON.parse(raw);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
console.log(section("Import"));
|
|
270
|
+
console.log(body(`${opts.census} is not valid JSON.`));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (census.census !== 1) {
|
|
274
|
+
console.log(section("Import"));
|
|
275
|
+
console.log(body("That file is not a census this version can send."));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
console.log(section("Reading your project"));
|
|
281
|
+
census = await takeCensus(root);
|
|
282
|
+
}
|
|
283
|
+
summarize(census);
|
|
284
|
+
const out = join(root, "_synthesisui", "census.json");
|
|
285
|
+
await mkdir(join(root, "_synthesisui"), { recursive: true });
|
|
286
|
+
await writeFile(out, `${JSON.stringify(census, null, 2)}\n`, "utf8");
|
|
287
|
+
console.log("");
|
|
288
|
+
console.log(body(`Written to ${paint.strong(relative(root, out))}`));
|
|
289
|
+
if (opts.dry) {
|
|
290
|
+
console.log(body("Nothing was sent. Read the file, then run it without --dry."));
|
|
291
|
+
printAgentContract();
|
|
292
|
+
console.log("");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const token = await readToken();
|
|
296
|
+
if (!token) {
|
|
297
|
+
console.log("");
|
|
298
|
+
console.log(body("To turn this into a system on your account:"));
|
|
299
|
+
console.log(body(` ${paint.strong("synthesisui login")}`));
|
|
300
|
+
console.log(body(" synthesisui import"));
|
|
301
|
+
console.log("");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const base = resolveRegistry(opts.registry);
|
|
305
|
+
const res = await fetch(`${base}/api/onboarding/import`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: {
|
|
308
|
+
"content-type": "application/json",
|
|
309
|
+
Authorization: `Bearer ${token}`,
|
|
310
|
+
},
|
|
311
|
+
body: JSON.stringify({ census, name: opts.name }),
|
|
312
|
+
}).catch(() => null);
|
|
313
|
+
if (!res || !res.ok) {
|
|
314
|
+
const detail = res
|
|
315
|
+
? await res
|
|
316
|
+
.json()
|
|
317
|
+
.then((b) => b?.message ?? null)
|
|
318
|
+
.catch(() => null)
|
|
319
|
+
: null;
|
|
320
|
+
console.log("");
|
|
321
|
+
console.log(body(res
|
|
322
|
+
? `The registry refused it (HTTP ${res.status})${detail ? `: ${detail}` : "."}`
|
|
323
|
+
: "Could not reach the registry. The census is on disk - try again later."));
|
|
324
|
+
console.log("");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const payload = (await res.json().catch(() => null));
|
|
328
|
+
console.log("");
|
|
329
|
+
console.log(section("Your system exists"));
|
|
330
|
+
console.log(body(`${paint.strong(payload?.name ?? "Your system")} - v1 mirrors your tokens exactly, nothing improved yet.`));
|
|
331
|
+
for (const note of payload?.notes ?? [])
|
|
332
|
+
console.log(body(paint.dim(note)));
|
|
333
|
+
// What a v2 would be worth, in their own values. Said here because this is
|
|
334
|
+
// where the person is standing, and because a number they can check beats an
|
|
335
|
+
// invitation they have to trust.
|
|
336
|
+
const n = payload?.normalize;
|
|
337
|
+
if (n?.decisions && n.decisions > 0) {
|
|
338
|
+
console.log("");
|
|
339
|
+
console.log(section("What a v2 would normalize"));
|
|
340
|
+
console.log(body(`${paint.strong(String(n.decisions))} colour${n.decisions === 1 ? "" : "s"} in your code ${n.decisions === 1 ? "is" : "are"} the same decision typed twice - ${paint.strong(String(n.retires ?? 0))} uses would retire.`));
|
|
341
|
+
for (const line of n.lines ?? [])
|
|
342
|
+
console.log(body(paint.dim(line)));
|
|
343
|
+
console.log("");
|
|
344
|
+
console.log(body("Nothing was changed. You approve it there:"));
|
|
345
|
+
}
|
|
346
|
+
else if (payload?.url) {
|
|
347
|
+
console.log("");
|
|
348
|
+
console.log(body("Name it, see it, and govern it:"));
|
|
349
|
+
}
|
|
350
|
+
if (payload?.url)
|
|
351
|
+
console.log(body(` ${paint.blue(payload.url)}`));
|
|
352
|
+
console.log("");
|
|
353
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { connect } from "./commands/connect.js";
|
|
|
9
9
|
import { doctor } from "./commands/doctor.js";
|
|
10
10
|
import { generate } from "./commands/generate.js";
|
|
11
11
|
import { hook } from "./commands/hook.js";
|
|
12
|
+
import { runImport } from "./commands/import.js";
|
|
12
13
|
import { init } from "./commands/init.js";
|
|
13
14
|
import { list } from "./commands/list.js";
|
|
14
15
|
import { login } from "./commands/login.js";
|
|
@@ -40,6 +41,7 @@ Usage - deterministic, FREE:
|
|
|
40
41
|
|
|
41
42
|
Usage - governance (deterministic, FREE):
|
|
42
43
|
synthesisui adopt [--write] turn the design system you ALREADY have into a contract
|
|
44
|
+
synthesisui import [--dry] read the app you already have and make it a system
|
|
43
45
|
your agent follows - without touching your CSS
|
|
44
46
|
synthesisui connect wire your agent: the check as an editor hook, the system
|
|
45
47
|
as MCP tools, and a contract that stops repeating itself
|
|
@@ -86,6 +88,7 @@ Options:
|
|
|
86
88
|
|
|
87
89
|
Examples:
|
|
88
90
|
synthesisui adopt # you already have a system: start here
|
|
91
|
+
synthesisui import --dry # see the census before anything is sent
|
|
89
92
|
synthesisui login
|
|
90
93
|
synthesisui init --target next
|
|
91
94
|
synthesisui init --target next --ds halogen bootstrap + bring a system in
|
|
@@ -149,6 +152,16 @@ async function main() {
|
|
|
149
152
|
const registry = typeof flags.registry === "string" ? flags.registry : undefined;
|
|
150
153
|
const dir = typeof flags.dir === "string" ? flags.dir : undefined;
|
|
151
154
|
switch (command) {
|
|
155
|
+
case "import":
|
|
156
|
+
// The census is arithmetic over their files; --dry keeps it on disk.
|
|
157
|
+
await runImport({
|
|
158
|
+
root: dir,
|
|
159
|
+
dry: flags.dry === true,
|
|
160
|
+
census: typeof flags.census === "string" ? flags.census : undefined,
|
|
161
|
+
name: typeof flags.name === "string" ? flags.name : undefined,
|
|
162
|
+
registry,
|
|
163
|
+
});
|
|
164
|
+
break;
|
|
152
165
|
case "adopt":
|
|
153
166
|
// Dry by default: `--write` is the only way anything lands on disk.
|
|
154
167
|
await adopt({
|
package/package.json
CHANGED