synthesisui 0.16.15 → 0.16.17

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
@@ -134,6 +134,35 @@ drift because it looks solved.`;
134
134
  * `--force` needs a person because it deletes. The dry run does not, so the
135
135
  * agent is pointed at the harmless half and told to ask for the other.
136
136
  */
137
+ /**
138
+ * THE ONE THING MOST LIKELY TO ALREADY BE IN THE PROJECT.
139
+ *
140
+ * Every system compiles a `shadcn.css` mapping shadcn's whole variable contract
141
+ * onto its own tokens. It has been generated into every project since it was
142
+ * built and nothing ever mentioned it, so the feature that answers "why not just
143
+ * use shadcn" was sitting unimported in the repo of the person who asked.
144
+ *
145
+ * Told to the AGENT and not only to the person, because the agent is who reaches
146
+ * for a shadcn block. Left to guess, it installs `dashboard-01` and gets a
147
+ * sidebar on shadcn's defaults next to components wearing the system, which
148
+ * reads as a broken product rather than a missing import.
149
+ *
150
+ * Conditional on `components.json`: a project without shadcn must not carry a
151
+ * paragraph about it.
152
+ */
153
+ const SHADCN = (slug) => `
154
+
155
+ **This project has shadcn, and this system already speaks its language.** A
156
+ generated bridge maps shadcn's whole variable contract - colour, charts, radius,
157
+ the sidebar family - onto this system's tokens, in both schemes:
158
+
159
+ @import "_synthesisui/ds/${slug}/shadcn.css";
160
+
161
+ after the tokens.css import, in the same stylesheet. With that line in place,
162
+ shadcn components wear this system and need no edits from you. Without it they
163
+ stay on shadcn's defaults, which looks like the design system failing rather than
164
+ one import missing - so check for it before you add a shadcn component, and add
165
+ it if it is not there.`;
137
166
  const VERIFY = `
138
167
 
139
168
  **Verify with a build, not a running server.** If you do start a dev server, stop
@@ -277,9 +306,16 @@ element. Write every user-facing string in that language - labels, empty states,
277
306
  \`alt\`. A screen reader pronounces \`aria-label\` using \`lang\`, so a mixed-language interface is
278
307
  worse than an untranslated one. If the attribute is wrong, change it rather than writing against
279
308
  it.`;
309
+ // Named for the first installed system: the bridge is per-system, and a
310
+ // project with two of them has already chosen which one dresses the app.
311
+ const bridgeable = installed.find((d) => !d.adopted);
312
+ const shadcn = bridgeable &&
313
+ (await readFile(join(projectRoot, "components.json"), "utf8").then(() => true, () => false))
314
+ ? SHADCN(bridgeable.slug)
315
+ : "";
280
316
  const body = `## Design Systems (via SynthesisUI)
281
317
 
282
- This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}
318
+ This project uses design system(s) tracked by the \`synthesisui\` CLI. ${rule}${language}${shadcn}
283
319
 
284
320
  ${sections.join("\n")}
285
321
 
@@ -138,15 +138,35 @@ export async function add(slug, opts) {
138
138
  console.log(line(`1. Import the system in your GLOBAL stylesheet, e.g. ${appDir}/globals.css`));
139
139
  console.log(line(` (the path is relative to that file - hence the leading ${importPrefix}):`));
140
140
  console.log("");
141
- console.log(snippet(hasTheme
142
- ? [
143
- `@import "tailwindcss";`,
144
- `@import "${importPrefix}_synthesisui/ds/${payload.slug}/tokens.css";`,
145
- `@import "${importPrefix}_synthesisui/ds/${payload.slug}/theme.css"; /* Tailwind utilities on your tokens */`,
146
- ]
147
- : [
148
- `@import "${importPrefix}_synthesisui/ds/${payload.slug}/tokens.css";`,
149
- ]));
141
+ // THE BRIDGE ONLY EXISTS FOR PEOPLE WHO ALREADY HAVE SHADCN, so it only
142
+ // appears for them. Every system compiles a `shadcn.css` mapping shadcn's
143
+ // whole variable contract onto its own tokens, and until 28/07 nothing said
144
+ // so - not this output, not the managed block, not the GUIDE beyond a filename
145
+ // in a list. The person it mattered most to asked "I don't get the use case, I
146
+ // can just build a design system with shadcn", with the answer sitting
147
+ // unimported in his own repo. Naming it to everybody would be noise; naming it
148
+ // to whoever has `components.json` is the whole feature arriving.
149
+ const hasShadcn = await access(join(projectRoot, "components.json")).then(() => true, () => false);
150
+ console.log(snippet([
151
+ ...(hasTheme ? [`@import "tailwindcss";`] : []),
152
+ `@import "${importPrefix}_synthesisui/ds/${payload.slug}/tokens.css";`,
153
+ ...(hasTheme
154
+ ? [
155
+ `@import "${importPrefix}_synthesisui/ds/${payload.slug}/theme.css"; /* Tailwind utilities on your tokens */`,
156
+ ]
157
+ : []),
158
+ ...(hasShadcn
159
+ ? [
160
+ `@import "${importPrefix}_synthesisui/ds/${payload.slug}/shadcn.css"; /* your shadcn components, wearing this system */`,
161
+ ]
162
+ : []),
163
+ ]));
164
+ if (hasShadcn) {
165
+ console.log("");
166
+ console.log(line(` You have shadcn here. That third line maps its whole variable contract`));
167
+ console.log(line(` onto this system - colour, charts, radius and the sidebar - so its`));
168
+ console.log(line(` components stop using shadcn's defaults. Both schemes included.`));
169
+ }
150
170
  console.log("");
151
171
  console.log(line(`2. Scope your app: add data-ds="${payload.slug}" to a ROOT element, e.g. ${appDir}/layout.tsx:`));
152
172
  console.log("");
@@ -1,10 +1,32 @@
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
3
  import { generateComponentFiles } from "../component-codegen.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
5
  import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
6
6
  import { body, section, snippet } from "../output.js";
7
7
  import { fetchComponent, RegistryError } from "../registry.js";
8
+ /**
9
+ * The consumer's React major, or null when we cannot tell.
10
+ *
11
+ * It decides whether the generated components can take a `ref`: from 19 that is
12
+ * an ordinary prop, before it a function component needs `forwardRef`. Guessing
13
+ * high on an older project would emit a type that accepts a ref React then
14
+ * silently drops, so anything unreadable falls back to the ref-less type.
15
+ */
16
+ async function reactMajorOf(root) {
17
+ const raw = await readFile(join(root, "package.json"), "utf8").catch(() => "");
18
+ if (!raw)
19
+ return null;
20
+ try {
21
+ const pkg = JSON.parse(raw);
22
+ const spec = pkg.dependencies?.react ?? pkg.devDependencies?.react;
23
+ const major = /(\d+)/.exec(spec ?? "")?.[1];
24
+ return major ? Number(major) : null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
8
30
  /** Slugs/names are kebab-case by contract; reject anything else before it ever
9
31
  * reaches a filesystem path (defense-in-depth against `../` traversal). */
10
32
  const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -59,7 +81,7 @@ export async function component(slug, name, opts) {
59
81
  filenames = [`${res.name}.tsx`, `${res.name}.css`, "index.ts"];
60
82
  }
61
83
  else {
62
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
84
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root));
63
85
  for (const file of files) {
64
86
  await writeFile(join(compDir, file.filename), file.code, "utf8");
65
87
  }
@@ -287,15 +287,37 @@ async function readWiring(root, slug) {
287
287
  fontsWritten: false,
288
288
  /** …and the stylesheet actually maps it onto the system's type tokens. */
289
289
  fontsMapped: false,
290
+ /**
291
+ * THE FEATURE NOBODY KNEW WE SHIPPED.
292
+ *
293
+ * Every system compiles a `shadcn.css` mapping shadcn's whole variable
294
+ * contract onto its own tokens, so shadcn components wear the system instead
295
+ * of shadcn's defaults. It has been generated into every project since it
296
+ * was built, and until 28/07 nothing mentioned it: not `add`, not the
297
+ * managed block, not the GUIDE except as a filename in a list. The only
298
+ * documentation was a comment inside the file, which is the same as none.
299
+ *
300
+ * The person it mattered most to is the one who asked "I don't get the use
301
+ * case, I can just build a design system with shadcn" - and the answer was
302
+ * sitting unimported in his own repo.
303
+ *
304
+ * Reported only when the project HAS shadcn. Telling everybody about a
305
+ * bridge they will never use is how a report earns the skim.
306
+ */
307
+ hasShadcn: false,
308
+ bridged: false,
290
309
  };
291
310
  if (!slug)
292
311
  return w;
312
+ w.hasShadcn = await readFile(join(root, "components.json"), "utf8").then(() => true, () => false);
293
313
  for await (const file of walk(root)) {
294
314
  const src = await readFile(file, "utf8").catch(() => "");
295
315
  if (!src)
296
316
  continue;
297
317
  if (src.includes(`_synthesisui/ds/${slug}/tokens.css`))
298
318
  w.imported = true;
319
+ if (src.includes(`_synthesisui/ds/${slug}/shadcn.css`))
320
+ w.bridged = true;
299
321
  if (src.includes(`data-ds="${slug}"`))
300
322
  w.scoped = true;
301
323
  // The requirement that stayed invisible. `init` writes a fonts file and
@@ -306,7 +328,9 @@ async function readWiring(root, slug) {
306
328
  w.fontsWritten = true;
307
329
  if (/--ds-typography-families-\w+\s*:\s*var\(\s*--font-ds-/.test(src))
308
330
  w.fontsMapped = true;
309
- if (w.imported && w.scoped && w.fontsMapped)
331
+ // The bridge joins the early exit, otherwise the walk can stop before the
332
+ // stylesheet that imports it and report a wired project as unbridged.
333
+ if (w.imported && w.scoped && w.fontsMapped && (!w.hasShadcn || w.bridged))
310
334
  break;
311
335
  }
312
336
  return w;
@@ -461,6 +485,37 @@ export async function doctor(opts) {
461
485
  }
462
486
  console.log(body("The exact snippets are in the output of `init`."));
463
487
  }
488
+ /**
489
+ * SHADCN IS THE MOST LIKELY THING ALREADY IN THE PROJECT, AND THE BRIDGE WAS
490
+ * INVISIBLE.
491
+ *
492
+ * Two states, and both are worth a line. Unimported is a real finding: the
493
+ * components a person spends all day looking at are on shadcn's defaults while
494
+ * everything around them wears the system, which reads as "this product does
495
+ * not work here" rather than "one import missing".
496
+ *
497
+ * Imported is worth saying too, and that is the unusual part. It is the only
498
+ * place we can answer "how do I know my shadcn is going through the system?"
499
+ * with something checked rather than claimed. A governance tool that cannot
500
+ * show its work is asking for trust it has not earned.
501
+ */
502
+ if (hasSystem && wiring.hasShadcn) {
503
+ console.log("");
504
+ if (wiring.bridged) {
505
+ console.log(body(`✓ shadcn is reading ${table.name ?? table.slug}, not its own`));
506
+ console.log(body(" defaults. Colour, charts, radius and the sidebar all resolve"));
507
+ console.log(body(" to this system's tokens, in both schemes."));
508
+ }
509
+ else {
510
+ console.log(body("This project has shadcn, and it is not wearing the"));
511
+ console.log(body("system yet - its components are still on shadcn's defaults."));
512
+ console.log("");
513
+ console.log(body(` @import "_synthesisui/ds/${table.slug}/shadcn.css";`));
514
+ console.log("");
515
+ console.log(body(" after the tokens.css import, in the same stylesheet. One line,"));
516
+ console.log(body(" and every shadcn component switches over."));
517
+ }
518
+ }
464
519
  if (hasSystem && measurable) {
465
520
  console.log("");
466
521
  console.log(body(`Token coverage ${meter(d.coverage)} ${String(d.coverage).padStart(3)}%`));
@@ -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
@@ -403,7 +448,7 @@ ${inner}
403
448
  * </${comp}>
404
449
  */`;
405
450
  }
406
- function emitCssMode(slug, name, recipe, version) {
451
+ function emitCssMode(slug, name, recipe, version, props) {
407
452
  const { tag, attrs, voidEl } = elementFor(name, recipe);
408
453
  const axes = axesOf(recipe.variants);
409
454
  const comp = pascal(name);
@@ -418,11 +463,14 @@ function emitCssMode(slug, name, recipe, version) {
418
463
  "className",
419
464
  "...props",
420
465
  ].join(", ");
466
+ const control = partIsInteractive(part);
467
+ const partTag = control ? "button" : "div";
468
+ const partAttrs = control ? '\n type="button"' : "";
421
469
  return `
422
470
  /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
423
- export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, "div")}) {
471
+ export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props)}) {
424
472
  return (
425
- <div
473
+ <${partTag}${partAttrs}
426
474
  className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
427
475
  ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
428
476
  />
@@ -432,9 +480,9 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
432
480
  return `${header(slug, name, version, "css")}
433
481
  import "./${name}.css";
434
482
 
435
- import type { ComponentPropsWithoutRef } from "react";
483
+ import type { ${props} } from "react";
436
484
 
437
- type ${comp}Props = ${propsType(axes, tag)};
485
+ type ${comp}Props = ${propsType(axes, tag, props)};
438
486
 
439
487
  ${compositionHint(comp, name, recipe, voidEl)}
440
488
  export function ${comp}({ ${destructure} }: ${comp}Props) {
@@ -444,7 +492,7 @@ ${rootJsx}
444
492
  }
445
493
  ${parts.join("\n")}`;
446
494
  }
447
- function emitTailwindMode(slug, name, recipe, version) {
495
+ function emitTailwindMode(slug, name, recipe, version, props) {
448
496
  const { tag, attrs, voidEl } = elementFor(name, recipe);
449
497
  const axes = axesOf(recipe.variants);
450
498
  const comp = pascal(name);
@@ -479,12 +527,12 @@ function emitTailwindMode(slug, name, recipe, version) {
479
527
  ].join(", ");
480
528
  return `${header(slug, name, version, "tailwind")}
481
529
 
482
- import type { ComponentPropsWithoutRef } from "react";
530
+ import type { ${props} } from "react";
483
531
 
484
532
  const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
485
533
  ${[...variantConsts, ...booleanConsts].join("\n")}
486
534
 
487
- type ${comp}Props = ${propsType(axes, tag)};
535
+ type ${comp}Props = ${propsType(axes, tag, props)};
488
536
 
489
537
  ${compositionHint(comp, name, recipe, voidEl)}
490
538
  export function ${comp}({ ${destructure} }: ${comp}Props) {
@@ -498,31 +546,38 @@ export function ${comp}({ ${destructure} }: ${comp}Props) {
498
546
  ${Object.entries(recipe.parts ?? {})
499
547
  .map(([partName, part]) => {
500
548
  const partComp = `${comp}${pascal(partName)}`;
549
+ const control = partIsInteractive(part);
550
+ const partTag = control ? "button" : "div";
551
+ const partAttrs = control ? ' type="button"' : "";
501
552
  return `
502
553
  /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
503
- export function ${partComp}({ className, ...props }: ComponentPropsWithoutRef<"div">) {
554
+ export function ${partComp}({ className, ...props }: ${props}<"${partTag}">) {
504
555
  return (
505
- <div className={${joinCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
556
+ <${partTag}${partAttrs} className={${joinCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
506
557
  );
507
558
  }`;
508
559
  })
509
560
  .join("\n")}`;
510
561
  }
511
562
  /** All files for one component, under `<componentsDir>/<name>/`. */
512
- export function generateComponentFiles(slug, name, recipe, css, version, styles) {
563
+ export function generateComponentFiles(slug, name, recipe, css, version, styles,
564
+ /** Consumer's React major, read from its package.json. Null = unknown, which
565
+ * keeps the ref-less type rather than guessing in the unsafe direction. */
566
+ reactMajor = null) {
513
567
  const comp = pascal(name);
514
568
  const files = [];
569
+ const props = propsTypeName(reactMajor);
515
570
  if (styles === "css") {
516
571
  files.push({
517
572
  filename: `${name}.tsx`,
518
- code: `${emitCssMode(slug, name, recipe, version)}\n`,
573
+ code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
519
574
  });
520
575
  files.push({ filename: `${name}.css`, code: `${css}\n` });
521
576
  }
522
577
  else {
523
578
  files.push({
524
579
  filename: `${name}.tsx`,
525
- code: `${emitTailwindMode(slug, name, recipe, version)}\n`,
580
+ code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
526
581
  });
527
582
  }
528
583
  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.15",
3
+ "version": "0.16.17",
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": {