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.
@@ -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 { buildTable, EMPTY_TABLE } from "../doctor/tokens.js";
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
- for (const [k, v] of bindingsFromDocument(JSON.parse(docRaw))) {
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 named = r.token ? ` → ${r.token}` : "";
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
- : "→ no token holds this value yet";
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
+ }
@@ -18,17 +18,49 @@ export const EMPTY_TABLE = {
18
18
  byName: new Map(),
19
19
  byValue: new Map(),
20
20
  };
21
- /** Lowercase, collapse whitespace, expand #abc to #aabbcc - so `#FFF`,
22
- * `#ffffff` and `# fff` all land on one key. */
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])$/.exec(v);
26
- if (short)
27
- return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`;
28
- // rgb(37 99 235) and rgb(37, 99, 235) are the same colour written twice.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
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": {