openpencil/packages/docs/programmable/cli/scripting.md

208 lines
7.3 KiB
Markdown
Raw Normal View History

---
title: Scripting
description: Execute JavaScript with a Figma-compatible Plugin API to query, batch-modify, and generate designs.
---
# Scripting
`openpencil eval` runs JavaScript against an OpenPencil document with a Figma-compatible `figma` global. Use it for headless batch edits, inspection, fixture setup, and automation without opening the editor UI.
## Basic usage
```sh
openpencil eval design.fig -c "return figma.currentPage.children.length"
```
feat: behaviours and preview mode (#893) * feat: author behaviours on main components A main component or component set can behave as a Switch, Checkbox, Slider, or Tabs, after Reka UI's primitives. The behaviour lives in OpenPencil plugin data: boolean values bind to variant or boolean properties with the values meaning on and off, a number keeps its own range since Figma has no number property, and the control's subcomponents bind to the component's slots. A Behaviour section in the properties panel adds, binds, and removes it, each as one undo step, and flags required bindings that are missing. The canvas-only layout's pill becomes a component that preview will reuse. * feat: preview instances with behaviours on the canvas View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas switches to the canvas-only layout with a Previewing pill, and a split canvas previews on its own side. Clicking a Switch or Checkbox flips it, dragging a Slider moves its thumb and range, and clicking a Tabs trigger shows its panel. Preview keeps its state on copies of the instances it touched, in a private graph with the document's ids, and the canvas draws those copies in place of the originals, so the document, undo, autosave, and collaborators never see it. Escape or the pill leaves preview, Reset restores every control, and editing shortcuts, labels, and outlines stay off while previewing. * feat: translate behaviour and preview strings; cover preview with an e2e flow * refactor(vue): reuse VariantDefinitionControl for behaviour property options * refactor: split variant actions and preview interactions by domain Variant authoring was one 706-line closure; it is now graph queries (model), undo snapshots (history), property definition edits (definitions), and the editor facade (index). Preview interactions move into play/kinds, one module per control, registered by behaviour kind so a new kind cannot ship without its contract and interaction. Behaviour contracts are keyed by kind. In the Vue SDK, slot and variant authoring controls get their own folders beside component-props and behaviour, and the app's variant section joins slot/ and behaviour/. * refactor: keep the behaviour model in scene-graph's plugin-data registry Master now defines every OpenPencil plugin-data key in one typed registry in scene-graph. The behaviour schema registers there as a field, and the model and contracts move beside slots, exported from the package root; the @open-pencil/core/behaviours subpath is gone. * feat: interaction states and keyboard focus in preview A behaviour can bind a variant property to the default, hover, pressed, focus, and disabled states; binding it maps values named like those states. Preview switches the instance's copy to the matching variant as the pointer hovers, presses, and releases, keeps other values when the set draws the combination and falls back to rest otherwise, and skips disabled instances. Tab moves visible keyboard focus between controls, Space, Enter, arrows, Home, and End use the focused one, and Escape takes visible focus off before leaving preview. A Button kind covers controls that only have states. * feat: toggle, radio, group, progress, collapsible, and accordion behaviours Radio group, toggle group, and accordion hold their items in a slot; each item is an instance with its own behaviour, so a press inside the slot goes to the group, which turns the pressed item on and the others off through the item's own interaction. Progress shares the slider's number handling through rangeControl, and a collapsible shows and hides its content slot from its trigger, remembering its open state even when no property draws it. Tabs and groups share arrow-key navigation. * feat: text field, textarea, and number field behaviours A behaviour value can now be text, bound to a text property, so preview types into a copy of the field through the same property path the editor uses. A bound Filled value switches to the placeholder variant when the field empties. A number field keeps its own range, shows its value through a text property, and steps from its increment and decrement slots and the arrow keys. Text fields show focus from a click, and the focused control receives every key; Option still types, and only Cmd or Ctrl combinations stay shortcuts. * fix: keep behaviour bindings when saving as .fig Saving as .fig gives component properties new GUIDs, but behaviours kept the old ids in their plugin data, so every binding read as missing after reopening. The export now renames the ids behaviours bind with the same GUIDs, on its own copy of the document. * fix: let previewed controls resize layout imported from .fig Layers from a .fig keep the sizes Figma computed, and auto layout prefers them, so an opened collapsible or accordion item kept its closed height in preview. When preview shows, hides, or retypes a layer in a copy, it drops those sizes from the layer's copied ancestors so auto layout sizes them again; untouched layers keep Figma's sizes. * fix: publish behaviours and other plugin content with library assets Every OpenPencil plugin-data field now declares its role: content that exists only as plugin data (behaviours, OkHCL picks), format copies of node fields written for files, or bookkeeping about where a document or node came from. Library snapshots keep a node's content plugin data, including other plugins' entries, and drop the rest; the asset hash counts the same entries, so a behaviour-only change is offered as an update while a .fig round trip still changes nothing. * feat: name behaviour rows by meaning and create what they need The Behaviour section named every main value "Value" under a "Values" heading, and a component without matching properties left an empty picker with no way forward. Rows are now named for the control (On, Checked, Pressed, Text), rows the control needs or already uses come first, and the optional rest folds under More options; a button keeps its states in view. An empty row creates what it needs in one undo step: a text layer and text property, Off and On variants on a set, or a slot frame for a part. The missing chip names the row it means and takes you there. * fix(dom-css): position free layers, hug content, and round ellipses HTML and Tailwind export stacked the layers of frames without auto layout in block flow, wrote fixed pixel sizes for auto layout frames set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers a parent does not lay out are now absolutely positioned at their coordinates inside a relative frame, hugging axes are left to the content, and ellipses get a 50% radius. * feat: run preview as live Reka UI islands over the canvas Preview simulated controls on the canvas: copies of instances, a handler per kind, its own key routing, and append-only text. It now runs them as real components. Each top-level layer that holds an instance with a behaviour becomes an island: its layers are projected to DOM through dom-css into a shadow root laid over the pane at its pan and zoom, and each behaviour mounts its Reka UI primitives on its layers, so text fields are real inputs and focus, keys, and layout are the browser's. Core's resolvePlayState shows instances in a state on a private graph, so the component's variants draw it, and controls are keyed by layer path so a variant switch keeps their DOM. The canvas leaves island layers to the islands, and the canvas play runtime and its key routing are gone. * fix: derive variant properties from Property=Value component names figma.combineAsVariants and Combine as variants only derived variant properties from slash-separated names, so components named as Figma names variants, such as State=On, Size=Large, became a set with no properties. Both now derive each named property and its values, after the slash form. * feat: script and tool access to behaviours by name Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers in the list slot and their content panels in a panels slot, and a slot of repeated parts names the Reka part of its children. A behaviour spec names component properties and slots instead of ids and resolves to the stored behaviour and back, with errors that list what the component has. Scripts get an `openpencil` global next to `figma`, in the Figma API's style: setBehaviour, getBehaviour with bindValue, bindPart, states, and missing, behaviourKinds, and createSlot. The eval tool, the CLI, and app automation compile scripts through one compileScript, so the CLI now returns the last expression as the others do. MCP and AI chat get set_behaviour, get_behaviour, and create_slot. * feat: write controls in design JSX with Reka UI's element names `<Switch.Root modelValue="State">` renders a main component, or a set when its children are variants, that behaves as a switch, and `<Switch.Thumb>` the slot that draws its thumb, one slot across the set's variants. Inputs become the text property of a field, tab triggers and panels go in their List and Panels slots, and a group's items are `<RadioGroup.Item of={…} />` instances in its Items slot. JSX export writes components with behaviours the same way, so they render back unchanged. The authoring reference documents controls, and the codegen and chat prompts now include it verbatim instead of dedenting its code examples. * chore: format the CLI export test * docs: document slots, behaviours, preview, and the openpencil API The components guide covers slots, behaviours, and preview with its shortcut; scripting covers the openpencil global and eval's last- expression result; the MCP and AI chat pages list the new tools; the features overview, README, and roadmap mention working controls. The chat prompt says how to build a control, and the codegen prompt builds components with behaviours on their Reka UI primitives. * chore: format the eval CLI test * docs: explain behaviours and preview islands, and guide the openpencil API A development page explains the behaviour model, the four authoring surfaces, how preview islands turn a control's state into live Reka UI components, and how to add a kind; the architecture page links it. The Core guide sets the rules for OpenPencilAPI: Figma-only `figma`, OpenPencil features on `openpencil` in the same style, one compileScript, names over ids, and docs with every member. Package READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and the behaviour model. Design JSX's behaviour modules move into a behaviours folder instead of a suffixed sibling. * refactor: center pasted layers through translate centerNodesAt repeated translate's loop, which test:dupes reports on master too. * fix: validate behaviour ranges and guess on and off by name A number value now needs max above min and a positive step: the schema, specs, and the panel reject a range a slider cannot step through. Binding a variant property guesses on and off by value name, as specs do, and a boolean property gets no on/off pair. Part bindings are read through partBinding, a replaced document restarts preview from its designed state, and the e2e preview shortcut uses ControlOrMeta. * feat: make the Behaviour section say what to do next A slider's range fields now carry inline Min, Max, Step, and Start labels. States offers Add state variants, which adds a Default, Hover, Pressed, Focus, and Disabled variant and binds them; Add Off and On variants and Add state variants turn a lone main component into a component set first, and a part's slot can be added to a set, in every variant under one slot id. Rows that could do nothing are gone: no empty pickers and no hints to combine variants by hand, and an unbound Disabled is left to the states. A warning line names what is still needed and replaces the missing chip, and the Switch's main value is called Checked. * fix: keep each slot to one part and keep creating slots at hand A slot draws one part, so the Behaviour section no longer offers a slot another part uses, and specs (the openpencil API, tools, and JSX) reject binding one slot to two parts. A part's picker keeps an action to add a new slot in its footer, so adding the first slot no longer hides it for the other parts.
2026-10-06 13:23:05 +00:00
The `-c` flag accepts JavaScript. Its last expression is the result, so `return` is optional; `await` works at the top level. A script whose last statement is not an expression, such as a declaration, has no result and prints nothing.
```sh
openpencil eval design.fig -c "
const frame = figma.createFrame()
frame.name = 'Card'
frame.resize(300, 200)
frame.layoutMode = 'VERTICAL'
frame.itemSpacing = 12
return { id: frame.id, name: frame.name }
"
```
## Query nodes
```sh
openpencil eval design.fig -c "
return figma.currentPage
.findAll((node) => node.type === 'FRAME' && node.name.includes('Button'))
.map((button) => ({
id: button.id,
name: button.name,
width: button.width,
height: button.height
}))
"
```
## Modify and save
Use `--write` / `-w` to write changes back to the input file:
```sh
openpencil eval design.fig -c "
figma.currentPage.children.forEach((node) => {
node.opacity = 0.5
})
" --write
```
Use `--output` / `-o` to write to a new file:
```sh
openpencil eval design.fig -c "figma.currentPage.name = 'Updated'" -o updated.fig
```
## Read scripts from stdin
```sh
cat transform.js | openpencil eval design.fig --stdin --write
```
## Live app mode
Omit the file path to run against the currently open document in the desktop app:
```sh
openpencil eval -c "return figma.currentPage.name"
```
The desktop app must be running with a document open.
## Output
By default, non-TTY output is JSON. Use `--json` to force JSON output:
```sh
openpencil eval design.fig -c "return figma.currentPage.children.map((n) => n.name)" --json
```
Use `--quiet` / `-q` to suppress output when only writing a file.
feat: behaviours and preview mode (#893) * feat: author behaviours on main components A main component or component set can behave as a Switch, Checkbox, Slider, or Tabs, after Reka UI's primitives. The behaviour lives in OpenPencil plugin data: boolean values bind to variant or boolean properties with the values meaning on and off, a number keeps its own range since Figma has no number property, and the control's subcomponents bind to the component's slots. A Behaviour section in the properties panel adds, binds, and removes it, each as one undo step, and flags required bindings that are missing. The canvas-only layout's pill becomes a component that preview will reuse. * feat: preview instances with behaviours on the canvas View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas switches to the canvas-only layout with a Previewing pill, and a split canvas previews on its own side. Clicking a Switch or Checkbox flips it, dragging a Slider moves its thumb and range, and clicking a Tabs trigger shows its panel. Preview keeps its state on copies of the instances it touched, in a private graph with the document's ids, and the canvas draws those copies in place of the originals, so the document, undo, autosave, and collaborators never see it. Escape or the pill leaves preview, Reset restores every control, and editing shortcuts, labels, and outlines stay off while previewing. * feat: translate behaviour and preview strings; cover preview with an e2e flow * refactor(vue): reuse VariantDefinitionControl for behaviour property options * refactor: split variant actions and preview interactions by domain Variant authoring was one 706-line closure; it is now graph queries (model), undo snapshots (history), property definition edits (definitions), and the editor facade (index). Preview interactions move into play/kinds, one module per control, registered by behaviour kind so a new kind cannot ship without its contract and interaction. Behaviour contracts are keyed by kind. In the Vue SDK, slot and variant authoring controls get their own folders beside component-props and behaviour, and the app's variant section joins slot/ and behaviour/. * refactor: keep the behaviour model in scene-graph's plugin-data registry Master now defines every OpenPencil plugin-data key in one typed registry in scene-graph. The behaviour schema registers there as a field, and the model and contracts move beside slots, exported from the package root; the @open-pencil/core/behaviours subpath is gone. * feat: interaction states and keyboard focus in preview A behaviour can bind a variant property to the default, hover, pressed, focus, and disabled states; binding it maps values named like those states. Preview switches the instance's copy to the matching variant as the pointer hovers, presses, and releases, keeps other values when the set draws the combination and falls back to rest otherwise, and skips disabled instances. Tab moves visible keyboard focus between controls, Space, Enter, arrows, Home, and End use the focused one, and Escape takes visible focus off before leaving preview. A Button kind covers controls that only have states. * feat: toggle, radio, group, progress, collapsible, and accordion behaviours Radio group, toggle group, and accordion hold their items in a slot; each item is an instance with its own behaviour, so a press inside the slot goes to the group, which turns the pressed item on and the others off through the item's own interaction. Progress shares the slider's number handling through rangeControl, and a collapsible shows and hides its content slot from its trigger, remembering its open state even when no property draws it. Tabs and groups share arrow-key navigation. * feat: text field, textarea, and number field behaviours A behaviour value can now be text, bound to a text property, so preview types into a copy of the field through the same property path the editor uses. A bound Filled value switches to the placeholder variant when the field empties. A number field keeps its own range, shows its value through a text property, and steps from its increment and decrement slots and the arrow keys. Text fields show focus from a click, and the focused control receives every key; Option still types, and only Cmd or Ctrl combinations stay shortcuts. * fix: keep behaviour bindings when saving as .fig Saving as .fig gives component properties new GUIDs, but behaviours kept the old ids in their plugin data, so every binding read as missing after reopening. The export now renames the ids behaviours bind with the same GUIDs, on its own copy of the document. * fix: let previewed controls resize layout imported from .fig Layers from a .fig keep the sizes Figma computed, and auto layout prefers them, so an opened collapsible or accordion item kept its closed height in preview. When preview shows, hides, or retypes a layer in a copy, it drops those sizes from the layer's copied ancestors so auto layout sizes them again; untouched layers keep Figma's sizes. * fix: publish behaviours and other plugin content with library assets Every OpenPencil plugin-data field now declares its role: content that exists only as plugin data (behaviours, OkHCL picks), format copies of node fields written for files, or bookkeeping about where a document or node came from. Library snapshots keep a node's content plugin data, including other plugins' entries, and drop the rest; the asset hash counts the same entries, so a behaviour-only change is offered as an update while a .fig round trip still changes nothing. * feat: name behaviour rows by meaning and create what they need The Behaviour section named every main value "Value" under a "Values" heading, and a component without matching properties left an empty picker with no way forward. Rows are now named for the control (On, Checked, Pressed, Text), rows the control needs or already uses come first, and the optional rest folds under More options; a button keeps its states in view. An empty row creates what it needs in one undo step: a text layer and text property, Off and On variants on a set, or a slot frame for a part. The missing chip names the row it means and takes you there. * fix(dom-css): position free layers, hug content, and round ellipses HTML and Tailwind export stacked the layers of frames without auto layout in block flow, wrote fixed pixel sizes for auto layout frames set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers a parent does not lay out are now absolutely positioned at their coordinates inside a relative frame, hugging axes are left to the content, and ellipses get a 50% radius. * feat: run preview as live Reka UI islands over the canvas Preview simulated controls on the canvas: copies of instances, a handler per kind, its own key routing, and append-only text. It now runs them as real components. Each top-level layer that holds an instance with a behaviour becomes an island: its layers are projected to DOM through dom-css into a shadow root laid over the pane at its pan and zoom, and each behaviour mounts its Reka UI primitives on its layers, so text fields are real inputs and focus, keys, and layout are the browser's. Core's resolvePlayState shows instances in a state on a private graph, so the component's variants draw it, and controls are keyed by layer path so a variant switch keeps their DOM. The canvas leaves island layers to the islands, and the canvas play runtime and its key routing are gone. * fix: derive variant properties from Property=Value component names figma.combineAsVariants and Combine as variants only derived variant properties from slash-separated names, so components named as Figma names variants, such as State=On, Size=Large, became a set with no properties. Both now derive each named property and its values, after the slash form. * feat: script and tool access to behaviours by name Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers in the list slot and their content panels in a panels slot, and a slot of repeated parts names the Reka part of its children. A behaviour spec names component properties and slots instead of ids and resolves to the stored behaviour and back, with errors that list what the component has. Scripts get an `openpencil` global next to `figma`, in the Figma API's style: setBehaviour, getBehaviour with bindValue, bindPart, states, and missing, behaviourKinds, and createSlot. The eval tool, the CLI, and app automation compile scripts through one compileScript, so the CLI now returns the last expression as the others do. MCP and AI chat get set_behaviour, get_behaviour, and create_slot. * feat: write controls in design JSX with Reka UI's element names `<Switch.Root modelValue="State">` renders a main component, or a set when its children are variants, that behaves as a switch, and `<Switch.Thumb>` the slot that draws its thumb, one slot across the set's variants. Inputs become the text property of a field, tab triggers and panels go in their List and Panels slots, and a group's items are `<RadioGroup.Item of={…} />` instances in its Items slot. JSX export writes components with behaviours the same way, so they render back unchanged. The authoring reference documents controls, and the codegen and chat prompts now include it verbatim instead of dedenting its code examples. * chore: format the CLI export test * docs: document slots, behaviours, preview, and the openpencil API The components guide covers slots, behaviours, and preview with its shortcut; scripting covers the openpencil global and eval's last- expression result; the MCP and AI chat pages list the new tools; the features overview, README, and roadmap mention working controls. The chat prompt says how to build a control, and the codegen prompt builds components with behaviours on their Reka UI primitives. * chore: format the eval CLI test * docs: explain behaviours and preview islands, and guide the openpencil API A development page explains the behaviour model, the four authoring surfaces, how preview islands turn a control's state into live Reka UI components, and how to add a kind; the architecture page links it. The Core guide sets the rules for OpenPencilAPI: Figma-only `figma`, OpenPencil features on `openpencil` in the same style, one compileScript, names over ids, and docs with every member. Package READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and the behaviour model. Design JSX's behaviour modules move into a behaviours folder instead of a suffixed sibling. * refactor: center pasted layers through translate centerNodesAt repeated translate's loop, which test:dupes reports on master too. * fix: validate behaviour ranges and guess on and off by name A number value now needs max above min and a positive step: the schema, specs, and the panel reject a range a slider cannot step through. Binding a variant property guesses on and off by value name, as specs do, and a boolean property gets no on/off pair. Part bindings are read through partBinding, a replaced document restarts preview from its designed state, and the e2e preview shortcut uses ControlOrMeta. * feat: make the Behaviour section say what to do next A slider's range fields now carry inline Min, Max, Step, and Start labels. States offers Add state variants, which adds a Default, Hover, Pressed, Focus, and Disabled variant and binds them; Add Off and On variants and Add state variants turn a lone main component into a component set first, and a part's slot can be added to a set, in every variant under one slot id. Rows that could do nothing are gone: no empty pickers and no hints to combine variants by hand, and an unbound Disabled is left to the states. A warning line names what is still needed and replaces the missing chip, and the Switch's main value is called Checked. * fix: keep each slot to one part and keep creating slots at hand A slot draws one part, so the Behaviour section no longer offers a slot another part uses, and specs (the openpencil API, tools, and JSX) reject binding one slot to two parts. A part's picker keeps an action to add a new slot in its footer, so adding the first slot no longer hides it for the other parts.
2026-10-06 13:23:05 +00:00
## The `openpencil` API
Next to `figma`, scripts get `openpencil`: what OpenPencil adds to the Figma Plugin API, in the same style. The same global is available to the `eval` tool and the running app's automation.
A main component or component set can [behave as a Reka UI control](/user-guide/components#behaviours-and-preview). Bind it by its own property and slot names:
```sh
openpencil eval design.fig -w -c '
const set = figma.currentPage.findOne((n) => n.type === "COMPONENT_SET" && n.name === "Switch")
const behaviour = openpencil.setBehaviour(set, "switch")
.bindValue("value", "State") // On and Off guessed from the variants
.bindPart("thumb", set.findOne((n) => n.name === "Thumb")) // the frame becomes a slot
behaviour.states = "Interaction"
behaviour.missing
'
```
- `openpencil.setBehaviour(node, kindOrSpec)` replaces a component's behaviour: a kind alone, or a spec such as `{ kind: "slider", parts: { track: "Track", thumb: "Thumb" }, numbers: { value: { min: 0, max: 10 } } }`. It returns the behaviour.
- `openpencil.getBehaviour(node)` reads one, or `null`; a variant reads its set's.
- A behaviour has `kind`, `spec` (by name), `missing` (required values and parts still unbound), and `states`, and `bindValue(valueId, property, { on, off })`, `bindPart(partId, slotNameOrFrame)`, `setNumber(valueId, { min, max, step, default })`, and `remove()`.
- `openpencil.behaviourKinds` lists every kind with the values and parts it binds.
- `openpencil.createSlot(frame)` makes a frame of a main component a slot and returns its name.
Names the component does not have fail with an error that lists the ones it has.
## Supported API surface
The API is intentionally close to Figma's Plugin API, but it maps to OpenPencil's scene graph and file format.
### Document and pages
- `figma.root`
- `figma.currentPage`
- `figma.currentPage.selection`
- `figma.getNodeById(id)`
- `figma.createPage()`
### Node creation
- `figma.createFrame()`
- `figma.createRectangle()`
- `figma.createEllipse()`
- `figma.createText()`
- `figma.createLine()`
- `figma.createPolygon()`
- `figma.createStar()`
- `figma.createVector()`
- `figma.createComponent()`
- `figma.createSection()`
### Tree operations
- `node.children`
- `node.parent`
- `node.appendChild(child)`
- `node.insertChild(index, child)`
- `node.clone()`
- `node.remove()`
- `node.findAll(callback?)`
- `node.findOne(callback)`
- `node.findChild(callback)`
- `node.findChildren(callback?)`
- `figma.group(nodes, parent)`
- `figma.ungroup(node)`
### Components
- `figma.createComponentFromNode(node)`
feat: behaviours and preview mode (#893) * feat: author behaviours on main components A main component or component set can behave as a Switch, Checkbox, Slider, or Tabs, after Reka UI's primitives. The behaviour lives in OpenPencil plugin data: boolean values bind to variant or boolean properties with the values meaning on and off, a number keeps its own range since Figma has no number property, and the control's subcomponents bind to the component's slots. A Behaviour section in the properties panel adds, binds, and removes it, each as one undo step, and flags required bindings that are missing. The canvas-only layout's pill becomes a component that preview will reuse. * feat: preview instances with behaviours on the canvas View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas switches to the canvas-only layout with a Previewing pill, and a split canvas previews on its own side. Clicking a Switch or Checkbox flips it, dragging a Slider moves its thumb and range, and clicking a Tabs trigger shows its panel. Preview keeps its state on copies of the instances it touched, in a private graph with the document's ids, and the canvas draws those copies in place of the originals, so the document, undo, autosave, and collaborators never see it. Escape or the pill leaves preview, Reset restores every control, and editing shortcuts, labels, and outlines stay off while previewing. * feat: translate behaviour and preview strings; cover preview with an e2e flow * refactor(vue): reuse VariantDefinitionControl for behaviour property options * refactor: split variant actions and preview interactions by domain Variant authoring was one 706-line closure; it is now graph queries (model), undo snapshots (history), property definition edits (definitions), and the editor facade (index). Preview interactions move into play/kinds, one module per control, registered by behaviour kind so a new kind cannot ship without its contract and interaction. Behaviour contracts are keyed by kind. In the Vue SDK, slot and variant authoring controls get their own folders beside component-props and behaviour, and the app's variant section joins slot/ and behaviour/. * refactor: keep the behaviour model in scene-graph's plugin-data registry Master now defines every OpenPencil plugin-data key in one typed registry in scene-graph. The behaviour schema registers there as a field, and the model and contracts move beside slots, exported from the package root; the @open-pencil/core/behaviours subpath is gone. * feat: interaction states and keyboard focus in preview A behaviour can bind a variant property to the default, hover, pressed, focus, and disabled states; binding it maps values named like those states. Preview switches the instance's copy to the matching variant as the pointer hovers, presses, and releases, keeps other values when the set draws the combination and falls back to rest otherwise, and skips disabled instances. Tab moves visible keyboard focus between controls, Space, Enter, arrows, Home, and End use the focused one, and Escape takes visible focus off before leaving preview. A Button kind covers controls that only have states. * feat: toggle, radio, group, progress, collapsible, and accordion behaviours Radio group, toggle group, and accordion hold their items in a slot; each item is an instance with its own behaviour, so a press inside the slot goes to the group, which turns the pressed item on and the others off through the item's own interaction. Progress shares the slider's number handling through rangeControl, and a collapsible shows and hides its content slot from its trigger, remembering its open state even when no property draws it. Tabs and groups share arrow-key navigation. * feat: text field, textarea, and number field behaviours A behaviour value can now be text, bound to a text property, so preview types into a copy of the field through the same property path the editor uses. A bound Filled value switches to the placeholder variant when the field empties. A number field keeps its own range, shows its value through a text property, and steps from its increment and decrement slots and the arrow keys. Text fields show focus from a click, and the focused control receives every key; Option still types, and only Cmd or Ctrl combinations stay shortcuts. * fix: keep behaviour bindings when saving as .fig Saving as .fig gives component properties new GUIDs, but behaviours kept the old ids in their plugin data, so every binding read as missing after reopening. The export now renames the ids behaviours bind with the same GUIDs, on its own copy of the document. * fix: let previewed controls resize layout imported from .fig Layers from a .fig keep the sizes Figma computed, and auto layout prefers them, so an opened collapsible or accordion item kept its closed height in preview. When preview shows, hides, or retypes a layer in a copy, it drops those sizes from the layer's copied ancestors so auto layout sizes them again; untouched layers keep Figma's sizes. * fix: publish behaviours and other plugin content with library assets Every OpenPencil plugin-data field now declares its role: content that exists only as plugin data (behaviours, OkHCL picks), format copies of node fields written for files, or bookkeeping about where a document or node came from. Library snapshots keep a node's content plugin data, including other plugins' entries, and drop the rest; the asset hash counts the same entries, so a behaviour-only change is offered as an update while a .fig round trip still changes nothing. * feat: name behaviour rows by meaning and create what they need The Behaviour section named every main value "Value" under a "Values" heading, and a component without matching properties left an empty picker with no way forward. Rows are now named for the control (On, Checked, Pressed, Text), rows the control needs or already uses come first, and the optional rest folds under More options; a button keeps its states in view. An empty row creates what it needs in one undo step: a text layer and text property, Off and On variants on a set, or a slot frame for a part. The missing chip names the row it means and takes you there. * fix(dom-css): position free layers, hug content, and round ellipses HTML and Tailwind export stacked the layers of frames without auto layout in block flow, wrote fixed pixel sizes for auto layout frames set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers a parent does not lay out are now absolutely positioned at their coordinates inside a relative frame, hugging axes are left to the content, and ellipses get a 50% radius. * feat: run preview as live Reka UI islands over the canvas Preview simulated controls on the canvas: copies of instances, a handler per kind, its own key routing, and append-only text. It now runs them as real components. Each top-level layer that holds an instance with a behaviour becomes an island: its layers are projected to DOM through dom-css into a shadow root laid over the pane at its pan and zoom, and each behaviour mounts its Reka UI primitives on its layers, so text fields are real inputs and focus, keys, and layout are the browser's. Core's resolvePlayState shows instances in a state on a private graph, so the component's variants draw it, and controls are keyed by layer path so a variant switch keeps their DOM. The canvas leaves island layers to the islands, and the canvas play runtime and its key routing are gone. * fix: derive variant properties from Property=Value component names figma.combineAsVariants and Combine as variants only derived variant properties from slash-separated names, so components named as Figma names variants, such as State=On, Size=Large, became a set with no properties. Both now derive each named property and its values, after the slash form. * feat: script and tool access to behaviours by name Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers in the list slot and their content panels in a panels slot, and a slot of repeated parts names the Reka part of its children. A behaviour spec names component properties and slots instead of ids and resolves to the stored behaviour and back, with errors that list what the component has. Scripts get an `openpencil` global next to `figma`, in the Figma API's style: setBehaviour, getBehaviour with bindValue, bindPart, states, and missing, behaviourKinds, and createSlot. The eval tool, the CLI, and app automation compile scripts through one compileScript, so the CLI now returns the last expression as the others do. MCP and AI chat get set_behaviour, get_behaviour, and create_slot. * feat: write controls in design JSX with Reka UI's element names `<Switch.Root modelValue="State">` renders a main component, or a set when its children are variants, that behaves as a switch, and `<Switch.Thumb>` the slot that draws its thumb, one slot across the set's variants. Inputs become the text property of a field, tab triggers and panels go in their List and Panels slots, and a group's items are `<RadioGroup.Item of={…} />` instances in its Items slot. JSX export writes components with behaviours the same way, so they render back unchanged. The authoring reference documents controls, and the codegen and chat prompts now include it verbatim instead of dedenting its code examples. * chore: format the CLI export test * docs: document slots, behaviours, preview, and the openpencil API The components guide covers slots, behaviours, and preview with its shortcut; scripting covers the openpencil global and eval's last- expression result; the MCP and AI chat pages list the new tools; the features overview, README, and roadmap mention working controls. The chat prompt says how to build a control, and the codegen prompt builds components with behaviours on their Reka UI primitives. * chore: format the eval CLI test * docs: explain behaviours and preview islands, and guide the openpencil API A development page explains the behaviour model, the four authoring surfaces, how preview islands turn a control's state into live Reka UI components, and how to add a kind; the architecture page links it. The Core guide sets the rules for OpenPencilAPI: Figma-only `figma`, OpenPencil features on `openpencil` in the same style, one compileScript, names over ids, and docs with every member. Package READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and the behaviour model. Design JSX's behaviour modules move into a behaviours folder instead of a suffixed sibling. * refactor: center pasted layers through translate centerNodesAt repeated translate's loop, which test:dupes reports on master too. * fix: validate behaviour ranges and guess on and off by name A number value now needs max above min and a positive step: the schema, specs, and the panel reject a range a slider cannot step through. Binding a variant property guesses on and off by value name, as specs do, and a boolean property gets no on/off pair. Part bindings are read through partBinding, a replaced document restarts preview from its designed state, and the e2e preview shortcut uses ControlOrMeta. * feat: make the Behaviour section say what to do next A slider's range fields now carry inline Min, Max, Step, and Start labels. States offers Add state variants, which adds a Default, Hover, Pressed, Focus, and Disabled variant and binds them; Add Off and On variants and Add state variants turn a lone main component into a component set first, and a part's slot can be added to a set, in every variant under one slot id. Rows that could do nothing are gone: no empty pickers and no hints to combine variants by hand, and an unbound Disabled is left to the states. A warning line names what is still needed and replaces the missing chip, and the Switch's main value is called Checked. * fix: keep each slot to one part and keep creating slots at hand A slot draws one part, so the Behaviour section no longer offers a slot another part uses, and specs (the openpencil API, tools, and JSX) reject binding one slot to two parts. A part's picker keeps an action to add a new slot in its footer, so adding the first slot no longer hides it for the other parts.
2026-10-06 13:23:05 +00:00
- `figma.combineAsVariants(components, parent)` — properties come from names such as `State=On, Size=Large`
- `component.createInstance()`
- `instance.mainComponent`
### Variables
- `figma.getLocalVariables(type?)`
- `figma.getVariableById(id)`
- `figma.getLocalVariableCollections()`
- `figma.getVariableCollectionById(id)`
- `figma.createVariable(name, type, collectionId, value?)`
- `figma.setVariableValue(variableId, modeId, value)`
- `figma.deleteVariable(id)`
- `figma.createVariableCollection(name)`
- `figma.deleteVariableCollection(id)`
- `figma.bindVariable(nodeId, field, variableId)`
- `figma.unbindVariable(nodeId, field)`
### Properties
Common node properties are readable/writable through the proxy, including:
- Geometry: `x`, `y`, `width`, `height`, `rotation`, `resize(width, height)`
- Appearance: `fills`, `strokes`, `effects`, `opacity`, `visible`, `locked`, `blendMode`, `clipsContent`
- Radius: `cornerRadius`, `topLeftRadius`, `topRightRadius`, `bottomRightRadius`, `bottomLeftRadius`
- Text: `characters`, `fontSize`, `fontName`, `fontWeight`, alignment, line height, letter spacing, style-run helpers
- Auto-layout: `layoutMode`, `primaryAxisAlignItems`, `counterAxisAlignItems`, `itemSpacing`, padding, sizing, and layout positioning fields
- Stroke helpers: `strokeWeight`, `strokeAlign`, `dashPattern`
### Utilities
- `figma.mixed`
- `figma.createImage(data)`
- `figma.loadFontAsync(fontName)` no-ops because OpenPencil does not gate text edits on plugin font loading
- `figma.listAvailableFontsAsync()` returns host-provided fonts when available
- `figma.getNodeByIdAsync(id)` and `instance.getMainComponentAsync()` resolve to the same nodes as `figma.getNodeById(id)` and `instance.mainComponent`, for scripts written for Figma's dynamic-page mode
- `figma.notify(message)` logs a warning in headless mode
- `instance.swapComponent(component)` points an instance at another component
feat: create, fill, and edit slots (#862) * feat(app): add slot property controls and a shared picker AppPicker is a searchable, grouped list that opens beside the properties panel, built on Reka's popover and listbox so search keeps arrow-key navigation. It has comfortable rows with a thumbnail and description and compact rows for plain names, plus an optional footer action. The slot property row shows whether an instance's slot is Default or Modified, its item count, its limits with a checklist popover, Add instances on AppPicker, and Reset slot and Delete contents. These are presentational; wiring them to the editor follows. Strings are English only until the locale files catch up. * feat(app): open variable, style, and instance-swap choices in the shared picker The variable binding picker, the shared style fields, and instance-swap properties now open AppPicker: a titled panel beside the properties panel with search that keeps arrow-key navigation, a check on the current choice, and footer actions. Variable binding keeps its detach and create-variable actions. Instance-swap choices list the property's preferred components first; instanceSwapOptions keeps the preferred flag it already computed. AppPickerField gives select-shaped fields the same picker with a combobox trigger. * feat(core): edit instance slot content Only an instance's slots take layers now. slotScope classifies a parent as free, a slot of an instance, or the locked rest of an instance. Moves, reorders, layer-panel drops, paste, duplicate, and instance creation claim an untouched slot first, as Figma does on the first edit, and refuse the locked part; drops over it land in the instance's parent. Claiming keeps the layers but unlinks them from the component and moves the instance's overrides on nested instances onto those instances. Reset slot, Delete contents, and Add instance are editor actions, each one undo step that restores the instance's subtree. A canvas drop or reorder that claims a slot undoes together with the move. * feat(app): show and edit slot properties of the selected instance The component properties section lists each slot of the selected instance with its state, item count and limits, and adds instances, resets or clears its content through the editor's slot actions. The slot model lives in the Vue SDK as useSlotProperties. * feat(app): outline slots on the canvas and mark them in the layers panel Hovering or selecting a component, an instance, or a layer inside a slot draws its slots with a dashed pink outline and tints empty ones. Slot frames are selected and hovered in pink and show a dashed-square icon in the layers panel. * test(app): cover slot outlines with canvas snapshots * feat(app): translate slot and picker strings * docs: note slot editing and the shared picker * refactor: share slot test and story setup * refactor(app): name the picker's header prop heading * feat(core): create, configure, and remove slots on main components A frame of a main component becomes a slot through a SLOT property named after it; other sibling layers are first wrapped in an auto layout frame. Slot settings and removal are single undo steps. Instances now follow the component's property bindings on sync, so a slot created or removed on the component reaches existing instances. Slot helpers move to a slots domain folder in Scene Graph and Core. * feat(app): create and configure slots from the menu and properties panel Create slot joins the canvas context menu for layers of a main component. A Slots section on main components and their frames renames slots, sets their description, layer limits, and preferred components, and removes them. * fix: keep slot claims in the same undo step and refuse wraps inside instances Creating an instance and pasting HTML now batch the slot claim with the edit. Undoing an added slot instance restores the previous selection. Grouping or wrapping layers in the locked part of an instance is refused, and a section that cannot move no longer claims a slot. The picker clears its search however it closes, and its close and slot actions labels are translated. * feat(core): create and inspect slots through the plugin API component.createSlot() adds a 100x100 frame named Slot, Slot 2, and so on, bound to a new SLOT property, as live Figma does. Slot frames read type SLOT, resetSlot() restores an instance slot's component content, and limitViolations reports BELOW_MIN, ABOVE_MAX, and HAS_NON_PREFERRED for instance slots. addComponentProperty and editComponentProperty take a description and slotSettings, a cloned slot is a plain frame, and componentPropertyReferences uses property keys in both directions. The limit checks move to Scene Graph so the Vue SDK and the plugin API share them. * fix: keep nested slot content across swaps and read nested instance properties A nested instance points at the instance it was cloned from, so its component properties resolved to nothing. Properties now resolve through those links to the main component, and a swap or variant switch parks the slot content nested instances own and restores it into nested instances of the same names, as live Figma does. Plugin appendChild and insertChild claim the slot they add to and refuse the locked part of an instance with Figma's error. * test(core): record resetSlot on a main component slot; fix the Slots roadmap row * fix(core): refuse deleting layers outside an instance's slots As in Figma, delete and plugin remove() leave an instance's own layers and its slot frames alone, while removing slot content claims the slot in the same undo step as the delete. * test(app): wait for the bulk rename dialog to close before the next shortcut * feat(app): unify add, remove, and settings controls in the properties panel A section's + adds an item and a row's - removes it everywhere: grid tracks and variant properties now follow the fill and effect lists, and the header + of a component set adds Property 1 ready to rename instead of an inline form. Removing a variant is Delete, as for any layer. Row settings share the sliders icon, the variables button opens with its own icon, every icon button requires a label, and the instance header buttons use sentence case. Create Slot joins the app menu. * docs: note the unified properties panel controls * feat(app): animate floating panels alike and share the severity icon Popovers, menus, selects, comboboxes, and pickers now fade and grow in from the side they open on and fade out on close, from one motion preset that respects reduced motion; tooltips keep the tooltip preset. SeverityIcon and its colours move from the design check to shared feedback UI, and slot limits use them, so a broken limit reads like a design-check warning. * docs: note consistent popover and menu animation * feat(app): give every floating panel one surface and close it at once Popovers, menus, selects, comboboxes, pickers, presence cards, chat history, and the issue tooltip share one rounded surface with a 1px ring that outlines it in both themes; one-line tooltips keep a compact shape with the same edge. The select theme's radius and elevation options and local shadow overrides are gone. Panels still grow in from their side but now close immediately: a fading modal menu kept blocking the canvas and shortcuts until it unmounted. * fix(app): keep a reopened context menu open and name option-drag undo Duplicate Closing the canvas menu hands focus back to the canvas; when that landed just after a quick reopen, the new menu closed as focus moved outside it. Shortcuts no longer wait for a panel that is already closing, and an option-drag duplicate that claims a slot is undone as Duplicate rather than Move. * test(app): wait for menus and popovers to close before the next key
2026-10-04 17:02:28 +00:00
- `component.createSlot()` adds a slot frame and its `SLOT` property; slot frames read `type: 'SLOT'`, `resetSlot()` brings back an instance slot's component content, and `limitViolations` lists the limits an instance slot breaks. `addComponentProperty` and `editComponentProperty` take a `description` and, for slots, `slotSettings`
- `figma.viewport`
## Not yet Figma-compatible
These Figma APIs are not exposed as compatible helpers yet:
- `node.exportAsync()`
- `node.setBoundVariable(field, variable)`
- `figma.combineAsVariants(components, parent)`
- Figma style APIs such as `figma.createPaintStyle()` / `figma.createTextStyle()`
- Full vector boolean operation parity
Use OpenPencil CLI export commands, core tools, or direct scene-graph helpers where available.