truecanvas 0.6.5 → 0.6.6

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
@@ -116,7 +116,7 @@ The **desktop app** (Linux AppImage and deb, from the [releases](https://github.
116
116
 
117
117
  **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.
118
118
 
119
- 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.
119
+ On a review site that hosts live sites (the Cloudflare one), each share also includes **the real site**: Truecanvas builds your app as static pages (a copy, so your dev server keeps running), and page frames show the actual page, scrollable and clickable, animations included. Clients click a page to interact with it, and **Full screen** opens it at a real screen size. Sharing again only uploads the files that changed. Pages that need a server (API routes, middleware) can't be built this way: the share then keeps frozen frames and says why. `--frozen` leaves the real site out; `--live <dir|url>` uses another build folder or the URL where the app runs.
120
120
 
121
121
  On the Cloudflare review site, **Go live** (next to Share), `npx truecanvas share <canvas> --session` or your agent's `start_session` opens a **live session**: everyone with access to the link sees the canvas live from your app, updating as you edit, with each other's cursors and comments arriving as they're written. Stop it and the link shows the latest published version again.
122
122
 
@@ -39,8 +39,11 @@ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); c
39
39
  .frame.interacting .interact-tag { display: inline-flex; }
40
40
  .interact-tag button { height: 20px; padding: 0 8px; border-radius: 6px; border: 0; background: var(--accent); color: #fff; font: inherit; font-size: 11px; font-weight: 600; cursor: pointer; }
41
41
  .frame-label { position: absolute; left: 0; bottom: 100%; padding-bottom: 6px; white-space: nowrap; color: var(--muted);
42
- font-size: 12px; transform-origin: 0 100%; }
43
- .frame-label b { color: var(--text); font-weight: 550; margin-right: 6px; }
42
+ font-size: 12px; transform-origin: 0 100%; display: flex; align-items: center; overflow: hidden; }
43
+ /* within its frame's width: the name gives way last, the size first */
44
+ .frame-label b { color: var(--text); font-weight: 550; margin-right: 6px; flex: 0 1 auto; min-width: 24px; overflow: hidden; text-overflow: ellipsis; }
45
+ .frame-label .fsize { flex: 0 1000 auto; min-width: 0; overflow: hidden; }
46
+ .frame-label .live-btn, .frame-label .interact-tag { flex: none; }
44
47
  .frame-box { position: relative; overflow: hidden; border-radius: 2px; background: #fff; box-shadow: var(--frame-shadow); }
45
48
  .frame-box img, .frame-box iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; display: block; }
46
49
  .frame-box iframe { opacity: 0; transition: opacity .25s var(--ease-out); pointer-events: none; }
@@ -52,7 +55,7 @@ header { position: fixed; top: 12px; left: 12px; right: 12px; display: flex; ali
52
55
  .brand span { font-weight: 600; letter-spacing: -.01em; }
53
56
  .brand .who { font-weight: 450; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
54
57
  .brand form { display: contents; }
55
- .brand .who-btn, .brand a.who-btn { height: 28px; padding: 0 8px; border-radius: 8px; font-size: 13px; color: var(--text); text-decoration: none; display: inline-flex; align-items: center; }
58
+ .brand .who-btn, .brand a.who-btn { height: 28px; padding: 0 8px; border-radius: 8px; font-size: 13px; color: var(--text); text-decoration: none; display: inline-flex; align-items: center; white-space: nowrap; }
56
59
  .brand .who-btn:hover { background: var(--line); }
57
60
  .title { font-weight: 550; }
58
61
  .meta { color: var(--muted); }
@@ -76,7 +79,8 @@ button:hover { background: var(--line); }
76
79
  button:active { transform: scale(.96); }
77
80
  button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
78
81
  #zoom { min-width: 56px; }
79
- .made { position: fixed; right: 14px; bottom: 18px; color: var(--muted); font-size: 11.5px; text-decoration: none; }
82
+ .made { position: fixed; right: 12px; bottom: 14px; height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 8px;
83
+ background: var(--panel); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border: 1px solid var(--line); color: var(--muted); font-size: 11.5px; text-decoration: none; }
80
84
  .made:hover { color: var(--text); }
81
85
  #empty { position: fixed; inset: 0; display: grid; place-items: center; color: var(--muted); }
82
86
  /* comments */
@@ -141,6 +145,16 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
141
145
  #pop { left: 8px !important; right: 8px; top: auto !important; bottom: 8px; width: auto; max-height: 60dvh; }
142
146
  #panel { left: 8px; right: 8px; top: 56px; bottom: 68px; width: auto; }
143
147
  }
148
+ /* first visit: how to review, once */
149
+ #welcome { position: fixed; z-index: 25; left: 50%; bottom: 66px; transform: translateX(-50%); width: min(360px, calc(100vw - 16px)); padding: 14px 16px 12px;
150
+ background: var(--panel-solid); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 18px 50px -12px rgba(0,0,0,.35); animation: pop-in .2s var(--ease-out); }
151
+ #welcome b { display: block; font-weight: 600; margin-bottom: 8px; }
152
+ #welcome ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 7px; color: var(--muted); }
153
+ #welcome li { display: flex; gap: 9px; align-items: baseline; }
154
+ #welcome li::before { content: ""; flex: none; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); transform: translateY(-2px); }
155
+ #welcome strong { font-weight: 600; color: var(--text); }
156
+ #welcome kbd { font: inherit; font-size: 11px; padding: 0 5px; border-radius: 5px; border: 1px solid var(--line); background: var(--field); color: var(--text); }
157
+ #welcome .welcome-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
144
158
  /* live site */
145
159
  .live-btn { height: 22px; min-width: 0; margin-left: 8px; padding: 0 8px 0 6px; gap: 4px; border-radius: 6px; vertical-align: middle;
146
160
  background: var(--accent-soft); color: var(--accent); font-size: 11.5px; font-weight: 600; }
@@ -340,7 +354,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
340
354
  const el = document.createElement("div");
341
355
  el.className = "frame";
342
356
  el.style.cssText = "left:" + (f.x - minX) + "px;top:" + (f.y - minY) + "px;width:" + f.width + "px;height:" + f.height + "px";
343
- 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="The real site in a screen-sized window: it scrolls like on a real screen, so scroll animations and pinned sections behave as they will for visitors"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>Full screen</button>' : "") + '<span class="interact-tag">Interacting<button type="button" class="interact-done">Done</button></span>' + "</div>" +
357
+ el.innerHTML = '<div class="frame-label" data-w="' + f.width + '"><b title="' + esc(f.name) + '">' + esc(f.name) + '</b><span class="fsize">' + f.width + " \xD7 " + Math.round(f.height) + "</span>" + (showsLive(f) ? '<button class="live-btn" title="The real site in a screen-sized window: it scrolls like on a real screen, so scroll animations and pinned sections behave as they will for visitors"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></svg>Full screen</button>' : "") + '<span class="interact-tag">Interacting<button type="button" class="interact-done">Done</button></span>' + "</div>" +
344
358
  '<div class="frame-box" style="width:' + f.width + "px;height:" + f.height + 'px"><img alt="" src="' + esc(f.image) + '"><iframe title="' + esc(f.name) + '" scrolling="no" loading="lazy"></iframe></div>';
345
359
  restFrame(f, el.querySelector("iframe"), el.querySelector("img"));
346
360
  const liveBtn = el.querySelector(".live-btn");
@@ -359,7 +373,10 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
359
373
  world.style.transform = "translate(" + x + "px," + y + "px) scale(" + z + ")";
360
374
  zoomLabel.textContent = Math.round(z * 100) + "%";
361
375
  // labels keep a readable size at any zoom
362
- for (const l of labels) l.style.transform = "scale(" + 1 / z + ")";
376
+ for (const l of labels) {
377
+ l.style.transform = "scale(" + 1 / z + ")";
378
+ l.style.maxWidth = Math.max(40, l.dataset.w * z) + "px";
379
+ }
363
380
  for (const p of world.querySelectorAll(".pin, .rcursor")) p.style.transform = "scale(" + 1 / z + ")";
364
381
  if (typeof placePop === "function") placePop();
365
382
  };
@@ -806,6 +823,35 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
806
823
  } catch {}
807
824
  }
808
825
 
826
+ welcome();
827
+ /** The first time someone opens a review link: how it works, in three lines. */
828
+ function welcome() {
829
+ try {
830
+ if (localStorage.getItem("tc-review-welcomed")) return;
831
+ } catch {
832
+ return;
833
+ }
834
+ const touch = matchMedia("(pointer: coarse)").matches;
835
+ const tips = [
836
+ touch ? "Drag to move around, pinch to zoom." : "Scroll or drag to move around. Pinch or <kbd>Ctrl</kbd> + scroll to zoom.",
837
+ manifest.frames.some(showsLive) ? "Pages are the real site: " + (touch ? "tap one" : "click one") + " to scroll and try it." : "",
838
+ canComment ? "Press <strong>Comment</strong>" + (touch ? "" : " (or <kbd>C</kbd>)") + ", then " + (touch ? "tap" : "click") + " the design to leave a note." : "",
839
+ ].filter(Boolean);
840
+ const card = document.createElement("div");
841
+ card.id = "welcome";
842
+ card.setAttribute("role", "dialog");
843
+ card.setAttribute("aria-label", "How to review");
844
+ card.innerHTML = "<b>How to review</b><ul>" + tips.map((t) => "<li><span>" + t + "</span></li>").join("") + '</ul><div class="welcome-actions"><button class="btn-primary">Got it</button></div>';
845
+ const done = () => {
846
+ card.remove();
847
+ try { localStorage.setItem("tc-review-welcomed", "1"); } catch {}
848
+ };
849
+ card.querySelector("button").onclick = done;
850
+ // the first comment or interaction counts as having read it
851
+ stage.addEventListener("pointerdown", () => setTimeout(done, 1200), { once: true });
852
+ document.body.appendChild(card);
853
+ }
854
+
809
855
  if (canComment) {
810
856
  for (const id of ["comment", "comment-sep", "list"]) document.getElementById(id).hidden = false;
811
857
  document.getElementById("comment").onclick = () => setCommenting(!commenting);
@@ -210,16 +210,27 @@ function projectName(dir) {
210
210
  }
211
211
  function inspect(dir) {
212
212
  const pkg = readPkg(dir);
213
- if (!pkg) return { next: false, ready: false, canvases: 0, exists: fs3.existsSync(dir) };
213
+ if (!pkg) return { next: false, ready: false, canvases: 0, clientComments: 0, exists: fs3.existsSync(dir) };
214
214
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
215
215
  let canvases = 0;
216
+ let clientComments = 0;
216
217
  for (const d of ["canvas", "src/canvas"]) {
218
+ let files;
217
219
  try {
218
- canvases += fs3.readdirSync(path3.join(dir, d)).filter((f) => f.endsWith(".canvas.tsx")).length;
220
+ files = fs3.readdirSync(path3.join(dir, d));
219
221
  } catch {
222
+ continue;
223
+ }
224
+ canvases += files.filter((f) => f.endsWith(".canvas.tsx")).length;
225
+ for (const f of files.filter((f2) => f2.endsWith(".comments.json"))) {
226
+ try {
227
+ const { threads } = JSON.parse(fs3.readFileSync(path3.join(dir, d, f), "utf8"));
228
+ clientComments += (threads ?? []).filter((t) => !t.resolved && t.messages?.[0]?.author?.kind === "client").length;
229
+ } catch {
230
+ }
220
231
  }
221
232
  }
222
- return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, exists: true };
233
+ return { next: "next" in deps || "vite" in deps && "react" in deps, ready: "truecanvas" in deps, canvases, clientComments, exists: true };
223
234
  }
224
235
  function discover() {
225
236
  const home = os3.homedir();