synthesisui 0.12.0 → 0.13.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.
@@ -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,9 +243,16 @@ 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();
@@ -261,9 +294,15 @@ export async function doctor(opts) {
261
294
  console.log(line);
262
295
  };
263
296
  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`));
297
+ console.log(body(
298
+ // Three states, not two. "yours" has no lock to name it, and printing
299
+ // `null v?` for a project that DOES have a system would be the worst
300
+ // possible first line.
301
+ table.source === "installed"
302
+ ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${plural(table.byName.size, "token")}, ${plural(d.scanned, "file")} read`
303
+ : table.source === "yours"
304
+ ? `Your own tokens - ${plural(table.byName.size, "token")} found, ${plural(d.scanned, "file")} read`
305
+ : `No system installed - ${plural(d.scanned, "file")} read`));
267
306
  if (scopes.length > 0) {
268
307
  console.log(body(`scope: ${opts.scopes?.join(", ")}`));
269
308
  }
@@ -281,11 +320,11 @@ export async function doctor(opts) {
281
320
  const asideTotal = [...aside.values()].reduce((n, v) => n + v, 0);
282
321
  if (verbose) {
283
322
  for (const [reason, count] of aside) {
284
- console.log(body(`set aside: ${count} value(s) in ${reason}`));
323
+ console.log(body(`set aside: ${plural(count, "value")} in ${reason}`));
285
324
  }
286
325
  }
287
326
  else if (asideTotal > 0) {
288
- console.log(body(`set aside ${asideTotal} value(s) a token could never hold (--verbose for why)`));
327
+ console.log(body(`set aside ${plural(asideTotal, "value")} a token could never hold (--verbose for why)`));
289
328
  }
290
329
  // 0 of 0 is not a perfect score, it is an empty measurement - printing a
291
330
  // full bar there would be the report's first lie.
@@ -595,7 +634,7 @@ export async function doctor(opts) {
595
634
  else if (!verbose) {
596
635
  if (conflictsInUse.length + frozen.length > 0) {
597
636
  console.log("");
598
- console.log(body(`${conflictsInUse.length + frozen.length} problem(s) in the SYSTEM - not fixable from this repo`));
637
+ console.log(body(`${plural(conflictsInUse.length + frozen.length, "problem")} in the SYSTEM - not fixable from this repo`));
599
638
  for (const c of conflictsInUse) {
600
639
  console.log(` ds-${c.component} the law forbids ${c.forbids}, the recipe binds it`);
601
640
  }
@@ -605,8 +644,8 @@ export async function doctor(opts) {
605
644
  }
606
645
  if (d.findings.length > 0 || overrides.length > 0) {
607
646
  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` +
647
+ console.log(body(`${plural(d.findings.length, "value")} by hand · ${d.named} already ${d.named === 1 ? "has" : "have"} a name`));
648
+ console.log(body(`${plural(overrides.length - lawKeepingCount, "override")} · ${offSystemCount} left the system` +
610
649
  (lawKeepingCount > 0
611
650
  ? ` · ${lawKeepingCount} more kept a law`
612
651
  : "")));
@@ -633,11 +672,11 @@ export async function doctor(opts) {
633
672
  // authors their own system wants the opposite, and asks for it.
634
673
  const theirs = conflictsInUse.length + frozen.length;
635
674
  if (opts.strictSystem && theirs > 0) {
636
- console.log(body(`--strict-system: ${theirs} problem(s) in the system itself. Failing.`));
675
+ console.log(body(`--strict-system: ${plural(theirs, "problem")} in the system itself. Failing.`));
637
676
  process.exitCode = 1;
638
677
  }
639
678
  else if (theirs > 0) {
640
- console.log(body(`${theirs} problem(s) above are in the system, not this repo - not failing.`));
679
+ console.log(body(`${plural(theirs, "problem")} above ${theirs === 1 ? "is" : "are"} in the system, not this repo - not failing.`));
641
680
  console.log(body("Run with --strict-system if the system is yours."));
642
681
  }
643
682
  if (mine)
@@ -6,6 +6,64 @@ import { diffLocalDocuments, localChangelogMarkdown, } from "../document-diff.js
6
6
  import { body, section, snippet } from "../output.js";
7
7
  import { fetchChangelog, fetchComponent, fetchDesignSystem, RegistryError, } from "../registry.js";
8
8
  import { add } from "./add.js";
9
+ /**
10
+ * The highest `v<n>` below `installed` among the folder names given, or the one
11
+ * asked for. Pure, so the version arithmetic can be tested without a disk.
12
+ */
13
+ export function pickSnapshot(names, installed, asked) {
14
+ const versions = names
15
+ .map((n) => /^v(\d+)$/.exec(n))
16
+ .filter((m) => m !== null)
17
+ .map((m) => Number(m[1]))
18
+ .filter((v) => v < installed)
19
+ .sort((a, b) => b - a);
20
+ if (asked !== undefined)
21
+ return versions.includes(asked) ? asked : null;
22
+ return versions[0] ?? null;
23
+ }
24
+ /**
25
+ * Re-diff two snapshots already on disk and rewrite UPGRADE.md. Touches
26
+ * nothing else: the artifacts are already correct, it is only the brief about
27
+ * them that was written by an older, wronger version of this tool.
28
+ */
29
+ async function rewriteBrief(slug, slugDir, from, to) {
30
+ const read = async (v) => JSON.parse(await readFile(join(slugDir, `v${v}`, "design-system.json"), "utf8"));
31
+ let local;
32
+ try {
33
+ local = diffLocalDocuments(await read(from), await read(to));
34
+ }
35
+ catch {
36
+ console.log(`✗ could not read both snapshots (v${from}, v${to}).`);
37
+ return;
38
+ }
39
+ const brief = [
40
+ localChangelogMarkdown(slug, from, to, local),
41
+ "",
42
+ "---",
43
+ "",
44
+ "## How to migrate this app",
45
+ "",
46
+ "- Fix the **Breaking** items first: search the codebase for each removed",
47
+ " token/component/variant and move usages to the closest replacement.",
48
+ "- Changed tokens re-theme automatically (CSS variables) - review screens",
49
+ " that hardcoded values instead of tokens.",
50
+ "- The full new contract lives in `design-system.json` / `GUIDE.md` next to this file.",
51
+ "",
52
+ ].join("\n");
53
+ await writeFile(join(slugDir, "UPGRADE.md"), brief, "utf8");
54
+ console.log(section(`Rewrote the brief: ${slug} v${from} → v${to}`));
55
+ if (local.breaking.length > 0) {
56
+ console.log(body(`Breaking changes (${local.breaking.length}):`));
57
+ console.log("");
58
+ console.log(snippet(local.breaking.map((i) => `- ${i}`)));
59
+ }
60
+ else {
61
+ console.log(body("No breaking changes between those two versions."));
62
+ }
63
+ console.log("");
64
+ console.log(body(`_synthesisui/ds/${slug}/UPGRADE.md`));
65
+ console.log("");
66
+ }
9
67
  /**
10
68
  * Marco B - `synthesisui upgrade <slug>`: brings the installed system to the
11
69
  * latest version, GUIDED. In one run it:
@@ -38,7 +96,25 @@ export async function upgrade(slug, opts) {
38
96
  console.log(`→ checking "${slug}" (installed: v${installed}) …`);
39
97
  const latest = await fetchDesignSystem(base, slug);
40
98
  if (latest.version === installed) {
41
- console.log(`✓ ${slug} is already at the latest version (v${installed}).`);
99
+ if (!opts.force) {
100
+ console.log(`✓ ${slug} is already at the latest version (v${installed}).`);
101
+ return;
102
+ }
103
+ // The brief is a PHOTOGRAPH: written once, at the moment of the upgrade,
104
+ // and it ages. When the tool that wrote it is fixed - as the breaking-change
105
+ // diff was on 25/07, after it had announced ten changes that were not
106
+ // breaking - the file on disk keeps the old answer and there is no way back
107
+ // to it, because there is no version gap left to trigger a rewrite. This is
108
+ // that way back: re-diff from the highest older snapshot still on disk.
109
+ const onDisk = await readdir(slugDir).catch(() => []);
110
+ const from = pickSnapshot(onDisk, installed, opts.from);
111
+ if (from === null) {
112
+ console.log(opts.from !== undefined
113
+ ? `✗ v${opts.from} is not on disk under ${slugDir}.`
114
+ : `✗ nothing to diff against: no snapshot older than v${installed} on disk.`);
115
+ return;
116
+ }
117
+ await rewriteBrief(slug, slugDir, from, installed);
42
118
  return;
43
119
  }
44
120
  if (latest.version < installed) {
@@ -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/dist/index.js CHANGED
@@ -52,6 +52,8 @@ Options:
52
52
  --instruction <s> refit: extra guidance for the adaptation
53
53
  --dry refit: adapt and print, but save nothing
54
54
  --force clean: apply the changes (without it, dry run)
55
+ upgrade: rewrite UPGRADE.md even with no version gap left
56
+ --from <n> upgrade --force: which older snapshot to diff from
55
57
  --strict doctor: exit 1 when drift is found in THIS repo (for CI)
56
58
  --strict-system doctor: also exit 1 when the system itself is inconsistent
57
59
  --verbose doctor: every finding, file by file (default is a summary)
@@ -267,7 +269,12 @@ async function main() {
267
269
  process.exitCode = 1;
268
270
  return;
269
271
  }
270
- await upgrade(slug, { registry, dir });
272
+ await upgrade(slug, {
273
+ registry,
274
+ dir,
275
+ force: flags.force === true,
276
+ from: typeof flags.from === "string" ? Number(flags.from) : undefined,
277
+ });
271
278
  break;
272
279
  }
273
280
  case "use": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.12.0",
3
+ "version": "0.13.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": {