mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
## What
**Layers panel from the keyboard** (two of the three open items of area 3, plus long-list QA)
- **F2 renames** the selected layer (a second key for `object.rename`, next to Cmd/Ctrl+R) from the canvas, after a click on a row, or from a layer row that has the keyboard (F6 mode, where the canvas shortcuts are off, so the layers branch of `a11y::keys` runs it). When the rename ends the row gets the keyboard back (`a11y::refocus_row`); Escape in the field cancels and stays in the panel instead of jumping to the canvas.
- **Shift+F10 opens the context menu** (`view.contextMenu`, a new command that only asks the UI): at the focused layer row when the layers panel has the keyboard (F6 or a clicked row), else on the canvas just below the selection (the middle of the canvas with nothing selected). The menu takes the keyboard like the other menus opened from the keyboard (`a11y::popup`), and Escape gives it back to the row or the canvas. The Menu key can't be supported: egui 0.36 has no key for it.
- **Keyboard box selection** (`select.area`, F8): for keyboard-only users, a selection box appears around the selection (or in the middle of the view); the arrows move it, Shift+arrows move its far corner (10 screen pixels a press), Enter selects what it covers exactly like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter takes nested layers), Escape or a click drops it. A toast and the canvas's screen-reader description say the box's size and the keys. With a box (`{x, y, width, height, add?, deep?}`) the command selects directly, for agents. The marquee's rules (top-level layers, frames only when covered, the children of the frame the box starts in, the focus view, Shift toggling) moved into `Session::marquee_within` / `marquee_selection`, shared by the pointer marquee and the command.
- **Long lists (10k layers)**: a test that a layer near the bottom of a 10k-layer list is revealed (parents expanded, list scrolled) and that F6 lands the keyboard on its row and the arrows walk on from it; the UI bench (`perf_ui_frames`) gained rows for expanding every layer, arrow keys down the expanded list, revealing a layer at the bottom and collapsing everything. Release, 10k layers: first frame with every layer expanded 1.22 ms, an arrow key 1.04 ms, reveal 0.30 ms, collapse all 0.37 ms, idle 0.17 ms.
**Stroke styles** (area 6)
- What the public documentation describes for strokes was mostly already in: colour styles apply to strokes from the stroke section's style picker (`style.apply {target: "stroke"}`, linked, following `style.update`, one undo step, `style.detach {which: "stroke"}` keeping the colour), and the stroke weight binds to number variables. A test now covers that path.
- Missing piece, added: **each side's stroke weight binds to a number variable on its own** (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`): `node.set` keys (setting or binding one side turns on individual strokes with the other sides at the weight), the stroke-weight variable scope, the stroke override group for instances, and REST `boundVariables` import. The Design panel's per-side fields are bindable, two to a row like the corner radii so a variable's name fits.
New strings go through `tr(...)` with `en.toml` / `es.toml` entries, and the two new commands have Spanish labels. Shortcuts are only in `keymap.rs`. Nothing redraws while idle (the box draws only when a key moves it). Selecting from the box leaves no undo step; each stroke edit is one.
## Verified
- **Desktop app** over the control channel (`target/debug/figcraft.exe` from this worktree's target dir, `--control <free port>`, separate `APPDATA`, keys sent as real `ui.input` key events, stopped by PID): F2 on a selected layer opens its name field in the layers panel (screenshot), typing and Enter rename it; F6 to the layers panel, Shift+F10 opens the menu at the Avatar row with Copy focused (screenshot), Escape gives the keyboard back to the row, F2 + typing + Enter renames it and the row keeps the keyboard ("Profile"); Shift+F10 on the canvas opens the menu for the selected frame with Copy focused (screenshot); F8 shows a box around the selected frame, Shift+arrows stretch it over a second layer (screenshot with the hint toast), Enter selects both; a rectangle with the colour style "Brand/Accent" on its stroke and its top weight bound to a number variable (screenshot: the style chip in the stroke section, the red stroke thicker at the top, the per-side field showing the variable).
- **MCP**: `figcraft-cli mcp` (headless): created a paint style, `style.apply {target: "stroke"}`, `object.rename`, bound `strokeLeftWeight` to a variable and ran `select.area` over the layer; `inspect_document` shows the name "Outlined card", `styles.stroke: "S:1"` with the style's colour on the strokes, `strokeSides [2, 2, 2, 6]`, `boundVariables {strokeLeftWeight}`, and one undo step per command.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: release UI bench at 10k layers (numbers above).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.
## Tests
- `ui-egui` `layers_keyboard_tests` (3): F2 from the canvas and from a focused row (the row gets the keyboard back, Escape cancels without leaving the panel); Shift+F10 at a focused row, on the canvas below the selection and after a clicked row, the menu taking and giving back the keyboard; F8: the box around the selection, the screen-reader description, Shift+arrows sizing, Enter selecting, arrows moving it without nudging layers, Escape, the middle of the view with nothing selected, a click dropping it.
- `ui-egui` `perf_tests::a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row`, plus the new bench rows.
- `engine` `select_area_tests` (2): marquee rules through the command (negative sizes, frames, starting inside a frame, deep, add toggling, empty spots), the UI request with no box, bad boxes refused (the junk sweep covers both new commands too).
- `engine` `stroke_style_tests` (2): a colour style on strokes (link, undo, style edits, detach); stroke weight and per-side weights bound to variables following the frame's mode, `node.set` per-side keys, scopes and override groups.
## Not in this PR
- The Menu (application) key: egui 0.36 doesn't deliver it.
- Enter doesn't rename: on the canvas and a focused row it already selects children / activates the row.
- The keyboard box doesn't pan the view when it is moved off screen (zoom and pan keys still work while it shows).
- Shift+F10 does nothing while a toolbar or properties control has the keyboard.
- Progress docs: area 3 95%/78% → 98%/82%, 2–4 h → 1–2 h; area 6 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; percentages unchanged at this rounding; full parity ~323–621 h → ~322–618 h (target-app-parity, ROADMAP); ui-parity rows (context menus, keyboard box selection, layers from the keyboard); `docs/mcp.md` documents `object.rename` without a name, `view.contextMenu`, `select.area` and stroke styles and variables.
1076 lines
84 KiB
Markdown
1076 lines
84 KiB
Markdown
# FigCraft MCP server
|
||
|
||
FigCraft ships a [Model Context Protocol](https://modelcontextprotocol.io) server so AI agents can
|
||
design with it: create and edit layers, run any menu command, inspect the result as JSON and look at
|
||
it as a PNG. It speaks newline-delimited JSON-RPC 2.0 over stdio (MCP protocol `2025-06-18`;
|
||
`2025-03-26` and `2024-11-05` clients are accepted too).
|
||
|
||
The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI:
|
||
|
||
| Command | Backend |
|
||
|---|---|
|
||
| `figcraft-cli mcp` | **Headless**: an in-process engine session with one untitled document. No window; renders with the CPU renderer. |
|
||
| `figcraft-cli mcp --attach <port>` | **Attached**: drives a running FigCraft app through its control channel (`figcraft --control <port>`). The user sees every change live. |
|
||
|
||
`--attach` also takes `host:port`. The control channel only listens on 127.0.0.1.
|
||
|
||
## Configure a client
|
||
|
||
Build the CLI first (`cargo build --release -p figcraft-cli`) and use the absolute path to
|
||
`target/release/figcraft-cli`.
|
||
|
||
### Claude Code
|
||
|
||
```sh
|
||
# headless
|
||
claude mcp add figcraft -- /path/to/figcraft-cli mcp
|
||
# drive the running app (start it with: figcraft --control 7979)
|
||
claude mcp add figcraft-app -- /path/to/figcraft-cli mcp --attach 7979
|
||
```
|
||
|
||
Or in a project's `.mcp.json`:
|
||
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"figcraft": { "command": "/path/to/figcraft-cli", "args": ["mcp"] }
|
||
}
|
||
}
|
||
```
|
||
|
||
### Claude Desktop
|
||
|
||
`claude_desktop_config.json` (Settings > Developer > Edit Config):
|
||
|
||
```json
|
||
{
|
||
"mcpServers": {
|
||
"figcraft": { "command": "/path/to/figcraft-cli", "args": ["mcp"] },
|
||
"figcraft-app": { "command": "/path/to/figcraft-cli", "args": ["mcp", "--attach", "7979"] }
|
||
}
|
||
}
|
||
```
|
||
|
||
## Conventions agents must know
|
||
|
||
- Coordinates are **page units** (1 unit = 1 px at 100% zoom), x right, **y down**.
|
||
- `create_node` and `set_properties` take `x`/`y` **relative to the parent** (the page when there is
|
||
no parent, so they are page coordinates then). `inspect_document` reports parent-relative `x`/`y`
|
||
plus `absoluteBounds` in page coordinates.
|
||
- Children of an auto-layout frame are positioned by the layout: set `layout`, `gap`, `padding`,
|
||
`mainAlign`, `crossAlign`, `sizingH`/`sizingV` instead of x/y. More auto layout props:
|
||
- `sizingH` / `sizingV`: `fixed`, `hug` (auto layout frames and text) or `fill` (children in an
|
||
auto layout flow). A child can't fill an axis its parent hugs: setting `fill` turns the parent
|
||
`fixed` on that axis, and a parent set to `hug` turns the children that filled it `fixed`. A
|
||
layer moved out of auto layout (or whose frame loses it) keeps its size as `fixed`. Fill
|
||
children share the space like CSS flexbox, within their min/max.
|
||
- `minWidth` / `maxWidth` / `minHeight` / `maxHeight` (px, `null` removes) bound Hug, Fill and
|
||
Fixed sizes; a hugging text wider than its max width wraps.
|
||
- `absolute: true` takes a child out of the flow (it stays where it is and follows its
|
||
`constraints` when the frame resizes; Fill becomes Fixed).
|
||
- `mainAlign` takes `min`, `center`, `max` or an auto gap: `spaceBetween` (no space at the
|
||
edges), `spaceEvenly` (the same space in every gap and at the edges) or `spaceAround` (gaps
|
||
twice the edge space). An auto gap never goes below 0.
|
||
- `padding` never lets a frame get smaller than it: a fixed or hugging frame (or one with a
|
||
smaller max size) sizes up to its padding.
|
||
- `strokesIncluded` (default `true` for new auto layout): the frame's own inside stroke takes up
|
||
space like padding (per side with `strokeSides`); center and outside strokes never do.
|
||
- `reverseZ` (canvas stacking): `true` paints and hits the first child on top, `false` (default)
|
||
the last. The layer order doesn't change.
|
||
- `crossAlign: "baseline"` lines up the first text baselines in horizontal layouts (other layers
|
||
align to the top; nested auto layout frames use their first child's baseline).
|
||
- `wrap` with `crossGap` (alias `rowGap`) for the gap between wrapped rows (horizontal) or
|
||
columns (vertical layouts wrap top to bottom into new columns).
|
||
- Grid (`layout: "grid"`): `gridColumns`, `gridRows` (minimum; rows grow), `columnGap` /
|
||
`rowGap`, track sizes `columnTracks` / `rowTracks` as `[120, "1fr", "auto"]` (px fixed, `fr`
|
||
fill share, `auto` hug) or the `layout.setTrack {axis, index, sizing, value?}` command; on a
|
||
child, `gridColumn` / `gridRow` (0-based anchor cell, `null` = placed automatically) and
|
||
`gridColumnSpan` / `gridRowSpan`, and `gridAlignH` / `gridAlignV` (`min`, `center`, `max`;
|
||
`auto` or `null` uses the frame's alignment) to align it in its cell. `fr` tracks behave like
|
||
the CSS `fr` unit: a track never gets less than its content (a fixed-size child bigger than
|
||
its share keeps it; the other `fr` tracks share the rest). `inspect` reports a grid child's
|
||
resolved `gridCell`.
|
||
- **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given,
|
||
else on the selection. New layers become the selection.
|
||
- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP
|
||
tools, `color` may also be a hex string and gradients can be written
|
||
`{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the
|
||
engine's paint JSON.
|
||
- **Colour profile**: colour values are in the file's profile, sRGB (default) or Display P3
|
||
(`inspect_document` reports `colorProfile`). `file.colorProfile {profile: srgb|displayP3,
|
||
convert?}` changes it: `convert` (default true) converts every colour value so colours keep their
|
||
appearance; `false` keeps the values and reinterprets them. Images are treated as sRGB and managed
|
||
into the file's space. PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus
|
||
`cICP`), Display P3 JPG and WebP embed the ICC profile, and SVG and PDF export Display P3 files
|
||
converted to sRGB (out-of-gamut colours clip). The app's canvas shows sRGB (P3 colours outside sRGB clip on screen):
|
||
`view.proofSrgb {on?}` (View ▸ Soft-proof sRGB) stripes those pixels so `screenshot {window:
|
||
true}` shows where they are (after the next canvas render), and the colour picker says Outside
|
||
sRGB for such a colour.
|
||
- **Image adjustments**: image paints carry `filters` (exposure, contrast, saturation, temperature,
|
||
tint, highlights, shadows; each -1..1). `image.adjust {ids?, target?: fill|stroke, index?,
|
||
preset?, filters?}` applies a built-in preset (`vivid`, `muted`, `warm`, `cool`, `blackAndWhite`,
|
||
`fade`, `reset`) or one saved in the file, with any `filters` values on top.
|
||
`image.savePreset {name, filters?}` saves the current (or given) adjustments in the file,
|
||
`image.deletePreset {name}` removes one and `image.presets` lists both kinds.
|
||
- Text props (`fontFamily`, `fontStyle`, `fontSize`, `lineHeight`, `letterSpacing`, `textDecoration`,
|
||
`textCase`, `textFills`, `fontVariations`…) apply to the whole layer, or to chars `start`..`end`
|
||
when given (`text.style {id, start, end, …}` does the same). Styled ranges come back as
|
||
`styledRuns`. Variable fonts: `fontVariations` (alias `variationSettings`) sets axis values in
|
||
user units, e.g. `{"wght": 550, "wdth": 85}` (`null` = back to what the style name implies);
|
||
`text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}`
|
||
lists a font's axes and named instances (named instances are also valid `fontStyle` values).
|
||
OpenType features: `features {"liga": false, "tnum": true}`.
|
||
- **Text styles** link chars, not just layers: `style.apply {style, ids?, start?, end?}` with a text
|
||
style links chars `start`..`end` (while text is edited, the selected chars; else whole layers), and
|
||
`style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is
|
||
the style all its chars share (none when they differ); `get_node` lists the linked chars as
|
||
`textStyleRanges` (`[start, end, style id]`).
|
||
Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis
|
||
values) on linked chars unlinks them; `style.update` moves every linked char. `style.create
|
||
{type: "text", name}` takes the selected chars while editing; a `value` may use the `node.set`
|
||
spellings (`{"fontSize": 32, "lineHeight": "120%", "letterSpacing": "2%"}`).
|
||
- **Live previews**: `text.preview {command, params}` shows what `style.apply`,
|
||
`library.applyStyle`, `text.setVariations` or `text.style` would do on the canvas without an undo
|
||
step (in place of an earlier preview); `text.endPreview` puts the document back. Any other command
|
||
ends the preview first, so running the command for real commits it as one undo step.
|
||
- Every mutation is one undo step (`batch` makes several one step). A failing command leaves the
|
||
document as it was.
|
||
- Programmatic calls never open dialogs.
|
||
- Verify your work: `inspect_document` / `get_node` for numbers, `screenshot` for pixels.
|
||
|
||
## Tools
|
||
|
||
| Tool | What it does |
|
||
|---|---|
|
||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `inspect.assets` (layers under the selection with export settings), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
|
||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||
| `get_node {id, depth}` | One layer as JSON. |
|
||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?}` (every layer once per setting, named `<layer><suffix>.<format>`). |
|
||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
|
||
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
|
||
| `undo` / `redo` | Step through history. |
|
||
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). |
|
||
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |
|
||
| `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. |
|
||
| `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). |
|
||
|
||
### Sections, handoff and arrow keys
|
||
|
||
`run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or
|
||
in a section, with some room around them), `section.toggleContents {ids?, hidden?}` (a section
|
||
hiding its contents shows only its name and outline; its layers aren't drawn or hit, and the layers
|
||
panel dims them), `object.resizeToFit`. A frame or section marked `devStatus: readyForDev` turns
|
||
"Changed" (`devChanged: true` in `get_node`) when a layer in it is edited, added or removed;
|
||
`dev.resolveChange {ids?, reason?}` returns it to Ready for dev and keeps the reason (`devNote`).
|
||
Frames in sections are top-level frames (flows can start there; `proto.flow.add`); a "Navigate to"
|
||
whose destination is a section shows the frame of it shown last in the presentation, else its
|
||
first.
|
||
|
||
`transform.nudge {dx, dy}` (arrow keys) moves auto layout children one place instead of nudging:
|
||
along the flow, to the previous/next row of a wrapping layout, to the neighbouring grid cell, and,
|
||
at the end of a flow or across a row's axis, into the neighbouring nested auto layout frame when the
|
||
frame around flows that way (a list of rows: Down moves an item into the row below).
|
||
|
||
### Keyboard shortcuts
|
||
|
||
The default shortcuts live in one table (`figcraft_engine::keymap`), grouped like the public shortcut
|
||
reference (Essential, Tools, View, Zoom, Text, Shape, Selection, Edit, Transform, Arrange,
|
||
Components, Prototype) with the macOS keys and, where they differ, the Windows/Linux keys (for
|
||
example Use as mask is ⌃⌘M on macOS and Ctrl+Alt+M elsewhere; Bring to front ⌥⌘] and
|
||
Ctrl+Shift+]). `list_commands` reports each command's first key for the platform the server runs
|
||
on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||
|
||
- `object.opacityKeys {digits, ids?}`: the opacity number keys. One digit is tens of percent (`"0"`
|
||
is 100%); two digits are the exact percentage (`"45"` is 45%, `"05"` is 5%), what typing two
|
||
digits in quick succession does on the canvas.
|
||
- `view.zoomAt {x, y, out?, width?, height?}`: the zoom tool (hold Z on the canvas; Alt zooms out).
|
||
A point zooms one step in (or out) keeping that page point in place; an area fills the view.
|
||
- `view.designPrototype` (`Shift+E`), `view.devMode` (`Shift+D`) and `view.leftSidebar`
|
||
(`Shift+Cmd+\`) switch the right panel's tab or hide the left sidebar in the app (no-ops headless).
|
||
- `doc.nextTab` / `doc.previousTab` (`Ctrl+Tab` / `Ctrl+Shift+Tab`): the next or previous open file.
|
||
- `view.nextRegion` / `view.previousRegion` (`F6` / `Shift+F6`, live while editing text too): move
|
||
the keyboard between the app's regions (toolbar, layers, canvas, properties). In the app, Tab and
|
||
Shift+Tab then move between the region's controls, Enter or Space activate one, the arrows move
|
||
along the toolbar and through the layer rows, and Escape returns to the canvas (no-ops headless).
|
||
- `object.rename {ids?}` without a `name` (`Cmd+R`, or `F2`): rename the layer inline in the app's
|
||
layers panel, also from a layer row that has the keyboard; when the rename ends the row gets the
|
||
keyboard back (Escape cancels it there). Headless it changes nothing and leaves no undo step.
|
||
- `view.contextMenu` (`Shift+F10`): the selection's context menu, at its layer row when the layers
|
||
panel has the keyboard (after F6 or a click on a row), else on the canvas below the selection; the
|
||
menu takes the keyboard and gives it back when it closes (no-op headless).
|
||
- `select.area {x, y, width, height, add?, deep?}`: select what a marquee from `x, y` over that box
|
||
(page units) would: top-level layers, a frame only when the box covers it, the children of the
|
||
top-level frame the box starts in; `add` toggles like a Shift marquee, `deep` takes nested layers
|
||
like Cmd/Ctrl. Returns `{selection}`. Without a box (`F8` in the app) it draws one for the
|
||
keyboard around the selection (or in the middle of the view): the arrows move it, Shift+arrows
|
||
move its far corner (10 screen pixels a press), Enter selects (Shift+Enter adds, Cmd/Ctrl+Enter
|
||
nested layers), Escape or a click drops it.
|
||
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
|
||
accessibility setting turns it on too). Prototype transitions still play.
|
||
- `pref.language {language: system|en|es|…}`: the app's interface language, applied live ("system":
|
||
the OS or browser language; a language without a catalog shows English). Command ids, labels and
|
||
every result stay English.
|
||
|
||
### Editing vectors
|
||
|
||
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
|
||
`vector.edit.begin {id}` (converts shapes to vectors), `vector.edit.state` (page-space overlay, the
|
||
local network, each point's handle mirroring and the width points), `vector.edit.tool {tool: move|pen|bend|paint|lasso|cut|eraser|width}`,
|
||
`vector.edit.select {vertices?, segments?, add?}`, `vector.edit.selectAll`, `vector.edit.moveVertices`,
|
||
`vector.edit.nudge`, `vector.edit.position {x?, y?}`, `vector.edit.setHandle`, `vector.edit.mirroring
|
||
{mode: none|angle|angleAndLength}`, `vector.edit.smooth {smooth?}`, `vector.edit.bend`,
|
||
`vector.edit.addVertex`, `vector.edit.connect {a, b}`, `vector.edit.pen {x, y, dx?, dy?}` (continues
|
||
from the pen's open end; clicking a point connects or branches), `vector.edit.penEnd`,
|
||
`vector.edit.paint {x, y}` (paint bucket: fills or unfills the closed area under a page point),
|
||
`vector.edit.cut {segment, t} | {vertex}`, `vector.edit.erase {points, radius?}` (the eraser),
|
||
`vector.edit.lasso {points}`, `vector.edit.delete`,
|
||
`vector.deleteHeal`, `vector.join` / `vector.smoothJoin` (join the selected points while editing),
|
||
`vector.edit.cornerRadius`, `vector.edit.end`. Point indices refer to `network.vertices` in
|
||
`vector.edit.state`; local coordinates are the layer's own.
|
||
|
||
The selected points' transform box: `vector.edit.bounds` (`{x, y, width, height, corners,
|
||
points}`: the box in the vector's axes, `x`/`y` in the parent's coordinates like the Position
|
||
fields), `vector.edit.resize {width?, height?, center?}` (keeps the top-left unless `center`),
|
||
`vector.edit.rotate {angle, cx?, cy?}` (degrees counter-clockwise about the box centre or a page
|
||
pivot), `vector.edit.transform {matrix: [a, b, c, d, e, f], vertices?}` (any page-space affine).
|
||
While points are selected, `object.flipH` / `object.flipV` and the 90°/180° rotations act on them.
|
||
On the canvas (`pointer`), dragging points, handles and pen clicks snap to other points and segment
|
||
midpoints (this vector's and other vectors'), the usual layer targets and the pixel grid; `cmd`
|
||
held while dragging turns the object snapping off.
|
||
|
||
Variable width strokes: `stroke.widthProfile {ids?, preset?: uniform|wedge|taper|eye|rounded|pinch,
|
||
points?: [[t, scale], ...]}` (also `node.set {widthProfile}`; `t` runs 0–1 along each subpath by
|
||
length, `scale` multiplies the stroke weight, widths in between are interpolated smoothly; no
|
||
preset or points = uniform). While editing, the Variable width tool (`width`, ⇧W) drags width
|
||
points: `vector.edit.widthPoint {t, width? (px) | scale?}` adds one, `{index, t?, width? | scale?}`
|
||
moves or resizes it, `{action: "remove", index}` deletes it. Rendering, PNG/SVG/PDF export and
|
||
`vector.outlineStroke` follow the profile.
|
||
|
||
One-sided widths: a width point can be wider on one side of the path than the other (left and
|
||
right looking along the path, y down). Points are `[t, left, right]` (or `{t, left, right}`) where
|
||
each side's scale is what `scale` would be with both sides that wide, so `[t, s]` equals
|
||
`[t, s, s]`; `get_node` reports one-sided points that way. `vector.edit.widthPoint {index, side:
|
||
left|right, width? | scale?}` changes one side (`width`: the distance from the centre line to that
|
||
edge; with `t` it adds a point keeping the other side as it is); a width without `side` makes the
|
||
point symmetric again. With `pointer`, `alt: true` while dragging a handle (or the path) changes
|
||
that side only; `vector.edit.state` lists `widthPoints` with both handle positions (`sides`:
|
||
right, left) and `oneSided`.
|
||
|
||
### Prototypes
|
||
|
||
Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep
|
||
the scroll position with `preserveScroll: true`). Variable actions: `{type: "setVariable",
|
||
variable, value}` sets a value, or with `expr` an expression computed when it runs;
|
||
`{type: "setVariableMode", collection, mode}` switches a collection's mode for the prototype (ids or
|
||
names; modes chosen on layers inside the screen still win); `{type: "conditional", blocks:
|
||
[{condition?, actions}]}` runs the first block whose condition holds (a block without one is the
|
||
"else"). Expressions are `{value: {type: number|string|boolean|color|alias, value}}` (`alias` reads
|
||
a variable) or `{op: {op, args}}` with `add` (text joins when a string is involved), `subtract`,
|
||
`multiply`, `divide`, `equals`, `notEqual`, `less`, `lessOrEqual`, `greater`, `greaterOrEqual`,
|
||
`and`, `or`, `not` and `negate`; `{inMode: {variable, mode}}` reads a variable's value in one mode
|
||
of its collection whichever mode is chosen, and `{mode: {collection}}` gives the name of the mode a
|
||
collection is in where the interaction runs (as text; it follows set variable mode actions).
|
||
Variables, collections and modes may be given by name; they are stored by id. Expressions can also
|
||
be written as text: `expression: "count + 1"`
|
||
on a set variable action, or `expression: "count >= 3 and not done"` on a conditional block, in
|
||
place of `expr` / `condition` (variables by name, `{name}` for any name; numbers, `"text"`,
|
||
`true` / `false`, `#RRGGBB`; `or` / `||`, `and` / `&&`, `not` / `!`, `==` (or `=`), `!=`, `<`,
|
||
`<=`, `>`, `>=`, `+` (joins text), `-`, `*`, `/` and brackets; `accent[Dark]` for a variable's
|
||
value in a mode, `mode(Theme) == "Dark"` for a collection's current mode, names with brackets or
|
||
spaces at their ends in quotes). Text expressions are checked: a
|
||
condition must give true or false and a set variable's expression a value the variable can hold,
|
||
otherwise the command fails with what's wrong (`Something is missing at the end`, `No variable
|
||
\`x\``, `Can't compare a number with text`). Single actions, also inside conditionals, are edited
|
||
with `proto.action.add {id?, index, path?, action}` (→ `{path}`), `proto.action.set {index, path,
|
||
action}`, `proto.action.remove {index, path}` and `proto.action.move {index, path, to}`, where
|
||
`index` is the interaction and `path` the action: `[i]` the interaction's action `i`, `[i, b, j]`
|
||
action `j` of block `b` of the conditional action `i`, and so on (for `add`, where it goes:
|
||
`[i, b, n]` appends to a block with `n` actions; default: after the last action). A conditional's
|
||
blocks: `proto.conditional.addBlock {index, path, else?, expression? | condition?}` adds an else if
|
||
(before the else; by default the first boolean variable equal to its value) or with `else: true`
|
||
the else, `proto.conditional.setCondition {index, path, block, expression | condition}` and
|
||
`proto.conditional.removeBlock {index, path, block}` (removing the if when only an else is left
|
||
removes the whole conditional). Every one is an undo step and applies to all `ids` given (layers
|
||
with the same interactions). Flows `proto.flow.add` / `rename` / `remove`,
|
||
and the device, its rotation and the background `proto.settings {device?, background?,
|
||
rotation?, color?, width?, height?, responsive?}` (`None`, `iPhone 16`, `iPhone 16 Pro Max`,
|
||
`Android Compact`, `iPad mini`, `MacBook Air`, `Desktop` or `Custom size`; `rotation` `portrait` or
|
||
`landscape`, which turns phones and tablets; `color` one of the device's colours by name, e.g.
|
||
`Black`, `White`, `Pink`, `Teal`, `Blue` for the iPhone 16 (an error lists them), `""` for the
|
||
default; `width` / `height` set the custom size's screen, 1–10000 points, and pick `Custom size`,
|
||
which is a bare screen without a body). On a device the presented frame is sized to the device's
|
||
screen: what doesn't fit is clipped, or scrolls when the frame's `overflow` allows it, while `fixed`
|
||
layers stay on screen (pinned to the edges their constraints name). With `responsive: true` (saved
|
||
in the file) the presented frame is instead resized to the device's screen, or without a device to
|
||
the presenter's window at 100%, and its constraints and auto layout respond as when it is resized
|
||
in the editor (an axis it hugs keeps hugging its content); the file's frames don't change. `proto.present {start?}` plays the prototype full-window and `proto.preview
|
||
{start?}` in a resizable pane over the canvas that follows edits live; `proto.closePreview` closes
|
||
it. `proto.presentOptions {scale?, deviceFrame?, hotspotHints?, inspect?}` sets how the
|
||
full-window presenter sizes the prototype (`fit` scales it down to fit, `fitWidth` fills the width,
|
||
`fill` covers the window, `actual` shows it at 100%; what overflows pans), whether it draws the
|
||
device, whether a click where nothing reacts briefly flashes the screen's hotspots (the layers with
|
||
interactions, on by default; also in the presenter's Options menu) and whether its Inspect panel
|
||
shows the size, colours and typography of the layer under the pointer (also the Inspect button in
|
||
its bar); the options are kept for later presentations (in the presenter Z cycles them and R
|
||
restarts). The player's state is
|
||
in `inspect_ui` (`presenter`, `preview`: screen, scroll, `scale`, `zoom`, `pan`, the screen's
|
||
`hotspots` and whether they are flashing, the `inspected` layer) when attached, and
|
||
`ui.set {presentScroll: [x, y]}` scrolls its screen (see [control-protocol.md](control-protocol.md)).
|
||
The presenter's Options menu offers Responsive next to the size options (without a device) or
|
||
Responsive / Fixed size (on one), the same `proto.settings {responsive}`; its flow picker lists each
|
||
flow with its description.
|
||
|
||
### Local libraries
|
||
|
||
A `.figcraft` file can be published as a library and used from other files (library files here;
|
||
libraries kept on a `figcraft-server` for a team: [Team libraries](#team-libraries); the browser
|
||
build has no file system, so there the commands that read library files return an error).
|
||
|
||
- **Publish** (in the library file): `library.publish {description?, include?, exclude?}` publishes
|
||
the file's components, component sets, styles and variable collections as a new version (by
|
||
default everything except names starting with `.` or `_`, or what the last publish offered;
|
||
`include`/`exclude` take component ids and style or collection ids or names). A collection comes
|
||
with its variables (values per mode, aliases, scopes), except those hidden from publishing
|
||
(`variable.update {variable, hiddenFromPublishing: true}`). Components, styles and variables that
|
||
published assets use (a component's bound variables, a variable's alias targets) come along,
|
||
unoffered. Each asset keeps the version it last changed in; publishing with no changes is refused.
|
||
Save the file (`file.save`) so other files see the new version.
|
||
- **Enable**: `library.add {path}` / `library.remove {id}` (id, name or path) / `library.list
|
||
{assets?}` (versions, pending updates, errors; with `assets: true` each library's components,
|
||
styles, variable collections (with their modes) and variables, and what this file can publish). Libraries are read read-only with size limits, and read
|
||
again when the file changes.
|
||
- **Use**: `library.place {library, component, x?, y?}` (a component, set or variant by id or name;
|
||
page point, default the view's centre) copies the component, with the components and styles it
|
||
uses, into the file as a read-only main component and places an instance; `library.import
|
||
{library, component}` only copies it (then `instance.create`, `instance.swap`…);
|
||
`library.swapAt {library, component, x, y}` copies it and swaps the instance at that page point to
|
||
it (as `instance.swapAt`, below).
|
||
`library.applyStyle {library, style, ids?, target?}` copies a style in (linked, read-only) and
|
||
applies it. Instances of library components behave like any instance: overrides, properties,
|
||
variants, swap, reset, detach. Library components keep their variable bindings and explicit modes
|
||
(bound to copies of the library's variables).
|
||
- **Library variables**: `library.bindVariable {library, variable, field, ids?}` copies a published
|
||
variable in (linked, read-only, with the variables it aliases and its collection) and binds it
|
||
like `variable.bind`; `library.aliasVariable {library, alias, variable, mode?}` makes a local
|
||
variable's value an alias of a library variable; `library.setVariableMode {library, collection,
|
||
mode, ids?}` sets a library collection's mode on frames (the page when no ids are given; the
|
||
Design panel's Variable modes section lists enabled libraries' collections under the library's
|
||
name and sets them this way);
|
||
`library.importVariable {library, variable}` only copies it (then `variable.bind`, `variable.set
|
||
{value: {alias}}`). The copies render without the library file. Editing the copies (library
|
||
components, styles, variables or collections) is refused; edit the library file and publish.
|
||
- **Extended collections**: `library.extendCollection {library, collection, name?}` → `{id}` makes
|
||
a collection of this file (default name "<name> (extended)") with the library collection's modes
|
||
and one inheriting variable per variable of it. `variable.set` on an inheriting variable
|
||
overrides its value in that mode (the variable lists it in `overrides`);
|
||
`variable.resetOverride {variable, mode?}` goes back to the inherited value (every mode when
|
||
`mode` is left out). Variables of its own come from `variable.create`. Modes, and the names of
|
||
inheriting variables, come from the library collection (renaming, adding or deleting them is
|
||
refused); accepting library updates brings new values (overrides stay), renames and new
|
||
variables. The Variables editor offers Extend collection on a library collection and marks
|
||
overridden values, with a reset button.
|
||
- **Update**: `library.updates` lists the components, styles, variable collections and variables
|
||
with a newer published version; `library.acceptUpdates {ids? (library ids)}` pulls them in,
|
||
keeping instance overrides; bound layers, aliases and explicit modes follow the updated values.
|
||
- **Paste between files**: instances copied from another open file (`edit.copy` there, `edit.paste`
|
||
here) follow their mains: library components join (or bring along) this file's copies of the
|
||
same library, components the other file publishes as a library are used from it (and that
|
||
library is enabled when the other file is saved), and other mains stay behind: the instance shows
|
||
a missing main (`component.restoreMain` makes it a main here).
|
||
|
||
### Team libraries
|
||
|
||
A file kept on a `figcraft-server` ([`server.md`](server.md#team-libraries)) can hold a library that
|
||
other files use through one of its share links. Each call waits for the server (a few seconds at
|
||
most when it can't be reached).
|
||
|
||
- `library.publishToServer {server?, key?, description?, include?, exclude?}` publishes the active
|
||
document's library as the next version of the one the server keeps under the file (selection and
|
||
per-asset versions as `library.publish`, following the server's version): `server` is an edit
|
||
link of the file, or omit it while the document is in a live session on a server with edit
|
||
access; with the server's address and key (`ws://host:47900/?key=…`, or `key`) the document is
|
||
kept there as a new file first and the result has its `file`, `edit` and `view` links. A view
|
||
link is refused. The document itself doesn't change (nothing to undo).
|
||
- `library.add {path: <share link>}` enables it (a view link is enough); then `library.place`,
|
||
`library.applyStyle`, `library.bindVariable`… work as for library files. `library.list` and
|
||
`library.updates` ask the server for a newer version first (`"server": true` in the listing);
|
||
`library.acceptUpdates` takes it, overrides kept.
|
||
- `library.usage {library?}` → `{id, name, instances, files: [{name, updated, instances}],
|
||
components: [{id, name, instances, files}]}`: what the files using it reported (each fetch
|
||
reports the asking document's name and its instances per component). `library` is an enabled
|
||
team library; omit it for the active document's own library on its server.
|
||
- `library.swap {from, to}` (any two enabled libraries, files or team) re-points every instance,
|
||
style and variable copied from `from` to the published asset of the same name in `to` (variants
|
||
by name in their set, styles by name and type, variables by collection, name and type, modes by
|
||
name), keeping overrides; one undo step → `{components, styles, variables, unmatched: [names
|
||
still from `from`, including this file's collections that extend one of its collections],
|
||
removed (nothing of `from` was left, so it was removed)}`.
|
||
|
||
### Swapping instances
|
||
|
||
- `instance.swap {ids?, component}` shows another component in the instances (overrides on layers
|
||
with matching names stay; property values with the same names too). Nested instances can be
|
||
swapped on their own (an override of the instance they're in).
|
||
- `instance.swapAt {component, x, y}` swaps the instance at a page point: of the instances under
|
||
it (innermost first), the first showing a variant of the same component set, else the innermost
|
||
(never one inside the dropped component). The Assets panel does this when a component is dropped
|
||
onto an instance with Alt (Option) held, highlighting that instance while dragging.
|
||
- Instance swap properties offer **preferred values**: `component.addProperty {type:
|
||
"instanceSwap", default, preferred? [component or set ids]}` and `component.editProperty {name,
|
||
preferred}`; `component.properties` lists them, and the Design panel's swap menus show them first.
|
||
|
||
### Slots
|
||
|
||
A slot is a frame inside a main component whose instances take any content of their own (add,
|
||
remove and reorder layers there, unlike the rest of an instance). It is a component property of type
|
||
`slot` (`component.properties` lists it: on a main with its `layers`, on an instance with
|
||
`value: {layer, edited, layers}`).
|
||
|
||
- **Define** (on the main): `component.makeSlot {id (a frame inside a main component), name?,
|
||
preferred? [component or set ids]}` (the property is named after the frame by default);
|
||
`component.editProperty {name, preferred}`; `component.removeSlot {id}` (instances lose their own
|
||
content in it). `component.addProperty {type: "slot", layer}` and `component.bindProperty {field:
|
||
"slot"}` work too (one frame per component for a slot).
|
||
- **Fill** (on an instance): `instance.slotInsert {id (the slot layer, or the instance with `slot`
|
||
(property name)), component, index?, replace?}` places an instance of a component in it; any
|
||
structure command (`shape.*`, `node.reparent`, `edit.paste` with the slot selected, `edit.delete`,
|
||
`node.reorder`, `instance.place` or a drag onto it) works inside it. The main's default content
|
||
shows (and follows the main) until the slot is first edited; then the slot's layers are the
|
||
instance's own (override group `slot`): kept across main updates, variant switches, copy/paste and
|
||
detach, and instances placed there stand alone (their own overrides, properties and detach).
|
||
- **Reset**: `instance.resetSlot {id}` (or `component.resetOverride {id, group: "slot"}`, or
|
||
`component.resetInstance`) brings back the main's default content.
|
||
|
||
`get_node` shows `"slot": "<property>"` on slot layers.
|
||
|
||
### Boolean groups
|
||
|
||
`bool.union` / `bool.subtract` / `bool.intersect` / `bool.exclude {ids?}` wrap layers in a new
|
||
boolean group, except: with one boolean group selected they set its operation (every operand follows
|
||
it again), and with layers inside boolean groups they set those operands' own operation. Each
|
||
operand above the bottom one combines with the result of the operands below it by its own
|
||
operation, or the group's (`node.set {operandOperation: union|subtract|intersect|exclude | null}`
|
||
on any operand, or `booleanOperation` on an operand that isn't itself a boolean group; on a group
|
||
`booleanOperation` is the group's). `get_node` shows `operandOperation` on operands that have one.
|
||
Operands stay editable layers: `select.children` (Enter) or a double-click selects them, `cmd: true`
|
||
clicks reach them directly, and moving, resizing or editing an operand's points (`vector.edit.*`)
|
||
updates the group's result on every pointer move, one undo step per gesture; a dragged operand
|
||
stays in its group. Clicks and `select.layersAt` hit a boolean group where its result is (a click in
|
||
a subtracted hole reaches what's behind it); while the group or one of its operands is selected, its
|
||
operands are hit instead, so each can be picked. `bool.flatten` bakes the result. Only the operands from the edited one up are
|
||
recombined during a drag (`figcraft_render::boolean`; timed by `cargo xtask bench`).
|
||
|
||
### Shape builder
|
||
|
||
The selected shapes' outlines split the canvas into regions (areas covered by the same shapes).
|
||
`shapeBuilder.regions {ids?}` lists them: an SVG `path` in page coordinates, the `sources` covering
|
||
each one (top-most first), the `parts` covering it (`{id, stroke}`: a shape's fill, or its stroke's
|
||
outlined area; top-most first), `bounds` and a `point` inside. Visible strokes take part as their
|
||
outlined areas, so stroked lines and open paths split the regions they cross and a stroke's band is a
|
||
region of its own. `shapeBuilder.merge {ids?, regions?, points?}` makes one vector from the regions
|
||
(by index, or the regions under page points), styled like the top-most fill or stroke over the first
|
||
one (a stroke's region is filled with the stroke's paints); `shapeBuilder.remove {ids?, regions?,
|
||
points?}` deletes them. Either way every shape covering a picked region loses that area (it becomes
|
||
a vector, or goes when nothing is left); the strokes of the shapes involved become outlined vectors
|
||
just above them (`outlines` in the result; an open path is replaced by its outline), and each call
|
||
is one undo step. The tool is `tool.shapeBuilder` (Shift+M):
|
||
click or drag across regions with `pointer` to merge, add `alt` to remove; `shapeBuilder.mode
|
||
{mode: merge|remove}` swaps what a plain click does.
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.regions","params":{"ids":[2,3]}}}}
|
||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"ids":[2,3],"points":[[120,80],[160,80]]}}}}
|
||
```
|
||
|
||
### Text on a path and pattern fills
|
||
|
||
- **Text on a path**: `text.onPath {text?, path?, start?, flip?}` (default: the selected text layer
|
||
and the other selected layer) puts a text layer on a vector's or shape's path: the text takes the
|
||
path layer's place, transform and box, and the path layer is removed (`edit.undo` brings it back).
|
||
The baseline follows the path from `start` (0..1 of its length); the text's horizontal alignment
|
||
places it there (left starts, centre centres, right ends at it); `flip` runs it on the other side
|
||
the other way. Glyphs past an open path's end are hidden; a closed path (ellipse, rectangle) wraps
|
||
them around; only the first subpath is followed. `text.pathOptions {start?, flip?, ids?}` changes
|
||
them, `text.removeFromPath {ids?}` makes it plain auto-width text again. Resizing the layer
|
||
scales the path. The text stays editable (`text.edit.*`, `type_text`); `get_node` shows
|
||
`textPath {d (SVG path data in the layer's space), start, flip}`. Exports draw it as outlines
|
||
(PDF too: not selectable); underline and strikethrough aren't drawn on a path.
|
||
- **Pattern fills**: `fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex|
|
||
verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?}` makes the fill at
|
||
`index` (default the top one) the source layer repeated, at `scale` × its own size with `spacing`
|
||
between tiles; hex tiles shift every other row (column) by half a tile; `align` puts a tile in the
|
||
box's top-left corner, centre or bottom-right corner. The fill is `{type: "pattern", source,
|
||
tile, scale, spacing, align}` and can also be set with `fill.set {paint}`. It updates as the
|
||
source changes; a layer can't tile itself or a layer containing it (a pattern nested more than
|
||
two deep draws nothing). Copies made together with their source tile the copied source. Exports:
|
||
SVG `<pattern>` and PDF tiling patterns of the source's vectors (a source with effects as an image
|
||
of the tile); the canvas draws one tile at the zoom it shows (at most 2048 px a side) and repeats it.
|
||
|
||
### Importing SVG
|
||
|
||
`open_file {path}` opens an SVG as a new document; `run_command {command: "file.import", params:
|
||
{path | data + name, x?, y?, center?}}` adds it to the current page as a frame (top-left at `x, y`,
|
||
or centred there with `center: true`, as pasting does). It comes in editable: groups named from
|
||
their `id`, transforms baked into the layers, `<rect>`/`<circle>`/`<ellipse>`/`<line>` (and paths
|
||
tracing those outlines) as rectangles with their corner radius, ellipses and lines, everything else
|
||
as vectors; strokes with their width, dashes, caps, joins and miter limit; linear and radial
|
||
gradients with their exact handles (`gradientTransform`, `reflect`/`repeat` unrolled, a focal
|
||
point as the centre); patterns as image fills (fill, fit, crop or tile); clip paths as vector masks
|
||
and masks as luminance or alpha masks; shadow and blur filters (`feDropShadow`, the offset / blur /
|
||
flood / composite chains, colour-matrix shadows, inner shadows, `feMorphology` spread) as drop
|
||
shadows, inner shadows and layer blurs; opacity and `mix-blend-mode`; text as text layers (fonts
|
||
the SVG names when installed, anchors, letter spacing, styled `<tspan>` runs, stacked lines as one
|
||
layer; text on one `<textPath>` as text on a path, with its start offset and anchor); images as
|
||
image fills, and SVG images imported in place. CSS, `<use>`/`<symbol>`, units
|
||
and `viewBox`/`preserveAspectRatio` are resolved. The result's `warnings` list what was left out
|
||
(filters that aren't shadows or blurs, rotated repeating patterns, a clip path's own clip path).
|
||
Linked files are never read, only data URIs; documents nested more than 512 elements deep are
|
||
refused.
|
||
|
||
### Importing `.fig` files
|
||
|
||
`open_file {path}` (a new, untitled document; the file is never saved over) and `file.import`
|
||
(pages appended) read `.fig` files, best effort. Each file carries the schema its data is encoded
|
||
with, so the data is decoded generically and mapped by field name onto the REST import below: the
|
||
same layers, paints, text, auto layout, components, styles, variables and prototyping come across,
|
||
with image fills from the file's own `images/` folder. Instances are rebuilt from their main
|
||
component with the overrides and property values the file records. Vectors come in as their
|
||
outlines (not editable vector networks); sticky notes, connectors, tables and other non-design
|
||
layers, set-variable and conditional actions and video fills are skipped. The result's `warnings`
|
||
list what was skipped.
|
||
|
||
### Importing REST API file JSON
|
||
|
||
The JSON returned by the public REST API's file endpoint (`GET /v1/files/:key`, best with
|
||
`geometry=paths` so vectors carry their outlines) and its nodes endpoint opens like any document:
|
||
`open_file {path}` (a new, untitled document; the JSON is never saved over) or
|
||
`run_command {command: "file.import", params: {path}}` (pages appended to the current document,
|
||
with their flows, prototype settings and variables).
|
||
The result's `warnings` list what was skipped or approximated (unsupported layer, paint and effect
|
||
types, video and gamepad triggers, expression functions other than a variable mode lookup over a
|
||
variable and one of its modes, bindings to library
|
||
variables, instances of components outside the file, which come in as frames). Pages, the layer
|
||
tree, geometry, paints, strokes, effects, corners, constraints, auto layout, layout grids, exports,
|
||
text with styled runs, vectors, components, component sets, instances and component properties are
|
||
mapped, and so is prototyping: `interactions` (on click, hover, press, drag, after delay, mouse
|
||
enter/leave/down/up and key triggers; navigate, overlays, back, scroll to, change to, open link,
|
||
set variable (values, variable references and expressions), set variable mode and conditional
|
||
(if / else if / else over expressions) actions; dissolve, smart animate, move, push and slide transitions with their easing,
|
||
custom bezier and spring curves included), flow starting points, scroll overflow and fixed/sticky
|
||
layers, and the prototype device. Image fills keep their `imageRef` and show a grey
|
||
placeholder unless the image files sit in an `images` folder next to the JSON, named by `imageRef`
|
||
(`images/<imageRef>.png`); nothing is fetched from the network.
|
||
|
||
Variables come from the local variables response (`GET /v1/files/:key/variables/local`):
|
||
collections with their modes (the default mode first), values per mode, aliases and scopes. Pass it
|
||
as `variables` (a path, or the JSON object or text) to `open_file`, `file.open` or `file.import`,
|
||
put it next to the file as `<name>.variables.json` or `variables.json`, or embed its `meta` in the
|
||
file JSON. Layers' `boundVariables` (sizes, gaps and padding, corner radii, opacity, stroke weight and
|
||
per-side stroke weights, visibility, text, font properties, single solid fills and strokes) and `explicitVariableModes` then
|
||
link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a
|
||
frame back to Auto, inheriting its parent's mode).
|
||
|
||
The same JSON carries variables between FigCraft files: `variable.export {collections?, format?,
|
||
path?}` writes this file's collections (default: all but library copies) as a local variables
|
||
response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default
|
||
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`), and
|
||
returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object,
|
||
JSON text or a path) reads one back: a collection joins this file's collection of the same name
|
||
(values matched by mode name, new modes and variables added, a same-named variable of another type
|
||
skipped with a warning; library copies and extended collections are never targets), others are
|
||
added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The
|
||
Variables editor's Import… and Export… buttons do the same through file dialogs.
|
||
|
||
Strokes take colour styles and variables like fills: `style.apply {style, target: "stroke"}` links
|
||
a colour style to the strokes (`styles.stroke`; edits of the style follow, `style.detach {which:
|
||
"stroke"}` keeps the colour), and `variable.bind` binds `strokeWeight` and each side's weight on its
|
||
own (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`; binding or
|
||
setting one side with `node.set` turns on individual strokes, the other sides at the weight) to
|
||
number variables scoped for stroke weights.
|
||
|
||
`variable.bind` also binds gradient stops (`fills/<i>/stops/<j>/color`, `strokes/<i>/stops/<j>/color`;
|
||
the binding follows its stop when an earlier stop is removed and its paint when the list is
|
||
reordered) and variant properties of instances (`componentProperties/<property>` with a string
|
||
variable: the instance shows the variant its value names; a value naming no variant changes
|
||
nothing). The colour picker binds the selected stop; the Design panel's variant dropdowns take a
|
||
string variable.
|
||
|
||
### Comments
|
||
|
||
Comment threads live in the file, pinned to a page point or to a layer (`anchor`: a node id, or
|
||
`true` for the innermost frame under the point) so they move with it; when the layer is deleted the
|
||
pin stays where it was, on the nearest surviving parent. Driven with `run_command`:
|
||
`comment.add {x, y, text, page?, anchor?}` (returns `{id, link}`), `comment.reply {id, text}`,
|
||
`comment.edit {id, text, reply?}` and `comment.delete {id, reply?}` (a message is the thread's
|
||
first one, or `reply`: an index into its replies; deleting the first message deletes the thread),
|
||
`comment.react {id, emoji, reply?, on?}` (toggles your reaction), `comment.resolve {id, resolved?}`,
|
||
`comment.move {id, x, y, anchor?}` (pins it to the frame under the new point unless `anchor: false`).
|
||
Everyone acts as the name from Preferences (`pref.userName {name}`, default "You") unless `author`
|
||
says otherwise; only a message's author can edit or delete it. `@Name` in a message mentions
|
||
someone (`comment.people` lists the names used in the file).
|
||
|
||
Read state is kept per name: a new message is unread for everyone but its author;
|
||
`comment.markRead {id? | link?, read?}` marks one thread (or, without `id`, all of them) read or
|
||
unread (not an undo step). `comment.list {page? | "all", filter? (all|open|resolved|mine|unread),
|
||
sort? (date|page), query?, frame?}` returns the threads with their position, `frame`/`frameName`,
|
||
`pageName`, `unread`, `link` and replies (with `edited` times and `reactions`). Links are
|
||
`figcraft://comment/<id>`: `comment.link {id}` returns one, `comment.open {id | link, view?}` goes
|
||
to its page, zooms to its frame (`fit`, the default; `center` keeps the zoom; `keep` doesn't move)
|
||
and opens it in the app, and `comment.next {id?, dir?, …list params}` steps to the next or previous
|
||
thread of a list the same way. `comment.zoom {ids}` zooms in on a group of pins.
|
||
|
||
### Version history
|
||
|
||
Named snapshots of the document, kept in the `.figcraft` file (they persist once the file is saved;
|
||
a new, renamed or deleted version marks the file as changed). Driven with `run_command`:
|
||
`file.saveVersion {name?, description?}` (returns `{id, index, name, description, time}`; untitled
|
||
versions are named "Version N"), `version.list` (oldest first; `time` is Unix seconds),
|
||
`version.restore {id | index}` (replaces the document with the version as one undo step; the file
|
||
name stays), `version.rename {id | index, name, description?}`, `version.delete {id | index}` and
|
||
`version.duplicate {id | index}` (opens the version as a new document tab).
|
||
`version.preview {id | index}` shows a version in place of the document, read-only: edits, undo and
|
||
gestures are refused until `version.restore` keeps it or `version.exitPreview` goes back. Saving,
|
||
closing or saving a version ends the preview first, and `version.list` marks the shown version
|
||
`previewing`. Indexes are validated
|
||
(a negative or out-of-range index is an error). A file keeps at most 100 versions.
|
||
`version.autosave` saves an automatic checkpoint (`auto: true`, named "Autosave") when the document
|
||
has unsaved changes since its last version; the app runs it every 30 minutes for each open document,
|
||
and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app.
|
||
|
||
### Branches
|
||
|
||
A branch is a copy of the file to try changes in, reviewed against the main file and merged back.
|
||
Branches are kept in the main `.figcraft` file (saving either tab saves the main file with its
|
||
branches). Driven with `run_command`:
|
||
|
||
- `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`.
|
||
The branch remembers the file as it is now (its base) and makes new ids in its own id space, so
|
||
layers, styles and comments made on both sides never clash.
|
||
- `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to):
|
||
`[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab).
|
||
- `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document.
|
||
- `branch.review {id?}` (default: the branch tab shown) returns what changed on each side since the
|
||
base and the conflicts: `{branch: {layers, document, truncated}, main: {…}, conflicts: [{key, id?,
|
||
name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property
|
||
changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables,
|
||
comments and file settings. A conflict is the same property changed on both sides to different
|
||
values (key `<layer id>/<property>`, e.g. `4/fills`; a layer's parent and position are one
|
||
property, `parent`), a layer deleted on one side while the other edited it or what's inside it
|
||
(`<layer id>/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides
|
||
(`style/<id>`, `variable/<id>`, `comment/<id>`, `doc/<property>`…).
|
||
- `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step,
|
||
archives it (`state: merged`), closes its tab and shows the main file. `resolutions` maps every
|
||
conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so
|
||
neither side's work is dropped without a choice.
|
||
- `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the
|
||
branch the same way (the new base is the main file as it is now). It is not undoable: the branch
|
||
tab's undo history starts over.
|
||
- `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be
|
||
opened, but not merged).
|
||
|
||
Versions are saved on the main file (`file.saveVersion` is refused on a branch tab), and branches
|
||
aren't available in a live session.
|
||
|
||
### Collaboration changes
|
||
|
||
`collab.diff {against? (undo: the last undoable action, the default | saved: since the last
|
||
save)}` returns `{changes, count, bytes}`: the change batch a live session would send for that edit
|
||
(per-property `create` / `set` / `delete` and document-level changes, see
|
||
`docs/collab-protocol.md`) and its binary size. It changes nothing. In a live session, where undo
|
||
is per user (`edit.undo` / `edit.redo` take back only your own steps, see
|
||
`docs/collab-protocol.md`), only `saved` applies.
|
||
|
||
### Windows
|
||
|
||
The attached desktop app can show files in several windows, each with its own tabs; the files,
|
||
preferences, libraries and clipboard are shared. `run_command` with `window.new {path?, index?}`
|
||
opens one (a new file, the file at `path`, or open file `index` moved out of its window) and
|
||
`window.focus {id}` brings one to the front; `inspect_ui` lists them as `windows` (`id`, `main`,
|
||
`title`, `files`, `active`, `focused`). Tools act on the window in front (the last one opened, focused or clicked):
|
||
focus a window before working on its files, and pass its id to `screenshot {window}` to see it.
|
||
Headless (one session, no windows) and in the browser the two commands do nothing.
|
||
|
||
### Live sessions
|
||
|
||
People (and agents) edit one file together and see each other's cursors, selections and
|
||
viewports. Both the headless session and the attached app can host and join (the web build can
|
||
join but not host); each call takes in what happened since the last one and sends your edits (a
|
||
joined file opens as a new document).
|
||
|
||
- `collab.host {port?, bind?}` starts hosting the active document: `bind` is `"local"` (this
|
||
machine, the default), `"lan"` (the local network) or an interface address; the port is 47900,
|
||
or any free one when it's taken (`port` picks one, 0 any). Returns the session as
|
||
`collab.peers` does, with `link` (`figcraft://join?host=…&file=…&token=…`) and `url`
|
||
(`ws://…/?file=…&token=…`): both carry the session's secret, so share them only with people you
|
||
invite. `collab.stop {}` ends it.
|
||
- `collab.join {link}` (any form: an app's invite, a `figcraft-server` share link, or the
|
||
`ws(s)://…?file=…&token=…` URL) returns at once with `status: "connecting"`; poll
|
||
`collab.peers` until `status` is `"live"`, or `"failed"` with a `reason` (a wrong token, a full
|
||
session, a revoked link, an unreachable host, a `wss://` server whose certificate isn't trusted:
|
||
see `FIGCRAFT_CA_FILE` in [`server.md`](server.md#behind-a-reverse-proxy-tls)). `collab.leave {}`
|
||
leaves; the synced document stays open as your copy.
|
||
- `collab.publish {server, name?, key?}` puts the active document on a `figcraft-server`
|
||
([`server.md`](server.md)) and syncs it from there: `server` is the address
|
||
`figcraft-server key` prints (`ws://host:47900/?key=…`; or give `key` separately), `name` the
|
||
file's name (default: the document's). It returns at once with `status: "connecting"`; the
|
||
document is read-only until the upload is done, then `collab.peers` has `published: {server,
|
||
file, name, edit, view}` with the file's first edit link and view link (they carry their
|
||
tokens: share them like passwords). Someone joining with the view link gets `access: "view"`:
|
||
every edit on the synced document is refused with "View only: …" (the server refuses them too).
|
||
- `collab.peers {}` → `{available, canHost (false in the web build), status
|
||
(off|connecting|live|reconnecting|failed), reason?,
|
||
attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network,
|
||
access (edit|view), readOnly (the active document can't be edited now: view only, or being
|
||
published), published (a file you published: {server, file, name, edit, view}),
|
||
follow, syncing (the document arrived), document (the index of the synced open document),
|
||
editing (the text layer you are typing in: {node, layer, caret, selection}, or null), peers: [{id,
|
||
name, color, editing (the text layer they are typing in, same shape, or null), presence: {cursor:
|
||
[page, x, y], selection: [ids], viewport: [page, x, y, w, h], editing_text: {node, caret,
|
||
selection?}}}]}`. Carets and selections count characters; someone's typing syncs when they
|
||
finish, so their caret may be past the end of your copy until then. Starting `text.edit.begin`
|
||
in a layer someone else is typing in sets the message "<name> is editing this text too: the
|
||
version finished last is kept" (both edits sync as whole values when each person finishes; the
|
||
last one wins).
|
||
- `collab.cursor {x, y}` puts your cursor at a page position (omit both to hide it): an agent's
|
||
pointer. Your selection, page and view go out by themselves when they change.
|
||
- `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any
|
||
pan or zoom of your own, or Escape).
|
||
- Styles, variables, collections, modes and comments you create on the session's document take
|
||
ids from your own id space (`S:<n>`, `VariableID:<n>`, … with large numbers), so two people
|
||
creating them at once never overwrite each other's; outside a session ids look as before (`S:1`,
|
||
`VariableID:1`…).
|
||
- On the session's document every command (or `batch`, or gesture) is one step of your own undo
|
||
history: `edit.undo` / `edit.redo` take back only your steps and leave what others changed since.
|
||
An edit the host refuses (someone deleted the layer) is rolled back and the reason is the
|
||
session's message. While the connection is reconnecting, editing goes on; the edits go out when
|
||
it is back (the same client, catching up with what it missed).
|
||
- `collab.registerLinks {on?}` → `{on, registered}` makes the desktop app the opener of
|
||
`figcraft://` invite links, or stops (no `on`: switch). Only where the app registers them itself
|
||
(Windows, per user); elsewhere it reports the command unavailable. It also sets the
|
||
Preferences checkbox. Starting the app with an invite link (`figcraft "figcraft://join?…"`)
|
||
joins that session.
|
||
|
||
### Plugins
|
||
|
||
Sandboxed WebAssembly plugins act through the same commands (see [`plugins.md`](plugins.md) for the
|
||
manifest, the ABI and the Rust SDK). Driven with `run_command`: `plugin.import {path}` (a
|
||
`figcraft-plugin.json` or its folder) or `plugin.import {manifest, wasm}` (the manifest as an
|
||
object or JSON text and the module as base64, for this session only), `plugin.list`,
|
||
`plugin.remove {id}`, and `plugin.run {id, command?, params?, fuel?}`, which returns
|
||
`{plugin, command, output, logs, calls, fuel, panel}`. A run is one undo step and is rolled back when it
|
||
fails; a plugin only reads the document unless its manifest asks for `write`, may be limited to
|
||
some commands, and can never touch files, other documents, the undo history, dialogs or other
|
||
plugins. A headless MCP session starts with no plugins: import them first. `plugins.runLast` runs
|
||
the last plugin command again.
|
||
|
||
A plugin that shows a panel (API 2) can be used headless too: `plugin.ui {id?}` returns the open
|
||
panel (`{plugin, command, title, controls, values}`, or null), `plugin.event {id, control, value?}`
|
||
presses a button or sets a field as a click would (the plugin is called back; one undo step;
|
||
returns like `plugin.run`, with the updated `panel`), and `plugin.close {id}` ends the session.
|
||
Plugin settings are kept per plugin with the preferences (in a headless session, for the session).
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.figcraft.examples.swatch-grid","command":"panel"}}}}
|
||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"palette","value":"Forest"}}}}
|
||
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}}
|
||
```
|
||
|
||
### Dev Mode
|
||
|
||
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
||
component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
||
interface, SwiftUI with a `View` struct, Compose with a `@Composable` function. Its component
|
||
properties are the parameters, with their defaults: variant properties become string unions
|
||
(React) or enums, booleans become flags around the layers they show, text properties strings,
|
||
instance swaps views (`React.ReactNode`, `AnyView`, a composable lambda) and slots optional
|
||
content. Every variant of a set shares one definition; in React the other variants' differences
|
||
become CSS rules on `data-*` attributes. On an instance (or a layer tree containing instances)
|
||
each instance is a call passing only the values it changes, e.g. `<Button state="Hover"
|
||
label="Buy" />`, `Button(state: .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label =
|
||
"Buy")`. Names are escaped and unique (keywords get a suffix, `default` → `defaultValue`; names
|
||
that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
|
||
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
|
||
default, value? (instances)}], definition, usage? (instances)}`.
|
||
- **Code connect:** link a component (a variant links its set) to the code component it is built
|
||
as, per language (`react`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
|
||
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
|
||
name and a call passing every property) → `{component, language, code, unknown}`. Templates are
|
||
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
|
||
for the language (React keeps JSX's braces, so `size={size}` gives `size="Large"` and
|
||
`disabled={disabled}` gives `disabled={true}`), `{property ? "a" : "b"}` inserts one of two
|
||
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
|
||
properties). Then `inspect.code` on an instance or the component returns the import line and the
|
||
snippet instead of generated code (`connected: true`), connected instances inside a generated
|
||
layer tree are their snippet, and `inspect.componentCode` returns `connected: {importPath, name,
|
||
template, import, code}` with the snippet as `usage`. Example: template `<Button
|
||
variant="primary" icon="{Has icon ? "cart" : "none"}">{label}</Button>` with import path
|
||
`@acme/ui` gives `import { Button } from "@acme/ui";` then `<Button variant="primary" icon="none">{"Buy
|
||
now"}</Button>`. `codeConnect.remove {id?, language?}` (no language: all). `codeConnect.export
|
||
{path?}` → `{text, links}` (or writes the file): `{version: 1, links: [{component, language,
|
||
importPath, name, template}]}`, sorted, meant to live in the code repository as
|
||
`figcraft.connect.json`; `codeConnect.import {json | path}` → `{linked, warnings}` links each
|
||
entry to every component or set with that name (one undo step; at most 5,000 links and 4 MB;
|
||
templates up to 4,000 characters, names 100, import paths 300; invalid entries are skipped with a
|
||
warning). `get_node` lists a component's links as `codeLinks`. In the app, the Inspect tab's
|
||
Connect to code section edits them in the Code section's language, with a live preview and a
|
||
Connected badge. An editor extension isn't needed: agents and editors read the same snippets
|
||
through this server. Plugins whose manifest asks only for `read` also run from the Inspect tab.
|
||
- **Annotations:** notes for developers on any layer, each with optional pinned properties that
|
||
Dev Mode shows with their current values (and the style or variable behind them).
|
||
`annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id,
|
||
index}`), `annotation.edit {id?, index, label?, properties?}` (`properties` replaces the list),
|
||
`annotation.remove {id?, index?}` (no index: all of the layer's), all undoable, and
|
||
`annotation.list {ids? | id? | page? ("all")}` → `{annotations: [{id, name, index, label,
|
||
properties: [{property, label, value, token?, color?}]}]}` (`value` is null when the layer doesn't
|
||
have that property). Pinnable ids: `width`, `height`, `top`, `left`, `rotation`, `opacity`,
|
||
`cornerRadius`, `direction`, `gap`, `padding`, `alignment`, `fontFamily`, `fontStyle`,
|
||
`fontSize`, `lineHeight`, `letterSpacing`, `textAlign`, `fill`, `stroke`, `strokeWeight`,
|
||
`effects`. A layer holds at most 64 annotations; notes are capped at 5,000 characters. They are
|
||
saved with the file (`get_node` lists them as `annotations`); instances don't inherit their
|
||
main's annotations and keep their own across main edits. In the app the Inspect tab draws them
|
||
as callouts beside their layers and edits them in its Annotations section.
|
||
- **Annotation categories:** every annotation can have a category, drawn as the colour of its
|
||
callout: the defaults are Development, Interaction, Accessibility and Content, and a file can add
|
||
its own. `annotation.add` / `annotation.edit` take `category` (an id or name; `null` clears it)
|
||
and `annotation.list` returns `category: {id, name, color}` and filters with `{category: id |
|
||
name | "none"}`. `annotation.categories {}` → `{categories: [{id, name, color, count}]}`;
|
||
undoable `annotation.addCategory {name, color?}` → `{id}` (a new one takes the first unused
|
||
colour), `annotation.editCategory {id, name?, color?}` (rename, recolour; ids stay) and
|
||
`annotation.removeCategory {id}` (its annotations stay, uncategorised). Names are unique (ignoring
|
||
case), up to 64 characters; at most 64 categories. Files keep the list only once it differs from
|
||
the defaults (old files open with the defaults). In the app, with nothing selected, the Inspect
|
||
tab filters the callouts by category and edits the categories (colour, name, delete, add); each
|
||
note has a category menu.
|
||
- **Ready for dev:** frames at the top of a page or section, and sections, carry a handoff status
|
||
(`node.set {devStatus: none|readyForDev|completed}`). `inspect.readyForDev {status?:
|
||
readyForDev|completed|any, page?}` lists them across pages (or on one page) in page and layer
|
||
order: `{items: [{id, name, page, status}]}`. The Inspect tab sets the status of the selected
|
||
frames and, with nothing selected, lists them with a status filter (click one to go to it, or its
|
||
focus button to open it in the focus view).
|
||
- **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the
|
||
file keeps a copy of it and everything in it as it was just before that edit (saved in the
|
||
`.figcraft` file only while there is a change; resolving the change, marking it again, another
|
||
status or deleting it drops the copy, so the next change compares with that state).
|
||
`dev.compare {id?, show?}` → `{id, name, changed, snapshot, added, removed, modified, truncated,
|
||
changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section,
|
||
property, before, after, text}]}]}`: layers matched by id, added and removed ones listed as a
|
||
whole (`layers` counts what's inside), changed ones with their properties formatted like Inspect
|
||
(`width 120 → 136`, `fill #FF0000 → #E03030`, `text “Welcome” → “Hello”`, `parent Badge → Screen`,
|
||
`order 3 → 1`, `component Card → Card 2`, auto layout, typography, strokes, effects, corner radius,
|
||
opacity, visibility, name), nested layers and instance contents included. `snapshot: false` means
|
||
the frame changed before copies were kept (an older file). `show: true` opens the compare view in
|
||
the app: before and now side by side (or overlaid with a slider), changed layers outlined (added
|
||
green, changed orange, removed red), the list of changes and Resolve change.
|
||
- **Focus view:** `dev.focus {id? | direction: next|previous}` → `{id, name, page, index, count}`
|
||
shows one top-level frame or section alone on the canvas (fitted, selected, other layers neither
|
||
drawn nor hit), switching page when needed; `direction` steps through the ready-for-dev frames
|
||
across pages, wrapping around. `dev.unfocus {}` ends it, as do Escape (once the frame or nothing
|
||
is selected), leaving the page or deleting the frame. The app shows a bar with the frame's name
|
||
and status, Compare changes, previous / next and exit; `inspect_ui` reports `devFocus`.
|
||
|
||
### Effects
|
||
|
||
`effect.add {ids?, type? | effect?}` adds an effect (`dropShadow`, `innerShadow`, `layerBlur`,
|
||
`backgroundBlur`, `noise`, `texture`, `glass`) with its defaults, or the full `effect` object;
|
||
`effect.set {ids?, index, effect}` replaces one. A layer paints its drop shadows first, then its
|
||
fills, inner shadows, children and strokes (strokes draw over inner shadows), with layer blur,
|
||
noise and texture over the result.
|
||
|
||
- **Blurs** (`layerBlur`, `backgroundBlur`) are uniform (`{"radius": 8}`) or progressive:
|
||
`{"type": "layerBlur", "blurType": "progressive", "startRadius": 0, "radius": 24, "startOffset":
|
||
[0.5, 0], "endOffset": [0.5, 1]}` ramps the radius linearly from `startRadius` at `startOffset` to
|
||
`radius` at `endOffset`, points in the layer's unit box (0–1 across and down, like a gradient's
|
||
handles; before the start and past the end the radius stays). Radii above 10,000 draw as
|
||
10,000. In the app, the Design panel's blur row picks Uniform or Progressive, and while a
|
||
progressive blur's settings are open its start and end points are handles on the canvas.
|
||
Generated CSS leaves progressive blurs out (CSS blurs are uniform); Inspect lists the ramp.
|
||
- **Exports:** PNG, PDF and SVG draw the same pixels as the canvas. PDF and SVG keep layers vector
|
||
and embed images for what vectors can't show: background blur and glass (from what lies under
|
||
the layer: on the page in PDF, among the exported layers in SVG), layer blur, noise and texture
|
||
in PDF; noise grains (an image over the vector layer), texture and progressive layer blur (the
|
||
layer whole) in SVG, where uniform layer blur and shadows stay SVG filters.
|
||
- **PDF for print:** a bleed grows each page by that much on every side and shows what lies past
|
||
the frame there (the frame's fills reach out to the bleed edge with gradients and images in
|
||
place, its children are clipped to the bleed instead of the frame, nothing is drawn past it);
|
||
a page of one frame is trimmed to the frame's box (its shadows go into the bleed). Crop marks
|
||
(at the trim's corners) and bleed marks (at the bleed's) are 0.25 pt hairlines 18 pt long,
|
||
starting 3 pt outside the bleed, in registration colour. MediaBox is the sheet, BleedBox the
|
||
trim grown by the bleed, TrimBox the frame. CMYK converts every colour (fills, strokes, text,
|
||
gradient stops, images and rasterised effects) from sRGB with `K = 1 − max(R, G, B)`,
|
||
`C = (1 − R − K) / (1 − K)` (M and Y alike), deterministic and profile-free: the file is
|
||
DeviceCMYK without an output intent, because embedding one needs a CMYK ICC profile and none
|
||
ships with FigCraft. Soft masks stay RGB (they aren't printed) and transparency groups blend in
|
||
RGB, as the PDF writer declares them.
|
||
|
||
### Brushes and pen pressure
|
||
|
||
- `stroke.brush {ids?, preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?}` draws a
|
||
layer's stroke with a brush (`preset: null` back to a plain stroke; a layer without a stroke gets
|
||
a black one); `node.set {brush}` takes the same (a preset id, an object or `null`) and `get_node`
|
||
shows `brush`. Stretch brushes bend one shape along each subpath: `taper`, `ink`, `ribbon`,
|
||
`bristle`. Scatter brushes drop copies along it every `spacing` stroke weights (default 1.5; at
|
||
most 5,000 copies a path): `dots`, `stars`, `confetti`, `leaves`, varied by `sizeJitter` (0–1),
|
||
`rotationJitter` (0–1, up to half a turn) and `scatter` (0–4 weights off the path), repeatably
|
||
from `seed`. The stroke weight sets the brush's size and a width profile scales it along the
|
||
path. Brushes draw on the canvas, in PNG, SVG and PDF (as filled outlines) and `vector.outlineStroke`
|
||
flattens them; dashes don't apply to brushes.
|
||
- **Pen pressure**: the Pencil records pressure into the stroke's width profile (up to 32 points by
|
||
length; medium pressure, 0.5, draws the stroke weight, from a tenth of it to twice it); without
|
||
pressure (a mouse) the stroke stays uniform. `pointer` takes `pressure` and, for drags,
|
||
`pressureTo`. `tool.pencilBrush {preset?, …}` sets the brush new Pencil strokes get (`null`
|
||
clears it; no `preset` reads it). The desktop app reads pressure from pen input on Windows; in the
|
||
browser from stylus touch events that report a force; macOS and Linux tablets report none.
|
||
|
||
### Paint, effect and layout grid lists
|
||
|
||
A layer's fills, strokes, effects and layout grids are lists kept **bottom first**: index 0 paints
|
||
first, the last one is on top (the Design panel lists them the other way round, topmost first).
|
||
|
||
- **Reorder:** `fill.move`, `stroke.move`, `effect.move` and `grid.move {ids?, from, to}` move one
|
||
item (one undo step for every layer given; out-of-range or equal indices change nothing, negative
|
||
or fractional ones are refused). The item keeps everything (an image fill its image, scale mode
|
||
and adjustments, a gradient its stops and handles), and a paint, effect or grid style linked to
|
||
the list is unlinked (the list no longer matches it). In the app, drag a row by its grip (the
|
||
empty strip left of its fields); a line shows where it lands.
|
||
- **Variable bindings follow their items** when a list is reordered or an item removed
|
||
(`fill.remove`, `stroke.remove`, `effect.remove`, `grid.remove`); a removed item's bindings go
|
||
with it. Binding fields (`variable.bind {field}`): `fill` / `stroke` (the first paint's colour) or
|
||
`fills/<i>/color` / `strokes/<i>/color`, `effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>`,
|
||
`layoutGrids/<i>/<size|count|gutter|offset>` (number variables; count is kept to 1–1000, size and
|
||
gutter are never negative). Moving a bound paint rewrites its key (`fill` becomes `fills/2/color`).
|
||
- **Instance properties:** `variable.bind {ids: [instance], field: "componentProperties/<name>",
|
||
variable}` drives a boolean property from a boolean variable and a text property from a string
|
||
variable (other types are refused); the value follows the variable's modes like any binding, the
|
||
binding follows renames of the property, and deleting the property, resetting the instance or
|
||
detaching it drops it. The instance's properties panel offers the same with its variable button.
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"fill.move","params":{"ids":[2],"from":0,"to":2}}}}
|
||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"variable.bind","params":{"ids":[5],"field":"layoutGrids/0/gutter","variable":"gutter"}}}}
|
||
```
|
||
|
||
### Ruler guides
|
||
|
||
`guide.add {vertical, offset, frame? | page?}` (returns `{index}`), `guide.move {index, offset, …}`,
|
||
`guide.remove {index, …}` and `guide.clear {…}` edit the guides of the current page, another
|
||
`page`, or a `frame` (frames, components and instances). A frame's guides are in its own
|
||
coordinates: they move with the frame, span only it, are copied with it and snap the layers inside
|
||
it. On a rotated frame they run along its own axes, and the layers inside it snap to them (and to
|
||
their siblings and the frame's edges) along those axes, keeping whole pixels in the frame's
|
||
coordinates. In the app, a guide dragged out of a ruler and dropped on a frame becomes that frame's
|
||
guide, along the frame's axis nearer the ruler's.
|
||
|
||
### Measuring, the Scale tool, arc handles and selecting
|
||
|
||
- **Measure** (what Alt/Option-hover shows): `measure.distances {ids? (default the selection), to?
|
||
(a layer; default the selection's parent)}` returns `{lines: [{x0, y0, x1, y1, label, dashed}]}`
|
||
in page coordinates, between bounding boxes (several layers by their union, rotated ones by
|
||
their bounds): one box inside the other measures to each edge, boxes apart measure the gaps
|
||
(`dashed` lines extend a gap line to a box it passes beside), overlapping boxes measure between
|
||
matching edges. `measure.padding {id? (an auto layout frame or a child)}` returns `{frame, lines}`:
|
||
the frame's padding and the gaps between neighbouring children (what Shift+Alt-hover shows).
|
||
With `pointer`, `alt: true` hovers measure (add `cmd` for nested layers, `shift` for padding) and
|
||
`alt` drags show live distances to the neighbours; `inspect_ui` reports them under
|
||
`tool.overlay` (`guides`, and `measure_box` for the box measured to).
|
||
- **Scale tool** (`tool.scale`, K): dragging a selection handle with `pointer` scales everything by
|
||
one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto
|
||
layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag,
|
||
about the anchor point `tool.scaleAnchor {anchor?: tl|t|tr|l|c|r|bl|b|br}` (or, when the anchor
|
||
lies on the side being dragged, the opposite handle; `alt: true` scales from the centre), with the
|
||
factor and pivot in `tool.overlay.scale` while dragging. Without
|
||
dragging, `transform.scale {ids?, factor, cx?, cy?}` does the same numerically.
|
||
- **Arc handles**: with the Move tool, a single selected ellipse (at least 40 px on screen, unlocked)
|
||
shows its arc handles, listed by `inspect_ui` under `tool.arcHandles` (`{id, sweep: [x, y],
|
||
start, ratio}` in page coordinates; `start` once there is an arc, `ratio` once there is an arc or
|
||
a ring). Drag them with `pointer`. **Sweep** starts inside the right edge. Dragged round the
|
||
ellipse, it opens an arc: positive up from the right edge, negative down. It closes again back at
|
||
the start. **Start** turns the arc and keeps its sweep. **Ratio** (the centre, or the inner edge
|
||
halfway round a ring) drags out to make a ring, and into the gap to show the other part.
|
||
`shift: true` snaps angles to 15° steps and the ratio to 5% steps. Each drag is one undo step
|
||
and writes `node.set`'s `arcStart` / `arcSweep` (degrees) and `arcInner` (0–0.99). The Design
|
||
panel's Arc fields (Start °, Sweep %, Ratio %) write the same keys; number variables bound to
|
||
`arcSweep` and `arcInner` (`variable.bind`) hold percentages, as the fields show them, and ones
|
||
bound to `arcStart` degrees.
|
||
`tool.overlay.arc` holds the readout while dragging (`{handle, label: "Sweep 75%", at}`), and
|
||
`tool.overlay.arc_hover` holds the handle under the pointer.
|
||
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its
|
||
depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
|
||
/ Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true`
|
||
clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch,
|
||
and `shift` drags toggle what they touch. A press on the background of a top-level frame with
|
||
layers in it (the frame not selected) starts a marquee inside it, as on the canvas; an empty frame
|
||
is picked and dragged. Locked layers (selectable with `select.set`) get no handles and stay put
|
||
when the rest of the selection is dragged. `select.all` / `select.inverse` skip hidden and locked
|
||
layers.
|
||
- **Transform gestures**: with `pointer`, `shift` keeps proportions, `alt` resizes from the centre
|
||
(both together too), dragging past the opposite edge flips (several layers mirror and flip);
|
||
groups and several layers scale each layer along its own axes, keeping its rotation and flip;
|
||
`cmd` while resizing a frame ignores its children's constraints (they stay where they are) and,
|
||
as in every drag, turns snapping off. Moves snap to edges, centres and equal spacing (the gaps
|
||
show in `tool.overlay.guides` with labels); with the pixel grid on, moves and resizes land on
|
||
whole pixels.
|
||
|
||
Tool failures come back as results with `isError: true` and a message; protocol problems (unknown
|
||
tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server.
|
||
|
||
## Resources
|
||
|
||
| URI | Content |
|
||
|---|---|
|
||
| `figcraft://document` | The active document (like `inspect_document` at depth 8). |
|
||
| `figcraft://commands` | The command catalog. |
|
||
| `figcraft://node/{id}` (template) | One layer with children to depth 2. |
|
||
| `figcraft://command/{id}` (template) | One command's docs, e.g. `figcraft://command/layout.add`. |
|
||
|
||
## Prompts
|
||
|
||
| Prompt | Arguments |
|
||
|---|---|
|
||
| `mobile_screen` | `screen` (required), `device` (default "iPhone 16"), `brand_color` |
|
||
| `design_system` | `name`, `primary` |
|
||
| `landing_page` | `product` (required), `width` (default 1440), `accent` |
|
||
| `prototype_flow` | `screens` (comma-separated), `transition` |
|
||
|
||
Each returns a step-by-step plan naming the exact tools and commands to use and how to verify.
|
||
|
||
## Examples
|
||
|
||
Create a card with auto layout and check it:
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_node","arguments":{
|
||
"type":"frame","name":"Card","x":100,"y":100,"width":320,
|
||
"props":{"fill":"#FFFFFF","cornerRadius":16,"layout":"vertical","padding":24,"gap":12,"sizingV":"hug",
|
||
"effects":[{"type":"dropShadow","color":"#00000026","offset":[0,4],"radius":16}]}}}}
|
||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_node","arguments":{
|
||
"type":"text","parent":2,"text":"Ada Lovelace","props":{"fontSize":24,"fontStyle":"Bold"}}}}
|
||
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_node","arguments":{"id":2,"depth":2}}}
|
||
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"screenshot","arguments":{"ids":[2]}}}
|
||
```
|
||
|
||
A component with an instance whose text is overridden (ids are the ones earlier calls returned; read
|
||
the instance's text layer id with `get_node`):
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"component.create","params":{"ids":[2]}}}}
|
||
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"instance.create","params":{"component":2,"x":500,"y":100}}}}
|
||
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"type_text","arguments":{"id":7,"text":"Grace Hopper"}}}
|
||
```
|
||
|
||
Align and distribute as one undo step:
|
||
|
||
```json
|
||
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Tidy","commands":[
|
||
{"command":"align.top","params":{"ids":[3,4,5]}},
|
||
{"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}}
|
||
```
|
||
|
||
The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of ten realistic
|
||
tasks (auto layout, components and overrides, gradients and shadows, paint styles, Light/Dark
|
||
variables, align/distribute/group, boolean subtract + flatten, export, undo/redo, drawing with tools).
|