synthesisui 0.5.0 → 0.7.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/README.md CHANGED
@@ -68,6 +68,29 @@ npx synthesisui@latest doctor
68
68
  21 #f1f3fa → --ds-color-gray-100
69
69
  ```
70
70
 
71
+ ### Overruled
72
+
73
+ With a system installed it runs a second pass that nothing else can:
74
+
75
+ ```
76
+ ── Overruled ─────────────────────────────────────────────────────
77
+
78
+ 3 places where the code takes a component
79
+ the system defines, and then overrules it locally.
80
+
81
+ components/Hero.tsx
82
+ 12 ds-button · border-radius: 4
83
+ the recipe binds {radius.md}
84
+ 18 ds-button · padding: px-8
85
+ the recipe binds {spacing.2xs} {spacing.md}
86
+ ```
87
+
88
+ It only flags a property the recipe **actually binds**. `w-full` beside a
89
+ button is layout; `px-8` is drift, because the recipe already decided the
90
+ padding. A rule reading `var(--ds-…)` back is the opposite of overruling and
91
+ is left alone. This needs the recipes, which are only in the project because
92
+ `add` put them there - a linter has no idea what `ds-button` promised.
93
+
71
94
  The last column is the point: not "you hardcoded a colour", but **the name
72
95
  your own system already has for it**. Exact matches only - a tool that guesses
73
96
  a near colour invites a silent visual change, and a diagnosis nobody trusts is
@@ -76,8 +99,28 @@ worse than none.
76
99
  With no system installed it still finds every hand-written value and counts
77
100
  the distinct ones. Runs offline, needs no account, writes nothing.
78
101
 
79
- `--strict` exits 1 when drift is found, for CI. `--all` lists everything
80
- instead of the loudest files.
102
+ ### What your system says
103
+
104
+ ```
105
+ ── What your system says about what you use ──────────────────────
106
+
107
+ ds-badge · 3 places
108
+ Badges whisper status in periwinkle; danger appears only for money at risk.
109
+
110
+ ds-button · 3 places
111
+ One indigo action per view; gold is reserved for moments of ceremony, never buttons.
112
+ Buttons speak quietly - sentence case, no exclamation.
113
+ ```
114
+
115
+ The usage laws your team wrote, for the components this project actually uses,
116
+ ordered by how much you use them. They are prose, so nothing verifies them -
117
+ the value is putting them in front of whoever is touching the component. No
118
+ other tool is positioned to do it, because no other tool knows these laws
119
+ exist.
120
+
121
+ `--strict` exits 1 when drift is found, for CI. `--all` lists every finding
122
+ instead of the loudest files. `--laws` shows every law instead of the busiest
123
+ components.
81
124
 
82
125
  ## What `add` materializes
83
126
 
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
3
4
  import { diagnose, scanSource, } from "../doctor/scan.js";
4
5
  import { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
5
6
  import { body, section, snippet } from "../output.js";
@@ -58,7 +59,10 @@ async function* walk(dir) {
58
59
  }
59
60
  }
60
61
  }
61
- /** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock`. */
62
+ /** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock`,
63
+ * and the recipes `add` put next to them in `design-system.json`. Those
64
+ * recipes are why the component pass can exist at all - a linter has no idea
65
+ * what `ds-button` promised. */
62
66
  async function loadSystem(root) {
63
67
  const dsDir = join(root, "_synthesisui", "ds");
64
68
  let slugs;
@@ -68,12 +72,13 @@ async function loadSystem(root) {
68
72
  .map((e) => e.name);
69
73
  }
70
74
  catch {
71
- return EMPTY_TABLE;
75
+ return { table: EMPTY_TABLE, recipes: new Map() };
72
76
  }
73
77
  // Several systems can live side by side; every token is prefixed --ds-, so
74
78
  // reading them all is both correct and what the running app actually sees.
75
79
  let css = "";
76
80
  let lock = null;
81
+ const recipes = new Map();
77
82
  for (const slug of slugs) {
78
83
  const dir = join(dsDir, slug);
79
84
  const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
@@ -99,8 +104,22 @@ async function loadSystem(root) {
99
104
  }
100
105
  }
101
106
  css += `\n${real || root}`;
107
+ const docRaw = mine?.version
108
+ ? await readFile(join(dir, `v${mine.version}`, "design-system.json"), "utf8").catch(() => "")
109
+ : "";
110
+ if (docRaw) {
111
+ try {
112
+ for (const [k, v] of bindingsFromDocument(JSON.parse(docRaw))) {
113
+ if (!recipes.has(k))
114
+ recipes.set(k, v);
115
+ }
116
+ }
117
+ catch {
118
+ // A document we cannot parse costs the component pass, not the run.
119
+ }
120
+ }
102
121
  }
103
- return buildTable({ css, lock });
122
+ return { table: buildTable({ css, lock }), recipes };
104
123
  }
105
124
  const KIND_LABEL = {
106
125
  color: "colour",
@@ -113,7 +132,7 @@ function meter(pct, width = 24) {
113
132
  const filled = Math.round((pct / 100) * width);
114
133
  return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
115
134
  }
116
- function verdict(d, hasSystem) {
135
+ function verdict(d, hasSystem, overruled) {
117
136
  // Nothing found and nothing installed: a utility package, a config folder,
118
137
  // the wrong directory. Selling a design system here would be noise.
119
138
  if (!hasSystem && d.findings.length === 0) {
@@ -133,12 +152,26 @@ function verdict(d, hasSystem) {
133
152
  body("Browse systems at https://www.synthesisui.com/gallery"),
134
153
  ];
135
154
  }
136
- if (d.findings.length === 0) {
155
+ if (d.findings.length === 0 && overruled === 0) {
137
156
  return [
138
157
  body("No drift. Every design value in this project comes from the"),
139
158
  body("system. That is a rarer sentence than it sounds."),
140
159
  ];
141
160
  }
161
+ // A report that accuses a component of being overruled and then closes with
162
+ // "no drift" is a report nobody believes twice.
163
+ if (d.findings.length === 0) {
164
+ return [
165
+ body("No loose values - everything is a token."),
166
+ body(overruled === 1
167
+ ? "But one place takes a component the system defines and"
168
+ : `But ${overruled} places take a component the system defines and`),
169
+ body(overruled === 1
170
+ ? "overrules it locally. Either the recipe should change, or"
171
+ : "overrule it locally. Either the recipe should change, or"),
172
+ body("that override should not be there."),
173
+ ];
174
+ }
142
175
  const lines = [
143
176
  body(`${d.named} of ${d.findings.length} already have a name in your system.`),
144
177
  body("Those are the cheap ones: swap the literal for the token."),
@@ -150,13 +183,25 @@ function verdict(d, hasSystem) {
150
183
  }
151
184
  export async function doctor(opts) {
152
185
  const root = resolve(opts.dir ?? process.cwd());
153
- const table = await loadSystem(root);
186
+ const { table, recipes } = await loadSystem(root);
154
187
  const hasSystem = table.byName.size > 0;
155
188
  const reports = [];
189
+ const overrides = [];
190
+ const used = new Map();
191
+ const known = new Set(recipes.keys());
156
192
  for await (const file of walk(root)) {
157
193
  const src = await readFile(file, "utf8").catch(() => "");
158
- if (src)
159
- reports.push(scanSource(relative(root, file), src, table));
194
+ if (!src)
195
+ continue;
196
+ const rel = relative(root, file);
197
+ reports.push(scanSource(rel, src, table));
198
+ if (recipes.size > 0) {
199
+ for (const o of findOverrides(src, recipes))
200
+ overrides.push({ ...o, file: rel });
201
+ for (const [name, n] of countComponents(src, known)) {
202
+ used.set(name, (used.get(name) ?? 0) + n);
203
+ }
204
+ }
160
205
  }
161
206
  if (reports.length === 0) {
162
207
  console.log(`\nNothing to read in ${root}.\n`);
@@ -167,7 +212,10 @@ export async function doctor(opts) {
167
212
  console.log(body(hasSystem
168
213
  ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
169
214
  : `No system installed - ${d.scanned} files read`));
170
- if (hasSystem) {
215
+ // 0 of 0 is not a perfect score, it is an empty measurement - printing a
216
+ // full bar there would be the report's first lie.
217
+ const measurable = d.tokenUses + d.findings.length > 0;
218
+ if (hasSystem && measurable) {
171
219
  console.log("");
172
220
  console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
173
221
  console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
@@ -215,10 +263,66 @@ export async function doctor(opts) {
215
263
  console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
216
264
  }
217
265
  }
266
+ if (overrides.length > 0) {
267
+ console.log(section("Overruled"));
268
+ console.log(body(`${overrides.length} place${overrides.length === 1 ? "" : "s"} where the code takes a component`));
269
+ console.log(body("the system defines, and then overrules it locally."));
270
+ console.log("");
271
+ const byFile = new Map();
272
+ for (const o of overrides) {
273
+ const list = byFile.get(o.file);
274
+ if (list)
275
+ list.push(o);
276
+ else
277
+ byFile.set(o.file, [o]);
278
+ }
279
+ const shown = opts.all ? [...byFile] : [...byFile].slice(0, 6);
280
+ for (const [file, list] of shown) {
281
+ console.log(body(file));
282
+ for (const o of opts.all ? list : list.slice(0, 3)) {
283
+ console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
284
+ if (o.recipe)
285
+ console.log(` the recipe binds ${o.recipe}`);
286
+ }
287
+ if (!opts.all && list.length > 3) {
288
+ console.log(` +${list.length - 3} more`);
289
+ }
290
+ console.log("");
291
+ }
292
+ if (byFile.size > shown.length) {
293
+ console.log(body(`+${byFile.size - shown.length} more files. Run with --all.`));
294
+ }
295
+ }
296
+ // The system's own words, for the components this project actually uses.
297
+ // Prose, so nothing verifies it - the value is putting it in front of
298
+ // whoever is touching the component, which no other tool is positioned to
299
+ // do because no other tool knows these laws exist.
300
+ const inUse = [...used.entries()]
301
+ .filter(([name]) => (recipes.get(name)?.usage.length ?? 0) > 0)
302
+ .sort((a, b) => b[1] - a[1]);
303
+ if (inUse.length > 0) {
304
+ console.log(section("What your system says about what you use"));
305
+ const shownComps = opts.laws || opts.all ? inUse : inUse.slice(0, 4);
306
+ for (const [name, count] of shownComps) {
307
+ const laws = recipes.get(name)?.usage ?? [];
308
+ const shown = opts.laws || opts.all ? laws : laws.slice(0, 2);
309
+ console.log(body(`ds-${name} · ${count} place${count === 1 ? "" : "s"}`));
310
+ for (const law of shown)
311
+ console.log(` ${law}`);
312
+ if (laws.length > shown.length) {
313
+ console.log(` +${laws.length - shown.length} more`);
314
+ }
315
+ console.log("");
316
+ }
317
+ if (inUse.length > shownComps.length) {
318
+ console.log(body(`+${inUse.length - shownComps.length} more components carry laws. Run with --laws.`));
319
+ console.log("");
320
+ }
321
+ }
218
322
  console.log(section("What this means"));
219
- for (const line of verdict(d, hasSystem))
323
+ for (const line of verdict(d, hasSystem, overrides.length))
220
324
  console.log(line);
221
325
  console.log("");
222
- if (opts.strict && d.findings.length > 0)
326
+ if (opts.strict && (d.findings.length > 0 || overrides.length > 0))
223
327
  process.exitCode = 1;
224
328
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * DOCTOR · drift ON a component the project is already using.
3
+ *
4
+ * The generic pass finds a hardcoded value anywhere. This finds the sharper
5
+ * thing: a place where the code takes a component the system defines and then
6
+ * overrules the system locally.
7
+ *
8
+ * <button className="ds-button" style={{ borderRadius: 4 }}>
9
+ * → the recipe already binds border-radius to {radius.md}
10
+ *
11
+ * This is the check nothing else can run, and the reason is structural: it
12
+ * needs the recipes, and the recipes are only in the project because `add`
13
+ * put them there (`_synthesisui/ds/<slug>/v<n>/design-system.json`). A linter
14
+ * has no idea what `ds-button` promised.
15
+ *
16
+ * It only ever flags a property the recipe ACTUALLY binds. `w-full` next to a
17
+ * button is layout, not drift; `p-4` is drift, because the recipe already
18
+ * decided the padding.
19
+ */
20
+ const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
21
+ /**
22
+ * Every property a recipe touches - base, variants, states and parts. A
23
+ * variant binding counts: if `intent=primary` sets the background, then
24
+ * writing a background by hand overrules the system just as surely.
25
+ */
26
+ export function bindingsOf(recipe) {
27
+ const props = new Set();
28
+ const baseValues = new Map();
29
+ const collect = (block, isBase) => {
30
+ if (!block || typeof block !== "object")
31
+ return;
32
+ for (const [k, v] of Object.entries(block)) {
33
+ if (typeof v === "string" || typeof v === "number") {
34
+ const p = kebab(k);
35
+ props.add(p);
36
+ if (isBase)
37
+ baseValues.set(p, String(v));
38
+ }
39
+ else if (v && typeof v === "object") {
40
+ collect(v, false);
41
+ }
42
+ }
43
+ };
44
+ const r = (recipe ?? {});
45
+ collect(r.base, true);
46
+ collect(r.variants, false);
47
+ collect(r.states, false);
48
+ collect(r.parts, false);
49
+ const usage = Array.isArray(r.usage)
50
+ ? r.usage.filter((x) => typeof x === "string")
51
+ : [];
52
+ return { props, baseValues, usage };
53
+ }
54
+ /**
55
+ * How many places each known component is used. An inventory nobody has today:
56
+ * a design system's own report can say "ds-button, 14 places" because it knows
57
+ * which names are components rather than guessing at class strings.
58
+ */
59
+ export function countComponents(source, known) {
60
+ const out = new Map();
61
+ for (const m of source.matchAll(DS_IN_CLASS)) {
62
+ if (!known.has(m[1]))
63
+ continue;
64
+ out.set(m[1], (out.get(m[1]) ?? 0) + 1);
65
+ }
66
+ return out;
67
+ }
68
+ /**
69
+ * Tailwind utilities that plainly set a CSS property. Deliberately short:
70
+ * every entry here can produce an accusation, so anything ambiguous is left
71
+ * out. Being quiet and right beats being loud.
72
+ */
73
+ const UTILITY = [
74
+ [
75
+ /^rounded(-(?:sm|md|lg|xl|2xl|3xl|full|none))?$|^rounded-\[/,
76
+ "border-radius",
77
+ ],
78
+ [/^p-|^px-|^py-|^pt-|^pr-|^pb-|^pl-/, "padding"],
79
+ [/^gap-/, "gap"],
80
+ [/^bg-\[|^bg-(?!clip|blend|origin|repeat|fixed|local|scroll)/, "background"],
81
+ [/^shadow(-|$)/, "box-shadow"],
82
+ [
83
+ /^font-(?:thin|light|normal|medium|semibold|bold|extrabold|black)$/,
84
+ "font-weight",
85
+ ],
86
+ [/^(?:text|font)-\[/, "font-size"],
87
+ [/^tracking-/, "letter-spacing"],
88
+ [/^border-\[|^border-\d/, "border-width"],
89
+ ];
90
+ function utilityProp(cls) {
91
+ for (const [re, prop] of UTILITY)
92
+ if (re.test(cls))
93
+ return prop;
94
+ return null;
95
+ }
96
+ const DS_IN_CLASS = /\bds-([a-z][a-z0-9-]*)\b/g;
97
+ const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
98
+ /**
99
+ * The tag around an offset. JSX puts `className` and `style` on separate
100
+ * lines constantly, so a line-scoped check would miss the common case; a
101
+ * bounded scan to the enclosing `<…>` is both simple and accurate.
102
+ */
103
+ function enclosingTag(src, at) {
104
+ const open = src.lastIndexOf("<", at);
105
+ if (open === -1)
106
+ return null;
107
+ let depth = 0;
108
+ for (let i = open; i < src.length && i < open + 4000; i++) {
109
+ const c = src[i];
110
+ if (c === "{")
111
+ depth++;
112
+ else if (c === "}")
113
+ depth--;
114
+ else if (c === ">" && depth <= 0)
115
+ return { text: src.slice(open, i), start: open };
116
+ }
117
+ return null;
118
+ }
119
+ const lineAt = (src, index) => src.slice(0, index).split("\n").length;
120
+ export function findOverrides(source, recipes) {
121
+ const out = [];
122
+ const lines = source.split("\n");
123
+ const seen = new Set();
124
+ const record = (o) => {
125
+ const key = `${o.component}:${o.prop}:${o.line}`;
126
+ if (seen.has(key))
127
+ return;
128
+ seen.add(key);
129
+ out.push(o);
130
+ };
131
+ // ── JSX: a ds-* class and a local style on the same element ───────────────
132
+ for (const m of source.matchAll(DS_IN_CLASS)) {
133
+ const name = m[1];
134
+ const bind = recipes.get(name);
135
+ if (!bind)
136
+ continue;
137
+ const tag = enclosingTag(source, m.index ?? 0);
138
+ if (!tag)
139
+ continue;
140
+ // inline style={{ borderRadius: 4 }}
141
+ for (const s of tag.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
142
+ const prop = kebab(s[1]);
143
+ if (!bind.props.has(prop))
144
+ continue;
145
+ record({
146
+ component: name,
147
+ prop,
148
+ wrote: s[2].trim().replace(/^["']|["']$/g, ""),
149
+ recipe: bind.baseValues.get(prop) ?? null,
150
+ line: lineAt(source, tag.start + (s.index ?? 0)),
151
+ excerpt: clip(lines[lineAt(source, tag.start + (s.index ?? 0)) - 1]?.trim() ?? ""),
152
+ });
153
+ }
154
+ // utility classes sitting beside the ds-* class
155
+ for (const cls of tag.text.matchAll(/[\w:[\]#().%/-]+/g)) {
156
+ const prop = utilityProp(cls[0]);
157
+ if (!prop || !bind.props.has(prop))
158
+ continue;
159
+ record({
160
+ component: name,
161
+ prop,
162
+ wrote: cls[0],
163
+ recipe: bind.baseValues.get(prop) ?? null,
164
+ line: lineAt(source, tag.start + (cls.index ?? 0)),
165
+ excerpt: clip(lines[lineAt(source, tag.start + (cls.index ?? 0)) - 1]?.trim() ?? ""),
166
+ });
167
+ }
168
+ }
169
+ // ── CSS: a rule whose selector reaches a ds-* component ───────────────────
170
+ for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
171
+ const selector = rule[1];
172
+ const hit = /\.ds-([a-z][a-z0-9-]*)\b/.exec(selector);
173
+ const bind = hit ? recipes.get(hit[1]) : undefined;
174
+ if (!hit || !bind)
175
+ continue;
176
+ for (const decl of rule[2].matchAll(/([a-z-]+)\s*:\s*([^;]+)/g)) {
177
+ const prop = decl[1].trim();
178
+ if (!bind.props.has(prop))
179
+ continue;
180
+ // Reading the system back is the opposite of overruling it.
181
+ if (decl[2].includes("var(--ds-"))
182
+ continue;
183
+ const at = (rule.index ?? 0) + rule[1].length + (decl.index ?? 0);
184
+ record({
185
+ component: hit[1],
186
+ prop,
187
+ wrote: decl[2].trim(),
188
+ recipe: bind.baseValues.get(prop) ?? null,
189
+ line: lineAt(source, at),
190
+ excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
191
+ });
192
+ }
193
+ }
194
+ return out.sort((a, b) => a.line - b.line);
195
+ }
196
+ /** Recipes from an installed `design-system.json`, as bindings. */
197
+ export function bindingsFromDocument(doc) {
198
+ const out = new Map();
199
+ const comps = doc?.components;
200
+ if (!comps)
201
+ return out;
202
+ for (const [name, recipe] of Object.entries(comps)) {
203
+ out.set(name, bindingsOf(recipe));
204
+ }
205
+ return out;
206
+ }
package/dist/index.js CHANGED
@@ -27,7 +27,8 @@ Usage - deterministic, FREE:
27
27
  synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
28
28
  synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
29
29
  synthesisui doctor [--strict] [--all] audit this repo for DRIFT: every design value written by
30
- hand, and the token your system already has for it
30
+ hand, the token your system already has for it, and the
31
+ laws your system carries for what you use
31
32
 
32
33
  Usage - AI, USES CREDITS (login required):
33
34
  synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
@@ -53,6 +54,7 @@ Options:
53
54
  --force clean: apply the changes (without it, dry run)
54
55
  --strict doctor: exit 1 when drift is found (for CI)
55
56
  --all doctor: list every finding, not just the loudest files
57
+ --laws doctor: show every usage law, not just the busiest components
56
58
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
57
59
  -h, --help this help
58
60
 
@@ -122,6 +124,7 @@ async function main() {
122
124
  dir,
123
125
  strict: flags.strict === true,
124
126
  all: flags.all === true,
127
+ laws: flags.laws === true,
125
128
  });
126
129
  break;
127
130
  case "list":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {