synthesisui 0.11.2 → 0.12.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.
@@ -252,6 +252,14 @@ export async function doctor(opts) {
252
252
  r.findings = r.findings.filter((f) => !explained.has(`${r.file}:${f.line}:${f.literal.toLowerCase()}`));
253
253
  }
254
254
  const d = diagnose(reports);
255
+ // Nine releases in one evening added a section each, every one justified on
256
+ // its own, and nobody read the whole. The result was 151 lines carrying about
257
+ // eight lines of meaning. Detail is now something you ask for.
258
+ const verbose = opts.verbose === true;
259
+ const say = (line) => {
260
+ if (verbose)
261
+ console.log(line);
262
+ };
255
263
  console.log(section("Doctor"));
256
264
  console.log(body(hasSystem
257
265
  ? `${table.name ?? table.slug} v${table.version ?? "?"} - ${table.byName.size} tokens, ${d.scanned} files read`
@@ -266,10 +274,18 @@ export async function doctor(opts) {
266
274
  for (const r of reports) {
267
275
  if (!r.setAside)
268
276
  continue;
269
- aside.set(r.setAside.reason, (aside.get(r.setAside.reason) ?? 0) + r.setAside.count);
277
+ for (const a of r.setAside) {
278
+ aside.set(a.reason, (aside.get(a.reason) ?? 0) + a.count);
279
+ }
280
+ }
281
+ const asideTotal = [...aside.values()].reduce((n, v) => n + v, 0);
282
+ if (verbose) {
283
+ for (const [reason, count] of aside) {
284
+ console.log(body(`set aside: ${count} value(s) in ${reason}`));
285
+ }
270
286
  }
271
- for (const [reason, count] of aside) {
272
- console.log(body(`set aside: ${count} value(s) in ${reason}`));
287
+ else if (asideTotal > 0) {
288
+ console.log(body(`set aside ${asideTotal} value(s) a token could never hold (--verbose for why)`));
273
289
  }
274
290
  // 0 of 0 is not a perfect score, it is an empty measurement - printing a
275
291
  // full bar there would be the report's first lie.
@@ -280,18 +296,18 @@ export async function doctor(opts) {
280
296
  console.log(body(` ${d.tokenUses} from the system, ${d.findings.length} by hand`));
281
297
  }
282
298
  if (d.findings.length > 0) {
283
- console.log(section("Drift"));
299
+ say(section("Drift"));
284
300
  const order = ["color", "radius", "spacing", "font"].filter((k) => d.counts[k] > 0);
285
301
  for (const kind of order) {
286
- console.log(body(`${d.counts[kind]} ${KIND_LABEL[kind]}`));
302
+ say(body(`${d.counts[kind]} ${KIND_LABEL[kind]}`));
287
303
  }
288
304
  // What a person actually acts on first: the value repeated everywhere.
289
305
  // One decision here retires dozens of sites, and a list sorted by file
290
306
  // never tells you that.
291
307
  const repeats = d.repeats.slice(0, 5);
292
308
  if (repeats.length > 0) {
293
- console.log("");
294
- console.log(body("Most repeated"));
309
+ say("");
310
+ say(body("Most repeated"));
295
311
  const w = Math.max(...repeats.map((r) => r.literal.length));
296
312
  for (const r of repeats) {
297
313
  const where = `${r.count}\u00d7 in ${r.files} file${r.files === 1 ? "" : "s"}`;
@@ -301,17 +317,17 @@ export async function doctor(opts) {
301
317
  : near
302
318
  ? ` → nearest is ${near.name} (${near.value})`
303
319
  : "";
304
- console.log(` ${r.literal.padEnd(w)} ${where}${named}`);
320
+ say(` ${r.literal.padEnd(w)} ${where}${named}`);
305
321
  }
306
322
  }
307
323
  // Loudest files first: drift concentrates, and the fix is usually one
308
324
  // shared component rather than two hundred call sites.
309
325
  const files = [...d.files].sort((a, b) => b.findings.length - a.findings.length);
310
- const shownFiles = opts.all ? files : files.slice(0, 8);
311
- console.log("");
326
+ const shownFiles = verbose ? files : files.slice(0, 8);
327
+ say("");
312
328
  for (const f of shownFiles) {
313
- console.log(body(`${f.file}`));
314
- const shown = opts.all ? f.findings : f.findings.slice(0, 3);
329
+ say(body(`${f.file}`));
330
+ const shown = verbose ? f.findings : f.findings.slice(0, 3);
315
331
  for (const x of shown) {
316
332
  // A dead end with a neighbour is not a dead end. Only for lengths -
317
333
  // "nearly the same blue" is the guess this tool must never make.
@@ -321,15 +337,15 @@ export async function doctor(opts) {
321
337
  : near
322
338
  ? `→ nearest is ${near.name} (${near.value})`
323
339
  : "→ no token holds this value yet";
324
- console.log(` ${String(x.line).padStart(4)} ${x.literal} ${named}`);
340
+ say(` ${String(x.line).padStart(4)} ${x.literal} ${named}`);
325
341
  }
326
342
  if (f.findings.length > shown.length) {
327
- console.log(` +${f.findings.length - shown.length} more`);
343
+ say(` +${f.findings.length - shown.length} more`);
328
344
  }
329
- console.log("");
345
+ say("");
330
346
  }
331
347
  if (files.length > shownFiles.length) {
332
- console.log(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
348
+ say(body(`+${files.length - shownFiles.length} more files. Run with --all to see everything.`));
333
349
  }
334
350
  }
335
351
  // Every other section asks whether the code obeys the system. This one asks
@@ -339,19 +355,19 @@ export async function doctor(opts) {
339
355
  const conflicts = documents.flatMap((doc) => findSelfConflicts(doc));
340
356
  const conflictsInUse = conflicts.filter((c) => used.has(c.component));
341
357
  if (conflictsInUse.length > 0) {
342
- console.log(section("Your system contradicts itself"));
343
- console.log(body(conflictsInUse.length === 1
358
+ say(section("Your system contradicts itself"));
359
+ say(body(conflictsInUse.length === 1
344
360
  ? "One component says one thing in prose and another in its recipe."
345
361
  : `${conflictsInUse.length} components say one thing in prose and another in their recipe.`));
346
- console.log("");
362
+ say("");
347
363
  for (const c of conflictsInUse) {
348
- console.log(body(`ds-${c.component}`));
349
- console.log(` "${c.law}"`);
350
- console.log(` but ${c.where} binds ${c.value}`);
351
- console.log("");
364
+ say(body(`ds-${c.component}`));
365
+ say(` "${c.law}"`);
366
+ say(` but ${c.where} binds ${c.value}`);
367
+ say("");
352
368
  }
353
- console.log(body("Whoever wrote the law and whoever wrote the recipe disagree."));
354
- console.log(body("Until they do not, no code here can be correct."));
369
+ say(body("Whoever wrote the law and whoever wrote the recipe disagree."));
370
+ say(body("Until they do not, no code here can be correct."));
355
371
  }
356
372
  // The other way a system fails itself: a recipe that names a SHELF where the
357
373
  // system has a ROLE. The value is legitimate, the reference resolves, the CSS
@@ -360,22 +376,24 @@ export async function doctor(opts) {
360
376
  .flatMap((doc) => findFrozenBindings(doc))
361
377
  .filter((f) => used.has(f.component));
362
378
  if (frozen.length > 0) {
363
- console.log(section("These will not follow your other scheme"));
364
- console.log(body(frozen.length === 1
379
+ say(section("These will not follow your other scheme"));
380
+ say(body(frozen.length === 1
365
381
  ? "One recipe names a primitive where a role holds the same value."
366
382
  : `${frozen.length} recipes name a primitive where a role holds the same value.`));
367
- console.log("");
383
+ say("");
368
384
  for (const f of frozen) {
369
- console.log(body(`ds-${f.component} · ${f.where}`));
370
- console.log(` binds ${f.wrote}`);
371
- console.log(` {color.semantic.${f.role}} holds that, and becomes ${f.becomes}`);
372
- console.log("");
385
+ say(body(`ds-${f.component} · ${f.where}`));
386
+ say(` binds ${f.wrote}`);
387
+ say(` {color.semantic.${f.role}} holds that, and becomes ${f.becomes}`);
388
+ say("");
373
389
  }
374
- console.log(body("The value resolves and the CSS compiles, so nothing"));
375
- console.log(body("complains - the surface just stays put when the scheme"));
376
- console.log(body("moves around it."));
390
+ say(body("The value resolves and the CSS compiles, so nothing"));
391
+ say(body("complains - the surface just stays put when the scheme"));
392
+ say(body("moves around it."));
377
393
  }
378
394
  const frozenAt = new Set(frozen.map((f) => `${f.component}:${f.where.split(" · ").pop()}`));
395
+ let offSystemCount = 0;
396
+ let lawKeepingCount = 0;
379
397
  if (overrides.length > 0) {
380
398
  // An override on a property the component's own law FORBIDS, written as a
381
399
  // reset, is not drift - it is the author keeping a promise the recipe
@@ -404,6 +422,7 @@ export async function doctor(opts) {
404
422
  };
405
423
  const drifting = overrides.filter((o) => lawKept(o) === null);
406
424
  const correcting = overrides.length - drifting.length;
425
+ lawKeepingCount = correcting;
407
426
  /**
408
427
  * Two very different acts wearing one label. Choosing a DIFFERENT token
409
428
  * from the system is a decision inside the vocabulary - the recipe says
@@ -429,18 +448,19 @@ export async function doctor(opts) {
429
448
  // REMOVES what the recipe sets, and no token can hold "no transform".
430
449
  // Marking it as a raw value asked for something that cannot exist.
431
450
  const offSystem = drifting.filter((o) => !onSystem(o) && !isReset(o.wrote)).length;
432
- console.log(section("Overruled"));
433
- console.log(body(`${drifting.length} place${drifting.length === 1 ? "" : "s"} where the code takes a component`));
434
- console.log(body("the system defines, and then overrules it locally."));
435
- console.log("");
436
- console.log(body(`${offSystem} of them leave the system entirely; the rest pick a different token.`));
451
+ offSystemCount = offSystem;
452
+ say(section("Overruled"));
453
+ say(body(`${drifting.length} place${drifting.length === 1 ? "" : "s"} where the code takes a component`));
454
+ say(body("the system defines, and then overrules it locally."));
455
+ say("");
456
+ say(body(`${offSystem} of them leave the system entirely; the rest pick a different token.`));
437
457
  if (correcting > 0) {
438
- console.log("");
439
- console.log(body(correcting === 1
458
+ say("");
459
+ say(body(correcting === 1
440
460
  ? "One more overrules it to KEEP a law the recipe breaks - marked below."
441
461
  : `${correcting} more overrule it to KEEP a law the recipe breaks - marked below.`));
442
462
  }
443
- console.log("");
463
+ say("");
444
464
  const byFile = new Map();
445
465
  for (const o of overrides) {
446
466
  const list = byFile.get(o.file);
@@ -449,20 +469,20 @@ export async function doctor(opts) {
449
469
  else
450
470
  byFile.set(o.file, [o]);
451
471
  }
452
- const shown = opts.all ? [...byFile] : [...byFile].slice(0, 6);
472
+ const shown = verbose ? [...byFile] : [...byFile].slice(0, 6);
453
473
  for (const [file, list] of shown) {
454
- console.log(body(file));
455
- for (const o of opts.all ? list : list.slice(0, 3)) {
456
- console.log(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
474
+ say(body(file));
475
+ for (const o of verbose ? list : list.slice(0, 3)) {
476
+ say(` ${String(o.line).padStart(4)} ds-${o.component} · ${o.prop}: ${o.wrote}`);
457
477
  // Always say what is being overruled. A finding that cannot name it is
458
478
  // indistinguishable from a bug in the reader's eyes, and one of these
459
479
  // (`.ds-card:hover { transform: none }`) was a real, deliberate call.
460
480
  if (o.recipe)
461
- console.log(` the recipe binds ${o.recipe}`);
481
+ say(` the recipe binds ${o.recipe}`);
462
482
  else if (o.where)
463
- console.log(` the recipe binds it ${o.where}`);
483
+ say(` the recipe binds it ${o.where}`);
464
484
  if (lawKept(o) === null && !onSystem(o)) {
465
- console.log(isReset(o.wrote)
485
+ say(isReset(o.wrote)
466
486
  ? " ↑ removes it rather than replacing it"
467
487
  : " ↑ a raw value, not a token");
468
488
  }
@@ -471,21 +491,21 @@ export async function doctor(opts) {
471
491
  // that as an infraction is the same unfairness as the law case above.
472
492
  if (frozenAt.has(`${o.component}:${o.prop}`) &&
473
493
  /var\(\s*--ds-color-semantic-/.test(o.wrote)) {
474
- console.log(" ✓ the recipe is frozen here; yours names a role");
494
+ say(" ✓ the recipe is frozen here; yours names a role");
475
495
  }
476
496
  const kept = lawKept(o);
477
497
  if (kept) {
478
- console.log(` ✓ but the law says: "${kept}"`);
479
- console.log(" this override keeps it - the recipe does not");
498
+ say(` ✓ but the law says: "${kept}"`);
499
+ say(" this override keeps it - the recipe does not");
480
500
  }
481
501
  }
482
- if (!opts.all && list.length > 3) {
483
- console.log(` +${list.length - 3} more`);
502
+ if (!verbose && list.length > 3) {
503
+ say(` +${list.length - 3} more`);
484
504
  }
485
- console.log("");
505
+ say("");
486
506
  }
487
507
  if (byFile.size > shown.length) {
488
- console.log(body(`+${byFile.size - shown.length} more files. Run with --all.`));
508
+ say(body(`+${byFile.size - shown.length} more files. Run with --all.`));
489
509
  }
490
510
  }
491
511
  // The system's own words, for the components this project actually uses.
@@ -496,28 +516,114 @@ export async function doctor(opts) {
496
516
  .filter(([name]) => (recipes.get(name)?.usage.length ?? 0) > 0)
497
517
  .sort((a, b) => b[1] - a[1]);
498
518
  if (inUse.length > 0) {
499
- console.log(section("What your system says about what you use"));
500
- const shownComps = opts.laws || opts.all ? inUse : inUse.slice(0, 4);
519
+ say(section("What your system says about what you use"));
520
+ const shownComps = opts.laws || verbose ? inUse : inUse.slice(0, 4);
501
521
  for (const [name, count] of shownComps) {
502
522
  const laws = recipes.get(name)?.usage ?? [];
503
- const shown = opts.laws || opts.all ? laws : laws.slice(0, 2);
504
- console.log(body(`ds-${name} · ${count} place${count === 1 ? "" : "s"}`));
523
+ const shown = opts.laws || verbose ? laws : laws.slice(0, 2);
524
+ say(body(`ds-${name} · ${count} place${count === 1 ? "" : "s"}`));
505
525
  for (const law of shown)
506
- console.log(` ${law}`);
526
+ say(` ${law}`);
507
527
  if (laws.length > shown.length) {
508
- console.log(` +${laws.length - shown.length} more`);
528
+ say(` +${laws.length - shown.length} more`);
509
529
  }
510
- console.log("");
530
+ say("");
511
531
  }
512
532
  if (inUse.length > shownComps.length) {
513
- console.log(body(`+${inUse.length - shownComps.length} more components carry laws. Run with --laws.`));
514
- console.log("");
533
+ say(body(`+${inUse.length - shownComps.length} more components carry laws. Run with --laws.`));
534
+ say("");
515
535
  }
516
536
  }
517
- console.log(section("What this means"));
537
+ say(section("What this means"));
518
538
  for (const line of verdict(d, hasSystem, overrides.length, conflictsInUse.length))
519
- console.log(line);
520
- console.log("");
539
+ say(line);
540
+ say("");
541
+ const plan = [];
542
+ for (const c of conflictsInUse) {
543
+ plan.push({
544
+ rank: 0,
545
+ what: `the ds-${c.component} contradiction`,
546
+ size: `${used.get(c.component) ?? 0} places`,
547
+ cheap: false,
548
+ });
549
+ }
550
+ for (const f of frozen) {
551
+ plan.push({
552
+ rank: 1,
553
+ what: `the frozen ds-${f.component} ${f.where.split(" · ").pop()}`,
554
+ size: `${used.get(f.component) ?? 0} places`,
555
+ cheap: true,
556
+ });
557
+ }
558
+ if (offSystemCount > 0) {
559
+ plan.push({
560
+ rank: 2,
561
+ what: `the ${offSystemCount} override${offSystemCount === 1 ? "" : "s"} that left the system`,
562
+ size: `${offSystemCount} place${offSystemCount === 1 ? "" : "s"}`,
563
+ cheap: false,
564
+ });
565
+ }
566
+ for (const r of d.repeats.filter((x) => x.token).slice(0, 3)) {
567
+ plan.push({
568
+ rank: 3,
569
+ what: `${r.literal} → ${r.token}`,
570
+ size: `${r.files} file${r.files === 1 ? "" : "s"}`,
571
+ cheap: true,
572
+ });
573
+ }
574
+ for (const r of d.repeats.filter((x) => !x.token).slice(0, 2)) {
575
+ const near = nearestToken(table, r.literal);
576
+ plan.push({
577
+ rank: 4,
578
+ what: near
579
+ ? `${r.literal} - name it, or snap to ${near.name}`
580
+ : `${r.literal} - the system has no name for it`,
581
+ size: `${r.files} file${r.files === 1 ? "" : "s"}`,
582
+ cheap: false,
583
+ });
584
+ }
585
+ plan.sort((a, b) => a.rank - b.rank);
586
+ if (!verbose && !hasSystem) {
587
+ // The cold run is the whole pitch: someone with no system, one command, a
588
+ // number they did not have. The summary must not swallow it - it is the
589
+ // only path here that has to persuade rather than inform.
590
+ console.log("");
591
+ for (const line of verdict(d, false, 0, 0))
592
+ console.log(line);
593
+ console.log("");
594
+ }
595
+ else if (!verbose) {
596
+ if (conflictsInUse.length + frozen.length > 0) {
597
+ console.log("");
598
+ console.log(body(`${conflictsInUse.length + frozen.length} problem(s) in the SYSTEM - not fixable from this repo`));
599
+ for (const c of conflictsInUse) {
600
+ console.log(` ds-${c.component} the law forbids ${c.forbids}, the recipe binds it`);
601
+ }
602
+ for (const f of frozen) {
603
+ console.log(` ds-${f.component} ${f.where.split(" · ").pop()} frozen at a primitive, will not follow the other scheme`);
604
+ }
605
+ }
606
+ if (d.findings.length > 0 || overrides.length > 0) {
607
+ console.log("");
608
+ console.log(body(`${d.findings.length} value(s) by hand · ${d.named} already have a name`));
609
+ console.log(body(`${overrides.length - lawKeepingCount} override(s) · ${offSystemCount} left the system` +
610
+ (lawKeepingCount > 0
611
+ ? ` · ${lawKeepingCount} more kept a law`
612
+ : "")));
613
+ }
614
+ if (plan.length > 0) {
615
+ console.log("");
616
+ console.log(body("Where to start"));
617
+ const w = Math.min(52, Math.max(...plan.slice(0, 5).map((j) => j.what.length)));
618
+ plan.slice(0, 5).forEach((j, i) => {
619
+ const what = j.what.length > w ? `${j.what.slice(0, w - 1)}…` : j.what.padEnd(w);
620
+ console.log(` ${i + 1}. ${what} ${j.size}${j.cheap ? " (cheap)" : ""}`);
621
+ });
622
+ }
623
+ console.log("");
624
+ console.log(body("synthesisui doctor --verbose every finding, file by file"));
625
+ console.log("");
626
+ }
521
627
  if (opts.strict) {
522
628
  const mine = d.findings.length > 0 || overrides.length > 0;
523
629
  // The two system lenses are deliberately NOT in here by default. A team
@@ -60,16 +60,21 @@ const IDIOM = new Set(["0", "0px", "1px", "9999px", "100%", "50%"]);
60
60
  export function scanSource(file, source, table) {
61
61
  const findings = [];
62
62
  let tokenUses = 0;
63
- let aside = 0;
63
+ // Reason by reason. Rolling two into "A or B" was the one place the report
64
+ // still lumped things it had told apart everywhere else.
65
+ const aside = new Map();
66
+ const setAside = (reason) => aside.set(reason, (aside.get(reason) ?? 0) + 1);
64
67
  if (RENDERS_IMAGE.test(source)) {
65
68
  return {
66
69
  file,
67
70
  findings: [],
68
71
  tokenUses: 0,
69
- setAside: {
70
- reason: "renders to an image, where CSS variables do not exist",
71
- count: (source.match(COLOR) ?? []).length,
72
- },
72
+ setAside: [
73
+ {
74
+ reason: "renders to an image, where CSS variables do not exist",
75
+ count: (source.match(COLOR) ?? []).length,
76
+ },
77
+ ],
73
78
  };
74
79
  }
75
80
  let svgDepth = 0;
@@ -101,7 +106,7 @@ export function scanSource(file, source, table) {
101
106
  if (literal.includes("$") || literal.includes("{"))
102
107
  return;
103
108
  if (col >= 0 && inFallback(col)) {
104
- aside++;
109
+ setAside("a token's own fallback");
105
110
  return;
106
111
  }
107
112
  const key = `${kind}:${literal}`;
@@ -123,7 +128,7 @@ export function scanSource(file, source, table) {
123
128
  (before.match(/<\/svg>/g) ?? []).length;
124
129
  // inside an <svg>, a colour on fill= or stroke= is paint, not surface
125
130
  if (depthHere > 0 && SVG_PAINT.test(before)) {
126
- aside++;
131
+ setAside("SVG artwork");
127
132
  continue;
128
133
  }
129
134
  push("color", m[0], m.index ?? -1);
@@ -150,12 +155,9 @@ export function scanSource(file, source, table) {
150
155
  file,
151
156
  findings,
152
157
  tokenUses,
153
- ...(aside > 0
158
+ ...(aside.size > 0
154
159
  ? {
155
- setAside: {
156
- reason: "SVG artwork or a token's own fallback",
157
- count: aside,
158
- },
160
+ setAside: [...aside].map(([reason, count]) => ({ reason, count })),
159
161
  }
160
162
  : null),
161
163
  };
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ Usage - deterministic, FREE:
26
26
  synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
27
27
  synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
28
28
  synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
29
- synthesisui doctor [paths…] [--all] audit for DRIFT: every design value written by
29
+ synthesisui doctor [paths…] [--verbose] audit for DRIFT: every design value written by
30
30
  hand, the token your system already has for it, and the
31
31
  laws your system carries for what you use
32
32
 
@@ -54,7 +54,7 @@ Options:
54
54
  --force clean: apply the changes (without it, dry run)
55
55
  --strict doctor: exit 1 when drift is found in THIS repo (for CI)
56
56
  --strict-system doctor: also exit 1 when the system itself is inconsistent
57
- --all doctor: list every finding, not just the loudest files
57
+ --verbose doctor: every finding, file by file (default is a summary)
58
58
  --laws doctor: show every usage law, not just the busiest components
59
59
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
60
60
  -h, --help this help
@@ -129,7 +129,9 @@ async function main() {
129
129
  scopes: args,
130
130
  strict: flags.strict === true || flags["strict-system"] === true,
131
131
  strictSystem: flags["strict-system"] === true,
132
- all: flags.all === true,
132
+ // --all is the old spelling; it keeps working silently so a script
133
+ // written last week does not break.
134
+ verbose: flags.verbose === true || flags.all === true,
133
135
  laws: flags.laws === true,
134
136
  });
135
137
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.11.2",
3
+ "version": "0.12.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": {