synthesisui 0.16.17 → 0.16.19

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/claude-md.js CHANGED
@@ -281,11 +281,11 @@ YOURSELF. It materializes that component as real typed code in this project, and
281
281
  extend that. The person who asked you for a feature should never have to know these command
282
282
  names or type them; finding the right component is your job, not theirs.
283
283
 
284
- When you override a style a component already sets, add \`!\` to your class:
285
- \`className="text-info!"\`, not \`className="text-info"\`. Both are plain utilities at the
286
- same specificity, so which one wins is decided by the order Tailwind writes the stylesheet -
287
- not by the order of the class names, which is what the code looks like it controls. This
288
- applies to the Tailwind flavour only; the CSS one is layered and needs no marker.
284
+ Overriding a style a component already sets just works - write
285
+ \`className="text-info"\`, with no \`!\`. The generated components resolve your class
286
+ against their own, so the last one written wins, which is what the call site reads like.
287
+ If an override is ignored, that component predates the resolver: regenerate it with
288
+ \`npx synthesisui@latest component <slug> <name>\` rather than reaching for \`!\`.
289
289
 
290
290
  Only write something new when nothing in the manifest covers the purpose - and when you do,
291
291
  say which entry you considered and why it did not fit. To review a
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `cn()`: the override the consumer writes has to actually win.
3
+ *
4
+ * THE BUG. Two plain Tailwind utilities that set the same property have the
5
+ * same specificity, so which one applies is decided by the order Tailwind emits
6
+ * them into the stylesheet - not by the order of names in the string, which is
7
+ * the only thing the call site appears to control. A component whose base says
8
+ * `text-foreground` therefore ignores `className="text-info"`, silently, and
9
+ * the code reads as if it should work.
10
+ *
11
+ * THE MITIGATION WE SHIPPED FIRST, and why it was not enough. On 27/07 the
12
+ * managed block started telling agents to write `text-info!`. It works, and the
13
+ * test on 28/07 measured what it costs: 61 exclamation marks in 1,885 generated
14
+ * lines, one forced escape every thirty lines. It also has a hole - two `!`
15
+ * utilities on the same property tie again, so it moves the coin flip up a
16
+ * level rather than removing it.
17
+ *
18
+ * WHY OURS CAN BE SMALLER THAN `tailwind-merge`. That library infers at runtime
19
+ * and cannot see your theme, so it carries a table of every Tailwind class group
20
+ * and still guesses at custom names. We generate the resolver next to the
21
+ * system, so it is not guessing: `text-foreground` is a colour because
22
+ * `foreground` is in this system's colour namespace, and `text-lg` is a size
23
+ * because `lg` is in its type scale. Those two families are the only genuinely
24
+ * ambiguous ones; everything else is decided by the prefix.
25
+ *
26
+ * WHAT IT DELIBERATELY DOES NOT DO. It resolves collisions on the SAME property
27
+ * key - `bg-a` against `bg-b`, `p-md` against `p-lg`. It does not model the
28
+ * hierarchy between `p` and `px` and `pt`, because Tailwind's own emission order
29
+ * already resolves those correctly, and pretending to solve more would be a
30
+ * second thing to be wrong about.
31
+ */
32
+ /**
33
+ * Reads the `@theme` namespaces out of a compiled theme.css.
34
+ *
35
+ * `--color-foreground: …` means the project has a `text-foreground`,
36
+ * `bg-foreground` and `border-foreground`. Suffixes like
37
+ * `--text-lg--line-height` are declarations ABOUT a name, not names, so the
38
+ * double dash is what excludes them.
39
+ */
40
+ export function readVocabulary(themeCss) {
41
+ const namesIn = (ns) => {
42
+ const out = new Set();
43
+ const re = new RegExp(`^\\s*--${ns}-([a-z0-9-]+)\\s*:`, "gm");
44
+ for (const m of themeCss.matchAll(re)) {
45
+ if (!m[1].includes("--"))
46
+ out.add(m[1]);
47
+ }
48
+ return [...out].sort();
49
+ };
50
+ const font = namesIn("font");
51
+ const weights = namesIn("font-weight");
52
+ return {
53
+ colors: namesIn("color"),
54
+ textSizes: namesIn("text"),
55
+ // `--font-weight-medium` also matches the `font` namespace as
56
+ // `weight-medium`; the real families are what is left after removing them.
57
+ fontFamilies: font.filter((f) => !f.startsWith("weight-")),
58
+ fontWeights: weights,
59
+ };
60
+ }
61
+ const set = (names) => `new Set([${names.map((n) => JSON.stringify(n)).join(", ")}])`;
62
+ /** The `cn.ts` a project gets, written against ITS system's vocabulary. */
63
+ export function emitCn(slug, vocab) {
64
+ return `/**
65
+ * Class resolver for the "${slug}" design system. Generated by SynthesisUI.
66
+ *
67
+ * Two plain Tailwind utilities setting the same property have the same
68
+ * specificity, so the winner is decided by the order Tailwind writes the
69
+ * stylesheet - not by the order in your className, which is what the code looks
70
+ * like it controls. A component whose base sets \`text-foreground\` would ignore
71
+ * \`className="text-info"\` without any error.
72
+ *
73
+ * This drops the earlier class when a later one sets the same property, so the
74
+ * last one wins, which is what the call site reads like. Pass the base first and
75
+ * the caller's className last.
76
+ *
77
+ * It resolves collisions on the same property key (\`bg-a\` vs \`bg-b\`, \`p-md\` vs
78
+ * \`p-lg\`). It does not model \`p\` against \`px\` against \`pt\`: Tailwind's own
79
+ * emission order already gets those right.
80
+ *
81
+ * Regenerated by \`synthesisui component\`. Safe to edit, but a new component
82
+ * will overwrite it.
83
+ */
84
+
85
+ /** This system's own namespaces, so \`text-*\` and \`font-*\` are never a guess. */
86
+ const COLORS = ${set(vocab.colors)};
87
+ const TEXT_SIZES = ${set(vocab.textSizes)};
88
+ const FONT_FAMILIES = ${set(vocab.fontFamilies)};
89
+ const FONT_WEIGHTS = ${set(vocab.fontWeights)};
90
+
91
+ /** Prefixes where the prefix alone decides the property. Longest first, so
92
+ * \`border-t\` is read before \`border\`. */
93
+ const PREFIXES = [
94
+ "bg",
95
+ "shadow",
96
+ "opacity",
97
+ "rounded-tl", "rounded-tr", "rounded-br", "rounded-bl",
98
+ "rounded-t", "rounded-r", "rounded-b", "rounded-l",
99
+ "rounded",
100
+ "border-t", "border-r", "border-b", "border-l", "border-x", "border-y",
101
+ "border",
102
+ "px", "py", "pt", "pr", "pb", "pl", "ps", "pe", "p",
103
+ "mx", "my", "mt", "mr", "mb", "ml", "ms", "me", "m",
104
+ "gap-x", "gap-y", "gap",
105
+ "w", "h", "min-w", "min-h", "max-w", "max-h",
106
+ "leading", "tracking",
107
+ "flex", "grid", "justify", "items", "self", "order", "z",
108
+ ];
109
+
110
+ /**
111
+ * The property this class sets, or the class itself when we cannot tell.
112
+ *
113
+ * Returning the class itself is the safe answer: an unknown class then collides
114
+ * only with an identical one, so nothing is ever dropped by mistake.
115
+ */
116
+ function groupOf(cls: string): string {
117
+ // A variant is part of the identity: \`hover:bg-x\` must not replace \`bg-y\`.
118
+ const cut = cls.lastIndexOf(":");
119
+ const variants = cut === -1 ? "" : cls.slice(0, cut + 1);
120
+ let base = cut === -1 ? cls : cls.slice(cut + 1);
121
+
122
+ // \`!\` marks importance, \`/40\` sets opacity. Neither changes the property.
123
+ base = base.replace(/^!|!$/g, "").replace(/\\/[^/]+$/, "");
124
+ const negative = base.startsWith("-");
125
+ if (negative) base = base.slice(1);
126
+
127
+ const dash = base.indexOf("-");
128
+ const head = dash === -1 ? base : base.slice(0, dash);
129
+ const rest = dash === -1 ? "" : base.slice(dash + 1);
130
+
131
+ // The two families a prefix cannot settle, answered by this system's names.
132
+ if (head === "text") {
133
+ if (TEXT_SIZES.has(rest)) return \`\${variants}font-size\`;
134
+ if (COLORS.has(rest) || rest.startsWith("[")) return \`\${variants}text-color\`;
135
+ return \`\${variants}\${cls}\`;
136
+ }
137
+ if (head === "font") {
138
+ if (FONT_WEIGHTS.has(rest)) return \`\${variants}font-weight\`;
139
+ if (FONT_FAMILIES.has(rest)) return \`\${variants}font-family\`;
140
+ return \`\${variants}\${cls}\`;
141
+ }
142
+
143
+ for (const p of PREFIXES) {
144
+ if (base === p || base.startsWith(\`\${p}-\`)) return \`\${variants}\${p}\`;
145
+ }
146
+ return \`\${variants}\${cls}\`;
147
+ }
148
+
149
+ export type ClassValue = string | false | null | undefined;
150
+
151
+ export function cn(...parts: ClassValue[]): string {
152
+ const won = new Map<string, string>();
153
+ for (const part of parts) {
154
+ if (!part) continue;
155
+ for (const cls of part.split(/\\s+/)) {
156
+ if (!cls) continue;
157
+ const key = groupOf(cls);
158
+ // Delete before set so the winner also takes the later POSITION. The
159
+ // string order does not decide the cascade, but it is what a person reads
160
+ // in the DOM, and a stale leading class there is a lie.
161
+ won.delete(key);
162
+ won.set(key, cls);
163
+ }
164
+ }
165
+ return [...won.values()].join(" ");
166
+ }
167
+ `;
168
+ }
@@ -1,10 +1,32 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { emitCn, readVocabulary } from "../cn-codegen.js";
3
4
  import { generateComponentFiles } from "../component-codegen.js";
4
5
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
6
  import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
6
7
  import { body, section, snippet } from "../output.js";
7
8
  import { fetchComponent, RegistryError } from "../registry.js";
9
+ /**
10
+ * Writes the shared `cn.ts` next to the components, built from THIS project's
11
+ * installed theme.
12
+ *
13
+ * Only the tailwind flavour needs it: in css mode the recipe lives in
14
+ * `@layer components` and every utility already outranks it. Generated from the
15
+ * theme on disk rather than from a table of Tailwind's own names, which is why
16
+ * it can tell `text-foreground` (a colour here) from `text-lg` (a size here)
17
+ * without guessing.
18
+ */
19
+ async function writeCn(root, compDir, slug) {
20
+ const theme = await readFile(join(root, "_synthesisui", "ds", slug, "theme.css"), "utf8").catch(() => "");
21
+ // The root file is a pointer at the pinned version; follow it.
22
+ const inner = /@import\s+["']\.\/([^"']+)["']/.exec(theme)?.[1];
23
+ const real = inner
24
+ ? await readFile(join(root, "_synthesisui", "ds", slug, inner), "utf8").catch(() => theme)
25
+ : theme;
26
+ if (!real)
27
+ return;
28
+ await writeFile(join(compDir, "..", "cn.ts"), emitCn(slug, readVocabulary(real)), "utf8");
29
+ }
8
30
  /**
9
31
  * The consumer's React major, or null when we cannot tell.
10
32
  *
@@ -85,6 +107,8 @@ export async function component(slug, name, opts) {
85
107
  for (const file of files) {
86
108
  await writeFile(join(compDir, file.filename), file.code, "utf8");
87
109
  }
110
+ if (config.styles === "tailwind")
111
+ await writeCn(root, compDir, slug);
88
112
  filenames = files.map((f) => f.filename);
89
113
  }
90
114
  const flavor = wantInteractive ? "interactive" : `styles: ${config.styles}`;
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import { findDivergences } from "../doctor/coherence.js";
3
4
  import { findFrozenBindings } from "../doctor/frozen.js";
4
5
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
5
6
  import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
@@ -633,6 +634,32 @@ export async function doctor(opts) {
633
634
  say(body("Whoever wrote the law and whoever wrote the recipe disagree."));
634
635
  say(body("Until they do not, no code here can be correct."));
635
636
  }
637
+ /**
638
+ * THE FAILURE THAT SURVIVES A PERFECT SCORE.
639
+ *
640
+ * Coverage asks whether the vocabulary was used. It cannot ask whether the
641
+ * system says ONE thing, because every component here can reference the right
642
+ * token and still answer the same question differently. Measured on 28/07 in
643
+ * a system this tool had just graded 100%: the input shows focus by moving its
644
+ * border, the button by drawing an outline.
645
+ *
646
+ * Reported quietly and only against a clear house style. Hover is legitimately
647
+ * different per component, and a checker that flagged variety would be
648
+ * uninstalled the same day it shipped.
649
+ */
650
+ const divergences = findDivergences(documents);
651
+ if (divergences.length > 0) {
652
+ say(section("The system answers the same question two ways"));
653
+ for (const d of divergences) {
654
+ say(body(`${d.concern}: ${d.houseCount} components use ${d.house.join(" + ")}.`));
655
+ for (const o of d.outliers) {
656
+ say(` ${o.component} uses ${o.props.join(" + ")} instead`);
657
+ }
658
+ say("");
659
+ }
660
+ say(body("Every one of these uses your tokens, so coverage says nothing"));
661
+ say(body("about them. A person meets the difference on screen."));
662
+ }
636
663
  // The other way a system fails itself: a recipe that names a SHELF where the
637
664
  // system has a ROLE. The value is legitimate, the reference resolves, the CSS
638
665
  // compiles - and the binding sits still while everything around it flips.
@@ -417,12 +417,33 @@ function header(slug, name, version, mode) {
417
417
  * which this generator has. Until it is built, the failure at least stops
418
418
  * being silent, which is the part that actually costs people time.
419
419
  */
420
+ /**
421
+ * This used to tell people to write `text-info!`, which worked and cost 61
422
+ * exclamation marks in 1,885 generated lines when it was measured (28/07). It
423
+ * also had a hole: two `!` utilities on the same property tie again.
424
+ *
425
+ * `cn()` removes the tie instead of moving it, so the instruction becomes a lie
426
+ * the moment it ships - and an instruction that asks for work the mechanism
427
+ * already did is the drift this product exists to catch.
428
+ */
420
429
  const OVERRIDE_WARNING = `//
421
- // Overriding a style this component already sets? Add \`!\`:
422
- // <Thing className="text-info!" /> not className="text-info"
423
- // Both are plain utilities at the same specificity, so which one wins is
424
- // decided by the stylesheet's order, not by this string's.`;
430
+ // Overriding a style this component already sets just works: \`className\` is
431
+ // resolved against the base by \`cn()\`, so the last one written wins.
432
+ // <Thing className="text-info" /> no \`!\` needed
433
+ // If an override is ignored, this file predates that resolver - regenerate it
434
+ // with \`npx synthesisui component <slug> <name>\`.`;
425
435
  const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
436
+ /**
437
+ * TAILWIND FLAVOUR ONLY, and the asymmetry is the point.
438
+ *
439
+ * In css mode the recipe compiles into `@layer components`, which every utility
440
+ * already outranks - so a caller's `className` wins by the cascade and a
441
+ * resolver would be dead weight. In tailwind mode the base IS utilities, tied
442
+ * with the caller's at the same specificity, and the winner is decided by
443
+ * stylesheet order. `cn()` drops the loser so the last one written actually
444
+ * applies, which is what the call site reads like.
445
+ */
446
+ const resolveCls = (parts) => `cn(${parts.join(", ")})`;
426
447
  /** JSDoc showing how to compose the component with its parts + content, so the
427
448
  * materialized code doesn't read as "a bare shell renders nothing" (dogfood
428
449
  * #5). Built from the recipe's parts. */
@@ -528,6 +549,7 @@ function emitTailwindMode(slug, name, recipe, version, props) {
528
549
  return `${header(slug, name, version, "tailwind")}
529
550
 
530
551
  import type { ${props} } from "react";
552
+ import { cn } from "../cn";
531
553
 
532
554
  const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
533
555
  ${[...variantConsts, ...booleanConsts].join("\n")}
@@ -538,7 +560,7 @@ ${compositionHint(comp, name, recipe, voidEl)}
538
560
  export function ${comp}({ ${destructure} }: ${comp}Props) {
539
561
  return (
540
562
  <${tag}${attrs}
541
- className={${joinCls(clsParts)}}
563
+ className={${resolveCls(clsParts)}}
542
564
  {...props}
543
565
  />
544
566
  );
@@ -553,7 +575,7 @@ ${Object.entries(recipe.parts ?? {})
553
575
  /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
554
576
  export function ${partComp}({ className, ...props }: ${props}<"${partTag}">) {
555
577
  return (
556
- <${partTag}${partAttrs} className={${joinCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
578
+ <${partTag}${partAttrs} className={${resolveCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
557
579
  );
558
580
  }`;
559
581
  })
@@ -0,0 +1,117 @@
1
+ /**
2
+ * DOES THE SYSTEM AGREE WITH ITSELF?
3
+ *
4
+ * Token coverage answers a different question than everybody assumes. It asks
5
+ * whether the vocabulary was used. It cannot ask whether the system says one
6
+ * thing, because every component can reference the correct token and still
7
+ * express the same idea in a different way.
8
+ *
9
+ * Measured on 28/07, in a system this tool had just graded at 100%: the input
10
+ * shows focus by moving its border, the button by drawing an outline. Same
11
+ * token, same toolbar, two answers to one question. Every deterministic
12
+ * assertion we had passed, and a person looking at the screen sees a system
13
+ * that has not decided what focus looks like.
14
+ *
15
+ * WHY IT LIVES IN CODE AND NOT IN THE DESIGN TOOL. A designer in the Design
16
+ * Systems Slack built a lint plugin that does this inside Figma. Asked whether
17
+ * it can compare across components rather than one at a time: "it can check one
18
+ * at a time or all, but this crashes figma most of the time". A plugin walks a
19
+ * document graph inside a sandbox; this reads recipes off disk, and the whole
20
+ * catalogue costs a few milliseconds. The check is feasible on this side and not
21
+ * on that one.
22
+ *
23
+ * WHAT MAKES IT HONEST RATHER THAN NOISY. Variety is not drift. Hover is
24
+ * legitimately different per component - a row tints, a link recolours, a chip
25
+ * moves its border - and a checker that flagged all of it would be uninstalled
26
+ * the same day. So it reports a MINORITY against a DOMINANT majority and says
27
+ * nothing at all when the catalogue has no majority to disagree with.
28
+ *
29
+ * Measured against the shipped catalogue, that rule reports the two real ones
30
+ * and neither of the false ones:
31
+ *
32
+ * focus 9 use outline, 3 use border-color → reported (75% dominant)
33
+ * disabled 12 dim and change the cursor, 1 only dims → reported (92%)
34
+ * hover 5 shapes across 11, none dominant → silent
35
+ * active 1 and 1 → silent
36
+ */
37
+ /**
38
+ * State names that answer the SAME question, folded together.
39
+ *
40
+ * Without this the check finds nothing here, because each name is unanimous on
41
+ * its own: `focusVisible` is nine-for-nine outline, `focus` is three-for-three
42
+ * border. The disagreement only exists once you know they are the same concern,
43
+ * which is exactly why it survived every per-name check anyone could write.
44
+ */
45
+ const CONCERNS = {
46
+ focus: "focus",
47
+ focusvisible: "focus",
48
+ focuswithin: "focus",
49
+ active: "pressed",
50
+ pressed: "pressed",
51
+ checked: "selected",
52
+ selected: "selected",
53
+ disabled: "disabled",
54
+ hover: "hover",
55
+ };
56
+ /** Below this share, the catalogue has no house style to disagree with, and
57
+ * every "outlier" is just the variety the concern is supposed to have. */
58
+ const DOMINANT = 0.7;
59
+ function collect(documents) {
60
+ const byConcern = new Map();
61
+ const visit = (label, node) => {
62
+ for (const [state, block] of Object.entries(node.states ?? {})) {
63
+ const concern = CONCERNS[state.toLowerCase()];
64
+ // An unrecognised state is not folded into anything. Guessing that two
65
+ // names mean the same thing is how a checker starts inventing findings.
66
+ if (!concern)
67
+ continue;
68
+ const props = Object.keys(block ?? {}).sort();
69
+ if (props.length === 0)
70
+ continue;
71
+ const list = byConcern.get(concern) ?? [];
72
+ list.push({ component: label, props });
73
+ byConcern.set(concern, list);
74
+ }
75
+ };
76
+ for (const raw of documents) {
77
+ const doc = raw;
78
+ for (const [name, recipe] of Object.entries(doc.components ?? {})) {
79
+ visit(name, recipe);
80
+ for (const [part, block] of Object.entries(recipe.parts ?? {})) {
81
+ visit(`${name}.${part}`, block);
82
+ }
83
+ }
84
+ }
85
+ return byConcern;
86
+ }
87
+ /** Every concern the system answers more than one way, with a clear majority. */
88
+ export function findDivergences(documents) {
89
+ const out = [];
90
+ for (const [concern, entries] of collect(documents)) {
91
+ // One component cannot disagree with itself, and two cannot form a house
92
+ // style - the smallest catalogue this can speak about is three.
93
+ if (entries.length < 3)
94
+ continue;
95
+ const shapes = new Map();
96
+ for (const e of entries) {
97
+ const key = e.props.join("+");
98
+ shapes.set(key, [...(shapes.get(key) ?? []), e]);
99
+ }
100
+ if (shapes.size < 2)
101
+ continue;
102
+ const ranked = [...shapes.entries()].sort((a, b) => b[1].length - a[1].length);
103
+ const [houseKey, houseList] = ranked[0];
104
+ if (houseList.length / entries.length < DOMINANT)
105
+ continue;
106
+ out.push({
107
+ concern,
108
+ house: houseKey.split("+"),
109
+ houseCount: houseList.length,
110
+ outliers: ranked
111
+ .slice(1)
112
+ .flatMap(([, list]) => list)
113
+ .sort((a, b) => a.component.localeCompare(b.component)),
114
+ });
115
+ }
116
+ return out.sort((a, b) => b.outliers.length - a.outliers.length);
117
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.17",
3
+ "version": "0.16.19",
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": {