SLOPSHOPPER

artboard-vector

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

paneguardcommandtoaststatusprocess
v0.1.4no licenseupdated 2026-10-18crlngn/claude-vector-edit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · artboard-vector
│ ┃ vector-open ✕ › fix the failing auth test and add an audit log call │ ┃ Vector editor ready for . Open it in your │ ┃ browser? ⏺ Read(src/auth.ts) │ ┃ [ Open ] [ Not now ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /vector-edit │ ⎿ artboard-vector: No Design canvas is known yet and none was foun │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · vector-open
Vector editor ready for . Open it in your browser? [ Open ] [ Not now ]
README

artboard-vector

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.

Install

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.

Use

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.

Develop

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).

Source 3 files
hooks/register.ts 492 lines
1import { 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}
492
shared/dc-scan.mjs 247 lines
1// 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, '&#39;') : String(edit.value).replace(/"/g, '&quot;')
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(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"')
245  try { return JSON.parse(raw) } catch { return {} }
246}
247
types/index.d.ts 22 lines
1/** 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