synthesisui 0.14.0 → 0.15.0

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/claude-md.js CHANGED
@@ -18,7 +18,12 @@ async function readInstalled(projectRoot) {
18
18
  try {
19
19
  const raw = await readFile(join(dsDir, slug, ".lock"), "utf8");
20
20
  const lock = JSON.parse(raw);
21
- locks.push({ slug: lock.slug, name: lock.name, version: lock.version });
21
+ locks.push({
22
+ slug: lock.slug,
23
+ name: lock.name,
24
+ version: lock.version,
25
+ adopted: lock.adopted === true,
26
+ });
22
27
  }
23
28
  catch {
24
29
  // folder without a valid .lock - ignore
@@ -87,24 +92,41 @@ async function renderRegion(projectRoot, installed) {
87
92
  }
88
93
  const sections = [];
89
94
  for (const ds of installed) {
90
- const head = `- **${ds.name}** (\`${ds.slug}\`, v${ds.version}) - guide: \`_synthesisui/ds/${ds.slug}/v${ds.version}/GUIDE.md\``;
95
+ // An adopted system has no published version and therefore no `v<n>/`
96
+ // folder - pointing the agent at one points it at a file that does not
97
+ // exist (caught by running it, 27/07).
98
+ const head = ds.adopted
99
+ ? `- **${ds.name}** (\`${ds.slug}\`, adopted from this repo) - guide: \`_synthesisui/ds/${ds.slug}/GUIDE.md\``
100
+ : `- **${ds.name}** (\`${ds.slug}\`, v${ds.version}) - guide: \`_synthesisui/ds/${ds.slug}/v${ds.version}/GUIDE.md\``;
91
101
  const manifest = await readManifest(projectRoot, ds);
92
102
  sections.push(manifest ? `${head}\n${manifest}` : head);
93
103
  }
94
- const body = `## Design Systems (via SynthesisUI)
95
-
96
- This project uses design system(s) brought in by the \`synthesisui\` CLI. **When creating or editing
97
- components, read the system's GUIDE.md and follow it:** use only semantic tokens
104
+ // Two truths, and asserting the wrong one misleads the agent every time it
105
+ // writes a line. An INSTALLED system speaks `--ds-*` and is scoped with
106
+ // `data-ds`; an ADOPTED one is the project's own vocabulary, already wired,
107
+ // with no `data-ds` anywhere to scope to.
108
+ const onlyAdopted = installed.every((d) => d.adopted);
109
+ const rule = onlyAdopted
110
+ ? `**When creating or editing components, read the system's GUIDE.md and follow it:** use the
111
+ project's OWN custom properties, exactly as the guide lists them. Do not write raw colours,
112
+ spacings or radii that a token already covers, and do not invent a new token silently - say so
113
+ instead, because a new token is a decision for a person to make. There is no component
114
+ manifest for an adopted system - the tokens ARE the contract. Run \`synthesisui doctor\` to see
115
+ how much of the shipped UI already resolves to them.`
116
+ : `**When creating or editing components, read the system's GUIDE.md and follow it:** use only semantic tokens
98
117
  (\`var(--ds-color-semantic-*)\`, \`--ds-spacing-*\`, etc.), scope the UI with \`data-ds="<slug>"\`,
99
118
  and reuse the \`.ds-*\` classes. Do not use raw values outside the system's scale. **Before
100
119
  creating any UI element, check the component manifest below - if it exists, use or extend
101
120
  it (\`synthesisui component <slug> <name>\` materializes it as your code).** To review a
102
121
  component, create an isolated sample page (e.g. \`app/synthesisui-samples/<component>/\`) - do not
103
- apply it to real production pages unless asked.
122
+ apply it to real production pages unless asked.`;
123
+ const body = `## Design Systems (via SynthesisUI)
124
+
125
+ This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}
104
126
 
105
127
  ${sections.join("\n")}
106
128
 
107
- _Block managed by the CLI - do not edit by hand; run \`synthesisui add <slug>\` to update._`;
129
+ _Block managed by the CLI - do not edit by hand; run \`synthesisui ${onlyAdopted ? "adopt --write" : "add <slug>"}\` to update._`;
108
130
  return `${START}\n${body}\n${END}`;
109
131
  }
110
132
  /**
@@ -0,0 +1,207 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { syncClaudeMd } from "../claude-md.js";
4
+ import { parseRootTokens } from "../doctor/tokens.js";
5
+ import { body, section, snippet } from "../output.js";
6
+ import { walk } from "./doctor.js";
7
+ /**
8
+ * `synthesisui adopt` - the system somebody ALREADY has, made legible.
9
+ *
10
+ * The whole product assumed you start from nothing. `init` installs OUR tokens
11
+ * and therefore needs four manual edits - import, scope with data-ds, wire
12
+ * fonts - which is where people give up (walked 27/07, the next run reads 0%).
13
+ *
14
+ * The reader who feels our pitch hardest does not need any of that. Their
15
+ * `--acme-*` tokens are already in `:root` and already painting the screen.
16
+ * What is missing is not code, it is the CONTRACT: the file their coding agent
17
+ * reads before writing UI.
18
+ *
19
+ * So this command writes NO CSS, and that is the point rather than a
20
+ * limitation:
21
+ *
22
+ * init writes CSS → needs wiring → 4 edits → 0% if one is wrong
23
+ * adopt writes none → no wiring → works on the first command
24
+ *
25
+ * THE PROMISE, printed rather than buried in docs: it reads your code and
26
+ * rewrites none of it; everything it creates lives in `_synthesisui/` and
27
+ * leaves with `clean`; your tokens keep your names.
28
+ *
29
+ * Dry by default. A command that edits somebody's repository on first run is
30
+ * not a command that people with a system in production ever try.
31
+ */
32
+ const SCOPE_HINT = ":root, html, :host and @theme";
33
+ /** Group by the prefix an author actually used, which is how they think of it. */
34
+ function byKind(tokens) {
35
+ const kinds = new Map();
36
+ const guess = (n) => /color|colour|bg|background|fg|foreground|surface|canvas|ink|brand/.test(n)
37
+ ? "colour"
38
+ : /space|spacing|gap|inset/.test(n)
39
+ ? "spacing"
40
+ : /radius|round|corner/.test(n)
41
+ ? "radius"
42
+ : /font|type|text|leading|tracking|weight/.test(n)
43
+ ? "type"
44
+ : /shadow|elevation/.test(n)
45
+ ? "shadow"
46
+ : /duration|ease|motion|transition/.test(n)
47
+ ? "motion"
48
+ : "other";
49
+ for (const name of tokens.keys()) {
50
+ const k = guess(name);
51
+ kinds.set(k, (kinds.get(k) ?? 0) + 1);
52
+ }
53
+ return [...kinds.entries()].sort((a, b) => b[1] - a[1]);
54
+ }
55
+ /** The commonest `--<prefix>-` in their own names - the slug they already use. */
56
+ function guessSlug(tokens, pkgName) {
57
+ const counts = new Map();
58
+ for (const n of tokens.keys()) {
59
+ const m = /^--([a-z0-9]+)-/.exec(n);
60
+ if (m)
61
+ counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
62
+ }
63
+ const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
64
+ // Only trust the prefix when it actually dominates; a repo whose tokens are
65
+ // `--color-*`, `--space-*` has no brand prefix and the package name is the
66
+ // better answer.
67
+ if (top &&
68
+ top[1] >= tokens.size * 0.5 &&
69
+ !/^(color|space|font|size)$/.test(top[0])) {
70
+ return top[0];
71
+ }
72
+ return (pkgName
73
+ .replace(/^@[^/]+\//, "")
74
+ .replace(/[^a-z0-9]+/gi, "-")
75
+ .replace(/^-|-$/g, "")
76
+ .toLowerCase() || "my-system");
77
+ }
78
+ async function findTokens(root, only) {
79
+ let files = 0;
80
+ const from = [];
81
+ const all = new Map();
82
+ const take = async (file) => {
83
+ const css = await readFile(file, "utf8").catch(() => "");
84
+ if (!css)
85
+ return;
86
+ files++;
87
+ const found = parseRootTokens(css);
88
+ if (found.size === 0)
89
+ return;
90
+ from.push(`${relative(root, file)} (${found.size})`);
91
+ for (const [k, v] of found)
92
+ if (!all.has(k))
93
+ all.set(k, v);
94
+ };
95
+ if (only) {
96
+ await take(resolve(root, only));
97
+ }
98
+ else {
99
+ for await (const f of walk(root)) {
100
+ if (/\.(css|scss|sass|less)$/i.test(f))
101
+ await take(f);
102
+ }
103
+ }
104
+ return { tokens: all, files, from };
105
+ }
106
+ /** The contract. Names THEIR tokens - there is no translation layer. */
107
+ function guide(name, slug, tokens) {
108
+ const lines = [...tokens.entries()].map(([k, v]) => ` ${k}: ${v};`);
109
+ return `# ${name} - the contract your agent follows
110
+
111
+ Adopted from this repository by \`synthesisui adopt\`. These are YOUR tokens,
112
+ under YOUR names - nothing was renamed and no stylesheet was rewritten.
113
+
114
+ ## The rule
115
+
116
+ When writing or editing UI in this project, use these custom properties.
117
+ Do not write raw colours, spacings or radii that a token already covers.
118
+
119
+ ✗ background: #3b82f6
120
+ ✓ background: var(--${slug}-color-primary)
121
+
122
+ If a value has no token, say so instead of inventing one silently - a new
123
+ token is a decision for a person to make.
124
+
125
+ ## The tokens (${tokens.size})
126
+
127
+ \`\`\`css
128
+ :root {
129
+ ${lines.join("\n")}
130
+ }
131
+ \`\`\`
132
+
133
+ ## Checking your work
134
+
135
+ npx synthesisui@latest doctor
136
+
137
+ Reports how much of the shipped UI resolves to these tokens, and names the
138
+ token for any hardcoded value that already has one.
139
+ `;
140
+ }
141
+ export async function adopt(opts) {
142
+ const root = resolve(opts.dir ?? process.cwd());
143
+ const pkg = await readFile(join(root, "package.json"), "utf8")
144
+ .then((r) => JSON.parse(r))
145
+ .catch(() => ({ name: undefined }));
146
+ const found = await findTokens(root, opts.tokens);
147
+ console.log(section("Adopt"));
148
+ if (found.tokens.size === 0) {
149
+ // Never "failed to adopt". Always: what was looked for, where, and the
150
+ // next thing to type. Being stuck with no move is the worst outcome.
151
+ console.log(body(`Looked in ${SCOPE_HINT} across ${found.files} stylesheet${found.files === 1 ? "" : "s"} and found no custom properties.`));
152
+ console.log("");
153
+ console.log(body("If your tokens live somewhere this did not reach:"));
154
+ console.log(snippet(["npx synthesisui@latest adopt --tokens <path/to.css>"]));
155
+ console.log("");
156
+ console.log(body("If you do not have a system yet, start from one:"));
157
+ console.log(body("https://www.synthesisui.com/gallery"));
158
+ return;
159
+ }
160
+ const slug = opts.slug ?? guessSlug(found.tokens, pkg.name ?? "");
161
+ const name = (pkg.name ?? slug).replace(/^@[^/]+\//, "");
162
+ const dir = join(root, "_synthesisui", "ds", slug);
163
+ console.log(body(`Reading your system … ${found.files} stylesheet${found.files === 1 ? "" : "s"}, ${found.tokens.size} tokens found`));
164
+ console.log("");
165
+ for (const [kind, n] of byKind(found.tokens)) {
166
+ console.log(body(` ${kind.padEnd(10)} ${String(n).padStart(3)}`));
167
+ }
168
+ console.log("");
169
+ for (const f of found.from.slice(0, 4))
170
+ console.log(body(` from ${f}`));
171
+ if (found.from.length > 4)
172
+ console.log(body(` and ${found.from.length - 4} more`));
173
+ if (!opts.write) {
174
+ console.log("");
175
+ console.log(body("Would create (nothing written yet):"));
176
+ console.log(body(` _synthesisui/ds/${slug}/system.json your system, described`));
177
+ console.log(body(` _synthesisui/ds/${slug}/GUIDE.md what your agent reads`));
178
+ console.log(body(" CLAUDE.md a managed block pointing at it"));
179
+ console.log("");
180
+ console.log(body("Your stylesheets are not touched. Nothing outside"));
181
+ console.log(body("_synthesisui/ is written, and `clean` removes all of it."));
182
+ console.log("");
183
+ console.log(snippet([
184
+ `npx synthesisui@latest adopt --write${opts.slug ? ` --slug ${slug}` : ""}`,
185
+ ]));
186
+ console.log(body(`writes it as "${slug}" - pass --slug to name it differently`));
187
+ return;
188
+ }
189
+ await mkdir(dir, { recursive: true });
190
+ await writeFile(join(dir, "system.json"), `${JSON.stringify({
191
+ meta: { name, slug, adopted: true, source: "repository" },
192
+ tokens: Object.fromEntries(found.tokens),
193
+ }, null, 2)}\n`, "utf8");
194
+ await writeFile(join(dir, "GUIDE.md"), guide(name, slug, found.tokens), "utf8");
195
+ // `version: 0` marks a system nobody published - the doctor and CLAUDE.md
196
+ // read this to know it is adopted rather than installed.
197
+ await writeFile(join(dir, ".lock"), `${JSON.stringify({ slug, name, version: 0, adopted: true }, null, 2)}\n`, "utf8");
198
+ await syncClaudeMd(root);
199
+ console.log("");
200
+ console.log(body(`✓ _synthesisui/ds/${slug}/ written`));
201
+ console.log(body("✓ CLAUDE.md managed block added"));
202
+ console.log("");
203
+ console.log(body("Your agent now knows your system. Nothing of yours changed."));
204
+ console.log("");
205
+ console.log(snippet(["npx synthesisui@latest doctor"]));
206
+ console.log(body("see what it makes measurable"));
207
+ }
@@ -40,7 +40,7 @@ const SKIP = new Set([
40
40
  // Our own installed artifacts are the answer, not the problem.
41
41
  "_synthesisui",
42
42
  ]);
43
- async function* walk(dir) {
43
+ export async function* walk(dir) {
44
44
  // `readdir`'s overloads infer a Buffer-named Dirent without an explicit
45
45
  // encoding; naming it keeps `e.name` a string.
46
46
  const entries = await readdir(dir, {
@@ -63,7 +63,7 @@ async function* walk(dir) {
63
63
  }
64
64
  /** The same walk over several roots, in the order the caller named them. A
65
65
  * single file passed as a scope is read as itself. */
66
- async function* walkAll(roots) {
66
+ export async function* walkAll(roots) {
67
67
  for (const r of roots) {
68
68
  if (EXTS.some((x) => r.endsWith(x))) {
69
69
  yield r;
@@ -91,6 +91,7 @@ async function loadSystem(root) {
91
91
  // reading them all is both correct and what the running app actually sees.
92
92
  let css = "";
93
93
  let lock = null;
94
+ let adopted = false;
94
95
  const recipes = new Map();
95
96
  const documents = [];
96
97
  for (const slug of slugs) {
@@ -118,6 +119,24 @@ async function loadSystem(root) {
118
119
  }
119
120
  }
120
121
  css += `\n${real || root}`;
122
+ // An ADOPTED system has no tokens.css of ours - `adopt` deliberately
123
+ // writes no CSS, because the project's own stylesheet already works. Its
124
+ // vocabulary lives in system.json, and without this the doctor falls
125
+ // through to harvesting and then offers to install a system to somebody
126
+ // who just adopted one (caught running it, 27/07).
127
+ if (mine?.adopted) {
128
+ const raw = await readFile(join(dir, "system.json"), "utf8").catch(() => "");
129
+ try {
130
+ const parsed = JSON.parse(raw);
131
+ for (const [k, v] of Object.entries(parsed.tokens ?? {})) {
132
+ css += `\n:root { ${k}: ${v}; }`;
133
+ }
134
+ adopted = true;
135
+ }
136
+ catch {
137
+ // Unreadable system.json costs the naming, not the run.
138
+ }
139
+ }
121
140
  const docRaw = mine?.version
122
141
  ? await readFile(join(dir, `v${mine.version}`, "design-system.json"), "utf8").catch(() => "")
123
142
  : "";
@@ -135,7 +154,11 @@ async function loadSystem(root) {
135
154
  }
136
155
  }
137
156
  }
138
- return { table: buildTable({ css, lock }), recipes, documents };
157
+ return {
158
+ table: buildTable({ css, lock, source: adopted ? "adopted" : "installed" }),
159
+ recipes,
160
+ documents,
161
+ };
139
162
  }
140
163
  /**
141
164
  * The project's OWN vocabulary, when we did not put one there.
@@ -321,9 +344,11 @@ export async function doctor(opts) {
321
344
  // possible first line.
322
345
  table.source === "installed"
323
346
  ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${plural(table.byName.size, "token")}, ${plural(d.scanned, "file")} read`
324
- : table.source === "yours"
325
- ? `Your own tokens - ${plural(table.byName.size, "token")} found, ${plural(d.scanned, "file")} read`
326
- : `No system installed - ${plural(d.scanned, "file")} read`));
347
+ : table.source === "adopted"
348
+ ? `${table.name ?? table.slug} (adopted) - ${plural(table.byName.size, "token")}, ${plural(d.scanned, "file")} read`
349
+ : table.source === "yours"
350
+ ? `Your own tokens - ${plural(table.byName.size, "token")} found, ${plural(d.scanned, "file")} read`
351
+ : `No system installed - ${plural(d.scanned, "file")} read`));
327
352
  if (scopes.length > 0) {
328
353
  console.log(body(`scope: ${opts.scopes?.join(", ")}`));
329
354
  }
@@ -212,7 +212,9 @@ export function parseRootTokens(css) {
212
212
  }
213
213
  export function buildTable(input) {
214
214
  const source = input.source ?? "installed";
215
- const byName = source === "yours" ? parseRootTokens(input.css) : parseTokens(input.css);
215
+ const byName = source === "installed"
216
+ ? parseTokens(input.css)
217
+ : parseRootTokens(input.css);
216
218
  const byValue = new Map();
217
219
  for (const [name, value] of byName) {
218
220
  const key = normalizeValue(value);
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { add } from "./commands/add.js";
3
+ import { adopt } from "./commands/adopt.js";
3
4
  import { advise } from "./commands/advise.js";
4
5
  import { clean } from "./commands/clean.js";
5
6
  import { component } from "./commands/component.js";
@@ -123,6 +124,15 @@ async function main() {
123
124
  const registry = typeof flags.registry === "string" ? flags.registry : undefined;
124
125
  const dir = typeof flags.dir === "string" ? flags.dir : undefined;
125
126
  switch (command) {
127
+ case "adopt":
128
+ // Dry by default: `--write` is the only way anything lands on disk.
129
+ await adopt({
130
+ dir,
131
+ write: flags.write === true,
132
+ tokens: typeof flags.tokens === "string" ? flags.tokens : undefined,
133
+ slug: typeof flags.slug === "string" ? flags.slug : undefined,
134
+ });
135
+ break;
126
136
  case "doctor":
127
137
  // positional paths scope the READING (the system is still found from the
128
138
  // root): `doctor apps/web packages/ui` in a monorepo
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
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": {