synthesisui 0.7.0 → 0.8.1

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.
@@ -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
@@ -185,11 +196,12 @@ export async function doctor(opts) {
185
196
  const root = resolve(opts.dir ?? process.cwd());
186
197
  const { table, recipes } = await loadSystem(root);
187
198
  const hasSystem = table.byName.size > 0;
199
+ const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
188
200
  const reports = [];
189
201
  const overrides = [];
190
202
  const used = new Map();
191
203
  const known = new Set(recipes.keys());
192
- for await (const file of walk(root)) {
204
+ for await (const file of scopes.length > 0 ? walkAll(scopes) : walk(root)) {
193
205
  const src = await readFile(file, "utf8").catch(() => "");
194
206
  if (!src)
195
207
  continue;
@@ -212,6 +224,21 @@ export async function doctor(opts) {
212
224
  console.log(body(hasSystem
213
225
  ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
214
226
  : `No system installed - ${d.scanned} files read`));
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
+ }
215
242
  // 0 of 0 is not a perfect score, it is an empty measurement - printing a
216
243
  // full bar there would be the report's first lie.
217
244
  const measurable = d.tokenUses + d.findings.length > 0;
@@ -281,8 +308,13 @@ export async function doctor(opts) {
281
308
  console.log(body(file));
282
309
  for (const o of opts.all ? list : list.slice(0, 3)) {
283
310
  console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
311
+ // Always say what is being overruled. A finding that cannot name it is
312
+ // indistinguishable from a bug in the reader's eyes, and one of these
313
+ // (`.ds-card:hover { transform: none }`) was a real, deliberate call.
284
314
  if (o.recipe)
285
315
  console.log(` the recipe binds ${o.recipe}`);
316
+ else if (o.where)
317
+ console.log(` the recipe binds it ${o.where}`);
286
318
  }
287
319
  if (!opts.all && list.length > 3) {
288
320
  console.log(` +${list.length - 3} more`);
@@ -46,10 +46,37 @@ export function bindingsOf(recipe) {
46
46
  collect(r.variants, false);
47
47
  collect(r.states, false);
48
48
  collect(r.parts, false);
49
+ // Where a non-base property lives, so the report can name it. Base wins: if
50
+ // base binds it too, that value is the one worth showing.
51
+ const boundIn = new Map();
52
+ const note = (block, label) => {
53
+ if (!block || typeof block !== "object")
54
+ return;
55
+ for (const [k, v] of Object.entries(block)) {
56
+ if (typeof v === "string" || typeof v === "number") {
57
+ const p = kebab(k);
58
+ if (!baseValues.has(p) && !boundIn.has(p))
59
+ boundIn.set(p, `${label}: ${v}`);
60
+ }
61
+ else if (v && typeof v === "object") {
62
+ note(v, label);
63
+ }
64
+ }
65
+ };
66
+ for (const [state, block] of Object.entries((r.states ?? {}))) {
67
+ note(block, `on ${state}`);
68
+ }
69
+ for (const [axis, opts] of Object.entries((r.variants ?? {}))) {
70
+ if (!opts || typeof opts !== "object")
71
+ continue;
72
+ for (const [opt, block] of Object.entries(opts)) {
73
+ note(block, `on ${axis}="${opt}"`);
74
+ }
75
+ }
49
76
  const usage = Array.isArray(r.usage)
50
77
  ? r.usage.filter((x) => typeof x === "string")
51
78
  : [];
52
- return { props, baseValues, usage };
79
+ return { props, baseValues, boundIn, usage };
53
80
  }
54
81
  /**
55
82
  * How many places each known component is used. An inventory nobody has today:
@@ -117,6 +144,77 @@ function enclosingTag(src, at) {
117
144
  return null;
118
145
  }
119
146
  const lineAt = (src, index) => src.slice(0, index).split("\n").length;
147
+ /**
148
+ * Whatever the `style` prop was given, with where it starts. The expression
149
+ * container, not `{{` specifically: a real component writes
150
+ * `style={showImg ? undefined : { color: "#fff" }}`, and requiring the double
151
+ * brace dropped that finding on the floor. Brace matching rather than a regex,
152
+ * because the value nests.
153
+ */
154
+ function styleRegions(tag) {
155
+ const out = [];
156
+ const open = /style\s*=\s*\{/g;
157
+ let m = open.exec(tag);
158
+ while (m !== null) {
159
+ const start = m.index + m[0].length;
160
+ let depth = 1; // the container brace just consumed
161
+ let end = start;
162
+ for (; end < tag.length; end++) {
163
+ if (tag[end] === "{")
164
+ depth++;
165
+ else if (tag[end] === "}" && --depth === 0)
166
+ break;
167
+ }
168
+ // Only the OBJECT LITERALS inside the container. Scanning the container
169
+ // whole let a one-line ternary eat the declaration: in
170
+ // `style={on ? undefined : { padding: "7px" }}` the colon after `undefined`
171
+ // reads as a property separator and swallows the padding behind it.
172
+ const container = tag.slice(start, end);
173
+ let d = 0;
174
+ let objStart = -1;
175
+ for (let i = 0; i < container.length; i++) {
176
+ if (container[i] === "{") {
177
+ if (d === 0)
178
+ objStart = i + 1;
179
+ d++;
180
+ }
181
+ else if (container[i] === "}" && d > 0 && --d === 0) {
182
+ out.push({
183
+ text: container.slice(objStart, i),
184
+ offset: start + objStart,
185
+ });
186
+ }
187
+ }
188
+ // `style={{ … }}` - the container IS the object
189
+ if (objStart === -1)
190
+ out.push({ text: container, offset: start });
191
+ open.lastIndex = end;
192
+ m = open.exec(tag);
193
+ }
194
+ return out;
195
+ }
196
+ /**
197
+ * The literal a style property was given, or null when it was given an
198
+ * expression instead.
199
+ *
200
+ * `style={{ width: w }}` passes a variable; `transform: shown ? "a" : "b"`
201
+ * passes a ternary. Neither is a value the author hardcoded over the recipe,
202
+ * and reporting them as "the recipe binds 100%" was noise on a real project
203
+ * (investidorez, 25/07). Only a quoted string or a bare number is an override
204
+ * anyone can act on.
205
+ */
206
+ function literalValue(raw) {
207
+ const v = raw.trim();
208
+ const quoted = /^(["'])([\s\S]*)\1$/.exec(v);
209
+ if (quoted) {
210
+ // a template that interpolates is decided at runtime, not here
211
+ return quoted[2].includes("${") ? null : quoted[2];
212
+ }
213
+ if (v.startsWith("`"))
214
+ return null;
215
+ // bare numbers are valid in a JSX style object (padding: 8)
216
+ return /^-?\d*\.?\d+(px|rem|em|%|vh|vw|ch|s|ms)?$/.test(v) ? v : null;
217
+ }
120
218
  export function findOverrides(source, recipes) {
121
219
  const out = [];
122
220
  const lines = source.split("\n");
@@ -137,19 +235,29 @@ export function findOverrides(source, recipes) {
137
235
  const tag = enclosingTag(source, m.index ?? 0);
138
236
  if (!tag)
139
237
  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
- });
238
+ // inline style={{ borderRadius: 4 }} - and ONLY that. Reading the whole tag
239
+ // meant reading every object prop on it, so Framer Motion's
240
+ // `animate={{ opacity: 1 }}` came back as "ds-card overrules opacity"
241
+ // (investidorez, 25/07). An animation is not a design decision.
242
+ for (const region of styleRegions(tag.text)) {
243
+ for (const s of region.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
244
+ const prop = kebab(s[1]);
245
+ if (!bind.props.has(prop))
246
+ continue;
247
+ const wrote = literalValue(s[2]);
248
+ if (wrote === null)
249
+ continue;
250
+ const at = tag.start + region.offset + (s.index ?? 0);
251
+ record({
252
+ component: name,
253
+ prop,
254
+ wrote,
255
+ recipe: bind.baseValues.get(prop) ?? null,
256
+ where: bind.boundIn.get(prop) ?? null,
257
+ line: lineAt(source, at),
258
+ excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
259
+ });
260
+ }
153
261
  }
154
262
  // utility classes sitting beside the ds-* class
155
263
  for (const cls of tag.text.matchAll(/[\w:[\]#().%/-]+/g)) {
@@ -161,6 +269,7 @@ export function findOverrides(source, recipes) {
161
269
  prop,
162
270
  wrote: cls[0],
163
271
  recipe: bind.baseValues.get(prop) ?? null,
272
+ where: bind.boundIn.get(prop) ?? null,
164
273
  line: lineAt(source, tag.start + (cls.index ?? 0)),
165
274
  excerpt: clip(lines[lineAt(source, tag.start + (cls.index ?? 0)) - 1]?.trim() ?? ""),
166
275
  });
@@ -168,7 +277,10 @@ export function findOverrides(source, recipes) {
168
277
  }
169
278
  // ── CSS: a rule whose selector reaches a ds-* component ───────────────────
170
279
  for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
171
- const selector = rule[1];
280
+ // A CSS comment is not a selector. `/* botões (.ds-button) … */` above an
281
+ // unrelated rule made the doctor attribute that rule to ds-button - it was
282
+ // reading prose as structure (investidorez, 25/07).
283
+ const selector = rule[1].replace(/\/\*[\s\S]*?\*\//g, "");
172
284
  const hit = /\.ds-([a-z][a-z0-9-]*)\b/.exec(selector);
173
285
  const bind = hit ? recipes.get(hit[1]) : undefined;
174
286
  if (!hit || !bind)
@@ -186,6 +298,7 @@ export function findOverrides(source, recipes) {
186
298
  prop,
187
299
  wrote: decl[2].trim(),
188
300
  recipe: bind.baseValues.get(prop) ?? null,
301
+ where: bind.boundIn.get(prop) ?? null,
189
302
  line: lineAt(source, at),
190
303
  excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
191
304
  });
@@ -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,7 +26,7 @@ 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
29
+ synthesisui doctor [paths…] [--all] audit for DRIFT: every design value written by
30
30
  hand, the token your system already has for it, and the
31
31
  laws your system carries for what you use
32
32
 
@@ -63,6 +63,7 @@ Examples:
63
63
  synthesisui init --target next
64
64
  synthesisui init --target next --ds halogen bootstrap + bring a system in
65
65
  synthesisui doctor
66
+ synthesisui doctor apps/web packages/ui # scope the read in a monorepo
66
67
  synthesisui doctor --strict
67
68
  synthesisui list
68
69
  synthesisui add halogen
@@ -120,8 +121,11 @@ async function main() {
120
121
  const dir = typeof flags.dir === "string" ? flags.dir : undefined;
121
122
  switch (command) {
122
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
123
126
  await doctor({
124
127
  dir,
128
+ scopes: args,
125
129
  strict: flags.strict === true,
126
130
  all: flags.all === true,
127
131
  laws: flags.laws === true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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": {