synthesisui 0.7.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.
@@ -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;
@@ -117,6 +117,28 @@ function enclosingTag(src, at) {
117
117
  return null;
118
118
  }
119
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
+ }
120
142
  export function findOverrides(source, recipes) {
121
143
  const out = [];
122
144
  const lines = source.split("\n");
@@ -142,10 +164,13 @@ export function findOverrides(source, recipes) {
142
164
  const prop = kebab(s[1]);
143
165
  if (!bind.props.has(prop))
144
166
  continue;
167
+ const wrote = literalValue(s[2]);
168
+ if (wrote === null)
169
+ continue;
145
170
  record({
146
171
  component: name,
147
172
  prop,
148
- wrote: s[2].trim().replace(/^["']|["']$/g, ""),
173
+ wrote,
149
174
  recipe: bind.baseValues.get(prop) ?? null,
150
175
  line: lineAt(source, tag.start + (s.index ?? 0)),
151
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,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.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": {