synthesisui 0.12.1 → 0.14.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.
@@ -206,6 +206,12 @@ export async function add(slug, opts) {
206
206
  console.log(line(` Prefer next/font or self-hosting? Fine - just register these exact families: ${customFontFamilies(families).join(", ")}.`));
207
207
  }
208
208
  console.log(section("Next"));
209
+ // FIRST, and before anything that assumes the setup worked. The block above
210
+ // is four manual edits across two files, one of them with a relative path
211
+ // the command itself has to warn about. Without a way to check, the person
212
+ // does not know whether they finished - and the next thing they see is a
213
+ // coverage number that reads 0% when an import is missing.
214
+ console.log(line(`synthesisui doctor confirm the setup above actually took`));
209
215
  console.log(line(`synthesisui component ${payload.slug} button bring a component in as YOUR code`));
210
216
  console.log(line(`synthesisui template ${payload.slug} landing materialize a whole page`));
211
217
  console.log("");
@@ -137,12 +137,38 @@ async function loadSystem(root) {
137
137
  }
138
138
  return { table: buildTable({ css, lock }), recipes, documents };
139
139
  }
140
+ /**
141
+ * The project's OWN vocabulary, when we did not put one there.
142
+ *
143
+ * Measured 27/07 against a repo holding eight `--acme-*` tokens in `:root`, a
144
+ * button correctly on `var(--acme-color-primary)` and a banner hardcoding the
145
+ * same `#3b82f6`: the report said "No system installed" and counted five
146
+ * anonymous values. That is the exact reader the landing page invites - the
147
+ * one who already owns a system and wants to know whether the code still
148
+ * obeys it - and the tool was blind to them because `parseTokens` only ever
149
+ * looked for our own `--ds-` prefix.
150
+ *
151
+ * Reads stylesheets only, and only when nothing of ours is installed, so the
152
+ * common path pays nothing for it.
153
+ */
154
+ async function harvestOwnTokens(roots) {
155
+ let css = "";
156
+ for await (const file of walkAll(roots)) {
157
+ if (!/\.(css|scss|sass|less)$/i.test(file))
158
+ continue;
159
+ css += `\n${await readFile(file, "utf8").catch(() => "")}`;
160
+ }
161
+ return buildTable({ css, source: "yours" });
162
+ }
140
163
  const KIND_LABEL = {
141
164
  color: "colour",
142
165
  radius: "radius",
143
166
  spacing: "spacing",
144
167
  font: "type",
145
168
  };
169
+ /** "1 file", "2 files" - a report that says "1 files read" on its very first
170
+ * line spends credibility before it has said anything. */
171
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
146
172
  /** A bar you can read at a glance, in a font that is always monospace. */
147
173
  function meter(pct, width = 24) {
148
174
  const filled = Math.round((pct / 100) * width);
@@ -217,18 +243,46 @@ function verdict(d, hasSystem, overruled, conflicts) {
217
243
  }
218
244
  export async function doctor(opts) {
219
245
  const root = resolve(opts.dir ?? process.cwd());
220
- const { table, recipes, documents } = await loadSystem(root);
221
- const hasSystem = table.byName.size > 0;
222
246
  const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
247
+ const installed = await loadSystem(root);
248
+ const { recipes, documents } = installed;
249
+ let table = installed.table;
250
+ // Nothing of ours here does not mean nothing to measure against. Fall back
251
+ // to whatever vocabulary the project already declares for itself.
252
+ if (table.byName.size === 0) {
253
+ table = await harvestOwnTokens(scopes.length > 0 ? scopes : [root]);
254
+ }
255
+ const hasSystem = table.byName.size > 0;
223
256
  const reports = [];
224
257
  const overrides = [];
225
258
  const used = new Map();
226
259
  const known = new Set(recipes.keys());
260
+ /**
261
+ * INSTALLED IS NOT THE SAME AS WORKING.
262
+ *
263
+ * `init` ends in four manual edits across two files. Skip them - or get the
264
+ * relative path in the import wrong, which the command itself warns about -
265
+ * and the next run reports "Token coverage 0%" with no explanation
266
+ * (reproduced 27/07 on a fresh project). The tool knows the system is
267
+ * installed, because it just read 85 tokens out of it, and reports a number
268
+ * that reads as failure when the truth is an unfinished setup.
269
+ *
270
+ * Nobody debugs from 0%. They conclude the product does not work.
271
+ */
272
+ const wiring = { imported: false, scoped: false };
227
273
  for await (const file of scopes.length > 0 ? walkAll(scopes) : walk(root)) {
228
274
  const src = await readFile(file, "utf8").catch(() => "");
229
275
  if (!src)
230
276
  continue;
231
277
  const rel = relative(root, file);
278
+ // The two facts that decide whether an installed system reaches the
279
+ // browser at all. Free: these files are already open and in memory.
280
+ if (table.slug) {
281
+ if (src.includes(`_synthesisui/ds/${table.slug}/tokens.css`))
282
+ wiring.imported = true;
283
+ if (src.includes(`data-ds="${table.slug}"`))
284
+ wiring.scoped = true;
285
+ }
232
286
  reports.push(scanSource(rel, src, table));
233
287
  if (recipes.size > 0) {
234
288
  for (const o of findOverrides(src, recipes))
@@ -261,9 +315,15 @@ export async function doctor(opts) {
261
315
  console.log(line);
262
316
  };
263
317
  console.log(section("Doctor"));
264
- console.log(body(hasSystem
265
- ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
266
- : `No system installed - ${d.scanned} files read`));
318
+ console.log(body(
319
+ // Three states, not two. "yours" has no lock to name it, and printing
320
+ // `null v?` for a project that DOES have a system would be the worst
321
+ // possible first line.
322
+ table.source === "installed"
323
+ ? `${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`));
267
327
  if (scopes.length > 0) {
268
328
  console.log(body(`scope: ${opts.scopes?.join(", ")}`));
269
329
  }
@@ -281,15 +341,34 @@ export async function doctor(opts) {
281
341
  const asideTotal = [...aside.values()].reduce((n, v) => n + v, 0);
282
342
  if (verbose) {
283
343
  for (const [reason, count] of aside) {
284
- console.log(body(`set aside: ${count} value(s) in ${reason}`));
344
+ console.log(body(`set aside: ${plural(count, "value")} in ${reason}`));
285
345
  }
286
346
  }
287
347
  else if (asideTotal > 0) {
288
- console.log(body(`set aside ${asideTotal} value(s) a token could never hold (--verbose for why)`));
348
+ console.log(body(`set aside ${plural(asideTotal, "value")} a token could never hold (--verbose for why)`));
289
349
  }
290
350
  // 0 of 0 is not a perfect score, it is an empty measurement - printing a
291
351
  // full bar there would be the report's first lie.
292
352
  const measurable = d.tokenUses + d.findings.length > 0;
353
+ // Before the number, because the number is the thing that misleads. An
354
+ // installed-but-unwired system reads 0%, and 0% reads as "broken product"
355
+ // rather than "one import missing".
356
+ const unwired = table.source === "installed" && (!wiring.imported || !wiring.scoped);
357
+ if (unwired) {
358
+ console.log("");
359
+ console.log(body(`${table.name ?? table.slug} is installed - but not wired up yet.`));
360
+ console.log("");
361
+ console.log(body(wiring.imported
362
+ ? ` ✓ some stylesheet imports _synthesisui/ds/${table.slug}/tokens.css`
363
+ : ` ✗ no stylesheet imports _synthesisui/ds/${table.slug}/tokens.css`));
364
+ console.log(body(wiring.scoped
365
+ ? ` ✓ data-ds="${table.slug}" found`
366
+ : ` ✗ no element carries data-ds="${table.slug}"`));
367
+ console.log("");
368
+ console.log(body(`Until both are true, none of the ${table.byName.size} tokens reach the browser`));
369
+ console.log(body("and the number below cannot mean anything."));
370
+ console.log(body("The exact snippets are in the output of `init`."));
371
+ }
293
372
  if (hasSystem && measurable) {
294
373
  console.log("");
295
374
  console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
@@ -595,7 +674,7 @@ export async function doctor(opts) {
595
674
  else if (!verbose) {
596
675
  if (conflictsInUse.length + frozen.length > 0) {
597
676
  console.log("");
598
- console.log(body(`${conflictsInUse.length + frozen.length} problem(s) in the SYSTEM - not fixable from this repo`));
677
+ console.log(body(`${plural(conflictsInUse.length + frozen.length, "problem")} in the SYSTEM - not fixable from this repo`));
599
678
  for (const c of conflictsInUse) {
600
679
  console.log(` ds-${c.component} the law forbids ${c.forbids}, the recipe binds it`);
601
680
  }
@@ -605,8 +684,8 @@ export async function doctor(opts) {
605
684
  }
606
685
  if (d.findings.length > 0 || overrides.length > 0) {
607
686
  console.log("");
608
- console.log(body(`${d.findings.length} value(s) by hand · ${d.named} already have a name`));
609
- console.log(body(`${overrides.length - lawKeepingCount} override(s) · ${offSystemCount} left the system` +
687
+ console.log(body(`${plural(d.findings.length, "value")} by hand · ${d.named} already ${d.named === 1 ? "has" : "have"} a name`));
688
+ console.log(body(`${plural(overrides.length - lawKeepingCount, "override")} · ${offSystemCount} left the system` +
610
689
  (lawKeepingCount > 0
611
690
  ? ` · ${lawKeepingCount} more kept a law`
612
691
  : "")));
@@ -622,6 +701,42 @@ export async function doctor(opts) {
622
701
  }
623
702
  console.log("");
624
703
  console.log(body("synthesisui doctor --verbose every finding, file by file"));
704
+ /**
705
+ * THE READER WHO GOT A NUMBER AND NOWHERE TO GO.
706
+ *
707
+ * Someone with no system at all is told to start one. Someone running OUR
708
+ * system has commands everywhere. The person in between - who already owns
709
+ * a design system, which is precisely the ICP - got the report and silence,
710
+ * and closed the terminal (walked 27/07).
711
+ *
712
+ * The sentence follows from THEIR number rather than pitching: the value
713
+ * is not that we would name these, it is that their agent has no way to
714
+ * know they should be named. That is also the line worth pasting into a
715
+ * thread, which is the other job this paragraph does.
716
+ */
717
+ if (table.source === "yours") {
718
+ const named = d.findings.filter((f) => f.token).length;
719
+ const anonymous = d.findings.length - named;
720
+ console.log("");
721
+ console.log(body(named > 0
722
+ ? `${named} of these already have a name in your own system.`
723
+ : "None of these have a name in your system yet."));
724
+ if (anonymous > 0) {
725
+ console.log(body(`The other ${anonymous} do not - and your coding agent has no way to know they should.`));
726
+ }
727
+ console.log("");
728
+ // Careful about what is being offered. This reader ALREADY has a design
729
+ // system, so "install ours" would mean replacing theirs, and a CTA that
730
+ // pretends otherwise is the kind of confident overclaim that costs
731
+ // trust the first time somebody follows it. What they are missing is
732
+ // not tokens - it is a contract their agent reads before writing UI.
733
+ console.log(body("Your tokens exist. What your agent is missing is a contract:"));
734
+ console.log(body("rules it reads BEFORE writing UI, and a manifest of what exists."));
735
+ console.log("");
736
+ console.log(snippet(["npx synthesisui@latest init --ds <slug>"]));
737
+ console.log(body("starts you on a system that ships one - browse them at"));
738
+ console.log(body("https://www.synthesisui.com/gallery"));
739
+ }
625
740
  console.log("");
626
741
  }
627
742
  if (opts.strict) {
@@ -633,11 +748,11 @@ export async function doctor(opts) {
633
748
  // authors their own system wants the opposite, and asks for it.
634
749
  const theirs = conflictsInUse.length + frozen.length;
635
750
  if (opts.strictSystem && theirs > 0) {
636
- console.log(body(`--strict-system: ${theirs} problem(s) in the system itself. Failing.`));
751
+ console.log(body(`--strict-system: ${plural(theirs, "problem")} in the system itself. Failing.`));
637
752
  process.exitCode = 1;
638
753
  }
639
754
  else if (theirs > 0) {
640
- console.log(body(`${theirs} problem(s) above are in the system, not this repo - not failing.`));
755
+ console.log(body(`${plural(theirs, "problem")} above ${theirs === 1 ? "is" : "are"} in the system, not this repo - not failing.`));
641
756
  console.log(body("Run with --strict-system if the system is yours."));
642
757
  }
643
758
  if (mine)
@@ -38,23 +38,53 @@ const RADIUS = /(?:border-radius\s*:\s*|rounded(?:-[a-z]+)?-\[)(-?\d*\.?\d+)(px|
38
38
  const SPACING = /(?:\b[pmg](?:[trblxy])?-\[|gap-\[|(?:padding|margin|gap)\s*:\s*)(-?\d*\.?\d+)(px|rem)/g;
39
39
  /** A font stack written by hand rather than taken from the type scale. */
40
40
  const FONT = /font-family\s*:\s*([^;}\n]+)/g;
41
- /** Uses of the system. Coverage is meaningless without them. */
42
- const TOKEN_USE = /var\(\s*--ds-[a-z0-9-]+/gi;
41
+ /**
42
+ * Uses of the system. Coverage is meaningless without them.
43
+ *
44
+ * Both of these used to be pinned to our own `--ds-` prefix, which quietly
45
+ * decided that only OUR systems count. A project with `--acme-color-primary`
46
+ * in `:root` and `var(--acme-color-primary)` in a button was reported at 0%
47
+ * coverage while doing everything right (measured 27/07). The rule now: our
48
+ * prefix always counts, and any other custom property counts once we have
49
+ * seen it declared - which is exactly what the token table knows.
50
+ */
51
+ const ANY_VAR_USE = /var\(\s*(--[a-z0-9_-]+)/gi;
52
+ const ANY_VAR_FALLBACK = /var\(\s*(--[a-z0-9_-]+)\s*,([^()]*)\)/gi;
53
+ const isKnownToken = (name, table) => name.startsWith("--ds-") || table.byName.has(name);
54
+ function countTokenUses(line, table) {
55
+ let n = 0;
56
+ for (const m of line.matchAll(ANY_VAR_USE)) {
57
+ if (isKnownToken(m[1].toLowerCase(), table))
58
+ n++;
59
+ }
60
+ return n;
61
+ }
43
62
  /**
44
63
  * `var(--ds-color-semantic-primary, #5266eb)` - the literal is the TOKEN'S OWN
45
64
  * fallback, written for safety, and reporting it as drift told an author to
46
65
  * tokenize something they had already tokenized (investidorez, 25/07). The
47
66
  * spans below are the fallback arguments on a line.
48
67
  */
49
- const VAR_FALLBACK = /var\(\s*--ds-[a-z0-9-]+\s*,([^()]*)\)/gi;
50
- function fallbackSpans(line) {
68
+ function fallbackSpans(line, table) {
51
69
  const out = [];
52
- for (const m of line.matchAll(VAR_FALLBACK)) {
70
+ for (const m of line.matchAll(ANY_VAR_FALLBACK)) {
71
+ if (!isKnownToken(m[1].toLowerCase(), table))
72
+ continue;
53
73
  const start = (m.index ?? 0) + m[0].indexOf(",") + 1;
54
- out.push([start, start + m[1].length]);
74
+ out.push([start, start + m[2].length]);
55
75
  }
56
76
  return out;
57
77
  }
78
+ /**
79
+ * The line that DEFINES a token is not a line that drifted from it.
80
+ *
81
+ * Our own tokens.css lives under `_synthesisui`, which the walk skips, so this
82
+ * never came up. A project's own stylesheet does not have that luxury: on the
83
+ * first run against one, every `--acme-color-primary: #3b82f6` came back as a
84
+ * hardcoded colour, which is the tool accusing the author of the very thing it
85
+ * is measuring them against.
86
+ */
87
+ const DECLARES_TOKEN = /^\s*(--[a-z0-9_-]+)\s*:/i;
58
88
  /** Under this, a radius or spacing value is idiom rather than a decision. */
59
89
  const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
60
90
  export function scanSource(file, source, table) {
@@ -81,7 +111,7 @@ export function scanSource(file, source, table) {
81
111
  source.split("\n").forEach((raw, i) => {
82
112
  const line = raw.trim();
83
113
  const at = i + 1;
84
- tokenUses += (line.match(TOKEN_USE) ?? []).length;
114
+ tokenUses += countTokenUses(line, table);
85
115
  // Depth at the START of this line, carried before the early return so a
86
116
  // blank line inside an <svg> cannot close the region by accident. The
87
117
  // per-match depth is recomputed below, because an icon is often written on
@@ -93,11 +123,18 @@ export function scanSource(file, source, table) {
93
123
  (line.match(/<\/svg>/g) ?? []).length);
94
124
  if (!line || IGNORE_LINE.test(line))
95
125
  return;
126
+ // The declaration of a token we are measuring against is the definition,
127
+ // not drift. Scoped to tokens the table already knows, so a one-off
128
+ // `--card-shadow: 0 2px 8px #0002` invented inside a component is still
129
+ // reported - that one really is a decision made outside the system.
130
+ const declares = DECLARES_TOKEN.exec(line);
131
+ if (declares && table.byName.has(declares[1].toLowerCase()))
132
+ return;
96
133
  // `rgba(${r}, ${g}, ${b}, ${a})` is code computing a colour, not a colour
97
134
  // written by hand - flagging it would be telling someone to tokenize a
98
135
  // variable. And the same literal twice in one declaration (a two-stop
99
136
  // shadow) is one decision, so it is reported once.
100
- const spans = fallbackSpans(line);
137
+ const spans = fallbackSpans(line, table);
101
138
  const inFallback = (at) => spans.some(([a, b]) => at >= a && at <= b);
102
139
  const seen = new Set();
103
140
  // NOT named `at`: that is the line number in this scope, and shadowing it
@@ -12,6 +12,7 @@
12
12
  * returns data, so the whole diagnosis is testable without a filesystem.
13
13
  */
14
14
  export const EMPTY_TABLE = {
15
+ source: null,
15
16
  name: null,
16
17
  slug: null,
17
18
  version: null,
@@ -21,6 +22,62 @@ export const EMPTY_TABLE = {
21
22
  const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n)))
22
23
  .toString(16)
23
24
  .padStart(2, "0");
25
+ /** Linear-light channel to the sRGB byte a screen actually paints. */
26
+ const gamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055) * 255;
27
+ /**
28
+ * `oklch(0.648 0.2 131.684)` → the same key as the hex somebody pasted.
29
+ *
30
+ * Measured 27/07 against shadcn/ui, 3846 files: 48 tokens found, 1383 values
31
+ * written by hand, and **zero** matched - because shadcn declares in oklch and
32
+ * the code hardcodes hex. Tailwind v4 emits oklch by default too, so the one
33
+ * thing this tool does that a linter cannot - say what your system already
34
+ * calls the value - silently returned nothing on the most common modern stack.
35
+ *
36
+ * This is not fuzzy matching, which the exact-match rule below rightly
37
+ * forbids. It is finishing the job `normalizeValue` already claims to do:
38
+ * every dialect lands on one key. Rounding to 8 bits is precisely what the
39
+ * browser does on an sRGB display, and out-of-gamut coordinates clamp the same
40
+ * way they would on screen.
41
+ */
42
+ function oklchToBytes(l, c, hDeg) {
43
+ const h = (hDeg * Math.PI) / 180;
44
+ const a = c * Math.cos(h);
45
+ const b = c * Math.sin(h);
46
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
47
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
48
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
49
+ return [
50
+ gamma(4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_),
51
+ gamma(-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_),
52
+ gamma(-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_),
53
+ ];
54
+ }
55
+ /** `hsl(222 47% 11%)`, the dialect shadcn used before it moved to oklch. */
56
+ function hslToBytes(h, s, l) {
57
+ const k = (n) => (n + h / 30) % 12;
58
+ const a = s * Math.min(l, 1 - l);
59
+ const f = (n) => (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))) * 255;
60
+ return [f(0), f(8), f(4)];
61
+ }
62
+ /** The numbers inside a colour function, in any of CSS's separator dialects. */
63
+ function args(body) {
64
+ const parts = body.split(/[\s,/]+/).filter(Boolean);
65
+ if (parts.length < 3)
66
+ return null;
67
+ const nums = parts.slice(0, 3).map((p) => {
68
+ const n = Number.parseFloat(p);
69
+ return p.endsWith("%") ? n / 100 : n;
70
+ });
71
+ if (!nums.every(Number.isFinite))
72
+ return null;
73
+ const rawA = parts[3];
74
+ const alpha = rawA === undefined
75
+ ? 1
76
+ : rawA.endsWith("%")
77
+ ? Number.parseFloat(rawA) / 100
78
+ : Number.parseFloat(rawA);
79
+ return { nums, alpha: Number.isFinite(alpha) ? alpha : 1 };
80
+ }
24
81
  /**
25
82
  * One key per colour, whatever dialect it was written in.
26
83
  *
@@ -41,6 +98,29 @@ export function normalizeValue(raw) {
41
98
  const long = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(v);
42
99
  if (long)
43
100
  return `#${long[1]}${long[2] ?? "ff"}`;
101
+ const ok = /^oklch\(([^)]+)\)$/.exec(v);
102
+ if (ok) {
103
+ const a = args(ok[1]);
104
+ // The hue is an angle, not a fraction: `131.684` must not be read as a
105
+ // percentage the way lightness is.
106
+ if (a) {
107
+ const hue = /%$/.test(ok[1].split(/[\s,/]+/).filter(Boolean)[2] ?? "")
108
+ ? a.nums[2] * 100
109
+ : a.nums[2];
110
+ const [r, g, b] = oklchToBytes(a.nums[0], a.nums[1], hue);
111
+ return `#${hex2(r)}${hex2(g)}${hex2(b)}${hex2(a.alpha * 255)}`;
112
+ }
113
+ }
114
+ const hsl = /^hsla?\(([^)]+)\)$/.exec(v);
115
+ if (hsl) {
116
+ const a = args(hsl[1]);
117
+ if (a) {
118
+ const raw = hsl[1].split(/[\s,/]+/).filter(Boolean);
119
+ const hue = Number.parseFloat(raw[0]);
120
+ const [r, g, b] = hslToBytes(((hue % 360) + 360) % 360, a.nums[1], a.nums[2]);
121
+ return `#${hex2(r)}${hex2(g)}${hex2(b)}${hex2(a.alpha * 255)}`;
122
+ }
123
+ }
44
124
  const rgb = /^rgba?\(([^)]+)\)$/.exec(v);
45
125
  if (rgb) {
46
126
  const parts = rgb[1].split(/[\s,/]+/).filter(Boolean);
@@ -86,8 +166,53 @@ export function parseTokens(css) {
86
166
  }
87
167
  return out;
88
168
  }
169
+ /**
170
+ * Custom properties the project declares AT THE ROOT, whatever it calls them.
171
+ *
172
+ * This is what lets the tool answer the only question its sharpest reader has:
173
+ * not "do you use SynthesisUI" but "does my own code still obey my own
174
+ * system". Someone with `--acme-color-primary: #3b82f6` in `:root` and
175
+ * `#3b82f6` hardcoded in a banner has drift, and until now we told them no
176
+ * system was installed and counted their values as anonymous.
177
+ *
178
+ * SCOPE IS THE HEURISTIC, not a prefix allow-list. A design token is declared
179
+ * once at the root; a runtime variable is set per element. That single rule
180
+ * keeps `:root`, `html`, `:host` and Tailwind v4's `@theme` - all places an
181
+ * author writes their vocabulary - and drops Tailwind v3's `--tw-*` defaults
182
+ * (declared on `*, ::before, ::after`) and Radix's `--radix-*` (set on the
183
+ * component) without naming a single vendor.
184
+ *
185
+ * The flat brace regex is deliberate and handles one level of nesting for
186
+ * free: against `@media x { :root { ... } }` the outer selector fails to match
187
+ * because its body contains a brace, so the scan moves on and finds the inner
188
+ * block on its own.
189
+ */
190
+ export function parseRootTokens(css) {
191
+ const out = new Map();
192
+ for (const block of css.matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
193
+ const selector = block[1].trim();
194
+ const isRoot = /^@theme\b/i.test(selector) ||
195
+ /(^|[\s,>+~])(:root|html|:host)\b/i.test(selector);
196
+ if (!isRoot)
197
+ continue;
198
+ for (const m of block[2].matchAll(/(--[a-z0-9_-]+)\s*:\s*([^;}]+)/gi)) {
199
+ const name = m[1].toLowerCase();
200
+ // Belt and braces: v4 emits some `--tw-*` bookkeeping into @theme, and
201
+ // it is machinery, not somebody's design vocabulary.
202
+ if (name.startsWith("--tw-"))
203
+ continue;
204
+ const value = m[2].trim();
205
+ if (!value || value.startsWith("var("))
206
+ continue;
207
+ if (!out.has(name))
208
+ out.set(name, value);
209
+ }
210
+ }
211
+ return out;
212
+ }
89
213
  export function buildTable(input) {
90
- const byName = parseTokens(input.css);
214
+ const source = input.source ?? "installed";
215
+ const byName = source === "yours" ? parseRootTokens(input.css) : parseTokens(input.css);
91
216
  const byValue = new Map();
92
217
  for (const [name, value] of byName) {
93
218
  const key = normalizeValue(value);
@@ -98,6 +223,7 @@ export function buildTable(input) {
98
223
  byValue.set(key, [name]);
99
224
  }
100
225
  return {
226
+ source: byName.size > 0 ? source : null,
101
227
  name: input.lock?.name ?? null,
102
228
  slug: input.lock?.slug ?? null,
103
229
  version: input.lock?.version ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.12.1",
3
+ "version": "0.14.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": {