14 KiB
UI parity and hardening plan
OpenPencil's headless editor architecture is moving in the right direction: the Vue SDK exposes renderless primitives and composables, while the app shell owns product-specific presentation. The current app UI is still MVP-grade. This plan turns the existing shell into a Figma-grade, maintainable interface without weakening the headless SDK boundary.
Goals
- Preserve the headless SDK model: controls and editor logic live in
packages/vue/src/**; the app shell renders opinionated OpenPencil UI insrc/components/**. - Replace ad-hoc panel markup with reusable UI primitives so future feature work is fast and consistent.
- Make large documents feel production-grade, especially the Layers panel.
- Close high-impact Figma panel gaps: mixed values, constraints, stroke caps/joins, corner smoothing, shared styles, component properties, and richer typography.
- Add guardrails so UI quality does not regress.
Current assessment
Strong foundation
packages/vue/src/primitives/**already follows a useful renderless-root pattern similar to Reka UI.packages/vue/src/controls/**keeps much property editing logic outside app components.- App components use
data-test-idconsistently enough to support reliable regression tests. - The menu and command registry direction is healthy and should remain the canonical source for shortcuts and command behavior.
Main gaps
src/components/ui/**is mostly class factories, not a complete UI kit. App sections repeatedly hand-build icon buttons, panel rows, toggle groups, and section headers.- Several templates call
use*UI()helpers inline, which recomputes variants during render and spreads design-system knowledge into many files. - Some components still use raw inline SVG icons, which creates inconsistent visual language and violates the project convention.
PropertyListRooterases item types, causing casts and non-null assertions in property sections.LayerTreeRootrebuilds and remounts the whole tree on every scene mutation viasceneVersionandtreeKey, which will not scale to real.figdocuments.- Multi-select mixed values are not a first-class editing state across the panel.
- Important Figma-like controls are missing or incomplete in the UI even when the scene graph or renderer already supports the data.
Phase 1: Build the panel UI kit
Purpose: Create shared components for the repeated panel patterns before adding new features.
Files to add or refactor
src/components/ui/IconButton.vuesrc/components/ui/SegmentedControl.vuesrc/components/ui/PanelSection.vuesrc/components/ui/PanelRow.vuesrc/components/ui/panel.tsfor slot variants and shared panel class composition- Optional icon namespace under
src/components/icons/or an Iconify custom collection for OpenPencil-specific micro-icons
Work items
- Add
IconButton.vuethat owns tooltip wrapping, icon rendering, size variants, pressed/active state, disabled state, and test-id forwarding. - Add
SegmentedControl.vuefor small enumerations currently rendered as repeated toggle buttons or dropdowns. - Add
PanelSection.vuefor the standard section wrapper, label, optional add button, optional collapse toggle, and action slot. - Add
PanelRow.vuefor fixed-density property rows and common two-column input layout. - Replace repeated button markup in:
src/components/properties/PositionSection.vuesrc/components/properties/StrokeSection.vuesrc/components/properties/EffectsSection.vuesrc/components/properties/ExportSection.vuesrc/components/properties/FillSection.vuesrc/components/properties/AppearanceSection.vuesrc/components/AssetsPanel.vuesrc/components/AppMenu.vue
- Replace raw inline SVGs in app templates with icon components.
- Hoist all
use*UI()calls out of templates into<script setup>constants/computed values, or hide them inside the new UI components.
Acceptance criteria
rg 'use\w+UI\(' src/components --glob '*.vue'has no matches inside template blocks.rg '<svg' src/components --glob '*.vue'returns zero, except for explicitly approved third-party content rendering.- Position, Stroke, Effects, Export, Fill, and Appearance sections use
PanelSectionandPanelRow. - Toggle-button visual states are implemented once through
SegmentedControlorIconButtonvariants.
Phase 2: Fix typed property-list boundaries
Purpose: Remove template casts and non-null assertions by making renderless primitives expose precise slot types.
Files to refactor
packages/vue/src/primitives/PropertyList/PropertyListRoot.vuepackages/vue/src/primitives/PropertyList/context.tspackages/vue/src/controls/stroke/helpers.tssrc/components/properties/StrokeSection.vuesrc/components/properties/EffectsSection.vuesrc/components/properties/FillSection.vuesrc/components/properties/LayoutSection/SizeControls.vue
Work items
- Make
PropertyListRootgeneric over the array prop key (fills,strokes,effects) so slot props exposeFill[],Stroke[], orEffect[]exactly. - Use
defineSlotsto document the slot contract and preserve type information in consuming templates. - Change the slot shape so consumers do not need
activeNode!; provide guarded actions or null-safe helpers instead. - Move stroke dash-pattern and independent-side logic from
StrokeSection.vueintopackages/vue/src/controls/stroke/helpers.ts. - Replace fake node casts with explicit helper functions that operate on real nodes and return typed patches.
Acceptance criteria
rg 'as Stroke|as Fill|as Effect|as unknown|activeNode!' src/components --glob '*.vue'returns zero.StrokeSection.vuebecomes mostly markup and delegates stroke-specific mutations to SDK helpers.bun run checkpasses.
Phase 3: Rewrite the Layers panel for scale
Purpose: Make the Layers panel usable on large imported Figma documents.
Files to refactor
packages/vue/src/primitives/LayerTree/LayerTreeRoot.vuepackages/vue/src/primitives/LayerTree/LayerTreeItem.vuepackages/vue/src/primitives/LayerTree/context.tspackages/vue/src/primitives/LayerTree/useLayerDrag.tssrc/components/LayerTree.vue- New app namespace:
src/components/LayerTree/LayerRow.vue,LayerRowRename.vue,LayerRowActions.vue,DropIndicator.vue
Work items
- Replace the recursive tree object with a flat visible-row model derived from the current page and expanded state.
- Remove
treeKeyremounting. Tree state must survive node edits, scroll, rename focus, and dragging. - Subscribe to editor lifecycle events instead of watching only
sceneVersion:node:creatednode:updatednode:deletednode:reparentednode:reorderedpage:changedselection:changed
- Patch rows incrementally for name, visibility, lock, and type changes. Rebuild the flat visible list only for structural changes and expansion changes.
- Add virtualization over the flat row list. Use fixed row height and keep selection scroll-to behavior by index rather than by per-row DOM refs.
- Split the 200+ line app template into row subcomponents.
- Add range selection with Shift and preserve additive selection with Cmd/Ctrl.
- Add focused and unfocused selected-row visual states.
Acceptance criteria
- Scrubbing a selected node's X/Y/W/H no longer remounts the Layers tree.
- Scroll position and rename focus survive unrelated scene updates.
- A 5,000-node document remains responsive while editing properties.
- Add tests under
tests/engine/vue/for visible-row derivation and undertests/e2e/perf/for large-tree interaction.
Phase 4: Make mixed values first-class
Purpose: Multi-select editing should behave like Figma, not like first-node editing.
Files to refactor
packages/vue/src/controls/node-props/helpers.tspackages/vue/src/controls/prop-scrub/use.tspackages/vue/src/primitives/ScrubInput/**- Every property section under
src/components/properties/**
Work items
- Promote the existing
MIXEDsymbol into a public SDK concept for control roots. - Make
ScrubInputRootand the appScrubInput.vuedisplay a mixed placeholder and commit typed input to all selected nodes. - Add mixed display semantics to color rows, selects, segmented controls, toggles, and variable-bound inputs.
- Ensure each panel section distinguishes:
- inactive/not applicable
- empty list
- mixed value
- explicit value
- Add e2e coverage for multi-select editing: position, fill, stroke, opacity, typography, effects, and layout.
Acceptance criteria
- Multi-select no longer silently displays the first selected node's value as if it applied to all nodes.
- A mixed field shows a clear mixed placeholder and a first edit applies uniformly to all selected nodes.
- Mixed array props (
fills,strokes,effects) show clear add/replace behavior.
Phase 5: Close high-impact Figma panel gaps
Each item should be implemented as SDK logic plus app presentation. Do not put durable editing logic directly in app components.
5.1 Constraints
- Add
packages/vue/src/controls/constraints/use.ts. - Add
packages/vue/src/primitives/ConstraintsControl/. - Add
src/components/properties/ConstraintsSection.vue. - Use a Figma-style pin/grid control plus horizontal and vertical mode selectors.
5.2 Stroke caps, joins, and miter limit
- Extend
packages/vue/src/controls/stroke/**. - Extend
src/components/properties/StrokeSection.vue. - Use segmented controls for cap and join modes; use a scrub input for miter limit.
5.3 Corner smoothing and independent corners
- Extend
packages/vue/src/controls/appearance/**. - Extend
src/components/properties/AppearanceSection.vue. - Add smoothing control and independent-corner presentation that does not depend on inline SVGs.
5.4 Shared styles
- Add or expose core model support for fill, stroke, text, effect, and grid style identifiers.
- Add SDK controls for style binding/unbinding.
- Add app UI in Fill, Stroke, Typography, Effects, and future Layout Grid sections.
- Preserve Figma style IDs on import/export where safe.
5.5 Component properties
- Add
packages/vue/src/controls/component-props/use.ts. - Add
src/components/properties/ComponentPropsSection.vue. - Expose text, boolean, variant, and instance-swap properties when available.
5.6 Blend modes and effect styles
- Extend Fill and Effects sections with blend-mode pickers.
- Add effect style binding once shared styles exist.
5.7 Typography depth
- Extend Typography controls for text case, vertical alignment, justification, truncation/max lines, and OpenType/font variation fields where supported by the scene graph and renderer.
Acceptance criteria
- Each new capability has SDK control tests and at least one app e2e test.
- New sections are renderless-friendly: third-party shells can use the SDK logic without OpenPencil app components.
Phase 6: Polish interaction and density
Purpose: Make the panels feel precise and Figma-like.
Work items
- Standardize panel density:
- 28px control rows
- consistent section padding
- consistent 4px/8px row gaps
- strict two-column input rhythm for paired numeric fields
- Extend
src/app.csstheme tokens:- selected row background
- selected row unfocused background
- panel secondary background
- subtle border/focus tokens
- warning/success/action states for both dark and light themes
- Extend scrub input interactions:
- Shift-drag for coarse increments
- modifier-drag for fine increments
- ArrowUp/ArrowDown increments
- math expression commit if a safe dependency or parser is selected deliberately
- Add consistent focus rings and keyboard navigation for panel fields, segmented controls, and layer rows.
- Audit empty, disabled, and not-applicable states for all property sections.
- Align toolbar flyout grouping with design-tool expectations: selection/move tools, frame/section tools, shape tools, drawing tools, text/comment/resource tools.
Acceptance criteria
- Panel screenshot tests cover single rectangle, text selection, multi-select, instance selection, empty canvas, and layer tree with nested groups.
- Keyboard-only panel navigation works for the main property sections.
- No one-off raw Tailwind control rows remain outside the shared panel primitives unless explicitly justified.
Phase 7: Testing and guardrails
Tests to add
tests/e2e/panels/visual.spec.tsfor DOM screenshots of panels.tests/e2e/properties/mixed-values.spec.ts.tests/e2e/layers/large-tree.spec.tsortests/e2e/perf/layers.spec.ts.tests/engine/vue/layer-tree.test.tsfor flat visible-row derivation.- SDK-level tests for each new control under existing engine/vue coverage patterns.
Static checks to add
- No
use*UI()calls inside Vue templates. - No raw
<svg>inside app templates unless the file is explicitly allowlisted. - No
as Stroke,as Fill,as Effect, or non-nullactiveNode!in app templates. - No new property-section component over 250 lines without a documented split.
Recommended sequencing
- Phase 1: UI kit consolidation.
- Phase 2: typed property-list boundaries.
- Phase 4: mixed values, because it affects every future section.
- Phase 3: Layers rewrite can run in parallel with 1-2 because it touches a separate surface.
- Phase 5: feature parity streams in parallel after 1, 2, and 4.
- Phase 6: density and interaction polish continuously, but final pass after 5.
- Phase 7: add guardrails as soon as Phase 1 introduces replacement patterns.
Parallel worktree split
Suggested future-agent branches:
ui-kit-foundation: Phase 1 and related guardrails.property-list-types: Phase 2.layers-virtualized: Phase 3.mixed-values: Phase 4.constraints-section: Phase 5.1.stroke-controls-parity: Phase 5.2.appearance-corners: Phase 5.3.shared-styles: Phase 5.4.component-props-panel: Phase 5.5.typography-depth: Phase 5.7.
Definition of done
OpenPencil's UI moves out of MVP status when:
- Panels are composed from shared panel primitives rather than ad-hoc markup.
- Large layer trees are virtualized and incremental.
- Mixed values behave predictably across multi-select.
- The highest-impact Figma-compatible properties are editable in the UI.
- Screenshot and static checks prevent visual and structural regression.
- SDK controls remain reusable by custom shells, preserving the broader goal of building OpenPencil as a set of Lego-like packages for custom Figma-like editors.