truecanvas 0.5.0 → 0.6.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.
@@ -40,6 +40,10 @@ header { position: fixed; top: 12px; left: 12px; right: 12px; display: flex; ali
40
40
  background: var(--panel); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border: 1px solid var(--line); box-shadow: 0 1px 2px rgba(0,0,0,.05); }
41
41
  .brand img { height: 20px; width: auto; display: block; }
42
42
  .brand span { font-weight: 600; letter-spacing: -.01em; }
43
+ .brand .who { font-weight: 450; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
44
+ .brand form { display: contents; }
45
+ .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; }
46
+ .brand .who-btn:hover { background: var(--line); }
43
47
  .title { font-weight: 550; }
44
48
  .meta { color: var(--muted); }
45
49
  .vselect { gap: 4px; color: var(--muted); padding: 0 6px 0 8px; margin-right: -6px; max-width: 46vw; }
@@ -120,6 +124,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
120
124
  @media (max-width: 640px) {
121
125
  .meta, .sep, .made, .vdate { display: none; } header { top: 8px; left: 8px; right: 8px; gap: 8px; }
122
126
  .brand:has(img) span { display: none; }
127
+ .brand .who { display: none; }
123
128
  header .pill { min-width: 0; padding: 0 10px; }
124
129
  header .pill:last-child { flex: 1 1 auto; justify-content: flex-end; }
125
130
  .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -141,13 +146,26 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
141
146
  .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
147
  .live-device iframe { position: absolute; left: 0; top: 0; border: 0; transform-origin: 0 0; background: #fff; }
143
148
  @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; } }
149
+ /* the room: who's here, their cursors, a live session */
150
+ #room { gap: 8px; padding: 0 10px 0 12px; }
151
+ .live-badge { display: inline-flex; align-items: center; gap: 6px; font-weight: 650; font-size: 12px; color: #dc2626; letter-spacing: .01em; }
152
+ .live-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,.18); }
153
+ .faces { display: flex; }
154
+ .face { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; color: #fff; font-size: 10.5px; font-weight: 650;
155
+ border: 2px solid var(--panel-solid); margin-left: -6px; }
156
+ .face:first-child { margin-left: 0; }
157
+ .rcursor { position: absolute; width: 0; height: 0; transform-origin: 0 0; z-index: 3; pointer-events: none; transition: left .09s linear, top .09s linear; }
158
+ .rcursor svg { position: absolute; left: -2px; top: -2px; display: block; }
159
+ .rcursor span { position: absolute; left: 13px; top: 15px; white-space: nowrap; padding: 2px 7px; border-radius: 7px; color: #fff; font-size: 11.5px; font-weight: 600;
160
+ box-shadow: 0 2px 6px rgba(0,0,0,.18); }
161
+ @media (prefers-reduced-motion: reduce) { .frame-box iframe { transition: none; } #live, .live-device { animation: none; } .rcursor { transition: none; } }
145
162
  </style>
146
163
  </head>
147
164
  <body>
148
165
  <div id="stage" aria-label="Design canvas"><div id="world"></div></div>
149
166
  <header>
150
167
  <div class="pill brand" id="brand"><span>Design review</span></div>
168
+ <div class="pill" id="room" hidden aria-live="polite"></div>
151
169
  <div class="pill"><span class="title" id="title"></span><span class="sep"></span><span class="meta" id="meta"></span><button id="list" hidden aria-label="Comments" title="Comments"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v11H9l-5 4z"/></svg><span class="count" id="count">0</span></button></div>
152
170
  </header>
153
171
  <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>
@@ -267,6 +285,14 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
267
285
  if (manifest.brand) {
268
286
  document.getElementById("brand").innerHTML = (manifest.brand.logo ? '<img alt="" src="' + esc(manifest.brand.logo) + '">' : "") + (manifest.brand.name ? "<span>" + esc(manifest.brand.name) + "</span>" : "");
269
287
  }
288
+ // signed in: who's commenting, and sign out. On review sites with sign-in, a way in for the studio and invitees.
289
+ if (manifest.viewer || manifest.signIn) {
290
+ const brandEl = document.getElementById("brand");
291
+ const here = location.pathname.replace(/\\/+$/, "");
292
+ brandEl.insertAdjacentHTML("beforeend", '<span class="sep"></span>' + (manifest.viewer
293
+ ? '<span class="who" title="' + esc(manifest.viewer.email) + '">' + esc(manifest.viewer.name) + '</span><form method="post" action="/signout"><input type="hidden" name="next" value="' + esc(here) + '"><button class="who-btn" type="submit">Sign out</button></form>'
294
+ : '<a class="who-btn" href="/signin?next=' + encodeURIComponent(here) + '">Sign in</a>'));
295
+ }
270
296
 
271
297
  // layout: frames at their canvas positions
272
298
  const frames = manifest.frames;
@@ -297,7 +323,7 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
297
323
  zoomLabel.textContent = Math.round(z * 100) + "%";
298
324
  // labels keep a readable size at any zoom
299
325
  for (const l of labels) l.style.transform = "scale(" + 1 / z + ")";
300
- for (const p of world.querySelectorAll(".pin")) p.style.transform = "scale(" + 1 / z + ")";
326
+ for (const p of world.querySelectorAll(".pin, .rcursor")) p.style.transform = "scale(" + 1 / z + ")";
301
327
  if (typeof placePop === "function") placePop();
302
328
  };
303
329
  const zoomAt = (nz, cx, cy) => {
@@ -467,7 +493,8 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
467
493
  var popAnchor = null;
468
494
  var pop = document.getElementById("pop");
469
495
  const studioName = (manifest.brand && manifest.brand.name) || "Studio";
470
- const storedName = () => { try { return localStorage.getItem("tc-review-name") || ""; } catch { return ""; } };
496
+ // signed in: comments carry the account's name, nothing to type
497
+ const storedName = () => { if (manifest.viewer) return manifest.viewer.name; try { return localStorage.getItem("tc-review-name") || ""; } catch { return ""; } };
471
498
  const saveName = (n) => { try { localStorage.setItem("tc-review-name", n); } catch {} };
472
499
  const ago = (t) => {
473
500
  const s = Math.round((Date.now() - t) / 1000);
@@ -716,6 +743,156 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
716
743
  setInterval(() => document.visibilityState === "visible" && refresh(), 15000);
717
744
  addEventListener("visibilitychange", () => document.visibilityState === "visible" && refresh());
718
745
  }
746
+
747
+ // ---------- the link's room: who's here, their cursors, comments as they come, live sessions ----------
748
+ if (manifest.room && "WebSocket" in window) {
749
+ const people = new Map();
750
+ const roomPill = document.getElementById("room");
751
+ let me = null, live = false, route = null, sock = null, retry = 1000, ping = 0;
752
+ // a session shows the app as it is now: only on the latest version
753
+ const canLive = !!manifest.session && (!manifest.versions || !manifest.versions.length || manifest.versions[0].id === manifest.id);
754
+ const initials = (n) => n.split(" ").filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("") || "?";
755
+
756
+ function renderRoom() {
757
+ const others = [...people.values()];
758
+ roomPill.hidden = !live && !others.length;
759
+ roomPill.textContent = "";
760
+ if (live) roomPill.appendChild(el("span", "live-badge", "Live"));
761
+ const faces = el("span", "faces");
762
+ for (const p of others.slice(0, 5)) {
763
+ const f = el("span", "face", initials(p.name));
764
+ f.style.background = p.color;
765
+ f.title = p.name + (p.kind === "studio" ? " (" + studioName + ")" : "");
766
+ faces.appendChild(f);
767
+ }
768
+ if (others.length > 5) faces.appendChild(el("span", "face", "+" + (others.length - 5))).style.background = "#8b8a85";
769
+ if (others.length) roomPill.appendChild(faces);
770
+ roomPill.setAttribute("aria-label", (live ? "Live session. " : "") + (others.length ? others.map((p) => p.name).join(", ") + " here" : ""));
771
+ }
772
+
773
+ function cursorOf(p) {
774
+ if (p.el) return p.el;
775
+ p.el = el("div", "rcursor");
776
+ p.el.innerHTML = '<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M2.2 1.6 15.1 8.3c.7.4.6 1.4-.2 1.6l-5.3 1.3-2.6 4.8c-.4.7-1.4.6-1.6-.2L1.1 2.7c-.2-.8.5-1.4 1.1-1.1Z" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/></svg>';
777
+ p.el.querySelector("path").setAttribute("fill", p.color);
778
+ const name = el("span", null, p.name);
779
+ name.style.background = p.color;
780
+ p.el.appendChild(name);
781
+ p.el.style.transform = "scale(" + 1 / z + ")";
782
+ world.appendChild(p.el);
783
+ return p.el;
784
+ }
785
+ function moveCursor(p, cx, cy) {
786
+ if (cx == null || cy == null) { if (p.el) p.el.hidden = true; return; }
787
+ const c = cursorOf(p);
788
+ c.hidden = false;
789
+ c.style.left = cx - minX + "px";
790
+ c.style.top = cy - minY + "px";
791
+ }
792
+ const add = (person) => { if (person.id !== me) people.set(person.id, Object.assign(people.get(person.id) || {}, person)); };
793
+ const remove = (id) => { const p = people.get(id); if (p && p.el) p.el.remove(); people.delete(id); };
794
+
795
+ // live frames: the studio's app, served from the link's session host, at each frame's size
796
+ function sessionFrame(f) {
797
+ const home = new URL(manifest.session.url);
798
+ const url = new URL((route || "/truecanvas/") + encodeURIComponent(manifest.canvas), home);
799
+ url.searchParams.set("frame", f.name);
800
+ if (f.theme) url.searchParams.set("theme", f.theme);
801
+ const token = home.searchParams.get("tc_access");
802
+ if (token) url.searchParams.set("tc_access", token);
803
+ return url.href;
804
+ }
805
+ function setLive(on) {
806
+ on = !!on && canLive;
807
+ if (on === live) return renderRoom();
808
+ live = on;
809
+ for (const frameEl of world.querySelectorAll(".frame")) {
810
+ const f = frameOf(frameEl.dataset.name);
811
+ const box = frameEl.querySelector(".frame-box");
812
+ const old = box.querySelector("iframe");
813
+ const img = box.querySelector("img");
814
+ const next = document.createElement("iframe");
815
+ next.title = f.name;
816
+ next.setAttribute("scrolling", "no");
817
+ if (on) {
818
+ // its own origin: the app's scripts run there, never on the review site
819
+ next.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
820
+ next.setAttribute("referrerpolicy", "no-referrer");
821
+ next.src = sessionFrame(f);
822
+ } else {
823
+ next.setAttribute("sandbox", "");
824
+ next.loading = "lazy";
825
+ next.src = f.html;
826
+ if (img) img.style.visibility = "";
827
+ }
828
+ next.addEventListener("load", () => { next.classList.add("ready"); if (on && img) img.style.visibility = "hidden"; });
829
+ old.replaceWith(next);
830
+ }
831
+ renderRoom();
832
+ toast(on ? "Live: you're seeing the design as the studio works on it" : "The live session ended: back to the published version");
833
+ }
834
+
835
+ function onEvent(e) {
836
+ if (e.t === "hello") {
837
+ me = e.you.id;
838
+ for (const id of [...people.keys()]) remove(id);
839
+ for (const p of e.people) add(p);
840
+ route = e.route;
841
+ setLive(e.live);
842
+ } else if (e.t === "join") { add(e.person); renderRoom(); }
843
+ else if (e.t === "leave") { remove(e.id); renderRoom(); }
844
+ else if (e.t === "cursor") { const p = people.get(e.id); if (p) moveCursor(p, e.x, e.y); }
845
+ else if (e.t === "state") { route = e.route || route; setLive(e.live); }
846
+ else if (e.t === "comments" && canComment) refresh();
847
+ }
848
+
849
+ function connect() {
850
+ const url = new URL("room", document.baseURI);
851
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
852
+ const typed = storedName();
853
+ if (!manifest.viewer && typed) url.searchParams.set("name", typed);
854
+ const s = new WebSocket(url.href);
855
+ sock = s;
856
+ s.onopen = () => {
857
+ retry = 1000;
858
+ clearInterval(ping);
859
+ // idle sockets are dropped after about 100 s: the room answers "ping" without waking up
860
+ ping = setInterval(() => s.readyState === 1 && s.send("ping"), 30000);
861
+ };
862
+ s.onmessage = (m) => {
863
+ if (m.data === "pong") return;
864
+ let e;
865
+ try { e = JSON.parse(m.data); } catch { return; }
866
+ onEvent(e);
867
+ };
868
+ s.onclose = () => {
869
+ clearInterval(ping);
870
+ if (sock !== s) return;
871
+ sock = null;
872
+ for (const id of [...people.keys()]) remove(id);
873
+ renderRoom();
874
+ setTimeout(connect, retry);
875
+ retry = Math.min(retry * 2, 15000);
876
+ };
877
+ }
878
+
879
+ // my cursor, in canvas coordinates, at most 20 times a second
880
+ let next = null, timer = 0, last = 0;
881
+ const flush = () => {
882
+ timer = 0;
883
+ if (!next || !sock || sock.readyState !== 1) return;
884
+ last = Date.now();
885
+ sock.send(JSON.stringify({ t: "cursor", x: next.x, y: next.y }));
886
+ next = null;
887
+ };
888
+ const sendCursor = (cx, cy) => {
889
+ next = { x: cx, y: cy };
890
+ if (!timer) timer = setTimeout(flush, Math.max(0, 50 - (Date.now() - last)));
891
+ };
892
+ stage.addEventListener("pointermove", (e) => sendCursor((e.clientX - x) / z + minX, (e.clientY - y) / z + minY));
893
+ stage.addEventListener("pointerleave", () => sendCursor(null, null));
894
+ connect();
895
+ }
719
896
  })();
720
897
  </script>
721
898
  </body>
@@ -723,6 +900,86 @@ button:focus-visible { outline: 2px solid #3b82f6; outline-offset: 1px; }
723
900
  `;
724
901
  }
725
902
 
903
+ // src/share/wire.ts
904
+ var CHUNK = 256 * 1024;
905
+ var enc = new TextEncoder();
906
+ var dec = new TextDecoder();
907
+ function encodeFrame(header, payload) {
908
+ const head = enc.encode(JSON.stringify(header));
909
+ const out = new Uint8Array(4 + head.length + (payload?.length ?? 0));
910
+ new DataView(out.buffer).setUint32(0, head.length);
911
+ out.set(head, 4);
912
+ if (payload) out.set(payload, 4 + head.length);
913
+ return out;
914
+ }
915
+ function decodeFrame(data) {
916
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
917
+ if (bytes.length < 4) throw new Error("Short tunnel frame");
918
+ const size = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
919
+ if (size > bytes.length - 4) throw new Error("Bad tunnel frame");
920
+ const header = JSON.parse(dec.decode(bytes.subarray(4, 4 + size)));
921
+ if (!header || typeof header.t !== "string" || typeof header.id !== "string") throw new Error("Bad tunnel frame");
922
+ return { header, payload: bytes.subarray(4 + size) };
923
+ }
924
+ function safePath(path) {
925
+ if (typeof path !== "string" || path.length > 8192) return null;
926
+ if (!path.startsWith("/") || path.startsWith("//") || /[\\\u0000-\u001f\u007f]/.test(path)) return null;
927
+ if (/^\/(%2f|%5c)/i.test(path)) return null;
928
+ return path;
929
+ }
930
+ var DROP_REQUEST = /* @__PURE__ */ new Set([
931
+ "host",
932
+ "connection",
933
+ "keep-alive",
934
+ "upgrade",
935
+ "transfer-encoding",
936
+ "te",
937
+ "trailer",
938
+ "proxy-authorization",
939
+ "proxy-connection",
940
+ "content-length",
941
+ "accept-encoding",
942
+ "origin",
943
+ "referer",
944
+ "authorization",
945
+ "cdn-loop",
946
+ "x-real-ip",
947
+ "true-client-ip",
948
+ "sec-websocket-key",
949
+ "sec-websocket-version",
950
+ "sec-websocket-extensions",
951
+ "sec-websocket-protocol"
952
+ ]);
953
+ function requestHeaders(headers) {
954
+ const out = [];
955
+ for (const [k, v] of headers) {
956
+ const key = k.toLowerCase();
957
+ if (DROP_REQUEST.has(key) || key.startsWith("cf-") || key.startsWith("x-forwarded-") || key.startsWith("sec-fetch-") || key.startsWith("x-tc-")) continue;
958
+ if (key === "cookie") {
959
+ const kept = v.split(";").map((c) => c.trim()).filter((c) => c && !c.startsWith("tc_"));
960
+ if (kept.length) out.push(["cookie", kept.join("; ")]);
961
+ continue;
962
+ }
963
+ out.push([key, v]);
964
+ }
965
+ return out;
966
+ }
967
+ var DROP_RESPONSE = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "content-encoding", "content-length", "upgrade", "trailer", "alt-svc"]);
968
+ function responseHeaders(headers) {
969
+ const out = [];
970
+ for (const [k, v] of headers) {
971
+ const key = k.toLowerCase();
972
+ if (!DROP_RESPONSE.has(key)) out.push([key, v]);
973
+ }
974
+ return out;
975
+ }
976
+
726
977
  export {
727
- viewerHtml
978
+ viewerHtml,
979
+ CHUNK,
980
+ encodeFrame,
981
+ decodeFrame,
982
+ safePath,
983
+ requestHeaders,
984
+ responseHeaders
728
985
  };
@@ -0,0 +1,45 @@
1
+ // src/core/config.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+ function loadConfig(root, overrides = {}) {
5
+ let file = {};
6
+ const configPath = path.join(root, "truecanvas.config.json");
7
+ if (fs.existsSync(configPath)) file = JSON.parse(fs.readFileSync(configPath, "utf8"));
8
+ let deps = {};
9
+ try {
10
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
11
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
12
+ } catch {
13
+ deps = {};
14
+ }
15
+ const framework = file.framework ?? detectFramework(deps);
16
+ const vite = framework === "vite";
17
+ const appDir = file.appDir ?? (vite ? fs.existsSync(path.join(root, "src")) ? "src" : "." : fs.existsSync(path.join(root, "src/app")) ? "src/app" : "app");
18
+ const srcPrefix = appDir === "src" || appDir.startsWith("src/") ? "src/" : "";
19
+ const knownLibraries = ["@paper-design/shaders-react"].filter((l) => l in deps);
20
+ return {
21
+ root,
22
+ framework,
23
+ appUrl: vite ? "http://localhost:5173" : "http://localhost:3000",
24
+ port: 4800,
25
+ canvasDir: `${srcPrefix}canvas`,
26
+ appDir,
27
+ routeDir: vite ? ".truecanvas" : `${appDir}/truecanvas`,
28
+ components: [`${srcPrefix}components/**/*.tsx`],
29
+ darkMode: { strategy: "class", value: "dark" },
30
+ ...file,
31
+ libraries: [.../* @__PURE__ */ new Set([...knownLibraries, ...file.libraries ?? []])],
32
+ ...stripUndefined(overrides)
33
+ };
34
+ }
35
+ function stripUndefined(o) {
36
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
37
+ }
38
+ function detectFramework(deps) {
39
+ return !("next" in deps) && "vite" in deps ? "vite" : "next";
40
+ }
41
+
42
+ export {
43
+ loadConfig,
44
+ detectFramework
45
+ };