From 1d60d3a407f228cf5a8caeafbcf8bec6d2732d3c Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Jul 2026 14:04:13 +0300 Subject: [PATCH] feat(ui): add binding field skins - Show one variable picker affordance with a quiet identity pill and accessible combobox - Keep bound NumberField focus non-destructive until the first value mutation - Prefer accessible and semantic test selectors over compound test IDs --- AGENTS.md | 3 +- CHANGELOG.md | 1 + packages/docs/development/contributing.md | 11 + .../sdk/api/components/bindable-value.md | 10 +- .../sdk/guides/property-panels.md | 18 ++ packages/vue/README.md | 3 +- .../NumberField/NumberFieldRoot.vue | 16 +- src/components/inputs/NumberField.vue | 51 +++-- .../properties/AppearanceSection.vue | 5 +- src/components/properties/PositionSection.vue | 6 + .../properties/VariableNumberField.vue | 40 ++-- .../binding/BindingField.stories.ts | 54 +++++ .../binding/VariableBindingPicker.vue | 208 ++++++++++++++++++ .../binding/demo/BindingFieldDemo.vue | 191 ++++++++++++++++ .../binding/demo/BindingFieldDemoItem.vue | 92 ++++++++ src/components/ui/SegmentedControl.vue | 11 +- src/components/ui/binding/BindingPill.vue | 53 +++++ src/components/ui/binding/BindingTrigger.vue | 69 ++++++ src/components/ui/binding/index.ts | 4 + src/components/ui/binding/ui.ts | 39 ++++ src/components/ui/panel/PanelSection.vue | 1 + src/theme/binding-field.ts | 69 ++++++ src/theme/panel/section.ts | 4 +- src/theme/segmented-control.ts | 8 +- tests/e2e/design/panel.spec.ts | 11 +- tests/e2e/editor/auto-layout/basic.spec.ts | 6 +- .../properties/corner-stroke-toggles.spec.ts | 8 +- tests/e2e/properties/number-field.spec.ts | 5 +- tests/e2e/properties/panel.spec.ts | 35 ++- tests/helpers/properties.ts | 9 + tools/architecture/src/steiger-rules/index.ts | 29 +++ 31 files changed, 978 insertions(+), 92 deletions(-) create mode 100644 src/components/properties/binding/BindingField.stories.ts create mode 100644 src/components/properties/binding/VariableBindingPicker.vue create mode 100644 src/components/properties/binding/demo/BindingFieldDemo.vue create mode 100644 src/components/properties/binding/demo/BindingFieldDemoItem.vue create mode 100644 src/components/ui/binding/BindingPill.vue create mode 100644 src/components/ui/binding/BindingTrigger.vue create mode 100644 src/components/ui/binding/index.ts create mode 100644 src/components/ui/binding/ui.ts create mode 100644 src/theme/binding-field.ts create mode 100644 tests/helpers/properties.ts diff --git a/AGENTS.md b/AGENTS.md index 29baf59b4..e05e7e99c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,7 +267,7 @@ Self-review checklist: - `src/components/Shell/**` is for app shell chrome and global app services rendered as components (menu bar, toast viewport, update/status chrome). Shell components may use app shell/editor stores. - `src/components/properties/**`, `src/components/chat/**`, `src/components/LayerTree/**`, `src/components/Toolbar/**`, and similar folders are feature/domain component namespaces. Keep feature-specific controls there unless they are genuinely reusable UI primitives. - Treat existing root-level picker/input/control components as migration candidates when touched; do not expand that pattern. -- Test hooks should be `data-test-id` attributes owned by the rendered markup or generated internally from semantic component state. Do not add `testId`, `visibilityTestId`, `triggerTestId`, or other test-id props to component APIs. +- Test locators follow Playwright's user-facing priority: role/name, label, and text first. Multi-part components expose scoped `data-slot` anatomy; app concepts use semantic attributes such as `data-property`, `data-command`, and `data-node-id` when accessible identity is insufficient. Reserve `data-test-id` for rare integration boundaries such as the canvas/editor host, never add `testId`/`testHook` props, and do not manufacture globally unique compound IDs inside shared components. - Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.) - Vue UI styling APIs follow the Nuxt UI architecture: static Tailwind Variants themes live under `src/theme/**` with `slots`, `variants`, `compoundVariants`, and `defaultVariants`; components resolve the theme with `tv()` and merge per-instance `ui` overrides at each rendered slot. Single-root components expose `class` rather than a one-slot `ui` object. Do not add one-off `fooClass`, `barClass`, `emptyActionClass`, etc. props. Use `UI` casing in type names (`SelectUI`, not `SelectUi`). @@ -287,6 +287,7 @@ Self-review checklist: - Mac keyboards: use `e.code` not `e.key` for shortcuts with modifiers (Option transforms characters) - Icons: use unplugin-icons with Iconify/Lucide (``) — don't use raw SVG or Unicode symbols - App menu (`src/components/Shell/AppMenu.vue`) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden when `IS_TAURI` is true +- Binding-aware fields must not mutate or detach on focus. Start detach/edit-variable transactions only on the first actual value mutation; opening the variable picker is also non-destructive. - Preserve established UI gotchas in nearby components before refactoring: splitter handle sizing, NumberField pointer ownership, section drag targets, side-panel containment, and global number-spinner styling. ## File format diff --git a/CHANGELOG.md b/CHANGELOG.md index 414822339..c3db85e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API. - Add provider-driven BindableValue primitives for variable and token binding, including detach-on-edit, read-only, edit-variable, mixed-value, and undo-batched interactions. - Add headless PropertySection, SegmentedControl, and typed PropertyList anatomy, with controlled list events and an undo-aware OpenPencil adapter. +- Refine variable-bound number fields with a quiet identity pill, one picker affordance, an accessible variable combobox, and non-destructive focus behavior. - Upgrade Vue SDK documentation with shared Tailwind demos, source-generated component API tables, and type-aware Twoslash examples in VitePress. - Add desktop image drag-and-drop into the Tauri app window. - Add open-document discovery for live CLI and MCP automation so agents can target the intended document and page. diff --git a/packages/docs/development/contributing.md b/packages/docs/development/contributing.md index bf428edf3..b11890595 100644 --- a/packages/docs/development/contributing.md +++ b/packages/docs/development/contributing.md @@ -64,6 +64,17 @@ bun run check - **Functions/variables** — camelCase - **Types/interfaces** — PascalCase +### Test selectors + +Playwright tests should locate behavior the way users and assistive technology do: prefer roles and +accessible names, labels, and visible text. Scope repeated controls to a named region. Multi-part UI +components expose local `data-slot` anatomy, while stable app concepts may expose semantic +attributes such as `data-property`, `data-command`, or `data-node-id`. + +Reserve `data-test-id` for integration boundaries that have no meaningful user-facing or domain +identity. Do not add test-ID props to reusable components or generate compound IDs from current +component nesting. + ### AI Agent Conventions Developers and AI agents working on the codebase should read `AGENTS.md` in the repo root ([view on GitHub](https://github.com/open-pencil/open-pencil/blob/master/AGENTS.md)). Covers rendering, scene graph, components & instances, layout, UI, file format, Tauri conventions, and known issues. diff --git a/packages/docs/programmable/sdk/api/components/bindable-value.md b/packages/docs/programmable/sdk/api/components/bindable-value.md index 8c3820e75..d9dac5fa6 100644 --- a/packages/docs/programmable/sdk/api/components/bindable-value.md +++ b/packages/docs/programmable/sdk/api/components/bindable-value.md @@ -24,12 +24,16 @@ automatically when nested beneath `BindableValueRoot`. ## Policies -- `detach-on-edit` unbinds targets and keeps the complete interaction in one provider undo batch. +- `detach-on-edit` unbinds targets on the first value mutation and keeps the complete interaction + in one provider undo batch. - `readonly-when-bound` blocks field editing, scrubbing, and keyboard stepping. - `edit-variable` sends changes to `provider.setValue()` instead of changing the target value. -Cancellation rolls back an open provider batch. Providers without undo support still receive -binding changes, with binding snapshots restored where possible. +Focusing a bound NumberField or opening its picker is non-destructive. The policy starts only when +the user types a changed draft, steps the value, or crosses the pointer-scrub threshold. Committing +an unchanged field creates no undo entry. Cancellation rolls back an open provider batch. +Providers without undo support still receive binding changes, with binding snapshots restored +where possible. ## Provider example diff --git a/packages/docs/programmable/sdk/guides/property-panels.md b/packages/docs/programmable/sdk/guides/property-panels.md index 9915119da..bc1a795b7 100644 --- a/packages/docs/programmable/sdk/guides/property-panels.md +++ b/packages/docs/programmable/sdk/guides/property-panels.md @@ -26,6 +26,24 @@ For list-style panels, use: - `useStrokeControls()` - `useEffectsControls()` +## Binding-aware fields + +Compose `BindableValueRoot` around fields that can reference variables or external design tokens. +The primitive is presentation-agnostic, but binding-aware interfaces should keep focus +non-destructive: + +- Show variable identity while the field is idle; expose the resolved value in supporting UI such + as a tooltip. +- Focusing or opening the picker must not detach a binding. +- Apply `detach-on-edit`, `readonly-when-bound`, or `edit-variable` only when the user actually + changes the value. +- Put explicit detach actions in the picker rather than on a destructive one-click field icon. +- Keep binding replacement, detach-on-edit, and multi-target changes in one provider batch. + +OpenPencil's app skin uses a violet variable-name pill at rest and reveals the resolved numeric +value when NumberField enters editing mode. Custom editor shells can present the same headless +state differently. + ## Example: position panel ```vue diff --git a/packages/vue/README.md b/packages/vue/README.md index 92838fbaf..b6eae7278 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -95,7 +95,8 @@ These components coordinate structure and state, but do not impose app styling. adds pointer scrubbing, Arrow-key stepping, mixed/bound state attributes, and safe arithmetic expressions such as `+10`, `*2`, `50%`, and `12*8+4`. `BindableValue` composes fields with a generic `BindingProvider` and supports detach-on-edit, read-only, and edit-variable policies. -`PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and +Focusing a bound NumberField is non-destructive; the configured policy begins only on the first +value mutation. `PropertyListRoot` is controlled and editor-agnostic; OpenPencil panels connect it to selection and undo through `useEditorPropertyList()`. ## Public API tiers diff --git a/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue b/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue index ee8902ab7..42042c415 100644 --- a/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue +++ b/packages/vue/src/primitives/NumberField/NumberFieldRoot.vue @@ -72,7 +72,7 @@ const ariaLabelValue = computed(() => ariaLabel) let interactionStartValue = 0 let interactionStartedMixed = false -let detachRequested = false +let mutationRequested = false let stopMove: (() => void) | undefined let stopUp: (() => void) | undefined let stopCancel: (() => void) | undefined @@ -84,17 +84,13 @@ function canMutate(): boolean { } function requestMutation(source: NumberFieldMutationSource): boolean { + if (mutationRequested) return true if (!canMutate()) return false if (binding && !binding.actions.beginMutation(source)) return false - if ( - !binding && - bound.value && - effectiveEditPolicy.value === 'detach-on-edit' && - !detachRequested - ) { - detachRequested = true + if (!binding && bound.value && effectiveEditPolicy.value === 'detach-on-edit') { emit('detach-request', source) } + mutationRequested = true return true } @@ -102,7 +98,7 @@ function beginInteraction() { interactionStartValue = numericValue.value interactionStartedMixed = isMixed.value workingValue.value = numericValue.value - detachRequested = false + mutationRequested = false invalidReason.value = null } @@ -134,7 +130,6 @@ function finishCommit(value: number) { function startEdit() { if (editing.value || !canMutate()) return beginInteraction() - if (!requestMutation('edit')) return draftValue.value = interactionStartedMixed ? '' : String(interactionStartValue) editing.value = true void nextTick(() => { @@ -144,6 +139,7 @@ function startEdit() { } function setDraft(value: string) { + if (value !== draftValue.value && !requestMutation('edit')) return draftValue.value = value const absoluteNumber = /^\s*(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?\s*$/i.test(value) if (absoluteNumber) updateValue(Number(value)) diff --git a/src/components/inputs/NumberField.vue b/src/components/inputs/NumberField.vue index 2f8b7d59c..94b80d227 100644 --- a/src/components/inputs/NumberField.vue +++ b/src/components/inputs/NumberField.vue @@ -1,5 +1,10 @@