synthesisui 0.4.2 → 0.4.4

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.
@@ -0,0 +1,120 @@
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). Match by
63
+ // the `create-next-app` utm markers its links carry (stable across Next
64
+ // versions) plus the boilerplate copy - so a newer scaffold isn't missed.
65
+ const pagePath = join(appDir, "page.tsx");
66
+ const pageSrc = await readIf(pagePath);
67
+ if (pageSrc &&
68
+ /create-next-app|get started by editing|to get started, edit/i.test(pageSrc)) {
69
+ actions.push({
70
+ verb: "reset",
71
+ path: rel(pagePath),
72
+ why: "default landing → minimal placeholder",
73
+ run: () => writeFile(pagePath, MINIMAL_PAGE, "utf8"),
74
+ });
75
+ }
76
+ // 3. Layout metadata still says "Create Next App".
77
+ const layoutPath = join(appDir, "layout.tsx");
78
+ const layoutSrc = await readIf(layoutPath);
79
+ if (layoutSrc &&
80
+ /"Create Next App"|"Generated by create next app"/.test(layoutSrc)) {
81
+ actions.push({
82
+ verb: "reset",
83
+ path: rel(layoutPath),
84
+ why: 'metadata "Create Next App" → neutral',
85
+ run: () => writeFile(layoutPath, layoutSrc
86
+ .replace(/"Create Next App"/g, '"App"')
87
+ .replace(/"Generated by create next app"/g, '""'), "utf8"),
88
+ });
89
+ }
90
+ // 4. Default README.
91
+ const readmePath = join(root, "README.md");
92
+ const readmeSrc = await readIf(readmePath);
93
+ if (readmeSrc && /create-next-app/.test(readmeSrc) && /Getting Started/.test(readmeSrc)) {
94
+ actions.push({
95
+ verb: "remove",
96
+ path: "README.md",
97
+ why: "create-next-app default README",
98
+ run: () => rm(readmePath),
99
+ });
100
+ }
101
+ if (actions.length === 0) {
102
+ console.log(section("Clean up scaffold"));
103
+ console.log(body("Nothing to clean - this project is already tidy."));
104
+ console.log("");
105
+ return;
106
+ }
107
+ console.log(section(opts.force ? "Cleaned up scaffold" : "Clean up scaffold (dry run)"));
108
+ const pad = Math.max(...actions.map((a) => a.path.length));
109
+ for (const a of actions) {
110
+ const mark = opts.force ? "✓" : "•";
111
+ console.log(body(`${mark} ${a.verb.padEnd(6)} ${a.path.padEnd(pad)} ${a.why}`));
112
+ if (opts.force)
113
+ await a.run();
114
+ }
115
+ console.log("");
116
+ console.log(body(opts.force
117
+ ? `Done - ${actions.length} item(s) tidied.`
118
+ : "Dry run - nothing changed. Re-run with `synthesisui clean --force` to apply."));
119
+ console.log("");
120
+ }
@@ -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";
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";
@@ -22,6 +23,7 @@ Usage - deterministic, FREE:
22
23
  synthesisui template <slug> <name> materialize a whole page from a DS template
23
24
  synthesisui upgrade <slug> update an installed DS + regenerate your components + migration brief
24
25
  synthesisui use <slug> "<intent>" print a ready-to-paste agent prompt to build/modify on-system
26
+ synthesisui clean [--force] strip create-next-app boilerplate (dry run without --force)
25
27
 
26
28
  Usage - AI, USES CREDITS (login required):
27
29
  synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
@@ -39,10 +41,12 @@ Options:
39
41
  --components-dir <dir> init: folder where components live (default: components)
40
42
  --styles <s> init: component code flavor: css | tailwind (default: css)
41
43
  --artifacts-only component: skip the .tsx materialization (recipe + css only)
44
+ --interactive component: materialize the rich/behaving variant (join-field, streak, xp-bar)
42
45
  --replace <name> refit: replace an existing DS component (keeps its name)
43
46
  --support <file> refit: supporting CSS file (globals/vars the code references)
44
47
  --instruction <s> refit: extra guidance for the adaptation
45
48
  --dry refit: adapt and print, but save nothing
49
+ --force clean: apply the changes (without it, dry run)
46
50
  --out <path> output path for the generated template (default: <pagesDir>/<file>)
47
51
  -h, --help this help
48
52
 
@@ -199,6 +203,7 @@ async function main() {
199
203
  dir,
200
204
  version,
201
205
  artifactsOnly: flags["artifacts-only"] === true,
206
+ interactive: flags.interactive === true,
202
207
  });
203
208
  break;
204
209
  }
@@ -242,6 +247,9 @@ async function main() {
242
247
  await use(slug, intent, { dir });
243
248
  break;
244
249
  }
250
+ case "clean":
251
+ await clean({ dir, force: flags.force === true });
252
+ break;
245
253
  case "advise": {
246
254
  const valueProp = args.join(" ").trim();
247
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.2",
3
+ "version": "0.4.4",
4
4
  "description": "Traz design systems do SynthesisUI para qualquer projeto (materializa em _local/ds/).",
5
5
  "type": "module",
6
6
  "bin": {