synthesisui 0.8.0 → 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 +56 -7
- package/dist/doctor/overrides.js +106 -18
- 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) {
|
|
@@ -308,8 +331,13 @@ export async function doctor(opts) {
|
|
|
308
331
|
console.log(body(file));
|
|
309
332
|
for (const o of opts.all ? list : list.slice(0, 3)) {
|
|
310
333
|
console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
|
|
334
|
+
// Always say what is being overruled. A finding that cannot name it is
|
|
335
|
+
// indistinguishable from a bug in the reader's eyes, and one of these
|
|
336
|
+
// (`.ds-card:hover { transform: none }`) was a real, deliberate call.
|
|
311
337
|
if (o.recipe)
|
|
312
338
|
console.log(` the recipe binds ${o.recipe}`);
|
|
339
|
+
else if (o.where)
|
|
340
|
+
console.log(` the recipe binds it ${o.where}`);
|
|
313
341
|
}
|
|
314
342
|
if (!opts.all && list.length > 3) {
|
|
315
343
|
console.log(` +${list.length - 3} more`);
|
|
@@ -323,6 +351,27 @@ export async function doctor(opts) {
|
|
|
323
351
|
// The system's own words, for the components this project actually uses.
|
|
324
352
|
// Prose, so nothing verifies it - the value is putting it in front of
|
|
325
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
|
+
}
|
|
326
375
|
// do because no other tool knows these laws exist.
|
|
327
376
|
const inUse = [...used.entries()]
|
|
328
377
|
.filter(([name]) => (recipes.get(name)?.usage.length ?? 0) > 0)
|
package/dist/doctor/overrides.js
CHANGED
|
@@ -46,10 +46,37 @@ export function bindingsOf(recipe) {
|
|
|
46
46
|
collect(r.variants, false);
|
|
47
47
|
collect(r.states, false);
|
|
48
48
|
collect(r.parts, false);
|
|
49
|
+
// Where a non-base property lives, so the report can name it. Base wins: if
|
|
50
|
+
// base binds it too, that value is the one worth showing.
|
|
51
|
+
const boundIn = new Map();
|
|
52
|
+
const note = (block, label) => {
|
|
53
|
+
if (!block || typeof block !== "object")
|
|
54
|
+
return;
|
|
55
|
+
for (const [k, v] of Object.entries(block)) {
|
|
56
|
+
if (typeof v === "string" || typeof v === "number") {
|
|
57
|
+
const p = kebab(k);
|
|
58
|
+
if (!baseValues.has(p) && !boundIn.has(p))
|
|
59
|
+
boundIn.set(p, `${label}: ${v}`);
|
|
60
|
+
}
|
|
61
|
+
else if (v && typeof v === "object") {
|
|
62
|
+
note(v, label);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (const [state, block] of Object.entries((r.states ?? {}))) {
|
|
67
|
+
note(block, `on ${state}`);
|
|
68
|
+
}
|
|
69
|
+
for (const [axis, opts] of Object.entries((r.variants ?? {}))) {
|
|
70
|
+
if (!opts || typeof opts !== "object")
|
|
71
|
+
continue;
|
|
72
|
+
for (const [opt, block] of Object.entries(opts)) {
|
|
73
|
+
note(block, `on ${axis}="${opt}"`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
49
76
|
const usage = Array.isArray(r.usage)
|
|
50
77
|
? r.usage.filter((x) => typeof x === "string")
|
|
51
78
|
: [];
|
|
52
|
-
return { props, baseValues, usage };
|
|
79
|
+
return { props, baseValues, boundIn, usage };
|
|
53
80
|
}
|
|
54
81
|
/**
|
|
55
82
|
* How many places each known component is used. An inventory nobody has today:
|
|
@@ -117,6 +144,55 @@ function enclosingTag(src, at) {
|
|
|
117
144
|
return null;
|
|
118
145
|
}
|
|
119
146
|
const lineAt = (src, index) => src.slice(0, index).split("\n").length;
|
|
147
|
+
/**
|
|
148
|
+
* Whatever the `style` prop was given, with where it starts. The expression
|
|
149
|
+
* container, not `{{` specifically: a real component writes
|
|
150
|
+
* `style={showImg ? undefined : { color: "#fff" }}`, and requiring the double
|
|
151
|
+
* brace dropped that finding on the floor. Brace matching rather than a regex,
|
|
152
|
+
* because the value nests.
|
|
153
|
+
*/
|
|
154
|
+
function styleRegions(tag) {
|
|
155
|
+
const out = [];
|
|
156
|
+
const open = /style\s*=\s*\{/g;
|
|
157
|
+
let m = open.exec(tag);
|
|
158
|
+
while (m !== null) {
|
|
159
|
+
const start = m.index + m[0].length;
|
|
160
|
+
let depth = 1; // the container brace just consumed
|
|
161
|
+
let end = start;
|
|
162
|
+
for (; end < tag.length; end++) {
|
|
163
|
+
if (tag[end] === "{")
|
|
164
|
+
depth++;
|
|
165
|
+
else if (tag[end] === "}" && --depth === 0)
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
// Only the OBJECT LITERALS inside the container. Scanning the container
|
|
169
|
+
// whole let a one-line ternary eat the declaration: in
|
|
170
|
+
// `style={on ? undefined : { padding: "7px" }}` the colon after `undefined`
|
|
171
|
+
// reads as a property separator and swallows the padding behind it.
|
|
172
|
+
const container = tag.slice(start, end);
|
|
173
|
+
let d = 0;
|
|
174
|
+
let objStart = -1;
|
|
175
|
+
for (let i = 0; i < container.length; i++) {
|
|
176
|
+
if (container[i] === "{") {
|
|
177
|
+
if (d === 0)
|
|
178
|
+
objStart = i + 1;
|
|
179
|
+
d++;
|
|
180
|
+
}
|
|
181
|
+
else if (container[i] === "}" && d > 0 && --d === 0) {
|
|
182
|
+
out.push({
|
|
183
|
+
text: container.slice(objStart, i),
|
|
184
|
+
offset: start + objStart,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// `style={{ … }}` - the container IS the object
|
|
189
|
+
if (objStart === -1)
|
|
190
|
+
out.push({ text: container, offset: start });
|
|
191
|
+
open.lastIndex = end;
|
|
192
|
+
m = open.exec(tag);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
120
196
|
/**
|
|
121
197
|
* The literal a style property was given, or null when it was given an
|
|
122
198
|
* expression instead.
|
|
@@ -159,22 +235,29 @@ export function findOverrides(source, recipes) {
|
|
|
159
235
|
const tag = enclosingTag(source, m.index ?? 0);
|
|
160
236
|
if (!tag)
|
|
161
237
|
continue;
|
|
162
|
-
// inline style={{ borderRadius: 4 }}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
238
|
+
// inline style={{ borderRadius: 4 }} - and ONLY that. Reading the whole tag
|
|
239
|
+
// meant reading every object prop on it, so Framer Motion's
|
|
240
|
+
// `animate={{ opacity: 1 }}` came back as "ds-card overrules opacity"
|
|
241
|
+
// (investidorez, 25/07). An animation is not a design decision.
|
|
242
|
+
for (const region of styleRegions(tag.text)) {
|
|
243
|
+
for (const s of region.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
|
|
244
|
+
const prop = kebab(s[1]);
|
|
245
|
+
if (!bind.props.has(prop))
|
|
246
|
+
continue;
|
|
247
|
+
const wrote = literalValue(s[2]);
|
|
248
|
+
if (wrote === null)
|
|
249
|
+
continue;
|
|
250
|
+
const at = tag.start + region.offset + (s.index ?? 0);
|
|
251
|
+
record({
|
|
252
|
+
component: name,
|
|
253
|
+
prop,
|
|
254
|
+
wrote,
|
|
255
|
+
recipe: bind.baseValues.get(prop) ?? null,
|
|
256
|
+
where: bind.boundIn.get(prop) ?? null,
|
|
257
|
+
line: lineAt(source, at),
|
|
258
|
+
excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
178
261
|
}
|
|
179
262
|
// utility classes sitting beside the ds-* class
|
|
180
263
|
for (const cls of tag.text.matchAll(/[\w:[\]#().%/-]+/g)) {
|
|
@@ -186,6 +269,7 @@ export function findOverrides(source, recipes) {
|
|
|
186
269
|
prop,
|
|
187
270
|
wrote: cls[0],
|
|
188
271
|
recipe: bind.baseValues.get(prop) ?? null,
|
|
272
|
+
where: bind.boundIn.get(prop) ?? null,
|
|
189
273
|
line: lineAt(source, tag.start + (cls.index ?? 0)),
|
|
190
274
|
excerpt: clip(lines[lineAt(source, tag.start + (cls.index ?? 0)) - 1]?.trim() ?? ""),
|
|
191
275
|
});
|
|
@@ -193,7 +277,10 @@ export function findOverrides(source, recipes) {
|
|
|
193
277
|
}
|
|
194
278
|
// ── CSS: a rule whose selector reaches a ds-* component ───────────────────
|
|
195
279
|
for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
196
|
-
|
|
280
|
+
// A CSS comment is not a selector. `/* botões (.ds-button) … */` above an
|
|
281
|
+
// unrelated rule made the doctor attribute that rule to ds-button - it was
|
|
282
|
+
// reading prose as structure (investidorez, 25/07).
|
|
283
|
+
const selector = rule[1].replace(/\/\*[\s\S]*?\*\//g, "");
|
|
197
284
|
const hit = /\.ds-([a-z][a-z0-9-]*)\b/.exec(selector);
|
|
198
285
|
const bind = hit ? recipes.get(hit[1]) : undefined;
|
|
199
286
|
if (!hit || !bind)
|
|
@@ -211,6 +298,7 @@ export function findOverrides(source, recipes) {
|
|
|
211
298
|
prop,
|
|
212
299
|
wrote: decl[2].trim(),
|
|
213
300
|
recipe: bind.baseValues.get(prop) ?? null,
|
|
301
|
+
where: bind.boundIn.get(prop) ?? null,
|
|
214
302
|
line: lineAt(source, at),
|
|
215
303
|
excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
|
|
216
304
|
});
|
|
@@ -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