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.
@@ -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 harvestOwnTable(root) {
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 buildTable({ css, source: "yours" });
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 table = await harvestOwnTable(root);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.39",
3
+ "version": "0.16.40",
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": {