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.
- package/dist/commands/doctor.js +51 -12
- package/dist/commands/upgrade.js +77 -1
- package/dist/doctor/scan.js +45 -8
- package/dist/doctor/tokens.js +127 -1
- package/dist/index.js +8 -1
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -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(
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
609
|
-
console.log(body(`${overrides.length - lawKeepingCount
|
|
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
|
|
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
|
|
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)
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/doctor/scan.js
CHANGED
|
@@ -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
|
-
/**
|
|
42
|
-
|
|
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
|
-
|
|
50
|
-
function fallbackSpans(line) {
|
|
68
|
+
function fallbackSpans(line, table) {
|
|
51
69
|
const out = [];
|
|
52
|
-
for (const m of line.matchAll(
|
|
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[
|
|
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
|
|
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
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -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
|
|
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, {
|
|
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