synthesisui 0.6.0 → 0.8.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
@@ -99,8 +99,28 @@ worse than none.
99
99
  With no system installed it still finds every hand-written value and counts
100
100
  the distinct ones. Runs offline, needs no account, writes nothing.
101
101
 
102
- `--strict` exits 1 when drift is found, for CI. `--all` lists everything
103
- 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.
104
124
 
105
125
  ## What `add` materializes
106
126
 
@@ -1,6 +1,6 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
- import { bindingsFromDocument, findOverrides, } from "../doctor/overrides.js";
3
+ import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
4
4
  import { diagnose, scanSource, } from "../doctor/scan.js";
5
5
  import { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
6
6
  import { body, section, snippet } from "../output.js";
@@ -59,6 +59,17 @@ async function* walk(dir) {
59
59
  }
60
60
  }
61
61
  }
62
+ /** The same walk over several roots, in the order the caller named them. A
63
+ * single file passed as a scope is read as itself. */
64
+ async function* walkAll(roots) {
65
+ for (const r of roots) {
66
+ if (EXTS.some((x) => r.endsWith(x))) {
67
+ yield r;
68
+ continue;
69
+ }
70
+ yield* walk(r);
71
+ }
72
+ }
62
73
  /** Find the installed system: `_synthesisui/ds/<slug>/tokens.css` + `.lock`,
63
74
  * and the recipes `add` put next to them in `design-system.json`. Those
64
75
  * recipes are why the component pass can exist at all - a linter has no idea
@@ -132,7 +143,7 @@ function meter(pct, width = 24) {
132
143
  const filled = Math.round((pct / 100) * width);
133
144
  return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
134
145
  }
135
- function verdict(d, hasSystem) {
146
+ function verdict(d, hasSystem, overruled) {
136
147
  // Nothing found and nothing installed: a utility package, a config folder,
137
148
  // the wrong directory. Selling a design system here would be noise.
138
149
  if (!hasSystem && d.findings.length === 0) {
@@ -152,12 +163,26 @@ function verdict(d, hasSystem) {
152
163
  body("Browse systems at https://www.synthesisui.com/gallery"),
153
164
  ];
154
165
  }
155
- if (d.findings.length === 0) {
166
+ if (d.findings.length === 0 && overruled === 0) {
156
167
  return [
157
168
  body("No drift. Every design value in this project comes from the"),
158
169
  body("system. That is a rarer sentence than it sounds."),
159
170
  ];
160
171
  }
172
+ // A report that accuses a component of being overruled and then closes with
173
+ // "no drift" is a report nobody believes twice.
174
+ if (d.findings.length === 0) {
175
+ return [
176
+ body("No loose values - everything is a token."),
177
+ body(overruled === 1
178
+ ? "But one place takes a component the system defines and"
179
+ : `But ${overruled} places take a component the system defines and`),
180
+ body(overruled === 1
181
+ ? "overrules it locally. Either the recipe should change, or"
182
+ : "overrule it locally. Either the recipe should change, or"),
183
+ body("that override should not be there."),
184
+ ];
185
+ }
161
186
  const lines = [
162
187
  body(`${d.named} of ${d.findings.length} already have a name in your system.`),
163
188
  body("Those are the cheap ones: swap the literal for the token."),
@@ -171,9 +196,12 @@ export async function doctor(opts) {
171
196
  const root = resolve(opts.dir ?? process.cwd());
172
197
  const { table, recipes } = await loadSystem(root);
173
198
  const hasSystem = table.byName.size > 0;
199
+ const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
174
200
  const reports = [];
175
201
  const overrides = [];
176
- for await (const file of walk(root)) {
202
+ const used = new Map();
203
+ const known = new Set(recipes.keys());
204
+ for await (const file of scopes.length > 0 ? walkAll(scopes) : walk(root)) {
177
205
  const src = await readFile(file, "utf8").catch(() => "");
178
206
  if (!src)
179
207
  continue;
@@ -182,6 +210,9 @@ export async function doctor(opts) {
182
210
  if (recipes.size > 0) {
183
211
  for (const o of findOverrides(src, recipes))
184
212
  overrides.push({ ...o, file: rel });
213
+ for (const [name, n] of countComponents(src, known)) {
214
+ used.set(name, (used.get(name) ?? 0) + n);
215
+ }
185
216
  }
186
217
  }
187
218
  if (reports.length === 0) {
@@ -193,7 +224,25 @@ export async function doctor(opts) {
193
224
  console.log(body(hasSystem
194
225
  ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
195
226
  : `No system installed - ${d.scanned} files read`));
196
- if (hasSystem) {
227
+ if (scopes.length > 0) {
228
+ console.log(body(`scope: ${opts.scopes?.join(", ")}`));
229
+ }
230
+ // What a token could never have held, said out loud. A report that quietly
231
+ // drops things is only trustworthy until someone notices, and then it is
232
+ // worth less than one that says too much.
233
+ const aside = new Map();
234
+ for (const r of reports) {
235
+ if (!r.setAside)
236
+ continue;
237
+ aside.set(r.setAside.reason, (aside.get(r.setAside.reason) ?? 0) + r.setAside.count);
238
+ }
239
+ for (const [reason, count] of aside) {
240
+ console.log(body(`set aside: ${count} value(s) in ${reason}`));
241
+ }
242
+ // 0 of 0 is not a perfect score, it is an empty measurement - printing a
243
+ // full bar there would be the report's first lie.
244
+ const measurable = d.tokenUses + d.findings.length > 0;
245
+ if (hasSystem && measurable) {
197
246
  console.log("");
198
247
  console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
199
248
  console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
@@ -271,8 +320,34 @@ export async function doctor(opts) {
271
320
  console.log(body(`+${byFile.size - shown.length} more files. Run with --all.`));
272
321
  }
273
322
  }
323
+ // The system's own words, for the components this project actually uses.
324
+ // Prose, so nothing verifies it - the value is putting it in front of
325
+ // whoever is touching the component, which no other tool is positioned to
326
+ // do because no other tool knows these laws exist.
327
+ const inUse = [...used.entries()]
328
+ .filter(([name]) => (recipes.get(name)?.usage.length ?? 0) > 0)
329
+ .sort((a, b) => b[1] - a[1]);
330
+ if (inUse.length > 0) {
331
+ console.log(section("What your system says about what you use"));
332
+ const shownComps = opts.laws || opts.all ? inUse : inUse.slice(0, 4);
333
+ for (const [name, count] of shownComps) {
334
+ const laws = recipes.get(name)?.usage ?? [];
335
+ const shown = opts.laws || opts.all ? laws : laws.slice(0, 2);
336
+ console.log(body(`ds-${name} · ${count} place${count === 1 ? "" : "s"}`));
337
+ for (const law of shown)
338
+ console.log(` ${law}`);
339
+ if (laws.length > shown.length) {
340
+ console.log(` +${laws.length - shown.length} more`);
341
+ }
342
+ console.log("");
343
+ }
344
+ if (inUse.length > shownComps.length) {
345
+ console.log(body(`+${inUse.length - shownComps.length} more components carry laws. Run with --laws.`));
346
+ console.log("");
347
+ }
348
+ }
274
349
  console.log(section("What this means"));
275
- for (const line of verdict(d, hasSystem))
350
+ for (const line of verdict(d, hasSystem, overrides.length))
276
351
  console.log(line);
277
352
  console.log("");
278
353
  if (opts.strict && (d.findings.length > 0 || overrides.length > 0))
@@ -46,7 +46,24 @@ export function bindingsOf(recipe) {
46
46
  collect(r.variants, false);
47
47
  collect(r.states, false);
48
48
  collect(r.parts, false);
49
- return { props, baseValues };
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;
50
67
  }
51
68
  /**
52
69
  * Tailwind utilities that plainly set a CSS property. Deliberately short:
@@ -100,6 +117,28 @@ function enclosingTag(src, at) {
100
117
  return null;
101
118
  }
102
119
  const lineAt = (src, index) => src.slice(0, index).split("\n").length;
120
+ /**
121
+ * The literal a style property was given, or null when it was given an
122
+ * expression instead.
123
+ *
124
+ * `style={{ width: w }}` passes a variable; `transform: shown ? "a" : "b"`
125
+ * passes a ternary. Neither is a value the author hardcoded over the recipe,
126
+ * and reporting them as "the recipe binds 100%" was noise on a real project
127
+ * (investidorez, 25/07). Only a quoted string or a bare number is an override
128
+ * anyone can act on.
129
+ */
130
+ function literalValue(raw) {
131
+ const v = raw.trim();
132
+ const quoted = /^(["'])([\s\S]*)\1$/.exec(v);
133
+ if (quoted) {
134
+ // a template that interpolates is decided at runtime, not here
135
+ return quoted[2].includes("${") ? null : quoted[2];
136
+ }
137
+ if (v.startsWith("`"))
138
+ return null;
139
+ // bare numbers are valid in a JSX style object (padding: 8)
140
+ return /^-?\d*\.?\d+(px|rem|em|%|vh|vw|ch|s|ms)?$/.test(v) ? v : null;
141
+ }
103
142
  export function findOverrides(source, recipes) {
104
143
  const out = [];
105
144
  const lines = source.split("\n");
@@ -125,10 +164,13 @@ export function findOverrides(source, recipes) {
125
164
  const prop = kebab(s[1]);
126
165
  if (!bind.props.has(prop))
127
166
  continue;
167
+ const wrote = literalValue(s[2]);
168
+ if (wrote === null)
169
+ continue;
128
170
  record({
129
171
  component: name,
130
172
  prop,
131
- wrote: s[2].trim().replace(/^["']|["']$/g, ""),
173
+ wrote,
132
174
  recipe: bind.baseValues.get(prop) ?? null,
133
175
  line: lineAt(source, tag.start + (s.index ?? 0)),
134
176
  excerpt: clip(lines[lineAt(source, tag.start + (s.index ?? 0)) - 1]?.trim() ?? ""),
@@ -10,6 +10,17 @@
10
10
  * takes eight seconds and needs a build step is a diagnosis nobody runs.
11
11
  */
12
12
  import { tokenFor } from "./tokens.js";
13
+ /**
14
+ * `next/og` renders JSX to a PNG on the server. There is no document, so there
15
+ * is no `var(--ds-*)` to read: every colour in such a file MUST be a literal.
16
+ * Telling the author to tokenize it is telling them to break the build
17
+ * (investidorez, 25/07 - two files, nineteen impossible findings).
18
+ */
19
+ const RENDERS_IMAGE = /from\s+["']next\/og["']|\bnew\s+ImageResponse\b/;
20
+ /** A colour sitting on an SVG paint attribute is artwork - a brand mark, an
21
+ * illustration. Google's logo contributed four "fix me" lines to a real
22
+ * report; no design system should own those four hexes. */
23
+ const SVG_PAINT = /(?:fill|stroke)\s*[=:]\s*["']?$/;
13
24
  const clip = (s) => (s.length > 84 ? `${s.slice(0, 81)}...` : s);
14
25
  /** Lines we must not read as authorship: imports, and our own installed CSS. */
15
26
  const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
@@ -34,10 +45,32 @@ const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
34
45
  export function scanSource(file, source, table) {
35
46
  const findings = [];
36
47
  let tokenUses = 0;
48
+ let aside = 0;
49
+ if (RENDERS_IMAGE.test(source)) {
50
+ return {
51
+ file,
52
+ findings: [],
53
+ tokenUses: 0,
54
+ setAside: {
55
+ reason: "renders to an image, where CSS variables do not exist",
56
+ count: (source.match(COLOR) ?? []).length,
57
+ },
58
+ };
59
+ }
60
+ let svgDepth = 0;
37
61
  source.split("\n").forEach((raw, i) => {
38
62
  const line = raw.trim();
39
63
  const at = i + 1;
40
64
  tokenUses += (line.match(TOKEN_USE) ?? []).length;
65
+ // Depth at the START of this line, carried before the early return so a
66
+ // blank line inside an <svg> cannot close the region by accident. The
67
+ // per-match depth is recomputed below, because an icon is often written on
68
+ // one line - `<svg><path fill="#4285F4"/></svg>` opens and closes around
69
+ // the value, and a line-level counter reads zero right where it matters.
70
+ const lineStart = svgDepth;
71
+ svgDepth = Math.max(0, svgDepth +
72
+ (line.match(/<svg\b/g) ?? []).length -
73
+ (line.match(/<\/svg>/g) ?? []).length);
41
74
  if (!line || IGNORE_LINE.test(line))
42
75
  return;
43
76
  // `rgba(${r}, ${g}, ${b}, ${a})` is code computing a colour, not a colour
@@ -60,8 +93,18 @@ export function scanSource(file, source, table) {
60
93
  excerpt: clip(line),
61
94
  });
62
95
  };
63
- for (const m of line.matchAll(COLOR))
96
+ for (const m of line.matchAll(COLOR)) {
97
+ const before = line.slice(0, m.index ?? 0);
98
+ const depthHere = lineStart +
99
+ (before.match(/<svg\b/g) ?? []).length -
100
+ (before.match(/<\/svg>/g) ?? []).length;
101
+ // inside an <svg>, a colour on fill= or stroke= is paint, not surface
102
+ if (depthHere > 0 && SVG_PAINT.test(before)) {
103
+ aside++;
104
+ continue;
105
+ }
64
106
  push("color", m[0]);
107
+ }
65
108
  for (const m of line.matchAll(RADIUS)) {
66
109
  const value = `${m[1]}${m[2]}`;
67
110
  if (!IDIOM.has(value))
@@ -80,7 +123,14 @@ export function scanSource(file, source, table) {
80
123
  push("font", stack);
81
124
  }
82
125
  });
83
- return { file, findings, tokenUses };
126
+ return {
127
+ file,
128
+ findings,
129
+ tokenUses,
130
+ ...(aside > 0
131
+ ? { setAside: { reason: "SVG artwork", count: aside } }
132
+ : null),
133
+ };
84
134
  }
85
135
  export function diagnose(files) {
86
136
  const flat = files.flatMap((f) => f.findings.map((x) => ({ ...x, file: f.file })));
@@ -46,7 +46,30 @@ function deepEqual(a, b) {
46
46
  }
47
47
  return false;
48
48
  }
49
- /** Variant options that existed and disappeared (breaking for the consumer). */
49
+ /**
50
+ * Did this variant option ever put a declaration on the page? An option like
51
+ * `{ "tone": { "default": {} } }` compiles to nothing, so nothing in the
52
+ * consumer's app can depend on it.
53
+ */
54
+ function declaresAnything(node) {
55
+ if (node === null || node === undefined)
56
+ return false;
57
+ if (typeof node !== "object")
58
+ return String(node).trim().length > 0;
59
+ if (Array.isArray(node))
60
+ return node.some(declaresAnything);
61
+ return Object.values(node).some(declaresAnything);
62
+ }
63
+ /**
64
+ * Variant options that existed, disappeared, AND used to render something.
65
+ *
66
+ * Vesper v2 → v15 announced ten breaking changes ("input no longer supports
67
+ * tone", and nine more). Every one of them was `{ tone: { default: {} } }` - an
68
+ * axis with a single empty option that emitted zero CSS. Removing it could not
69
+ * break an app, but the brief sent the consumer's agent hunting for `tone=`
70
+ * props that never existed (investidorez, 25/07). Housekeeping and a breaking
71
+ * change now read differently, because they are different.
72
+ */
50
73
  function removedVariantOptions(before, after) {
51
74
  const out = [];
52
75
  const prevVariants = (before.variants ?? {});
@@ -54,11 +77,14 @@ function removedVariantOptions(before, after) {
54
77
  for (const [axis, options] of Object.entries(prevVariants)) {
55
78
  const nextAxis = nextVariants[axis];
56
79
  if (!nextAxis) {
57
- out.push(axis);
80
+ if (declaresAnything(options))
81
+ out.push(axis);
58
82
  continue;
59
83
  }
60
- for (const option of Object.keys(options)) {
61
- if (!(option in nextAxis))
84
+ for (const [option, decls] of Object.entries(options)) {
85
+ if (option in nextAxis)
86
+ continue;
87
+ if (declaresAnything(decls))
62
88
  out.push(`${axis}="${option}"`);
63
89
  }
64
90
  }
package/dist/index.js CHANGED
@@ -26,8 +26,9 @@ Usage - deterministic, FREE:
26
26
  synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
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
- 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
29
+ synthesisui doctor [paths…] [--all] audit for DRIFT: every design value written by
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
 
@@ -61,6 +63,7 @@ Examples:
61
63
  synthesisui init --target next
62
64
  synthesisui init --target next --ds halogen bootstrap + bring a system in
63
65
  synthesisui doctor
66
+ synthesisui doctor apps/web packages/ui # scope the read in a monorepo
64
67
  synthesisui doctor --strict
65
68
  synthesisui list
66
69
  synthesisui add halogen
@@ -118,10 +121,14 @@ async function main() {
118
121
  const dir = typeof flags.dir === "string" ? flags.dir : undefined;
119
122
  switch (command) {
120
123
  case "doctor":
124
+ // positional paths scope the READING (the system is still found from the
125
+ // root): `doctor apps/web packages/ui` in a monorepo
121
126
  await doctor({
122
127
  dir,
128
+ scopes: args,
123
129
  strict: flags.strict === true,
124
130
  all: flags.all === true,
131
+ laws: flags.laws === true,
125
132
  });
126
133
  break;
127
134
  case "list":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.6.0",
3
+ "version": "0.8.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": {