synthesisui 0.1.24 → 0.2.1

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.
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { syncClaudeMd } from "../claude-md.js";
4
4
  import { resolveRegistry } from "../config.js";
5
5
  import { buildGuide } from "../guide.js";
6
+ import { body as line, section, snippet } from "../output.js";
6
7
  import { fetchDesignSystem } from "../registry.js";
7
8
  async function readRootLock(path) {
8
9
  try {
@@ -109,16 +110,27 @@ export async function add(slug, opts) {
109
110
  console.log(` philosophy.md → ${sections.length} section(s) (read after rules)`);
110
111
  }
111
112
  console.log(` CLAUDE.md ${claudeMd.created ? "created" : "updated"} (${claudeMd.count} system(s) installed)`);
112
- console.log("");
113
113
  const hasTheme = cssArtifacts.includes("theme.css");
114
- console.log("Next steps:");
115
- console.log(" In your global CSS, import the system (use a path relative to that CSS file -");
116
- console.log(" from `app/globals.css` in a Next App Router project that means a leading `../`):");
117
- console.log(` @import "_synthesisui/ds/${payload.slug}/tokens.css";`);
118
- if (hasTheme) {
119
- console.log(` @import "_synthesisui/ds/${payload.slug}/theme.css"; /* Tailwind v4 utilities - required */`);
120
- console.log(' (import `theme.css` after `tokens.css`, both after `@import "tailwindcss";`)');
121
- }
122
- console.log(` • scope your UI with data-ds="${payload.slug}"`);
123
- console.log(` • details and rules in _synthesisui/ds/${payload.slug}/v${v}/GUIDE.md`);
114
+ // ── DX: concrete paths + copy-pasteable snippets, with breathing room ──
115
+ console.log(section("One-time setup (once per app)"));
116
+ console.log(line("1. Import the system in your GLOBAL stylesheet, e.g. app/globals.css"));
117
+ console.log(line(" (the path is relative to that file - hence the leading ../):"));
118
+ console.log("");
119
+ console.log(snippet(hasTheme
120
+ ? [
121
+ `@import "tailwindcss";`,
122
+ `@import "../_synthesisui/ds/${payload.slug}/tokens.css";`,
123
+ `@import "../_synthesisui/ds/${payload.slug}/theme.css"; /* Tailwind utilities on your tokens */`,
124
+ ]
125
+ : [`@import "../_synthesisui/ds/${payload.slug}/tokens.css";`]));
126
+ console.log("");
127
+ console.log(line(`2. Scope your app: add data-ds="${payload.slug}" to a ROOT element, e.g. app/layout.tsx:`));
128
+ console.log("");
129
+ console.log(snippet([`<body data-ds="${payload.slug}">{children}</body>`]));
130
+ console.log(section("Next"));
131
+ console.log(line(`synthesisui component ${payload.slug} button bring a component in as YOUR code`));
132
+ console.log(line(`synthesisui template ${payload.slug} landing materialize a whole page`));
133
+ console.log("");
134
+ console.log(line(`Guide for you and your agent: _synthesisui/ds/${payload.slug}/v${v}/GUIDE.md`));
135
+ console.log("");
124
136
  }
@@ -1,27 +1,108 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { resolveRegistry } from "../config.js";
4
- import { fetchComponent } from "../registry.js";
3
+ import { generateComponentFiles } from "../component-codegen.js";
4
+ import { readProjectConfig, resolveRegistry } from "../config.js";
5
+ import { body, section, snippet } from "../output.js";
6
+ import { fetchComponent, RegistryError } from "../registry.js";
7
+ /** Slugs/names are kebab-case by contract; reject anything else before it ever
8
+ * reaches a filesystem path (defense-in-depth against `../` traversal). */
9
+ const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
10
  /**
6
11
  * Brings ONE component from a design system into the project (granular "bring
7
- * specific", INS-18 fatia 3) - its recipe + compiled CSS, written under
8
- * `_synthesisui/ds/<slug>/components/`. Handy for a component you refit/created
9
- * on the platform. The component's styles reference the DS tokens, so the system
10
- * itself must be installed (`synthesisui add <slug>`) for `tokens.css` to resolve.
12
+ * specific", INS-18 fatia 3):
13
+ *
14
+ * 1. Artifacts (source of truth) `_synthesisui/ds/<slug>/components/`:
15
+ * the recipe (.json, for agents/tooling) + compiled CSS (.css).
16
+ * 2. YOUR component (unless --artifacts-only, target "next") →
17
+ * `<componentsDir>/<name>/` from `_synthesisui/config.json`: a real
18
+ * `export function <Pascal>()` with variants as typed props, in the
19
+ * project's chosen flavor (`styles: "css" | "tailwind"`).
20
+ *
21
+ * The component's styles reference the DS tokens, so the system itself must be
22
+ * installed (`synthesisui add <slug>`) for `tokens.css`/`theme.css` to resolve.
11
23
  */
12
24
  export async function component(slug, name, opts) {
13
25
  const base = resolveRegistry(opts.registry);
14
26
  const root = opts.dir ?? process.cwd();
27
+ if (!SAFE_NAME.test(slug)) {
28
+ throw new RegistryError(`Invalid slug "${slug}".`);
29
+ }
15
30
  console.log(`→ fetching "${name}" from "${slug}" …`);
16
31
  const res = await fetchComponent(base, slug, name, opts.version);
32
+ // The server should only ever return a kebab-case name, but never trust a
33
+ // network value as a path segment.
34
+ if (!SAFE_NAME.test(res.name)) {
35
+ throw new RegistryError(`Registry returned an unsafe component name.`);
36
+ }
17
37
  const dir = join(root, "_synthesisui", "ds", slug, "components");
18
38
  await mkdir(dir, { recursive: true });
19
39
  await writeFile(join(dir, `${res.name}.json`), `${JSON.stringify(res.recipe, null, 2)}\n`, "utf8");
20
40
  await writeFile(join(dir, `${res.name}.css`), `${res.css}\n`, "utf8");
21
41
  console.log(`✓ ${res.name} → _synthesisui/ds/${slug}/components/${res.name}.{json,css} (${slug} v${res.version})`);
42
+ // 2. YOUR component - a real, importable `export function <Pascal>()` in the
43
+ // project's flavor (config: styles css|tailwind), under componentsDir.
44
+ const config = await readProjectConfig(root);
45
+ if (!opts.artifactsOnly && config.target === "next") {
46
+ const compDir = join(root, config.componentsDir, res.name);
47
+ await mkdir(compDir, { recursive: true });
48
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
49
+ for (const file of files) {
50
+ await writeFile(join(compDir, file.filename), file.code, "utf8");
51
+ }
52
+ const names = files.map((f) => f.filename).join(", ");
53
+ console.log(`✓ ${config.componentsDir}/${res.name}/ → ${names} (styles: ${config.styles})`);
54
+ }
55
+ // ── DX: concrete paths + copy-pasteable snippets, with breathing room ──
56
+ const tailwind = config.styles === "tailwind";
57
+ const imports = tailwind
58
+ ? [
59
+ `@import "tailwindcss";`,
60
+ `@import "../_synthesisui/ds/${slug}/tokens.css";`,
61
+ `@import "../_synthesisui/ds/${slug}/theme.css";`,
62
+ ]
63
+ : [`@import "../_synthesisui/ds/${slug}/tokens.css";`];
64
+ console.log(section(`One-time setup (once per app, for "${slug}")`));
65
+ console.log(body(`1. Import the design system in your GLOBAL stylesheet, e.g. app/globals.css`));
66
+ console.log(body(` (the path is relative to that file - hence the leading ../):`));
67
+ console.log("");
68
+ console.log(snippet(imports));
69
+ console.log("");
70
+ console.log(body(`2. Scope your app: add data-ds="${slug}" to a ROOT element, e.g. app/layout.tsx:`));
71
+ console.log("");
72
+ console.log(snippet([`<body data-ds="${slug}">{children}</body>`]));
73
+ console.log("");
74
+ console.log(body(`(If you haven't installed the system yet, run: synthesisui add ${slug})`));
75
+ console.log(section("Use it"));
76
+ if (!opts.artifactsOnly && config.target === "next") {
77
+ const pascalName = res.name
78
+ .split(/[^a-zA-Z0-9]+/)
79
+ .filter(Boolean)
80
+ .map((p) => p[0].toUpperCase() + p.slice(1))
81
+ .join("");
82
+ // Show a real variant in the example when the recipe has one.
83
+ const firstAxis = Object.entries(res.recipe.variants ?? {}).find(([, options]) => Object.values(options).some((block) => Object.keys(block).length > 0));
84
+ const exampleProp = firstAxis
85
+ ? ` ${firstAxis[0]}="${Object.keys(firstAxis[1])[0]}"`
86
+ : "";
87
+ console.log(snippet([
88
+ `import { ${pascalName} } from "@/${config.componentsDir}/${res.name}";`,
89
+ "",
90
+ `<${pascalName}${exampleProp} />`,
91
+ ]));
92
+ console.log("");
93
+ console.log(body(`(adjust "@/" to your project's import alias if it differs)`));
94
+ console.log("");
95
+ console.log(body("Or ask your agent:"));
96
+ console.log(snippet([
97
+ `"Use the ${pascalName} component from ${config.componentsDir}/${res.name} (SynthesisUI ${slug})."`,
98
+ ]));
99
+ }
100
+ else {
101
+ console.log(snippet([
102
+ `@import "../_synthesisui/ds/${slug}/components/${res.name}.css";`,
103
+ "",
104
+ `<div class="ds-${res.name}">…</div>`,
105
+ ]));
106
+ }
22
107
  console.log("");
23
- console.log("Use it:");
24
- console.log(` • ensure the DS is installed: synthesisui add ${slug} (provides tokens.css)`);
25
- console.log(` • @import "_synthesisui/ds/${slug}/components/${res.name}.css" in your CSS`);
26
- console.log(` • <div data-ds="${slug}"><div class="ds-${res.name}">…</div></div>`);
27
108
  }
@@ -14,12 +14,14 @@ export async function init(opts) {
14
14
  target,
15
15
  pagesDir: opts.pagesDir ?? (target === "next" ? "app" : DEFAULT_CONFIG.pagesDir),
16
16
  componentsDir: opts.componentsDir ?? DEFAULT_CONFIG.componentsDir,
17
+ styles: opts.styles === "tailwind" ? "tailwind" : "css",
17
18
  };
18
19
  await writeProjectConfig(root, config);
19
20
  console.log("✓ wrote _synthesisui/config.json");
20
21
  console.log(` target: ${config.target}`);
21
22
  console.log(` pagesDir: ${config.pagesDir}`);
22
23
  console.log(` componentsDir: ${config.componentsDir}`);
24
+ console.log(` styles: ${config.styles}`);
23
25
  // --ds bootstraps the project with a system in one step (tokens + philosophy
24
26
  // + rules + CLAUDE.md all arrive via `add`).
25
27
  if (opts.ds) {
@@ -0,0 +1,382 @@
1
+ const kebab = (v) => v.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
2
+ const pascal = (name) => name
3
+ .split(/[^a-zA-Z0-9]+/)
4
+ .filter(Boolean)
5
+ .map((p) => p[0].toUpperCase() + p.slice(1))
6
+ .join("");
7
+ const camel = (name) => {
8
+ const p = pascal(name);
9
+ return p[0].toLowerCase() + p.slice(1);
10
+ };
11
+ /** Intrinsic element + extra attrs per component, chosen like the platform
12
+ * renderer does (name first, then preview.kind). Fallback: div + children. */
13
+ function elementFor(name, recipe) {
14
+ const byName = {
15
+ input: { tag: "input" },
16
+ textarea: { tag: "textarea" },
17
+ select: { tag: "select" },
18
+ checkbox: { tag: "input", attrs: ' type="checkbox"' },
19
+ radio: { tag: "input", attrs: ' type="radio"' },
20
+ switch: { tag: "input", attrs: ' type="checkbox" role="switch"' },
21
+ slider: { tag: "input", attrs: ' type="range"' },
22
+ button: { tag: "button", attrs: ' type="button"' },
23
+ "icon-button": { tag: "button", attrs: ' type="button"' },
24
+ badge: { tag: "span" },
25
+ tag: { tag: "span" },
26
+ avatar: { tag: "span" },
27
+ divider: { tag: "hr" },
28
+ link: { tag: "a" },
29
+ };
30
+ const hit = byName[name] ??
31
+ (recipe.preview?.kind === "action"
32
+ ? { tag: "button", attrs: ' type="button"' }
33
+ : undefined);
34
+ const tag = hit?.tag ?? "div";
35
+ return {
36
+ tag,
37
+ attrs: hit?.attrs ?? "",
38
+ voidEl: tag === "input" || tag === "hr",
39
+ };
40
+ }
41
+ /** Variant axes → typed props. An axis whose options ⊆ {true,false} is a
42
+ * boolean prop; empty axes (no visual effect) are skipped. */
43
+ function axesOf(variants) {
44
+ const axes = [];
45
+ for (const [axis, options] of Object.entries(variants ?? {})) {
46
+ const keys = Object.keys(options).filter((k) => Object.keys(options[k] ?? {}).length > 0);
47
+ if (keys.length === 0)
48
+ continue;
49
+ axes.push({
50
+ key: axis,
51
+ prop: camel(axis),
52
+ attr: kebab(axis),
53
+ boolean: keys.every((k) => k === "true" || k === "false"),
54
+ styledFalse: keys.includes("false"),
55
+ options: keys,
56
+ });
57
+ }
58
+ return axes;
59
+ }
60
+ function propsType(axes, tag) {
61
+ const extras = axes.map((a) => a.boolean
62
+ ? ` ${a.prop}?: boolean;`
63
+ : ` ${a.prop}?: ${a.options.map((o) => `"${o}"`).join(" | ")};`);
64
+ if (extras.length === 0)
65
+ return `ComponentPropsWithoutRef<"${tag}">`;
66
+ return `ComponentPropsWithoutRef<"${tag}"> & {\n${extras.join("\n")}\n}`;
67
+ }
68
+ function dataAttrLines(axes) {
69
+ return axes
70
+ .map((a) => a.boolean
71
+ ? a.styledFalse
72
+ ? ` data-${a.attr}={${a.prop} ? "true" : "false"}`
73
+ : ` data-${a.attr}={${a.prop} ? "true" : undefined}`
74
+ : ` data-${a.attr}={${a.prop}}`)
75
+ .join("\n");
76
+ }
77
+ // ── Tailwind translation ─────────────────────────────────────────────────────
78
+ /**
79
+ * Namespace-aware token key: returns the utility suffix ONLY when the ref
80
+ * lives in a namespace the `theme.css` @theme adapter actually maps
81
+ * (semantic/series colors, spacing, radius, shadow, families, weights, type
82
+ * scale). Anything else (e.g. color primitives) must use the --ds-* fallback.
83
+ */
84
+ const nsKey = (v, ns) => {
85
+ const m = v.match(new RegExp(`^\\{${ns.replace(/\./g, "\\.")}\\.([a-zA-Z0-9-]+)\\}$`));
86
+ return m ? kebab(m[1]) : null;
87
+ };
88
+ /** Color refs the adapter maps: semantic → <role>, series → series-<n>. */
89
+ const colorKey = (v) => {
90
+ const semantic = nsKey(v, "color.semantic");
91
+ if (semantic)
92
+ return semantic;
93
+ const series = nsKey(v, "color.series");
94
+ if (series)
95
+ return `series-${series}`;
96
+ return null;
97
+ };
98
+ /** "{typography.scale.sm.fontSize}" → "sm" (the --text-<key> utility). */
99
+ const scaleKey = (v) => {
100
+ const m = v.match(/^\{typography\.scale\.([a-zA-Z0-9-]+)\.fontSize\}$/);
101
+ return m ? kebab(m[1]) : null;
102
+ };
103
+ /** "{color.semantic.primary}" → "var(--ds-color-semantic-primary)" - the raw
104
+ * scoped vars always exist, so arbitrary-property fallbacks never dangle. */
105
+ const refToDsVar = (v) => v.replace(/\{([a-z0-9.-]+)\}/gi, (_, path) => {
106
+ return `var(--ds-${path.split(".").map(kebab).join("-")})`;
107
+ });
108
+ /** Arbitrary-property escape hatch: guaranteed-faithful when no pretty utility
109
+ * exists. Spaces become underscores per Tailwind's arbitrary syntax. */
110
+ const arbitrary = (prop, value) => `[${kebab(prop)}:${refToDsVar(value).replace(/\s+/g, "_")}]`;
111
+ const STATIC = {
112
+ display: {
113
+ flex: "flex",
114
+ "inline-flex": "inline-flex",
115
+ grid: "grid",
116
+ block: "block",
117
+ "inline-block": "inline-block",
118
+ none: "hidden",
119
+ },
120
+ alignItems: {
121
+ center: "items-center",
122
+ "flex-start": "items-start",
123
+ "flex-end": "items-end",
124
+ baseline: "items-baseline",
125
+ stretch: "items-stretch",
126
+ },
127
+ justifyContent: {
128
+ center: "justify-center",
129
+ "space-between": "justify-between",
130
+ "flex-start": "justify-start",
131
+ "flex-end": "justify-end",
132
+ },
133
+ flexDirection: { column: "flex-col", row: "flex-row" },
134
+ textAlign: { center: "text-center", left: "text-left", right: "text-right" },
135
+ cursor: { pointer: "cursor-pointer", "not-allowed": "cursor-not-allowed" },
136
+ width: { "100%": "w-full" },
137
+ height: { "100%": "h-full" },
138
+ textDecoration: { none: "no-underline", underline: "underline" },
139
+ };
140
+ /** One declaration → Tailwind classes (pretty when mappable, arbitrary-property
141
+ * otherwise - never dropped). */
142
+ function declToTailwind(prop, value) {
143
+ const stat = STATIC[prop]?.[value];
144
+ if (stat)
145
+ return [stat];
146
+ switch (prop) {
147
+ case "backgroundColor": {
148
+ if (value === "transparent")
149
+ return ["bg-transparent"];
150
+ const key = colorKey(value);
151
+ if (key)
152
+ return [`bg-${key}`];
153
+ break;
154
+ }
155
+ case "color": {
156
+ const key = colorKey(value);
157
+ if (key)
158
+ return [`text-${key}`];
159
+ break;
160
+ }
161
+ case "borderColor": {
162
+ const key = colorKey(value);
163
+ if (key)
164
+ return [`border-${key}`];
165
+ break;
166
+ }
167
+ case "border": {
168
+ // "1px solid {color.semantic.x}" → border + border-<x>
169
+ const m = value.match(/^1px\s+solid\s+(\{[^}]+\})$/);
170
+ if (m) {
171
+ const key = colorKey(m[1]);
172
+ if (key)
173
+ return ["border", `border-${key}`];
174
+ }
175
+ break;
176
+ }
177
+ case "borderRadius": {
178
+ const key = nsKey(value, "radius");
179
+ if (key)
180
+ return [`rounded-${key}`];
181
+ break;
182
+ }
183
+ case "gap": {
184
+ const key = nsKey(value, "spacing");
185
+ if (key)
186
+ return [`gap-${key}`];
187
+ break;
188
+ }
189
+ case "padding": {
190
+ const keys = value.split(/\s+/).map((v) => nsKey(v, "spacing"));
191
+ if (keys.length === 1 && keys[0])
192
+ return [`p-${keys[0]}`];
193
+ if (keys.length === 2 && keys[0] && keys[1])
194
+ return [`py-${keys[0]}`, `px-${keys[1]}`];
195
+ break;
196
+ }
197
+ case "fontSize": {
198
+ const key = scaleKey(value);
199
+ if (key)
200
+ return [`text-${key}`];
201
+ break;
202
+ }
203
+ case "fontFamily": {
204
+ const key = nsKey(value, "typography.families");
205
+ if (key)
206
+ return [`font-${key}`];
207
+ break;
208
+ }
209
+ case "fontWeight": {
210
+ const key = nsKey(value, "typography.weights");
211
+ if (key)
212
+ return [`font-${key}`];
213
+ break;
214
+ }
215
+ case "boxShadow": {
216
+ const key = nsKey(value, "shadow");
217
+ if (key)
218
+ return [`shadow-${key}`];
219
+ break;
220
+ }
221
+ case "lineHeight":
222
+ // usually paired with the same scale's fontSize (text-<key> carries the
223
+ // scale's line-height via --text-<key>--line-height)
224
+ if (/^\{typography\.scale\./.test(value))
225
+ return [];
226
+ break;
227
+ }
228
+ return [arbitrary(prop, value)];
229
+ }
230
+ const STATE_PREFIX = {
231
+ hover: "hover:",
232
+ focus: "focus:",
233
+ focusVisible: "focus-visible:",
234
+ active: "active:",
235
+ disabled: "disabled:",
236
+ };
237
+ function blockToTailwind(block, prefix = "") {
238
+ return Object.entries(block).flatMap(([prop, value]) => declToTailwind(prop, value).map((cls) => `${prefix}${cls}`));
239
+ }
240
+ function tailwindClassList(recipe) {
241
+ const classes = [
242
+ ...blockToTailwind(recipe.base),
243
+ ...Object.entries(recipe.states ?? {}).flatMap(([state, block]) => STATE_PREFIX[state] ? blockToTailwind(block, STATE_PREFIX[state]) : []),
244
+ ];
245
+ return classes.join(" ");
246
+ }
247
+ // ── Emission ─────────────────────────────────────────────────────────────────
248
+ function header(slug, name, version, mode) {
249
+ const setup = mode === "tailwind"
250
+ ? `import _synthesisui/ds/${slug}/theme.css (Tailwind adapter) + tokens.css`
251
+ : `import _synthesisui/ds/${slug}/tokens.css`;
252
+ return [
253
+ `// Generated by SynthesisUI - "${name}" from the "${slug}" design system (v${version}).`,
254
+ `// On-system by construction: every style resolves to the DS tokens.`,
255
+ `// Global setup (once per app): ${setup}`,
256
+ `// and put data-ds="${slug}" on a root element (e.g. <body data-ds="${slug}">).`,
257
+ ].join("\n");
258
+ }
259
+ const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
260
+ function emitCssMode(slug, name, recipe, version) {
261
+ const { tag, attrs, voidEl } = elementFor(name, recipe);
262
+ const axes = axesOf(recipe.variants);
263
+ const comp = pascal(name);
264
+ const propNames = axes.map((a) => a.prop);
265
+ const destructure = [...propNames, "className", "...props"].join(", ");
266
+ void voidEl; // both void and container elements self-close ({...props} carries children)
267
+ const rootJsx = ` <${tag}${attrs}\n className={${joinCls([`"ds-${name}"`, "className"])}}\n${dataAttrLines(axes)}${axes.length ? "\n" : ""} {...props}\n />`;
268
+ const parts = Object.entries(recipe.parts ?? {}).map(([partName, part]) => {
269
+ const partAxes = axesOf(part.variants ?? {});
270
+ const partComp = `${comp}${pascal(partName)}`;
271
+ const partDestructure = [
272
+ ...partAxes.map((a) => a.prop),
273
+ "className",
274
+ "...props",
275
+ ].join(", ");
276
+ return `
277
+ /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
278
+ export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, "div")}) {
279
+ return (
280
+ <div
281
+ className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
282
+ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
283
+ />
284
+ );
285
+ }`;
286
+ });
287
+ return `${header(slug, name, version, "css")}
288
+ import "./${name}.css";
289
+
290
+ import type { ComponentPropsWithoutRef } from "react";
291
+
292
+ type ${comp}Props = ${propsType(axes, tag)};
293
+
294
+ export function ${comp}({ ${destructure} }: ${comp}Props) {
295
+ return (
296
+ ${rootJsx}
297
+ );
298
+ }
299
+ ${parts.join("\n")}`;
300
+ }
301
+ function emitTailwindMode(slug, name, recipe, version) {
302
+ const { tag, attrs, voidEl } = elementFor(name, recipe);
303
+ const axes = axesOf(recipe.variants);
304
+ const comp = pascal(name);
305
+ const variantConsts = axes
306
+ .filter((a) => !a.boolean)
307
+ .map((a) => {
308
+ const entries = a.options
309
+ .map((o) => ` ${JSON.stringify(o)}: ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.[o] ?? {}).join(" "))},`)
310
+ .join("\n");
311
+ return `const ${a.prop.toUpperCase()}: Record<string, string> = {\n${entries}\n};`;
312
+ });
313
+ const booleanConsts = axes
314
+ .filter((a) => a.boolean)
315
+ .map((a) => `const ${a.prop.toUpperCase()} = ${JSON.stringify(blockToTailwind(recipe.variants[a.key]?.true ?? {}).join(" "))};`);
316
+ const clsParts = [
317
+ "BASE",
318
+ ...axes.map((a) => a.boolean
319
+ ? `${a.prop} ? ${a.prop.toUpperCase()} : ""`
320
+ : `${a.prop} ? ${a.prop.toUpperCase()}[${a.prop}] : ""`),
321
+ "className",
322
+ ];
323
+ const destructure = [
324
+ ...axes.map((a) => a.prop),
325
+ "className",
326
+ "...props",
327
+ ].join(", ");
328
+ void voidEl;
329
+ return `${header(slug, name, version, "tailwind")}
330
+
331
+ import type { ComponentPropsWithoutRef } from "react";
332
+
333
+ const BASE = ${JSON.stringify(tailwindClassList(recipe))};
334
+ ${[...variantConsts, ...booleanConsts].join("\n")}
335
+
336
+ type ${comp}Props = ${propsType(axes, tag)};
337
+
338
+ export function ${comp}({ ${destructure} }: ${comp}Props) {
339
+ return (
340
+ <${tag}${attrs}
341
+ className={${joinCls(clsParts)}}
342
+ {...props}
343
+ />
344
+ );
345
+ }
346
+ ${Object.entries(recipe.parts ?? {})
347
+ .map(([partName, part]) => {
348
+ const partComp = `${comp}${pascal(partName)}`;
349
+ return `
350
+ /** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
351
+ export function ${partComp}({ className, ...props }: ComponentPropsWithoutRef<"div">) {
352
+ return (
353
+ <div className={${joinCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
354
+ );
355
+ }`;
356
+ })
357
+ .join("\n")}`;
358
+ }
359
+ /** All files for one component, under `<componentsDir>/<name>/`. */
360
+ export function generateComponentFiles(slug, name, recipe, css, version, styles) {
361
+ const comp = pascal(name);
362
+ const files = [];
363
+ if (styles === "css") {
364
+ files.push({
365
+ filename: `${name}.tsx`,
366
+ code: `${emitCssMode(slug, name, recipe, version)}\n`,
367
+ });
368
+ files.push({ filename: `${name}.css`, code: `${css}\n` });
369
+ }
370
+ else {
371
+ files.push({
372
+ filename: `${name}.tsx`,
373
+ code: `${emitTailwindMode(slug, name, recipe, version)}\n`,
374
+ });
375
+ }
376
+ files.push({
377
+ filename: "index.ts",
378
+ code: `export * from "./${name}";\n`,
379
+ });
380
+ void comp;
381
+ return files;
382
+ }
package/dist/config.js CHANGED
@@ -40,6 +40,7 @@ export const DEFAULT_CONFIG = {
40
40
  target: "next",
41
41
  pagesDir: "app",
42
42
  componentsDir: "components",
43
+ styles: "css",
43
44
  };
44
45
  const projectConfigPath = (root) => join(root, "_synthesisui", "config.json");
45
46
  /** Reads the project config, falling back to defaults when absent/invalid. */
@@ -55,6 +56,7 @@ export async function readProjectConfig(root) {
55
56
  componentsDir: typeof parsed.componentsDir === "string" && parsed.componentsDir
56
57
  ? parsed.componentsDir
57
58
  : DEFAULT_CONFIG.componentsDir,
59
+ styles: parsed.styles === "tailwind" ? "tailwind" : "css",
58
60
  };
59
61
  }
60
62
  catch {
package/dist/guide.js CHANGED
@@ -145,6 +145,18 @@ place** - wire real data, split into components, swap the chart/icon/media place
145
145
  \`data-ds="${slug}"\` wrapper and the \`.ds-*\` / layout classes so it stays on-system. Run
146
146
  \`synthesisui init\` once to set the target (next/general) and the output folder.
147
147
 
148
+ ## Single components as YOUR code
149
+
150
+ Bring one component in as a real, typed React component (variants become props):
151
+ \`\`\`bash
152
+ synthesisui component ${slug} button
153
+ \`\`\`
154
+ It writes \`<componentsDir>/button/\` with \`button.tsx\` (+ colocated \`button.css\` in the default
155
+ \`styles: "css"\` flavor, or Tailwind utilities inline with \`styles: "tailwind"\` - set once via
156
+ \`synthesisui init --styles tailwind\`). Import and render: \`import { Button } from "components/button"\`.
157
+ The boundary: tokens are global (\`tokens.css\` + the \`data-ds\` root attribute, once per app);
158
+ everything a component owns lives in its own folder.
159
+
148
160
  ---
149
161
  `
150
162
  : "";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ Usage:
17
17
  synthesisui list [options] list the published design systems
18
18
  synthesisui add <slug> [options] materialize a DS into _synthesisui/ds/<slug>/
19
19
  synthesisui template <slug> <name> materialize a whole page from a DS template
20
- synthesisui component <slug> <name> bring one component (recipe + css) into the project
20
+ synthesisui component <slug> <name> bring one component in - artifacts + YOUR <Pascal>.tsx in componentsDir
21
21
  synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
22
22
  synthesisui advise "<value prop>" engagement-pattern proposals for this project (login required)
23
23
  synthesisui generate "<desc>" generate a token-only component recipe for your DS (login required)
@@ -31,6 +31,8 @@ Options:
31
31
  --target <t> template/init target: next | general (default: next)
32
32
  --pages-dir <dir> init: folder for generated pages (default: app)
33
33
  --components-dir <dir> init: folder where components live (default: components)
34
+ --styles <s> init: component code flavor: css | tailwind (default: css)
35
+ --artifacts-only component: skip the .tsx materialization (recipe + css only)
34
36
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
35
37
  -h, --help this help
36
38
 
@@ -125,7 +127,16 @@ async function main() {
125
127
  ? flags["components-dir"]
126
128
  : undefined;
127
129
  const ds = typeof flags.ds === "string" ? flags.ds : undefined;
128
- await init({ dir, registry, target, pagesDir, componentsDir, ds });
130
+ const styles = typeof flags.styles === "string" ? flags.styles : undefined;
131
+ await init({
132
+ dir,
133
+ registry,
134
+ target,
135
+ pagesDir,
136
+ componentsDir,
137
+ styles,
138
+ ds,
139
+ });
129
140
  break;
130
141
  }
131
142
  // `page` is the legacy alias (renamed to `template`); it still works so
@@ -173,7 +184,12 @@ async function main() {
173
184
  return;
174
185
  }
175
186
  }
176
- await component(slug, name, { registry, dir, version });
187
+ await component(slug, name, {
188
+ registry,
189
+ dir,
190
+ version,
191
+ artifactsOnly: flags["artifacts-only"] === true,
192
+ });
177
193
  break;
178
194
  }
179
195
  case "use": {
package/dist/output.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Terminal output helpers - the CLI's DX surface. Principles:
3
+ * - breathing room: blank lines around sections, never a wall of bullets;
4
+ * - concrete over abstract: real paths and copy-pasteable snippets, not
5
+ * "import it globally";
6
+ * - one idea per section, titled with a scannable ruled heading.
7
+ */
8
+ const WIDTH = 66;
9
+ /** `── Title ───────────…` ruled section heading (with breathing room). */
10
+ export function section(title) {
11
+ const head = `── ${title} `;
12
+ const rest = Math.max(4, WIDTH - head.length);
13
+ return `\n${head}${"─".repeat(rest)}\n`;
14
+ }
15
+ /** Indented, copy-pasteable code block (empty lines stay truly empty). */
16
+ export function snippet(lines) {
17
+ return lines.map((line) => (line ? ` ${line}` : "")).join("\n");
18
+ }
19
+ /** Indented body text line. */
20
+ export function body(line) {
21
+ return ` ${line}`;
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.1.24",
3
+ "version": "0.2.1",
4
4
  "description": "Traz design systems do SynthesisUI para qualquer projeto (materializa em _local/ds/).",
5
5
  "type": "module",
6
6
  "bin": {