synthesisui 0.16.16 → 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,54 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
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
+ }
30
+ /**
31
+ * The consumer's React major, or null when we cannot tell.
32
+ *
33
+ * It decides whether the generated components can take a `ref`: from 19 that is
34
+ * an ordinary prop, before it a function component needs `forwardRef`. Guessing
35
+ * high on an older project would emit a type that accepts a ref React then
36
+ * silently drops, so anything unreadable falls back to the ref-less type.
37
+ */
38
+ async function reactMajorOf(root) {
39
+ const raw = await readFile(join(root, "package.json"), "utf8").catch(() => "");
40
+ if (!raw)
41
+ return null;
42
+ try {
43
+ const pkg = JSON.parse(raw);
44
+ const spec = pkg.dependencies?.react ?? pkg.devDependencies?.react;
45
+ const major = /(\d+)/.exec(spec ?? "")?.[1];
46
+ return major ? Number(major) : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
8
52
  /** Slugs/names are kebab-case by contract; reject anything else before it ever
9
53
  * reaches a filesystem path (defense-in-depth against `../` traversal). */
10
54
  const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -59,10 +103,12 @@ export async function component(slug, name, opts) {
59
103
  filenames = [`${res.name}.tsx`, `${res.name}.css`, "index.ts"];
60
104
  }
61
105
  else {
62
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
106
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root));
63
107
  for (const file of files) {
64
108
  await writeFile(join(compDir, file.filename), file.code, "utf8");
65
109
  }
110
+ if (config.styles === "tailwind")
111
+ await writeCn(root, compDir, slug);
66
112
  filenames = files.map((f) => f.filename);
67
113
  }
68
114
  const flavor = wantInteractive ? "interactive" : `styles: ${config.styles}`;
@@ -8,6 +8,28 @@ const camel = (name) => {
8
8
  const p = pascal(name);
9
9
  return p[0].toLowerCase() + p.slice(1);
10
10
  };
11
+ /**
12
+ * A PART THAT STYLES FOCUS IS A CONTROL, AND EVERY ONE OF THEM SHIPPED AS A DIV.
13
+ *
14
+ * Found by an agent building a carousel (27/07). `PaginationItem` came out as a
15
+ * `<div>` carrying `cursor-pointer`, `hover:text-foreground`,
16
+ * `focus-visible:[outline:2px]` and `disabled:[opacity:0.4]` - every affordance
17
+ * of a button on an element that is not focusable, is not announced as a
18
+ * control, and where `disabled:` can never match. It worked around us by
19
+ * putting the class on a real `<button>` instead of using the component.
20
+ *
21
+ * The recipe already knew. Nobody writes a focus ring on a div by accident, so
22
+ * `focus`/`focusVisible`/`disabled` in a part's states IS the declaration that
23
+ * it is operable. `hover` deliberately does not count: a card that lifts under
24
+ * the cursor is still a card.
25
+ *
26
+ * Conservative by construction. Across the shipped catalogue this promotes
27
+ * three parts of fifty - stepper.button, tabs.trigger, pagination.item - and
28
+ * all three are buttons that were wearing the wrong tag.
29
+ */
30
+ export function partIsInteractive(part) {
31
+ return Object.keys(part.states ?? {}).some((s) => /^(focus|focusVisible|focus-visible|disabled)$/i.test(s));
32
+ }
11
33
  /** Intrinsic element + extra attrs per component, chosen like the platform
12
34
  * renderer does (name first, then preview.kind). Fallback: div + children. */
13
35
  function elementFor(name, recipe) {
@@ -82,13 +104,36 @@ function axesOf(variants) {
82
104
  }
83
105
  return axes;
84
106
  }
85
- function propsType(axes, tag) {
107
+ /**
108
+ * THE COMPONENTS COULD NOT TAKE A REF, AND SOME UI CANNOT BE BUILT WITHOUT ONE.
109
+ *
110
+ * `ComponentPropsWithoutRef` is the correct type under React 18, where a
111
+ * function component needs `forwardRef` to receive one. Under React 19 `ref` is
112
+ * an ordinary prop, so the same code works and the type is simply lying about
113
+ * what the component accepts.
114
+ *
115
+ * It costs real things. An agent building a dense screen (28/07) had to address
116
+ * rows by DOM id and reach for `document.getElementById`, because roving focus
117
+ * needs a ref. And `indeterminate` on a checkbox is a DOM property with no HTML
118
+ * attribute: a partial select-all is impossible without one.
119
+ *
120
+ * Read from the consumer's own package.json, never assumed. Unknown or older
121
+ * keeps today's behaviour, because emitting a ref-taking type onto React 18
122
+ * trades a missing feature for a silent one - the ref is quietly undefined and
123
+ * only a dev-mode warning says so.
124
+ */
125
+ export function propsTypeName(reactMajor) {
126
+ return reactMajor !== null && reactMajor >= 19
127
+ ? "ComponentProps"
128
+ : "ComponentPropsWithoutRef";
129
+ }
130
+ function propsType(axes, tag, base) {
86
131
  const extras = axes.map((a) => a.boolean
87
132
  ? ` ${a.prop}?: boolean;`
88
133
  : ` ${a.prop}?: ${a.options.map((o) => `"${o}"`).join(" | ")};`);
89
134
  if (extras.length === 0)
90
- return `ComponentPropsWithoutRef<"${tag}">`;
91
- return `ComponentPropsWithoutRef<"${tag}"> & {\n${extras.join("\n")}\n}`;
135
+ return `${base}<"${tag}">`;
136
+ return `${base}<"${tag}"> & {\n${extras.join("\n")}\n}`;
92
137
  }
93
138
  function dataAttrLines(axes) {
94
139
  return axes
@@ -372,12 +417,33 @@ function header(slug, name, version, mode) {
372
417
  * which this generator has. Until it is built, the failure at least stops
373
418
  * being silent, which is the part that actually costs people time.
374
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
+ */
375
429
  const OVERRIDE_WARNING = `//
376
- // Overriding a style this component already sets? Add \`!\`:
377
- // <Thing className="text-info!" /> not className="text-info"
378
- // Both are plain utilities at the same specificity, so which one wins is
379
- // 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>\`.`;
380
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(", ")})`;
381
447
  /** JSDoc showing how to compose the component with its parts + content, so the
382
448
  * materialized code doesn't read as "a bare shell renders nothing" (dogfood
383
449
  * #5). Built from the recipe's parts. */
@@ -403,7 +469,7 @@ ${inner}
403
469
  * </${comp}>
404
470
  */`;
405
471
  }
406
- function emitCssMode(slug, name, recipe, version) {
472
+ function emitCssMode(slug, name, recipe, version, props) {
407
473
  const { tag, attrs, voidEl } = elementFor(name, recipe);
408
474
  const axes = axesOf(recipe.variants);
409
475
  const comp = pascal(name);
@@ -418,11 +484,14 @@ function emitCssMode(slug, name, recipe, version) {
418
484
  "className",
419
485
  "...props",
420
486
  ].join(", ");
487
+ const control = partIsInteractive(part);
488
+ const partTag = control ? "button" : "div";
489
+ const partAttrs = control ? '\n type="button"' : "";
421
490
  return `
422
491
  /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
423
- export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, "div")}) {
492
+ export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props)}) {
424
493
  return (
425
- <div
494
+ <${partTag}${partAttrs}
426
495
  className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
427
496
  ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
428
497
  />
@@ -432,9 +501,9 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
432
501
  return `${header(slug, name, version, "css")}
433
502
  import "./${name}.css";
434
503
 
435
- import type { ComponentPropsWithoutRef } from "react";
504
+ import type { ${props} } from "react";
436
505
 
437
- type ${comp}Props = ${propsType(axes, tag)};
506
+ type ${comp}Props = ${propsType(axes, tag, props)};
438
507
 
439
508
  ${compositionHint(comp, name, recipe, voidEl)}
440
509
  export function ${comp}({ ${destructure} }: ${comp}Props) {
@@ -444,7 +513,7 @@ ${rootJsx}
444
513
  }
445
514
  ${parts.join("\n")}`;
446
515
  }
447
- function emitTailwindMode(slug, name, recipe, version) {
516
+ function emitTailwindMode(slug, name, recipe, version, props) {
448
517
  const { tag, attrs, voidEl } = elementFor(name, recipe);
449
518
  const axes = axesOf(recipe.variants);
450
519
  const comp = pascal(name);
@@ -479,18 +548,19 @@ function emitTailwindMode(slug, name, recipe, version) {
479
548
  ].join(", ");
480
549
  return `${header(slug, name, version, "tailwind")}
481
550
 
482
- import type { ComponentPropsWithoutRef } from "react";
551
+ import type { ${props} } from "react";
552
+ import { cn } from "../cn";
483
553
 
484
554
  const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
485
555
  ${[...variantConsts, ...booleanConsts].join("\n")}
486
556
 
487
- type ${comp}Props = ${propsType(axes, tag)};
557
+ type ${comp}Props = ${propsType(axes, tag, props)};
488
558
 
489
559
  ${compositionHint(comp, name, recipe, voidEl)}
490
560
  export function ${comp}({ ${destructure} }: ${comp}Props) {
491
561
  return (
492
562
  <${tag}${attrs}
493
- className={${joinCls(clsParts)}}
563
+ className={${resolveCls(clsParts)}}
494
564
  {...props}
495
565
  />
496
566
  );
@@ -498,31 +568,38 @@ export function ${comp}({ ${destructure} }: ${comp}Props) {
498
568
  ${Object.entries(recipe.parts ?? {})
499
569
  .map(([partName, part]) => {
500
570
  const partComp = `${comp}${pascal(partName)}`;
571
+ const control = partIsInteractive(part);
572
+ const partTag = control ? "button" : "div";
573
+ const partAttrs = control ? ' type="button"' : "";
501
574
  return `
502
575
  /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
503
- export function ${partComp}({ className, ...props }: ComponentPropsWithoutRef<"div">) {
576
+ export function ${partComp}({ className, ...props }: ${props}<"${partTag}">) {
504
577
  return (
505
- <div className={${joinCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
578
+ <${partTag}${partAttrs} className={${resolveCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
506
579
  );
507
580
  }`;
508
581
  })
509
582
  .join("\n")}`;
510
583
  }
511
584
  /** All files for one component, under `<componentsDir>/<name>/`. */
512
- export function generateComponentFiles(slug, name, recipe, css, version, styles) {
585
+ export function generateComponentFiles(slug, name, recipe, css, version, styles,
586
+ /** Consumer's React major, read from its package.json. Null = unknown, which
587
+ * keeps the ref-less type rather than guessing in the unsafe direction. */
588
+ reactMajor = null) {
513
589
  const comp = pascal(name);
514
590
  const files = [];
591
+ const props = propsTypeName(reactMajor);
515
592
  if (styles === "css") {
516
593
  files.push({
517
594
  filename: `${name}.tsx`,
518
- code: `${emitCssMode(slug, name, recipe, version)}\n`,
595
+ code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
519
596
  });
520
597
  files.push({ filename: `${name}.css`, code: `${css}\n` });
521
598
  }
522
599
  else {
523
600
  files.push({
524
601
  filename: `${name}.tsx`,
525
- code: `${emitTailwindMode(slug, name, recipe, version)}\n`,
602
+ code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
526
603
  });
527
604
  }
528
605
  files.push({
@@ -31,11 +31,32 @@ const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
31
31
  * 3/6/8 digit run terminated by a non-hex character.
32
32
  */
33
33
  const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
34
- /** `rounded-[14px]`, `border-radius: 14px`. Zero and full pills are idiom,
35
- * not drift - nobody tokenizes `0` or `9999px`. */
36
- const RADIUS = /(?:border-radius\s*:\s*|rounded(?:-[a-z]+)?-\[)(-?\d*\.?\d+)(px|rem|em)/g;
37
- /** Arbitrary spacing: `p-[18px]`, `gap-[7px]`, `margin: 18px`. */
38
- const SPACING = /(?:\b[pmg](?:[trblxy])?-\[|gap-\[|(?:padding|margin|gap)\s*:\s*)(-?\d*\.?\d+)(px|rem)/g;
34
+ /**
35
+ * THE QUOTE THAT MADE HALF THE DRIFT INVISIBLE.
36
+ *
37
+ * Both patterns below used to require `property:` followed directly by a digit.
38
+ * CSS writes `padding: 2rem` and matches. JSX writes `padding: "2rem"`, and the
39
+ * quote ends the match before it starts - so a component built with an inline
40
+ * style object was scanned for colour and nothing else.
41
+ *
42
+ * Colour escaped it by luck: that pattern hunts `#hex` anywhere and never
43
+ * needed a property in front. Which is exactly why nobody noticed, because
44
+ * every test file had a colour in it and the colour always came back.
45
+ *
46
+ * Found 28/07 writing a file with `gap: "1.25rem"` and `padding: "2rem"` on
47
+ * purpose - both exact tokens of the system - and being told six colours, zero
48
+ * spacings.
49
+ *
50
+ * JSX also camelCases, so `borderRadius` has to be as welcome as
51
+ * `border-radius`.
52
+ */
53
+ const OPEN = `["']?`;
54
+ /** `rounded-[14px]`, `border-radius: 14px`, `borderRadius: "14px"`. Zero and
55
+ * full pills are idiom, not drift - nobody tokenizes `0` or `9999px`. */
56
+ const RADIUS = new RegExp(`(?:border-?[Rr]adius\\s*:\\s*${OPEN}|rounded(?:-[a-z]+)?-\\[)(-?\\d*\\.?\\d+)(px|rem|em)`, "g");
57
+ /** Arbitrary spacing: `p-[18px]`, `gap-[7px]`, `margin: 18px`, `gap: "18px"`.
58
+ * The JSX side also writes `paddingLeft`, `marginTop` and friends. */
59
+ const SPACING = new RegExp(`(?:\\b[pmg](?:[trblxy])?-\\[|gap-\\[|(?:padding|margin|gap)(?:[A-Z][a-z]+)?\\s*:\\s*${OPEN})(-?\\d*\\.?\\d+)(px|rem)`, "g");
39
60
  /** A font stack written by hand rather than taken from the type scale. */
40
61
  const FONT = /font-family\s*:\s*([^;}\n]+)/g;
41
62
  /**
@@ -252,7 +273,9 @@ export function scanSource(file, source, table) {
252
273
  kind,
253
274
  line: at,
254
275
  literal,
255
- token: tokenFor(table, literal),
276
+ // The kind is already known here, and without passing it the lookup
277
+ // answers a `gap` with a radius token.
278
+ token: tokenFor(table, literal, kind),
256
279
  excerpt: clip(line),
257
280
  });
258
281
  };
@@ -416,11 +416,35 @@ export function nearestToken(table, literal) {
416
416
  const limit = unit === "rem" ? Math.max(n * 0.25, 0.5) : Math.max(n * 0.25, 8);
417
417
  return best.delta <= limit ? best : null;
418
418
  }
419
- export function tokenFor(table, literal) {
419
+ /**
420
+ * The family a token belongs to, from the drift it was found in.
421
+ *
422
+ * Without this the lookup is by VALUE alone, and a value belongs to more than
423
+ * one family: `1.25rem` is both `--ds-spacing-sm` and `--ds-radius-lg` in the
424
+ * same system. Measured 28/07 on `gap-[1.25rem]`, which was correctly counted as
425
+ * spacing and then told to use a radius token.
426
+ *
427
+ * That is worse than saying nothing. A tool that answers a gap with a corner
428
+ * radius is one a person stops reading, and this one has exactly one job that
429
+ * nobody else does: naming the right token.
430
+ */
431
+ const FAMILY = {
432
+ color: "--ds-color-",
433
+ radius: "--ds-radius-",
434
+ spacing: "--ds-spacing-",
435
+ font: "--ds-typography-",
436
+ };
437
+ export function tokenFor(table, literal, kind) {
420
438
  const hit = table.byValue.get(normalizeValue(literal));
421
439
  if (!hit || hit.length === 0)
422
440
  return null;
441
+ // Narrow to the family first, and only fall back to the whole set when the
442
+ // system has no token of that kind holding this value - a fallback is still
443
+ // better than silence, it just stops being a recommendation.
444
+ const prefix = kind ? FAMILY[kind] : undefined;
445
+ const family = prefix ? hit.filter((n) => n.startsWith(prefix)) : [];
446
+ const pool = family.length > 0 ? family : hit;
423
447
  // Semantic roles name intent; primitives name a shelf. Prefer intent.
424
- const semantic = hit.find((n) => n.includes("-semantic-"));
425
- return semantic ?? hit[0];
448
+ const semantic = pool.find((n) => n.includes("-semantic-"));
449
+ return semantic ?? pool[0];
426
450
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.16",
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": {