pi-tldraw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/bridge/app-bridge-entry.js +6 -0
  4. package/mcp-app/LICENSE.md +9 -0
  5. package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
  6. package/mcp-app/README.md +129 -0
  7. package/mcp-app/dev-tunnel.sh +51 -0
  8. package/mcp-app/dist/editor-api.json +8493 -0
  9. package/mcp-app/dist/mcp-app.html +643 -0
  10. package/mcp-app/dist/method-map.json +915 -0
  11. package/mcp-app/package.json +42 -0
  12. package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
  13. package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
  14. package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
  15. package/mcp-app/scripts/extract-editor-api.ts +1374 -0
  16. package/mcp-app/server.json +21 -0
  17. package/mcp-app/src/logger.ts +45 -0
  18. package/mcp-app/src/register-tools.ts +368 -0
  19. package/mcp-app/src/shared/generated-data.ts +160 -0
  20. package/mcp-app/src/shared/pending-requests.ts +69 -0
  21. package/mcp-app/src/shared/types.ts +76 -0
  22. package/mcp-app/src/shared/utils.ts +132 -0
  23. package/mcp-app/src/tools/exec.ts +120 -0
  24. package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
  25. package/mcp-app/src/tools/search.ts +150 -0
  26. package/mcp-app/src/widget/app-context.tsx +29 -0
  27. package/mcp-app/src/widget/dev-log.tsx +70 -0
  28. package/mcp-app/src/widget/exec-helpers.ts +232 -0
  29. package/mcp-app/src/widget/export-tldr.ts +35 -0
  30. package/mcp-app/src/widget/focused/defaults.ts +141 -0
  31. package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
  32. package/mcp-app/src/widget/focused/format.ts +366 -0
  33. package/mcp-app/src/widget/focused/to-focused.ts +258 -0
  34. package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
  35. package/mcp-app/src/widget/image-guard.tsx +106 -0
  36. package/mcp-app/src/widget/index.html +33 -0
  37. package/mcp-app/src/widget/mcp-app.css +113 -0
  38. package/mcp-app/src/widget/mcp-app.tsx +857 -0
  39. package/mcp-app/src/widget/persistence.ts +337 -0
  40. package/mcp-app/src/widget/snapshot.ts +157 -0
  41. package/mcp-app/src/worker.ts +305 -0
  42. package/mcp-app/tsconfig.json +23 -0
  43. package/mcp-app/vite.config.ts +13 -0
  44. package/mcp-app/wrangler.toml +45 -0
  45. package/mcp-app-source.json +36 -0
  46. package/package.json +90 -0
  47. package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
  48. package/scripts/assemble-mcp-app.mjs +193 -0
  49. package/scripts/build-bridge.mjs +74 -0
  50. package/scripts/e2e-mcp.mjs +69 -0
  51. package/scripts/e2e-packaged-mcp-app.mjs +79 -0
  52. package/scripts/run-mcp-app-dev.mjs +44 -0
  53. package/scripts/verify-bundle.mjs +41 -0
  54. package/scripts/verify-mcp-app-source.mjs +51 -0
  55. package/scripts/verify-mcp-app.mjs +38 -0
  56. package/scripts/verify-package-files.mjs +50 -0
  57. package/src/canvas/export.ts +164 -0
  58. package/src/canvas/state.ts +117 -0
  59. package/src/canvas/workflow.ts +105 -0
  60. package/src/commands/tldraw-command.ts +48 -0
  61. package/src/diagram/guidance.ts +44 -0
  62. package/src/host/local-host.ts +289 -0
  63. package/src/index.ts +762 -0
  64. package/src/mcp/client.ts +126 -0
  65. package/src/mcp/response.ts +74 -0
  66. package/src/semantic/layer.ts +309 -0
  67. package/src/server/server-manager.ts +153 -0
  68. package/src/store/export-store.ts +33 -0
  69. package/src/store/project-store.ts +251 -0
  70. package/src/ui/tldraw-status.ts +88 -0
  71. package/static/app-bridge-bundle.js +18114 -0
  72. package/static/app-bridge-bundle.meta.json +164 -0
  73. package/static/host.html +390 -0
  74. package/tsconfig.json +13 -0
@@ -0,0 +1,164 @@
1
+ export type CanvasExportFormat = 'png' | 'svg'
2
+ export type CanvasExportScope = 'selected' | 'all'
3
+
4
+ export interface CanvasExportCodeOptions {
5
+ format?: string
6
+ scope?: string
7
+ background?: boolean
8
+ padding?: number
9
+ scale?: number
10
+ pixelRatio?: number
11
+ }
12
+
13
+ export interface CanvasExportPayload {
14
+ dataUrl: string
15
+ width: number
16
+ height: number
17
+ format: CanvasExportFormat
18
+ scope: CanvasExportScope
19
+ exportedShapeIds: string[]
20
+ selectedCount: number
21
+ fallbackToAll: boolean
22
+ }
23
+
24
+ export interface ParsedDataUrl {
25
+ mimeType: string
26
+ data: string
27
+ bytes: number
28
+ }
29
+
30
+ export function normalizeCanvasExportFormat(format?: string): CanvasExportFormat {
31
+ const normalized = format?.toLowerCase().trim()
32
+ return normalized === 'svg' ? 'svg' : 'png'
33
+ }
34
+
35
+ export function normalizeCanvasExportScope(scope?: string): CanvasExportScope {
36
+ const normalized = scope?.toLowerCase().trim()
37
+ return normalized === 'all' ? 'all' : 'selected'
38
+ }
39
+
40
+ export function buildCanvasExportCode(options: CanvasExportCodeOptions = {}) {
41
+ const format = normalizeCanvasExportFormat(options.format)
42
+ const scope = normalizeCanvasExportScope(options.scope)
43
+ const exportOptions: Record<string, unknown> = {
44
+ format,
45
+ background: options.background ?? true,
46
+ }
47
+ if (typeof options.pixelRatio === 'number' && Number.isFinite(options.pixelRatio)) {
48
+ exportOptions.pixelRatio = options.pixelRatio
49
+ } else if (format === 'png') {
50
+ exportOptions.pixelRatio = 1
51
+ }
52
+ if (typeof options.padding === 'number' && Number.isFinite(options.padding)) exportOptions.padding = options.padding
53
+ if (typeof options.scale === 'number' && Number.isFinite(options.scale)) exportOptions.scale = options.scale
54
+
55
+ return `
56
+ const __piExportScope = ${JSON.stringify(scope)}
57
+ const __piExportFormat = ${JSON.stringify(format)}
58
+ const __piExportOptions = ${JSON.stringify(exportOptions)}
59
+ const __piToTldrawId = (id) => {
60
+ const value = String(id)
61
+ return value.startsWith('shape:') ? value : 'shape:' + value
62
+ }
63
+ const __piAllShapeIds = editor.getCurrentPageShapes()
64
+ .map((shape) => shape.shapeId ?? shape.id)
65
+ .filter(Boolean)
66
+ .map(String)
67
+ let __piSelectedShapeIds = []
68
+ try { __piSelectedShapeIds = (editor.getSelectedShapeIds() || []).map(String) } catch { __piSelectedShapeIds = [] }
69
+ const __piFallbackToAll = __piExportScope === 'selected' && __piSelectedShapeIds.length === 0
70
+ const __piFocusedIds = __piExportScope === 'selected' && __piSelectedShapeIds.length > 0 ? __piSelectedShapeIds : __piAllShapeIds
71
+ if (__piFocusedIds.length === 0) throw new Error('No shapes to export.')
72
+ const __piInternalIds = __piFocusedIds.map(__piToTldrawId)
73
+
74
+ // The mcp-app exec sandbox disables window.setTimeout/setInterval/fetch/XMLHttpRequest
75
+ // while user code runs. tldraw's export pipeline (exportToSvg + getSvgAsImage) needs
76
+ // setTimeout (to unmount the react root on the next tick) and may need fetch (to embed
77
+ // fonts/images). Borrow working globals from a hidden same-origin about:blank iframe and
78
+ // install them on window only for the duration of the export call.
79
+ const __piSandboxFrame = document.createElement('iframe')
80
+ __piSandboxFrame.setAttribute('aria-hidden', 'true')
81
+ __piSandboxFrame.style.cssText = 'width:0;height:0;border:0;position:absolute;left:-9999px;'
82
+ document.body.appendChild(__piSandboxFrame)
83
+ const __piBorrowedWindow = __piSandboxFrame.contentWindow
84
+ const __piPriorSetTimeout = window.setTimeout
85
+ const __piPriorSetInterval = window.setInterval
86
+ const __piPriorFetch = window.fetch
87
+ const __piPriorXHR = window.XMLHttpRequest
88
+ try {
89
+ window.setTimeout = __piBorrowedWindow.setTimeout.bind(__piBorrowedWindow)
90
+ window.setInterval = __piBorrowedWindow.setInterval.bind(__piBorrowedWindow)
91
+ window.fetch = __piBorrowedWindow.fetch.bind(__piBorrowedWindow)
92
+ window.XMLHttpRequest = __piBorrowedWindow.XMLHttpRequest
93
+
94
+ // The focused editor proxy marks toImageDataUrl with ret:'this', so a direct call
95
+ // returns the proxy instead of the image result. Install a temp method on the real
96
+ // editor; the proxy invokes it via value.apply(target, args) with this === the real
97
+ // editor, so this.toImageDataUrl reaches the genuine implementation and its return
98
+ // value flows back untouched.
99
+ const __piMethodName = '__piExportImage_' + Math.random().toString(36).slice(2)
100
+ editor[__piMethodName] = async function(ids, opts) {
101
+ return await this.toImageDataUrl(ids, opts)
102
+ }
103
+ try {
104
+ const __piImage = await editor[__piMethodName](__piInternalIds, __piExportOptions)
105
+ return {
106
+ dataUrl: __piImage.url,
107
+ width: __piImage.width,
108
+ height: __piImage.height,
109
+ format: __piExportFormat,
110
+ scope: __piExportScope,
111
+ exportedShapeIds: __piFocusedIds,
112
+ selectedCount: __piSelectedShapeIds.length,
113
+ fallbackToAll: __piFallbackToAll,
114
+ }
115
+ } finally {
116
+ try { delete editor[__piMethodName] } catch {}
117
+ }
118
+ } finally {
119
+ window.setTimeout = __piPriorSetTimeout
120
+ window.setInterval = __piPriorSetInterval
121
+ window.fetch = __piPriorFetch
122
+ window.XMLHttpRequest = __piPriorXHR
123
+ __piSandboxFrame.remove()
124
+ }
125
+ `
126
+ }
127
+
128
+ export function parseCanvasExportPayload(value: unknown): CanvasExportPayload {
129
+ if (!value || typeof value !== 'object') throw new Error('Canvas export did not return an object.')
130
+ const record = value as Record<string, unknown>
131
+ if (typeof record.dataUrl !== 'string') throw new Error('Canvas export did not return a data URL.')
132
+ if (typeof record.width !== 'number' || typeof record.height !== 'number') {
133
+ throw new Error('Canvas export did not return image dimensions.')
134
+ }
135
+ return {
136
+ dataUrl: record.dataUrl,
137
+ width: record.width,
138
+ height: record.height,
139
+ format: normalizeCanvasExportFormat(typeof record.format === 'string' ? record.format : undefined),
140
+ scope: normalizeCanvasExportScope(typeof record.scope === 'string' ? record.scope : undefined),
141
+ exportedShapeIds: Array.isArray(record.exportedShapeIds)
142
+ ? record.exportedShapeIds.map(String)
143
+ : [],
144
+ selectedCount: typeof record.selectedCount === 'number' ? record.selectedCount : 0,
145
+ fallbackToAll: record.fallbackToAll === true,
146
+ }
147
+ }
148
+
149
+ export function parseDataUrl(dataUrl: string): ParsedDataUrl {
150
+ const match = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/s)
151
+ if (!match) throw new Error('Invalid image data URL returned by canvas export.')
152
+ const mimeType = match[1]
153
+ const isBase64 = Boolean(match[2])
154
+ const payload = match[3]
155
+ const data = isBase64 ? payload : Buffer.from(decodeURIComponent(payload), 'utf8').toString('base64')
156
+ return { mimeType, data, bytes: Buffer.byteLength(data, 'base64') }
157
+ }
158
+
159
+ export function formatBytes(bytes: number) {
160
+ if (bytes < 1024) return `${bytes} B`
161
+ const kib = bytes / 1024
162
+ if (kib < 1024) return `${kib.toFixed(1)} KiB`
163
+ return `${(kib / 1024).toFixed(1)} MiB`
164
+ }
@@ -0,0 +1,117 @@
1
+ import type { CanvasBundle } from '../semantic/layer'
2
+
3
+ export type CanvasStateBundle = {
4
+ shapes: unknown[]
5
+ assets?: unknown[]
6
+ bindings?: unknown[]
7
+ selectedIds?: unknown[]
8
+ }
9
+
10
+ export type SceneLens = 'all' | 'selected'
11
+
12
+ export const INITIALIZE_CANVAS_CODE = 'return { initialized: true }'
13
+
14
+ export const READ_CANVAS_STATE_CODE = `
15
+ const shapes = editor.getCurrentPageShapes()
16
+ const assets = typeof editor.getAssets === 'function' ? editor.getAssets() : []
17
+ let bindings = []
18
+ try {
19
+ for (const shape of shapes) {
20
+ const shapeId = shape.shapeId ?? shape.id
21
+ if (!shapeId || typeof editor.getBindingsFromShape !== 'function') continue
22
+ const shapeBindings = editor.getBindingsFromShape(shapeId, 'arrow') ?? []
23
+ bindings.push(...shapeBindings)
24
+ }
25
+ } catch {
26
+ bindings = []
27
+ }
28
+ let selectedIds = []
29
+ try { selectedIds = (editor.getSelectedShapeIds() || []).map((id) => String(id)) } catch { selectedIds = [] }
30
+ return { shapes, assets, bindings, selectedIds }
31
+ `
32
+
33
+ export function summarizeSnapshot(snapshot: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] }) {
34
+ const shapeCount = Array.isArray(snapshot.shapes) ? snapshot.shapes.length : 0
35
+ const assetCount = Array.isArray(snapshot.assets) ? snapshot.assets.length : 0
36
+ const bindingCount = Array.isArray(snapshot.bindings) ? snapshot.bindings.length : 0
37
+ return `${shapeCount} shape(s), ${assetCount} asset(s), ${bindingCount} binding(s)`
38
+ }
39
+
40
+ export const normalizeShapeId = (id: unknown): string | null =>
41
+ id == null ? null : String(id).replace(/^shape:/, '')
42
+
43
+ export function shapeIdOf(shape: unknown): string | null {
44
+ if (!shape || typeof shape !== 'object') return null
45
+ const record = shape as Record<string, unknown>
46
+ return normalizeShapeId(record.shapeId ?? record.id)
47
+ }
48
+
49
+ export function bindingTouchesSelection(binding: unknown, selectedIds: ReadonlySet<string>): boolean {
50
+ if (!binding || typeof binding !== 'object') return false
51
+ const record = binding as Record<string, unknown>
52
+ return [record.fromId, record.toId].some((id) => {
53
+ const normalized = normalizeShapeId(id)
54
+ return normalized != null && selectedIds.has(normalized)
55
+ })
56
+ }
57
+
58
+ export function applySceneLens(bundle: CanvasStateBundle, lens: SceneLens): CanvasStateBundle {
59
+ if (lens === 'all') return bundle
60
+
61
+ const selectedIds = selectedIdSet(bundle.selectedIds)
62
+ return {
63
+ ...bundle,
64
+ shapes: bundle.shapes.filter((shape) => {
65
+ const id = shapeIdOf(shape)
66
+ return id != null && selectedIds.has(id)
67
+ }),
68
+ bindings: Array.isArray(bundle.bindings)
69
+ ? bundle.bindings.filter((binding) => bindingTouchesSelection(binding, selectedIds))
70
+ : bundle.bindings,
71
+ selectedIds: [...selectedIds],
72
+ }
73
+ }
74
+
75
+ export const selectedIdSet = (selectedIds: unknown): ReadonlySet<string> =>
76
+ new Set(
77
+ (Array.isArray(selectedIds) ? selectedIds : [])
78
+ .map((id) => normalizeShapeId(id))
79
+ .filter((id): id is string => id != null)
80
+ )
81
+
82
+ export function asCanvasBundle(bundle: CanvasStateBundle): CanvasBundle {
83
+ return {
84
+ shapes: bundle.shapes as CanvasBundle['shapes'],
85
+ assets: bundle.assets,
86
+ bindings: bundle.bindings,
87
+ }
88
+ }
89
+
90
+ export function toRawCanvasState(bundle: CanvasStateBundle) {
91
+ return {
92
+ shapes: bundle.shapes,
93
+ assets: bundle.assets ?? [],
94
+ bindings: bundle.bindings ?? [],
95
+ }
96
+ }
97
+
98
+ export function restoreCanvasCode(snapshot: { shapes?: unknown[] }) {
99
+ const shapes = Array.isArray(snapshot.shapes) ? snapshot.shapes : []
100
+ return `
101
+ const snapshotShapes = ${JSON.stringify(shapes)}
102
+ const currentShapes = editor.getCurrentPageShapes()
103
+ const currentIds = currentShapes.map((shape) => shape.shapeId ?? shape.id).filter(Boolean)
104
+ if (currentIds.length) editor.deleteShapes(currentIds)
105
+ const nonArrows = snapshotShapes.filter((shape) => (shape._type ?? shape.type) !== 'arrow')
106
+ const arrows = snapshotShapes.filter((shape) => (shape._type ?? shape.type) === 'arrow')
107
+ for (const shape of nonArrows) editor.createShape(shape)
108
+ for (const shape of arrows) editor.createShape(shape)
109
+ const restoredIds = snapshotShapes.map((shape) => shape.shapeId ?? shape.id).filter(Boolean)
110
+ if (restoredIds.length) {
111
+ editor.select(...restoredIds)
112
+ editor.zoomToSelection()
113
+ editor.selectNone()
114
+ }
115
+ return { restored: snapshotShapes.length, shapeIds: restoredIds }
116
+ `
117
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ INITIALIZE_CANVAS_CODE,
3
+ READ_CANVAS_STATE_CODE,
4
+ restoreCanvasCode,
5
+ summarizeSnapshot,
6
+ type CanvasStateBundle,
7
+ } from './state'
8
+ import { extractReturnValue, extractTextContent, parseCanvasIdFromText, type McpToolResult } from '../mcp/response'
9
+ import type { StoredCanvasSnapshot } from '../store/project-store'
10
+
11
+ export type CanvasHostPort = {
12
+ open(signal?: AbortSignal): Promise<{ url: string; spawned: boolean }>
13
+ execOnCanvas(
14
+ input: { code: string; canvasId?: string; timeoutMs?: number },
15
+ signal?: AbortSignal
16
+ ): Promise<unknown>
17
+ getStatus(): { url: string | null; browserConnected: boolean }
18
+ }
19
+
20
+ export type CanvasWorkflowDeps = {
21
+ canvasHost: CanvasHostPort
22
+ loadCanvasSnapshot(cwd: string, canvasId?: string): Promise<StoredCanvasSnapshot | null>
23
+ saveCanvasSnapshot(
24
+ cwd: string,
25
+ canvasId: string,
26
+ state: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] },
27
+ opts?: { allowEmptyOverwrite?: boolean }
28
+ ): Promise<StoredCanvasSnapshot>
29
+ resolveCanvasId(cwd: string, explicitCanvasId?: string): Promise<string | undefined>
30
+ rememberCanvasId(cwd: string, canvasId: string): Promise<void>
31
+ createProjectCanvasId(): string
32
+ }
33
+
34
+ export function createCanvasWorkflows(deps: CanvasWorkflowDeps) {
35
+ return {
36
+ async ensureBrowserAndRestore(
37
+ cwd: string,
38
+ canvasId: string | undefined,
39
+ signal?: AbortSignal,
40
+ opts: { restore?: boolean } = {}
41
+ ) {
42
+ const { url, spawned } = await deps.canvasHost.open(signal)
43
+ let resolvedId = await deps.resolveCanvasId(cwd, canvasId)
44
+ let restoreText = spawned
45
+ ? 'No project canvas restored.'
46
+ : 'Browser already open; live canvas unchanged.'
47
+
48
+ if (spawned && opts.restore !== false) {
49
+ if (!resolvedId) resolvedId = deps.createProjectCanvasId()
50
+ const snapshot = await deps.loadCanvasSnapshot(cwd, resolvedId)
51
+ if (snapshot) {
52
+ await deps.canvasHost.execOnCanvas(
53
+ { code: restoreCanvasCode(snapshot), canvasId: snapshot.canvasId },
54
+ signal
55
+ )
56
+ await deps.rememberCanvasId(cwd, snapshot.canvasId)
57
+ restoreText = `Restored project canvas ${snapshot.canvasId} (${summarizeSnapshot(snapshot)}). Autosave is on.`
58
+ } else {
59
+ await deps.canvasHost.execOnCanvas(
60
+ { code: INITIALIZE_CANVAS_CODE, canvasId: resolvedId },
61
+ signal
62
+ )
63
+ await deps.rememberCanvasId(cwd, resolvedId)
64
+ restoreText = `Started new project canvas ${resolvedId}. Autosave is on.`
65
+ }
66
+ }
67
+
68
+ return { url, spawned, resolvedId, restoreText }
69
+ },
70
+
71
+ async snapshotLiveCanvas(
72
+ cwd: string,
73
+ canvasId: string | undefined,
74
+ signal?: AbortSignal,
75
+ opts?: { allowEmptyOverwrite?: boolean }
76
+ ) {
77
+ const hostStatus = deps.canvasHost.getStatus()
78
+ if (!hostStatus.browserConnected) {
79
+ throw new Error(
80
+ `No live browser canvas is connected. Open the canvas tab with /tldraw open, wait for "Canvas ready", then save again. Host: ${hostStatus.url ?? 'not started'}`
81
+ )
82
+ }
83
+ const result = (await deps.canvasHost.execOnCanvas(
84
+ { code: READ_CANVAS_STATE_CODE, canvasId, timeoutMs: 60_000 },
85
+ signal
86
+ )) as McpToolResult
87
+ const text = extractTextContent(result)
88
+ const returnedCanvasId = parseCanvasIdFromText(text) ?? canvasId
89
+ if (!returnedCanvasId) throw new Error('Could not determine canvasId from live canvas read.')
90
+ const state = (result.structuredContent ?? extractReturnValue(result) ?? {}) as Partial<CanvasStateBundle>
91
+ const saved = await deps.saveCanvasSnapshot(
92
+ cwd,
93
+ returnedCanvasId,
94
+ {
95
+ shapes: Array.isArray(state.shapes) ? state.shapes : [],
96
+ assets: Array.isArray(state.assets) ? state.assets : [],
97
+ bindings: Array.isArray(state.bindings) ? state.bindings : [],
98
+ },
99
+ { allowEmptyOverwrite: opts?.allowEmptyOverwrite }
100
+ )
101
+ await deps.rememberCanvasId(cwd, returnedCanvasId)
102
+ return saved
103
+ },
104
+ }
105
+ }
@@ -0,0 +1,48 @@
1
+ const TLDRAW_COMMAND_TYPES = [
2
+ 'reset',
3
+ 'current',
4
+ 'canvases',
5
+ 'save',
6
+ 'start',
7
+ 'restart',
8
+ 'tools',
9
+ 'open',
10
+ 'host',
11
+ 'resource',
12
+ 'status',
13
+ ] as const
14
+
15
+ export type TldrawCommandType = (typeof TLDRAW_COMMAND_TYPES)[number]
16
+
17
+ export type ParsedTldrawCommand = {
18
+ type: TldrawCommandType
19
+ force: boolean
20
+ canvasId?: string
21
+ requestedAction?: string
22
+ }
23
+
24
+ const COMMANDS: ReadonlySet<string> = new Set(TLDRAW_COMMAND_TYPES)
25
+
26
+ export function parseTldrawCommand(args: string): ParsedTldrawCommand {
27
+ const parts = args.trim().split(/\s+/).filter(Boolean)
28
+ const rawAction = parts[0] ?? 'status'
29
+ const force = rawAction.endsWith('!') || parts.includes('--force')
30
+ const normalizedAction = rawAction.endsWith('!') ? rawAction.slice(0, -1) : rawAction
31
+ const canvasId = parts.slice(1).find((part) => !part.startsWith('--'))
32
+
33
+ return isTldrawCommandType(normalizedAction)
34
+ ? withOptionalCanvasId({ type: normalizedAction, force }, canvasId)
35
+ : withOptionalCanvasId(
36
+ { type: 'status', force, requestedAction: normalizedAction },
37
+ canvasId
38
+ )
39
+ }
40
+
41
+ const isTldrawCommandType = (value: string): value is TldrawCommandType => COMMANDS.has(value)
42
+
43
+ function withOptionalCanvasId<T extends Omit<ParsedTldrawCommand, 'canvasId'>>(
44
+ command: T,
45
+ canvasId: string | undefined
46
+ ): T | (T & { canvasId: string }) {
47
+ return canvasId ? { ...command, canvasId } : command
48
+ }
@@ -0,0 +1,44 @@
1
+ export interface DiagramGuidanceOptions {
2
+ focus?: string
3
+ }
4
+
5
+ export interface DiagramGuidance {
6
+ focus?: string
7
+ text: string
8
+ }
9
+
10
+ export function normalizeFocus(focus?: string) {
11
+ const value = focus?.trim()
12
+ return value ? value : undefined
13
+ }
14
+
15
+ export function buildDiagramGuidance(options: DiagramGuidanceOptions = {}): DiagramGuidance {
16
+ const focus = normalizeFocus(options.focus)
17
+ const lines = ['# tldraw drawing guidance', '']
18
+
19
+ if (focus) lines.push(`Focus: ${focus}`, '')
20
+
21
+ lines.push(
22
+ '## One rule',
23
+ '',
24
+ 'Draw for legibility: make space do the explanatory work.',
25
+ '',
26
+ '## Ideas while drawing',
27
+ '',
28
+ '- Begin with the relationship the viewer should understand, then place shapes so that relationship is visible before any label is read.',
29
+ '- Use negative space deliberately. Leave breathing room around shapes, groups, arrow paths, and labels.',
30
+ '- Prefer simple alignment over clever geometry. Order and rhythm make diagrams easier to scan.',
31
+ '- Let grouping emerge from spacing and containment, not from crowding.',
32
+ '- Route connections through open space. If a connector competes with another mark, move the shapes rather than forcing the line.',
33
+ '- Keep labels short and readable. Put explanatory text where it does not interrupt the drawing.',
34
+ '- Use visual weight sparingly. Color, fill, and shape should clarify attention, not decorate every element.',
35
+ '',
36
+ '## Finish by looking, not explaining',
37
+ '',
38
+ '- Step back and inspect the canvas as a reader.',
39
+ '- Ask what feels cramped, ambiguous, or noisy.',
40
+ '- Add space, simplify marks, or reposition elements until the relationships can breathe.'
41
+ )
42
+
43
+ return { focus, text: lines.join('\n') }
44
+ }