synthesisui 0.8.0 → 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) {
@@ -308,8 +331,13 @@ export async function doctor(opts) {
308
331
  console.log(body(file));
309
332
  for (const o of opts.all ? list : list.slice(0, 3)) {
310
333
  console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
334
+ // Always say what is being overruled. A finding that cannot name it is
335
+ // indistinguishable from a bug in the reader's eyes, and one of these
336
+ // (`.ds-card:hover { transform: none }`) was a real, deliberate call.
311
337
  if (o.recipe)
312
338
  console.log(` the recipe binds ${o.recipe}`);
339
+ else if (o.where)
340
+ console.log(` the recipe binds it ${o.where}`);
313
341
  }
314
342
  if (!opts.all && list.length > 3) {
315
343
  console.log(` +${list.length - 3} more`);
@@ -323,6 +351,27 @@ export async function doctor(opts) {
323
351
  // The system's own words, for the components this project actually uses.
324
352
  // Prose, so nothing verifies it - the value is putting it in front of
325
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
+ }
326
375
  // do because no other tool knows these laws exist.
327
376
  const inUse = [...used.entries()]
328
377
  .filter(([name]) => (recipes.get(name)?.usage.length ?? 0) > 0)
@@ -46,10 +46,37 @@ export function bindingsOf(recipe) {
46
46
  collect(r.variants, false);
47
47
  collect(r.states, false);
48
48
  collect(r.parts, false);
49
+ // Where a non-base property lives, so the report can name it. Base wins: if
50
+ // base binds it too, that value is the one worth showing.
51
+ const boundIn = new Map();
52
+ const note = (block, label) => {
53
+ if (!block || typeof block !== "object")
54
+ return;
55
+ for (const [k, v] of Object.entries(block)) {
56
+ if (typeof v === "string" || typeof v === "number") {
57
+ const p = kebab(k);
58
+ if (!baseValues.has(p) && !boundIn.has(p))
59
+ boundIn.set(p, `${label}: ${v}`);
60
+ }
61
+ else if (v && typeof v === "object") {
62
+ note(v, label);
63
+ }
64
+ }
65
+ };
66
+ for (const [state, block] of Object.entries((r.states ?? {}))) {
67
+ note(block, `on ${state}`);
68
+ }
69
+ for (const [axis, opts] of Object.entries((r.variants ?? {}))) {
70
+ if (!opts || typeof opts !== "object")
71
+ continue;
72
+ for (const [opt, block] of Object.entries(opts)) {
73
+ note(block, `on ${axis}="${opt}"`);
74
+ }
75
+ }
49
76
  const usage = Array.isArray(r.usage)
50
77
  ? r.usage.filter((x) => typeof x === "string")
51
78
  : [];
52
- return { props, baseValues, usage };
79
+ return { props, baseValues, boundIn, usage };
53
80
  }
54
81
  /**
55
82
  * How many places each known component is used. An inventory nobody has today:
@@ -117,6 +144,55 @@ function enclosingTag(src, at) {
117
144
  return null;
118
145
  }
119
146
  const lineAt = (src, index) => src.slice(0, index).split("\n").length;
147
+ /**
148
+ * Whatever the `style` prop was given, with where it starts. The expression
149
+ * container, not `{{` specifically: a real component writes
150
+ * `style={showImg ? undefined : { color: "#fff" }}`, and requiring the double
151
+ * brace dropped that finding on the floor. Brace matching rather than a regex,
152
+ * because the value nests.
153
+ */
154
+ function styleRegions(tag) {
155
+ const out = [];
156
+ const open = /style\s*=\s*\{/g;
157
+ let m = open.exec(tag);
158
+ while (m !== null) {
159
+ const start = m.index + m[0].length;
160
+ let depth = 1; // the container brace just consumed
161
+ let end = start;
162
+ for (; end < tag.length; end++) {
163
+ if (tag[end] === "{")
164
+ depth++;
165
+ else if (tag[end] === "}" && --depth === 0)
166
+ break;
167
+ }
168
+ // Only the OBJECT LITERALS inside the container. Scanning the container
169
+ // whole let a one-line ternary eat the declaration: in
170
+ // `style={on ? undefined : { padding: "7px" }}` the colon after `undefined`
171
+ // reads as a property separator and swallows the padding behind it.
172
+ const container = tag.slice(start, end);
173
+ let d = 0;
174
+ let objStart = -1;
175
+ for (let i = 0; i < container.length; i++) {
176
+ if (container[i] === "{") {
177
+ if (d === 0)
178
+ objStart = i + 1;
179
+ d++;
180
+ }
181
+ else if (container[i] === "}" && d > 0 && --d === 0) {
182
+ out.push({
183
+ text: container.slice(objStart, i),
184
+ offset: start + objStart,
185
+ });
186
+ }
187
+ }
188
+ // `style={{ … }}` - the container IS the object
189
+ if (objStart === -1)
190
+ out.push({ text: container, offset: start });
191
+ open.lastIndex = end;
192
+ m = open.exec(tag);
193
+ }
194
+ return out;
195
+ }
120
196
  /**
121
197
  * The literal a style property was given, or null when it was given an
122
198
  * expression instead.
@@ -159,22 +235,29 @@ export function findOverrides(source, recipes) {
159
235
  const tag = enclosingTag(source, m.index ?? 0);
160
236
  if (!tag)
161
237
  continue;
162
- // inline style={{ borderRadius: 4 }}
163
- for (const s of tag.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
164
- const prop = kebab(s[1]);
165
- if (!bind.props.has(prop))
166
- continue;
167
- const wrote = literalValue(s[2]);
168
- if (wrote === null)
169
- continue;
170
- record({
171
- component: name,
172
- prop,
173
- wrote,
174
- recipe: bind.baseValues.get(prop) ?? null,
175
- line: lineAt(source, tag.start + (s.index ?? 0)),
176
- excerpt: clip(lines[lineAt(source, tag.start + (s.index ?? 0)) - 1]?.trim() ?? ""),
177
- });
238
+ // inline style={{ borderRadius: 4 }} - and ONLY that. Reading the whole tag
239
+ // meant reading every object prop on it, so Framer Motion's
240
+ // `animate={{ opacity: 1 }}` came back as "ds-card overrules opacity"
241
+ // (investidorez, 25/07). An animation is not a design decision.
242
+ for (const region of styleRegions(tag.text)) {
243
+ for (const s of region.text.matchAll(/([a-zA-Z-]+)\s*:\s*("[^"]*"|'[^']*'|[^,}\n]+)/g)) {
244
+ const prop = kebab(s[1]);
245
+ if (!bind.props.has(prop))
246
+ continue;
247
+ const wrote = literalValue(s[2]);
248
+ if (wrote === null)
249
+ continue;
250
+ const at = tag.start + region.offset + (s.index ?? 0);
251
+ record({
252
+ component: name,
253
+ prop,
254
+ wrote,
255
+ recipe: bind.baseValues.get(prop) ?? null,
256
+ where: bind.boundIn.get(prop) ?? null,
257
+ line: lineAt(source, at),
258
+ excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
259
+ });
260
+ }
178
261
  }
179
262
  // utility classes sitting beside the ds-* class
180
263
  for (const cls of tag.text.matchAll(/[\w:[\]#().%/-]+/g)) {
@@ -186,6 +269,7 @@ export function findOverrides(source, recipes) {
186
269
  prop,
187
270
  wrote: cls[0],
188
271
  recipe: bind.baseValues.get(prop) ?? null,
272
+ where: bind.boundIn.get(prop) ?? null,
189
273
  line: lineAt(source, tag.start + (cls.index ?? 0)),
190
274
  excerpt: clip(lines[lineAt(source, tag.start + (cls.index ?? 0)) - 1]?.trim() ?? ""),
191
275
  });
@@ -193,7 +277,10 @@ export function findOverrides(source, recipes) {
193
277
  }
194
278
  // ── CSS: a rule whose selector reaches a ds-* component ───────────────────
195
279
  for (const rule of source.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
196
- const selector = rule[1];
280
+ // A CSS comment is not a selector. `/* botões (.ds-button) … */` above an
281
+ // unrelated rule made the doctor attribute that rule to ds-button - it was
282
+ // reading prose as structure (investidorez, 25/07).
283
+ const selector = rule[1].replace(/\/\*[\s\S]*?\*\//g, "");
197
284
  const hit = /\.ds-([a-z][a-z0-9-]*)\b/.exec(selector);
198
285
  const bind = hit ? recipes.get(hit[1]) : undefined;
199
286
  if (!hit || !bind)
@@ -211,6 +298,7 @@ export function findOverrides(source, recipes) {
211
298
  prop,
212
299
  wrote: decl[2].trim(),
213
300
  recipe: bind.baseValues.get(prop) ?? null,
301
+ where: bind.boundIn.get(prop) ?? null,
214
302
  line: lineAt(source, at),
215
303
  excerpt: clip(lines[lineAt(source, at) - 1]?.trim() ?? ""),
216
304
  });
@@ -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.0",
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": {