synthesisui 0.10.1 → 0.11.1

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,5 +1,6 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import { findFrozenBindings } from "../doctor/frozen.js";
3
4
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
4
5
  import { diagnose, scanSource, } from "../doctor/scan.js";
5
6
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
@@ -352,6 +353,28 @@ export async function doctor(opts) {
352
353
  console.log(body("Whoever wrote the law and whoever wrote the recipe disagree."));
353
354
  console.log(body("Until they do not, no code here can be correct."));
354
355
  }
356
+ // The other way a system fails itself: a recipe that names a SHELF where the
357
+ // system has a ROLE. The value is legitimate, the reference resolves, the CSS
358
+ // compiles - and the binding sits still while everything around it flips.
359
+ const frozen = documents
360
+ .flatMap((doc) => findFrozenBindings(doc))
361
+ .filter((f) => used.has(f.component));
362
+ if (frozen.length > 0) {
363
+ console.log(section("These will not follow your other scheme"));
364
+ console.log(body(frozen.length === 1
365
+ ? "One recipe names a primitive where a role holds the same value."
366
+ : `${frozen.length} recipes name a primitive where a role holds the same value.`));
367
+ console.log("");
368
+ 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("");
373
+ }
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."));
377
+ }
355
378
  if (overrides.length > 0) {
356
379
  // An override on a property the component's own law FORBIDS, written as a
357
380
  // reset, is not drift - it is the author keeping a promise the recipe
@@ -487,6 +510,23 @@ export async function doctor(opts) {
487
510
  for (const line of verdict(d, hasSystem, overrides.length, conflictsInUse.length))
488
511
  console.log(line);
489
512
  console.log("");
490
- if (opts.strict && (d.findings.length > 0 || overrides.length > 0))
491
- process.exitCode = 1;
513
+ if (opts.strict) {
514
+ const mine = d.findings.length > 0 || overrides.length > 0;
515
+ // The two system lenses are deliberately NOT in here by default. A team
516
+ // that installed someone else's system cannot fix its self-contradiction
517
+ // by editing this repo, and failing their build for it punishes the wrong
518
+ // person. It still prints, so a CI log carries it upstream. Anyone who
519
+ // authors their own system wants the opposite, and asks for it.
520
+ const theirs = conflictsInUse.length + frozen.length;
521
+ if (opts.strictSystem && theirs > 0) {
522
+ console.log(body(`--strict-system: ${theirs} problem(s) in the system itself. Failing.`));
523
+ process.exitCode = 1;
524
+ }
525
+ else if (theirs > 0) {
526
+ console.log(body(`${theirs} problem(s) above are in the system, not this repo - not failing.`));
527
+ console.log(body("Run with --strict-system if the system is yours."));
528
+ }
529
+ if (mine)
530
+ process.exitCode = 1;
531
+ }
492
532
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * DOCTOR · bindings that will not follow the scheme.
3
+ *
4
+ * A system with two schemes keeps the flip in ONE place: the semantic layer.
5
+ * `surface` points at navy-800 in dark and navy-50 in light, and every recipe
6
+ * that binds `{color.semantic.surface}` flips for free.
7
+ *
8
+ * A recipe that reaches past the role and names the shelf - `{color.navy.800}` -
9
+ * is frozen. It looks identical in the authored scheme and wrong in the other
10
+ * one, and nothing in the pipeline says so: the value is a legitimate token,
11
+ * the reference resolves, the CSS compiles.
12
+ *
13
+ * Vesper does this in five places. Its chip binds `{color.navy.800}` for a
14
+ * background where `semantic.surface` holds the same value and becomes navy-50
15
+ * in light mode - so the chip stays dark on a light page (found by reading the
16
+ * doctor's own output on a real project, investidorez, 25/07).
17
+ *
18
+ * This is a correctness check, not a style one. It fires only when the role
19
+ * actually differs between the two schemes; a role that is the same in both
20
+ * costs nothing to spell either way, and this stays quiet about it.
21
+ */
22
+ const PRIMITIVE = /^\{color\.([a-z0-9]+)\.([a-z0-9]+)\}$/i;
23
+ const BACKGROUND = /^background(-color)?$/;
24
+ const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
25
+ function* colorBindings(node, where) {
26
+ if (!node || typeof node !== "object")
27
+ return;
28
+ for (const [k, v] of Object.entries(node)) {
29
+ if (typeof v === "string") {
30
+ // Backgrounds only. A FOREGROUND bound to a primitive is usually right:
31
+ // white on an indigo button must stay white, because the fill it sits on
32
+ // does not flip either. Deciding that needs the sibling fill and the
33
+ // author's intent, so this lens does not try - a wrong accusation here
34
+ // would have someone invert readable text.
35
+ if (BACKGROUND.test(kebab(k)) && PRIMITIVE.test(v))
36
+ yield { prop: kebab(k), value: v, where };
37
+ }
38
+ else if (v && typeof v === "object") {
39
+ yield* colorBindings(v, where);
40
+ }
41
+ }
42
+ }
43
+ export function findFrozenBindings(document) {
44
+ const doc = (document ?? {});
45
+ const foundations = (doc.foundations ?? {});
46
+ const color = (foundations.color ?? {});
47
+ const semantic = (color.semantic ?? {});
48
+ const alt = (color.semanticAlt ?? {});
49
+ // Only roles that actually MOVE between schemes. A role spelled the same in
50
+ // both is not a correctness problem, and this lens does not do taste.
51
+ //
52
+ // Several roles can hold one primitive (Vesper's navy-700 is raised, overlay
53
+ // AND border). Surfaces are preferred, because this only ever reports a
54
+ // background - naming "border" for a background reads like a bug in the tool.
55
+ const RANK = ["surface", "raised", "canvas", "overlay", "border"];
56
+ const roleOf = new Map();
57
+ for (const [role, ref] of Object.entries(semantic)) {
58
+ const other = alt[role];
59
+ if (typeof ref !== "string" || typeof other !== "string")
60
+ continue;
61
+ if (other === ref)
62
+ continue;
63
+ const m = PRIMITIVE.exec(ref);
64
+ if (!m)
65
+ continue;
66
+ const key = `${m[1]}.${m[2]}`.toLowerCase();
67
+ const held = roleOf.get(key);
68
+ const better = !held ||
69
+ (RANK.indexOf(role) !== -1 &&
70
+ (RANK.indexOf(held.role) === -1 ||
71
+ RANK.indexOf(role) < RANK.indexOf(held.role)));
72
+ if (better)
73
+ roleOf.set(key, { role, becomes: other });
74
+ }
75
+ if (roleOf.size === 0)
76
+ return [];
77
+ const out = [];
78
+ const components = (doc.components ?? {});
79
+ for (const [name, raw] of Object.entries(components)) {
80
+ const recipe = (raw ?? {});
81
+ const found = [
82
+ ...colorBindings(recipe.base, "base"),
83
+ ...colorBindings(recipe.states, "a state"),
84
+ ...colorBindings(recipe.variants, "a variant"),
85
+ ...colorBindings(recipe.parts, "a part"),
86
+ ];
87
+ for (const b of found) {
88
+ const m = PRIMITIVE.exec(b.value);
89
+ if (!m)
90
+ continue;
91
+ const hit = roleOf.get(`${m[1]}.${m[2]}`.toLowerCase());
92
+ if (!hit)
93
+ continue;
94
+ out.push({
95
+ component: name,
96
+ where: `${b.where} · ${b.prop}`,
97
+ wrote: b.value,
98
+ role: hit.role,
99
+ becomes: hit.becomes,
100
+ });
101
+ }
102
+ }
103
+ return out;
104
+ }
package/dist/index.js CHANGED
@@ -52,7 +52,8 @@ Options:
52
52
  --instruction <s> refit: extra guidance for the adaptation
53
53
  --dry refit: adapt and print, but save nothing
54
54
  --force clean: apply the changes (without it, dry run)
55
- --strict doctor: exit 1 when drift is found (for CI)
55
+ --strict doctor: exit 1 when drift is found in THIS repo (for CI)
56
+ --strict-system doctor: also exit 1 when the system itself is inconsistent
56
57
  --all doctor: list every finding, not just the loudest files
57
58
  --laws doctor: show every usage law, not just the busiest components
58
59
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
@@ -126,7 +127,8 @@ async function main() {
126
127
  await doctor({
127
128
  dir,
128
129
  scopes: args,
129
- strict: flags.strict === true,
130
+ strict: flags.strict === true || flags["strict-system"] === true,
131
+ strictSystem: flags["strict-system"] === true,
130
132
  all: flags.all === true,
131
133
  laws: flags.laws === true,
132
134
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
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": {