synthesisui 0.16.77 → 0.16.79

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.
@@ -1,4 +1,12 @@
1
+ export const DEFAULT_CONVENTION = {
2
+ prefix: "ds-",
3
+ partSeparator: "-",
4
+ };
1
5
  const kebab = (v) => v.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
6
+ /** `metric-card` → `ds-metric-card`, or `metric-card`, or `sui-metric-card`. */
7
+ const elementClass = (name, c) => `${c.prefix}${kebab(name)}`;
8
+ /** `metric-card` + `title` → `ds-metric-card-title`, or `metric-card__title`. */
9
+ const partClassName = (name, part, c) => `${elementClass(name, c)}${c.partSeparator}${kebab(part)}`;
2
10
  const pascal = (name) => name
3
11
  .split(/[^a-zA-Z0-9]+/)
4
12
  .filter(Boolean)
@@ -199,7 +207,19 @@ const colorKey = (v) => {
199
207
  return `series-${series}`;
200
208
  return null;
201
209
  };
202
- /** "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility). */
210
+ /**
211
+ * "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility).
212
+ *
213
+ * A STEP maps to a utility; a ROLE deliberately does not.
214
+ *
215
+ * `typography.scale.<step>` names a step the system actually has, and a step it
216
+ * got from THEIR declaration is bridged into Tailwind's namespace - so `text-h1`
217
+ * exists and is the prettier output. `typography.role.<slot>` is one of our seven
218
+ * slots, and the bridge only ever claims a name they gave us: `text-base` may not
219
+ * exist in an imported system at all. So a role falls through to
220
+ * `var(--ds-typography-role-base-font-size)`, which is always emitted and can
221
+ * never dangle.
222
+ */
203
223
  const scaleKey = (v) => {
204
224
  const m = v.match(/^\{typography\.scale\.([a-zA-Z0-9-]+)\.fontSize\}$/);
205
225
  return m ? kebab(m[1]) : null;
@@ -478,10 +498,91 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
478
498
  * applies, which is what the call site reads like.
479
499
  */
480
500
  const resolveCls = (parts) => `cn(${parts.join(", ")})`;
501
+ /**
502
+ * WHAT ELEMENT EACH FORM IS, and this is where the anatomy stops being a picture.
503
+ *
504
+ * Every part came out as a `<div>` - a title, a value, a cover image, all divs -
505
+ * because a flat list of names cannot say what a part IS. The tree says, so a
506
+ * heading generates an `<h3>`, an image an `<img>`, a field an `<input>`. That is
507
+ * not cosmetic: it decides what a screen reader announces and what a browser lets
508
+ * you tab to.
509
+ */
510
+ const FORM_TAG = {
511
+ image: { tag: "img", voidEl: true },
512
+ heading: { tag: "h3" },
513
+ text: { tag: "span" },
514
+ button: { tag: "button" },
515
+ field: { tag: "input", voidEl: true },
516
+ icon: { tag: "span" },
517
+ row: { tag: "div" },
518
+ stack: { tag: "div" },
519
+ };
520
+ /** Only these two arrange; the rest are leaves. */
521
+ const ARRANGES = new Set(["row", "stack"]);
522
+ /** Every part the tree names, in tree order - so the generated part components
523
+ * come out in the order somebody reading the file expects to find them. */
524
+ function treeParts(nodes, out = []) {
525
+ for (const node of nodes ?? []) {
526
+ if (node.part && !out.includes(node.part))
527
+ out.push(node.part);
528
+ if (node.children)
529
+ treeParts(node.children, out);
530
+ }
531
+ return out;
532
+ }
533
+ /**
534
+ * THE COMPOSITION, EMITTED AS JSX RATHER THAN DESCRIBED IN A COMMENT.
535
+ *
536
+ * This is the third thing the anatomy was supposed to buy, in the owner's own
537
+ * words: speed, and fewer tokens spent in Claude Code. `<ArticleCard />` used to
538
+ * render an empty shell, and assembling it meant an agent reading a JSDoc example
539
+ * and writing the tree by hand - every time, for every component. Now the tree is
540
+ * in the file.
541
+ *
542
+ * A frontier is a COMMENT, never invented markup. A component of theirs has a
543
+ * recipe of its own and the generated sibling may not exist yet; a third-party
544
+ * library is not ours to render. Both say what belongs there and leave the slot
545
+ * open, which is the honest instruction.
546
+ */
547
+ function emitTree(nodes, comp, indent) {
548
+ const lines = [];
549
+ for (const node of nodes) {
550
+ if (node.as === "component") {
551
+ lines.push(`${indent}{/* your <${pascal(node.ref ?? "")} /> goes here - it has a recipe of its own, so it is not inlined */}`);
552
+ continue;
553
+ }
554
+ if (node.as === "external") {
555
+ lines.push(`${indent}{/* ${node.from} renders here - a third party's component, so its markup is theirs */}`);
556
+ continue;
557
+ }
558
+ if (!node.part) {
559
+ // Pure structure with no styles of its own: keep the arrangement, skip the
560
+ // element - a div that carries nothing is a div nobody needs.
561
+ if (node.children)
562
+ lines.push(emitTree(node.children, comp, indent));
563
+ continue;
564
+ }
565
+ const partComp = `${comp}${pascal(node.part)}`;
566
+ if (ARRANGES.has(node.as) && node.children && node.children.length > 0) {
567
+ lines.push(`${indent}<${partComp}>`);
568
+ lines.push(emitTree(node.children, comp, `${indent} `));
569
+ lines.push(`${indent}</${partComp}>`);
570
+ continue;
571
+ }
572
+ const { voidEl } = FORM_TAG[node.as] ?? {};
573
+ if (voidEl) {
574
+ lines.push(`${indent}<${partComp} />`);
575
+ continue;
576
+ }
577
+ lines.push(`${indent}<${partComp}>${node.text ?? `{/* ${node.part} */}`}</${partComp}>`);
578
+ }
579
+ return lines.filter(Boolean).join("\n");
580
+ }
481
581
  /** JSDoc showing how to compose the component with its parts + content, so the
482
582
  * materialized code doesn't read as "a bare shell renders nothing" (dogfood
483
583
  * #5). Built from the recipe's parts. */
484
584
  function compositionHint(comp, name, recipe, voidEl = false) {
585
+ const tree = recipe.preview?.parts;
485
586
  const partNames = Object.keys(recipe.parts ?? {});
486
587
  // An `<input>` or `<hr>` takes no children, and telling somebody to put
487
588
  // content inside one is an instruction that throws. With parts, the element
@@ -489,6 +590,16 @@ function compositionHint(comp, name, recipe, voidEl = false) {
489
590
  if (voidEl) {
490
591
  return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
491
592
  }
593
+ if (tree && tree.length > 0) {
594
+ // The shape is IN the file below, so the comment stops teaching assembly and
595
+ // starts saying what is already there and how to change it.
596
+ return `/**
597
+ * Wears the "${name}" recipe, composed as your own code composes it - the shape
598
+ * below was read out of your JSX, so it renders as the component rather than as
599
+ * an empty shell. Replace the placeholders with your content; every part is also
600
+ * exported on its own if you need a different arrangement.
601
+ */`;
602
+ }
492
603
  if (partNames.length === 0) {
493
604
  return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
494
605
  }
@@ -503,37 +614,112 @@ ${inner}
503
614
  * </${comp}>
504
615
  */`;
505
616
  }
506
- function emitCssMode(slug, name, recipe, version, props) {
617
+ /**
618
+ * WHICH TAG A PART GETS, and the tree is what finally lets this be right.
619
+ *
620
+ * A part that styles focus is a control and has always been promoted to a button
621
+ * - that check stays and still wins, because a focus ring is a stronger statement
622
+ * than a name. Below it, the tree's `as` decides: a heading is an `<h3>`, an image
623
+ * an `<img>`, a field an `<input>`. Without a tree everything falls back to `div`,
624
+ * which is exactly what every part used to be.
625
+ */
626
+ function partTagFor(part, form) {
627
+ if (partIsInteractive(part)) {
628
+ return { tag: "button", attrs: ' type="button"', voidEl: false };
629
+ }
630
+ const mapped = form ? FORM_TAG[form] : undefined;
631
+ if (!mapped)
632
+ return { tag: "div", attrs: "", voidEl: false };
633
+ return {
634
+ tag: mapped.tag,
635
+ /**
636
+ * `type="button"` IS NOT OPTIONAL ON A BUTTON.
637
+ *
638
+ * A `<button>` with no type defaults to `submit`, so the moment one of these
639
+ * parts sits inside a form, clicking it submits the form. `asElement` already
640
+ * knows how to carry this through the `as` escape hatch - it just needs to be
641
+ * told, and the interactive branch above was the only caller telling it.
642
+ *
643
+ * An `<img>` with no `alt` is an accessibility failure the generator would be
644
+ * authoring, so it ships with an empty one: decorative by default, and the
645
+ * caller overrides it through `...props` the moment it carries meaning.
646
+ */
647
+ attrs: mapped.tag === "button"
648
+ ? ' type="button"'
649
+ : mapped.tag === "img"
650
+ ? ' alt=""'
651
+ : "",
652
+ voidEl: Boolean(mapped.voidEl),
653
+ };
654
+ }
655
+ function emitCssMode(slug, name, recipe, version, props, convention,
656
+ /** The name it takes in THEIR project. The class stays the system's. */
657
+ localName = name) {
507
658
  const { tag, attrs, voidEl } = elementFor(name, recipe);
508
659
  const el = asElement(tag, attrs, voidEl);
509
660
  const axes = axesOf(recipe.variants);
510
- const comp = pascal(name);
661
+ const comp = pascal(localName);
511
662
  const propNames = axes.map((a) => a.prop);
663
+ const tree = recipe.preview?.parts ?? [];
664
+ // With a shape the root RENDERS its children, so it needs `children` in the
665
+ // destructure and a real closing tag rather than a self-closing one.
666
+ const hasShape = tree.length > 0 && !voidEl;
512
667
  const destructure = [
513
668
  ...propNames,
514
669
  ...(el.offersAs ? ["as"] : []),
670
+ ...(hasShape ? ["children"] : []),
515
671
  "className",
516
672
  "...props",
517
673
  ].join(", ");
518
- const rootJsx = ` <${el.jsxTag}${el.jsxAttrs}\n className={${joinCls([`"ds-${name}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n />`;
519
- const parts = Object.entries(recipe.parts ?? {}).map(([partName, part]) => {
674
+ const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}\n className={${joinCls([`"${elementClass(name, convention)}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n `;
675
+ const rootJsx = hasShape
676
+ ? `${rootOpen}>\n${emitTree(tree, comp, " ")}\n {children}\n </${el.jsxTag}>`
677
+ : `${rootOpen}/>`;
678
+ // Tree order first, because that is the order somebody reads them in the file;
679
+ // anything the tree does not mention still gets its component.
680
+ const ordered = [
681
+ ...treeParts(recipe.preview?.parts).filter((p) => recipe.parts?.[p]),
682
+ ...Object.keys(recipe.parts ?? {}).filter((p) => !treeParts(recipe.preview?.parts).includes(p)),
683
+ ];
684
+ const formOf = new Map();
685
+ const collect = (nodes) => {
686
+ for (const node of nodes) {
687
+ if (node.part)
688
+ formOf.set(node.part, node.as);
689
+ if (node.children)
690
+ collect(node.children);
691
+ }
692
+ };
693
+ collect(tree);
694
+ const parts = ordered.map((partName) => {
695
+ const part = recipe.parts?.[partName];
696
+ if (!part)
697
+ return "";
520
698
  const partAxes = axesOf(part.variants ?? {});
521
699
  const partComp = `${comp}${pascal(partName)}`;
522
- const control = partIsInteractive(part);
523
- const partTag = control ? "button" : "div";
524
- const partEl = asElement(partTag, control ? ' type="button"' : "", false);
700
+ const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
701
+ const partEl = asElement(partTag, partAttrs, partVoid);
702
+ /**
703
+ * `as` ONLY WHERE THE ELEMENT HONOURS IT.
704
+ *
705
+ * A void element ignores it - `asElement` says so with `offersAs: false` - so
706
+ * typing and destructuring it anyway would declare a prop that silently does
707
+ * nothing. This file already paid for that exact shape once, on a different
708
+ * prop, and no part could be void until the tree started naming images and
709
+ * fields.
710
+ */
525
711
  const partDestructure = [
526
712
  ...partAxes.map((a) => a.prop),
527
- "as",
713
+ ...(partEl.offersAs ? ["as"] : []),
528
714
  "className",
529
715
  "...props",
530
716
  ].join(", ");
531
717
  return `
532
- /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
533
- export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, true)}) {
718
+ /** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
719
+ export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
534
720
  ${partEl.setup} return (
535
721
  <${partEl.jsxTag}${partEl.jsxAttrs}
536
- className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
722
+ className={${joinCls([`"${partClassName(name, partName, convention)}"`, "className"])}}
537
723
  ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
538
724
  />
539
725
  );
@@ -541,7 +727,7 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
541
727
  });
542
728
  const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
543
729
  return `${header(slug, name, version, "css")}
544
- import "./${name}.css";
730
+ import "./${localName}.css";
545
731
 
546
732
  import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
547
733
 
@@ -553,13 +739,15 @@ ${el.setup} return (
553
739
  ${rootJsx}
554
740
  );
555
741
  }
556
- ${parts.join("\n")}`;
742
+ ${parts.filter(Boolean).join("\n")}`;
557
743
  }
558
- function emitTailwindMode(slug, name, recipe, version, props) {
744
+ function emitTailwindMode(slug, name, recipe, version, props,
745
+ /** The name it takes in THEIR project. The utilities stay the system's. */
746
+ localName = name) {
559
747
  const { tag, attrs, voidEl } = elementFor(name, recipe);
560
748
  const el = asElement(tag, attrs, voidEl);
561
749
  const axes = axesOf(recipe.variants);
562
- const comp = pascal(name);
750
+ const comp = pascal(localName);
563
751
  const variantConsts = axes
564
752
  .filter((a) => !a.boolean)
565
753
  .map((a) => {
@@ -584,13 +772,37 @@ function emitTailwindMode(slug, name, recipe, version, props) {
584
772
  : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
585
773
  "className",
586
774
  ];
775
+ const tree = recipe.preview?.parts ?? [];
776
+ // Same as css mode: with a shape the root renders its children, so it needs
777
+ // `children` and a real closing tag. The CLASSES differ between flavours; the
778
+ // SHAPE is the same fact about their component either way.
779
+ const hasShape = tree.length > 0 && !voidEl;
587
780
  const destructure = [
588
781
  ...axes.map((a) => a.prop),
589
782
  ...(el.offersAs ? ["as"] : []),
783
+ ...(hasShape ? ["children"] : []),
590
784
  "className",
591
785
  "...props",
592
786
  ].join(", ");
593
787
  const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
788
+ const formOf = new Map();
789
+ const collect = (nodes) => {
790
+ for (const node of nodes) {
791
+ if (node.part)
792
+ formOf.set(node.part, node.as);
793
+ if (node.children)
794
+ collect(node.children);
795
+ }
796
+ };
797
+ collect(tree);
798
+ const treeOrder = treeParts(recipe.preview?.parts);
799
+ const orderedParts = [
800
+ ...treeOrder.filter((p) => recipe.parts?.[p]),
801
+ ...Object.keys(recipe.parts ?? {}).filter((p) => !treeOrder.includes(p)),
802
+ ];
803
+ const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}
804
+ className={${resolveCls(clsParts)}}
805
+ ${dataAttrLines(axes)}${axes.length ? "\n" : ""} `;
594
806
  return `${header(slug, name, version, "tailwind")}
595
807
 
596
808
  import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
@@ -604,19 +816,25 @@ type ${comp}Props = ${propsType(axes, tag, props, el.offersAs)};
604
816
  ${compositionHint(comp, name, recipe, voidEl)}
605
817
  export function ${comp}({ ${destructure} }: ${comp}Props) {
606
818
  ${el.setup} return (
607
- <${el.jsxTag}${el.jsxAttrs}
608
- className={${resolveCls(clsParts)}}
609
- ${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}
610
- />
819
+ ${hasShape
820
+ ? `${rootOpen} {...props}
821
+ >
822
+ ${emitTree(tree, comp, " ")}
823
+ {children}
824
+ </${el.jsxTag}>`
825
+ : `${rootOpen} {...props}
826
+ />`}
611
827
  );
612
828
  }
613
- ${Object.entries(recipe.parts ?? {})
614
- .map(([partName, part]) => {
829
+ ${orderedParts
830
+ .map((partName) => {
831
+ const part = recipe.parts?.[partName];
832
+ if (!part)
833
+ return "";
615
834
  const partComp = `${comp}${pascal(partName)}`;
616
835
  const partAxes = axesOf(part.variants ?? {});
617
- const control = partIsInteractive(part);
618
- const partTag = control ? "button" : "div";
619
- const partEl = asElement(partTag, control ? ' type="button"' : "", false);
836
+ const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
837
+ const partEl = asElement(partTag, partAttrs, partVoid);
620
838
  /**
621
839
  * THE STATE THE CATALOGUE DOCUMENTS, DELIVERED (1kro2o, 29/07).
622
840
  *
@@ -637,15 +855,24 @@ ${Object.entries(recipe.parts ?? {})
637
855
  const partCls = [tailwindClassList(part), ...partVariantClasses]
638
856
  .filter(Boolean)
639
857
  .join(" ");
858
+ /**
859
+ * `as` ONLY WHERE THE ELEMENT HONOURS IT.
860
+ *
861
+ * A void element ignores it - `asElement` says so with `offersAs: false` - so
862
+ * typing and destructuring it anyway would declare a prop that silently does
863
+ * nothing. This file already paid for that exact shape once, on a different
864
+ * prop, and no part could be void until the tree started naming images and
865
+ * fields.
866
+ */
640
867
  const partDestructure = [
641
868
  ...partAxes.map((a) => a.prop),
642
- "as",
869
+ ...(partEl.offersAs ? ["as"] : []),
643
870
  "className",
644
871
  "...props",
645
872
  ].join(", ");
646
873
  return `
647
- /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
648
- export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, true)}) {
874
+ /** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
875
+ export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
649
876
  ${partEl.setup} return (
650
877
  <${partEl.jsxTag}${partEl.jsxAttrs}
651
878
  className={${resolveCls([JSON.stringify(partCls), "className"])}}
@@ -654,33 +881,50 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
654
881
  );
655
882
  }`;
656
883
  })
884
+ .filter(Boolean)
657
885
  .join("\n")}`;
658
886
  }
659
887
  /** All files for one component, under `<componentsDir>/<name>/`. */
660
888
  export function generateComponentFiles(slug, name, recipe, css, version, styles,
661
889
  /** Consumer's React major, read from its package.json. Null = unknown, which
662
890
  * keeps the ref-less type rather than guessing in the unsafe direction. */
663
- reactMajor = null) {
664
- const comp = pascal(name);
891
+ reactMajor = null,
892
+ /**
893
+ * HOW THE SYSTEM SPELLS A CLASS, from the registry. Absent = ours, which is
894
+ * every caller that predates the convention being the user's - and getting this
895
+ * wrong shipped a component wearing classes its own stylesheet never emits.
896
+ */
897
+ convention = DEFAULT_CONVENTION,
898
+ /**
899
+ * THE NAME IT TAKES IN THEIR PROJECT, when it cannot take its own.
900
+ *
901
+ * A project that already exports `Button` should not have to give the name up to
902
+ * install ours - we adapt to what they built. So the FILE and the EXPORT can be
903
+ * renamed while the CLASS stays the design system's: the recipe compiles
904
+ * `.ds-button`, and a `<MySystemButton>` wearing it is styled correctly and
905
+ * shadows nothing of theirs.
906
+ *
907
+ * Absent means the component keeps its own name, which is every caller today.
908
+ */
909
+ localName = name) {
665
910
  const files = [];
666
911
  const props = propsTypeName(reactMajor);
667
912
  if (styles === "css") {
668
913
  files.push({
669
- filename: `${name}.tsx`,
670
- code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
914
+ filename: `${localName}.tsx`,
915
+ code: `${emitCssMode(slug, name, recipe, version, props, convention, localName)}\n`,
671
916
  });
672
- files.push({ filename: `${name}.css`, code: `${css}\n` });
917
+ files.push({ filename: `${localName}.css`, code: `${css}\n` });
673
918
  }
674
919
  else {
675
920
  files.push({
676
- filename: `${name}.tsx`,
677
- code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
921
+ filename: `${localName}.tsx`,
922
+ code: `${emitTailwindMode(slug, name, recipe, version, props, localName)}\n`,
678
923
  });
679
924
  }
680
925
  files.push({
681
926
  filename: "index.ts",
682
- code: `export * from "./${name}";\n`,
927
+ code: `export * from "./${localName}";\n`,
683
928
  });
684
- void comp;
685
929
  return files;
686
930
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * THE LIBRARIES THE SYSTEM SAYS YOU NEED, CHECKED AGAINST WHAT YOU HAVE.
3
+ *
4
+ * Some of somebody's components are built on a third-party library - a
5
+ * `TextEditor` on tiptap, a chart on recharts. We do not have that library, will
6
+ * never have it, and cannot draw it, so the anatomy stops at the frontier and the
7
+ * value moves into a RULE: `TextEditor requires @tiptap/react`.
8
+ *
9
+ * A rule nobody checks is a sentence. This is the half that makes it governance:
10
+ * the rule carries the package name as a field, and here we read the consumer's
11
+ * own manifest and say what is missing.
12
+ *
13
+ * TWO THINGS IT DELIBERATELY DOES NOT DO.
14
+ *
15
+ * It never installs. Installing a package on somebody's behalf is exactly the
16
+ * class of thing this product does not do - the agent reports and ASKS, because a
17
+ * dependency is a decision with a licence, a bundle cost and a maintainer
18
+ * attached (dono, 01/08).
19
+ *
20
+ * It never checks a VERSION. The rule carries the name and the evidence carries
21
+ * the range their own project pinned - `^2.1.0 in packages/ui` - which is
22
+ * information, not a requirement. Turning it into one would mean failing somebody
23
+ * for upgrading a library we do not ship, and the range would be stale the day
24
+ * after it was measured.
25
+ */
26
+ /**
27
+ * Every required package this project does not have.
28
+ *
29
+ * `deps` is the merged dependencies + devDependencies of the nearest manifests -
30
+ * `resolveDeps` in `stack.ts`, the same reader the stack detection uses, so a
31
+ * monorepo's root and package manifests both count.
32
+ *
33
+ * A package needed by three components is ONE finding with three names on it, not
34
+ * three findings: the action is a single install, and repeating it would make a
35
+ * well-factored system look like it has more problems than a badly factored one.
36
+ */
37
+ export function missingDependencies(rules, deps) {
38
+ const byName = new Map();
39
+ for (const rule of rules) {
40
+ const name = rule.requires?.trim();
41
+ if (!name)
42
+ continue;
43
+ // Present is present. We do not compare ranges - see the note above.
44
+ if (Object.hasOwn(deps, name))
45
+ continue;
46
+ const held = byName.get(name);
47
+ // The component the rule is about, which is what the reader needs in order to
48
+ // decide whether they even use that part of the system.
49
+ const owners = (rule.applies ?? []).filter(Boolean);
50
+ if (held) {
51
+ for (const owner of owners) {
52
+ if (!held.neededBy.includes(owner))
53
+ held.neededBy.push(owner);
54
+ }
55
+ held.pinned ??= rule.pinned;
56
+ continue;
57
+ }
58
+ byName.set(name, {
59
+ name,
60
+ neededBy: [...new Set(owners)],
61
+ ...(rule.pinned ? { pinned: rule.pinned } : {}),
62
+ });
63
+ }
64
+ return [...byName.values()];
65
+ }
66
+ /**
67
+ * What the doctor prints, and the wording is the point.
68
+ *
69
+ * This is not drift and it is not a mistake: the author's own project has the
70
+ * library, and the project being checked is a DIFFERENT one that has not installed
71
+ * it yet. So it reads as a prerequisite, names who needs it, and stops - the
72
+ * install command is offered, never run.
73
+ */
74
+ export function describeMissing(missing) {
75
+ const lines = [];
76
+ for (const dep of missing) {
77
+ const who = dep.neededBy.length > 0
78
+ ? `${dep.neededBy.map((n) => `ds-${n}`).join(", ")} need${dep.neededBy.length === 1 ? "s" : ""} it`
79
+ : "part of this system needs it";
80
+ lines.push(`${dep.name} not in your manifest - ${who}${dep.pinned ? ` (${dep.pinned})` : ""}`);
81
+ }
82
+ return lines;
83
+ }
84
+ /** The one sentence above the list, once. */
85
+ export function summarizeMissing(missing) {
86
+ if (missing.length === 0)
87
+ return null;
88
+ const n = missing.length;
89
+ return `${n} librar${n === 1 ? "y this system needs is" : "ies this system needs are"} not installed here. ${n === 1 ? "It is" : "They are"} a third party's, not ours - so nothing was installed for you, and the components that need ${n === 1 ? "it" : "them"} will not render until you decide.`;
90
+ }
@@ -123,6 +123,58 @@ const TAILWIND_COLOR = {
123
123
  "neutral-900": "#171717",
124
124
  "neutral-950": "#0a0a0a",
125
125
  };
126
+ /**
127
+ * TYPOGRAPHY, and the reason it is here at all.
128
+ *
129
+ * Colour, spacing and radius were read and type was not, so every text part of
130
+ * every imported component arrived with an EMPTY style block. Structurally the
131
+ * anatomy was right and a card's title still previewed at body size - which is
132
+ * the same "it is not a version of the component" complaint one layer down, and
133
+ * the spec view's whole `type` line never fired because there was nothing to
134
+ * report (dono, 01/08).
135
+ *
136
+ * Same argument as the tables above: Tailwind's type scale is fixed and
137
+ * published, so reading it is arithmetic rather than a guess. Their own
138
+ * `--text-*` and `--font-weight-*` tokens win, exactly as with colour.
139
+ */
140
+ const FONT_SIZE = {
141
+ xs: "0.75rem",
142
+ sm: "0.875rem",
143
+ base: "1rem",
144
+ lg: "1.125rem",
145
+ xl: "1.25rem",
146
+ "2xl": "1.5rem",
147
+ "3xl": "1.875rem",
148
+ "4xl": "2.25rem",
149
+ "5xl": "3rem",
150
+ "6xl": "3.75rem",
151
+ "7xl": "4.5rem",
152
+ };
153
+ const FONT_WEIGHT = {
154
+ thin: "100",
155
+ extralight: "200",
156
+ light: "300",
157
+ normal: "400",
158
+ medium: "500",
159
+ semibold: "600",
160
+ bold: "700",
161
+ extrabold: "800",
162
+ black: "900",
163
+ };
164
+ /** The three family slots the document actually has. Nothing else may be a ref. */
165
+ const FAMILY_SLOT = {
166
+ sans: "body",
167
+ serif: "display",
168
+ mono: "mono",
169
+ };
170
+ /** Type utilities with no scale behind them - a fact, not a decision deferred. */
171
+ const TYPE_KEYWORD = {
172
+ uppercase: { property: "textTransform", value: "uppercase" },
173
+ lowercase: { property: "textTransform", value: "lowercase" },
174
+ capitalize: { property: "textTransform", value: "capitalize" },
175
+ italic: { property: "fontStyle", value: "italic" },
176
+ underline: { property: "textDecoration", value: "underline" },
177
+ };
126
178
  /** States we recognise. Anything else is skipped rather than invented. */
127
179
  const STATE = /^(hover|focus|focus-visible|active|disabled|checked)$/;
128
180
  const DATA_STATE = /^data-\[([a-z-]+)\]$/;
@@ -164,6 +216,11 @@ export function readUtility(utility, declared) {
164
216
  // would mean computing a colour they never wrote. The property still belongs
165
217
  // in the recipe, so the base colour travels and the alpha does not.
166
218
  const [core] = utility.split("/");
219
+ // A bare keyword carries a declaration with no scale behind it, so it is read
220
+ // before the dash split that everything else needs.
221
+ const keyword = TYPE_KEYWORD[core];
222
+ if (keyword)
223
+ return keyword;
167
224
  const dash = core.indexOf("-");
168
225
  if (dash === -1)
169
226
  return null;
@@ -180,6 +237,66 @@ export function readUtility(utility, declared) {
180
237
  const builtin = TAILWIND_COLOR[rest];
181
238
  if (builtin)
182
239
  return { property: colorProp, value: builtin };
240
+ /**
241
+ * `text-` IS AMBIGUOUS, and only this branch knows it failed.
242
+ *
243
+ * `text-white` is a colour and `text-2xl` is a size, and both arrive with the
244
+ * same prefix. Returning null here dropped every font size in the codebase in
245
+ * silence - so a `text` that resolved to no colour falls through to the type
246
+ * scale instead of ending the read.
247
+ */
248
+ if (prefix !== "text")
249
+ return null;
250
+ }
251
+ if (prefix === "text") {
252
+ /**
253
+ * A SIZE TRAVELS AS A LITERAL, and that is not a shortcut.
254
+ *
255
+ * The document holds `typography.scale` as named STYLES (size plus line
256
+ * height plus weight), not as a flat table of lengths - so there is no ref a
257
+ * `font-size` can point at, and inventing `{typography.sizes.lg}` would emit
258
+ * a token that resolves to nothing. That renders as no size at all, which is
259
+ * worse than the literal and reads as a missing style.
260
+ *
261
+ * So the value they wrote travels, and the spec view marks it as an open
262
+ * decision - which is exactly what it is: a real length with no step of
263
+ * theirs to land on yet. That is the v2's to propose, not ours to fabricate.
264
+ */
265
+ const own = `--text-${rest}`;
266
+ const theirs = declared.get(own);
267
+ // Their own declaration outranks Tailwind's default for the same name.
268
+ if (theirs)
269
+ return { property: "fontSize", value: theirs, token: own };
270
+ if (FONT_SIZE[rest])
271
+ return { property: "fontSize", value: FONT_SIZE[rest] };
272
+ return null;
273
+ }
274
+ if (prefix === "font") {
275
+ // A family (`font-sans`) and a weight (`font-bold`) share this prefix, and
276
+ // the weight table is what tells them apart.
277
+ if (FONT_WEIGHT[rest]) {
278
+ /**
279
+ * ALSO A LITERAL, for a reason worth writing down: a weight ref is a valid
280
+ * SHAPE and still not safe. `{typography.weights.bold}` dangles on a system
281
+ * whose document declares `regular / medium / semibold` - and this side has
282
+ * no document to ask, because the CLI runs before one exists. A number
283
+ * renders correctly everywhere and the spec view marks it as the open
284
+ * decision it is.
285
+ */
286
+ return { property: "fontWeight", value: FONT_WEIGHT[rest] };
287
+ }
288
+ // A family DOES have a safe ref: the document's `families` is a fixed
289
+ // display/body/mono, so only those three map and anything else is skipped
290
+ // rather than pointed at a slot that does not exist.
291
+ const slot = FAMILY_SLOT[rest];
292
+ const family = `--font-${rest}`;
293
+ if (slot && declared.has(family)) {
294
+ return {
295
+ property: "fontFamily",
296
+ value: `{typography.families.${slot}}`,
297
+ token: family,
298
+ };
299
+ }
183
300
  return null;
184
301
  }
185
302
  const spaceProp = SPACING_PROPERTY[prefix];