+
+
Super Prototyping
diff --git a/canvas/home.html b/canvas/home.html
index 244ac6a7..ead562d2 100644
--- a/canvas/home.html
+++ b/canvas/home.html
@@ -2,6 +2,23 @@
+
+
diff --git a/canvas/index.html b/canvas/index.html
index 3700b0ee..9d647701 100644
--- a/canvas/index.html
+++ b/canvas/index.html
@@ -2,6 +2,23 @@
+
+
diff --git a/canvas/server/agent.ts b/canvas/server/agent.ts
index 9aa1dee3..a565655d 100644
--- a/canvas/server/agent.ts
+++ b/canvas/server/agent.ts
@@ -14,7 +14,7 @@ import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import os from "node:os";
import path from "node:path";
-import { CANVASES } from "./boards.ts";
+import { CANVASES, hashBoards } from "./boards.ts";
import { command, stop } from "./command.ts";
import { AGENT_SKILLS, installSkills } from "./skills.ts";
import { canvasFile, folderOf, sameOrigin } from "./sp.ts";
@@ -102,11 +102,28 @@ export function createAgentServer(options: {
// above have moved on. The panel's own copy, not the agent's transcript: Claude Code's and
// Codex's files are theirs, change shape between releases, and hold no attachments by number.
const keptOf = (id: string) => path.join(workspaces, ".runs", id);
- const say = (run: Run, event: string, data: unknown) =>
+ // The boards each live run's project held as it started, so its end can say which it made
+ // or changed: the panel puts a button under the reply for each, which finds it on the canvas.
+ // Off the files rather than the tool lines, which cannot say what a generator wrote.
+ const boardsBefore = new Map }>();
+ const say = (run: Run, event: string, data: unknown) => {
+ const before = event === "end" && boardsBefore.get(run.id);
+ if (before) {
+ boardsBefore.delete(run.id);
+ const made = [...hashBoards(before.dir)]
+ .filter(([board, hash]) => before.hashes.get(board) !== hash)
+ .map(([board]) => ({
+ board,
+ status: before.hashes.has(board) ? "updated" : "new",
+ }))
+ .sort((a, b) => a.board.localeCompare(b.board));
+ data = { ...(data as object), made };
+ }
fs.appendFileSync(
path.join(keptOf(run.id), "events.jsonl"),
JSON.stringify(emit(run, event, data)) + "\n",
);
+ };
const picture = (id: string, name: string, type: string, data: Buffer) => {
const file = path.join(keptOf(id), `${name}.${type.slice(6)}`);
fs.writeFileSync(file, data);
@@ -549,6 +566,7 @@ export function createAgentServer(options: {
};
},
);
+ const hashes = hashBoards(boards);
const c = command(
def.bin,
def.args({
@@ -576,6 +594,7 @@ export function createAgentServer(options: {
dir,
});
runs.set(run.id, run);
+ boardsBefore.set(run.id, { dir: boards, hashes });
record.runs.push(run.id);
record.updated = Date.now();
save(record);
diff --git a/canvas/server/boards.ts b/canvas/server/boards.ts
index c2e4be4a..02cbc7f5 100644
--- a/canvas/server/boards.ts
+++ b/canvas/server/boards.ts
@@ -3,10 +3,41 @@
* `/__sp/index.json`. The server scans on every request, and the build writes the same shape
* into `dist` once. `src/canvasIndex.ts` declares the shape the client reads.
*/
+import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { svgSignature } from "../src/svgSignature.ts";
+export const boardHash = (file: string) =>
+ createHash("sha1").update(fs.readFileSync(file)).digest("hex");
+
+/**
+ * What each board in a boards directory holds, by `/.html`: a generator writes every
+ * board on each run, and only the ones whose bytes changed are news. An unreadable or missing
+ * folder holds none, and a dot-file or a folder named like a board is no board.
+ */
+export function hashBoards(canvasesDir: string) {
+ const list = (dir: string) => {
+ try {
+ return fs.readdirSync(dir);
+ } catch {
+ return [];
+ }
+ };
+ const hashes = new Map();
+ for (const slug of list(canvasesDir))
+ for (const name of list(path.join(canvasesDir, slug))) {
+ const file = path.join(canvasesDir, slug, name);
+ if (
+ name.endsWith(".html") &&
+ !name.startsWith(".") &&
+ fs.statSync(file, { throwIfNoEntry: false })?.isFile()
+ )
+ hashes.set(`${slug}/${name}`.normalize("NFC"), boardHash(file));
+ }
+ return hashes;
+}
+
/**
* A project's boards: this folder under it, one subfolder per canvas. The same folder under the
* plugin root is the examples every project is shown beside its own.
diff --git a/canvas/server/sp.ts b/canvas/server/sp.ts
index aa2661bd..f57003e4 100644
--- a/canvas/server/sp.ts
+++ b/canvas/server/sp.ts
@@ -18,7 +18,9 @@ import {
CANVASES,
DOCS,
IMAGE_MIME,
+ boardHash,
boardIndex,
+ hashBoards,
readDocs,
canvasesNamespace,
readJson,
@@ -75,7 +77,10 @@ export function folderOf(
fs.statSync(own, { throwIfNoEntry: false })?.isDirectory() &&
fs
.readdirSync(own)
- .some((f) => f === "layout.json" || (!f.startsWith(".") && f.endsWith(".html")));
+ .some(
+ (f) =>
+ f === "layout.json" || (!f.startsWith(".") && f.endsWith(".html")),
+ );
const example = path.join(examplesDir, slug);
return !isCanvas && fs.existsSync(example) ? example : own;
}
@@ -266,7 +271,9 @@ export function shoot(board: string, size: number[], res: ServerResponse) {
// running without it, and Chrome is its own ask.
fail(
res,
- (error as NodeJS.ErrnoException | null)?.code === "ENOENT" ? 503 : 500,
+ (error as NodeJS.ErrnoException | null)?.code === "ENOENT"
+ ? 503
+ : 500,
error
? `could not render the board: ${error.message}`
: "refkit wrote no image",
@@ -390,7 +397,8 @@ export function createSpServer(options: {
.map((b) => ({ ...b, example: true })),
].sort((a, b) => (a.slug < b.slug ? -1 : 1));
const docs = projectDir === undefined ? undefined : readDocs(projectDir);
- const title = projectDir === undefined ? undefined : readProjectJson(projectDir).name;
+ const title =
+ projectDir === undefined ? undefined : readProjectJson(projectDir).name;
return { ...index, boards, project: projectName(), title, docs };
};
@@ -432,7 +440,11 @@ export function createSpServer(options: {
updated: Math.max(
fs.statSync(dir).mtimeMs,
...canvases.map((c) => c.updated),
- ...DOCS.map((doc) => fs.statSync(path.join(dir, doc), { throwIfNoEntry: false })?.mtimeMs ?? 0),
+ ...DOCS.map(
+ (doc) =>
+ fs.statSync(path.join(dir, doc), { throwIfNoEntry: false })
+ ?.mtimeMs ?? 0,
+ ),
),
canvases,
cover: projectCover(boards, json.cover),
@@ -527,8 +539,11 @@ export function createSpServer(options: {
const size = fs.statSync(file).size;
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
if (range && (range[1] || range[2])) {
- const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]));
- const end = range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
+ const start = range[1]
+ ? Number(range[1])
+ : Math.max(0, size - Number(range[2]));
+ const end =
+ range[1] && range[2] ? Math.min(Number(range[2]), size - 1) : size - 1;
if (start > end) {
res.setHeader("Content-Range", `bytes */${size}`);
return send(416, "range not satisfiable");
@@ -673,7 +688,8 @@ export function createSpServer(options: {
try {
const { slug, name } = JSON.parse(body || "{}");
if (!SAFE_NAME.test(slug ?? "")) return send(400, "bad canvas name");
- if (typeof name !== "string" || !name.trim()) return send(400, "empty name");
+ if (typeof name !== "string" || !name.trim())
+ return send(400, "empty name");
if (isExample(slug)) return send(403, READ_ONLY);
if (!fs.existsSync(path.join(canvasesDir, slug)))
return send(404, `no canvas folder named ${slug}`);
@@ -717,7 +733,8 @@ export function createSpServer(options: {
if (isExample(slug)) return send(403, READ_ONLY);
trash(folder).then(
() => send(204, ""),
- (error: Error) => send(500, `“${slug}” is still there: ${error.message}`),
+ (error: Error) =>
+ send(500, `“${slug}” is still there: ${error.message}`),
);
});
});
@@ -1017,20 +1034,26 @@ export function createSpServer(options: {
res.statusCode = code;
res.end(message);
};
- if (projectDir === undefined) return send(409, "Open a project to write its documents.");
+ if (projectDir === undefined)
+ return send(409, "Open a project to write its documents.");
let name: unknown, text: unknown, base: unknown;
try {
({ name, text, base } = JSON.parse(body || "{}"));
} catch {
return send(400, "bad json");
}
- if (typeof name !== "string" || !DOCS.includes(name)) return send(400, "not a document");
- if (typeof text !== "string" || typeof base !== "string") return send(400, "no text");
+ if (typeof name !== "string" || !DOCS.includes(name))
+ return send(400, "not a document");
+ if (typeof text !== "string" || typeof base !== "string")
+ return send(400, "no text");
const file = path.join(projectDir, name);
// The text the edit began from, so a rewrite since, an agent's, is not overwritten unseen.
try {
if (fs.readFileSync(file, "utf8") !== base)
- return send(409, `${name} changed since you began editing. Copy your text, then reopen it.`);
+ return send(
+ 409,
+ `${name} changed since you began editing. Copy your text, then reopen it.`,
+ );
fs.writeFileSync(file, text);
} catch (error) {
return send(500, String(error));
@@ -1114,9 +1137,10 @@ export function createSpServer(options: {
0,
...list(canvasesDir).map(
(had) =>
- (readJson(path.join(canvasesDir, had, "layout.json")) as
- | { order?: number }
- | undefined)?.order ?? 0,
+ (
+ readJson(path.join(canvasesDir, had, "layout.json")) as
+ { order?: number } | undefined
+ )?.order ?? 0,
),
) + 1;
fs.mkdirSync(path.join(canvasesDir, slug));
@@ -1146,15 +1170,7 @@ export function createSpServer(options: {
// index says so on its own.
const boardKey = (file: string) =>
path.relative(canvasesDir, file).split(path.sep).join("/").normalize("NFC");
- const boardHash = (file: string) =>
- createHash("sha1").update(fs.readFileSync(file)).digest("hex");
- const boardHashes = new Map();
- for (const slug of list(canvasesDir))
- for (const name of list(path.join(canvasesDir, slug)))
- if (name.endsWith(".html")) {
- const file = path.join(canvasesDir, slug, name);
- boardHashes.set(boardKey(file), boardHash(file));
- }
+ const boardHashes = hashBoards(canvasesDir);
/**
* One settled batch of writes, answered once.
@@ -1289,7 +1305,9 @@ export function createSpServer(options: {
let docWatcher: fs.FSWatcher | undefined;
let docBatch: ReturnType | undefined;
if (projectDir !== undefined) {
- let names = readDocs(projectDir).map((doc) => doc.name).join();
+ let names = readDocs(projectDir)
+ .map((doc) => doc.name)
+ .join();
let title = readProjectJson(projectDir).name;
docWatcher = fs.watch(projectDir, (_event, name) => {
if (!name || ![...DOCS, PROJECT_JSON].includes(name.toString())) return;
diff --git a/canvas/src/App.tsx b/canvas/src/App.tsx
index 458cb004..fd1623bf 100644
--- a/canvas/src/App.tsx
+++ b/canvas/src/App.tsx
@@ -1,10 +1,4 @@
-import {
- useCallback,
- useEffect,
- useReducer,
- useRef,
- useState,
-} from "react";
+import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import {
Ellipse2d,
ImageShapeUtil,
@@ -21,6 +15,7 @@ import {
renderPlaintextFromRichText,
toRichText,
type Editor,
+ type TLPage,
type TLPageId,
type TLAsset,
type TLAssetStore,
@@ -94,9 +89,10 @@ import {
readCanvasLibrary,
refetchBoards,
} from "./canvasLibrary";
-import { BOARDS_CHANGED, canvasIndex } from "./canvasIndex";
+import { BOARDS_CHANGED, FOCUS_BOARD, canvasIndex } from "./canvasIndex";
import { lockedOverlayUtils } from "./lockedIndicator";
import { installCanvasComments, readCommentUser } from "./canvasComments";
+import { storedTheme, THEME_KEY } from "./theme";
import {
CanvasLinkPaste,
agentBoardPaths,
@@ -808,7 +804,6 @@ function initializeCanvasLibrary(editor: Editor) {
},
})),
);
-
}
// What the pass did not place is what the folder no longer has: a board renamed or removed,
@@ -1217,6 +1212,39 @@ export default function App() {
if (page) editor.setCurrentPage(page.id);
};
+ // A board a reply in the chat made or changed, from the button under it: its canvas brought
+ // forward and the board fitted to it, as a double-click on it would. Heard on the window, which
+ // holds the chat.
+ useEffect(() => {
+ if (!editor) return;
+ const focus = (event: Event) => {
+ const board = (event as CustomEvent).detail;
+ // By its path rather than the library's id: an agent places boards under ids of its own
+ // (canvas.json). Its own canvas first, where the library puts it. None when it has gone
+ // since the reply, deleted or renamed by a later one.
+ const slug = board.split("/")[0];
+ const own = (page: TLPage) =>
+ Number(String(page.meta.canvasSlug).normalize("NFC") === slug);
+ const pages = editor.getPages().sort((a, b) => own(b) - own(a));
+ for (const page of pages)
+ for (const id of editor.getPageShapeIds(page)) {
+ const shape = editor.getShape(id)!;
+ if (
+ shape.type === CANVAS_FILE_SHAPE_TYPE &&
+ (shape.props as { path: string }).path
+ .normalize("NFC")
+ .endsWith(`canvases/${board}`)
+ ) {
+ openTab({ kind: "canvas", slug: page.meta.canvasSlug as string });
+ zoomToFill(editor, id);
+ return;
+ }
+ }
+ };
+ window.parent.addEventListener(FOCUS_BOARD, focus);
+ return () => window.parent.removeEventListener(FOCUS_BOARD, focus);
+ });
+
// A layout.json edit moves boards: a row reordered, a label changed, a size override added.
// The pass reconciles, so the boards that stay keep their shapes and only what moved is
// touched.
@@ -1274,9 +1302,14 @@ export default function App() {
function handleMount(editor: Editor) {
setEditor(editor);
- // tldraw's own dark theme, to match the panel's. A dark rail against tldraw's near-white ground
- // looks like two apps in one window, and the ground is most of the window.
- editor.user.updateUserPreferences({ colorScheme: "dark" });
+ // tldraw's scheme is the page's (tokens.css), or a dark rail sits against a near-white ground
+ // and the window looks like two apps. tldraw's "system" and the page's follow the same OS
+ // preference. Another page picking one reaches this one as a storage event (theme.ts).
+ editor.user.updateUserPreferences({ colorScheme: storedTheme() });
+ const scheme = (e: StorageEvent) =>
+ e.key === THEME_KEY &&
+ editor.user.updateUserPreferences({ colorScheme: storedTheme() });
+ window.addEventListener("storage", scheme);
initializeCanvas(editor);
// After the library, which is what creates the pages both are keyed to, and content first,
// since a comment can be pinned to a shape the person put there.
@@ -1300,6 +1333,7 @@ export default function App() {
const disposeZoom = installDoubleClickZoom(editor);
const disposeHover = installBoardHover(editor);
return () => {
+ window.removeEventListener("storage", scheme);
disposeHover();
disposeContent();
disposeComments();
diff --git a/canvas/src/AppShell.tsx b/canvas/src/AppShell.tsx
index be943a08..fd62214b 100644
--- a/canvas/src/AppShell.tsx
+++ b/canvas/src/AppShell.tsx
@@ -30,11 +30,13 @@ import { Onboarding } from "./Onboarding";
declare global {
interface Window {
- /** The desktop app's own (desktop/preload.ts), absent in a browser: the onboarding's answer
- * and its update check, both about the app and not the project. */
+ /** The desktop app's own (desktop/preload.ts), absent in a browser: the onboarding's answer,
+ * its update check and the theme, all about the app and not the project. */
startup?: {
agent(id: string): Promise;
check(): Promise;
+ /** The top bar's theme, for the window's own title bar (ThemeSwitcher.tsx). */
+ theme(theme: string): void;
};
/** The window's side of the frame (here): what the canvas has in front, at what address. */
spShell?: {
diff --git a/canvas/src/CanvasStrip.tsx b/canvas/src/CanvasStrip.tsx
index 172cc7ae..bbab0838 100644
--- a/canvas/src/CanvasStrip.tsx
+++ b/canvas/src/CanvasStrip.tsx
@@ -3,7 +3,12 @@ import { CanvasChromeContext } from "./canvasChrome";
import { shortName } from "./canvasLibrary";
import { docsOf, ownCanvases, pageOf, tabFor, tabUrl } from "./canvasTabs";
import { canvasIndex, LAYOUT_CHANGED } from "./canvasIndex";
-import { groundEditable, setGround, useGround } from "./canvasGround";
+import {
+ DEFAULT_GROUND,
+ groundEditable,
+ setGround,
+ useGround,
+} from "./canvasGround";
import { ViewIcon } from "./CanvasTabBar";
import { shareUrl, sheetPageUrl, type CanvasTab } from "./canvasUrl";
import {
@@ -205,7 +210,11 @@ export function CanvasStrip() {