synthesisui 0.16.36 → 0.16.38

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.
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join, relative } from "node:path";
3
3
  import { readToken, resolveRegistry } from "../config.js";
4
+ import { isNearDuplicate } from "../doctor/color-distance.js";
4
5
  import { diagnose, scanSource } from "../doctor/scan.js";
5
6
  import { buildTable } from "../doctor/tokens.js";
6
7
  import { body, paint, section } from "../output.js";
@@ -24,6 +25,9 @@ import { walk, walkAll } from "./doctor.js";
24
25
  * worst case is ~380 entries, about 30KB of json - cheap for the one payload
25
26
  * that describes somebody's entire vocabulary.
26
27
  */
28
+ /** How many rare colours may ride along on similarity. Bounded so a
29
+ * pathological palette cannot turn the payload into a log. */
30
+ const TWIN_BUDGET = 250;
27
31
  const MAX_PER_KIND = {
28
32
  color: 250,
29
33
  radius: 60,
@@ -95,17 +99,27 @@ async function detectStack(root) {
95
99
  stack.push("plain css");
96
100
  return stack;
97
101
  }
98
- /** Their own vocabulary, read from stylesheets - the same harvest the doctor
99
- * runs when nothing of ours is installed. */
100
- async function declaredTokens(root) {
102
+ /**
103
+ * Their own vocabulary, read from stylesheets - the same harvest the doctor runs
104
+ * when nothing of ours is installed.
105
+ *
106
+ * Returns the TABLE, not just the names, because the scan needs it. The first
107
+ * version harvested the names for the payload and then scanned with an EMPTY
108
+ * table, which made the census blind in a way a real app exposed (dono, 30/07):
109
+ * a project with 2416 uses of its own `var(--dashboard-*)` reported
110
+ * `tokenUses: 0`, `coverage: 0`, and `token: null` on every single value. The
111
+ * sharpest sentence the doctor knows - "your own system already has a name for
112
+ * this" - could not be said, and we ended up rediscovering by colour distance
113
+ * what their stylesheet had spelled out all along.
114
+ */
115
+ async function harvestOwnTable(root) {
101
116
  let css = "";
102
117
  for await (const file of walkAll([root])) {
103
118
  if (!/\.(css|scss|sass|less)$/i.test(file))
104
119
  continue;
105
120
  css += `\n${await readFile(file, "utf8").catch(() => "")}`;
106
121
  }
107
- const table = buildTable({ css, source: "yours" });
108
- return Object.fromEntries(table.byName);
122
+ return buildTable({ css, source: "yours" });
109
123
  }
110
124
  /**
111
125
  * EVERY distinct design value, commonest first - not just the repeated ones.
@@ -135,18 +149,47 @@ function distinctValues(d) {
135
149
  });
136
150
  }
137
151
  // Per-kind budgets, each family ranked inside its own: colour never starves
138
- // radius, and the low-frequency tail of colour (where near-duplicates live)
139
- // survives a cut that a global cap would have made first.
152
+ // radius, and the low-frequency tail of colour survives a cut a global cap
153
+ // would have made first.
140
154
  const kept = [];
141
155
  const dropped = new Map();
156
+ let ridealong = 0;
142
157
  for (const kind of Object.keys(MAX_PER_KIND)) {
143
158
  const family = [...by.values()]
144
159
  .filter((v) => v.kind === kind)
145
160
  .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
146
161
  const budget = MAX_PER_KIND[kind];
147
- if (family.length > budget)
148
- dropped.set(kind, family.length - budget);
149
- for (const v of family.slice(0, budget)) {
162
+ const take = family.slice(0, budget);
163
+ let rest = family.slice(budget);
164
+ /**
165
+ * THE TWIN RIDES ALONG (measured 30/07 on a 2876-file project: 899 distinct
166
+ * colours, 250 carried, 649 left behind).
167
+ *
168
+ * Frequency is the wrong sole criterion for colour. A near-duplicate is BY
169
+ * DEFINITION rare - the twin of a brand blue used three times - so ranking
170
+ * by count discards precisely the evidence the normalisation pass exists to
171
+ * find. Raising the budget does not fix it either; it just moves the wall.
172
+ *
173
+ * So after the budget is spent, any colour sitting within the
174
+ * just-noticeable difference of one we already kept comes too. It costs a
175
+ * little payload and it is the whole point of the payload.
176
+ */
177
+ if (kind === "color") {
178
+ const anchors = take.map((v) => v.value);
179
+ const near = [];
180
+ const far = [];
181
+ for (const v of rest) {
182
+ (anchors.some((a) => isNearDuplicate(a, v.value)) ? near : far).push(v);
183
+ }
184
+ // Bounded: a pathological palette could put thousands here, and a payload
185
+ // that large stops being a vocabulary.
186
+ take.push(...near.slice(0, TWIN_BUDGET));
187
+ ridealong += Math.min(near.length, TWIN_BUDGET);
188
+ rest = [...far, ...near.slice(TWIN_BUDGET)];
189
+ }
190
+ if (rest.length > 0)
191
+ dropped.set(kind, rest.length);
192
+ for (const v of take) {
150
193
  kept.push({
151
194
  kind: v.kind,
152
195
  value: v.value,
@@ -156,15 +199,20 @@ function distinctValues(d) {
156
199
  });
157
200
  }
158
201
  }
202
+ if (ridealong > 0) {
203
+ console.log(body(paint.dim(`(+${ridealong} rare colour${ridealong === 1 ? "" : "s"} carried anyway: each sits within the just-noticeable difference of one above, which is what normalizing is about)`)));
204
+ }
159
205
  // NO SILENT CAPS: a payload that quietly stops at a budget reads as "this is
160
206
  // everything you have", which is the one thing a census must never imply.
161
207
  for (const [kind, n] of dropped) {
162
- console.log(body(paint.faint(`(${n} more ${kind} value${n === 1 ? "" : "s"} exist below the ${MAX_PER_KIND[kind]} we carry - each used less often than the ones above)`)));
208
+ console.log(body(paint.faint(kind === "color"
209
+ ? `(${n} more colour${n === 1 ? "" : "s"} left behind: rarer than the ${MAX_PER_KIND[kind]} above AND not close enough to any of them to matter)`
210
+ : `(${n} more ${kind} value${n === 1 ? "" : "s"} exist below the ${MAX_PER_KIND[kind]} we carry - each used less often than the ones above)`)));
163
211
  }
164
212
  return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
165
213
  }
166
214
  export async function takeCensus(root) {
167
- const table = buildTable({ css: "", source: "yours" });
215
+ const table = await harvestOwnTable(root);
168
216
  const reports = [];
169
217
  for await (const file of walk(root)) {
170
218
  const src = await readFile(file, "utf8").catch(() => "");
@@ -186,7 +234,7 @@ export async function takeCensus(root) {
186
234
  return {
187
235
  census: 1,
188
236
  project: { name, stack: await detectStack(root) },
189
- declared: await declaredTokens(root),
237
+ declared: Object.fromEntries(table.byName),
190
238
  observed: distinctValues(d),
191
239
  totals: {
192
240
  scanned: d.scanned,
@@ -213,6 +261,12 @@ function summarize(c) {
213
261
  if (Object.keys(c.declared).length > 0) {
214
262
  console.log(body(`${Object.keys(c.declared).length} tokens you already declare - your names travel unchanged`));
215
263
  }
264
+ // The sharpest number in the report, and it only became sayable once the scan
265
+ // started using their own token table: not "you have drift" but "you have
266
+ // drift your own system already solved".
267
+ if (c.totals.named > 0) {
268
+ console.log(body(`${paint.strong(String(c.totals.named))} of those values ALREADY have a name in your system - ${c.totals.coverage}% of your design values come from it today`));
269
+ }
216
270
  const top = c.observed.slice(0, 5);
217
271
  if (top.length > 0) {
218
272
  console.log("");
@@ -0,0 +1,70 @@
1
+ /**
2
+ * PERCEPTUAL COLOUR DISTANCE, in the CLI.
3
+ *
4
+ * ΔE76 in CIE Lab: the cheapest metric that matches what an eye calls "the same
5
+ * colour". Landmarks - under 1 nobody sees it, 2.3 is the just-noticeable
6
+ * difference, past 5 a designer defends the difference.
7
+ *
8
+ * DELIBERATE DUPLICATION. The platform has this same maths in
9
+ * `apps/web/src/lib/engine/near-colors.ts`, and the CLI is a standalone npm
10
+ * package that cannot import from the app. The two are kept honest by specs on
11
+ * both sides asserting the SAME landmark numbers, so a change to one that drifts
12
+ * from the other turns red rather than quietly producing two different opinions
13
+ * about whether two greys are the same grey.
14
+ *
15
+ * Why the CLI needs it at all: the census picks which values travel, and
16
+ * frequency alone picks wrong. A near-duplicate is by definition rare - the twin
17
+ * of a brand colour used three times - so a frequency-ranked cut throws away
18
+ * exactly the evidence the normalisation pass is looking for. With this, a rare
19
+ * value rides along when it sits close to one we already kept.
20
+ */
21
+ const HEX6 = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
22
+ /** The just-noticeable difference for ΔE76. Same constant as the platform's. */
23
+ export const JND = 2.3;
24
+ function toRgb(hex) {
25
+ const h = hex.trim().replace("#", "");
26
+ if (!HEX6.test(`#${h}`))
27
+ return null;
28
+ const full = h.length === 3
29
+ ? h
30
+ .split("")
31
+ .map((c) => c + c)
32
+ .join("")
33
+ : h;
34
+ const n = Number.parseInt(full, 16);
35
+ if (Number.isNaN(n))
36
+ return null;
37
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
38
+ }
39
+ /** sRGB → CIE Lab (D65). The gamma expansion and the white point matter: skip
40
+ * them and dark values read as far closer together than they look. */
41
+ export function toLab(hex) {
42
+ const rgb = toRgb(hex);
43
+ if (!rgb)
44
+ return null;
45
+ const lin = rgb.map((c) => {
46
+ const s = c / 255;
47
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
48
+ });
49
+ const x = (lin[0] * 0.4124 + lin[1] * 0.3576 + lin[2] * 0.1805) / 0.95047;
50
+ const y = lin[0] * 0.2126 + lin[1] * 0.7152 + lin[2] * 0.0722;
51
+ const z = (lin[0] * 0.0193 + lin[1] * 0.1192 + lin[2] * 0.9505) / 1.08883;
52
+ const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
53
+ const fx = f(x);
54
+ const fy = f(y);
55
+ const fz = f(z);
56
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
57
+ }
58
+ /** ΔE76 between two colours, or null when either cannot be read. */
59
+ export function deltaE(a, b) {
60
+ const la = toLab(a);
61
+ const lb = toLab(b);
62
+ if (!la || !lb)
63
+ return null;
64
+ return Math.sqrt((la[0] - lb[0]) ** 2 + (la[1] - lb[1]) ** 2 + (la[2] - lb[2]) ** 2);
65
+ }
66
+ /** True when two colours are the same decision typed twice. */
67
+ export function isNearDuplicate(a, b, threshold = JND) {
68
+ const d = deltaE(a, b);
69
+ return d != null && d <= threshold;
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.36",
3
+ "version": "0.16.38",
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": {