synthesisui 0.4.1 → 0.4.3

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.
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { syncClaudeMd } from "../claude-md.js";
4
4
  import { resolveRegistry } from "../config.js";
5
+ import { customFontFamilies, googleFontsHref } from "../fonts.js";
5
6
  import { buildGuide } from "../guide.js";
6
7
  import { body as line, section, snippet } from "../output.js";
7
8
  import { fetchDesignSystem } from "../registry.js";
@@ -129,6 +130,22 @@ export async function add(slug, opts) {
129
130
  console.log(line(`2. Scope your app: add data-ds="${payload.slug}" to a ROOT element, e.g. app/layout.tsx:`));
130
131
  console.log("");
131
132
  console.log(snippet([`<body data-ds="${payload.slug}">{children}</body>`]));
133
+ // 3. Load the type - the DS ships token NAMES, not the fonts themselves.
134
+ // Without this, display/body silently fall back and the identity is lost.
135
+ const families = payload.document.foundations.typography.families;
136
+ const fontsHref = googleFontsHref(families);
137
+ if (fontsHref) {
138
+ console.log("");
139
+ console.log(line("3. Load the type - this system ships font NAMES, not the fonts. Add to your app's <head> (e.g. app/layout.tsx) so the families resolve (else they fall back and the look is lost):"));
140
+ console.log("");
141
+ console.log(snippet([
142
+ `<link rel="preconnect" href="https://fonts.googleapis.com" />`,
143
+ `<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />`,
144
+ `<link rel="stylesheet" href="${fontsHref}" />`,
145
+ ]));
146
+ console.log("");
147
+ console.log(line(` Prefer next/font or self-hosting? Fine - just register these exact families: ${customFontFamilies(families).join(", ")}.`));
148
+ }
132
149
  console.log(section("Next"));
133
150
  console.log(line(`synthesisui component ${payload.slug} button bring a component in as YOUR code`));
134
151
  console.log(line(`synthesisui template ${payload.slug} landing materialize a whole page`));
@@ -1,6 +1,39 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
1
3
  import { resolveRegistry } from "../config.js";
2
4
  import { postAdvisor } from "../registry.js";
3
5
  import { buildRepoContext } from "../repo-context.js";
6
+ /**
7
+ * Component names of the design system(s) installed in this project (from each
8
+ * DS's design-system.json). Grounds the advisor in what the DS ACTUALLY has, so
9
+ * it prefers real blocks and flags missing ones (instead of a fixed vocabulary).
10
+ */
11
+ async function installedBlocks(root) {
12
+ const dsRoot = join(root, "_synthesisui", "ds");
13
+ const blocks = new Set();
14
+ let slugs = [];
15
+ try {
16
+ const entries = await readdir(dsRoot, { withFileTypes: true });
17
+ slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ for (const slug of slugs) {
23
+ try {
24
+ const lockRaw = await readFile(join(dsRoot, slug, ".lock"), "utf8");
25
+ const version = JSON.parse(lockRaw).version ?? 1;
26
+ const docRaw = await readFile(join(dsRoot, slug, `v${version}`, "design-system.json"), "utf8");
27
+ const doc = JSON.parse(docRaw);
28
+ for (const name of Object.keys(doc.components ?? {}))
29
+ blocks.add(name);
30
+ }
31
+ catch {
32
+ // skip a DS we can't read - the advisor still works without its catalog
33
+ }
34
+ }
35
+ return [...blocks].sort();
36
+ }
4
37
  /**
5
38
  * Asks the hosted advisor for engagement-pattern proposals, grounded in THIS
6
39
  * project (the CLI gathers a compact repo summary) + the value proposition you
@@ -10,7 +43,11 @@ export async function advise(valueProp, opts) {
10
43
  const base = resolveRegistry(opts.registry);
11
44
  const root = opts.dir ?? process.cwd();
12
45
  const repo = await buildRepoContext(root);
13
- const context = `Proposta de valor: ${valueProp}\n\n${repo}`;
46
+ const blocks = await installedBlocks(root);
47
+ const catalog = blocks.length
48
+ ? `\n\nDesign-system blocks available in this project (prefer these; anything else can be created with \`synthesisui generate\`): ${blocks.join(", ")}`
49
+ : "";
50
+ const context = `Value proposition: ${valueProp}\n\n${repo}${catalog}`;
14
51
  console.log(`→ asking the advisor at ${base} …`);
15
52
  const res = await postAdvisor(base, context);
16
53
  if (res.proposals.length === 0) {
@@ -0,0 +1,117 @@
1
+ import { readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { body, section } from "../output.js";
4
+ // The five SVGs create-next-app drops into public/. Filenames are specific
5
+ // enough to be safe, and the dry-run + --force gate is the real guard.
6
+ const DEFAULT_SVGS = [
7
+ "next.svg",
8
+ "vercel.svg",
9
+ "file.svg",
10
+ "globe.svg",
11
+ "window.svg",
12
+ ];
13
+ const MINIMAL_PAGE = `export default function Home() {
14
+ return (
15
+ <main>
16
+ <h1>New app</h1>
17
+ </main>
18
+ );
19
+ }
20
+ `;
21
+ async function pathExists(p) {
22
+ try {
23
+ await stat(p);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ async function readIf(p) {
31
+ try {
32
+ return await readFile(p, "utf8");
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * Strips create-next-app boilerplate so a fresh project starts clean before you
40
+ * dress it in a design system. Content-matched: files you've edited are left
41
+ * untouched. Dry-run by default - re-run with --force to apply. (Dogfood #2b.)
42
+ */
43
+ export async function clean(opts) {
44
+ const root = opts.dir ?? process.cwd();
45
+ const appDir = (await pathExists(join(root, "src", "app")))
46
+ ? join(root, "src", "app")
47
+ : join(root, "app");
48
+ const rel = (p) => relative(root, p).replace(/\\/g, "/");
49
+ const actions = [];
50
+ // 1. Default public SVGs.
51
+ for (const svg of DEFAULT_SVGS) {
52
+ const p = join(root, "public", svg);
53
+ if (await pathExists(p)) {
54
+ actions.push({
55
+ verb: "remove",
56
+ path: `public/${svg}`,
57
+ why: "create-next-app default asset",
58
+ run: () => rm(p),
59
+ });
60
+ }
61
+ }
62
+ // 2. Default landing page → minimal placeholder (only if unedited).
63
+ const pagePath = join(appDir, "page.tsx");
64
+ const pageSrc = await readIf(pagePath);
65
+ if (pageSrc && /Get started by editing|Deploy now|Read our docs/.test(pageSrc)) {
66
+ actions.push({
67
+ verb: "reset",
68
+ path: rel(pagePath),
69
+ why: "default landing → minimal placeholder",
70
+ run: () => writeFile(pagePath, MINIMAL_PAGE, "utf8"),
71
+ });
72
+ }
73
+ // 3. Layout metadata still says "Create Next App".
74
+ const layoutPath = join(appDir, "layout.tsx");
75
+ const layoutSrc = await readIf(layoutPath);
76
+ if (layoutSrc &&
77
+ /"Create Next App"|"Generated by create next app"/.test(layoutSrc)) {
78
+ actions.push({
79
+ verb: "reset",
80
+ path: rel(layoutPath),
81
+ why: 'metadata "Create Next App" → neutral',
82
+ run: () => writeFile(layoutPath, layoutSrc
83
+ .replace(/"Create Next App"/g, '"App"')
84
+ .replace(/"Generated by create next app"/g, '""'), "utf8"),
85
+ });
86
+ }
87
+ // 4. Default README.
88
+ const readmePath = join(root, "README.md");
89
+ const readmeSrc = await readIf(readmePath);
90
+ if (readmeSrc && /create-next-app/.test(readmeSrc) && /Getting Started/.test(readmeSrc)) {
91
+ actions.push({
92
+ verb: "remove",
93
+ path: "README.md",
94
+ why: "create-next-app default README",
95
+ run: () => rm(readmePath),
96
+ });
97
+ }
98
+ if (actions.length === 0) {
99
+ console.log(section("Clean up scaffold"));
100
+ console.log(body("Nothing to clean - this project is already tidy."));
101
+ console.log("");
102
+ return;
103
+ }
104
+ console.log(section(opts.force ? "Cleaned up scaffold" : "Clean up scaffold (dry run)"));
105
+ const pad = Math.max(...actions.map((a) => a.path.length));
106
+ for (const a of actions) {
107
+ const mark = opts.force ? "✓" : "•";
108
+ console.log(body(`${mark} ${a.verb.padEnd(6)} ${a.path.padEnd(pad)} ${a.why}`));
109
+ if (opts.force)
110
+ await a.run();
111
+ }
112
+ console.log("");
113
+ console.log(body(opts.force
114
+ ? `Done - ${actions.length} item(s) tidied.`
115
+ : "Dry run - nothing changed. Re-run with `synthesisui clean --force` to apply."));
116
+ console.log("");
117
+ }
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { generateComponentFiles } from "../component-codegen.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
+ import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
5
6
  import { body, section, snippet } from "../output.js";
6
7
  import { fetchComponent, RegistryError } from "../registry.js";
7
8
  /** Slugs/names are kebab-case by contract; reject anything else before it ever
@@ -42,15 +43,33 @@ export async function component(slug, name, opts) {
42
43
  // 2. YOUR component - a real, importable `export function <Pascal>()` in the
43
44
  // project's flavor (config: styles css|tailwind), under componentsDir.
44
45
  const config = await readProjectConfig(root);
46
+ const wantInteractive = opts.interactive && hasInteractiveTemplate(res.name);
45
47
  if (!opts.artifactsOnly && config.target === "next") {
46
48
  const compDir = join(root, config.componentsDir, res.name);
47
49
  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");
50
+ let filenames;
51
+ if (wantInteractive) {
52
+ // Curated interactive variant: the behaving .tsx + the compiled classes
53
+ // it wears (.css) + the barrel. Ignores the css|tailwind flavor - the
54
+ // template drives itself off the .ds-* classes.
55
+ const tsx = interactiveTemplate(res.name);
56
+ await writeFile(join(compDir, `${res.name}.tsx`), tsx, "utf8");
57
+ await writeFile(join(compDir, `${res.name}.css`), `${res.css}\n`, "utf8");
58
+ await writeFile(join(compDir, "index.ts"), `export * from "./${res.name}";\n`, "utf8");
59
+ filenames = [`${res.name}.tsx`, `${res.name}.css`, "index.ts"];
51
60
  }
52
- const names = files.map((f) => f.filename).join(", ");
53
- console.log(`✓ ${config.componentsDir}/${res.name}/ ${names} (styles: ${config.styles})`);
61
+ else {
62
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
63
+ for (const file of files) {
64
+ await writeFile(join(compDir, file.filename), file.code, "utf8");
65
+ }
66
+ filenames = files.map((f) => f.filename);
67
+ }
68
+ const flavor = wantInteractive ? "interactive" : `styles: ${config.styles}`;
69
+ console.log(`✓ ${config.componentsDir}/${res.name}/ → ${filenames.join(", ")} (${flavor})`);
70
+ }
71
+ else if (opts.interactive && !hasInteractiveTemplate(res.name)) {
72
+ console.log(` note: no interactive template for "${res.name}" - materialized the standard shell.`);
54
73
  }
55
74
  // ── DX: concrete paths + copy-pasteable snippets, with breathing room ──
56
75
  const tailwind = config.styles === "tailwind";
@@ -1,7 +1,23 @@
1
- import { mkdir, readdir, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { resolveRegistry } from "../config.js";
3
+ import { generateComponentFiles } from "../component-codegen.js";
4
+ import { readProjectConfig, resolveRegistry } from "../config.js";
4
5
  import { postGenerate, RegistryError } from "../registry.js";
6
+ /** PascalCase para o hint de import (course-card → CourseCard). */
7
+ function pascalName(name) {
8
+ return name.replace(/(^|[-_])([a-z0-9])/g, (_, __, c) => c.toUpperCase());
9
+ }
10
+ /** Versão ativa do DS instalado (do `.lock`); 1 se ausente - só nomeia o header. */
11
+ async function readActiveVersion(root, slug) {
12
+ try {
13
+ const raw = await readFile(join(root, "_synthesisui", "ds", slug, ".lock"), "utf8");
14
+ const lock = JSON.parse(raw);
15
+ return typeof lock.version === "number" ? lock.version : 1;
16
+ }
17
+ catch {
18
+ return 1;
19
+ }
20
+ }
5
21
  /** Slugs materialized under `_synthesisui/ds/` in the project. */
6
22
  async function installedSlugs(root) {
7
23
  try {
@@ -45,9 +61,32 @@ export async function generate(description, opts) {
45
61
  const tries = `${res.tries} ${res.tries === 1 ? "try" : "tries"}`;
46
62
  console.log(`✓ ${res.name} generated (${res.model}, ${tries})`);
47
63
  console.log(` → _synthesisui/ds/${slug}/generated/${res.name}.{json,css}`);
64
+ // Materialize YOUR component (.tsx) too - the SAME codegen `component` uses -
65
+ // so a generated component is as usable as a brought-in one, not just a
66
+ // recipe you have to wire by hand.
67
+ const config = await readProjectConfig(root);
68
+ let materialized = false;
69
+ if (config.target === "next") {
70
+ const version = await readActiveVersion(root, slug);
71
+ const compDir = join(root, config.componentsDir, res.name);
72
+ await mkdir(compDir, { recursive: true });
73
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, version, config.styles);
74
+ for (const file of files) {
75
+ await writeFile(join(compDir, file.filename), file.code, "utf8");
76
+ }
77
+ materialized = true;
78
+ console.log(`✓ ${config.componentsDir}/${res.name}/ → ${files.map((f) => f.filename).join(", ")} (styles: ${config.styles})`);
79
+ }
48
80
  console.log("");
49
81
  console.log("Use it:");
50
- console.log(` • @import "_synthesisui/ds/${slug}/generated/${res.name}.css" in your CSS`);
51
- console.log(` • <div data-ds="${slug}"><div class="ds-${res.name}">…</div></div>`);
52
- console.log(` (${res.usage.inputTokens} in / ${res.usage.outputTokens} out tokens - recipe is additive; nothing else changed)`);
82
+ if (materialized) {
83
+ const comp = pascalName(res.name);
84
+ console.log(` • import { ${comp} } from "@/${config.componentsDir}/${res.name}";`);
85
+ console.log(` • <${comp} /> - compose its parts + content (see the recipe)`);
86
+ }
87
+ else {
88
+ console.log(` • @import "_synthesisui/ds/${slug}/generated/${res.name}.css" in your CSS`);
89
+ console.log(` • <div data-ds="${slug}"><div class="ds-${res.name}">…</div></div>`);
90
+ }
91
+ console.log(` (${res.usage.inputTokens} in / ${res.usage.outputTokens} out tokens · AI action - additive; nothing else changed)`);
53
92
  }
@@ -136,7 +136,30 @@ const STATIC = {
136
136
  width: { "100%": "w-full" },
137
137
  height: { "100%": "h-full" },
138
138
  textDecoration: { none: "no-underline", underline: "underline" },
139
+ overflow: {
140
+ hidden: "overflow-hidden",
141
+ auto: "overflow-auto",
142
+ scroll: "overflow-scroll",
143
+ visible: "overflow-visible",
144
+ clip: "overflow-clip",
145
+ },
146
+ position: {
147
+ relative: "relative",
148
+ absolute: "absolute",
149
+ fixed: "fixed",
150
+ sticky: "sticky",
151
+ static: "static",
152
+ },
139
153
  };
154
+ /** Fixed rem → Tailwind numeric scale step (0.25rem base), or null if not a
155
+ * clean multiple. So `0.5rem` → `2` (h-2/w-2) instead of an arbitrary value. */
156
+ function remToTwScale(value) {
157
+ const m = value.trim().match(/^(\d*\.?\d+)rem$/);
158
+ if (!m)
159
+ return null;
160
+ const n = Number.parseFloat(m[1]) / 0.25;
161
+ return Number.isInteger(n) && n >= 0 && n <= 96 ? String(n) : null;
162
+ }
140
163
  /** One declaration → Tailwind classes (pretty when mappable, arbitrary-property
141
164
  * otherwise - never dropped). */
142
165
  function declToTailwind(prop, value) {
@@ -224,6 +247,18 @@ function declToTailwind(prop, value) {
224
247
  if (/^\{typography\.scale\./.test(value))
225
248
  return [];
226
249
  break;
250
+ case "height": {
251
+ const n = remToTwScale(value);
252
+ if (n)
253
+ return [`h-${n}`];
254
+ break;
255
+ }
256
+ case "width": {
257
+ const n = remToTwScale(value);
258
+ if (n)
259
+ return [`w-${n}`];
260
+ break;
261
+ }
227
262
  }
228
263
  return [arbitrary(prop, value)];
229
264
  }
@@ -257,6 +292,25 @@ function header(slug, name, version, mode) {
257
292
  ].join("\n");
258
293
  }
259
294
  const joinCls = (parts) => `[${parts.join(", ")}].filter(Boolean).join(" ")`;
295
+ /** JSDoc showing how to compose the component with its parts + content, so the
296
+ * materialized code doesn't read as "a bare shell renders nothing" (dogfood
297
+ * #5). Built from the recipe's parts. */
298
+ function compositionHint(comp, name, recipe) {
299
+ const partNames = Object.keys(recipe.parts ?? {});
300
+ if (partNames.length === 0) {
301
+ return `/** Wears the "${name}" recipe. Put your content inside: <${comp}>…</${comp}>. */`;
302
+ }
303
+ const inner = partNames
304
+ .map((p) => ` * <${comp}${pascal(p)}>…</${comp}${pascal(p)}>`)
305
+ .join("\n");
306
+ return `/**
307
+ * Compose it with its parts + your own content - a bare <${comp} /> is just the
308
+ * empty shell. For example:
309
+ * <${comp}>
310
+ ${inner}
311
+ * </${comp}>
312
+ */`;
313
+ }
260
314
  function emitCssMode(slug, name, recipe, version) {
261
315
  const { tag, attrs, voidEl } = elementFor(name, recipe);
262
316
  const axes = axesOf(recipe.variants);
@@ -291,6 +345,7 @@ import type { ComponentPropsWithoutRef } from "react";
291
345
 
292
346
  type ${comp}Props = ${propsType(axes, tag)};
293
347
 
348
+ ${compositionHint(comp, name, recipe)}
294
349
  export function ${comp}({ ${destructure} }: ${comp}Props) {
295
350
  return (
296
351
  ${rootJsx}
@@ -335,6 +390,7 @@ ${[...variantConsts, ...booleanConsts].join("\n")}
335
390
 
336
391
  type ${comp}Props = ${propsType(axes, tag)};
337
392
 
393
+ ${compositionHint(comp, name, recipe)}
338
394
  export function ${comp}({ ${destructure} }: ${comp}Props) {
339
395
  return (
340
396
  <${tag}${attrs}
package/dist/fonts.js ADDED
@@ -0,0 +1,40 @@
1
+ // Fallbacks genéricos do CSS - não são webfonts.
2
+ const GENERIC_FAMILIES = new Set([
3
+ "sans-serif",
4
+ "serif",
5
+ "monospace",
6
+ "system-ui",
7
+ "ui-sans-serif",
8
+ "ui-serif",
9
+ "ui-monospace",
10
+ "cursive",
11
+ "fantasy",
12
+ "inherit",
13
+ "initial",
14
+ ]);
15
+ /** Famílias CUSTOM (display/body/mono), dedup e sem os genéricos. */
16
+ export function customFontFamilies(families) {
17
+ const seen = new Set();
18
+ const names = [];
19
+ for (const family of [families.display, families.body, families.mono]) {
20
+ const name = family?.trim();
21
+ if (!name)
22
+ continue;
23
+ const key = name.toLowerCase();
24
+ if (GENERIC_FAMILIES.has(key) || seen.has(key))
25
+ continue;
26
+ seen.add(key);
27
+ names.push(name);
28
+ }
29
+ return names;
30
+ }
31
+ /** URL do Google Fonts CSS2 pras famílias do documento, ou null se só genéricos. */
32
+ export function googleFontsHref(families) {
33
+ const names = customFontFamilies(families);
34
+ if (names.length === 0)
35
+ return null;
36
+ const query = names
37
+ .map((name) => `family=${name.replace(/ /g, "+")}:wght@400;500;600;700`)
38
+ .join("&");
39
+ return `https://fonts.googleapis.com/css2?${query}&display=swap`;
40
+ }
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { add } from "./commands/add.js";
3
3
  import { advise } from "./commands/advise.js";
4
+ import { clean } from "./commands/clean.js";
4
5
  import { component } from "./commands/component.js";
5
6
  import { generate } from "./commands/generate.js";
6
7
  import { init } from "./commands/init.js";
@@ -13,18 +14,21 @@ import { use } from "./commands/use.js";
13
14
  import { RegistryError } from "./registry.js";
14
15
  const HELP = `synthesisui - bring SynthesisUI design systems into your project
15
16
 
16
- Usage:
17
+ Usage - deterministic, FREE:
17
18
  synthesisui login [options] connect the CLI to your account (device-flow)
18
19
  synthesisui init [options] write _synthesisui/config.json (target, dirs); --ds to bring one in
19
20
  synthesisui list [options] list the published design systems
20
21
  synthesisui add <slug> [options] materialize a DS into _synthesisui/ds/<slug>/
22
+ synthesisui component <slug> <name> bring one EXISTING component in as YOUR <Pascal>.tsx
21
23
  synthesisui template <slug> <name> materialize a whole page from a DS template
22
- synthesisui component <slug> <name> bring one component in - artifacts + YOUR <Pascal>.tsx in componentsDir
23
24
  synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
24
- synthesisui refit <file> [--ds <slug>] send an app component INTO your DS (token-only) and get it back as code
25
25
  synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
26
- synthesisui advise "<value prop>" engagement-pattern proposals for this project (login required)
27
- synthesisui generate "<desc>" generate a token-only component recipe for your DS (login required)
26
+ synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
27
+
28
+ Usage - AI, USES CREDITS (login required):
29
+ synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
30
+ synthesisui advise "<value prop>" AI engagement-pattern proposals for this project
31
+ synthesisui refit <file> [--ds <slug>] AI-adapt an app component INTO your DS, get it back as code
28
32
 
29
33
  Options:
30
34
  --registry <url> registry URL (or env SYNTHESISUI_REGISTRY_URL)
@@ -37,10 +41,12 @@ Options:
37
41
  --components-dir <dir> init: folder where components live (default: components)
38
42
  --styles <s> init: component code flavor: css | tailwind (default: css)
39
43
  --artifacts-only component: skip the .tsx materialization (recipe + css only)
44
+ --interactive component: materialize the rich/behaving variant (join-field, streak, xp-bar)
40
45
  --replace <name> refit: replace an existing DS component (keeps its name)
41
46
  --support <file> refit: supporting CSS file (globals/vars the code references)
42
47
  --instruction <s> refit: extra guidance for the adaptation
43
48
  --dry refit: adapt and print, but save nothing
49
+ --force clean: apply the changes (without it, dry run)
44
50
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
45
51
  -h, --help this help
46
52
 
@@ -197,6 +203,7 @@ async function main() {
197
203
  dir,
198
204
  version,
199
205
  artifactsOnly: flags["artifacts-only"] === true,
206
+ interactive: flags.interactive === true,
200
207
  });
201
208
  break;
202
209
  }
@@ -240,6 +247,9 @@ async function main() {
240
247
  await use(slug, intent, { dir });
241
248
  break;
242
249
  }
250
+ case "clean":
251
+ await clean({ dir, force: flags.force === true });
252
+ break;
243
253
  case "advise": {
244
254
  const valueProp = args.join(" ").trim();
245
255
  if (!valueProp) {
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Curated INTERACTIVE component templates (dogfood #5-full). `component <name>
3
+ * --interactive` materializes one of these instead of the bare shell, so a
4
+ * generated component behaves like the gallery demo (state + sample content),
5
+ * not an empty `<div className="ds-*">`.
6
+ *
7
+ * Each template is self-contained: it wears the compiled `.ds-<name>*` classes
8
+ * (so it still needs the component's `.css`), and drives behavior with local
9
+ * React state. Motion uses CANONICAL tokens (`--ds-motion-*-standard/base`), so
10
+ * the template is DS-agnostic. Ported from the platform's showcase renderers.
11
+ */
12
+ /** Component names that have an interactive/rich template. */
13
+ export function hasInteractiveTemplate(name) {
14
+ return name in TEMPLATES;
15
+ }
16
+ /** The `.tsx` source for a component's interactive/rich variant, or null. */
17
+ export function interactiveTemplate(name) {
18
+ return TEMPLATES[name] ?? null;
19
+ }
20
+ const JOIN_FIELD = `"use client";
21
+
22
+ import { useEffect, useRef, useState, type CSSProperties } from "react";
23
+ import "./join-field.css";
24
+
25
+ const EASE = "var(--ds-motion-easings-standard, cubic-bezier(0.2, 0.7, 0.2, 1))";
26
+ const DUR = "var(--ds-motion-durations-base, 300ms)";
27
+
28
+ /** One layer of the morph, sharing a single grid cell (no layout shift). */
29
+ function layer(visible: boolean, fromBelow = true): CSSProperties {
30
+ return {
31
+ gridColumn: 1,
32
+ gridRow: 1,
33
+ transition: \`opacity \${DUR} \${EASE}, transform \${DUR} \${EASE}\`,
34
+ transitionDelay: visible ? "0.18s" : "0s",
35
+ opacity: visible ? 1 : 0,
36
+ transform: visible ? "translateY(0)" : \`translateY(\${fromBelow ? "4px" : "-4px"})\`,
37
+ pointerEvents: visible ? "auto" : "none",
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Join CTA that morphs: pill -> inline email field + Join -> success. The three
43
+ * layers share one grid cell, so the surrounding layout never shifts. Wire the
44
+ * submit to your API where marked.
45
+ */
46
+ export function JoinField({ label = "Join the next cohort" }: { label?: string }) {
47
+ const [open, setOpen] = useState(false);
48
+ const [joined, setJoined] = useState(false);
49
+ const inputRef = useRef<HTMLInputElement>(null);
50
+ const expanded = open || joined;
51
+
52
+ useEffect(() => {
53
+ if (!open || joined) return;
54
+ const id = requestAnimationFrame(() => inputRef.current?.focus());
55
+ return () => cancelAnimationFrame(id);
56
+ }, [open, joined]);
57
+
58
+ return (
59
+ <div style={{ display: "flex", justifyContent: "center", width: "100%" }}>
60
+ <div
61
+ className="ds-join-field"
62
+ style={{ display: "inline-grid", alignItems: "center", width: "min(27rem, 100%)" }}
63
+ >
64
+ {/* Collapsed CTA */}
65
+ <button
66
+ type="button"
67
+ className="ds-join-field-trigger"
68
+ aria-hidden={expanded}
69
+ tabIndex={expanded ? -1 : 0}
70
+ onClick={() => setOpen(true)}
71
+ style={{ ...layer(!expanded, false), justifyContent: "center", width: "100%" }}
72
+ >
73
+ <span aria-hidden>✨</span>
74
+ {label}
75
+ <span aria-hidden>→</span>
76
+ </button>
77
+
78
+ {/* Email form */}
79
+ <form
80
+ aria-hidden={!open || joined}
81
+ onSubmit={(e) => {
82
+ e.preventDefault();
83
+ // TODO: send inputRef.current?.value to your API, then:
84
+ setJoined(true);
85
+ }}
86
+ style={{ ...layer(open && !joined), display: "flex", alignItems: "stretch", gap: 8 }}
87
+ >
88
+ <span className="ds-join-field-input" style={{ flex: 1, minWidth: 0 }}>
89
+ <span aria-hidden>✉</span>
90
+ <input
91
+ ref={inputRef}
92
+ type="email"
93
+ placeholder="Enter your best e-mail"
94
+ tabIndex={open && !joined ? 0 : -1}
95
+ style={{ flex: 1, minWidth: 0, height: "100%", border: "none", outline: "none", background: "transparent", color: "inherit", font: "inherit", padding: 0 }}
96
+ />
97
+ </span>
98
+ <button type="submit" className="ds-join-field-submit" tabIndex={open && !joined ? 0 : -1}>
99
+ Join
100
+ </button>
101
+ </form>
102
+
103
+ {/* Success (click to reset) */}
104
+ <button
105
+ type="button"
106
+ className="ds-join-field-success"
107
+ aria-hidden={!joined}
108
+ tabIndex={joined ? 0 : -1}
109
+ title="Reset"
110
+ onClick={() => {
111
+ setJoined(false);
112
+ setOpen(false);
113
+ }}
114
+ style={{ ...layer(joined), textAlign: "left", width: "100%" }}
115
+ >
116
+ <span aria-hidden>✓</span>
117
+ <span>
118
+ <strong style={{ display: "block", lineHeight: 1.2 }}>You&apos;re in.</strong>
119
+ <span style={{ opacity: 0.8, fontSize: "0.85em" }}>See you on day one.</span>
120
+ </span>
121
+ </button>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+ `;
127
+ const STREAK = `import "./streak.css";
128
+
129
+ /** Streak chip - flame + day count. Populated + prop-driven (not an empty shell). */
130
+ export function Streak({ days = 12 }: { days?: number }) {
131
+ return (
132
+ <span className="ds-streak">
133
+ <span aria-hidden>🔥</span>
134
+ <span className="ds-streak-count">{days}</span>
135
+ <span className="ds-streak-label">day streak</span>
136
+ </span>
137
+ );
138
+ }
139
+ `;
140
+ const XP_BAR = `import "./xp-bar.css";
141
+
142
+ /** XP progress bar - the fill grows to \`percent\`. Prop-driven (not an empty shell). */
143
+ export function XpBar({ percent = 62 }: { percent?: number }) {
144
+ return (
145
+ <div className="ds-xp-bar">
146
+ <div
147
+ className="ds-xp-bar-fill"
148
+ style={{ width: \`\${Math.max(0, Math.min(100, percent))}%\` }}
149
+ />
150
+ </div>
151
+ );
152
+ }
153
+ `;
154
+ const TEMPLATES = {
155
+ "join-field": JOIN_FIELD,
156
+ streak: STREAK,
157
+ "xp-bar": XP_BAR,
158
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Traz design systems do SynthesisUI para qualquer projeto (materializa em _local/ds/).",
5
5
  "type": "module",
6
6
  "bin": {