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 fromassets/intoscriptDir.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.fill | theme field actually painted |
|---|---|
solid | the color's semi |
semi | the theme's background |
fill | the color's fill (the raw hex) |
pattern | the 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:
| meaning | dash | use |
|---|---|---|
sync | solid | request, call, default |
async | dotted | event, fire-and-forget |
return | dashed | response, ack |
fk | solid grey | ERD relationship |
legacy | dashed | non-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.jsruns before mount; it registersshapeUtils,tools,components. Saving it rebuilds the store — document, camera, and selection survive, undo history does not.main.jsruns 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) })
