truecanvas 0.4.0 → 0.5.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.
- package/README.md +7 -4
- package/dist/catalog-worker.js +1 -1
- package/dist/{chunk-WU7NBTJH.js → chunk-2IIAMOFH.js} +11 -3
- package/dist/{chunk-OVYSWA2F.js → chunk-A4QIAVTG.js} +12 -7
- package/dist/{chunk-M7B3W7EP.js → chunk-DV76O5SZ.js} +73 -4
- package/dist/cli.js +203 -133
- package/dist/core/index.d.ts +3 -1
- package/dist/core/index.js +2 -2
- package/dist/share/index.d.ts +4 -1
- package/dist/share/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,11 +114,14 @@ The **Truecanvas window** (`npx truecanvas open`) manages several projects in ta
|
|
|
114
114
|
|
|
115
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
116
|
|
|
117
|
-
|
|
117
|
+
Add `--live <dir|url>` (or `live` in `share_canvas`) and clients also get **View live** on each frame: the real site at that frame's width, animations and interactions included. Pass a static build (Next `out/`, Vite `dist/`) for the review site to host, or the URL where the app already runs.
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
You deploy the review site once, on your own domain:
|
|
120
|
+
|
|
121
|
+
- **Cloudflare** (`packages/review-worker`, recommended): Workers, D1 and R2 on the free plan, hosts live sites. See its [README](packages/review-worker/README.md).
|
|
122
|
+
- **Vercel** (`packages/review`): create a project with **Root Directory** `packages/review`, add a private **Blob** store and **Upstash for Redis**, set `REVIEW_TOKEN` (a long random string) and optionally `BRAND_NAME` and `BRAND_LOGO`, then add your domain. Frozen frames and comments only.
|
|
123
|
+
|
|
124
|
+
Then connect Truecanvas: `npx truecanvas share setup https://review.your-studio.com`.
|
|
122
125
|
|
|
123
126
|
Without a review site, Share still makes a local preview of what clients would see.
|
|
124
127
|
|
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";
|
|
@@ -2757,7 +2762,7 @@ var Workspace = class {
|
|
|
2757
2762
|
const spec = this.catalog.get(cmd.component);
|
|
2758
2763
|
if (!spec) throw new EditError(`Unknown component "${cmd.component}". Use list_components.`);
|
|
2759
2764
|
const jsx = componentJsx(spec, cmd.props, cmd.text);
|
|
2760
|
-
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);
|
|
2761
2766
|
ed.insertChild(parent, cmd.index, jsx);
|
|
2762
2767
|
return `Inserted ${spec.name}`;
|
|
2763
2768
|
}
|
|
@@ -2893,7 +2898,7 @@ var Workspace = class {
|
|
|
2893
2898
|
const preset = cmd.preset ? spec.presets?.find((p) => p.name.toLowerCase() === cmd.preset.toLowerCase()) : void 0;
|
|
2894
2899
|
if (cmd.preset && !preset) throw new EditError(`${spec.name} has no preset "${cmd.preset}". Presets: ${spec.presets?.map((p) => p.name).join(", ") || "none"}`);
|
|
2895
2900
|
const props = { className: "pointer-events-none absolute inset-0 -z-10 h-full w-full", ...preset ? presetDiff(spec, preset.props) : {} };
|
|
2896
|
-
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);
|
|
2897
2902
|
if (parent.kind === "element") {
|
|
2898
2903
|
const cls = parent.props.className;
|
|
2899
2904
|
if (cls === void 0 || cls.kind === "string") {
|
|
@@ -2924,7 +2929,7 @@ var Workspace = class {
|
|
|
2924
2929
|
if (!prop) throw new EditError(`${spec.name} has no prop "${cmd.prop}".`);
|
|
2925
2930
|
const values = prop.type === "enum" ? prop.options : prop.type === "boolean" ? [false, true] : [];
|
|
2926
2931
|
if (!values.length) throw new EditError(`Prop "${cmd.prop}" is ${prop.typeText}; variants need a union of string literals or a boolean.`);
|
|
2927
|
-
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);
|
|
2928
2933
|
const label = (v) => typeof v === "string" ? v[0].toUpperCase() + v.slice(1) : `${prop.name}: ${v}`;
|
|
2929
2934
|
const rows = indentBlock(values.map((v) => componentJsx(spec, { ...cmd.props, [prop.name]: v }, spec.acceptsChildren ? label(v) : void 0)).join("\n"));
|
|
2930
2935
|
const right = rightEdge(doc);
|
|
@@ -2943,7 +2948,7 @@ ${rows}
|
|
|
2943
2948
|
ed.selectPaths.push(existing.path);
|
|
2944
2949
|
return `Opened ${spec.name}`;
|
|
2945
2950
|
}
|
|
2946
|
-
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);
|
|
2947
2952
|
const right = rightEdge(doc);
|
|
2948
2953
|
const top = doc.frames.length ? Math.min(...doc.frames.map((f) => f.y)) : 0;
|
|
2949
2954
|
ed.insertFrame(
|
|
@@ -3083,7 +3088,7 @@ ${rows}
|
|
|
3083
3088
|
if (ed.doc.imports.some((i) => i.names.includes(name) || i.defaultName === name)) continue;
|
|
3084
3089
|
const spec = this.catalog.get(name);
|
|
3085
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.`);
|
|
3086
|
-
ed.ensureImport(name, importSpecifier(this.config.root, rel, spec.file));
|
|
3091
|
+
ed.ensureImport(name, importSpecifier(this.config.root, rel, spec.file), spec.defaultExport);
|
|
3087
3092
|
}
|
|
3088
3093
|
}
|
|
3089
3094
|
cleanCache = /* @__PURE__ */ new Map();
|
|
@@ -126,7 +126,22 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
126
126
|
#pop { left: 8px !important; right: 8px; top: auto !important; bottom: 8px; width: auto; max-height: 60dvh; }
|
|
127
127
|
#panel { left: 8px; right: 8px; top: 56px; bottom: 68px; width: auto; }
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
/* live site */
|
|
130
|
+
.live-btn { height: 22px; min-width: 0; margin-left: 8px; padding: 0 8px 0 6px; gap: 4px; border-radius: 6px; vertical-align: middle;
|
|
131
|
+
background: var(--accent-soft); color: var(--accent); font-size: 11.5px; font-weight: 600; }
|
|
132
|
+
.live-btn:hover { background: var(--accent); color: #fff; }
|
|
133
|
+
#live { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 16px;
|
|
134
|
+
background: rgba(12,12,11,.6); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: fade-in .16s var(--ease-out); }
|
|
135
|
+
@keyframes fade-in { from { opacity: 0; } }
|
|
136
|
+
.live-bar { display: flex; align-items: center; gap: 10px; max-width: 100%; height: 40px; padding: 0 6px 0 14px; border-radius: 12px;
|
|
137
|
+
background: var(--panel-solid); border: 1px solid var(--line); box-shadow: 0 8px 28px -10px rgba(0,0,0,.35); }
|
|
138
|
+
.live-bar b { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
139
|
+
.live-bar a { color: var(--text); white-space: nowrap; text-decoration: none; padding: 0 8px; height: 30px; display: inline-flex; align-items: center; border-radius: 8px; }
|
|
140
|
+
.live-bar a:hover { background: var(--line); }
|
|
141
|
+
.live-device { position: relative; overflow: hidden; border-radius: 10px; background: #fff; box-shadow: 0 30px 80px -20px rgba(0,0,0,.6); animation: pop-in .2s var(--ease-out); }
|
|
142
|
+
.live-device iframe { position: absolute; left: 0; top: 0; border: 0; transform-origin: 0 0; background: #fff; }
|
|
143
|
+
@media (max-width: 640px) { .live-bar .meta { display: none; } }
|
|
144
|
+
@media (prefers-reduced-motion: reduce) { .frame-box iframe { transition: none; } #live, .live-device { animation: none; } }
|
|
130
145
|
</style>
|
|
131
146
|
</head>
|
|
132
147
|
<body>
|
|
@@ -137,6 +152,10 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
137
152
|
</header>
|
|
138
153
|
<aside id="panel" hidden aria-label="Comments"><div class="panel-head"><span>Comments</span><button id="panel-close" aria-label="Close">\u2715</button></div><div class="panel-list" id="panel-list"></div></aside>
|
|
139
154
|
<div id="pop" hidden role="dialog" aria-label="Comment"></div>
|
|
155
|
+
<div id="live" hidden role="dialog" aria-modal="true" aria-label="Live site">
|
|
156
|
+
<div class="live-bar"><b id="live-name"></b><span class="meta" id="live-size"></span><a id="live-tab" target="_blank" rel="noopener">Open in a new tab</a><button id="live-close" aria-label="Close live site" title="Close (Esc)">\u2715</button></div>
|
|
157
|
+
<div class="live-device" id="live-device"></div>
|
|
158
|
+
</div>
|
|
140
159
|
<footer><div class="pill">
|
|
141
160
|
<button id="out" aria-label="Zoom out" title="Zoom out (-)">\u2212</button>
|
|
142
161
|
<button id="zoom" aria-label="Zoom to fit" title="Zoom to fit (1)">100%</button>
|
|
@@ -258,10 +277,12 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
258
277
|
const el = document.createElement("div");
|
|
259
278
|
el.className = "frame";
|
|
260
279
|
el.style.cssText = "left:" + (f.x - minX) + "px;top:" + (f.y - minY) + "px;width:" + f.width + "px;height:" + f.height + "px";
|
|
261
|
-
el.innerHTML = '<div class="frame-label"><b>' + esc(f.name) + "</b>" + f.width + " \xD7 " + Math.round(f.height) + "</div>" +
|
|
280
|
+
el.innerHTML = '<div class="frame-label"><b>' + esc(f.name) + "</b>" + f.width + " \xD7 " + Math.round(f.height) + (manifest.live ? '<button class="live-btn" title="Open the live site at this width"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.5v15l13-7.5z"/></svg>View live</button>' : "") + "</div>" +
|
|
262
281
|
'<div class="frame-box" style="width:' + f.width + "px;height:" + f.height + 'px"><img alt="" src="' + esc(f.image) + '"><iframe sandbox title="' + esc(f.name) + '" scrolling="no" loading="lazy" src="' + esc(f.html) + '"></iframe></div>';
|
|
263
282
|
const iframe = el.querySelector("iframe");
|
|
264
283
|
iframe.addEventListener("load", () => iframe.classList.add("ready"));
|
|
284
|
+
const liveBtn = el.querySelector(".live-btn");
|
|
285
|
+
if (liveBtn) liveBtn.onclick = (e) => { e.stopPropagation(); openLive(f); };
|
|
265
286
|
el.dataset.name = f.name;
|
|
266
287
|
world.appendChild(el);
|
|
267
288
|
labels.push(el.querySelector(".frame-label"));
|
|
@@ -319,8 +340,8 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
319
340
|
let pinch = null;
|
|
320
341
|
let moved = false, downAt = null;
|
|
321
342
|
stage.addEventListener("pointerdown", (e) => {
|
|
322
|
-
// pins are buttons: they take their own clicks
|
|
323
|
-
if (e.target.closest && e.target.closest(".pin")) return;
|
|
343
|
+
// pins and View live are buttons: they take their own clicks
|
|
344
|
+
if (e.target.closest && e.target.closest(".pin, .live-btn")) return;
|
|
324
345
|
stage.setPointerCapture(e.pointerId);
|
|
325
346
|
moved = false;
|
|
326
347
|
downAt = { x: e.clientX, y: e.clientY };
|
|
@@ -378,6 +399,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
378
399
|
document.getElementById("out").onclick = () => zoomAt(z / 1.25, ...center());
|
|
379
400
|
zoomLabel.onclick = () => (tall() ? fitWidth() : fit());
|
|
380
401
|
addEventListener("keydown", (e) => {
|
|
402
|
+
if (liveOpen()) { if (e.key === "Escape") closeLive(); return; }
|
|
381
403
|
if (e.target.closest && e.target.closest("input, textarea, select")) return;
|
|
382
404
|
if (e.key === "Escape") { closePop(); setCommenting(false); return; }
|
|
383
405
|
if ((e.key === "c" || e.key === "C") && canComment && !e.metaKey && !e.ctrlKey) { setCommenting(!commenting); return; }
|
|
@@ -389,6 +411,53 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
|
|
389
411
|
});
|
|
390
412
|
addEventListener("resize", apply);
|
|
391
413
|
|
|
414
|
+
// ---------- live site: the real thing, at the frame's width ----------
|
|
415
|
+
const liveBox = document.getElementById("live");
|
|
416
|
+
const liveDevice = document.getElementById("live-device");
|
|
417
|
+
let liveFrame = null, liveReturn = null;
|
|
418
|
+
const liveOpen = () => !liveBox.hidden;
|
|
419
|
+
// a realistic screen for the width: desktop, tablet or phone
|
|
420
|
+
const viewportHeight = (w) => (w >= 1024 ? 900 : w > 500 ? 1024 : 844);
|
|
421
|
+
function sizeLive() {
|
|
422
|
+
if (!liveFrame) return;
|
|
423
|
+
const w = liveFrame.width, h = viewportHeight(w);
|
|
424
|
+
const s = Math.min(1, (innerWidth - 32) / w, (innerHeight - 32 - 50) / h);
|
|
425
|
+
liveDevice.style.width = Math.round(w * s) + "px";
|
|
426
|
+
liveDevice.style.height = Math.round(h * s) + "px";
|
|
427
|
+
const iframe = liveDevice.querySelector("iframe");
|
|
428
|
+
iframe.style.width = w + "px";
|
|
429
|
+
iframe.style.height = h + "px";
|
|
430
|
+
iframe.style.transform = s < 1 ? "scale(" + s + ")" : "";
|
|
431
|
+
document.getElementById("live-size").textContent = w + " \xD7 " + h + (s < 1 ? " \xB7 " + Math.round(s * 100) + "%" : "");
|
|
432
|
+
}
|
|
433
|
+
function openLive(f) {
|
|
434
|
+
liveFrame = f;
|
|
435
|
+
liveReturn = document.activeElement;
|
|
436
|
+
liveDevice.textContent = "";
|
|
437
|
+
// its own origin: scripts run there, never on the review site
|
|
438
|
+
const iframe = document.createElement("iframe");
|
|
439
|
+
iframe.title = "Live site: " + f.name;
|
|
440
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
|
|
441
|
+
iframe.setAttribute("referrerpolicy", "no-referrer");
|
|
442
|
+
iframe.src = manifest.live.url;
|
|
443
|
+
liveDevice.appendChild(iframe);
|
|
444
|
+
document.getElementById("live-name").textContent = f.name;
|
|
445
|
+
document.getElementById("live-tab").href = manifest.live.url;
|
|
446
|
+
liveBox.hidden = false;
|
|
447
|
+
sizeLive();
|
|
448
|
+
document.getElementById("live-close").focus();
|
|
449
|
+
}
|
|
450
|
+
function closeLive() {
|
|
451
|
+
liveBox.hidden = true;
|
|
452
|
+
liveDevice.textContent = "";
|
|
453
|
+
liveFrame = null;
|
|
454
|
+
if (liveReturn && liveReturn.focus) liveReturn.focus();
|
|
455
|
+
}
|
|
456
|
+
document.getElementById("live-close").onclick = closeLive;
|
|
457
|
+
// a click on the backdrop closes it too
|
|
458
|
+
liveBox.addEventListener("click", (e) => { if (e.target === liveBox) closeLive(); });
|
|
459
|
+
addEventListener("resize", sizeLive);
|
|
460
|
+
|
|
392
461
|
// ---------- comments (on a review site: the manifest says so) ----------
|
|
393
462
|
var canComment = !!manifest.comments;
|
|
394
463
|
var commenting = false;
|