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 +31 -2
- package/dist/catalog-worker.js +1 -1
- package/dist/{chunk-WU7NBTJH.js → chunk-2IIAMOFH.js} +11 -3
- package/dist/{chunk-XWQ5MS24.js → chunk-A4QIAVTG.js} +39 -14
- package/dist/chunk-M7B3W7EP.js +659 -0
- package/dist/cli.js +900 -214
- package/dist/core/index.d.ts +17 -2
- package/dist/core/index.js +2 -2
- package/dist/editor/assets/{Menu-DXPm0EXo.css → Menu-BJ3OmqzH.css} +1 -1
- package/dist/editor/assets/{hub-UhU8yp5F.js → hub-B5hqZCM2.js} +1 -1
- package/dist/editor/assets/{main-UtSaCfnK.js → main-woTyWmya.js} +5 -5
- package/dist/editor/hub.html +3 -3
- package/dist/editor/index.html +3 -3
- package/dist/share/index.d.ts +36 -0
- package/dist/share/index.js +6 -0
- package/package.json +5 -1
- /package/dist/editor/assets/{Menu-BFifVcgz.js → Menu-C0Ti-6T2.js} +0 -0
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.**
|
|
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
|
-
|
|
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
|
|
package/dist/catalog-worker.js
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
|
|
992
|
+
read(canvas) {
|
|
988
993
|
try {
|
|
989
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|