synthesisui 0.14.0 → 0.16.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
@@ -81,30 +86,70 @@ async function readManifest(projectRoot, ds) {
81
86
  return null;
82
87
  }
83
88
  }
89
+ /**
90
+ * The loop that closes itself, and the one paragraph both kinds of system share.
91
+ *
92
+ * The contract said what to write and never said to check, so verification
93
+ * happened days later, by a human, across a whole repo - archaeology rather
94
+ * than a fix. `doctor <file> --verbose` already existed; nobody was pointing
95
+ * the agent at it.
96
+ *
97
+ * Held in one constant on purpose: the same paragraph typed into two template
98
+ * literals is two paragraphs that drift.
99
+ */
100
+ const SELF_CHECK = `
101
+
102
+ **After writing or editing any UI file, check it before moving on:**
103
+
104
+ npx synthesisui@latest doctor <the file you just wrote> --verbose
105
+
106
+ It names the token for every hardcoded value it finds. Fix what it names, run it
107
+ again, and only then move to the next thing. Do not batch this up for the end -
108
+ a component checked while you still have its context is a two-line fix; the same
109
+ component found in a repo-wide audit next week is an archaeology exercise.
110
+
111
+ If it reports a value with no token, do NOT invent one. Say which value and what
112
+ you would call it, and let a person decide.`;
84
113
  async function renderRegion(projectRoot, installed) {
85
114
  if (installed.length === 0) {
86
115
  return `${START}\n${END}`;
87
116
  }
88
117
  const sections = [];
89
118
  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\``;
119
+ // An adopted system has no published version and therefore no `v<n>/`
120
+ // folder - pointing the agent at one points it at a file that does not
121
+ // exist (caught by running it, 27/07).
122
+ const head = ds.adopted
123
+ ? `- **${ds.name}** (\`${ds.slug}\`, adopted from this repo) - guide: \`_synthesisui/ds/${ds.slug}/GUIDE.md\``
124
+ : `- **${ds.name}** (\`${ds.slug}\`, v${ds.version}) - guide: \`_synthesisui/ds/${ds.slug}/v${ds.version}/GUIDE.md\``;
91
125
  const manifest = await readManifest(projectRoot, ds);
92
126
  sections.push(manifest ? `${head}\n${manifest}` : head);
93
127
  }
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
128
+ // Two truths, and asserting the wrong one misleads the agent every time it
129
+ // writes a line. An INSTALLED system speaks `--ds-*` and is scoped with
130
+ // `data-ds`; an ADOPTED one is the project's own vocabulary, already wired,
131
+ // with no `data-ds` anywhere to scope to.
132
+ const onlyAdopted = installed.every((d) => d.adopted);
133
+ const rule = onlyAdopted
134
+ ? `**When creating or editing components, read the system's GUIDE.md and follow it:** use the
135
+ project's OWN custom properties, exactly as the guide lists them. Do not write raw colours,
136
+ spacings or radii that a token already covers, and do not invent a new token silently - say so
137
+ instead, because a new token is a decision for a person to make. There is no component
138
+ manifest for an adopted system - the tokens ARE the contract.${SELF_CHECK}`
139
+ : `**When creating or editing components, read the system's GUIDE.md and follow it:** use only semantic tokens
98
140
  (\`var(--ds-color-semantic-*)\`, \`--ds-spacing-*\`, etc.), scope the UI with \`data-ds="<slug>"\`,
99
141
  and reuse the \`.ds-*\` classes. Do not use raw values outside the system's scale. **Before
100
142
  creating any UI element, check the component manifest below - if it exists, use or extend
101
143
  it (\`synthesisui component <slug> <name>\` materializes it as your code).** To review a
102
144
  component, create an isolated sample page (e.g. \`app/synthesisui-samples/<component>/\`) - do not
103
- apply it to real production pages unless asked.
145
+ apply it to real production pages unless asked.${SELF_CHECK}`;
146
+ const body = `## Design Systems (via SynthesisUI)
147
+
148
+ This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}
104
149
 
105
150
  ${sections.join("\n")}
106
151
 
107
- _Block managed by the CLI - do not edit by hand; run \`synthesisui add <slug>\` to update._`;
152
+ _Block managed by the CLI - do not edit by hand; run \`synthesisui ${onlyAdopted ? "adopt --write" : "add <slug>"}\` to update._`;
108
153
  return `${START}\n${body}\n${END}`;
109
154
  }
110
155
  /**
@@ -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.16.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": {