truecanvas 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,9 +18,10 @@ Truecanvas renders your actual `.tsx` components on an infinite canvas and write
18
18
 
19
19
  - **Real components.** Layers are instances of your components, with a props panel generated from their TypeScript types.
20
20
  - **Code is the document.** A canvas is a `.canvas.tsx` file. Edits are minimal patches that keep your formatting, so diffs stay reviewable.
21
- - **Agent-first.** 44 MCP tools to read the tree, insert JSX, set props and classes, create frames and variants, and take screenshots.
21
+ - **Agent-first.** 45 MCP tools to read the tree, insert JSX, set props and classes, create frames and variants, and take screenshots.
22
22
  - **Design on real pages.** Link any Next.js page as a frame: editing its layers edits `page.tsx` and its layouts.
23
23
  - **Components and libraries.** Turn a selection into a component, edit main components, install icon sets (Lucide, Tabler, Phosphor, Heroicons, Radix) and add shadcn/ui components.
24
+ - **Share with clients.** One click freezes a page into a private link on your own domain: clients look and comment in their browser, nothing to install, and their comments land on your canvas for you or your agent to answer.
24
25
  - **Git built in.** Per-frame change summaries, branches, commits, pull requests with before/after images, visual compare and comments that travel with branches.
25
26
  - **Motion, devices, themes.** Scroll reveals and text animations, iPhone/Pixel/iPad presets with device chrome, and per-frame light and dark themes.
26
27
  - **Lightweight.** Frames render through your existing dev server. Headless Chromium only starts when an agent asks for a screenshot.
@@ -91,7 +92,35 @@ export default function ChatCanvas() {
91
92
  2. Each frame loads `/truecanvas/<canvas>` from your dev server, where a small runtime maps what's under the pointer back to that position.
92
93
  3. Your gestures and your agent's MCP calls become the same commands. The server patches the source and your dev server hot-reloads the frame.
93
94
 
94
- Collaboration goes through git: canvases, comments and linked pages are files, so design changes are branched, reviewed and merged like code. The **Truecanvas window** (`npx truecanvas open`) manages several projects in tabs, clones from GitHub and reviews pull requests on the canvas.
95
+ ## MCP tools
96
+
97
+ | | Tools |
98
+ | --- | --- |
99
+ | Read | `list_canvases` `get_canvas` `get_node` `get_selection` `list_components` `get_component` `get_design_tokens` `list_comments` |
100
+ | Edit | `insert_jsx` `replace_node` `set_props` `set_text` `set_class_name` `move_node` `duplicate_nodes` `delete_nodes` `wrap_nodes` `undo` `redo` |
101
+ | Frames and pages | `create_frame` `update_frame` `create_variants_frame` `create_canvas` `rename_canvas` `delete_canvas` `import_page` `explore_copy` `apply_to_page` |
102
+ | Components and libraries | `create_component` `open_component` `list_libraries` `install_library` `search_icons` `insert_icon` `add_shadcn_components` |
103
+ | Look and show | `share_canvas` `screenshot_frame` `focus` `play_frame` `add_animation` `remove_animation` `add_background` `apply_preset` `reply_comment` `resolve_comment` |
104
+
105
+ Node ids are the JSX tag's `line:col` and change after edits; every write returns the fresh ids. Catalog components are imported automatically.
106
+
107
+ ## Collaborate
108
+
109
+ Canvases, comments and linked pages are files in your repo, so design changes are branched, reviewed and merged like code. From the Git panel you can branch, commit with a per-frame summary, open a pull request with before/after images, and compare against any branch or commit. Comments are saved with the canvas and agents can read and resolve them.
110
+
111
+ The **Truecanvas window** (`npx truecanvas open`) manages several projects in tabs, clones from GitHub and reviews pull requests on the canvas.
112
+
113
+ ## Share with clients
114
+
115
+ **Share** (top right in the editor), `npx truecanvas share <canvas>`, or your agent's `share_canvas` renders every frame through your app and freezes it into static HTML and CSS: no scripts, so a link can never call your API or leak a session. It's published to your own review site as a new version of that page's link. Clients open it in any browser, with an optional password, and pin comments on the frames. Their threads sync into the canvas's comments while Truecanvas runs; your replies and your agent's (`reply_comment`, `resolve_comment`) go back to the link, under your studio's name. Comments you start in Truecanvas stay internal. `--revoke` takes a link down and `--delete` removes it with its comments.
116
+
117
+ The review site is `packages/review`, a small Next.js app you deploy once to your Vercel account:
118
+
119
+ 1. Create a Vercel project from this repo with **Root Directory** `packages/review`, and add a private **Blob** store and **Upstash for Redis** (both have free tiers).
120
+ 2. Set `REVIEW_TOKEN` (a long random string), and optionally `BRAND_NAME` and `BRAND_LOGO` (a logo URL) for your studio's branding.
121
+ 3. Add your domain (for example `review.your-studio.com`), then connect Truecanvas: `npx truecanvas share setup https://review.your-studio.com`.
122
+
123
+ Without a review site, Share still makes a local preview of what clients would see.
95
124
 
96
125
  ## Configuration
97
126
 
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  analyzeComponents
3
- } from "./chunk-WU7NBTJH.js";
3
+ } from "./chunk-2IIAMOFH.js";
4
4
 
5
5
  // src/core/catalog-worker.ts
6
6
  import { parentPort, workerData } from "worker_threads";
@@ -86,19 +86,27 @@ async function analyzeComponents(root, globs, libraries = []) {
86
86
  if (!sf) continue;
87
87
  const moduleSymbol = checker.getSymbolAtLocation(sf);
88
88
  if (!moduleSymbol) continue;
89
+ const named = /* @__PURE__ */ new Set();
90
+ const found = [];
89
91
  for (let sym of checker.getExportsOfModule(moduleSymbol)) {
92
+ const exported = sym.getName();
90
93
  if (sym.flags & ts.SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym);
91
- const name = sym.getName();
92
- if (!/^[A-Z]/.test(name) || !(sym.flags & ts.SymbolFlags.Value)) continue;
93
94
  const decl = sym.valueDeclaration ?? sym.declarations?.[0];
94
95
  if (!decl) continue;
96
+ const own = decl.name;
97
+ const name = sym.getName() === "default" && own && ts.isIdentifier(own) ? own.text : sym.getName();
98
+ if (!/^[A-Z]/.test(name) || !(sym.flags & ts.SymbolFlags.Value)) continue;
95
99
  const type = checker.getTypeOfSymbolAtLocation(sym, decl);
96
100
  const sig = type.getCallSignatures()[0];
97
101
  if (!sig) continue;
98
102
  const ret = checker.typeToString(sig.getReturnType());
99
103
  if (!/Element|ReactNode|ReactPortal|null|any/.test(ret) || /^Promise</.test(ret)) continue;
100
- out.push(describe(ts, checker, sym, decl, sig, library ?? posix(path2.relative(root, file)), library));
104
+ const spec = { ...describe(ts, checker, sym, decl, sig, library ?? posix(path2.relative(root, file)), library), name };
105
+ if (exported === "default") spec.defaultExport = true;
106
+ else named.add(spec.name);
107
+ found.push(spec);
101
108
  }
109
+ out.push(...found.filter((s) => !(s.defaultExport && named.has(s.name))));
102
110
  }
103
111
  for (const spec of out) applyProfile(spec);
104
112
  if (libraries.length) await attachPresets(root, out);
@@ -12,7 +12,7 @@ import {
12
12
  importSpecifier,
13
13
  posix,
14
14
  resolveAliasPath
15
- } from "./chunk-WU7NBTJH.js";
15
+ } from "./chunk-2IIAMOFH.js";
16
16
  import {
17
17
  ICON_LIBRARIES,
18
18
  iconLibrary,
@@ -549,10 +549,14 @@ ${ind}${close}`);
549
549
  this.selectPaths.push(node.path);
550
550
  }
551
551
  added = /* @__PURE__ */ new Set();
552
- ensureImport(name, from) {
552
+ ensureImport(name, from, isDefault = false) {
553
553
  if (this.doc.imports.some((i) => i.names.includes(name) || i.defaultName === name)) return;
554
554
  if (this.added.has(name)) return;
555
555
  this.added.add(name);
556
+ if (isDefault) {
557
+ this.addImportLine(`import ${name} from ${JSON.stringify(from)};`);
558
+ return;
559
+ }
556
560
  const same = this.doc.imports.find((i) => i.source === from && !i.typeOnly && i.lastSpecifierEnd !== null);
557
561
  if (same) {
558
562
  this.s.appendLeft(same.lastSpecifierEnd, `, ${name}`);
@@ -954,6 +958,7 @@ function componentRegistry(config, specs) {
954
958
  from = posix(path.relative(routeDir, path.join(config.root, c.file))).replace(/\.(tsx|ts|jsx|js)$/, "");
955
959
  if (!from.startsWith(".")) from = `./${from}`;
956
960
  }
961
+ if (c.defaultExport) return ` ${JSON.stringify(c.name)}: lazy(() => import(${JSON.stringify(from)})),`;
957
962
  return ` ${JSON.stringify(c.name)}: lazy(() => import(${JSON.stringify(from)}).then((m) => ({ default: m[${JSON.stringify(c.name)}] }))),`;
958
963
  });
959
964
  return `"use client";
@@ -984,21 +989,30 @@ var Comments = class {
984
989
  file(canvas) {
985
990
  return path2.join(this.config.root, this.config.canvasDir, `${assertCanvasName(canvas)}.comments.json`);
986
991
  }
987
- list(canvas) {
992
+ read(canvas) {
988
993
  try {
989
- return JSON.parse(fs2.readFileSync(this.file(canvas), "utf8")).threads ?? [];
994
+ const f = JSON.parse(fs2.readFileSync(this.file(canvas), "utf8"));
995
+ return { version: 1, threads: f.threads ?? [], dismissed: f.dismissed };
990
996
  } catch {
991
- return [];
997
+ return { version: 1, threads: [] };
992
998
  }
993
999
  }
994
- save(canvas, threads) {
1000
+ list(canvas) {
1001
+ return this.read(canvas).threads;
1002
+ }
1003
+ /** Ids of client threads deleted here. */
1004
+ dismissed(canvas) {
1005
+ return new Set(this.read(canvas).dismissed ?? []);
1006
+ }
1007
+ save(canvas, threads, dismissed = this.read(canvas).dismissed) {
995
1008
  const file = this.file(canvas);
996
- if (!threads.length) {
1009
+ if (!threads.length && !dismissed?.length) {
997
1010
  if (fs2.existsSync(file)) fs2.rmSync(file);
998
1011
  return;
999
1012
  }
1000
1013
  fs2.mkdirSync(path2.dirname(file), { recursive: true });
1001
- fs2.writeFileSync(file, `${JSON.stringify({ version: 1, threads }, null, 2)}
1014
+ const data = { version: 1, threads, ...dismissed?.length ? { dismissed } : {} };
1015
+ fs2.writeFileSync(file, `${JSON.stringify(data, null, 2)}
1002
1016
  `);
1003
1017
  }
1004
1018
  update(canvas, id, fn) {
@@ -1036,12 +1050,23 @@ var Comments = class {
1036
1050
  return this.update(canvas, id, (t) => {
1037
1051
  t.resolved = resolved;
1038
1052
  t.resolvedBy = resolved ? author : void 0;
1053
+ t.resolvedAt = Date.now();
1039
1054
  });
1040
1055
  }
1056
+ /** Applies synced threads (from a share link) in one write; returns whether anything changed. */
1057
+ merge(canvas, fn) {
1058
+ const threads = this.list(canvas);
1059
+ if (!fn(threads)) return false;
1060
+ this.save(canvas, threads);
1061
+ return true;
1062
+ }
1041
1063
  remove(canvas, id) {
1064
+ const { threads, dismissed = [] } = this.read(canvas);
1065
+ const gone = threads.find((t) => t.id === id);
1042
1066
  this.save(
1043
1067
  canvas,
1044
- this.list(canvas).filter((t) => t.id !== id)
1068
+ threads.filter((t) => t.id !== id),
1069
+ gone?.share ? [.../* @__PURE__ */ new Set([...dismissed, id])] : dismissed
1045
1070
  );
1046
1071
  }
1047
1072
  /** Frames keep their comments when renamed. */
@@ -2737,7 +2762,7 @@ var Workspace = class {
2737
2762
  const spec = this.catalog.get(cmd.component);
2738
2763
  if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
2739
2764
  const jsx = componentJsx(spec, cmd.props, cmd.text);
2740
- ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2765
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file), spec.defaultExport);
2741
2766
  ed.insertChild(parent, cmd.index, jsx);
2742
2767
  return `Inserted ${spec.name}`;
2743
2768
  }
@@ -2873,7 +2898,7 @@ var Workspace = class {
2873
2898
  const preset = cmd.preset ? spec.presets?.find((p) => p.name.toLowerCase() === cmd.preset.toLowerCase()) : void 0;
2874
2899
  if (cmd.preset && !preset) throw new EditError(`${spec.name} has no preset "${cmd.preset}". Presets: ${spec.presets?.map((p) => p.name).join(", ") || "none"}`);
2875
2900
  const props = { className: "pointer-events-none absolute inset-0 -z-10 h-full w-full", ...preset ? presetDiff(spec, preset.props) : {} };
2876
- ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2901
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file), spec.defaultExport);
2877
2902
  if (parent.kind === "element") {
2878
2903
  const cls = parent.props.className;
2879
2904
  if (cls === void 0 || cls.kind === "string") {
@@ -2904,7 +2929,7 @@ var Workspace = class {
2904
2929
  if (!prop) throw new EditError(`${spec.name} has no prop "${cmd.prop}".`);
2905
2930
  const values = prop.type === "enum" ? prop.options : prop.type === "boolean" ? [false, true] : [];
2906
2931
  if (!values.length) throw new EditError(`Prop "${cmd.prop}" is ${prop.typeText}; variants need a union of string literals or a boolean.`);
2907
- ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2932
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file), spec.defaultExport);
2908
2933
  const label = (v) => typeof v === "string" ? v[0].toUpperCase() + v.slice(1) : `${prop.name}: ${v}`;
2909
2934
  const rows = indentBlock(values.map((v) => componentJsx(spec, { ...cmd.props, [prop.name]: v }, spec.acceptsChildren ? label(v) : void 0)).join("\n"));
2910
2935
  const right = rightEdge(doc);
@@ -2923,7 +2948,7 @@ ${rows}
2923
2948
  ed.selectPaths.push(existing.path);
2924
2949
  return `Opened ${spec.name}`;
2925
2950
  }
2926
- ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file));
2951
+ ed.ensureImport(spec.name, importSpecifier(this.config.root, rel, spec.file), spec.defaultExport);
2927
2952
  const right = rightEdge(doc);
2928
2953
  const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
2929
2954
  ed.insertFrame(
@@ -3063,7 +3088,7 @@ ${rows}
3063
3088
  if (ed.doc.imports.some((i) => i.names.includes(name) || i.defaultName === name)) continue;
3064
3089
  const spec = this.catalog.get(name);
3065
3090
  if (!spec) throw new EditError(`Unknown component <${name}>. It is not imported in the canvas and not in the component catalog. Use list_components.`);
3066
- ed.ensureImport(name, importSpecifier(this.config.root, rel, spec.file));
3091
+ ed.ensureImport(name, importSpecifier(this.config.root, rel, spec.file), spec.defaultExport);
3067
3092
  }
3068
3093
  }
3069
3094
  cleanCache = /* @__PURE__ */ new Map();