feat(app): add color variable binding picker

This commit is contained in:
Danila Poyarkov 2026-05-01 17:11:50 +03:00
parent b248e0c53a
commit c7db8c6004
23 changed files with 752 additions and 128 deletions

View file

@ -10,6 +10,7 @@
- Use `@use-gesture/vanilla` for wheel gesture lifecycle handling and faster trackpad zoom behavior.
- Add auto-layout inspector controls for min/max dimensions, Auto gap distribution, wrap cross-axis gap, and two-axis padding controls.
- Add signed Tauri updater configuration, release artifacts, startup update checks, and a native Check for Updates menu item.
- Add inspector variable binding controls for fill and stroke colors, including existing variable selection and named color variable creation.
### Fixes
@ -26,6 +27,7 @@
- Keep the global startup loader visible until CanvasKit fonts load and the first font-backed render completes, avoiding a flash of missing text in opened files and the demo.
- Fix hot reload creating duplicate editor tabs during development.
- Improve layout inspector dropdown anchoring and icon clarity for spacing and padding controls.
- Fix bound color variable inspector swatches to display the resolved variable color and detach the binding when edited directly.
### Performance

View file

@ -1135,6 +1135,70 @@ const noComponentRootSiblingFolder = {
}
}
const noUselessPassThroughWrappers = {
meta: {
docs: {
description: 'Disallow functions that only return another function call with the same arguments'
}
},
create(context) {
function paramNames(params) {
const names = []
for (const param of params ?? []) {
if (param.type !== 'Identifier') return null
names.push(param.name)
}
return names
}
function returnedCall(body) {
if (!body) return null
if (body.type === 'CallExpression') return body
if (body.type !== 'BlockStatement') return null
const statements = body.body?.filter((statement) => statement.type !== 'EmptyStatement') ?? []
if (statements.length !== 1) return null
const statement = statements[0]
if (statement.type !== 'ReturnStatement') return null
return statement.argument?.type === 'CallExpression' ? statement.argument : null
}
function calleeName(callee) {
return callee?.type === 'Identifier' ? callee.name : null
}
function isSameArgumentForwarding(args, params) {
if (args?.length !== params.length) return false
return args.every((arg, index) => arg.type === 'Identifier' && arg.name === params[index])
}
function check(node, name, params, body) {
const names = paramNames(params)
if (!names) return
const call = returnedCall(body)
if (!call || !isSameArgumentForwarding(call.arguments, names)) return
const target = calleeName(call.callee)
if (!target || target === name) return
context.report({
node,
message: `Remove pass-through wrapper '${name}'. Call '${target}' directly or give the wrapper real domain logic.`
})
}
return {
FunctionDeclaration(node) {
if (!node.id?.name) return
check(node, node.id.name, node.params, node.body)
},
VariableDeclarator(node) {
if (node.id?.type !== 'Identifier') return
const init = node.init
if (!init || (init.type !== 'ArrowFunctionExpression' && init.type !== 'FunctionExpression')) return
check(node, node.id.name, init.params, init.body)
}
}
}
}
const noFunctionAliasImports = {
meta: {
docs: {
@ -1229,6 +1293,7 @@ const plugin = {
'component-namespace-pascal-case': componentNamespacePascalCase,
'non-component-source-directories-kebab-case': nonComponentSourceDirectoriesKebabCase,
'no-component-root-sibling-folder': noComponentRootSiblingFolder,
'no-useless-pass-through-wrappers': noUselessPassThroughWrappers,
'no-function-alias-imports': noFunctionAliasImports,
'no-flat-kiwi-modules': noFlatKiwiModules
}

View file

@ -116,6 +116,12 @@
"open-pencil/max-composition-root-lines": ["error", { "max": 260 }]
},
"overrides": [
{
"files": ["src/**/*.ts"],
"rules": {
"open-pencil/no-useless-pass-through-wrappers": "error"
}
},
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {

View file

@ -1,47 +1,67 @@
import { useEditor } from '#vue/editor/context'
import { useFilter } from 'reka-ui'
import { computed, ref } from 'vue'
import { useVariableBinding } from '#vue/controls/variable-binding/use'
import type { Variable } from '@open-pencil/core/scene-graph'
import { randomHex } from '@open-pencil/core/random'
const FALLBACK_COLOR_VARIABLE_NAME = 'New color'
import type { VariableCollection } from '@open-pencil/core/scene-graph'
import type { Color } from '@open-pencil/core/types'
type ColorBindingKind = 'fills' | 'strokes'
export function useColorVariableBinding(kind: ColorBindingKind) {
const store = useEditor()
const colorVariables = computed(() => store.getVariablesByType('COLOR'))
const searchTerm = ref('')
const { contains } = useFilter({ sensitivity: 'base' })
const filteredVariables = computed(() => {
if (!searchTerm.value) return colorVariables.value
return colorVariables.value.filter((v) => contains(v.name, searchTerm.value))
const binding = useVariableBinding({
type: 'COLOR',
path: (index) => `${kind}/${index}/color`
})
function bindingPath(index: number) {
return `${kind}/${index}/color`
function colorCollection(): VariableCollection {
const existing = binding.store
.getCollections()
.find((collection) =>
collection.variableIds.some(
(variableId) => binding.store.getVariable(variableId)?.type === 'COLOR'
)
)
if (existing) return existing
const collection: VariableCollection = {
id: `col:${randomHex(8)}`,
name: 'Colors',
modes: [{ modeId: 'default', name: 'Mode 1' }],
defaultModeId: 'default',
variableIds: []
}
binding.store.addCollection(collection)
return collection
}
function getBoundVariable(nodeId: string, index: number): Variable | undefined {
const n = store.getNode(nodeId)
if (!n) return undefined
const varId = n.boundVariables[bindingPath(index)]
return varId ? store.getVariable(varId) : undefined
}
function bindVariable(nodeId: string, index: number, variableId: string) {
store.bindVariable(nodeId, bindingPath(index), variableId)
}
function unbindVariable(nodeId: string, index: number) {
store.unbindVariable(nodeId, bindingPath(index))
function createAndBindVariable(
nodeId: string,
index: number,
color: Color,
name = FALLBACK_COLOR_VARIABLE_NAME
) {
const collection = colorCollection()
const id = `var:${randomHex(8)}`
binding.store.addVariable({
id,
name: name.trim() || FALLBACK_COLOR_VARIABLE_NAME,
type: 'COLOR',
collectionId: collection.id,
valuesByMode: Object.fromEntries(collection.modes.map((mode) => [mode.modeId, color])),
description: '',
hiddenFromPublishing: false
})
binding.bindVariable(nodeId, id, index)
}
return {
store,
colorVariables,
searchTerm,
filteredVariables,
getBoundVariable,
bindVariable,
unbindVariable
...binding,
colorVariables: binding.variables,
bindVariable: (nodeId: string, index: number, variableId: string) =>
binding.bindVariable(nodeId, variableId, index),
unbindVariable: (nodeId: string, index: number) => binding.unbindVariable(nodeId, index),
createAndBindVariable
}
}

View file

@ -0,0 +1,67 @@
import { useEditor } from '#vue/editor/context'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
import { useFilter } from 'reka-ui'
import { computed, ref } from 'vue'
import type { Variable, VariableType } from '@open-pencil/core/scene-graph'
export type VariableBindingState = 'unbound' | 'bound' | 'mixed'
export interface UseVariableBindingOptions {
type: VariableType
path: string | ((index: number) => string)
}
export function useVariableBinding(options: UseVariableBindingOptions) {
const store = useEditor()
const searchTerm = ref('')
const variables = useSceneComputed(() => store.getVariablesByType(options.type))
const { contains } = useFilter({ sensitivity: 'base' })
const filteredVariables = computed(() => {
if (!searchTerm.value) return variables.value
return variables.value.filter((variable) => contains(variable.name, searchTerm.value))
})
function bindingPath(index?: number) {
if (typeof options.path === 'string') return options.path
return options.path(index ?? 0)
}
function getBoundVariable(nodeId: string, index?: number): Variable | undefined {
const node = store.getNode(nodeId)
if (!node) return undefined
const variableId = node.boundVariables[bindingPath(index)]
return variableId ? store.getVariable(variableId) : undefined
}
function getBindingState(nodeIds: string[], index?: number): VariableBindingState {
const variableIds = new Set<string | undefined>()
for (const nodeId of nodeIds) {
const node = store.getNode(nodeId)
variableIds.add(node?.boundVariables[bindingPath(index)])
}
if (variableIds.size > 1) return 'mixed'
return variableIds.has(undefined) ? 'unbound' : 'bound'
}
function bindVariable(nodeId: string, variableId: string, index?: number) {
store.bindVariable(nodeId, bindingPath(index), variableId)
}
function unbindVariable(nodeId: string, index?: number) {
store.unbindVariable(nodeId, bindingPath(index))
}
return {
store,
searchTerm,
variables,
filteredVariables,
bindingPath,
getBoundVariable,
getBindingState,
bindVariable,
unbindVariable
}
}

View file

@ -171,7 +171,10 @@ export const panelMessages = i18n('panels', {
strokeAlignOutside: 'Outside',
exportPreview: 'Preview',
exportRenderingPreview: 'Rendering preview…',
create: 'Create',
createVariable: 'Create variable',
createColorVariable: params('Create color variable from {value}'),
variableName: 'Variable name',
mixed: 'Mixed',
layersCount: params('{count} layers'),
goToMainComponent: 'Go to Main Component',

View file

@ -55,6 +55,11 @@ export { useExport } from '#vue/document/export/use'
export type { ExportFormatId } from '#vue/document/export/use'
export { useFillControls } from '#vue/controls/fill/use'
export { useColorVariableBinding } from '#vue/controls/color-variable-binding/use'
export { useVariableBinding } from '#vue/controls/variable-binding/use'
export type {
VariableBindingState,
UseVariableBindingOptions
} from '#vue/controls/variable-binding/use'
export { useEffectsControls } from '#vue/controls/effects/use'
export { useStrokeControls } from '#vue/controls/stroke/use'
export { useOkHCL } from '#vue/controls/okhcl/use'

View file

@ -169,7 +169,10 @@
"strokeAlignOutside": "Außen",
"exportPreview": "Vorschau",
"exportRenderingPreview": "Vorschau wird gerendert…",
"createVariable": "Variable erstellen"
"createVariable": "Variable erstellen",
"createColorVariable": "Farbvariable aus {value} erstellen",
"variableName": "Variablenname",
"create": "Erstellen"
},
"pages": {
"newPage": "Neue Seite",

View file

@ -169,7 +169,10 @@
"strokeAlignOutside": "Exterior",
"exportPreview": "Vista previa",
"exportRenderingPreview": "Renderizando vista previa…",
"createVariable": "Crear variable"
"createVariable": "Crear variable",
"createColorVariable": "Crear variable de color desde {value}",
"variableName": "Nombre de variable",
"create": "Crear"
},
"pages": {
"newPage": "Nueva página",

View file

@ -169,7 +169,10 @@
"strokeAlignOutside": "Extérieur",
"exportPreview": "Aperçu",
"exportRenderingPreview": "Rendu de laperçu…",
"createVariable": "Créer une variable"
"createVariable": "Créer une variable",
"createColorVariable": "Créer une variable de couleur depuis {value}",
"variableName": "Nom de la variable",
"create": "Créer"
},
"pages": {
"newPage": "Nouvelle page",

View file

@ -169,7 +169,10 @@
"strokeAlignOutside": "Esterno",
"exportPreview": "Anteprima",
"exportRenderingPreview": "Rendering anteprima…",
"createVariable": "Crea variabile"
"createVariable": "Crea variabile",
"createColorVariable": "Crea variabile colore da {value}",
"variableName": "Nome variabile",
"create": "Crea"
},
"pages": {
"newPage": "Nuova pagina",

View file

@ -169,7 +169,10 @@
"strokeAlignOutside": "Zewnętrzny",
"exportPreview": "Podgląd",
"exportRenderingPreview": "Renderowanie podglądu…",
"createVariable": "Utwórz zmienną"
"createVariable": "Utwórz zmienną",
"createColorVariable": "Utwórz zmienną koloru z {value}",
"variableName": "Nazwa zmiennej",
"create": "Utwórz"
},
"pages": {
"newPage": "Nowa strona",

View file

@ -169,7 +169,10 @@
"strokeAlignOutside": "Снаружи",
"exportPreview": "Предпросмотр",
"exportRenderingPreview": "Рендеринг предпросмотра…",
"createVariable": "Создать переменную"
"createVariable": "Создать переменную",
"createColorVariable": "Создать переменную цвета из {value}",
"variableName": "Имя переменной",
"create": "Создать"
},
"pages": {
"newPage": "Новая страница",

View file

@ -169,7 +169,10 @@
"strokeAlignOutside": "外部",
"exportPreview": "预览",
"exportRenderingPreview": "正在渲染预览…",
"createVariable": "创建变量"
"createVariable": "创建变量",
"createColorVariable": "从 {value} 创建颜色变量",
"variableName": "变量名称",
"create": "创建"
},
"pages": {
"newPage": "新建页面",

View file

@ -22,7 +22,15 @@ function tabClass(active: boolean) {
)
}
const { fill, okhcl = null } = defineProps<{ fill: Fill; okhcl?: OkHCLControls | null }>()
const {
fill,
okhcl = null,
swatchBackground
} = defineProps<{
fill: Fill
okhcl?: OkHCLControls | null
swatchBackground?: string
}>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const cls = usePopoverUI({ content: 'w-60 p-2' })
const { panels } = useI18n()
@ -39,7 +47,7 @@ const { panels } = useI18n()
<button
data-test-id="fill-picker-swatch"
class="size-5 shrink-0 cursor-pointer rounded border border-border p-0"
:style="style"
:style="{ ...style, background: swatchBackground ?? style.background }"
/>
</template>
<template #default="{ fill: currentFill, category, toSolid, toGradient, toImage, update }">

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import Tip from '@/components/ui/Tip.vue'
const { label, testId } = defineProps<{
label: string
testId?: string
}>()
const emit = defineEmits<{
detach: []
}>()
</script>
<template>
<Tip :label="label">
<button
:data-test-id="testId"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
@click="emit('detach')"
>
<icon-lucide-diamond-minus class="size-3.5" />
</button>
</Tip>
</template>

View file

@ -1,21 +1,9 @@
<script setup lang="ts">
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import ScrubInput from '@/components/ScrubInput.vue'
import Tip from '@/components/ui/Tip.vue'
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
import { useIconButtonUI } from '@/components/ui/icon-button'
import { ref } from 'vue'
import { useI18n } from '@open-pencil/vue'
import {
@ -23,17 +11,29 @@ import {
opacityPercent,
variableSwatchBackground
} from '@/components/properties/color-style-row'
import { colorToHexRaw } from '@open-pencil/core/color'
import type { ColorVariableBindingApi } from '@/components/properties/color-style-row'
import type { Variable } from '@open-pencil/core/scene-graph'
import type { Color } from '@open-pencil/core/types'
const { item, index, activeNodeId, bindingApi, visibilityTestId, unbindTestId } = defineProps<{
const {
item,
index,
activeNodeId,
bindingApi,
visibilityTestId,
applyVariableTestId,
unbindTestId,
variableColor
} = defineProps<{
item: { opacity: number; visible: boolean }
index: number
activeNodeId?: string | null
bindingApi: ColorVariableBindingApi
visibilityTestId: string
applyVariableTestId?: string
unbindTestId?: string
variableColor?: Color
}>()
const emit = defineEmits<{
@ -43,7 +43,6 @@ const emit = defineEmits<{
}>()
const { panels, dialogs } = useI18n()
const varPopoverOpen = ref(false)
</script>
<template>
@ -61,72 +60,43 @@ const varPopoverOpen = ref(false)
@update:model-value="emit('patch', { opacity: opacityFromPercent($event) })"
/>
<PopoverRoot
<VariablePickerPopover
v-if="
activeNodeId &&
bindingApi.colorVariables.value.length > 0 &&
(bindingApi.colorVariables.value.length > 0 ||
(variableColor && bindingApi.createAndBindVariable)) &&
!bindingApi.getBoundVariable(activeNodeId, index)
"
@update:open="varPopoverOpen = $event"
>
<Tip :label="panels.applyVariable" :disabled="varPopoverOpen">
<PopoverTrigger
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
>
<icon-lucide-link class="size-3.5" />
</PopoverTrigger>
</Tip>
<PopoverPortal>
<PopoverContent
side="left"
:side-offset="8"
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
>
<ComboboxRoot
@update:model-value="
activeNodeId && bindingApi.bindVariable(activeNodeId, index, ($event as Variable).id)
v-model:search-term="bindingApi.searchTerm.value"
:variables="bindingApi.filteredVariables.value"
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="applyVariableTestId"
:create-label="
variableColor && bindingApi.createAndBindVariable
? panels.createColorVariable({ value: colorToHexRaw(variableColor) })
: undefined
"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-default-name="bindingApi.searchTerm.value"
:create-test-id="applyVariableTestId ? `${applyVariableTestId}-create` : undefined"
:swatch-background="(variableId) => variableSwatchBackground(bindingApi, variableId)"
@select="activeNodeId && bindingApi.bindVariable(activeNodeId, index, $event.id)"
@create="
activeNodeId &&
variableColor &&
bindingApi.createAndBindVariable?.(activeNodeId, index, variableColor, $event)
"
>
<ComboboxInput
:model-value="bindingApi.searchTerm.value"
:placeholder="dialogs.search"
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
@update:model-value="bindingApi.searchTerm.value = String($event)"
/>
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
<ComboboxEmpty class="px-2 py-3 text-center text-[11px] text-muted">{{
panels.noVariablesFound
}}</ComboboxEmpty>
<ComboboxItem
v-for="v in bindingApi.filteredVariables.value"
:key="v.id"
:value="v"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
>
<div
class="size-3 shrink-0 rounded-sm border border-border"
:style="{ background: variableSwatchBackground(bindingApi, v.id) }"
/>
<span class="min-w-0 flex-1 truncate">{{ v.name }}</span>
</ComboboxItem>
</ComboboxContent>
</ComboboxRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
<Tip
<BoundVariableButton
v-else-if="activeNodeId && bindingApi.getBoundVariable(activeNodeId, index)"
:test-id="unbindTestId"
:label="panels.detachVariable"
>
<button
:data-test-id="unbindTestId"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
@click="bindingApi.unbindVariable(activeNodeId, index)"
>
<icon-lucide-unlink class="size-3" />
</button>
</Tip>
@detach="bindingApi.unbindVariable(activeNodeId, index)"
/>
<button
:data-test-id="visibilityTestId"

View file

@ -3,17 +3,33 @@ import { PropertyListRoot, useFillControls, useOkHCL, useI18n } from '@open-penc
import FillPicker from '@/components/FillPicker.vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
import {
boundVariableSwatchBackground,
displayFillWithBoundVariable
} from '@/components/properties/color-style-row'
import { fillLabel } from '@/components/properties/fill-label'
import { createFillOkhclAdapter } from '@/components/properties/fill-okhcl'
import { useIconButtonUI } from '@/components/ui/icon-button'
import { useSectionUI } from '@/components/ui/section'
import type { Fill } from '@open-pencil/core/scene-graph'
import type { Fill, SceneNode } from '@open-pencil/core/scene-graph'
const fillCtx = useFillControls()
const okhcl = useOkHCL()
const { panels } = useI18n()
const sectionCls = useSectionUI()
function updateFill(
activeNode: SceneNode | null | undefined,
index: number,
fill: Fill,
update: (index: number, fill: Fill) => void
) {
if (activeNode && fillCtx.getBoundVariable(activeNode.id, index)) {
fillCtx.unbindVariable(activeNode.id, index)
}
update(index, fill)
}
</script>
<template>
@ -41,7 +57,9 @@ const sectionCls = useSectionUI()
:index="i"
:active-node-id="activeNode?.id ?? null"
:binding-api="fillCtx"
:variable-color="fill.type === 'SOLID' ? fill.color : undefined"
:visibility-test-id="`fill-visibility-${i}`"
:apply-variable-test-id="`fill-apply-variable-${i}`"
unbind-test-id="fill-unbind-variable"
data-test-id="fill-item"
:data-test-index="i"
@ -50,9 +68,12 @@ const sectionCls = useSectionUI()
@remove="remove(i)"
>
<FillPicker
:fill="fill"
:fill="activeNode ? displayFillWithBoundVariable(fillCtx, activeNode.id, i, fill) : fill"
:okhcl="createFillOkhclAdapter(okhcl, activeNode, i)"
@update="update(i, $event)"
:swatch-background="
activeNode ? boundVariableSwatchBackground(fillCtx, activeNode.id, i) : undefined
"
@update="updateFill(activeNode, i, $event, update)"
/>
<span

View file

@ -11,6 +11,7 @@ import {
} from '@open-pencil/vue'
import ColorStyleRow from '@/components/properties/ColorStyleRow.vue'
import { boundVariableColor } from '@/components/properties/color-style-row'
import AppSelect from '@/components/ui/AppSelect.vue'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
import ScrubInput from '@/components/ScrubInput.vue'
@ -18,7 +19,7 @@ import Tip from '@/components/ui/Tip.vue'
import { useIconButtonUI } from '@/components/ui/icon-button'
import { useSectionUI } from '@/components/ui/section'
import type { SceneNode, Stroke } from '@open-pencil/core/scene-graph'
import type { Color, SceneNode, Stroke } from '@open-pencil/core/scene-graph'
const strokeCtx = useStrokeControls()
const strokeVarCtx = useColorVariableBinding('strokes')
@ -28,6 +29,18 @@ const sectionCls = useSectionUI()
const expandedSides = ref(false)
function updateStrokeColor(
activeNode: SceneNode | null | undefined,
index: number,
color: Color,
patch: (index: number, changes: Record<string, unknown>) => void
) {
if (activeNode && strokeVarCtx.getBoundVariable(activeNode.id, index)) {
strokeVarCtx.unbindVariable(activeNode.id, index)
}
patch(index, applySolidStrokeColor(color))
}
function onToggleSides(activeNode: SceneNode) {
const next = !expandedSides.value
expandedSides.value = next
@ -73,7 +86,9 @@ function onToggleSides(activeNode: SceneNode) {
:index="i"
:active-node-id="activeNode?.id ?? null"
:binding-api="strokeVarCtx"
:variable-color="stroke.color"
:visibility-test-id="`stroke-visibility-${i}`"
:apply-variable-test-id="`stroke-apply-variable-${i}`"
unbind-test-id="stroke-unbind-variable"
data-test-id="stroke-item"
:data-test-index="i"
@ -83,7 +98,11 @@ function onToggleSides(activeNode: SceneNode) {
>
<ColorInput
class="min-w-0 flex-1"
:color="stroke.color"
:color="
activeNode
? (boundVariableColor(strokeVarCtx, activeNode.id, i) ?? stroke.color)
: stroke.color
"
:okhcl="
activeNode
? {
@ -97,7 +116,7 @@ function onToggleSides(activeNode: SceneNode) {
: null
"
editable
@update="patch(i, applySolidStrokeColor($event))"
@update="updateStrokeColor(activeNode, i, $event, patch)"
/>
</ColorStyleRow>

View file

@ -0,0 +1,168 @@
<script setup lang="ts">
import {
ComboboxContent,
ComboboxInput,
ComboboxItem,
ComboboxRoot,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import type { Variable } from '@open-pencil/core/scene-graph'
const searchTerm = defineModel<string>('searchTerm', { default: '' })
const {
variables,
triggerLabel,
searchPlaceholder,
emptyLabel,
createLabel,
createNamePlaceholder = 'Variable name',
createSubmitLabel = 'Create',
createDefaultName = '',
createTestId,
triggerTestId,
swatchBackground
} = defineProps<{
variables: Variable[]
triggerLabel: string
searchPlaceholder: string
emptyLabel: string
createLabel?: string
createNamePlaceholder?: string
createSubmitLabel?: string
createDefaultName?: string
createTestId?: string
triggerTestId?: string
swatchBackground?: (variableId: string) => string
}>()
const emit = defineEmits<{
select: [variable: Variable]
create: [name: string]
}>()
const open = ref(false)
const creating = ref(false)
const createName = ref('')
const createInput = ref<HTMLInputElement | null>(null)
const canCreate = computed(() => createName.value.trim().length > 0)
watch(open, (value) => {
if (!value) creating.value = false
})
function startCreate() {
creating.value = true
createName.value = createDefaultName
void nextTick(() => {
createInput.value?.focus()
createInput.value?.select()
})
}
function submitCreate() {
const name = createName.value.trim()
if (!name) return
emit('create', name)
open.value = false
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger
:data-test-id="triggerTestId"
:aria-label="triggerLabel"
:title="triggerLabel"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
>
<icon-lucide-diamond-plus class="size-3.5" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="left"
align="center"
:side-offset="8"
:collision-padding="8"
class="z-50 w-56 rounded-lg border border-border bg-panel shadow-lg"
>
<ComboboxRoot
:open="true"
:ignore-filter="true"
@update:model-value="
($event) => {
if ($event) {
emit('select', $event as Variable)
open = false
}
}
"
>
<ComboboxInput
v-model="searchTerm"
:placeholder="searchPlaceholder"
class="w-full border-b border-border bg-transparent px-2 py-1.5 text-[11px] text-surface outline-none placeholder:text-muted"
/>
<ComboboxContent class="max-h-48 overflow-y-auto p-1">
<div v-if="variables.length === 0" class="px-2 py-3 text-center text-[11px] text-muted">
{{ emptyLabel }}
</div>
<ComboboxItem
v-for="variable in variables"
:key="variable.id"
:value="variable"
:text-value="variable.name"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-[11px] text-surface data-[highlighted]:bg-hover"
>
<div
v-if="swatchBackground"
class="size-3 shrink-0 rounded-sm border border-border"
:style="{ background: swatchBackground(variable.id) }"
/>
<icon-lucide-diamond v-else class="size-3 shrink-0 text-violet-400" />
<span class="min-w-0 flex-1 truncate">{{ variable.name }}</span>
</ComboboxItem>
</ComboboxContent>
<div v-if="createLabel" class="border-t border-border">
<form
v-if="creating"
class="flex items-center gap-1.5 p-1.5"
@submit.prevent="submitCreate"
@keydown.esc.prevent="creating = false"
>
<input
ref="createInput"
v-model="createName"
:placeholder="createNamePlaceholder"
class="min-w-0 flex-1 rounded border border-border bg-transparent px-1.5 py-1 text-[11px] text-surface outline-none placeholder:text-muted focus:border-accent"
/>
<button
:data-test-id="createTestId"
:disabled="!canCreate"
class="rounded border border-border bg-panel px-1.5 py-1 text-[11px] text-surface hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50"
type="submit"
>
{{ createSubmitLabel }}
</button>
</form>
<button
v-else
:data-test-id="createTestId"
class="flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-2 py-1.5 text-left text-[11px] text-muted hover:bg-hover hover:text-surface"
@click="startCreate"
>
<icon-lucide-plus class="size-3" />
<span class="min-w-0 flex-1 truncate">{{ createLabel }}</span>
</button>
</div>
</ComboboxRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View file

@ -1,6 +1,6 @@
import { colorToCSS } from '@open-pencil/core/color'
import type { Color, Variable } from '@open-pencil/core/scene-graph'
import type { Color, Fill, Variable } from '@open-pencil/core/scene-graph'
export type ColorVariableBindingApi = {
store: {
@ -12,6 +12,7 @@ export type ColorVariableBindingApi = {
getBoundVariable: (nodeId: string, index: number) => Variable | undefined
bindVariable: (nodeId: string, index: number, variableId: string) => void
unbindVariable: (nodeId: string, index: number) => void
createAndBindVariable?: (nodeId: string, index: number, color: Color, name?: string) => void
}
export function opacityPercent(opacity: number) {
@ -22,7 +23,52 @@ export function opacityFromPercent(percent: number) {
return Math.max(0, Math.min(1, percent / 100))
}
export function variableSwatchBackground(bindingApi: ColorVariableBindingApi, variableId: string) {
const color = bindingApi.store.resolveColorVariable(variableId) as Color | null
return color ? colorToCSS(color) : '#000'
function isColor(value: unknown): value is Color {
return (
typeof value === 'object' &&
value !== null &&
'r' in value &&
'g' in value &&
'b' in value &&
'a' in value &&
typeof value.r === 'number' &&
typeof value.g === 'number' &&
typeof value.b === 'number' &&
typeof value.a === 'number'
)
}
export function variableSwatchBackground(bindingApi: ColorVariableBindingApi, variableId: string) {
const color = bindingApi.store.resolveColorVariable(variableId)
return isColor(color) ? colorToCSS(color) : 'transparent'
}
export function boundVariableColor(
bindingApi: ColorVariableBindingApi,
nodeId: string,
index: number
): Color | undefined {
const variable = bindingApi.getBoundVariable(nodeId, index)
if (!variable) return undefined
const color = bindingApi.store.resolveColorVariable(variable.id)
return isColor(color) ? color : undefined
}
export function boundVariableSwatchBackground(
bindingApi: ColorVariableBindingApi,
nodeId: string,
index: number
): string | undefined {
const color = boundVariableColor(bindingApi, nodeId, index)
return color ? colorToCSS(color) : undefined
}
export function displayFillWithBoundVariable(
bindingApi: ColorVariableBindingApi,
nodeId: string,
index: number,
fill: Fill
): Fill {
const color = fill.type === 'SOLID' ? boundVariableColor(bindingApi, nodeId, index) : undefined
return color ? { ...fill, color } : fill
}

View file

@ -106,6 +106,71 @@ test('variable bind badge appears on fill', async () => {
canvas.assertNoErrors()
})
test('fill color can bind an existing variable', async () => {
await canvas.clearCanvas()
await canvas.drawRect(200, 200, 80, 80)
const variableId = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const col = store.graph.createCollection('Colors')
const variable = store.graph.createVariable('test-brand-red', 'COLOR', col.id, {
r: 1,
g: 0,
b: 0,
a: 1
})
store.state.sceneVersion++
return variable.id
})
await canvas.waitForRender()
await page.locator('[data-test-id="fill-apply-variable-0"]').click()
await page.getByText('test-brand-red', { exact: true }).click()
await canvas.waitForRender()
await expect(page.locator('[data-test-id="fill-unbind-variable"]')).toBeVisible()
const fillSwatch = page.locator('[data-test-id="fill-picker-swatch"]')
await expect(fillSwatch).toHaveCSS('background-color', 'rgb(255, 0, 0)')
await fillSwatch.click()
const colorInputs = page.locator('[role="dialog"] input[type="number"]:not(.hidden)')
await expect(colorInputs.first()).toHaveValue('255')
await colorInputs.first().fill('0')
await colorInputs.first().press('Enter')
await canvas.waitForRender()
await expect(page.locator('[data-test-id="fill-unbind-variable"]')).toBeHidden()
const boundVariableId = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const id = [...store.state.selectedIds][0]
return id ? (store.getNode(id)?.boundVariables['fills/0/color'] ?? null) : null
})
expect(boundVariableId).toBeNull()
canvas.assertNoErrors()
})
test('fill color can create and bind a variable', async () => {
await canvas.clearCanvas()
await canvas.drawRect(200, 200, 80, 80)
await page.locator('[data-test-id="fill-apply-variable-0"]').click()
await expect(page.getByText(/Create color variable from #?[0-9A-F]{6}/)).toBeVisible()
await page.locator('[data-test-id="fill-apply-variable-0-create"]').click()
await page.getByPlaceholder('Variable name').fill('Surface/default')
await page.locator('[data-test-id="fill-apply-variable-0-create"]').click()
await canvas.waitForRender()
await expect(page.locator('[data-test-id="fill-unbind-variable"]')).toBeVisible()
const boundVariable = await page.evaluate(() => {
const store = window.__OPEN_PENCIL_STORE__!
const id = [...store.state.selectedIds][0]
if (!id) return null
const node = store.getNode(id)
const variableId = node?.boundVariables['fills/0/color']
return variableId ? store.getVariable(variableId)?.name : null
})
expect(boundVariable).toBe('Surface/default')
canvas.assertNoErrors()
})
test('alignment buttons align nodes to same X', async () => {
await canvas.clearCanvas()
await canvas.drawRect(50, 200, 60, 60)

114
variables-ui-roadmap.md Normal file
View file

@ -0,0 +1,114 @@
# Variables UI Roadmap
OpenPencil already has local variable data structures and a basic variables dialog. The next work should make variables usable from the inspector fields where users edit real design properties, then expand the management UI.
## Product model
Variables should feel like a design-system layer:
- Variables are grouped into collections.
- Collections contain modes.
- Variables may be grouped by slash paths, e.g. `color/text/primary`.
- Variables can be bound to node/page properties.
- Bound properties resolve through the active mode context.
Supported variable types:
- Color — fills, strokes, text color, effect colors, page background.
- Number — dimensions, layout spacing, radius, opacity, stroke width, typography sizes.
- String — text content and eventually font names.
- Boolean — visibility and eventually boolean design state.
## UI principles
- Existing inspector fields must support variable binding directly; a standalone variables table is not enough.
- Every bindable field should have the same three states:
- Direct value with an apply-variable affordance.
- Bound value with variable name and resolved preview.
- Mixed value/binding for multi-selection.
- Variable pickers should be filtered by type and scope.
- Variable names should stay readable in cramped inspector rows.
- Warning/error states should be copyable when they carry diagnostic text.
## Phase 1 — Field-level binding primitives
Create reusable UI for applying variables to existing fields.
- Add a shared variable picker popover.
- Add a bound-variable pill/button pattern.
- Replace the current one-off fill/stroke color variable popover with the shared primitive.
- Keep current core path convention for color bindings: `fills/{index}/color`, `strokes/{index}/color`.
- Preserve existing fill/stroke behavior and tests.
Initial target fields:
- Fill color rows.
- Stroke color rows.
## Phase 2 — Number bindings in the inspector
Add reusable number-variable binding support and integrate the high-impact fields first:
- Width and height.
- Min/max width and height.
- Corner radius and independent radii.
- Auto-layout gap and padding.
- Stroke width.
- Opacity.
- Font size, line height, and letter spacing.
## Phase 3 — Variables management view
Improve the variables editor itself after field binding patterns are established.
- Make the variables dialog larger, closer to an edge-to-edge variables view.
- Replace collection tabs with a collection sidebar.
- Add a variable type picker for `+ Variable`.
- Render slash-path groups as grouped rows.
- Improve type-specific value cells.
- Add clearer empty states.
## Phase 4 — Mode management
Make modes editable as first-class collection columns.
- Add mode.
- Rename mode.
- Duplicate mode.
- Delete mode.
- Reorder mode.
- Treat the left-most mode as the default mode, matching Figmas model.
- Add undo/redo for mode operations.
## Phase 5 — Mode context
Allow pages/frames/layers to choose variable modes.
Resolution order should be:
1. Explicit mode on the node for the collection.
2. Parent-chain explicit mode.
3. Page explicit mode.
4. Collection default mode.
Inspector UI:
- Page variables section: collection → selected mode.
- Selected frame/layer Appearance section: collection → Auto/Default/specific mode.
## Phase 6 — Aliases and token workflows
- Add alias editing in value cells.
- Filter alias picker by same variable type.
- Show resolved value previews.
- Prevent alias cycles.
- Add DTCG token import/export later.
## Non-goals for the first implementation
- Remote libraries.
- Publishing workflows.
- Extended collections.
- Prototype variable actions.
- Expressions.
- Team/workspace default modes.