diff --git a/bun.lock b/bun.lock index 22fc200..3a7e4c4 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "devalue": "^5.6.4", + "elkjs": "^0.11.1", "env-paths": "^4.0.0", "fontkit": "^2.0.4", "frimousse": "^0.3.0", @@ -1114,6 +1115,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], diff --git a/docs/moi-scratchpad.md b/docs/moi-scratchpad.md index f10d143..9c89986 100644 --- a/docs/moi-scratchpad.md +++ b/docs/moi-scratchpad.md @@ -38,11 +38,63 @@ The agent works the canvas through a `moi scratch` CLI — it both **sees** and `read` is for logic; `view` / `read-image` are for vision. -### Drawing +### Drawing diagrams -The agent doesn't emit raw tldraw records. `moi scratch` exposes a small **primitive layer** -that maps onto tldraw's own shape API — friendly to drive and stable against tldraw's -internal schema: +For anything boxes-and-arrows shaped — architectures, flows, pipelines, how-X-works — the +agent shouldn't place shapes by hand. `moi scratch diagram` takes a **declarative JSON +spec** (nodes, groups, labeled edges, a title) and compiles it onto the canvas: every label +is measured with the actual canvas font, nodes are sized to fit, and positions come from +ELK's layered auto-layout. Geometry is non-overlapping, aligned, and evenly spaced by +construction — no coordinates in the spec at all. + +``` +moi scratch diagram [--spec file.json] [--at x,y] [--id prefix] +``` + +No `--spec` reads the spec from stdin (pipe a heredoc). `--at` pins the diagram's top-left +corner; omitted, it auto-places below whatever is already on the canvas. `--id` sets the +prefix of the created shape ids — every shape lands as `-`, +`-title`, `-edge-` (printed on success), so the agent can `move`, `set`, +or `delete` the pieces afterwards. + +The spec: + +```jsonc +{ + "title": "How to expose a Tailscale service", // optional; big text above the diagram + "direction": "right", // main flow: "right" (default) or "down" + "nodes": [ + // shape: "rect" (default, sized to fit its label) | "note" (sticky, fixed 200×200); + // color/fill take the same values as the CLI flags; width = optional wrap-width hint + { "id": "browser", "label": "Browser (internet)", "color": "blue" } + ], + "groups": [ + // a container rect drawn around its children (nodes or nested groups) + { + "id": "tailnet", + "label": "Tailscale network (private)", + "color": "grey", + "children": ["svc"] + } + ], + "edges": [ + // arrows bound to their endpoint shapes; "elbow": true for right-angle routing + { "from": "browser", "to": "proxy", "label": "https://app.yourdomain.com", "color": "blue" } + ] +} +``` + +Edges are real bound arrows (they follow their shapes when moved later), groups render as +outline rects with the label at the top, and the whole diagram goes in as one batch — +either all of it lands or none. Spec mistakes (unknown edge endpoint, duplicate id, bad +color) come back as descriptive errors naming the offending entry. + +### Drawing primitives + +For **annotations and one-off marks** — a note next to the user's sketch, a single arrow, a +label — the agent uses the primitive layer directly. It doesn't emit raw tldraw records: +`moi scratch` exposes a small set that maps onto tldraw's own shape API — friendly to drive +and stable against tldraw's internal schema: ``` moi scratch add text --at --text "..." [--id NAME] [--color C] [--font-size S] @@ -80,8 +132,8 @@ moi scratch clear # wipe the whole canvas The set is deliberately small — text, rect, note, arrow (with color plus each shape's fill/font-size/stroke), plus -move/set/delete/clear. Enough to lay out a diagram or annotate the user's drawing; not a full -tldraw API. +move/set/delete/clear. Enough to annotate the user's drawing or drop a one-off shape; for +laying out a whole diagram, `moi scratch diagram` does the geometry instead. ## How it works @@ -104,6 +156,9 @@ on the server (the mutations). Only `view` — rendering pixels — genuinely re `add image` additionally resizes the file through `sharp` (the same dep the icon pipeline uses) before embedding it. (We drive the store, not an `Editor`, because the Editor needs a DOM + text measurement the server runtime doesn't have. See `server/scratchpad-executor.ts`.) + `diagram` runs through the same path with two extra stages up front: labels are measured with + the real canvas font (`server/scratchpad-metrics.ts`), then `elkjs` computes the layout in a + worker before the records are built (`server/scratchpad-diagram.ts`). - **Viewing** (`moi scratch view`) is the one op still relayed to a connected tab: only the browser can rasterize the canvas (`editor.toImageDataUrl`). With no tab showing **this** workspace's scratchpad it returns "No live canvas" — every other op still works off disk. diff --git a/lib/types.ts b/lib/types.ts index 77f9df8..eada9b6 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -65,6 +65,50 @@ export type ScratchStyle = { color?: ScratchColor; size?: ScratchSize; fill?: Sc // the canvas light), 'hi' allows more pixels when detail matters. export type ScratchImageQuality = 'lo' | 'hi' +// A declarative boxes-and-arrows diagram, compiled server-side by `moi scratch +// diagram`: labels are measured, nodes sized to fit, and positions computed by +// ELK auto-layout — the agent declares structure and never touches a coordinate. +// `color`/`fill` carry the CLI's user-facing vocabulary (a palette name or hex; +// none|semi|pattern|solid) as raw strings: the spec is agent input, so parsing +// and validation happen in one place server-side (server/scratchpad-diagram.ts) +// and errors travel back over the control socket. +export type ScratchDiagramNode = { + id: string + label: string + // 'rect' (default): a geo rectangle sized to fit its label. 'note': a sticky + // note — fixed 200×200, so keep note labels short. + shape?: 'rect' | 'note' + color?: string + fill?: string + // Preferred label wrap width in px (default 260). A hint: the rect can come + // out wider for an unbreakable word, or narrower for a short label. + width?: number +} +export type ScratchDiagramGroup = { + id: string + label?: string + color?: string + // Ids of the nodes (or nested groups) drawn inside this container. + children: string[] +} +export type ScratchDiagramEdge = { + from: string + to: string + label?: string + color?: string + // Right-angle routing, like `add arrow --elbow`; default is a curved arc. + elbow?: boolean +} +export type ScratchDiagramSpec = { + // Big heading placed above the diagram bounds. + title?: string + // Main flow direction: 'right' (default) lays layers left→right, 'down' top→bottom. + direction?: 'right' | 'down' + nodes: ScratchDiagramNode[] + groups?: ScratchDiagramGroup[] + edges?: ScratchDiagramEdge[] +} + export type ScratchOp = | ({ kind: 'add-text'; name: string; x: number; y: number; text: string } & ScratchStyle) | ({ @@ -96,15 +140,25 @@ export type ScratchOp = // Right-angle (orthogonal) routing for clean diagrams; default is a curved arc. elbow?: boolean } & ScratchStyle) + // Compile a declarative diagram spec into laid-out shapes (see + // ScratchDiagramSpec). `name` prefixes every created shape id — + // `-`, `-title`, `-edge-` — so the agent can + // address the pieces afterwards. Omitted x/y auto-places the diagram below + // the existing canvas content. + | { kind: 'diagram'; name: string; spec: ScratchDiagramSpec; x?: number; y?: number } | { kind: 'move'; name: string; x: number; y: number } | { kind: 'set'; name: string; text: string } | { kind: 'delete'; name: string } | { kind: 'clear' } | { kind: 'view' } -// What a tab returns after running an op: a shape's `name` for add ops, a PNG -// data URL for `view`, or a bare ack for mutations. -export type ScratchOpResult = { name: string } | { image: string } | { ok: true } +// What a tab returns after running an op: a shape's `name` for add ops (plus +// the full list of `created` shape ids for `diagram`), a PNG data URL for +// `view`, or a bare ack for mutations. +export type ScratchOpResult = + | { name: string; created?: string[] } + | { image: string } + | { ok: true } // An attachment uploaded ahead of a chat message. The bytes live server-side in // an in-memory upload store (see server/uploads.ts); a chat frame references it diff --git a/package.json b/package.json index 0cdfebb..22856ff 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "devalue": "^5.6.4", + "elkjs": "^0.11.1", "env-paths": "^4.0.0", "fontkit": "^2.0.4", "frimousse": "^0.3.0", diff --git a/server/cli.ts b/server/cli.ts index 76230b9..76c11cc 100755 --- a/server/cli.ts +++ b/server/cli.ts @@ -10,11 +10,9 @@ import { COLOR_THEMES, FONT_THEMES } from '@/lib/themes' import type { ColorTheme, FontTheme } from '@/lib/themes' import type { ScratchArrowEnd, - ScratchColor, - ScratchFill, + ScratchDiagramSpec, ScratchImageQuality, ScratchOp, - ScratchSize, ScratchStyle } from '@/lib/types' @@ -27,6 +25,16 @@ import { } from './cli-env' import { columns } from './cli-ui' import { CONTROL_PORT, PORT } from './constants' +import { + COLOR_NAMES, + FILL_NAMES, + FONT_SIZE_NAMES, + STROKE_NAMES, + parseColor, + parseFill, + parseFontSize, + parseStroke +} from './scratchpad-style' import { type OpenClawAgent, discoverOpenClawAgents } from './openclaw' import { liftToWorkspaceRoot, registerWorkspace } from './registry' import { serverCwd } from './server-cwd' @@ -1109,87 +1117,8 @@ function parseEnd(s: string): ScratchArrowEnd { return { name: s } } -// The Scratchpad palette (matches the UI toolbar's six swatches) and each color's -// light-theme solid hex — used to snap an arbitrary `--color #rrggbb` to the nearest -// palette entry (tldraw shapes can't hold free hex). Keep in sync with the swatches -// in client/components/Scratchpad.tsx. -const COLOR_HEX: Record = { - black: '#1d1d1d', - red: '#e03131', - yellow: '#f1ac4b', - green: '#099268', - blue: '#4465e9', - grey: '#9fa8b2' -} -const COLOR_NAMES = Object.keys(COLOR_HEX) as ScratchColor[] - -// Arrows expose tldraw's size as a line weight; the CLI mirrors the UI's two sizes. -const STROKE_SIZES: Record = { small: 'm', large: 'xl' } -const STROKE_NAMES = Object.keys(STROKE_SIZES) - -// Text & notes expose the same size style as a label font size, under friendlier names. -const FONT_SIZES: Record = { regular: 'm', big: 'xl' } -const FONT_SIZE_NAMES = Object.keys(FONT_SIZES) - -// Rectangle fills — the UI toolbar's four options. Each user-facing name maps onto a -// tldraw DefaultFillStyle value (see ScratchFill for tldraw's semi/solid quirk). Keep -// in sync with FILL_OPTIONS in client/components/Scratchpad.tsx. -const FILL_STYLES: Record = { - none: 'none', - semi: 'solid', - pattern: 'pattern', - solid: 'fill' -} -const FILL_NAMES = Object.keys(FILL_STYLES) - -function hexToRgb(hex: string): [number, number, number] | null { - let h = hex.trim().replace(/^#/, '') - if (h.length === 3) h = h.replace(/(.)/g, '$1$1') - if (!/^[0-9a-fA-F]{6}$/.test(h)) return null - return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] -} - -// Accept a palette name as-is, or snap any hex to the nearest palette color by -// squared RGB distance. Throws on anything else. -function parseColor(s: string): ScratchColor { - const lower = s.trim().toLowerCase() - if ((COLOR_NAMES as string[]).includes(lower)) return lower as ScratchColor - const rgb = hexToRgb(s) - if (!rgb) { - throw new Error( - `Unknown color "${s}". Use a hex like "#4465e9" or one of: ${COLOR_NAMES.join(', ')}.` - ) - } - let best: ScratchColor = 'black' - let bestDist = Infinity - for (const name of COLOR_NAMES) { - const [r, g, b] = hexToRgb(COLOR_HEX[name])! - const d = (r - rgb[0]) ** 2 + (g - rgb[1]) ** 2 + (b - rgb[2]) ** 2 - if (d < bestDist) { - bestDist = d - best = name - } - } - return best -} - -function parseStroke(s: string): ScratchSize { - const size = STROKE_SIZES[s.trim().toLowerCase()] - if (!size) throw new Error(`Unknown stroke "${s}". Use one of: ${STROKE_NAMES.join(', ')}.`) - return size -} - -function parseFontSize(s: string): ScratchSize { - const size = FONT_SIZES[s.trim().toLowerCase()] - if (!size) throw new Error(`Unknown font size "${s}". Use one of: ${FONT_SIZE_NAMES.join(', ')}.`) - return size -} - -function parseFill(s: string): ScratchFill { - const fill = FILL_STYLES[s.trim().toLowerCase()] - if (!fill) throw new Error(`Unknown fill "${s}". Use one of: ${FILL_NAMES.join(', ')}.`) - return fill -} +// The palette / fill / size vocabulary is shared with the `diagram` spec +// compiler, so it lives in scratchpad-style.ts (imported at the top of this file). // Resize preset for `add image` — defaults to 'lo' (keep the canvas light). function parseImageQuality(s: string | undefined): ScratchImageQuality { @@ -1519,6 +1448,65 @@ const scratchAdd = defineCommand({ } }) +// The declarative diagram compiler: a JSON spec in (file or stdin), measured + +// auto-laid-out shapes on the canvas out. See server/scratchpad-diagram.ts. +const scratchDiagram = defineCommand({ + meta: { + name: 'diagram', + description: + 'Compile a declarative diagram (JSON spec) onto the canvas with measured labels and ELK auto-layout — no coordinates needed. ' + + 'Spec: {"title":"...", "direction":"right|down", ' + + '"nodes":[{"id":"a","label":"...","shape":"rect|note","color":"blue","fill":"semi","width":260}], ' + + '"groups":[{"id":"g","label":"...","color":"grey","children":["a"]}], ' + + '"edges":[{"from":"a","to":"b","label":"...","elbow":true}]}' + }, + args: { + spec: { type: 'string', description: 'Path to the JSON spec file (default: read stdin)' }, + at: { + type: 'string', + description: 'Top-left anchor "x,y" (default: auto-place below existing content)' + }, + id: { + type: 'string', + description: 'Id prefix for created shapes (-, -title, ...)' + }, + dir: dirArg + }, + async run({ args }) { + const raw = args.spec ? await Bun.file(resolve(args.spec)).text() : await Bun.stdin.text() + let spec: unknown + try { + spec = JSON.parse(raw) + } catch (err) { + const source = args.spec ? args.spec : 'stdin' + console.error( + '\n' + + pc.red('✗') + + ` The spec (${source}) is not valid JSON: ${err instanceof Error ? err.message : String(err)}\n` + ) + process.exit(1) + } + const at = args.at ? parseXY(args.at) : undefined + sendScratch( + resolve(args.dir), + { + kind: 'diagram', + name: args.id ?? '', + // Validated server-side (compileDiagramSpec) so errors surface in one + // place; the CLI only guarantees it's JSON. + spec: spec as ScratchDiagramSpec, + ...(at ? { x: at.x, y: at.y } : {}) + }, + res => { + const result = res.result as { name?: string; created?: string[] } | undefined + console.log('\n' + pc.green('✓') + ' diagram ' + pc.bold(result?.name ?? '(diagram)')) + for (const id of result?.created ?? []) console.log(' ' + id) + console.log('') + } + ) + } +}) + const scratchMove = defineCommand({ meta: { name: 'move', description: 'Move a shape to a new position' }, args: { @@ -1581,6 +1569,7 @@ const scratch = defineCommand({ 'read-image': scratchReadImage, view: scratchView, add: scratchAdd, + diagram: scratchDiagram, move: scratchMove, set: scratchSet, delete: scratchDelete, diff --git a/server/control.ts b/server/control.ts index de35949..592ebcb 100644 --- a/server/control.ts +++ b/server/control.ts @@ -228,9 +228,11 @@ export const control = Bun.serve({ } // Assign add ops a stable name when the caller didn't (`--id`), so the - // derived tldraw shape id is deterministic and addressable later. - if (op.kind.startsWith('add-') && !op.name) { - op.name = `s_${crypto.randomUUID().slice(0, 8)}` + // derived tldraw shape id is deterministic and addressable later. A + // diagram's name is the prefix of every shape it creates. + if ((op.kind.startsWith('add-') || op.kind === 'diagram') && !op.name) { + const prefix = op.kind === 'diagram' ? 'd' : 's' + op.name = `${prefix}_${crypto.randomUUID().slice(0, 8)}` } try { diff --git a/server/scratchpad-diagram.ts b/server/scratchpad-diagram.ts new file mode 100644 index 0000000..d807de9 --- /dev/null +++ b/server/scratchpad-diagram.ts @@ -0,0 +1,652 @@ +import { pathToFileURL } from 'node:url' + +import ELK from 'elkjs/lib/elk-api.js' +import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk-api.js' +import { type TLStore, createShapeId, getIndexAbove, toRichText } from 'tldraw' +import type { TLRecord } from 'tldraw' + +import type { ScratchColor, ScratchFill, ScratchOp, ScratchOpResult } from '@/lib/types' + +import { + ARROW_LABEL_FONT_SIZES, + LABEL_FONT_SIZES, + LABEL_PADDING, + LINE_HEIGHT, + TEXT_FONT_SIZES, + fitRectToLabel, + textBlockSize +} from './scratchpad-metrics' +import { + arrowBinding, + defaultProps, + firstPageId, + nextIndex, + shapeRecord +} from './scratchpad-records' +import { parseColor, parseFill } from './scratchpad-style' + +// The declarative diagram compiler behind `moi scratch diagram`. The agent +// declares structure — nodes, groups, labeled edges, a title — and never touches +// a coordinate: labels are measured with the real canvas font +// (scratchpad-metrics.ts), nodes sized to fit, and positions computed by ELK's +// layered algorithm (the engine behind D2 and Mermaid's ELK mode). Geometry is +// non-overlapping, aligned, and evenly spaced by construction. + +// ---- geometry constants ------------------------------------------------------- + +// Node sizing: wrap labels at ~260px (a comfortable diagram-box width) and never +// go smaller than a clickable box. +const NODE_TARGET_WIDTH = 260 +const NODE_MIN_W = 120 +const NODE_MIN_H = 64 +// tldraw note shapes are a fixed 200×200 (no w/h props) — mirrored here for layout. +const NOTE_SIZE = 200 +// Gap between the title's baseline block and the diagram's top edge. +const TITLE_GAP = 48 +// Auto-placement: gap below the existing canvas content. +const AUTO_PLACE_GAP = 96 +// Room reserved inside a group's top edge so its label doesn't sit on children. +const GROUP_PADDING = '[top=56,left=24,bottom=24,right=24]' + +// ---- ELK ---------------------------------------------------------------------- + +// elkjs's default entry (`elkjs`/`lib/main.js`) breaks under Bun: Bun defines +// `self`, so the bundled fake-worker shim misdetects its environment. The +// real-worker API sidesteps that — layout runs in an actual Worker. The instance +// is a lazy module singleton; `unref` keeps the worker from pinning a +// short-lived process (tests, one-shot CLI paths) open. +type ElkInstance = InstanceType +let elkSingleton: ElkInstance | undefined +function elk(): ElkInstance { + if (!elkSingleton) { + const workerPath = Bun.resolveSync('elkjs/lib/elk-worker.min.js', import.meta.dir) + elkSingleton = new ELK({ + workerFactory: () => { + const worker = new Worker(pathToFileURL(workerPath).href) + worker.unref() + return worker + } + }) + } + return elkSingleton +} + +// ---- spec validation ---------------------------------------------------------- + +// The compiled (validated + measured) spec: colors/fills parsed onto their +// tldraw values, every node sized. +type CompiledNode = { + id: string + label: string + shape: 'rect' | 'note' + color?: ScratchColor + fill?: ScratchFill + w: number + h: number +} +type CompiledGroup = { id: string; label?: string; color?: ScratchColor; children: string[] } +type CompiledEdge = { + from: string + to: string + label?: string + color?: ScratchColor + elbow: boolean +} +type CompiledSpec = { + title?: string + direction: 'right' | 'down' + nodes: CompiledNode[] + groups: CompiledGroup[] + edges: CompiledEdge[] +} + +// Shape ids double as CLI handles (`moi scratch move ...`), so keep them +// flag-safe and unambiguous. +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/ + +function fail(message: string): never { + throw new Error(`Invalid diagram spec: ${message}`) +} + +function isRecord(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +// Reject unknown keys so a typo ("colour", "text" instead of "label") surfaces +// as an error instead of being silently dropped. +function checkKeys(obj: Record, allowed: string[], where: string) { + for (const key of Object.keys(obj)) { + if (!allowed.includes(key)) { + fail(`unknown key "${key}" in ${where}. Allowed keys: ${allowed.join(', ')}.`) + } + } +} + +function requireId(v: unknown, where: string): string { + if (typeof v !== 'string' || v.length === 0) fail(`${where} needs a non-empty string "id"`) + if (!ID_RE.test(v)) { + fail(`${where} id "${v}" is invalid — use letters, digits, "_" or "-" (it becomes a shape id)`) + } + // These ids are minted by the compiler itself under the same prefix. + if (v === 'title' || /^edge-\d+$/.test(v)) { + fail(`${where} id "${v}" is reserved for the diagram's own title/edge shapes — pick another id`) + } + return v +} + +// Parse a color/fill through the shared CLI vocabulary, prefixing errors with +// where in the spec they happened. +function parseColorAt(v: unknown, where: string): ScratchColor { + if (typeof v !== 'string') fail(`${where}: "color" must be a string (palette name or hex)`) + try { + return parseColor(v) + } catch (err) { + fail(`${where}: ${err instanceof Error ? err.message : String(err)}`) + } +} + +function parseFillAt(v: unknown, where: string): ScratchFill { + if (typeof v !== 'string') fail(`${where}: "fill" must be a string`) + try { + return parseFill(v) + } catch (err) { + fail(`${where}: ${err instanceof Error ? err.message : String(err)}`) + } +} + +// Validate the raw (JSON-parsed) spec and compile it: parse colors/fills, size +// every node to its measured label. Throws descriptive, actionable errors — +// they travel back to the CLI over the control socket. +export function compileDiagramSpec(raw: unknown): CompiledSpec { + if (!isRecord(raw)) fail('the spec must be a JSON object like {"nodes": [...], "edges": [...]}') + checkKeys(raw, ['title', 'direction', 'nodes', 'groups', 'edges'], 'the spec') + + let title: string | undefined + if (raw.title !== undefined) { + if (typeof raw.title !== 'string' || raw.title.length === 0) { + fail('"title" must be a non-empty string') + } + title = raw.title + } + + let direction: 'right' | 'down' = 'right' + if (raw.direction !== undefined) { + if (raw.direction !== 'right' && raw.direction !== 'down') { + fail(`"direction" must be "right" or "down", got ${JSON.stringify(raw.direction)}`) + } + direction = raw.direction + } + + if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) { + fail('"nodes" must be a non-empty array — a diagram needs at least one node') + } + + const ids = new Set() + const claimId = (id: string, where: string) => { + if (ids.has(id)) + fail(`duplicate id "${id}" (${where}) — node and group ids share one namespace`) + ids.add(id) + } + + const nodes: CompiledNode[] = raw.nodes.map((n, i) => { + const where = `nodes[${i}]` + if (!isRecord(n)) fail(`${where} must be an object`) + checkKeys(n, ['id', 'label', 'shape', 'color', 'fill', 'width'], where) + const id = requireId(n.id, where) + claimId(id, where) + if (typeof n.label !== 'string' || n.label.length === 0) { + fail(`${where} ("${id}") needs a non-empty string "label"`) + } + const shape = n.shape ?? 'rect' + if (shape !== 'rect' && shape !== 'note') { + fail(`${where} ("${id}"): "shape" must be "rect" or "note", got ${JSON.stringify(n.shape)}`) + } + if (shape === 'note' && n.fill !== undefined) { + fail(`${where} ("${id}"): "fill" doesn't apply to note-shaped nodes`) + } + let width: number | undefined + if (n.width !== undefined) { + if (typeof n.width !== 'number' || !Number.isFinite(n.width) || n.width < 40) { + fail(`${where} ("${id}"): "width" must be a number ≥ 40 (a label wrap-width hint in px)`) + } + width = n.width + } + const size = + shape === 'note' + ? { w: NOTE_SIZE, h: NOTE_SIZE } + : fitRectToLabel(n.label, { + size: 'm', + targetWidth: width ?? NODE_TARGET_WIDTH, + minW: NODE_MIN_W, + minH: NODE_MIN_H + }) + return { + id, + label: n.label, + shape, + ...(n.color !== undefined ? { color: parseColorAt(n.color, `${where} ("${id}")`) } : {}), + ...(n.fill !== undefined ? { fill: parseFillAt(n.fill, `${where} ("${id}")`) } : {}), + ...size + } + }) + + const rawGroups = raw.groups === undefined ? [] : raw.groups + if (!Array.isArray(rawGroups)) fail('"groups" must be an array') + const groups: CompiledGroup[] = rawGroups.map((g, i) => { + const where = `groups[${i}]` + if (!isRecord(g)) fail(`${where} must be an object`) + checkKeys(g, ['id', 'label', 'color', 'children'], where) + const id = requireId(g.id, where) + claimId(id, where) + if (g.label !== undefined && (typeof g.label !== 'string' || g.label.length === 0)) { + fail(`${where} ("${id}"): "label" must be a non-empty string when given`) + } + if (!Array.isArray(g.children) || g.children.length === 0) { + fail(`${where} ("${id}") needs a non-empty "children" array of node/group ids`) + } + const children = g.children.map(c => { + if (typeof c !== 'string') fail(`${where} ("${id}"): children must be strings (ids)`) + return c + }) + return { + id, + ...(g.label !== undefined ? { label: g.label } : {}), + ...(g.color !== undefined ? { color: parseColorAt(g.color, `${where} ("${id}")`) } : {}), + children + } + }) + + // Group membership: every child exists, belongs to at most one group, and + // group nesting can't form a cycle. + const parentOf = new Map() + for (const g of groups) { + for (const child of g.children) { + if (!ids.has(child)) { + fail( + `group "${g.id}": unknown child "${child}" — children must reference node or group ids defined in the spec` + ) + } + const prev = parentOf.get(child) + if (prev) { + fail(`"${child}" is a child of both "${prev}" and "${g.id}" — it can only be in one group`) + } + parentOf.set(child, g.id) + } + } + for (const g of groups) { + const seen = new Set([g.id]) + for (let p = parentOf.get(g.id); p; p = parentOf.get(p)) { + if (seen.has(p)) + fail(`group nesting forms a cycle through "${p}" — groups cannot contain themselves`) + seen.add(p) + } + } + + const rawEdges = raw.edges === undefined ? [] : raw.edges + if (!Array.isArray(rawEdges)) fail('"edges" must be an array') + const edges: CompiledEdge[] = rawEdges.map((e, i) => { + const where = `edges[${i}]` + if (!isRecord(e)) fail(`${where} must be an object`) + checkKeys(e, ['from', 'to', 'label', 'color', 'elbow'], where) + for (const end of ['from', 'to'] as const) { + if (typeof e[end] !== 'string' || !ids.has(e[end])) { + fail( + `${where}: unknown endpoint ${JSON.stringify(e[end])} — "${end}" must be a node or group id defined in the spec` + ) + } + } + const from = e.from as string + const to = e.to as string + if (from === to) + fail(`${where}: "from" and "to" are both "${from}" — self-loops aren't supported`) + if (e.label !== undefined && typeof e.label !== 'string') { + fail(`${where}: "label" must be a string`) + } + if (e.elbow !== undefined && typeof e.elbow !== 'boolean') { + fail(`${where}: "elbow" must be true or false`) + } + return { + from, + to, + ...(e.label ? { label: e.label } : {}), + ...(e.color !== undefined ? { color: parseColorAt(e.color, where) } : {}), + elbow: e.elbow === true + } + }) + + return { ...(title !== undefined ? { title } : {}), direction, nodes, groups, edges } +} + +// ---- layout ------------------------------------------------------------------- + +// Build the ELK graph: groups become hierarchical nodes (INCLUDE_CHILDREN lets +// edges cross their boundaries), model order follows the spec so output is +// stable and reads in the order the agent wrote it. +function buildElkGraph(spec: CompiledSpec): ElkNode { + const childrenOf = new Map() + const hasParent = new Set() + for (const g of spec.groups) { + childrenOf.set(g.id, g.children) + for (const c of g.children) hasParent.add(c) + } + const nodeById = new Map(spec.nodes.map(n => [n.id, n])) + const groupById = new Map(spec.groups.map(g => [g.id, g])) + + const toElkNode = (id: string): ElkNode => { + const node = nodeById.get(id) + if (node) return { id, width: node.w, height: node.h } + const group = groupById.get(id)! + // A long group label must not overflow its rect: force a minimum width from + // the measured label (children alone decide the size otherwise). + const labelMinW = group.label + ? Math.ceil(textBlockSize(group.label, LABEL_FONT_SIZES.m).w + LABEL_PADDING * 2) + : 0 + return { + id, + layoutOptions: { + 'elk.padding': GROUP_PADDING, + ...(labelMinW > 0 + ? { + 'elk.nodeSize.constraints': 'MINIMUM_SIZE', + 'elk.nodeSize.minimum': `(${labelMinW},0)` + } + : {}) + }, + children: childrenOf.get(id)!.map(toElkNode) + } + } + + // Top level = everything not claimed by a group, in spec order (nodes first, + // then groups — considerModelOrder keeps this reading order in the output). + const topLevel = [ + ...spec.nodes.filter(n => !hasParent.has(n.id)).map(n => n.id), + ...spec.groups.filter(g => !hasParent.has(g.id)).map(g => g.id) + ] + + const edges: ElkExtendedEdge[] = spec.edges.map((e, i) => ({ + id: `edge-${i}`, + sources: [e.from], + targets: [e.to], + ...(e.label + ? { + labels: [ + { + text: e.label, + // Measured at the arrow-label font so ELK reserves real room. + width: Math.ceil(textBlockSize(e.label, ARROW_LABEL_FONT_SIZES.m).w), + height: Math.ceil(textBlockSize(e.label, ARROW_LABEL_FONT_SIZES.m).h), + layoutOptions: { 'elk.edgeLabels.inline': 'true' } + } + ] + } + : {}) + })) + + return { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + 'elk.direction': spec.direction === 'down' ? 'DOWN' : 'RIGHT', + // Required for edges that cross group boundaries — without it each group + // lays out in isolation and cross-group edges aren't routed. + 'elk.hierarchyHandling': 'INCLUDE_CHILDREN', + 'elk.spacing.nodeNode': '48', + 'elk.layered.spacing.nodeNodeBetweenLayers': '96', + 'elk.spacing.componentComponent': '64', + // Stable, spec-order-respecting output: nodes/edges keep the order the + // agent declared them instead of being re-sorted by crossing heuristics. + 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES' + }, + children: topLevel.map(toElkNode), + edges + } +} + +type PlacedRect = { x: number; y: number; w: number; h: number } + +// ELK child coordinates are parent-relative — accumulate to absolute. +function collectPlacements(node: ElkNode, ox: number, oy: number, out: Map) { + for (const child of node.children ?? []) { + const x = ox + (child.x ?? 0) + const y = oy + (child.y ?? 0) + out.set(child.id, { x, y, w: child.width ?? 0, h: child.height ?? 0 }) + collectPlacements(child, x, y, out) + } +} + +// ---- anchoring ---------------------------------------------------------------- + +// Best-effort bounding box of what's already on the canvas, for auto-placement. +// Geo/image shapes carry w/h; notes are 200×200 (+growY); text height is +// approximated from its font size; arrows fall back to their start/end points. +// Precision doesn't matter much — the diagram lands AUTO_PLACE_GAP below. +function contentBounds(store: TLStore): PlacedRect | null { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const record of store.allRecords()) { + if (record.typeName !== 'shape') continue + const r = record as unknown as { + type: string + x: number + y: number + props: { + w?: number + h?: number + growY?: number + size?: string + start?: { x: number; y: number } + end?: { x: number; y: number } + } + } + let w = typeof r.props.w === 'number' ? r.props.w : 0 + let h = typeof r.props.h === 'number' ? r.props.h : 0 + if (r.type === 'note') { + w = NOTE_SIZE + h = NOTE_SIZE + (r.props.growY ?? 0) + } else if (r.type === 'text') { + h = (TEXT_FONT_SIZES[r.props.size ?? 'm'] ?? TEXT_FONT_SIZES.m) * LINE_HEIGHT + } else if (r.type === 'arrow' && r.props.start && r.props.end) { + const xs = [r.props.start.x, r.props.end.x] + const ys = [r.props.start.y, r.props.end.y] + w = Math.max(...xs) - Math.min(...xs) + h = Math.max(...ys) - Math.min(...ys) + minX = Math.min(minX, r.x + Math.min(...xs)) + minY = Math.min(minY, r.y + Math.min(...ys)) + maxX = Math.max(maxX, r.x + Math.max(...xs)) + maxY = Math.max(maxY, r.y + Math.max(...ys)) + continue + } else { + h += r.props.growY ?? 0 + } + minX = Math.min(minX, r.x) + minY = Math.min(minY, r.y) + maxX = Math.max(maxX, r.x + w) + maxY = Math.max(maxY, r.y + h) + } + if (!Number.isFinite(minX)) return null + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY } +} + +// ---- compilation to records ----------------------------------------------------- + +/** + * Compile a `diagram` op onto the store: validate the spec, measure and size + * nodes, run ELK layered layout, and put every resulting tldraw record in one + * batch. Returns the created shape names so the agent can address them. + */ +export async function applyDiagram( + store: TLStore, + op: Extract +): Promise { + const spec = compileDiagramSpec(op.spec) + const pageId = firstPageId(store) + + const layout = await elk().layout(buildElkGraph(spec)) + const placed = new Map() + collectPlacements(layout, 0, 0, placed) + + // Diagram-local bounds (ELK content only; the title hangs above them). + let minX = Infinity + let minY = Infinity + for (const rect of placed.values()) { + minX = Math.min(minX, rect.x) + minY = Math.min(minY, rect.y) + } + if (!Number.isFinite(minX)) { + minX = 0 + minY = 0 + } + + // Title block, placed above the diagram with a gap; it extends the overall + // bounds so the anchor is the top-left of *everything* emitted. + const titleSize = spec.title ? textBlockSize(spec.title, TEXT_FONT_SIZES.xl) : null + const titleLocal = titleSize + ? { x: minX, y: minY - TITLE_GAP - titleSize.h, w: Math.ceil(titleSize.w), h: titleSize.h } + : null + + // Anchor: explicit `--at`, else just below the existing canvas content. + let anchor: { x: number; y: number } + if (op.x !== undefined && op.y !== undefined) { + anchor = { x: op.x, y: op.y } + } else { + const existing = contentBounds(store) + anchor = existing + ? { x: existing.x, y: existing.y + existing.h + AUTO_PLACE_GAP } + : { x: 0, y: 0 } + } + const dx = anchor.x - (titleLocal ? Math.min(minX, titleLocal.x) : minX) + const dy = anchor.y - (titleLocal ? titleLocal.y : minY) + + // Deterministic prefixed ids — refuse to silently overwrite existing shapes. + const shapeName = (suffix: string) => `${op.name}-${suffix}` + const requireFree = (name: string) => { + if (store.get(createShapeId(name))) { + throw new Error(`Shape "${name}" already exists — pass a different --id prefix`) + } + return createShapeId(name) + } + + const records: TLRecord[] = [] + const created: string[] = [] + let index = nextIndex(store, pageId) + const pushShape = (fields: Omit[0], 'index' | 'parentId'>) => { + records.push(shapeRecord({ ...fields, index, parentId: pageId })) + index = getIndexAbove(index) + } + + // Groups first (lowest z, so nodes draw on top). DFS order isn't needed — + // `placed` already has absolute rects — but outer-before-inner keeps nested + // group rects stacked correctly, and spec order does that for the common case. + for (const group of spec.groups) { + const rect = placed.get(group.id)! + const name = shapeName(group.id) + pushShape({ + id: requireFree(name), + type: 'geo', + x: Math.round(rect.x + dx), + y: Math.round(rect.y + dy), + props: { + ...defaultProps('geo'), + geo: 'rectangle', + w: Math.round(rect.w), + h: Math.round(rect.h), + fill: 'none', + ...(group.color ? { color: group.color } : {}), + ...(group.label + ? { richText: toRichText(group.label), align: 'start', verticalAlign: 'start' } + : {}) + } + }) + created.push(name) + } + + for (const node of spec.nodes) { + const rect = placed.get(node.id)! + const name = shapeName(node.id) + if (node.shape === 'note') { + pushShape({ + id: requireFree(name), + type: 'note', + x: Math.round(rect.x + dx), + y: Math.round(rect.y + dy), + props: { + ...defaultProps('note'), + richText: toRichText(node.label), + ...(node.color ? { color: node.color } : {}) + } + }) + } else { + pushShape({ + id: requireFree(name), + type: 'geo', + x: Math.round(rect.x + dx), + y: Math.round(rect.y + dy), + props: { + ...defaultProps('geo'), + geo: 'rectangle', + w: Math.round(rect.w), + h: Math.round(rect.h), + richText: toRichText(node.label), + // Default to the toolbar's default rect look (a light wash) unless + // the spec picked a fill. + fill: node.fill ?? 'solid', + ...(node.color ? { color: node.color } : {}) + } + }) + } + created.push(name) + } + + if (spec.title && titleLocal) { + const name = shapeName('title') + pushShape({ + id: requireFree(name), + type: 'text', + x: Math.round(titleLocal.x + dx), + y: Math.round(titleLocal.y + dy), + props: { + ...defaultProps('text'), + richText: toRichText(spec.title), + size: 'xl', + w: titleLocal.w, + autoSize: true + } + }) + created.push(name) + } + + // Edges last (topmost). Arrows are *bound* to their endpoint shapes: the + // browser routes bound arrows itself and re-routes them when shapes move, so + // ELK's edge routes are unnecessary — we only seed start/end with the shape + // centers so the raw snapshot isn't degenerate before a tab opens it. + const bindings: TLRecord[] = [] + for (const [i, edge] of spec.edges.entries()) { + const name = shapeName(`edge-${i}`) + const arrowId = requireFree(name) + const from = placed.get(edge.from)! + const to = placed.get(edge.to)! + pushShape({ + id: arrowId, + type: 'arrow', + x: 0, + y: 0, + props: { + ...defaultProps('arrow'), + ...(edge.elbow ? { kind: 'elbow' } : {}), + ...(edge.color ? { color: edge.color } : {}), + ...(edge.label ? { richText: toRichText(edge.label) } : {}), + start: { x: Math.round(from.x + from.w / 2 + dx), y: Math.round(from.y + from.h / 2 + dy) }, + end: { x: Math.round(to.x + to.w / 2 + dx), y: Math.round(to.y + to.h / 2 + dy) } + } + }) + bindings.push(arrowBinding(arrowId, createShapeId(shapeName(edge.from)), 'start')) + bindings.push(arrowBinding(arrowId, createShapeId(shapeName(edge.to)), 'end')) + created.push(name) + } + + // One atomic put: either the whole diagram lands or none of it does. + store.put([...records, ...bindings]) + return { name: op.name, created } +} diff --git a/server/scratchpad-executor.ts b/server/scratchpad-executor.ts index 3b3ae72..250a714 100644 --- a/server/scratchpad-executor.ts +++ b/server/scratchpad-executor.ts @@ -2,18 +2,13 @@ import { basename } from 'path' import sharp from 'sharp' import { - type IndexKey, - type TLPageId, type TLRecord, type TLStore, AssetRecordType, - ZERO_INDEX_KEY, - createBindingId, createShapeId, createTLStore, defaultBindingUtils, defaultShapeUtils, - getIndexAbove, getSnapshot, loadSnapshot, toRichText @@ -23,6 +18,14 @@ import type { ScratchImageQuality, ScratchOp, ScratchOpResult, ScratchStyle } fr import { publishEvent } from './events' import { type ScratchpadDoc, loadScratchpadDoc, saveScratchpadDoc } from './scratchpad' +import { applyDiagram } from './scratchpad-diagram' +import { + arrowBinding, + defaultProps, + firstPageId, + nextIndex, + shapeRecord +} from './scratchpad-records' // Server-side Scratchpad writer. The browser is no longer required to draw: we run // the same ops against a *headless* tldraw store here, persist the snapshot, and @@ -35,24 +38,8 @@ import { type ScratchpadDoc, loadScratchpadDoc, saveScratchpadDoc } from './scra // validates every `put`, so a malformed record throws instead of corrupting the // file — that validation is what keeps hand-built records honest. -// Each shape type's default props, read once from its ShapeUtil. `getDefaultProps` -// is an instance method but doesn't touch the editor for the shapes we create, so a -// throwaway instance is enough; the result is static per type, so we cache it. -const shapeDefaults = new Map>() -function defaultProps(type: string): Record { - let cached = shapeDefaults.get(type) - if (!cached) { - const Util = defaultShapeUtils.find(u => (u as unknown as { type: string }).type === type) - if (!Util) throw new Error(`Unknown shape type "${type}"`) - // eslint-disable-next-line new-cap -- Util is a class constructor pulled from a list - const util = new (Util as unknown as new (editor: unknown) => { - getDefaultProps(): Record - })({}) - cached = util.getDefaultProps() - shapeDefaults.set(type, cached) - } - return { ...cached } -} +// Record construction (defaultProps / shapeRecord / arrowBinding / index helpers) +// lives in scratchpad-records.ts, shared with the diagram compiler. // Map an op's optional color/size/fill onto tldraw shape props (omitted → tldraw default). function styleProps(style: ScratchStyle): Record { @@ -75,71 +62,6 @@ function buildStore(doc: ScratchpadDoc | null): TLStore { return store } -function firstPageId(store: TLStore): TLPageId { - for (const record of store.allRecords()) { - if (record.typeName === 'page') return record.id - } - // ensureStoreIsUsable always leaves a page; this is unreachable in practice. - throw new Error('Scratchpad has no page') -} - -// The next fractional index above every shape on the page, so a new shape lands on -// top. IndexKeys sort lexicographically, so a string max is a valid ordering. -function nextIndex(store: TLStore, pageId: TLPageId): IndexKey { - let max: IndexKey = ZERO_INDEX_KEY - for (const record of store.allRecords()) { - if (record.typeName === 'shape' && record.parentId === pageId && record.index > max) { - max = record.index - } - } - return getIndexAbove(max) -} - -// Hand-build a shape record. TS can't correlate `type` with its matching props -// variant across the TLRecord union, so we assert — `store.put` validates the -// result at runtime, which is the real guarantee. -function shapeRecord(fields: { - id: ReturnType - type: string - x: number - y: number - index: IndexKey - parentId: TLPageId - props: Record -}): TLRecord { - return { - typeName: 'shape', - rotation: 0, - isLocked: false, - opacity: 1, - meta: {}, - ...fields - } as unknown as TLRecord -} - -// Bind one arrow terminal to a target shape, so the arrow follows when it moves. -function arrowBinding( - arrowId: ReturnType, - targetId: ReturnType, - terminal: 'start' | 'end' -): TLRecord { - return { - id: createBindingId(), - typeName: 'binding', - type: 'arrow', - fromId: arrowId, - toId: targetId, - meta: {}, - props: { - terminal, - normalizedAnchor: { x: 0.5, y: 0.5 }, - isPrecise: false, - isExact: false, - snap: 'none' - } - } as unknown as TLRecord -} - // Resize an image file to fit the canvas and embed it as a webp data URL, never // enlarging — so a 10MB paste becomes a lightweight asset. `quality` picks the // preset: 'lo' caps the long side smaller (default), 'hi' keeps more pixels. @@ -370,8 +292,14 @@ export function executeScratchOp( return withLock(workspacePath, async () => { const { document } = await loadScratchpadDoc(workspacePath) const store = buildStore(document) - // add-image decodes/resizes the file first, so it's the one async op. - const result = op.kind === 'add-image' ? await applyAddImage(store, op) : applyOp(store, op) + // add-image (decode/resize) and diagram (ELK layout in a worker) are async; + // every other op mutates the store synchronously. + const result = + op.kind === 'add-image' + ? await applyAddImage(store, op) + : op.kind === 'diagram' + ? await applyDiagram(store, op) + : applyOp(store, op) const next = getSnapshot(store).document as unknown as ScratchpadDoc await saveScratchpadDoc(next, workspacePath) publishEvent({ type: 'scratchpad:updated', workspaceId }) diff --git a/server/scratchpad-records.ts b/server/scratchpad-records.ts new file mode 100644 index 0000000..d676ea9 --- /dev/null +++ b/server/scratchpad-records.ts @@ -0,0 +1,100 @@ +import { + type IndexKey, + type TLPageId, + type TLRecord, + type TLStore, + ZERO_INDEX_KEY, + createBindingId, + createShapeId, + defaultShapeUtils, + getIndexAbove +} from 'tldraw' + +// Hand-built tldraw record construction, shared by the headless op executor +// (scratchpad-executor.ts) and the diagram compiler (scratchpad-diagram.ts). +// Records built here are validated by `store.put`, so a malformed one throws +// instead of corrupting the snapshot — that validation is the real guarantee. + +// Each shape type's default props, read once from its ShapeUtil. `getDefaultProps` +// is an instance method but doesn't touch the editor for the shapes we create, so a +// throwaway instance is enough; the result is static per type, so we cache it. +const shapeDefaults = new Map>() +export function defaultProps(type: string): Record { + let cached = shapeDefaults.get(type) + if (!cached) { + const Util = defaultShapeUtils.find(u => (u as unknown as { type: string }).type === type) + if (!Util) throw new Error(`Unknown shape type "${type}"`) + // eslint-disable-next-line new-cap -- Util is a class constructor pulled from a list + const util = new (Util as unknown as new (editor: unknown) => { + getDefaultProps(): Record + })({}) + cached = util.getDefaultProps() + shapeDefaults.set(type, cached) + } + return { ...cached } +} + +export function firstPageId(store: TLStore): TLPageId { + for (const record of store.allRecords()) { + if (record.typeName === 'page') return record.id + } + // ensureStoreIsUsable always leaves a page; this is unreachable in practice. + throw new Error('Scratchpad has no page') +} + +// The next fractional index above every shape on the page, so a new shape lands on +// top. IndexKeys sort lexicographically, so a string max is a valid ordering. +export function nextIndex(store: TLStore, pageId: TLPageId): IndexKey { + let max: IndexKey = ZERO_INDEX_KEY + for (const record of store.allRecords()) { + if (record.typeName === 'shape' && record.parentId === pageId && record.index > max) { + max = record.index + } + } + return getIndexAbove(max) +} + +// Hand-build a shape record. TS can't correlate `type` with its matching props +// variant across the TLRecord union, so we assert — `store.put` validates the +// result at runtime, which is the real guarantee. +export function shapeRecord(fields: { + id: ReturnType + type: string + x: number + y: number + index: IndexKey + parentId: TLPageId + props: Record +}): TLRecord { + return { + typeName: 'shape', + rotation: 0, + isLocked: false, + opacity: 1, + meta: {}, + ...fields + } as unknown as TLRecord +} + +// Bind one arrow terminal to a target shape, so the arrow follows when it moves. +export function arrowBinding( + arrowId: ReturnType, + targetId: ReturnType, + terminal: 'start' | 'end' +): TLRecord { + return { + id: createBindingId(), + typeName: 'binding', + type: 'arrow', + fromId: arrowId, + toId: targetId, + meta: {}, + props: { + terminal, + normalizedAnchor: { x: 0.5, y: 0.5 }, + isPrecise: false, + isExact: false, + snap: 'none' + } + } as unknown as TLRecord +} diff --git a/server/scratchpad-style.ts b/server/scratchpad-style.ts new file mode 100644 index 0000000..ec57edb --- /dev/null +++ b/server/scratchpad-style.ts @@ -0,0 +1,88 @@ +import type { ScratchColor, ScratchFill, ScratchSize } from '@/lib/types' + +// Shared parsing for the Scratchpad's user-facing style vocabulary (colors, +// fills, stroke/font sizes). Two consumers: the `moi scratch` CLI flags and the +// `diagram` spec compiler (server/scratchpad-diagram.ts) — both accept the same +// names, so the palette and its parse rules live here once. + +// The Scratchpad palette (matches the UI toolbar's six swatches) and each color's +// light-theme solid hex — used to snap an arbitrary `#rrggbb` to the nearest +// palette entry (tldraw shapes can't hold free hex). Keep in sync with the swatches +// in client/components/Scratchpad.tsx. +export const COLOR_HEX: Record = { + black: '#1d1d1d', + red: '#e03131', + yellow: '#f1ac4b', + green: '#099268', + blue: '#4465e9', + grey: '#9fa8b2' +} +export const COLOR_NAMES = Object.keys(COLOR_HEX) as ScratchColor[] + +// Arrows expose tldraw's size as a line weight; the CLI mirrors the UI's two sizes. +export const STROKE_SIZES: Record = { small: 'm', large: 'xl' } +export const STROKE_NAMES = Object.keys(STROKE_SIZES) + +// Text & notes expose the same size style as a label font size, under friendlier names. +export const FONT_SIZES: Record = { regular: 'm', big: 'xl' } +export const FONT_SIZE_NAMES = Object.keys(FONT_SIZES) + +// Rectangle fills — the UI toolbar's four options. Each user-facing name maps onto a +// tldraw DefaultFillStyle value (see ScratchFill for tldraw's semi/solid quirk). Keep +// in sync with FILL_OPTIONS in client/components/Scratchpad.tsx. +export const FILL_STYLES: Record = { + none: 'none', + semi: 'solid', + pattern: 'pattern', + solid: 'fill' +} +export const FILL_NAMES = Object.keys(FILL_STYLES) + +function hexToRgb(hex: string): [number, number, number] | null { + let h = hex.trim().replace(/^#/, '') + if (h.length === 3) h = h.replace(/(.)/g, '$1$1') + if (!/^[0-9a-fA-F]{6}$/.test(h)) return null + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] +} + +// Accept a palette name as-is, or snap any hex to the nearest palette color by +// squared RGB distance. Throws on anything else. +export function parseColor(s: string): ScratchColor { + const lower = s.trim().toLowerCase() + if ((COLOR_NAMES as string[]).includes(lower)) return lower as ScratchColor + const rgb = hexToRgb(s) + if (!rgb) { + throw new Error( + `Unknown color "${s}". Use a hex like "#4465e9" or one of: ${COLOR_NAMES.join(', ')}.` + ) + } + let best: ScratchColor = 'black' + let bestDist = Infinity + for (const name of COLOR_NAMES) { + const [r, g, b] = hexToRgb(COLOR_HEX[name])! + const d = (r - rgb[0]) ** 2 + (g - rgb[1]) ** 2 + (b - rgb[2]) ** 2 + if (d < bestDist) { + bestDist = d + best = name + } + } + return best +} + +export function parseStroke(s: string): ScratchSize { + const size = STROKE_SIZES[s.trim().toLowerCase()] + if (!size) throw new Error(`Unknown stroke "${s}". Use one of: ${STROKE_NAMES.join(', ')}.`) + return size +} + +export function parseFontSize(s: string): ScratchSize { + const size = FONT_SIZES[s.trim().toLowerCase()] + if (!size) throw new Error(`Unknown font size "${s}". Use one of: ${FONT_SIZE_NAMES.join(', ')}.`) + return size +} + +export function parseFill(s: string): ScratchFill { + const fill = FILL_STYLES[s.trim().toLowerCase()] + if (!fill) throw new Error(`Unknown fill "${s}". Use one of: ${FILL_NAMES.join(', ')}.`) + return fill +} diff --git a/server/test/scratchpad-diagram.test.ts b/server/test/scratchpad-diagram.test.ts new file mode 100644 index 0000000..c12a515 --- /dev/null +++ b/server/test/scratchpad-diagram.test.ts @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'path' + +import { createTLStore, defaultBindingUtils, defaultShapeUtils, loadSnapshot } from 'tldraw' + +import type { ScratchDiagramSpec, ScratchOp } from '@/lib/types' + +import { loadScratchpadDoc, readScratchpadShapes } from '../scratchpad' +import { compileDiagramSpec } from '../scratchpad-diagram' +import { executeScratchOp } from '../scratchpad-executor' +import { fitRectToLabel } from '../scratchpad-metrics' + +// The diagram compiler end-to-end: spec in, measured + ELK-laid-out tldraw +// records out, persisted through the same headless path as every other op. The +// tests assert the *guarantees* the compiler makes — no overlaps, labels fit, +// groups contain their children, arrows are bound — not exact pixel positions, +// which belong to ELK. + +let WS: string +beforeEach(() => { + WS = mkdtempSync(join(import.meta.dir, 'scratch-diagram-test-')) +}) +afterEach(() => { + rmSync(WS, { recursive: true, force: true }) +}) + +const run = (op: ScratchOp) => executeScratchOp(WS, 'ws-test', op) + +// Loads the persisted snapshot into a fresh store the way the browser does. Throws +// (failing the test) if any record is invalid, then returns the shape count. +async function assertLoadable(): Promise { + const { document } = await loadScratchpadDoc(WS) + const store = createTLStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils }) + loadSnapshot(store, { document } as unknown as Parameters[1]) + return store.allRecords().filter(r => r.typeName === 'shape').length +} + +// The design doc's motivating scenario: browser → proxy → service-in-a-private- +// network, labeled edges, a title, and a side note. +const TAILSCALE_SPEC: ScratchDiagramSpec = { + title: 'How to expose a Tailscale service', + direction: 'right', + nodes: [ + { id: 'browser', label: 'Browser (internet)', color: 'blue' }, + { + id: 'proxy', + label: 'Reverse proxy (Caddy)\nterminates TLS, forwards to the tailnet', + color: 'green', + width: 280 + }, + { id: 'svc', label: 'Service\nlocalhost:3000', color: 'black' }, + { + id: 'tip', + label: 'MagicDNS gives every machine a stable name', + shape: 'note', + color: 'yellow' + } + ], + groups: [ + { id: 'tailnet', label: 'Tailscale network (private)', color: 'grey', children: ['svc'] } + ], + edges: [ + { from: 'browser', to: 'proxy', label: 'https://app.yourdomain.com', color: 'blue' }, + { from: 'proxy', to: 'svc', label: 'tailnet IP', elbow: true } + ] +} + +type Rect = { x: number; y: number; w: number; h: number } +const overlaps = (a: Rect, b: Rect) => + a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h + +// Read a shape's rect off the disk snapshot; notes have no w/h props (fixed 200). +async function shapeRects(): Promise> { + const shapes = await readScratchpadShapes(WS) + return new Map( + shapes.map(s => [ + s.id, + { x: s.x, y: s.y, w: s.w ?? 200, h: s.h ?? 200, type: s.type, text: s.text } + ]) + ) +} + +describe('moi scratch diagram (compiler)', () => { + test('compiles the Tailscale spec into a browser-loadable snapshot with prefixed ids', async () => { + const result = await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + expect(result).toEqual({ + name: 'd1', + created: [ + 'd1-tailnet', + 'd1-browser', + 'd1-proxy', + 'd1-svc', + 'd1-tip', + 'd1-title', + 'd1-edge-0', + 'd1-edge-1' + ] + }) + // 4 nodes + 1 group + title + 2 arrows. + expect(await assertLoadable()).toBe(8) + + const rects = await shapeRects() + expect(rects.get('d1-browser')).toMatchObject({ type: 'geo', text: 'Browser (internet)' }) + expect(rects.get('d1-tip')?.type).toBe('note') + expect(rects.get('d1-title')).toMatchObject({ + type: 'text', + text: 'How to expose a Tailscale service' + }) + expect(rects.get('d1-edge-0')).toMatchObject({ + type: 'arrow', + text: 'https://app.yourdomain.com' + }) + }) + + test('node rects never pairwise overlap', async () => { + await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + const rects = await shapeRects() + const nodeIds = ['d1-browser', 'd1-proxy', 'd1-svc', 'd1-tip'] + for (let i = 0; i < nodeIds.length; i++) { + for (let j = i + 1; j < nodeIds.length; j++) { + const a = rects.get(nodeIds[i])! + const b = rects.get(nodeIds[j])! + expect(overlaps(a, b)).toBe(false) + } + } + }) + + test('every node rect is at least as big as its measured label', async () => { + await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + const rects = await shapeRects() + for (const node of TAILSCALE_SPEC.nodes) { + if (node.shape === 'note') continue + const fit = fitRectToLabel(node.label, { + size: 'm', + targetWidth: node.width ?? 260, + minW: 120, + minH: 64 + }) + const rect = rects.get(`d1-${node.id}`)! + expect(rect.w).toBeGreaterThanOrEqual(fit.w) + expect(rect.h).toBeGreaterThanOrEqual(fit.h) + } + }) + + test('a group rect contains its children with padding', async () => { + await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + const rects = await shapeRects() + const group = rects.get('d1-tailnet')! + const child = rects.get('d1-svc')! + // ELK padding: [top=56,left=24,bottom=24,right=24] (label room at the top). + expect(child.x).toBeGreaterThanOrEqual(group.x + 24) + expect(child.y).toBeGreaterThanOrEqual(group.y + 56) + expect(child.x + child.w).toBeLessThanOrEqual(group.x + group.w - 24) + expect(child.y + child.h).toBeLessThanOrEqual(group.y + group.h - 24) + // Group boxes are outlines, not washes — children stay visible. + const { document } = await loadScratchpadDoc(WS) + const record = Object.values(document!.store!).find( + r => (r as { id?: string }).id === 'shape:d1-tailnet' + ) as { props: { fill: string; align: string; verticalAlign: string } } + expect(record.props.fill).toBe('none') + expect(record.props.align).toBe('start') + expect(record.props.verticalAlign).toBe('start') + }) + + test('arrows are bound to their endpoint shapes', async () => { + await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + const { document } = await loadScratchpadDoc(WS) + const bindings = Object.values(document!.store!).filter( + r => (r as { typeName?: string }).typeName === 'binding' + ) as { fromId: string; toId: string; props: { terminal: string } }[] + const byArrow = (arrow: string, terminal: string) => + bindings.find(b => b.fromId === `shape:${arrow}` && b.props.terminal === terminal) + expect(byArrow('d1-edge-0', 'start')?.toId).toBe('shape:d1-browser') + expect(byArrow('d1-edge-0', 'end')?.toId).toBe('shape:d1-proxy') + expect(byArrow('d1-edge-1', 'start')?.toId).toBe('shape:d1-proxy') + expect(byArrow('d1-edge-1', 'end')?.toId).toBe('shape:d1-svc') + }) + + test('the title sits above every node', async () => { + await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + const rects = await shapeRects() + const title = rects.get('d1-title')! + for (const id of ['d1-browser', 'd1-proxy', 'd1-svc', 'd1-tip', 'd1-tailnet']) { + expect(title.y).toBeLessThan(rects.get(id)!.y) + } + }) + + test('a second diagram auto-places below the first; --at overrides', async () => { + await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + const before = await shapeRects() + const firstBottom = Math.max( + ...['d1-browser', 'd1-proxy', 'd1-svc', 'd1-tip', 'd1-tailnet'].map(id => { + const r = before.get(id)! + return r.y + r.h + }) + ) + + await run({ + kind: 'diagram', + name: 'd2', + spec: { nodes: [{ id: 'solo', label: 'Below the fold' }] } + }) + const after = await shapeRects() + expect(after.get('d2-solo')!.y).toBeGreaterThanOrEqual(firstBottom + 96) + + await run({ + kind: 'diagram', + name: 'd3', + spec: { nodes: [{ id: 'pinned', label: 'Pinned' }] }, + x: 5000, + y: 7000 + }) + const pinned = (await shapeRects()).get('d3-pinned')! + expect(pinned.x).toBe(5000) + expect(pinned.y).toBe(7000) + }) + + test('an empty canvas anchors the diagram (title included) at 0,0', async () => { + await run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC }) + const rects = await shapeRects() + // The title is the topmost element, so it carries the anchor's y. + expect(rects.get('d1-title')!.y).toBe(0) + }) + + test('refuses to overwrite an existing shape id', async () => { + await run({ kind: 'add-rect', name: 'd1-browser', x: 0, y: 0, w: 10, h: 10 }) + await expect(run({ kind: 'diagram', name: 'd1', spec: TAILSCALE_SPEC })).rejects.toThrow( + /d1-browser.*already exists/ + ) + }) + + test('layout is deterministic for the same spec', async () => { + await run({ kind: 'diagram', name: 'a', spec: TAILSCALE_SPEC, x: 0, y: 0 }) + await run({ kind: 'diagram', name: 'b', spec: TAILSCALE_SPEC, x: 0, y: 10000 }) + const rects = await shapeRects() + for (const node of TAILSCALE_SPEC.nodes) { + const a = rects.get(`a-${node.id}`)! + const b = rects.get(`b-${node.id}`)! + expect(b.x).toBe(a.x) + expect(b.y - 10000).toBe(a.y) + } + }) + + test('nested groups are laid out and contained', async () => { + await run({ + kind: 'diagram', + name: 'n', + spec: { + nodes: [{ id: 'leaf', label: 'Leaf' }], + groups: [ + { id: 'outer', label: 'Outer', children: ['inner'] }, + { id: 'inner', label: 'Inner', children: ['leaf'] } + ] + } + }) + const rects = await shapeRects() + const outer = rects.get('n-outer')! + const inner = rects.get('n-inner')! + const leaf = rects.get('n-leaf')! + expect(inner.x).toBeGreaterThanOrEqual(outer.x) + expect(inner.y).toBeGreaterThanOrEqual(outer.y) + expect(inner.x + inner.w).toBeLessThanOrEqual(outer.x + outer.w) + expect(leaf.x).toBeGreaterThanOrEqual(inner.x) + expect(await assertLoadable()).toBe(3) + }) + + test('direction: down stacks layers vertically', async () => { + await run({ + kind: 'diagram', + name: 'v', + spec: { + direction: 'down', + nodes: [ + { id: 'top', label: 'Top' }, + { id: 'bottom', label: 'Bottom' } + ], + edges: [{ from: 'top', to: 'bottom' }] + } + }) + const rects = await shapeRects() + const top = rects.get('v-top')! + const bottom = rects.get('v-bottom')! + expect(bottom.y).toBeGreaterThanOrEqual(top.y + top.h) + }) +}) + +describe('diagram spec validation', () => { + const expectFail = (spec: unknown, pattern: RegExp) => + expect(() => compileDiagramSpec(spec)).toThrow(pattern) + + test('rejects a non-object spec', () => { + expectFail([], /must be a JSON object/) + expectFail('nope', /must be a JSON object/) + }) + + test('rejects empty or missing nodes', () => { + expectFail({}, /"nodes" must be a non-empty array/) + expectFail({ nodes: [] }, /needs at least one node/) + }) + + test('rejects duplicate ids across nodes and groups', () => { + expectFail( + { + nodes: [ + { id: 'a', label: 'A' }, + { id: 'a', label: 'A again' } + ] + }, + /duplicate id "a"/ + ) + expectFail( + { nodes: [{ id: 'a', label: 'A' }], groups: [{ id: 'a', label: 'G', children: ['a'] }] }, + /duplicate id "a"/ + ) + }) + + test('rejects an unknown edge endpoint, naming it', () => { + expectFail( + { nodes: [{ id: 'a', label: 'A' }], edges: [{ from: 'a', to: 'ghost' }] }, + /unknown endpoint "ghost"/ + ) + }) + + test('rejects an unknown color with the palette in the message', () => { + expectFail({ nodes: [{ id: 'a', label: 'A', color: 'mauve' }] }, /Unknown color "mauve".*blue/) + }) + + test('rejects unknown keys (typo safety)', () => { + expectFail({ nodes: [{ id: 'a', label: 'A', colour: 'blue' }] }, /unknown key "colour"/) + expectFail({ nodes: [{ id: 'a', label: 'A' }], arrows: [] }, /unknown key "arrows"/) + }) + + test('rejects unknown group children and double membership', () => { + expectFail( + { nodes: [{ id: 'a', label: 'A' }], groups: [{ id: 'g', children: ['ghost'] }] }, + /unknown child "ghost"/ + ) + expectFail( + { + nodes: [{ id: 'a', label: 'A' }], + groups: [ + { id: 'g1', children: ['a'] }, + { id: 'g2', children: ['a'] } + ] + }, + /child of both "g1" and "g2"/ + ) + }) + + test('rejects group nesting cycles', () => { + expectFail( + { + nodes: [{ id: 'a', label: 'A' }], + groups: [ + { id: 'g1', children: ['g2'] }, + { id: 'g2', children: ['g1', 'a'] } + ] + }, + /cycle/ + ) + }) + + test('rejects reserved and malformed ids', () => { + expectFail({ nodes: [{ id: 'title', label: 'T' }] }, /reserved/) + expectFail({ nodes: [{ id: 'edge-0', label: 'E' }] }, /reserved/) + expectFail({ nodes: [{ id: 'has space', label: 'X' }] }, /invalid/) + }) + + test('rejects fill on note-shaped nodes and bad directions', () => { + expectFail( + { nodes: [{ id: 'a', label: 'A', shape: 'note', fill: 'semi' }] }, + /"fill" doesn't apply to note/ + ) + expectFail({ direction: 'left', nodes: [{ id: 'a', label: 'A' }] }, /"right" or "down"/) + }) + + test('validation errors surface through the executor too', async () => { + await expect( + run({ kind: 'diagram', name: 'd', spec: { nodes: [] } as unknown as ScratchDiagramSpec }) + ).rejects.toThrow(/at least one node/) + }) +}) diff --git a/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md b/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md index 69cb293..c6fc163 100644 --- a/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md +++ b/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md @@ -19,7 +19,74 @@ clean, agent-friendly view. Treat it exactly like `.moi/.workspace.json` — CLI `read` is for logic; `view` / `read-image` are for vision. -## Drawing +## Drawing diagrams — `moi scratch diagram` + +**For ANY boxes-and-arrows structure — architectures, flows, pipelines, "how X works" — use +`diagram`, never hand-placed primitives.** You declare nodes, groups, and edges; the server +measures every label with the real canvas font, sizes the boxes to fit, and computes the +layout with ELK. No overlaps, no clipped text, straight rows, even gaps — and you never +touch a coordinate. Hand-placing a multi-box diagram with `add rect` wastes turns and comes +out crooked. + +``` +moi scratch diagram [--spec file.json] [--at x,y] [--id prefix] +``` + +- No `--spec` reads stdin — pipe a heredoc (easiest). +- Omit `--at` and the diagram auto-places below whatever is already on the canvas. +- `--id` prefixes every created shape id: `-`, `-title`, + `-edge-`. The ids are printed on success — use them with `move`/`set`/`delete` + to adjust individual pieces afterwards. + +A complete worked example: + +```sh +moi scratch diagram --id tailscale <<'EOF' +{ + "title": "How to expose a Tailscale service", + "direction": "right", + "nodes": [ + { "id": "browser", "label": "Browser (internet)", "color": "blue" }, + { "id": "proxy", "label": "Reverse proxy (Caddy)\nterminates TLS, forwards to the tailnet", "color": "green", "width": 280 }, + { "id": "svc", "label": "Service\nlocalhost:3000" }, + { "id": "tip", "label": "MagicDNS gives every machine a stable name", "shape": "note", "color": "yellow" } + ], + "groups": [ + { "id": "tailnet", "label": "Tailscale network (private)", "color": "grey", "children": ["svc"] } + ], + "edges": [ + { "from": "browser", "to": "proxy", "label": "https://app.yourdomain.com", "color": "blue" }, + { "from": "proxy", "to": "svc", "label": "tailnet IP", "elbow": true } + ] +} +EOF +``` + +The spec, field by field: + +- `title` (optional) — big heading placed above the diagram. +- `direction` — main flow: `"right"` (default) or `"down"`. +- `nodes` — required, at least one. Each needs a unique `id` and a `label` (use `\n` for + explicit line breaks; long labels wrap automatically). Optional: `shape` (`"rect"` + default — sized to fit its label; `"note"` — a sticky, fixed 200×200, keep its text + short), `color` and `fill` (same values as the CLI flags below), `width` (label + wrap-width hint in px, default 260 — bump it for long one-line labels). +- `groups` (optional) — container rects drawn around their `children` (node ids, or other + group ids for nesting). Rendered as an outline with the label at the top; edges may cross + group boundaries freely. +- `edges` (optional) — arrows **bound** to their endpoint shapes (they follow if anything + is moved later). `from`/`to` reference node or group ids; optional `label`, `color`, and + `elbow: true` for right-angle routing. + +Everything lands in one batch — either the whole diagram or nothing. Spec mistakes come +back as errors naming the exact entry (`edges[1]: unknown endpoint "svc2" ...`), so fix and +re-run. To regenerate a diagram, `delete` its shapes (you have the prefixed ids) and compile +again. + +## Drawing primitives + +For **annotations and one-offs** — a sticky note next to the user's sketch, one arrow, a +caption, an image — not for laying out diagrams (that's `diagram` above). ``` moi scratch add text --at --text "..." [--id NAME] [--color C] [--font-size S] diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index c0a05c8..4f20e72 100644 --- a/workspace/.claude/skills/moi-workspace/SKILL.md +++ b/workspace/.claude/skills/moi-workspace/SKILL.md @@ -282,4 +282,4 @@ This skill is installed with moi (via the CLI or the UI) and can fall behind whe - **Then** — if you updated, mention it. - +