truecanvas 0.1.0 → 0.2.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,7 +1,14 @@
1
1
  import {
2
2
  Catalog,
3
- importSpecifier
4
- } from "./chunk-753N4QRO.js";
3
+ importSpecifier,
4
+ resolveAliasPath
5
+ } from "./chunk-T2TCBSTG.js";
6
+ import {
7
+ ICON_LIBRARIES,
8
+ iconLibrary,
9
+ loadIcons,
10
+ searchIcons
11
+ } from "./chunk-C4SATRZT.js";
5
12
 
6
13
  // src/core/config.ts
7
14
  import fs from "fs";
@@ -73,6 +80,15 @@ function walk(node, visit, parent = null) {
73
80
  function isComponentName(name) {
74
81
  return /^[A-Z]/.test(name) || name.includes(".");
75
82
  }
83
+ function rewriteImportSource(statement, from, to) {
84
+ if (from === to) return statement;
85
+ for (const q of ['"', "'"]) {
86
+ const needle = `${q}${from}${q}`;
87
+ const i = statement.indexOf(needle);
88
+ if (i >= 0) return `${statement.slice(0, i)}${q}${to}${q}${statement.slice(i + needle.length)}`;
89
+ }
90
+ return statement;
91
+ }
76
92
 
77
93
  // src/core/parse.ts
78
94
  function parseCanvas(name, file, source) {
@@ -102,8 +118,8 @@ function parseCanvas(name, file, source) {
102
118
  let frameIndex = 0;
103
119
  for (const child of canvasNode.children) {
104
120
  if (child.kind !== "component" || child.name !== "Frame") continue;
105
- const path12 = [frameIndex++];
106
- reindex(child, path12);
121
+ const path15 = [frameIndex++];
122
+ reindex(child, path15);
107
123
  frames.push({
108
124
  ...child,
109
125
  frameName: str(child.props.name) ?? `Frame ${frameIndex}`,
@@ -134,6 +150,7 @@ function readImports(ast) {
134
150
  typeOnly,
135
151
  // value bindings only: `import type` and `{ type X }` don't exist at runtime
136
152
  names: typeOnly ? [] : [...named.filter((s) => s.importKind !== "type").map((s) => s.local.name), ...ns ? [ns.local.name] : []],
153
+ 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
154
  defaultName: def ? def.local.name : null,
138
155
  start: stmt.start,
139
156
  end: stmt.end,
@@ -153,9 +170,9 @@ function topLevelNames(ast) {
153
170
  }
154
171
  return out;
155
172
  }
156
- function reindex(node, path12) {
157
- node.path = path12;
158
- node.children.forEach((c, i) => reindex(c, [...path12, i]));
173
+ function reindex(node, path15) {
174
+ node.path = path15;
175
+ node.children.forEach((c, i) => reindex(c, [...path15, i]));
159
176
  }
160
177
  function str(v) {
161
178
  return v?.kind === "string" ? v.value : void 0;
@@ -163,10 +180,10 @@ function str(v) {
163
180
  function num(v) {
164
181
  return v?.kind === "number" ? v.value : void 0;
165
182
  }
166
- function buildNode(el, source, path12) {
183
+ function buildNode(el, source, path15) {
167
184
  const children = [];
168
185
  for (const child of el.children) {
169
- const built = buildChild(child, source, [...path12, children.length]);
186
+ const built = buildChild(child, source, [...path15, children.length]);
170
187
  if (built) children.push(built);
171
188
  }
172
189
  if (el.type === "JSXFragment") {
@@ -181,7 +198,7 @@ function buildNode(el, source, path12) {
181
198
  end: el.end,
182
199
  line: el.loc.start.line,
183
200
  col: el.loc.start.column,
184
- path: path12,
201
+ path: path15,
185
202
  attrs: [],
186
203
  nameEnd: el.openingFragment.end - 1,
187
204
  openEnd: el.openingFragment.end,
@@ -216,7 +233,7 @@ function buildNode(el, source, path12) {
216
233
  end: el.end,
217
234
  line: open.loc.start.line,
218
235
  col: open.loc.start.column,
219
- path: path12,
236
+ path: path15,
220
237
  attrs,
221
238
  // after type arguments: `<Select<Opt> …>`
222
239
  nameEnd: (open.typeParameters ?? open.name).end,
@@ -225,27 +242,27 @@ function buildNode(el, source, path12) {
225
242
  closeStart: el.closingElement ? el.closingElement.start : open.end
226
243
  };
227
244
  }
228
- function buildChild(child, source, path12) {
229
- if (child.type === "JSXElement" || child.type === "JSXFragment") return buildNode(child, source, path12);
245
+ function buildChild(child, source, path15) {
246
+ if (child.type === "JSXElement" || child.type === "JSXFragment") return buildNode(child, source, path15);
230
247
  if (child.type === "JSXText") {
231
248
  const text = child.value.replace(/\s+/g, " ").trim();
232
249
  if (!text) return null;
233
- return leaf("text", text, child, path12, { text });
250
+ return leaf("text", text, child, path15, { text });
234
251
  }
235
252
  if (child.type === "JSXExpressionContainer") {
236
253
  const expr = child.expression;
237
254
  if (expr.type === "JSXEmptyExpression") return null;
238
- if (expr.type === "StringLiteral") return leaf("text", expr.value, child, path12, { text: expr.value });
255
+ if (expr.type === "StringLiteral") return leaf("text", expr.value, child, path15, { text: expr.value });
239
256
  if (expr.type === "TemplateLiteral" && expr.expressions.length === 0) {
240
257
  const text = expr.quasis.map((q) => q.value.cooked ?? "").join("");
241
- return leaf("text", text, child, path12, { text });
258
+ return leaf("text", text, child, path15, { text });
242
259
  }
243
- return leaf("expression", source.slice(expr.start, expr.end), child, path12, {});
260
+ return leaf("expression", source.slice(expr.start, expr.end), child, path15, {});
244
261
  }
245
- if (child.type === "JSXSpreadChild") return leaf("expression", source.slice(child.start, child.end), child, path12, {});
262
+ if (child.type === "JSXSpreadChild") return leaf("expression", source.slice(child.start, child.end), child, path15, {});
246
263
  return null;
247
264
  }
248
- function leaf(kind, name, node, path12, extra) {
265
+ function leaf(kind, name, node, path15, extra) {
249
266
  return {
250
267
  id: `${kind[0]}${idOf(node)}`,
251
268
  kind,
@@ -258,7 +275,7 @@ function leaf(kind, name, node, path12, extra) {
258
275
  end: node.end,
259
276
  line: node.loc.start.line,
260
277
  col: node.loc.start.column,
261
- path: path12,
278
+ path: path15,
262
279
  attrs: [],
263
280
  nameEnd: node.end,
264
281
  openEnd: node.end,
@@ -322,10 +339,10 @@ function findNode(doc, id) {
322
339
  function findFrame(doc, ref) {
323
340
  return doc.frames.find((f) => f.id === ref || f.frameName === ref) ?? null;
324
341
  }
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];
342
+ function findByPath(doc, path15) {
343
+ if (!path15.length) return null;
344
+ let node = doc.frames[path15[0]];
345
+ for (const i of path15.slice(1)) node = node?.children[i];
329
346
  return node ?? null;
330
347
  }
331
348
 
@@ -418,7 +435,8 @@ var CanvasEditor = class {
418
435
  } else if (node.openEnd === node.closeStart) {
419
436
  this.s.appendLeft(node.openEnd, code);
420
437
  } else {
421
- this.s.overwrite(node.openEnd, node.closeStart, code);
438
+ const comments = this.source.slice(node.openEnd, node.closeStart).match(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g) ?? [];
439
+ this.s.overwrite(node.openEnd, node.closeStart, comments.join("") + code);
422
440
  }
423
441
  }
424
442
  // ---------- indentation helpers: edits match the file's existing style ----------
@@ -454,13 +472,20 @@ var CanvasEditor = class {
454
472
  }
455
473
  return this.lineIndent(parent.start) + this.unit();
456
474
  }
457
- /** Re-indents lines 2..n of a snippet so it sits at `indent`. */
475
+ /**
476
+ * Re-indents lines 2..n of a snippet so it sits at `indent`. Lines that start
477
+ * inside a multi-line string or template literal are content, not code: they
478
+ * are kept exactly as written.
479
+ */
458
480
  shift(code, indent) {
459
- const lines = code.trim().split("\n");
460
- const rest = lines.slice(1).filter((l) => l.trim());
481
+ const trimmed = code.trim();
482
+ const lines = trimmed.split("\n");
483
+ const frozen = literalLines(trimmed);
484
+ const free = (i) => !frozen.has(i);
485
+ const rest = lines.slice(1).filter((l, i) => l.trim() && free(i + 1));
461
486
  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);
487
+ const relative = lines.slice(1).map((l, i) => free(i + 1) ? l.trim() ? l.slice(base) : "" : l);
488
+ const steps = relative.filter((_, i) => free(i + 1)).map((l) => /^ */.exec(l)[0].length).filter((n) => n > 0);
464
489
  const from = steps.length ? Math.min(...steps) : 0;
465
490
  const unit = this.unit();
466
491
  const convert = (l) => {
@@ -468,7 +493,7 @@ var CanvasEditor = class {
468
493
  const n = /^ */.exec(l)[0].length;
469
494
  return unit.repeat(Math.floor(n / from)) + " ".repeat(n % from) + l.slice(n);
470
495
  };
471
- return [lines[0], ...relative.map((l) => l ? indent + convert(l) : "")].join("\n");
496
+ return [lines[0], ...relative.map((l, i) => !free(i + 1) ? l : l ? indent + convert(l) : "")].join("\n");
472
497
  }
473
498
  openToClose(node, inner) {
474
499
  let end = node.openEnd - 2;
@@ -532,16 +557,18 @@ ${pi}`);
532
557
  this.placed.push({ x: right + 80, y: f.y, width: f.width, height: h });
533
558
  code = rewriteFrameProps(code, { name, x: right + 80 });
534
559
  }
560
+ const marked = withMarker(code.trim(), node);
535
561
  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);
562
+ ${this.lineIndent(node.start)}${marked}`);
563
+ if (marked === code.trim()) {
564
+ const path15 = [...node.path];
565
+ path15[path15.length - 1] += 1;
566
+ this.selectPaths.push(path15);
567
+ }
540
568
  }
541
569
  move(node, target, index) {
542
570
  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;
571
+ const code = withMarker(this.source.slice(this.contentStart(node), this.contentEnd(node)), node);
545
572
  const kids = target.children.filter((c) => c.id !== node.id);
546
573
  const i = index === void 0 || index < 0 || index > kids.length ? kids.length : index;
547
574
  this.remove(node);
@@ -583,7 +610,7 @@ ${ind}${close}`);
583
610
  this.remove(node);
584
611
  return;
585
612
  }
586
- const inner = this.source.slice(this.contentStart(kids[0]), this.contentEnd(kids[kids.length - 1]));
613
+ const inner = node.selfClosing ? "" : this.source.slice(node.openEnd, node.closeStart).trim();
587
614
  this.s.overwrite(this.contentStart(node), this.contentEnd(node), this.shift(inner, this.lineIndent(this.contentStart(node))));
588
615
  this.selectPaths.push(node.path);
589
616
  }
@@ -601,8 +628,36 @@ ${ind}${close}`);
601
628
  this.s.appendLeft(same.lastSpecifierEnd, `, ${name}`);
602
629
  return;
603
630
  }
631
+ this.addImportLine(`import { ${name} } from ${JSON.stringify(from)};`);
632
+ }
633
+ /**
634
+ * Imports `name` from `from` and returns the local name to write in JSX. When
635
+ * the name is already taken by something else (another import, a local
636
+ * component), it is imported under `alias` instead: `{ Search as SearchIcon }`.
637
+ */
638
+ importAs(name, from, alias) {
639
+ const existing = this.doc.imports.find((i) => i.source === from && !i.typeOnly)?.bindings.find((b) => b.imported === name);
640
+ if (existing) return existing.local;
641
+ const key = `${from}\0${name}`;
642
+ const pending = this.importedAs.get(key);
643
+ if (pending) return pending;
644
+ const taken = (n) => this.doc.declared.includes(n) || this.added.has(n) || this.doc.imports.some((i) => i.names.includes(n) || i.defaultName === n);
645
+ let local = name;
646
+ if (taken(local)) {
647
+ local = alias;
648
+ for (let k = 2; taken(local); k++) local = `${alias}${k}`;
649
+ }
650
+ this.added.add(local);
651
+ this.importedAs.set(key, local);
652
+ const specifier = local === name ? name : `${name} as ${local}`;
653
+ const same = this.doc.imports.find((i) => i.source === from && !i.typeOnly && i.lastSpecifierEnd !== null);
654
+ if (same) this.s.appendLeft(same.lastSpecifierEnd, `, ${specifier}`);
655
+ else this.addImportLine(`import { ${specifier} } from ${JSON.stringify(from)};`);
656
+ return local;
657
+ }
658
+ importedAs = /* @__PURE__ */ new Map();
659
+ addImportLine(line) {
604
660
  const last = this.doc.imports[this.doc.imports.length - 1];
605
- const line = `import { ${name} } from ${JSON.stringify(from)};`;
606
661
  if (last) this.s.appendLeft(last.end, `
607
662
  ${line}`);
608
663
  else if (this.doc.prologueEnd) this.s.appendLeft(this.doc.prologueEnd, `
@@ -622,12 +677,7 @@ ${line}`);
622
677
  const known = /* @__PURE__ */ new Set([...this.doc.imports.flatMap((i) => [...i.names, ...i.defaultName ? [i.defaultName] : []]), ...this.added]);
623
678
  if (names.length && names.every((n) => known.has(n))) return;
624
679
  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);
680
+ this.addImportLine(statement);
631
681
  }
632
682
  importsPrepended = false;
633
683
  /** First imports of a file: in order, with a blank line before the code. */
@@ -683,6 +733,43 @@ function componentNamesIn(jsx) {
683
733
  });
684
734
  return [...names];
685
735
  }
736
+ function withMarker(code, node) {
737
+ if (node.kind !== "component" && node.kind !== "element") return code;
738
+ const m = /^<[\w.:-]+/.exec(code);
739
+ if (!m) return code;
740
+ let i = m[0].length;
741
+ if (code[i] === "<") {
742
+ for (let depth = 0; i < code.length; i++) {
743
+ if (code[i] === "<") depth++;
744
+ else if (code[i] === ">" && --depth === 0) {
745
+ i++;
746
+ break;
747
+ }
748
+ }
749
+ }
750
+ return `${code.slice(0, i)} __tcsel${code.slice(i)}`;
751
+ }
752
+ function literalLines(code) {
753
+ const out = /* @__PURE__ */ new Set();
754
+ if (!code.includes("\n") || !/[`"']/.test(code)) return out;
755
+ let ast;
756
+ try {
757
+ ast = parseModule(`<>${code}</>`);
758
+ } catch {
759
+ return out;
760
+ }
761
+ const ranges = [];
762
+ walk(ast, (n) => {
763
+ if (n.type === "TemplateLiteral" || n.type === "StringLiteral") ranges.push([n.start - 2, n.end - 2]);
764
+ });
765
+ if (!ranges.length) return out;
766
+ let offset = 0;
767
+ code.split("\n").forEach((line, i) => {
768
+ if (i > 0 && ranges.some(([a, b]) => offset > a && offset < b)) out.add(i);
769
+ offset += line.length + 1;
770
+ });
771
+ return out;
772
+ }
686
773
  function parseJsx(jsx) {
687
774
  try {
688
775
  return parseModule(`<>${jsx}</>`);
@@ -725,20 +812,208 @@ function findDevice(id) {
725
812
  }
726
813
 
727
814
  // src/core/comments.ts
815
+ import fs3 from "fs";
816
+ import path3 from "path";
817
+ import { randomUUID } from "crypto";
818
+
819
+ // src/core/scaffold.ts
728
820
  import fs2 from "fs";
729
821
  import path2 from "path";
730
- import { randomUUID } from "crypto";
822
+ var CANVAS_SUFFIX = ".canvas.tsx";
823
+ var ROUTE = "truecanvas";
824
+ function canvasFiles(config) {
825
+ const dir = path2.join(config.root, config.canvasDir);
826
+ if (!fs2.existsSync(dir)) return [];
827
+ return fs2.readdirSync(dir).filter((f) => f.endsWith(CANVAS_SUFFIX)).sort((a, b) => canvasNameOf(a).localeCompare(canvasNameOf(b)));
828
+ }
829
+ function canvasNameOf(file) {
830
+ return path2.basename(file).slice(0, -CANVAS_SUFFIX.length);
831
+ }
832
+ function assertCanvasName(name) {
833
+ if (!/^[a-z0-9][a-z0-9-_]*$/i.test(name)) throw new Error(`Invalid canvas name "${name}". Use letters, digits, - and _.`);
834
+ return name;
835
+ }
836
+ function canvasPath(config, name) {
837
+ assertCanvasName(name);
838
+ return path2.join(config.root, config.canvasDir, `${name}${CANVAS_SUFFIX}`);
839
+ }
840
+ function writeIfChanged(file, content) {
841
+ if (fs2.existsSync(file) && fs2.readFileSync(file, "utf8") === content) return false;
842
+ fs2.mkdirSync(path2.dirname(file), { recursive: true });
843
+ fs2.writeFileSync(file, content);
844
+ return true;
845
+ }
846
+ function syncRoute(config) {
847
+ const routeDir = path2.join(config.root, config.appDir, ROUTE);
848
+ const pageFile = path2.join(routeDir, "[canvas]", "page.tsx");
849
+ const registryFile = path2.join(routeDir, "canvases.tsx");
850
+ const canvasDirAbs = path2.join(config.root, config.canvasDir);
851
+ let rel = path2.relative(routeDir, canvasDirAbs);
852
+ if (!rel.startsWith(".")) rel = `./${rel}`;
853
+ const compareDir = path2.join(routeDir, "compare");
854
+ const compares = fs2.existsSync(compareDir) ? fs2.readdirSync(compareDir).filter((f) => f.endsWith(CANVAS_SUFFIX)) : [];
855
+ const entries = [
856
+ ...canvasFiles(config).map((f) => ` ${JSON.stringify(canvasNameOf(f))}: lazy(() => import(${JSON.stringify(`${rel}/${f.replace(/\.tsx$/, "")}`)})),`),
857
+ // older versions rendered for Compare
858
+ ...compares.map((f) => ` ${JSON.stringify(`__compare__${canvasNameOf(f)}`)}: lazy(() => import(${JSON.stringify(`./compare/${f.replace(/\.tsx$/, "")}`)})),`)
859
+ ].join("\n");
860
+ const registry = `"use client";
861
+ /* eslint-disable */
862
+ // Generated by Truecanvas. Do not edit: it is regenerated when canvases are added or removed.
863
+ import { lazy } from "react";
864
+ import { TruecanvasHost, type HostProps } from "truecanvas/host";
865
+ import { components } from "./components";
866
+
867
+ const canvases = {
868
+ ${entries}
869
+ };
870
+
871
+ export default function Canvases(props: Omit<HostProps, "canvases" | "components">) {
872
+ return <TruecanvasHost {...props} canvases={canvases} components={components} />;
873
+ }
874
+ `;
875
+ const dm = config.darkMode;
876
+ const page = `/* eslint-disable */
877
+ // Generated by Truecanvas. Dev-only route that renders canvas frames.
878
+ import { notFound } from "next/navigation";
879
+ import Canvases from "../canvases";
880
+
881
+ export const dynamic = "force-dynamic";
882
+
883
+ export default async function TruecanvasFrame({
884
+ params,
885
+ searchParams,
886
+ }: {
887
+ params: Promise<{ canvas: string }>;
888
+ searchParams: Promise<Record<string, string | undefined>>;
889
+ }) {
890
+ if (process.env.NODE_ENV !== "development") notFound();
891
+ const { canvas } = await params;
892
+ const query = await searchParams;
893
+ return (
894
+ <Canvases
895
+ canvas={canvas}
896
+ frame={query.frame ?? null}
897
+ theme={(query.theme as "light" | "dark" | "system" | undefined) ?? "system"}
898
+ darkMode={${JSON.stringify(dm)}}
899
+ />
900
+ );
901
+ }
902
+ `;
903
+ const componentsFile = path2.join(routeDir, "components.tsx");
904
+ if (!fs2.existsSync(componentsFile)) writeIfChanged(componentsFile, componentRegistry(config, []));
905
+ const a = writeIfChanged(registryFile, registry);
906
+ const b = writeIfChanged(pageFile, page);
907
+ return a || b;
908
+ }
909
+ var STARTER_CANVAS = `"use client";
910
+ import { Canvas, Frame } from "truecanvas";
911
+
912
+ /*
913
+ * A placeholder page. Replace the Welcome frame with your own components:
914
+ * drag them from Assets, draw frames with F, or ask your agent over MCP.
915
+ * Inline styles keep it readable in any app, with or without Tailwind.
916
+ */
917
+ export default function StarterCanvas() {
918
+ return (
919
+ <Canvas>
920
+ <Frame name="Welcome" x={0} y={0} width={1200} height={760}>
921
+ <div
922
+ style={{
923
+ height: "100%",
924
+ display: "flex",
925
+ alignItems: "center",
926
+ justifyContent: "center",
927
+ fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
928
+ color: "light-dark(#1d1d1b, #ededea)",
929
+ backgroundColor: "light-dark(#f6f5f2, #121211)",
930
+ 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%)",
931
+ backgroundSize: "22px 22px, 100% 100%",
932
+ }}
933
+ >
934
+ <div
935
+ style={{
936
+ width: 520,
937
+ padding: 40,
938
+ borderRadius: 24,
939
+ backgroundColor: "light-dark(#ffffff, #1b1b1a)",
940
+ border: "1px solid light-dark(#e9e8e4, #2a2a28)",
941
+ boxShadow: "0 1px 2px rgba(0,0,0,.04), 0 24px 60px -24px rgba(0,0,0,.25)",
942
+ }}
943
+ >
944
+ <svg width="44" height="44" viewBox="0 0 64 64" aria-hidden>
945
+ <defs>
946
+ <linearGradient id="tc-starter" x1="0" y1="0" x2="0" y2="1">
947
+ <stop offset="0" stopColor="#ff9466" />
948
+ <stop offset="1" stopColor="#ea6a3c" />
949
+ </linearGradient>
950
+ </defs>
951
+ <rect width="64" height="64" rx="15" fill="url(#tc-starter)" />
952
+ <path fill="#f6f5f1" fillRule="evenodd" d="M10.5 10.5h43v13.1h-43z M25.5 16.8h13v36.7h-13z" />
953
+ </svg>
954
+ <h1 style={{ margin: "22px 0 6px", fontSize: 26, lineHeight: "32px", fontWeight: 650, letterSpacing: "-0.02em" }}>Your canvas is ready</h1>
955
+ <p style={{ margin: 0, fontSize: 15, lineHeight: "23px", color: "light-dark(#6b6b66, #a3a39d)" }}>
956
+ Everything here is real code from your app. Replace this frame with your own components.
957
+ </p>
958
+ <div style={{ marginTop: 28, display: "flex", flexDirection: "column", gap: 14 }}>
959
+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
960
+ <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>
961
+ <span>Drag a component from your design system onto a frame</span>
962
+ </div>
963
+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
964
+ <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>
965
+ <span>Draw a frame, or pick an iPhone or desktop size</span>
966
+ </div>
967
+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 14 }}>
968
+ <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>
969
+ <span>Ask: \u201CBuild a pricing page with our components\u201D</span>
970
+ </div>
971
+ </div>
972
+ </div>
973
+ </div>
974
+ </Frame>
975
+ </Canvas>
976
+ );
977
+ }
978
+ `;
979
+ function componentRegistry(config, specs) {
980
+ const routeDir = path2.join(config.root, config.appDir, ROUTE);
981
+ const lines = specs.map((c) => {
982
+ let from = c.library;
983
+ if (!from) {
984
+ from = path2.relative(routeDir, path2.join(config.root, c.file)).replace(/\.(tsx|ts|jsx|js)$/, "");
985
+ if (!from.startsWith(".")) from = `./${from}`;
986
+ }
987
+ return ` ${JSON.stringify(c.name)}: lazy(() => import(${JSON.stringify(from)}).then((m) => ({ default: m[${JSON.stringify(c.name)}] }))),`;
988
+ });
989
+ return `"use client";
990
+ /* eslint-disable */
991
+ // Generated by Truecanvas. Do not edit: it is regenerated when components change.
992
+ import { lazy, type ComponentType } from "react";
993
+
994
+ // any: components with required props are rendered with whatever the preview passes
995
+ export const components: Record<string, ComponentType<any>> = {
996
+ ${lines.join("\n")}
997
+ };
998
+ `;
999
+ }
1000
+ function syncComponentRegistry(config, specs) {
1001
+ const file = path2.join(config.root, config.appDir, ROUTE, "components.tsx");
1002
+ return writeIfChanged(file, componentRegistry(config, specs));
1003
+ }
1004
+
1005
+ // src/core/comments.ts
731
1006
  var Comments = class {
732
1007
  constructor(config) {
733
1008
  this.config = config;
734
1009
  }
735
1010
  config;
736
1011
  file(canvas) {
737
- return path2.join(this.config.root, this.config.canvasDir, `${canvas}.comments.json`);
1012
+ return path3.join(this.config.root, this.config.canvasDir, `${assertCanvasName(canvas)}.comments.json`);
738
1013
  }
739
1014
  list(canvas) {
740
1015
  try {
741
- return JSON.parse(fs2.readFileSync(this.file(canvas), "utf8")).threads ?? [];
1016
+ return JSON.parse(fs3.readFileSync(this.file(canvas), "utf8")).threads ?? [];
742
1017
  } catch {
743
1018
  return [];
744
1019
  }
@@ -746,17 +1021,18 @@ var Comments = class {
746
1021
  save(canvas, threads) {
747
1022
  const file = this.file(canvas);
748
1023
  if (!threads.length) {
749
- if (fs2.existsSync(file)) fs2.rmSync(file);
1024
+ if (fs3.existsSync(file)) fs3.rmSync(file);
750
1025
  return;
751
1026
  }
752
- fs2.mkdirSync(path2.dirname(file), { recursive: true });
753
- fs2.writeFileSync(file, `${JSON.stringify({ version: 1, threads }, null, 2)}
1027
+ fs3.mkdirSync(path3.dirname(file), { recursive: true });
1028
+ fs3.writeFileSync(file, `${JSON.stringify({ version: 1, threads }, null, 2)}
754
1029
  `);
755
1030
  }
756
1031
  update(canvas, id, fn) {
757
1032
  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}".`);
1033
+ const prefixed = id.length >= 4 ? threads.filter((x) => x.id.startsWith(id)) : [];
1034
+ const t = threads.find((x) => x.id === id) ?? (prefixed.length === 1 ? prefixed[0] : void 0);
1035
+ if (!t) throw new Error(prefixed.length > 1 ? `"${id}" matches several comment threads. Use the full id.` : `No comment thread "${id}".`);
760
1036
  fn(t);
761
1037
  this.save(canvas, threads);
762
1038
  return t;
@@ -804,13 +1080,14 @@ var Comments = class {
804
1080
  }
805
1081
  renameCanvas(from, to) {
806
1082
  const a = this.file(from);
807
- if (fs2.existsSync(a)) fs2.renameSync(a, this.file(to));
1083
+ if (fs3.existsSync(a)) fs3.renameSync(a, this.file(to));
808
1084
  }
809
1085
  };
810
1086
 
811
1087
  // src/core/git.ts
812
1088
  import { execFile } from "child_process";
813
- import path3 from "path";
1089
+ import fs4 from "fs";
1090
+ import path4 from "path";
814
1091
  var GitError = class extends EditError {
815
1092
  };
816
1093
  function git(cwd, args, timeout = 3e4, opts = {}) {
@@ -833,6 +1110,10 @@ function run(cmd, cwd, args, timeout, opts = {}) {
833
1110
  if (opts.input !== void 0) child.stdin?.end(opts.input);
834
1111
  });
835
1112
  }
1113
+ function safeRef(ref) {
1114
+ if (!ref || ref.startsWith("-") || !/^[\w./~^@{}+-]+$/.test(ref) || ref.includes("..")) throw new GitError(`Invalid git ref "${ref}".`);
1115
+ return ref;
1116
+ }
836
1117
  var Git = class {
837
1118
  constructor(cwd) {
838
1119
  this.cwd = cwd;
@@ -845,9 +1126,11 @@ var Git = class {
845
1126
  } catch {
846
1127
  return { repo: false, root: null, branch: null, unborn: false, upstream: null, ahead: 0, behind: 0, files: [], user: null, defaultBranch: null, remote: null };
847
1128
  }
848
- const out = await git(this.cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=all", "--", "."]);
1129
+ const out = await git(this.cwd, ["status", "--porcelain=v2", "-z", "--branch", "--untracked-files=all", "--", "."]);
849
1130
  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")) {
1131
+ const entries = out.split("\0");
1132
+ for (let i = 0; i < entries.length; i++) {
1133
+ const line = entries[i];
851
1134
  if (line.startsWith("# branch.head ")) s.branch = line.slice(14) === "(detached)" ? null : line.slice(14);
852
1135
  else if (line.startsWith("# branch.oid ")) s.unborn = line.slice(13) === "(initial)";
853
1136
  else if (line.startsWith("# branch.upstream ")) s.upstream = line.slice(18);
@@ -857,9 +1140,12 @@ var Git = class {
857
1140
  } else if (line.startsWith("1 ") || line.startsWith("2 ")) {
858
1141
  const parts = line.split(" ");
859
1142
  const xy = parts[1];
860
- const file = line.startsWith("2 ") ? line.split(" ")[0].split(" ").slice(9).join(" ") : parts.slice(8).join(" ");
1143
+ const file = parts.slice(line.startsWith("2 ") ? 9 : 8).join(" ");
1144
+ if (line.startsWith("2 ")) i++;
861
1145
  const code = xy.replace(".", "")[0] ?? "M";
862
1146
  s.files.push({ path: this.rel(root, file), status: code === "A" ? "added" : code === "D" ? "deleted" : code === "R" ? "renamed" : "modified", staged: xy[0] !== "." });
1147
+ } else if (line.startsWith("u ")) {
1148
+ s.files.push({ path: this.rel(root, line.split(" ").slice(10).join(" ")), status: "conflicted", staged: false });
863
1149
  } else if (line.startsWith("? ")) s.files.push({ path: this.rel(root, line.slice(2)), status: "untracked", staged: false });
864
1150
  }
865
1151
  s.user = (await git(this.cwd, ["config", "user.name"]).catch(() => "")).trim() || null;
@@ -881,11 +1167,12 @@ var Git = class {
881
1167
  }
882
1168
  /** When a commit was made (ms). */
883
1169
  async commitAt(ref) {
884
- const out = await git(this.cwd, ["log", "-1", "--format=%ct", ref]).catch(() => "");
1170
+ const out = await git(this.cwd, ["log", "-1", "--format=%ct", safeRef(ref), "--"]).catch(() => "");
885
1171
  return Number(out.trim() || 0) * 1e3;
886
1172
  }
887
1173
  /** Commit where this branch left `base`. */
888
1174
  async mergeBase(base) {
1175
+ safeRef(base);
889
1176
  for (const ref of [`origin/${base}`, base]) {
890
1177
  const out = await git(this.cwd, ["merge-base", ref, "HEAD"]).catch(() => "");
891
1178
  if (out.trim()) return out.trim();
@@ -894,7 +1181,7 @@ var Git = class {
894
1181
  }
895
1182
  /** Files changed between a commit and HEAD, from the project root. */
896
1183
  async changedSince(ref) {
897
- const out = await git(this.cwd, ["diff", "--name-only", "--relative", `${ref}...HEAD`]).catch(() => "");
1184
+ const out = await git(this.cwd, ["diff", "--name-only", "--relative", `${safeRef(ref)}...HEAD`, "--"]).catch(() => "");
898
1185
  return out.split("\n").filter(Boolean);
899
1186
  }
900
1187
  /** Unified diff of these files against HEAD, including new files. */
@@ -911,10 +1198,11 @@ var Git = class {
911
1198
  * (used for PR screenshots), and pushes it. Returns the branch tip.
912
1199
  */
913
1200
  async commitToSideBranch(branch, files, message) {
1201
+ safeRef(branch);
914
1202
  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
1203
  const os = await import("os");
916
- const fs11 = await import("fs");
917
- const index = path3.join(os.tmpdir(), `truecanvas-index-${process.pid}-${Date.now()}`);
1204
+ const fs15 = await import("fs");
1205
+ const index = path4.join(os.tmpdir(), `truecanvas-index-${process.pid}-${Date.now()}`);
918
1206
  const env = { GIT_INDEX_FILE: index };
919
1207
  try {
920
1208
  if (tip) await git(this.cwd, ["read-tree", tip], 3e4, { env });
@@ -928,7 +1216,7 @@ var Git = class {
928
1216
  await git(this.cwd, ["update-ref", `refs/heads/${branch}`, commit]);
929
1217
  await git(this.cwd, ["push", "origin", `refs/heads/${branch}:refs/heads/${branch}`], 12e4);
930
1218
  } finally {
931
- fs11.rmSync(index, { force: true });
1219
+ fs15.rmSync(index, { force: true });
932
1220
  }
933
1221
  }
934
1222
  // ---------- GitHub (gh CLI) ----------
@@ -952,7 +1240,7 @@ var Git = class {
952
1240
  return { number: pr.number, url: pr.url, title: pr.title, state: pr.state, draft: pr.isDraft, review: pr.reviewDecision || null, checks };
953
1241
  }
954
1242
  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 });
1243
+ 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
1244
  return out.trim().split("\n").pop() ?? "";
957
1245
  }
958
1246
  /** Creates a GitHub repo for this project and pushes it (gh repo create). */
@@ -963,7 +1251,18 @@ var Git = class {
963
1251
  }
964
1252
  /** git reports paths from the repo root; we show them from the project root. */
965
1253
  rel(root, file) {
966
- return path3.relative(this.cwd, path3.join(root, file));
1254
+ return path4.relative(this.realCwd(), path4.join(root, file));
1255
+ }
1256
+ /**
1257
+ * The project folder as git sees it: git reports real paths, so a folder
1258
+ * reached through a symlink (macOS /var → /private/var) must be resolved too.
1259
+ */
1260
+ realCwd() {
1261
+ try {
1262
+ return fs4.realpathSync(this.cwd);
1263
+ } catch {
1264
+ return this.cwd;
1265
+ }
967
1266
  }
968
1267
  async branches() {
969
1268
  const out = await git(this.cwd, ["branch", "-a", "--format=%(refname)%09%(HEAD)"]).catch(() => "");
@@ -992,8 +1291,8 @@ var Git = class {
992
1291
  /** A file's content at a ref, or null if it doesn't exist there. */
993
1292
  async show(ref, file) {
994
1293
  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);
1294
+ const inRepo = path4.relative(root, path4.join(this.realCwd(), file)).split(path4.sep).join("/");
1295
+ return git(this.cwd, ["show", `${safeRef(ref)}:${inRepo}`, "--"]).catch(() => null);
997
1296
  }
998
1297
  async commit(message, files) {
999
1298
  if (!message.trim()) throw new GitError("Write a commit message.");
@@ -1015,23 +1314,23 @@ var Git = class {
1015
1314
  });
1016
1315
  }
1017
1316
  async switch(branch, create = false) {
1018
- if (!/^[\w./-]+$/.test(branch) || branch.startsWith("-")) throw new GitError("Invalid branch name.");
1317
+ safeRef(branch);
1019
1318
  await git(this.cwd, create ? ["switch", "-c", branch] : ["switch", branch]);
1020
1319
  }
1021
1320
  };
1022
1321
 
1023
1322
  // src/core/workspace.ts
1024
- import fs8 from "fs";
1323
+ import fs9 from "fs";
1025
1324
  import path9 from "path";
1026
1325
  import { createRequire } from "module";
1027
1326
 
1028
1327
  // src/core/link.ts
1029
- import fs4 from "fs";
1030
- import path5 from "path";
1328
+ import fs6 from "fs";
1329
+ import path6 from "path";
1031
1330
 
1032
1331
  // src/core/routes.ts
1033
- import fs3 from "fs";
1034
- import path4 from "path";
1332
+ import fs5 from "fs";
1333
+ import path5 from "path";
1035
1334
 
1036
1335
  // src/core/source.ts
1037
1336
  function defaultReturn(source, layout) {
@@ -1243,28 +1542,28 @@ function parseTargetDoc(file, source) {
1243
1542
  var PAGE = /^page\.(tsx|jsx|ts|js)$/;
1244
1543
  var LAYOUT = ["layout.tsx", "layout.jsx", "layout.js", "layout.ts"];
1245
1544
  function listRoutes(config) {
1246
- const appDir = path4.join(config.root, config.appDir);
1545
+ const appDir = path5.join(config.root, config.appDir);
1247
1546
  const out = [];
1248
1547
  const walkDir = (dir, segments) => {
1249
1548
  let entries;
1250
1549
  try {
1251
- entries = fs3.readdirSync(dir, { withFileTypes: true });
1550
+ entries = fs5.readdirSync(dir, { withFileTypes: true });
1252
1551
  } catch {
1253
1552
  return;
1254
1553
  }
1255
1554
  for (const e of entries) {
1256
1555
  if (e.isFile() && PAGE.test(e.name)) {
1257
1556
  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("[")) });
1557
+ out.push({ route: `/${visible.join("/")}`, file: path5.relative(config.root, path5.join(dir, e.name)), dynamic: segments.some((s) => s.startsWith("[")) });
1259
1558
  }
1260
- 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]);
1559
+ if (e.isDirectory() && !e.name.startsWith("_") && e.name !== "truecanvas" && e.name !== "api" && e.name !== "node_modules") walkDir(path5.join(dir, e.name), [...segments, e.name]);
1261
1560
  }
1262
1561
  };
1263
1562
  walkDir(appDir, []);
1264
1563
  return out.sort((a, b) => a.route.localeCompare(b.route));
1265
1564
  }
1266
1565
  function extract(file, canvasFile, config, layout) {
1267
- const source = fs3.readFileSync(file, "utf8");
1566
+ const source = fs5.readFileSync(file, "utf8");
1268
1567
  const info = defaultReturn(source, layout);
1269
1568
  if (!info?.pure) return null;
1270
1569
  const imports = [];
@@ -1274,32 +1573,32 @@ function extract(file, canvasFile, config, layout) {
1274
1573
  if (/\.(css|scss|sass|less)$/.test(src)) continue;
1275
1574
  const from = repoint(config, src, file, canvasFile);
1276
1575
  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}'`));
1576
+ imports.push(from === src ? text : rewriteImportSource(text, src, from));
1278
1577
  }
1279
1578
  return { jsx: source.slice(info.node.start, info.node.end), imports };
1280
1579
  }
1281
1580
  function repoint(config, spec, fromFile, toFile) {
1282
1581
  if (!spec.startsWith(".")) return spec;
1283
- return importSpecifier(config.root, path4.relative(config.root, toFile), path4.relative(config.root, path4.resolve(path4.dirname(fromFile), spec)));
1582
+ return importSpecifier(config.root, path5.relative(config.root, toFile), path5.relative(config.root, path5.resolve(path5.dirname(fromFile), spec)));
1284
1583
  }
1285
1584
  function layoutFiles(config, pageFile) {
1286
- const appDir = path4.join(config.root, config.appDir);
1585
+ const appDir = path5.join(config.root, config.appDir);
1287
1586
  const out = [];
1288
- for (let dir = path4.dirname(path4.resolve(config.root, pageFile)); dir.startsWith(appDir) && dir !== appDir; dir = path4.dirname(dir)) {
1289
- const file = LAYOUT.map((f) => path4.join(dir, f)).find((f) => fs3.existsSync(f));
1290
- if (file) out.push(path4.relative(config.root, file));
1587
+ for (let dir = path5.dirname(path5.resolve(config.root, pageFile)); dir.startsWith(appDir) && dir !== appDir; dir = path5.dirname(dir)) {
1588
+ const file = LAYOUT.map((f) => path5.join(dir, f)).find((f) => fs5.existsSync(f));
1589
+ if (file) out.push(path5.relative(config.root, file));
1291
1590
  }
1292
1591
  return out;
1293
1592
  }
1294
1593
  function routeOf(config, pageFile) {
1295
- const rel = path4.relative(path4.join(config.root, config.appDir), path4.dirname(path4.resolve(config.root, pageFile)));
1296
- const visible = rel.split(path4.sep).filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("@"));
1594
+ const rel = path5.relative(path5.join(config.root, config.appDir), path5.dirname(path5.resolve(config.root, pageFile)));
1595
+ const visible = rel.split(path5.sep).filter((s) => s && !/^\(.*\)$/.test(s) && !s.startsWith("@"));
1297
1596
  return `/${visible.join("/")}`;
1298
1597
  }
1299
1598
  function routeFrame(config, pageFile, canvasFile) {
1300
- const appDir = path4.join(config.root, config.appDir);
1301
- const pageAbs = path4.resolve(config.root, pageFile);
1302
- const canvasAbs = path4.resolve(config.root, canvasFile);
1599
+ const appDir = path5.join(config.root, config.appDir);
1600
+ const pageAbs = path5.resolve(config.root, pageFile);
1601
+ const canvasAbs = path5.resolve(config.root, canvasFile);
1303
1602
  const imports = [];
1304
1603
  let mode = "inline";
1305
1604
  const page = extract(pageAbs, canvasAbs, config, false);
@@ -1313,17 +1612,18 @@ function routeFrame(config, pageFile, canvasFile) {
1313
1612
  jsx = "<RoutePage />";
1314
1613
  }
1315
1614
  let i = 0;
1316
- for (let dir = path4.dirname(pageAbs); dir.startsWith(appDir) && dir !== appDir; dir = path4.dirname(dir)) {
1317
- const file = LAYOUT.map((f) => path4.join(dir, f)).find((f) => fs3.existsSync(f));
1615
+ for (let dir = path5.dirname(pageAbs); dir.startsWith(appDir) && dir !== appDir; dir = path5.dirname(dir)) {
1616
+ const file = LAYOUT.map((f) => path5.join(dir, f)).find((f) => fs5.existsSync(f));
1318
1617
  if (!file) continue;
1319
1618
  const layout = extract(file, canvasAbs, config, true);
1320
1619
  const holes = layout ? (layout.jsx.match(/\{\s*children\s*\}/g) ?? []).length : 0;
1321
1620
  if (layout && holes === 1) {
1322
- jsx = layout.jsx.replace(/\{\s*children\s*\}/, jsx.startsWith("<>") ? jsx.slice(2, -3) : jsx);
1621
+ const inner = jsx.startsWith("<>") ? jsx.slice(2, -3) : jsx;
1622
+ jsx = layout.jsx.replace(/\{\s*children\s*\}/, () => inner);
1323
1623
  imports.push(...layout.imports);
1324
1624
  } else {
1325
1625
  const local = `RouteLayout${i++ || ""}`;
1326
- imports.push(`import ${local} from ${JSON.stringify(importSpecifier(config.root, canvasFile, path4.relative(config.root, file)))};`);
1626
+ imports.push(`import ${local} from ${JSON.stringify(importSpecifier(config.root, canvasFile, path5.relative(config.root, file)))};`);
1327
1627
  jsx = `<${local}>${jsx}</${local}>`;
1328
1628
  }
1329
1629
  }
@@ -1344,11 +1644,23 @@ function wrapperChain(frame) {
1344
1644
  }
1345
1645
  function readSource(config, rel) {
1346
1646
  try {
1347
- return fs4.readFileSync(path5.join(config.root, rel), "utf8");
1647
+ return fs6.readFileSync(path6.join(config.root, rel), "utf8");
1348
1648
  } catch {
1349
1649
  return null;
1350
1650
  }
1351
1651
  }
1652
+ var parseMemo = /* @__PURE__ */ new Map();
1653
+ function memoParse(key, source, parse2) {
1654
+ let hit = parseMemo.get(key);
1655
+ if (!hit || hit.source !== source) {
1656
+ hit = { source, doc: parse2() };
1657
+ parseMemo.set(key, hit);
1658
+ }
1659
+ return hit.doc && { ...hit.doc, frames: hit.doc.frames.map(cloneTree) };
1660
+ }
1661
+ function cloneTree(n) {
1662
+ return { ...n, children: n.children.map(cloneTree) };
1663
+ }
1352
1664
  function resolveLinks(config, doc) {
1353
1665
  if (!doc.frames.some((f) => f.page || f.component)) return doc;
1354
1666
  return { ...doc, frames: doc.frames.map((f) => f.component ? linkComponent(config, f) : f.page ? linkFrame(config, f) : f) };
@@ -1361,7 +1673,7 @@ function linkComponent(config, original) {
1361
1673
  const frame = { ...original };
1362
1674
  const { file, name } = splitComponentRef(frame.component);
1363
1675
  const source = readSource(config, file);
1364
- const doc = source === null ? null : parseModuleDoc(file, source);
1676
+ const doc = source === null ? null : memoParse(`module:${file}`, source, () => parseModuleDoc(file, source));
1365
1677
  const own = doc?.frames.find((f) => f.frameName === name);
1366
1678
  if (!own) {
1367
1679
  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 +1696,7 @@ function linkFrame(config, original) {
1384
1696
  const chain = wrapperChain(original);
1385
1697
  const files = [];
1386
1698
  let current;
1387
- const pageDoc = parseSourceDoc(page, pageSource, false);
1699
+ const pageDoc = memoParse(`page:${page}`, pageSource, () => parseSourceDoc(page, pageSource, false));
1388
1700
  if (pageDoc) {
1389
1701
  current = flatten(pageDoc.frames[0].children[0]);
1390
1702
  files.push(page);
@@ -1394,7 +1706,7 @@ function linkFrame(config, original) {
1394
1706
  const layouts = layoutFiles(config, page);
1395
1707
  layouts.forEach((rel, i) => {
1396
1708
  const src = readSource(config, rel);
1397
- const ldoc = src === null ? null : parseSourceDoc(rel, src, true);
1709
+ const ldoc = src === null ? null : memoParse(`layout:${rel}`, src, () => parseSourceDoc(rel, src, true));
1398
1710
  const root = ldoc?.frames[0].children[0];
1399
1711
  const slot = root && findSlot(root);
1400
1712
  if (root && slot) {
@@ -1443,7 +1755,7 @@ function linkedFrameBody(config, doc, canvasRel, page) {
1443
1755
  const pageName = importName(config, doc, canvasRel, page, `${segments.length ? pascal(segments[segments.length - 1]) : "Home"}Page`, taken, imports);
1444
1756
  let jsx = `<${pageName} />`;
1445
1757
  for (const rel of layoutFiles(config, page)) {
1446
- const dir = path5.basename(path5.dirname(rel));
1758
+ const dir = path6.basename(path6.dirname(rel));
1447
1759
  const name = importName(config, doc, canvasRel, rel, `${pascal(dir) || "Route"}Layout`, taken, imports);
1448
1760
  jsx = `<${name}>
1449
1761
  ${indentBlock(jsx)}
@@ -1465,7 +1777,7 @@ function dedent(code) {
1465
1777
  function explorationBody(config, frame, canvasRel) {
1466
1778
  if (frame.component) throw new EditError(`"${frame.frameName}" is a main component: edit it directly, every instance follows.`);
1467
1779
  if (!frame.page) throw new EditError(`"${frame.frameName}" isn't linked to a page.`);
1468
- const pageAbs = path5.join(config.root, frame.page);
1780
+ const pageAbs = path6.join(config.root, frame.page);
1469
1781
  const source = readSource(config, frame.page);
1470
1782
  if (source === null) throw new EditError(`${frame.page} doesn't exist anymore.`);
1471
1783
  const chain = wrapperChain(frame);
@@ -1477,9 +1789,9 @@ function explorationBody(config, frame, canvasRel) {
1477
1789
  for (const stmt of info.ast.program.body) {
1478
1790
  if (stmt.type !== "ImportDeclaration" || stmt.importKind === "type" || /\.(css|scss|sass|less)$/.test(stmt.source.value)) continue;
1479
1791
  const src = stmt.source.value;
1480
- const from = repoint(config, src, pageAbs, path5.join(config.root, canvasRel));
1792
+ const from = repoint(config, src, pageAbs, path6.join(config.root, canvasRel));
1481
1793
  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}'`));
1794
+ imports.push(from === src ? text : rewriteImportSource(text, src, from));
1483
1795
  }
1484
1796
  jsx = unwrapFragment(source.slice(info.node.start, info.node.end));
1485
1797
  } else {
@@ -1493,7 +1805,7 @@ ${indentBlock(jsx)}
1493
1805
  function applyPlan(config, canvasSource, doc, frame, canvasRel) {
1494
1806
  const page = frame.from;
1495
1807
  if (!page) throw new EditError(`"${frame.frameName}" isn't an exploration of a page. Explore a copy of a linked frame first.`);
1496
- if (!fs4.existsSync(path5.join(config.root, page))) throw new EditError(`${page} doesn't exist anymore.`);
1808
+ if (!fs6.existsSync(path6.join(config.root, page))) throw new EditError(`${page} doesn't exist anymore.`);
1497
1809
  const layoutSpecs = new Set(doc.imports.filter((i) => /(^|\/)layout(\.\w+)?$/.test(i.source)).flatMap((i) => [...i.names, ...i.defaultName ? [i.defaultName] : []]));
1498
1810
  let content = frame.children;
1499
1811
  for (; ; ) {
@@ -1519,7 +1831,7 @@ ${indentBlock(body)}
1519
1831
  continue;
1520
1832
  }
1521
1833
  const sp = decl.specifiers.find((x) => x.local.name === name);
1522
- const from = repoint(config, decl.source.value, path5.join(config.root, canvasRel), path5.join(config.root, page));
1834
+ const from = repoint(config, decl.source.value, path6.join(config.root, canvasRel), path6.join(config.root, page));
1523
1835
  const what = sp.type === "ImportDefaultSpecifier" ? name : sp.type === "ImportNamespaceSpecifier" ? `* as ${name}` : `{ ${sp.imported.type === "Identifier" && sp.imported.name !== name ? `${sp.imported.name} as ${name}` : name} }`;
1524
1836
  imports.push(`import ${what} from ${JSON.stringify(from)};`);
1525
1837
  }
@@ -1552,8 +1864,8 @@ function pruneImports(source, candidates) {
1552
1864
  }
1553
1865
 
1554
1866
  // src/core/motion.ts
1555
- import fs5 from "fs";
1556
- import path6 from "path";
1867
+ import fs7 from "fs";
1868
+ import path7 from "path";
1557
1869
  var REVEAL = `"use client";
1558
1870
 
1559
1871
  import { Children, cloneElement, isValidElement, useEffect, useRef, useState, type CSSProperties, type ReactElement, type ReactNode } from "react";
@@ -1778,8 +2090,8 @@ export function TextAnimate({ effect = "words", stagger = 0.06, delay = 0, durat
1778
2090
  function motionFiles(config) {
1779
2091
  const glob = config.components[0] ?? "components/**/*.tsx";
1780
2092
  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") };
2093
+ const dir = path7.join(base, "motion");
2094
+ return { reveal: path7.join(dir, "reveal.tsx"), text: path7.join(dir, "text-animate.tsx") };
1783
2095
  }
1784
2096
  function ensureMotionComponents(config) {
1785
2097
  const files = motionFiles(config);
@@ -1788,18 +2100,18 @@ function ensureMotionComponents(config) {
1788
2100
  [files.reveal, REVEAL],
1789
2101
  [files.text, TEXT_ANIMATE]
1790
2102
  ]) {
1791
- const abs = path6.join(config.root, rel);
1792
- if (fs5.existsSync(abs)) continue;
1793
- fs5.mkdirSync(path6.dirname(abs), { recursive: true });
1794
- fs5.writeFileSync(abs, content);
2103
+ const abs = path7.join(config.root, rel);
2104
+ if (fs7.existsSync(abs)) continue;
2105
+ fs7.mkdirSync(path7.dirname(abs), { recursive: true });
2106
+ fs7.writeFileSync(abs, content);
1795
2107
  created = true;
1796
2108
  }
1797
2109
  return { ...files, created };
1798
2110
  }
1799
2111
 
1800
2112
  // src/core/components.ts
1801
- import fs6 from "fs";
1802
- import path7 from "path";
2113
+ import fs8 from "fs";
2114
+ import path8 from "path";
1803
2115
  function componentsDir(config) {
1804
2116
  const glob = config.components[0] ?? "components/**/*.tsx";
1805
2117
  return glob.split("/").filter((p) => !p.includes("*")).join("/") || "components";
@@ -1863,11 +2175,11 @@ function importsFor(config, jsx, source, doc, fromRel, toRel) {
1863
2175
  (s) => s.type === "ImportDeclaration" && s.importKind !== "type" && s.specifiers.some((sp2) => sp2.local.name === name)
1864
2176
  );
1865
2177
  if (!decl) {
1866
- if (doc.declared.includes(name)) throw new EditError(`These layers use ${name}, defined in ${path7.basename(fromRel)}. Move it to its own file first, or ask your agent to extract the component.`);
2178
+ if (doc.declared.includes(name)) throw new EditError(`These layers use ${name}, defined in ${path8.basename(fromRel)}. Move it to its own file first, or ask your agent to extract the component.`);
1867
2179
  throw new EditError(`These layers use \`${name}\` from the surrounding code, so they can't become a component automatically. Ask your agent to extract it with props.`);
1868
2180
  }
1869
2181
  const sp = decl.specifiers.find((x) => x.local.name === name);
1870
- const from = repoint(config, decl.source.value, path7.join(config.root, fromRel), path7.join(config.root, toRel));
2182
+ const from = repoint(config, decl.source.value, path8.join(config.root, fromRel), path8.join(config.root, toRel));
1871
2183
  const what = sp.type === "ImportDefaultSpecifier" ? name : sp.type === "ImportNamespaceSpecifier" ? `* as ${name}` : `{ ${sp.imported.type === "Identifier" && sp.imported.name !== name ? `${sp.imported.name} as ${name}` : name} }`;
1872
2184
  out.push(`import ${what} from ${JSON.stringify(from)};`);
1873
2185
  }
@@ -1891,193 +2203,11 @@ ${indentBlock(indentBlock(jsx.trim()))}
1891
2203
  `;
1892
2204
  }
1893
2205
  function newComponentPath(config, name) {
1894
- const rel = path7.join(componentsDir(config), `${kebab(name)}.tsx`);
1895
- if (fs6.existsSync(path7.join(config.root, rel))) throw new EditError(`${rel} already exists. Pick another name.`);
2206
+ const rel = path8.join(componentsDir(config), `${kebab(name)}.tsx`);
2207
+ if (fs8.existsSync(path8.join(config.root, rel))) throw new EditError(`${rel} already exists. Pick another name.`);
1896
2208
  return rel;
1897
2209
  }
1898
2210
 
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
2211
  // src/core/workspace.ts
2082
2212
  var require2 = createRequire(import.meta.url);
2083
2213
  var MARK = "__tcsel";
@@ -2094,13 +2224,14 @@ var Workspace = class {
2094
2224
  this.catalog = new Catalog(config.root, config.components, config.libraries);
2095
2225
  this.comments = new Comments(config);
2096
2226
  this.git = new Git(config.root);
2097
- for (const c of this.canvases()) this.sources.set(c, fs8.readFileSync(this.file(c), "utf8"));
2227
+ for (const c of this.canvases()) this.sources.set(c, fs9.readFileSync(this.file(c), "utf8"));
2098
2228
  }
2099
2229
  config;
2100
2230
  catalog;
2101
2231
  sources = /* @__PURE__ */ new Map();
2102
2232
  undoStacks = /* @__PURE__ */ new Map();
2103
2233
  redoStacks = /* @__PURE__ */ new Map();
2234
+ /** Activity across canvases: labels only, never file copies. */
2104
2235
  feed = [];
2105
2236
  seq = 0;
2106
2237
  queue = Promise.resolve();
@@ -2128,25 +2259,48 @@ var Workspace = class {
2128
2259
  }
2129
2260
  read(canvas) {
2130
2261
  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");
2262
+ if (!fs9.existsSync(file)) throw new EditError(`Canvas "${canvas}" does not exist. Canvases: ${this.canvases().join(", ") || "none"}`);
2263
+ const src = fs9.readFileSync(file, "utf8");
2133
2264
  if (!this.sources.has(canvas)) this.sources.set(canvas, src);
2134
2265
  return src;
2135
2266
  }
2136
2267
  doc(canvas) {
2137
2268
  return this.docFrom(canvas, this.read(canvas));
2138
2269
  }
2139
- /** A canvas doc with linked frames resolved to their page's layers. */
2270
+ /**
2271
+ * A canvas doc with linked frames resolved to their page's layers. Resolving
2272
+ * reads and parses every linked page and layout, so the result is reused
2273
+ * until the canvas source or one of those files changes (checked with stat).
2274
+ */
2140
2275
  docFrom(canvas, source) {
2141
- return resolveLinks(this.config, parseCanvas(canvas, this.relFile(canvas), source));
2276
+ const hit = this.docCache.get(canvas);
2277
+ if (hit && hit.source === source && hit.stamps.every(([rel, stamp]) => this.stamp(rel) === stamp)) return { ...hit.doc, rev: ++this.revision };
2278
+ const doc = resolveLinks(this.config, parseCanvas(canvas, this.relFile(canvas), source));
2279
+ const files = new Set(doc.frames.flatMap((f) => [...f.link?.files ?? [], ...f.page ? [f.page] : []]));
2280
+ this.docCache.set(canvas, { source, doc, stamps: [...files].map((rel) => [rel, this.stamp(rel)]) });
2281
+ return { ...doc, rev: ++this.revision };
2282
+ }
2283
+ /** a doc computed later reflects the files at least as recently: its rev is higher */
2284
+ revision = 0;
2285
+ docCache = /* @__PURE__ */ new Map();
2286
+ stamp(rel) {
2287
+ try {
2288
+ const st = fs9.statSync(path9.join(this.config.root, rel));
2289
+ return `${st.mtimeMs}:${st.size}`;
2290
+ } catch {
2291
+ return "missing";
2292
+ }
2142
2293
  }
2143
2294
  /** Where a node's code lives: the canvas, or a linked page/layout file. */
2144
2295
  nodeSource(canvas, id) {
2145
- const hit = findNode(this.doc(canvas), id);
2296
+ const doc = this.doc(canvas);
2297
+ const hit = findNode(doc, id);
2146
2298
  if (!hit) return null;
2147
- const rel = fileOfId(id) ?? this.relFile(canvas);
2299
+ const linked = fileOfId(id);
2300
+ if (linked) assertShown(doc, linked);
2301
+ const rel = linked ?? this.relFile(canvas);
2148
2302
  const abs = path9.join(this.config.root, rel);
2149
- return { node: hit.node, rel, abs, source: fs8.readFileSync(abs, "utf8") };
2303
+ return { node: hit.node, rel, abs, source: fs9.readFileSync(abs, "utf8") };
2150
2304
  }
2151
2305
  /** Page and layout files shown by linked frames, across all canvases (the Git panel counts them as design files). */
2152
2306
  linkedFiles() {
@@ -2165,7 +2319,7 @@ var Workspace = class {
2165
2319
  */
2166
2320
  designFiles() {
2167
2321
  const m = motionFiles(this.config);
2168
- return [...this.linkedFiles(), ...[m.reveal, m.text].filter((f) => fs8.existsSync(path9.join(this.config.root, f)))];
2322
+ return [...this.linkedFiles(), ...[m.reveal, m.text].filter((f) => fs9.existsSync(path9.join(this.config.root, f)))];
2169
2323
  }
2170
2324
  /** Canvases with a linked frame showing this page or layout file. */
2171
2325
  canvasesLinking(rel) {
@@ -2181,20 +2335,21 @@ var Workspace = class {
2181
2335
  linkedFileChanged(rel) {
2182
2336
  let next;
2183
2337
  try {
2184
- next = fs8.readFileSync(path9.join(this.config.root, rel), "utf8");
2338
+ next = fs9.readFileSync(path9.join(this.config.root, rel), "utf8");
2185
2339
  } catch {
2186
2340
  next = "";
2187
2341
  }
2188
2342
  const key = `file:${rel}`;
2189
2343
  if (this.sources.get(key) === next) return;
2190
2344
  this.sources.set(key, next);
2345
+ this.docCache.clear();
2191
2346
  for (const c of this.canvasesLinking(rel)) this.emit({ type: "doc", canvas: c, doc: this.doc(c) });
2192
2347
  }
2193
2348
  createCanvas(name, actor, content = STARTER_CANVAS) {
2194
2349
  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);
2350
+ if (fs9.existsSync(file)) throw new EditError(`Canvas "${name}" already exists.`);
2351
+ fs9.mkdirSync(path9.dirname(file), { recursive: true });
2352
+ fs9.writeFileSync(file, content);
2198
2353
  this.sources.set(name, content);
2199
2354
  this.canvasesChanged();
2200
2355
  this.record(name, `Created canvas ${name}`, actor, "", content, []);
@@ -2229,10 +2384,10 @@ var Workspace = class {
2229
2384
  }
2230
2385
  renameCanvas(from, to) {
2231
2386
  const src = this.file(from);
2232
- if (!fs8.existsSync(src)) throw new EditError(`Canvas "${from}" does not exist.`);
2387
+ if (!fs9.existsSync(src)) throw new EditError(`Canvas "${from}" does not exist.`);
2233
2388
  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);
2389
+ if (fs9.existsSync(dest)) throw new EditError(`A canvas named "${to}" already exists.`);
2390
+ fs9.renameSync(src, dest);
2236
2391
  this.comments.renameCanvas(from, to);
2237
2392
  for (const map of [this.sources, this.undoStacks, this.redoStacks]) {
2238
2393
  if (map.has(from)) {
@@ -2249,7 +2404,7 @@ var Workspace = class {
2249
2404
  duplicateCanvas(name) {
2250
2405
  const source = this.read(name);
2251
2406
  const copy = this.uniqueCanvasName(`${name}-copy`);
2252
- fs8.writeFileSync(canvasPath(this.config, copy), source);
2407
+ fs9.writeFileSync(canvasPath(this.config, copy), source);
2253
2408
  this.sources.set(copy, source);
2254
2409
  this.canvasesChanged();
2255
2410
  return copy;
@@ -2258,17 +2413,19 @@ var Workspace = class {
2258
2413
  trash = /* @__PURE__ */ new Map();
2259
2414
  deleteCanvas(name) {
2260
2415
  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);
2416
+ if (!fs9.existsSync(file)) throw new EditError(`Canvas "${name}" does not exist.`);
2417
+ this.trash.set(name, fs9.readFileSync(file, "utf8"));
2418
+ fs9.unlinkSync(file);
2264
2419
  this.sources.delete(name);
2420
+ this.undoStacks.delete(name);
2421
+ this.redoStacks.delete(name);
2265
2422
  this.canvasesChanged();
2266
2423
  }
2267
2424
  restoreCanvas(name) {
2268
2425
  const source = this.trash.get(name);
2269
2426
  if (source === void 0) throw new EditError(`Nothing to restore for "${name}".`);
2270
2427
  const target = this.uniqueCanvasName(name);
2271
- fs8.writeFileSync(canvasPath(this.config, target), source);
2428
+ fs9.writeFileSync(canvasPath(this.config, target), source);
2272
2429
  this.sources.set(target, source);
2273
2430
  this.trash.delete(name);
2274
2431
  this.canvasesChanged();
@@ -2279,14 +2436,14 @@ var Workspace = class {
2279
2436
  if (!fileName.endsWith(CANVAS_SUFFIX)) return;
2280
2437
  const canvas = canvasNameOf(fileName);
2281
2438
  const file = this.file(canvas);
2282
- if (!fs8.existsSync(file)) {
2439
+ if (!fs9.existsSync(file)) {
2283
2440
  this.sources.delete(canvas);
2284
2441
  syncRoute(this.config);
2285
2442
  this.emit({ type: "canvases", canvases: this.canvases() });
2286
2443
  return;
2287
2444
  }
2288
2445
  const prev = this.sources.get(canvas);
2289
- const next = fs8.readFileSync(file, "utf8");
2446
+ const next = fs9.readFileSync(file, "utf8");
2290
2447
  if (prev === next) return;
2291
2448
  this.sources.set(canvas, next);
2292
2449
  if (prev === void 0) {
@@ -2306,6 +2463,7 @@ var Workspace = class {
2306
2463
  if (!(err instanceof ConflictError)) throw err;
2307
2464
  if (err.file) this.linkedFileChanged(err.file);
2308
2465
  else this.externalChange(path9.basename(this.file(cmd.canvas)));
2466
+ if (nodeRefs(cmd).length || "frame" in cmd && cmd.frame) throw new EditError(`${path9.basename(err.file ?? this.relFile(cmd.canvas))} changed while editing. Re-read the canvas and try again.`);
2309
2467
  return this.exec(cmd, actor);
2310
2468
  }
2311
2469
  });
@@ -2314,32 +2472,55 @@ var Workspace = class {
2314
2472
  }
2315
2473
  async exec(cmd, actor) {
2316
2474
  cmd = { ...cmd };
2317
- if (cmd.op === "add_component_frame" && !fs8.existsSync(this.file(cmd.canvas))) this.createCanvas(cmd.canvas, actor, COMPONENTS_CANVAS);
2475
+ let prepared = null;
2476
+ if (cmd.op === "add_component_frame" && !fs9.existsSync(this.file(cmd.canvas))) {
2477
+ await this.catalog.load();
2478
+ const spec = this.catalog.get(cmd.component);
2479
+ if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
2480
+ if (spec.library) throw new EditError(`${spec.name} comes from ${spec.library}: it can't be edited here.`);
2481
+ this.createCanvas(cmd.canvas, actor, COMPONENTS_CANVAS);
2482
+ }
2318
2483
  const canvasSource = this.read(cmd.canvas);
2319
2484
  const canvasDoc = parseCanvas(cmd.canvas, this.relFile(cmd.canvas), canvasSource);
2320
2485
  if (canvasDoc.error) throw new EditError(`The canvas file does not parse: ${canvasDoc.error}`);
2321
2486
  const view = resolveLinks(this.config, canvasDoc);
2322
2487
  cmd = this.routeLinked(cmd, view);
2323
2488
  let target = this.targetOf(cmd);
2489
+ if (target) assertShown(view, target);
2324
2490
  if (cmd.op === "explore_copy") {
2325
2491
  const frame = findFrame(canvasDoc, cmd.frame);
2326
2492
  if (!frame) throw new EditError(`Frame "${cmd.frame}" not found.`);
2327
2493
  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) };
2494
+ prepared = { kind: "explore", frame, imports: body.imports, jsx: await this.formatSnippet(body.jsx) };
2329
2495
  }
2330
2496
  if (cmd.op === "apply_to_page") {
2331
2497
  const frame = findFrame(canvasDoc, cmd.frame);
2332
2498
  if (!frame) throw new EditError(`Frame "${cmd.frame}" not found.`);
2333
2499
  const plan = applyPlan(this.config, canvasSource, canvasDoc, frame, this.relFile(cmd.canvas));
2334
2500
  target = plan.page;
2335
- this.pending = { kind: "apply", frame, ...plan };
2501
+ prepared = { kind: "apply", frame, ...plan };
2336
2502
  }
2337
2503
  const rel = target ?? this.relFile(cmd.canvas);
2338
2504
  const file = path9.join(this.config.root, rel);
2339
- const before = target ? fs8.readFileSync(file, "utf8") : canvasSource;
2505
+ const original = target ? fs9.readFileSync(file, "utf8") : canvasSource;
2506
+ const crlf = original.includes("\r\n");
2507
+ const before = crlf ? original.replace(/\r\n/g, "\n") : original;
2340
2508
  const parse2 = (src) => target ? parseTargetDoc(target, src) : parseCanvas(cmd.canvas, rel, src);
2341
2509
  const doc = parse2(before);
2342
2510
  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.`);
2511
+ if (cmd.op === "insert_icon") {
2512
+ const icon = cmd;
2513
+ const lib = iconLibrary(icon.library);
2514
+ if (!lib) throw new EditError(`Unknown icon library "${icon.library}". Known: ${ICON_LIBRARIES.map((l) => l.id).join(", ")}.`);
2515
+ const icons = await loadIcons(this.config.root, lib.id);
2516
+ const wanted = icon.name.toLowerCase();
2517
+ const exact = icons.find((i) => i.name === icon.name) ?? icons.find((i) => i.name.toLowerCase() === wanted);
2518
+ if (!exact) {
2519
+ const near = searchIcons(icons, icon.name, 5).icons.map((i) => i.name);
2520
+ throw new EditError(`No icon "${icon.name}" in ${lib.label}.${near.length ? ` Did you mean ${near.join(", ")}?` : " Use search_icons."}`);
2521
+ }
2522
+ cmd = { ...icon, library: lib.id, name: exact.name };
2523
+ }
2343
2524
  if ((cmd.op === "insert_jsx" || cmd.op === "replace" || cmd.op === "create_frame") && cmd.jsx) {
2344
2525
  cmd = { ...cmd, jsx: await this.formatSnippet(cmd.jsx) };
2345
2526
  }
@@ -2349,16 +2530,16 @@ var Workspace = class {
2349
2530
  if (!r) throw new EditError(`No page "${cmd.route}". Pages: ${routes.map((x) => x.route).join(", ") || "none"}`);
2350
2531
  if (cmd.copy) {
2351
2532
  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) };
2533
+ prepared = { kind: "route", route: r.route, page: null, imports: built.imports, mode: built.mode, jsx: await this.formatSnippet(built.jsx) };
2353
2534
  } else {
2354
2535
  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 };
2536
+ prepared = { kind: "route", route: r.route, page: r.file, imports: built.imports, mode: "linked", jsx: built.jsx };
2356
2537
  }
2357
2538
  }
2358
2539
  if (cmd.op === "add_animation") {
2359
2540
  const files = ensureMotionComponents(this.config);
2360
2541
  if (files.created) this.catalog.invalidate();
2361
- this.motion = files;
2542
+ prepared = { kind: "motion", files };
2362
2543
  }
2363
2544
  let newComponent = null;
2364
2545
  let movedNames = /* @__PURE__ */ new Set();
@@ -2381,13 +2562,13 @@ var Workspace = class {
2381
2562
  }
2382
2563
  const abs = path9.join(this.config.root, fileRel);
2383
2564
  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 };
2565
+ prepared = { kind: "component", name, file: fileRel };
2385
2566
  }
2386
2567
  await this.catalog.load().catch(() => []);
2387
2568
  const ed = new CanvasEditor(before, doc);
2388
2569
  const renamedFrame = cmd.op === "update_frame" && cmd.name !== void 0 ? findFrame(doc, cmd.frame) : null;
2389
2570
  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);
2571
+ const label = this.apply(ed, cmd, rel, prepared);
2391
2572
  const pretty = await this.isPrettierClean(before, file);
2392
2573
  const finish = async (code) => pretty ? this.format(code, file) : validate(code);
2393
2574
  let result = ed.result();
@@ -2421,19 +2602,25 @@ var Workspace = class {
2421
2602
  ids = paths.map((p) => pathId(clean, p)).filter(Boolean);
2422
2603
  }
2423
2604
  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" });
2605
+ const writeNewComponent = () => {
2606
+ if (!newComponent) return;
2607
+ fs9.mkdirSync(path9.dirname(path9.join(this.config.root, newComponent.rel)), { recursive: true });
2608
+ fs9.writeFileSync(path9.join(this.config.root, newComponent.rel), newComponent.content, { flag: "wx" });
2429
2609
  this.catalog.invalidate();
2430
2610
  this.emit({ type: "catalog" });
2611
+ };
2612
+ if (crlf) after = after.replace(/\n/g, "\r\n");
2613
+ if (after === original) {
2614
+ writeNewComponent();
2615
+ return { doc: target ? this.doc(cmd.canvas) : view, ids, label };
2431
2616
  }
2617
+ if (fs9.readFileSync(file, "utf8") !== original) throw new ConflictError(`${path9.basename(rel)} changed during the edit.`, target ?? void 0);
2618
+ writeNewComponent();
2432
2619
  if (target) this.sources.set(`file:${target}`, after);
2433
2620
  else this.sources.set(cmd.canvas, after);
2434
- fs8.writeFileSync(file, after);
2621
+ fs9.writeFileSync(file, after);
2435
2622
  const newDoc = this.doc(cmd.canvas);
2436
- this.record(cmd.canvas, label, actor, before, after, ids, target ?? void 0, touchedFrames(cmd, ids, view, newDoc));
2623
+ this.record(cmd.canvas, label, actor, original, after, ids, target ?? void 0, touchedFrames(cmd, ids, view, newDoc));
2437
2624
  if (renamedFrame) {
2438
2625
  const now = newDoc.frames[renamedFrame.path[0]];
2439
2626
  if (now && now.frameName !== renamedFrame.frameName) {
@@ -2457,7 +2644,7 @@ var Workspace = class {
2457
2644
  const f = findFrame(view, cmd.parent);
2458
2645
  if (f?.link) {
2459
2646
  if (!f.link.editable) throw viewOnly(f);
2460
- const src = fs8.readFileSync(path9.join(this.config.root, f.link.page), "utf8");
2647
+ const src = fs9.readFileSync(path9.join(this.config.root, f.link.page), "utf8");
2461
2648
  if (f.link.kind === "component") {
2462
2649
  const own = parseModuleDoc(f.link.page, src)?.frames.find((x) => x.frameName === f.link.route);
2463
2650
  if (!own) throw viewOnly(f);
@@ -2487,7 +2674,46 @@ var Workspace = class {
2487
2674
  }
2488
2675
  return [...files][0] ?? null;
2489
2676
  }
2490
- apply(ed, cmd, rel) {
2677
+ /** Applies a command to the file being edited and returns its history label. */
2678
+ apply(ed, cmd, rel, prepared) {
2679
+ switch (cmd.op) {
2680
+ case "set_props":
2681
+ case "set_text":
2682
+ case "set_class":
2683
+ case "insert_jsx":
2684
+ case "insert_component":
2685
+ case "insert_icon":
2686
+ case "replace":
2687
+ case "duplicate":
2688
+ case "delete":
2689
+ case "move":
2690
+ case "reorder":
2691
+ case "wrap":
2692
+ return this.applyLayerEdit(ed, cmd, rel, prepared);
2693
+ case "create_frame":
2694
+ case "update_frame":
2695
+ case "add_background":
2696
+ case "apply_preset":
2697
+ case "create_variants":
2698
+ case "add_component_frame":
2699
+ return this.applyFrameEdit(ed, cmd, rel, prepared);
2700
+ case "import_route":
2701
+ case "explore_copy":
2702
+ case "apply_to_page":
2703
+ return this.applyPageEdit(ed, cmd, rel, prepared);
2704
+ case "add_animation":
2705
+ case "remove_animation":
2706
+ return this.applyMotionEdit(ed, cmd, rel, prepared);
2707
+ case "create_component":
2708
+ return this.applyComponentEdit(ed, cmd, rel, prepared);
2709
+ default: {
2710
+ const unknown = cmd;
2711
+ throw new EditError(`Unknown command ${unknown.op}.`);
2712
+ }
2713
+ }
2714
+ }
2715
+ /** Edits to layers: props, text, classes, inserting, moving, wrapping. */
2716
+ applyLayerEdit(ed, cmd, rel, prepared) {
2491
2717
  const doc = ed.doc;
2492
2718
  const guard = (node, verb) => {
2493
2719
  if (doc.canvasNode) return;
@@ -2545,6 +2771,14 @@ var Workspace = class {
2545
2771
  ed.insertChild(parent, cmd.index, jsx);
2546
2772
  return `Inserted ${spec.name}`;
2547
2773
  }
2774
+ case "insert_icon": {
2775
+ const parent = ed.container(cmd.parent);
2776
+ const lib = iconLibrary(cmd.library);
2777
+ const local = ed.importAs(cmd.name, lib.from, `${cmd.name.replace(/Icon$/, "")}Icon`);
2778
+ const className = cmd.className?.trim() ?? "size-4";
2779
+ ed.insertChild(parent, cmd.index, `<${local}${className ? ` className=${JSON.stringify(className)}` : ""} />`);
2780
+ return `Inserted ${cmd.name} icon`;
2781
+ }
2548
2782
  case "replace": {
2549
2783
  const { node } = ed.node(cmd.id);
2550
2784
  parseJsx(cmd.jsx);
@@ -2553,7 +2787,8 @@ var Workspace = class {
2553
2787
  return `Replaced ${node.name}`;
2554
2788
  }
2555
2789
  case "duplicate": {
2556
- const nodes = cmd.ids.map((id) => ed.node(id));
2790
+ const all = cmd.ids.map((id) => ed.node(id));
2791
+ const nodes = all.filter(({ node: n }) => !all.some(({ node: o }) => o !== n && n.start >= o.start && n.end <= o.end));
2557
2792
  for (const { node } of nodes) guard(node, "duplicate");
2558
2793
  for (const { node } of nodes) ed.duplicate(node);
2559
2794
  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 +2825,19 @@ var Workspace = class {
2590
2825
  ed.wrap(nodes, `<div className="${cls}">`, "</div>");
2591
2826
  return nodes.length === 1 ? `Wrapped ${nodes[0].name} in auto layout` : `Wrapped ${nodes.length} layers in auto layout`;
2592
2827
  }
2828
+ default:
2829
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyLayerEdit.`);
2830
+ }
2831
+ }
2832
+ /** Frames: creating, updating, backgrounds, presets, variant grids, main component frames. */
2833
+ applyFrameEdit(ed, cmd, rel, prepared) {
2834
+ const doc = ed.doc;
2835
+ switch (cmd.op) {
2593
2836
  case "create_frame": {
2594
2837
  const device = findDevice(cmd.device);
2595
2838
  if (cmd.device && !device) throw new EditError(`Unknown device "${cmd.device}". Devices: ${DEVICES.map((d) => d.id).join(", ")}`);
2596
2839
  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);
2840
+ const right = rightEdge(doc);
2598
2841
  const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2599
2842
  if (cmd.jsx) {
2600
2843
  parseJsx(cmd.jsx);
@@ -2684,19 +2927,68 @@ var Workspace = class {
2684
2927
  ed.selectPaths.push(node.path);
2685
2928
  return `Applied ${preset.name} preset to ${spec.name}`;
2686
2929
  }
2930
+ case "create_variants": {
2931
+ const spec = this.catalog.get(cmd.component);
2932
+ if (!spec) throw new EditError(`Unknown component "${cmd.component}".`);
2933
+ const prop = spec.props.find((p) => p.name === cmd.prop);
2934
+ if (!prop) throw new EditError(`${spec.name} has no prop "${cmd.prop}".`);
2935
+ const values = prop.type === "enum" ? prop.options : prop.type === "boolean" ? [false, true] : [];
2936
+ if (!values.length) throw new EditError(`Prop "${cmd.prop}" is ${prop.typeText}; variants need a union of string literals or a boolean.`);
2937
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2938
+ const label = (v) => typeof v === "string" ? v[0].toUpperCase() + v.slice(1) : `${prop.name}: ${v}`;
2939
+ const rows = indentBlock(values.map((v) => componentJsx(spec, { ...cmd.props, [prop.name]: v }, spec.acceptsChildren ? label(v) : void 0)).join("\n"));
2940
+ const right = rightEdge(doc);
2941
+ const name = uniqueFrameName(doc, `${spec.name} \xB7 ${prop.name}`);
2942
+ 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">
2943
+ ${rows}
2944
+ </div>` }));
2945
+ return `Created ${spec.name} ${prop.name} variants`;
2946
+ }
2947
+ case "add_component_frame": {
2948
+ const spec = this.catalog.get(cmd.component);
2949
+ if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
2950
+ if (spec.library) throw new EditError(`${spec.name} comes from ${spec.library}: it can't be edited here.`);
2951
+ const existing = doc.frames.find((f) => f.component === `${spec.file}#${spec.name}`);
2952
+ if (existing) {
2953
+ ed.selectPaths.push(existing.path);
2954
+ return `Opened ${spec.name}`;
2955
+ }
2956
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2957
+ const right = rightEdge(doc);
2958
+ const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2959
+ ed.insertFrame(
2960
+ frameCode({
2961
+ name: uniqueFrameName(doc, spec.name),
2962
+ x: cmd.x ?? right + 80,
2963
+ y: cmd.y ?? top,
2964
+ width: cmd.width ?? 640,
2965
+ component: `${spec.file}#${spec.name}`,
2966
+ body: `<div className="p-10">
2967
+ ${componentJsx(spec)}
2968
+ </div>`
2969
+ })
2970
+ );
2971
+ return `Opened main component ${spec.name}`;
2972
+ }
2973
+ default:
2974
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyFrameEdit.`);
2975
+ }
2976
+ }
2977
+ /** Linked pages: adding a page, exploring a copy, applying it back. */
2978
+ applyPageEdit(ed, cmd, rel, prepared) {
2979
+ const doc = ed.doc;
2980
+ switch (cmd.op) {
2687
2981
  case "import_route": {
2688
- const built = this.pending;
2689
- this.pending = null;
2982
+ const built = take(prepared, "route");
2690
2983
  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);
2984
+ const right = rightEdge(doc);
2692
2985
  const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2693
2986
  const name = uniqueFrameName(doc, cmd.name ?? (built.route === "/" ? "Home" : built.route.split("/").filter(Boolean).map((p) => p.replace(/^./, (c) => c.toUpperCase())).join(" / ")));
2694
2987
  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
2988
  return built.mode === "linked" ? `Linked ${built.route}` : built.mode === "inline" ? `Copied ${built.route} as layers` : `Copied ${built.route}`;
2696
2989
  }
2697
2990
  case "explore_copy": {
2698
- const built = this.pending;
2699
- this.pending = null;
2991
+ const built = take(prepared, "explore");
2700
2992
  const f = built.frame;
2701
2993
  for (const stmt of built.imports) ed.addImportStatement(stmt);
2702
2994
  const h = (fr) => fr.height ?? 2e3;
@@ -2707,17 +2999,23 @@ var Workspace = class {
2707
2999
  return `Explored a copy of ${f.frameName}`;
2708
3000
  }
2709
3001
  case "apply_to_page": {
2710
- const plan = this.pending;
2711
- this.pending = null;
3002
+ const plan = take(prepared, "apply");
2712
3003
  for (const stmt of plan.imports) ed.addImportStatement(stmt);
2713
3004
  ed.replace(doc.frames[0].children[0], plan.jsx);
2714
3005
  return `Applied ${plan.frame.frameName} to ${plan.page}`;
2715
3006
  }
3007
+ default:
3008
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyPageEdit.`);
3009
+ }
3010
+ }
3011
+ /** Scroll reveals and text animations. */
3012
+ applyMotionEdit(ed, cmd, rel, prepared) {
3013
+ switch (cmd.op) {
2716
3014
  case "add_animation": {
2717
3015
  const { node, parent } = ed.node(cmd.id);
2718
3016
  if ("frameName" in node) throw new EditError("Animate a layer inside the frame, not the frame itself.");
2719
3017
  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);
3018
+ const files = prepared?.kind === "motion" ? prepared.files : motionFiles(this.config);
2721
3019
  const props = {};
2722
3020
  if (cmd.effect !== void 0) props.effect = cmd.effect;
2723
3021
  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 +3063,16 @@ var Workspace = class {
2765
3063
  ed.selectPaths.push(wrapper === kids[0] ? node.path : wrapper.path);
2766
3064
  return cmd.kind === "reveal" ? "Removed the reveal" : "Removed the text animation";
2767
3065
  }
3066
+ default:
3067
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyMotionEdit.`);
3068
+ }
3069
+ }
3070
+ /** New components, from scratch or from selected layers. */
3071
+ applyComponentEdit(ed, cmd, rel, prepared) {
3072
+ switch (cmd.op) {
2768
3073
  case "create_component": {
2769
- const made = this.pending;
2770
- this.pending = null;
2771
- ed.ensureImport(made.name, importSpecifier(this.config.root, rel, made.file));
3074
+ const made = take(prepared, "component");
3075
+ if (cmd.from || cmd.parent) ed.ensureImport(made.name, importSpecifier(this.config.root, rel, made.file));
2772
3076
  if (cmd.from) {
2773
3077
  const { node } = ed.node(cmd.from);
2774
3078
  ed.replace(node, `<${made.name} />`);
@@ -2778,49 +3082,8 @@ var Workspace = class {
2778
3082
  ed.insertChild(ed.container(cmd.parent), cmd.index, `<${made.name} />`);
2779
3083
  return `Created component ${made.name}`;
2780
3084
  }
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
- }
3085
+ default:
3086
+ throw new Error(`Internal: ${cmd.op} isn't handled by applyComponentEdit.`);
2824
3087
  }
2825
3088
  }
2826
3089
  autoImport(ed, rel, jsx) {
@@ -2833,9 +3096,6 @@ ${rows}
2833
3096
  ed.ensureImport(name, importSpecifier(this.config.root, rel, spec.file));
2834
3097
  }
2835
3098
  }
2836
- motion = null;
2837
- /** work resolved (async) in exec before the synchronous apply */
2838
- pending = null;
2839
3099
  cleanCache = /* @__PURE__ */ new Map();
2840
3100
  async isPrettierClean(source, file) {
2841
3101
  const hit = this.cleanCache.get(source);
@@ -2880,11 +3140,29 @@ ${rows}
2880
3140
  if (stack.length > 200) stack.shift();
2881
3141
  this.undoStacks.set(canvas, stack);
2882
3142
  this.redoStacks.set(canvas, []);
2883
- this.feed.push(entry);
3143
+ this.feed.push(publicEntry(entry));
2884
3144
  if (this.feed.length > 300) this.feed.shift();
3145
+ this.trimHistory();
2885
3146
  this.emit({ type: "change", entry: publicEntry(entry), ids });
2886
3147
  this.emitHistory(canvas);
2887
3148
  }
3149
+ /**
3150
+ * Undo entries hold whole files (before and after). Keep their total under a
3151
+ * budget by dropping the oldest entries across canvases, so long sessions on
3152
+ * big pages don't grow without limit.
3153
+ */
3154
+ trimHistory(budget = 48 * 1024 * 1024) {
3155
+ const all = [...this.undoStacks.values(), ...this.redoStacks.values()];
3156
+ let total = 0;
3157
+ for (const stack of all) for (const e of stack) total += e.before.length + e.after.length;
3158
+ while (total > budget) {
3159
+ let oldest = null;
3160
+ for (const stack of all) if (stack.length && (!oldest || stack[0].at < oldest[0].at)) oldest = stack;
3161
+ if (!oldest) break;
3162
+ const e = oldest.shift();
3163
+ total -= e.before.length + e.after.length;
3164
+ }
3165
+ }
2888
3166
  /** Changes still in effect (not undone) on a canvas since a time, oldest first. */
2889
3167
  changesSince(canvas, since) {
2890
3168
  return (this.undoStacks.get(canvas) ?? []).filter((e) => e.at > since).map(publicEntry);
@@ -2909,17 +3187,20 @@ ${rows}
2909
3187
  const entry = stack[stack.length - 1];
2910
3188
  if (!entry) throw new EditError(`Nothing to ${dir}.`);
2911
3189
  if (dir === "undo" && entry.before === "") throw new EditError("Cannot undo creating the canvas; delete the file instead.");
2912
- stack.pop();
2913
3190
  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.`);
3191
+ const name = entry.file ? path9.basename(entry.file) : "the file";
3192
+ let current;
3193
+ try {
3194
+ current = entry.file ? fs9.readFileSync(file, "utf8") : this.read(canvas);
3195
+ } catch {
3196
+ throw new EditError(`Cannot ${dir} "${entry.label}": ${name} can't be read right now.`);
2919
3197
  }
3198
+ const expected = dir === "undo" ? entry.after : entry.before;
3199
+ if (current !== expected) throw new EditError(`Cannot ${dir} "${entry.label}": ${name} changed since.`);
3200
+ stack.pop();
2920
3201
  const next = dir === "undo" ? entry.before : entry.after;
2921
3202
  this.sources.set(entry.file ? `file:${entry.file}` : canvas, next);
2922
- fs8.writeFileSync(file, next);
3203
+ fs9.writeFileSync(file, next);
2923
3204
  const list = to.get(canvas) ?? [];
2924
3205
  list.push(entry);
2925
3206
  to.set(canvas, list);
@@ -2929,9 +3210,10 @@ ${rows}
2929
3210
  for (const c of this.canvasesLinking(entry.file)) if (c !== canvas) this.emit({ type: "doc", canvas: c, doc: this.doc(c) });
2930
3211
  }
2931
3212
  const label = `${dir === "undo" ? "Undid" : "Redid"} \u201C${entry.label}\u201D`;
2932
- const marker = { ...entry, id: ++this.seq, label, actor, at: Date.now() };
3213
+ const marker = publicEntry({ ...entry, id: ++this.seq, label, actor, at: Date.now() });
2933
3214
  this.feed.push(marker);
2934
- this.emit({ type: "change", entry: publicEntry(marker), ids: [] });
3215
+ if (this.feed.length > 300) this.feed.shift();
3216
+ this.emit({ type: "change", entry: marker, ids: [] });
2935
3217
  this.emitHistory(canvas);
2936
3218
  return { doc, label };
2937
3219
  });
@@ -2939,7 +3221,7 @@ ${rows}
2939
3221
  return job;
2940
3222
  }
2941
3223
  publicFeed() {
2942
- return this.feed.map(publicEntry);
3224
+ return this.feed;
2943
3225
  }
2944
3226
  // ---------- agents ----------
2945
3227
  touchAgent(session, name) {
@@ -2959,17 +3241,17 @@ ${rows}
2959
3241
  if (this.agents.delete(session)) this.emit({ type: "agents", agents: [...this.agents.values()] });
2960
3242
  }
2961
3243
  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 });
3244
+ if (!fs9.existsSync(path9.join(this.config.root, this.config.canvasDir))) {
3245
+ fs9.mkdirSync(path9.join(this.config.root, this.config.canvasDir), { recursive: true });
2964
3246
  }
2965
3247
  if (!this.canvases().length) {
2966
3248
  const file = canvasPath(this.config, "home");
2967
3249
  const content = this.homeCanvas();
2968
- fs8.writeFileSync(file, content);
3250
+ fs9.writeFileSync(file, content);
2969
3251
  void this.format(content, file).then((pretty) => {
2970
- if (fs8.readFileSync(file, "utf8") !== content) return;
3252
+ if (fs9.readFileSync(file, "utf8") !== content) return;
2971
3253
  this.sources.set("home", pretty);
2972
- fs8.writeFileSync(file, pretty);
3254
+ fs9.writeFileSync(file, pretty);
2973
3255
  }).catch(() => {
2974
3256
  });
2975
3257
  }
@@ -3020,6 +3302,10 @@ export default function ComponentsCanvas() {
3020
3302
  return <Canvas></Canvas>;
3021
3303
  }
3022
3304
  `;
3305
+ function take(prepared, kind) {
3306
+ if (prepared?.kind !== kind) throw new Error(`Internal: the ${kind} step wasn't prepared.`);
3307
+ return prepared;
3308
+ }
3023
3309
  function validate(code) {
3024
3310
  try {
3025
3311
  parseModule(code);
@@ -3032,6 +3318,13 @@ function publicEntry(e) {
3032
3318
  const { before: _b, after: _a, ...rest } = e;
3033
3319
  return rest;
3034
3320
  }
3321
+ function assertShown(view, rel) {
3322
+ const shown = view.frames.some((f) => f.link?.files.includes(rel));
3323
+ if (!shown || path9.isAbsolute(rel) || rel.split(/[\\/]/).includes("..")) throw new EditError(`${rel} isn't shown on this canvas. Re-read the canvas for current ids.`);
3324
+ }
3325
+ function rightEdge(doc) {
3326
+ return doc.frames.reduce((m, f) => Math.max(m, f.x + f.width), -80);
3327
+ }
3035
3328
  function nodeRefs(cmd) {
3036
3329
  const refs = [];
3037
3330
  if ("id" in cmd && cmd.id) refs.push(cmd.id);
@@ -3097,6 +3390,124 @@ function componentJsx(spec, props = {}, text) {
3097
3390
  return childText !== void 0 ? `${open}>${textCode(childText)}</${spec.name}>` : `${open} />`;
3098
3391
  }
3099
3392
 
3393
+ // src/core/pm.ts
3394
+ import { spawn } from "child_process";
3395
+ import fs10 from "fs";
3396
+ import path10 from "path";
3397
+ function detectPm(dir) {
3398
+ for (let d = dir; ; d = path10.dirname(d)) {
3399
+ if (fs10.existsSync(path10.join(d, "pnpm-lock.yaml")) || fs10.existsSync(path10.join(d, "pnpm-workspace.yaml"))) return "pnpm";
3400
+ if (fs10.existsSync(path10.join(d, "yarn.lock"))) return "yarn";
3401
+ if (fs10.existsSync(path10.join(d, "bun.lock")) || fs10.existsSync(path10.join(d, "bun.lockb"))) return "bun";
3402
+ if (fs10.existsSync(path10.join(d, "package-lock.json"))) return "npm";
3403
+ if (path10.dirname(d) === d || fs10.existsSync(path10.join(d, ".git"))) break;
3404
+ }
3405
+ const agent = process.env.npm_config_user_agent ?? "";
3406
+ if (agent.startsWith("pnpm")) return "pnpm";
3407
+ if (agent.startsWith("yarn")) return "yarn";
3408
+ if (agent.startsWith("bun")) return "bun";
3409
+ return "npm";
3410
+ }
3411
+ function addArgs(pm, specs, dev = false) {
3412
+ if (pm === "npm") return ["install", ...dev ? ["-D"] : [], ...specs];
3413
+ if (pm === "bun") return ["add", ...dev ? ["-d"] : [], ...specs];
3414
+ return ["add", ...dev ? ["-D"] : [], ...specs];
3415
+ }
3416
+ function dlx(pm, pkg, args) {
3417
+ if (pm === "pnpm") return ["pnpm", ["dlx", pkg, ...args]];
3418
+ if (pm === "yarn") return ["yarn", ["dlx", pkg, ...args]];
3419
+ if (pm === "bun") return ["bunx", [pkg, ...args]];
3420
+ return ["npx", ["--yes", pkg, ...args]];
3421
+ }
3422
+ function toolEnv(extra = {}) {
3423
+ return { ...process.env, PATH: `${path10.dirname(process.execPath)}${path10.delimiter}${process.env.PATH ?? ""}`, ...extra };
3424
+ }
3425
+ function run2(cmd, args, cwd, timeoutMs = 3e5) {
3426
+ return new Promise((resolve) => {
3427
+ let out = "";
3428
+ let done = false;
3429
+ const finish = (ok, extra = "") => {
3430
+ if (done) return;
3431
+ done = true;
3432
+ clearTimeout(timer);
3433
+ resolve({ ok, out: (out + extra).slice(-2e4) });
3434
+ };
3435
+ const p = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: toolEnv({ CI: "1", FORCE_COLOR: "0", NO_COLOR: "1" }), shell: process.platform === "win32" });
3436
+ const timer = setTimeout(() => {
3437
+ p.kill();
3438
+ finish(false, `
3439
+ ${cmd} timed out after ${Math.round(timeoutMs / 1e3)}s`);
3440
+ }, timeoutMs);
3441
+ p.stdout.on("data", (d) => out += d);
3442
+ p.stderr.on("data", (d) => out += d);
3443
+ p.on("error", (err) => finish(false, `
3444
+ ${err.message}`));
3445
+ p.on("close", (code) => finish(code === 0));
3446
+ });
3447
+ }
3448
+
3449
+ // src/core/shadcn.ts
3450
+ import fs11 from "fs";
3451
+ import path11 from "path";
3452
+ function shadcnStatus(root) {
3453
+ const file = path11.join(root, "components.json");
3454
+ if (!fs11.existsSync(file)) return { initialized: false, uiDir: null, installed: [] };
3455
+ let ui = "@/components/ui";
3456
+ try {
3457
+ ui = JSON.parse(fs11.readFileSync(file, "utf8"))?.aliases?.ui ?? ui;
3458
+ } catch {
3459
+ }
3460
+ const dir = resolveAliasPath(root, ui);
3461
+ let installed = [];
3462
+ try {
3463
+ installed = fs11.readdirSync(dir).filter((f) => /\.(tsx|jsx)$/.test(f)).map((f) => f.replace(/\.(tsx|jsx)$/, "")).sort();
3464
+ } catch {
3465
+ installed = [];
3466
+ }
3467
+ return { initialized: true, uiDir: path11.relative(root, dir), installed };
3468
+ }
3469
+ 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(" ");
3470
+ var registryCache = null;
3471
+ async function shadcnRegistry() {
3472
+ if (registryCache && Date.now() - registryCache.at < 36e5) return { names: registryCache.names, offline: false };
3473
+ try {
3474
+ const res = await fetch("https://ui.shadcn.com/r/index.json", { signal: AbortSignal.timeout(8e3) });
3475
+ if (!res.ok) throw new Error(String(res.status));
3476
+ const items = await res.json();
3477
+ const names = items.filter((i) => i.type === "registry:ui" && VALID.test(i.name)).map((i) => i.name).sort();
3478
+ if (!names.length) throw new Error("empty registry");
3479
+ registryCache = { at: Date.now(), names };
3480
+ return { names, offline: false };
3481
+ } catch {
3482
+ return { names: FALLBACK, offline: true };
3483
+ }
3484
+ }
3485
+ var VALID = /^[a-z0-9][a-z0-9-]*$/;
3486
+ async function shadcnInit(root) {
3487
+ const [cmd, args] = dlx(detectPm(root), "shadcn@latest", ["init", "--defaults", "--yes", "--no-monorepo"]);
3488
+ return run2(cmd, args, root);
3489
+ }
3490
+ async function shadcnAdd(root, names) {
3491
+ const bad = names.filter((n) => !VALID.test(n));
3492
+ if (bad.length) return { ok: false, out: `Not a component name: ${bad.join(", ")}`, added: [] };
3493
+ const before = new Set(shadcnStatus(root).installed);
3494
+ let log = "";
3495
+ if (!shadcnStatus(root).initialized) {
3496
+ const init = await shadcnInit(root);
3497
+ log += init.out;
3498
+ if (!init.ok) return { ok: false, out: log, added: [] };
3499
+ }
3500
+ const wanted = names.filter((n) => !before.has(n));
3501
+ if (wanted.length) {
3502
+ const [cmd, args] = dlx(detectPm(root), "shadcn@latest", ["add", ...wanted, "--yes"]);
3503
+ const res = await run2(cmd, args, root);
3504
+ log += res.out;
3505
+ if (!res.ok) return { ok: false, out: log, added: [] };
3506
+ }
3507
+ const added = shadcnStatus(root).installed.filter((n) => !before.has(n));
3508
+ return { ok: true, out: log, added };
3509
+ }
3510
+
3100
3511
  // src/core/outline.ts
3101
3512
  function propText(name, v) {
3102
3513
  if (v.kind === "boolean") return v.value ? name : `${name}={false}`;
@@ -3185,8 +3596,8 @@ function describeComponent(c, verbose = false) {
3185
3596
  }
3186
3597
 
3187
3598
  // src/core/tokens.ts
3188
- import fs9 from "fs";
3189
- import path10 from "path";
3599
+ import fs12 from "fs";
3600
+ import path12 from "path";
3190
3601
  import { globSync } from "tinyglobby";
3191
3602
  var GROUPS = [
3192
3603
  ["--color-", "Colors", "bg-{name} text-{name} border-{name}"],
@@ -3203,9 +3614,9 @@ function readDesignTokens(root) {
3203
3614
  const sources = [];
3204
3615
  let darkVariant = null;
3205
3616
  for (const file of files) {
3206
- const css = fs9.readFileSync(file, "utf8");
3617
+ const css = fs12.readFileSync(file, "utf8");
3207
3618
  if (!css.includes("@theme") && !css.includes("@custom-variant")) continue;
3208
- sources.push(path10.relative(root, file));
3619
+ sources.push(path12.relative(root, file));
3209
3620
  const dv = /@custom-variant\s+dark\s*\(([^;]+)\);/.exec(css);
3210
3621
  if (dv) darkVariant = dv[1].trim();
3211
3622
  for (const block of css.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) {
@@ -3244,7 +3655,7 @@ function readTokenData(root) {
3244
3655
  for (const m of body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) into.set(m[1], m[2].trim());
3245
3656
  };
3246
3657
  for (const file of files) {
3247
- const css = fs9.readFileSync(file, "utf8");
3658
+ const css = fs12.readFileSync(file, "utf8");
3248
3659
  for (const block of css.matchAll(/@theme[^{]*\{([\s\S]*?)\n\}/g)) decls(block[1], theme);
3249
3660
  for (const block of css.matchAll(/(?:^|\n)\s*:root\s*\{([\s\S]*?)\n\}/g)) decls(block[1], lightVars);
3250
3661
  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 +3687,30 @@ function readTokenData(root) {
3276
3687
  }
3277
3688
 
3278
3689
  // src/core/compare.ts
3279
- import fs10 from "fs";
3280
- import path11 from "path";
3690
+ import fs13 from "fs";
3691
+ import path13 from "path";
3281
3692
  var COMPARE_PREFIX = "__compare__";
3282
3693
  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`);
3694
+ const routeDir = path13.join(config.root, config.appDir, "truecanvas");
3695
+ const dir = path13.join(routeDir, "compare");
3696
+ fs13.mkdirSync(dir, { recursive: true });
3697
+ const canvasDir = path13.join(config.root, config.canvasDir);
3698
+ const canvasRel = path13.join(config.canvasDir, `${canvas}.canvas.tsx`);
3288
3699
  const swapped = /* @__PURE__ */ new Map();
3289
3700
  for (const [rel, content] of Object.entries(files)) {
3290
- const ext = path11.extname(rel);
3701
+ const ext = path13.extname(rel);
3291
3702
  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));
3703
+ const original = path13.join(config.root, rel);
3704
+ fs13.writeFileSync(path13.join(dir, name), rewriteImports(content, (spec) => spec.startsWith(".") ? relativeSpec(dir, path13.resolve(path13.dirname(original), spec)) : spec));
3294
3705
  swapped.set(importSpecifier(config.root, canvasRel, rel), `./${name.slice(0, -ext.length)}`);
3295
3706
  }
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);
3707
+ const out = rewriteImports(source, (spec) => swapped.get(spec) ?? (spec.startsWith(".") ? relativeSpec(dir, path13.resolve(canvasDir, spec)) : spec));
3708
+ fs13.writeFileSync(path13.join(dir, `${as}.canvas.tsx`), out);
3298
3709
  syncRoute(config);
3299
3710
  return `${COMPARE_PREFIX}${as}`;
3300
3711
  }
3301
3712
  function relativeSpec(fromDir, target) {
3302
- const next = path11.relative(fromDir, target);
3713
+ const next = path13.relative(fromDir, target);
3303
3714
  return next.startsWith(".") ? next : `./${next}`;
3304
3715
  }
3305
3716
  function rewriteImports(source, map) {
@@ -3308,15 +3719,16 @@ function rewriteImports(source, map) {
3308
3719
  for (const imp of [...doc.imports].sort((a, b) => b.start - a.start)) {
3309
3720
  const next = map(imp.source);
3310
3721
  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}'`);
3722
+ const stmt = rewriteImportSource(out.slice(imp.start, imp.end), imp.source, next);
3312
3723
  out = out.slice(0, imp.start) + stmt + out.slice(imp.end);
3313
3724
  }
3314
3725
  return out;
3315
3726
  }
3316
3727
  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));
3728
+ if (canvas) assertCanvasName(canvas);
3729
+ const dir = path13.join(config.root, config.appDir, "truecanvas", "compare");
3730
+ if (!fs13.existsSync(dir)) return;
3731
+ for (const f of fs13.readdirSync(dir)) if (!canvas || f === `${canvas}.canvas.tsx` || f.startsWith(`${canvas}--`)) fs13.rmSync(path13.join(dir, f));
3320
3732
  syncRoute(config);
3321
3733
  }
3322
3734
  function frameChanges(before, after) {
@@ -3332,6 +3744,137 @@ function frameChanges(before, after) {
3332
3744
  return out;
3333
3745
  }
3334
3746
 
3747
+ // src/core/init.ts
3748
+ import fs14 from "fs";
3749
+ import MagicString2 from "magic-string";
3750
+ import path14 from "path";
3751
+ var CONFIG_FILES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.mts"];
3752
+ function nextConfigStatus(root) {
3753
+ for (const name of CONFIG_FILES) {
3754
+ const file = path14.join(root, name);
3755
+ if (fs14.existsSync(file)) return { file, wrapped: fs14.readFileSync(file, "utf8").includes("withTruecanvas") };
3756
+ }
3757
+ return { file: null, wrapped: false };
3758
+ }
3759
+ function initProject(config) {
3760
+ const { root } = config;
3761
+ const report = { done: [], todo: [] };
3762
+ const status = nextConfigStatus(root);
3763
+ if (!status.file) {
3764
+ const file = path14.join(root, "next.config.ts");
3765
+ fs14.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(`${path14.basename(status.file)} already uses withTruecanvas()`);
3775
+ } else {
3776
+ const src = fs14.readFileSync(status.file, "utf8");
3777
+ const next = wrapConfigExport(src);
3778
+ if (next && next !== src && next.includes("withTruecanvas(")) {
3779
+ fs14.writeFileSync(status.file, next);
3780
+ report.done.push(`Wrapped ${path14.basename(status.file)} with withTruecanvas()`);
3781
+ } else {
3782
+ report.todo.push(`Wrap your ${path14.basename(status.file)} export with withTruecanvas() from "truecanvas/next"`);
3783
+ }
3784
+ }
3785
+ const pkgFile = path14.join(root, "package.json");
3786
+ try {
3787
+ const raw = fs14.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
+ fs14.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)) report.todo.push("Next.js isn't in package.json: Truecanvas currently needs a Next.js (App Router) app");
3800
+ } catch {
3801
+ report.todo.push("Couldn't read package.json");
3802
+ }
3803
+ const ignoreFile = path14.join(root, ".gitignore");
3804
+ const entry = `/${config.appDir}/truecanvas/`;
3805
+ const ignore = fs14.existsSync(ignoreFile) ? fs14.readFileSync(ignoreFile, "utf8") : "";
3806
+ if (!ignore.split("\n").some((l) => l.trim() === entry || l.trim() === entry.slice(1))) {
3807
+ fs14.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 = path14.join(root, name);
3815
+ if (!fs14.existsSync(file)) continue;
3816
+ const src = fs14.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
+ fs14.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 (!fs14.existsSync(path14.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
+
3335
3878
  export {
3336
3879
  loadConfig,
3337
3880
  parseModule,
@@ -3351,16 +3894,28 @@ export {
3351
3894
  componentsDir,
3352
3895
  DEVICES,
3353
3896
  findDevice,
3897
+ assertCanvasName,
3898
+ syncComponentRegistry,
3354
3899
  Comments,
3355
3900
  Git,
3356
- syncComponentRegistry,
3357
3901
  Workspace,
3358
3902
  componentJsx,
3903
+ detectPm,
3904
+ addArgs,
3905
+ toolEnv,
3906
+ run2 as run,
3907
+ shadcnStatus,
3908
+ shadcnRegistry,
3909
+ shadcnInit,
3910
+ shadcnAdd,
3359
3911
  outlineDoc,
3360
3912
  describeComponent,
3361
3913
  readDesignTokens,
3362
3914
  readTokenData,
3363
3915
  writeCompare,
3364
3916
  clearCompare,
3365
- frameChanges
3917
+ frameChanges,
3918
+ nextConfigStatus,
3919
+ initProject,
3920
+ wrapConfigExport
3366
3921
  };