openpencil/packages/docs/development/behaviours-and-preview.md
Danila Poyarkov 4254204685
feat(dom-css): compile component variants into state styles (#965)
* feat(dom-css): compile component variants into state styles

Code export needs a component's variants as one markup tree whose look follows the control's state. stateStyles keeps the rest variant as the base and turns each other variant into only what it changes, under the data-state, data-disabled and interaction conditions Reka and Radix set, or a data attribute for other variant properties. Layers only some variants draw stay hidden where absent. stateStylesToCSS writes a stylesheet with readable classes and stateStylesToTailwind writes utilities behind state variants.

* refactor(dom-css): build state stylesheets with the CSS object model

The stylesheet was assembled from strings with JSON-quoted attribute values. It is now built with @acemir/cssom, which rejects an invalid selector and prints the declarations, and attribute values are escaped as CSS strings by cssesc. The object model loads lazily, as for font-face rules, so stateStylesToCSS is async.

* refactor(dom-css): split state styles by step and require a rest variant

model.ts mixed reading conditions, projecting and merging variants, and comparing styles. They now live in conditions.ts, layers.ts, and model.ts, with the types in types.ts. A layer only some variants draw keeps the style of the variant that adds it, so no separate first-style pass is needed. A set with no rest variant now has no state styles, instead of using a checked or hovered variant as its rest look.
2026-10-07 21:15:40 +00:00

6.7 KiB

title description
Behaviours and Preview How components become working controls, how agents and scripts author them, and how preview runs them as live Reka UI islands.

Behaviours and Preview

A behaviour makes a main component or component set work as a real control: a Switch, Slider, Tabs, Text field, and so on, after Reka UI's primitives. It adds no node types. It describes which of the component's existing properties hold the control's values, which of its slots draw the control's parts, and which variants draw its interaction states. Preview then runs the control as the real Reka UI component, and code export can generate the same.

The model

The model lives in @open-pencil/scene-graph (packages/scene-graph/src/behaviours/).

  • Kinds and contracts (kinds.ts). BEHAVIOUR_CONTRACTS gives each kind its values and parts, keyed by kind, so a kind without a contract does not type-check. Contracts follow Reka UI's anatomy: every Reka subcomponent is a slot of the component, the component itself is the root, and a slot that holds repeated parts names the Reka part of its children, such as the tab list's triggers or a group's items.
  • Storage (schema.ts, model.ts). A behaviour is stored as the behaviour field of OpenPencil's plugin data on the component or set, with properties and slots bound by id. Its role is content, so it travels with library assets and counts towards their update hash. .fig export renames the bound ids to the GUIDs it gives the properties.
  • Values: a boolean is a variant property (with the values that mean on and off) or a boolean property; text is a text property; a number is the behaviour's own range, since Figma has no number property; a choice is which child of a slot is active.
  • States: a variant property whose values draw rest, hover, pressed, focus, and disabled.
  • Specs (spec.ts). behaviourFromSpec and behaviourToSpec convert between the stored behaviour and a spec that names properties and slots, which is how every authoring surface except the panel writes it. Errors name what the component has.

Authoring surfaces

All of them write the same model.

Surface Where Notes
Behaviour section src/components/properties/component-properties/behaviour/, useBehaviour in packages/vue Binds by id; Core's editor actions (setBehaviour, addBehaviourText, addBehaviourVariant, addBehaviourPart) create what is missing in one undo step.
openpencil scripting API packages/core/src/openpencil-api/ The openpencil global next to figma in every script runner, through compileScript.
Tools packages/core/src/tools/create/behaviours.ts set_behaviour, get_behaviour, create_slot, built on the openpencil API.
Design JSX packages/design-jsx/src/behaviours/, export/behaviours.ts Reka-named elements (Switch.Root, Switch.Thumb) render and export components with behaviours.

Preview islands

Preview is a per-pane mode (EditorViewState.play). It runs controls as real components; it never simulates them on the canvas.

  1. Islands. playIslandRoots (Core) picks the top-level layers of the page that hold an instance with a behaviour. The renderer skips them while the pane previews, and PlayIslands (packages/vue/src/canvas/islands/) mounts one island per root, laid over the canvas at the pane's pan and zoom.
  2. Shadow DOM. Each island renders into its own shadow root: app CSS does not reach in, island CSS does not leak out, fonts already loaded by the app apply, and the island moves with the canvas in the same frame. Wheel gestures over an island are passed to the canvas.
  3. State to design. An island keeps each control's state (values, choice, number, typed text, hover, pressed, focus). Core's resolvePlayState copies the island's layers into a private graph and shows each instance in its state: the matching variant, boolean properties, and layers a control reveals, such as tab panels. The document is never changed.
  4. Design to DOM. The private graph is projected to DOM through @open-pencil/dom-css, the same projection HTML export uses, so island fidelity and export fidelity improve together.
  5. DOM to Reka. Each projected layer has a role: a control's root, a part, a group's item, a tab trigger or panel, or a text input. behaviourControls and controlRoles in @open-pencil/dom-css (packages/dom-css/src/behaviours/) assign them, so code export reads the same roles. Roles wrap the layer in its Reka UI primitive with asChild, so Reka renders through the designed element. Typing, focus, keyboard navigation, and accessibility are the browser's and Reka's.

Controls and DOM keys use layer paths (names below the island root), not node ids: a variant switch rebuilds an instance's layers with new ids, and a stable path keeps the DOM in place, including a focused input.

Code export

Export reads the same model as preview. behaviourArgs gives a component set its props: a boolean value drawn by an on/off variant property becomes checked, pressed, open, or disabled.

stateStyles merges a set's variants into one markup tree, matching layers by layer path:

  • The rest variant is the base. Each other variant becomes a rule holding only what it changes, under conditions on the control's root.
  • The conditions are the attributes Reka and Radix set, data-state and data-disabled; the browser's :hover, :active, and :focus-visible; and a data-* attribute for any other variant property, which the generated component sets from its prop.
  • Layers only some variants draw stay in the tree, hidden where absent, and a label that reads differently is a layer per state. A combination the set doesn't draw combines from its parts, and a drawn combination keeps only what its parts don't already give.

stateStylesToCSS writes the result as a stylesheet with a readable class per layer. stateStylesToTailwind writes it as utilities behind state variants, with a named group per control so a control nested in another reacts only to its own root.

Adding a kind or a part

  1. Add the kind to BEHAVIOUR_KINDS and its contract to BEHAVIOUR_CONTRACTS in scene-graph; part ids follow Reka's part names.
  2. Map it in preview: its root and parts in packages/vue/src/canvas/islands/controls/, with the Reka primitives it uses.
  3. Name its elements in design JSX: REKA_ELEMENTS in packages/design-jsx/src/behaviours/index.ts and the export's root table in export/behaviours.ts.
  4. Label it in the Behaviour section (labels.ts) and translate the strings.
  5. Cover the model and spec in scene-graph tests, preview in tests/e2e/components/behaviours.spec.ts, and JSX in packages/core/tests/design-jsx/behaviours.test.ts.