truecanvas 0.1.0 → 0.3.0

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.
@@ -1,78 +1,24 @@
1
+ import {
2
+ detectFramework,
3
+ idOf,
4
+ isComponentName,
5
+ jsxName,
6
+ parseModule,
7
+ rewriteImportSource,
8
+ walk
9
+ } from "./chunk-PBKX3SBC.js";
1
10
  import {
2
11
  Catalog,
3
- importSpecifier
4
- } from "./chunk-753N4QRO.js";
5
-
6
- // src/core/config.ts
7
- import fs from "fs";
8
- import path from "path";
9
- function loadConfig(root, overrides = {}) {
10
- let file = {};
11
- const configPath = path.join(root, "truecanvas.config.json");
12
- if (fs.existsSync(configPath)) file = JSON.parse(fs.readFileSync(configPath, "utf8"));
13
- const appDir = file.appDir ?? (fs.existsSync(path.join(root, "src/app")) ? "src/app" : "app");
14
- const srcPrefix = appDir.startsWith("src/") ? "src/" : "";
15
- let deps = {};
16
- try {
17
- const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
18
- deps = { ...pkg.dependencies, ...pkg.devDependencies };
19
- } catch {
20
- deps = {};
21
- }
22
- const knownLibraries = ["@paper-design/shaders-react"].filter((l) => l in deps);
23
- return {
24
- root,
25
- appUrl: "http://localhost:3000",
26
- port: 4800,
27
- canvasDir: `${srcPrefix}canvas`,
28
- appDir,
29
- components: [`${srcPrefix}components/**/*.tsx`],
30
- darkMode: { strategy: "class", value: "dark" },
31
- ...file,
32
- libraries: [.../* @__PURE__ */ new Set([...knownLibraries, ...file.libraries ?? []])],
33
- ...stripUndefined(overrides)
34
- };
35
- }
36
- function stripUndefined(o) {
37
- return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
38
- }
39
-
40
- // src/core/ast.ts
41
- import { parse } from "@babel/parser";
42
- function parseModule(source) {
43
- return parse(source, {
44
- sourceType: "module",
45
- plugins: ["typescript", "jsx"],
46
- errorRecovery: false
47
- });
48
- }
49
- function idOf(node) {
50
- const loc = node.loc.start;
51
- return `${loc.line}:${loc.column}`;
52
- }
53
- function jsxName(name) {
54
- if (name.type === "JSXIdentifier") return name.name;
55
- if (name.type === "JSXNamespacedName") return `${name.namespace.name}:${name.name.name}`;
56
- return `${jsxName(name.object)}.${name.property.name}`;
57
- }
58
- function walk(node, visit, parent = null) {
59
- if (!node || typeof node !== "object") return;
60
- if (Array.isArray(node)) {
61
- for (const child of node) walk(child, visit, parent);
62
- return;
63
- }
64
- const n = node;
65
- if (typeof n.type !== "string") return;
66
- if (visit(n, parent) === false) return;
67
- for (const key of Object.keys(n)) {
68
- if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "extra") continue;
69
- const value = n[key];
70
- if (value && typeof value === "object") walk(value, visit, n);
71
- }
72
- }
73
- function isComponentName(name) {
74
- return /^[A-Z]/.test(name) || name.includes(".");
75
- }
12
+ importSpecifier,
13
+ posix,
14
+ resolveAliasPath
15
+ } from "./chunk-WU7NBTJH.js";
16
+ import {
17
+ ICON_LIBRARIES,
18
+ iconLibrary,
19
+ loadIcons,
20
+ searchIcons
21
+ } from "./chunk-C4SATRZT.js";
76
22
 
77
23
  // src/core/parse.ts
78
24
  function parseCanvas(name, file, source) {
@@ -102,8 +48,8 @@ function parseCanvas(name, file, source) {
102
48
  let frameIndex = 0;
103
49
  for (const child of canvasNode.children) {
104
50
  if (child.kind !== "component" || child.name !== "Frame") continue;
105
- const path12 = [frameIndex++];
106
- reindex(child, path12);
51
+ const path14 = [frameIndex++];
52
+ reindex(child, path14);
107
53
  frames.push({
108
54
  ...child,
109
55
  frameName: str(child.props.name) ?? `Frame ${frameIndex}`,
@@ -134,6 +80,7 @@ function readImports(ast) {
134
80
  typeOnly,
135
81
  // value bindings only: `import type` and `{ type X }` don't exist at runtime
136
82
  names: typeOnly ? [] : [...named.filter((s) => s.importKind !== "type").map((s) => s.local.name), ...ns ? [ns.local.name] : []],
83
+ bindings: typeOnly ? [] : named.filter((s) => s.importKind !== "type").map((s) => ({ imported: s.imported.type === "Identifier" ? s.imported.name : s.imported.value, local: s.local.name })),
137
84
  defaultName: def ? def.local.name : null,
138
85
  start: stmt.start,
139
86
  end: stmt.end,
@@ -153,9 +100,9 @@ function topLevelNames(ast) {
153
100
  }
154
101
  return out;
155
102
  }
156
- function reindex(node, path12) {
157
- node.path = path12;
158
- node.children.forEach((c, i) => reindex(c, [...path12, i]));
103
+ function reindex(node, path14) {
104
+ node.path = path14;
105
+ node.children.forEach((c, i) => reindex(c, [...path14, i]));
159
106
  }
160
107
  function str(v) {
161
108
  return v?.kind === "string" ? v.value : void 0;
@@ -163,10 +110,10 @@ function str(v) {
163
110
  function num(v) {
164
111
  return v?.kind === "number" ? v.value : void 0;
165
112
  }
166
- function buildNode(el, source, path12) {
113
+ function buildNode(el, source, path14) {
167
114
  const children = [];
168
115
  for (const child of el.children) {
169
- const built = buildChild(child, source, [...path12, children.length]);
116
+ const built = buildChild(child, source, [...path14, children.length]);
170
117
  if (built) children.push(built);
171
118
  }
172
119
  if (el.type === "JSXFragment") {
@@ -181,7 +128,7 @@ function buildNode(el, source, path12) {
181
128
  end: el.end,
182
129
  line: el.loc.start.line,
183
130
  col: el.loc.start.column,
184
- path: path12,
131
+ path: path14,
185
132
  attrs: [],
186
133
  nameEnd: el.openingFragment.end - 1,
187
134
  openEnd: el.openingFragment.end,
@@ -216,7 +163,7 @@ function buildNode(el, source, path12) {
216
163
  end: el.end,
217
164
  line: open.loc.start.line,
218
165
  col: open.loc.start.column,
219
- path: path12,
166
+ path: path14,
220
167
  attrs,
221
168
  // after type arguments: `<Select<Opt> …>`
222
169
  nameEnd: (open.typeParameters ?? open.name).end,
@@ -225,27 +172,27 @@ function buildNode(el, source, path12) {
225
172
  closeStart: el.closingElement ? el.closingElement.start : open.end
226
173
  };
227
174
  }
228
- function buildChild(child, source, path12) {
229
- if (child.type === "JSXElement" || child.type === "JSXFragment") return buildNode(child, source, path12);
175
+ function buildChild(child, source, path14) {
176
+ if (child.type === "JSXElement" || child.type === "JSXFragment") return buildNode(child, source, path14);
230
177
  if (child.type === "JSXText") {
231
178
  const text = child.value.replace(/\s+/g, " ").trim();
232
179
  if (!text) return null;
233
- return leaf("text", text, child, path12, { text });
180
+ return leaf("text", text, child, path14, { text });
234
181
  }
235
182
  if (child.type === "JSXExpressionContainer") {
236
183
  const expr = child.expression;
237
184
  if (expr.type === "JSXEmptyExpression") return null;
238
- if (expr.type === "StringLiteral") return leaf("text", expr.value, child, path12, { text: expr.value });
185
+ if (expr.type === "StringLiteral") return leaf("text", expr.value, child, path14, { text: expr.value });
239
186
  if (expr.type === "TemplateLiteral" && expr.expressions.length === 0) {
240
187
  const text = expr.quasis.map((q) => q.value.cooked ?? "").join("");
241
- return leaf("text", text, child, path12, { text });
188
+ return leaf("text", text, child, path14, { text });
242
189
  }
243
- return leaf("expression", source.slice(expr.start, expr.end), child, path12, {});
190
+ return leaf("expression", source.slice(expr.start, expr.end), child, path14, {});
244
191
  }
245
- if (child.type === "JSXSpreadChild") return leaf("expression", source.slice(child.start, child.end), child, path12, {});
192
+ if (child.type === "JSXSpreadChild") return leaf("expression", source.slice(child.start, child.end), child, path14, {});
246
193
  return null;
247
194
  }
248
- function leaf(kind, name, node, path12, extra) {
195
+ function leaf(kind, name, node, path14, extra) {
249
196
  return {
250
197
  id: `${kind[0]}${idOf(node)}`,
251
198
  kind,
@@ -258,7 +205,7 @@ function leaf(kind, name, node, path12, extra) {
258
205
  end: node.end,
259
206
  line: node.loc.start.line,
260
207
  col: node.loc.start.column,
261
- path: path12,
208
+ path: path14,
262
209
  attrs: [],
263
210
  nameEnd: node.end,
264
211
  openEnd: node.end,
@@ -322,10 +269,10 @@ function findNode(doc, id) {
322
269
  function findFrame(doc, ref) {
323
270
  return doc.frames.find((f) => f.id === ref || f.frameName === ref) ?? null;
324
271
  }
325
- function findByPath(doc, path12) {
326
- if (!path12.length) return null;
327
- let node = doc.frames[path12[0]];
328
- for (const i of path12.slice(1)) node = node?.children[i];
272
+ function findByPath(doc, path14) {
273
+ if (!path14.length) return null;
274
+ let node = doc.frames[path14[0]];
275
+ for (const i of path14.slice(1)) node = node?.children[i];
329
276
  return node ?? null;
330
277
  }
331
278
 
@@ -418,7 +365,8 @@ var CanvasEditor = class {
418
365
  } else if (node.openEnd === node.closeStart) {
419
366
  this.s.appendLeft(node.openEnd, code);
420
367
  } else {
421
- this.s.overwrite(node.openEnd, node.closeStart, code);
368
+ const comments = this.source.slice(node.openEnd, node.closeStart).match(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g) ?? [];
369
+ this.s.overwrite(node.openEnd, node.closeStart, comments.join("") + code);
422
370
  }
423
371
  }
424
372
  // ---------- indentation helpers: edits match the file's existing style ----------
@@ -454,13 +402,20 @@ var CanvasEditor = class {
454
402
  }
455
403
  return this.lineIndent(parent.start) + this.unit();
456
404
  }
457
- /** Re-indents lines 2..n of a snippet so it sits at `indent`. */
405
+ /**
406
+ * Re-indents lines 2..n of a snippet so it sits at `indent`. Lines that start
407
+ * inside a multi-line string or template literal are content, not code: they
408
+ * are kept exactly as written.
409
+ */
458
410
  shift(code, indent) {
459
- const lines = code.trim().split("\n");
460
- const rest = lines.slice(1).filter((l) => l.trim());
411
+ const trimmed = code.trim();
412
+ const lines = trimmed.split("\n");
413
+ const frozen = literalLines(trimmed);
414
+ const free = (i) => !frozen.has(i);
415
+ const rest = lines.slice(1).filter((l, i) => l.trim() && free(i + 1));
461
416
  const base = rest.length ? Math.min(...rest.map((l) => /^[ \t]*/.exec(l)[0].length)) : 0;
462
- const relative = lines.slice(1).map((l) => l.trim() ? l.slice(base) : "");
463
- const steps = relative.map((l) => /^ */.exec(l)[0].length).filter((n) => n > 0);
417
+ const relative = lines.slice(1).map((l, i) => free(i + 1) ? l.trim() ? l.slice(base) : "" : l);
418
+ const steps = relative.filter((_, i) => free(i + 1)).map((l) => /^ */.exec(l)[0].length).filter((n) => n > 0);
464
419
  const from = steps.length ? Math.min(...steps) : 0;
465
420
  const unit = this.unit();
466
421
  const convert = (l) => {
@@ -468,7 +423,7 @@ var CanvasEditor = class {
468
423
  const n = /^ */.exec(l)[0].length;
469
424
  return unit.repeat(Math.floor(n / from)) + " ".repeat(n % from) + l.slice(n);
470
425
  };
471
- return [lines[0], ...relative.map((l) => l ? indent + convert(l) : "")].join("\n");
426
+ return [lines[0], ...relative.map((l, i) => !free(i + 1) ? l : l ? indent + convert(l) : "")].join("\n");
472
427
  }
473
428
  openToClose(node, inner) {
474
429
  let end = node.openEnd - 2;
@@ -532,16 +487,18 @@ ${pi}`);
532
487
  this.placed.push({ x: right + 80, y: f.y, width: f.width, height: h });
533
488
  code = rewriteFrameProps(code, { name, x: right + 80 });
534
489
  }
490
+ const marked = withMarker(code.trim(), node);
535
491
  this.s.appendLeft(this.contentEnd(node), `
536
- ${this.lineIndent(node.start)}${code.trim()}`);
537
- const path12 = [...node.path];
538
- path12[path12.length - 1] += 1;
539
- this.selectPaths.push(path12);
492
+ ${this.lineIndent(node.start)}${marked}`);
493
+ if (marked === code.trim()) {
494
+ const path14 = [...node.path];
495
+ path14[path14.length - 1] += 1;
496
+ this.selectPaths.push(path14);
497
+ }
540
498
  }
541
499
  move(node, target, index) {
542
500
  if (isInside(target, node)) throw new EditError("Cannot move a node into itself.");
543
- const raw = this.source.slice(this.contentStart(node), this.contentEnd(node));
544
- const code = node.kind === "component" || node.kind === "element" ? raw.replace(/^<([\w.:-]+)/, "<$1 __tcsel") : raw;
501
+ const code = withMarker(this.source.slice(this.contentStart(node), this.contentEnd(node)), node);
545
502
  const kids = target.children.filter((c) => c.id !== node.id);
546
503
  const i = index === void 0 || index < 0 || index > kids.length ? kids.length : index;
547
504
  this.remove(node);
@@ -583,7 +540,7 @@ ${ind}${close}`);
583
540
  this.remove(node);
584
541
  return;
585
542
  }
586
- const inner = this.source.slice(this.contentStart(kids[0]), this.contentEnd(kids[kids.length - 1]));
543
+ const inner = node.selfClosing ? "" : this.source.slice(node.openEnd, node.closeStart).trim();
587
544
  this.s.overwrite(this.contentStart(node), this.contentEnd(node), this.shift(inner, this.lineIndent(this.contentStart(node))));
588
545
  this.selectPaths.push(node.path);
589
546
  }
@@ -601,8 +558,36 @@ ${ind}${close}`);
601
558
  this.s.appendLeft(same.lastSpecifierEnd, `, ${name}`);
602
559
  return;
603
560
  }
561
+ this.addImportLine(`import { ${name} } from ${JSON.stringify(from)};`);
562
+ }
563
+ /**
564
+ * Imports `name` from `from` and returns the local name to write in JSX. When
565
+ * the name is already taken by something else (another import, a local
566
+ * component), it is imported under `alias` instead: `{ Search as SearchIcon }`.
567
+ */
568
+ importAs(name, from, alias) {
569
+ const existing = this.doc.imports.find((i) => i.source === from && !i.typeOnly)?.bindings.find((b) => b.imported === name);
570
+ if (existing) return existing.local;
571
+ const key = `${from}\0${name}`;
572
+ const pending = this.importedAs.get(key);
573
+ if (pending) return pending;
574
+ const taken = (n) => this.doc.declared.includes(n) || this.added.has(n) || this.doc.imports.some((i) => i.names.includes(n) || i.defaultName === n);
575
+ let local = name;
576
+ if (taken(local)) {
577
+ local = alias;
578
+ for (let k = 2; taken(local); k++) local = `${alias}${k}`;
579
+ }
580
+ this.added.add(local);
581
+ this.importedAs.set(key, local);
582
+ const specifier = local === name ? name : `${name} as ${local}`;
583
+ const same = this.doc.imports.find((i) => i.source === from && !i.typeOnly && i.lastSpecifierEnd !== null);
584
+ if (same) this.s.appendLeft(same.lastSpecifierEnd, `, ${specifier}`);
585
+ else this.addImportLine(`import { ${specifier} } from ${JSON.stringify(from)};`);
586
+ return local;
587
+ }
588
+ importedAs = /* @__PURE__ */ new Map();
589
+ addImportLine(line) {
604
590
  const last = this.doc.imports[this.doc.imports.length - 1];
605
- const line = `import { ${name} } from ${JSON.stringify(from)};`;
606
591
  if (last) this.s.appendLeft(last.end, `
607
592
  ${line}`);
608
593
  else if (this.doc.prologueEnd) this.s.appendLeft(this.doc.prologueEnd, `
@@ -622,12 +607,7 @@ ${line}`);
622
607
  const known = /* @__PURE__ */ new Set([...this.doc.imports.flatMap((i) => [...i.names, ...i.defaultName ? [i.defaultName] : []]), ...this.added]);
623
608
  if (names.length && names.every((n) => known.has(n))) return;
624
609
  for (const n of names) this.added.add(n);
625
- const last = this.doc.imports[this.doc.imports.length - 1];
626
- if (last) this.s.appendLeft(last.end, `
627
- ${statement}`);
628
- else if (this.doc.prologueEnd) this.s.appendLeft(this.doc.prologueEnd, `
629
- ${statement}`);
630
- else this.prependImport(statement);
610
+ this.addImportLine(statement);
631
611
  }
632
612
  importsPrepended = false;
633
613
  /** First imports of a file: in order, with a blank line before the code. */
@@ -683,6 +663,43 @@ function componentNamesIn(jsx) {
683
663
  });
684
664
  return [...names];
685
665
  }
666
+ function withMarker(code, node) {
667
+ if (node.kind !== "component" && node.kind !== "element") return code;
668
+ const m = /^<[\w.:-]+/.exec(code);
669
+ if (!m) return code;
670
+ let i = m[0].length;
671
+ if (code[i] === "<") {
672
+ for (let depth = 0; i < code.length; i++) {
673
+ if (code[i] === "<") depth++;
674
+ else if (code[i] === ">" && --depth === 0) {
675
+ i++;
676
+ break;
677
+ }
678
+ }
679
+ }
680
+ return `${code.slice(0, i)} __tcsel${code.slice(i)}`;
681
+ }
682
+ function literalLines(code) {
683
+ const out = /* @__PURE__ */ new Set();
684
+ if (!code.includes("\n") || !/[`"']/.test(code)) return out;
685
+ let ast;
686
+ try {
687
+ ast = parseModule(`<>${code}</>`);
688
+ } catch {
689
+ return out;
690
+ }
691
+ const ranges = [];
692
+ walk(ast, (n) => {
693
+ if (n.type === "TemplateLiteral" || n.type === "StringLiteral") ranges.push([n.start - 2, n.end - 2]);
694
+ });
695
+ if (!ranges.length) return out;
696
+ let offset = 0;
697
+ code.split("\n").forEach((line, i) => {
698
+ if (i > 0 && ranges.some(([a, b]) => offset > a && offset < b)) out.add(i);
699
+ offset += line.length + 1;
700
+ });
701
+ return out;
702
+ }
686
703
  function parseJsx(jsx) {
687
704
  try {
688
705
  return parseModule(`<>${jsx}</>`);
@@ -724,6 +741,237 @@ function findDevice(id) {
724
741
  return id ? DEVICES.find((d) => d.id === id) : void 0;
725
742
  }
726
743
 
744
+ // src/core/scaffold.ts
745
+ import fs from "fs";
746
+ import path from "path";
747
+ var CANVAS_SUFFIX = ".canvas.tsx";
748
+ function canvasFiles(config) {
749
+ const dir = path.join(config.root, config.canvasDir);
750
+ if (!fs.existsSync(dir)) return [];
751
+ return fs.readdirSync(dir).filter((f) => f.endsWith(CANVAS_SUFFIX)).sort((a, b) => canvasNameOf(a).localeCompare(canvasNameOf(b)));
752
+ }
753
+ function canvasNameOf(file) {
754
+ return path.basename(file).slice(0, -CANVAS_SUFFIX.length);
755
+ }
756
+ function assertCanvasName(name) {
757
+ if (!/^[a-z0-9][a-z0-9-_]*$/i.test(name)) throw new Error(`Invalid canvas name "${name}". Use letters, digits, - and _.`);
758
+ return name;
759
+ }
760
+ function canvasPath(config, name) {
761
+ assertCanvasName(name);
762
+ return path.join(config.root, config.canvasDir, `${name}${CANVAS_SUFFIX}`);
763
+ }
764
+ function writeIfChanged(file, content) {
765
+ if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === content) return false;
766
+ fs.mkdirSync(path.dirname(file), { recursive: true });
767
+ fs.writeFileSync(file, content);
768
+ return true;
769
+ }
770
+ function routeDirOf(config) {
771
+ return path.join(config.root, config.routeDir);
772
+ }
773
+ function syncRoute(config) {
774
+ const routeDir = routeDirOf(config);
775
+ const registryFile = path.join(routeDir, "canvases.tsx");
776
+ const canvasDirAbs = path.join(config.root, config.canvasDir);
777
+ let rel = posix(path.relative(routeDir, canvasDirAbs));
778
+ if (!rel.startsWith(".")) rel = `./${rel}`;
779
+ const compareDir = path.join(routeDir, "compare");
780
+ const compares = fs.existsSync(compareDir) ? fs.readdirSync(compareDir).filter((f) => f.endsWith(CANVAS_SUFFIX)) : [];
781
+ const entries = [
782
+ ...canvasFiles(config).map((f) => ` ${JSON.stringify(canvasNameOf(f))}: lazy(() => import(${JSON.stringify(`${rel}/${f.replace(/\.tsx$/, "")}`)})),`),
783
+ // older versions rendered for Compare
784
+ ...compares.map((f) => ` ${JSON.stringify(`__compare__${canvasNameOf(f)}`)}: lazy(() => import(${JSON.stringify(`./compare/${f.replace(/\.tsx$/, "")}`)})),`)
785
+ ].join("\n");
786
+ const registry = `"use client";
787
+ /* eslint-disable */
788
+ // Generated by Truecanvas. Do not edit: it is regenerated when canvases are added or removed.
789
+ import { lazy } from "react";
790
+ import { TruecanvasHost, type HostProps } from "truecanvas/host";
791
+ import { components } from "./components";
792
+
793
+ const canvases = {
794
+ ${entries}
795
+ };
796
+
797
+ export default function Canvases(props: Omit<HostProps, "canvases" | "components">) {
798
+ return <TruecanvasHost {...props} canvases={canvases} components={components} />;
799
+ }
800
+ `;
801
+ const dm = config.darkMode;
802
+ const componentsFile = path.join(routeDir, "components.tsx");
803
+ if (!fs.existsSync(componentsFile)) writeIfChanged(componentsFile, componentRegistry(config, []));
804
+ const a = writeIfChanged(registryFile, registry);
805
+ if (config.framework === "vite") return writeIfChanged(path.join(routeDir, "entry.tsx"), viteEntry(config)) || a;
806
+ const page = `/* eslint-disable */
807
+ // Generated by Truecanvas. Dev-only route that renders canvas frames.
808
+ import { notFound } from "next/navigation";
809
+ import Canvases from "../canvases";
810
+
811
+ export const dynamic = "force-dynamic";
812
+
813
+ export default async function TruecanvasFrame({
814
+ params,
815
+ searchParams,
816
+ }: {
817
+ params: Promise<{ canvas: string }>;
818
+ searchParams: Promise<Record<string, string | undefined>>;
819
+ }) {
820
+ if (process.env.NODE_ENV !== "development") notFound();
821
+ const { canvas } = await params;
822
+ const query = await searchParams;
823
+ return (
824
+ <Canvases
825
+ canvas={canvas}
826
+ frame={query.frame ?? null}
827
+ theme={(query.theme as "light" | "dark" | "system" | undefined) ?? "system"}
828
+ darkMode={${JSON.stringify(dm)}}
829
+ />
830
+ );
831
+ }
832
+ `;
833
+ return writeIfChanged(path.join(routeDir, "[canvas]", "page.tsx"), page) || a;
834
+ }
835
+ function viteEntry(config) {
836
+ const css = (config.css ?? viteStylesheets(config.root)).map((f) => f.startsWith("/") ? f : f.startsWith("./") ? f.slice(1) : fs.existsSync(path.join(config.root, f)) ? `/${f}` : f);
837
+ return `/* eslint-disable */
838
+ // Generated by Truecanvas. Dev-only entry that renders canvas frames at /truecanvas/<canvas>.
839
+ ${css.map((f) => `import ${JSON.stringify(f)};`).join("\n")}
840
+ import { createRoot } from "react-dom/client";
841
+ import Canvases from "./canvases";
842
+
843
+ const query = new URLSearchParams(location.search);
844
+ const canvas = decodeURIComponent(location.pathname.split("/")[2] ?? "");
845
+
846
+ createRoot(document.getElementById("tc-root")!).render(
847
+ <Canvases
848
+ canvas={canvas}
849
+ frame={query.get("frame")}
850
+ theme={(query.get("theme") as "light" | "dark" | "system" | null) ?? "system"}
851
+ darkMode={${JSON.stringify(config.darkMode)}}
852
+ />,
853
+ );
854
+ `;
855
+ }
856
+ function viteStylesheets(root) {
857
+ let html = "";
858
+ try {
859
+ html = fs.readFileSync(path.join(root, "index.html"), "utf8");
860
+ } catch {
861
+ return [];
862
+ }
863
+ const src = /<script[^>]*type=["']module["'][^>]*src=["']([^"']+)["']/i.exec(html)?.[1] ?? /<script[^>]*src=["']([^"']+)["'][^>]*type=["']module["']/i.exec(html)?.[1];
864
+ if (!src || /^https?:/.test(src)) return [];
865
+ const entry = path.join(root, src.replace(/^\//, ""));
866
+ let code = "";
867
+ try {
868
+ code = fs.readFileSync(entry, "utf8");
869
+ } catch {
870
+ return [];
871
+ }
872
+ const out = [];
873
+ for (const m of code.matchAll(/^\s*import\s+["']([^"']+\.(?:css|scss|sass|less|styl|pcss))["']/gm)) {
874
+ const spec = m[1];
875
+ out.push(spec.startsWith(".") ? `/${posix(path.relative(root, path.resolve(path.dirname(entry), spec)))}` : spec);
876
+ }
877
+ return out;
878
+ }
879
+ var STARTER_CANVAS = `"use client";
880
+ import { Canvas, Frame } from "truecanvas";
881
+
882
+ /*
883
+ * A placeholder page. Replace the Welcome frame with your own components:
884
+ * drag them from Assets, draw frames with F, or ask your agent over MCP.
885
+ * Inline styles keep it readable in any app, with or without Tailwind.
886
+ */
887
+ export default function StarterCanvas() {
888
+ return (
889
+ <Canvas>
890
+ <Frame name="Welcome" x={0} y={0} width={1200} height={760}>
891
+ <div
892
+ style={{
893
+ height: "100%",
894
+ display: "flex",
895
+ alignItems: "center",
896
+ justifyContent: "center",
897
+ fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
898
+ color: "light-dark(#1d1d1b, #ededea)",
899
+ backgroundColor: "light-dark(#f6f5f2, #121211)",
900
+ backgroundImage: "radial-gradient(light-dark(rgba(0,0,0,.07), rgba(255,255,255,.06)) 1px, transparent 1px), radial-gradient(circle at 50% 38%, rgba(255,138,92,.16), transparent 55%)",
901
+ backgroundSize: "22px 22px, 100% 100%",
902
+ }}
903
+ >
904
+ <div
905
+ style={{
906
+ width: 520,
907
+ padding: 40,
908
+ borderRadius: 24,
909
+ backgroundColor: "light-dark(#ffffff, #1b1b1a)",
910
+ border: "1px solid light-dark(#e9e8e4, #2a2a28)",
911
+ boxShadow: "0 1px 2px rgba(0,0,0,.04), 0 24px 60px -24px rgba(0,0,0,.25)",
912
+ }}
913
+ >
914
+ <svg width="44" height="44" viewBox="0 0 64 64" aria-hidden>
915
+ <defs>
916
+ <linearGradient id="tc-starter" x1="0" y1="0" x2="0" y2="1">
917
+ <stop offset="0" stopColor="#ff9466" />
918
+ <stop offset="1" stopColor="#ea6a3c" />
919
+ </linearGradient>
920
+ </defs>
921
+ <rect width="64" height="64" rx="15" fill="url(#tc-starter)" />
922
+ <path fill="#f6f5f1" fillRule="evenodd" d="M10.5 10.5h43v13.1h-43z M25.5 16.8h13v36.7h-13z" />
923
+ </svg>
924
+ <h1 style={{ margin: "22px 0 6px", fontSize: 26, lineHeight: "32px", fontWeight: 650, letterSpacing: "-0.02em" }}>Your canvas is ready</h1>
925
+ <p style={{ margin: 0, fontSize: 15, lineHeight: "23px", color: "light-dark(#6b6b66, #a3a39d)" }}>
926
+ Everything here is real code from your app. Replace this frame with your own components.
927
+ </p>
928
+ <div style={{ marginTop: 28, display: "flex", flexDirection: "column", gap: 14 }}>
929
+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
930
+ <span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, backgroundColor: "light-dark(#f3f2ee, #262624)", border: "1px solid light-dark(#e3e1dc, #34342f)" }}>Assets</span>
931
+ <span>Drag a component from your design system onto a frame</span>
932
+ </div>
933
+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
934
+ <span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, backgroundColor: "light-dark(#f3f2ee, #262624)", border: "1px solid light-dark(#e3e1dc, #34342f)" }}>F</span>
935
+ <span>Draw a frame, or pick an iPhone or desktop size</span>
936
+ </div>
937
+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
938
+ <span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, color: "#fff", backgroundColor: "#ee7445", border: "1px solid #e0652f" }}>Agent</span>
939
+ <span>Ask: \u201CBuild a pricing page with our components\u201D</span>
940
+ </div>
941
+ </div>
942
+ </div>
943
+ </div>
944
+ </Frame>
945
+ </Canvas>
946
+ );
947
+ }
948
+ `;
949
+ function componentRegistry(config, specs) {
950
+ const routeDir = routeDirOf(config);
951
+ const lines = specs.map((c) => {
952
+ let from = c.library;
953
+ if (!from) {
954
+ from = posix(path.relative(routeDir, path.join(config.root, c.file))).replace(/\.(tsx|ts|jsx|js)$/, "");
955
+ if (!from.startsWith(".")) from = `./${from}`;
956
+ }
957
+ return ` ${JSON.stringify(c.name)}: lazy(() => import(${JSON.stringify(from)}).then((m) => ({ default: m[${JSON.stringify(c.name)}] }))),`;
958
+ });
959
+ return `"use client";
960
+ /* eslint-disable */
961
+ // Generated by Truecanvas. Do not edit: it is regenerated when components change.
962
+ import { lazy, type ComponentType } from "react";
963
+
964
+ // any: components with required props are rendered with whatever the preview passes
965
+ export const components: Record<string, ComponentType<any>> = {
966
+ ${lines.join("\n")}
967
+ };
968
+ `;
969
+ }
970
+ function syncComponentRegistry(config, specs) {
971
+ const file = path.join(routeDirOf(config), "components.tsx");
972
+ return writeIfChanged(file, componentRegistry(config, specs));
973
+ }
974
+
727
975
  // src/core/comments.ts
728
976
  import fs2 from "fs";
729
977
  import path2 from "path";
@@ -734,7 +982,7 @@ var Comments = class {
734
982
  }
735
983
  config;
736
984
  file(canvas) {
737
- return path2.join(this.config.root, this.config.canvasDir, `${canvas}.comments.json`);
985
+ return path2.join(this.config.root, this.config.canvasDir, `${assertCanvasName(canvas)}.comments.json`);
738
986
  }
739
987
  list(canvas) {
740
988
  try {
@@ -755,8 +1003,9 @@ var Comments = class {
755
1003
  }
756
1004
  update(canvas, id, fn) {
757
1005
  const threads = this.list(canvas);
758
- const t = threads.find((x) => x.id === id || x.id.startsWith(id));
759
- if (!t) throw new Error(`No comment thread "${id}".`);
1006
+ const prefixed = id.length >= 4 ? threads.filter((x) => x.id.startsWith(id)) : [];
1007
+ const t = threads.find((x) => x.id === id) ?? (prefixed.length === 1 ? prefixed[0] : void 0);
1008
+ if (!t) throw new Error(prefixed.length > 1 ? `"${id}" matches several comment threads. Use the full id.` : `No comment thread "${id}".`);
760
1009
  fn(t);
761
1010
  this.save(canvas, threads);
762
1011
  return t;
@@ -833,6 +1082,10 @@ function run(cmd, cwd, args, timeout, opts = {}) {
833
1082
  if (opts.input !== void 0) child.stdin?.end(opts.input);
834
1083
  });
835
1084
  }
1085
+ function safeRef(ref) {
1086
+ if (!ref || ref.startsWith("-") || !/^[\w./~^@{}+-]+$/.test(ref) || ref.includes("..")) throw new GitError(`Invalid git ref "${ref}".`);
1087
+ return ref;
1088
+ }
836
1089
  var Git = class {
837
1090
  constructor(cwd) {
838
1091
  this.cwd = cwd;
@@ -845,9 +1098,12 @@ var Git = class {
845
1098
  } catch {
846
1099
  return { repo: false, root: null, branch: null, unborn: false, upstream: null, ahead: 0, behind: 0, files: [], user: null, defaultBranch: null, remote: null };
847
1100
  }
848
- const out = await git(this.cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=all", "--", "."]);
1101
+ const out = await git(this.cwd, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--", "."]);
1102
+ const prefix = await this.prefix();
849
1103
  const s = { repo: true, root, branch: null, unborn: false, upstream: null, ahead: 0, behind: 0, files: [], user: null, defaultBranch: null, remote: null };
850
- for (const line of out.split("\n")) {
1104
+ const entries = out.split("\0");
1105
+ for (let i = 0; i < entries.length; i++) {
1106
+ const line = entries[i];
851
1107
  if (line.startsWith("# branch.head ")) s.branch = line.slice(14) === "(detached)" ? null : line.slice(14);
852
1108
  else if (line.startsWith("# branch.oid ")) s.unborn = line.slice(13) === "(initial)";
853
1109
  else if (line.startsWith("# branch.upstream ")) s.upstream = line.slice(18);
@@ -857,10 +1113,13 @@ var Git = class {
857
1113
  } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
858
1114
  const parts = line.split(" ");
859
1115
  const xy = parts[1];
860
- const file = line.startsWith("2 ") ? line.split(" ")[0].split(" ").slice(9).join(" ") : parts.slice(8).join(" ");
1116
+ const file = parts.slice(line.startsWith("2 ") ? 9 : 8).join(" ");
1117
+ if (line.startsWith("2 ")) i++;
861
1118
  const code = xy.replace(".", "")[0] ?? "M";
862
- s.files.push({ path: this.rel(root, file), status: code === "A" ? "added" : code === "D" ? "deleted" : code === "R" ? "renamed" : "modified", staged: xy[0] !== "." });
863
- } else if (line.startsWith("? ")) s.files.push({ path: this.rel(root, line.slice(2)), status: "untracked", staged: false });
1119
+ s.files.push({ path: this.rel(prefix, file), status: code === "A" ? "added" : code === "D" ? "deleted" : code === "R" ? "renamed" : "modified", staged: xy[0] !== "." });
1120
+ } else if (line.startsWith("u ")) {
1121
+ s.files.push({ path: this.rel(prefix, line.split(" ").slice(10).join(" ")), status: "conflicted", staged: false });
1122
+ } else if (line.startsWith("? ")) s.files.push({ path: this.rel(prefix, line.slice(2)), status: "untracked", staged: false });
864
1123
  }
865
1124
  s.user = (await git(this.cwd, ["config", "user.name"]).catch(() => "")).trim() || null;
866
1125
  s.remote = (await git(this.cwd, ["remote", "get-url", "origin"]).catch(() => "")).trim() || null;
@@ -881,11 +1140,12 @@ var Git = class {
881
1140
  }
882
1141
  /** When a commit was made (ms). */
883
1142
  async commitAt(ref) {
884
- const out = await git(this.cwd, ["log", "-1", "--format=%ct", ref]).catch(() => "");
1143
+ const out = await git(this.cwd, ["log", "-1", "--format=%ct", safeRef(ref), "--"]).catch(() => "");
885
1144
  return Number(out.trim() || 0) * 1e3;
886
1145
  }
887
1146
  /** Commit where this branch left `base`. */
888
1147
  async mergeBase(base) {
1148
+ safeRef(base);
889
1149
  for (const ref of [`origin/${base}`, base]) {
890
1150
  const out = await git(this.cwd, ["merge-base", ref, "HEAD"]).catch(() => "");
891
1151
  if (out.trim()) return out.trim();
@@ -894,7 +1154,7 @@ var Git = class {
894
1154
  }
895
1155
  /** Files changed between a commit and HEAD, from the project root. */
896
1156
  async changedSince(ref) {
897
- const out = await git(this.cwd, ["diff", "--name-only", "--relative", `${ref}...HEAD`]).catch(() => "");
1157
+ const out = await git(this.cwd, ["diff", "--name-only", "--relative", `${safeRef(ref)}...HEAD`, "--"]).catch(() => "");
898
1158
  return out.split("\n").filter(Boolean);
899
1159
  }
900
1160
  /** Unified diff of these files against HEAD, including new files. */
@@ -911,9 +1171,10 @@ var Git = class {
911
1171
  * (used for PR screenshots), and pushes it. Returns the branch tip.
912
1172
  */
913
1173
  async commitToSideBranch(branch, files, message) {
1174
+ safeRef(branch);
914
1175
  const tip = (await git(this.cwd, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]).catch(() => "")).trim() || (await git(this.cwd, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`]).catch(() => "")).trim();
915
1176
  const os = await import("os");
916
- const fs11 = await import("fs");
1177
+ const fs13 = await import("fs");
917
1178
  const index = path3.join(os.tmpdir(), `truecanvas-index-${process.pid}-${Date.now()}`);
918
1179
  const env = { GIT_INDEX_FILE: index };
919
1180
  try {
@@ -928,7 +1189,7 @@ var Git = class {
928
1189
  await git(this.cwd, ["update-ref", `refs/heads/${branch}`, commit]);
929
1190
  await git(this.cwd, ["push", "origin", `refs/heads/${branch}:refs/heads/${branch}`], 12e4);
930
1191
  } finally {
931
- fs11.rmSync(index, { force: true });
1192
+ fs13.rmSync(index, { force: true });
932
1193
  }
933
1194
  }
934
1195
  // ---------- GitHub (gh CLI) ----------
@@ -952,7 +1213,7 @@ var Git = class {
952
1213
  return { number: pr.number, url: pr.url, title: pr.title, state: pr.state, draft: pr.isDraft, review: pr.reviewDecision || null, checks };
953
1214
  }
954
1215
  async createPullRequest(opts) {
955
- const out = await run("gh", this.cwd, ["pr", "create", "--title", opts.title, "--body-file", "-", "--base", opts.base, ...opts.draft ? ["--draft"] : []], 12e4, { input: opts.body });
1216
+ const out = await run("gh", this.cwd, ["pr", "create", "--title", opts.title, "--body-file", "-", "--base", safeRef(opts.base), ...opts.draft ? ["--draft"] : []], 12e4, { input: opts.body });
956
1217
  return out.trim().split("\n").pop() ?? "";
957
1218
  }
958
1219
  /** Creates a GitHub repo for this project and pushes it (gh repo create). */
@@ -962,8 +1223,16 @@ var Git = class {
962
1223
  return (await git(this.cwd, ["remote", "get-url", "origin"])).trim();
963
1224
  }
964
1225
  /** git reports paths from the repo root; we show them from the project root. */
965
- rel(root, file) {
966
- return path3.relative(this.cwd, path3.join(root, file));
1226
+ rel(prefix, file) {
1227
+ return file.startsWith(prefix) ? file.slice(prefix.length) : posix(path3.posix.relative(prefix, file));
1228
+ }
1229
+ /**
1230
+ * The project folder inside the repo ("apps/web/", or "" at the root), as git
1231
+ * itself sees it. Asking git instead of comparing file system paths holds up
1232
+ * through symlinks (macOS /var → /private/var), junctions and Windows short names.
1233
+ */
1234
+ async prefix() {
1235
+ return (await git(this.cwd, ["rev-parse", "--show-prefix"]).catch(() => "")).trim();
967
1236
  }
968
1237
  async branches() {
969
1238
  const out = await git(this.cwd, ["branch", "-a", "--format=%(refname)%09%(HEAD)"]).catch(() => "");
@@ -991,9 +1260,8 @@ var Git = class {
991
1260
  }
992
1261
  /** A file's content at a ref, or null if it doesn't exist there. */
993
1262
  async show(ref, file) {
994
- const root = (await git(this.cwd, ["rev-parse", "--show-toplevel"])).trim();
995
- const inRepo = path3.relative(root, path3.join(this.cwd, file)).split(path3.sep).join("/");
996
- return git(this.cwd, ["show", `${ref}:${inRepo}`]).catch(() => null);
1263
+ const inRepo = path3.posix.normalize(`${await this.prefix()}${posix(file)}`);
1264
+ return git(this.cwd, ["show", `${safeRef(ref)}:${inRepo}`, "--"]).catch(() => null);
997
1265
  }
998
1266
  async commit(message, files) {
999
1267
  if (!message.trim()) throw new GitError("Write a commit message.");
@@ -1015,14 +1283,14 @@ var Git = class {
1015
1283
  });
1016
1284
  }
1017
1285
  async switch(branch, create = false) {
1018
- if (!/^[\w./-]+$/.test(branch) || branch.startsWith("-")) throw new GitError("Invalid branch name.");
1286
+ safeRef(branch);
1019
1287
  await git(this.cwd, create ? ["switch", "-c", branch] : ["switch", branch]);
1020
1288
  }
1021
1289
  };
1022
1290
 
1023
1291
  // src/core/workspace.ts
1024
- import fs8 from "fs";
1025
- import path9 from "path";
1292
+ import fs7 from "fs";
1293
+ import path8 from "path";
1026
1294
  import { createRequire } from "module";
1027
1295
 
1028
1296
  // src/core/link.ts
@@ -1243,6 +1511,7 @@ function parseTargetDoc(file, source) {
1243
1511
  var PAGE = /^page\.(tsx|jsx|ts|js)$/;
1244
1512
  var LAYOUT = ["layout.tsx", "layout.jsx", "layout.js", "layout.ts"];
1245
1513
  function listRoutes(config) {
1514
+ if (config.framework !== "next") return [];
1246
1515
  const appDir = path4.join(config.root, config.appDir);
1247
1516
  const out = [];
1248
1517
  const walkDir = (dir, segments) => {
@@ -1255,7 +1524,7 @@ function listRoutes(config) {
1255
1524
  for (const e of entries) {
1256
1525
  if (e.isFile() && PAGE.test(e.name)) {
1257
1526
  const visible = segments.filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith("@"));
1258
- out.push({ route: `/${visible.join("/")}`, file: path4.relative(config.root, path4.join(dir, e.name)), dynamic: segments.some((s) => s.startsWith("[")) });
1527
+ out.push({ route: `/${visible.join("/")}`, file: posix(path4.relative(config.root, path4.join(dir, e.name))), dynamic: segments.some((s) => s.startsWith("[")) });
1259
1528
  }
1260
1529
  if (e.isDirectory() && !e.name.startsWith("_") && e.name !== "truecanvas" && e.name !== "api" && e.name !== "node_modules") walkDir(path4.join(dir, e.name), [...segments, e.name]);
1261
1530
  }
@@ -1274,7 +1543,7 @@ function extract(file, canvasFile, config, layout) {
1274
1543
  if (/\.(css|scss|sass|less)$/.test(src)) continue;
1275
1544
  const from = repoint(config, src, file, canvasFile);
1276
1545
  const text = source.slice(stmt.start, stmt.end);
1277
- imports.push(from === src ? text : text.replace(JSON.stringify(src), JSON.stringify(from)).replace(`'${src}'`, `'${from}'`));
1546
+ imports.push(from === src ? text : rewriteImportSource(text, src, from));
1278
1547
  }
1279
1548
  return { jsx: source.slice(info.node.start, info.node.end), imports };
1280
1549
  }
@@ -1287,7 +1556,7 @@ function layoutFiles(config, pageFile) {
1287
1556
  const out = [];
1288
1557
  for (let dir = path4.dirname(path4.resolve(config.root, pageFile)); dir.startsWith(appDir) && dir !== appDir; dir = path4.dirname(dir)) {
1289
1558
  const file = LAYOUT.map((f) => path4.join(dir, f)).find((f) => fs3.existsSync(f));
1290
- if (file) out.push(path4.relative(config.root, file));
1559
+ if (file) out.push(posix(path4.relative(config.root, file)));
1291
1560
  }
1292
1561
  return out;
1293
1562
  }
@@ -1319,7 +1588,8 @@ function routeFrame(config, pageFile, canvasFile) {
1319
1588
  const layout = extract(file, canvasAbs, config, true);
1320
1589
  const holes = layout ? (layout.jsx.match(/\{\s*children\s*\}/g) ?? []).length : 0;
1321
1590
  if (layout && holes === 1) {
1322
- jsx = layout.jsx.replace(/\{\s*children\s*\}/, jsx.startsWith("<>") ? jsx.slice(2, -3) : jsx);
1591
+ const inner = jsx.startsWith("<>") ? jsx.slice(2, -3) : jsx;
1592
+ jsx = layout.jsx.replace(/\{\s*children\s*\}/, () => inner);
1323
1593
  imports.push(...layout.imports);
1324
1594
  } else {
1325
1595
  const local = `RouteLayout${i++ || ""}`;
@@ -1349,6 +1619,18 @@ function readSource(config, rel) {
1349
1619
  return null;
1350
1620
  }
1351
1621
  }
1622
+ var parseMemo = /* @__PURE__ */ new Map();
1623
+ function memoParse(key, source, parse) {
1624
+ let hit = parseMemo.get(key);
1625
+ if (!hit || hit.source !== source) {
1626
+ hit = { source, doc: parse() };
1627
+ parseMemo.set(key, hit);
1628
+ }
1629
+ return hit.doc && { ...hit.doc, frames: hit.doc.frames.map(cloneTree) };
1630
+ }
1631
+ function cloneTree(n) {
1632
+ return { ...n, children: n.children.map(cloneTree) };
1633
+ }
1352
1634
  function resolveLinks(config, doc) {
1353
1635
  if (!doc.frames.some((f) => f.page || f.component)) return doc;
1354
1636
  return { ...doc, frames: doc.frames.map((f) => f.component ? linkComponent(config, f) : f.page ? linkFrame(config, f) : f) };
@@ -1361,7 +1643,7 @@ function linkComponent(config, original) {
1361
1643
  const frame = { ...original };
1362
1644
  const { file, name } = splitComponentRef(frame.component);
1363
1645
  const source = readSource(config, file);
1364
- const doc = source === null ? null : parseModuleDoc(file, source);
1646
+ const doc = source === null ? null : memoParse(`module:${file}`, source, () => parseModuleDoc(file, source));
1365
1647
  const own = doc?.frames.find((f) => f.frameName === name);
1366
1648
  if (!own) {
1367
1649
  frame.link = { kind: "component", page: file, route: name, files: [], editable: false, reason: source === null ? `${file} doesn't exist anymore.` : `${file} has no exported component ${name} returning JSX.` };
@@ -1384,7 +1666,7 @@ function linkFrame(config, original) {
1384
1666
  const chain = wrapperChain(original);
1385
1667
  const files = [];
1386
1668
  let current;
1387
- const pageDoc = parseSourceDoc(page, pageSource, false);
1669
+ const pageDoc = memoParse(`page:${page}`, pageSource, () => parseSourceDoc(page, pageSource, false));
1388
1670
  if (pageDoc) {
1389
1671
  current = flatten(pageDoc.frames[0].children[0]);
1390
1672
  files.push(page);
@@ -1394,7 +1676,7 @@ function linkFrame(config, original) {
1394
1676
  const layouts = layoutFiles(config, page);
1395
1677
  layouts.forEach((rel, i) => {
1396
1678
  const src = readSource(config, rel);
1397
- const ldoc = src === null ? null : parseSourceDoc(rel, src, true);
1679
+ const ldoc = src === null ? null : memoParse(`layout:${rel}`, src, () => parseSourceDoc(rel, src, true));
1398
1680
  const root = ldoc?.frames[0].children[0];
1399
1681
  const slot = root && findSlot(root);
1400
1682
  if (root && slot) {
@@ -1479,7 +1761,7 @@ function explorationBody(config, frame, canvasRel) {
1479
1761
  const src = stmt.source.value;
1480
1762
  const from = repoint(config, src, pageAbs, path5.join(config.root, canvasRel));
1481
1763
  const text = source.slice(stmt.start, stmt.end);
1482
- imports.push(from === src ? text : text.replace(JSON.stringify(src), JSON.stringify(from)).replace(`'${src}'`, `'${from}'`));
1764
+ imports.push(from === src ? text : rewriteImportSource(text, src, from));
1483
1765
  }
1484
1766
  jsx = unwrapFragment(source.slice(info.node.start, info.node.end));
1485
1767
  } else {
@@ -1778,8 +2060,8 @@ export function TextAnimate({ effect = "words", stagger = 0.06, delay = 0, durat
1778
2060
  function motionFiles(config) {
1779
2061
  const glob = config.components[0] ?? "components/**/*.tsx";
1780
2062
  const base = glob.split("/").filter((p) => !p.includes("*")).join("/") || "components";
1781
- const dir = path6.join(base, "motion");
1782
- return { reveal: path6.join(dir, "reveal.tsx"), text: path6.join(dir, "text-animate.tsx") };
2063
+ const dir = `${base}/motion`;
2064
+ return { reveal: `${dir}/reveal.tsx`, text: `${dir}/text-animate.tsx` };
1783
2065
  }
1784
2066
  function ensureMotionComponents(config) {
1785
2067
  const files = motionFiles(config);
@@ -1891,193 +2173,11 @@ ${indentBlock(indentBlock(jsx.trim()))}
1891
2173
  `;
1892
2174
  }
1893
2175
  function newComponentPath(config, name) {
1894
- const rel = path7.join(componentsDir(config), `${kebab(name)}.tsx`);
2176
+ const rel = `${componentsDir(config)}/${kebab(name)}.tsx`;
1895
2177
  if (fs6.existsSync(path7.join(config.root, rel))) throw new EditError(`${rel} already exists. Pick another name.`);
1896
2178
  return rel;
1897
2179
  }
1898
2180
 
1899
- // src/core/scaffold.ts
1900
- import fs7 from "fs";
1901
- import path8 from "path";
1902
- var CANVAS_SUFFIX = ".canvas.tsx";
1903
- var ROUTE = "truecanvas";
1904
- function canvasFiles(config) {
1905
- const dir = path8.join(config.root, config.canvasDir);
1906
- if (!fs7.existsSync(dir)) return [];
1907
- return fs7.readdirSync(dir).filter((f) => f.endsWith(CANVAS_SUFFIX)).sort((a, b) => canvasNameOf(a).localeCompare(canvasNameOf(b)));
1908
- }
1909
- function canvasNameOf(file) {
1910
- return path8.basename(file).slice(0, -CANVAS_SUFFIX.length);
1911
- }
1912
- function canvasPath(config, name) {
1913
- if (!/^[a-z0-9][a-z0-9-_]*$/i.test(name)) throw new Error(`Invalid canvas name "${name}". Use letters, digits, - and _.`);
1914
- return path8.join(config.root, config.canvasDir, `${name}${CANVAS_SUFFIX}`);
1915
- }
1916
- function writeIfChanged(file, content) {
1917
- if (fs7.existsSync(file) && fs7.readFileSync(file, "utf8") === content) return false;
1918
- fs7.mkdirSync(path8.dirname(file), { recursive: true });
1919
- fs7.writeFileSync(file, content);
1920
- return true;
1921
- }
1922
- function syncRoute(config) {
1923
- const routeDir = path8.join(config.root, config.appDir, ROUTE);
1924
- const pageFile = path8.join(routeDir, "[canvas]", "page.tsx");
1925
- const registryFile = path8.join(routeDir, "canvases.tsx");
1926
- const canvasDirAbs = path8.join(config.root, config.canvasDir);
1927
- let rel = path8.relative(routeDir, canvasDirAbs);
1928
- if (!rel.startsWith(".")) rel = `./${rel}`;
1929
- const compareDir = path8.join(routeDir, "compare");
1930
- const compares = fs7.existsSync(compareDir) ? fs7.readdirSync(compareDir).filter((f) => f.endsWith(CANVAS_SUFFIX)) : [];
1931
- const entries = [
1932
- ...canvasFiles(config).map((f) => ` ${JSON.stringify(canvasNameOf(f))}: lazy(() => import(${JSON.stringify(`${rel}/${f.replace(/\.tsx$/, "")}`)})),`),
1933
- // older versions rendered for Compare
1934
- ...compares.map((f) => ` ${JSON.stringify(`__compare__${canvasNameOf(f)}`)}: lazy(() => import(${JSON.stringify(`./compare/${f.replace(/\.tsx$/, "")}`)})),`)
1935
- ].join("\n");
1936
- const registry = `"use client";
1937
- /* eslint-disable */
1938
- // Generated by Truecanvas. Do not edit: it is regenerated when canvases are added or removed.
1939
- import { lazy } from "react";
1940
- import { TruecanvasHost, type HostProps } from "truecanvas/host";
1941
- import { components } from "./components";
1942
-
1943
- const canvases = {
1944
- ${entries}
1945
- };
1946
-
1947
- export default function Canvases(props: Omit<HostProps, "canvases" | "components">) {
1948
- return <TruecanvasHost {...props} canvases={canvases} components={components} />;
1949
- }
1950
- `;
1951
- const dm = config.darkMode;
1952
- const page = `/* eslint-disable */
1953
- // Generated by Truecanvas. Dev-only route that renders canvas frames.
1954
- import { notFound } from "next/navigation";
1955
- import Canvases from "../canvases";
1956
-
1957
- export const dynamic = "force-dynamic";
1958
-
1959
- export default async function TruecanvasFrame({
1960
- params,
1961
- searchParams,
1962
- }: {
1963
- params: Promise<{ canvas: string }>;
1964
- searchParams: Promise<Record<string, string | undefined>>;
1965
- }) {
1966
- if (process.env.NODE_ENV !== "development") notFound();
1967
- const { canvas } = await params;
1968
- const query = await searchParams;
1969
- return (
1970
- <Canvases
1971
- canvas={canvas}
1972
- frame={query.frame ?? null}
1973
- theme={(query.theme as "light" | "dark" | "system" | undefined) ?? "system"}
1974
- darkMode={${JSON.stringify(dm)}}
1975
- />
1976
- );
1977
- }
1978
- `;
1979
- const componentsFile = path8.join(routeDir, "components.tsx");
1980
- if (!fs7.existsSync(componentsFile)) writeIfChanged(componentsFile, componentRegistry(config, []));
1981
- const a = writeIfChanged(registryFile, registry);
1982
- const b = writeIfChanged(pageFile, page);
1983
- return a || b;
1984
- }
1985
- var STARTER_CANVAS = `"use client";
1986
- import { Canvas, Frame } from "truecanvas";
1987
-
1988
- /*
1989
- * A placeholder page. Replace the Welcome frame with your own components:
1990
- * drag them from Assets, draw frames with F, or ask your agent over MCP.
1991
- * Inline styles keep it readable in any app, with or without Tailwind.
1992
- */
1993
- export default function StarterCanvas() {
1994
- return (
1995
- <Canvas>
1996
- <Frame name="Welcome" x={0} y={0} width={1200} height={760}>
1997
- <div
1998
- style={{
1999
- height: "100%",
2000
- display: "flex",
2001
- alignItems: "center",
2002
- justifyContent: "center",
2003
- fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
2004
- color: "light-dark(#1d1d1b, #ededea)",
2005
- backgroundColor: "light-dark(#f6f5f2, #121211)",
2006
- backgroundImage: "radial-gradient(light-dark(rgba(0,0,0,.07), rgba(255,255,255,.06)) 1px, transparent 1px), radial-gradient(circle at 50% 38%, rgba(255,138,92,.16), transparent 55%)",
2007
- backgroundSize: "22px 22px, 100% 100%",
2008
- }}
2009
- >
2010
- <div
2011
- style={{
2012
- width: 520,
2013
- padding: 40,
2014
- borderRadius: 24,
2015
- backgroundColor: "light-dark(#ffffff, #1b1b1a)",
2016
- border: "1px solid light-dark(#e9e8e4, #2a2a28)",
2017
- boxShadow: "0 1px 2px rgba(0,0,0,.04), 0 24px 60px -24px rgba(0,0,0,.25)",
2018
- }}
2019
- >
2020
- <svg width="44" height="44" viewBox="0 0 64 64" aria-hidden>
2021
- <defs>
2022
- <linearGradient id="tc-starter" x1="0" y1="0" x2="0" y2="1">
2023
- <stop offset="0" stopColor="#ff9466" />
2024
- <stop offset="1" stopColor="#ea6a3c" />
2025
- </linearGradient>
2026
- </defs>
2027
- <rect width="64" height="64" rx="15" fill="url(#tc-starter)" />
2028
- <path fill="#f6f5f1" fillRule="evenodd" d="M10.5 10.5h43v13.1h-43z M25.5 16.8h13v36.7h-13z" />
2029
- </svg>
2030
- <h1 style={{ margin: "22px 0 6px", fontSize: 26, lineHeight: "32px", fontWeight: 650, letterSpacing: "-0.02em" }}>Your canvas is ready</h1>
2031
- <p style={{ margin: 0, fontSize: 15, lineHeight: "23px", color: "light-dark(#6b6b66, #a3a39d)" }}>
2032
- Everything here is real code from your app. Replace this frame with your own components.
2033
- </p>
2034
- <div style={{ marginTop: 28, display: "flex", flexDirection: "column", gap: 14 }}>
2035
- <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
2036
- <span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, backgroundColor: "light-dark(#f3f2ee, #262624)", border: "1px solid light-dark(#e3e1dc, #34342f)" }}>Assets</span>
2037
- <span>Drag a component from your design system onto a frame</span>
2038
- </div>
2039
- <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
2040
- <span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, backgroundColor: "light-dark(#f3f2ee, #262624)", border: "1px solid light-dark(#e3e1dc, #34342f)" }}>F</span>
2041
- <span>Draw a frame, or pick an iPhone or desktop size</span>
2042
- </div>
2043
- <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
2044
- <span style={{ minWidth: 58, textAlign: "center", padding: "3px 8px", borderRadius: 7, fontSize: 12, fontWeight: 600, color: "#fff", backgroundColor: "#ee7445", border: "1px solid #e0652f" }}>Agent</span>
2045
- <span>Ask: \u201CBuild a pricing page with our components\u201D</span>
2046
- </div>
2047
- </div>
2048
- </div>
2049
- </div>
2050
- </Frame>
2051
- </Canvas>
2052
- );
2053
- }
2054
- `;
2055
- function componentRegistry(config, specs) {
2056
- const routeDir = path8.join(config.root, config.appDir, ROUTE);
2057
- const lines = specs.map((c) => {
2058
- let from = c.library;
2059
- if (!from) {
2060
- from = path8.relative(routeDir, path8.join(config.root, c.file)).replace(/\.(tsx|ts|jsx|js)$/, "");
2061
- if (!from.startsWith(".")) from = `./${from}`;
2062
- }
2063
- return ` ${JSON.stringify(c.name)}: lazy(() => import(${JSON.stringify(from)}).then((m) => ({ default: m[${JSON.stringify(c.name)}] }))),`;
2064
- });
2065
- return `"use client";
2066
- /* eslint-disable */
2067
- // Generated by Truecanvas. Do not edit: it is regenerated when components change.
2068
- import { lazy, type ComponentType } from "react";
2069
-
2070
- // any: components with required props are rendered with whatever the preview passes
2071
- export const components: Record<string, ComponentType<any>> = {
2072
- ${lines.join("\n")}
2073
- };
2074
- `;
2075
- }
2076
- function syncComponentRegistry(config, specs) {
2077
- const file = path8.join(config.root, config.appDir, ROUTE, "components.tsx");
2078
- return writeIfChanged(file, componentRegistry(config, specs));
2079
- }
2080
-
2081
2181
  // src/core/workspace.ts
2082
2182
  var require2 = createRequire(import.meta.url);
2083
2183
  var MARK = "__tcsel";
@@ -2094,13 +2194,14 @@ var Workspace = class {
2094
2194
  this.catalog = new Catalog(config.root, config.components, config.libraries);
2095
2195
  this.comments = new Comments(config);
2096
2196
  this.git = new Git(config.root);
2097
- for (const c of this.canvases()) this.sources.set(c, fs8.readFileSync(this.file(c), "utf8"));
2197
+ for (const c of this.canvases()) this.sources.set(c, fs7.readFileSync(this.file(c), "utf8"));
2098
2198
  }
2099
2199
  config;
2100
2200
  catalog;
2101
2201
  sources = /* @__PURE__ */ new Map();
2102
2202
  undoStacks = /* @__PURE__ */ new Map();
2103
2203
  redoStacks = /* @__PURE__ */ new Map();
2204
+ /** Activity across canvases: labels only, never file copies. */
2104
2205
  feed = [];
2105
2206
  seq = 0;
2106
2207
  queue = Promise.resolve();
@@ -2124,29 +2225,52 @@ var Workspace = class {
2124
2225
  return canvasPath(this.config, canvas);
2125
2226
  }
2126
2227
  relFile(canvas) {
2127
- return path9.relative(this.config.root, this.file(canvas));
2228
+ return posix(path8.relative(this.config.root, this.file(canvas)));
2128
2229
  }
2129
2230
  read(canvas) {
2130
2231
  const file = this.file(canvas);
2131
- if (!fs8.existsSync(file)) throw new EditError(`Canvas "${canvas}" does not exist. Canvases: ${this.canvases().join(", ") || "none"}`);
2132
- const src = fs8.readFileSync(file, "utf8");
2232
+ if (!fs7.existsSync(file)) throw new EditError(`Canvas "${canvas}" does not exist. Canvases: ${this.canvases().join(", ") || "none"}`);
2233
+ const src = fs7.readFileSync(file, "utf8");
2133
2234
  if (!this.sources.has(canvas)) this.sources.set(canvas, src);
2134
2235
  return src;
2135
2236
  }
2136
2237
  doc(canvas) {
2137
2238
  return this.docFrom(canvas, this.read(canvas));
2138
2239
  }
2139
- /** A canvas doc with linked frames resolved to their page's layers. */
2240
+ /**
2241
+ * A canvas doc with linked frames resolved to their page's layers. Resolving
2242
+ * reads and parses every linked page and layout, so the result is reused
2243
+ * until the canvas source or one of those files changes (checked with stat).
2244
+ */
2140
2245
  docFrom(canvas, source) {
2141
- return resolveLinks(this.config, parseCanvas(canvas, this.relFile(canvas), source));
2246
+ const hit = this.docCache.get(canvas);
2247
+ if (hit && hit.source === source && hit.stamps.every(([rel, stamp]) => this.stamp(rel) === stamp)) return { ...hit.doc, rev: ++this.revision };
2248
+ const doc = resolveLinks(this.config, parseCanvas(canvas, this.relFile(canvas), source));
2249
+ const files = new Set(doc.frames.flatMap((f) => [...f.link?.files ?? [], ...f.page ? [f.page] : []]));
2250
+ this.docCache.set(canvas, { source, doc, stamps: [...files].map((rel) => [rel, this.stamp(rel)]) });
2251
+ return { ...doc, rev: ++this.revision };
2252
+ }
2253
+ /** a doc computed later reflects the files at least as recently: its rev is higher */
2254
+ revision = 0;
2255
+ docCache = /* @__PURE__ */ new Map();
2256
+ stamp(rel) {
2257
+ try {
2258
+ const st = fs7.statSync(path8.join(this.config.root, rel));
2259
+ return `${st.mtimeMs}:${st.size}`;
2260
+ } catch {
2261
+ return "missing";
2262
+ }
2142
2263
  }
2143
2264
  /** Where a node's code lives: the canvas, or a linked page/layout file. */
2144
2265
  nodeSource(canvas, id) {
2145
- const hit = findNode(this.doc(canvas), id);
2266
+ const doc = this.doc(canvas);
2267
+ const hit = findNode(doc, id);
2146
2268
  if (!hit) return null;
2147
- const rel = fileOfId(id) ?? this.relFile(canvas);
2148
- const abs = path9.join(this.config.root, rel);
2149
- return { node: hit.node, rel, abs, source: fs8.readFileSync(abs, "utf8") };
2269
+ const linked = fileOfId(id);
2270
+ if (linked) assertShown(doc, linked);
2271
+ const rel = linked ?? this.relFile(canvas);
2272
+ const abs = path8.join(this.config.root, rel);
2273
+ return { node: hit.node, rel, abs, source: fs7.readFileSync(abs, "utf8") };
2150
2274
  }
2151
2275
  /** Page and layout files shown by linked frames, across all canvases (the Git panel counts them as design files). */
2152
2276
  linkedFiles() {
@@ -2165,7 +2289,7 @@ var Workspace = class {
2165
2289
  */
2166
2290
  designFiles() {
2167
2291
  const m = motionFiles(this.config);
2168
- return [...this.linkedFiles(), ...[m.reveal, m.text].filter((f) => fs8.existsSync(path9.join(this.config.root, f)))];
2292
+ return [...this.linkedFiles(), ...[m.reveal, m.text].filter((f) => fs7.existsSync(path8.join(this.config.root, f)))];
2169
2293
  }
2170
2294
  /** Canvases with a linked frame showing this page or layout file. */
2171
2295
  canvasesLinking(rel) {
@@ -2181,20 +2305,21 @@ var Workspace = class {
2181
2305
  linkedFileChanged(rel) {
2182
2306
  let next;
2183
2307
  try {
2184
- next = fs8.readFileSync(path9.join(this.config.root, rel), "utf8");
2308
+ next = fs7.readFileSync(path8.join(this.config.root, rel), "utf8");
2185
2309
  } catch {
2186
2310
  next = "";
2187
2311
  }
2188
2312
  const key = `file:${rel}`;
2189
2313
  if (this.sources.get(key) === next) return;
2190
2314
  this.sources.set(key, next);
2315
+ this.docCache.clear();
2191
2316
  for (const c of this.canvasesLinking(rel)) this.emit({ type: "doc", canvas: c, doc: this.doc(c) });
2192
2317
  }
2193
2318
  createCanvas(name, actor, content = STARTER_CANVAS) {
2194
2319
  const file = canvasPath(this.config, name);
2195
- if (fs8.existsSync(file)) throw new EditError(`Canvas "${name}" already exists.`);
2196
- fs8.mkdirSync(path9.dirname(file), { recursive: true });
2197
- fs8.writeFileSync(file, content);
2320
+ if (fs7.existsSync(file)) throw new EditError(`Canvas "${name}" already exists.`);
2321
+ fs7.mkdirSync(path8.dirname(file), { recursive: true });
2322
+ fs7.writeFileSync(file, content);
2198
2323
  this.sources.set(name, content);
2199
2324
  this.canvasesChanged();
2200
2325
  this.record(name, `Created canvas ${name}`, actor, "", content, []);
@@ -2229,10 +2354,10 @@ var Workspace = class {
2229
2354
  }
2230
2355
  renameCanvas(from, to) {
2231
2356
  const src = this.file(from);
2232
- if (!fs8.existsSync(src)) throw new EditError(`Canvas "${from}" does not exist.`);
2357
+ if (!fs7.existsSync(src)) throw new EditError(`Canvas "${from}" does not exist.`);
2233
2358
  const dest = canvasPath(this.config, to);
2234
- if (fs8.existsSync(dest)) throw new EditError(`A canvas named "${to}" already exists.`);
2235
- fs8.renameSync(src, dest);
2359
+ if (fs7.existsSync(dest)) throw new EditError(`A canvas named "${to}" already exists.`);
2360
+ fs7.renameSync(src, dest);
2236
2361
  this.comments.renameCanvas(from, to);
2237
2362
  for (const map of [this.sources, this.undoStacks, this.redoStacks]) {
2238
2363
  if (map.has(from)) {
@@ -2249,7 +2374,7 @@ var Workspace = class {
2249
2374
  duplicateCanvas(name) {
2250
2375
  const source = this.read(name);
2251
2376
  const copy = this.uniqueCanvasName(`${name}-copy`);
2252
- fs8.writeFileSync(canvasPath(this.config, copy), source);
2377
+ fs7.writeFileSync(canvasPath(this.config, copy), source);
2253
2378
  this.sources.set(copy, source);
2254
2379
  this.canvasesChanged();
2255
2380
  return copy;
@@ -2258,17 +2383,19 @@ var Workspace = class {
2258
2383
  trash = /* @__PURE__ */ new Map();
2259
2384
  deleteCanvas(name) {
2260
2385
  const file = this.file(name);
2261
- if (!fs8.existsSync(file)) throw new EditError(`Canvas "${name}" does not exist.`);
2262
- this.trash.set(name, fs8.readFileSync(file, "utf8"));
2263
- fs8.unlinkSync(file);
2386
+ if (!fs7.existsSync(file)) throw new EditError(`Canvas "${name}" does not exist.`);
2387
+ this.trash.set(name, fs7.readFileSync(file, "utf8"));
2388
+ fs7.unlinkSync(file);
2264
2389
  this.sources.delete(name);
2390
+ this.undoStacks.delete(name);
2391
+ this.redoStacks.delete(name);
2265
2392
  this.canvasesChanged();
2266
2393
  }
2267
2394
  restoreCanvas(name) {
2268
2395
  const source = this.trash.get(name);
2269
2396
  if (source === void 0) throw new EditError(`Nothing to restore for "${name}".`);
2270
2397
  const target = this.uniqueCanvasName(name);
2271
- fs8.writeFileSync(canvasPath(this.config, target), source);
2398
+ fs7.writeFileSync(canvasPath(this.config, target), source);
2272
2399
  this.sources.set(target, source);
2273
2400
  this.trash.delete(name);
2274
2401
  this.canvasesChanged();
@@ -2279,14 +2406,14 @@ var Workspace = class {
2279
2406
  if (!fileName.endsWith(CANVAS_SUFFIX)) return;
2280
2407
  const canvas = canvasNameOf(fileName);
2281
2408
  const file = this.file(canvas);
2282
- if (!fs8.existsSync(file)) {
2409
+ if (!fs7.existsSync(file)) {
2283
2410
  this.sources.delete(canvas);
2284
2411
  syncRoute(this.config);
2285
2412
  this.emit({ type: "canvases", canvases: this.canvases() });
2286
2413
  return;
2287
2414
  }
2288
2415
  const prev = this.sources.get(canvas);
2289
- const next = fs8.readFileSync(file, "utf8");
2416
+ const next = fs7.readFileSync(file, "utf8");
2290
2417
  if (prev === next) return;
2291
2418
  this.sources.set(canvas, next);
2292
2419
  if (prev === void 0) {
@@ -2305,7 +2432,8 @@ var Workspace = class {
2305
2432
  } catch (err) {
2306
2433
  if (!(err instanceof ConflictError)) throw err;
2307
2434
  if (err.file) this.linkedFileChanged(err.file);
2308
- else this.externalChange(path9.basename(this.file(cmd.canvas)));
2435
+ else this.externalChange(path8.basename(this.file(cmd.canvas)));
2436
+ if (nodeRefs(cmd).length || "frame" in cmd && cmd.frame) throw new EditError(`${path8.basename(err.file ?? this.relFile(cmd.canvas))} changed while editing. Re-read the canvas and try again.`);
2309
2437
  return this.exec(cmd, actor);
2310
2438
  }
2311
2439
  });
@@ -2314,32 +2442,55 @@ var Workspace = class {
2314
2442
  }
2315
2443
  async exec(cmd, actor) {
2316
2444
  cmd = { ...cmd };
2317
- if (cmd.op === "add_component_frame" && !fs8.existsSync(this.file(cmd.canvas))) this.createCanvas(cmd.canvas, actor, COMPONENTS_CANVAS);
2445
+ let prepared = null;
2446
+ if (cmd.op === "add_component_frame" && !fs7.existsSync(this.file(cmd.canvas))) {
2447
+ await this.catalog.load();
2448
+ const spec = this.catalog.get(cmd.component);
2449
+ if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
2450
+ if (spec.library) throw new EditError(`${spec.name} comes from ${spec.library}: it can't be edited here.`);
2451
+ this.createCanvas(cmd.canvas, actor, COMPONENTS_CANVAS);
2452
+ }
2318
2453
  const canvasSource = this.read(cmd.canvas);
2319
2454
  const canvasDoc = parseCanvas(cmd.canvas, this.relFile(cmd.canvas), canvasSource);
2320
2455
  if (canvasDoc.error) throw new EditError(`The canvas file does not parse: ${canvasDoc.error}`);
2321
2456
  const view = resolveLinks(this.config, canvasDoc);
2322
2457
  cmd = this.routeLinked(cmd, view);
2323
2458
  let target = this.targetOf(cmd);
2459
+ if (target) assertShown(view, target);
2324
2460
  if (cmd.op === "explore_copy") {
2325
2461
  const frame = findFrame(canvasDoc, cmd.frame);
2326
2462
  if (!frame) throw new EditError(`Frame "${cmd.frame}" not found.`);
2327
2463
  const body = explorationBody(this.config, frame, this.relFile(cmd.canvas));
2328
- this.pending = { kind: "explore", frame, imports: body.imports, jsx: await this.formatSnippet(body.jsx) };
2464
+ prepared = { kind: "explore", frame, imports: body.imports, jsx: await this.formatSnippet(body.jsx) };
2329
2465
  }
2330
2466
  if (cmd.op === "apply_to_page") {
2331
2467
  const frame = findFrame(canvasDoc, cmd.frame);
2332
2468
  if (!frame) throw new EditError(`Frame "${cmd.frame}" not found.`);
2333
2469
  const plan = applyPlan(this.config, canvasSource, canvasDoc, frame, this.relFile(cmd.canvas));
2334
2470
  target = plan.page;
2335
- this.pending = { kind: "apply", frame, ...plan };
2471
+ prepared = { kind: "apply", frame, ...plan };
2336
2472
  }
2337
2473
  const rel = target ?? this.relFile(cmd.canvas);
2338
- const file = path9.join(this.config.root, rel);
2339
- const before = target ? fs8.readFileSync(file, "utf8") : canvasSource;
2340
- const parse2 = (src) => target ? parseTargetDoc(target, src) : parseCanvas(cmd.canvas, rel, src);
2341
- const doc = parse2(before);
2474
+ const file = path8.join(this.config.root, rel);
2475
+ const original = target ? fs7.readFileSync(file, "utf8") : canvasSource;
2476
+ const crlf = original.includes("\r\n");
2477
+ const before = crlf ? original.replace(/\r\n/g, "\n") : original;
2478
+ const parse = (src) => target ? parseTargetDoc(target, src) : parseCanvas(cmd.canvas, rel, src);
2479
+ const doc = parse(before);
2342
2480
  if (!doc) throw new EditError(`${rel} has data or logic in its component now, so it can't be edited from the canvas. Explore a copy instead.`);
2481
+ if (cmd.op === "insert_icon") {
2482
+ const icon = cmd;
2483
+ const lib = iconLibrary(icon.library);
2484
+ if (!lib) throw new EditError(`Unknown icon library "${icon.library}". Known: ${ICON_LIBRARIES.map((l) => l.id).join(", ")}.`);
2485
+ const icons = await loadIcons(this.config.root, lib.id);
2486
+ const wanted = icon.name.toLowerCase();
2487
+ const exact = icons.find((i) => i.name === icon.name) ?? icons.find((i) => i.name.toLowerCase() === wanted);
2488
+ if (!exact) {
2489
+ const near = searchIcons(icons, icon.name, 5).icons.map((i) => i.name);
2490
+ throw new EditError(`No icon "${icon.name}" in ${lib.label}.${near.length ? ` Did you mean ${near.join(", ")}?` : " Use search_icons."}`);
2491
+ }
2492
+ cmd = { ...icon, library: lib.id, name: exact.name };
2493
+ }
2343
2494
  if ((cmd.op === "insert_jsx" || cmd.op === "replace" || cmd.op === "create_frame") && cmd.jsx) {
2344
2495
  cmd = { ...cmd, jsx: await this.formatSnippet(cmd.jsx) };
2345
2496
  }
@@ -2349,16 +2500,16 @@ var Workspace = class {
2349
2500
  if (!r) throw new EditError(`No page "${cmd.route}". Pages: ${routes.map((x) => x.route).join(", ") || "none"}`);
2350
2501
  if (cmd.copy) {
2351
2502
  const built = routeFrame(this.config, r.file, this.relFile(cmd.canvas));
2352
- this.pending = { kind: "route", route: r.route, page: null, imports: built.imports, mode: built.mode, jsx: await this.formatSnippet(built.jsx) };
2503
+ prepared = { kind: "route", route: r.route, page: null, imports: built.imports, mode: built.mode, jsx: await this.formatSnippet(built.jsx) };
2353
2504
  } else {
2354
2505
  const built = linkedFrameBody(this.config, canvasDoc, this.relFile(cmd.canvas), r.file);
2355
- this.pending = { kind: "route", route: r.route, page: r.file, imports: built.imports, mode: "linked", jsx: built.jsx };
2506
+ prepared = { kind: "route", route: r.route, page: r.file, imports: built.imports, mode: "linked", jsx: built.jsx };
2356
2507
  }
2357
2508
  }
2358
2509
  if (cmd.op === "add_animation") {
2359
2510
  const files = ensureMotionComponents(this.config);
2360
2511
  if (files.created) this.catalog.invalidate();
2361
- this.motion = files;
2512
+ prepared = { kind: "motion", files };
2362
2513
  }
2363
2514
  let newComponent = null;
2364
2515
  let movedNames = /* @__PURE__ */ new Set();
@@ -2379,15 +2530,15 @@ var Workspace = class {
2379
2530
  imports = importsFor(this.config, jsx, before, doc, rel, fileRel);
2380
2531
  movedNames = new Set(componentNamesIn(jsx));
2381
2532
  }
2382
- const abs = path9.join(this.config.root, fileRel);
2533
+ const abs = path8.join(this.config.root, fileRel);
2383
2534
  newComponent = { rel: fileRel, content: await this.format(componentFile(name, jsx, imports), abs).catch(() => componentFile(name, jsx, imports)) };
2384
- this.pending = { kind: "component", name, file: fileRel };
2535
+ prepared = { kind: "component", name, file: fileRel };
2385
2536
  }
2386
2537
  await this.catalog.load().catch(() => []);
2387
2538
  const ed = new CanvasEditor(before, doc);
2388
2539
  const renamedFrame = cmd.op === "update_frame" && cmd.name !== void 0 ? findFrame(doc, cmd.frame) : null;
2389
2540
  const usedBefore = cmd.op === "apply_to_page" ? new Set(componentNamesIn(before.slice(doc.frames[0].children[0].start, doc.frames[0].children[0].end))) : null;
2390
- const label = this.apply(ed, cmd, rel);
2541
+ const label = this.apply(ed, cmd, rel, prepared);
2391
2542
  const pretty = await this.isPrettierClean(before, file);
2392
2543
  const finish = async (code) => pretty ? this.format(code, file) : validate(code);
2393
2544
  let result = ed.result();
@@ -2397,8 +2548,8 @@ var Workspace = class {
2397
2548
  let after = await finish(result);
2398
2549
  let ids = [];
2399
2550
  const reparse = (src) => {
2400
- const d = parse2(src);
2401
- if (!d) throw new EditError(`That edit would put logic into ${path9.basename(rel)}. Only plain JSX can be edited from the canvas.`);
2551
+ const d = parse(src);
2552
+ if (!d) throw new EditError(`That edit would put logic into ${path8.basename(rel)}. Only plain JSX can be edited from the canvas.`);
2402
2553
  return d;
2403
2554
  };
2404
2555
  if (after.includes(MARK)) {
@@ -2421,19 +2572,25 @@ var Workspace = class {
2421
2572
  ids = paths.map((p) => pathId(clean, p)).filter(Boolean);
2422
2573
  }
2423
2574
  if (cmd.op === "apply_to_page") ids = [findFrame(canvasDoc, cmd.frame).id];
2424
- if (after === before) return { doc: target ? this.doc(cmd.canvas) : view, ids, label };
2425
- if (fs8.readFileSync(file, "utf8") !== before) throw new ConflictError(`${path9.basename(rel)} changed during the edit.`, target ?? void 0);
2426
- if (newComponent) {
2427
- fs8.mkdirSync(path9.dirname(path9.join(this.config.root, newComponent.rel)), { recursive: true });
2428
- fs8.writeFileSync(path9.join(this.config.root, newComponent.rel), newComponent.content, { flag: "wx" });
2575
+ const writeNewComponent = () => {
2576
+ if (!newComponent) return;
2577
+ fs7.mkdirSync(path8.dirname(path8.join(this.config.root, newComponent.rel)), { recursive: true });
2578
+ fs7.writeFileSync(path8.join(this.config.root, newComponent.rel), newComponent.content, { flag: "wx" });
2429
2579
  this.catalog.invalidate();
2430
2580
  this.emit({ type: "catalog" });
2581
+ };
2582
+ if (crlf) after = after.replace(/\n/g, "\r\n");
2583
+ if (after === original) {
2584
+ writeNewComponent();
2585
+ return { doc: target ? this.doc(cmd.canvas) : view, ids, label };
2431
2586
  }
2587
+ if (fs7.readFileSync(file, "utf8") !== original) throw new ConflictError(`${path8.basename(rel)} changed during the edit.`, target ?? void 0);
2588
+ writeNewComponent();
2432
2589
  if (target) this.sources.set(`file:${target}`, after);
2433
2590
  else this.sources.set(cmd.canvas, after);
2434
- fs8.writeFileSync(file, after);
2591
+ fs7.writeFileSync(file, after);
2435
2592
  const newDoc = this.doc(cmd.canvas);
2436
- this.record(cmd.canvas, label, actor, before, after, ids, target ?? void 0, touchedFrames(cmd, ids, view, newDoc));
2593
+ this.record(cmd.canvas, label, actor, original, after, ids, target ?? void 0, touchedFrames(cmd, ids, view, newDoc));
2437
2594
  if (renamedFrame) {
2438
2595
  const now = newDoc.frames[renamedFrame.path[0]];
2439
2596
  if (now && now.frameName !== renamedFrame.frameName) {
@@ -2457,7 +2614,7 @@ var Workspace = class {
2457
2614
  const f = findFrame(view, cmd.parent);
2458
2615
  if (f?.link) {
2459
2616
  if (!f.link.editable) throw viewOnly(f);
2460
- const src = fs8.readFileSync(path9.join(this.config.root, f.link.page), "utf8");
2617
+ const src = fs7.readFileSync(path8.join(this.config.root, f.link.page), "utf8");
2461
2618
  if (f.link.kind === "component") {
2462
2619
  const own = parseModuleDoc(f.link.page, src)?.frames.find((x) => x.frameName === f.link.route);
2463
2620
  if (!own) throw viewOnly(f);
@@ -2482,21 +2639,60 @@ var Workspace = class {
2482
2639
  const refs = nodeRefs(cmd);
2483
2640
  const files = new Set(refs.map(fileOfId));
2484
2641
  if (files.size > 1) {
2485
- const names = [...files].map((f) => f ? path9.basename(f) : "the canvas");
2642
+ const names = [...files].map((f) => f ? path8.basename(f) : "the canvas");
2486
2643
  throw new EditError(`These layers live in different files (${names.join(", ")}). Edit them one file at a time.`);
2487
2644
  }
2488
2645
  return [...files][0] ?? null;
2489
2646
  }
2490
- apply(ed, cmd, rel) {
2647
+ /** Applies a command to the file being edited and returns its history label. */
2648
+ apply(ed, cmd, rel, prepared) {
2649
+ switch (cmd.op) {
2650
+ case "set_props":
2651
+ case "set_text":
2652
+ case "set_class":
2653
+ case "insert_jsx":
2654
+ case "insert_component":
2655
+ case "insert_icon":
2656
+ case "replace":
2657
+ case "duplicate":
2658
+ case "delete":
2659
+ case "move":
2660
+ case "reorder":
2661
+ case "wrap":
2662
+ return this.applyLayerEdit(ed, cmd, rel, prepared);
2663
+ case "create_frame":
2664
+ case "update_frame":
2665
+ case "add_background":
2666
+ case "apply_preset":
2667
+ case "create_variants":
2668
+ case "add_component_frame":
2669
+ return this.applyFrameEdit(ed, cmd, rel, prepared);
2670
+ case "import_route":
2671
+ case "explore_copy":
2672
+ case "apply_to_page":
2673
+ return this.applyPageEdit(ed, cmd, rel, prepared);
2674
+ case "add_animation":
2675
+ case "remove_animation":
2676
+ return this.applyMotionEdit(ed, cmd, rel, prepared);
2677
+ case "create_component":
2678
+ return this.applyComponentEdit(ed, cmd, rel, prepared);
2679
+ default: {
2680
+ const unknown = cmd;
2681
+ throw new EditError(`Unknown command ${unknown.op}.`);
2682
+ }
2683
+ }
2684
+ }
2685
+ /** Edits to layers: props, text, classes, inserting, moving, wrapping. */
2686
+ applyLayerEdit(ed, cmd, rel, prepared) {
2491
2687
  const doc = ed.doc;
2492
2688
  const guard = (node, verb) => {
2493
2689
  if (doc.canvasNode) return;
2494
- if (node.path.length <= 2) throw new EditError(`Can't ${verb} the root element of ${path9.basename(rel)}.`);
2495
- if (node.kind === "expression" && node.name.trim() === "children" && /^layout\.[jt]sx?$/.test(path9.basename(rel))) throw new EditError(`Can't ${verb} the {children} slot of ${path9.basename(rel)}: that's where the page renders.`);
2690
+ if (node.path.length <= 2) throw new EditError(`Can't ${verb} the root element of ${path8.basename(rel)}.`);
2691
+ if (node.kind === "expression" && node.name.trim() === "children" && /^layout\.[jt]sx?$/.test(path8.basename(rel))) throw new EditError(`Can't ${verb} the {children} slot of ${path8.basename(rel)}: that's where the page renders.`);
2496
2692
  };
2497
2693
  const literalOnly = (node, key) => {
2498
2694
  const v = node.props[key];
2499
- if (!doc.canvasNode && v?.kind === "expression") throw new EditError(`${key} on ${node.name} is computed in code ({${short(v.code, 50)}}). Edit it in ${path9.basename(rel)}, or ask your agent.`);
2695
+ if (!doc.canvasNode && v?.kind === "expression") throw new EditError(`${key} on ${node.name} is computed in code ({${short(v.code, 50)}}). Edit it in ${path8.basename(rel)}, or ask your agent.`);
2500
2696
  };
2501
2697
  switch (cmd.op) {
2502
2698
  case "set_props": {
@@ -2545,6 +2741,14 @@ var Workspace = class {
2545
2741
  ed.insertChild(parent, cmd.index, jsx);
2546
2742
  return `Inserted ${spec.name}`;
2547
2743
  }
2744
+ case "insert_icon": {
2745
+ const parent = ed.container(cmd.parent);
2746
+ const lib = iconLibrary(cmd.library);
2747
+ const local = ed.importAs(cmd.name, lib.from, `${cmd.name.replace(/Icon$/, "")}Icon`);
2748
+ const className = cmd.className?.trim() ?? "size-4";
2749
+ ed.insertChild(parent, cmd.index, `<${local}${className ? ` className=${JSON.stringify(className)}` : ""} />`);
2750
+ return `Inserted ${cmd.name} icon`;
2751
+ }
2548
2752
  case "replace": {
2549
2753
  const { node } = ed.node(cmd.id);
2550
2754
  parseJsx(cmd.jsx);
@@ -2553,7 +2757,8 @@ var Workspace = class {
2553
2757
  return `Replaced ${node.name}`;
2554
2758
  }
2555
2759
  case "duplicate": {
2556
- const nodes = cmd.ids.map((id) => ed.node(id));
2760
+ const all = cmd.ids.map((id) => ed.node(id));
2761
+ const nodes = all.filter(({ node: n }) => !all.some(({ node: o }) => o !== n && n.start >= o.start && n.end <= o.end));
2557
2762
  for (const { node } of nodes) guard(node, "duplicate");
2558
2763
  for (const { node } of nodes) ed.duplicate(node);
2559
2764
  return nodes.length === 1 ? `Duplicated ${nodes[0].node.kind === "component" && "frameName" in nodes[0].node ? "frame" : nodes[0].node.name}` : `Duplicated ${nodes.length} layers`;
@@ -2590,11 +2795,19 @@ var Workspace = class {
2590
2795
  ed.wrap(nodes, `<div className="${cls}">`, "</div>");
2591
2796
  return nodes.length === 1 ? `Wrapped ${nodes[0].name} in auto layout` : `Wrapped ${nodes.length} layers in auto layout`;
2592
2797
  }
2798
+ default:
2799
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyLayerEdit.`);
2800
+ }
2801
+ }
2802
+ /** Frames: creating, updating, backgrounds, presets, variant grids, main component frames. */
2803
+ applyFrameEdit(ed, cmd, rel, prepared) {
2804
+ const doc = ed.doc;
2805
+ switch (cmd.op) {
2593
2806
  case "create_frame": {
2594
2807
  const device = findDevice(cmd.device);
2595
2808
  if (cmd.device && !device) throw new EditError(`Unknown device "${cmd.device}". Devices: ${DEVICES.map((d) => d.id).join(", ")}`);
2596
2809
  const name = uniqueFrameName(doc, cmd.name ?? device?.name ?? "Frame");
2597
- const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
2810
+ const right = rightEdge(doc);
2598
2811
  const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2599
2812
  if (cmd.jsx) {
2600
2813
  parseJsx(cmd.jsx);
@@ -2684,19 +2897,68 @@ var Workspace = class {
2684
2897
  ed.selectPaths.push(node.path);
2685
2898
  return `Applied ${preset.name} preset to ${spec.name}`;
2686
2899
  }
2900
+ case "create_variants": {
2901
+ const spec = this.catalog.get(cmd.component);
2902
+ if (!spec) throw new EditError(`Unknown component "${cmd.component}".`);
2903
+ const prop = spec.props.find((p) => p.name === cmd.prop);
2904
+ if (!prop) throw new EditError(`${spec.name} has no prop "${cmd.prop}".`);
2905
+ const values = prop.type === "enum" ? prop.options : prop.type === "boolean" ? [false, true] : [];
2906
+ if (!values.length) throw new EditError(`Prop "${cmd.prop}" is ${prop.typeText}; variants need a union of string literals or a boolean.`);
2907
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2908
+ const label = (v) => typeof v === "string" ? v[0].toUpperCase() + v.slice(1) : `${prop.name}: ${v}`;
2909
+ const rows = indentBlock(values.map((v) => componentJsx(spec, { ...cmd.props, [prop.name]: v }, spec.acceptsChildren ? label(v) : void 0)).join("\n"));
2910
+ const right = rightEdge(doc);
2911
+ const name = uniqueFrameName(doc, `${spec.name} \xB7 ${prop.name}`);
2912
+ ed.insertFrame(frameCode({ name, x: cmd.x ?? right + 80, y: cmd.y ?? 0, width: 360, body: `<div className="flex flex-col gap-3 p-4">
2913
+ ${rows}
2914
+ </div>` }));
2915
+ return `Created ${spec.name} ${prop.name} variants`;
2916
+ }
2917
+ case "add_component_frame": {
2918
+ const spec = this.catalog.get(cmd.component);
2919
+ if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
2920
+ if (spec.library) throw new EditError(`${spec.name} comes from ${spec.library}: it can't be edited here.`);
2921
+ const existing = doc.frames.find((f) => f.component === `${spec.file}#${spec.name}`);
2922
+ if (existing) {
2923
+ ed.selectPaths.push(existing.path);
2924
+ return `Opened ${spec.name}`;
2925
+ }
2926
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2927
+ const right = rightEdge(doc);
2928
+ const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2929
+ ed.insertFrame(
2930
+ frameCode({
2931
+ name: uniqueFrameName(doc, spec.name),
2932
+ x: cmd.x ?? right + 80,
2933
+ y: cmd.y ?? top,
2934
+ width: cmd.width ?? 640,
2935
+ component: `${spec.file}#${spec.name}`,
2936
+ body: `<div className="p-10">
2937
+ ${componentJsx(spec)}
2938
+ </div>`
2939
+ })
2940
+ );
2941
+ return `Opened main component ${spec.name}`;
2942
+ }
2943
+ default:
2944
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyFrameEdit.`);
2945
+ }
2946
+ }
2947
+ /** Linked pages: adding a page, exploring a copy, applying it back. */
2948
+ applyPageEdit(ed, cmd, rel, prepared) {
2949
+ const doc = ed.doc;
2950
+ switch (cmd.op) {
2687
2951
  case "import_route": {
2688
- const built = this.pending;
2689
- this.pending = null;
2952
+ const built = take(prepared, "route");
2690
2953
  for (const stmt of built.imports) ed.addImportStatement(stmt);
2691
- const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
2954
+ const right = rightEdge(doc);
2692
2955
  const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2693
2956
  const name = uniqueFrameName(doc, cmd.name ?? (built.route === "/" ? "Home" : built.route.split("/").filter(Boolean).map((p) => p.replace(/^./, (c) => c.toUpperCase())).join(" / ")));
2694
2957
  ed.insertFrame(frameCode({ name, x: cmd.x ?? right + 80, y: cmd.y ?? top, width: cmd.width ?? 1440, height: null, page: built.page ?? void 0, body: built.jsx }));
2695
2958
  return built.mode === "linked" ? `Linked ${built.route}` : built.mode === "inline" ? `Copied ${built.route} as layers` : `Copied ${built.route}`;
2696
2959
  }
2697
2960
  case "explore_copy": {
2698
- const built = this.pending;
2699
- this.pending = null;
2961
+ const built = take(prepared, "explore");
2700
2962
  const f = built.frame;
2701
2963
  for (const stmt of built.imports) ed.addImportStatement(stmt);
2702
2964
  const h = (fr) => fr.height ?? 2e3;
@@ -2707,17 +2969,23 @@ var Workspace = class {
2707
2969
  return `Explored a copy of ${f.frameName}`;
2708
2970
  }
2709
2971
  case "apply_to_page": {
2710
- const plan = this.pending;
2711
- this.pending = null;
2972
+ const plan = take(prepared, "apply");
2712
2973
  for (const stmt of plan.imports) ed.addImportStatement(stmt);
2713
2974
  ed.replace(doc.frames[0].children[0], plan.jsx);
2714
2975
  return `Applied ${plan.frame.frameName} to ${plan.page}`;
2715
2976
  }
2977
+ default:
2978
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyPageEdit.`);
2979
+ }
2980
+ }
2981
+ /** Scroll reveals and text animations. */
2982
+ applyMotionEdit(ed, cmd, rel, prepared) {
2983
+ switch (cmd.op) {
2716
2984
  case "add_animation": {
2717
2985
  const { node, parent } = ed.node(cmd.id);
2718
2986
  if ("frameName" in node) throw new EditError("Animate a layer inside the frame, not the frame itself.");
2719
2987
  if (node.kind === "expression") throw new EditError("Expressions can't be animated; pick the element around it.");
2720
- const files = this.motion ?? motionFiles(this.config);
2988
+ const files = prepared?.kind === "motion" ? prepared.files : motionFiles(this.config);
2721
2989
  const props = {};
2722
2990
  if (cmd.effect !== void 0) props.effect = cmd.effect;
2723
2991
  for (const k of ["delay", "duration", "stagger"]) if (cmd[k] !== void 0 && !(cmd.kind === "reveal" && k === "stagger")) props[k] = Math.round(cmd[k] * 100) / 100;
@@ -2765,10 +3033,16 @@ var Workspace = class {
2765
3033
  ed.selectPaths.push(wrapper === kids[0] ? node.path : wrapper.path);
2766
3034
  return cmd.kind === "reveal" ? "Removed the reveal" : "Removed the text animation";
2767
3035
  }
3036
+ default:
3037
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyMotionEdit.`);
3038
+ }
3039
+ }
3040
+ /** New components, from scratch or from selected layers. */
3041
+ applyComponentEdit(ed, cmd, rel, prepared) {
3042
+ switch (cmd.op) {
2768
3043
  case "create_component": {
2769
- const made = this.pending;
2770
- this.pending = null;
2771
- ed.ensureImport(made.name, importSpecifier(this.config.root, rel, made.file));
3044
+ const made = take(prepared, "component");
3045
+ if (cmd.from || cmd.parent) ed.ensureImport(made.name, importSpecifier(this.config.root, rel, made.file));
2772
3046
  if (cmd.from) {
2773
3047
  const { node } = ed.node(cmd.from);
2774
3048
  ed.replace(node, `<${made.name} />`);
@@ -2778,49 +3052,8 @@ var Workspace = class {
2778
3052
  ed.insertChild(ed.container(cmd.parent), cmd.index, `<${made.name} />`);
2779
3053
  return `Created component ${made.name}`;
2780
3054
  }
2781
- case "add_component_frame": {
2782
- const spec = this.catalog.get(cmd.component);
2783
- if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
2784
- if (spec.library) throw new EditError(`${spec.name} comes from ${spec.library}: it can't be edited here.`);
2785
- const existing = doc.frames.find((f) => f.component === `${spec.file}#${spec.name}`);
2786
- if (existing) {
2787
- ed.selectPaths.push(existing.path);
2788
- return `Opened ${spec.name}`;
2789
- }
2790
- ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2791
- const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
2792
- const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2793
- ed.insertFrame(
2794
- frameCode({
2795
- name: uniqueFrameName(doc, spec.name),
2796
- x: cmd.x ?? right + 80,
2797
- y: cmd.y ?? top,
2798
- width: cmd.width ?? 640,
2799
- component: `${spec.file}#${spec.name}`,
2800
- body: `<div className="p-10">
2801
- ${componentJsx(spec)}
2802
- </div>`
2803
- })
2804
- );
2805
- return `Opened main component ${spec.name}`;
2806
- }
2807
- case "create_variants": {
2808
- const spec = this.catalog.get(cmd.component);
2809
- if (!spec) throw new EditError(`Unknown component "${cmd.component}".`);
2810
- const prop = spec.props.find((p) => p.name === cmd.prop);
2811
- if (!prop) throw new EditError(`${spec.name} has no prop "${cmd.prop}".`);
2812
- const values = prop.type === "enum" ? prop.options : prop.type === "boolean" ? [false, true] : [];
2813
- if (!values.length) throw new EditError(`Prop "${cmd.prop}" is ${prop.typeText}; variants need a union of string literals or a boolean.`);
2814
- ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2815
- const label = (v) => typeof v === "string" ? v[0].toUpperCase() + v.slice(1) : `${prop.name}: ${v}`;
2816
- const rows = indentBlock(values.map((v) => componentJsx(spec, { ...cmd.props, [prop.name]: v }, spec.acceptsChildren ? label(v) : void 0)).join("\n"));
2817
- const right = doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
2818
- const name = uniqueFrameName(doc, `${spec.name} \xB7 ${prop.name}`);
2819
- ed.insertFrame(frameCode({ name, x: cmd.x ?? right + 80, y: cmd.y ?? 0, width: 360, body: `<div className="flex flex-col gap-3 p-4">
2820
- ${rows}
2821
- </div>` }));
2822
- return `Created ${spec.name} ${prop.name} variants`;
2823
- }
3055
+ default:
3056
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyComponentEdit.`);
2824
3057
  }
2825
3058
  }
2826
3059
  autoImport(ed, rel, jsx) {
@@ -2833,9 +3066,6 @@ ${rows}
2833
3066
  ed.ensureImport(name, importSpecifier(this.config.root, rel, spec.file));
2834
3067
  }
2835
3068
  }
2836
- motion = null;
2837
- /** work resolved (async) in exec before the synchronous apply */
2838
- pending = null;
2839
3069
  cleanCache = /* @__PURE__ */ new Map();
2840
3070
  async isPrettierClean(source, file) {
2841
3071
  const hit = this.cleanCache.get(source);
@@ -2880,11 +3110,29 @@ ${rows}
2880
3110
  if (stack.length > 200) stack.shift();
2881
3111
  this.undoStacks.set(canvas, stack);
2882
3112
  this.redoStacks.set(canvas, []);
2883
- this.feed.push(entry);
3113
+ this.feed.push(publicEntry(entry));
2884
3114
  if (this.feed.length > 300) this.feed.shift();
3115
+ this.trimHistory();
2885
3116
  this.emit({ type: "change", entry: publicEntry(entry), ids });
2886
3117
  this.emitHistory(canvas);
2887
3118
  }
3119
+ /**
3120
+ * Undo entries hold whole files (before and after). Keep their total under a
3121
+ * budget by dropping the oldest entries across canvases, so long sessions on
3122
+ * big pages don't grow without limit.
3123
+ */
3124
+ trimHistory(budget = 48 * 1024 * 1024) {
3125
+ const all = [...this.undoStacks.values(), ...this.redoStacks.values()];
3126
+ let total = 0;
3127
+ for (const stack of all) for (const e of stack) total += e.before.length + e.after.length;
3128
+ while (total > budget) {
3129
+ let oldest = null;
3130
+ for (const stack of all) if (stack.length && (!oldest || stack[0].at < oldest[0].at)) oldest = stack;
3131
+ if (!oldest) break;
3132
+ const e = oldest.shift();
3133
+ total -= e.before.length + e.after.length;
3134
+ }
3135
+ }
2888
3136
  /** Changes still in effect (not undone) on a canvas since a time, oldest first. */
2889
3137
  changesSince(canvas, since) {
2890
3138
  return (this.undoStacks.get(canvas) ?? []).filter((e) => e.at > since).map(publicEntry);
@@ -2909,17 +3157,20 @@ ${rows}
2909
3157
  const entry = stack[stack.length - 1];
2910
3158
  if (!entry) throw new EditError(`Nothing to ${dir}.`);
2911
3159
  if (dir === "undo" && entry.before === "") throw new EditError("Cannot undo creating the canvas; delete the file instead.");
2912
- stack.pop();
2913
- const file = entry.file ? path9.join(this.config.root, entry.file) : this.file(canvas);
2914
- const current = entry.file ? fs8.readFileSync(file, "utf8") : this.read(canvas);
2915
- const expected = dir === "undo" ? entry.after : entry.before;
2916
- if (current !== expected) {
2917
- stack.push(entry);
2918
- throw new EditError(`Cannot ${dir} "${entry.label}": ${entry.file ? path9.basename(entry.file) : "the file"} changed since.`);
3160
+ const file = entry.file ? path8.join(this.config.root, entry.file) : this.file(canvas);
3161
+ const name = entry.file ? path8.basename(entry.file) : "the file";
3162
+ let current;
3163
+ try {
3164
+ current = entry.file ? fs7.readFileSync(file, "utf8") : this.read(canvas);
3165
+ } catch {
3166
+ throw new EditError(`Cannot ${dir} "${entry.label}": ${name} can't be read right now.`);
2919
3167
  }
3168
+ const expected = dir === "undo" ? entry.after : entry.before;
3169
+ if (current !== expected) throw new EditError(`Cannot ${dir} "${entry.label}": ${name} changed since.`);
3170
+ stack.pop();
2920
3171
  const next = dir === "undo" ? entry.before : entry.after;
2921
3172
  this.sources.set(entry.file ? `file:${entry.file}` : canvas, next);
2922
- fs8.writeFileSync(file, next);
3173
+ fs7.writeFileSync(file, next);
2923
3174
  const list = to.get(canvas) ?? [];
2924
3175
  list.push(entry);
2925
3176
  to.set(canvas, list);
@@ -2929,9 +3180,10 @@ ${rows}
2929
3180
  for (const c of this.canvasesLinking(entry.file)) if (c !== canvas) this.emit({ type: "doc", canvas: c, doc: this.doc(c) });
2930
3181
  }
2931
3182
  const label = `${dir === "undo" ? "Undid" : "Redid"} \u201C${entry.label}\u201D`;
2932
- const marker = { ...entry, id: ++this.seq, label, actor, at: Date.now() };
3183
+ const marker = publicEntry({ ...entry, id: ++this.seq, label, actor, at: Date.now() });
2933
3184
  this.feed.push(marker);
2934
- this.emit({ type: "change", entry: publicEntry(marker), ids: [] });
3185
+ if (this.feed.length > 300) this.feed.shift();
3186
+ this.emit({ type: "change", entry: marker, ids: [] });
2935
3187
  this.emitHistory(canvas);
2936
3188
  return { doc, label };
2937
3189
  });
@@ -2939,7 +3191,7 @@ ${rows}
2939
3191
  return job;
2940
3192
  }
2941
3193
  publicFeed() {
2942
- return this.feed.map(publicEntry);
3194
+ return this.feed;
2943
3195
  }
2944
3196
  // ---------- agents ----------
2945
3197
  touchAgent(session, name) {
@@ -2959,17 +3211,17 @@ ${rows}
2959
3211
  if (this.agents.delete(session)) this.emit({ type: "agents", agents: [...this.agents.values()] });
2960
3212
  }
2961
3213
  ensureRoute() {
2962
- if (!fs8.existsSync(path9.join(this.config.root, this.config.canvasDir))) {
2963
- fs8.mkdirSync(path9.join(this.config.root, this.config.canvasDir), { recursive: true });
3214
+ if (!fs7.existsSync(path8.join(this.config.root, this.config.canvasDir))) {
3215
+ fs7.mkdirSync(path8.join(this.config.root, this.config.canvasDir), { recursive: true });
2964
3216
  }
2965
3217
  if (!this.canvases().length) {
2966
3218
  const file = canvasPath(this.config, "home");
2967
3219
  const content = this.homeCanvas();
2968
- fs8.writeFileSync(file, content);
3220
+ fs7.writeFileSync(file, content);
2969
3221
  void this.format(content, file).then((pretty) => {
2970
- if (fs8.readFileSync(file, "utf8") !== content) return;
3222
+ if (fs7.readFileSync(file, "utf8") !== content) return;
2971
3223
  this.sources.set("home", pretty);
2972
- fs8.writeFileSync(file, pretty);
3224
+ fs7.writeFileSync(file, pretty);
2973
3225
  }).catch(() => {
2974
3226
  });
2975
3227
  }
@@ -2980,7 +3232,7 @@ ${rows}
2980
3232
  try {
2981
3233
  const home = listRoutes(this.config).find((r) => r.route === "/");
2982
3234
  if (!home) return STARTER_CANVAS;
2983
- const rel = path9.relative(this.config.root, canvasPath(this.config, "home"));
3235
+ const rel = path8.relative(this.config.root, canvasPath(this.config, "home"));
2984
3236
  const empty = parseCanvas("home", rel, `import { Canvas, Frame } from "truecanvas";
2985
3237
  export default function C() { return <Canvas></Canvas>; }
2986
3238
  `);
@@ -3020,6 +3272,10 @@ export default function ComponentsCanvas() {
3020
3272
  return <Canvas></Canvas>;
3021
3273
  }
3022
3274
  `;
3275
+ function take(prepared, kind) {
3276
+ if (prepared?.kind !== kind) throw new Error(`Internal: the ${kind} step wasn't prepared.`);
3277
+ return prepared;
3278
+ }
3023
3279
  function validate(code) {
3024
3280
  try {
3025
3281
  parseModule(code);
@@ -3032,6 +3288,13 @@ function publicEntry(e) {
3032
3288
  const { before: _b, after: _a, ...rest } = e;
3033
3289
  return rest;
3034
3290
  }
3291
+ function assertShown(view, rel) {
3292
+ const shown = view.frames.some((f) => f.link?.files.includes(rel));
3293
+ if (!shown || path8.isAbsolute(rel) || rel.split(/[\\/]/).includes("..")) throw new EditError(`${rel} isn't shown on this canvas. Re-read the canvas for current ids.`);
3294
+ }
3295
+ function rightEdge(doc) {
3296
+ return doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
3297
+ }
3035
3298
  function nodeRefs(cmd) {
3036
3299
  const refs = [];
3037
3300
  if ("id" in cmd && cmd.id) refs.push(cmd.id);
@@ -3097,6 +3360,130 @@ function componentJsx(spec, props = {}, text) {
3097
3360
  return childText !== void 0 ? `${open}>${textCode(childText)}</${spec.name}>` : `${open} />`;
3098
3361
  }
3099
3362
 
3363
+ // src/core/pm.ts
3364
+ import { spawn } from "child_process";
3365
+ import fs8 from "fs";
3366
+ import path9 from "path";
3367
+ function detectPm(dir) {
3368
+ for (let d = dir; ; d = path9.dirname(d)) {
3369
+ if (fs8.existsSync(path9.join(d, "pnpm-lock.yaml")) || fs8.existsSync(path9.join(d, "pnpm-workspace.yaml"))) return "pnpm";
3370
+ if (fs8.existsSync(path9.join(d, "yarn.lock"))) return "yarn";
3371
+ if (fs8.existsSync(path9.join(d, "bun.lock")) || fs8.existsSync(path9.join(d, "bun.lockb"))) return "bun";
3372
+ if (fs8.existsSync(path9.join(d, "package-lock.json"))) return "npm";
3373
+ if (path9.dirname(d) === d || fs8.existsSync(path9.join(d, ".git"))) break;
3374
+ }
3375
+ const agent = process.env.npm_config_user_agent ?? "";
3376
+ if (agent.startsWith("pnpm")) return "pnpm";
3377
+ if (agent.startsWith("yarn")) return "yarn";
3378
+ if (agent.startsWith("bun")) return "bun";
3379
+ return "npm";
3380
+ }
3381
+ function addArgs(pm, specs, dev = false) {
3382
+ if (pm === "npm") return ["install", ...dev ? ["-D"] : [], ...specs];
3383
+ if (pm === "bun") return ["add", ...dev ? ["-d"] : [], ...specs];
3384
+ return ["add", ...dev ? ["-D"] : [], ...specs];
3385
+ }
3386
+ function dlx(pm, pkg, args) {
3387
+ if (pm === "pnpm") return ["pnpm", ["dlx", pkg, ...args]];
3388
+ if (pm === "yarn") return ["yarn", ["dlx", pkg, ...args]];
3389
+ if (pm === "bun") return ["bunx", [pkg, ...args]];
3390
+ return ["npx", ["--yes", pkg, ...args]];
3391
+ }
3392
+ function toolEnv(extra = {}) {
3393
+ return { ...process.env, PATH: `${path9.dirname(process.execPath)}${path9.delimiter}${process.env.PATH ?? ""}`, ...extra };
3394
+ }
3395
+ function shellSafe(cmd, args) {
3396
+ if (process.platform !== "win32") return { cmd, args, shell: false };
3397
+ const quote = (a) => /[\s"&|<>^()%!]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a;
3398
+ return { cmd: quote(cmd), args: args.map(quote), shell: true };
3399
+ }
3400
+ function run2(cmd, args, cwd, timeoutMs = 3e5) {
3401
+ return new Promise((resolve) => {
3402
+ let out = "";
3403
+ let done = false;
3404
+ const finish = (ok, extra = "") => {
3405
+ if (done) return;
3406
+ done = true;
3407
+ clearTimeout(timer);
3408
+ resolve({ ok, out: (out + extra).slice(-2e4) });
3409
+ };
3410
+ const safe = shellSafe(cmd, args);
3411
+ const p = spawn(safe.cmd, safe.args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: toolEnv({ CI: "1", FORCE_COLOR: "0", NO_COLOR: "1" }), shell: safe.shell });
3412
+ const timer = setTimeout(() => {
3413
+ p.kill();
3414
+ finish(false, `
3415
+ ${cmd} timed out after ${Math.round(timeoutMs / 1e3)}s`);
3416
+ }, timeoutMs);
3417
+ p.stdout.on("data", (d) => out += d);
3418
+ p.stderr.on("data", (d) => out += d);
3419
+ p.on("error", (err) => finish(false, `
3420
+ ${err.message}`));
3421
+ p.on("close", (code) => finish(code === 0));
3422
+ });
3423
+ }
3424
+
3425
+ // src/core/shadcn.ts
3426
+ import fs9 from "fs";
3427
+ import path10 from "path";
3428
+ function shadcnStatus(root) {
3429
+ const file = path10.join(root, "components.json");
3430
+ if (!fs9.existsSync(file)) return { initialized: false, uiDir: null, installed: [] };
3431
+ let ui = "@/components/ui";
3432
+ try {
3433
+ ui = JSON.parse(fs9.readFileSync(file, "utf8"))?.aliases?.ui ?? ui;
3434
+ } catch {
3435
+ }
3436
+ const dir = resolveAliasPath(root, ui);
3437
+ let installed = [];
3438
+ try {
3439
+ installed = fs9.readdirSync(dir).filter((f) => /\.(tsx|jsx)$/.test(f)).map((f) => f.replace(/\.(tsx|jsx)$/, "")).sort();
3440
+ } catch {
3441
+ installed = [];
3442
+ }
3443
+ return { initialized: true, uiDir: posix(path10.relative(root, dir)), installed };
3444
+ }
3445
+ var FALLBACK = "accordion alert alert-dialog aspect-ratio avatar badge breadcrumb button button-group calendar card carousel chart checkbox collapsible combobox command context-menu dialog drawer dropdown-menu empty field form hover-card input input-group input-otp item kbd label menubar navigation-menu pagination popover progress radio-group resizable scroll-area select separator sheet sidebar skeleton slider sonner spinner switch table tabs textarea toggle toggle-group tooltip".split(" ");
3446
+ var registryCache = null;
3447
+ async function shadcnRegistry() {
3448
+ if (registryCache && Date.now() - registryCache.at < 36e5) return { names: registryCache.names, offline: false };
3449
+ try {
3450
+ const res = await fetch("https://ui.shadcn.com/r/index.json", { signal: AbortSignal.timeout(8e3) });
3451
+ if (!res.ok) throw new Error(String(res.status));
3452
+ const items = await res.json();
3453
+ const names = items.filter((i) => i.type === "registry:ui" && VALID.test(i.name)).map((i) => i.name).sort();
3454
+ if (!names.length) throw new Error("empty registry");
3455
+ registryCache = { at: Date.now(), names };
3456
+ return { names, offline: false };
3457
+ } catch {
3458
+ return { names: FALLBACK, offline: true };
3459
+ }
3460
+ }
3461
+ var VALID = /^[a-z0-9][a-z0-9-]*$/;
3462
+ async function shadcnInit(root) {
3463
+ const [cmd, args] = dlx(detectPm(root), "shadcn@latest", ["init", "--defaults", "--yes", "--no-monorepo"]);
3464
+ return run2(cmd, args, root);
3465
+ }
3466
+ async function shadcnAdd(root, names) {
3467
+ const bad = names.filter((n) => !VALID.test(n));
3468
+ if (bad.length) return { ok: false, out: `Not a component name: ${bad.join(", ")}`, added: [] };
3469
+ const before = new Set(shadcnStatus(root).installed);
3470
+ let log = "";
3471
+ if (!shadcnStatus(root).initialized) {
3472
+ const init = await shadcnInit(root);
3473
+ log += init.out;
3474
+ if (!init.ok) return { ok: false, out: log, added: [] };
3475
+ }
3476
+ const wanted = names.filter((n) => !before.has(n));
3477
+ if (wanted.length) {
3478
+ const [cmd, args] = dlx(detectPm(root), "shadcn@latest", ["add", ...wanted, "--yes"]);
3479
+ const res = await run2(cmd, args, root);
3480
+ log += res.out;
3481
+ if (!res.ok) return { ok: false, out: log, added: [] };
3482
+ }
3483
+ const added = shadcnStatus(root).installed.filter((n) => !before.has(n));
3484
+ return { ok: true, out: log, added };
3485
+ }
3486
+
3100
3487
  // src/core/outline.ts
3101
3488
  function propText(name, v) {
3102
3489
  if (v.kind === "boolean") return v.value ? name : `${name}={false}`;
@@ -3185,8 +3572,8 @@ function describeComponent(c, verbose = false) {
3185
3572
  }
3186
3573
 
3187
3574
  // src/core/tokens.ts
3188
- import fs9 from "fs";
3189
- import path10 from "path";
3575
+ import fs10 from "fs";
3576
+ import path11 from "path";
3190
3577
  import { globSync } from "tinyglobby";
3191
3578
  var GROUPS = [
3192
3579
  ["--color-", "Colors", "bg-{name} text-{name} border-{name}"],
@@ -3203,9 +3590,9 @@ function readDesignTokens(root) {
3203
3590
  const sources = [];
3204
3591
  let darkVariant = null;
3205
3592
  for (const file of files) {
3206
- const css = fs9.readFileSync(file, "utf8");
3593
+ const css = fs10.readFileSync(file, "utf8");
3207
3594
  if (!css.includes("@theme") && !css.includes("@custom-variant")) continue;
3208
- sources.push(path10.relative(root, file));
3595
+ sources.push(posix(path11.relative(root, file)));
3209
3596
  const dv = /@custom-variant\s+dark\s*\(([^;]+)\);/.exec(css);
3210
3597
  if (dv) darkVariant = dv[1].trim();
3211
3598
  for (const block of css.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) {
@@ -3244,7 +3631,7 @@ function readTokenData(root) {
3244
3631
  for (const m of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) into.set(m[1], m[2].trim());
3245
3632
  };
3246
3633
  for (const file of files) {
3247
- const css = fs9.readFileSync(file, "utf8");
3634
+ const css = fs10.readFileSync(file, "utf8");
3248
3635
  for (const block of css.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) decls(block[1], theme);
3249
3636
  for (const block of css.matchAll(/(?:^|\n)\s*:root\s*\{([\s\S]*?)\n\}/g)) decls(block[1], lightVars);
3250
3637
  for (const block of css.matchAll(/(?:^|\n)\s*(?:\.dark|\[data-theme=["']?dark["']?\]|:root\.dark)\s*\{([\s\S]*?)\n\}/g)) decls(block[1], darkVars);
@@ -3276,30 +3663,30 @@ function readTokenData(root) {
3276
3663
  }
3277
3664
 
3278
3665
  // src/core/compare.ts
3279
- import fs10 from "fs";
3280
- import path11 from "path";
3666
+ import fs11 from "fs";
3667
+ import path12 from "path";
3281
3668
  var COMPARE_PREFIX = "__compare__";
3282
3669
  function writeCompare(config, canvas, source, files = {}, as = canvas) {
3283
- const routeDir = path11.join(config.root, config.appDir, "truecanvas");
3284
- const dir = path11.join(routeDir, "compare");
3285
- fs10.mkdirSync(dir, { recursive: true });
3286
- const canvasDir = path11.join(config.root, config.canvasDir);
3287
- const canvasRel = path11.join(config.canvasDir, `${canvas}.canvas.tsx`);
3670
+ const routeDir = routeDirOf(config);
3671
+ const dir = path12.join(routeDir, "compare");
3672
+ fs11.mkdirSync(dir, { recursive: true });
3673
+ const canvasDir = path12.join(config.root, config.canvasDir);
3674
+ const canvasRel = `${config.canvasDir}/${canvas}.canvas.tsx`;
3288
3675
  const swapped = /* @__PURE__ */ new Map();
3289
3676
  for (const [rel, content] of Object.entries(files)) {
3290
- const ext = path11.extname(rel);
3677
+ const ext = path12.extname(rel);
3291
3678
  const name = `${as}--${rel.slice(0, -ext.length).replace(/[^\w.-]+/g, "_")}${ext}`;
3292
- const original = path11.join(config.root, rel);
3293
- fs10.writeFileSync(path11.join(dir, name), rewriteImports(content, (spec) => spec.startsWith(".") ? relativeSpec(dir, path11.resolve(path11.dirname(original), spec)) : spec));
3679
+ const original = path12.join(config.root, rel);
3680
+ fs11.writeFileSync(path12.join(dir, name), rewriteImports(content, (spec) => spec.startsWith(".") ? relativeSpec(dir, path12.resolve(path12.dirname(original), spec)) : spec));
3294
3681
  swapped.set(importSpecifier(config.root, canvasRel, rel), `./${name.slice(0, -ext.length)}`);
3295
3682
  }
3296
- const out = rewriteImports(source, (spec) => swapped.get(spec) ?? (spec.startsWith(".") ? relativeSpec(dir, path11.resolve(canvasDir, spec)) : spec));
3297
- fs10.writeFileSync(path11.join(dir, `${as}.canvas.tsx`), out);
3683
+ const out = rewriteImports(source, (spec) => swapped.get(spec) ?? (spec.startsWith(".") ? relativeSpec(dir, path12.resolve(canvasDir, spec)) : spec));
3684
+ fs11.writeFileSync(path12.join(dir, `${as}.canvas.tsx`), out);
3298
3685
  syncRoute(config);
3299
3686
  return `${COMPARE_PREFIX}${as}`;
3300
3687
  }
3301
3688
  function relativeSpec(fromDir, target) {
3302
- const next = path11.relative(fromDir, target);
3689
+ const next = posix(path12.relative(fromDir, target));
3303
3690
  return next.startsWith(".") ? next : `./${next}`;
3304
3691
  }
3305
3692
  function rewriteImports(source, map) {
@@ -3308,15 +3695,16 @@ function rewriteImports(source, map) {
3308
3695
  for (const imp of [...doc.imports].sort((a, b) => b.start - a.start)) {
3309
3696
  const next = map(imp.source);
3310
3697
  if (next === imp.source) continue;
3311
- const stmt = out.slice(imp.start, imp.end).replace(JSON.stringify(imp.source), JSON.stringify(next)).replace(`'${imp.source}'`, `'${next}'`);
3698
+ const stmt = rewriteImportSource(out.slice(imp.start, imp.end), imp.source, next);
3312
3699
  out = out.slice(0, imp.start) + stmt + out.slice(imp.end);
3313
3700
  }
3314
3701
  return out;
3315
3702
  }
3316
3703
  function clearCompare(config, canvas) {
3317
- const dir = path11.join(config.root, config.appDir, "truecanvas", "compare");
3318
- if (!fs10.existsSync(dir)) return;
3319
- for (const f of fs10.readdirSync(dir)) if (!canvas || f === `${canvas}.canvas.tsx` || f.startsWith(`${canvas}--`)) fs10.rmSync(path11.join(dir, f));
3704
+ if (canvas) assertCanvasName(canvas);
3705
+ const dir = path12.join(routeDirOf(config), "compare");
3706
+ if (!fs11.existsSync(dir)) return;
3707
+ for (const f of fs11.readdirSync(dir)) if (!canvas || f === `${canvas}.canvas.tsx` || f.startsWith(`${canvas}--`)) fs11.rmSync(path12.join(dir, f));
3320
3708
  syncRoute(config);
3321
3709
  }
3322
3710
  function frameChanges(before, after) {
@@ -3332,11 +3720,189 @@ function frameChanges(before, after) {
3332
3720
  return out;
3333
3721
  }
3334
3722
 
3723
+ // src/core/init.ts
3724
+ import fs12 from "fs";
3725
+ import MagicString2 from "magic-string";
3726
+ import path13 from "path";
3727
+ var CONFIG_FILES = {
3728
+ next: ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.mts"],
3729
+ vite: ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"]
3730
+ };
3731
+ function projectFramework(root) {
3732
+ try {
3733
+ const pkg = JSON.parse(fs12.readFileSync(path13.join(root, "package.json"), "utf8"));
3734
+ return detectFramework({ ...pkg.dependencies, ...pkg.devDependencies });
3735
+ } catch {
3736
+ return "next";
3737
+ }
3738
+ }
3739
+ function configStatus(root, framework = projectFramework(root)) {
3740
+ const marker = framework === "vite" ? "truecanvas/vite" : "withTruecanvas";
3741
+ for (const name of CONFIG_FILES[framework]) {
3742
+ const file = path13.join(root, name);
3743
+ if (fs12.existsSync(file)) return { file, wrapped: fs12.readFileSync(file, "utf8").includes(marker), framework };
3744
+ }
3745
+ return { file: null, wrapped: false, framework };
3746
+ }
3747
+ function initProject(config) {
3748
+ const { root } = config;
3749
+ const report = { done: [], todo: [] };
3750
+ const vite = config.framework === "vite";
3751
+ const status = configStatus(root, config.framework);
3752
+ if (vite) {
3753
+ const name = status.file ? path13.basename(status.file) : "vite.config.ts";
3754
+ if (status.wrapped) report.done.push(`${name} already uses the truecanvas() plugin`);
3755
+ else {
3756
+ const src = status.file ? fs12.readFileSync(status.file, "utf8") : null;
3757
+ const next = src && addVitePlugin(src);
3758
+ if (next) {
3759
+ fs12.writeFileSync(status.file, next);
3760
+ report.done.push(`Added the truecanvas() plugin to ${name}`);
3761
+ } else report.todo.push(`Add truecanvas() from "truecanvas/vite" to the plugins in ${name}`);
3762
+ }
3763
+ } else if (!status.file) {
3764
+ const file = path13.join(root, "next.config.ts");
3765
+ fs12.writeFileSync(file, `import type { NextConfig } from "next";
3766
+ import { withTruecanvas } from "truecanvas/next";
3767
+
3768
+ const nextConfig: NextConfig = {};
3769
+
3770
+ export default withTruecanvas(nextConfig);
3771
+ `);
3772
+ report.done.push("Created next.config.ts with withTruecanvas()");
3773
+ } else if (status.wrapped) {
3774
+ report.done.push(`${path13.basename(status.file)} already uses withTruecanvas()`);
3775
+ } else {
3776
+ const src = fs12.readFileSync(status.file, "utf8");
3777
+ const next = wrapConfigExport(src);
3778
+ if (next && next !== src && next.includes("withTruecanvas(")) {
3779
+ fs12.writeFileSync(status.file, next);
3780
+ report.done.push(`Wrapped ${path13.basename(status.file)} with withTruecanvas()`);
3781
+ } else {
3782
+ report.todo.push(`Wrap your ${path13.basename(status.file)} export with withTruecanvas() from "truecanvas/next"`);
3783
+ }
3784
+ }
3785
+ const pkgFile = path13.join(root, "package.json");
3786
+ try {
3787
+ const raw = fs12.readFileSync(pkgFile, "utf8");
3788
+ const pkg = JSON.parse(raw);
3789
+ pkg.scripts ??= {};
3790
+ if (!Object.values(pkg.scripts).some((v) => typeof v === "string" && v.includes("truecanvas"))) {
3791
+ pkg.scripts.canvas = "truecanvas dev";
3792
+ const indent = /\n(\s+)"/.exec(raw)?.[1] ?? " ";
3793
+ fs12.writeFileSync(pkgFile, `${JSON.stringify(pkg, null, indent)}
3794
+ `);
3795
+ report.done.push(`Added the "canvas" script (npm run canvas)`);
3796
+ }
3797
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
3798
+ if (!("tailwindcss" in deps)) report.todo.push("Tailwind CSS isn't installed: layout and style controls write Tailwind classes");
3799
+ if (!("next" in deps) && !("vite" in deps)) report.todo.push("Neither Next.js nor Vite is in package.json: Truecanvas needs a Next.js (App Router) or Vite + React app");
3800
+ } catch {
3801
+ report.todo.push("Couldn't read package.json");
3802
+ }
3803
+ const ignoreFile = path13.join(root, ".gitignore");
3804
+ const entry = `/${config.routeDir}/`;
3805
+ const ignore = fs12.existsSync(ignoreFile) ? fs12.readFileSync(ignoreFile, "utf8") : "";
3806
+ if (!ignore.split("\n").some((l) => l.trim() === entry || l.trim() === entry.slice(1))) {
3807
+ fs12.writeFileSync(ignoreFile, `${ignore}${ignore && !ignore.endsWith("\n") ? "\n" : ""}
3808
+ # Truecanvas (generated dev-only route)
3809
+ ${entry}
3810
+ `);
3811
+ report.done.push(`Ignored ${entry} in .gitignore`);
3812
+ }
3813
+ for (const name of ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.mjs", "tailwind.config.cjs"]) {
3814
+ const file = path13.join(root, name);
3815
+ if (!fs12.existsSync(file)) continue;
3816
+ const src = fs12.readFileSync(file, "utf8");
3817
+ const glob = `./${config.canvasDir}/**/*.{ts,tsx}`;
3818
+ if (src.includes(config.canvasDir)) break;
3819
+ const m = /content\s*:\s*\[([ \t]*\n?)([ \t]*)/.exec(src);
3820
+ if (m) {
3821
+ const indent = m[2] || " ";
3822
+ const at = m.index + m[0].length;
3823
+ const entry2 = m[1].includes("\n") ? `${JSON.stringify(glob)},
3824
+ ${indent}` : `${JSON.stringify(glob)}, `;
3825
+ fs12.writeFileSync(file, src.slice(0, at) + entry2 + src.slice(at));
3826
+ report.done.push(`Added ${config.canvasDir} to Tailwind's content in ${name}`);
3827
+ } else report.todo.push(`Add "${glob}" to the content list in ${name} so Tailwind styles your canvases`);
3828
+ break;
3829
+ }
3830
+ if (!vite && !fs12.existsSync(path13.join(root, config.appDir))) report.todo.push(`No ${config.appDir}/ folder found: Truecanvas needs the Next.js App Router`);
3831
+ return report;
3832
+ }
3833
+ function addImport(src, line) {
3834
+ const lines = src.split("\n");
3835
+ let at = 0;
3836
+ lines.forEach((l, i) => {
3837
+ if (/^\s*import\s/.test(l) || /^\s*["']use (client|server)["']/.test(l)) at = i + 1;
3838
+ });
3839
+ while (at > 0 && at < lines.length && !/;\s*$|from\s+["'][^"']+["']\s*;?\s*$/.test(lines[at - 1])) at++;
3840
+ lines.splice(at, 0, line);
3841
+ return lines.join("\n");
3842
+ }
3843
+ function wrapConfigExport(src) {
3844
+ let ast;
3845
+ try {
3846
+ ast = parseModule(src);
3847
+ } catch {
3848
+ return null;
3849
+ }
3850
+ const s = new MagicString2(src);
3851
+ for (const stmt of ast.program.body) {
3852
+ if (stmt.type === "ExportDefaultDeclaration") {
3853
+ const d = stmt.declaration;
3854
+ if (d.type === "FunctionDeclaration" && d.id) {
3855
+ s.remove(stmt.start, d.start);
3856
+ s.appendLeft(stmt.end, `
3857
+
3858
+ export default withTruecanvas(${d.id.name});`);
3859
+ } else if (d.type === "FunctionDeclaration" || d.type === "ArrowFunctionExpression" || d.type.endsWith("Expression") || d.type === "Identifier") {
3860
+ s.appendLeft(d.start, "withTruecanvas(");
3861
+ s.appendRight(d.end, ")");
3862
+ } else return null;
3863
+ return addImport(s.toString(), `import { withTruecanvas } from "truecanvas/next";`);
3864
+ }
3865
+ if (stmt.type === "ExpressionStatement" && stmt.expression.type === "AssignmentExpression") {
3866
+ const { left, right } = stmt.expression;
3867
+ const isModuleExports = left.type === "MemberExpression" && left.object.type === "Identifier" && left.object.name === "module" && left.property.type === "Identifier" && left.property.name === "exports";
3868
+ if (!isModuleExports) continue;
3869
+ s.appendLeft(right.start, "withTruecanvas(");
3870
+ s.appendRight(right.end, ")");
3871
+ return `const { withTruecanvas } = require("truecanvas/next");
3872
+ ${s.toString()}`;
3873
+ }
3874
+ }
3875
+ return null;
3876
+ }
3877
+ function addVitePlugin(src) {
3878
+ let ast;
3879
+ try {
3880
+ ast = parseModule(src);
3881
+ } catch {
3882
+ return null;
3883
+ }
3884
+ let plugins = null;
3885
+ walk(ast, (node) => {
3886
+ if (plugins) return false;
3887
+ if (node.type !== "ObjectProperty" || node.value.type !== "ArrayExpression") return;
3888
+ const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "StringLiteral" ? node.key.value : null;
3889
+ if (key !== "plugins") return;
3890
+ plugins = { start: node.value.start, end: node.value.end, elements: node.value.elements.filter((e) => e !== null).map((e) => ({ start: e.start, end: e.end })) };
3891
+ return false;
3892
+ });
3893
+ if (!plugins) return null;
3894
+ const { start, end, elements } = plugins;
3895
+ const s = new MagicString2(src);
3896
+ if (!elements.length) s.overwrite(start, end, "[truecanvas()]");
3897
+ else {
3898
+ const last = elements[elements.length - 1];
3899
+ const before = src.slice(start + 1, elements[0].start);
3900
+ s.appendLeft(last.end, before.includes("\n") ? `,${before.replace(/^[^\n]*/, "")}truecanvas()` : ", truecanvas()");
3901
+ }
3902
+ return addImport(s.toString(), `import { truecanvas } from "truecanvas/vite";`);
3903
+ }
3904
+
3335
3905
  export {
3336
- loadConfig,
3337
- parseModule,
3338
- idOf,
3339
- walk,
3340
3906
  parseCanvas,
3341
3907
  allNodes,
3342
3908
  findNode,
@@ -3351,16 +3917,32 @@ export {
3351
3917
  componentsDir,
3352
3918
  DEVICES,
3353
3919
  findDevice,
3920
+ assertCanvasName,
3921
+ syncRoute,
3922
+ viteStylesheets,
3923
+ syncComponentRegistry,
3354
3924
  Comments,
3355
3925
  Git,
3356
- syncComponentRegistry,
3357
3926
  Workspace,
3358
3927
  componentJsx,
3928
+ detectPm,
3929
+ addArgs,
3930
+ toolEnv,
3931
+ shellSafe,
3932
+ run2 as run,
3933
+ shadcnStatus,
3934
+ shadcnRegistry,
3935
+ shadcnInit,
3936
+ shadcnAdd,
3359
3937
  outlineDoc,
3360
3938
  describeComponent,
3361
3939
  readDesignTokens,
3362
3940
  readTokenData,
3363
3941
  writeCompare,
3364
3942
  clearCompare,
3365
- frameChanges
3943
+ frameChanges,
3944
+ configStatus,
3945
+ initProject,
3946
+ wrapConfigExport,
3947
+ addVitePlugin
3366
3948
  };