mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(skills): add tldraw-offline agent scripting skill
Optional skill for driving the tldraw offline desktop app via its local
HTTP control API (the same curl-based path the app's own agent skills use
for Codex/Claude Code/Cursor/Gemini) — read the canvas, make live edits,
and write embedded document scripts.
Grounded in the app's bundled script-context.d.ts and agent playbook, and
in the real tldraw SDK v5 shape schema:
- document-script contract: export default function ({ editor, helpers, signal })
- HTTP API: /api/search, /api/doc/:id/exec, /api/doc/:id/script-workspace,
/api/doc/:id/script-status (bearer token from server.json, re-read per call)
- shape schema table validated against @tldraw/tlschema (scripts/validate_shapes.mjs, 3/3)
- interactive-UI example (scripts/counter.js) + diagram-generation (scripts/main.js)
- honest verification boundary: click->state logic verified via /exec dispatch
(0->1->2->1->0), with documented host caveats (inotify watcher, Electron
background-click rejection)
Tests: tests/skills/test_tldraw_offline_skill.py (15 passing).
This commit is contained in:
parent
683059feb5
commit
e321b83392
5 changed files with 698 additions and 0 deletions
273
optional-skills/creative/tldraw-offline/SKILL.md
Normal file
273
optional-skills/creative/tldraw-offline/SKILL.md
Normal file
|
|
@ -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 <token>`. 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 <server.json>); TOKEN=$(jq -r .token <server.json>)`.
|
||||
- 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": "<digest>" }`, 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": ["<digest>"] }`, 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`.
|
||||
114
optional-skills/creative/tldraw-offline/scripts/counter.js
Normal file
114
optional-skills/creative/tldraw-offline/scripts/counter.js
Normal file
|
|
@ -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 } })
|
||||
}
|
||||
78
optional-skills/creative/tldraw-offline/scripts/main.js
Normal file
78
optional-skills/creative/tldraw-offline/scripts/main.js
Normal file
|
|
@ -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 } })
|
||||
}
|
||||
|
|
@ -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)
|
||||
168
tests/skills/test_tldraw_offline_skill.py
Normal file
168
tests/skills/test_tldraw_offline_skill.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue