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 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
- The review site is `packages/review`, a small Next.js app you deploy once to your Vercel account:
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
- 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`.
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
 
@@ -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";
@@ -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
- @media (prefers-reduced-motion: reduce) { .frame-box iframe { transition: none; } }
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;