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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.34",
3
+ "version": "0.16.36",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {