app-dev/tldraw-design-systems/SKILL.md

name: tldraw-design-systems description: >- Apply a named design system to a tldraw board via document scripts: themes, panels, role nodes, ERD tables, sequence diagrams, toolbar tools, and connection meanings. Use when styling a board, matching a visual system, adding custom tldraw furniture, or building an ERD with structured fields and PK/SK/FK relationships.

tldraw-design-systems

Make a tldraw board obey a named design system — palette, type, stroke weight, canvas ground — and add custom shapes that carry the system's visual language. Switching systems is one constant; every existing shape restyles.

Requires the tldraw desktop app running. Use the tldraw-offline skill for the server/token/tq mechanics; this skill assumes you can already reach /exec and /script-workspace.

Install into a board

sh "$HOME/skills/tldraw-offline/tq" POST /api/doc/DOC_ID/script-workspace
sh "$HOME/skills/tldraw-offline/tq" GET /api/doc/DOC_ID/script-status

Read isDefaultScript from the workspace response before writing:

  • true: install the files from assets/ into scriptDir.
  • false: read every existing script file and merge the required imports, registrations, and behavior. Never blanket-copy over a non-default script.

After writing, wait for script-status.state === 'applied' before creating any custom shape. assets/ holds the mechanism (main.js, config.js, tpPanel.js, tpPanelTool.js, tpNode.js, tpTable.js, tpSequence.js, tpConnect.js) plus systems.js, which is example data. Only systems.js is meant to be edited day to day; ACTIVE at its top selects the system.

main.js publishes the sizing and connection helpers on globalThis.__tp so /exec can call them without resolving sibling imports. Document scripts still import from the sibling files — config.js and main.js are separate module graphs.

The script travels inside the .tldraw file. Only stock color slots get repainted, so shapes still store color: 'blue' and stay portable if the script is removed.

The expensive lessons

Elbow-arrow labels can wrap into a ~64px column. On kind: 'elbow' arrows, some geometries force the richText label into a badly narrow wrapped column regardless of content length; alignment/dy nudges do not fix it and the trigger condition is not fully diagnosed. Reliable workaround: keep on-arrow labels only where they render fine (short vertical/diagonal hops) and put longer labels in standalone text shapes positioned beside the edge.

Shape colors are not CSS variables. --tl-color-* are UI chrome only; --tl-color-blue resolves empty. The shape palette is resolved in JS from the theme object. Probe the live editor before theorizing: Object.getOwnPropertyNames(Object.getPrototypeOf(editor)).filter(n => /theme|color/i.test(n))

Fill style names do not map to the theme field of the same name. Verified by reading rendered SVG fill attributes:

props.filltheme field actually painted
solidthe color's semi
semithe theme's background
fillthe color's fill (the raw hex)
patternthe color's pattern, as a fixed-density hatch

So fill: 'semi' paints the page color and reads as invisible on the canvas. If a fill looks wrong, read the DOM rather than reasoning from the name.

updateThemes replaces the whole map. Always use the updater form or you drop the built-in default theme: editor.updateThemes(prev => ({ ...prev, [id]: theme }))

Themes must be complete. Build with structuredClone(DEFAULT_THEME) and overlay; a partial theme object is not merged for you.

A color entry has many required fields (solid, fill, linedFill, semi, pattern, noteFill, the frame* set, highlightSrgb, highlightP3). Derive them from one hex rather than authoring by hand, and assert the field count against DEFAULT_THEME.colors.light.blue so SDK drift breaks the script loudly instead of rendering wrong.

noteText is the note's actual foreground color. Keep it at the canvas text color; mixing it toward the canvas background makes every note label nearly invisible even though the rest of the palette looks correct.

theme.fonts takes custom faces, including remote URLs. Easy to miss, and it is the single biggest fidelity lever when matching a reference — a monospace face changes the read more than any palette tweak.

config.tools.push(Tool) registers the state node but not a UI entry. useTools()['<id>'] stays undefined, so a toolbar override written against useTools silently renders nothing with no error. Build the item as a React component — useEditor / useValue throw if they run in a helper that Toolbar maps over:

function ToolItem({ id, label, icon }) {
  const editor = useEditor()
  const isSelected = useValue('sel', () => editor.getCurrentToolId() === id, [editor])
  return createElement(TldrawUiMenuItem, {
    id, label, icon, isSelected,
    onSelect: () => editor.setCurrentTool(id),
  })
}

Never await save inside the /exec that mutates the board. The app marks a history stopping point around exec'd code and calls bailToMark() if that exec eventually fails. A delayed helpers.saveDoc() failure can therefore replay an old snapshot over newer work, wiping unrelated meta, restoring deleted records, and reverting coordinates. Run the mutation, return successfully, verify the records, then drive File → Save as a separate operation:

osascript -e 'tell application "System Events" to tell process "tldraw offline"' \
  -e 'set frontmost to true' \
  -e 'click menu item "Save" of menu "File" of menu bar item "File" of menu bar 1' \
  -e 'end tell'

Then confirm with unsavedChanges from api.getDocs().

If an exec containing an awaited save times out, stop writing to that editor. Reopen the document or trigger a real config.js remount before continuing so the stale handler cannot roll back later work. Wrap script-owned writes in editor.run(fn, { history: 'ignore' }), and derive relationships from stable ids instead of meta where possible.

Custom shapes are cheap

A decorative or box-like custom shape is a ShapeUtil with getGeometry + component; BaseBoxShapeTool gives drag-to-create in four lines, and canEdit() returning true plus useIsEditing(shape.id) gives double-click-to-edit text. When the system's visual language needs something stock shapes cannot carry, build one rather than approximating it.

BaseBoxShapeTool accepts any drag size, including a box smaller than structured content. Enforce content-derived minimums in the shape util's onBeforeCreate and onBeforeUpdate, not only in the tool, so MCP creation, paste, and later prop edits cannot clip tables or sequences either. Loaded records bypass onBeforeCreate, so run the same guard from main.js normalization to heal undersized saved shapes on open.

Do not build composite visuals from paired shapes. A companion shape (drop shadow, backdrop, label plate) needs a naming convention, a sync pass to keep it positioned and sized, and it silently breaks the moment someone creates the main shape by hand. Render the companion inside the owner's component() instead — one shape that moves, resizes, and duplicates as a unit, and a large amount of machinery never gets written. If a sync pass over sibling shapes is appearing in the design, that is the signal to fold it into the shape instead.

The bundled panel and table render their own shadow. The default is the Turbopuffer dot crosshatch; systems.js may explicitly opt into a solid shadow.

For implementation pseudocode, use a tp-panel with tone: 'code'. Put the real repo-relative file path on the first body line, the owning symbol or scope on the second, then the pseudocode. Label it code that does this · <area> so the diagram states both the behavior and where the implementation belongs. Never invent a destination path when the source tree is available to inspect. Size with measurePanel({ body, tone, w }).

Nodes

Use tp-node for a connectable architecture vertex — a process, store, actor, boundary, or queue. It is a compact labeled box with a role plate; it has no body. Keep tp-panel for a chunk of system that needs body text or code. role is one of process, store, actor, boundary, queue. Size with measureNode({ sub, w }).

Connection routing

Never draw reciprocal or parallel messages as overlapping center-to-center arrows, and do not collapse two directions into one double-headed arrow. Each message gets its own bound elbow connection and its own exterior lane: route one above both components from top edge to top edge, and the other below from bottom edge to bottom edge. Put each label on the long exterior run. This makes direction and ownership readable immediately and leaves the component bodies clear. Use the same top/bottom lane split for two same-direction connections.

Make the route-defining terminals precise edge-point bindings (normalizedAnchor.y: 0 for the top lane, 1 for the bottom lane); bind both ends precisely when automatic target placement does not land on the matching edge. Leave enough clearance outside the panels for the label and dot-crosshatch shadow.

Create every meaningful connection with connectShapes(editor, helpers, fromId, toId, options) from tpConnect.js (also globalThis.__tp.connectShapes). Meanings:

meaningdashuse
syncsolidrequest, call, default
asyncdottedevent, fire-and-forget
returndashedresponse, ack
fksolid greyERD relationship
legacydashednon-authoritative

Options: label, from/to (left/right/top/bottom), fromField/toField (table row names). Every connection goes through connectShapes — the meaning table, dash styles, and bound elbow routing live there, and a custom arrow shape would fall outside all of them.

ERD tables

Use tp-table for database tables, caches, and other structured entities. It renders one shape containing a fieldset-style title and engine legend on a grey header band, explicit cell rules, key-role icons, border, and shadow. Its three columns are KEY, FIELD, and TYPE; keep explanations out of the type cell and put constraints or lifecycle notes in the footer. Put one field on each line of its columns prop:

PK|id|uuid
SK,FK|agent_id|uuid
FK|flow_id|uuid
|created_at|timestamptz

The first cell accepts comma-separated PK, SK, and FK roles. Size with measureTable({ columns, footer, w }). Pin FK/SK to the referenced PK with rowAnchor(props, field, side) — never generic box centers. After paint, grow with fitTable(editor, id) so wrapping TYPE cells and footers are not clipped, then bind arrows against the live shape.props.h. Culled (off-viewport) shapes have no element — skip them and they correct the next time they render. Label each edge with its field and cardinality. Use connectShapes with meaning: 'fk' (or legacy for non-authoritative). Use separate bend lanes for parallel or reciprocal relationships so every turn stays on the 90-degree grid.

Prefer short-hop edges. An arrow that crosses a whole column of unrelated tables routes straight through them and drops its label on top of a header; the → table.field text already in the TYPE column carries that relationship without a wire. To skip a neighbor inside one column, anchor both ends on the same side (left/left) so the elbow routes out through the gutter.

For a required back-edge or multi-rank connection, precise exterior anchors alone are not obstacle avoidance. Split the cable into bound elbow segments through small junction shapes: leave the source into its adjacent rank gutter, travel on a lane outside the diagram bounds, then enter from the target's adjacent gutter. Put the label on the long exterior segment and give parallel cables separate lanes. This keeps every turn orthogonal without crossing the boxes between the endpoints.

Sequence diagrams

tp-sequence renders a whole sequence diagram as one shape — outer frame with legend, lane header boxes, dashed lifelines, numbered messages, phase bands and notes. Its lanes prop is one lane per line (id|Label|sublabel|tone); its steps prop is a mermaid-ish mini-syntax:

== phase band
a->b: solid message
b-->a: dashed / return
a->a: self call
a->b: fails closed [red]
note over a,b: spanning note

A trailing [tone] colors one message or tints one note; unparsed lines render in red rather than throwing, so a typo is visible instead of blanking the board. Size the shape with measureSequence({ lanes, steps }) — never guess w and h.

A self-call's label sits to the right of its loop and may pass over the next lifeline; the loop underneath says which lane owns it. Only the rightmost lane folds inward, because only there would the label leave the frame. Do not "fix" the others by clamping to half a lane — half a lane is narrower than the flip threshold, so every self label folds and wraps into a sliver.

config.js vs main.js

  • config.js runs before mount; it registers shapeUtils, tools, components. Saving it rebuilds the store — document, camera, and selection survive, undo history does not.
  • main.js runs against the live editor and reruns on save with no remount. Keep all run-on-mount logic here.
  • They are separate module graphs. Share code through a sibling file both import, never module state.

Verifying

The desktop editor holds at most 40 pages in one document. createPage can silently leave the current page selected at that ceiling, so subsequent shapes pile onto the wrong page. Count first, start another numbered source board before 40, and verify the new page exists and is current before creating shapes.

Read records with api.getShapes() first; screenshot only when placement or styling is genuinely uncertain. mode: 'window' is required to see toolbar and panel overrides, which live outside the canvas.

A window capture taken immediately after a script apply can catch the UI mid-render — if an override looks like it failed, confirm against the DOM (document.querySelectorAll('[data-testid]')) before concluding anything. If a bounds capture comes back looking like a full window, retake without bounds — check captureMode on the response rather than trusting the option took.

The reported width/height are page units; the JPEG on disk is 2x. Crop geometry computed from the response silently grabs the top-left quarter. Read the real size (sips -g pixelWidth) and double every offset before cropping an export out of a full-canvas capture.

To exercise a tool without a human, dispatch synthetic pointer events:

editor.setCurrentTool('<id>')
const base = { type:'pointer', target:'canvas', pointerId:1, button:0, isPen:false,
  shiftKey:false, altKey:false, ctrlKey:false, metaKey:false, accelKey:false }
editor.dispatch({ ...base, name:'pointer_down', point: editor.pageToScreen(a) })
editor.dispatch({ ...base, name:'pointer_move', point: editor.pageToScreen(b) })
editor.dispatch({ ...base, name:'pointer_up',   point: editor.pageToScreen(b) })