feat(lint): suggest group-to-frame and hidden-layer fixes; keep canvas edits in code previews (#870)

* feat(lint): suggest converting groups to frames and deleting hidden layers

no-groups and no-hidden-layers now carry suggestions the Lint panel, the
lint_fix tool and editor.applyLintFixes can apply. A group becomes a frame
in place, keeping its id, children, bounds and look; a hidden layer is
deleted with its children. Neither is offered for locked layers or inside
components and instances, and both are checked again when applied.

The editor gains convertGroupToFrame and deleteNodes, which deleteSelected
now uses, and applies lint fixes through a bridge so structure changes and
property updates share one undo step. lint_fix becomes a document mutation
because atomic tools cannot remove layers.

* fix(code): keep canvas edits made while replaced code waits to render

Replacing all the code drops its layer links until the preview links it
again, so a canvas edit in the preview delay could not be patched into the
code and the preview drew over it. Edits made while code waits to render
are now applied again once the preview has linked the code, in the same
undo step, and reach the code like any other canvas change.

* fix(figma-api): default paint opacity and visibility like Figma

Plugin scripts may leave out a paint's opacity and visible; Figma reads
them back as 1 and true. The fills and strokes setters stored the paint
as given, so the Design panel received an undefined opacity.

* build(dev): forward only errors from the browser console

Vite forwards browser logs when an agent starts the dev server. Serializing
a Vue warning's component props walks the editor state and freezes the
tab, so warnings stay in the browser console.

* fix(code): follow values while they are dragged or scrubbed

Live previews change layers without a new scene version, so the code kept
the old value until the gesture ended. The Code tab now follows preview
updates once per frame, as the Design panel does, and goes back when the
gesture is cancelled.

* fix(code): keep canvas edits when the replaced code fails to preview

A failed preview took the canvas edits waiting for it and dropped them,
and stopped recording new ones, so correcting the code drew over them.
The edits now wait for the next preview.
This commit is contained in:
Danila Poyarkov 2026-10-04 12:46:06 +00:00 committed by GitHub
parent 767fd99493
commit bb69735d27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 499 additions and 51 deletions

View file

@ -17,9 +17,9 @@
### Added
- Check designs from the new Lint tab in the right panel: issues on the page, in the selection, or across the document are grouped by rule, hovering one highlights its layer on the canvas, clicking selects it and brings it into view, and one-click fixes bind colors to the variable they match and round subpixel geometry for a row or a whole group, and snap radius, spacing, and small text to the scale one row at a time. Rules can be turned off individually or switched between the Recommended, Strict, and Accessibility presets.
- Check designs from the new Lint tab in the right panel: issues on the page, in the selection, or across the document are grouped by rule, hovering one highlights its layer on the canvas, clicking selects it and brings it into view, and one-click fixes bind colors to the variable they match and round subpixel geometry for a row or a whole group, and snap radius, spacing, and small text to the scale, convert groups to frames, and delete hidden layers one row at a time. Rules can be turned off individually or switched between the Recommended, Strict, and Accessibility presets.
- See which layer the code is about in the Code tab's Design JSX and Tailwind JSX: the element around the cursor marks its opening and closing tag and shows its layer on the canvas as a tinted box, apart from canvas hover, and design issues are underlined on the property that causes them, including while you edit Design JSX live.
- Fix lint issues outside the app: `openpencil lint --fix -o fixed.fig` binds colors to the variable they match and rounds subpixel geometry, and the `lint` and `lint_fix` tools let MCP clients and AI chat check a page and apply those fixes, plus radius, spacing, and text size suggestions on request. Lint messages carry each fix as data in `fix` and `suggestions`.
- Fix lint issues outside the app: `openpencil lint --fix -o fixed.fig` binds colors to the variable they match and rounds subpixel geometry, and the `lint` and `lint_fix` tools let MCP clients and AI chat check a page and apply those fixes, plus radius, spacing, text size, group-to-frame, and hidden-layer suggestions on request. Lint messages carry each fix as data in `fix` and `suggestions`.
- Mark layers with errors and warnings on the canvas while you work, and pin those outside the view to the canvas edge in their direction, with matching marks in the Layers panel and error and warning counts in the page list; hover a marker or pin for its issues or click it to open them in Lint, and turn markers on or off with View → Design issues.
- Follow collaborators and their AI agents from the avatars in the toolbar: an avatar counts that person's agents, hovering lists what each is doing and on which page, and clicking follows. A frame in their color and a “Following …” bar show whom you follow; Escape, clicking, scrolling, zooming, or switching pages stops it. Your own avatar renames your agents and leaves the room.
- See where the built-in AI chat is working: while it replies, a cursor whose outlined label shows a sparkle and a callsign such as *Fern* marks the layers it edits. In a shared room, collaborators see each other's agents in the color of the person running them.
@ -53,6 +53,7 @@
### Fixed
- Give paints set through the plugin API, `eval`, and AI and MCP scripts an opacity of 1 and make them visible when the script leaves those out, as Figma does. Such paints were stored without them, which the Design panel could not show.
- Show variable-bound colours and numbers correctly when a `.fig` exported from OpenPencil opens in Figma. Figma draws the value a bound field stores until something makes it resolve the variable again, and exports stored the colour from before the binding, so a bound fill appeared in its old colour. Each bound field is now written as it resolves in its layer's mode, or in the collection's default mode when the layer sets none.
- Open Figma files that use slots with each instance's own slot content instead of its component's default, keep slot properties, their settings, and instance content when saving back to `.fig`, and keep an instance's slot content when you switch its variant.
- Stop showing a “signal is aborted without reason” error when you switch pages again before the previous page has finished loading.

View file

@ -0,0 +1,34 @@
import type { createClipboardActions } from '#core/editor/clipboard'
import type { createNodeActions } from '#core/editor/nodes'
import type { createStructureActions } from '#core/editor/structure'
import type { EditorContext } from '#core/editor/types'
import { applyLintFixes as applyFixes, type LintFixRequest } from '#core/lint/fixes'
type NodeActions = ReturnType<typeof createNodeActions>
type StructureActions = ReturnType<typeof createStructureActions>
type ClipboardActions = ReturnType<typeof createClipboardActions>
export function createLintFixBridge(
ctx: EditorContext,
nodes: NodeActions,
structure: StructureActions,
clipboard: ClipboardActions
) {
/** Applies lint fixes that still hold as one undo step; returns how many applied. */
function applyLintFixes(requests: readonly LintFixRequest[]): number {
return ctx.undo.runBatch('Fix design issues', () =>
applyFixes(
{
graph: ctx.graph,
updateNode: (id, changes) => nodes.updateNodeWithUndo(id, changes, 'Fix design issue'),
bindVariable: nodes.bindVariable,
convertToFrame: structure.convertGroupToFrame,
deleteNodes: (ids) => clipboard.deleteNodes(ids)
},
requests
)
)
}
return { applyLintFixes }
}

View file

@ -310,14 +310,18 @@ export function createClipboardActions(ctx: EditorContext) {
return missingImageHashes(nodeIds).length > 0
}
function deleteSelected() {
/**
* Deletes layers with their children as one undo step, skipping locked ones. Deleted layers
* leave the selection, and undo restores the selection from before.
*/
function deleteNodes(nodeIds: Iterable<string>, nextSelection?: ReadonlySet<string>) {
const entries: Array<{
id: string
parentId: string
index: number
subtree: Map<string, SceneNode>
}> = []
for (const id of ctx.state.selectedIds) {
for (const id of nodeIds) {
const node = ctx.graph.getNode(id)
if (!node || node.locked) continue
const parentId = node.parentId ?? ctx.state.currentPageId
@ -336,13 +340,15 @@ export function createClipboardActions(ctx: EditorContext) {
const prevSelection = new Set(ctx.state.selectedIds)
for (const { id } of entries) ctx.graph.deleteNode(id)
relayoutParents()
const selectionAfter =
nextSelection ?? new Set([...prevSelection].filter((id) => ctx.graph.getNode(id)))
ctx.undo.push({
label: 'Delete',
forward: () => {
for (const { id } of entries) ctx.graph.deleteNode(id)
relayoutParents()
ctx.setSelectedIds(new Set())
ctx.setSelectedIds(new Set(selectionAfter))
},
inverse: () => {
restoreDeletedEntries(ctx, entries)
@ -350,7 +356,11 @@ export function createClipboardActions(ctx: EditorContext) {
ctx.setSelectedIds(prevSelection)
}
})
ctx.setSelectedIds(new Set())
ctx.setSelectedIds(new Set(selectionAfter))
}
function deleteSelected() {
deleteNodes(ctx.state.selectedIds, new Set())
}
const copyActions = createClipboardCopyActions(ctx)
@ -368,6 +378,7 @@ export function createClipboardActions(ctx: EditorContext) {
pasteSnapshot,
pasteFromHTML,
warnMissingImages,
deleteNodes,
deleteSelected,
...assetActions,
...exportActions

View file

@ -20,6 +20,7 @@ import { fontResolver } from '#core/text/resolver'
import { createAlignmentActions } from './alignment'
import { createClipboardBridge } from './bridges/clipboard'
import { createComponentBridge } from './bridges/components'
import { createLintFixBridge } from './bridges/lint'
import { createStructureBridge } from './bridges/structure'
import { createUndoBridge } from './bridges/undo'
import { createClipboardActions } from './clipboard'
@ -235,6 +236,7 @@ export function createEditor(options?: EditorOptions) {
const componentBridge = createComponentBridge(components, selection, structure, pages)
const structureBridge = createStructureBridge(structure, selection)
const undoBridge = createUndoBridge(undoActions, selection)
const lintFixBridge = createLintFixBridge(ctx, nodes, structure, clipboard)
function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) {
_ck = ck
@ -371,7 +373,10 @@ export function createEditor(options?: EditorOptions) {
...componentBridge,
// Structure — bridge functions that need selectedNodes
...structureBridge
...structureBridge,
// Lint fixes, which touch nodes, structure and deletion in one undo step
...lintFixBridge
}
}

View file

@ -9,7 +9,6 @@ import {
} from '@open-pencil/scene-graph'
import { reconcileVariableLayouts } from '#core/layout/variables'
import { applyLintFixes as applyFixes, type LintFixRequest } from '#core/lint/fixes'
import { createLayoutModeActions } from './layout-mode'
import { createNodePreviewActions } from './node-preview'
@ -111,23 +110,8 @@ export function createNodeActions(ctx: EditorContext) {
)
}
/** Applies lint fixes that still hold as one undo step; returns how many applied. */
function applyLintFixes(requests: readonly LintFixRequest[]): number {
return ctx.undo.runBatch('Fix design issues', () =>
applyFixes(
{
graph: ctx.graph,
updateNode: (id, changes) => updateNodeWithUndo(id, changes, 'Fix design issue'),
bindVariable: variableBindingActions.bindVariable
},
requests
)
)
}
return {
updateNode,
applyLintFixes,
...createNodePreviewActions(ctx, updateNode),
updateNodeWithUndo,
setOpacity,

View file

@ -67,6 +67,24 @@ export function createStructureActions(ctx: EditorContext) {
})
}
/**
* Turns a group into a frame in place. The layer keeps its id, children, bounds and look: a
* group has no fill and does not clip, and the frame starts the same way.
*/
function convertGroupToFrame(nodeId: string) {
if (ctx.graph.getNode(nodeId)?.type !== 'GROUP') return
const apply = (type: 'GROUP' | 'FRAME') => {
ctx.graph.updateNode(nodeId, { type })
ctx.runLayoutForNode(nodeId)
}
apply('FRAME')
ctx.undo.push({
label: 'Convert to frame',
forward: () => apply('FRAME'),
inverse: () => apply('GROUP')
})
}
function booleanOperationSelected(selectedNodes: SceneNode[], operation: BooleanOperation) {
return booleanOperationSelectedImpl(ctx, selectedNodes, operation)
}
@ -142,6 +160,7 @@ export function createStructureActions(ctx: EditorContext) {
wrapInAutoLayout,
groupSelected,
frameSelection,
convertGroupToFrame,
booleanOperationSelected,
ungroupSelected,
flattenSelected,

View file

@ -24,6 +24,11 @@ function styleReference(
}
}
/** Figma treats a paint's `opacity` and `visible` as optional, defaulting to 1 and `true`. */
function paintDefaults(paint: Partial<Pick<Fill, 'opacity' | 'visible'>>) {
return { opacity: paint.opacity ?? 1, visible: paint.visible ?? true }
}
export function installVisualNodeProxyAccessors(
prototype: object,
internals: NodeProxyInternals,
@ -38,6 +43,7 @@ export function installVisualNodeProxyAccessors(
updateNode(this, internals, {
fills: value.map((fill) => ({
...fill,
...paintDefaults(fill),
color: normalizeColor(fill.color),
gradientStops: fill.gradientStops?.map((stop) => ({
...stop,
@ -53,7 +59,11 @@ export function installVisualNodeProxyAccessors(
},
set(this: ProxyThis, value: readonly Stroke[]) {
updateNode(this, internals, {
strokes: value.map((stroke) => ({ ...stroke, color: normalizeColor(stroke.color) }))
strokes: value.map((stroke) => ({
...stroke,
...paintDefaults(stroke),
color: normalizeColor(stroke.color)
}))
})
}
},

View file

@ -2,6 +2,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { colorToHex } from '@open-pencil/scene-graph/color'
import type { LintFix, LintMessage } from './types'
import { ownsComponentTree } from './utils'
/** A fix for the layer it was reported on. A `LintMessage` with a `fix` is one. */
export interface LintFixRequest {
@ -17,13 +18,20 @@ export interface LintFixTarget {
readonly graph: SceneGraph
updateNode(nodeId: string, changes: Partial<SceneNode>): void
bindVariable(nodeId: string, path: string, variableId: string): void
convertToFrame(nodeId: string): void
/** Deletes layers with their children; none of them is inside another. */
deleteNodes(nodeIds: readonly string[]): void
}
export function graphFixTarget(graph: SceneGraph): LintFixTarget {
return {
graph,
updateNode: (nodeId, changes) => graph.updateNode(nodeId, changes),
bindVariable: (nodeId, path, variableId) => graph.bindVariable(nodeId, path, variableId)
bindVariable: (nodeId, path, variableId) => graph.bindVariable(nodeId, path, variableId),
convertToFrame: (nodeId) => graph.updateNode(nodeId, { type: 'FRAME' }),
deleteNodes: (nodeIds) => {
for (const nodeId of nodeIds) graph.deleteNode(nodeId)
}
}
}
@ -49,30 +57,76 @@ function canBind(graph: SceneGraph, node: SceneNode, path: string, variableId: s
return colorToHex({ ...paint.color, a: 1 }) === colorToHex({ ...color, a: 1 })
}
function* ancestors(graph: SceneGraph, node: SceneNode): Generator<SceneNode> {
for (let id = node.parentId; id;) {
const parent = graph.getNode(id)
if (!parent) return
yield parent
id = parent.parentId
}
}
/**
* Structural fixes apply only where the layer's structure is the user's to change: not locked,
* and not inside a component or an instance.
*/
function canRestructure(graph: SceneGraph, node: SceneNode): boolean {
if (node.locked) return false
for (const parent of ancestors(graph, node)) if (ownsComponentTree(parent.type)) return false
return true
}
function hasAncestorIn(graph: SceneGraph, node: SceneNode, ids: ReadonlySet<string>): boolean {
for (const parent of ancestors(graph, node)) if (ids.has(parent.id)) return true
return false
}
/**
* Applies fixes that still hold against the current graph. Changes to one layer merge into a
* single update. Returns the number of fixes applied.
* single update, and deletions run last so other fixes still find their layers. Returns the
* number of fixes applied.
*/
export function applyLintFixes(target: LintFixTarget, requests: readonly LintFixRequest[]): number {
const { graph } = target
const updates = new Map<string, Partial<SceneNode>>()
const conversions = new Set<string>()
const deletions = new Set<string>()
let applied = 0
for (const { nodeId, fix } of requests) {
const node = graph.getNode(nodeId)
if (!node) continue
if (fix.kind === 'bind-variable') {
if (!canBind(graph, node, fix.path, fix.variableId)) continue
target.bindVariable(nodeId, fix.path, fix.variableId)
applied++
continue
switch (fix.kind) {
case 'bind-variable':
if (!canBind(graph, node, fix.path, fix.variableId)) continue
target.bindVariable(nodeId, fix.path, fix.variableId)
break
case 'set': {
const changes = Object.fromEntries(
Object.entries(fix.changes).filter(([key, value]) => Reflect.get(node, key) !== value)
)
if (Object.keys(changes).length === 0) continue
updates.set(nodeId, { ...updates.get(nodeId), ...changes })
break
}
case 'convert-to-frame':
if (node.type !== 'GROUP' || conversions.has(nodeId) || !canRestructure(graph, node))
continue
conversions.add(nodeId)
break
case 'delete':
// Only a layer that is still hidden: one shown since the check is in use.
if (node.visible || deletions.has(nodeId) || !canRestructure(graph, node)) continue
deletions.add(nodeId)
break
}
const changes = Object.fromEntries(
Object.entries(fix.changes).filter(([key, value]) => Reflect.get(node, key) !== value)
)
if (Object.keys(changes).length === 0) continue
updates.set(nodeId, { ...updates.get(nodeId), ...changes })
applied++
}
for (const [nodeId, changes] of updates) target.updateNode(nodeId, changes)
for (const nodeId of conversions) target.convertToFrame(nodeId)
const roots = [...deletions].filter((nodeId) => {
const node = graph.getNode(nodeId)
return node ? !hasAncestorIn(graph, node, deletions) : false
})
if (roots.length > 0) target.deleteNodes(roots)
return applied
}

View file

@ -1,4 +1,6 @@
import { defineRule } from '#core/lint/rule'
import { inComponentTree } from '#core/lint/utils'
export default defineRule({
meta: {
id: 'no-groups',
@ -10,7 +12,10 @@ export default defineRule({
context.report({
node,
message: 'Group should be converted to Frame',
suggest: 'Groups cannot use auto layout. Convert to Frame for better control.'
suggest: 'Groups cannot use auto layout. Convert to Frame for better control.',
...(node.locked || inComponentTree(node)
? {}
: { suggestions: [{ kind: 'convert-to-frame' }] })
})
}
})

View file

@ -1,4 +1,6 @@
import { defineRule } from '#core/lint/rule'
import { inComponentTree } from '#core/lint/utils'
export default defineRule({
meta: {
id: 'no-hidden-layers',
@ -6,11 +8,12 @@ export default defineRule({
description: 'Hidden layers may indicate unused elements'
},
check(node, context) {
if (!node.visible)
context.report({
node,
message: 'Hidden layer detected',
suggest: 'Delete if unused or keep only if required for component states'
})
if (node.visible) return
context.report({
node,
message: 'Hidden layer detected',
suggest: 'Delete if unused or keep only if required for component states',
...(node.locked || inComponentTree(node) ? {} : { suggestions: [{ kind: 'delete' }] })
})
}
})

View file

@ -40,6 +40,10 @@ export type LintFixProperty =
export type LintFix =
| { kind: 'bind-variable'; path: string; variableId: string; variableName: string }
| { kind: 'set'; changes: Readonly<Partial<Record<LintFixProperty, number>>> }
/** Turns a group into a frame in place, keeping its children, bounds and look. */
| { kind: 'convert-to-frame' }
/** Deletes the layer with its children. */
| { kind: 'delete' }
export interface LintMessage {
ruleId: string

View file

@ -52,3 +52,26 @@ export function getNodePath(node: LintPathNode): string[] {
}
export const SPACING_SCALE = [0, 1, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 128]
const COMPONENT_TREE_TYPES = new Set(['COMPONENT', 'COMPONENT_SET', 'INSTANCE'])
/** Layer types whose descendants' structure belongs to a component. */
export function ownsComponentTree(type: string): boolean {
return COMPONENT_TREE_TYPES.has(type)
}
interface LintTreeNode {
type: string
parent?: LintTreeNode
}
/**
* Whether a layer sits inside a component or an instance, whose structure belongs to the
* component: instance layers cannot be removed, and a component property may show a hidden one.
*/
export function inComponentTree(node: LintTreeNode): boolean {
for (let current = node.parent; current; current = current.parent) {
if (ownsComponentTree(current.type)) return true
}
return false
}

View file

@ -91,8 +91,9 @@ export const lintFix = defineTool({
description:
'Apply lint fixes: bind colors to the variable they match and round geometry to whole pixels. ' +
'With suggestions: true, also apply each finding’s first suggestion (snap radius and spacing to the scale, ' +
'raise text to the minimum size). Returns what was applied and what remains.',
execution: { kind: 'sync', mutation: 'properties' },
'raise text to the minimum size, convert groups to frames, delete hidden layers). ' +
'Returns what was applied and what remains.',
execution: { kind: 'sync', mutation: 'document' },
input: v.object({
...lintScopeInput,
suggestions: v.optional(

View file

@ -183,3 +183,116 @@ describe('editor.applyLintFixes', () => {
expect(restored.boundVariables['fills/0/color']).toBeUndefined()
})
})
function suggestionRequests(messages: readonly LintMessage[]) {
return messages.flatMap((m) => (m.suggestions ?? []).map((fix) => ({ nodeId: m.nodeId, fix })))
}
describe('structure suggestions', () => {
test('convert a group to a frame in place, keeping its layers and look', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const group = graph.createNode('GROUP', pageId, { x: 40, y: 20, width: 30, height: 30 })
const dot = graph.createNode('ELLIPSE', group.id, { width: 10, height: 10 })
const messages = lint(graph, 'no-groups')
expect(messages[0]?.suggestions).toEqual([{ kind: 'convert-to-frame' }])
expect(safeFixes(messages)).toEqual([])
expect(applyLintFixes(graphFixTarget(graph), suggestionRequests(messages))).toBe(1)
const frame = getNodeOrThrow(graph, group.id)
expect(frame).toMatchObject({ type: 'FRAME', x: 40, y: 20, width: 30, height: 30 })
expect(frame.childIds).toEqual([dot.id])
expect(frame.fills).toEqual([])
expect(frame.clipsContent).toBe(false)
expect(lint(graph, 'no-groups')).toEqual([])
})
test('delete hidden layers once, together with hidden layers inside them', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const panel = graph.createNode('FRAME', pageId, { visible: false })
const inner = graph.createNode('RECTANGLE', panel.id, { visible: false })
const shown = graph.createNode('RECTANGLE', pageId)
const messages = lint(graph, 'no-hidden-layers')
expect(messages.map((m) => m.suggestions)).toEqual([[{ kind: 'delete' }], [{ kind: 'delete' }]])
expect(applyLintFixes(graphFixTarget(graph), suggestionRequests(messages))).toBe(2)
expect(graph.getNode(panel.id)).toBeUndefined()
expect(graph.getNode(inner.id)).toBeUndefined()
expect(graph.getNode(shown.id)).toBeDefined()
})
test('keep a layer shown again since the check', () => {
const graph = new SceneGraph()
const layer = graph.createNode('RECTANGLE', graph.getPages()[0].id, { visible: false })
const messages = lint(graph, 'no-hidden-layers')
graph.updateNode(layer.id, { visible: true })
expect(applyLintFixes(graphFixTarget(graph), suggestionRequests(messages))).toBe(0)
expect(graph.getNode(layer.id)).toBeDefined()
})
test('leave locked layers and the structure of components and instances alone', () => {
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const lockedGroup = graph.createNode('GROUP', pageId, { locked: true })
const lockedHidden = graph.createNode('RECTANGLE', pageId, { visible: false, locked: true })
const button = graph.createNode('COMPONENT', pageId)
const icon = graph.createNode('GROUP', button.id)
const badge = graph.createNode('RECTANGLE', button.id, { visible: false })
const instance = graph.createNode('INSTANCE', pageId, { componentId: button.id })
const copy = graph.createNode('RECTANGLE', instance.id, { visible: false })
const messages = [...lint(graph, 'no-groups'), ...lint(graph, 'no-hidden-layers')]
// The linter does not look inside instances, so the copy is only checked when applying.
const reported = [lockedGroup, lockedHidden, icon, badge].map((node) => node.id)
expect(reported.every((id) => messages.some((m) => m.nodeId === id))).toBe(true)
expect(messages.every((m) => m.suggestions === undefined)).toBe(true)
// Requests built elsewhere are checked against the graph as well.
const requests = [
{ nodeId: lockedGroup.id, fix: { kind: 'convert-to-frame' } as const },
{ nodeId: icon.id, fix: { kind: 'convert-to-frame' } as const },
{ nodeId: lockedHidden.id, fix: { kind: 'delete' } as const },
{ nodeId: copy.id, fix: { kind: 'delete' } as const }
]
expect(applyLintFixes(graphFixTarget(graph), requests)).toBe(0)
expect(getNodeOrThrow(graph, icon.id).type).toBe('GROUP')
expect(graph.getNode(copy.id)).toBeDefined()
})
})
describe('editor structure fixes', () => {
test('convert and delete as one undo step that restores layers and selection', () => {
const editor = createEditor()
const pageId = editor.graph.getPages()[0].id
const group = editor.graph.createNode('GROUP', pageId, { width: 30, height: 30 })
editor.graph.createNode('ELLIPSE', group.id, { width: 10, height: 10 })
const hidden = editor.graph.createNode('RECTANGLE', pageId, { visible: false })
editor.select([hidden.id, group.id])
const messages = createLinter({
rules: ['no-groups', 'no-hidden-layers'],
config: { rules: { 'no-groups': 'warning', 'no-hidden-layers': 'warning' } }
}).lintGraph(editor.graph).messages
expect(editor.applyLintFixes(suggestionRequests(messages))).toBe(2)
expect(getNodeOrThrow(editor.graph, group.id).type).toBe('FRAME')
expect(editor.graph.getNode(hidden.id)).toBeUndefined()
expect([...editor.state.selectedIds]).toEqual([group.id])
editor.undoAction()
expect(getNodeOrThrow(editor.graph, group.id).type).toBe('GROUP')
expect(getNodeOrThrow(editor.graph, hidden.id).visible).toBe(false)
expect(new Set(editor.state.selectedIds)).toEqual(new Set([hidden.id, group.id]))
editor.redoAction()
expect(getNodeOrThrow(editor.graph, group.id).type).toBe('FRAME')
expect(editor.graph.getNode(hidden.id)).toBeUndefined()
})
})

View file

@ -25,6 +25,7 @@ Rows that can be fixed in one step show a button on hover:
- A hardcoded color that matches one of the document's color variables binds to it (link button). The row names the variable.
- Subpixel positions and sizes round to whole pixels. Values that auto layout or text resizing sets, and vector artwork and the parts of groups, are left alone.
- Off-scale corner radius and spacing change to the nearest scale value, and text below the minimum size grows to it (wand button). These change the design, so they apply one row at a time.
- A group converts to a frame in place, keeping its layers, position, and look (frame button), and a hidden layer can be deleted (trash button). Neither is offered for locked layers or layers inside a component or instance, whose structure belongs to the component.
Binding colors and rounding pixels keep the design as it looks, so their groups also offer **Bind all** or **Fix all**. Every fix is a single undo step.

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "Auf ganze Pixel runden",
"fixUseValue": "Auf {value} ändern",
"fixFullRadius": "Vollen Radius verwenden",
"fixConvertToFrame": "In Rahmen umwandeln",
"fixDeleteLayer": "Ebene löschen",
"fixAll": "Alle {count} beheben",
"ruleNoDefaultNames": "Standardname der Ebene",
"ruleNoDefaultNamesHelp": "Aussagekräftige Namen machen Ebenen leichter auffindbar, übergebbar und wiederverwendbar.",

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "Redondear a píxeles enteros",
"fixUseValue": "Cambiar a {value}",
"fixFullRadius": "Usar radio completo",
"fixConvertToFrame": "Convertir en marco",
"fixDeleteLayer": "Eliminar capa",
"fixAll": "Corregir las {count}",
"ruleNoDefaultNames": "Nombre de capa predeterminado",
"ruleNoDefaultNamesHelp": "Los nombres descriptivos facilitan encontrar, entregar y reutilizar capas.",

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "Arrondir au pixel entier",
"fixUseValue": "Remplacer par {value}",
"fixFullRadius": "Utiliser un rayon complet",
"fixConvertToFrame": "Convertir en cadre",
"fixDeleteLayer": "Supprimer le calque",
"fixAll": "Tout corriger ({count})",
"ruleNoDefaultNames": "Nom de calque par défaut",
"ruleNoDefaultNamesHelp": "Des noms descriptifs facilitent la recherche, la transmission et la réutilisation des calques.",

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "Arrotonda a pixel interi",
"fixUseValue": "Cambia in {value}",
"fixFullRadius": "Usa raggio completo",
"fixConvertToFrame": "Converti in frame",
"fixDeleteLayer": "Elimina livello",
"fixAll": "Correggi tutti ({count})",
"ruleNoDefaultNames": "Nome del livello predefinito",
"ruleNoDefaultNamesHelp": "Nomi descrittivi rendono i livelli più facili da trovare, consegnare e riutilizzare.",

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "整数ピクセルに丸める",
"fixUseValue": "{value} に変更",
"fixFullRadius": "最大の角丸を使う",
"fixConvertToFrame": "フレームに変換",
"fixDeleteLayer": "レイヤーを削除",
"fixAll": "{count} 件すべて修正",
"ruleNoDefaultNames": "デフォルトのレイヤー名",
"ruleNoDefaultNamesHelp": "わかりやすい名前を付けると、レイヤーを探しやすく、受け渡しや再利用もしやすくなります。",

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "Zaokrąglij do pełnych pikseli",
"fixUseValue": "Zmień na {value}",
"fixFullRadius": "Użyj pełnego promienia",
"fixConvertToFrame": "Przekształć w ramkę",
"fixDeleteLayer": "Usuń warstwę",
"fixAll": "Popraw wszystkie ({count})",
"ruleNoDefaultNames": "Domyślna nazwa warstwy",
"ruleNoDefaultNamesHelp": "Opisowe nazwy ułatwiają znajdowanie, przekazywanie i ponowne użycie warstw.",

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "Округлить до целых пикселей",
"fixUseValue": "Изменить на {value}",
"fixFullRadius": "Использовать полное скругление",
"fixConvertToFrame": "Преобразовать во фрейм",
"fixDeleteLayer": "Удалить слой",
"fixAll": "Исправить все ({count})",
"ruleNoDefaultNames": "Стандартное имя слоя",
"ruleNoDefaultNamesHelp": "Понятные имена помогают находить, передавать и переиспользовать слои.",

View file

@ -65,6 +65,8 @@
"fixRoundPixels": "取整到整像素",
"fixUseValue": "改为 {value}",
"fixFullRadius": "使用完全圆角",
"fixConvertToFrame": "转换为画框",
"fixDeleteLayer": "删除图层",
"fixAll": "全部修复({count})",
"ruleNoDefaultNames": "默认图层名称",
"ruleNoDefaultNamesHelp": "描述性的名称让图层更易查找、交付和复用。",

View file

@ -73,6 +73,8 @@ export const designCheckMessageDefaults = {
fixRoundPixels: 'Round to whole pixels',
fixUseValue: params('Change to {value}'),
fixFullRadius: 'Use full radius',
fixConvertToFrame: 'Convert to frame',
fixDeleteLayer: 'Delete layer',
fixAll: params('Fix all {count}'),
ruleNoDefaultNames: 'Default layer name',
ruleNoDefaultNamesHelp: 'Descriptive names make layers easier to find, hand off and reuse.',

View file

@ -1,7 +1,7 @@
import { renderTree } from '@open-pencil/core/design-jsx'
import { computeAllLayouts } from '@open-pencil/core/layout'
import { reconcileRenderedLayers, type TreeNode } from '@open-pencil/design-jsx'
import type { Vector } from '@open-pencil/scene-graph'
import type { SceneNode, Vector } from '@open-pencil/scene-graph'
import { convertDesignJSXRoots } from '@/app/code/sandbox/convert'
import { evaluateDesignJSX } from '@/app/code/sandbox/evaluate'
@ -140,6 +140,26 @@ export async function previewDesignJSX(
}
}
/**
* Applies canvas edits made while the code waited to render, which its preview drew over. They
* came after the code, so they win, and join the edit's undo step. Layers the preview removed
* are skipped.
*/
export function reapplyCanvasEdits(
store: EditorStore,
session: DesignJSXEditSession,
edits: ReadonlyMap<string, Partial<SceneNode>>
): void {
const present = [...edits].filter(([id]) => store.graph.getNode(id))
if (present.length === 0) return
const before = store.snapshotPage()
const selection = [...store.state.selectedIds]
for (const [id, changes] of present) store.graph.updateNode(id, changes)
computeAllLayouts(store.graph, store.state.currentPageId)
store.requestRender()
recordPreview(store, session, before, selection)
}
function sameIds(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((id, index) => id === b[index])
}

View file

@ -185,6 +185,8 @@ function fixLabel(fix: LintFix, messages: Messages): string {
if (fix.kind === 'bind-variable') {
return messages.bindVariable({ variable: fix.variableName })
}
if (fix.kind === 'convert-to-frame') return messages.fixConvertToFrame
if (fix.kind === 'delete') return messages.fixDeleteLayer
const changes = Object.entries(fix.changes)
// Geometry changes only come from rounding to whole pixels.
if (changes.length !== 1 || changes.some(([property]) => property in GEOMETRY_LABELS)) {

View file

@ -7,12 +7,14 @@ import {
onBeforeUnmount,
ref,
shallowRef,
nextTick,
useTemplateRef,
watch
} from 'vue'
import { JSX_REFERENCE } from '@open-pencil/design-jsx'
import { useI18n } from '@open-pencil/vue'
import type { SceneNode } from '@open-pencil/scene-graph'
import { useEditorEvent, useI18n } from '@open-pencil/vue'
import {
commitDOMCodeSession,
@ -26,6 +28,7 @@ import { useCodeLayers } from '@/app/code/layers/use'
import {
createDesignJSXEditSession,
previewDesignJSX,
reapplyCanvasEdits,
resetDesignJSXPreview,
type DesignJSXEditSession,
type DesignJSXLayerLine
@ -65,6 +68,30 @@ let previewing = 0
let previewedVersion: number | null = null
/** The selection the code was generated for. */
let followedSelection: string | null = null
/** Code typed since the last preview started, which the canvas does not show yet. */
let previewDue = false
/**
* Canvas edits made while code waits to render. Code replaced as a whole has no links to patch
* them into until its preview links it again, so they are applied once more after that preview.
*/
let canvasEdits = new Map<string, Partial<SceneNode>>()
useEditorEvent('node:updated', (id, changes) => {
if (!previewDue || previewing > 0 || source.value !== 'design-jsx') return
canvasEdits.set(id, { ...canvasEdits.get(id), ...structuredClone(changes) })
})
function takeCanvasEdits(): Map<string, Partial<SceneNode>> {
const edits = canvasEdits
canvasEdits = new Map()
previewDue = false
return edits
}
/** Hands edits taken by a superseded or failed preview to the next one, behind any made since. */
function returnCanvasEdits(edits: ReadonlyMap<string, Partial<SceneNode>>) {
for (const [id, changes] of edits) canvasEdits.set(id, { ...changes, ...canvasEdits.get(id) })
}
/** Set by "Write JSX": the editor stays open for new layers until something is selected. */
const composing = ref(false)
@ -141,6 +168,7 @@ async function commitCurrentSession(): Promise<void> {
await pendingPreview
await previewQueue
updateVersion += 1
takeCanvasEdits()
const dom = domSession
designSession.value = null
domSession = null
@ -157,6 +185,7 @@ async function commitCurrentSession(): Promise<void> {
async function runPreview(version: number): Promise<void> {
if (version !== updateVersion || readOnly.value || !draft.value.trim()) return
const edits = takeCanvasEdits()
status.value = 'updating'
error.value = ''
previewing += 1
@ -178,14 +207,26 @@ async function runPreview(version: number): Promise<void> {
previewedVersion = store.state.sceneVersion
followedSelection = selectionKey.value
}
if (version !== updateVersion) return
if (version !== updateVersion) {
returnCanvasEdits(edits)
return
}
if (previewLayers) codeLayers.showPreview(previewLayers)
if (!result.ok) {
status.value = 'error'
error.value = result.error
// The code still waits to render, so its next preview applies these and later edits.
previewDue = true
returnCanvasEdits(edits)
return
}
status.value = 'updated'
const session = designSession.value
if (!session || edits.size === 0) return
// Once the code is linked again, the edits reach it as canvas changes do.
await nextTick()
if (version === updateVersion) reapplyCanvasEdits(store, session, edits)
else returnCanvasEdits(edits)
}
const schedulePreview = useDebounceFn(
@ -204,6 +245,7 @@ function updateDraft(value: string, origin: 'user' | 'layers'): void {
edited.value = true
error.value = ''
updateVersion += 1
previewDue = true
pendingPreview = schedulePreview(updateVersion)
}
@ -211,6 +253,7 @@ async function resetDraft(): Promise<void> {
updateVersion += 1
await pendingPreview
await previewQueue
takeCanvasEdits()
const design = designSession.value
const dom = domSession
designSession.value = null
@ -255,7 +298,7 @@ function showCanvas(next: Exclude<CodeSource, 'html-css'>) {
* generated again; code the person wrote is patched where layers changed, keeping the rest as
* written. Another selection starts over with its generated code.
*/
function followCanvas() {
function followCanvas(live = false) {
const current = source.value
if (!editorActive.value || current === 'html-css' || previewing > 0) return
if (selectionKey.value !== followedSelection) {
@ -266,15 +309,27 @@ function followCanvas() {
showCanvas(current)
return
}
if (store.state.sceneVersion === previewedVersion) return
// A live preview leaves the scene version alone, so it is followed whatever the version.
if (!live && store.state.sceneVersion === previewedVersion) return
codeEditor.value?.patchFromLayers()
}
watch([() => store.state.sceneVersion, selectionKey, source, editorActive], followCanvas, {
watch([() => store.state.sceneVersion, selectionKey, source, editorActive], () => followCanvas(), {
immediate: true
})
/** Dragging or scrubbing a value previews it; the code follows once per frame, like the Design panel. */
let livePreviewFrame = 0
useEditorEvent('node:previewUpdated', () => {
if (livePreviewFrame) return
livePreviewFrame = requestAnimationFrame(() => {
livePreviewFrame = 0
followCanvas(true)
})
})
onBeforeUnmount(() => {
cancelAnimationFrame(livePreviewFrame)
void commitCurrentSession()
})

View file

@ -74,6 +74,16 @@ const styles = designCheck()
class="size-3"
aria-hidden="true"
/>
<icon-lucide-frame
v-else-if="row.action.kind === 'convert-to-frame'"
class="size-3"
aria-hidden="true"
/>
<icon-lucide-trash-2
v-else-if="row.action.kind === 'delete'"
class="size-3"
aria-hidden="true"
/>
<icon-lucide-wand-sparkles v-else class="size-3" aria-hidden="true" />
</button>
</Tip>

View file

@ -352,3 +352,64 @@ test('a reorder that cannot move the code still writes layers added with it', as
'Frame'
)
})
test('a canvas edit right after replacing all the code survives its preview', async () => {
await buildScene(editor.page)
await openCode(editor.page)
const code = (await codeText(editor.page)).replace(/(name="Card"[^>]*?) h=\{160\}/, '$1 h={170}')
await codeLine(editor.page, 'name="Card"').click()
await editor.page.keyboard.press('ControlOrMeta+a')
await editor.page.keyboard.insertText(code)
// Before the preview runs, the replaced code has no links to patch.
await editLayer(editor.page, 'Card', { width: 400 })
await expect.poll(() => cardSize(editor.page)).toBe('400x170')
await expect(codeLine(editor.page, 'name="Card"')).toContainText('w={400}')
await expect(codeLine(editor.page, 'name="Card"')).toContainText('h={170}')
// The edit applied again belongs to the code's undo step, which leaves the canvas edit.
await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction())
await expect.poll(() => cardSize(editor.page)).toBe('400x160')
})
test('code follows a value while it is dragged and goes back when the drag is cancelled', async () => {
await buildScene(editor.page)
await openCode(editor.page)
const [during, after] = await editor.page.evaluate(async () => {
const store = window.openPencil?.getStore?.()
const card = [...(store?.graph.getAllNodes() ?? [])].find((node) => node.name === 'Card')
if (!store || !card) throw new Error('Card not found')
const frames = async (count: number) => {
for (let i = 0; i < count; i++) await new Promise(requestAnimationFrame)
}
const code = () => document.querySelector('[data-slot="code-editor"] .cm-content')?.textContent
const preview = store.beginNodePreview('Resize')
preview.update(card.id, { width: 360 })
await frames(3)
const live = code()
preview.cancel()
await frames(3)
return [live, code()]
})
expect(during).toContain('w={360}')
expect(after).toContain('w={320}')
})
test('a canvas edit while replaced code does not render survives the corrected code', async () => {
await buildScene(editor.page)
await openCode(editor.page)
const valid = (await codeText(editor.page)).replace(/(name="Card"[^>]*?) h=\{160\}/, '$1 h={170}')
await codeLine(editor.page, 'name="Card"').click()
await editor.page.keyboard.press('ControlOrMeta+a')
await editor.page.keyboard.insertText(`${valid}\n<Frame`)
await expect(editor.page.getByTestId('code-panel-status')).toHaveText('Preview failed')
await editLayer(editor.page, 'Card', { width: 400 })
await editor.page.keyboard.press('ControlOrMeta+a')
await editor.page.keyboard.insertText(valid)
await expect.poll(() => cardSize(editor.page)).toBe('400x170')
await expect(codeLine(editor.page, 'name="Card"')).toContainText('w={400}')
})

View file

@ -35,6 +35,16 @@ describe('property access', () => {
expect(rect.fills[0].color.r).toBe(1)
})
test('paints default to full opacity and visible, as in Figma', () => {
const api = createAPI()
const rect = api.createRectangle()
// Plugin scripts may leave out `opacity` and `visible`; Figma fills them in.
Reflect.set(rect, 'fills', [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }])
Reflect.set(rect, 'strokes', [{ type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 0.5 }])
expect(rect.fills[0]).toMatchObject({ opacity: 1, visible: true })
expect(rect.strokes[0]).toMatchObject({ opacity: 0.5, visible: true })
})
test('opacity get/set', () => {
const api = createAPI()
const frame = api.createFrame()

View file

@ -33,6 +33,10 @@ export function createDevServerOptions(host: string | undefined, rootDir: string
port: 1421
}
: undefined,
// Vite forwards browser logs to the terminal when an agent starts it. Vue warnings carry the
// component's props, and serializing the editor state behind them freezes the tab, so only
// errors are forwarded.
forwardConsole: { unhandledErrors: true, logLevels: ['error'] },
watch: {
// Ignore nested checkouts, not an active checkout whose own path contains .worktrees.
ignored: [...WATCH_IGNORED, `${normalizePath(resolve(rootDir, '.worktrees'))}/**`]