| name | writing-patchwork-tools |
|---|---|
| description | Build, scaffold, or modify a Patchwork tool, datatype, or action in this repo. Use whenever creating a new tool from scratch, porting something into Patchwork, adding a datatype/tool/action plugin, wiring an automerge document model, or setting up the build/sync (vite + pushwork). House style: default to small, simple tools written in plain vanilla JavaScript, but TypeScript, npm dependencies, and a bundled build are all fine (pushwork handles them). If a reactive framework is needed, use Solid (never React). Covers the plugin registration shape, the (handle, element) => cleanup render contract, the datatype lifecycle, bundleless vs bundled builds, multiplayer/ephemeral messaging, the importmap, and the common gotchas (undefined assignment, Solid click delegation, light-DOM CSS). |
A Patchwork tool is a plugin module that registers one or more plugins and renders a
view into a host-provided DOM element. The host (Patchwork) supplies the automerge repo,
the document DocHandle, and the element to render into. You ship an ES module; Patchwork
loads it.
There is no shadow DOM — tools render into the light DOM inside a <patchwork-view>.
Namespace your CSS class names and inject styles with the JS bundle (see Build).
These are the defaults for new tools in this repo. Follow them unless the user says otherwise.
- Default to small and simple: plain vanilla JavaScript. A file that re-renders the DOM on
handle.on("change", …)is the preferred shape (see §4) — it's the simplest thing that works. Document shapes with a JSDoc@typedefcomment if you want a schema note (seetic-tac-toe.js). - TypeScript, npm dependencies, and a bundled build are fine when you want them. pushwork
builds and syncs
.ts/.tsx+ vite tools like anything else — no need to avoid them. Reach for a bundle when the tool has a multi-file source tree, JSX, or npm deps. - If you need fine-grained reactivity, use Solid — never React. Solid is in the importmap:
use
solid-jswith JSX (via a vite bundle) orsolid-js/htmltagged templates. Reach for it when the UI has enough independent live-updating pieces that hand-diffing the DOM gets painful. - React is legacy here. Several existing tools use React; do not start new tools that way, and when editing a React tool, match its existing style rather than rewriting it.
Rule of thumb: vanilla JS first → Solid if you need reactivity → React only when modifying a tool that already uses it.
Bundleless (single .js) |
Bundled (vite) | |
|---|---|---|
| Use when | small tools: vanilla JS, Web Components, or Solid via solid-js/html |
Solid with JSX, TypeScript, npm deps, or a multi-file source tree |
| Source | one hand-written .js (e.g. walkies.js, catclock.js, tic-tac-toe.js) |
src/ compiled to dist/index.js |
| Imports | bare specifiers via Patchwork's importmap; assets via import.meta.url |
same, but bundled; deps marked external |
| Build | none — pushwork sync directly |
pnpm build then pushwork sync |
package.json main |
the .js file |
./dist/index.js |
Both flavors export the same plugins array. The importmap already covers automerge,
solid-js, codemirror, and the patchwork packages, so a bundleless tool has nothing to build. You
can write a fully reactive Solid tool bundleless using solid-js/html (§4). Reach for the bundled
flavor (Solid+JSX, TypeScript, npm deps, or a multi-file source tree) whenever you want it —
pushwork builds and syncs it fine.
Every tool module exports plugins, an array of plugin objects. Three plugin types:
export const plugins = [
{
type: "patchwork:datatype", // describes a document shape
id: "tic-tac-toe",
name: "Tic Tac Toe",
icon: "Grid3x3", // a lucide-react icon NAME
// unlisted: true, // hide from "new document" menus (e.g. "file")
async load() {
return TicTacToeDatatype // the datatype object (see §3)
},
},
{
type: "patchwork:tool", // a view/editor for a datatype
id: "tic-tac-toe", // ⚠ MUST equal the pin id when pinning a tool
name: "Tic Tac Toe",
icon: "Grid3x3",
supportedDatatypes: ["tic-tac-toe"], // or ["*"] for any doc (e.g. inspectors)
async load() {
return Tool // a render function (see §4)
},
},
]-
load()is async and lazy —import()the heavy code inside it so the registry stays cheap. Bundleless tools oftenreturn SomeObjectdirectly; bundled toolsconst { X } = await import("./x"); return X. -
Split the datatype and the tool into separate files and
import()each inside its ownload(). The entry module that exportspluginsshould contain only the plugin metadata (type,id,name,icon,supportedDatatypes) plus thinload()functions — no datatype object, no render function, no heavy deps in the top-level scope. Put the datatype in e.g.datatype.jsand the tool intool.js, then:export const plugins = [ { type: "patchwork:datatype", id: "counter", name: "Counter", icon: "Hash", async load() { return (await import("./datatype.js")).CounterDatatype } }, { type: "patchwork:tool", id: "counter", name: "Counter", icon: "Hash", supportedDatatypes: ["counter"], async load() { return (await import("./tool.js")).default } }, ]
The tool file should
export defaultits render function soload()can grab.defaultwithout caring what it's named. (The datatype file can do the same, or use a named export.)Why: Patchwork reads the
pluginsarray up front just to list tools/datatypes (names, icons, supported types). If the datatype and render code live in the entry module, that whole payload — codemirror, solid, audio/wasm, etc. — gets parsed just to show a description. Keeping eachload()as a one-line dynamic import means the entry module is tiny, descriptions load instantly, and a heavy file is only fetched when that specific plugin is actually used. (For the smallest single-file demos you can stillreturn SomeObjectdirectly, but split as soon as the tool carries real weight.) -
idrule: a tool'sidand the pinidmust match. When pinning a tool, use the same id the tool registers with. -
iconis a lucide icon name ("Music","Database","File","Sparkles","Cat","Grid3x3"…). -
A package can register many plugins (a datatype + its tool, or several datatypes). See
file/src/index.ts(file + new-file) andbento/main.js(datatype + tool).
Patchwork loads the module that exports plugins inside a Web Worker, reads the array,
and structuredClones each plugin entry to the main thread. Two hard consequences:
-
No functions as top-level plugin fields (except
load). The host clones every field of a plugin exceptload(which it strips and reconstructs on the main side). Any other function-valued field throwsDataCloneError: (…) => … could not be clonedand the whole module fails to import. So a plugin entry may only carry JSON-ish metadata at the top level (type,id,name,icon,tier, strings, numbers, arrays, plain objects, andRegExp/Date/Map/Set— all clone-safe). All "fancy code" — every function, and any behaviour object — must live behindasync load(), which returns it (the registry exposes the loaded result under the plugin's.module). This is exactly why thepatchwork:toolrender function and the datatype object go behindload(), and it applies equally to any custom-typed plugins you register (e.g.chat:slash,chat:messageaction,sketchy:*). If you have an array of behaviour-bearing descriptors, register them as metadata-only descriptions and defer the code:// WRONG — `transform` is a top-level function → DataCloneError in the worker { type: "chat:slash", id: "me", cmd: "/me", transform: (arg) => ({ text: arg }) } // RIGHT — metadata only at the top level; the fn is returned by load() { type: "chat:slash", id: "me", cmd: "/me", async load() { return { transform: (arg) => ({ text: arg }) } } }
(A tool can still keep the inline-function version in a separate module it imports on the main thread for its own use — that copy is never cloned. Only what goes into the exported
pluginsarray crosses the worker boundary.) -
No bare external imports in the entry module's static graph. The worker has no importmap, so a top-level
import … from "@inkandswitch/patchwork-plugins"(or any other importmap-provided / bootloader-external specifier) fails with "Failed to resolve module specifier" — even transitively, and even if the imported binding is unused (marked-external modules can't be tree-shaken, so bundlers keep a bare side-effectimport). Keep such imports insideload()(dynamicimport()), which runs on the main thread where the importmap exists. If a data-only helper module (e.g. your plugin metadata) accidentally pulls in an external via one stray import, split that import out so the entry graph stays clean.
A datatype object describes a document type and how to title it:
export const TicTacToeDatatype = {
init(doc) { // seed a brand-new doc (runs inside a change)
doc.title = "Tic Tac Toe"
doc.board = [null, null, null, null, null, null, null, null, null]
doc.currentPlayer = "X"
doc.status = "playing"
doc.winner = null
},
getTitle(doc) { // string shown in the UI / file lists
return doc.title || "Tic Tac Toe"
},
setTitle(doc, title) { // rename (called inside a handle.change)
doc.title = title
}
}initdefines your document schema by example — the fields you set here are your data model. Keep it flat and automerge-friendly (plain objects/arrays/strings/numbers).getTitle/setTitleare required for a doc to show a name.markCopyis optional.- Datatypes can carry richer helpers/exports too (type guards, content getters) — see
file/src/datatype.ts.
A tool's load() resolves to a render function. It receives the document DocHandle and
the host element, mounts UI into element, and returns a cleanup function.
Vanilla / Web Components. Re-render on every change:
function Tool(handle, element) {
const container = document.createElement("div")
const style = document.createElement("style")
style.textContent = `.ttt-board { /* namespaced! */ }`
element.append(style, container)
function render() {
const doc = handle.doc() // current snapshot
// ...build DOM from doc, wire events that call handle.change(...)
}
render()
handle.on("change", render) // re-render when the doc changes (local or remote)
return () => { // cleanup: undo everything you did
handle.off("change", render)
container.remove()
style.remove()
}
}Solid (when you need reactivity). Use solid-js with JSX if you're bundling, or solid-js/html
tagged templates without a build step — both give the same fine-grained updates. The
solid-js/html version is shown here: wrap the doc in a signal you push to from the change
event; write dynamic expressions as functions so they stay reactive. render() returns a disposer
— return it from cleanup:
import { render } from "solid-js/web"
import html from "solid-js/html"
import { createSignal } from "solid-js"
function CounterTool(handle, element) {
const [doc, setDoc] = createSignal(handle.doc())
const onChange = () => setDoc(handle.doc())
handle.on("change", onChange)
const dispose = render(
() => html`<button onClick=${() => handle.change(d => { d.count++ })}>
count: ${() => doc().count}
</button>`,
element,
)
return () => { handle.off("change", onChange); dispose() }
}React — legacy; do NOT use for new tools. Existing React tools mount a root and unmount in cleanup. Edit them in place; don't introduce React into a new tool.
import { createRoot } from "react-dom/client"
export function Tool(handle, element) {
const root = createRoot(element)
root.render(<App handle={handle} element={element} />)
return () => root.unmount()
}The cleanup function is mandatory — Patchwork calls it when the tool unmounts. Remove listeners, dispose roots, cancel intervals/animation frames, close audio contexts.
const doc = handle.doc() // synchronous snapshot of current state
handle.change(d => { d.foo = 1 }) // ALL writes go through change()
handle.on("change", render) // fires on local + remote edits
handle.off("change", render)Automerge gotchas:
- You cannot assign
undefinedto a doc property. To remove a field,delete d.propinside achange(); to nullify, setd.prop = null. - Always mutate inside
handle.change(d => …). Never mutatehandle.doc()directly. - Keep the model JSON-shaped. For collaborative text, prefer
@automerge/automerge-codemirror- Automerge cursors over naive string replacement (see
file,call,datalog).
- Automerge cursors over naive string replacement (see
Patchwork exposes globals — use them directly, don't dig through handles:
window.repo // the automerge Repo
window.accountDocHandle // current user's account DocHandle
window.hive // tool/datatype registry
const handle = await repo.find(url) // ⚠ returns a Promise<DocHandle>, already ready
const fresh = await repo.create2(initial) // create a new doc (repo.create is deprecated)Current automerge-repo API (do not use the old patterns):
// WRONG — old API
const handle = repo.find(url); await handle.whenReady()
// CORRECT — current API
const handle = await repo.find(url)Get the user's name:
(await repo.find(window.accountDocHandle.doc().contactUrl)).doc().name.
DocHandle has a built-in broadcast channel for non-persisted peer-to-peer messages —
ideal for presence, cursors, typing indicators, "now playing" state, WebRTC signaling:
handle.broadcast({ type: "play", playing: true }) // to all peers with this doc open
handle.on("ephemeral-message", payload => {
const msg = payload.message // the wrapped payload
})
handle.off("ephemeral-message", handler)Messages reach only currently-connected peers and are never stored. For persisted shared
state, write to the doc with handle.change().
Patchwork events bubble and are composed (defined in @inkandswitch/patchwork-elements):
import { openDocument } from "@inkandswitch/patchwork-elements"
openDocument(element, url, toolId) // navigate Patchwork to another document
// or manually:
element.dispatchEvent(new CustomEvent("patchwork:open-document", {
detail: { url, toolId }, bubbles: true, composed: true,
}))
element.addEventListener("patchwork:mounted", () => { /* inner tool is ready */ })Patchwork provides an importmap, so both flavors can import these by bare specifier:
@automerge/automerge,@automerge/automerge/slim@automerge/automerge-repo,@automerge/automerge-repo-keyhive@keyhive/keyhive@inkandswitch/patchwork-bootloader,-elements,-filesystem,-plugins@codemirror/state,@codemirror/view,@codemirror/languagesolid-jsand its subpaths (solid-js/web,/store,/html,/h,/jsx-runtime)
Use direct imports (import("@codemirror/view")) — never esm.sh / unpkg URLs for these.
In bundleless tools, load your own static assets with new URL("./x.flac", import.meta.url).
From @inkandswitch/patchwork-filesystem:
type FolderDoc = { title: string; docs: DocLink[] }
type DocLink = { name: string; type: string; url: AutomergeUrl; icon?: string; copyOf?: AutomergeUrl }
type UnixFileEntry = { content: string | Uint8Array | ImmutableString; extension: string; mimeType: string; name: string }automergeUrlToServiceWorkerUrl(url)→ a URL usable as<img src>/<audio src>for a doc's bytes. Import from@inkandswitch/patchwork-filesystem.- Docs may carry a
@patchworkmetadata field:{ type: "file", suggestedImportUrl: "…" }. - Load a datatype at runtime:
await getRegistry("patchwork:datatype").load(id).
Do NOT use Tailwind, DaisyUI, or any CSS framework. Write plain CSS following the CUBE CSS methodology (cube.fyi). The theming system provides CSS custom properties for colors, fonts, spacing, radius, and shadows.
We structure CSS using Composition, Utility, Block, Exception layers:
-
Composition — layout primitives that control how elements flow and relate to each other. These are generic and reusable (e.g. a
.flowclass that adds vertical rhythm, a.clusterfor inline groups with gap). Composition classes never apply visual treatments — only spacing and layout. -
Block — the namespaced component. A block is a skeletal container for one contextual piece of UI (e.g.
.history-panel,.sideboard,.comments-thread). Blocks define their appearance using local CSS variables derived from the theme. Child elements use simple descendant selectors:.my-tool .header,.my-tool .title. -
Exception — a state deviation from a block, applied via data attributes (not class modifiers). E.g.
data-selected,data-state="reversed",data-expanded.
<!-- Block with exception via data attribute -->
<div class="history-item" data-selected>…</div>
<!-- Composition + Block together -->
<div class="flow comments-panel">…</div>/* Composition — generic layout */
.flow > * + * {
margin-top: var(--flow-space, var(--studio-space, 0.75rem));
}
/* Block — the component */
.history-item {
display: flex;
gap: var(--studio-space-sm, 0.5rem);
padding: var(--studio-space-xs, 0.375rem);
border-radius: var(--studio-radius-sm, 4px);
cursor: pointer;
}
/* Exception — state via data attribute */
.history-item[data-selected] {
background: var(--history-accent);
color: var(--history-accent-fg);
}The theme exposes two families of surface variables:
--editor-*— the surface a tool/editor paints on (the document content area). Most tools should derive their fill, line, and typography from--editor-*. The editor surface can be themed independently of the studio chrome (e.g. a document with its own paper colour), and--editor-*defaults to the matching--studio-*value, so deriving from editor is always at least as correct as deriving from studio.--studio-*— the studio's own chrome: the top bar, bottom bar, and sidebar. These belong topatchwork-frame(the studio shell). Only UI that is the studio chrome should derive its fill/line from--studio-*.
Rule of thumb: if you're building a tool, derive fill/line/typography from --editor-*. Reach
for --studio-* only when you're styling the studio frame itself.
Accent colours (--studio-primary etc.), spacing, radius, shadow, and transition tokens are
studio-global — the accent itself has no --editor-* equivalent, so use --studio-*
everywhere. The one exception is the accent -text variant (accent used as ink on a
surface), which does fan out per surface — see the accent section below.
Use these variables (with fallbacks) instead of hardcoded values:
Colors (derive from --editor-* in tools):
var(--editor-fill, white)/var(--editor-line, black)— background/foregroundvar(--editor-fill-offset-10)through-50— tinted backgrounds (mix of fill + line)var(--editor-line-offset-10)through-50— muted text (mix of line + fill)var(--editor-selection-fill)/var(--editor-selection-line),var(--editor-cursor-fill)
Accent colors:
var(--studio-primary, #35f7ca),--studio-secondary,--studio-danger,--studio-warningvar(--studio-added),--studio-deleted,--studio-modified,--studio-link
Each of primary/secondary/danger/warning also ships as a Bulma-style contrast pair
plus a text variant — pick by how you're using the accent:
--studio-{accent}-fill— the accent used as a surface (a button/pill/badge background). Same value as the bare--studio-{accent}, but says "I'm a surface".--studio-{accent}-line— the invert: ink (text/icons) placed on that fill, e.g. the label inside a primary button. Legible on the accent regardless of light/dark theme.--studio-{accent}-fill-offset-10..50— a hover/border ramp for the fill (like--studio-chrome-fill-offset-*).-10== the fill itself; the first visible step is-20.--studio-{accent}-text— the accent used as ink on the ambient background (a primary-coloured heading/link on the normal page). This is the one that fans out per surface: use--editor-{accent}-texton the editor surface and--text-editor-{accent}-textin a text editor, since legibility depends on which background the text sits on.
Rule of thumb: painting a surface the accent → -fill (+ -line for text on it, -fill-offset-*
for its hover). Writing text in the accent on a normal background → -text (surface-matched).
Typography (derive from --editor-* in tools):
var(--editor-family-sans, system-ui, sans-serif)— UI textvar(--editor-family-code, ui-monospace, monospace)— code/monovar(--editor-font-size, 16px),var(--editor-line-height, 1.5),var(--editor-font-weight)
Spacing:
var(--studio-space-2xs)(4px) throughvar(--studio-space-2xl)(48px)
Border radius:
var(--studio-radius-sm, 4px)throughvar(--studio-radius-xl, 16px)var(--studio-radius-round, 9999px)for pills
Shadows:
var(--studio-shadow-sm)throughvar(--studio-shadow-lg)
Transitions:
var(--studio-transition-fast, 0.1s ease)throughvar(--studio-transition-slow, 0.25s ease)
Every tool's CSS follows this structure:
- Define local variables in a
@layer packageblock targeting:root, :host, [theme]so they re-evaluate when a theme is applied (the theme system sets a[theme]attribute on<html>) and sit at the right cascade priority (see "CSS cascade layers" below). The@layer package { … }wrapper is required — do NOT define these variables in your top-level/root rule or any other unlayered block:
@layer package {
:root,
:host,
[theme] {
--my-tool-bg: var(--editor-fill, white);
--my-tool-fg: var(--editor-line, black);
--my-tool-muted: var(--editor-line-offset-50, #999);
--my-tool-border: var(--editor-fill-offset-20, #ccc);
--my-tool-accent: var(--studio-primary, #35f7ca);
--my-tool-hover: color-mix(in oklch, var(--editor-fill), var(--editor-line) 5%);
--my-tool-family: var(--editor-family-sans, system-ui, sans-serif);
--my-tool-family-code: var(--editor-family-code, ui-monospace, monospace);
}
}Note fill/line/typography derive from --editor-* (this is a tool), while the accent comes from
--studio-primary (accents have no editor equivalent). The @layer package wrapper is the one
place every host var your tool consumes is mapped to a --my-tool-* token; style rules below
reference only those tokens.
Important: Global --editor-* and --studio-* variables must NEVER be used directly in CSS
rules. They should ONLY appear inside :root, :host, [theme] derivation blocks. The derived
--my-tool-* variables are what you use in actual rules. This ensures themes re-evaluate
correctly when the [theme] attribute changes (e.g. switching between light/dark mode).
This holds for every tool, even one that only touches a couple of globals — a tool whose
CSS is just color: var(--editor-line) and one font-family still needs a derivation block
with --my-tool-fg / --my-tool-family rather than referencing the globals inline. If you find
a global var(--editor-*)/var(--studio-*) (color, font, or background) anywhere in a style
rule, lift it into a --my-tool-* variable in the derivation block and reference that instead.
- Composition classes (if needed) for layout patterns:
.my-tool .flow > * + * {
margin-top: var(--flow-space, var(--studio-space-sm, 0.5rem));
}Note: spacing (--studio-space-*), radius (--studio-radius-*), and transition
(--studio-transition-*) vars are layout concerns and may be used directly in rules with
their fallbacks — only color, font, and background vars need to be derived.
- Block classes using the local variables:
.my-tool {
background: var(--my-tool-bg);
color: var(--my-tool-fg);
font-family: var(--my-tool-family);
}
.my-tool .header {
border-bottom: 1px solid var(--my-tool-border);
color: var(--my-tool-muted);
}- Exception rules via data attributes:
.my-tool .card[data-selected] {
background: var(--my-tool-accent);
}
.my-tool .card[data-state="editing"] {
outline: 2px solid var(--my-tool-accent);
}Never introduce new hex color values. Derive all colors from the theme using
color-mix():
/* Good — derived from theme (editor surface for a tool) */
background: color-mix(in oklch, var(--editor-fill), var(--editor-line) 5%);
border-color: color-mix(in oklch, var(--studio-primary), transparent 50%);
color: color-mix(in oklch, var(--editor-line), var(--editor-fill) 40%);
/* Bad — hardcoded */
background: #f5f5f5;
border-color: rgba(53, 247, 202, 0.5);
color: #666;When you need "lighter" or "darker", mix with var(--editor-fill) or var(--editor-line)
respectively (or the --studio-* equivalents if you're styling the studio chrome) — not
literal white/black — so the derivations invert correctly in dark themes.
When you tint an accent toward the surface (e.g. a soft-filled badge), keep the accent as
--studio-* but anchor the mix on the editor surface so it still inverts with the document:
/* Good — studio accent, editor-surface anchor */
--my-tool-badge: color-mix(in oklch, var(--studio-secondary), var(--editor-fill) 60%);
/* Bad — anchoring the tint on the studio surface inside a tool */
--my-tool-badge: color-mix(in oklch, var(--studio-secondary), var(--studio-fill) 60%);Never add @media (prefers-color-scheme: dark) blocks. The theme system handles
light/dark by swapping the CSS variable values. Your tool just uses the variables.
Register a theme plugin to contribute a color scheme:
{ type: "patchwork:theme", id: "my-theme", name: "My Theme",
style: new URL("./my-theme.css", import.meta.url).href,
async load() { return {} } }The CSS file should use only [theme="my-theme"] as its selector — not :root, :host.
Theme CSS targets the specific [theme] attribute value so multiple themes can coexist in the
document simultaneously (the theming system includes all theme CSS files and switches by setting
[theme] on <html>). The default theme (theme.css) uses :root, :host, [theme] to provide
base values; individual themes override with [theme="name"] only.
Only CSS custom property definitions are wrapped in @layer package { }. Actual style
rules live outside any layer (unlayered) so they can't be overridden by unlayered resets
or normalise stylesheets.
| CSS type | Layer | Priority |
|---|---|---|
Base system variables (theme.css) |
@layer patchwork |
lowest |
| Tool variable defaults | @layer package |
middle |
| Tool style rules | unlayered | high |
| Theme CSS (lychee, gloom, custom) | unlayered | high (specificity wins) |
Variable definitions go in @layer package; style rules stay unlayered:
@layer package {
:root,
:host,
[theme] {
--my-tool-bg: var(--editor-fill, white);
}
}
.my-tool {
background: var(--my-tool-bg);
}If a tool CSS file has no variable definitions, it should have no @layer wrapper at all.
Theme CSS files (lychee.css, gloom.css, custom themes) must not be wrapped in any
layer — they stay unlayered so they reliably override everything.
Bundleless: no build step or toolchain. From the tool dir:
pushwork sync. package.json is minimal:
{ "type": "module", "main": "tic-tac-toe.js" }Bundled (when you want Solid+JSX, TypeScript, npm deps, or a multi-file tree): mark Patchwork
deps external via the bootloader, output a single ES entry, and inject CSS into the JS (because
there's no shadow DOM, styles must ship with the bundle). A .jsx/.js or .tsx/.ts entry
both work — vite compiles them:
// vite.config.js (Solid)
import { defineConfig } from "vite"
import solidPlugin from "vite-plugin-solid"
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"
import external from "@inkandswitch/patchwork-bootloader/externals"
export default defineConfig({
base: "./",
plugins: [solidPlugin(), cssInjectedByJsPlugin()],
build: {
minify: false,
rollupOptions: {
external,
input: "./src/index.jsx",
output: { format: "es", entryFileNames: "[name].js" },
preserveEntrySignatures: "strict",
},
},
})The essentials are format: es, external from the bootloader, and CSS-in-JS. (Some existing
tools use vite-plugin-react — that's the legacy React path; don't reach for React in new tools,
though a Solid or TypeScript bundle is fine.)
package.json: "main": "./dist/index.js", scripts "build": "vite build" and
"push": "pnpm build && pushwork sync". After ANY change to a bundled tool:
cd into the tool dir, pnpm build, then pushwork sync. Use pushwork sync
(not npx pushwork sync). The tool's automerge url is stored in package.json under
pushwork.url.
- No shadow DOM. Tools render into the light DOM. Namespace CSS classes; don't rely on
scoped styles. Bundle CSS into the JS (
vite-plugin-css-injected-by-js). - Scope all CSS under a root class. Put a unique-enough class (the tool id is ideal,
e.g.
class="my-tool-id") on your root element, and nest every selector under it (e.g..my-tool-id .message { ... }). Otherwise your styles leak into the rest of Patchwork and other tools' styles bleed into yours, since everything shares the light DOM. - Size your outermost container; handle scrolling internally. The host renders tools
with
layout: contain, so your tool is a bounded box that won't grow the page or scroll itself. Give the outermost container you mountheight: 100%, then make whichever part should scroll handle its own overflow (overflow: auto) — that may be the root, or just an inner pane (e.g. a message list under a fixed header). Without this the tool clips or overflows instead of scrolling within its pane. - Never
stopPropagation()onclick. Solid (and other frameworks) delegateclicktodocument; stopping it kills theironClick. Only stop propagation onpointerdown/pointerup(what tldraw uses). - No
undefinedin automerge. Usedelete d.x(insidechange) ornull. repo.find/repo.create2return Promises that resolve to ready handles — nowhenReady().- Pin id === tool id. Mismatched ids mean the pin won't resolve to the tool.
- The
pluginsentry module runs in a worker and each entry isstructuredCloned. So a plugin may carry only serializable metadata at the top level — all functions/behaviour go behindasync load()(the host stripsloadbefore cloning; any other top-level function throwsDataCloneError). And no bare external imports in that module's static graph (the worker has no importmap) — keep them insideload()'s dynamicimport(). See §2. - Always return a cleanup function from the render function and actually tear down (listeners, roots, intervals, rAF, AudioContext, workers).
- New tools: default to vanilla JS for simplicity. TypeScript, npm deps, and a vite bundle
are all fine when you want them — pushwork builds them. If you need reactivity, use Solid
(
solid-jswith JSX, orsolid-js/html); never React. Existing tools may be React/Solid/Svelte/TS — match the existing tool's style when editing one, and don't start new tools with React.
// my-counter.js → package.json: { "type": "module", "main": "my-counter.js" }
export const CounterDatatype = {
init(doc) { doc.title = "Counter"; doc.count = 0 },
getTitle(doc) { return doc.title || "Counter" },
setTitle(doc, t) { doc.title = t },
markCopy(doc) { doc.title = "Copy of " + this.getTitle(doc) },
}
function CounterTool(handle, element) {
const root = document.createElement("div")
const style = document.createElement("style")
style.textContent = `.counter-btn{font:600 2rem system-ui;padding:.5rem 1rem}`
element.append(style, root)
function render() {
const { count } = handle.doc()
root.innerHTML = `<button class="counter-btn">count: ${count}</button>`
root.firstChild.onclick = () => handle.change(d => { d.count++ })
}
render()
handle.on("change", render)
return () => { handle.off("change", render); root.remove(); style.remove() }
}
export const plugins = [
{ type: "patchwork:datatype", id: "counter", name: "Counter", icon: "Hash",
async load() { return CounterDatatype } },
{ type: "patchwork:tool", id: "counter", name: "Counter", icon: "Hash",
supportedDatatypes: ["counter"], async load() { return CounterTool } },
]Then from the tool's directory: pushwork sync. Done.
Copy these patterns (vanilla JS — the house style):
- Bundleless / vanilla:
tic-tac-toe,catclock,walkies,sparkles,webtile - Web Components + audio/wasm:
bento,call,sound - Headless actions:
actions(written in TypeScript)
Reach for Solid if you need reactivity: cache-browser, file, chat, paper (these use
JSX + a bundle; solid-js/html is a bundleless alternative).
Legacy React — reference for behavior, NOT for style (don't copy the React approach):
datagrid, boardgame, datalog, doc-copy-history.
- CodeMirror extension:
codemirror-latex,file - Whole-folder / inspector (
supportedDatatypes: ["*"]):inspector,cache-browser,breadboard,doc-copy-history
Each tool's directory often has its own CLAUDE.md/README.md and a prompts/<tool>.md
one-shot spec — read those for tool-specific detail. The repo-root CLAUDE.md is the source
of truth for the APIs above.