Skip to content

Commit 9e93f0b

Browse files
Jing-yilinclaude
andcommitted
An Inspector switch on the canvas strip brings back main's panel, off by default
The switch sits right of the ground swatch. While it is on, the panel shows the one board or picture selected: layers, styles, tokens, assets and comments, as on main. The selected board runs the inspect agent in place of the plain hover outline, and a click on it, once selected, picks the element under the pointer. The panel follows the selection rather than keeping its own, so the address, Space to present and the double-click zoom need no change. Closing it clears the selection. The asset index, readCanvasImage and the cover crop of a pointed element are back with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 013fd4b commit 9e93f0b

30 files changed

Lines changed: 4168 additions & 81 deletions

‎AGENTS.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ Rules inside a canvas folder:
103103
behind the tokens.
104104
- Commit `assets.json` where a folder has one (three do). It is a
105105
`name → data URI` map of pre-encoded images the generator inlines, not
106-
evidence.
106+
evidence. The canvas's inspector names a board's images by content, from
107+
`assets/` first and `assets.json` second, so a re-encoded image that
108+
matches neither falls back to its `alt`. It names an inline `<svg>` the
109+
same way from `assets/icons/`, by its geometry rather than its bytes, and
110+
hands it back as a vector asset.
107111
- Never commit `ref-*.html` or `assets/refs/`. They hold third-party
108112
captures, the root `.gitignore` already excludes them, and the
109113
sp-clone-prototype skill rebuilds them. `spotify-ios` is the exception: its

‎canvas/server/boards.ts‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import fs from "node:fs";
77
import path from "node:path";
8+
import { svgSignature } from "../src/svgSignature.ts";
89

910
/**
1011
* A project's boards: this folder under it, one subfolder per canvas. The same folder under the
@@ -44,6 +45,126 @@ export const IMAGE_MIME: Record<string, string> = {
4445
*/
4546
export const THUMB_EDGE = 880;
4647

48+
/**
49+
* FNV-1a 32 over a string's code units, base 36. The inspector's agent runs the same function
50+
* over the base64 payload of each data: URI inside the board, and joins on `length:hash`. A
51+
* plain hash rather than SHA because the agent runs in a sandboxed frame with no `crypto.subtle`
52+
* in every deployment, and this does 3 MB in about 12 ms.
53+
*/
54+
function fnv1a(s: string) {
55+
let h = 0x811c9dc5;
56+
for (let i = 0; i < s.length; i++)
57+
h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0;
58+
return h.toString(36);
59+
}
60+
61+
interface AssetName {
62+
/** Path inside the board folder, `assets/art/hero.png`, or `assets.json#key`. */
63+
name: string;
64+
/** Decoded size, i.e. the file's own byte count. */
65+
bytes: number;
66+
}
67+
68+
/**
69+
* What one asset file, or one assets.json, contributes to the index, remembered by the file's
70+
* size and mtime. The index is built on every request for it, and this repo's own boards hold
71+
* 300 MB of assets: hashing them once is a second, a stat each is nothing.
72+
*/
73+
const hashed = new Map<
74+
string,
75+
{ stamp: string; keys: [string, AssetName][] }
76+
>();
77+
function keysOf(file: string, read: (buf: Buffer) => [string, AssetName][]) {
78+
const stat = fs.statSync(file);
79+
const stamp = `${stat.size}:${stat.mtimeMs}`;
80+
let entry = hashed.get(file);
81+
if (entry?.stamp !== stamp) {
82+
entry = { stamp, keys: read(fs.readFileSync(file)) };
83+
hashed.set(file, entry);
84+
}
85+
return entry.keys;
86+
}
87+
88+
/**
89+
* `length:hash` of the base64 payload -> the source file, for every image a folder's generator
90+
* could have inlined: `assets/**`, `assets-dark/**` and the values of `assets.json`. `refs/` is
91+
* skipped because it holds third-party captures that are never committed. A generator that
92+
* re-encodes on the way (a PIL resize) produces bytes that match nothing here, and the
93+
* inspector then falls back to the image's alt text. An `.svg` file is indexed twice: by its
94+
* bytes like any image, and as `svg:hash` of its geometry, which is how an inline `<svg>` on a
95+
* board is keyed, since the generator rewrote its root tag on the way in.
96+
*/
97+
function assetIndex(folder: string): Record<string, AssetName> {
98+
const out: Record<string, AssetName> = {};
99+
const add = ([key, name]: [string, AssetName]) => {
100+
if (!(key in out)) out[key] = name;
101+
};
102+
const walk = (dir: string, rel: string) => {
103+
let entries: fs.Dirent[];
104+
try {
105+
entries = fs.readdirSync(dir, { withFileTypes: true });
106+
} catch {
107+
return;
108+
}
109+
for (const e of entries) {
110+
if (e.name.startsWith(".")) continue;
111+
const p = path.join(dir, e.name);
112+
if (e.isDirectory()) {
113+
if (e.name !== "refs") walk(p, `${rel}${e.name}/`);
114+
continue;
115+
}
116+
if (!(path.extname(e.name).toLowerCase() in IMAGE_MIME)) continue;
117+
const name = { name: rel + e.name, bytes: 0 };
118+
keysOf(p, (buf) => {
119+
const payload = buf.toString("base64");
120+
const named = { ...name, bytes: buf.length };
121+
const keys: [string, AssetName][] = [
122+
[`${payload.length}:${fnv1a(payload)}`, named],
123+
];
124+
if (e.name.toLowerCase().endsWith(".svg"))
125+
keys.push([
126+
`svg:${fnv1a(svgSignature(buf.toString("utf8")))}`,
127+
named,
128+
]);
129+
return keys;
130+
}).forEach(add);
131+
}
132+
};
133+
for (const sub of ["assets", "assets-dark"])
134+
walk(path.join(folder, sub), `${sub}/`);
135+
const json = path.join(folder, "assets.json");
136+
if (fs.existsSync(json)) {
137+
keysOf(json, (buf) => {
138+
const keys: [string, AssetName][] = [];
139+
try {
140+
const map: unknown = JSON.parse(buf.toString("utf8"));
141+
if (map && typeof map === "object") {
142+
for (const [key, v] of Object.entries(map)) {
143+
if (typeof v !== "string" || !v.startsWith("data:")) continue;
144+
const payload = v.slice(v.indexOf(",") + 1);
145+
const pad = payload.endsWith("==")
146+
? 2
147+
: payload.endsWith("=")
148+
? 1
149+
: 0;
150+
keys.push([
151+
`${payload.length}:${fnv1a(payload)}`,
152+
{
153+
name: `assets.json#${key}`,
154+
bytes: Math.floor((payload.length * 3) / 4) - pad,
155+
},
156+
]);
157+
}
158+
}
159+
} catch {
160+
// a malformed assets.json names nothing; the boards still render
161+
}
162+
return keys;
163+
}).forEach(add);
164+
}
165+
return out;
166+
}
167+
47168
/**
48169
* `#` and `?` are legal in a filename but are a fragment and a query in a URL, and no encoding
49170
* survives the round trip (the request path is decoded with decodeURI, which leaves both alone).
@@ -178,6 +299,7 @@ export function boardIndex(
178299
thumbnail: fs.existsSync(path.join(folder, "thumbnail.png")),
179300
brand: brandImages(folder),
180301
thumbs: [] as string[],
302+
assets: assetIndex(folder),
181303
comments: readJson(path.join(folder, "comments.json")),
182304
// null for a canvas.json that is there but will not parse, after a merge left half
183305
// done, say. The page must not take that for no file and write over it

‎canvas/src/App.tsx‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
type TLTextShape,
3434
useEditor,
3535
useLocalStore,
36+
useValue,
3637
} from "tldraw";
3738
import "tldraw/tldraw.css";
3839
import "@tldraw/commenting/commenting.css";
@@ -65,6 +66,7 @@ import {
6566
zoomToFill,
6667
} from "./canvasClicks";
6768
import { CanvasPresent } from "./CanvasPresent";
69+
import { ImagePanel, InspectorPanel } from "./InspectorPanel";
6870
import { attachToChat } from "./canvasAttach";
6971
import { CanvasStatusBannerShapeUtil } from "./CanvasStatusBannerShapeUtil";
7072
import {
@@ -85,6 +87,7 @@ import {
8587
canvasImageKey,
8688
canvasImageRef,
8789
canvasImageUrl,
90+
readCanvasImage,
8891
readCanvasLayout,
8992
pageNameFor,
9093
isLibraryShapeId,
@@ -1134,6 +1137,10 @@ function initializeCanvas(editor: Editor) {
11341137

11351138
export default function App() {
11361139
const [commentUser, setCommentUser] = useState(readCommentUser);
1140+
/** The inspector, off until the strip's button turns it on. */
1141+
const [inspectorOn, setInspectorOn] = useState(false);
1142+
/** That selected board's frame on the canvas: the panel reads its report and posts back there. */
1143+
const inspectorFrame = useRef<HTMLIFrameElement | null>(null);
11371144
/** State rather than a ref: the chrome context hands it to parts that render outside `<Tldraw>`. */
11381145
const [editor, setEditor] = useState<Editor | null>(null);
11391146
/**
@@ -1145,6 +1152,12 @@ export default function App() {
11451152
resolveTab(tabFromUrl(window.location.href)),
11461153
);
11471154
const store = useLocalStore(storeOptions);
1155+
/** What the inspector shows while it is on: the one board or picture selected. */
1156+
const inspected = useValue(
1157+
"inspected",
1158+
() => asCanvasTarget(editor?.getOnlySelectedShape() ?? undefined),
1159+
[editor],
1160+
);
11481161
/** Writes the address from the tab in front and the selection; installed with the editor. */
11491162
const writeUrl = useRef<(push: boolean) => void>(() => {});
11501163
/** The tab in front as of this call rather than as of the last render, for that writer. */
@@ -1306,6 +1319,11 @@ export default function App() {
13061319
setCommentUser,
13071320
activeTab,
13081321
openTab,
1322+
inspectorOn,
1323+
setInspectorOn,
1324+
setInspectorFrame: (frame: HTMLIFrameElement | null) => {
1325+
inspectorFrame.current = frame;
1326+
},
13091327
}}
13101328
>
13111329
{/* The project's side of the window: its canvases across the top, then the canvas. The bar
@@ -1386,6 +1404,36 @@ export default function App() {
13861404
</div>
13871405
)}
13881406
</div>
1407+
{inspectorOn && activeTab.kind === "canvas" && inspected && editor
1408+
? (() => {
1409+
if (inspected.type === CANVAS_FILE_SHAPE_TYPE) {
1410+
const file = readCanvasLibrary()
1411+
.flatMap((c) => c.files)
1412+
.find((c) => c.path === inspected.props.path);
1413+
// Keyed by path: a different board is a fresh panel, with its own selection
1414+
// and report, rather than one that resets its state in an effect.
1415+
return file ? (
1416+
<InspectorPanel
1417+
key={file.path}
1418+
path={file.path}
1419+
name={file.title}
1420+
size={boardSize(file)}
1421+
frame={inspectorFrame}
1422+
onClose={() => editor.selectNone()}
1423+
/>
1424+
) : null;
1425+
}
1426+
const ref = canvasImageRef(inspected.id);
1427+
const entry = ref && readCanvasImage(ref.slug, ref.file);
1428+
return ref && entry ? (
1429+
<ImagePanel
1430+
key={inspected.id}
1431+
pick={{ shapeId: inspected.id, ...ref, ...entry }}
1432+
onClose={() => editor.selectNone()}
1433+
/>
1434+
) : null;
1435+
})()
1436+
: null}
13891437
</div>
13901438
</div>
13911439
</CanvasChromeContext.Provider>

‎canvas/src/CanvasFileShapeUtil.tsx‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CSSProperties } from "react";
1+
import { useContext, type CSSProperties } from "react";
22
import {
33
BaseBoxShapeUtil,
44
FileHelpers,
@@ -10,13 +10,15 @@ import {
1010
useIsEditing,
1111
useValue,
1212
} from "tldraw";
13+
import { CanvasChromeContext } from "./canvasChrome";
1314
import { local } from "./canvasIndex";
1415
import {
1516
CANVAS_FILE_DEFAULT_SIZE,
1617
canvasBoardRef,
1718
hasCanvasFile,
1819
useCanvasFileHtml,
1920
} from "./canvasLibrary";
21+
import { injectAgent } from "./inspectorAgent";
2022

2123
export const CANVAS_FILE_SHAPE_TYPE = "canvas-file" as const;
2224

@@ -53,6 +55,7 @@ export type CanvasFileShape = TLShape<typeof CANVAS_FILE_SHAPE_TYPE>;
5355
function CanvasFile({ shape }: { shape: CanvasFileShape }) {
5456
const isEditing = useIsEditing(shape.id);
5557
const html = useCanvasFileHtml(shape.props.path);
58+
const { inspectorOn, setInspectorFrame } = useContext(CanvasChromeContext);
5659
const editor = useEditor();
5760
const selected = useValue(
5861
"board selected",
@@ -100,13 +103,32 @@ function CanvasFile({ shape }: { shape: CanvasFileShape }) {
100103
sandbox=""
101104
style={{ ...frame, zIndex: isEditing ? undefined : -2 }}
102105
/>
103-
{/* The selected board outlines the element under the pointer (HOVER). It loads over the
104-
board rather than into its frame: Chrome drops a second srcdoc navigation while the
105-
first is pending and leaves the frame blank, and a remount reloads the mockup under
106-
the click that selected it. Pixel for pixel the same board, so the swap is invisible.
106+
{/* The selected board outlines the element under the pointer: with the inspector on it
107+
runs the inspect agent (inspectorAgent.ts), which also picks the element clicked and
108+
reports the board to the panel, and otherwise only HOVER. It loads over the board
109+
rather than into its frame: Chrome drops a second srcdoc navigation while the first
110+
is pending and leaves the frame blank, and a remount reloads the mockup under the
111+
click that selected it. Pixel for pixel the same board, so the swap is invisible.
107112
`allow-scripts` and deliberately not `allow-same-origin`, which together would let
108113
the board reach back out into the canvas. */}
109-
{selected ? (
114+
{selected && inspectorOn ? (
115+
<iframe
116+
ref={(el) => {
117+
setInspectorFrame(el);
118+
return () => setInspectorFrame(null);
119+
}}
120+
title={shape.props.name}
121+
srcDoc={injectAgent(html)}
122+
sandbox="allow-scripts"
123+
data-sp-hover={shape.id}
124+
// The agent answers with its report; the frame's own load event may have fired
125+
// before the panel was listening.
126+
onLoad={(e) =>
127+
e.currentTarget.contentWindow?.postMessage({ type: "sp:hello" }, "*")
128+
}
129+
style={{ ...frame, zIndex: isEditing ? undefined : -1 }}
130+
/>
131+
) : selected ? (
110132
<iframe
111133
title={shape.props.name}
112134
srcDoc={

‎canvas/src/CanvasStrip.tsx‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
TRASH,
1616
} from "./contextMenu";
1717
import { DocModeSwitch } from "./DocTab";
18-
import { FileText, LogoFigma, Plus } from "./geistIcons";
18+
import { FileText, Inspect, LogoFigma, Plus } from "./geistIcons";
1919

2020
/**
2121
* The project's canvases, across the top of the project under the bar's tab for it, after its
@@ -28,14 +28,16 @@ import { FileText, LogoFigma, Plus } from "./geistIcons";
2828
* layout.json's, and the folder keeps its slug. An example is the app's, so it has no "+" and
2929
* no renaming, and a build has no server to make a canvas.
3030
*
31-
* At the far end are the controls of the tab in front: a canvas's ground colour, then Export to
32-
* Figma, the one place a canvas goes from here; a document's switch between reading and editing.
31+
* At the far end are the controls of the tab in front: a canvas's ground colour, the inspector's
32+
* switch, then Export to Figma, the one place a canvas goes from here; a document's switch
33+
* between reading and editing.
3334
*/
3435
/** The canvas whose tab is up for renaming, kept across the reload that brings a new one in. */
3536
const RENAME_KEY = "sp:rename-canvas";
3637

3738
export function CanvasStrip() {
38-
const { activeTab, openTab, editor } = useContext(CanvasChromeContext);
39+
const { activeTab, openTab, editor, inspectorOn, setInspectorOn } =
40+
useContext(CanvasChromeContext);
3941
const tab = tabFor(activeTab);
4042
const canvases = tab.kind === "example" ? [tab.slug] : ownCanvases();
4143
const here = activeTab.kind === "canvas" ? activeTab.slug : undefined;
@@ -213,6 +215,18 @@ export function CanvasStrip() {
213215
/>
214216
</label>
215217
)}
218+
{page && (
219+
<button
220+
type="button"
221+
className="sp-canvas-tabs-inspector"
222+
aria-pressed={inspectorOn}
223+
aria-label="Inspector"
224+
title={inspectorOn ? "Hide the inspector" : "Inspect the selected board"}
225+
onClick={() => setInspectorOn(!inspectorOn)}
226+
>
227+
<Inspect />
228+
</button>
229+
)}
216230
{/* An anchor, not a button, because the sheet is a page of its own, and the page that
217231
walks through the import, so ⌘-click and copy-link have to work on it. */}
218232
{slug && (

0 commit comments

Comments
 (0)