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.
- package/dist/commands/add.js +6 -0
- package/dist/commands/doctor.js +127 -12
- package/dist/doctor/scan.js +45 -8
- package/dist/doctor/tokens.js +127 -1
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -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("");
|
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,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(
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
609
|
-
console.log(body(`${overrides.length - lawKeepingCount
|
|
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
|
|
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
|
|
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)
|
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/package.json
CHANGED