openpencil/packages/docs/reference/design-authoring.md

93 lines
14 KiB
Markdown
Raw Normal View History

<!-- Generated from design-jsx reference and renderer metadata. Do not edit; run bun run generate:authoring-reference. -->
# OpenPencil design authoring
This reference describes scene creation, not React DOM output. Use the `render` tool for JSX strings, or import `Frame`, `Text`, and other authoring exports from `@open-pencil/design-jsx` and render them with `renderTree` from `@open-pencil/core/design-jsx` in library code. Library exports are not automatically globals in agent `eval`; use only the bindings exposed by that execution environment.
## Composition and layout
- `flex="row"` / `flex="col"` enables auto-layout. Use it for content; reserve explicit `x`/`y` or `position="absolute"` for intentional overlays and artwork. Without layout, children share the origin unless positioned.
- `w` / `h` accept pixels, `"hug"` (content-sized), or `"fill"` (available space in a supported layout parent). Use Hug for notes, cards, and long pages instead of guessing heights. Fixed viewport sizes and artwork geometry are intentional exceptions.
- `gap` controls spacing. `p`, `px`, `py`, and `pt`/`pr`/`pb`/`pl` control padding; longhands override shorthands. There is no margin shorthand.
- `justify="start"|"end"|"center"|"between"` controls the primary axis; `items="start"|"end"|"center"|"stretch"` controls the cross axis. Distribution needs available space: `between` cannot create extra room in a Hug container.
- `grow` distributes available space. Avoid circular Hug/Fill dependencies and redundant fixed widths on growing children. Keep Fill sizing through intermediate containers that should stretch.
- For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow.
2026-10-05 14:06:07 +00:00
- `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"` or `columns="repeat(7, 1fr)"`); tracks are `fr`, pixel lengths, `auto`, `repeat()`, and `minmax()`, which grows like its maximum. Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`.
- `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately.
feat(design-jsx): export every property the renderer accepts (#814) * feat(design-jsx): export every property the renderer accepts JSX export wrote only part of a layer: one solid fill, one stroke without its alignment, shadows as repeated attributes, background blurs as layer blurs, and nothing for hidden children, constraints, size limits, absolute positioning, vertical text alignment, masks, or variable bindings. Rendering an export lost those properties, and JSX diffs could not see changes to them. The export now writes them, using paint and effect helper calls when a shorthand cannot express a value exactly, and leaves out values the renderer would infer, so ordinary output stays as it was. Prop values can now hold objects, arrays, and helper calls, printed through @open-pencil/codegen's builders, which gain a call expression. The language gains `visible`, `locked`, `constraints` (Figma's constraints object with lowercase values, as `blendMode` uses), `italic`, `strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now applies `strokeAlign`, `strokeDash`, and the size limits, which it accepted but ignored. Per-corner radii are written even when the uniform radius is 0. A round-trip test renders each case's export and checks the fields and that exporting again changes nothing. * docs(fig): name the saved-glyph fixture by its repository path The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere. * fix(design-jsx): export the node-level dash pattern A node's own dashPattern was not written, so a node dashed at node level came back solid and the DOM/CSS export chose a solid border. It now round-trips as a separate dashPattern prop; strokeDash stays the stroke-local dash.
2026-10-03 11:40:21 +00:00
- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it.
- Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed.
## Paint, text, and artwork
- `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`, each taking an array of stops and optional `{ opacity, transform }`: `fills={[linearGradient([['#3b82f6', 0], ['#8b5cf6', 1]])]}`.
feat(design-jsx): export every property the renderer accepts (#814) * feat(design-jsx): export every property the renderer accepts JSX export wrote only part of a layer: one solid fill, one stroke without its alignment, shadows as repeated attributes, background blurs as layer blurs, and nothing for hidden children, constraints, size limits, absolute positioning, vertical text alignment, masks, or variable bindings. Rendering an export lost those properties, and JSX diffs could not see changes to them. The export now writes them, using paint and effect helper calls when a shorthand cannot express a value exactly, and leaves out values the renderer would infer, so ordinary output stays as it was. Prop values can now hold objects, arrays, and helper calls, printed through @open-pencil/codegen's builders, which gain a call expression. The language gains `visible`, `locked`, `constraints` (Figma's constraints object with lowercase values, as `blendMode` uses), `italic`, `strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now applies `strokeAlign`, `strokeDash`, and the size limits, which it accepted but ignored. Per-corner radii are written even when the uniform radius is 0. A round-trip test renders each case's export and checks the fields and that exporting again changes nothing. * docs(fig): name the saved-glyph fixture by its repository path The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere. * fix(design-jsx): export the node-level dash pattern A node's own dashPattern was not written, so a node dashed at node level came back solid and the DOM/CSS export chose a solid border. It now round-trips as a separate dashPattern prop; strokeDash stays the stroke-local dash.
2026-10-03 11:40:21 +00:00
- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`.
- `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct.
fix: share CSS value parsing between dom-css and design JSX (#910) * fix(core): let fill text share an auto-layout row Fill frames grow and shrink from a zero flex basis, so fill siblings split a row's free space. Fill text only got flexGrow, and its measure function capped it at its stored width, 100px for new text, so seven fill labels in a 280px row each kept 100px and overflowed. Without a measurer, the fallback pinned that width and set no grow at all. Fill text now uses the same zero basis as fill frames on both paths. * fix(design-jsx): read repeat() and minmax() in grid tracks Track lists were split on whitespace, so columns="repeat(7, 1fr)" became the tracks repeat(7, and 1fr), read as fixed 0px and 1px columns that collapsed the grid. Tokens inside parentheses now stay together, repeat() expands its tracks, minmax() grows like its maximum, and a track the grid cannot express sizes to its content instead of to 0. * refactor(scene-graph): parse CSS grid tracks with postcss-value-parser design-jsx read repeat() and minmax() with a hand-written tokenizer and regexes, while dom-css already parses CSS values with postcss-value-parser. Track lists are now parsed in @open-pencil/scene-graph/css on that library, where both packages can use it, and design-jsx calls it. dom-css's hand-written declaration of the library's types is replaced by the types the library ships, which the shared module needs. The Scene Graph guide records that CSS values are parsed there. * fix(dom-css): read shadows, borders, and lengths with the shared CSS parser dom-css tried each word of a shadow as a color and parseColor turned the leading 0 into black, so a shadow written in the usual order imported black, and the headless runtime split the border shorthand on spaces, which cut rgb(226, 232, 240) apart and also gave a black border. Numbers, colors, shadow lists, and shorthand parts are now parsed in @open-pencil/scene-graph/css on postcss-value-parser. Every shadow layer imports, inset ones as inner shadows, a fully transparent color counts as none, and lengths in units that depend on context, such as % or em, are no longer read as pixels. tryParseColor gives the color or null, and parseColor builds on it. * fix(design-jsx): read the shadow prop as a CSS shadow list The shadow prop was split on spaces, so a color before the lengths or a spread made the shadow black, and only one shadow could be set. It now takes a CSS box-shadow list through the shared parser, and pixel lengths in style props use the shared number parser. A rem grid track now has its size instead of sizing to content. * test: type grid track and shadow fixtures The test type check added in #896 rejects the grid track tests that #866 merged, because their object literals widen sizing to string, so bun run check fails on master. The fixtures are now typed GridTrack and Effect values.
2026-10-05 17:02:43 +00:00
- `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow` takes a CSS `box-shadow` list, such as `shadow="0 4 8 #0002, inset 0 1 0 #fff"`, and `blur` a layer blur radius; both are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning.
feat(design-jsx): export every property the renderer accepts (#814) * feat(design-jsx): export every property the renderer accepts JSX export wrote only part of a layer: one solid fill, one stroke without its alignment, shadows as repeated attributes, background blurs as layer blurs, and nothing for hidden children, constraints, size limits, absolute positioning, vertical text alignment, masks, or variable bindings. Rendering an export lost those properties, and JSX diffs could not see changes to them. The export now writes them, using paint and effect helper calls when a shorthand cannot express a value exactly, and leaves out values the renderer would infer, so ordinary output stays as it was. Prop values can now hold objects, arrays, and helper calls, printed through @open-pencil/codegen's builders, which gain a call expression. The language gains `visible`, `locked`, `constraints` (Figma's constraints object with lowercase values, as `blendMode` uses), `italic`, `strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now applies `strokeAlign`, `strokeDash`, and the size limits, which it accepted but ignored. Per-corner radii are written even when the uniform radius is 0. A round-trip test renders each case's export and checks the fields and that exporting again changes nothing. * docs(fig): name the saved-glyph fixture by its repository path The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere. * fix(design-jsx): export the node-level dash pattern A node's own dashPattern was not written, so a node dashed at node level came back solid and the DOM/CSS export chose a solid border. It now round-trips as a separate dashPattern prop; strokeDash stays the stroke-local dash.
2026-10-03 11:40:21 +00:00
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
- `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible.
- Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work.
## Variables and components
- Create document variables before referencing them with `designVar('id-or-name')`. `defineVars` groups references; it does not create variable collections.
- COLOR references work in paint props. FLOAT references work in `w`, `h`, `gap`, padding, corner radii, `strokeWidth`, `opacity`, text `size`/`fontSize`, `lineHeight`, and `letterSpacing`. Grid `columnGap`/`rowGap` and wrapped flex `rowGap` also support FLOAT references; grid `gap` overrides both axis-specific gaps. Use numbers or FLOAT references for these scalar props, not CSS unit strings.
- References preserve real graph bindings, not just copied values. Set the intended collection mode on the parent before creating scalar-bound content: initial scalar layout resolves that inherited mode. This does not guarantee automatic scalar layout recomputation after a later mode switch. Verify resulting geometry as well as paint when changing modes. Missing or incorrectly typed scalar variables are errors.
- `bind` maps supported scene-field paths to variable IDs or references when no shorthand exists. Use semantic tokens consistently rather than declaring unused collections.
- A reusable JavaScript function shares source code, not component identity. Use `Component`, `ComponentSet`, and `Instance` for editable main components and linked instances.
- `Instance` resolves an existing component through `of`, `component`, or `componentId`. Component-set children named `variant=Primary`, for example, define variants that can be selected when instantiating the set.
- `Component` and `ComponentSet` accept `properties`, an array of native property definitions (`id`, `name`, `type`, `defaultValue`). `Instance` accepts `properties`, an ID-to-value assignment object. Ordinary nodes do not accept `properties`.
- Child `propertyRefs` connect fields to stable property IDs, for example `[{ propertyId: 'message', field: 'TEXT' }]`. Supported fields are `TEXT`, `VISIBLE`, and `INSTANCE_SWAP`; text and swap references require text and instance nodes respectively. References do not depend on layer names.
- Instance assignments use the native string values (including `'true'` / `'false'` for BOOLEAN properties and component IDs for swaps). For example `Instance({ of: noteId, properties: { message: 'Updated review' } })`. Assignments persist through component synchronization; unknown IDs and invalid values fail rather than silently creating inert overrides. Select variants through component-set variant props, not through instance property assignments.
- Reuse existing local or library components before recreating them. Keep meaningful text, visibility, and swap properties exposed rather than hand-editing cloned child nodes.
- Explicit instance `w` / `h` replace the inherited sizing mode on that axis; omitted dimensions retain the main component's sizing. Authored overrides survive component synchronization. Distinguish those placement constraints from the main component's default size, and verify actual bounds in narrower parents. Do not compensate for a sizing mismatch with guessed heights, clipping, or manually positioned siblings.
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
## Controls
- A main component can behave as a Reka UI control in preview and code. Write it with Reka's names: `Switch.Root` is the component (a component set when its children are `Component` variants), and each Reka part is the slot that draws it, such as `Switch.Thumb`, `Slider.Track`, `Slider.Range`, `Slider.Thumb`, `Tabs.List`, `Collapsible.Trigger`, or `NumberField.Increment`. Parts in different variants share one slot.
- The root names the properties that hold its values: `modelValue` (`open` on `Collapsible.Root`), `disabled`, and, for text fields, `filled`, as a variant or boolean property name, or `{ property, on, off }` when the variant values are not named like On and Off. `states` names the variant property that draws default, hover, pressed, focus, and disabled; a slider, progress bar, or number field takes `min`, `max`, `step`, and `defaultValue`.
- `TextField.Input`, `Textarea.Input`, and `NumberField.Input` are the text layers whose text becomes the field's text property. `Tabs.Trigger` goes in `Tabs.List`; `Tabs.Content` panels may sit directly under `Tabs.Root`, the first showing.
- A group's item component is written on its own (`RadioGroup.Item`, `ToggleGroup.Item`, `Accordion.Item` with its `Accordion.Trigger` and `Accordion.Content`); the group's root then lists items as `<RadioGroup.Item of={radioId} />`.
```tsx
<Switch.Root name="Switch" modelValue="State" states="Interaction">
<Component name="State=Off, Interaction=Default" w={44} h={24} rounded={12} bg="#D0D4DA">
<Switch.Thumb x={2} y={2} w={20} h={20} rounded={10} bg="#FFFFFF" />
</Component>
<Component name="State=On, Interaction=Default" w={44} h={24} rounded={12} bg="#3B6CF6">
<Switch.Thumb x={22} y={2} w={20} h={20} rounded={10} bg="#FFFFFF" />
</Component>
</Switch.Root>
```
## Verification
Inspect structure and actual rendered output. Node counts and `describe` diagnostics do not establish visual fidelity. Check wrapping with longer content, narrower containers, component edits, and relevant modes. Resolve overflow and contrast problems at their source. Reuse IDs returned by creation tools rather than repeatedly searching for the same nodes.
The examples below are executed by the authoring-reference tests. Create the named variables before running a variable-bound example.
## Content-sized review note
```jsx
<Frame name="Review note" w={280} h="hug" flex="col" gap={8} p={16} bg="#FFFFFF">
<Text name="Author" size={12} weight="medium" color="#252A31">June Lee</Text>
<Text name="Message" w="fill" size={12} color="#6B7079">Give the date a little more room at the bottom.</Text>
</Frame>
```
## Variable-bound spacing and typography
```jsx
<Frame name="Bound note" w={280} h="hug" flex="col" gap={designVar('Space/small')} p={designVar('Space/medium')} bg="#FFFFFF">
<Text name="Message" w="fill" size={designVar('Type/body')} lineHeight={designVar('Type/body-leading')} letterSpacing={designVar('Type/body-tracking')} color="#252A31">A note that grows with its content.</Text>
</Frame>
```
## Supported syntax inventory
Generated from the renderer metadata. This inventory lists accepted names, not arbitrary browser CSS support.
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
**Elements:** `Frame`, `Text`, `Rectangle`, `Ellipse`, `Line`, `Star`, `Polygon`, `Vector`, `Group`, `Section`, `Component`, `ComponentSet`, `Instance`, `View`, `Rect`, `Icon`, `Button.Root`, `Toggle.Root`, `Switch.Root`, `Switch.Thumb`, `Checkbox.Root`, `Checkbox.Indicator`, `RadioGroup.Root`, `RadioGroup.Item`, `RadioGroup.Indicator`, `ToggleGroup.Root`, `ToggleGroup.Item`, `Slider.Root`, `Slider.Track`, `Slider.Range`, `Slider.Thumb`, `Progress.Root`, `Progress.Indicator`, `Tabs.Root`, `Tabs.List`, `Tabs.Trigger`, `Tabs.Content`, `Collapsible.Root`, `Collapsible.Trigger`, `Collapsible.Content`, `Accordion.Root`, `Accordion.Item`, `Accordion.Header`, `Accordion.Trigger`, `Accordion.Content`, `NumberField.Root`, `NumberField.Input`, `NumberField.Increment`, `NumberField.Decrement`, `TextField.Root`, `TextField.Input`, `Textarea.Root`, `Textarea.Input`.
**Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`.
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
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`, `modelValue`, `open`, `disabled`, `filled`, `states`, `min`, `max`, `step`, `defaultValue`.