synthesisui 0.16.16 → 0.16.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-md.js +5 -5
- package/dist/cn-codegen.js +168 -0
- package/dist/commands/component.js +48 -2
- package/dist/component-codegen.js +98 -21
- package/dist/doctor/scan.js +29 -6
- package/dist/doctor/tokens.js +27 -3
- package/package.json +1 -1
package/dist/claude-md.js
CHANGED
|
@@ -281,11 +281,11 @@ YOURSELF. It materializes that component as real typed code in this project, and
|
|
|
281
281
|
extend that. The person who asked you for a feature should never have to know these command
|
|
282
282
|
names or type them; finding the right component is your job, not theirs.
|
|
283
283
|
|
|
284
|
-
|
|
285
|
-
\`className="text-info
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
284
|
+
Overriding a style a component already sets just works - write
|
|
285
|
+
\`className="text-info"\`, with no \`!\`. The generated components resolve your class
|
|
286
|
+
against their own, so the last one written wins, which is what the call site reads like.
|
|
287
|
+
If an override is ignored, that component predates the resolver: regenerate it with
|
|
288
|
+
\`npx synthesisui@latest component <slug> <name>\` rather than reaching for \`!\`.
|
|
289
289
|
|
|
290
290
|
Only write something new when nothing in the manifest covers the purpose - and when you do,
|
|
291
291
|
say which entry you considered and why it did not fit. To review a
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cn()`: the override the consumer writes has to actually win.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG. Two plain Tailwind utilities that set the same property have the
|
|
5
|
+
* same specificity, so which one applies is decided by the order Tailwind emits
|
|
6
|
+
* them into the stylesheet - not by the order of names in the string, which is
|
|
7
|
+
* the only thing the call site appears to control. A component whose base says
|
|
8
|
+
* `text-foreground` therefore ignores `className="text-info"`, silently, and
|
|
9
|
+
* the code reads as if it should work.
|
|
10
|
+
*
|
|
11
|
+
* THE MITIGATION WE SHIPPED FIRST, and why it was not enough. On 27/07 the
|
|
12
|
+
* managed block started telling agents to write `text-info!`. It works, and the
|
|
13
|
+
* test on 28/07 measured what it costs: 61 exclamation marks in 1,885 generated
|
|
14
|
+
* lines, one forced escape every thirty lines. It also has a hole - two `!`
|
|
15
|
+
* utilities on the same property tie again, so it moves the coin flip up a
|
|
16
|
+
* level rather than removing it.
|
|
17
|
+
*
|
|
18
|
+
* WHY OURS CAN BE SMALLER THAN `tailwind-merge`. That library infers at runtime
|
|
19
|
+
* and cannot see your theme, so it carries a table of every Tailwind class group
|
|
20
|
+
* and still guesses at custom names. We generate the resolver next to the
|
|
21
|
+
* system, so it is not guessing: `text-foreground` is a colour because
|
|
22
|
+
* `foreground` is in this system's colour namespace, and `text-lg` is a size
|
|
23
|
+
* because `lg` is in its type scale. Those two families are the only genuinely
|
|
24
|
+
* ambiguous ones; everything else is decided by the prefix.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT DELIBERATELY DOES NOT DO. It resolves collisions on the SAME property
|
|
27
|
+
* key - `bg-a` against `bg-b`, `p-md` against `p-lg`. It does not model the
|
|
28
|
+
* hierarchy between `p` and `px` and `pt`, because Tailwind's own emission order
|
|
29
|
+
* already resolves those correctly, and pretending to solve more would be a
|
|
30
|
+
* second thing to be wrong about.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Reads the `@theme` namespaces out of a compiled theme.css.
|
|
34
|
+
*
|
|
35
|
+
* `--color-foreground: …` means the project has a `text-foreground`,
|
|
36
|
+
* `bg-foreground` and `border-foreground`. Suffixes like
|
|
37
|
+
* `--text-lg--line-height` are declarations ABOUT a name, not names, so the
|
|
38
|
+
* double dash is what excludes them.
|
|
39
|
+
*/
|
|
40
|
+
export function readVocabulary(themeCss) {
|
|
41
|
+
const namesIn = (ns) => {
|
|
42
|
+
const out = new Set();
|
|
43
|
+
const re = new RegExp(`^\\s*--${ns}-([a-z0-9-]+)\\s*:`, "gm");
|
|
44
|
+
for (const m of themeCss.matchAll(re)) {
|
|
45
|
+
if (!m[1].includes("--"))
|
|
46
|
+
out.add(m[1]);
|
|
47
|
+
}
|
|
48
|
+
return [...out].sort();
|
|
49
|
+
};
|
|
50
|
+
const font = namesIn("font");
|
|
51
|
+
const weights = namesIn("font-weight");
|
|
52
|
+
return {
|
|
53
|
+
colors: namesIn("color"),
|
|
54
|
+
textSizes: namesIn("text"),
|
|
55
|
+
// `--font-weight-medium` also matches the `font` namespace as
|
|
56
|
+
// `weight-medium`; the real families are what is left after removing them.
|
|
57
|
+
fontFamilies: font.filter((f) => !f.startsWith("weight-")),
|
|
58
|
+
fontWeights: weights,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const set = (names) => `new Set([${names.map((n) => JSON.stringify(n)).join(", ")}])`;
|
|
62
|
+
/** The `cn.ts` a project gets, written against ITS system's vocabulary. */
|
|
63
|
+
export function emitCn(slug, vocab) {
|
|
64
|
+
return `/**
|
|
65
|
+
* Class resolver for the "${slug}" design system. Generated by SynthesisUI.
|
|
66
|
+
*
|
|
67
|
+
* Two plain Tailwind utilities setting the same property have the same
|
|
68
|
+
* specificity, so the winner is decided by the order Tailwind writes the
|
|
69
|
+
* stylesheet - not by the order in your className, which is what the code looks
|
|
70
|
+
* like it controls. A component whose base sets \`text-foreground\` would ignore
|
|
71
|
+
* \`className="text-info"\` without any error.
|
|
72
|
+
*
|
|
73
|
+
* This drops the earlier class when a later one sets the same property, so the
|
|
74
|
+
* last one wins, which is what the call site reads like. Pass the base first and
|
|
75
|
+
* the caller's className last.
|
|
76
|
+
*
|
|
77
|
+
* It resolves collisions on the same property key (\`bg-a\` vs \`bg-b\`, \`p-md\` vs
|
|
78
|
+
* \`p-lg\`). It does not model \`p\` against \`px\` against \`pt\`: Tailwind's own
|
|
79
|
+
* emission order already gets those right.
|
|
80
|
+
*
|
|
81
|
+
* Regenerated by \`synthesisui component\`. Safe to edit, but a new component
|
|
82
|
+
* will overwrite it.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/** This system's own namespaces, so \`text-*\` and \`font-*\` are never a guess. */
|
|
86
|
+
const COLORS = ${set(vocab.colors)};
|
|
87
|
+
const TEXT_SIZES = ${set(vocab.textSizes)};
|
|
88
|
+
const FONT_FAMILIES = ${set(vocab.fontFamilies)};
|
|
89
|
+
const FONT_WEIGHTS = ${set(vocab.fontWeights)};
|
|
90
|
+
|
|
91
|
+
/** Prefixes where the prefix alone decides the property. Longest first, so
|
|
92
|
+
* \`border-t\` is read before \`border\`. */
|
|
93
|
+
const PREFIXES = [
|
|
94
|
+
"bg",
|
|
95
|
+
"shadow",
|
|
96
|
+
"opacity",
|
|
97
|
+
"rounded-tl", "rounded-tr", "rounded-br", "rounded-bl",
|
|
98
|
+
"rounded-t", "rounded-r", "rounded-b", "rounded-l",
|
|
99
|
+
"rounded",
|
|
100
|
+
"border-t", "border-r", "border-b", "border-l", "border-x", "border-y",
|
|
101
|
+
"border",
|
|
102
|
+
"px", "py", "pt", "pr", "pb", "pl", "ps", "pe", "p",
|
|
103
|
+
"mx", "my", "mt", "mr", "mb", "ml", "ms", "me", "m",
|
|
104
|
+
"gap-x", "gap-y", "gap",
|
|
105
|
+
"w", "h", "min-w", "min-h", "max-w", "max-h",
|
|
106
|
+
"leading", "tracking",
|
|
107
|
+
"flex", "grid", "justify", "items", "self", "order", "z",
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The property this class sets, or the class itself when we cannot tell.
|
|
112
|
+
*
|
|
113
|
+
* Returning the class itself is the safe answer: an unknown class then collides
|
|
114
|
+
* only with an identical one, so nothing is ever dropped by mistake.
|
|
115
|
+
*/
|
|
116
|
+
function groupOf(cls: string): string {
|
|
117
|
+
// A variant is part of the identity: \`hover:bg-x\` must not replace \`bg-y\`.
|
|
118
|
+
const cut = cls.lastIndexOf(":");
|
|
119
|
+
const variants = cut === -1 ? "" : cls.slice(0, cut + 1);
|
|
120
|
+
let base = cut === -1 ? cls : cls.slice(cut + 1);
|
|
121
|
+
|
|
122
|
+
// \`!\` marks importance, \`/40\` sets opacity. Neither changes the property.
|
|
123
|
+
base = base.replace(/^!|!$/g, "").replace(/\\/[^/]+$/, "");
|
|
124
|
+
const negative = base.startsWith("-");
|
|
125
|
+
if (negative) base = base.slice(1);
|
|
126
|
+
|
|
127
|
+
const dash = base.indexOf("-");
|
|
128
|
+
const head = dash === -1 ? base : base.slice(0, dash);
|
|
129
|
+
const rest = dash === -1 ? "" : base.slice(dash + 1);
|
|
130
|
+
|
|
131
|
+
// The two families a prefix cannot settle, answered by this system's names.
|
|
132
|
+
if (head === "text") {
|
|
133
|
+
if (TEXT_SIZES.has(rest)) return \`\${variants}font-size\`;
|
|
134
|
+
if (COLORS.has(rest) || rest.startsWith("[")) return \`\${variants}text-color\`;
|
|
135
|
+
return \`\${variants}\${cls}\`;
|
|
136
|
+
}
|
|
137
|
+
if (head === "font") {
|
|
138
|
+
if (FONT_WEIGHTS.has(rest)) return \`\${variants}font-weight\`;
|
|
139
|
+
if (FONT_FAMILIES.has(rest)) return \`\${variants}font-family\`;
|
|
140
|
+
return \`\${variants}\${cls}\`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const p of PREFIXES) {
|
|
144
|
+
if (base === p || base.startsWith(\`\${p}-\`)) return \`\${variants}\${p}\`;
|
|
145
|
+
}
|
|
146
|
+
return \`\${variants}\${cls}\`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type ClassValue = string | false | null | undefined;
|
|
150
|
+
|
|
151
|
+
export function cn(...parts: ClassValue[]): string {
|
|
152
|
+
const won = new Map<string, string>();
|
|
153
|
+
for (const part of parts) {
|
|
154
|
+
if (!part) continue;
|
|
155
|
+
for (const cls of part.split(/\\s+/)) {
|
|
156
|
+
if (!cls) continue;
|
|
157
|
+
const key = groupOf(cls);
|
|
158
|
+
// Delete before set so the winner also takes the later POSITION. The
|
|
159
|
+
// string order does not decide the cascade, but it is what a person reads
|
|
160
|
+
// in the DOM, and a stale leading class there is a lie.
|
|
161
|
+
won.delete(key);
|
|
162
|
+
won.set(key, cls);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [...won.values()].join(" ");
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
}
|
|
@@ -1,10 +1,54 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { emitCn, readVocabulary } from "../cn-codegen.js";
|
|
3
4
|
import { generateComponentFiles } from "../component-codegen.js";
|
|
4
5
|
import { readProjectConfig, resolveRegistry } from "../config.js";
|
|
5
6
|
import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
|
|
6
7
|
import { body, section, snippet } from "../output.js";
|
|
7
8
|
import { fetchComponent, RegistryError } from "../registry.js";
|
|
9
|
+
/**
|
|
10
|
+
* Writes the shared `cn.ts` next to the components, built from THIS project's
|
|
11
|
+
* installed theme.
|
|
12
|
+
*
|
|
13
|
+
* Only the tailwind flavour needs it: in css mode the recipe lives in
|
|
14
|
+
* `@layer components` and every utility already outranks it. Generated from the
|
|
15
|
+
* theme on disk rather than from a table of Tailwind's own names, which is why
|
|
16
|
+
* it can tell `text-foreground` (a colour here) from `text-lg` (a size here)
|
|
17
|
+
* without guessing.
|
|
18
|
+
*/
|
|
19
|
+
async function writeCn(root, compDir, slug) {
|
|
20
|
+
const theme = await readFile(join(root, "_synthesisui", "ds", slug, "theme.css"), "utf8").catch(() => "");
|
|
21
|
+
// The root file is a pointer at the pinned version; follow it.
|
|
22
|
+
const inner = /@import\s+["']\.\/([^"']+)["']/.exec(theme)?.[1];
|
|
23
|
+
const real = inner
|
|
24
|
+
? await readFile(join(root, "_synthesisui", "ds", slug, inner), "utf8").catch(() => theme)
|
|
25
|
+
: theme;
|
|
26
|
+
if (!real)
|
|
27
|
+
return;
|
|
28
|
+
await writeFile(join(compDir, "..", "cn.ts"), emitCn(slug, readVocabulary(real)), "utf8");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The consumer's React major, or null when we cannot tell.
|
|
32
|
+
*
|
|
33
|
+
* It decides whether the generated components can take a `ref`: from 19 that is
|
|
34
|
+
* an ordinary prop, before it a function component needs `forwardRef`. Guessing
|
|
35
|
+
* high on an older project would emit a type that accepts a ref React then
|
|
36
|
+
* silently drops, so anything unreadable falls back to the ref-less type.
|
|
37
|
+
*/
|
|
38
|
+
async function reactMajorOf(root) {
|
|
39
|
+
const raw = await readFile(join(root, "package.json"), "utf8").catch(() => "");
|
|
40
|
+
if (!raw)
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const pkg = JSON.parse(raw);
|
|
44
|
+
const spec = pkg.dependencies?.react ?? pkg.devDependencies?.react;
|
|
45
|
+
const major = /(\d+)/.exec(spec ?? "")?.[1];
|
|
46
|
+
return major ? Number(major) : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
8
52
|
/** Slugs/names are kebab-case by contract; reject anything else before it ever
|
|
9
53
|
* reaches a filesystem path (defense-in-depth against `../` traversal). */
|
|
10
54
|
const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -59,10 +103,12 @@ export async function component(slug, name, opts) {
|
|
|
59
103
|
filenames = [`${res.name}.tsx`, `${res.name}.css`, "index.ts"];
|
|
60
104
|
}
|
|
61
105
|
else {
|
|
62
|
-
const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
|
|
106
|
+
const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root));
|
|
63
107
|
for (const file of files) {
|
|
64
108
|
await writeFile(join(compDir, file.filename), file.code, "utf8");
|
|
65
109
|
}
|
|
110
|
+
if (config.styles === "tailwind")
|
|
111
|
+
await writeCn(root, compDir, slug);
|
|
66
112
|
filenames = files.map((f) => f.filename);
|
|
67
113
|
}
|
|
68
114
|
const flavor = wantInteractive ? "interactive" : `styles: ${config.styles}`;
|
|
@@ -8,6 +8,28 @@ const camel = (name) => {
|
|
|
8
8
|
const p = pascal(name);
|
|
9
9
|
return p[0].toLowerCase() + p.slice(1);
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* A PART THAT STYLES FOCUS IS A CONTROL, AND EVERY ONE OF THEM SHIPPED AS A DIV.
|
|
13
|
+
*
|
|
14
|
+
* Found by an agent building a carousel (27/07). `PaginationItem` came out as a
|
|
15
|
+
* `<div>` carrying `cursor-pointer`, `hover:text-foreground`,
|
|
16
|
+
* `focus-visible:[outline:2px]` and `disabled:[opacity:0.4]` - every affordance
|
|
17
|
+
* of a button on an element that is not focusable, is not announced as a
|
|
18
|
+
* control, and where `disabled:` can never match. It worked around us by
|
|
19
|
+
* putting the class on a real `<button>` instead of using the component.
|
|
20
|
+
*
|
|
21
|
+
* The recipe already knew. Nobody writes a focus ring on a div by accident, so
|
|
22
|
+
* `focus`/`focusVisible`/`disabled` in a part's states IS the declaration that
|
|
23
|
+
* it is operable. `hover` deliberately does not count: a card that lifts under
|
|
24
|
+
* the cursor is still a card.
|
|
25
|
+
*
|
|
26
|
+
* Conservative by construction. Across the shipped catalogue this promotes
|
|
27
|
+
* three parts of fifty - stepper.button, tabs.trigger, pagination.item - and
|
|
28
|
+
* all three are buttons that were wearing the wrong tag.
|
|
29
|
+
*/
|
|
30
|
+
export function partIsInteractive(part) {
|
|
31
|
+
return Object.keys(part.states ?? {}).some((s) => /^(focus|focusVisible|focus-visible|disabled)$/i.test(s));
|
|
32
|
+
}
|
|
11
33
|
/** Intrinsic element + extra attrs per component, chosen like the platform
|
|
12
34
|
* renderer does (name first, then preview.kind). Fallback: div + children. */
|
|
13
35
|
function elementFor(name, recipe) {
|
|
@@ -82,13 +104,36 @@ function axesOf(variants) {
|
|
|
82
104
|
}
|
|
83
105
|
return axes;
|
|
84
106
|
}
|
|
85
|
-
|
|
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
|
|
91
|
-
return
|
|
135
|
+
return `${base}<"${tag}">`;
|
|
136
|
+
return `${base}<"${tag}"> & {\n${extras.join("\n")}\n}`;
|
|
92
137
|
}
|
|
93
138
|
function dataAttrLines(axes) {
|
|
94
139
|
return axes
|
|
@@ -372,12 +417,33 @@ function header(slug, name, version, mode) {
|
|
|
372
417
|
* which this generator has. Until it is built, the failure at least stops
|
|
373
418
|
* being silent, which is the part that actually costs people time.
|
|
374
419
|
*/
|
|
420
|
+
/**
|
|
421
|
+
* This used to tell people to write `text-info!`, which worked and cost 61
|
|
422
|
+
* exclamation marks in 1,885 generated lines when it was measured (28/07). It
|
|
423
|
+
* also had a hole: two `!` utilities on the same property tie again.
|
|
424
|
+
*
|
|
425
|
+
* `cn()` removes the tie instead of moving it, so the instruction becomes a lie
|
|
426
|
+
* the moment it ships - and an instruction that asks for work the mechanism
|
|
427
|
+
* already did is the drift this product exists to catch.
|
|
428
|
+
*/
|
|
375
429
|
const OVERRIDE_WARNING = `//
|
|
376
|
-
// Overriding a style this component already sets
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
430
|
+
// Overriding a style this component already sets just works: \`className\` is
|
|
431
|
+
// resolved against the base by \`cn()\`, so the last one written wins.
|
|
432
|
+
// <Thing className="text-info" /> no \`!\` needed
|
|
433
|
+
// If an override is ignored, this file predates that resolver - regenerate it
|
|
434
|
+
// with \`npx synthesisui component <slug> <name>\`.`;
|
|
380
435
|
const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
|
|
436
|
+
/**
|
|
437
|
+
* TAILWIND FLAVOUR ONLY, and the asymmetry is the point.
|
|
438
|
+
*
|
|
439
|
+
* In css mode the recipe compiles into `@layer components`, which every utility
|
|
440
|
+
* already outranks - so a caller's `className` wins by the cascade and a
|
|
441
|
+
* resolver would be dead weight. In tailwind mode the base IS utilities, tied
|
|
442
|
+
* with the caller's at the same specificity, and the winner is decided by
|
|
443
|
+
* stylesheet order. `cn()` drops the loser so the last one written actually
|
|
444
|
+
* applies, which is what the call site reads like.
|
|
445
|
+
*/
|
|
446
|
+
const resolveCls = (parts) => `cn(${parts.join(", ")})`;
|
|
381
447
|
/** JSDoc showing how to compose the component with its parts + content, so the
|
|
382
448
|
* materialized code doesn't read as "a bare shell renders nothing" (dogfood
|
|
383
449
|
* #5). Built from the recipe's parts. */
|
|
@@ -403,7 +469,7 @@ ${inner}
|
|
|
403
469
|
* </${comp}>
|
|
404
470
|
*/`;
|
|
405
471
|
}
|
|
406
|
-
function emitCssMode(slug, name, recipe, version) {
|
|
472
|
+
function emitCssMode(slug, name, recipe, version, props) {
|
|
407
473
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
408
474
|
const axes = axesOf(recipe.variants);
|
|
409
475
|
const comp = pascal(name);
|
|
@@ -418,11 +484,14 @@ function emitCssMode(slug, name, recipe, version) {
|
|
|
418
484
|
"className",
|
|
419
485
|
"...props",
|
|
420
486
|
].join(", ");
|
|
487
|
+
const control = partIsInteractive(part);
|
|
488
|
+
const partTag = control ? "button" : "div";
|
|
489
|
+
const partAttrs = control ? '\n type="button"' : "";
|
|
421
490
|
return `
|
|
422
491
|
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
423
|
-
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes,
|
|
492
|
+
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props)}) {
|
|
424
493
|
return (
|
|
425
|
-
|
|
494
|
+
<${partTag}${partAttrs}
|
|
426
495
|
className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
|
|
427
496
|
${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
428
497
|
/>
|
|
@@ -432,9 +501,9 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
|
432
501
|
return `${header(slug, name, version, "css")}
|
|
433
502
|
import "./${name}.css";
|
|
434
503
|
|
|
435
|
-
import type {
|
|
504
|
+
import type { ${props} } from "react";
|
|
436
505
|
|
|
437
|
-
type ${comp}Props = ${propsType(axes, tag)};
|
|
506
|
+
type ${comp}Props = ${propsType(axes, tag, props)};
|
|
438
507
|
|
|
439
508
|
${compositionHint(comp, name, recipe, voidEl)}
|
|
440
509
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
@@ -444,7 +513,7 @@ ${rootJsx}
|
|
|
444
513
|
}
|
|
445
514
|
${parts.join("\n")}`;
|
|
446
515
|
}
|
|
447
|
-
function emitTailwindMode(slug, name, recipe, version) {
|
|
516
|
+
function emitTailwindMode(slug, name, recipe, version, props) {
|
|
448
517
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
449
518
|
const axes = axesOf(recipe.variants);
|
|
450
519
|
const comp = pascal(name);
|
|
@@ -479,18 +548,19 @@ function emitTailwindMode(slug, name, recipe, version) {
|
|
|
479
548
|
].join(", ");
|
|
480
549
|
return `${header(slug, name, version, "tailwind")}
|
|
481
550
|
|
|
482
|
-
import type {
|
|
551
|
+
import type { ${props} } from "react";
|
|
552
|
+
import { cn } from "../cn";
|
|
483
553
|
|
|
484
554
|
const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
|
|
485
555
|
${[...variantConsts, ...booleanConsts].join("\n")}
|
|
486
556
|
|
|
487
|
-
type ${comp}Props = ${propsType(axes, tag)};
|
|
557
|
+
type ${comp}Props = ${propsType(axes, tag, props)};
|
|
488
558
|
|
|
489
559
|
${compositionHint(comp, name, recipe, voidEl)}
|
|
490
560
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
491
561
|
return (
|
|
492
562
|
<${tag}${attrs}
|
|
493
|
-
className={${
|
|
563
|
+
className={${resolveCls(clsParts)}}
|
|
494
564
|
{...props}
|
|
495
565
|
/>
|
|
496
566
|
);
|
|
@@ -498,31 +568,38 @@ export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
|
498
568
|
${Object.entries(recipe.parts ?? {})
|
|
499
569
|
.map(([partName, part]) => {
|
|
500
570
|
const partComp = `${comp}${pascal(partName)}`;
|
|
571
|
+
const control = partIsInteractive(part);
|
|
572
|
+
const partTag = control ? "button" : "div";
|
|
573
|
+
const partAttrs = control ? ' type="button"' : "";
|
|
501
574
|
return `
|
|
502
575
|
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
503
|
-
export function ${partComp}({ className, ...props }:
|
|
576
|
+
export function ${partComp}({ className, ...props }: ${props}<"${partTag}">) {
|
|
504
577
|
return (
|
|
505
|
-
|
|
578
|
+
<${partTag}${partAttrs} className={${resolveCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
|
|
506
579
|
);
|
|
507
580
|
}`;
|
|
508
581
|
})
|
|
509
582
|
.join("\n")}`;
|
|
510
583
|
}
|
|
511
584
|
/** All files for one component, under `<componentsDir>/<name>/`. */
|
|
512
|
-
export function generateComponentFiles(slug, name, recipe, css, version, styles
|
|
585
|
+
export function generateComponentFiles(slug, name, recipe, css, version, styles,
|
|
586
|
+
/** Consumer's React major, read from its package.json. Null = unknown, which
|
|
587
|
+
* keeps the ref-less type rather than guessing in the unsafe direction. */
|
|
588
|
+
reactMajor = null) {
|
|
513
589
|
const comp = pascal(name);
|
|
514
590
|
const files = [];
|
|
591
|
+
const props = propsTypeName(reactMajor);
|
|
515
592
|
if (styles === "css") {
|
|
516
593
|
files.push({
|
|
517
594
|
filename: `${name}.tsx`,
|
|
518
|
-
code: `${emitCssMode(slug, name, recipe, version)}\n`,
|
|
595
|
+
code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
|
|
519
596
|
});
|
|
520
597
|
files.push({ filename: `${name}.css`, code: `${css}\n` });
|
|
521
598
|
}
|
|
522
599
|
else {
|
|
523
600
|
files.push({
|
|
524
601
|
filename: `${name}.tsx`,
|
|
525
|
-
code: `${emitTailwindMode(slug, name, recipe, version)}\n`,
|
|
602
|
+
code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
|
|
526
603
|
});
|
|
527
604
|
}
|
|
528
605
|
files.push({
|
package/dist/doctor/scan.js
CHANGED
|
@@ -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
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
425
|
-
return semantic ??
|
|
448
|
+
const semantic = pool.find((n) => n.includes("-semantic-"));
|
|
449
|
+
return semantic ?? pool[0];
|
|
426
450
|
}
|
package/package.json
CHANGED