synthesisui 0.8.1 → 0.9.1

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, forbiddenProps, isReset, propMatchesLabel, } 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) {
@@ -290,10 +313,64 @@ export async function doctor(opts) {
290
313
  console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
291
314
  }
292
315
  }
316
+ // Every other section asks whether the code obeys the system. This one asks
317
+ // whether the system obeys itself, and it is only here because the tool once
318
+ // reported a consumer for overruling a recipe while they were obeying the
319
+ // law written on that same component.
320
+ const conflicts = documents.flatMap((doc) => findSelfConflicts(doc));
321
+ const conflictsInUse = conflicts.filter((c) => used.has(c.component));
322
+ if (conflictsInUse.length > 0) {
323
+ console.log(section("Your system contradicts itself"));
324
+ console.log(body(conflictsInUse.length === 1
325
+ ? "One component says one thing in prose and another in its recipe."
326
+ : `${conflictsInUse.length} components say one thing in prose and another in their recipe.`));
327
+ console.log("");
328
+ for (const c of conflictsInUse) {
329
+ console.log(body(`ds-${c.component}`));
330
+ console.log(` "${c.law}"`);
331
+ console.log(` but ${c.where} binds ${c.value}`);
332
+ console.log("");
333
+ }
334
+ console.log(body("Whoever wrote the law and whoever wrote the recipe disagree."));
335
+ console.log(body("Until they do not, no code here can be correct."));
336
+ }
293
337
  if (overrides.length > 0) {
338
+ // An override on a property the component's own law FORBIDS, written as a
339
+ // reset, is not drift - it is the author keeping a promise the recipe
340
+ // broke. Counting it against them was the report being unfair to the only
341
+ // person in the loop who read the law (investidorez, 25/07).
342
+ const forbidden = new Map();
343
+ for (const doc of documents) {
344
+ for (const [comp, props] of forbiddenProps(doc)) {
345
+ const merged = forbidden.get(comp) ?? new Map();
346
+ for (const [label, law] of props)
347
+ merged.set(label, law);
348
+ forbidden.set(comp, merged);
349
+ }
350
+ }
351
+ const lawKept = (o) => {
352
+ if (!isReset(o.wrote))
353
+ return null;
354
+ const props = forbidden.get(o.component);
355
+ if (!props)
356
+ return null;
357
+ for (const [label, law] of props) {
358
+ if (propMatchesLabel(o.prop, label))
359
+ return law;
360
+ }
361
+ return null;
362
+ };
363
+ const drifting = overrides.filter((o) => lawKept(o) === null);
364
+ const correcting = overrides.length - drifting.length;
294
365
  console.log(section("Overruled"));
295
- console.log(body(`${overrides.length} place${overrides.length === 1 ? "" : "s"} where the code takes a component`));
366
+ console.log(body(`${drifting.length} place${drifting.length === 1 ? "" : "s"} where the code takes a component`));
296
367
  console.log(body("the system defines, and then overrules it locally."));
368
+ if (correcting > 0) {
369
+ console.log("");
370
+ console.log(body(correcting === 1
371
+ ? "One more overrules it to KEEP a law the recipe breaks - marked below."
372
+ : `${correcting} more overrule it to KEEP a law the recipe breaks - marked below.`));
373
+ }
297
374
  console.log("");
298
375
  const byFile = new Map();
299
376
  for (const o of overrides) {
@@ -315,6 +392,11 @@ export async function doctor(opts) {
315
392
  console.log(` the recipe binds ${o.recipe}`);
316
393
  else if (o.where)
317
394
  console.log(` the recipe binds it ${o.where}`);
395
+ const kept = lawKept(o);
396
+ if (kept) {
397
+ console.log(` ✓ but the law says: "${kept}"`);
398
+ console.log(" this override keeps it - the recipe does not");
399
+ }
318
400
  }
319
401
  if (!opts.all && list.length > 3) {
320
402
  console.log(` +${list.length - 3} more`);
@@ -0,0 +1,175 @@
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
+ * Per component, the properties its own laws forbid and the law that does it.
99
+ *
100
+ * The report needs this separately from the conflicts: an override on a
101
+ * forbidden property is not drift, it is the author keeping a promise the
102
+ * recipe broke. Listing them together was accusing the person who was right.
103
+ */
104
+ export function forbiddenProps(document) {
105
+ const out = new Map();
106
+ const doc = (document ?? {});
107
+ const components = (doc.components ?? {});
108
+ for (const [name, raw] of Object.entries(components)) {
109
+ const recipe = (raw ?? {});
110
+ const laws = Array.isArray(recipe.usage)
111
+ ? recipe.usage.filter((x) => typeof x === "string")
112
+ : [];
113
+ for (const law of laws) {
114
+ for (const rule of FORBIDDABLE) {
115
+ if (!forbids(law, rule.words))
116
+ continue;
117
+ const byProp = out.get(name) ?? new Map();
118
+ byProp.set(rule.label, law);
119
+ out.set(name, byProp);
120
+ }
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ /** Is this property one the law names, given how the rule matches? */
126
+ export function propMatchesLabel(prop, label) {
127
+ const rule = FORBIDDABLE.find((r) => r.label === label);
128
+ return rule ? rule.prop.test(prop) : false;
129
+ }
130
+ /** A value that removes rather than sets - the law being kept. */
131
+ export function isReset(value) {
132
+ return !isSet(value);
133
+ }
134
+ /**
135
+ * Every place a component's recipe does what its own law forbids.
136
+ */
137
+ export function findSelfConflicts(document) {
138
+ const out = [];
139
+ const doc = (document ?? {});
140
+ const components = (doc.components ?? {});
141
+ for (const [name, raw] of Object.entries(components)) {
142
+ const recipe = (raw ?? {});
143
+ const laws = Array.isArray(recipe.usage)
144
+ ? recipe.usage.filter((x) => typeof x === "string")
145
+ : [];
146
+ if (laws.length === 0)
147
+ continue;
148
+ const decls = [
149
+ ...declarations(recipe.base, "base"),
150
+ ...declarations(recipe.states, "a state"),
151
+ ...declarations(recipe.variants, "a variant"),
152
+ ...declarations(recipe.parts, "a part"),
153
+ ];
154
+ for (const law of laws) {
155
+ for (const rule of FORBIDDABLE) {
156
+ const word = forbids(law, rule.words);
157
+ if (!word)
158
+ continue;
159
+ for (const d of decls) {
160
+ if (!rule.prop.test(d.prop) || !isSet(d.value))
161
+ continue;
162
+ out.push({
163
+ component: name,
164
+ law,
165
+ forbids: word,
166
+ where: `${d.where} · ${d.prop}`,
167
+ value: d.value,
168
+ });
169
+ break; // one report per law per property family
170
+ }
171
+ }
172
+ }
173
+ }
174
+ return out;
175
+ }
@@ -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.1",
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": {