synthesisui 0.16.77 → 0.16.78

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)
@@ -478,10 +486,91 @@ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
478
486
  * applies, which is what the call site reads like.
479
487
  */
480
488
  const resolveCls = (parts) => `cn(${parts.join(", ")})`;
489
+ /**
490
+ * WHAT ELEMENT EACH FORM IS, and this is where the anatomy stops being a picture.
491
+ *
492
+ * Every part came out as a `<div>` - a title, a value, a cover image, all divs -
493
+ * because a flat list of names cannot say what a part IS. The tree says, so a
494
+ * heading generates an `<h3>`, an image an `<img>`, a field an `<input>`. That is
495
+ * not cosmetic: it decides what a screen reader announces and what a browser lets
496
+ * you tab to.
497
+ */
498
+ const FORM_TAG = {
499
+ image: { tag: "img", voidEl: true },
500
+ heading: { tag: "h3" },
501
+ text: { tag: "span" },
502
+ button: { tag: "button" },
503
+ field: { tag: "input", voidEl: true },
504
+ icon: { tag: "span" },
505
+ row: { tag: "div" },
506
+ stack: { tag: "div" },
507
+ };
508
+ /** Only these two arrange; the rest are leaves. */
509
+ const ARRANGES = new Set(["row", "stack"]);
510
+ /** Every part the tree names, in tree order - so the generated part components
511
+ * come out in the order somebody reading the file expects to find them. */
512
+ function treeParts(nodes, out = []) {
513
+ for (const node of nodes ?? []) {
514
+ if (node.part && !out.includes(node.part))
515
+ out.push(node.part);
516
+ if (node.children)
517
+ treeParts(node.children, out);
518
+ }
519
+ return out;
520
+ }
521
+ /**
522
+ * THE COMPOSITION, EMITTED AS JSX RATHER THAN DESCRIBED IN A COMMENT.
523
+ *
524
+ * This is the third thing the anatomy was supposed to buy, in the owner's own
525
+ * words: speed, and fewer tokens spent in Claude Code. `<ArticleCard />` used to
526
+ * render an empty shell, and assembling it meant an agent reading a JSDoc example
527
+ * and writing the tree by hand - every time, for every component. Now the tree is
528
+ * in the file.
529
+ *
530
+ * A frontier is a COMMENT, never invented markup. A component of theirs has a
531
+ * recipe of its own and the generated sibling may not exist yet; a third-party
532
+ * library is not ours to render. Both say what belongs there and leave the slot
533
+ * open, which is the honest instruction.
534
+ */
535
+ function emitTree(nodes, comp, indent) {
536
+ const lines = [];
537
+ for (const node of nodes) {
538
+ if (node.as === "component") {
539
+ lines.push(`${indent}{/* your <${pascal(node.ref ?? "")} /> goes here - it has a recipe of its own, so it is not inlined */}`);
540
+ continue;
541
+ }
542
+ if (node.as === "external") {
543
+ lines.push(`${indent}{/* ${node.from} renders here - a third party's component, so its markup is theirs */}`);
544
+ continue;
545
+ }
546
+ if (!node.part) {
547
+ // Pure structure with no styles of its own: keep the arrangement, skip the
548
+ // element - a div that carries nothing is a div nobody needs.
549
+ if (node.children)
550
+ lines.push(emitTree(node.children, comp, indent));
551
+ continue;
552
+ }
553
+ const partComp = `${comp}${pascal(node.part)}`;
554
+ if (ARRANGES.has(node.as) && node.children && node.children.length > 0) {
555
+ lines.push(`${indent}<${partComp}>`);
556
+ lines.push(emitTree(node.children, comp, `${indent} `));
557
+ lines.push(`${indent}</${partComp}>`);
558
+ continue;
559
+ }
560
+ const { voidEl } = FORM_TAG[node.as] ?? {};
561
+ if (voidEl) {
562
+ lines.push(`${indent}<${partComp} />`);
563
+ continue;
564
+ }
565
+ lines.push(`${indent}<${partComp}>${node.text ?? `{/* ${node.part} */}`}</${partComp}>`);
566
+ }
567
+ return lines.filter(Boolean).join("\n");
568
+ }
481
569
  /** JSDoc showing how to compose the component with its parts + content, so the
482
570
  * materialized code doesn't read as "a bare shell renders nothing" (dogfood
483
571
  * #5). Built from the recipe's parts. */
484
572
  function compositionHint(comp, name, recipe, voidEl = false) {
573
+ const tree = recipe.preview?.parts;
485
574
  const partNames = Object.keys(recipe.parts ?? {});
486
575
  // An `<input>` or `<hr>` takes no children, and telling somebody to put
487
576
  // content inside one is an instruction that throws. With parts, the element
@@ -489,6 +578,16 @@ function compositionHint(comp, name, recipe, voidEl = false) {
489
578
  if (voidEl) {
490
579
  return `/** Wears the "${name}" recipe. Takes no children - it renders a single void element. */`;
491
580
  }
581
+ if (tree && tree.length > 0) {
582
+ // The shape is IN the file below, so the comment stops teaching assembly and
583
+ // starts saying what is already there and how to change it.
584
+ return `/**
585
+ * Wears the "${name}" recipe, composed as your own code composes it - the shape
586
+ * below was read out of your JSX, so it renders as the component rather than as
587
+ * an empty shell. Replace the placeholders with your content; every part is also
588
+ * exported on its own if you need a different arrangement.
589
+ */`;
590
+ }
492
591
  if (partNames.length === 0) {
493
592
  return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
494
593
  }
@@ -503,37 +602,110 @@ ${inner}
503
602
  * </${comp}>
504
603
  */`;
505
604
  }
506
- function emitCssMode(slug, name, recipe, version, props) {
605
+ /**
606
+ * WHICH TAG A PART GETS, and the tree is what finally lets this be right.
607
+ *
608
+ * A part that styles focus is a control and has always been promoted to a button
609
+ * - that check stays and still wins, because a focus ring is a stronger statement
610
+ * than a name. Below it, the tree's `as` decides: a heading is an `<h3>`, an image
611
+ * an `<img>`, a field an `<input>`. Without a tree everything falls back to `div`,
612
+ * which is exactly what every part used to be.
613
+ */
614
+ function partTagFor(part, form) {
615
+ if (partIsInteractive(part)) {
616
+ return { tag: "button", attrs: ' type="button"', voidEl: false };
617
+ }
618
+ const mapped = form ? FORM_TAG[form] : undefined;
619
+ if (!mapped)
620
+ return { tag: "div", attrs: "", voidEl: false };
621
+ return {
622
+ tag: mapped.tag,
623
+ /**
624
+ * `type="button"` IS NOT OPTIONAL ON A BUTTON.
625
+ *
626
+ * A `<button>` with no type defaults to `submit`, so the moment one of these
627
+ * parts sits inside a form, clicking it submits the form. `asElement` already
628
+ * knows how to carry this through the `as` escape hatch - it just needs to be
629
+ * told, and the interactive branch above was the only caller telling it.
630
+ *
631
+ * An `<img>` with no `alt` is an accessibility failure the generator would be
632
+ * authoring, so it ships with an empty one: decorative by default, and the
633
+ * caller overrides it through `...props` the moment it carries meaning.
634
+ */
635
+ attrs: mapped.tag === "button"
636
+ ? ' type="button"'
637
+ : mapped.tag === "img"
638
+ ? ' alt=""'
639
+ : "",
640
+ voidEl: Boolean(mapped.voidEl),
641
+ };
642
+ }
643
+ function emitCssMode(slug, name, recipe, version, props, convention) {
507
644
  const { tag, attrs, voidEl } = elementFor(name, recipe);
508
645
  const el = asElement(tag, attrs, voidEl);
509
646
  const axes = axesOf(recipe.variants);
510
647
  const comp = pascal(name);
511
648
  const propNames = axes.map((a) => a.prop);
649
+ const tree = recipe.preview?.parts ?? [];
650
+ // With a shape the root RENDERS its children, so it needs `children` in the
651
+ // destructure and a real closing tag rather than a self-closing one.
652
+ const hasShape = tree.length > 0 && !voidEl;
512
653
  const destructure = [
513
654
  ...propNames,
514
655
  ...(el.offersAs ? ["as"] : []),
656
+ ...(hasShape ? ["children"] : []),
515
657
  "className",
516
658
  "...props",
517
659
  ].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]) => {
660
+ const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}\n className={${joinCls([`"${elementClass(name, convention)}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n `;
661
+ const rootJsx = hasShape
662
+ ? `${rootOpen}>\n${emitTree(tree, comp, " ")}\n {children}\n </${el.jsxTag}>`
663
+ : `${rootOpen}/>`;
664
+ // Tree order first, because that is the order somebody reads them in the file;
665
+ // anything the tree does not mention still gets its component.
666
+ const ordered = [
667
+ ...treeParts(recipe.preview?.parts).filter((p) => recipe.parts?.[p]),
668
+ ...Object.keys(recipe.parts ?? {}).filter((p) => !treeParts(recipe.preview?.parts).includes(p)),
669
+ ];
670
+ const formOf = new Map();
671
+ const collect = (nodes) => {
672
+ for (const node of nodes) {
673
+ if (node.part)
674
+ formOf.set(node.part, node.as);
675
+ if (node.children)
676
+ collect(node.children);
677
+ }
678
+ };
679
+ collect(tree);
680
+ const parts = ordered.map((partName) => {
681
+ const part = recipe.parts?.[partName];
682
+ if (!part)
683
+ return "";
520
684
  const partAxes = axesOf(part.variants ?? {});
521
685
  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);
686
+ const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
687
+ const partEl = asElement(partTag, partAttrs, partVoid);
688
+ /**
689
+ * `as` ONLY WHERE THE ELEMENT HONOURS IT.
690
+ *
691
+ * A void element ignores it - `asElement` says so with `offersAs: false` - so
692
+ * typing and destructuring it anyway would declare a prop that silently does
693
+ * nothing. This file already paid for that exact shape once, on a different
694
+ * prop, and no part could be void until the tree started naming images and
695
+ * fields.
696
+ */
525
697
  const partDestructure = [
526
698
  ...partAxes.map((a) => a.prop),
527
- "as",
699
+ ...(partEl.offersAs ? ["as"] : []),
528
700
  "className",
529
701
  "...props",
530
702
  ].join(", ");
531
703
  return `
532
- /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
533
- export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, true)}) {
704
+ /** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
705
+ export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
534
706
  ${partEl.setup} return (
535
707
  <${partEl.jsxTag}${partEl.jsxAttrs}
536
- className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
708
+ className={${joinCls([`"${partClassName(name, partName, convention)}"`, "className"])}}
537
709
  ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
538
710
  />
539
711
  );
@@ -553,7 +725,7 @@ ${el.setup} return (
553
725
  ${rootJsx}
554
726
  );
555
727
  }
556
- ${parts.join("\n")}`;
728
+ ${parts.filter(Boolean).join("\n")}`;
557
729
  }
558
730
  function emitTailwindMode(slug, name, recipe, version, props) {
559
731
  const { tag, attrs, voidEl } = elementFor(name, recipe);
@@ -584,13 +756,37 @@ function emitTailwindMode(slug, name, recipe, version, props) {
584
756
  : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ${fallbackFor(a)}`),
585
757
  "className",
586
758
  ];
759
+ const tree = recipe.preview?.parts ?? [];
760
+ // Same as css mode: with a shape the root renders its children, so it needs
761
+ // `children` and a real closing tag. The CLASSES differ between flavours; the
762
+ // SHAPE is the same fact about their component either way.
763
+ const hasShape = tree.length > 0 && !voidEl;
587
764
  const destructure = [
588
765
  ...axes.map((a) => a.prop),
589
766
  ...(el.offersAs ? ["as"] : []),
767
+ ...(hasShape ? ["children"] : []),
590
768
  "className",
591
769
  "...props",
592
770
  ].join(", ");
593
771
  const needsElementType = el.offersAs || Object.keys(recipe.parts ?? {}).length > 0;
772
+ const formOf = new Map();
773
+ const collect = (nodes) => {
774
+ for (const node of nodes) {
775
+ if (node.part)
776
+ formOf.set(node.part, node.as);
777
+ if (node.children)
778
+ collect(node.children);
779
+ }
780
+ };
781
+ collect(tree);
782
+ const treeOrder = treeParts(recipe.preview?.parts);
783
+ const orderedParts = [
784
+ ...treeOrder.filter((p) => recipe.parts?.[p]),
785
+ ...Object.keys(recipe.parts ?? {}).filter((p) => !treeOrder.includes(p)),
786
+ ];
787
+ const rootOpen = ` <${el.jsxTag}${el.jsxAttrs}
788
+ className={${resolveCls(clsParts)}}
789
+ ${dataAttrLines(axes)}${axes.length ? "\n" : ""} `;
594
790
  return `${header(slug, name, version, "tailwind")}
595
791
 
596
792
  import type { ${needsElementType ? `ElementType, ${props}` : props} } from "react";
@@ -604,19 +800,25 @@ type ${comp}Props = ${propsType(axes, tag, props, el.offersAs)};
604
800
  ${compositionHint(comp, name, recipe, voidEl)}
605
801
  export function ${comp}({ ${destructure} }: ${comp}Props) {
606
802
  ${el.setup} return (
607
- <${el.jsxTag}${el.jsxAttrs}
608
- className={${resolveCls(clsParts)}}
609
- ${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}
610
- />
803
+ ${hasShape
804
+ ? `${rootOpen} {...props}
805
+ >
806
+ ${emitTree(tree, comp, " ")}
807
+ {children}
808
+ </${el.jsxTag}>`
809
+ : `${rootOpen} {...props}
810
+ />`}
611
811
  );
612
812
  }
613
- ${Object.entries(recipe.parts ?? {})
614
- .map(([partName, part]) => {
813
+ ${orderedParts
814
+ .map((partName) => {
815
+ const part = recipe.parts?.[partName];
816
+ if (!part)
817
+ return "";
615
818
  const partComp = `${comp}${pascal(partName)}`;
616
819
  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);
820
+ const { tag: partTag, attrs: partAttrs, voidEl: partVoid, } = partTagFor(part, formOf.get(partName));
821
+ const partEl = asElement(partTag, partAttrs, partVoid);
620
822
  /**
621
823
  * THE STATE THE CATALOGUE DOCUMENTS, DELIVERED (1kro2o, 29/07).
622
824
  *
@@ -637,15 +839,24 @@ ${Object.entries(recipe.parts ?? {})
637
839
  const partCls = [tailwindClassList(part), ...partVariantClasses]
638
840
  .filter(Boolean)
639
841
  .join(" ");
842
+ /**
843
+ * `as` ONLY WHERE THE ELEMENT HONOURS IT.
844
+ *
845
+ * A void element ignores it - `asElement` says so with `offersAs: false` - so
846
+ * typing and destructuring it anyway would declare a prop that silently does
847
+ * nothing. This file already paid for that exact shape once, on a different
848
+ * prop, and no part could be void until the tree started naming images and
849
+ * fields.
850
+ */
640
851
  const partDestructure = [
641
852
  ...partAxes.map((a) => a.prop),
642
- "as",
853
+ ...(partEl.offersAs ? ["as"] : []),
643
854
  "className",
644
855
  "...props",
645
856
  ].join(", ");
646
857
  return `
647
- /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
648
- export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, true)}) {
858
+ /** Part "${partName}" of ${comp}${formOf.get(partName) ? ` (${formOf.get(partName)})` : ""} - compose it inside <${comp}>. */
859
+ export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props, partEl.offersAs)}) {
649
860
  ${partEl.setup} return (
650
861
  <${partEl.jsxTag}${partEl.jsxAttrs}
651
862
  className={${resolveCls([JSON.stringify(partCls), "className"])}}
@@ -654,20 +865,27 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
654
865
  );
655
866
  }`;
656
867
  })
868
+ .filter(Boolean)
657
869
  .join("\n")}`;
658
870
  }
659
871
  /** All files for one component, under `<componentsDir>/<name>/`. */
660
872
  export function generateComponentFiles(slug, name, recipe, css, version, styles,
661
873
  /** Consumer's React major, read from its package.json. Null = unknown, which
662
874
  * keeps the ref-less type rather than guessing in the unsafe direction. */
663
- reactMajor = null) {
875
+ reactMajor = null,
876
+ /**
877
+ * HOW THE SYSTEM SPELLS A CLASS, from the registry. Absent = ours, which is
878
+ * every caller that predates the convention being the user's - and getting this
879
+ * wrong shipped a component wearing classes its own stylesheet never emits.
880
+ */
881
+ convention = DEFAULT_CONVENTION) {
664
882
  const comp = pascal(name);
665
883
  const files = [];
666
884
  const props = propsTypeName(reactMajor);
667
885
  if (styles === "css") {
668
886
  files.push({
669
887
  filename: `${name}.tsx`,
670
- code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
888
+ code: `${emitCssMode(slug, name, recipe, version, props, convention)}\n`,
671
889
  });
672
890
  files.push({ filename: `${name}.css`, code: `${css}\n` });
673
891
  }
@@ -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];