synthesisui 0.4.0 → 0.4.2

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) {
@@ -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
  }
@@ -1,21 +1,44 @@
1
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
1
+ import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
  import { generateComponentFiles } from "../component-codegen.js";
4
4
  import { readProjectConfig, resolveRegistry } from "../config.js";
5
5
  import { body, section, snippet } from "../output.js";
6
6
  import { fetchComponent, postRefit, postSaveComponent, RegistryError, } from "../registry.js";
7
- /** Slugs materialized under `_synthesisui/ds/` in the project. */
7
+ /** Slugs INSTALLED under `_synthesisui/ds/` (a `.lock` marks a real install -
8
+ * a folder holding only refit artifacts doesn't count). */
8
9
  async function installedSlugs(root) {
9
10
  try {
10
11
  const entries = await readdir(join(root, "_synthesisui", "ds"), {
11
12
  withFileTypes: true,
12
13
  });
13
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
14
+ const slugs = [];
15
+ for (const entry of entries) {
16
+ if (!entry.isDirectory())
17
+ continue;
18
+ try {
19
+ await access(join(root, "_synthesisui", "ds", entry.name, ".lock"));
20
+ slugs.push(entry.name);
21
+ }
22
+ catch {
23
+ // artifacts-only folder (e.g. a refit before `add`) - not installed
24
+ }
25
+ }
26
+ return slugs;
14
27
  }
15
28
  catch {
16
29
  return [];
17
30
  }
18
31
  }
32
+ /** True when the system is actually installed (tokens.css present). */
33
+ async function isInstalled(root, slug) {
34
+ try {
35
+ await access(join(root, "_synthesisui", "ds", slug, ".lock"));
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
19
42
  /**
20
43
  * Marco B5 - the REVERSE bridge, from the CLI: takes a component that lives in
21
44
  * YOUR app (arbitrary React/CSS), re-expresses it in the design system's
@@ -116,6 +139,16 @@ export async function refit(file, opts) {
116
139
  materialized = true;
117
140
  console.log(`✓ ${config.componentsDir}/${res.name}/ → ${files.map((f) => f.filename).join(", ")} (styles: ${config.styles})`);
118
141
  }
142
+ // The materialized component references this system's tokens - without the
143
+ // install (tokens.css + scope) it renders unstyled. Say so, concretely.
144
+ if (!(await isInstalled(root, slug))) {
145
+ console.log(section("Heads up - system not installed here yet"));
146
+ console.log(body(`The component references "${slug}" tokens that this project doesn't have yet.`));
147
+ console.log("");
148
+ console.log(snippet([`npx synthesisui add ${slug}`]));
149
+ console.log("");
150
+ console.log(body("(then follow its one-time setup: global @import + data-ds scope)"));
151
+ }
119
152
  if (res.suggestedRule) {
120
153
  console.log(section("Suggested rule"));
121
154
  console.log(body("The AI inferred a reusable rule from this component:"));
@@ -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
@@ -13,18 +13,20 @@ import { use } from "./commands/use.js";
13
13
  import { RegistryError } from "./registry.js";
14
14
  const HELP = `synthesisui - bring SynthesisUI design systems into your project
15
15
 
16
- Usage:
16
+ Usage - deterministic, FREE:
17
17
  synthesisui login [options] connect the CLI to your account (device-flow)
18
18
  synthesisui init [options] write _synthesisui/config.json (target, dirs); --ds to bring one in
19
19
  synthesisui list [options] list the published design systems
20
20
  synthesisui add <slug> [options] materialize a DS into _synthesisui/ds/<slug>/
21
+ synthesisui component <slug> <name> bring one EXISTING component in as YOUR <Pascal>.tsx
21
22
  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
23
  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
24
  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)
25
+
26
+ Usage - AI, USES CREDITS (login required):
27
+ synthesisui generate "<desc>" AI-create a NEW component your DS doesn't have (token-only recipe)
28
+ synthesisui advise "<value prop>" AI engagement-pattern proposals for this project
29
+ synthesisui refit <file> [--ds <slug>] AI-adapt an app component INTO your DS, get it back as code
28
30
 
29
31
  Options:
30
32
  --registry <url> registry URL (or env SYNTHESISUI_REGISTRY_URL)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Traz design systems do SynthesisUI para qualquer projeto (materializa em _local/ds/).",
5
5
  "type": "module",
6
6
  "bin": {