synthesisui 0.16.35 → 0.16.37
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/commands/import.js +92 -13
- package/dist/doctor/color-distance.js +70 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -1,13 +1,40 @@
|
|
|
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";
|
|
7
8
|
import { walk, walkAll } from "./doctor.js";
|
|
8
|
-
/**
|
|
9
|
-
*
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* How many distinct values travel, PER KIND.
|
|
11
|
+
*
|
|
12
|
+
* It was one global cap of 120, and a real app broke it (30/07): 2876 files,
|
|
13
|
+
* 3976 findings, and the payload came back holding exactly 120 - ranked by
|
|
14
|
+
* frequency with every kind competing for the same slots. Two things went wrong
|
|
15
|
+
* there, and the second is the serious one:
|
|
16
|
+
*
|
|
17
|
+
* - radius and spacing lost their slots to colour, so the scale downstream
|
|
18
|
+
* would have been built from a partial set
|
|
19
|
+
* - the NEAR-DUPLICATE colours - a fifth grey used three times, the twin of
|
|
20
|
+
* the brand blue - are by definition low-frequency, which is exactly what a
|
|
21
|
+
* frequency-ranked cut throws away first. The collapse that justifies the
|
|
22
|
+
* whole v2 lives in that tail.
|
|
23
|
+
*
|
|
24
|
+
* So colour gets a deep budget and the dimensional families get their own. The
|
|
25
|
+
* worst case is ~380 entries, about 30KB of json - cheap for the one payload
|
|
26
|
+
* that describes somebody's entire vocabulary.
|
|
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;
|
|
31
|
+
const MAX_PER_KIND = {
|
|
32
|
+
color: 250,
|
|
33
|
+
radius: 60,
|
|
34
|
+
spacing: 60,
|
|
35
|
+
motion: 40,
|
|
36
|
+
font: 30,
|
|
37
|
+
};
|
|
11
38
|
/**
|
|
12
39
|
* Dependencies as the project actually resolves them - which means reading
|
|
13
40
|
* ANCESTOR package.json files too.
|
|
@@ -111,16 +138,68 @@ function distinctValues(d) {
|
|
|
111
138
|
token: f.token,
|
|
112
139
|
});
|
|
113
140
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
// Per-kind budgets, each family ranked inside its own: colour never starves
|
|
142
|
+
// radius, and the low-frequency tail of colour survives a cut a global cap
|
|
143
|
+
// would have made first.
|
|
144
|
+
const kept = [];
|
|
145
|
+
const dropped = new Map();
|
|
146
|
+
let ridealong = 0;
|
|
147
|
+
for (const kind of Object.keys(MAX_PER_KIND)) {
|
|
148
|
+
const family = [...by.values()]
|
|
149
|
+
.filter((v) => v.kind === kind)
|
|
150
|
+
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
151
|
+
const budget = MAX_PER_KIND[kind];
|
|
152
|
+
const take = family.slice(0, budget);
|
|
153
|
+
let rest = family.slice(budget);
|
|
154
|
+
/**
|
|
155
|
+
* THE TWIN RIDES ALONG (measured 30/07 on a 2876-file project: 899 distinct
|
|
156
|
+
* colours, 250 carried, 649 left behind).
|
|
157
|
+
*
|
|
158
|
+
* Frequency is the wrong sole criterion for colour. A near-duplicate is BY
|
|
159
|
+
* DEFINITION rare - the twin of a brand blue used three times - so ranking
|
|
160
|
+
* by count discards precisely the evidence the normalisation pass exists to
|
|
161
|
+
* find. Raising the budget does not fix it either; it just moves the wall.
|
|
162
|
+
*
|
|
163
|
+
* So after the budget is spent, any colour sitting within the
|
|
164
|
+
* just-noticeable difference of one we already kept comes too. It costs a
|
|
165
|
+
* little payload and it is the whole point of the payload.
|
|
166
|
+
*/
|
|
167
|
+
if (kind === "color") {
|
|
168
|
+
const anchors = take.map((v) => v.value);
|
|
169
|
+
const near = [];
|
|
170
|
+
const far = [];
|
|
171
|
+
for (const v of rest) {
|
|
172
|
+
(anchors.some((a) => isNearDuplicate(a, v.value)) ? near : far).push(v);
|
|
173
|
+
}
|
|
174
|
+
// Bounded: a pathological palette could put thousands here, and a payload
|
|
175
|
+
// that large stops being a vocabulary.
|
|
176
|
+
take.push(...near.slice(0, TWIN_BUDGET));
|
|
177
|
+
ridealong += Math.min(near.length, TWIN_BUDGET);
|
|
178
|
+
rest = [...far, ...near.slice(TWIN_BUDGET)];
|
|
179
|
+
}
|
|
180
|
+
if (rest.length > 0)
|
|
181
|
+
dropped.set(kind, rest.length);
|
|
182
|
+
for (const v of take) {
|
|
183
|
+
kept.push({
|
|
184
|
+
kind: v.kind,
|
|
185
|
+
value: v.value,
|
|
186
|
+
count: v.count,
|
|
187
|
+
files: v.files.size,
|
|
188
|
+
token: v.token,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (ridealong > 0) {
|
|
193
|
+
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)`)));
|
|
194
|
+
}
|
|
195
|
+
// NO SILENT CAPS: a payload that quietly stops at a budget reads as "this is
|
|
196
|
+
// everything you have", which is the one thing a census must never imply.
|
|
197
|
+
for (const [kind, n] of dropped) {
|
|
198
|
+
console.log(body(paint.faint(kind === "color"
|
|
199
|
+
? `(${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)`
|
|
200
|
+
: `(${n} more ${kind} value${n === 1 ? "" : "s"} exist below the ${MAX_PER_KIND[kind]} we carry - each used less often than the ones above)`)));
|
|
201
|
+
}
|
|
202
|
+
return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
124
203
|
}
|
|
125
204
|
export async function takeCensus(root) {
|
|
126
205
|
const table = buildTable({ css: "", source: "yours" });
|
|
@@ -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