synthesisui 0.16.6 → 0.16.8

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";
@@ -409,7 +409,12 @@ export async function doctor(opts) {
409
409
  }
410
410
  }
411
411
  else if (asideTotal > 0) {
412
- console.log(body(`set aside ${plural(asideTotal, "value")} a token could never hold (--verbose for why)`));
412
+ // "a token could never hold" was written when every aside was an image
413
+ // renderer or an SVG paint. A literal sitting in a token's OWN fallback -
414
+ // `var(--ds-color-semantic-knob, #ffffff)` - is set aside for the opposite
415
+ // reason: it is already tokenized. The summary said the false half out
416
+ // loud and hid the true half behind a flag.
417
+ console.log(body(`set aside ${plural(asideTotal, "value")} that are not drift (--verbose for why)`));
413
418
  }
414
419
  // 0 of 0 is not a perfect score, it is an empty measurement - printing a
415
420
  // full bar there would be the report's first lie.
@@ -459,7 +464,45 @@ export async function doctor(opts) {
459
464
  if (hasSystem && measurable) {
460
465
  console.log("");
461
466
  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`));
467
+ console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand${d.phantomUses > 0 ? `, ${d.phantomUses} naming nothing` : ""}`));
468
+ }
469
+ /**
470
+ * Its own section, above drift, because it is a worse problem wearing a
471
+ * better disguise.
472
+ *
473
+ * Drift is a literal where a token belongs: the screen looks right and the
474
+ * system lost. A name the system does not declare resolves to NOTHING - no
475
+ * error, no console warning, the property simply does not apply - and it
476
+ * reads in review as the most obedient line in the file.
477
+ */
478
+ const phantoms = new Map();
479
+ for (const f of d.files) {
480
+ for (const p of f.phantoms ?? []) {
481
+ const at = phantoms.get(p.name) ?? { files: new Set(), line: p.line };
482
+ at.files.add(f.file);
483
+ phantoms.set(p.name, at);
484
+ }
485
+ }
486
+ /**
487
+ * `console.log`, not `say`. Everything else earns its place behind
488
+ * `--verbose` because the reader can already see it on screen; this is the
489
+ * one finding whose entire nature is that NOTHING is on screen. Hiding the
490
+ * invisible failure behind a flag is the same bug as not reporting it.
491
+ */
492
+ if (phantoms.size > 0) {
493
+ console.log(section("Names your system does not have"));
494
+ console.log(body(`${plural(phantoms.size, "name")} written as tokens that your system never declares.`));
495
+ console.log(body("An undeclared custom property applies nothing at all."));
496
+ console.log("");
497
+ for (const [name, at] of [...phantoms].sort()) {
498
+ const where = [...at.files][0];
499
+ console.log(body(`${name} ${where}:${at.line}${at.files.size > 1 ? ` +${at.files.size - 1} more` : ""}`));
500
+ const kin = siblingTokens(name, table);
501
+ if (kin.length > 0)
502
+ console.log(snippet([`your system has ${kin.join(", ")}`]));
503
+ }
504
+ console.log("");
505
+ console.log(body("Add the name to the system, or use one it has. Do not leave it."));
463
506
  }
464
507
  if (d.findings.length > 0) {
465
508
  say(section("Drift"));
@@ -32,11 +32,36 @@ function elementFor(name, recipe) {
32
32
  ? { tag: "button", attrs: ' type="button"' }
33
33
  : undefined);
34
34
  const tag = hit?.tag ?? "div";
35
- return {
36
- tag,
37
- attrs: hit?.attrs ?? "",
38
- voidEl: tag === "input" || tag === "hr",
39
- };
35
+ const attrs = hit?.attrs ?? "";
36
+ const isVoid = tag === "input" || tag === "hr";
37
+ /**
38
+ * A VOID ELEMENT CANNOT HOST THE PARTS WE TELL PEOPLE TO PUT INSIDE IT.
39
+ *
40
+ * `switch` resolved to `<input type="checkbox">`, and the recipe gives it a
41
+ * `thumb` part - so the generated file documented `<Switch><SwitchThumb/>
42
+ * </Switch>`, which React refuses at runtime: input is void and must not
43
+ * have children. Found by an agent building a settings page against it
44
+ * (my-test4, 27/07). The documented usage was impossible.
45
+ *
46
+ * The recipe was right and the tag was wrong. Its own CSS styles `.ds-switch`
47
+ * as an `inline-flex` track with a 16px knob inside a 24px rail - that is a
48
+ * container, described as one, and only the element choice disagreed.
49
+ *
50
+ * `<button role="switch">` is the accessible pattern for exactly this: it
51
+ * takes children, it is focusable and operable by keyboard for free, and the
52
+ * caller supplies `aria-checked`. Promotion happens ONLY when parts exist, so
53
+ * a plain input stays an input.
54
+ */
55
+ if (isVoid && Object.keys(recipe.parts ?? {}).length > 0) {
56
+ // Carry the ARIA role across - it is the part of the input's meaning that
57
+ // survives the tag change, and dropping it would trade a runtime error for
58
+ // a silent accessibility regression.
59
+ const role = / role="[a-z]+"/.exec(attrs)?.[0] ?? "";
60
+ return tag === "input"
61
+ ? { tag: "button", attrs: ` type="button"${role}`, voidEl: false }
62
+ : { tag: "div", attrs: role || ' role="separator"', voidEl: false };
63
+ }
64
+ return { tag, attrs, voidEl: isVoid };
40
65
  }
41
66
  /** Variant axes → typed props. An axis whose options ⊆ {true,false} is a
42
67
  * boolean prop; empty axes (no visual effect) are skipped. */
@@ -272,13 +297,44 @@ const STATE_PREFIX = {
272
297
  function blockToTailwind(block, prefix = "") {
273
298
  return Object.entries(block).flatMap(([prop, value]) => declToTailwind(prop, value).map((cls) => `${prefix}${cls}`));
274
299
  }
275
- function tailwindClassList(recipe) {
300
+ function tailwindClassList(recipe,
301
+ /** CSS properties a variant axis owns - see `variantOwnedProps`. */
302
+ exclude) {
303
+ const base = exclude
304
+ ? Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => !exclude.has(prop)))
305
+ : recipe.base;
276
306
  const classes = [
277
- ...blockToTailwind(recipe.base),
307
+ ...blockToTailwind(base),
308
+ // States keep everything: `hover:` and `disabled:` cannot collide with an
309
+ // unprefixed variant class, so there is nothing to resolve.
278
310
  ...Object.entries(recipe.states ?? {}).flatMap(([state, block]) => STATE_PREFIX[state] ? blockToTailwind(block, STATE_PREFIX[state]) : []),
279
311
  ];
280
312
  return classes.join(" ");
281
313
  }
314
+ /**
315
+ * A VARIANT CANNOT OVERRIDE THE BASE WHEN BOTH ARE PLAIN UTILITIES.
316
+ *
317
+ * `Card` emitted `p-md` in BASE and `[padding:0]` for `padding="none"`. Same
318
+ * specificity, so which one wins is decided by the order Tailwind emits them
319
+ * in the stylesheet - not by the order of the class names, which is what the
320
+ * code looks like it controls. The prop silently did nothing (found by an
321
+ * agent that worked around it in a comment rather than reporting it,
322
+ * my-test4, 27/07).
323
+ *
324
+ * CSS mode never had this: it selects on `[data-padding="none"]`, which
325
+ * outranks the base class honestly. Tailwind mode has no such ladder, so the
326
+ * conflict has to be resolved where it is created - at generation.
327
+ *
328
+ * The property leaves BASE and the base value becomes the axis's default, so
329
+ * exactly one class ever sets it.
330
+ */
331
+ function variantOwnedProps(variants, axis) {
332
+ const props = new Set();
333
+ for (const option of axis.options)
334
+ for (const prop of Object.keys(variants?.[axis.key]?.[option] ?? {}))
335
+ props.add(prop);
336
+ return [...props];
337
+ }
282
338
  // ── Emission ─────────────────────────────────────────────────────────────────
283
339
  function header(slug, name, version, mode) {
284
340
  const setup = mode === "tailwind"
@@ -295,8 +351,14 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
295
351
  /** JSDoc showing how to compose the component with its parts + content, so the
296
352
  * materialized code doesn't read as "a bare shell renders nothing" (dogfood
297
353
  * #5). Built from the recipe's parts. */
298
- function compositionHint(comp, name, recipe) {
354
+ function compositionHint(comp, name, recipe, voidEl = false) {
299
355
  const partNames = Object.keys(recipe.parts ?? {});
356
+ // An `<input>` or `<hr>` takes no children, and telling somebody to put
357
+ // content inside one is an instruction that throws. With parts, the element
358
+ // was promoted above and this branch never runs for a void tag.
359
+ if (voidEl) {
360
+ return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
361
+ }
300
362
  if (partNames.length === 0) {
301
363
  return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
302
364
  }
@@ -317,7 +379,6 @@ function emitCssMode(slug, name, recipe, version) {
317
379
  const comp = pascal(name);
318
380
  const propNames = axes.map((a) => a.prop);
319
381
  const destructure = [...propNames, "className", "...props"].join(", ");
320
- void voidEl; // both void and container elements self-close ({...props} carries children)
321
382
  const rootJsx = ` <${tag}${attrs}\n className={${joinCls([`"ds-${name}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n />`;
322
383
  const parts = Object.entries(recipe.parts ?? {}).map(([partName, part]) => {
323
384
  const partAxes = axesOf(part.variants ?? {});
@@ -345,7 +406,7 @@ import type { ComponentPropsWithoutRef } from "react";
345
406
 
346
407
  type ${comp}Props = ${propsType(axes, tag)};
347
408
 
348
- ${compositionHint(comp, name, recipe)}
409
+ ${compositionHint(comp, name, recipe, voidEl)}
349
410
  export function ${comp}({ ${destructure} }: ${comp}Props) {
350
411
  return (
351
412
  ${rootJsx}
@@ -368,11 +429,17 @@ function emitTailwindMode(slug, name, recipe, version) {
368
429
  const booleanConsts = axes
369
430
  .filter((a) => a.boolean)
370
431
  .map((a) => `const ${a.prop.toUpperCase()} = ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.true ?? {}).join(" "))};`);
432
+ // Every property some axis controls leaves BASE, and the base value becomes
433
+ // that axis's default - so exactly one class ever sets it and the prop
434
+ // actually wins.
435
+ const owned = new Map(axes.map((a) => [a.prop, variantOwnedProps(recipe.variants, a)]));
436
+ const excluded = new Set([...owned.values()].flat());
437
+ const fallbackFor = (a) => JSON.stringify(blockToTailwind(Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => (owned.get(a.prop) ?? []).includes(prop)))).join(" "));
371
438
  const clsParts = [
372
439
  "BASE",
373
440
  ...axes.map((a) => a.boolean
374
- ? `${a.prop} ? ${a.prop.toUpperCase()} : ""`
375
- : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ""`),
441
+ ? `${a.prop} ? ${a.prop.toUpperCase()} : ${fallbackFor(a)}`
442
+ : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
376
443
  "className",
377
444
  ];
378
445
  const destructure = [
@@ -380,17 +447,16 @@ function emitTailwindMode(slug, name, recipe, version) {
380
447
  "className",
381
448
  "...props",
382
449
  ].join(", ");
383
- void voidEl;
384
450
  return `${header(slug, name, version, "tailwind")}
385
451
 
386
452
  import type { ComponentPropsWithoutRef } from "react";
387
453
 
388
- const BASE = ${JSON.stringify(tailwindClassList(recipe))};
454
+ const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
389
455
  ${[...variantConsts, ...booleanConsts].join("\n")}
390
456
 
391
457
  type ${comp}Props = ${propsType(axes, tag)};
392
458
 
393
- ${compositionHint(comp, name, recipe)}
459
+ ${compositionHint(comp, name, recipe, voidEl)}
394
460
  export function ${comp}({ ${destructure} }: ${comp}Props) {
395
461
  return (
396
462
  <${tag}${attrs}
@@ -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.8",
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": {