synthesisui 0.10.0 → 0.11.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 +31 -3
- package/dist/doctor/frozen.js +104 -0
- package/dist/doctor/scan.js +33 -5
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { join, relative, resolve } from "node:path";
|
|
3
|
+
import { findFrozenBindings } from "../doctor/frozen.js";
|
|
3
4
|
import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
|
|
4
5
|
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
5
6
|
import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
|
|
@@ -352,6 +353,28 @@ export async function doctor(opts) {
|
|
|
352
353
|
console.log(body("Whoever wrote the law and whoever wrote the recipe disagree."));
|
|
353
354
|
console.log(body("Until they do not, no code here can be correct."));
|
|
354
355
|
}
|
|
356
|
+
// The other way a system fails itself: a recipe that names a SHELF where the
|
|
357
|
+
// system has a ROLE. The value is legitimate, the reference resolves, the CSS
|
|
358
|
+
// compiles - and the binding sits still while everything around it flips.
|
|
359
|
+
const frozen = documents
|
|
360
|
+
.flatMap((doc) => findFrozenBindings(doc))
|
|
361
|
+
.filter((f) => used.has(f.component));
|
|
362
|
+
if (frozen.length > 0) {
|
|
363
|
+
console.log(section("These will not follow your other scheme"));
|
|
364
|
+
console.log(body(frozen.length === 1
|
|
365
|
+
? "One recipe names a primitive where a role holds the same value."
|
|
366
|
+
: `${frozen.length} recipes name a primitive where a role holds the same value.`));
|
|
367
|
+
console.log("");
|
|
368
|
+
for (const f of frozen) {
|
|
369
|
+
console.log(body(`ds-${f.component} · ${f.where}`));
|
|
370
|
+
console.log(` binds ${f.wrote}`);
|
|
371
|
+
console.log(` {color.semantic.${f.role}} holds that, and becomes ${f.becomes}`);
|
|
372
|
+
console.log("");
|
|
373
|
+
}
|
|
374
|
+
console.log(body("The value resolves and the CSS compiles, so nothing"));
|
|
375
|
+
console.log(body("complains - the surface just stays put when the scheme"));
|
|
376
|
+
console.log(body("moves around it."));
|
|
377
|
+
}
|
|
355
378
|
if (overrides.length > 0) {
|
|
356
379
|
// An override on a property the component's own law FORBIDS, written as a
|
|
357
380
|
// reset, is not drift - it is the author keeping a promise the recipe
|
|
@@ -401,7 +424,10 @@ export async function doctor(opts) {
|
|
|
401
424
|
}
|
|
402
425
|
return false;
|
|
403
426
|
};
|
|
404
|
-
|
|
427
|
+
// Three acts, not two. `transform: none` does not leave the system - it
|
|
428
|
+
// REMOVES what the recipe sets, and no token can hold "no transform".
|
|
429
|
+
// Marking it as a raw value asked for something that cannot exist.
|
|
430
|
+
const offSystem = drifting.filter((o) => !onSystem(o) && !isReset(o.wrote)).length;
|
|
405
431
|
console.log(section("Overruled"));
|
|
406
432
|
console.log(body(`${drifting.length} place${drifting.length === 1 ? "" : "s"} where the code takes a component`));
|
|
407
433
|
console.log(body("the system defines, and then overrules it locally."));
|
|
@@ -434,8 +460,10 @@ export async function doctor(opts) {
|
|
|
434
460
|
console.log(` the recipe binds ${o.recipe}`);
|
|
435
461
|
else if (o.where)
|
|
436
462
|
console.log(` the recipe binds it ${o.where}`);
|
|
437
|
-
if (
|
|
438
|
-
console.log(
|
|
463
|
+
if (lawKept(o) === null && !onSystem(o)) {
|
|
464
|
+
console.log(isReset(o.wrote)
|
|
465
|
+
? " ↑ removes it rather than replacing it"
|
|
466
|
+
: " ↑ a raw value, not a token");
|
|
439
467
|
}
|
|
440
468
|
const kept = lawKept(o);
|
|
441
469
|
if (kept) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOCTOR · bindings that will not follow the scheme.
|
|
3
|
+
*
|
|
4
|
+
* A system with two schemes keeps the flip in ONE place: the semantic layer.
|
|
5
|
+
* `surface` points at navy-800 in dark and navy-50 in light, and every recipe
|
|
6
|
+
* that binds `{color.semantic.surface}` flips for free.
|
|
7
|
+
*
|
|
8
|
+
* A recipe that reaches past the role and names the shelf - `{color.navy.800}` -
|
|
9
|
+
* is frozen. It looks identical in the authored scheme and wrong in the other
|
|
10
|
+
* one, and nothing in the pipeline says so: the value is a legitimate token,
|
|
11
|
+
* the reference resolves, the CSS compiles.
|
|
12
|
+
*
|
|
13
|
+
* Vesper does this in five places. Its chip binds `{color.navy.800}` for a
|
|
14
|
+
* background where `semantic.surface` holds the same value and becomes navy-50
|
|
15
|
+
* in light mode - so the chip stays dark on a light page (found by reading the
|
|
16
|
+
* doctor's own output on a real project, investidorez, 25/07).
|
|
17
|
+
*
|
|
18
|
+
* This is a correctness check, not a style one. It fires only when the role
|
|
19
|
+
* actually differs between the two schemes; a role that is the same in both
|
|
20
|
+
* costs nothing to spell either way, and this stays quiet about it.
|
|
21
|
+
*/
|
|
22
|
+
const PRIMITIVE = /^\{color\.([a-z0-9]+)\.([a-z0-9]+)\}$/i;
|
|
23
|
+
const BACKGROUND = /^background(-color)?$/;
|
|
24
|
+
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
25
|
+
function* colorBindings(node, where) {
|
|
26
|
+
if (!node || typeof node !== "object")
|
|
27
|
+
return;
|
|
28
|
+
for (const [k, v] of Object.entries(node)) {
|
|
29
|
+
if (typeof v === "string") {
|
|
30
|
+
// Backgrounds only. A FOREGROUND bound to a primitive is usually right:
|
|
31
|
+
// white on an indigo button must stay white, because the fill it sits on
|
|
32
|
+
// does not flip either. Deciding that needs the sibling fill and the
|
|
33
|
+
// author's intent, so this lens does not try - a wrong accusation here
|
|
34
|
+
// would have someone invert readable text.
|
|
35
|
+
if (BACKGROUND.test(kebab(k)) && PRIMITIVE.test(v))
|
|
36
|
+
yield { prop: kebab(k), value: v, where };
|
|
37
|
+
}
|
|
38
|
+
else if (v && typeof v === "object") {
|
|
39
|
+
yield* colorBindings(v, where);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function findFrozenBindings(document) {
|
|
44
|
+
const doc = (document ?? {});
|
|
45
|
+
const foundations = (doc.foundations ?? {});
|
|
46
|
+
const color = (foundations.color ?? {});
|
|
47
|
+
const semantic = (color.semantic ?? {});
|
|
48
|
+
const alt = (color.semanticAlt ?? {});
|
|
49
|
+
// Only roles that actually MOVE between schemes. A role spelled the same in
|
|
50
|
+
// both is not a correctness problem, and this lens does not do taste.
|
|
51
|
+
//
|
|
52
|
+
// Several roles can hold one primitive (Vesper's navy-700 is raised, overlay
|
|
53
|
+
// AND border). Surfaces are preferred, because this only ever reports a
|
|
54
|
+
// background - naming "border" for a background reads like a bug in the tool.
|
|
55
|
+
const RANK = ["surface", "raised", "canvas", "overlay", "border"];
|
|
56
|
+
const roleOf = new Map();
|
|
57
|
+
for (const [role, ref] of Object.entries(semantic)) {
|
|
58
|
+
const other = alt[role];
|
|
59
|
+
if (typeof ref !== "string" || typeof other !== "string")
|
|
60
|
+
continue;
|
|
61
|
+
if (other === ref)
|
|
62
|
+
continue;
|
|
63
|
+
const m = PRIMITIVE.exec(ref);
|
|
64
|
+
if (!m)
|
|
65
|
+
continue;
|
|
66
|
+
const key = `${m[1]}.${m[2]}`.toLowerCase();
|
|
67
|
+
const held = roleOf.get(key);
|
|
68
|
+
const better = !held ||
|
|
69
|
+
(RANK.indexOf(role) !== -1 &&
|
|
70
|
+
(RANK.indexOf(held.role) === -1 ||
|
|
71
|
+
RANK.indexOf(role) < RANK.indexOf(held.role)));
|
|
72
|
+
if (better)
|
|
73
|
+
roleOf.set(key, { role, becomes: other });
|
|
74
|
+
}
|
|
75
|
+
if (roleOf.size === 0)
|
|
76
|
+
return [];
|
|
77
|
+
const out = [];
|
|
78
|
+
const components = (doc.components ?? {});
|
|
79
|
+
for (const [name, raw] of Object.entries(components)) {
|
|
80
|
+
const recipe = (raw ?? {});
|
|
81
|
+
const found = [
|
|
82
|
+
...colorBindings(recipe.base, "base"),
|
|
83
|
+
...colorBindings(recipe.states, "a state"),
|
|
84
|
+
...colorBindings(recipe.variants, "a variant"),
|
|
85
|
+
...colorBindings(recipe.parts, "a part"),
|
|
86
|
+
];
|
|
87
|
+
for (const b of found) {
|
|
88
|
+
const m = PRIMITIVE.exec(b.value);
|
|
89
|
+
if (!m)
|
|
90
|
+
continue;
|
|
91
|
+
const hit = roleOf.get(`${m[1]}.${m[2]}`.toLowerCase());
|
|
92
|
+
if (!hit)
|
|
93
|
+
continue;
|
|
94
|
+
out.push({
|
|
95
|
+
component: name,
|
|
96
|
+
where: `${b.where} · ${b.prop}`,
|
|
97
|
+
wrote: b.value,
|
|
98
|
+
role: hit.role,
|
|
99
|
+
becomes: hit.becomes,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
package/dist/doctor/scan.js
CHANGED
|
@@ -40,6 +40,21 @@ const SPACING = /(?:\b[pmg](?:[trblxy])?-\[|gap-\[|(?:padding|margin|gap)\s*:\s*
|
|
|
40
40
|
const FONT = /font-family\s*:\s*([^;}\n]+)/g;
|
|
41
41
|
/** Uses of the system. Coverage is meaningless without them. */
|
|
42
42
|
const TOKEN_USE = /var\(\s*--ds-[a-z0-9-]+/gi;
|
|
43
|
+
/**
|
|
44
|
+
* `var(--ds-color-semantic-primary, #5266eb)` - the literal is the TOKEN'S OWN
|
|
45
|
+
* fallback, written for safety, and reporting it as drift told an author to
|
|
46
|
+
* tokenize something they had already tokenized (investidorez, 25/07). The
|
|
47
|
+
* spans below are the fallback arguments on a line.
|
|
48
|
+
*/
|
|
49
|
+
const VAR_FALLBACK = /var\(\s*--ds-[a-z0-9-]+\s*,([^()]*)\)/gi;
|
|
50
|
+
function fallbackSpans(line) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const m of line.matchAll(VAR_FALLBACK)) {
|
|
53
|
+
const start = (m.index ?? 0) + m[0].indexOf(",") + 1;
|
|
54
|
+
out.push([start, start + m[1].length]);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
43
58
|
/** Under this, a radius or spacing value is idiom rather than a decision. */
|
|
44
59
|
const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
|
|
45
60
|
export function scanSource(file, source, table) {
|
|
@@ -77,10 +92,18 @@ export function scanSource(file, source, table) {
|
|
|
77
92
|
// written by hand - flagging it would be telling someone to tokenize a
|
|
78
93
|
// variable. And the same literal twice in one declaration (a two-stop
|
|
79
94
|
// shadow) is one decision, so it is reported once.
|
|
95
|
+
const spans = fallbackSpans(line);
|
|
96
|
+
const inFallback = (at) => spans.some(([a, b]) => at >= a && at <= b);
|
|
80
97
|
const seen = new Set();
|
|
81
|
-
|
|
98
|
+
// NOT named `at`: that is the line number in this scope, and shadowing it
|
|
99
|
+
// put column positions into the report as line numbers.
|
|
100
|
+
const push = (kind, literal, col = -1) => {
|
|
82
101
|
if (literal.includes("$") || literal.includes("{"))
|
|
83
102
|
return;
|
|
103
|
+
if (col >= 0 && inFallback(col)) {
|
|
104
|
+
aside++;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
84
107
|
const key = `${kind}:${literal}`;
|
|
85
108
|
if (seen.has(key))
|
|
86
109
|
return;
|
|
@@ -103,17 +126,17 @@ export function scanSource(file, source, table) {
|
|
|
103
126
|
aside++;
|
|
104
127
|
continue;
|
|
105
128
|
}
|
|
106
|
-
push("color", m[0]);
|
|
129
|
+
push("color", m[0], m.index ?? -1);
|
|
107
130
|
}
|
|
108
131
|
for (const m of line.matchAll(RADIUS)) {
|
|
109
132
|
const value = `${m[1]}${m[2]}`;
|
|
110
133
|
if (!IDIOM.has(value))
|
|
111
|
-
push("radius", value);
|
|
134
|
+
push("radius", value, m.index ?? -1);
|
|
112
135
|
}
|
|
113
136
|
for (const m of line.matchAll(SPACING)) {
|
|
114
137
|
const value = `${m[1]}${m[2]}`;
|
|
115
138
|
if (!IDIOM.has(value))
|
|
116
|
-
push("spacing", value);
|
|
139
|
+
push("spacing", value, m.index ?? -1);
|
|
117
140
|
}
|
|
118
141
|
for (const m of line.matchAll(FONT)) {
|
|
119
142
|
const stack = m[1].trim();
|
|
@@ -128,7 +151,12 @@ export function scanSource(file, source, table) {
|
|
|
128
151
|
findings,
|
|
129
152
|
tokenUses,
|
|
130
153
|
...(aside > 0
|
|
131
|
-
? {
|
|
154
|
+
? {
|
|
155
|
+
setAside: {
|
|
156
|
+
reason: "SVG artwork or a token's own fallback",
|
|
157
|
+
count: aside,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
132
160
|
: null),
|
|
133
161
|
};
|
|
134
162
|
}
|
package/package.json
CHANGED