synthesisui 0.16.6 → 0.16.7
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/claude-md.js +61 -1
- package/dist/commands/doctor.js +40 -2
- package/dist/doctor/scan.js +109 -3
- package/dist/doctor/tokens.js +21 -0
- package/package.json +1 -1
package/dist/claude-md.js
CHANGED
|
@@ -106,6 +106,51 @@ component found in a repo-wide audit next week is an archaeology exercise.
|
|
|
106
106
|
|
|
107
107
|
If it reports a value with no token, do NOT invent one. Say which value and what
|
|
108
108
|
you would call it, and let a person decide.`;
|
|
109
|
+
/**
|
|
110
|
+
* THE INTERFACE LANGUAGE, READ FROM THE PROJECT RATHER THAN ASKED FOR.
|
|
111
|
+
*
|
|
112
|
+
* Prompted in Portuguese, an agent wrote a dashboard whose menu was English
|
|
113
|
+
* (the labels the person dictated), whose content was Portuguese, and whose
|
|
114
|
+
* root element still said `lang="en"` inherited from the landing page
|
|
115
|
+
* (my-test4, 27/07). Nothing in the contract had ever mentioned language, so
|
|
116
|
+
* it was not disobedience - nobody had read that attribute out loud to it.
|
|
117
|
+
*
|
|
118
|
+
* The cruelty is that careful accessibility makes it worse: a screen reader
|
|
119
|
+
* pronounces `aria-label="Sessões por canal"` with English phonemes. The most
|
|
120
|
+
* conscientious lines in the file are the ones that break hardest.
|
|
121
|
+
*
|
|
122
|
+
* This does NOT belong to the design system. One system serves many apps, and
|
|
123
|
+
* one of them will be Spanish - a locale baked into the tokens would break the
|
|
124
|
+
* first customer with two markets. The PROJECT owns its language, it already
|
|
125
|
+
* declares it in the layout, and this only reads it back.
|
|
126
|
+
*/
|
|
127
|
+
const HTML_LANG = /<html[^>]*\slang=(?:"([a-z-]+)"|'([a-z-]+)'|\{)/i;
|
|
128
|
+
const LAYOUTS = [
|
|
129
|
+
"app/layout.tsx",
|
|
130
|
+
"app/layout.jsx",
|
|
131
|
+
"src/app/layout.tsx",
|
|
132
|
+
"src/app/layout.jsx",
|
|
133
|
+
"pages/_document.tsx",
|
|
134
|
+
"src/pages/_document.tsx",
|
|
135
|
+
"index.html",
|
|
136
|
+
"public/index.html",
|
|
137
|
+
];
|
|
138
|
+
async function readInterfaceLanguage(projectRoot) {
|
|
139
|
+
for (const rel of LAYOUTS) {
|
|
140
|
+
const src = await readFile(join(projectRoot, rel), "utf8").catch(() => "");
|
|
141
|
+
if (!src)
|
|
142
|
+
continue;
|
|
143
|
+
const m = HTML_LANG.exec(src);
|
|
144
|
+
if (!m)
|
|
145
|
+
continue;
|
|
146
|
+
const lang = m[1] ?? m[2];
|
|
147
|
+
// `lang={locale}` - the project is internationalised, and the useful
|
|
148
|
+
// instruction is the opposite one: literal copy in JSX is the mistake,
|
|
149
|
+
// whatever language it is in.
|
|
150
|
+
return lang ? { lang } : { i18n: true };
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
109
154
|
async function renderRegion(projectRoot, installed) {
|
|
110
155
|
if (installed.length === 0) {
|
|
111
156
|
return `${START}\n${END}`;
|
|
@@ -149,9 +194,24 @@ Only write something new when nothing in the manifest covers the purpose - and w
|
|
|
149
194
|
say which entry you considered and why it did not fit. To review a
|
|
150
195
|
component, create an isolated sample page (e.g. \`app/synthesisui-samples/<component>/\`) - do not
|
|
151
196
|
apply it to real production pages unless asked.${SELF_CHECK}`;
|
|
197
|
+
const locale = await readInterfaceLanguage(projectRoot);
|
|
198
|
+
const language = locale === null
|
|
199
|
+
? ""
|
|
200
|
+
: "i18n" in locale
|
|
201
|
+
? `
|
|
202
|
+
|
|
203
|
+
**This project is internationalised** - its \`<html lang>\` is set from a variable. User-facing
|
|
204
|
+
copy belongs in the message catalogue, never written literally into JSX.`
|
|
205
|
+
: `
|
|
206
|
+
|
|
207
|
+
**This project's interface language is \`${locale.lang}\`**, from the \`lang\` attribute on its root
|
|
208
|
+
element. Write every user-facing string in that language - labels, empty states, \`aria-label\`,
|
|
209
|
+
\`alt\`. A screen reader pronounces \`aria-label\` using \`lang\`, so a mixed-language interface is
|
|
210
|
+
worse than an untranslated one. If the attribute is wrong, change it rather than writing against
|
|
211
|
+
it.`;
|
|
152
212
|
const body = `## Design Systems (via SynthesisUI)
|
|
153
213
|
|
|
154
|
-
This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}
|
|
214
|
+
This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}
|
|
155
215
|
|
|
156
216
|
${sections.join("\n")}
|
|
157
217
|
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readdir, readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join, relative, resolve } from "node:path";
|
|
3
3
|
import { findFrozenBindings } from "../doctor/frozen.js";
|
|
4
4
|
import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
|
|
5
|
-
import { diagnose, scanSource, } from "../doctor/scan.js";
|
|
5
|
+
import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
|
|
6
6
|
import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
|
|
7
7
|
import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
|
|
8
8
|
import { body, section, snippet } from "../output.js";
|
|
@@ -459,7 +459,45 @@ export async function doctor(opts) {
|
|
|
459
459
|
if (hasSystem && measurable) {
|
|
460
460
|
console.log("");
|
|
461
461
|
console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
|
|
462
|
-
console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
|
|
462
|
+
console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}`));
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Its own section, above drift, because it is a worse problem wearing a
|
|
466
|
+
* better disguise.
|
|
467
|
+
*
|
|
468
|
+
* Drift is a literal where a token belongs: the screen looks right and the
|
|
469
|
+
* system lost. A name the system does not declare resolves to NOTHING - no
|
|
470
|
+
* error, no console warning, the property simply does not apply - and it
|
|
471
|
+
* reads in review as the most obedient line in the file.
|
|
472
|
+
*/
|
|
473
|
+
const phantoms = new Map();
|
|
474
|
+
for (const f of d.files) {
|
|
475
|
+
for (const p of f.phantoms ?? []) {
|
|
476
|
+
const at = phantoms.get(p.name) ?? { files: new Set(), line: p.line };
|
|
477
|
+
at.files.add(f.file);
|
|
478
|
+
phantoms.set(p.name, at);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* `console.log`, not `say`. Everything else earns its place behind
|
|
483
|
+
* `--verbose` because the reader can already see it on screen; this is the
|
|
484
|
+
* one finding whose entire nature is that NOTHING is on screen. Hiding the
|
|
485
|
+
* invisible failure behind a flag is the same bug as not reporting it.
|
|
486
|
+
*/
|
|
487
|
+
if (phantoms.size > 0) {
|
|
488
|
+
console.log(section("Names your system does not have"));
|
|
489
|
+
console.log(body(`${plural(phantoms.size, "name")} written as tokens that your system never declares.`));
|
|
490
|
+
console.log(body("An undeclared custom property applies nothing at all."));
|
|
491
|
+
console.log("");
|
|
492
|
+
for (const [name, at] of [...phantoms].sort()) {
|
|
493
|
+
const where = [...at.files][0];
|
|
494
|
+
console.log(body(`${name} ${where}:${at.line}${at.files.size > 1 ? ` +${at.files.size - 1} more` : ""}`));
|
|
495
|
+
const kin = siblingTokens(name, table);
|
|
496
|
+
if (kin.length > 0)
|
|
497
|
+
console.log(snippet([`your system has ${kin.join(", ")}`]));
|
|
498
|
+
}
|
|
499
|
+
console.log("");
|
|
500
|
+
console.log(body("Add the name to the system, or use one it has. Do not leave it."));
|
|
463
501
|
}
|
|
464
502
|
if (d.findings.length > 0) {
|
|
465
503
|
say(section("Drift"));
|
package/dist/doctor/scan.js
CHANGED
|
@@ -49,16 +49,111 @@ const FONT = /font-family\s*:\s*([^;}\n]+)/g;
|
|
|
49
49
|
* seen it declared - which is exactly what the token table knows.
|
|
50
50
|
*/
|
|
51
51
|
const ANY_VAR_USE = /var\(\s*(--[a-z0-9_-]+)/gi;
|
|
52
|
+
/**
|
|
53
|
+
* `var(--ds-xp, 60%)` - a name with a fallback ALWAYS renders, so it is never
|
|
54
|
+
* a phantom. This is not a mistake pattern either: it is how a runtime knob is
|
|
55
|
+
* written, and our own generated components ship two of them, set inline by
|
|
56
|
+
* whoever uses the component. Flagging those would have accused the platform's
|
|
57
|
+
* own output on the first real run.
|
|
58
|
+
*/
|
|
59
|
+
const VAR_WITH_FALLBACK = /var\(\s*(--[a-z0-9_-]+)\s*,/gi;
|
|
52
60
|
const ANY_VAR_FALLBACK = /var\(\s*(--[a-z0-9_-]+)\s*,([^()]*)\)/gi;
|
|
53
61
|
const isKnownToken = (name, table) => name.startsWith("--ds-") || table.byName.has(name);
|
|
62
|
+
/**
|
|
63
|
+
* A NAME THAT RESOLVES TO NOTHING IS NOT COVERAGE.
|
|
64
|
+
*
|
|
65
|
+
* The prefix rule above is generous on purpose, and it had a hole underneath
|
|
66
|
+
* it: `var(--ds-color-series-99)` counted as a token use because it started
|
|
67
|
+
* with `--ds-`, whether or not the system declares it. Injected two invented
|
|
68
|
+
* names into a real project and the report said 100%, 20 from the system
|
|
69
|
+
* (my-test4, 27/07).
|
|
70
|
+
*
|
|
71
|
+
* That is the exact failure the whole product exists to prevent. An undeclared
|
|
72
|
+
* custom property is not a fallback to some default - it renders as NOTHING,
|
|
73
|
+
* with no error and no console warning. The managed block tells the agent not
|
|
74
|
+
* to invent tokens; until now the measurement could not tell whether it had
|
|
75
|
+
* obeyed.
|
|
76
|
+
*
|
|
77
|
+
* Only OUR prefix can be judged this way. `--acme-*` belongs to the project and
|
|
78
|
+
* we never see its full vocabulary, so an unknown one there means we did not
|
|
79
|
+
* read the file that declares it, not that it does not exist.
|
|
80
|
+
*/
|
|
81
|
+
const canJudgeDsNames = (table) => {
|
|
82
|
+
for (const name of table.declared)
|
|
83
|
+
if (name.startsWith("--ds-"))
|
|
84
|
+
return true;
|
|
85
|
+
// No `--ds-` in the table at all: an adopted system, a project with none, or
|
|
86
|
+
// a lock we failed to parse. Calling every reference a phantom there would
|
|
87
|
+
// turn one unreadable file into a report that condemns the whole repo.
|
|
88
|
+
return false;
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Names we decline to judge, because being wrong here is worse than being
|
|
92
|
+
* quiet. Each of these was a real line in a real project, not a hypothetical.
|
|
93
|
+
*/
|
|
94
|
+
const unjudgeable = (name, line) => {
|
|
95
|
+
// Cheap and exact: the same line, re-read for `var(<name>,`.
|
|
96
|
+
for (const m of line.matchAll(VAR_WITH_FALLBACK))
|
|
97
|
+
if (m[1].toLowerCase() === name)
|
|
98
|
+
return true;
|
|
99
|
+
return (
|
|
100
|
+
// `var(--ds-color-series-${i})` and `var(--ds-${kind}-500)` both capture
|
|
101
|
+
// up to the interpolation and stop. The name is assembled at runtime; the
|
|
102
|
+
// static text is not a claim about any one token.
|
|
103
|
+
name.endsWith("-") ||
|
|
104
|
+
// A comment that documents the pattern - `var(--ds-color-series-*)` - is
|
|
105
|
+
// prose, and accusing a comment of drift is the tool losing the reader.
|
|
106
|
+
line.startsWith("//") ||
|
|
107
|
+
line.startsWith("*") ||
|
|
108
|
+
line.startsWith("/*"));
|
|
109
|
+
};
|
|
54
110
|
function countTokenUses(line, table) {
|
|
55
111
|
let n = 0;
|
|
112
|
+
const judge = canJudgeDsNames(table);
|
|
56
113
|
for (const m of line.matchAll(ANY_VAR_USE)) {
|
|
57
|
-
|
|
114
|
+
const name = m[1].toLowerCase();
|
|
115
|
+
if (judge && name.startsWith("--ds-") && !table.declared.has(name)) {
|
|
116
|
+
if (!unjudgeable(name, line))
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (isKnownToken(name, table))
|
|
58
120
|
n++;
|
|
59
121
|
}
|
|
60
122
|
return n;
|
|
61
123
|
}
|
|
124
|
+
/** The undeclared `--ds-` names on a line, for the report. Same rules as the
|
|
125
|
+
* counter above, so a name is never both uncounted and unmentioned. */
|
|
126
|
+
function findPhantoms(line, table) {
|
|
127
|
+
if (!canJudgeDsNames(table))
|
|
128
|
+
return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const m of line.matchAll(ANY_VAR_USE)) {
|
|
131
|
+
const name = m[1].toLowerCase();
|
|
132
|
+
if (!name.startsWith("--ds-"))
|
|
133
|
+
continue;
|
|
134
|
+
if (table.declared.has(name) || unjudgeable(name, line))
|
|
135
|
+
continue;
|
|
136
|
+
out.push(name);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* What the author probably meant, by the longest prefix the system does hold.
|
|
142
|
+
*
|
|
143
|
+
* `--ds-color-series-99` against a system with series 1 to 5 should not answer
|
|
144
|
+
* "no idea" - the family is right there, and naming its members is the same
|
|
145
|
+
* move the drift report already makes for values.
|
|
146
|
+
*/
|
|
147
|
+
export function siblingTokens(name, table) {
|
|
148
|
+
const parts = name.split("-").filter(Boolean);
|
|
149
|
+
for (let take = parts.length - 1; take >= 3; take--) {
|
|
150
|
+
const prefix = `--${parts.slice(0, take).join("-")}-`;
|
|
151
|
+
const kin = [...table.declared].filter((k) => k.startsWith(prefix) && k !== name);
|
|
152
|
+
if (kin.length > 0)
|
|
153
|
+
return kin.sort().slice(0, 5);
|
|
154
|
+
}
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
62
157
|
/**
|
|
63
158
|
* `var(--ds-color-semantic-primary, #5266eb)` - the literal is the TOKEN'S OWN
|
|
64
159
|
* fallback, written for safety, and reporting it as drift told an author to
|
|
@@ -89,6 +184,7 @@ const DECLARES_TOKEN = /^\s*(--[a-z0-9_-]+)\s*:/i;
|
|
|
89
184
|
const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
|
|
90
185
|
export function scanSource(file, source, table) {
|
|
91
186
|
const findings = [];
|
|
187
|
+
const phantoms = [];
|
|
92
188
|
let tokenUses = 0;
|
|
93
189
|
// Reason by reason. Rolling two into "A or B" was the one place the report
|
|
94
190
|
// still lumped things it had told apart everywhere else.
|
|
@@ -112,6 +208,8 @@ export function scanSource(file, source, table) {
|
|
|
112
208
|
const line = raw.trim();
|
|
113
209
|
const at = i + 1;
|
|
114
210
|
tokenUses += countTokenUses(line, table);
|
|
211
|
+
for (const name of findPhantoms(line, table))
|
|
212
|
+
phantoms.push({ name, line: at });
|
|
115
213
|
// Depth at the START of this line, carried before the early return so a
|
|
116
214
|
// blank line inside an <svg> cannot close the region by accident. The
|
|
117
215
|
// per-match depth is recomputed below, because an icon is often written on
|
|
@@ -192,6 +290,7 @@ export function scanSource(file, source, table) {
|
|
|
192
290
|
file,
|
|
193
291
|
findings,
|
|
194
292
|
tokenUses,
|
|
293
|
+
...(phantoms.length > 0 ? { phantoms } : null),
|
|
195
294
|
...(aside.size > 0
|
|
196
295
|
? {
|
|
197
296
|
setAside: [...aside].map(([reason, count]) => ({ reason, count })),
|
|
@@ -210,7 +309,11 @@ export function diagnose(files) {
|
|
|
210
309
|
for (const f of flat)
|
|
211
310
|
counts[f.kind] += 1;
|
|
212
311
|
const tokenUses = files.reduce((n, f) => n + f.tokenUses, 0);
|
|
213
|
-
|
|
312
|
+
// A name that resolves to nothing did not come from the system, so it cannot
|
|
313
|
+
// sit outside the fraction. Reporting 100% directly above "2 names your
|
|
314
|
+
// system never declares" is the report contradicting itself in six lines.
|
|
315
|
+
const phantomUses = files.reduce((n, f) => n + (f.phantoms?.length ?? 0), 0);
|
|
316
|
+
const total = tokenUses + flat.length + phantomUses;
|
|
214
317
|
const byLiteral = new Map();
|
|
215
318
|
for (const f of flat) {
|
|
216
319
|
const key = `${f.kind}:${f.literal.toLowerCase()}`;
|
|
@@ -239,11 +342,14 @@ export function diagnose(files) {
|
|
|
239
342
|
.filter((r) => r.count > 1)
|
|
240
343
|
.sort((a, b) => b.count - a.count);
|
|
241
344
|
return {
|
|
242
|
-
|
|
345
|
+
// A file with a phantom name and no drift has nothing to say by the old
|
|
346
|
+
// measure and the worst thing to say by the new one. Both keep it.
|
|
347
|
+
files: files.filter((f) => f.findings.length > 0 || (f.phantoms?.length ?? 0) > 0),
|
|
243
348
|
findings: flat,
|
|
244
349
|
counts,
|
|
245
350
|
named: flat.filter((f) => f.token).length,
|
|
246
351
|
tokenUses,
|
|
352
|
+
phantomUses,
|
|
247
353
|
coverage: total === 0 ? 100 : Math.round((tokenUses / total) * 100),
|
|
248
354
|
scanned: files.length,
|
|
249
355
|
repeats,
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -18,6 +18,7 @@ export const EMPTY_TABLE = {
|
|
|
18
18
|
version: null,
|
|
19
19
|
byName: new Map(),
|
|
20
20
|
byValue: new Map(),
|
|
21
|
+
declared: new Set(),
|
|
21
22
|
};
|
|
22
23
|
const hex2 = (n) => Math.max(0, Math.min(255, Math.round(n)))
|
|
23
24
|
.toString(16)
|
|
@@ -166,6 +167,25 @@ export function parseTokens(css) {
|
|
|
166
167
|
}
|
|
167
168
|
return out;
|
|
168
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* EVERY name the stylesheet declares, aliases included.
|
|
172
|
+
*
|
|
173
|
+
* "What value does this hold" and "does this name exist" are different
|
|
174
|
+
* questions, and the table only answered the first. `parseTokens` drops
|
|
175
|
+
* aliases on purpose - you cannot match a hardcoded colour against
|
|
176
|
+
* `var(--ds-color-blue-500)` - so fifteen perfectly real tokens, the whole
|
|
177
|
+
* `--ds-color-series-*` family among them, were absent from `byName` while
|
|
178
|
+
* being declared two lines apart in the same file.
|
|
179
|
+
*
|
|
180
|
+
* Harmless while nothing asked about existence. The moment the phantom check
|
|
181
|
+
* did, it called them invented (caught before shipping, 27/07).
|
|
182
|
+
*/
|
|
183
|
+
export function parseDeclaredNames(css) {
|
|
184
|
+
const out = new Set();
|
|
185
|
+
for (const m of css.matchAll(/(--[a-z0-9_-]+)\s*:\s*[^;}]+[;}]/gi))
|
|
186
|
+
out.add(m[1].toLowerCase());
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
169
189
|
/**
|
|
170
190
|
* Custom properties the project declares AT THE ROOT, whatever it calls them.
|
|
171
191
|
*
|
|
@@ -276,6 +296,7 @@ export function buildTable(input) {
|
|
|
276
296
|
version: input.lock?.version ?? null,
|
|
277
297
|
byName,
|
|
278
298
|
byValue,
|
|
299
|
+
declared: parseDeclaredNames(input.css),
|
|
279
300
|
};
|
|
280
301
|
}
|
|
281
302
|
/**
|
package/package.json
CHANGED