synthesisui 0.16.7 → 0.16.8

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.
@@ -409,7 +409,12 @@ export async function doctor(opts) {
409
409
  }
410
410
  }
411
411
  else if (asideTotal > 0) {
412
- console.log(body(`set aside ${plural(asideTotal, "value")} a token could never hold (--verbose for why)`));
412
+ // "a token could never hold" was written when every aside was an image
413
+ // renderer or an SVG paint. A literal sitting in a token's OWN fallback -
414
+ // `var(--ds-color-semantic-knob, #ffffff)` - is set aside for the opposite
415
+ // reason: it is already tokenized. The summary said the false half out
416
+ // loud and hid the true half behind a flag.
417
+ console.log(body(`set aside ${plural(asideTotal, "value")} that are not drift (--verbose for why)`));
413
418
  }
414
419
  // 0 of 0 is not a perfect score, it is an empty measurement - printing a
415
420
  // full bar there would be the report's first lie.
@@ -32,11 +32,36 @@ function elementFor(name, recipe) {
32
32
  ? { tag: "button", attrs: ' type="button"' }
33
33
  : undefined);
34
34
  const tag = hit?.tag ?? "div";
35
- return {
36
- tag,
37
- attrs: hit?.attrs ?? "",
38
- voidEl: tag === "input" || tag === "hr",
39
- };
35
+ const attrs = hit?.attrs ?? "";
36
+ const isVoid = tag === "input" || tag === "hr";
37
+ /**
38
+ * A VOID ELEMENT CANNOT HOST THE PARTS WE TELL PEOPLE TO PUT INSIDE IT.
39
+ *
40
+ * `switch` resolved to `<input type="checkbox">`, and the recipe gives it a
41
+ * `thumb` part - so the generated file documented `<Switch><SwitchThumb/>
42
+ * </Switch>`, which React refuses at runtime: input is void and must not
43
+ * have children. Found by an agent building a settings page against it
44
+ * (my-test4, 27/07). The documented usage was impossible.
45
+ *
46
+ * The recipe was right and the tag was wrong. Its own CSS styles `.ds-switch`
47
+ * as an `inline-flex` track with a 16px knob inside a 24px rail - that is a
48
+ * container, described as one, and only the element choice disagreed.
49
+ *
50
+ * `<button role="switch">` is the accessible pattern for exactly this: it
51
+ * takes children, it is focusable and operable by keyboard for free, and the
52
+ * caller supplies `aria-checked`. Promotion happens ONLY when parts exist, so
53
+ * a plain input stays an input.
54
+ */
55
+ if (isVoid && Object.keys(recipe.parts ?? {}).length > 0) {
56
+ // Carry the ARIA role across - it is the part of the input's meaning that
57
+ // survives the tag change, and dropping it would trade a runtime error for
58
+ // a silent accessibility regression.
59
+ const role = / role="[a-z]+"/.exec(attrs)?.[0] ?? "";
60
+ return tag === "input"
61
+ ? { tag: "button", attrs: ` type="button"${role}`, voidEl: false }
62
+ : { tag: "div", attrs: role || ' role="separator"', voidEl: false };
63
+ }
64
+ return { tag, attrs, voidEl: isVoid };
40
65
  }
41
66
  /** Variant axes → typed props. An axis whose options ⊆ {true,false} is a
42
67
  * boolean prop; empty axes (no visual effect) are skipped. */
@@ -272,13 +297,44 @@ const STATE_PREFIX = {
272
297
  function blockToTailwind(block, prefix = "") {
273
298
  return Object.entries(block).flatMap(([prop, value]) => declToTailwind(prop, value).map((cls) => `${prefix}${cls}`));
274
299
  }
275
- function tailwindClassList(recipe) {
300
+ function tailwindClassList(recipe,
301
+ /** CSS properties a variant axis owns - see `variantOwnedProps`. */
302
+ exclude) {
303
+ const base = exclude
304
+ ? Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => !exclude.has(prop)))
305
+ : recipe.base;
276
306
  const classes = [
277
- ...blockToTailwind(recipe.base),
307
+ ...blockToTailwind(base),
308
+ // States keep everything: `hover:` and `disabled:` cannot collide with an
309
+ // unprefixed variant class, so there is nothing to resolve.
278
310
  ...Object.entries(recipe.states ?? {}).flatMap(([state, block]) => STATE_PREFIX[state] ? blockToTailwind(block, STATE_PREFIX[state]) : []),
279
311
  ];
280
312
  return classes.join(" ");
281
313
  }
314
+ /**
315
+ * A VARIANT CANNOT OVERRIDE THE BASE WHEN BOTH ARE PLAIN UTILITIES.
316
+ *
317
+ * `Card` emitted `p-md` in BASE and `[padding:0]` for `padding="none"`. Same
318
+ * specificity, so which one wins is decided by the order Tailwind emits them
319
+ * in the stylesheet - not by the order of the class names, which is what the
320
+ * code looks like it controls. The prop silently did nothing (found by an
321
+ * agent that worked around it in a comment rather than reporting it,
322
+ * my-test4, 27/07).
323
+ *
324
+ * CSS mode never had this: it selects on `[data-padding="none"]`, which
325
+ * outranks the base class honestly. Tailwind mode has no such ladder, so the
326
+ * conflict has to be resolved where it is created - at generation.
327
+ *
328
+ * The property leaves BASE and the base value becomes the axis's default, so
329
+ * exactly one class ever sets it.
330
+ */
331
+ function variantOwnedProps(variants, axis) {
332
+ const props = new Set();
333
+ for (const option of axis.options)
334
+ for (const prop of Object.keys(variants?.[axis.key]?.[option] ?? {}))
335
+ props.add(prop);
336
+ return [...props];
337
+ }
282
338
  // ── Emission ─────────────────────────────────────────────────────────────────
283
339
  function header(slug, name, version, mode) {
284
340
  const setup = mode === "tailwind"
@@ -295,8 +351,14 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
295
351
  /** JSDoc showing how to compose the component with its parts + content, so the
296
352
  * materialized code doesn't read as "a bare shell renders nothing" (dogfood
297
353
  * #5). Built from the recipe's parts. */
298
- function compositionHint(comp, name, recipe) {
354
+ function compositionHint(comp, name, recipe, voidEl = false) {
299
355
  const partNames = Object.keys(recipe.parts ?? {});
356
+ // An `<input>` or `<hr>` takes no children, and telling somebody to put
357
+ // content inside one is an instruction that throws. With parts, the element
358
+ // was promoted above and this branch never runs for a void tag.
359
+ if (voidEl) {
360
+ return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
361
+ }
300
362
  if (partNames.length === 0) {
301
363
  return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
302
364
  }
@@ -317,7 +379,6 @@ function emitCssMode(slug, name, recipe, version) {
317
379
  const comp = pascal(name);
318
380
  const propNames = axes.map((a) => a.prop);
319
381
  const destructure = [...propNames, "className", "...props"].join(", ");
320
- void voidEl; // both void and container elements self-close ({...props} carries children)
321
382
  const rootJsx = ` <${tag}${attrs}\n className={${joinCls([`"ds-${name}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n />`;
322
383
  const parts = Object.entries(recipe.parts ?? {}).map(([partName, part]) => {
323
384
  const partAxes = axesOf(part.variants ?? {});
@@ -345,7 +406,7 @@ import type { ComponentPropsWithoutRef } from "react";
345
406
 
346
407
  type ${comp}Props = ${propsType(axes, tag)};
347
408
 
348
- ${compositionHint(comp, name, recipe)}
409
+ ${compositionHint(comp, name, recipe, voidEl)}
349
410
  export function ${comp}({ ${destructure} }: ${comp}Props) {
350
411
  return (
351
412
  ${rootJsx}
@@ -368,11 +429,17 @@ function emitTailwindMode(slug, name, recipe, version) {
368
429
  const booleanConsts = axes
369
430
  .filter((a) => a.boolean)
370
431
  .map((a) => `const ${a.prop.toUpperCase()} = ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.true ?? {}).join(" "))};`);
432
+ // Every property some axis controls leaves BASE, and the base value becomes
433
+ // that axis's default - so exactly one class ever sets it and the prop
434
+ // actually wins.
435
+ const owned = new Map(axes.map((a) => [a.prop, variantOwnedProps(recipe.variants, a)]));
436
+ const excluded = new Set([...owned.values()].flat());
437
+ const fallbackFor = (a) => JSON.stringify(blockToTailwind(Object.fromEntries(Object.entries(recipe.base).filter(([prop]) => (owned.get(a.prop) ?? []).includes(prop)))).join(" "));
371
438
  const clsParts = [
372
439
  "BASE",
373
440
  ...axes.map((a) => a.boolean
374
- ? `${a.prop} ? ${a.prop.toUpperCase()} : ""`
375
- : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ""`),
441
+ ? `${a.prop} ? ${a.prop.toUpperCase()} : ${fallbackFor(a)}`
442
+ : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
376
443
  "className",
377
444
  ];
378
445
  const destructure = [
@@ -380,17 +447,16 @@ function emitTailwindMode(slug, name, recipe, version) {
380
447
  "className",
381
448
  "...props",
382
449
  ].join(", ");
383
- void voidEl;
384
450
  return `${header(slug, name, version, "tailwind")}
385
451
 
386
452
  import type { ComponentPropsWithoutRef } from "react";
387
453
 
388
- const BASE = ${JSON.stringify(tailwindClassList(recipe))};
454
+ const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
389
455
  ${[...variantConsts, ...booleanConsts].join("\n")}
390
456
 
391
457
  type ${comp}Props = ${propsType(axes, tag)};
392
458
 
393
- ${compositionHint(comp, name, recipe)}
459
+ ${compositionHint(comp, name, recipe, voidEl)}
394
460
  export function ${comp}({ ${destructure} }: ${comp}Props) {
395
461
  return (
396
462
  <${tag}${attrs}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.7",
3
+ "version": "0.16.8",
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": {