# FigCraft plugins A FigCraft plugin is a WebAssembly module with a small JSON manifest. It acts on the document only through FigCraft's commands, the same ones the menus, the CLI, the control channel and the MCP server run (`figcraft-cli commands` lists them), so a plugin can do what a script can do, within the permissions its manifest asks for, and nothing more. - **Sandboxed.** Plugins run in [wasmi](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter (no JIT), in the desktop app and in the browser build alike. There is no WASI: no file system, network, clock or randomness. A run has an instruction budget (fuel) and a memory cap, so an endless loop or a runaway allocation fails that run, not the app. - **One undo step.** Everything a run does is one step in the undo history, named after the plugin; so is everything one callback from its panel does. When a run or a callback fails (the plugin returns an error, traps, panics or runs out of fuel or memory), the document, selection and page are put back as they were. - **Panels and settings (API 2).** A plugin can show a small panel of its own while it runs: FigCraft draws its fields and buttons with its own widgets and calls the plugin back when one is used. A plugin can also keep settings from one run to the next. - **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer (frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its parts calls the plugin back to redraw it from its state, which is kept in the document. - **More for widgets and panels (API 4).** A widget's texts can be edited in place, a selected widget shows a property menu the plugin declares, its parts can have a hover style and a tooltip, and it can ask to be called back after a delay. Widgets draw lines, vectors, gradients and shadows too, and panels get sliders, pictures, progress bars, dividers and sections. - **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and can narrow the commands it may call. FigCraft enforces both. - **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are published to it with the server's key, browsed and installed from Plugins ▸ Manage plugins, and updated when a newer version is published (see [Plugin directories](#plugin-directories)). No third-party service is involved. ## Using plugins - **Plugins ▸ Development ▸ Import plugin from manifest…** picks a `figcraft-plugin.json`. The plugin is kept in the preferences and its module is read again on every run, so a rebuilt development plugin runs its new code without importing it again. - The desktop app saves the preferences the moment a plugin is imported, installed, updated or removed, or changes its settings (a temporary file renamed over the old one), so a crash or a killed app never loses them; it doesn't wait until it quits. - The installed plugins head the **Plugins** menu: a plugin with one command is an item, one with several a submenu. A command with parameters (an ellipsis after its name) first opens a small form with a field per parameter, drawn with FigCraft's own widgets; **Run** runs it. - **Plugins ▸ Run last plugin** (⌥⌘P on macOS, Ctrl+Alt+Shift+P on Windows and Linux, where Ctrl+Alt+P is Pixel preview) runs the last plugin command again with the same parameters. - **Plugins ▸ Manage plugins…** has two tabs. **Installed** lists the installed plugins (version, access, where each comes from: its manifest path or the server it was installed from) with a Remove button for each, and an Import button. **Browse** shows a server's plugin directory, with Install, Update and Remove (see [Plugin directories](#plugin-directories)). - **Plugins ▸ Widgets** lists the installed widget plugins; picking one puts its widget in the middle of the view. A message the plugin shows (`notify`) appears as a toast; so does a text result. **Plugin panels.** A plugin written for API 2 can show a panel while it runs: a floating window, first placed beside the properties panel (drag its title bar to move it), with the plugin's notes, fields (text, number, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets and named for screen readers. A colour field's swatch opens FigCraft's colour picker (the plugin hears of the colour when a drag in the picker ends); its hex can also be typed. Pressing a button or changing a field calls the plugin back with every field's value; what that callback does is one undo step (none when it changes nothing), and the plugin can update the panel or close it. The module stays loaded while its panel is open, so the plugin keeps its state between callbacks. With API 4 a panel also has sliders (a number field beside each takes the keyboard; while one is dragged the plugin hears of it at most ten times a second, and once more when the drag ends), pictures, progress bars, dividers and sections that fold the controls after them; a control can be greyed out, and a field can show a validation message under it. Closing the window ends the plugin's session. Each plugin has at most one panel, and the panels of different plugins open side by side (leftwards from the first, three to a row); running a plugin again replaces its own panel. An open panel costs nothing while nobody uses it (no frames are drawn). **Widgets.** A widget is a frame on the canvas that belongs to its plugin. It moves, copies, deletes, exports and undoes like any layer, as one layer: the layers inside it are locked. Clicking one of its parts that the plugin made clickable (with the Move tool; the pointer turns into a hand over them) calls the plugin back, which redraws the widget; that click is one undo step. Dragging moves the widget instead, and Shift- or Cmd-clicks only select. Each widget keeps its plugin's id and its state (a small JSON object) in its own layer data, so a saved file, a copy, an undo step or a live session carries the widget as it is, and a copy is a widget of its own. A widget whose plugin isn't installed stays as it was drawn; clicking it says the plugin is missing. A widget written for API 4 can do more: - **Text typed in place.** A text the plugin made editable shows an I-beam; a click edits it in place with FigCraft's text editor (caret, selection, the input method), all of it selected. What you type shows at once, stops at the text's maximum length, and stays on one line unless the text takes several. Enter (Escape or a click elsewhere too; in a text of several lines Enter starts a new line) commits: the plugin is called back with the text and redraws the widget, as one undo step. Leaving the text as it was calls nobody. - **A property menu.** A selected widget shows the menu its plugin declared as a bar beside it on the canvas (above it, or under it when there is no room), and the same items under **Widget** in the Design panel, where Tab reaches them: buttons, toggles, dropdowns, a row of colours to pick one from, separators and links. Using one calls the plugin back with the item's name and its new value, as one undo step. A link opens only when it is clicked, only if it is an `http` or `https` address, and its plugin isn't called. - **Hover styles and tooltips.** A part can change its fill, stroke or opacity while the pointer is over it, and show a tooltip. FigCraft does that itself: the plugin isn't called for pointer moves, and the look is never part of the document (it isn't saved, synced or undone). - **Timers.** A callback can ask for the widget to be called back after 100 ms to an hour. A widget has one timer at most; asking again replaces it. Timers are kept with the open file, not in it: they run while the file is open and in front (one that came due meanwhile fires when you come back to it), wait while you drag or type and while a prototype is presented, and are gone once the file is closed or the widget is deleted. A run of timer callbacks is one undo step, and undoing it, or the step that started the timer, stops the timer. The window only wakes when the next timer is due. **Timers in a live session** run for one person: whoever's click (or typing, or menu choice) started the timer last. Starting a timer stamps the widget's layer with a mark only that person's app knows, and a timer fires only while its mark is still on the layer; when someone else starts the widget's timer, their mark replaces it and the earlier timer stops without calling anyone. So two people's timers never take turns rewriting a widget, everyone else sees the timer's updates arrive like any other edit, and when the person whose timer it is leaves, it stops until someone starts it again. A file you may only view runs no callbacks at all. In a presented prototype (the presenter or the preview pane) clicks on a widget's clickable parts call its plugin back the same way, with the same limits, but on the prototype's own copy of the file: the widget changes in the prototype only, the file and its undo history stay as they are, and restarting the prototype starts the widget over. An interaction on the clicked layer wins over the widget. Hover styles and tooltips show there too; a click on an editable text opens a field over it (one line: Enter or a click elsewhere commits, Escape leaves the text as it was); and timers run on the player's clock, for the prototype's copy only. While a prototype is presented full screen, the file's own timers wait. If a callback fails, it is rolled back and its message shown; a plugin that returned an error keeps its panel, one that crashed or ran out of fuel or memory loses it. From scripts, the CLI and MCP the same commands apply, panels included: ```jsonl {"command": "plugin.import", "params": {"path": "plugins/tidy-names"}} {"command": "select.all", "params": {}} {"command": "plugin.run", "params": {"id": "org.figcraft.examples.tidy-names", "command": "tidy", "params": {"case": "Title Case"}}} {"command": "plugin.import", "params": {"path": "target/plugins/dist/swatch-grid"}} {"command": "plugin.run", "params": {"id": "org.figcraft.examples.swatch-grid", "command": "panel"}} {"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "palette", "value": "Ocean"}} {"command": "plugin.event", "params": {"id": "org.figcraft.examples.swatch-grid", "control": "generate"}} {"command": "plugin.close", "params": {"id": "org.figcraft.examples.swatch-grid"}} {"command": "plugin.import", "params": {"path": "target/plugins/dist/counter"}} {"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.counter", "x": 0, "y": 0}} {"command": "widget.list", "params": {}} {"command": "widget.click", "params": {"id": 12, "target": "increment"}} {"command": "widget.menu", "params": {"id": 12}} {"command": "widget.menuAction", "params": {"id": 12, "item": "step", "value": "5"}} {"command": "plugin.import", "params": {"path": "target/plugins/dist/focus-list"}} {"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.focus-list", "x": 0, "y": 200}} {"command": "widget.setText", "params": {"input": "title", "text": "Release day"}} {"command": "widget.click", "params": {"target": "focus"}} {"command": "widget.fireTimer", "params": {}} ``` A widget command without an `id` acts on the selected widget (`widget.insert` selects the one it makes). `figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over MCP, `run_command` takes the same ids. A session started by the CLI or the MCP server starts with no plugins: import them in the script. | Command | Params | Does | |---|---|---| | `plugin.list` | `{}` | `{plugins: [{id, name, version, description, main, api, permissions, commands, path, loaded}], last: {id, command}}` | | `plugin.import` | `{path}` or `{manifest, wasm}` | `path` is a `figcraft-plugin.json` or its folder. Without files (the browser, tests), pass the manifest (an object or JSON text) and the module as base64 `wasm`; such a plugin lasts for the session only. A plugin with the same id is replaced. The module is checked first. | | `plugin.remove` | `{id}` | Uninstalls it. | | `plugin.run` | `{id, command?, params?, fuel?}` | Runs a command (default: the first) with its parameters checked against the manifest and the missing ones filled with their defaults; `fuel` lowers the instruction budget (for the panel's callbacks too). Returns `{plugin, command, output, logs, calls, fuel, panel}`; `output` is the plugin's result, parsed as JSON when it is JSON; `panel` is the plugin's open panel (as `plugin.ui` returns it) or null. | | `plugin.ui` | `{id?}` | Plugin `id`'s open panel: `{plugin, command, title, controls, values}` (`values`: every field's current value by name), or null. Without `id`, the panel opened last. | | `plugin.event` | `{id, control, value?}` | Uses a control of plugin `id`'s open panel, as a click would: a button (no `value`), or a field set to `value` (checked like a parameter: a number is clamped, a list or menu value must be one of its options, a colour must be hex). The plugin is called back; returns like `plugin.run`. | | `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. | | `plugins.runLast` | `{}` | Runs the last plugin command again. | | `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. | | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, inputs, menu, timer, output, logs, calls, fuel}`: `targets` are the names of its clickable parts, `inputs` its editable texts (`[{name, text, maxLength, multiline}]`), `menu` its property menu (as `widget.menu` lists it), `timer` the milliseconds until its pending timer is due (null: none). | | `widget.click` | `{id?, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in; without `id`, the selected one), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.setText` | `{id?, input, text, fuel?}` | API 4. Commits `text` to the editable text named `input`, as typing it in place and pressing Enter does: cut to its maximum length, on one line unless it takes several, control characters dropped. The plugin is called back with it; one undo step; returns like `widget.insert`. | | `widget.menu` | `{id?}` | API 4. `{id, plugin, items: [{type, name, label, value, options, url}]}`: the widget's property menu (`type`: `action`, `toggle`, `dropdown`, `color`, `separator` or `link`). | | `widget.menuAction` | `{id?, item, value?, fuel?}` | API 4. Uses menu item `item`: an action takes no `value`; a toggle `true` or `false` (without one it flips); a dropdown or a colour one of its `options`. The plugin is called back; one undo step; returns like `widget.insert`. A link isn't opened and its plugin isn't called: `{id, item, url}`. | | `widget.fireTimer` | `{id?, fuel?}` | API 4. Fires the widget's pending timer now, for scripts and tests (the app fires it when it is due; a headless session has no clock for it): the plugin is called back. A run of timer callbacks is one undo step. Refused when no timer is pending. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, inputs, menu, timer}]}`: every widget in the file. | | `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | | `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | ## Plugin directories A `figcraft-server` ([`server.md`](server.md#plugin-directory)) can keep a directory of plugins for a team. Anyone who can reach the server may browse it and install from it; only whoever has the server's key may publish to it or unpublish from it. There are no accounts: the server's operator decides who gets the key and moderates what's there. **Installing.** In **Plugins ▸ Manage plugins… ▸ Browse**, type the server's address (`ws://host:47900/`, or `wss://…` behind TLS) and press **Browse**: nothing is fetched before that. The list shows each plugin's latest version, author (as the publisher typed it), size and the access it asks for; the search filters it. **Install** opens a confirmation that lists what the plugin asks permission to do (read or edit the document, the commands it may call, whether it draws a widget), the server it comes from and its SHA-256; only its **Install** button downloads and installs it. The app never waits for the server: while the listing or the download is on its way, Browse shows "Fetching from the server…" and the confirmation "Installing…", each with **Cancel**, and the rest of the app keeps working (closing the window cancels too). The window is drawn again only when the answer arrives. Scripts, the CLI, the control channel and the MCP server get their answer from the same commands as before, waiting for it. The download must match the SHA-256 the directory lists, and the bundle is then checked like an imported plugin (manifest, plugin API version, the module compiled but not run, asset names) before anything is written. A plugin installed this way runs in the same sandbox, with the same limits and its manifest's permissions, as any other. The desktop app keeps such plugins per user, beside its settings (`Plugins/` in FigCraft's settings folder: `%APPDATA%\FigCraft` on Windows, `~/Library/Application Support/FigCraft` on macOS, `~/.config/FigCraft` elsewhere), each in a folder of its own named by a hash of its id, with its module and its assets. The CLI and the MCP server install for the session only. **Updating.** When the directory has a newer version than the one installed, the plugin's row offers **Update**, with the same confirmation (the permissions may have changed). Browsing again is the update check; nothing checks in the background. **Remove** uninstalls it and deletes its folder. **Publishing.** A plugin folder holds `figcraft-plugin.json`, the module its `main` names and, if you like, an `assets/` folder of up to 32 plain files (an icon, a licence) that are installed with it. Publish it with the server's key: ```sh FIGCRAFT_SERVER_KEY= figcraft-cli plugin publish target/plugins/dist/counter --server ws://host:47900/ ``` (the key may also be in the address, `?key=…`, but the environment keeps it out of your shell history), or `plugin.directory.publish {path, server}` from a script or MCP. The server checks the whole bundle before keeping it, refuses a version that isn't newer than the one it has, and lists the new version at once. `plugin.directory.unpublish` (or the operator's `figcraft-server plugins remove`) takes a version, or the whole plugin, down; installed copies keep working. **In the browser** the directory isn't available yet: the browser build's network link has no plugin directory requests (`plugin.directory.*` says so). Import plugins with `plugin.import {manifest, wasm}` there. ## The manifest: `figcraft-plugin.json` ```json { "id": "org.figcraft.examples.swatch-grid", "name": "Swatch grid", "version": "1.0.0", "description": "Makes a grid of colour swatches from a palette of hex colours.", "main": "swatch_grid.wasm", "api": 1, "permissions": { "access": "write", "commands": ["view.get", "shape.rectangle", "text.create", "select.set"] }, "commands": [ { "id": "grid", "label": "Swatch grid…", "params": [ { "name": "palette", "label": "Palette", "type": "text", "default": "#FF5A5F, #00A699" }, { "name": "columns", "label": "Columns", "type": "number", "default": 3, "min": 1, "max": 24 }, { "name": "labels", "label": "Label each swatch", "type": "boolean", "default": true } ] } ] } ``` | Field | | |---|---| | `id` | Required. 1–128 letters, digits, `.`, `-`, `_`; a reverse domain name is a good choice. | | `name` | Required. Shown in the Plugins menu (64 characters at most). | | `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | | `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (text input, property menus, hover, timers and richer drawing in widgets; sliders, pictures and sections in panels). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. What a version added is read only from plugins that ask for it: an older plugin's descriptions are read as its version read them, so it behaves as it always did. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | | `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`, unless it's a widget (a widget needs no command). | | `params[]` | `{name, label?, type, default?, options?, min?, max?, step?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list), `color` (hex, `#RRGGBB` or `#RRGGBBAA`) or, with API 4, `slider` (a number from `min` to `max`, 0 to 100 when not given, in `step`s). A default that doesn't fit is dropped. | ## What a plugin can't do Whatever its manifest says, FigCraft refuses these calls (the plugin gets the refusal as an error it can handle): - files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`, `image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`; - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and `version.list`); - the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is already one undo step), `plugin.*` / `plugins.*` (no plugin runs another) and `widget.*`; - writing layer data whose key starts with `figcraft.widget` (a widget's own data: no plugin can forge a widget or change another one's state); - commands that open a dialog or panel (for example `object.rename` without a `name`, which would start renaming in the layers panel): the call is undone and refused; - for a `read` plugin, any command that changes the document (checked before the call for commands that make an undo step, and after it for the rest: a change is undone and refused). Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls (each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to 16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and its images 1 MB each, 2 MB together and 4096 pixels on a side. With API 4: a text or a typed text is at most 1,000 characters (`maxLength` lowers it), a vector's path data 8 KB with every coordinate within ±4,000 and 64 vectors in a widget, a gradient has 2–8 stops, a tooltip is at most 200 characters, a property menu has at most 16 items with 32 options each and links of at most 2,048 characters, a timer runs 100 ms to an hour with one pending per widget, and a panel is at most 1 MB with its pictures 512 KB together (a picture is shown up to 2,048 pixels on a side). Every one of those callbacks (typed text, a menu item, a timer) is metered and capped like a click. ## ABI (version 4) Version 2 adds panels and settings to version 1, version 3 adds widgets, and version 4 adds one host function (`widget_timer`), more events for widgets and more in the panel spec and the widget description; a version 1, 2 or 3 module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts, the reply buffer holds the input JSON, `input_len` bytes: ```json {"api": 1, "command": "grid", "params": {"palette": "#FF5A5F", "columns": 3, "labels": true}, "selection": [12, 15]} ``` `api` is the manifest's. When the plugin's panel calls it back, `figcraft_run` runs again in the same instance (its memory as the last call left it) with the event and every field's value added: ```json {"api": 2, "command": "panel", "params": {}, "selection": [], "event": {"control": "generate", "value": null}, "values": {"palette": "Ocean", "columns": 3, "labels": true}} ``` When its widget is inserted or clicked, `figcraft_run` runs in a fresh instance (nothing is kept from one call to the next: a widget's state is all it remembers, so the same state and click always give the same widget) with `widget` added: the widget's layer id, its state (an empty object for a new widget) and what was clicked (`event` is null when it's inserted): ```json {"api": 3, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"target": "increment"}}} ``` With API 4, `event` is also one of these, for a text typed in place, an item of the property menu (`value`: a toggle's new state, a dropdown's or colour's choice, null for an action) and a timer that is due: ```json {"input": "title", "text": "Release day"} {"menu": "step", "value": "5"} {"timer": true} ``` It returns `0` for success; anything else fails the run (and rolls it back), with the text passed to `set_output` as the error message. The module may **import** these functions from the module `figcraft` (anything else, WASI included, is refused when the plugin is imported): | Import | Signature | Does | |---|---|---| | `call` | `(cmd_ptr, cmd_len, params_ptr, params_len) -> status` | Runs a command with JSON params (empty: `{}`). `0`: done, the reply holds its result JSON. `1`: refused or failed, the reply holds the message. | | `selection` | `() -> len` | The reply becomes the selected layer ids, a JSON array. | | `reply_len` | `() -> len` | The reply's length in bytes. | | `reply_read` | `(ptr, len) -> copied` | Copies up to `len` bytes of the reply to `ptr`. | | `log` | `(ptr, len)` | A line of the run's log (returned in `logs`, and written to FigCraft's log). | | `notify` | `(ptr, len)` | A short message for the user (a toast in the app). | | `set_output` | `(ptr, len)` | The run's result (JSON or text), or its error message when it fails. | | `ui_show` | `(ptr, len) -> status` | API 2. Shows the plugin's panel, or replaces the one showing (the panel spec below, as JSON). `1`: the spec is invalid, the reply says why. | | `ui_close` | `()` | API 2. Closes the panel when the call returns. | | `setting_get` | `(key_ptr, key_len) -> status` | API 2. `0`: the reply holds the setting's JSON. `1`: there is none. | | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. | | `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. | | `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). | | `widget_timer` | `(ms) -> status` | API 4. Asks for the widget being called back to be called again in `ms` milliseconds (100 to 3,600,000), replacing its pending timer; `0` stops it. `1`: refused (out of range, or the manifest's `api` is below 4), the reply says why. The timer starts when the callback succeeds. | A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only be called while `figcraft_run` runs (not from a start function). Settings are forgotten when the plugin is removed. ### Panel spec ```json {"title": "Swatch grid", "controls": [ {"type": "list", "name": "palette", "label": "Palette", "options": ["Sunset", "Ocean"], "value": "Ocean"}, {"type": "note", "label": "#0A2463, #1E5AA8, #3E92CC"}, {"type": "number", "name": "columns", "label": "Columns", "min": 1, "max": 24, "value": 3}, {"type": "boolean", "name": "labels", "label": "Label each swatch", "value": true}, {"type": "color", "name": "accent", "label": "Accent", "value": "#FF5A5F"}, {"type": "button", "name": "generate", "label": "Generate"}, {"type": "button", "name": "close", "label": "Close"} ]} ``` `title` defaults to the plugin's name. Controls are the manifest's parameter types (`text`, `number`, `boolean`, `select`, `list`, `color`) plus `button` and `note` (text in `label`, up to 1,000 characters, no `name` needed), in order, each `name` unique. A field shows its `value`; a field without one keeps what the user entered before the panel was updated, so a plugin can update a note without resetting the other fields. Buttons next to each other share a row; the first button is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values that don't fit their field. API 4 adds these controls and fields: ```json {"title": "Dot grid", "controls": [ {"type": "text", "name": "name", "label": "Name", "value": "", "error": "Give the grid a name"}, {"type": "slider", "name": "columns", "label": "Dots per row", "min": 2, "max": 12, "step": 1, "value": 6}, {"type": "section", "label": "Spacing", "value": false}, {"type": "slider", "name": "gap", "label": "Gap", "min": 0, "max": 32, "step": 1, "value": 8}, {"type": "divider"}, {"type": "image", "label": "A preview of the dot grid", "data": "Qk02bAAA…", "height": 120, "fit": "fit"}, {"type": "progress", "label": "36 of 144 dots", "value": 0.25}, {"type": "button", "name": "generate", "label": "Generate", "disabled": true} ]} ``` - `slider`: a field holding a number from `min` to `max` (0 to 100 when not given) on a `step` counted from `min` (`number` takes `step` too). - `image`: a picture from its file's bytes as base64 in `data` (PNG, JPEG, BMP…, usually carried in the module), `height` high (16–400, 120 when not given), `fit` in the panel's width (`fit` shows all of it, `fill` stretches it, `crop` and `tile` cover the box); `label` names it for screen readers. - `progress`: a bar filled to `value` (0–1), with `label` above it. - `divider`: a line across the panel. - `section`: a heading (`label`) that folds the controls after it, up to the next section or divider; `value` says whether it starts open (true when not given). Folding is the user's and calls nobody. - `disabled: true` on any control greys it out: it can't be used (and `plugin.event` refuses it). - `error` on a field shows that message under it (200 characters at most). None of these needs a `name` except `slider`. An API 4 panel may be 1 MB. ### Widget description ```json {"state": {"count": 2}, "root": {"type": "frame", "layout": "horizontal", "gap": 12, "padding": 12, "radius": 12, "fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [ {"type": "frame", "target": "decrement", "layout": "horizontal", "justify": "center", "width": 32, "height": 32, "radius": 8, "fill": "#F0F0F0", "children": [{"type": "text", "text": "−", "size": 16, "bold": true}]}, {"type": "text", "name": "Count", "text": "2", "size": 24, "bold": true, "color": "#1E1E1E"}, {"type": "image", "data": "iVBORw0KGgo…", "width": 24, "height": 24} ]}} ``` `state` is the widget's new state, a JSON object (left out: it keeps the one it has). `root` is the widget itself, a frame; FigCraft replaces everything inside it with the layers described and keeps the widget where it is (and its size, unless the description gives one). Every layer has a `type` (`frame`, `text`, `rectangle`, `ellipse`, `image`) and may have a `name`, a `target` (clicking it calls the plugin back with that name: letters, digits, `.`, `-`, `_`), `x` and `y` (in its frame, ignored in auto layout), `width` and `height`, `fill` and `stroke` (hex colours), `strokeWeight`, `radius` (frames, rectangles, images) and `opacity`. A frame may have `layout` (`horizontal` or `vertical`), `gap`, `padding`, `justify` and `align` (`min`, `center`, `max`: along and across the layout) and `children`; with auto layout it hugs its contents on an axis without a size. A text has `text`, `size`, `color` and `bold` (with a `width` it wraps). An image has `data`, the image's bytes (PNG, JPEG…) as base64, usually carried in the module itself; without a size it takes the image's. FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth, image sizes) and refuses it if anything is off. API 4 adds: ```json {"state": {"title": "Launch checklist"}, "menu": [ {"type": "action", "name": "clear", "label": "Clear ticked"}, {"type": "toggle", "name": "pinned", "label": "Pinned", "value": false}, {"type": "dropdown", "name": "length", "label": "Countdown", "options": ["30 seconds", "1 minute"], "value": "30 seconds"}, {"type": "color", "name": "paper", "label": "Paper", "options": ["#FFF3B0", "#D9F2E3"], "value": "#FFF3B0"}, {"type": "separator"}, {"type": "link", "name": "help", "label": "About FigCraft", "url": "https://getartcraft.com/apps/figcraft"}], "root": {"type": "frame", "layout": "vertical", "width": 272, "gap": 10, "padding": [16, 16, 20, 16], "radius": [2, 2, 14, 2], "gradient": {"type": "linear", "angle": 90, "stops": [{"at": 0, "color": "#FFF7C7"}, {"at": 1, "color": "#FFF3B0"}]}, "shadow": {"x": 0, "y": 4, "blur": 12, "color": "#00000030"}, "children": [ {"type": "text", "text": "Launch checklist", "input": "title", "maxLength": 60, "width": 240, "size": 16, "weight": 700, "textAlign": "left", "maxLines": 2, "tooltip": "Click to rename"}, {"type": "line", "width": 240, "stroke": "#00000022", "fillWidth": true}, {"type": "frame", "target": "toggle.0", "layout": "horizontal", "justify": "center", "width": 16, "height": 16, "radius": 4, "fill": "#2B2B2B", "hover": {"opacity": 0.8}, "children": [{"type": "vector", "path": "M2 6 L5 9 L10 3", "stroke": "#FFFFFF", "strokeWeight": 2}]}, {"type": "image", "data": "iVBORw0KGgo…", "width": 24, "height": 24, "fit": "fit"} ]}} ``` - **Layers.** `line` is a horizontal line `width` long (black and 1 thick unless it has a `stroke`). `vector` is a shape from SVG path data in `path` (`M L H V C S Q T A Z`, absolute or relative); its box is the path's, and a `width` or `height` scales it. - **Auto layout.** `padding` is one number or four (`[top, right, bottom, left]`), `radius` one or four (`[top-left, top-right, bottom-right, bottom-left]`). `fillWidth` / `fillHeight` on a child take the room that's left on that axis of its auto layout frame. That frame needs a size on the axis (its own, or because it fills too): a frame can't hug what fills it, and the widget itself has nothing to fill. - **Looks.** `gradient` replaces `fill` on frames, rectangles, ellipses and vectors: `linear` along `angle` in degrees (0 runs left to right, 90 top to bottom, the default) or `radial` from the centre, with 2–8 `stops` (`at` 0–1, `color` hex). `shadow` is a drop shadow (`x`, `y`, `blur`, `spread`, `color`). - **Text.** `weight` (100–900; it overrides `bold`), `textAlign` (`left`, `center`, `right`), and with a `width`, `maxLines`: the text keeps a fixed box and what doesn't fit is cut with an ellipsis. - **Images.** `fit` is `fill` (the default), `fit`, `crop` or `tile`. - **Editable text.** `input` names a text the user can edit in place; the plugin gets `{"input": name, "text": …}` when it's committed. `maxLength` (1–1,000) caps it and `multiline` lets it take several lines. Each name is used once. The plugin decides what the widget shows afterwards: a callback that draws nothing leaves the text as it was. - **Hover and tooltip.** `hover` (`fill`, `stroke`, `opacity`) is what changes while the pointer is over the layer; `tooltip` (200 characters at most) shows when it rests there. - **The property menu.** `menu` beside `root` lists the items (16 at most): `action` (a button), `toggle` (`value`: true or false), `dropdown` (`value`: one of `options`), `color` (`value`: one of the hex colours in `options`), `separator` and `link` (`url`, http or https). Every item but a separator has a `name`, used once, and a `label`. Left out, the widget keeps the menu it has; an empty list removes it. FigCraft remembers a toggle's, dropdown's or colour's new value when it's chosen, so a callback needn't hand the menu back. A plugin written for API 3 gets none of this: the fields are ignored as unknown fields always were, and the new kinds and the four-number forms are refused as they always were. ## Writing a plugin in Rust `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate `figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and `plugins/examples` has five plugins: - **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left by duplicating) and optionally their letter case, inside the selection too. Two commands: one with a form (case, include layers inside), and a quick one without. - **Swatch grid** (`swatch-grid`, API 2): a grid of colour swatches, centred in the view, named after their colours and optionally labelled. **Palettes…** opens a panel with a list of built-in palettes (picking one shows its colours), columns, labels and their colour, and a Generate button; the palette picked last is remembered as a setting for next time. **From hex colours…** takes a palette of hex colours in the parameters form. - **Counter** (`counter`, API 4): a widget, a count between − and + buttons; each click changes the count kept in the widget's state. Its property menu resets the count and picks the step of a click and the count's colour, and its buttons have a hover style and a tooltip. It only reads the document. - **Focus list** (`focus-list`, API 4): a sticky checklist widget laid out with auto layout. Its title and items are edited in place, a last line adds an item, each item has a box to tick (a vector tick), and a button starts a focus countdown that a timer ticks down every second, with a progress line. The menu clears ticked items, sets the countdown's length and picks the note's colour. - **Dot grid** (`dot-grid`, API 4): a panel with sliders for the dots per row, their size and the gap between them (in a folding section), a colour, a preview picture that follows them (drawn pixel by pixel in the plugin: no image file) and a progress bar. Without a name the field says so and Generate is greyed out; Generate puts the grid in the middle of the view as one group. `cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and builds them, leaves each example's manifest and module side by side in `target/plugins/dist//`, ready to import (it prints each module's size: 75–92 KB), and then runs every built example in the engine as a script would, so a module that stops working fails the build. A plugin of your own: ```toml # Cargo.toml [package] name = "my-plugin" version = "0.1.0" edition = "2024" [lib] crate-type = ["cdylib"] [dependencies] figcraft-plugin-sdk = { path = "../figcraft/plugins/sdk" } [profile.release] opt-level = "z" lto = true panic = "abort" strip = true ``` ```rust // src/lib.rs use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify}; fn run(input: Input) -> Result { if input.selection.is_empty() { return Err("Select some layers first".into()); } for id in &input.selection { let layer = call("node.get", &json!({"id": id}))?; let name = layer["name"].as_str().unwrap_or_default().to_uppercase(); call("object.rename", &json!({"ids": [id], "name": name}))?; } notify(&format!("Renamed {} layers", input.selection.len())); Ok(json!({"renamed": input.selection.len()})) } entry!(run); ``` Build it with `cargo build --release --target wasm32-unknown-unknown`, put `target/wasm32-unknown-unknown/release/my_plugin.wasm` next to a `figcraft-plugin.json` whose `main` names it, and import the manifest. The SDK gives `call`, `selection`, `log`, `notify` and the `Input` (`command`, `params` with `str`/`f64`/`bool` helpers, `selection`, and `event` when the panel calls back: `control`, `value` and every field's `values`); returning `Err` fails the run and rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With `"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With `"api": 4`: `input.widget.event` says what happened (`WidgetEvent::Insert`, `Click(target)`, `Text { input, text }`, `Menu { name, value }` or `Timer`), `set_timer(ms)` and `cancel_timer()` manage the widget's timer, and the `ui` module builds descriptions without spelling the JSON out: ```rust use figcraft_plugin_sdk::ui::{Node, menu_action, menu_toggle, render}; use figcraft_plugin_sdk::{Input, Result, Value, WidgetEvent, entry, json, set_timer}; fn run(input: Input) -> Result { let w = input.widget.ok_or("Insert me from Plugins ▸ Widgets")?; let mut title = w.state["title"].as_str().unwrap_or("Untitled").to_string(); match &w.event { WidgetEvent::Text { input, text } if input == "title" && !text.is_empty() => title = text.clone(), WidgetEvent::Click(target) if target == "remind" => set_timer(60_000)?, _ => {} } let root = Node::column() .width(240.0) .gap(8.0) .padding(12.0) .radius(12.0) .fill("#FFFFFF") .shadow(0.0, 2.0, 8.0, "#00000033") .child(Node::text(&title).input("title", 60).width(216.0).weight(600.0).tooltip("Click to rename")) .child(Node::line(216.0).stroke("#E6E6E6", 1.0).fill_width()) .child(Node::row().target("remind").padding(8.0).radius(8.0).fill("#F0F0F0").hover(Some("#E0E0E0"), None, None) .child(Node::text("Remind me in a minute"))); render(&json!({"title": title}), root, Some(vec![menu_action("clear", "Clear"), menu_toggle("pinned", "Pinned", false)]))?; Ok(Value::Null) } entry!(run); ``` `Node::set(key, value)` reaches any property the builders don't name, and plain `json!` still works everywhere. For panels, `ui::slider`, `ui::image` (from a file's bytes; `ui::base64` encodes them), `ui::progress`, `ui::divider`, `ui::section`, `ui::disabled` and `ui::invalid` make the controls API 4 added. Outside WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, import what you need from `figcraft`. ## Not yet - Distribution: directories live on your own `figcraft-server`; there is no public catalogue, no publisher accounts or signatures (the server's key is the publisher), no ratings, reviews or install counts, and updates are found when you browse, not in the background. The browser build can't use a directory yet. - Plugins written for other apps (JavaScript plugins) don't run. - Widgets: they can't open a panel, their layers can't be components or use styles or variables, an image is bytes in the description (the module carries it; a widget can't name a file from the plugin's `assets/` folder), there are no pointer events beyond clicks and FigCraft's own hover styles (no drag, no callback on hover), and a timer doesn't run while its file is closed or behind another tab. In a presented prototype, typed text is one line and can't be emptied. - Panels: no free layout or tabs inside a panel (sections fold; controls stack), and a slider has no tick marks or labels of its own. - The property menu bar on the canvas is used with the pointer; from the keyboard its items are in the Design panel. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: {pluginData: {key: value}}}`; there is no separate per-document store.