synthesisui 0.16.17 → 0.16.18

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}`;
@@ -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
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.17",
3
+ "version": "0.16.18",
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": {