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.
- package/dist/commands/add.js +17 -0
- package/dist/commands/advise.js +38 -1
- package/dist/commands/clean.js +117 -0
- package/dist/commands/component.js +24 -5
- package/dist/commands/generate.js +44 -5
- package/dist/component-codegen.js +56 -0
- package/dist/fonts.js +40 -0
- package/dist/index.js +15 -5
- package/dist/interactive-templates.js +158 -0
- package/package.json +1 -1
package/dist/commands/add.js
CHANGED
|
@@ -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`));
|
package/dist/commands/advise.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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";
|
|
@@ -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 {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
27
|
-
|
|
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'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
|
+
};
|