Vector-edit the SVG shapes of a /design artboard in the browser pane; saves back to the canvas.

A Claude Code mod that adds point-level vector editing to the SVG shapes inside a /design artboard. It opens a small editor in the Claude desktop app's built-in browser pane; saves splice only the changed path data back into the artboard file and republish the canvas.
Tools: Move (select, marquee, drag, nudge, click an edge to add a point, Delete to remove), Lasso, Bend (drag edges, drag handles, double-click to toggle corner/smooth), undo/redo, auto-save toggle.
At the prompt of a terminal session:
/plugin install artboard-vector --marketplace crlngn/claude-vector-edit
then y to add the marketplace, and pick a scope (user is the usual choice).
From a shell:
claude plugin install artboard-vector --marketplace crlngn/claude-vector-edit
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with crlngn/claude-vector-edit, then Add plugin.
In a session that has worked on a Design canvas (or with the canvas URL once):
/vector-edit Shapes.dc.html
/vector-edit https://claude.ai/artifact/<id> Shapes.dc.html
/vector-edit --list
With a shape selected in the canvas, /vector-edit alone opens on it. A comment on a shape sent to Claude that starts with /vector-edit or vector: opens the editor there too, when the comment is delivered to the session running the mod; any other comment goes to Claude as usual.
Only inline SVG geometry is editable (path, rect, circle, ellipse, line, polygon, polyline); shapes bound to template holes are shown locked, and CSS-drawn shapes are not shapes to the editor.
node --test test/*.test.mjs
claude plugin validate .
claude plugin test .
Requires node on the machine (the editor server is a zero-dependency node script).
hooks/register.ts 492 lines1import { atom, read, update } from 'claude-code'
2import type { Register, EngineInterface } from 'claude-code'
3
4import type { ArtboardVectorCanvas, ArtboardVectorServer, ArtboardVectorBoard, ArtboardVectorPending } from '../types'
5import { scanSvgSpans, countEditable } from '../shared/dc-scan.mjs'
6
7const PLUGIN = 'artboard-vector'
8const COMMAND = 'vector-edit'
9const canvasRef = atom({ plugin: 'artboard-vector', key: 'canvas' } as const, null as ArtboardVectorCanvas | null)
10const serverRef = atom({ plugin: 'artboard-vector', key: 'server' } as const, null as ArtboardVectorServer | null)
11const pendingRef = atom({ plugin: 'artboard-vector', key: 'pendingPublish' } as const, null as ArtboardVectorPending | null)
12const notesRef = atom({ plugin: 'artboard-vector', key: 'notes' } as const, [] as string[])
13const openOfferRef = atom({ plugin: 'artboard-vector', key: 'openOffer' } as const, null as { url: string; board: string } | null)
14const OPEN_PANE = 'vector-open'
15let sessionCwd = ''
16// Server starts are queued here and run by a loop rooted in session.start, so the child outlives whatever dispatch asked for it.
17const serverRequests: { root: string; ok: (s: ArtboardVectorServer) => void; no: (e: Error) => void }[] = []
18// A comment opens the editor only when it starts with `/vector-edit` or `vector:` (anything after is ignored).
19const TRIGGER = /^\s*(\/vector-edit\b|vector:)/i
20const boardsFamily = { plugin: 'artboard-vector', key: 'boards' } as const
21
22type $ = EngineInterface
23type ServerEvent =
24 | { event: 'listening'; port: number; root: string }
25 | { event: 'saved'; board: string; path: string; svgOrdinals: number[]; at: string }
26 | { event: 'dirty'; board: string; path: string; dirty: boolean }
27 | { event: 'shutdown' }
28 | { event: 'error'; message: string }
29
30const isBoardName = (s: string) => /^[A-Za-z0-9_][A-Za-z0-9_.-]*\.dc\.html$/.test(s)
31const artifactId = (url: string) => (url.match(/\/artifact\/([A-Za-z0-9-]+)/) || [])[1] || 'canvas'
32
33/** `root` of a Design canvas publish/read from the Artifact tool's arguments. */
34function rootFromArtifactArgs(e: { root?: string; file_path?: string; out_dir?: string }): string | null {
35 if (e.root) return e.root
36 for (const p of [e.file_path, e.out_dir]) {
37 if (!p) continue
38 const i = p.indexOf('/project/')
39 if (i > 0) return p.slice(0, i)
40 }
41 return null
42}
43
44/** The viewer's live selection from the last `<artifact-view-context>` block, if any. */
45async function viewContext($: $): Promise<{ board?: string; select?: string }> {
46 const messages = await $.session.messages()
47 for (let i = messages.length - 1; i >= 0; i--) {
48 const m = messages[i]
49 if (m.role !== 'user') continue
50 const block = m.text.match(/<artifact-view-context[^>]*>([\s\S]*?)<\/artifact-view-context>/)
51 if (!block) continue
52 try {
53 const data = JSON.parse(block[1])
54 const ctx = data && typeof data === 'object' ? (data.context || data) : {}
55 const boards: unknown = ctx.selectedArtboards || ctx.visibleArtboards
56 const first = Array.isArray(boards) && typeof boards[0] === 'string' ? decodeURIComponent(boards[0]) : undefined
57 const sel: unknown = Array.isArray(ctx.selected) && typeof ctx.selected[0] === 'string' ? ctx.selected[0] : undefined
58 const m2 = typeof sel === 'string' ? sel.match(/^(.+?\.dc\.html)#(\d+:\d+(?:\/\d+)*)$/) : null
59 return { board: m2 ? decodeURIComponent(m2[1]) : first, select: m2 ? m2[2] : undefined }
60 } catch { return {} }
61 }
62 return {}
63}
64
65async function nodeBinary($: $): Promise<string> {
66 for (const p of ['/opt/homebrew/bin/node', '/usr/local/bin/node']) if (await $.fs.exists(p)) return p
67 return 'node'
68}
69
70async function serverAlive($: $, server: ArtboardVectorServer | null, root: string): Promise<boolean> {
71 if (!server || server.root !== root) return false
72 try {
73 const r = await $.http.fetch(`http://127.0.0.1:${server.port}/api/health?t=${encodeURIComponent(server.token)}`)
74 return r.ok
75 } catch { return false }
76}
77
78async function stopServer($: $, server: ArtboardVectorServer | null) {
79 if (!server) return
80 try { await $.http.fetch(`http://127.0.0.1:${server.port}/api/shutdown?t=${encodeURIComponent(server.token)}`, { method: 'POST' }) } catch {}
81 await update($, serverRef, () => null)
82}
83
84/** Ask the session.start runner to start the editor server for `root`; resolves once it listens. */
85function startServer($: $, root: string): Promise<ArtboardVectorServer> {
86 return new Promise<ArtboardVectorServer>((ok, no) => {
87 serverRequests.push({ root, ok, no })
88 void $.clock.sleep(8000).then(() => no(new Error('the editor server did not start in 8 s')))
89 })
90}
91
92/** Runs one editor server to its end: spawns it, relays its events, and resolves the request once it listens. */
93async function runServer($: $, req: { root: string; ok: (s: ArtboardVectorServer) => void; no: (e: Error) => void }) {
94 const token = crypto.randomUUID().replace(/-/g, '')
95 const argv = [await nodeBinary($), `${$.plugin.root}/server/server.mjs`, '--root', req.root, '--token', token]
96 const stream = $.process.spawn({ argv, cwd: req.root })
97 let buffer = ''
98 try {
99 for await (const chunk of stream) {
100 if (!('stream' in chunk)) break
101 if (chunk.stream !== 'stdout') { $.ui.log(`${PLUGIN} server: ${chunk.text.trim()}`, { to: 'debug' }); continue }
102 buffer += chunk.text
103 let nl: number
104 while ((nl = buffer.indexOf('\n')) >= 0) {
105 const line = buffer.slice(0, nl).trim(); buffer = buffer.slice(nl + 1)
106 if (!line) continue
107 let ev: ServerEvent
108 try { ev = JSON.parse(line) } catch { continue }
109 if (ev.event === 'listening') {
110 const s = { port: ev.port, token, root: req.root }
111 await update($, serverRef, () => s)
112 req.ok(s)
113 } else if (ev.event === 'saved') {
114 await update($, { ...boardsFamily, id: ev.board }, () => ({ dirty: false, lastSaved: ev.at }))
115 await publishBack($, ev)
116 } else if (ev.event === 'dirty') {
117 await update($, { ...boardsFamily, id: ev.board }, (b?: ArtboardVectorBoard) => ({ dirty: ev.dirty, lastSaved: b?.lastSaved ?? null }))
118 } else if (ev.event === 'error') {
119 $.ui.toast(`Vector editor: ${ev.message}`)
120 }
121 }
122 }
123 } catch (e) {
124 req.no(e instanceof Error ? e : new Error(String(e)))
125 } finally {
126 await update($, serverRef, () => null)
127 }
128}
129
130/** Append one line to the mod's publish log (`<plugin root>/.publish.log`), kept to 200 lines. */
131async function publishLog($: $, line: string) {
132 try {
133 const path = `${$.plugin.root}/.publish.log`
134 let prior = ''
135 try { prior = await $.fs.read(path) } catch {}
136 await $.fs.write(path, (prior + `${new Date().toISOString()} ${line}\n`).split('\n').slice(-200).join('\n'))
137 } catch {}
138}
139
140/** Republish the saved board to the canvas through the Artifact tool, retrying once over a stale refusal. */
141async function publishBack($: $, saved: { board: string; path: string; svgOrdinals: number[]; at: string }) {
142 const canvas = await read($, canvasRef)
143 if (!canvas) { await publishLog($, `saved ${saved.board} but no canvas is bound`); return }
144 const attempt = async () => {
145 try {
146 const r = await $.tool.call({ tool: 'Artifact', action: 'publish', url: canvas.url, root: canvas.root, file_path: saved.path })
147 await publishLog($, `publish ${saved.board}: deny=${r.deny ?? ''} isError=${r.isError ?? ''} text=${(r.text ?? '').toString().slice(0, 300).replace(/\n/g, ' ')}`)
148 return r
149 } catch (e) {
150 await publishLog($, `publish ${saved.board} threw: ${e instanceof Error ? e.message : String(e)}`)
151 throw e
152 }
153 }
154 let ran = await attempt()
155 if (ran.deny === undefined && ran.isError !== true) {
156 $.ui.toast(`${saved.board} saved to the canvas`)
157 $.ui.status(`vector: ${saved.board} saved`)
158 await update($, notesRef, notes => [...notes, `${PLUGIN}: the user edited project/${saved.board} by hand in the vector editor (${saved.at}); it was republished to the canvas. Re-read the file before changing it.`])
159 return
160 }
161 const text = (ran.deny ?? ran.text ?? '').toString()
162 $.ui.log(`${PLUGIN}: publish of ${saved.board} failed: ${text.slice(0, 400)}`, { to: 'debug' })
163 const stale = /re-?read|refus|changed|newer|conflict|latest version/i.test(text) && ran.deny === undefined
164 if (stale) {
165 const mine = await $.fs.read(saved.path)
166 // The tool wants the artifact itself viewed at its latest version, then the file re-read.
167 await $.tool.call({ tool: 'Artifact', action: 'read', url: canvas.url })
168 const fresh = await pullFile($, canvas, `project/${saved.board}`)
169 if (fresh.ok) {
170 const theirs = await $.fs.read(saved.path)
171 const mineSpans = scanSvgSpans(mine), theirSpans = scanSvgSpans(theirs)
172 let merged = theirs
173 for (const ordinal of [...saved.svgOrdinals].sort((a, b) => b - a)) {
174 const t = theirSpans[ordinal], m = mineSpans[ordinal]
175 if (t && m) merged = merged.slice(0, t.start) + m.text + merged.slice(t.end)
176 }
177 await $.fs.write(saved.path, merged)
178 ran = await attempt()
179 if (ran.deny === undefined && ran.isError !== true) { $.ui.toast(`${saved.board} merged over a canvas edit and saved`); return }
180 }
181 }
182 await update($, pendingRef, () => ({ url: canvas.url, root: canvas.root, filePath: saved.path, board: saved.board, at: saved.at }))
183 $.ui.toast(`${saved.board} saved locally; the canvas publish needs Claude (send any message). Reason: ${text.slice(0, 120)}`)
184}
185
186/** Why a write to `filePath` should wait, while the editor holds unsaved changes for that board; null when it may go ahead. */
187async function guard($: $, filePath: string | undefined, what: string): Promise<string | null> {
188 const canvas = await read($, canvasRef)
189 if (!canvas || !filePath || !filePath.startsWith(canvas.root + '/project/')) return null
190 const board = filePath.slice(canvas.root.length + '/project/'.length)
191 const { value } = await $.state.get({ ...boardsFamily, id: board })
192 return value && value.dirty ? `${PLUGIN}: the vector editor has unsaved changes for ${board}; wait for its auto-save (or press Save there) before ${what}.` : null
193}
194
195export const register: Register = on => {
196 on('session.start', async ($, e, next) => {
197 sessionCwd = e.cwd
198 await $.command.register({ name: COMMAND, description: 'Vector-edit the shapes of a /design artboard in the browser pane', argumentHint: '[canvas words] [artboard] [--list] [--canvases]' })
199 let kept: ArtboardVectorCanvas | undefined
200 try { kept = (await $.store.get('canvas')) as ArtboardVectorCanvas | undefined } catch {}
201 if (kept && kept.url && kept.root && !(await read($, canvasRef))) await update($, canvasRef, () => kept)
202 // Serve queued server starts from here, so each child lives as long as the session (or the module), not one dispatch.
203 void (async () => {
204 for (;;) {
205 await $.clock.sleep(250)
206 const req = serverRequests.shift()
207 if (req) await runServer($, req)
208 }
209 })()
210 return next(e)
211 })
212
213 // Learn the canvas (url + local root) from the design skill's own Artifact calls.
214 on('tool.call', { tool: 'Artifact' }, async ($, e, next) => {
215 const ran = await next(e)
216 if (ran.deny !== undefined || ran.isError === true) return ran
217 const url = (e as { url?: string }).url
218 const root = rootFromArtifactArgs(e as { root?: string; file_path?: string; out_dir?: string })
219 if (url && root && /\/artifact\//.test(url)) {
220 const uuid = ((ran.text || '').match(/Artifact ([0-9a-f]{8}-[0-9a-f-]{27})/) || [])[1]
221 const prior = await read($, canvasRef)
222 const keepRoot = prior && prior.url === url && prior.root.includes('/.artboard-vector/') ? prior.root : root
223 const canvas: ArtboardVectorCanvas = { url, root: keepRoot, uuid: uuid ?? (prior && prior.url === url ? prior.uuid : undefined) }
224 await update($, canvasRef, () => canvas)
225 try { await $.store.set('canvas', canvas) } catch {}
226 }
227 return ran
228 })
229
230 // While the editor holds unsaved changes for a board, keep Claude from writing that file or publishing the canvas.
231 for (const tool of ['Edit', 'Write', 'MultiEdit'] as const) {
232 on('tool.call', { tool }, async ($, e, next) => {
233 const why = await guard($, (e as { file_path?: string }).file_path, 'editing it')
234 return why ? { deny: why } : next(e)
235 }).catch(($, e, next) => next(e))
236 }
237 on('tool.call', { tool: 'Artifact' }, async ($, e, next) => {
238 const args = e as { action?: string; file_path?: string }
239 if (args.action !== 'publish' && args.action !== undefined) return next(e)
240 const why = await guard($, args.file_path, 'publishing')
241 return why ? { deny: why } : next(e)
242 }).catch(($, e, next) => next(e))
243
244 // Hand Claude the notes a save left, and a publish the mod could not make itself.
245 on('turn.start', async ($, e, next) => {
246 if (e.agentId !== undefined) return next(e)
247 const notes = await read($, notesRef)
248 const pending = await read($, pendingRef)
249 const lines = [...notes]
250 if (pending) lines.push(`${PLUGIN}: the user saved project/${pending.board} in the vector editor at ${pending.at} but the canvas was not republished. Publish it now with the Artifact tool: { action: "publish", url: "${pending.url}", root: "${pending.root}", file_path: "${pending.filePath}" }; if the publish is refused as stale, read that file from the canvas first, re-apply the svg changes, and publish again.`)
251 if (lines.length) {
252 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: lines.join('\n') }] } })
253 await update($, notesRef, () => [])
254 await update($, pendingRef, () => null)
255 }
256 return next(e)
257 })
258
259 on('command.run', { command: COMMAND }, async ($, e) => {
260 const words = e.args.trim().split(/\s+/).filter(Boolean)
261 let canvas = await read($, canvasRef)
262 let board: string | undefined
263 let wantList = false
264 let wantCanvasList = false
265 const query: string[] = []
266 for (let i = 0; i < words.length; i++) {
267 const w = words[i]
268 if (w === '--list') { wantList = true; continue }
269 if (w === '--canvases') { wantCanvasList = true; continue }
270 if (w === '--url' && words[i + 1]) { canvas = { url: words[++i], root: '' }; continue }
271 if (w === '--root' && words[i + 1]) { canvas = { url: canvas?.url ?? '', root: words[++i] }; continue }
272 if (/^https?:\/\//.test(w)) { canvas = { url: w, root: canvas && canvas.url === w ? canvas.root : '' }; continue }
273 if (isBoardName(w)) board = w
274 else query.push(w)
275 }
276 // Free words: first try them as a canvas title (when no canvas is bound, or they clearly name another one), then as an artboard.
277 if (wantCanvasList || !canvas || !canvas.url || query.length) {
278 const canvases = await listCanvases($)
279 if (wantCanvasList) return { text: canvases.length ? `Your Design canvases:\n${canvases.map(c => ` ${c.title} ${c.url}`).join('\n')}\n\nOpen one with /${COMMAND} <words from its title> [artboard].` : 'No Design canvases found for your account.' }
280 const match = query.length ? bestMatch(canvases.map(c => ({ key: c.title, value: c })), query) : null
281 const boundAmong = match && canvas && canvas.url ? canvases.find(c => c.url === canvas!.url && match.candidates.includes(c.title)) : undefined
282 if (match && (match.unique || boundAmong)) {
283 if (match.unique && (!canvas || canvas.url !== match.value.url)) canvas = { url: match.value.url, root: '' }
284 query.splice(0, query.length, ...match.rest)
285 } else if (!canvas || !canvas.url) {
286 if (!canvases.length) return { text: `No Design canvas is known yet and none was found for your account. Run /${COMMAND} <canvas url> [Board.dc.html].` }
287 const shown = match && match.candidates.length ? match.candidates : canvases
288 return { text: `Which canvas? Say /${COMMAND} <words from its title> [artboard]:\n${shown.map(c => ` ${c.title}`).join('\n')}` }
289 }
290 }
291 const ready = await ensureRoot($, canvas)
292 if ('text' in ready) return ready
293 canvas = ready.canvas
294 if (wantList) return listBoards($, canvas)
295 let select: string | undefined
296 if (!board) {
297 const ctx = await viewContext($)
298 if (ctx.board) { board = ctx.board; select = ctx.select }
299 }
300 if (!board) {
301 const names = await boardNames($, canvas)
302 if (query.length) {
303 const titles = await boardTitles($, canvas)
304 const items = names.map(n => ({ key: n.replace(/\.dc\.html$/, '') + ' ' + (titles[n] || ''), value: n }))
305 const known = query.filter(w => items.some(it => (' ' + it.key.toLowerCase().replace(/[^a-z0-9]+/g, ' ') + ' ').includes(w.toLowerCase().replace(/[^a-z0-9]+/g, ' '))))
306 const m = known.length ? bestMatch(items, known) : null
307 if (m && m.unique) board = m.value
308 else if (m && m.candidates.length) return { text: `Which artboard? ${m.candidates.map(c => c.trim()).join(' · ')}` }
309 }
310 if (!board && names.length === 1) board = names[0]
311 if (!board) return { text: names.length ? `Which artboard? /${COMMAND} <name> (or /${COMMAND} --list to see which have editable shapes)\n${names.map(n => ` ${n.replace(/\.dc\.html$/, '')}`).join('\n')}` : `No artboards found for ${canvas.url}.` }
312 }
313 return openEditor($, canvas, board, select)
314 })
315
316 // Terminal: a small "Open it?" dialog after the editor is ready; y opens the URL in the default browser.
317 on('ui.render', { component: 'Pane', requestId: OPEN_PANE }, async ($, e) => {
318 const { Box, Text, Button } = $.ui.resolve(e)
319 const offer = await read($, openOfferRef)
320 const url = offer ? offer.url : ''
321 const board = offer ? offer.board : ''
322 const close = () => { void $.ui.close({ id: OPEN_PANE }) }
323 return h(Box, { flexDirection: 'column' },
324 h(Text, null, `Vector editor ready for ${board}. Open it in your browser?`),
325 h(Box, { gap: 1 },
326 h(Button, { key: 'open', label: 'Open', hotkey: 'y', variant: 'primary', onPress: () => { void openInBrowser($, url); close() } }),
327 h(Button, { key: 'no', label: 'Not now', hotkey: 'n', onPress: close }),
328 ),
329 )
330 })
331
332 // A comment sent to Claude from the canvas that asks to edit shapes opens the editor on its anchor.
333 on('session.receive', async ($, e, next) => {
334 if (e.agentId !== undefined || !/Artifact comment sent to Claude/.test(e.text)) return next(e)
335 const body = (e.text.match(/=== BEGIN ARTIFACT COMMENT \w+ ===\n([\s\S]*?)\n=== END ARTIFACT COMMENT/) || [])[1] || ''
336 if (!TRIGGER.test(body)) return next(e)
337 const uuid = (e.text.match(/Artifact: https?:\/\/\S+\/artifact\/([0-9a-f-]{36})/) || [])[1]
338 const anchor = e.text.match(/Anchor detail: (\S+\.dc\.html): #(\d+:\d+(?:\/\d+)*)/)
339 const board = anchor ? decodeURIComponent(anchor[1]) : undefined
340 const select = anchor ? anchor[2] : undefined
341 let canvas = await read($, canvasRef)
342 if (!canvas || (uuid && canvas.uuid && canvas.uuid !== uuid)) return next({ ...e, text: e.text + `\n\n[${PLUGIN}] The comment asks to edit shapes, but this session's vector editor is bound to a different canvas (${canvas ? canvas.url : 'none yet'}). Reply in the thread that the user should run /${COMMAND} <that canvas url> ${board ?? ''} in the chat.` })
343 const ready = await ensureRoot($, canvas)
344 if ('text' in ready) return next({ ...e, text: e.text + `\n\n[${PLUGIN}] ${ready.text}` })
345 canvas = ready.canvas
346 const target = board && isBoardName(board) ? board : (await boardNames($, canvas))[0]
347 if (!target) return next(e)
348 const opened = await openEditor($, canvas, target, select)
349 const note = [opened.text, ...(opened.context ?? [])].join(' ')
350 return next({ ...e, text: e.text + `\n\n[${PLUGIN}] ${note} Then reply in the comment thread, in one line, that the vector editor is open on ${target} in the browser pane beside the canvas.` })
351 })
352}
353
354/** Pull one canvas file (`project/...`) into the root: the Artifact tool's `read` saves it in the scratchpad, then it is copied. */
355async function pullFile($: $, canvas: ArtboardVectorCanvas, path: string): Promise<{ ok: true } | { ok: false; why: string }> {
356 const ran = await $.tool.call({ tool: 'Artifact', action: 'read', url: canvas.url, path })
357 if (ran.deny !== undefined || ran.isError === true) return { ok: false, why: (ran.deny ?? ran.text ?? 'read failed').toString().slice(0, 300) }
358 const saved = (ran.text || '').match(/saved(?: under)?:? "([^"]+)"/)
359 if (!saved) return { ok: false, why: 'the read named no saved file' }
360 const src = saved[1].endsWith(path) ? saved[1] : `${saved[1]}/${path}`
361 if (!(await $.fs.exists(src))) return { ok: false, why: `saved file not found at ${src}` }
362 await $.fs.write(`${canvas.root}/${path}`, await $.fs.read(src))
363 return { ok: true }
364}
365
366/** Make sure the canvas has a root the Artifact tool accepts for publishing: a folder under the session's working directory. */
367async function ensureRoot($: $, canvas: ArtboardVectorCanvas): Promise<{ canvas: ArtboardVectorCanvas } | { text: string }> {
368 let next = canvas
369 const wanted = `${sessionCwd || '.'}/.artboard-vector/${artifactId(canvas.url)}`
370 if (canvas.root !== wanted) {
371 next = { url: canvas.url, root: wanted, uuid: canvas.uuid }
372 try { await $.fs.write(`${wanted}/.gitignore`, '*\n') } catch {}
373 }
374 if (!next.uuid) {
375 const probe = await $.tool.call({ tool: 'Artifact', action: 'read', url: canvas.url })
376 const uuid = ((probe.text || '').match(/Artifact ([0-9a-f]{8}-[0-9a-f-]{27})/) || [])[1]
377 if (uuid) next = { ...next, uuid }
378 }
379 await update($, canvasRef, () => next)
380 try { await $.store.set('canvas', next) } catch {}
381 return { canvas: next }
382}
383
384/** The canvas's artboard names, from the local index or the canvas's own. */
385async function boardNames($: $, canvas: ArtboardVectorCanvas): Promise<string[]> {
386 let names: string[] = []
387 try { names = (JSON.parse(await $.fs.read(`${canvas.root}/project/canvas.json`)).order || []).filter(isBoardName) } catch {}
388 if (names.length) return names
389 const idx = await pullFile($, canvas, 'project/canvas.json')
390 if (idx.ok) {
391 try { names = (JSON.parse(await $.fs.read(`${canvas.root}/project/canvas.json`)).order || []).filter(isBoardName) } catch {}
392 }
393 if (!names.length) try { names = (await $.fs.list(`${canvas.root}/project`)).map(f => f.name).filter(isBoardName) } catch {}
394 return names
395}
396
397/** `/vector-edit --list`: every artboard with how many editable shapes it holds. */
398async function listBoards($: $, canvas: ArtboardVectorCanvas): Promise<{ text: string }> {
399 const names = await boardNames($, canvas)
400 if (!names.length) return { text: `No artboards found for ${canvas.url}.` }
401 const missing: string[] = []
402 for (const n of names) if (!(await $.fs.exists(`${canvas.root}/project/${n}`))) missing.push(n)
403 for (const n of missing) await pullFile($, canvas, `project/${n}`)
404 const rows: string[] = []
405 for (const n of names) {
406 try {
407 const c = countEditable(await $.fs.read(`${canvas.root}/project/${n}`))
408 rows.push(` ${c.shapes ? String(c.shapes).padStart(3) : ' -'} ${n}${c.locked ? ` (${c.locked} locked)` : ''}${c.svgs === 0 ? ' no inline svg' : ''}`)
409 } catch { rows.push(` ? ${n} (not readable)`) }
410 }
411 return { text: `Editable shapes per artboard on ${canvas.url}:\n${rows.join('\n')}\n\nOpen one with /${COMMAND} <name>.` }
412}
413
414/** Pull the board fresh, make sure the server runs for this root, and open the editor. */
415async function openEditor($: $, canvas: ArtboardVectorCanvas, board: string, select?: string): Promise<{ text: string; context?: string[] }> {
416 const pulled = await pullFile($, canvas, `project/${board}`)
417 const localPath = `${canvas.root}/project/${board}`
418 if (!pulled.ok && !(await $.fs.exists(localPath))) return { text: `Could not get project/${board} from ${canvas.url}: ${pulled.why}` }
419
420 let server = await read($, serverRef)
421 if (!(await serverAlive($, server, canvas.root))) {
422 await stopServer($, server)
423 try { server = await startServer($, canvas.root) } catch (err) {
424 return { text: `The vector editor server could not start: ${err instanceof Error ? err.message : String(err)}` }
425 }
426 }
427 const url = `http://127.0.0.1:${server!.port}/edit?t=${server!.token}&board=${encodeURIComponent(board)}${select ? `&select=${encodeURIComponent(select)}` : ''}`
428
429 let opened = false
430 for (const name of ['Claude_Browser', 'Claude Browser', 'claude_browser']) {
431 try {
432 const r = await $.mcp.call(name, 'preview_start', { url })
433 if (!r.isError) { opened = true; break }
434 } catch {}
435 }
436 $.ui.status(`vector: editing ${board}`)
437 if (opened) return { text: `Vector editor open for ${board}.` }
438 const surfaces = await $.session.surfaces()
439 const onDesktop = surfaces.some(s => s === 'desktop')
440 if (!onDesktop) {
441 await update($, openOfferRef, () => ({ url, board }))
442 void $.ui.open({ id: OPEN_PANE, title: 'Vector editor', focus: true, closeOnEscape: true, holdToasts: true, rows: 4 })
443 return { text: `Vector editor ready for ${board}: ${url}` }
444 }
445 return { text: `Vector editor ready for ${board}: ${url}`, context: [`Open ${url} in the built-in browser pane with preview_start (url), then stop and let the user edit there. The ${PLUGIN} mod republishes the canvas itself on save; do not read, verify or publish the board unless asked.`] }
446}
447
448/** Open a URL in the person's default browser (macOS `open`, Linux `xdg-open`). */
449async function openInBrowser($: $, url: string) {
450 if (!/^http:\/\/127\.0\.0\.1:\d+\//.test(url)) return
451 for (const argv of [['open', url], ['xdg-open', url]]) {
452 try { const r = await $.process.run(argv, { timeoutMs: 5000 }); if (r.exitCode === 0) return } catch {}
453 }
454 $.ui.toast(`Could not open a browser; paste this URL: ${url}`)
455}
456
457/** The user's Design canvases, from the Artifact tool's listing. */
458async function listCanvases($: $): Promise<{ title: string; url: string }[]> {
459 const ran = await $.tool.call({ tool: 'Artifact', action: 'list', type: 'Design', limit: 50 })
460 if (ran.deny !== undefined || ran.isError === true || !ran.text) return []
461 const out: { title: string; url: string }[] = []
462 for (const line of ran.text.split('\n')) {
463 const m = line.match(/^- (?:\([^)]*\) )?(?:[^:]*default[^:]*: )?(.+?) — (https:\/\/claude\.ai\/artifact\/[A-Za-z0-9-]+)/)
464 if (m) out.push({ title: m[1].trim(), url: m[2] })
465 }
466 return out
467}
468
469/** Words against names: every query word must appear (case-insensitive) in the name; unique when exactly one name wins. */
470function bestMatch<T>(items: { key: string; value: T }[], words: string[]): { unique: boolean; value: T; candidates: string[]; rest: string[] } | null {
471 const norm = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, ' ')
472 let best: { score: number; items: { key: string; value: T }[]; rest: string[] } | null = null
473 // try the longest prefix of the words first, so trailing words can name the artboard
474 for (let n = words.length; n >= 1; n--) {
475 const use = words.slice(0, n).map(norm), rest = words.slice(n)
476 const hits = items.filter(it => { const k = ' ' + norm(it.key) + ' '; return use.every(w => k.includes(w)) })
477 if (hits.length) { best = { score: n, items: hits, rest }; break }
478 }
479 if (!best) return null
480 return { unique: best.items.length === 1, value: best.items[0].value, candidates: best.items.map(i => i.key), rest: best.rest }
481}
482
483/** Artboard titles from the local canvas.json, keyed by file name. */
484async function boardTitles($: $, canvas: ArtboardVectorCanvas): Promise<Record<string, string>> {
485 try {
486 const idx = JSON.parse(await $.fs.read(`${canvas.root}/project/canvas.json`))
487 const out: Record<string, string> = {}
488 for (const [name, b] of Object.entries(idx.boards || {})) if (b && typeof (b as { title?: unknown }).title === 'string') out[name] = (b as { title: string }).title
489 return out
490 } catch { return {} }
491}
492shared/dc-scan.mjs 247 lines1// dc-scan: locate inline <svg> spans inside a .dc.html artboard and splice
2// attribute values by byte range, so everything else in the file stays
3// byte-identical ({{holes}}, CSS vars, data-props, the DCLogic script).
4//
5// Plain ESM with no imports: it runs in node (server), in the browser
6// (editor) and in the hooks module (publish-back) unchanged.
7
8const GEOMETRY_ATTRS = {
9 rect: ['x', 'y', 'width', 'height', 'rx', 'ry'],
10 circle: ['cx', 'cy', 'r'],
11 ellipse: ['cx', 'cy', 'rx', 'ry'],
12 line: ['x1', 'y1', 'x2', 'y2'],
13 polygon: ['points'],
14 polyline: ['points'],
15 path: ['d'],
16}
17
18/** FNV-1a 32-bit over UTF-16 code units, as 8 hex digits. Identity, not security. */
19export function hashText(text) {
20 let h = 0x811c9dc5
21 for (let i = 0; i < text.length; i++) {
22 h ^= text.charCodeAt(i)
23 h = Math.imul(h, 0x01000193) >>> 0
24 }
25 return h.toString(16).padStart(8, '0')
26}
27
28/** The [start, end) of the <x-dc>…</x-dc> fragment (inner text), or null. */
29export function findFragment(text) {
30 const open = text.search(/<x-dc(\s[^>]*)?>/i)
31 if (open < 0) return null
32 const openEnd = text.indexOf('>', open) + 1
33 const close = text.lastIndexOf('</x-dc>')
34 if (close < openEnd) return null
35 return { start: openEnd, end: close }
36}
37
38// Skips what is not markup to scan: comments, <script>, <style>, <![CDATA[.
39function skipOpaque(text, i, limit) {
40 if (text.startsWith('<!--', i)) {
41 const e = text.indexOf('-->', i + 4)
42 return e < 0 ? limit : Math.min(limit, e + 3)
43 }
44 for (const tag of ['script', 'style']) {
45 const re = new RegExp(`^<${tag}(\\s|>)`, 'i')
46 if (re.test(text.slice(i, i + tag.length + 2))) {
47 const e = text.toLowerCase().indexOf(`</${tag}>`, i)
48 return e < 0 ? limit : Math.min(limit, e + tag.length + 3)
49 }
50 }
51 return -1
52}
53
54/**
55 * Top-level inline <svg> spans inside the fragment, in document order:
56 * `{ ordinal, start, end, text, hash }`, absolute offsets into `text`.
57 */
58export function scanSvgSpans(text) {
59 const frag = findFragment(text)
60 if (!frag) return []
61 const spans = []
62 let i = frag.start
63 let depth = 0
64 let spanStart = -1
65 while (i < frag.end) {
66 const lt = text.indexOf('<', i)
67 if (lt < 0 || lt >= frag.end) break
68 const skipped = skipOpaque(text, lt, frag.end)
69 if (skipped >= 0) { i = skipped; continue }
70 if (/^<svg(\s|>|\/)/i.test(text.slice(lt, lt + 5))) {
71 const gt = text.indexOf('>', lt)
72 if (gt < 0) break
73 const selfClosed = text[gt - 1] === '/'
74 if (depth === 0) spanStart = lt
75 if (selfClosed) {
76 if (depth === 0) pushSpan(spans, text, spanStart, gt + 1)
77 } else depth++
78 i = gt + 1
79 continue
80 }
81 if (/^<\/svg\s*>/i.test(text.slice(lt, lt + 8))) {
82 const gt = text.indexOf('>', lt)
83 depth = Math.max(0, depth - 1)
84 if (depth === 0 && spanStart >= 0) pushSpan(spans, text, spanStart, gt + 1)
85 i = gt + 1
86 continue
87 }
88 i = lt + 1
89 }
90 return spans
91}
92
93function pushSpan(spans, text, start, end) {
94 const t = text.slice(start, end)
95 spans.push({ ordinal: spans.length, start, end, text: t, hash: hashText(t) })
96}
97
98const VOID_HTML = new Set(['br', 'img', 'input', 'hr', 'meta', 'link', 'wbr', 'source', 'use'])
99const TAG_RE = /<(\/?)([A-Za-z][\w:.-]*)((?:\s+[^\s"'=<>\/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*(\/?)>/g
100const ATTR_RE = /([^\s"'=<>\/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
101
102/**
103 * Element tree of a markup span: each node `{ tag, open:[s,e), close:[s,e)|null,
104 * attrs:[{name, value, valueStart, valueEnd, quote}], children }`, offsets relative
105 * to the span text. Element children only (text and comments are not nodes),
106 * so a child path here matches `element.children` in a parsed DOM.
107 */
108export function parseTree(spanText) {
109 const root = { tag: '#root', open: [0, 0], close: null, attrs: [], children: [] }
110 const stack = [root]
111 TAG_RE.lastIndex = 0
112 let m
113 while ((m = TAG_RE.exec(spanText))) {
114 const [whole, slash, rawTag, attrText, selfSlash] = m
115 const tag = rawTag.toLowerCase()
116 const s = m.index
117 const e = s + whole.length
118 if (slash) {
119 // close: pop to the matching open tag if any
120 for (let k = stack.length - 1; k > 0; k--) {
121 if (stack[k].tag === tag) {
122 stack[k].close = [s, e]
123 stack.length = k
124 break
125 }
126 }
127 continue
128 }
129 const attrs = []
130 const attrBase = s + whole.indexOf(attrText, 1 + rawTag.length)
131 ATTR_RE.lastIndex = 0
132 let a
133 while ((a = ATTR_RE.exec(attrText))) {
134 if (a[0].trim() === '') { if (ATTR_RE.lastIndex === a.index) ATTR_RE.lastIndex++; continue }
135 const name = a[1]
136 let value = null, valueStart = -1, valueEnd = -1, quote = ''
137 if (a[2] !== undefined) { value = a[2]; quote = '"' }
138 else if (a[3] !== undefined) { value = a[3]; quote = "'" }
139 else if (a[4] !== undefined) { value = a[4]; quote = '' }
140 if (value !== null) {
141 const eq = a[0].indexOf('=')
142 const vs = a.index + eq + 1 + a[0].slice(eq + 1).search(/\S/)
143 valueStart = attrBase + vs + (quote ? 1 : 0)
144 valueEnd = valueStart + value.length
145 }
146 attrs.push({ name, value, valueStart, valueEnd, quote })
147 }
148 const node = { tag, open: [s, e], close: null, attrs, children: [] }
149 stack[stack.length - 1].children.push(node)
150 if (!selfSlash && !VOID_HTML.has(tag)) stack.push(node)
151 }
152 return root
153}
154
155/** The node at a child-index path from the span root (`[]` is the svg itself). */
156export function nodeAtPath(tree, path) {
157 let node = tree.children[0]
158 for (const idx of path) {
159 if (!node) return null
160 node = node.children[idx]
161 }
162 return node || null
163}
164
165export function attrOf(node, name) {
166 return node.attrs.find(a => a.name.toLowerCase() === name.toLowerCase()) || null
167}
168
169/**
170 * Apply edits to the file text. Each edit:
171 * { svgOrdinal, hash, path: number[], attr: 'd', value: string }
172 * { svgOrdinal, hash, path: number[], convert: { tag: 'path', d: string } }
173 * Returns { text, changed: ordinal[] }. Throws { code: 'stale' | 'not_found' | 'locked', ordinal }.
174 */
175export function applyEdits(text, edits) {
176 const spans = scanSvgSpans(text)
177 const patches = []
178 const changed = new Set()
179 for (const edit of edits) {
180 const span = spans[edit.svgOrdinal]
181 if (!span) throw Object.assign(new Error('no such svg'), { code: 'not_found', ordinal: edit.svgOrdinal })
182 if (edit.hash && edit.hash !== span.hash) throw Object.assign(new Error('svg changed on disk'), { code: 'stale', ordinal: edit.svgOrdinal })
183 const tree = parseTree(span.text)
184 const node = nodeAtPath(tree, edit.path)
185 if (!node) throw Object.assign(new Error('no element at path'), { code: 'not_found', ordinal: edit.svgOrdinal })
186 if (edit.convert) {
187 const d = edit.convert.d
188 if (typeof d !== 'string' || !d) throw Object.assign(new Error('convert needs d'), { code: 'not_found', ordinal: edit.svgOrdinal })
189 const drop = new Set(GEOMETRY_ATTRS[node.tag] || [])
190 const kept = node.attrs.filter(a => !drop.has(a.name.toLowerCase()))
191 if (kept.some(a => a.value !== null && a.value.includes('{{')) && false) { /* presentation holes are fine */ }
192 const openText = span.text.slice(node.open[0], node.open[1])
193 const selfClosed = /\/>$/.test(openText)
194 const attrsText = kept.map(a => a.value === null ? a.name : `${a.name}=${a.quote || '"'}${a.value}${a.quote || '"'}`).join(' ')
195 const newOpen = `<path${attrsText ? ' ' + attrsText : ''} d="${d}"${selfClosed ? '/>' : '>'}`
196 patches.push({ start: span.start + node.open[0], end: span.start + node.open[1], value: newOpen })
197 if (node.close) patches.push({ start: span.start + node.close[0], end: span.start + node.close[1], value: '</path>' })
198 } else {
199 const attr = attrOf(node, edit.attr || 'd')
200 if (!attr || attr.value === null) throw Object.assign(new Error('attribute missing'), { code: 'not_found', ordinal: edit.svgOrdinal })
201 if (attr.value.includes('{{')) throw Object.assign(new Error('attribute is a template hole'), { code: 'locked', ordinal: edit.svgOrdinal })
202 const value = attr.quote === "'" ? String(edit.value).replace(/'/g, ''') : String(edit.value).replace(/"/g, '"')
203 patches.push({ start: span.start + attr.valueStart, end: span.start + attr.valueEnd, value })
204 }
205 changed.add(edit.svgOrdinal)
206 }
207 patches.sort((a, b) => b.start - a.start)
208 for (let k = 1; k < patches.length; k++) {
209 if (patches[k].end > patches[k - 1].start) throw Object.assign(new Error('overlapping edits'), { code: 'not_found', ordinal: -1 })
210 }
211 let out = text
212 for (const p of patches) out = out.slice(0, p.start) + p.value + out.slice(p.end)
213 return { text: out, changed: [...changed].sort((a, b) => a - b) }
214}
215
216const SHAPE_TAGS = new Set(['path', 'rect', 'circle', 'ellipse', 'line', 'polygon', 'polyline'])
217const SKIP_TAGS = new Set(['defs', 'clippath', 'mask', 'marker', 'pattern', 'symbol'])
218
219/** Count editable svg shapes in an artboard: `{ svgs, shapes, locked }`. */
220export function countEditable(text) {
221 const spans = scanSvgSpans(text)
222 let shapes = 0, locked = 0
223 const walk = node => {
224 for (const c of node.children) {
225 if (SKIP_TAGS.has(c.tag)) continue
226 if (SHAPE_TAGS.has(c.tag)) {
227 const geom = c.tag === 'path' ? ['d'] : GEOMETRY_ATTRS[c.tag]
228 const vals = geom.map(k => { const a = attrOf(c, k); return a ? a.value : null })
229 if (c.tag === 'path' && vals[0] === null) continue
230 if (vals.some(v => typeof v === 'string' && v.includes('{{'))) locked++
231 else shapes++
232 }
233 walk(c)
234 }
235 }
236 for (const span of spans) walk(parseTree(span.text))
237 return { svgs: spans.length, shapes, locked }
238}
239
240/** The `data-props` JSON of the artboard's DCLogic script, or {}. */
241export function readDataProps(text) {
242 const m = text.match(/<script[^>]*data-dc-script[^>]*data-props='([^']*)'/i) || text.match(/<script[^>]*data-dc-script[^>]*data-props="([^"]*)"/i)
243 if (!m) return {}
244 const raw = m[1].replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"')
245 try { return JSON.parse(raw) } catch { return {} }
246}
247types/index.d.ts 22 lines1/** The /design canvas the editor works on: its artifact url and the local root holding `project/`. */
2export type ArtboardVectorCanvas = { url: string; root: string; uuid?: string }
3/** The running editor server. */
4export type ArtboardVectorServer = { port: number; token: string; root: string }
5/** Per-board editor state, keyed by the board's file name (`Shapes.dc.html`). */
6export type ArtboardVectorBoard = { dirty: boolean; lastSaved: string | null }
7/** A save the mod could not publish itself; Claude is asked to publish it on the next turn. */
8export type ArtboardVectorPending = { url: string; root: string; filePath: string; board: string; at: string }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'artboard-vector': {
13 canvas: ArtboardVectorCanvas | null
14 server: ArtboardVectorServer | null
15 boards: { readonly byId: ArtboardVectorBoard }
16 pendingPublish: ArtboardVectorPending | null
17 notes: string[]
18 openOffer: { url: string; board: string } | null
19 }
20 }
21}
22