refactor: share behaviour control roles through dom-css (#960)

* refactor: share behaviour control roles through dom-css

Code export needs the same control model and layer roles preview islands use. They move from packages/vue to packages/dom-css/src/behaviours as behaviourControls and controlRoles, and the layerPath, findLayerByPath and hasBehaviour helpers they need move from Core down to scene-graph, since dom-css cannot depend on Core. Preview behaves the same.

* fix(scene-graph): escape layer path names and stop on parent cycles

A layer named Wifi#1 or Row/1 did not round-trip through its path, so preview skipped that control or reveal target, and a parent cycle in imported data hung the walk. Names escape % / and #, and the walk goes through closest(), which stops on a cycle.
This commit is contained in:
Danila Poyarkov 2026-10-07 20:06:16 +00:00 committed by GitHub
parent 6a06b31dd8
commit c47d1b78e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 209 additions and 98 deletions

View file

@ -18,8 +18,8 @@ export type { PageSnapshot } from './history/snapshot'
export { graphFromPageSnapshot } from './history/snapshot-graph'
export type { ClipboardPayload, ClipboardSnapshot } from './clipboard/copy'
export { resolvePasteTarget } from './clipboard/paste-target'
export { hasBehaviour, playIslandRoots } from './play/islands'
export { layerPath, resolvePlayState, type InstanceState } from './play/states'
export { playIslandRoots } from './play/islands'
export { resolvePlayState, type InstanceState } from './play/states'
export type { PlayState } from './play/actions'
export type { Editor } from './create'
export type { VariableTokenFields } from './variables'

View file

@ -1,17 +1,4 @@
import {
behaviourOwner,
readBehaviour,
type SceneGraph,
type SceneNode
} from '@open-pencil/scene-graph'
/** Whether an instance's main component, or its set, has a behaviour. */
export function hasBehaviour(graph: SceneGraph, node: SceneNode): boolean {
if (node.type !== 'INSTANCE' || !node.componentId) return false
const component = graph.getNode(node.componentId)
const owner = component && behaviourOwner(graph, component)
return !!owner && !!readBehaviour(owner)
}
import { hasBehaviour, type SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
function containsControl(graph: SceneGraph, node: SceneNode): boolean {
if (hasBehaviour(graph, node)) return true

View file

@ -1,6 +1,7 @@
import {
applyComponentPropertyValue,
componentPropertyDefinitions,
findLayerByPath,
SceneGraph,
type SceneNode
} from '@open-pencil/scene-graph'
@ -22,37 +23,6 @@ export interface InstanceState {
reveal?: string[]
}
/**
* A layer's path below a root: the names of the layers down to it, with the position among
* same-named siblings when names repeat. It stays the same when a variant switch rebuilds an
* instance's layers, so it identifies controls and keeps their DOM in place.
*/
export function layerPath(graph: SceneGraph, rootId: string, nodeId: string): string {
const segments: string[] = []
let current = graph.getNode(nodeId)
while (current && current.id !== rootId) {
const parent = current.parentId ? graph.getNode(current.parentId) : undefined
const { id, name } = current
const twins = parent ? graph.getChildren(parent.id).filter((child) => child.name === name) : []
const index = twins.findIndex((child) => child.id === id)
segments.unshift(twins.length > 1 ? `${name}#${index}` : name)
current = parent
}
return segments.join('/')
}
function findByPath(graph: SceneGraph, rootId: string, path: string): SceneNode | undefined {
let current = graph.getNode(rootId)
for (const segment of path ? path.split('/') : []) {
if (!current) return undefined
const hash = segment.lastIndexOf('#')
const name = hash === -1 ? segment : segment.slice(0, hash)
const twins = graph.getChildren(current.id).filter((child) => child.name === name)
current = twins.at(hash === -1 ? 0 : Number(segment.slice(hash + 1)))
}
return current
}
/** The root's layers and every component they show, so instances can switch variant. */
function closure(source: SceneGraph, rootId: string): Set<string> {
const ids = new Set<string>()
@ -158,7 +128,7 @@ function applyState(
reflow(graph, instance.id)
}
for (const path of state.reveal ?? []) {
const layer = findByPath(graph, rootId, path)
const layer = findLayerByPath(graph, rootId, path)
if (layer && !layer.visible) {
graph.updateNode(layer.id, { visible: true })
reflow(graph, layer.id)
@ -179,7 +149,7 @@ export function resolvePlayState(
const graph = copyGraph(source, closure(source, rootId))
const ordered = [...states].sort(([a], [b]) => a.split('/').length - b.split('/').length)
for (const [path, state] of ordered) {
const instance = findByPath(graph, rootId, path)
const instance = findLayerByPath(graph, rootId, path)
if (instance?.type === 'INSTANCE') applyState(graph, rootId, instance, state)
}
computeAllLayouts(graph, rootId)

View file

@ -1,12 +1,12 @@
import { describe, expect, test } from 'bun:test'
import { createEditor, playIslandRoots, resolvePlayState } from '@open-pencil/core/editor'
import {
createEditor,
emptyBehaviour,
instanceMainComponent,
layerPath,
playIslandRoots,
resolvePlayState
} from '@open-pencil/core/editor'
import { emptyBehaviour, instanceMainComponent, SceneGraph } from '@open-pencil/scene-graph'
SceneGraph
} from '@open-pencil/scene-graph'
/** A Switch set (State × Interaction) and a card frame holding an Off instance with a label. */
function switchCard() {

View file

@ -36,7 +36,7 @@ Preview is a per-pane mode (`EditorViewState.play`). It runs controls as real co
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. 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.
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.

View file

@ -1,12 +1,13 @@
import { hasBehaviour, layerPath } from '@open-pencil/core/editor'
import {
behaviourContract,
behaviourOwner,
behaviourProperties,
booleanBinding,
findComponentPropertyTargets,
hasBehaviour,
instanceMainComponent,
instanceSlotFrames,
layerPath,
partBinding,
readBehaviour,
slotPropertyId,
@ -36,23 +37,23 @@ export interface StatesModel {
designedDisabled: boolean
}
/** An instance with a behaviour inside an island, as the island runs it. */
/** An instance with a behaviour below a root layer: its values, states, and bound layers. */
export interface ControlModel {
/** The instance's layer path below the island root. */
/** The instance's layer path below the root. */
path: string
kind: Behaviour['kind']
behaviour: Behaviour
booleans: Record<string, BooleanModel>
states: StatesModel | null
/** Each bound part's slot frame, by part id, as a layer path below the island root. */
/** Each bound part's slot frame, by part id, as a layer path below the root. */
parts: Record<string, string>
/** Each bound text value's text layer, by value id, as a layer path below the island root. */
/** Each bound text value's text layer, by value id, as a layer path below the root. */
texts: Record<string, { path: string; designed: string }>
/** The children of the items slot that are controls themselves, in order. */
items: ControlModel[]
/**
* Layers the control decides to show, which the design may hide: tab panels, or a
* collapsible's content, as layer paths below the island root.
* collapsible's content, as layer paths below the root.
*/
reveal: string[]
/** For tabs, each panel's layer path, in order. */
@ -173,10 +174,11 @@ function controlModel(graph: SceneGraph, rootId: string, instance: SceneNode): C
}
/**
* The controls of an island, by layer path, outermost first. Items of a group are listed both
* in the group's `items` and on their own, since each keeps its own state.
* The controls below a root layer, by layer path, outermost first. Items of a group are listed
* both in the group's `items` and on their own, since each keeps its own state. Preview runs
* them as live islands; export generates components from them.
*/
export function islandControls(graph: SceneGraph, rootId: string): Map<string, ControlModel> {
export function behaviourControls(graph: SceneGraph, rootId: string): Map<string, ControlModel> {
const controls = new Map<string, ControlModel>()
const visit = (node: SceneNode) => {
if (hasBehaviour(graph, node)) {

View file

@ -0,0 +1,8 @@
export {
behaviourControls,
booleanOf,
type BooleanModel,
type ControlModel,
type StatesModel
} from './controls'
export { controlRoles, groupOf, type ControlRole } from './roles'

View file

@ -1,7 +1,7 @@
import type { ControlModel } from './model'
import type { ControlModel } from './controls'
/** What a layer of an island is to the controls in it. */
export type IslandRole =
/** What a layer below a root is to the controls in it. */
export type ControlRole =
| { type: 'root'; control: ControlModel }
| { type: 'part'; control: ControlModel; part: string }
| { type: 'item'; group: ControlModel; control: ControlModel; index: number }
@ -14,8 +14,10 @@ export type IslandRole =
* and panels, and the text layers that become inputs. Items of a group take the item role in
* place of their root role, since the group drives them.
*/
export function islandRoles(controls: ReadonlyMap<string, ControlModel>): Map<string, IslandRole> {
const roles = new Map<string, IslandRole>()
export function controlRoles(
controls: ReadonlyMap<string, ControlModel>
): Map<string, ControlRole> {
const roles = new Map<string, ControlRole>()
for (const control of controls.values()) {
if (!roles.has(control.path)) roles.set(control.path, { type: 'root', control })
for (const [part, path] of Object.entries(control.parts))
@ -34,7 +36,7 @@ export function islandRoles(controls: ReadonlyMap<string, ControlModel>): Map<st
/** The group a control is an item of, if any. */
export function groupOf(
roles: ReadonlyMap<string, IslandRole>,
roles: ReadonlyMap<string, ControlRole>,
control: ControlModel
): ControlModel | undefined {
const role = roles.get(control.path)

View file

@ -9,6 +9,7 @@ export {
} from './tailwind-jsx'
export { serializeHTML } from './html'
export * from '../tokens'
export * from '../behaviours'
export type {
ExportHTMLBundle,
ExportHTMLBundleOptions,

View file

@ -0,0 +1,61 @@
import { describe, expect, test } from 'bun:test'
import { behaviourControls, booleanOf, controlRoles } from '#dom-css/behaviours'
import { emptyBehaviour, SceneGraph, withBehaviour } from '@open-pencil/scene-graph'
/** A Switch set (State) and a card frame holding an On instance named Wifi. */
function switchCard() {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const set = graph.createNode('COMPONENT_SET', pageId, {
name: 'Switch',
componentPropertyDefinitions: [
{
id: 'state',
name: 'State',
type: 'VARIANT',
defaultValue: 'Off',
variantOptions: ['Off', 'On']
}
]
})
graph.createNode('COMPONENT', set.id, {
name: 'State=Off',
componentPropertyValues: { State: 'Off' }
})
const on = graph.createNode('COMPONENT', set.id, {
name: 'State=On',
componentPropertyValues: { State: 'On' }
})
graph.updateNode(set.id, {
pluginData: withBehaviour(set, {
...emptyBehaviour('switch'),
booleans: { value: { propertyId: 'state', on: 'On', off: 'Off' } }
})
})
const card = graph.createNode('FRAME', pageId, { name: 'Card' })
const instance = graph.createInstance(on.id, card.id)
if (!instance) throw new Error('No instance')
graph.updateNode(instance.id, { name: 'Wifi' })
return { graph, card }
}
describe('behaviour controls', () => {
test('finds the controls below a root by layer path with the values their design shows', () => {
const { graph, card } = switchCard()
const controls = behaviourControls(graph, card.id)
expect([...controls.keys()]).toEqual(['Wifi'])
const control = controls.get('Wifi')
expect(control?.kind).toBe('switch')
expect(control && booleanOf(control, 'value')?.designed).toBe(true)
expect(control && booleanOf(control, 'open')).toBeUndefined()
})
test('gives a control root its role by layer path', () => {
const { graph, card } = switchCard()
const roles = controlRoles(behaviourControls(graph, card.id))
expect(roles.get('Wifi')?.type).toBe('root')
expect(roles.size).toBe(1)
})
})

View file

@ -0,0 +1,51 @@
import type { SceneGraph } from '../index'
import type { SceneNode } from '../types'
import { behaviourOwner, readBehaviour } from './model'
/** Whether an instance's main component, or its set, has a behaviour. */
export function hasBehaviour(graph: SceneGraph, node: SceneNode): boolean {
if (node.type !== 'INSTANCE' || !node.componentId) return false
const component = graph.getNode(node.componentId)
const owner = component && behaviourOwner(graph, component)
return !!owner && !!readBehaviour(owner)
}
/** A name as a path segment: `/` separates segments and `#` the twin index, so both are escaped. */
const pathName = (name: string) => name.replace(/[%/#]/g, (char) => encodeURIComponent(char))
/**
* A layer's path below a root: the names of the layers down to it, with the position among
* same-named siblings when names repeat. It stays the same when a variant switch rebuilds an
* instance's layers, so it identifies controls and keeps their DOM in place.
*/
export function layerPath(graph: SceneGraph, rootId: string, nodeId: string): string {
const segments: string[] = []
// `closest` stops on a parent cycle in bad data, where a hand-written walk would not.
graph.closest(nodeId, (node) => {
if (node.id === rootId) return true
const twins = node.parentId
? graph.getChildren(node.parentId).filter((child) => child.name === node.name)
: []
const index = twins.findIndex((child) => child.id === node.id)
segments.unshift(twins.length > 1 ? `${pathName(node.name)}#${index}` : pathName(node.name))
return false
})
return segments.join('/')
}
/** The layer at `path` below a root, the inverse of `layerPath`. */
export function findLayerByPath(
graph: SceneGraph,
rootId: string,
path: string
): SceneNode | undefined {
let current = graph.getNode(rootId)
for (const segment of path ? path.split('/') : []) {
if (!current) return undefined
const hash = segment.lastIndexOf('#')
const name = hash === -1 ? segment : segment.slice(0, hash)
const twins = graph.getChildren(current.id).filter((child) => pathName(child.name) === name)
current = twins.at(hash === -1 ? 0 : Number(segment.slice(hash + 1)))
}
return current
}

View file

@ -20,6 +20,7 @@ export * from './slots/content'
export * from './slots/authoring'
export * from './slots/limits'
export * from './behaviours/kinds'
export * from './behaviours/layers'
export * from './behaviours/model'
export * from './behaviours/spec'
export * from './copy'

View file

@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test'
import { findLayerByPath, layerPath, SceneGraph } from '@open-pencil/scene-graph'
describe('layer paths', () => {
test('round-trip names that contain the path separators', () => {
const graph = new SceneGraph()
const root = graph.createNode('FRAME', graph.getPages()[0].id, { name: 'Card' })
const group = graph.createNode('FRAME', root.id, { name: 'Row/1' })
for (const name of ['Wifi#1', '50%', 'Label', 'Label']) {
const node = graph.createNode('FRAME', group.id, { name })
const path = layerPath(graph, root.id, node.id)
expect(findLayerByPath(graph, root.id, path)?.id).toBe(node.id)
}
expect(layerPath(graph, root.id, group.id)).toBe('Row%2F1')
})
test('stops on a parent cycle in bad data', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const root = graph.createNode('FRAME', pageId, { name: 'Card' })
const a = graph.createNode('FRAME', pageId, { name: 'A' })
const b = graph.createNode('FRAME', a.id, { name: 'B' })
// Imported data can point parents at each other; the walk must still end.
const loose = graph.getNode(a.id)
if (loose) loose.parentId = b.id
expect(typeof layerPath(graph, root.id, b.id)).toBe('string')
})
})

View file

@ -18,6 +18,7 @@ The app's `DesignPanel` retains at most one selection-property subtree through `
## Preview islands
- Preview runs controls as real components, never as canvas simulations: `packages/vue/src/canvas/islands/` renders each top-level layer that holds an instance with a behaviour as DOM in a shadow root over the canvas, projected through `@open-pencil/dom-css`, and mounts each behaviour's Reka UI primitives on its layers. Typing, focus, and keyboard handling belong to the browser and Reka.
- Islands only map layers to Reka components. Which layer is a control, part, item, trigger, panel, or input comes from `behaviourControls` and `controlRoles` in `@open-pencil/dom-css`, which code export shares; never derive roles in Vue (`packages/dom-css/tests/behaviours/controls.test.ts`).
- The concept, the pipeline from state to DOM, and how to add a kind are in `packages/docs/development/behaviours-and-preview.md`.
- Variants draw control states: Core's `resolvePlayState` shows instances in a state on a private graph, and controls are keyed by layer path so a variant switch keeps their DOM, including a focused input. The document is never changed (`packages/core/tests/editor/play/states.test.ts`, `tests/e2e/components/behaviours.spec.ts`).

View file

@ -2,13 +2,15 @@
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
import { resolvePlayState } from '@open-pencil/core/editor'
import { sceneNodeToDesignDocument } from '@open-pencil/dom-css/export'
import {
behaviourControls,
controlRoles,
sceneNodeToDesignDocument
} from '@open-pencil/dom-css/export'
import { useEditor } from '#vue/editor/context'
import { islandControls } from './model'
import { renderNode } from './render'
import { islandRoles } from './roles'
import { useShadowContainer } from './shadow'
import { createIslandState, instanceStates } from './state'
@ -30,9 +32,9 @@ const container = useShadowContainer(useTemplateRef<HTMLElement>('host'), ISLAND
const controls = computed(() => {
void editor.state.sceneVersion
return islandControls(editor.graph, rootId)
return behaviourControls(editor.graph, rootId)
})
const roles = computed(() => islandRoles(controls.value))
const roles = computed(() => controlRoles(controls.value))
const state = shallowRef(createIslandState(controls.value))
watch(
() => [rootId, revision, [...controls.value.keys()].join('\n')],

View file

@ -6,11 +6,10 @@ import {
} from 'reka-ui'
import { h, type VNode } from 'vue'
import { booleanOf, type ControlModel, type ControlRole } from '@open-pencil/dom-css/export'
import { numberSettings } from '@open-pencil/scene-graph'
import { booleanOf, type ControlModel } from '#vue/canvas/islands/model'
import type { ElementOverride, IslandRenderContext } from '#vue/canvas/islands/render'
import type { IslandRole } from '#vue/canvas/islands/roles'
import { disabled, interaction, stateOf } from './shared'
@ -53,7 +52,7 @@ export function fieldRoot(island: IslandRenderContext, control: ControlModel, ba
/** The text layer of a field, as the input it is, or a number field's stepper part. */
export function fieldRole(
island: IslandRenderContext,
role: IslandRole,
role: ControlRole,
base: Base
): VNode | undefined {
if (role.type === 'part' && role.control.kind === 'numberField') {

View file

@ -28,12 +28,10 @@ import {
} from 'reka-ui'
import { h, type Component, type VNode } from 'vue'
import type { DesignElement } from '@open-pencil/dom-css/export'
import { groupOf, type ControlRole, type DesignElement } from '@open-pencil/dom-css/export'
import { numberSettings } from '@open-pencil/scene-graph'
import type { ElementOverride, IslandRenderContext } from '#vue/canvas/islands/render'
import type { IslandRole } from '#vue/canvas/islands/roles'
import { groupOf } from '#vue/canvas/islands/roles'
import { fieldRole, fieldRoot } from './fields'
import { chooseItem, disabled, interaction, stateOf, type RoleContext } from './shared'
@ -45,7 +43,7 @@ function asChild(component: Component, props: Record<string, unknown>, child: ()
const ON_OFF: Record<string, Component> = { switch: SwitchRoot, toggle: Toggle }
function root({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'root' }>>): VNode {
function root({ island, role, base }: RoleContext<Extract<ControlRole, { type: 'root' }>>): VNode {
const { control } = role
const state = stateOf(island, control)
const off = disabled(island, control)
@ -170,7 +168,7 @@ function groupRoot(
}
/** An item of a group: a radio, a toggle, or a collapsible inside an accordion. */
function item({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'item' }>>): VNode {
function item({ island, role, base }: RoleContext<Extract<ControlRole, { type: 'item' }>>): VNode {
const state = stateOf(island, role.control)
const value = String(role.index)
const pointer = interaction(state)
@ -187,7 +185,7 @@ const SLIDER_PARTS: Record<string, { component: Component; omit: readonly string
thumb: { component: SliderThumb, omit: ['left', 'transform'] }
}
function part({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'part' }>>): VNode {
function part({ island, role, base }: RoleContext<Extract<ControlRole, { type: 'part' }>>): VNode {
const { control } = role
const sliderPart = control.kind === 'slider' ? SLIDER_PARTS[role.part] : undefined
if (sliderPart) return asChild(sliderPart.component, {}, () => base({ omit: sliderPart.omit }))
@ -209,7 +207,7 @@ function part({ island, role, base }: RoleContext<Extract<IslandRole, { type: 'p
/** A collapsible's trigger and content, or an accordion item's when it is one. */
function disclosurePart(
island: IslandRenderContext,
role: Extract<IslandRole, { type: 'part' }>,
role: Extract<ControlRole, { type: 'part' }>,
base: RoleContext['base']
): VNode {
const inAccordion = groupOf(island.roles, role.control)?.kind === 'accordion'
@ -227,7 +225,7 @@ function disclosurePart(
/** Render a layer with the role it has for the controls of its island. */
export function wrapRole(
island: IslandRenderContext,
role: IslandRole,
role: ControlRole,
element: DesignElement,
base: (override?: ElementOverride) => VNode
): VNode {

View file

@ -1,14 +1,12 @@
import type { VNode } from 'vue'
import type { DesignElement } from '@open-pencil/dom-css/export'
import type { ControlModel, ControlRole, DesignElement } from '@open-pencil/dom-css/export'
import type { ControlModel } from '#vue/canvas/islands/model'
import type { ElementOverride, IslandRenderContext } from '#vue/canvas/islands/render'
import type { IslandRole } from '#vue/canvas/islands/roles'
import { isDisabled, type ControlState } from '#vue/canvas/islands/state'
/** What a role's wrapper gets: the island, the layer, and how to render it as designed. */
export interface RoleContext<R extends IslandRole = IslandRole> {
export interface RoleContext<R extends ControlRole = ControlRole> {
island: IslandRenderContext
role: R
element: DesignElement

View file

@ -1,12 +1,14 @@
import { h, type VNode } from 'vue'
import { layerPath } from '@open-pencil/core/editor'
import type { DesignElement, DesignNode } from '@open-pencil/dom-css/export'
import type { SceneGraph } from '@open-pencil/scene-graph'
import type {
ControlModel,
ControlRole,
DesignElement,
DesignNode
} from '@open-pencil/dom-css/export'
import { layerPath, type SceneGraph } from '@open-pencil/scene-graph'
import { wrapRole } from './controls'
import type { ControlModel } from './model'
import type { IslandRole } from './roles'
import type { IslandState } from './state'
export interface IslandRenderContext {
@ -14,7 +16,7 @@ export interface IslandRenderContext {
graph: SceneGraph
rootId: string
controls: ReadonlyMap<string, ControlModel>
roles: ReadonlyMap<string, IslandRole>
roles: ReadonlyMap<string, ControlRole>
state: IslandState
}

View file

@ -1,10 +1,9 @@
import { reactive } from 'vue'
import type { InstanceState } from '@open-pencil/core/editor'
import { booleanOf, type ControlModel } from '@open-pencil/dom-css/export'
import { numberSettings, type InteractionState } from '@open-pencil/scene-graph'
import { booleanOf, type ControlModel } from './model'
/** What one control of an island holds while it runs. */
export interface ControlState {
booleans: Partial<Record<string, boolean>>