synthesisui 0.16.77 → 0.16.79

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,272 @@
1
+ /**
2
+ * THE ANATOMY THE SKILL READ, TURNED INTO SOMETHING THE SYSTEM CAN HOLD.
3
+ *
4
+ * The first version of this contract asked for the parts as a FLAT LIST and threw
5
+ * away the structure the skill had just finished reading. So an `ArticleCard`
6
+ * with a 16:9 cover, a title, a full editor and a footer of five buttons came
7
+ * back as four labels in a row - and the owner compared it to the real component
8
+ * and saw a broken version of it. It was not a broken version. It was not a
9
+ * version of anything (dono, 01/08).
10
+ *
11
+ * THE THREE FRONTIERS, and knowing which one a node is is the whole job:
12
+ *
13
+ * a PART an element of theirs it has styles, and we draw it
14
+ * a COMPONENT a component of theirs it has a recipe of its own, so it
15
+ * renders as a NAMED BLOCK and becomes
16
+ * a rule about the two of them
17
+ * an EXTERNAL a third-party library we do not have it and never will, so
18
+ * it renders as a block and becomes
19
+ * rules about how to build with it
20
+ *
21
+ * **Depth is not a number, it is where the frontier sits.** The skill descends
22
+ * until it meets another component or a library, stops, and records the edge -
23
+ * which answers "how deep should a part go" without inventing a parameter, and
24
+ * makes the answer different per component because it genuinely is.
25
+ *
26
+ * WHAT THIS FILE GUARANTEES, and it is the reason the two halves are derived from
27
+ * one source instead of being sent as two fields: the flat `parts` map and the
28
+ * tree can never disagree. A tree pointing at a part that does not exist would
29
+ * validate everywhere and render as a hole, and "valid and unread" is the worst
30
+ * of the three outcomes this pipeline has paid for twice.
31
+ */
32
+ import { transcribe, } from "./doctor/transcribe.js";
33
+ /** The nine forms, closed. A renderer that accepts any tag draws anything. */
34
+ const FORMS = new Set([
35
+ "image",
36
+ "heading",
37
+ "text",
38
+ "button",
39
+ "field",
40
+ "icon",
41
+ "row",
42
+ "stack",
43
+ "component",
44
+ "external",
45
+ ]);
46
+ /** The two that arrange, and the only two that take children. */
47
+ const CONTAINERS = new Set(["row", "stack"]);
48
+ /** The two frontiers: no name, no styles - what is there is not ours to hold. */
49
+ const FRONTIERS = new Set(["component", "external"]);
50
+ /**
51
+ * Names that ARE the element itself. A node for one of these draws a box inside
52
+ * its own box, because its styles already sit on the component's base.
53
+ */
54
+ const STRUCTURAL = /^(root|wrapper|base|el|outer)$/i;
55
+ /** A pathological transcription of somebody's DOM, bounded. Six levels reaches
56
+ * any real component; past that it is layout divs all the way down. */
57
+ const MAX_DEPTH = 6;
58
+ const MAX_NODES = 48;
59
+ /**
60
+ * `Actions.Generate` → `actions-generate`, and never `actions.generate`.
61
+ *
62
+ * A dot compiles to `.ds-card-actions.generate`, which is two classes and
63
+ * invalid - and `z.record(z.string())` validates the key all the way through to
64
+ * a broken stylesheet, so the guard has to be here rather than in the schema.
65
+ */
66
+ export function safePartName(raw) {
67
+ return raw
68
+ .trim()
69
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9]+/g, "-")
72
+ .replace(/-+/g, "-")
73
+ .replace(/^-|-$/g, "")
74
+ .replace(/^([0-9])/, "n$1");
75
+ }
76
+ /**
77
+ * Which form a node takes, forgiving a wrong label without losing the subtree.
78
+ *
79
+ * A tenth form is a mistake, and the cheap response - drop the node - would take
80
+ * its children with it. Something with children ARRANGES, so it becomes a stack;
81
+ * something without carries content, so it becomes text.
82
+ */
83
+ function formOf(node) {
84
+ const as = String(node.as ?? "")
85
+ .trim()
86
+ .toLowerCase();
87
+ if (FORMS.has(as))
88
+ return { as, coerced: false };
89
+ const arranges = Array.isArray(node.children) && node.children.length > 0;
90
+ return { as: arranges ? "stack" : "text", coerced: true };
91
+ }
92
+ /**
93
+ * Fold the anatomy into a flat parts map and a tree, in one pass.
94
+ *
95
+ * `declared` is their own tokens, so a `bg-ocean-500` on a nested part resolves
96
+ * to the name they gave it rather than to a literal.
97
+ */
98
+ export function resolveAnatomy(read, declared, deps,
99
+ /** Their name → the slug it reaches in this system. See `RefResolver`. */
100
+ resolve,
101
+ /** What the component returns, when the skill said and it is not a plain tag. */
102
+ root) {
103
+ const parts = {};
104
+ const composes = [];
105
+ const external = [];
106
+ const notes = [];
107
+ let budget = MAX_NODES;
108
+ let coerced = 0;
109
+ /**
110
+ * A SINGLE STRUCTURAL ROOT IS THE COMPONENT, NOT A NODE OF IT.
111
+ *
112
+ * The skill is told not to send `root`/`wrapper`, and when it does anyway at
113
+ * the top the honest fix is to unwrap it: its styles are the base's, and
114
+ * keeping it would draw the component inside a copy of itself.
115
+ */
116
+ let top = read;
117
+ while (Array.isArray(top) &&
118
+ top.length === 1 &&
119
+ top[0] &&
120
+ STRUCTURAL.test(String(top[0].name ?? "")) &&
121
+ Array.isArray(top[0].children) &&
122
+ top[0].children.length > 0) {
123
+ top = top[0].children;
124
+ notes.push("the outermost wrapper is the component itself, so its children are the anatomy");
125
+ }
126
+ /** Part names, deduped: a second `label` becomes `label-2`, deterministically. */
127
+ const claim = (wanted) => {
128
+ if (parts[wanted] == null)
129
+ return wanted;
130
+ for (let n = 2; n < 100; n++) {
131
+ const candidate = `${wanted}-${n}`;
132
+ if (parts[candidate] == null)
133
+ return candidate;
134
+ }
135
+ return `${wanted}-x`;
136
+ };
137
+ const walk = (nodes, depth) => {
138
+ const out = [];
139
+ if (depth > MAX_DEPTH)
140
+ return out;
141
+ for (const node of nodes) {
142
+ if (!node || typeof node !== "object")
143
+ continue;
144
+ if (budget <= 0)
145
+ break;
146
+ budget -= 1;
147
+ const { as, coerced: wasCoerced } = formOf(node);
148
+ if (wasCoerced)
149
+ coerced += 1;
150
+ // A FRONTIER carries a reference and nothing else. A name or a class list
151
+ // on one would be us claiming styles that belong to somebody else.
152
+ if (FRONTIERS.has(as)) {
153
+ if (as === "component") {
154
+ const name = String(node.ref ?? "").trim();
155
+ if (!name)
156
+ continue;
157
+ if (!composes.includes(name))
158
+ composes.push(name);
159
+ /**
160
+ * THE CROSSWALK DECIDES WHAT THIS REACHES.
161
+ *
162
+ * `Tag` is bucketed `nearly` with canonical `badge`; kebab-casing it
163
+ * looks for `ds-tag` and finds nothing. Without a resolver we keep the
164
+ * kebab, which is what a system generated by us would want anyway.
165
+ */
166
+ const slug = resolve?.(name) ?? safePartName(name);
167
+ out.push(slug && slug !== safePartName(name)
168
+ ? { as, ref: slug, refName: name }
169
+ : { as, ref: slug || safePartName(name) });
170
+ continue;
171
+ }
172
+ const from = String(node.from ?? "").trim();
173
+ if (!from)
174
+ continue;
175
+ const version = deps?.[from];
176
+ if (!external.some((e) => e.from === from))
177
+ external.push({ from, version });
178
+ out.push(version ? { as, from, version } : { as, from });
179
+ continue;
180
+ }
181
+ const node_ = { as };
182
+ // The NAME is what carries the styles, so it is claimed even when nothing
183
+ // resolved: the tree triggers on presence, and structure is most of the
184
+ // value. A node with no name is pure structure and that is legitimate.
185
+ const wanted = safePartName(String(node.name ?? ""));
186
+ if (wanted) {
187
+ const key = claim(wanted);
188
+ if (key !== wanted) {
189
+ notes.push(`two parts named \`${wanted}\` - the second is \`${key}\``);
190
+ }
191
+ const classes = typeof node.classes === "string"
192
+ ? node.classes.split(/\s+/).filter(Boolean)
193
+ : [];
194
+ const t = transcribe(classes, declared);
195
+ parts[key] = { base: t.base, dark: t.dark, states: t.states };
196
+ node_.part = key;
197
+ }
198
+ const text = typeof node.text === "string" ? node.text.trim() : "";
199
+ if (text)
200
+ node_.text = text.slice(0, 120);
201
+ // Only the two arrangers descend. A leaf with children is a leaf that was
202
+ // labelled wrongly, and its children would render nowhere.
203
+ if (CONTAINERS.has(as) && Array.isArray(node.children)) {
204
+ const children = walk(node.children, depth + 1);
205
+ if (children.length > 0)
206
+ node_.children = children;
207
+ }
208
+ else if (Array.isArray(node.children) && node.children.length > 0) {
209
+ const children = walk(node.children, depth + 1);
210
+ // Hoist rather than lose them: the label was wrong, the structure was not.
211
+ out.push(node_, ...children);
212
+ continue;
213
+ }
214
+ out.push(node_);
215
+ }
216
+ return out;
217
+ };
218
+ const tree = walk(top, 0);
219
+ /**
220
+ * THE ROOT, RESOLVED THE SAME WAY.
221
+ *
222
+ * A dotted capitalised name is somebody else's namespace - `Radio.Root`,
223
+ * `BaseDialog.Root` - so there is nothing of theirs to reach and the honest
224
+ * answer is to name the library. Anything else is a component of theirs and goes
225
+ * through the crosswalk exactly like a child frontier does.
226
+ */
227
+ let rootOut;
228
+ const rootRaw = String(root ?? "").trim();
229
+ if (rootRaw && /^[A-Z]/.test(rootRaw)) {
230
+ if (rootRaw.includes(".")) {
231
+ rootOut = { from: rootRaw };
232
+ }
233
+ else {
234
+ const slug = resolve?.(rootRaw) ?? safePartName(rootRaw);
235
+ if (slug)
236
+ rootOut = { ref: slug, name: rootRaw };
237
+ }
238
+ }
239
+ if (coerced > 0) {
240
+ notes.push(`${coerced} node${coerced === 1 ? "" : "s"} named a form that does not exist - kept, drawn as what ${coerced === 1 ? "it holds" : "they hold"}`);
241
+ }
242
+ if (budget <= 0) {
243
+ notes.push(`the anatomy was longer than ${MAX_NODES} nodes - the rest is layout, and it is not read`);
244
+ }
245
+ return {
246
+ parts,
247
+ tree,
248
+ composes,
249
+ external,
250
+ notes,
251
+ ...(rootOut ? { root: rootOut } : {}),
252
+ };
253
+ }
254
+ /**
255
+ * The OLD flat form, folded into the same result shape.
256
+ *
257
+ * 23 standard components carry flat parts and no tree, and a skill written
258
+ * against the previous contract sends this. Both keep working: the parts style
259
+ * correctly, and the absence of a tree means the preview lays them out in a row
260
+ * exactly as it did before.
261
+ */
262
+ export function resolveFlatParts(read, declared) {
263
+ const parts = {};
264
+ for (const part of read) {
265
+ const name = safePartName(String(part?.name ?? ""));
266
+ if (!name || typeof part.classes !== "string")
267
+ continue;
268
+ const t = transcribe(part.classes.split(/\s+/).filter(Boolean), declared);
269
+ parts[name] = { base: t.base, dark: t.dark, states: t.states };
270
+ }
271
+ return { parts, tree: [], composes: [], external: [], notes: [] };
272
+ }
@@ -6,7 +6,7 @@ import { customFontFamilies, googleFontsHref, nextFontSnippet, } from "../fonts.
6
6
  import { buildGuide } from "../guide.js";
7
7
  import { body as line, section, snippet } from "../output.js";
8
8
  import { fetchDesignSystem } from "../registry.js";
9
- import { describeFiltered, rulesForProject } from "../rule-filter.js";
9
+ import { describeFiltered, ruleApplies, rulesForProject, } from "../rule-filter.js";
10
10
  import { detectStack } from "../stack.js";
11
11
  async function readRootLock(path) {
12
12
  try {
@@ -97,6 +97,28 @@ export async function add(slug, opts) {
97
97
  // a thing the person should hear once, not discover by its absence.
98
98
  if (leftOut)
99
99
  console.log(line(leftOut));
100
+ /**
101
+ * 5b-ii. THE PACKAGES THIS SYSTEM NEEDS, kept where the doctor can read them.
102
+ *
103
+ * `rules.md` is prose with maximum authority, and prose is the wrong thing to
104
+ * enforce against: a check that grepped "requires @tiptap/react" out of a
105
+ * sentence would break the first time somebody reworded it. So the one
106
+ * verifiable field travels as data, filtered by THIS project's stack exactly
107
+ * like the prose was.
108
+ *
109
+ * Written only when there is something to say, so a system with no third-party
110
+ * component leaves no file behind.
111
+ */
112
+ const required = (payload.ruleSet ?? [])
113
+ .filter((r) => r.requires && ruleApplies(r, stack))
114
+ .map((r) => ({
115
+ requires: r.requires,
116
+ applies: r.applies ?? [],
117
+ ...(r.pinned ? { pinned: r.pinned } : {}),
118
+ }));
119
+ if (required.length > 0) {
120
+ await writeFile(join(slugDir, "requires.json"), `${JSON.stringify(required, null, 2)}\n`, "utf8");
121
+ }
100
122
  // 5c. structured philosophy (personal DS) → philosophy.md at the slug root.
101
123
  // Narrative guidance (mission, principles, voice, motion doctrine…); the
102
124
  // GUIDE points the agent here right after rules.md.
@@ -5,6 +5,7 @@ import { generateComponentFiles } from "../component-codegen.js";
5
5
  import { readProjectConfig, resolveRegistry } from "../config.js";
6
6
  import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
7
7
  import { body, section, snippet } from "../output.js";
8
+ import { findCollision, reactMajorOf, readInstalledConvention, } from "../project-facts.js";
8
9
  import { fetchComponent, RegistryError } from "../registry.js";
9
10
  /**
10
11
  * Writes the shared `cn.ts` next to the components, built from THIS project's
@@ -27,28 +28,6 @@ async function writeCn(root, compDir, slug) {
27
28
  return;
28
29
  await writeFile(join(compDir, "..", "cn.ts"), emitCn(slug, readVocabulary(real)), "utf8");
29
30
  }
30
- /**
31
- * The consumer's React major, or null when we cannot tell.
32
- *
33
- * It decides whether the generated components can take a `ref`: from 19 that is
34
- * an ordinary prop, before it a function component needs `forwardRef`. Guessing
35
- * high on an older project would emit a type that accepts a ref React then
36
- * silently drops, so anything unreadable falls back to the ref-less type.
37
- */
38
- async function reactMajorOf(root) {
39
- const raw = await readFile(join(root, "package.json"), "utf8").catch(() => "");
40
- if (!raw)
41
- return null;
42
- try {
43
- const pkg = JSON.parse(raw);
44
- const spec = pkg.dependencies?.react ?? pkg.devDependencies?.react;
45
- const major = /(\d+)/.exec(spec ?? "")?.[1];
46
- return major ? Number(major) : null;
47
- }
48
- catch {
49
- return null;
50
- }
51
- }
52
31
  /** Slugs/names are kebab-case by contract; reject anything else before it ever
53
32
  * reaches a filesystem path (defense-in-depth against `../` traversal). */
54
33
  const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -89,6 +68,43 @@ export async function component(slug, name, opts) {
89
68
  const config = await readProjectConfig(root);
90
69
  const wantInteractive = opts.interactive && hasInteractiveTemplate(res.name);
91
70
  if (!opts.artifactsOnly && config.target === "next") {
71
+ /**
72
+ * WE DO NOT TAKE A NAME THEY ARE USING.
73
+ *
74
+ * This wrote with a bare `writeFile`, so it overwrote whatever was there - and
75
+ * a project that already has its own `Button` is the normal case. The
76
+ * crosswalk makes it sharper: their `ToolbarButton` resolves to `button`, so
77
+ * asking for one hands back a `Button`, which is a different component.
78
+ *
79
+ * Re-materializing something WE generated is almost always what they meant, so
80
+ * that keeps going. Shadowing a component of theirs stops and asks.
81
+ */
82
+ const pascalName = res.name
83
+ .split(/[^a-zA-Z0-9]+/)
84
+ .filter(Boolean)
85
+ .map((p) => p[0].toUpperCase() + p.slice(1))
86
+ .join("");
87
+ const clash = await findCollision(root, config.componentsDir, res.name, pascalName);
88
+ if (!opts.force && (clash.exported || (clash.file && !clash.ours))) {
89
+ console.log(section("This name is already taken in your project"));
90
+ if (clash.exported) {
91
+ console.log(body(`\`${pascalName}\` is already exported from ${clash.exported}.`));
92
+ }
93
+ if (clash.file && !clash.ours) {
94
+ console.log(body(`${clash.file} exists and we did not write it.`));
95
+ }
96
+ console.log("");
97
+ console.log(body("The recipe and the compiled CSS are on disk either way - only the .tsx was not written. Your options:"));
98
+ console.log("");
99
+ console.log(snippet([
100
+ `npx synthesisui component ${slug} ${res.name} --force`,
101
+ `# or bring it under a name of your own:`,
102
+ `npx synthesisui component ${slug} ${res.name} --as my-${res.name}`,
103
+ ]));
104
+ console.log("");
105
+ console.log(body("Nothing of yours was touched. Which name it takes is your call, not ours."));
106
+ return;
107
+ }
92
108
  const compDir = join(root, config.componentsDir, res.name);
93
109
  await mkdir(compDir, { recursive: true });
94
110
  let filenames;
@@ -103,7 +119,16 @@ export async function component(slug, name, opts) {
103
119
  filenames = [`${res.name}.tsx`, `${res.name}.css`, "index.ts"];
104
120
  }
105
121
  else {
106
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root));
122
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root),
123
+ /**
124
+ * THE SPELLING, FROM THE VERSION WE JUST FETCHED.
125
+ *
126
+ * The response wins over the document on disk because it belongs to the
127
+ * exact version being written - and the CSS in `res.css` was compiled with
128
+ * it, so the TSX and the stylesheet in the same folder agree by
129
+ * construction. Falling back to disk keeps an older registry working.
130
+ */
131
+ res.classNames ?? (await readInstalledConvention(root, slug)));
107
132
  for (const file of files) {
108
133
  await writeFile(join(compDir, file.filename), file.code, "utf8");
109
134
  }
@@ -3,6 +3,7 @@ import { join, relative, resolve } from "node:path";
3
3
  import { findDivergences } from "../doctor/coherence.js";
4
4
  import { emptyTally, internalSpecifiers, scanComponentsInto, tallyToInventory, } from "../doctor/components-scan.js";
5
5
  import { checkContracts } from "../doctor/contract-check.js";
6
+ import { describeMissing, missingDependencies, summarizeMissing, } from "../doctor/dependencies.js";
6
7
  import { findFrozenBindings } from "../doctor/frozen.js";
7
8
  import { appendEvent, readEvents, summarize } from "../doctor/ledger.js";
8
9
  import { bindingsFromDocument, countComponents, findOverrides, } from "../doctor/overrides.js";
@@ -11,6 +12,7 @@ import { diagnose, scanSource, siblingTokens, } from "../doctor/scan.js";
11
12
  import { findSelfConflicts, forbiddenProps, isReset, propMatchesLabel, } from "../doctor/self-conflict.js";
12
13
  import { buildTable, EMPTY_TABLE, nearestToken, } from "../doctor/tokens.js";
13
14
  import { body, paint, section, snippet } from "../output.js";
15
+ import { resolveDeps } from "../stack.js";
14
16
  /**
15
17
  * `synthesisui doctor` - the check nobody else ships.
16
18
  *
@@ -114,7 +116,12 @@ export async function loadSystem(root) {
114
116
  .map((e) => e.name);
115
117
  }
116
118
  catch {
117
- return { table: EMPTY_TABLE, recipes: new Map(), documents: [] };
119
+ return {
120
+ table: EMPTY_TABLE,
121
+ recipes: new Map(),
122
+ documents: [],
123
+ requires: [],
124
+ };
118
125
  }
119
126
  // Several systems can live side by side; every token is prefixed --ds-, so
120
127
  // reading them all is both correct and what the running app actually sees.
@@ -123,6 +130,7 @@ export async function loadSystem(root) {
123
130
  let adopted = false;
124
131
  const recipes = new Map();
125
132
  const documents = [];
133
+ const requires = [];
126
134
  for (const slug of slugs) {
127
135
  const dir = join(dsDir, slug);
128
136
  const raw = await readFile(join(dir, ".lock"), "utf8").catch(() => "");
@@ -182,11 +190,25 @@ export async function loadSystem(root) {
182
190
  // A document we cannot parse costs the component pass, not the run.
183
191
  }
184
192
  }
193
+ // Written by `add` only when this system has a third-party component, so its
194
+ // absence is the normal case rather than a failure.
195
+ const reqRaw = await readFile(join(dir, "requires.json"), "utf8").catch(() => "");
196
+ if (reqRaw) {
197
+ try {
198
+ const parsed = JSON.parse(reqRaw);
199
+ if (Array.isArray(parsed))
200
+ requires.push(...parsed);
201
+ }
202
+ catch {
203
+ // Unreadable costs this check, not the run.
204
+ }
205
+ }
185
206
  }
186
207
  return {
187
208
  table: buildTable({ css, lock, source: adopted ? "adopted" : "installed" }),
188
209
  recipes,
189
210
  documents,
211
+ requires,
190
212
  };
191
213
  }
192
214
  /**
@@ -870,6 +892,32 @@ export async function doctor(opts) {
870
892
  say(body("complains - the surface just stays put when the scheme"));
871
893
  say(body("moves around it."));
872
894
  }
895
+ /**
896
+ * THE LIBRARIES THE SYSTEM SAYS YOU NEED - the half that makes a rule about a
897
+ * third-party component into governance rather than a sentence.
898
+ *
899
+ * Some of their components are built on somebody else's library. We do not have
900
+ * it, cannot draw it, and said so: the anatomy stops at the frontier and the
901
+ * knowledge moved into a rule carrying the package name as DATA. This is where
902
+ * that gets checked, against the same manifest reader the stack detection uses.
903
+ *
904
+ * It never installs. A dependency has a licence, a bundle cost and a maintainer
905
+ * attached, so the answer is to report and let them decide.
906
+ */
907
+ const missingDeps = missingDependencies(installed.requires, await resolveDeps(root).catch(() => ({})));
908
+ if (missingDeps.length > 0) {
909
+ say(section("This system needs libraries you do not have"));
910
+ const summary = summarizeMissing(missingDeps);
911
+ if (summary)
912
+ say(body(summary));
913
+ say("");
914
+ for (const dep of describeMissing(missingDeps))
915
+ say(body(dep));
916
+ say("");
917
+ say(body("Nothing was installed for you. Install what you actually use:"));
918
+ say("");
919
+ say(` npm i ${missingDeps.map((d) => d.name).join(" ")}`);
920
+ }
873
921
  const frozenAt = new Set(frozen.map((f) => `${f.component}:${f.where.split(" · ").pop()}`));
874
922
  let offSystemCount = 0;
875
923
  let lawKeepingCount = 0;
@@ -2,6 +2,7 @@ import { mkdir, readdir, readFile, 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 { reactMajorOf, readInstalledConvention } from "../project-facts.js";
5
6
  import { postGenerate, RegistryError } from "../registry.js";
6
7
  /** PascalCase para o hint de import (course-card → CourseCard). */
7
8
  function pascalName(name) {
@@ -70,7 +71,10 @@ export async function generate(description, opts) {
70
71
  const version = await readActiveVersion(root, slug);
71
72
  const compDir = join(root, config.componentsDir, res.name);
72
73
  await mkdir(compDir, { recursive: true });
73
- const files = generateComponentFiles(slug, res.name, res.recipe, res.css, version, config.styles);
74
+ const files = generateComponentFiles(slug, res.name, res.recipe, res.css, version, config.styles, await reactMajorOf(root),
75
+ // Read off the installed document: a generated component lands in the same
76
+ // project as the stylesheet it has to match.
77
+ await readInstalledConvention(root, slug));
74
78
  for (const file of files) {
75
79
  await writeFile(join(compDir, file.filename), file.code, "utf8");
76
80
  }