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 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
 
@@ -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"));
@@ -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
- if (isKnownToken(m[1].toLowerCase(), table))
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
- const total = tokenUses + flat.length;
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
- files: files.filter((f) => f.findings.length > 0),
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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.6",
3
+ "version": "0.16.7",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {