synthesisui 0.16.39 → 0.16.40
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 +98 -4
- package/dist/doctor/scheme-blocks.js +77 -0
- package/package.json +1 -1
package/dist/commands/import.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import { readToken, resolveRegistry } from "../config.js";
|
|
4
4
|
import { isNearDuplicate } from "../doctor/color-distance.js";
|
|
5
5
|
import { diagnose, scanSource } from "../doctor/scan.js";
|
|
6
|
+
import { parseSchemeBlocks } from "../doctor/scheme-blocks.js";
|
|
6
7
|
import { buildTable } from "../doctor/tokens.js";
|
|
7
8
|
import { body, paint, section } from "../output.js";
|
|
8
9
|
import { walk, walkAll } from "./doctor.js";
|
|
@@ -35,6 +36,56 @@ const MAX_PER_KIND = {
|
|
|
35
36
|
motion: 40,
|
|
36
37
|
font: 30,
|
|
37
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* SIBLING APPS UNDER ONE ROOT - the shape a monorepo takes, and a question the
|
|
41
|
+
* census cannot answer on its own.
|
|
42
|
+
*
|
|
43
|
+
* Reading a workspace root averages every app in it. On the project this was
|
|
44
|
+
* built for that meant one LIGHT app and one DARK app producing a system that
|
|
45
|
+
* is neither, while the shared package that actually holds their design system
|
|
46
|
+
* contributed 97 tokens out of 137 and no special standing.
|
|
47
|
+
*
|
|
48
|
+
* So the command says what it sees and names the shape that works: the system
|
|
49
|
+
* comes from wherever the vocabulary is centralised, and each app is a consumer
|
|
50
|
+
* measured against it.
|
|
51
|
+
*/
|
|
52
|
+
async function siblingProjects(root) {
|
|
53
|
+
const apps = [];
|
|
54
|
+
const shared = [];
|
|
55
|
+
for (const group of ["apps", "packages", "libs"]) {
|
|
56
|
+
const dir = join(root, group);
|
|
57
|
+
for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
58
|
+
if (!e.isDirectory())
|
|
59
|
+
continue;
|
|
60
|
+
const has = await readFile(join(dir, e.name, "package.json"), "utf8")
|
|
61
|
+
.then(() => true)
|
|
62
|
+
.catch(() => false);
|
|
63
|
+
if (!has)
|
|
64
|
+
continue;
|
|
65
|
+
(group === "apps" ? apps : shared).push(`${group}/${e.name}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// The shared package that actually DECLARES the vocabulary leads - picking the
|
|
69
|
+
// first one alphabetically suggested `packages/core` for a project whose
|
|
70
|
+
// design system lives in `packages/ui` (97 of its 137 tokens).
|
|
71
|
+
const weighed = await Promise.all(shared.map(async (rel) => {
|
|
72
|
+
let n = 0;
|
|
73
|
+
for await (const file of walkAll([join(root, rel)])) {
|
|
74
|
+
if (!/\.(css|scss|sass|less)$/i.test(file))
|
|
75
|
+
continue;
|
|
76
|
+
const css = await readFile(file, "utf8").catch(() => "");
|
|
77
|
+
n += (css.match(/(^|[\s{;])--[a-z0-9-]+\s*:/gi) ?? []).length;
|
|
78
|
+
}
|
|
79
|
+
return { rel, n };
|
|
80
|
+
}));
|
|
81
|
+
return {
|
|
82
|
+
apps,
|
|
83
|
+
shared: weighed
|
|
84
|
+
.filter((w) => w.n > 0)
|
|
85
|
+
.sort((a, b) => b.n - a.n)
|
|
86
|
+
.map((w) => w.rel),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
38
89
|
/**
|
|
39
90
|
* Dependencies as the project actually resolves them - which means reading
|
|
40
91
|
* ANCESTOR package.json files too.
|
|
@@ -112,14 +163,14 @@ async function detectStack(root) {
|
|
|
112
163
|
* this" - could not be said, and we ended up rediscovering by colour distance
|
|
113
164
|
* what their stylesheet had spelled out all along.
|
|
114
165
|
*/
|
|
115
|
-
async function
|
|
166
|
+
async function harvestOwnCss(root) {
|
|
116
167
|
let css = "";
|
|
117
168
|
for await (const file of walkAll([root])) {
|
|
118
169
|
if (!/\.(css|scss|sass|less)$/i.test(file))
|
|
119
170
|
continue;
|
|
120
171
|
css += `\n${await readFile(file, "utf8").catch(() => "")}`;
|
|
121
172
|
}
|
|
122
|
-
return
|
|
173
|
+
return css;
|
|
123
174
|
}
|
|
124
175
|
/**
|
|
125
176
|
* EVERY distinct design value, commonest first - not just the repeated ones.
|
|
@@ -212,7 +263,9 @@ function distinctValues(d) {
|
|
|
212
263
|
return kept.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
|
213
264
|
}
|
|
214
265
|
export async function takeCensus(root) {
|
|
215
|
-
const
|
|
266
|
+
const css = await harvestOwnCss(root);
|
|
267
|
+
const table = buildTable({ css, source: "yours" });
|
|
268
|
+
const schemes = parseSchemeBlocks(css);
|
|
216
269
|
const reports = [];
|
|
217
270
|
for await (const file of walk(root)) {
|
|
218
271
|
const src = await readFile(file, "utf8").catch(() => "");
|
|
@@ -235,6 +288,9 @@ export async function takeCensus(root) {
|
|
|
235
288
|
census: 1,
|
|
236
289
|
project: { name, stack: await detectStack(root) },
|
|
237
290
|
declared: Object.fromEntries(table.byName),
|
|
291
|
+
...(schemes.alt.size > 0
|
|
292
|
+
? { declaredAlt: Object.fromEntries(schemes.alt) }
|
|
293
|
+
: {}),
|
|
238
294
|
observed: distinctValues(d),
|
|
239
295
|
totals: {
|
|
240
296
|
scanned: d.scanned,
|
|
@@ -264,6 +320,9 @@ function summarize(c) {
|
|
|
264
320
|
// The sharpest number in the report, and it only became sayable once the scan
|
|
265
321
|
// started using their own token table: not "you have drift" but "you have
|
|
266
322
|
// drift your own system already solved".
|
|
323
|
+
if (c.declaredAlt && Object.keys(c.declaredAlt).length > 0) {
|
|
324
|
+
console.log(body(`${Object.keys(c.declaredAlt).length} of them are declared again for dark - both schemes travel`));
|
|
325
|
+
}
|
|
267
326
|
if (c.totals.named > 0) {
|
|
268
327
|
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
328
|
}
|
|
@@ -304,6 +363,39 @@ function printAgentContract() {
|
|
|
304
363
|
console.log("");
|
|
305
364
|
console.log(body(` ${paint.strong("synthesisui import --census _synthesisui/census.json")}`));
|
|
306
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Name the shape when the root holds several projects, and point at the one
|
|
368
|
+
* that should be the system.
|
|
369
|
+
*
|
|
370
|
+
* Not a refusal, and not a guess dressed as a fact: the run still happens and
|
|
371
|
+
* still produces something useful (a diagnosis across the whole workspace). It
|
|
372
|
+
* just stops letting a person believe the average of three apps is their design
|
|
373
|
+
* system.
|
|
374
|
+
*/
|
|
375
|
+
async function sayIfWorkspace(root) {
|
|
376
|
+
const { apps, shared } = await siblingProjects(root);
|
|
377
|
+
if (apps.length < 2)
|
|
378
|
+
return;
|
|
379
|
+
console.log("");
|
|
380
|
+
console.log(section("This root holds several projects"));
|
|
381
|
+
console.log(body(`${apps.length} apps (${apps.slice(0, 3).join(", ")}${apps.length > 3 ? ", …" : ""}) - and this census is the average of all of them.`));
|
|
382
|
+
console.log(body("That is a fine DIAGNOSIS and a poor system: a light app and a dark one"));
|
|
383
|
+
console.log(body("average into a palette that is neither."));
|
|
384
|
+
console.log("");
|
|
385
|
+
if (shared.length > 0) {
|
|
386
|
+
console.log(body("The shape that works, if your vocabulary is shared:"));
|
|
387
|
+
console.log(body(` ${paint.strong(`synthesisui import --dir ${shared[0]}`)} ${paint.faint("the system comes from here")}`));
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
console.log(body("The shape that works:"));
|
|
391
|
+
console.log(body(` ${paint.strong(`synthesisui import --dir ${apps[0]}`)} ${paint.faint("one app, one vocabulary")}`));
|
|
392
|
+
}
|
|
393
|
+
for (const app of apps.slice(0, 3)) {
|
|
394
|
+
console.log(body(` ${paint.dim(`synthesisui doctor ${app}`)} ${paint.faint("how far this one is from it")}`));
|
|
395
|
+
}
|
|
396
|
+
console.log("");
|
|
397
|
+
console.log(body(paint.dim("One system, many consumers - each app gets its own number against the same contract.")));
|
|
398
|
+
}
|
|
307
399
|
export async function runImport(opts) {
|
|
308
400
|
const root = opts.root ?? process.cwd();
|
|
309
401
|
// A census handed to us (an agent annotated it) is sent as-is; the numbers
|
|
@@ -335,6 +427,8 @@ export async function runImport(opts) {
|
|
|
335
427
|
census = await takeCensus(root);
|
|
336
428
|
}
|
|
337
429
|
summarize(census);
|
|
430
|
+
if (!opts.census)
|
|
431
|
+
await sayIfWorkspace(root);
|
|
338
432
|
const out = join(root, "_synthesisui", "census.json");
|
|
339
433
|
await mkdir(join(root, "_synthesisui"), { recursive: true });
|
|
340
434
|
await writeFile(out, `${JSON.stringify(census, null, 2)}\n`, "utf8");
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOKENS BY SCHEME - which declarations belong to the base scheme and which to
|
|
3
|
+
* the dark one.
|
|
4
|
+
*
|
|
5
|
+
* The harvest used to flatten every stylesheet into one `name → value` map with
|
|
6
|
+
* the first declaration winning, which silently discards the second half of
|
|
7
|
+
* every themed project. A shadcn codebase declares its whole vocabulary twice -
|
|
8
|
+
* `:root` and `.dark` - so half of it was being thrown away before anything
|
|
9
|
+
* could read it.
|
|
10
|
+
*
|
|
11
|
+
* Honest scope note (measured 30/07 on the project this was written for): its
|
|
12
|
+
* own dark block holds exactly two tokens, because its dark mode lives in a
|
|
13
|
+
* Tailwind `dark:` variant applied in components rather than in token values.
|
|
14
|
+
* This reader finds what a project declares; it cannot find what a project
|
|
15
|
+
* expresses in class names.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately a scanner, not a CSS parser. It tracks brace depth and the
|
|
18
|
+
* selector that opened each level, which is all that is needed to answer "which
|
|
19
|
+
* scope is this declaration in" - and it survives the nesting Tailwind v4
|
|
20
|
+
* produces (`@layer base { :root { … } }`) without a dependency.
|
|
21
|
+
*/
|
|
22
|
+
/** A scope that means "this is the dark theme". Covers the spellings that
|
|
23
|
+
* actually appear: shadcn's `.dark`, the data-attribute forms, and the
|
|
24
|
+
* `:where(...)` wrapper Tailwind v4 emits for a custom variant.
|
|
25
|
+
*
|
|
26
|
+
* The closing `]` is load-bearing and a spec caught it missing:
|
|
27
|
+
* `[data-theme="darkroom"]` was being read as the dark theme. */
|
|
28
|
+
const DARK_SCOPE = /(^|[\s,(])(\.dark\b|\.theme-dark\b|\[data-(?:theme|scheme|mode)\s*[~^|*$]?=\s*["']?dark["']?\s*\])/i;
|
|
29
|
+
/** A scope that carries a document's own tokens, as opposed to a component's. */
|
|
30
|
+
const ROOT_SCOPE = /(^|[\s,(])(:root\b|html\b|body\b)/i;
|
|
31
|
+
const DECL = /(--[a-z0-9-]+)\s*:\s*([^;]+)/gi;
|
|
32
|
+
export function parseSchemeBlocks(css) {
|
|
33
|
+
const base = new Map();
|
|
34
|
+
const alt = new Map();
|
|
35
|
+
const clean = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
36
|
+
// The selector (or at-rule) that opened each open brace, innermost last.
|
|
37
|
+
const stack = [];
|
|
38
|
+
let prelude = "";
|
|
39
|
+
for (let i = 0; i < clean.length; i++) {
|
|
40
|
+
const ch = clean[i];
|
|
41
|
+
if (ch === "{") {
|
|
42
|
+
stack.push(prelude.trim());
|
|
43
|
+
prelude = "";
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === "}") {
|
|
47
|
+
stack.pop();
|
|
48
|
+
prelude = "";
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === ";") {
|
|
52
|
+
// A declaration ends here: everything since the last delimiter is it.
|
|
53
|
+
const text = prelude;
|
|
54
|
+
prelude = "";
|
|
55
|
+
if (stack.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
const dark = stack.some((s) => DARK_SCOPE.test(s));
|
|
58
|
+
const rooted = stack.some((s) => ROOT_SCOPE.test(s) || /^@theme\b/i.test(s));
|
|
59
|
+
if (!dark && !rooted)
|
|
60
|
+
continue;
|
|
61
|
+
DECL.lastIndex = 0;
|
|
62
|
+
const m = DECL.exec(text);
|
|
63
|
+
if (!m)
|
|
64
|
+
continue;
|
|
65
|
+
const name = m[1].toLowerCase();
|
|
66
|
+
const value = m[2].trim();
|
|
67
|
+
const into = dark ? alt : base;
|
|
68
|
+
// First declaration wins, the same rule the flat harvest used - a later
|
|
69
|
+
// sheet overriding an earlier one is a cascade question this cannot see.
|
|
70
|
+
if (!into.has(name))
|
|
71
|
+
into.set(name, value);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
prelude += ch;
|
|
75
|
+
}
|
|
76
|
+
return { base, alt };
|
|
77
|
+
}
|
package/package.json
CHANGED