diff --git a/optional-skills/creative/tldraw-offline/SKILL.md b/optional-skills/creative/tldraw-offline/SKILL.md new file mode 100644 index 00000000000..a721d2376cf --- /dev/null +++ b/optional-skills/creative/tldraw-offline/SKILL.md @@ -0,0 +1,273 @@ +--- +name: tldraw-offline +description: Drive and script tldraw offline canvases with an agent. +version: 1.0.0 +author: Teknium + Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [tldraw, canvas, whiteboard, document-script, diagramming] + category: creative + related_skills: [] +--- + +# tldraw offline Skill + +Work with the tldraw offline desktop app (offline.tldraw.com): read the open +canvas, make edits, and write **document scripts** — JavaScript embedded in a +`.tldraw` file that runs on load and gives the file durable behavior. The app +runs a **local HTTP API** (default `localhost:7236`) that a coding agent drives +with plain `curl` from its terminal — this is exactly how the app's own homepage +demo (Codex editing a canvas live) works. The agent does NOT use computer-use / +GUI clicking, and does NOT hand-edit the `.tldraw` file directly. Keep tldraw +offline open while you work. + +## When to Use + +- The user has tldraw offline open and asks you to build or modify a canvas + (diagrams, wireframes, layouts). +- You want to add durable behavior to a drawing (reactive shapes, interactive + buttons, animation, connection logic) via an embedded document script. + +Do NOT hand-place shapes to imitate a drawing — write the code that generates +them. Agents are far better at scripting the canvas than at drawing on it. + +## Prerequisites + +- **tldraw offline installed and running**, with a document open. Releases: + https://github.com/tldraw/tldraw-offline/releases/latest (macOS DMG, Windows + x64/Arm64, Linux `x86_64`/`arm64` AppImage or amd64/arm64 `.deb`). +- **Agent skills installed in the app**: `Develop → Install Agent Skills`. The + app writes its own tldraw skill into `~/.codex/skills/`, `~/.claude/skills/`, + `~/.cursor/skills/`, and `~/.gemini/skills/` — teaching that agent the `curl` + recipes below. (This Hermes skill mirrors that guidance for Hermes.) +- **The local control API.** On launch the app writes `server.json` to its config + dir (Linux `~/.config/tldraw/`, macOS `~/Library/Application Support/tldraw/`, + Windows `%APPDATA%\tldraw\`) with `port` (default `7236`), a bearer `token`, + `pid`, and `startedAt`. Every request except `GET /` needs + `Authorization: Bearer `. A clean quit removes `server.json`; if it's + present but the port doesn't answer, the app quit uncleanly — treat as not + running. +- **Re-read port + token on EVERY shell call.** Each terminal call is a fresh + shell, so an `export`ed token does not persist — "export once and reuse" sends + an empty token and 401s. Read both inline at the top of each call: + `PORT=$(jq -r .port ); TOKEN=$(jq -r .token )`. +- No account or network needed for local editing. + +## How to Run + +Two distinct workflows. Pick by whether the change must survive a reload. + +**A. One-off canvas edits (`/exec`)** — layout, generating shapes, cleanup. This +is a live edit, not saved script: + +```bash +BASE=http://localhost:7236 +TOKEN=$(python3 -c "import json;print(json.load(open('$HOME/.config/tldraw/server.json'))['token'])") +# find the focused document id +DOC=$(curl -s "$BASE/api/search" -X POST -H 'content-type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"code":"return (await api.getFocusedDoc()).id"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['result'])") +# run code with the live `editor` + `helpers` in scope +curl -s "$BASE/api/doc/$DOC/exec" -X POST -H 'content-type: application/json' \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"code":"const {createShapeId,toRichText}=await import(\"tldraw\"); editor.createShape({id:createShapeId(),type:\"geo\",x:0,y:0,props:{geo:\"rectangle\",w:200,h:100,color:\"blue\",fill:\"solid\",richText:toRichText(\"hello\")}}); return editor.getCurrentPageShapes().length"}' +``` + +**B. Durable behavior (`script/main.js`)** — reactive/interactive logic that must +survive reload. Edit the file on disk; the app's watcher applies it: + +```bash +# get the live script file path for the doc +curl -s "$BASE/api/doc/$DOC/script-workspace" -X POST \ + -H "Authorization: Bearer $TOKEN" # -> result.mainJsPath, result.isDefaultScript +# edit result.mainJsPath with read_file / patch / write_file (see scripts/main.js) +# then confirm the watcher applied it: +curl -s "$BASE/api/doc/$DOC/script-status" -H "Authorization: Bearer $TOKEN" +``` + +The ready-to-adapt document script is `scripts/main.js`. + +## Quick Reference + +The document-script contract (verified against the app's bundled +`script-context.d.ts`): + +```js +import { createShapeId, toRichText } from 'tldraw' // primitives: import, not globals + +export default function ({ editor, helpers, signal }) { + editor.run(() => { // batch = one undo step + helpers.createShapeIfMissing({ // idempotent furniture + id: createShapeId('node-1'), type: 'geo', x: 0, y: 0, + props: { geo: 'rectangle', w: 200, h: 100, richText: toRichText('hi') }, + }) + }) + + const stop = editor.store.listen(() => { /* react */ }) // fires the tick AFTER a commit + signal.addEventListener('abort', () => stop()) // REQUIRED cleanup on rerun/close +} +``` + +- `ctx.editor` — the live `Editor` (`createShape`, `updateShape`, `deleteShapes`, + `getCurrentPageShapes`, `getShape`, `getBindingsFromShape`, `zoomToFit`, + `on('tick'|'event', fn)`, `run(fn, { history: 'ignore' })`). +- `ctx.helpers` — `createShapeIfMissing`, `createShapesIfMissing`, + `createArrowBetweenShapes(from, to, { arrowheadEnd })`, `translateShapes`, + `onShapeTranslate(id, fn, { signal })`, `richTextToPlainText`, `boxShapes`, + `getLints`. +- `ctx.signal` — `AbortSignal`; attach every listener/interval teardown to it. +- `config.js` (separate file) registers custom shape/tool/component utils and + runs before mount; `main.js` runs against the mounted editor and reruns on save. + +## Interactive UI (clickable buttons that drive state) + +Drawn shapes can behave like a real app — the thing a static whiteboard can't do. +Full example: `scripts/counter.js` (a number display + MINUS/RESET/PLUS buttons). + +Verification boundary — read this before claiming interaction works or doesn't. +The app's OWN agent playbook says to verify a clickable-UI script with "one +simulated click and one state read" via `/exec` (`editor.dispatch` a pointer +event, await a tick, read the shape's state) — NOT by driving a real mouse. By +that standard the counter is verified: dispatched clicks stepped it +`0 → 1 → 2 → 1 → 0`. Two caveats worth writing down: +- **The script only runs once the app's file-watcher applies it.** On Linux that + watcher uses inotify; a host with an exhausted `fs.inotify.max_user_instances` + logs `inotify_add_watch ... No space left on device`, `script-status` shows + `state: "not-watching"` / `hasEntry: false`, and the script never executes. + This is a host limit, not a script bug — a normal desktop is unaffected. +- **Real background GUI clicks (computer-use style) do NOT reach the canvas.** + Chromium/Electron reject synthetic pointer input to an unfocused/occluded + renderer, so `computer_use` no-focus automation can't click buttons. This is + irrelevant to the actual product path (agents use `/exec`, not clicks) — but + don't try to "verify" a script by having a background agent click the GUI. + +The pattern: + +```js +export default function ({ editor, helpers, signal }) { + // 1. Build buttons idempotently; tag each with meta so the handler finds them. + // Give buttons a visible label AND a meta.action. + // 2. Hit-test pointer_down in PAGE coordinates against the button bounds: + const inside = (b, p) => p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h + function onEvent(info) { + if (!info || info.name !== 'pointer_down') return + let p = null + try { if (info.point && editor.screenToPage) p = editor.screenToPage(info.point) } catch {} + p = p ?? editor.inputs?.currentPagePoint + if (!p) return + const hit = editor.getCurrentPageShapes().find( + (s) => s.meta?.ui === 'button' && + inside({ x: s.x, y: s.y, w: s.props.w, h: s.props.h }, p) + ) + if (hit) runAction(hit.meta.action) // mutate state; store it in a shape's meta + } + editor.on('event', onEvent) + signal.addEventListener('abort', () => editor.off('event', onEvent)) // REQUIRED +} +``` + +- Find buttons by `meta` (or visible label via `helpers.richTextToPlainText`), + not by hard-coded coordinates. +- **One script owns both build and read.** If the shapes are created by one code + path (with `meta.action: 'inc'`) and the handler reads another convention + (`meta.action === 'PLUS'`), clicks silently do nothing. Ship the buttons built + by the same script that handles them, or ship an empty canvas so the script + builds them fresh — never pre-bake mismatched shapes into the file's db. +- Keep app state in a shape's `meta` (e.g. `meta.count`) and render it as that + shape's `richText` label, so it survives save and is readable for verification. +- **Detach the listener on `signal` abort.** Skipping this is not cosmetic: on + the next save the old `onEvent` stays attached alongside the new one, so every + click fires twice and a counter jumps by 2 instead of 1. +- For continuous motion use `editor.on('tick', fn)`; for a moving anchor with + attached pieces use `helpers.onShapeTranslate(id, fn, { signal })`. + +### Shipping a self-running scripted `.tldraw` + +A `.tldraw` is a zip of `metadata.json` + `session.json` + `db.sqlite` + `assets/` ++ `script/` (only those entries are packable). For the script to auto-run without +the "This document contains a script → Run Script" consent dialog: + +- `metadata.json` must carry a `script` manifest: `{ "sha256": "" }`, where + the digest is `sha256` over each sorted `script/` path as `` `${path}\0${sha256hex(bytes)}\n` ``. + A mismatch is rejected as tampered. +- Pre-trust the digest by adding it to `~/.tldraw/script-trust.json` + (`{ "trusted": [""] }`, or `$TLDRAW_SCRIPT_TRUST`). The app skips consent + when `isScriptTrusted(digest)` is true. + +## Procedure + +1. Read the current token/port from `server.json`. Find the target doc with + `api.getFocusedDoc()` (or `api.getDocs()`); name it explicitly if several are + open. +2. For layout/generation, use `/exec`. For durable behavior, edit + `script/main.js` via `/script-workspace`. +3. Make scripts idempotent: create durable shapes with `helpers.createShapeIfMissing` + and stable `createShapeId('name')` ids. Scripts rerun on every load. +4. Keep script-owned writes out of the user's undo stack: + `editor.run(fn, { history: 'ignore' })` (or `helpers.translateShapes`, which + already does). +5. For reactivity, `editor.store.listen(cb)` and tear it down on `signal` abort. + For interaction, `editor.on('event', h)` (hit-test `pointer_down` in page + coords); for animation, `editor.on('tick', h)`. +6. For a single moving anchor + attached internals, prefer + `helpers.onShapeTranslate(anchorId, fn, { signal })` over a broad store + listener — a broad listener can turn your own writes into feedback loops. + +## Shape props (validated against tldraw SDK v5 schema) + +`editor.createShape` / `createShapeIfMissing` accept partial props (shape utils +fill defaults). When building **raw records** for a file snapshot, every prop +below is required (run `scripts/validate_shapes.mjs`): + +| Shape | Required props | +|-------|----------------| +| `note` | `richText`, `color`, `labelColor`, `size`, `font`, `align`, `verticalAlign`, `growY`, `fontSizeAdjustment`, `url`, `scale`, `textLastEditedBy` | +| `text` | `richText`, `color`, `size`, `font`, `textAlign`, `w`, `scale`, `autoSize` | +| `frame` | `w`, `h`, `name`, `color` | +| `geo` | `geo`, `w`, `h`, `color`, `fill`, `richText` (+ dash/size/etc. defaulted) | + +`richText` must be `toRichText('...')` — a bare string is rejected. `color` enum: +`black grey light-violet violet blue light-blue yellow orange green light-green +light-red red white`. `font` enum: `draw sans serif mono`. + +## Pitfalls + +- **`store.listen` fires on the tick AFTER a commit, not synchronously.** If you + write a shape and immediately read state expecting the listener to have run, it + hasn't. Verified live: an in-turn read shows 0 fires; after one `setTimeout` + tick it shows 1. Same reason the app notes `editor.dispatch` is async — await a + tick before verifying. +- **`ctx`, not globals.** The entry is `export default function ({ editor, + helpers, signal })`. There is no bare `editor` global in a document script. + `createShapeId` / `toRichText` / `Vec` come from `import ... from 'tldraw'`. +- **`richText`, not `text`.** Text/note/geo labels use `richText: toRichText(s)`. +- **Raw records need every prop; `createShape` does not.** In-app pass only the + props you care about; a hand-built `.tldraw` snapshot needs the full set (table). +- **Scripts rerun on every load — be idempotent.** Use `createShapeIfMissing` + with stable ids or you duplicate content and clobber user edits. +- **Clean up on `signal`.** `signal.addEventListener('abort', () => stop())` for + every `store.listen` / `editor.on` / `setInterval`; the signal fires before + rerun and on close. +- **Keep script writes out of undo:** `editor.run(fn, { history: 'ignore' })`. +- **`editor.on('tick')` pauses when the window is hidden** (it is a RAF loop); + `setInterval` keeps firing but Electron throttles it to ~1/s in the background. +- **The API needs the bearer token** from `server.json`; the port can be non-default + (`server.listen(0)` picks one) — always read the file, don't hardcode `7236`. +- **Only `tldraw` / `react` / `react-dom` import** — not a Node project. + +## Verification + +- **Shape schema (offline, no app):** `node scripts/validate_shapes.mjs` — builds + the real tldraw schema and validates note/text/frame. Passing prints `3/3`. +- **Live canvas edits:** after `/exec`, read back with `/api/search` → + `api.getShapes(docId)` (returns `{ page, viewport, shapes }`) and + `api.getBindings(docId)` (array). Confirm expected shapes/bindings exist. Grab + `api.getScreenshot(docId)` (returns `{ filePath, ... }`) and inspect the PNG/JPEG + with `vision_analyze`. +- **Durable script applied:** `GET /api/doc/:id/script-status`. Success is + `state: "applied"` (`currentDiskDigest === lastAppliedDigest === manifestSha256`, + `pendingApply === false`, `lastApplyError === null`). If it stays `"pending"` + after a short retry, report that instead of claiming success; `"error"` means + the apply failed — read `errorLogPath`. diff --git a/optional-skills/creative/tldraw-offline/scripts/counter.js b/optional-skills/creative/tldraw-offline/scripts/counter.js new file mode 100644 index 00000000000..d389451a071 --- /dev/null +++ b/optional-skills/creative/tldraw-offline/scripts/counter.js @@ -0,0 +1,114 @@ +// Interactive Counter — a tldraw offline document script. +// +// HOW TO RUN IT ON YOUR MACHINE: +// 1. Open tldraw offline, create or open a document. +// 2. Develop → Reveal Script… (creates script/main.js + a workspace folder) +// 3. Replace the contents of script/main.js with THIS file, and save. +// 4. The app reruns the script automatically. You'll see a "Counter" panel +// with MINUS / RESET / PLUS buttons — click them; the number updates live. +// 5. File → Save to persist the script into the .tldraw file. Now the file +// *is* a little app: reopen it anywhere and the buttons still work. +// +// This is the document-script contract (from the app's script-context.d.ts): +// export default function ({ editor, helpers, signal }) { ... } +// editor — the live tldraw Editor +// helpers — editor-bound conveniences (richTextToPlainText, etc.) +// signal — an AbortSignal fired before the script reruns / on close; +// register ALL cleanup on it so re-saving never leaks listeners. + +import { createShapeId, toRichText } from 'tldraw' + +export default function ({ editor, helpers, signal }) { + // Stable ids => idempotent: re-running reuses shapes instead of duplicating. + const IDS = { + title: createShapeId('counter-title'), + display: createShapeId('counter-display'), + dec: createShapeId('counter-btn-dec'), + reset: createShapeId('counter-btn-reset'), + inc: createShapeId('counter-btn-inc'), + } + + // Create-if-missing helper (leaves user edits intact on rerun). + function ensure(partial) { + if (editor.getShape(partial.id)) return + editor.createShape(partial) + } + + editor.run(() => { + ensure({ + id: IDS.title, type: 'text', x: 40, y: 20, + props: { richText: toRichText('Counter'), size: 'xl', font: 'draw', color: 'black' }, + }) + ensure({ + id: IDS.display, type: 'geo', x: 40, y: 80, + props: { geo: 'rectangle', w: 360, h: 160, color: 'black', fill: 'none', richText: toRichText('0'), size: 'xl' }, + meta: { ui: 'display', count: 0 }, + }) + // Button labels are load-bearing — the click handler finds buttons by text. + ensure({ + id: IDS.dec, type: 'geo', x: 40, y: 270, + props: { geo: 'rectangle', w: 100, h: 80, color: 'red', fill: 'solid', richText: toRichText('MINUS'), size: 'l' }, + meta: { ui: 'button', action: 'MINUS' }, + }) + ensure({ + id: IDS.reset, type: 'geo', x: 170, y: 270, + props: { geo: 'rectangle', w: 100, h: 80, color: 'grey', fill: 'solid', richText: toRichText('RESET'), size: 'l' }, + meta: { ui: 'button', action: 'RESET' }, + }) + ensure({ + id: IDS.inc, type: 'geo', x: 300, y: 270, + props: { geo: 'rectangle', w: 100, h: 80, color: 'green', fill: 'solid', richText: toRichText('PLUS'), size: 'l' }, + meta: { ui: 'button', action: 'PLUS' }, + }) + }) + + const STEP = { MINUS: -1, PLUS: +1 } + + function displayShape() { + return editor.getCurrentPageShapes().find((s) => s.meta && s.meta.ui === 'display') + } + function setCount(n) { + const d = displayShape() + editor.run( + () => + editor.updateShape({ + id: d.id, type: 'geo', + props: { richText: toRichText(String(n)) }, + meta: { ...d.meta, count: n }, + }), + { history: 'ignore' } // keep script writes out of the user's undo stack + ) + } + function runAction(label) { + const d = displayShape() + const cur = d.meta && typeof d.meta.count === 'number' ? d.meta.count : 0 + if (label === 'RESET') setCount(0) + else if (label in STEP) setCount(cur + STEP[label]) + } + + function bounds(s) { + return { x: s.x, y: s.y, w: s.props.w ?? 0, h: s.props.h ?? 0 } + } + function inside(b, p) { + return p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h + } + + function onEvent(info) { + if (!info || info.name !== 'pointer_down') return + let p = null + try { + if (info.point && editor.screenToPage) p = editor.screenToPage(info.point) + } catch {} + p = p ?? editor.inputs?.currentPagePoint + if (!p) return + const hit = editor + .getCurrentPageShapes() + .find((s) => s.meta && s.meta.ui === 'button' && inside(bounds(s), p)) + if (hit) runAction(hit.meta.action) + } + + editor.on('event', onEvent) + signal.addEventListener('abort', () => editor.off('event', onEvent)) // required cleanup + + editor.zoomToFit({ animation: { duration: 200 } }) +} diff --git a/optional-skills/creative/tldraw-offline/scripts/main.js b/optional-skills/creative/tldraw-offline/scripts/main.js new file mode 100644 index 00000000000..3370161df1e --- /dev/null +++ b/optional-skills/creative/tldraw-offline/scripts/main.js @@ -0,0 +1,78 @@ +// tldraw offline — document script (script/main.js) +// +// A document script's default export receives a ctx object and runs whenever the +// document loads (and reruns when you save the script). Contract, verified against +// the app's bundled script-context.d.ts: +// +// export default function ({ editor, helpers, signal }) { ... } +// +// editor — the live tldraw Editor for this document +// helpers — editor-bound conveniences: createShapeIfMissing, createShapesIfMissing, +// createArrowBetweenShapes, translateShapes, onShapeTranslate, +// richTextToPlainText, boxShapes, getLints +// signal — an AbortSignal fired before the script reruns and when the board +// closes. Register ALL cleanup on it (this is how you avoid leaks). +// +// Pure tldraw primitives (createShapeId, toRichText, Vec, ...) are imported from +// the `tldraw` app module — NOT globals. react / react-dom are also importable. +// It is not a Node project; only those modules are available. + +import { createShapeId, toRichText } from 'tldraw' + +export default function ({ editor, helpers, signal }) { + const { createShapeIfMissing, createArrowBetweenShapes } = helpers + + // --- 1. Build durable "furniture" idempotently (stable ids, create-if-missing). + // Re-running the script must NOT duplicate or clobber user edits. + const nodes = [ + { id: createShapeId('node-ui'), x: 0, y: 0, color: 'blue', label: 'CLI / Gateway' }, + { id: createShapeId('node-core'), x: 280, y: 0, color: 'violet', label: 'Agent Core' }, + { id: createShapeId('node-tools'), x: 560, y: 0, color: 'green', label: 'Tools' }, + ] + + editor.run(() => { + for (const n of nodes) { + createShapeIfMissing({ + id: n.id, + type: 'geo', + x: n.x, + y: n.y, + props: { + geo: 'rectangle', + w: 220, + h: 110, + color: n.color, + fill: 'solid', + richText: toRichText(n.label), + }, + }) + } + }) + + // Connect them (arrows bind to the shapes, so they follow when moved). + createArrowBetweenShapes(nodes[0].id, nodes[1].id, { arrowheadEnd: 'arrow' }) + createArrowBetweenShapes(nodes[1].id, nodes[2].id, { arrowheadEnd: 'arrow' }) + + // --- 2. Add reactive behavior: recolor the last node based on arrow count. + // store.listen fires on the tick AFTER a commit — never read state you just + // wrote synchronously and expect the listener to have run yet. + const targetId = nodes[2].id + function update() { + const hasArrows = editor.getCurrentPageShapes().some((s) => s.type === 'arrow') + editor.run( + () => + editor.updateShape({ + id: targetId, + type: 'geo', + props: { fill: hasArrows ? 'solid' : 'none' }, + }), + { history: 'ignore' } // keep script-owned writes out of the user's undo stack + ) + } + + const stop = editor.store.listen(update) + signal.addEventListener('abort', () => stop()) // <-- the one required cleanup + update() // run once on load + + editor.zoomToFit({ animation: { duration: 200 } }) +} diff --git a/optional-skills/creative/tldraw-offline/scripts/validate_shapes.mjs b/optional-skills/creative/tldraw-offline/scripts/validate_shapes.mjs new file mode 100644 index 00000000000..c8787ead106 --- /dev/null +++ b/optional-skills/creative/tldraw-offline/scripts/validate_shapes.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// validate_shapes.mjs — verify that the note/text/frame records this skill +// documents are valid against the real tldraw SDK v5 schema. +// +// Usage: +// npm install @tldraw/tlschema +// node validate_shapes.mjs +// +// Exits 0 when all sample records validate, 1 otherwise. No network, no DOM. + +import { createTLSchema, toRichText, createShapeId, PageRecordType } from '@tldraw/tlschema' + +const schema = createTLSchema() +const shapeRecord = schema.types.shape +const pageId = PageRecordType.createId() + +// Complete default prop sets (required when building raw records outside the editor). +const COMPLETE = { + note: { + richText: toRichText(''), color: 'black', labelColor: 'black', size: 'm', + font: 'draw', align: 'middle', verticalAlign: 'middle', growY: 0, + fontSizeAdjustment: 0, url: '', scale: 1, textLastEditedBy: '', + }, + text: { + richText: toRichText(''), color: 'black', size: 'm', font: 'draw', + textAlign: 'start', w: 8, scale: 1, autoSize: true, + }, + frame: { w: 300, h: 640, name: '', color: 'black' }, +} + +function makeRecord(type, props, meta = {}, x = 0, y = 0) { + return { + id: createShapeId(), typeName: 'shape', type, parentId: pageId, index: 'a1', + x, y, rotation: 0, isLocked: false, opacity: 1, meta, + props: { ...COMPLETE[type], ...props }, + } +} + +const cases = [ + ['frame', makeRecord('frame', { w: 300, h: 640, name: 'To Do' }, { role: 'column' })], + ['text', makeRecord('text', { richText: toRichText('To Do · WIP 2'), size: 's', color: 'grey', font: 'sans' }, { role: 'count' }, 8, -34)], + ['note', makeRecord('note', { richText: toRichText('Design the thing'), size: 's' }, { role: 'card' }, 20, 48)], +] + +let ok = 0 +const validated = [] +for (const [name, rec] of cases) { + try { + validated.push(shapeRecord.validate(rec)) + console.log(`OK ${name}`) + ok++ + } catch (e) { + console.log(`FAIL ${name}: ${String(e.message).split('\n')[0]}`) + } +} + +// Round-trip through JSON to mimic file save/load. +let rok = 0 +for (const v of validated) { + try { shapeRecord.validate(JSON.parse(JSON.stringify(v))); rok++ } catch { /* counted below */ } +} + +console.log(`\n${ok}/${cases.length} shape records valid against the tldraw schema.`) +console.log(`${rok}/${validated.length} survive a JSON round-trip (file save/load).`) +process.exit(ok === cases.length && rok === validated.length ? 0 : 1) diff --git a/tests/skills/test_tldraw_offline_skill.py b/tests/skills/test_tldraw_offline_skill.py new file mode 100644 index 00000000000..e3251985b9b --- /dev/null +++ b/tests/skills/test_tldraw_offline_skill.py @@ -0,0 +1,168 @@ +"""Tests for the tldraw-offline optional skill. + +Structural + internal-consistency checks only (stdlib + pytest, no network). +The skill's runtime claims were validated live against the real tldraw offline +app (headless) and its bundled script-context.d.ts; scripts/validate_shapes.mjs +re-checks the shape schema against the tldraw SDK. +""" + +import re +from pathlib import Path + +import pytest + +SKILL_DIR = ( + Path(__file__).resolve().parents[2] + / "optional-skills" + / "creative" + / "tldraw-offline" +) +SKILL_MD = SKILL_DIR / "SKILL.md" +MAIN_JS = SKILL_DIR / "scripts" / "main.js" + + +@pytest.fixture(scope="module") +def skill_text() -> str: + return SKILL_MD.read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def main_js() -> str: + return MAIN_JS.read_text(encoding="utf-8") + + +def test_skill_file_exists(): + assert SKILL_MD.is_file(), f"missing {SKILL_MD}" + + +def test_frontmatter_present(skill_text: str): + assert skill_text.startswith("---\n"), "SKILL.md must open with YAML frontmatter" + assert skill_text.count("---") >= 2, "frontmatter must be delimited by two '---'" + + +def test_description_under_sixty_chars(skill_text: str): + m = re.search(r"^description: (.*)$", skill_text, re.MULTILINE) + assert m, "no description field" + desc = m.group(1).strip() + assert len(desc) <= 60, f"description is {len(desc)} chars (>60): {desc!r}" + assert desc.endswith("."), "description should end with a period" + + +def test_required_sections_present(skill_text: str): + for heading in ( + "## When to Use", + "## Prerequisites", + "## How to Run", + "## Quick Reference", + "## Procedure", + "## Pitfalls", + "## Verification", + ): + assert heading in skill_text, f"missing section: {heading}" + + +def test_supporting_scripts_present(): + assert MAIN_JS.is_file() + assert (SKILL_DIR / "scripts" / "validate_shapes.mjs").is_file() + assert (SKILL_DIR / "scripts" / "counter.js").is_file() + + +def test_counter_example_is_interactive_and_safe(): + """The counter.js example must show the verified interactive-UI pattern: + ctx contract, pointer_down handling, and REQUIRED signal-based cleanup + (whose absence causes the double-fire bug found live).""" + counter = (SKILL_DIR / "scripts" / "counter.js").read_text(encoding="utf-8") + assert "export default function ({ editor, helpers, signal })" in counter + assert "pointer_down" in counter + assert "editor.on('event'" in counter + # the cleanup that prevents the click-doubling leak + assert "signal.addEventListener('abort'" in counter + assert "editor.off('event'" in counter + # state kept in meta, rendered as label + assert "meta" in counter and "count" in counter + + +def test_skill_documents_interactive_ui(skill_text: str): + assert "## Interactive UI" in skill_text + assert "counter.js" in skill_text + # the double-fire pitfall must be documented + assert "twice" in skill_text.lower() or "double" in skill_text.lower() + + +def test_documents_the_ctx_contract(skill_text: str): + # The single biggest correctness fact learned from running the real app: + # a document script is `export default function ({ editor, helpers, signal })`, + # NOT a top-level bare-`editor`-global script. + assert "export default function" in skill_text + assert "{ editor, helpers, signal }" in skill_text + assert "AbortSignal" in skill_text or "signal" in skill_text + + +def test_documents_http_control_api(skill_text: str): + # Agents drive/verify the canvas through the local HTTP API. + for token in ("/api/doc/", "/exec", "script-status", "script-workspace", + "server.json", "Authorization: Bearer"): + assert token in skill_text, f"HTTP API detail missing: {token}" + + +def test_documents_tick_timing_pitfall(skill_text: str): + # Verified live: store.listen fires the tick AFTER a commit, not synchronously. + assert "store.listen" in skill_text + assert "tick" in skill_text.lower() + + +def test_uses_richtext_not_bare_string(skill_text: str): + assert "toRichText" in skill_text + assert "richText" in skill_text + + +def test_shape_prop_table_matches_validator(skill_text: str): + validator = (SKILL_DIR / "scripts" / "validate_shapes.mjs").read_text(encoding="utf-8") + assert "createTLSchema" in validator # validates against the real schema + expected = { + "note": { + "richText", "color", "labelColor", "size", "font", "align", + "verticalAlign", "growY", "fontSizeAdjustment", "url", "scale", + "textLastEditedBy", + }, + "text": {"richText", "color", "size", "font", "textAlign", "w", "scale", "autoSize"}, + "frame": {"w", "h", "name", "color"}, + } + table_region = skill_text.split("## Shape props")[1].split("## Pitfalls")[0] + for shape, props in expected.items(): + for prop in props: + assert re.search(rf"`{re.escape(prop)}`", table_region), ( + f"{shape} prop `{prop}` in validator but missing from SKILL.md table" + ) + + +def test_main_js_matches_verified_contract(main_js: str): + # main.js must use the real contract learned from the running app. + assert "export default function ({ editor, helpers, signal })" in main_js + # primitives imported from tldraw, not used as globals + assert "from 'tldraw'" in main_js + assert "createShapeId" in main_js and "toRichText" in main_js + # idempotent furniture + assert "createShapeIfMissing" in main_js + # batched writes + assert "editor.run(" in main_js + # reactive + REQUIRED signal cleanup + assert "editor.store.listen" in main_js + assert "signal.addEventListener('abort'" in main_js + # script-owned writes kept out of undo + assert "history: 'ignore'" in main_js + + +def test_main_js_is_not_bare_global_style(main_js: str): + # Guard against regressing to the old (wrong) top-level-global form. + # A bare-global script would call editor.* at module top level with no ctx. + assert "export default function" in main_js, ( + "main.js must be a default-export ctx function, not a top-level script" + ) + + +def test_platforms_declared(skill_text: str): + m = re.search(r"^platforms: (.*)$", skill_text, re.MULTILINE) + assert m, "platforms field required (cross-platform desktop app)" + for os_name in ("linux", "macos", "windows"): + assert os_name in m.group(1)