synthesisui 0.8.1 → 0.9.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 -7
- package/dist/doctor/self-conflict.js +138 -0
- package/dist/doctor/tokens.js +74 -6
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -2,7 +2,8 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join, relative, resolve } from "node:path";
|
|
3
3
|
import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
|
|
4
4
|
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
5
|
-
import {
|
|
5
|
+
import { findSelfConflicts } from "../doctor/self-conflict.js";
|
|
6
|
+
import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
|
|
6
7
|
import { body, section, snippet } from "../output.js";
|
|
7
8
|
/**
|
|
8
9
|
* `synthesisui doctor` - the check nobody else ships.
|
|
@@ -83,13 +84,14 @@ async function loadSystem(root) {
|
|
|
83
84
|
.map((e) => e.name);
|
|
84
85
|
}
|
|
85
86
|
catch {
|
|
86
|
-
return { table: EMPTY_TABLE, recipes: new Map() };
|
|
87
|
+
return { table: EMPTY_TABLE, recipes: new Map(), documents: [] };
|
|
87
88
|
}
|
|
88
89
|
// Several systems can live side by side; every token is prefixed --ds-, so
|
|
89
90
|
// reading them all is both correct and what the running app actually sees.
|
|
90
91
|
let css = "";
|
|
91
92
|
let lock = null;
|
|
92
93
|
const recipes = new Map();
|
|
94
|
+
const documents = [];
|
|
93
95
|
for (const slug of slugs) {
|
|
94
96
|
const dir = join(dsDir, slug);
|
|
95
97
|
const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
|
|
@@ -120,7 +122,9 @@ async function loadSystem(root) {
|
|
|
120
122
|
: "";
|
|
121
123
|
if (docRaw) {
|
|
122
124
|
try {
|
|
123
|
-
|
|
125
|
+
const parsed = JSON.parse(docRaw);
|
|
126
|
+
documents.push(parsed);
|
|
127
|
+
for (const [k, v] of bindingsFromDocument(parsed)) {
|
|
124
128
|
if (!recipes.has(k))
|
|
125
129
|
recipes.set(k, v);
|
|
126
130
|
}
|
|
@@ -130,7 +134,7 @@ async function loadSystem(root) {
|
|
|
130
134
|
}
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
|
-
return { table: buildTable({ css, lock }), recipes };
|
|
137
|
+
return { table: buildTable({ css, lock }), recipes, documents };
|
|
134
138
|
}
|
|
135
139
|
const KIND_LABEL = {
|
|
136
140
|
color: "colour",
|
|
@@ -194,7 +198,7 @@ function verdict(d, hasSystem, overruled) {
|
|
|
194
198
|
}
|
|
195
199
|
export async function doctor(opts) {
|
|
196
200
|
const root = resolve(opts.dir ?? process.cwd());
|
|
197
|
-
const { table, recipes } = await loadSystem(root);
|
|
201
|
+
const { table, recipes, documents } = await loadSystem(root);
|
|
198
202
|
const hasSystem = table.byName.size > 0;
|
|
199
203
|
const scopes = (opts.scopes ?? []).map((s) => resolve(root, s));
|
|
200
204
|
const reports = [];
|
|
@@ -219,6 +223,15 @@ export async function doctor(opts) {
|
|
|
219
223
|
console.log(`\nNothing to read in ${root}.\n`);
|
|
220
224
|
return;
|
|
221
225
|
}
|
|
226
|
+
// The same line said twice, two different ways: `#fff` on an avatar came
|
|
227
|
+
// back under Drift as "no token holds this value yet" AND under Overruled as
|
|
228
|
+
// "the recipe binds {color.navy.50}". The override is strictly the better
|
|
229
|
+
// sentence - it names the component and what it promised - so the generic
|
|
230
|
+
// one steps aside (investidorez, 25/07).
|
|
231
|
+
const explained = new Set(overrides.map((o) => `${o.file}:${o.line}:${o.wrote.toLowerCase()}`));
|
|
232
|
+
for (const r of reports) {
|
|
233
|
+
r.findings = r.findings.filter((f) => !explained.has(`${r.file}:${f.line}:${f.literal.toLowerCase()}`));
|
|
234
|
+
}
|
|
222
235
|
const d = diagnose(reports);
|
|
223
236
|
console.log(section("Doctor"));
|
|
224
237
|
console.log(body(hasSystem
|
|
@@ -263,7 +276,12 @@ export async function doctor(opts) {
|
|
|
263
276
|
const w = Math.max(...repeats.map((r) => r.literal.length));
|
|
264
277
|
for (const r of repeats) {
|
|
265
278
|
const where = `${r.count}\u00d7 in ${r.files} file${r.files === 1 ? "" : "s"}`;
|
|
266
|
-
const
|
|
279
|
+
const near = r.token ? null : nearestToken(table, r.literal);
|
|
280
|
+
const named = r.token
|
|
281
|
+
? ` → ${r.token}`
|
|
282
|
+
: near
|
|
283
|
+
? ` → nearest is ${near.name} (${near.value})`
|
|
284
|
+
: "";
|
|
267
285
|
console.log(` ${r.literal.padEnd(w)} ${where}${named}`);
|
|
268
286
|
}
|
|
269
287
|
}
|
|
@@ -276,9 +294,14 @@ export async function doctor(opts) {
|
|
|
276
294
|
console.log(body(`${f.file}`));
|
|
277
295
|
const shown = opts.all ? f.findings : f.findings.slice(0, 3);
|
|
278
296
|
for (const x of shown) {
|
|
297
|
+
// A dead end with a neighbour is not a dead end. Only for lengths -
|
|
298
|
+
// "nearly the same blue" is the guess this tool must never make.
|
|
299
|
+
const near = x.token ? null : nearestToken(table, x.literal);
|
|
279
300
|
const named = x.token
|
|
280
301
|
? `→ ${x.token}`
|
|
281
|
-
:
|
|
302
|
+
: near
|
|
303
|
+
? `→ nearest is ${near.name} (${near.value})`
|
|
304
|
+
: "→ no token holds this value yet";
|
|
282
305
|
console.log(` ${String(x.line).padStart(4)} ${x.literal} ${named}`);
|
|
283
306
|
}
|
|
284
307
|
if (f.findings.length > shown.length) {
|
|
@@ -328,6 +351,27 @@ export async function doctor(opts) {
|
|
|
328
351
|
// The system's own words, for the components this project actually uses.
|
|
329
352
|
// Prose, so nothing verifies it - the value is putting it in front of
|
|
330
353
|
// whoever is touching the component, which no other tool is positioned to
|
|
354
|
+
// Every other section asks whether the code obeys the system. This one asks
|
|
355
|
+
// whether the system obeys itself, and it is only here because the tool once
|
|
356
|
+
// reported a consumer for overruling a recipe while they were obeying the
|
|
357
|
+
// law written on that same component.
|
|
358
|
+
const conflicts = documents.flatMap((doc) => findSelfConflicts(doc));
|
|
359
|
+
const conflictsInUse = conflicts.filter((c) => used.has(c.component));
|
|
360
|
+
if (conflictsInUse.length > 0) {
|
|
361
|
+
console.log(section("Your system contradicts itself"));
|
|
362
|
+
console.log(body(conflictsInUse.length === 1
|
|
363
|
+
? "One component says one thing in prose and another in its recipe."
|
|
364
|
+
: `${conflictsInUse.length} components say one thing in prose and another in their recipe.`));
|
|
365
|
+
console.log("");
|
|
366
|
+
for (const c of conflictsInUse) {
|
|
367
|
+
console.log(body(`ds-${c.component}`));
|
|
368
|
+
console.log(` "${c.law}"`);
|
|
369
|
+
console.log(` but ${c.where} binds ${c.value}`);
|
|
370
|
+
console.log("");
|
|
371
|
+
}
|
|
372
|
+
console.log(body("Whoever wrote the law and whoever wrote the recipe disagree."));
|
|
373
|
+
console.log(body("Until they do not, no code here can be correct."));
|
|
374
|
+
}
|
|
331
375
|
// do because no other tool knows these laws exist.
|
|
332
376
|
const inUse = [...used.entries()]
|
|
333
377
|
.filter(([name]) => (recipes.get(name)?.usage.length ?? 0) > 0)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOCTOR · the system against itself.
|
|
3
|
+
*
|
|
4
|
+
* Every other check asks whether the CODE obeys the system. This one asks
|
|
5
|
+
* whether the system obeys ITSELF - whether a component's recipe contradicts
|
|
6
|
+
* the law written on that same component.
|
|
7
|
+
*
|
|
8
|
+
* It exists because of a real report. Vesper says of its card:
|
|
9
|
+
*
|
|
10
|
+
* "Cards are ledger pages: hairline borders, dusk surfaces, no drop shadows."
|
|
11
|
+
*
|
|
12
|
+
* and its recipe binds `boxShadow: {shadow.md}` on hover. A consumer wrote
|
|
13
|
+
* `box-shadow: none` over it, with a comment explaining why - and the doctor
|
|
14
|
+
* reported THEM as the one overruling the system. They were obeying the prose
|
|
15
|
+
* and contradicting the machine, and nothing in the tool could see it
|
|
16
|
+
* (investidorez, 25/07).
|
|
17
|
+
*
|
|
18
|
+
* Prose is not verifiable in general, and this does not try. It reads one
|
|
19
|
+
* narrow shape - a law that FORBIDS something the recipe then binds - and says
|
|
20
|
+
* nothing at all when it is unsure. A false accusation here is worse than
|
|
21
|
+
* silence, because the thing being accused is the system's own author.
|
|
22
|
+
*/
|
|
23
|
+
/** A property the recipe can bind, and the words a law uses to forbid it. */
|
|
24
|
+
const FORBIDDABLE = [
|
|
25
|
+
{ prop: /^box-shadow$/, words: ["shadow", "shadows"], label: "box-shadow" },
|
|
26
|
+
{ prop: /^border(-width)?$/, words: ["border", "borders"], label: "border" },
|
|
27
|
+
{
|
|
28
|
+
prop: /^border-radius$/,
|
|
29
|
+
words: ["radius", "rounding", "rounded corners"],
|
|
30
|
+
label: "border-radius",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
prop: /^text-transform$/,
|
|
34
|
+
words: ["uppercase", "all caps", "caps"],
|
|
35
|
+
label: "text-transform",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
prop: /^background(-image)?$/,
|
|
39
|
+
words: ["gradient", "gradients"],
|
|
40
|
+
label: "gradient",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
prop: /^(transform|animation)$/,
|
|
44
|
+
words: ["animation", "animations", "movement", "motion"],
|
|
45
|
+
label: "motion",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
prop: /^letter-spacing$/,
|
|
49
|
+
words: ["tracking", "letter-spacing"],
|
|
50
|
+
label: "letter-spacing",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* The negations a designer actually writes. Anchored to the word so "no drop
|
|
55
|
+
* shadows" hits and "no more than one shadow" does not - that is a rule about
|
|
56
|
+
* quantity, and this lens does not count.
|
|
57
|
+
*/
|
|
58
|
+
const NEGATIONS = ["no", "never", "without", "avoid"];
|
|
59
|
+
const isSet = (v) => {
|
|
60
|
+
const t = v.trim().toLowerCase();
|
|
61
|
+
return (t !== "" && t !== "none" && t !== "0" && t !== "unset" && t !== "initial");
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Does this law forbid `label`? True only when a negation sits within a few
|
|
65
|
+
* words of the term - "no drop shadows" yes, "shadows are allowed when nothing
|
|
66
|
+
* else separates two surfaces" no.
|
|
67
|
+
*/
|
|
68
|
+
const QUANTITY = /\b(more|less|fewer|than|most|one|two|three|\d)\b/;
|
|
69
|
+
function forbids(law, words) {
|
|
70
|
+
const text = law.toLowerCase();
|
|
71
|
+
for (const word of words) {
|
|
72
|
+
for (const neg of NEGATIONS) {
|
|
73
|
+
const re = new RegExp(`\\b${neg}\\b([^.;,]{0,24}?)\\b${word}\\b`);
|
|
74
|
+
const m = re.exec(text);
|
|
75
|
+
// "never MORE THAN ONE shadow" is a rule about how many, not a ban. This
|
|
76
|
+
// lens does not count, so it says nothing.
|
|
77
|
+
if (m && !QUANTITY.test(m[1]))
|
|
78
|
+
return word;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** Walk a recipe block, yielding every property it actually sets. */
|
|
84
|
+
function* declarations(node, where) {
|
|
85
|
+
if (!node || typeof node !== "object")
|
|
86
|
+
return;
|
|
87
|
+
for (const [k, v] of Object.entries(node)) {
|
|
88
|
+
if (typeof v === "string" || typeof v === "number") {
|
|
89
|
+
const prop = k.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
90
|
+
yield { prop, value: String(v), where };
|
|
91
|
+
}
|
|
92
|
+
else if (v && typeof v === "object") {
|
|
93
|
+
yield* declarations(v, where);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Every place a component's recipe does what its own law forbids.
|
|
99
|
+
*/
|
|
100
|
+
export function findSelfConflicts(document) {
|
|
101
|
+
const out = [];
|
|
102
|
+
const doc = (document ?? {});
|
|
103
|
+
const components = (doc.components ?? {});
|
|
104
|
+
for (const [name, raw] of Object.entries(components)) {
|
|
105
|
+
const recipe = (raw ?? {});
|
|
106
|
+
const laws = Array.isArray(recipe.usage)
|
|
107
|
+
? recipe.usage.filter((x) => typeof x === "string")
|
|
108
|
+
: [];
|
|
109
|
+
if (laws.length === 0)
|
|
110
|
+
continue;
|
|
111
|
+
const decls = [
|
|
112
|
+
...declarations(recipe.base, "base"),
|
|
113
|
+
...declarations(recipe.states, "a state"),
|
|
114
|
+
...declarations(recipe.variants, "a variant"),
|
|
115
|
+
...declarations(recipe.parts, "a part"),
|
|
116
|
+
];
|
|
117
|
+
for (const law of laws) {
|
|
118
|
+
for (const rule of FORBIDDABLE) {
|
|
119
|
+
const word = forbids(law, rule.words);
|
|
120
|
+
if (!word)
|
|
121
|
+
continue;
|
|
122
|
+
for (const d of decls) {
|
|
123
|
+
if (!rule.prop.test(d.prop) || !isSet(d.value))
|
|
124
|
+
continue;
|
|
125
|
+
out.push({
|
|
126
|
+
component: name,
|
|
127
|
+
law,
|
|
128
|
+
forbids: word,
|
|
129
|
+
where: `${d.where} · ${d.prop}`,
|
|
130
|
+
value: d.value,
|
|
131
|
+
});
|
|
132
|
+
break; // one report per law per property family
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -18,17 +18,49 @@ export const EMPTY_TABLE = {
|
|
|
18
18
|
byName: new Map(),
|
|
19
19
|
byValue: new Map(),
|
|
20
20
|
};
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n)))
|
|
22
|
+
.toString(16)
|
|
23
|
+
.padStart(2, "0");
|
|
24
|
+
/**
|
|
25
|
+
* One key per colour, whatever dialect it was written in.
|
|
26
|
+
*
|
|
27
|
+
* Every colour lands on 8-digit hex, so `#FFF`, `#ffffff`, `rgb(255 255 255)`
|
|
28
|
+
* and `rgba(255,255,255,1)` are one value - and a translucent literal finds a
|
|
29
|
+
* translucent token: Vesper's scrim is authored `#09090bb3`, and an author
|
|
30
|
+
* writing `rgba(9, 9, 11, 0.7)` was being told no token held it.
|
|
31
|
+
*
|
|
32
|
+
* Anything that is not a colour passes through lowercased and collapsed.
|
|
33
|
+
*/
|
|
23
34
|
export function normalizeValue(raw) {
|
|
24
35
|
const v = raw.trim().toLowerCase().replace(/\s+/g, " ");
|
|
25
|
-
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])
|
|
26
|
-
if (short)
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/.exec(v);
|
|
37
|
+
if (short) {
|
|
38
|
+
const d = (c) => c + c;
|
|
39
|
+
return `#${d(short[1])}${d(short[2])}${d(short[3])}${short[4] ? d(short[4]) : "ff"}`;
|
|
40
|
+
}
|
|
41
|
+
const long = /^#([0-9a-f]{6})([0-9a-f]{2})?$/.exec(v);
|
|
42
|
+
if (long)
|
|
43
|
+
return `#${long[1]}${long[2] ?? "ff"}`;
|
|
29
44
|
const rgb = /^rgba?\(([^)]+)\)$/.exec(v);
|
|
30
45
|
if (rgb) {
|
|
31
46
|
const parts = rgb[1].split(/[\s,/]+/).filter(Boolean);
|
|
47
|
+
if (parts.length >= 3 &&
|
|
48
|
+
parts.slice(0, 3).every((p) => /^[\d.]+%?$/.test(p))) {
|
|
49
|
+
const ch = parts
|
|
50
|
+
.slice(0, 3)
|
|
51
|
+
.map((p) => p.endsWith("%") ? (Number.parseFloat(p) / 100) * 255 : Number(p));
|
|
52
|
+
const rawA = parts[3];
|
|
53
|
+
const a = rawA === undefined
|
|
54
|
+
? 1
|
|
55
|
+
: rawA.endsWith("%")
|
|
56
|
+
? Number.parseFloat(rawA) / 100
|
|
57
|
+
: Number(rawA);
|
|
58
|
+
if (ch.every((c) => Number.isFinite(c)) && Number.isFinite(a)) {
|
|
59
|
+
return `#${ch.map(hex2).join("")}${hex2(a * 255)}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// a colour we cannot read (a variable inside, a colour space) still gets a
|
|
63
|
+
// stable key, just not a comparable one
|
|
32
64
|
return `rgb(${parts.join(" ")})`;
|
|
33
65
|
}
|
|
34
66
|
return v;
|
|
@@ -82,6 +114,42 @@ export function buildTable(input) {
|
|
|
82
114
|
* a diagnosis nobody can trust is worse than no diagnosis. Near misses are
|
|
83
115
|
* reported as drift, not as a fix.
|
|
84
116
|
*/
|
|
117
|
+
/**
|
|
118
|
+
* The token nearest a length the system has no exact name for.
|
|
119
|
+
*
|
|
120
|
+
* `60px` appeared three times across three files on a real project, and the
|
|
121
|
+
* report could only say "no token holds this value yet" - a dead end. The
|
|
122
|
+
* nearest step is `--ds-spacing-2xl` at 64px: four pixels away, and almost
|
|
123
|
+
* certainly what the author meant. Naming it turns a dead end into a decision.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately narrow: same unit, within 25% or 8px, and never for a colour -
|
|
126
|
+
* "nearly the same blue" is exactly the guess this tool must not make.
|
|
127
|
+
*/
|
|
128
|
+
export function nearestToken(table, literal) {
|
|
129
|
+
const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(literal.trim().toLowerCase());
|
|
130
|
+
if (!m)
|
|
131
|
+
return null;
|
|
132
|
+
const n = Number(m[1]);
|
|
133
|
+
const unit = m[2];
|
|
134
|
+
if (!Number.isFinite(n) || n === 0)
|
|
135
|
+
return null;
|
|
136
|
+
let best = null;
|
|
137
|
+
for (const [name, raw] of table.byName) {
|
|
138
|
+
const t = /^(-?\d*\.?\d+)(px|rem)$/.exec(raw.trim().toLowerCase());
|
|
139
|
+
if (!t || t[2] !== unit)
|
|
140
|
+
continue;
|
|
141
|
+
const v = Number(t[1]);
|
|
142
|
+
if (!Number.isFinite(v) || v === n)
|
|
143
|
+
continue;
|
|
144
|
+
const delta = Math.abs(v - n);
|
|
145
|
+
if (best === null || delta < best.delta)
|
|
146
|
+
best = { name, value: raw, delta };
|
|
147
|
+
}
|
|
148
|
+
if (!best)
|
|
149
|
+
return null;
|
|
150
|
+
const limit = unit === "rem" ? Math.max(n * 0.25, 0.5) : Math.max(n * 0.25, 8);
|
|
151
|
+
return best.delta <= limit ? best : null;
|
|
152
|
+
}
|
|
85
153
|
export function tokenFor(table, literal) {
|
|
86
154
|
const hit = table.byValue.get(normalizeValue(literal));
|
|
87
155
|
if (!hit || hit.length === 0)
|
package/package.json
CHANGED