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.
- package/dist/commands/clean.js +120 -0
- package/dist/commands/component.js +24 -5
- package/dist/index.js +8 -0
- package/dist/interactive-templates.js +158 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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'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
|
+
};
|