diff --git a/bun.lock b/bun.lock index 22fc2003..df4ec72a 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@anthropic-ai/sdk": "^0.109.1", "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", + "@resvg/resvg-js": "^2.6.2", "@tabler/icons-react": "^3.44.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.100.1", @@ -44,6 +45,7 @@ "tldraw": "^5.1.1", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", + "wawoff2": "^2.0.1", "wouter": "^3.9.0", "zustand": "^5.0.12", }, @@ -668,6 +670,32 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], + "@resvg/resvg-js": ["@resvg/resvg-js@2.6.2", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.6.2", "@resvg/resvg-js-android-arm64": "2.6.2", "@resvg/resvg-js-darwin-arm64": "2.6.2", "@resvg/resvg-js-darwin-x64": "2.6.2", "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", "@resvg/resvg-js-linux-arm64-musl": "2.6.2", "@resvg/resvg-js-linux-x64-gnu": "2.6.2", "@resvg/resvg-js-linux-x64-musl": "2.6.2", "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", "@resvg/resvg-js-win32-x64-msvc": "2.6.2" } }, "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.6.2", "", { "os": "android", "cpu": "arm" }, "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.6.2", "", { "os": "android", "cpu": "arm64" }, "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.6.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.6.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.6.2", "", { "os": "linux", "cpu": "arm" }, "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.6.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.6.2", "", { "os": "linux", "cpu": "x64" }, "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.6.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.6.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], @@ -2034,6 +2062,8 @@ "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "wawoff2": ["wawoff2@2.0.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "woff2_compress.js": "bin/woff2_compress.js", "woff2_decompress.js": "bin/woff2_decompress.js" } }, "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/docs/moi-scratchpad.md b/docs/moi-scratchpad.md index f10d143b..9108cba6 100644 --- a/docs/moi-scratchpad.md +++ b/docs/moi-scratchpad.md @@ -34,9 +34,16 @@ The agent works the canvas through a `moi scratch` CLI — it both **sees** and path). Served off disk, like `read` — this is how the agent pulls the pixels of one image that `read` omitted. A remote (`http`) asset prints its URL instead. - `moi scratch view` — render the **whole canvas** to a **PNG**. Use this to actually _see_ - what the user drew (freehand, layout, anything structure can't capture). + what the user drew (freehand, layout, anything structure can't capture). **Always works**: + a live Scratchpad tab renders the exact canvas; with no tab open the server renders a + faithful approximation itself (same font, same layout — minus tldraw's hand-drawn stroke + texture) and says so on stderr. `--headless` skips the tab outright. +- `moi scratch lint` — check the canvas geometry for the ways a drawing reads as sloppy: + labels that overflow their boxes (measured with the real canvas font), overlapping shapes, + pairs that _almost_ align, uneven gaps in rows and columns. Each finding carries a + ready-to-run fix command. `--json` for structured output; always exits 0 (advisory). -`read` is for logic; `view` / `read-image` are for vision. +`read` is for logic; `view` / `read-image` are for vision; `lint` is for taste. ### Drawing @@ -51,6 +58,7 @@ moi scratch add note --at --text "..." [--id NAME] [--color C] moi scratch add arrow --from --to [--id NAME] [--color C] [--stroke S] [--elbow] moi scratch add image [--at ] [--id NAME] [--quality lo|hi] moi scratch move --to +moi scratch resize --size # rects & images only moi scratch set --text "..." # relabel / edit moi scratch delete moi scratch clear # wipe the whole canvas @@ -75,9 +83,16 @@ moi scratch clear # wipe the whole canvas paste never lands on the canvas whole. `lo` keeps the constantly-rewritten snapshot light and is well within Claude's vision budget; reach for `hi` only when fine detail (e.g. screenshot text) matters. EXIF orientation is baked in; images are never enlarged. +- `resize` changes a rectangle's or image's size — notes, text, and arrows size themselves. + It's the actionable half of a lint `text-overflow` finding: the finding's fix is the exact + resize that makes the label fit. - `clear` deletes every shape on the canvas in one shot. - Coordinates are tldraw canvas space (origin top-left, y down). +The loop that makes agent drawings look human-made: **draw → `lint` (fix every error, judge +the warnings) → `view` to eyeball → adjust**. Lint catches what structure can measure — +overflow, overlap, misalignment, spacing; `view` catches what only eyes can. + 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 @@ -86,9 +101,10 @@ tldraw API. ## How it works The canvas the **user** sees is a real tldraw editor in the browser. The **agent**, though, -doesn't need that tab open to draw: every `moi scratch` op except `view` runs against the disk -snapshot, either by parsing it (`read`) or by replaying it through a **headless tldraw store** -on the server (the mutations). Only `view` — rendering pixels — genuinely requires the browser. +doesn't need that tab open at all: every `moi scratch` op runs against the disk snapshot, +either by parsing it (`read`, `lint`) or by replaying it through a **headless tldraw store** +on the server (the mutations). Even `view` works tab-less — a live tab renders the exact +pixels, and the server renders an approximation when none is open. - **Persistence.** `.moi/.scratchpad.json` holds a tldraw document snapshot (owned by moi — not hand-edited). The browser autosaves it ~500ms after you stop drawing; the server writes it @@ -104,9 +120,15 @@ 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`.) -- **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. +- **Viewing** (`moi scratch view`) prefers a connected tab — the browser rasterizes the exact + canvas (`editor.toImageDataUrl`). With no tab showing **this** workspace's scratchpad it + falls back to a **server-side renderer** (`server/scratchpad-render.ts`): our own SVG + emitter for the primitive shape set, rasterized by resvg with the real canvas font + (woff2 → ttf, cached). Approximate strokes, exact layout — the agent is never blind. +- **Linting** (`moi scratch lint`) is read-only geometry checking off the disk snapshot + (`server/scratchpad-lint.ts`), built on the same server-side text measurement + (`server/scratchpad-metrics.ts`) the sizing helpers use — overflow findings are measured + with the actual font, not guessed. - **Each command targets its own workspace's canvas.** The CLI runs in a workspace directory, which resolves to that workspace; reads, writes, and the relayed `view` all key off that identity, so one workspace never touches another's canvas. diff --git a/lib/scratch-palette.ts b/lib/scratch-palette.ts new file mode 100644 index 00000000..468a10c9 --- /dev/null +++ b/lib/scratch-palette.ts @@ -0,0 +1,15 @@ +import type { ScratchColor } from './types' + +// The Scratchpad palette (matches the UI toolbar's six swatches) and each color's +// light-theme solid hex. The CLI uses it to snap an arbitrary `--color #rrggbb` +// to the nearest palette entry (tldraw shapes can't hold free hex); the server +// renderer uses it to paint those palette colors back into pixels. Keep in sync +// with the swatches in client/components/Scratchpad.tsx. +export const SCRATCH_COLOR_HEX: Record = { + black: '#1d1d1d', + red: '#e03131', + yellow: '#f1ac4b', + green: '#099268', + blue: '#4465e9', + grey: '#9fa8b2' +} diff --git a/lib/types.ts b/lib/types.ts index 77f9df87..4408a3f0 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -97,14 +97,38 @@ export type ScratchOp = elbow?: boolean } & ScratchStyle) | { kind: 'move'; name: string; x: number; y: number } + // Resize a rectangle or image — the actionable half of a `text-overflow` lint + // finding. Other shape kinds (notes, text, arrows) size themselves. + | { kind: 'resize'; name: string; w: number; h: number } | { kind: 'set'; name: string; text: string } | { kind: 'delete'; name: string } | { kind: 'clear' } - | { kind: 'view' } + // `headless` skips the browser relay and always uses the server-side renderer + // (deterministic for tests/CI); without it the relay is tried first. + | { kind: 'view'; headless?: boolean } // 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 } +// data URL for `view`, or a bare ack for mutations. A `view` served by the +// server-side renderer (no live tab) is marked `headless` so the CLI can tell +// the agent it's looking at an approximation, not the browser's pixels. +export type ScratchOpResult = + | { name: string } + | { image: string; headless?: boolean } + | { ok: true } + +// One `moi scratch lint` finding — a machine-checkable "looks off": an +// overflowing label, an overlap, an almost-aligned pair, uneven gaps. `ids` are +// the shape names involved; `fix` is a ready-to-run `moi scratch` command that +// resolves it. Errors are things a human would always fix; warns are judgement +// calls. Lint is advisory — the CLI always exits 0. +export type ScratchLintSeverity = 'error' | 'warn' +export type ScratchLintFinding = { + code: 'text-overflow' | 'overlap' | 'near-misalign' | 'uneven-gaps' + severity: ScratchLintSeverity + ids: string[] + message: string + fix?: string +} // 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 0cdfebbe..97b69711 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@anthropic-ai/sdk": "^0.109.1", "@base-ui/react": "^1.2.0", "@dnd-kit/core": "^6.3.1", + "@resvg/resvg-js": "^2.6.2", "@tabler/icons-react": "^3.44.0", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.100.1", @@ -94,6 +95,7 @@ "tldraw": "^5.1.1", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", + "wawoff2": "^2.0.1", "wouter": "^3.9.0", "zustand": "^5.0.12" }, diff --git a/server/cli.ts b/server/cli.ts index 76230b96..129967a9 100755 --- a/server/cli.ts +++ b/server/cli.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'path' import pc from 'picocolors' +import { SCRATCH_COLOR_HEX } from '@/lib/scratch-palette' import { COLOR_THEMES, FONT_THEMES } from '@/lib/themes' import type { ColorTheme, FontTheme } from '@/lib/themes' import type { @@ -13,6 +14,7 @@ import type { ScratchColor, ScratchFill, ScratchImageQuality, + ScratchLintFinding, ScratchOp, ScratchSize, ScratchStyle @@ -1109,19 +1111,11 @@ 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[] +// The Scratchpad palette hexes live in lib/scratch-palette.ts — shared with the +// server-side renderer, which paints these same colors back into pixels. Here +// they snap an arbitrary `--color #rrggbb` to the nearest palette entry (tldraw +// shapes can't hold free hex). +const COLOR_NAMES = Object.keys(SCRATCH_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' } @@ -1163,7 +1157,7 @@ function parseColor(s: string): ScratchColor { let best: ScratchColor = 'black' let bestDist = Infinity for (const name of COLOR_NAMES) { - const [r, g, b] = hexToRgb(COLOR_HEX[name])! + const [r, g, b] = hexToRgb(SCRATCH_COLOR_HEX[name])! const d = (r - rgb[0]) ** 2 + (g - rgb[1]) ** 2 + (b - rgb[2]) ** 2 if (d < bestDist) { bestDist = d @@ -1235,7 +1229,11 @@ function styleArgs(args: { } } -type ScratchCliOp = ScratchOp | { kind: 'read' } | { kind: 'read-image'; name: string } +type ScratchCliOp = + | ScratchOp + | { kind: 'read' } + | { kind: 'read-image'; name: string } + | { kind: 'lint' } // Round-trip one op through the control port and hand the reply to `onResult`. // Mirrors the `bundle`/`theme` commands: one socket per invocation, print, exit. @@ -1290,22 +1288,86 @@ const scratchRead = defineCommand({ }) const scratchView = defineCommand({ - meta: { name: 'view', description: 'Render the canvas to a PNG (needs an open Scratchpad tab)' }, + meta: { + name: 'view', + description: 'Render the canvas to a PNG (live tab when open, server-rendered otherwise)' + }, args: { dir: dirArg, - out: { type: 'string', description: 'Output PNG path (default: a temp file)' } + out: { type: 'string', description: 'Output PNG path (default: a temp file)' }, + headless: { + type: 'boolean', + description: 'Skip the browser and always use the server-side renderer' + } }, async run({ args }) { - sendScratch(resolve(args.dir), { kind: 'view' }, async res => { - const result = res.result as { image?: string } | undefined - if (!result?.image) { - console.error(pc.red('No image returned')) - process.exit(1) + sendScratch( + resolve(args.dir), + { kind: 'view', ...(args.headless ? { headless: true } : {}) }, + async res => { + const result = res.result as { image?: string; headless?: boolean } | undefined + if (!result?.image) { + console.error(pc.red('No image returned')) + process.exit(1) + } + const b64 = result.image.replace(/^data:image\/png;base64,/, '') + const outPath = args.out + ? resolve(args.out) + : join(tmpdir(), `moi-scratch-${Date.now()}.png`) + await Bun.write(outPath, Buffer.from(b64, 'base64')) + console.log(outPath) + // Path stays alone on stdout; the provenance note rides on stderr. + if (result.headless) { + console.error( + pc.dim('server-rendered approximation — open the Scratchpad tab for the exact view') + ) + } } - const b64 = result.image.replace(/^data:image\/png;base64,/, '') - const outPath = args.out ? resolve(args.out) : join(tmpdir(), `moi-scratch-${Date.now()}.png`) - await Bun.write(outPath, Buffer.from(b64, 'base64')) - console.log(outPath) + ) + } +}) + +const scratchLint = defineCommand({ + meta: { + name: 'lint', + description: 'Check the canvas layout: overflowing labels, overlaps, misalignment, gaps' + }, + args: { + json: { type: 'boolean', description: 'Print findings as JSON' }, + dir: dirArg + }, + run({ args }) { + sendScratch(resolve(args.dir), { kind: 'lint' }, res => { + const findings = (res.findings ?? []) as ScratchLintFinding[] + if (args.json) { + console.log(JSON.stringify(findings, null, 2)) + return + } + if (findings.length === 0) { + console.log('\n' + pc.green('✓') + ' no findings — the canvas layout looks clean\n') + return + } + // Errors first, then warns; each finding shows its ready-to-run fix. + const ordered = [ + ...findings.filter(f => f.severity === 'error'), + ...findings.filter(f => f.severity === 'warn') + ] + console.log('') + for (const f of ordered) { + const mark = f.severity === 'error' ? pc.red('✗') : pc.yellow('!') + console.log(`${mark} ${pc.bold(f.code)} ${f.message}`) + if (f.fix) console.log(pc.dim(` fix: ${f.fix}`)) + } + const errors = findings.filter(f => f.severity === 'error').length + const warns = findings.length - errors + console.log( + '\n' + + pc.dim( + `${errors} error${errors === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'} — fix errors, judge warnings.` + ) + + '\n' + ) + // Advisory: findings never fail the command (sendScratch exits 0). }) } }) @@ -1534,6 +1596,21 @@ const scratchMove = defineCommand({ } }) +const scratchResize = defineCommand({ + meta: { name: 'resize', description: 'Resize a rectangle or image shape' }, + args: { + id: { type: 'positional', required: true, description: 'Shape name' }, + size: { type: 'string', required: true, description: 'New size "w,h"' }, + dir: dirArg + }, + run({ args }) { + const { x: w, y: h } = parseXY(args.size) + sendScratch(resolve(args.dir), { kind: 'resize', name: args.id, w, h }, () => + console.log('\n' + pc.green('✓') + ' resized ' + pc.bold(args.id) + '\n') + ) + } +}) + const scratchSet = defineCommand({ meta: { name: 'set', description: "Relabel / edit a shape's text" }, args: { @@ -1580,8 +1657,10 @@ const scratch = defineCommand({ read: scratchRead, 'read-image': scratchReadImage, view: scratchView, + lint: scratchLint, add: scratchAdd, move: scratchMove, + resize: scratchResize, set: scratchSet, delete: scratchDelete, clear: scratchClear diff --git a/server/control.ts b/server/control.ts index de35949e..84abcc80 100644 --- a/server/control.ts +++ b/server/control.ts @@ -1,6 +1,6 @@ import { resolve } from 'path' -import type { WorkspaceEntry } from '@/lib/types' +import type { ScratchOpResult, WorkspaceEntry } from '@/lib/types' import { CONTROL_PORT } from './constants' import { applyEnvChanged } from './env-apply' @@ -9,8 +9,10 @@ import { loadLayout, saveLayout } from './layout' import { publishEvent } from './events' import { findWorkspaceForPath, listWorkspaces, registerWorkspace } from './registry' import { executeScratchOp } from './scratchpad-executor' +import { lintScratchpad } from './scratchpad-lint' import { readScratchpadImage, readScratchpadShapes } from './scratchpad' -import { relayScratchOp } from './scratchpad-relay' +import { renderScratchpadPng } from './scratchpad-render' +import { NO_LIVE_CANVAS, relayScratchOp } from './scratchpad-relay' import { broadcastAll } from './state' import { applyThemeUpdate, matchColorTheme } from './theme' import { handleBundle } from './widgets' @@ -47,6 +49,24 @@ async function resolveWorkspace( return match } +// Serve a `view`: the browser relay gives exact pixels, so it's tried first; +// when no tab is showing this workspace's canvas — and only then — the +// server-side renderer takes over and the result is marked `headless` so the +// CLI can say it's an approximation. `headless: true` skips the relay outright +// (deterministic for tests/CI, no 10s timeout to wait through). +async function viewScratchpad(match: WorkspaceEntry, headless: boolean): Promise { + if (!headless) { + try { + return await relayScratchOp(match.id, { kind: 'view' }) + } catch (err) { + // Anything else (a tab answered but failed to rasterize) is a real error. + if (!(err instanceof Error) || err.message !== NO_LIVE_CANVAS) throw err + } + } + const png = await renderScratchpadPng(match.path) + return { image: `data:image/png;base64,${Buffer.from(png).toString('base64')}`, headless: true } +} + export const control = Bun.serve({ port: CONTROL_PORT, hostname: '127.0.0.1', @@ -227,6 +247,13 @@ export const control = Bun.serve({ return } + // `lint` is read-only geometry checking off the disk snapshot — like + // `read`, it never touches the executor or a live tab. + if (op.kind === 'lint') { + ws.send(JSON.stringify({ findings: await lintScratchpad(match.path) })) + return + } + // 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) { @@ -234,12 +261,13 @@ export const control = Bun.serve({ } try { - // `view` renders pixels — only the browser can do that, so it relays to - // a live tab (and fails if none is open). Every mutation runs headlessly - // against the disk snapshot, so drawing never needs an open canvas. + // `view` prefers a live tab (exact pixels) and falls back to the + // server-side renderer when none is open — or skips the relay + // entirely with `headless`. Every mutation runs headlessly against + // the disk snapshot, so drawing never needs an open canvas. const result = op.kind === 'view' - ? await relayScratchOp(match.id, op) + ? await viewScratchpad(match, op.headless === true) : await executeScratchOp(match.path, match.id, op) ws.send(JSON.stringify({ ok: true, result })) } catch (err) { diff --git a/server/scratchpad-executor.ts b/server/scratchpad-executor.ts index 3b3ae727..0f584051 100644 --- a/server/scratchpad-executor.ts +++ b/server/scratchpad-executor.ts @@ -305,6 +305,20 @@ function applyOp(store: TLStore, op: ScratchOp): ScratchOpResult { store.put([{ ...shape, x: op.x, y: op.y }]) return { ok: true } } + case 'resize': { + const shape = requireShape(op.name) + // Only shapes with real w/h props resize: geo (rects) and images. Notes, + // text, and arrows size themselves — a clear error beats a silent no-op. + const type = (shape as unknown as { type?: string }).type + if (type !== 'geo' && type !== 'image') { + throw new Error(`Only rectangles and images can be resized — "${op.name}" is a ${type}.`) + } + if (!(op.w > 0) || !(op.h > 0)) { + throw new Error(`Size must be positive, got ${op.w},${op.h}`) + } + store.put([{ ...shape, props: { ...shape.props, w: op.w, h: op.h } } as TLRecord]) + return { ok: true } + } case 'set': { const shape = requireShape(op.name) store.put([ diff --git a/server/scratchpad-lint.ts b/server/scratchpad-lint.ts new file mode 100644 index 00000000..81c95469 --- /dev/null +++ b/server/scratchpad-lint.ts @@ -0,0 +1,318 @@ +import type { ScratchLintFinding } from '@/lib/types' + +import { + type ScratchBounds, + type ScratchRecord, + labelFontSize, + readScratchpadRecords, + scratchShapeBounds +} from './scratchpad' +import { LABEL_PADDING, fitRectToLabel, measureLine, textBlockSize } from './scratchpad-metrics' + +// Read-only geometry lint for the Scratchpad: the machine-checkable half of +// "looks off". Every check works off the disk snapshot (no browser, no store +// mutation) and measures text with the real canvas font, so overflow is fact, +// not guess. Findings are advisory — each carries a ready-to-run `moi scratch` +// fix where one is mechanical. Tuned against nagging: warn-level codes are +// capped per run, and an overlapping pair is never also flagged as misaligned. + +// Warn-level chatter cap per code — a messy canvas should surface its worst +// offenders, not a hundred near-misses. +const MAX_WARN_FINDINGS = 10 +// Edges/centers this close (but not equal) were probably *meant* to align. +const NEAR_MISALIGN_PX = 10 +// Row/column gap spread beyond this reads as uneven spacing. +const GAP_SPREAD_PX = 12 +// One shape covering ≥ this fraction of another is grouping, not collision. +const CONTAINMENT_RATIO = 0.9 +// Clearance suggested when pushing an overlapping shape out of the way. +const OVERLAP_GAP = 16 + +type Boxed = { shape: ScratchRecord; b: ScratchBounds } + +const fmtN = (n: number) => String(Math.round(n * 10) / 10) +const fmtXY = (x: number, y: number) => `${fmtN(x)},${fmtN(y)}` + +function intersection(a: ScratchBounds, b: ScratchBounds): number { + const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) + const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) + return w > 0 && h > 0 ? w * h : 0 +} + +// ---- text-overflow ----------------------------------------------------------------- + +// A geo rect whose label, wrapped at the rect's inner width, needs more height +// than the rect has — or contains a single word wider than the inner width (it +// would hard-break mid-word, the "localhost:30 00" bug). The fix is the exact +// resize `fitRectToLabel` computes, never narrower than the current rect. +function checkTextOverflow(boxed: Boxed[]): ScratchLintFinding[] { + const findings: ScratchLintFinding[] = [] + for (const { shape, b } of boxed) { + if (shape.type !== 'geo' || !shape.text) continue + const geo = (shape.props as { geo?: unknown }).geo + if (geo !== undefined && geo !== 'rectangle') continue + const fontSize = labelFontSize(shape) + const innerW = b.w - 2 * LABEL_PADDING + const innerH = b.h - 2 * LABEL_PADDING + const block = textBlockSize(shape.text, fontSize, Math.max(innerW, fontSize)) + const wordTooWide = shape.text + .split(/\s+/) + .some(word => word.length > 0 && measureLine(word, fontSize) > innerW) + if (block.h <= innerH && !wordTooWide) continue + const sizeToken = (shape.props as { size?: unknown }).size + const fit = fitRectToLabel(shape.text, { + ...(typeof sizeToken === 'string' ? { size: sizeToken } : {}), + targetWidth: Math.max(innerW, 240), + minW: b.w + }) + findings.push({ + code: 'text-overflow', + severity: 'error', + ids: [shape.id], + message: + `label of "${shape.id}" overflows its ${fmtN(b.w)}×${fmtN(b.h)} rect ` + + `(needs ${fmtN(fit.w)}×${fmtN(fit.h)})`, + fix: `moi scratch resize ${shape.id} --size ${fit.w},${fit.h}` + }) + } + return findings +} + +// ---- overlap ------------------------------------------------------------------------- + +// Two shapes colliding where neither ~contains the other. Full containment is +// intentional grouping (a labeled container around its members) and is skipped. +// Arrows and freehand strokes never reach here — the entry point filters them +// out (arrows are supposed to cross shapes; strokes annotate over everything). +function checkOverlap(boxed: Boxed[]): ScratchLintFinding[] { + const findings: ScratchLintFinding[] = [] + for (let i = 0; i < boxed.length; i++) { + for (let j = i + 1; j < boxed.length; j++) { + const A = boxed[i] + const B = boxed[j] + const inter = intersection(A.b, B.b) + if (inter <= 0) continue + const areaA = A.b.w * A.b.h + const areaB = B.b.w * B.b.h + if (inter >= CONTAINMENT_RATIO * Math.min(areaA, areaB)) continue + // Push the smaller shape out along the axis of least overlap, away from + // the larger one's center — the smallest concrete move that separates them. + const [small, large] = areaA <= areaB ? [A, B] : [B, A] + const overlapX = Math.min(A.b.x + A.b.w, B.b.x + B.b.w) - Math.max(A.b.x, B.b.x) + const overlapY = Math.min(A.b.y + A.b.h, B.b.y + B.b.h) - Math.max(A.b.y, B.b.y) + const smallCx = small.b.x + small.b.w / 2 + const smallCy = small.b.y + small.b.h / 2 + const largeCx = large.b.x + large.b.w / 2 + const largeCy = large.b.y + large.b.h / 2 + let toX = small.shape.x + let toY = small.shape.y + if (overlapX <= overlapY) { + toX += (smallCx >= largeCx ? 1 : -1) * (overlapX + OVERLAP_GAP) + } else { + toY += (smallCy >= largeCy ? 1 : -1) * (overlapY + OVERLAP_GAP) + } + findings.push({ + code: 'overlap', + severity: 'error', + ids: [A.shape.id, B.shape.id], + message: `"${A.shape.id}" and "${B.shape.id}" overlap`, + fix: `moi scratch move ${small.shape.id} --to ${fmtXY(toX, toY)}` + }) + } + } + return findings +} + +// ---- near-misalign --------------------------------------------------------------------- + +type AlignAxis = { + label: string + // Value compared between the two shapes. + value: (x: Boxed) => number + // New shape origin for `b` that makes its value equal `a`'s. + align: (a: Boxed, b: Boxed) => { x: number; y: number } +} + +const ALIGN_AXES: AlignAxis[] = [ + { + label: 'left edges', + value: s => s.b.x, + align: (a, b) => ({ x: b.shape.x + (a.b.x - b.b.x), y: b.shape.y }) + }, + { + label: 'horizontal centers', + value: s => s.b.x + s.b.w / 2, + align: (a, b) => ({ x: b.shape.x + (a.b.x + a.b.w / 2 - (b.b.x + b.b.w / 2)), y: b.shape.y }) + }, + { + label: 'top edges', + value: s => s.b.y, + align: (a, b) => ({ x: b.shape.x, y: b.shape.y + (a.b.y - b.b.y) }) + }, + { + label: 'vertical centers', + value: s => s.b.y + s.b.h / 2, + align: (a, b) => ({ x: b.shape.x, y: b.shape.y + (a.b.y + a.b.h / 2 - (b.b.y + b.b.h / 2)) }) + } +] + +// A pair that *almost* lines up — off by ≤10px on an edge or center — was +// probably meant to align exactly. One finding per pair (the closest axis wins). +// Intersecting pairs are skipped entirely: a true overlap is already an error +// (never doubled with a misalign warn), and a contained shape sitting near its +// container's edge is layout, not a slip. +function checkNearMisalign(boxed: Boxed[]): ScratchLintFinding[] { + const scored: { finding: ScratchLintFinding; diff: number }[] = [] + for (let i = 0; i < boxed.length; i++) { + for (let j = i + 1; j < boxed.length; j++) { + const a = boxed[i] + const b = boxed[j] + if (intersection(a.b, b.b) > 0) continue + let best: { axis: AlignAxis; diff: number } | undefined + for (const axis of ALIGN_AXES) { + const diff = Math.abs(axis.value(a) - axis.value(b)) + if (diff > 0 && diff <= NEAR_MISALIGN_PX && (!best || diff < best.diff)) { + best = { axis, diff } + } + } + if (!best) continue + const to = best.axis.align(a, b) + scored.push({ + diff: best.diff, + finding: { + code: 'near-misalign', + severity: 'warn', + ids: [a.shape.id, b.shape.id], + message: + `${best.axis.label} of "${a.shape.id}" and "${b.shape.id}" differ ` + + `by ${fmtN(best.diff)}px`, + fix: `moi scratch move ${b.shape.id} --to ${fmtXY(to.x, to.y)}` + } + }) + } + } + // Worst offenders = the closest near-misses (a 1px slip is almost certainly a + // mistake; 9px might be intentional). Cap so a messy canvas doesn't nag. + scored.sort((a, b) => a.diff - b.diff) + return scored.slice(0, MAX_WARN_FINDINGS).map(s => s.finding) +} + +// ---- uneven-gaps ---------------------------------------------------------------------- + +// Detect rows (shapes whose vertical extents mutually overlap ≥50%) and columns +// (the transpose) of ≥3 shapes, and flag runs whose consecutive gaps vary by +// more than the threshold. The fix redistributes to the median gap, keeping the +// first shape put. Shapes contained inside another (grouped) sit in *their +// container's* layout, not the page row — they'd register as negative gaps +// against their container, so they're left out. +function checkUnevenGaps(all: Boxed[]): ScratchLintFinding[] { + const boxed = all.filter( + s => + !all.some( + t => + t !== s && + t.b.w * t.b.h > s.b.w * s.b.h && + intersection(s.b, t.b) >= CONTAINMENT_RATIO * s.b.w * s.b.h + ) + ) + const findings: ScratchLintFinding[] = [] + const overlapRatio = (a: [number, number], b: [number, number]) => { + const inter = Math.min(a[1], b[1]) - Math.max(a[0], b[0]) + return inter / Math.max(Math.min(a[1] - a[0], b[1] - b[0]), 1) + } + + const detect = ( + axis: 'row' | 'column', + extent: (s: Boxed) => [number, number], + start: (s: Boxed) => number, + size: (s: Boxed) => number + ) => { + // Greedy clustering along the run direction: a shape joins the group when + // its cross-axis extent overlaps the previous member's by ≥50%. + const sorted = [...boxed].sort((a, b) => start(a) - start(b)) + const used = new Set() + for (const seed of sorted) { + if (used.has(seed.shape.id)) continue + const group = [seed] + for (const cand of sorted) { + if (cand === seed || used.has(cand.shape.id)) continue + if (overlapRatio(extent(group[group.length - 1]), extent(cand)) >= 0.5) { + group.push(cand) + } + } + if (group.length < 3) continue + group.forEach(g => used.add(g.shape.id)) + // Chaining can pull in a member that starts before the seed (when the + // seed's own earlier group fell short) — re-sort so gaps are consecutive. + group.sort((a, b) => start(a) - start(b)) + const gaps: number[] = [] + for (let i = 0; i < group.length - 1; i++) { + gaps.push(start(group[i + 1]) - (start(group[i]) + size(group[i]))) + } + // A negative gap means two members overlap along the run direction — + // that's not a row/column (and any true collision is already an overlap + // error), so the group isn't a spacing candidate at all. + if (gaps.some(g => g < 0)) continue + const spread = Math.max(...gaps) - Math.min(...gaps) + if (spread <= GAP_SPREAD_PX) continue + const median = [...gaps].sort((a, b) => a - b)[Math.floor(gaps.length / 2)] + // Re-lay the run: first shape stays, each next sits `median` past the last. + const moves: string[] = [] + let cursor = start(group[0]) + size(group[0]) + for (let i = 1; i < group.length; i++) { + const g = group[i] + const delta = cursor + median - start(g) + cursor = start(g) + delta + size(g) + if (Math.abs(delta) < 0.5) continue + const toX = axis === 'row' ? g.shape.x + delta : g.shape.x + const toY = axis === 'row' ? g.shape.y : g.shape.y + delta + moves.push(`moi scratch move ${g.shape.id} --to ${fmtXY(toX, toY)}`) + } + findings.push({ + code: 'uneven-gaps', + severity: 'warn', + ids: group.map(g => g.shape.id), + message: + `uneven ${axis === 'row' ? 'horizontal' : 'vertical'} gaps in ${axis} ` + + `${group.map(g => `"${g.shape.id}"`).join(', ')} ` + + `(${gaps.map(g => fmtN(g)).join('px, ')}px)`, + ...(moves.length > 0 ? { fix: moves.join('; ') } : {}) + }) + } + } + + detect( + 'row', + s => [s.b.y, s.b.y + s.b.h], + s => s.b.x, + s => s.b.w + ) + detect( + 'column', + s => [s.b.x, s.b.x + s.b.w], + s => s.b.y, + s => s.b.h + ) + return findings.slice(0, MAX_WARN_FINDINGS) +} + +// ---- entry --------------------------------------------------------------------------- + +export async function lintScratchpad(workspacePath: string): Promise { + const { shapes } = await readScratchpadRecords(workspacePath) + // Arrows have no static bounds and freehand strokes are annotation — geometry + // checks run over the "layout" shapes: rects, notes, text, images. + const boxed: Boxed[] = [] + for (const shape of shapes) { + if (shape.type === 'arrow' || shape.type === 'draw') continue + const b = scratchShapeBounds(shape) + if (b && b.w > 0 && b.h > 0) boxed.push({ shape, b }) + } + + return [ + ...checkTextOverflow(boxed), + ...checkOverlap(boxed), + ...checkNearMisalign(boxed), + ...checkUnevenGaps(boxed) + ] +} diff --git a/server/scratchpad-relay.ts b/server/scratchpad-relay.ts index 404c89bc..7b6f6889 100644 --- a/server/scratchpad-relay.ts +++ b/server/scratchpad-relay.ts @@ -17,6 +17,11 @@ type Pending = { const pendingOps = new Map() const RELAY_TIMEOUT_MS = 10_000 +// The exact failure a relay times out with. Exported so `view`'s headless +// fallback (server/control.ts) can catch precisely this case — no tab showing +// the canvas — and not swallow real render errors from a live tab. +export const NO_LIVE_CANVAS = 'No live canvas — open the Scratchpad tab for this workspace.' + // Relay one op and await the first tab's reply. Rejects after a timeout when no // tab answers — i.e. no tab is showing this workspace's Scratchpad. (Add ops // must already carry a `name`, assigned by the caller, so execution is @@ -26,7 +31,7 @@ export function relayScratchOp(workspaceId: string, op: ScratchOp): Promise((resolve, reject) => { const timer = setTimeout(() => { pendingOps.delete(opId) - reject(new Error('No live canvas — open the Scratchpad tab for this workspace.')) + reject(new Error(NO_LIVE_CANVAS)) }, RELAY_TIMEOUT_MS) pendingOps.set(opId, { resolve, reject, timer }) broadcastAll({ type: 'scratchpad:op', workspaceId, opId, op }) diff --git a/server/scratchpad-render.ts b/server/scratchpad-render.ts new file mode 100644 index 00000000..00c913bd --- /dev/null +++ b/server/scratchpad-render.ts @@ -0,0 +1,647 @@ +import envPaths from 'env-paths' +import { mkdir } from 'node:fs/promises' +import { join } from 'path' + +import { Resvg } from '@resvg/resvg-js' +import sharp from 'sharp' +import { decompress } from 'wawoff2' + +import type { ScratchColor } from '@/lib/types' + +import { SCRATCH_COLOR_HEX } from '@/lib/scratch-palette' + +import { + type ScratchArrowBinding, + type ScratchBounds, + type ScratchRecord, + labelFontSize, + readScratchpadRecords, + scratchShapeBounds +} from './scratchpad' +import { + ARROW_LABEL_FONT_SIZES, + LABEL_PADDING, + LINE_HEIGHT, + TEXT_FONT_SIZES, + textBlockSize, + wrapText +} from './scratchpad-metrics' + +// Server-side Scratchpad renderer: our own SVG emitter for the primitive shape +// set, rasterized by resvg with the real canvas font. This is the always-on half +// of `moi scratch view` — with no live browser tab, the agent still gets pixels. +// It is a *layout-feedback approximation*, not tldraw's painter: geometry, text +// wrap, and color are faithful; the hand-drawn stroke texture is not. Anything +// outside the primitive set renders as a labeled placeholder rather than failing +// the whole view. + +// ---- Font ---------------------------------------------------------------------- + +// resvg's fontdb can't read woff2, so the canvas font (Shantell Sans Informal, +// shipped in @tldraw/assets) is decompressed to TTF once and cached in moi's +// cache dir, keyed by the @tldraw/assets version so upgrades re-derive it. +const FONT_FAMILY = 'Shantell Sans Informal' +const CACHE_DIR = envPaths('moi', { suffix: false }).cache + +let fontTtfPath: Promise | undefined +function ensureFontTtf(): Promise { + fontTtfPath ??= (async () => { + const woff2Path = Bun.resolveSync( + '@tldraw/assets/fonts/Shantell_Sans-Informal_Regular.woff2', + import.meta.dir + ) + // The package version rides in the sibling package.json of the fonts dir — + // resolving the woff2 already pinned us inside the installed package. + let version = '0' + try { + const pkg = await Bun.file(join(woff2Path, '..', '..', 'package.json')).json() + if (typeof pkg.version === 'string') version = pkg.version + } catch {} + const ttfPath = join(CACHE_DIR, 'fonts', `shantell-${version}.ttf`) + if (!(await Bun.file(ttfPath).exists())) { + const ttf = await decompress(new Uint8Array(await Bun.file(woff2Path).arrayBuffer())) + await mkdir(join(CACHE_DIR, 'fonts'), { recursive: true }) + await Bun.write(ttfPath, ttf) + } + return ttfPath + })() + return fontTtfPath +} + +// ---- Colors -------------------------------------------------------------------- + +const BACKGROUND = '#f9fafb' // tldraw light-theme canvas +const PLACEHOLDER_GREY = '#9fa8b2' + +// tldraw's stroke widths per size token (STROKE_SIZES in its shape constants). +const STROKE_WIDTHS: Record = { s: 2, m: 3.5, l: 5, xl: 10 } + +function paletteHex(props: Record): string { + const color = props.color + return ( + SCRATCH_COLOR_HEX[(typeof color === 'string' ? color : 'black') as ScratchColor] ?? + SCRATCH_COLOR_HEX.black + ) +} + +function strokeWidth(props: Record): number { + const size = props.size + return STROKE_WIDTHS[typeof size === 'string' ? size : 'm'] ?? STROKE_WIDTHS.m +} + +// Mix a palette hex toward white (positive ratio) or black (negative) — +// approximates tldraw's light "semi" fill tints, pastel note bodies, and +// darkened note ink without carrying its full theme tables. +function whiteMix(hex: string, ratio: number): string { + const n = parseInt(hex.slice(1), 16) + const target = ratio >= 0 ? 255 : 0 + const t = Math.abs(ratio) + const mix = (c: number) => Math.round(c * (1 - t) + target * t) + const [r, g, b] = [mix((n >> 16) & 0xff), mix((n >> 8) & 0xff), mix(n & 0xff)] + return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}` +} + +// ---- SVG helpers ---------------------------------------------------------------- + +function esc(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +const fmt = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)) + +type TextBlockOptions = { + lines: string[] + // The box the block is aligned within. + box: ScratchBounds + fontSize: number + fill: string + align: 'start' | 'middle' | 'end' + verticalAlign: 'start' | 'middle' | 'end' + // Halo the glyphs in the background color, so labels stay readable over lines. + halo?: boolean +} + +// Emit one wrapped text block as per-line elements. resvg has no CSS +// layout, so line positions are computed here with the same LINE_HEIGHT the +// measurement module uses — what lint measures is what renders. +function textBlockSvg(opts: TextBlockOptions): string { + const { lines, box, fontSize, fill, align, verticalAlign } = opts + const lineH = fontSize * LINE_HEIGHT + const totalH = lines.length * lineH + const top = + verticalAlign === 'start' + ? box.y + : verticalAlign === 'end' + ? box.y + box.h - totalH + : box.y + (box.h - totalH) / 2 + const anchorX = align === 'start' ? box.x : align === 'end' ? box.x + box.w : box.x + box.w / 2 + const anchor = align === 'start' ? 'start' : align === 'end' ? 'end' : 'middle' + const halo = opts.halo + ? ` stroke="${BACKGROUND}" stroke-width="${fmt(fontSize / 4)}" paint-order="stroke" stroke-linejoin="round"` + : '' + const out: string[] = [] + for (let i = 0; i < lines.length; i++) { + if (lines[i].length === 0) continue + // Baseline ≈ 0.35em above the line box's vertical center — close enough to + // Shantell's metrics for layout feedback. + const baseline = top + i * lineH + lineH / 2 + fontSize * 0.35 + out.push( + `` + + `${esc(lines[i])}` + ) + } + return out.join('\n') +} + +function alignOf(props: Record, key: 'align' | 'verticalAlign' | 'textAlign') { + const v = props[key] + return v === 'start' || v === 'end' ? v : ('middle' as const) +} + +function rotationAttr(shape: ScratchRecord, bounds: ScratchBounds): string { + if (!shape.rotation) return '' + const deg = (shape.rotation * 180) / Math.PI + const cx = bounds.x + bounds.w / 2 + const cy = bounds.y + bounds.h / 2 + return ` transform="rotate(${fmt(deg)} ${fmt(cx)} ${fmt(cy)})"` +} + +// Grey dashed stand-in for anything outside the primitive set — the agent still +// sees that *something* occupies the space, labeled with its type. +function placeholderSvg(label: string, b: ScratchBounds): string { + const box = + `` + const text = textBlockSvg({ + lines: [label], + box: { ...b, w: Math.max(b.w, 40), h: Math.max(b.h, 24) }, + fontSize: 14, + fill: PLACEHOLDER_GREY, + align: 'middle', + verticalAlign: 'middle' + }) + return `${box}\n${text}` +} + +// ---- Fills (rect interiors) ------------------------------------------------------- + +// Map a shape's tldraw fill style onto SVG paint. 'solid' is the light tint and +// 'fill' the true opaque body — the naming quirk documented on ScratchFill. +// 'pattern' references a per-color hatch collected into . +function rectFill(props: Record, hex: string, patterns: Set): string { + switch (props.fill) { + case 'solid': + return whiteMix(hex, 0.8) + case 'fill': + return hex + case 'pattern': { + const color = typeof props.color === 'string' ? props.color : 'black' + patterns.add(color) + return `url(#hatch-${color})` + } + default: + return 'none' + } +} + +function hatchDefs(patterns: Set): string { + if (patterns.size === 0) return '' + const defs = [...patterns].map(color => { + const hex = SCRATCH_COLOR_HEX[color as ScratchColor] ?? SCRATCH_COLOR_HEX.black + return ( + `` + + `` + + `` + ) + }) + return `${defs.join('')}` +} + +// ---- Arrows --------------------------------------------------------------------- + +type ArrowGeometry = { + // The rendered polyline/curve control points: [start, ...bends, end]. + points: { x: number; y: number }[] + // Quadratic bézier control for the arc kind (undefined = straight/elbow). + ctrl?: { x: number; y: number } + mid: { x: number; y: number } +} + +type Pt = { x: number; y: number } + +const sub = (a: Pt, b: Pt): Pt => ({ x: a.x - b.x, y: a.y - b.y }) +const len = (v: Pt) => Math.hypot(v.x, v.y) +const norm = (v: Pt): Pt => { + const l = len(v) || 1 + return { x: v.x / l, y: v.y / l } +} + +// Where the segment from `from` toward `center` (inside `rect`) crosses the rect +// border, backed off by `standoff` px so the arrowhead sits just outside. +function clipToRect(from: Pt, center: Pt, rect: ScratchBounds, standoff: number): Pt { + const d = sub(center, from) + // Parametric entry point: the largest t in [0,1] where the point is still + // outside on some axis. Standard slab test against each of the four edges. + let tEnter = 0 + if (d.x !== 0) { + const t1 = (rect.x - from.x) / d.x + const t2 = (rect.x + rect.w - from.x) / d.x + tEnter = Math.max(tEnter, Math.min(t1, t2)) + } + if (d.y !== 0) { + const t1 = (rect.y - from.y) / d.y + const t2 = (rect.y + rect.h - from.y) / d.y + tEnter = Math.max(tEnter, Math.min(t1, t2)) + } + tEnter = Math.min(Math.max(tEnter, 0), 1) + const hit = { x: from.x + d.x * tEnter, y: from.y + d.y * tEnter } + const dir = norm(d) + return { x: hit.x - dir.x * standoff, y: hit.y - dir.y * standoff } +} + +const ARROW_STANDOFF = 6 + +// Resolve an arrow's drawable geometry: bound terminals land on the target's +// border (center-aimed, clipped, 6px standoff); free terminals sit at their +// stored point. Elbow arrows route orthogonally; arc arrows bow perpendicular. +function arrowGeometry( + shape: ScratchRecord, + bindings: ScratchArrowBinding[], + boundsById: Map +): ArrowGeometry | null { + const p = shape.props as { start?: Pt; end?: Pt; bend?: unknown; kind?: unknown } + const terminalRect = (terminal: 'start' | 'end'): ScratchBounds | undefined => { + const b = bindings.find(b => b.arrowId === shape.id && b.terminal === terminal) + return b ? boundsById.get(b.targetId) : undefined + } + const startRect = terminalRect('start') + const endRect = terminalRect('end') + const center = (r: ScratchBounds): Pt => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }) + const free = (t?: Pt): Pt => ({ x: shape.x + (t?.x ?? 0), y: shape.y + (t?.y ?? 0) }) + let start = startRect ? center(startRect) : free(p.start) + let end = endRect ? center(endRect) : free(p.end) + if (len(sub(end, start)) < 1) return null + + if (p.kind === 'elbow') { + // H-V(-H) or V-H(-V) around the dominant axis, then clip the end segments + // (each is axis-aligned, so the border hit stays on the route). + const d = sub(end, start) + const route: Pt[] = + Math.abs(d.x) >= Math.abs(d.y) + ? [ + start, + { x: (start.x + end.x) / 2, y: start.y }, + { x: (start.x + end.x) / 2, y: end.y }, + end + ] + : [ + start, + { x: start.x, y: (start.y + end.y) / 2 }, + { x: end.x, y: (start.y + end.y) / 2 }, + end + ] + if (startRect) route[0] = clipToRect(route[1], route[0], startRect, ARROW_STANDOFF) + if (endRect) { + route[route.length - 1] = clipToRect( + route[route.length - 2], + route[route.length - 1], + endRect, + ARROW_STANDOFF + ) + } + const mid = { + x: (route[1].x + route[2].x) / 2, + y: (route[1].y + route[2].y) / 2 + } + return { points: route, mid } + } + + // Arc: clip the straight chord first, then bow it. tldraw's `bend` is the + // perpendicular offset at the midpoint; a hair of bow (8% of length) stands in + // for tldraw's hand-drawn feel when bend is zero. + if (startRect) start = clipToRect(end, start, startRect, ARROW_STANDOFF) + if (endRect) end = clipToRect(start, end, endRect, ARROW_STANDOFF) + const d = sub(end, start) + const l = len(d) + const bend = typeof p.bend === 'number' && p.bend !== 0 ? p.bend : l * 0.08 + const perp = { x: -d.y / (l || 1), y: d.x / (l || 1) } + const mid = { x: (start.x + end.x) / 2 + perp.x * bend, y: (start.y + end.y) / 2 + perp.y * bend } + // Quadratic control that makes the curve pass through `mid` at t=0.5. + const ctrl = { + x: 2 * mid.x - (start.x + end.x) / 2, + y: 2 * mid.y - (start.y + end.y) / 2 + } + return { points: [start, end], ctrl, mid } +} + +function arrowheadSvg(tip: Pt, back: Pt, sw: number, hex: string): string { + const dir = norm(sub(tip, back)) + const size = Math.max(sw * 3, 9) + const base = { x: tip.x - dir.x * size, y: tip.y - dir.y * size } + const perp = { x: -dir.y, y: dir.x } + const half = size * 0.55 + const a = { x: base.x + perp.x * half, y: base.y + perp.y * half } + const b = { x: base.x - perp.x * half, y: base.y - perp.y * half } + return ( + `` + ) +} + +function arrowSvg(shape: ScratchRecord, geo: ArrowGeometry): string { + const hex = paletteHex(shape.props) + const sw = strokeWidth(shape.props) + const p = shape.props as { arrowheadStart?: unknown; arrowheadEnd?: unknown; size?: unknown } + const pts = geo.points + const path = geo.ctrl + ? `M ${fmt(pts[0].x)} ${fmt(pts[0].y)} Q ${fmt(geo.ctrl.x)} ${fmt(geo.ctrl.y)} ` + + `${fmt(pts[1].x)} ${fmt(pts[1].y)}` + : `M ${pts.map(q => `${fmt(q.x)} ${fmt(q.y)}`).join(' L ')}` + const parts = [ + `` + ] + // Default arrowheads: none at the start, a triangle at the end. + const end = pts[pts.length - 1] + const beforeEnd = geo.ctrl ?? pts[pts.length - 2] + if (p.arrowheadEnd !== 'none') parts.push(arrowheadSvg(end, beforeEnd, sw, hex)) + const beforeStart = geo.ctrl ?? pts[1] + if (p.arrowheadStart && p.arrowheadStart !== 'none') { + parts.push(arrowheadSvg(pts[0], beforeStart, sw, hex)) + } + if (shape.text) { + const size = (shape.props as { size?: unknown }).size + const fontSize = + ARROW_LABEL_FONT_SIZES[typeof size === 'string' ? size : 'm'] ?? ARROW_LABEL_FONT_SIZES.m + const block = textBlockSize(shape.text, fontSize) + parts.push( + textBlockSvg({ + lines: block.lines, + box: { x: geo.mid.x - block.w / 2, y: geo.mid.y - block.h / 2, w: block.w, h: block.h }, + fontSize, + fill: hex, + align: 'middle', + verticalAlign: 'middle', + halo: true + }) + ) + } + return parts.join('\n') +} + +// ---- Shape emitters -------------------------------------------------------------- + +function geoSvg(shape: ScratchRecord, bounds: ScratchBounds, patterns: Set): string { + const props = shape.props + const hex = paletteHex(props) + const sw = strokeWidth(props) + const fill = rectFill(props, hex, patterns) + const rot = rotationAttr(shape, bounds) + const kind = (props as { geo?: unknown }).geo + let body: string + if (kind === 'rectangle' || kind === undefined) { + body = + `` + } else if (kind === 'ellipse') { + body = + `` + } else { + return placeholderSvg(`geo:${String(kind)}`, bounds) + } + if (!shape.text) return body + const fontSize = labelFontSize(shape) + const lines = wrapText(shape.text, fontSize, Math.max(bounds.w - 2 * LABEL_PADDING, fontSize)) + const inner = { + x: bounds.x + LABEL_PADDING, + y: bounds.y + LABEL_PADDING, + w: bounds.w - 2 * LABEL_PADDING, + h: bounds.h - 2 * LABEL_PADDING + } + const label = textBlockSvg({ + lines, + box: inner, + fontSize, + fill: SCRATCH_COLOR_HEX.black, + align: alignOf(props, 'align'), + verticalAlign: alignOf(props, 'verticalAlign') + }) + return `${body}\n${label}` +} + +function noteSvg(shape: ScratchRecord, bounds: ScratchBounds): string { + const hex = paletteHex(shape.props) + const rot = rotationAttr(shape, bounds) + const body = + `` + if (!shape.text) return body + const fontSize = labelFontSize(shape) + const lines = wrapText(shape.text, fontSize, Math.max(bounds.w - 2 * LABEL_PADDING, fontSize)) + const label = textBlockSvg({ + lines, + box: { + x: bounds.x + LABEL_PADDING, + y: bounds.y + LABEL_PADDING, + w: bounds.w - 2 * LABEL_PADDING, + h: bounds.h - 2 * LABEL_PADDING + }, + fontSize, + // Ink is the palette hex pulled toward black, so it reads over the pastel + // body even for the light colors (yellow, grey). + fill: whiteMix(hex, -0.45), + align: alignOf(shape.props, 'align'), + verticalAlign: alignOf(shape.props, 'verticalAlign') + }) + return `${body}\n${label}` +} + +function textSvg(shape: ScratchRecord, bounds: ScratchBounds): string { + if (!shape.text) return '' + const props = shape.props as { size?: unknown; autoSize?: unknown; w?: unknown } + const fontSize = + TEXT_FONT_SIZES[typeof props.size === 'string' ? props.size : 'm'] ?? TEXT_FONT_SIZES.m + // autoSize text keeps its natural lines; fixed-width text wraps like the browser. + const lines = + props.autoSize === false && typeof props.w === 'number' + ? wrapText(shape.text, fontSize, Math.max(props.w, fontSize)) + : shape.text.split('\n') + return textBlockSvg({ + lines, + box: bounds, + fontSize, + fill: paletteHex(shape.props), + align: alignOf(shape.props, 'textAlign'), + verticalAlign: 'start' + }) +} + +function drawSvg(shape: ScratchRecord): string { + const hex = paletteHex(shape.props) + const sw = strokeWidth(shape.props) + const segments = (shape.props as { segments?: unknown }).segments + const pts: Pt[] = [] + if (Array.isArray(segments)) { + for (const seg of segments) { + const points = (seg as { points?: unknown })?.points + if (!Array.isArray(points)) continue + for (const pt of points) { + const q = pt as { x?: unknown; y?: unknown } + if (typeof q.x === 'number' && typeof q.y === 'number') { + pts.push({ x: shape.x + q.x, y: shape.y + q.y }) + } + } + } + } + if (pts.length === 0) return '' + // A single tap leaves one point — draw a dot instead of an empty polyline. + if (pts.length === 1) { + return `` + } + const points = pts.map(q => `${fmt(q.x)},${fmt(q.y)}`).join(' ') + const closed = (shape.props as { isClosed?: unknown }).isClosed === true + const tag = closed ? 'polygon' : 'polyline' + return ( + `<${tag} points="${points}" fill="none" stroke="${hex}" stroke-width="${fmt(sw)}" ` + + `stroke-linecap="round" stroke-linejoin="round"/>` + ) +} + +// resvg decodes png/jpeg/gif but not webp — and tldraw embeds pasted images as +// webp data URLs — so webp is transcoded to a PNG data URL via sharp. Remote +// URLs can't be fetched here; they fall back to a placeholder. +async function imageSvg( + shape: ScratchRecord, + bounds: ScratchBounds, + assets: Map +): Promise { + const assetId = (shape.props as { assetId?: unknown }).assetId + const src = typeof assetId === 'string' ? assets.get(assetId) : undefined + if (!src || !src.startsWith('data:')) return placeholderSvg('image', bounds) + let href = src + if (src.startsWith('data:image/webp')) { + const b64 = src.slice(src.indexOf(',') + 1) + const png = await sharp(Buffer.from(b64, 'base64')).png().toBuffer() + href = `data:image/png;base64,${png.toString('base64')}` + } + return ( + `` + ) +} + +// ---- Renderer -------------------------------------------------------------------- + +const CANVAS_PADDING = 48 +const MAX_SIDE = 2048 + +export async function renderScratchpadPng(workspacePath: string): Promise { + const { shapes, bindings, assets } = await readScratchpadRecords(workspacePath) + if (shapes.length === 0) throw new Error('Canvas is empty') + + // z-order: fractional indexes sort lexicographically. + const ordered = [...shapes].sort((a, b) => (a.index < b.index ? -1 : a.index > b.index ? 1 : 0)) + + const boundsById = new Map() + for (const shape of ordered) { + const b = scratchShapeBounds(shape) + if (b) boundsById.set(shape.id, b) + } + + // Canvas bbox: every shape's bounds, plus arrow terminals (arrows have no + // static bounds of their own). + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + const extend = (x: number, y: number) => { + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + } + const arrowGeos = new Map() + for (const shape of ordered) { + if (shape.type === 'arrow') { + const geo = arrowGeometry(shape, bindings, boundsById) + if (geo) { + arrowGeos.set(shape.id, geo) + for (const q of geo.points) extend(q.x, q.y) + if (geo.ctrl) extend(geo.ctrl.x, geo.ctrl.y) + } + continue + } + const b = boundsById.get(shape.id) + if (b) { + extend(b.x, b.y) + extend(b.x + b.w, b.y + b.h) + } + } + if (minX === Infinity) throw new Error('Canvas is empty') + + minX -= CANVAS_PADDING + minY -= CANVAS_PADDING + const width = Math.ceil(maxX - minX + CANVAS_PADDING) + const height = Math.ceil(maxY - minY + CANVAS_PADDING) + + const patterns = new Set() + const body: string[] = [] + for (const shape of ordered) { + const bounds = boundsById.get(shape.id) + switch (shape.type) { + case 'geo': + body.push(geoSvg(shape, bounds!, patterns)) + break + case 'note': + body.push(noteSvg(shape, bounds!)) + break + case 'text': + body.push(textSvg(shape, bounds!)) + break + case 'draw': + body.push(drawSvg(shape)) + break + case 'image': + body.push(await imageSvg(shape, bounds!, assets)) + break + case 'arrow': { + const geo = arrowGeos.get(shape.id) + if (geo) body.push(arrowSvg(shape, geo)) + break + } + default: + body.push(placeholderSvg(shape.type, bounds!)) + } + } + + const svg = + `` + + `${hatchDefs(patterns)}` + + `` + + `${body.join('\n')}` + + const ttfPath = await ensureFontTtf() + const resvg = new Resvg(svg, { + font: { fontFiles: [ttfPath], loadSystemFonts: false, defaultFontFamily: FONT_FAMILY }, + // Cap the long side — a sprawling canvas rasterizes to a bounded PNG. + ...(Math.max(width, height) > MAX_SIDE + ? { + fitTo: { + mode: width >= height ? ('width' as const) : ('height' as const), + value: MAX_SIDE + } + } + : {}) + }) + return new Uint8Array(resvg.render().asPng()) +} diff --git a/server/scratchpad.ts b/server/scratchpad.ts index 9dcbeb39..2516ba59 100644 --- a/server/scratchpad.ts +++ b/server/scratchpad.ts @@ -1,5 +1,7 @@ import { join } from 'path' +import { LABEL_FONT_SIZES, TEXT_FONT_SIZES, textBlockSize } from './scratchpad-metrics' + // The Scratchpad is a shared tldraw canvas per workspace, persisted as a tldraw // *document* snapshot here (the per-tab `session` is intentionally dropped). Two // writers: the browser autosaves on user edits, and the server writes on agent @@ -62,8 +64,10 @@ function omitBase64(text: string): string { // Pull readable text out of a shape's props. tldraw stores labels as `richText` // (a ProseMirror-style doc) on most shapes; older/simple shapes may use a flat -// `text` string. Best-effort — never throw on an unexpected shape. -function extractText(props: unknown): string | undefined { +// `text` string. Paragraph boundaries become newlines, so multi-line labels +// round-trip through measurement and rendering. Best-effort — never throw on an +// unexpected shape. Shared by `read`, the lint checks, and the renderer. +export function extractShapeText(props: unknown): string | undefined { if (!props || typeof props !== 'object') return undefined const p = props as { text?: unknown; richText?: unknown } if (typeof p.text === 'string' && p.text.length > 0) return p.text @@ -73,7 +77,15 @@ function extractText(props: unknown): string | undefined { if (!node || typeof node !== 'object') return const n = node as { type?: string; text?: unknown; content?: unknown } if (n.type === 'text' && typeof n.text === 'string') out.push(n.text) - if (Array.isArray(n.content)) n.content.forEach(walk) + if (Array.isArray(n.content)) { + n.content.forEach((child, i) => { + walk(child) + const c = child as { type?: string } + if (c && c.type === 'paragraph' && i < (n.content as unknown[]).length - 1) { + out.push('\n') + } + }) + } } walk(p.richText) const joined = out.join('').trim() @@ -161,7 +173,7 @@ export async function readScratchpadShapes(workspacePath: string): Promise { - const text = extractText(r.props) + const text = extractShapeText(r.props) return text !== undefined ? { text: omitBase64(text) } : {} })(), ...(rawSrc !== undefined ? { src: omitBase64(rawSrc) } : {}) @@ -169,3 +181,175 @@ export async function readScratchpadShapes(workspacePath: string): Promise + text?: string +} + +// An arrow terminal bound to a shape (ids stripped of their prefixes). +export type ScratchArrowBinding = { arrowId: string; targetId: string; terminal: 'start' | 'end' } + +export type ScratchRecords = { + shapes: ScratchRecord[] + bindings: ScratchArrowBinding[] + // assetId → src (data or https URL), for image shapes' `props.assetId`. + assets: Map +} + +export async function readScratchpadRecords(workspacePath: string): Promise { + const { document } = await loadScratchpadDoc(workspacePath) + const store = document?.store + const out: ScratchRecords = { shapes: [], bindings: [], assets: new Map() } + if (!store || typeof store !== 'object') return out + + for (const record of Object.values(store)) { + if (!record || typeof record !== 'object') continue + const r = record as { + typeName?: string + id?: string + type?: string + x?: number + y?: number + rotation?: number + index?: string + props?: Record + fromId?: string + toId?: string + } + if (r.typeName === 'asset' && typeof r.id === 'string') { + const src = (r.props as { src?: unknown } | undefined)?.src + if (typeof src === 'string') out.assets.set(r.id, src) + continue + } + if (r.typeName === 'binding' && r.type === 'arrow') { + const terminal = (r.props as { terminal?: unknown } | undefined)?.terminal + if ( + typeof r.fromId === 'string' && + typeof r.toId === 'string' && + (terminal === 'start' || terminal === 'end') + ) { + out.bindings.push({ + arrowId: r.fromId.replace(/^shape:/, ''), + targetId: r.toId.replace(/^shape:/, ''), + terminal + }) + } + continue + } + if (r.typeName !== 'shape') continue + const props = r.props && typeof r.props === 'object' ? r.props : {} + out.shapes.push({ + id: (r.id ?? '').replace(/^shape:/, ''), + type: r.type ?? 'unknown', + x: typeof r.x === 'number' ? r.x : 0, + y: typeof r.y === 'number' ? r.y : 0, + rotation: typeof r.rotation === 'number' ? r.rotation : 0, + index: typeof r.index === 'string' ? r.index : 'a1', + props, + ...(() => { + const text = extractShapeText(props) + return text !== undefined ? { text } : {} + })() + }) + } + return out +} + +export type ScratchBounds = { x: number; y: number; w: number; h: number } + +// tldraw's fixed sticky-note edge (NOTE_SIZE); notes have no w/h props. +const NOTE_SIZE = 200 + +// Axis-aligned bounds of one shape, without a browser: fixed props where tldraw +// stores them, measured text where it doesn't. Rotation is ignored (best-effort — +// lint geometry and the renderer's canvas bbox only need the unrotated frame). +// Arrows return null: their extent depends on resolved terminals, which the +// renderer computes itself. +export function scratchShapeBounds(shape: ScratchRecord): ScratchBounds | null { + const p = shape.props as { + w?: unknown + h?: unknown + growY?: unknown + size?: unknown + autoSize?: unknown + segments?: unknown + } + const growY = typeof p.growY === 'number' ? p.growY : 0 + switch (shape.type) { + case 'geo': + case 'image': { + const w = typeof p.w === 'number' ? p.w : 100 + const h = typeof p.h === 'number' ? p.h : 100 + return { x: shape.x, y: shape.y, w, h: h + growY } + } + case 'note': + return { x: shape.x, y: shape.y, w: NOTE_SIZE, h: NOTE_SIZE + growY } + case 'text': { + // autoSize text flows to its natural line widths; the persisted `w` is + // stale for headless-created shapes, so measure instead. Fixed-width text + // wraps at `w`. + const fontSize = + TEXT_FONT_SIZES[typeof p.size === 'string' ? p.size : 'm'] ?? TEXT_FONT_SIZES.m + const text = shape.text ?? '' + const block = + p.autoSize === false && typeof p.w === 'number' + ? textBlockSize(text, fontSize, p.w) + : textBlockSize(text, fontSize) + const w = p.autoSize === false && typeof p.w === 'number' ? p.w : block.w + return { x: shape.x, y: shape.y, w, h: block.h } + } + case 'draw': { + // Freehand: the bbox of every segment point, offset by the shape origin. + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + const segments = Array.isArray(p.segments) ? p.segments : [] + for (const seg of segments) { + const points = (seg as { points?: unknown })?.points + if (!Array.isArray(points)) continue + for (const pt of points) { + const q = pt as { x?: unknown; y?: unknown } + if (typeof q.x !== 'number' || typeof q.y !== 'number') continue + minX = Math.min(minX, q.x) + minY = Math.min(minY, q.y) + maxX = Math.max(maxX, q.x) + maxY = Math.max(maxY, q.y) + } + } + if (minX === Infinity) return { x: shape.x, y: shape.y, w: 0, h: 0 } + return { x: shape.x + minX, y: shape.y + minY, w: maxX - minX, h: maxY - minY } + } + case 'arrow': + return null + default: { + // Unknown shape kinds still occupy space when they carry w/h; otherwise + // give them a nominal footprint so bbox and lint don't lose them. + const w = typeof p.w === 'number' ? p.w : 100 + const h = typeof p.h === 'number' ? p.h : 100 + return { x: shape.x, y: shape.y, w, h } + } + } +} + +// Label font size for a shape that centers text inside itself (geo, note). +export function labelFontSize(shape: ScratchRecord): number { + const size = (shape.props as { size?: unknown }).size + return LABEL_FONT_SIZES[typeof size === 'string' ? size : 'm'] ?? LABEL_FONT_SIZES.m +} diff --git a/server/test/scratchpad-lint.test.ts b/server/test/scratchpad-lint.test.ts new file mode 100644 index 00000000..6458f397 --- /dev/null +++ b/server/test/scratchpad-lint.test.ts @@ -0,0 +1,138 @@ +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 { ScratchOp } from '@/lib/types' + +import { executeScratchOp } from '../scratchpad-executor' +import { loadScratchpadDoc } from '../scratchpad' +import { lintScratchpad } from '../scratchpad-lint' +import { LABEL_PADDING, fitRectToLabel } from '../scratchpad-metrics' + +// Lint reads geometry off the disk snapshot and measures text with the real +// canvas font — findings must be facts (with runnable fixes), and a clean +// canvas must stay silent. + +let WS: string +beforeEach(() => { + WS = mkdtempSync(join(import.meta.dir, 'scratch-lint-test-')) +}) +afterEach(() => { + rmSync(WS, { recursive: true, force: true }) +}) + +const run = (op: ScratchOp) => executeScratchOp(WS, 'ws-test', op) +const lint = () => lintScratchpad(WS) + +// Loads the persisted snapshot into a fresh store the way the browser does — +// mutations (resize) must never corrupt the document. +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 +} + +describe('lintScratchpad', () => { + test('flags an overflowing label with the fitRectToLabel resize as the fix', async () => { + const text = 'reverse proxy at localhost:3000 with TLS termination' + await run({ kind: 'add-rect', name: 'proxy', x: 0, y: 0, w: 140, h: 60, text }) + + const findings = await lint() + const overflow = findings.find(f => f.code === 'text-overflow') + expect(overflow).toBeDefined() + expect(overflow!.severity).toBe('error') + expect(overflow!.ids).toEqual(['proxy']) + + // The fix is exactly what fitRectToLabel computes for this rect (default + // 'm' label size; wrap target never narrower than 240 or the current rect). + const fit = fitRectToLabel(text, { + size: 'm', + targetWidth: Math.max(140 - 2 * LABEL_PADDING, 240), + minW: 140 + }) + expect(overflow!.fix).toBe(`moi scratch resize proxy --size ${fit.w},${fit.h}`) + }) + + test('resize applies the fix and clears the overflow finding', async () => { + const text = 'reverse proxy at localhost:3000 with TLS termination' + await run({ kind: 'add-rect', name: 'proxy', x: 0, y: 0, w: 140, h: 60, text }) + const before = await lint() + const overflow = before.find(f => f.code === 'text-overflow') + expect(overflow).toBeDefined() + + const [, w, h] = overflow!.fix!.match(/--size (\d+),(\d+)/)! + await run({ kind: 'resize', name: 'proxy', w: Number(w), h: Number(h) }) + expect(await assertLoadable()).toBe(1) + + const after = await lint() + expect(after.find(f => f.code === 'text-overflow')).toBeUndefined() + }) + + test('resize rejects shapes that size themselves', async () => { + await run({ kind: 'add-note', name: 'sticky', x: 0, y: 0, text: 'hi' }) + await expect(run({ kind: 'resize', name: 'sticky', w: 100, h: 100 })).rejects.toThrow( + /Only rectangles and images/ + ) + }) + + test('flags partial overlap with a concrete separating move', async () => { + await run({ kind: 'add-rect', name: 'a', x: 0, y: 0, w: 100, h: 100 }) + await run({ kind: 'add-rect', name: 'b', x: 60, y: 10, w: 100, h: 100 }) + + const findings = await lint() + const overlap = findings.find(f => f.code === 'overlap') + expect(overlap).toBeDefined() + expect(overlap!.severity).toBe('error') + expect(overlap!.ids!.sort()).toEqual(['a', 'b']) + expect(overlap!.fix).toMatch(/^moi scratch move [ab] --to -?[\d.]+,-?[\d.]+$/) + // Overlapping pairs are never also nagged about alignment. + expect(findings.find(f => f.code === 'near-misalign')).toBeUndefined() + }) + + test('containment is grouping, not an overlap', async () => { + await run({ kind: 'add-rect', name: 'container', x: 0, y: 0, w: 400, h: 300 }) + await run({ kind: 'add-rect', name: 'member', x: 40, y: 40, w: 120, h: 80, text: 'svc' }) + + expect(await lint()).toEqual([]) + }) + + test('flags 4px-off top edges with the exact aligning move', async () => { + await run({ kind: 'add-rect', name: 'a', x: 0, y: 0, w: 100, h: 50 }) + await run({ kind: 'add-rect', name: 'b', x: 204, y: 4, w: 100, h: 50 }) + + const findings = await lint() + const misalign = findings.find(f => f.code === 'near-misalign') + expect(misalign).toBeDefined() + expect(misalign!.severity).toBe('warn') + expect(misalign!.message).toContain('top edges') + expect(misalign!.message).toContain('4px') + expect(misalign!.fix).toBe('moi scratch move b --to 204,0') + }) + + test('flags uneven gaps in a row, fixed with the median gap', async () => { + await run({ kind: 'add-rect', name: 'r1', x: 0, y: 0, w: 100, h: 50 }) + await run({ kind: 'add-rect', name: 'r2', x: 140, y: 0, w: 100, h: 50 }) // gap 40 + await run({ kind: 'add-rect', name: 'r3', x: 340, y: 0, w: 100, h: 50 }) // gap 100 + + const findings = await lint() + const gaps = findings.find(f => f.code === 'uneven-gaps') + expect(gaps).toBeDefined() + expect(gaps!.severity).toBe('warn') + expect(gaps!.ids).toEqual(['r1', 'r2', 'r3']) + expect(gaps!.fix).toContain('moi scratch move') + }) + + test('a clean, aligned canvas yields zero findings', async () => { + // A tidy row: same y, equal sizes, equal 80px gaps, labels that fit. + await run({ kind: 'add-rect', name: 'a', x: 0, y: 0, w: 160, h: 80, text: 'A' }) + await run({ kind: 'add-rect', name: 'b', x: 240, y: 0, w: 160, h: 80, text: 'B' }) + await run({ kind: 'add-rect', name: 'c', x: 480, y: 0, w: 160, h: 80, text: 'C' }) + await run({ kind: 'add-arrow', name: 'ab', from: { name: 'a' }, to: { name: 'b' } }) + await run({ kind: 'add-text', name: 'title', x: 0, y: -80, text: 'clean row' }) + + expect(await lint()).toEqual([]) + }) +}) diff --git a/server/test/scratchpad-render.test.ts b/server/test/scratchpad-render.test.ts new file mode 100644 index 00000000..204dd345 --- /dev/null +++ b/server/test/scratchpad-render.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'path' +import sharp from 'sharp' + +import type { ScratchOp } from '@/lib/types' + +import { executeScratchOp } from '../scratchpad-executor' +import { getScratchpadPath } from '../scratchpad' +import { renderScratchpadPng } from '../scratchpad-render' + +// The server-side view: canvases drawn through the headless executor must +// rasterize to a real PNG with no browser — approximate fidelity, exact layout. + +let WS: string +beforeEach(() => { + WS = mkdtempSync(join(import.meta.dir, 'scratch-render-test-')) +}) +afterEach(() => { + rmSync(WS, { recursive: true, force: true }) +}) + +const run = (op: ScratchOp) => executeScratchOp(WS, 'ws-test', op) + +const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47] + +function pngSize(png: Uint8Array): { w: number; h: number } { + // Width and height are big-endian u32s at offsets 16/20 of the IHDR chunk. + const view = new DataView(png.buffer, png.byteOffset, png.byteLength) + return { w: view.getUint32(16), h: view.getUint32(20) } +} + +describe('renderScratchpadPng', () => { + test('renders the primitive shape set to a plausible PNG', async () => { + await run({ kind: 'add-rect', name: 'box1', x: 0, y: 0, w: 160, h: 80, text: 'hello' }) + await run({ kind: 'add-rect', name: 'box2', x: 400, y: 200, w: 120, h: 80, text: 'world' }) + await run({ kind: 'add-note', name: 'note1', x: 240, y: 0, text: 'a note' }) + await run({ kind: 'add-text', name: 'label', x: 0, y: 240, text: 'free text' }) + await run({ + kind: 'add-arrow', + name: 'bound', + from: { name: 'box1' }, + to: { name: 'box2' }, + elbow: true + }) + await run({ kind: 'add-arrow', name: 'free', from: { x: 0, y: 400 }, to: { x: 200, y: 440 } }) + + const png = await renderScratchpadPng(WS) + expect([...png.slice(0, 4)]).toEqual(PNG_MAGIC) + + // Content spans x 0..520, y 0..440; plus 48px padding each side. The exact + // extent depends on measured text, so assert a sane envelope, not pixels. + const { w, h } = pngSize(png) + expect(w).toBeGreaterThan(520) + expect(w).toBeLessThan(1200) + expect(h).toBeGreaterThan(440) + expect(h).toBeLessThan(1000) + }) + + test('caps the long side at 2048px for a sprawling canvas', async () => { + await run({ kind: 'add-rect', name: 'a', x: 0, y: 0, w: 100, h: 100 }) + await run({ kind: 'add-rect', name: 'b', x: 6000, y: 0, w: 100, h: 100 }) + const { w, h } = pngSize(await renderScratchpadPng(WS)) + expect(w).toBe(2048) + expect(h).toBeLessThan(2048) + }) + + test('renders a webp image shape (transcoded for resvg)', async () => { + const file = join(WS, 'tiny.webp') + await sharp({ + create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 40, b: 90 } } + }) + .webp() + .toFile(file) + await run({ kind: 'add-image', name: 'pic', x: 0, y: 0, path: file }) + + const png = await renderScratchpadPng(WS) + expect([...png.slice(0, 4)]).toEqual(PNG_MAGIC) + }) + + test('an unsupported shape type renders as a placeholder, not a throw', async () => { + await run({ kind: 'add-rect', name: 'box', x: 0, y: 0, w: 100, h: 100 }) + // Splice a shape kind the renderer doesn't know into the snapshot — the + // browser can create types (frame, embed, …) the executor never makes. + const path = getScratchpadPath(WS) + const snapshot = await Bun.file(path).json() + snapshot.document.store['shape:weird'] = { + typeName: 'shape', + id: 'shape:weird', + type: 'frame', + x: 200, + y: 0, + rotation: 0, + index: 'a9', + parentId: snapshot.document.store['shape:box'].parentId, + isLocked: false, + opacity: 1, + meta: {}, + props: { w: 150, h: 90 } + } + await Bun.write(path, JSON.stringify(snapshot)) + + const png = await renderScratchpadPng(WS) + expect([...png.slice(0, 4)]).toEqual(PNG_MAGIC) + }) + + test('an empty canvas is a clear error, not a blank image', async () => { + await expect(renderScratchpadPng(WS)).rejects.toThrow('Canvas is empty') + }) +}) diff --git a/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md b/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md index 69cb293c..5515f83c 100644 --- a/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md +++ b/workspace/.claude/skills/moi-workspace/SCRATCHPAD.md @@ -15,9 +15,38 @@ clean, agent-friendly view. Treat it exactly like `.moi/.workspace.json` — CLI text. Off disk, so it works whether or not a browser tab is open. - `moi scratch read-image ` — save one image shape to a file (its actual bytes; `read` omits the blob). Off disk too. -- `moi scratch view` — render the whole canvas to a PNG. Needs an open Scratchpad tab. +- `moi scratch view` — render the whole canvas to a PNG and print the file path. **Always + works.** With a Scratchpad tab open you get the browser's exact pixels; without one the + server renders a faithful approximation (same font, same layout, plainer strokes) and notes + that on stderr. `--headless` forces the server renderer. Read the PNG to actually look at + what you (or the user) drew. +- `moi scratch lint` — check the canvas geometry for what makes a drawing read as sloppy: + - `text-overflow` (error) — a rect's label doesn't fit its box, measured with the real + canvas font. The fix is an exact `resize`. + - `overlap` (error) — two shapes collide (full containment counts as grouping and is fine). + - `near-misalign` (warn) — edges or centers off by ≤10px; they were probably meant to align. + - `uneven-gaps` (warn) — a row/column with inconsistent spacing. -`read` is for logic; `view` / `read-image` are for vision. + Every finding carries a ready-to-run fix command. `--json` for structured output. Advisory — + it always exits 0; findings, not failures. + +`read` is for logic; `view` / `read-image` are for vision; `lint` is for taste. + +## The loop: draw, lint, look + +Never draw blind and call it done. After laying out shapes: + +1. **`moi scratch lint`** — fix **every error** (run the suggested fix commands; they're + exact). Read the warnings and judge: a 4px misalignment is almost always a mistake, an + uneven gap sometimes isn't. +2. **`moi scratch view`** — read the PNG and eyeball it like a human would: does anything + overlap the title, crowd an edge, or float unanchored? Lint catches what geometry can + measure; the picture catches the rest. +3. Adjust (`move` / `resize` / `set`) and re-check until lint is clean and the picture looks + deliberate. + +Sizing boxes: don't guess label widths. If lint flags `text-overflow`, apply its resize — it's +computed from the actual rendered font, so the label fits exactly. ## Drawing @@ -28,6 +57,7 @@ moi scratch add note --at --text "..." [--id NAME] [--color C moi scratch add arrow --from --to [--id NAME] [--color C] [--stroke W] [--elbow] moi scratch add image [--at ] [--id NAME] [--quality lo|hi] moi scratch move --to +moi scratch resize --size # rects & images only moi scratch set --text "..." moi scratch delete moi scratch clear @@ -39,6 +69,8 @@ moi scratch clear they move. Endpoints can also be bare `x,y`. `--elbow` routes with right angles. - `add image` resizes to fit the canvas — `--quality lo` (default) or `hi` for more pixels — so a huge file never gets embedded whole. +- `resize` changes a rect's or image's size. Notes, text, and arrows size themselves — for a + rect whose label doesn't fit, use the exact size a `lint` `text-overflow` finding suggests. - `--color` is `black|red|yellow|green|blue|grey` or any hex (snapped to nearest). The other style flags mirror each shape's toolbar controls: `--fill` (rect) is `none|semi|pattern|solid`; `--font-size` (text, note, and a rect's label) is `regular|big`; `--stroke` (arrow) is diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index c0a05c8d..4f20e727 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. - +