diff --git a/packages/core/src/figma-api-proxy.ts b/packages/core/src/figma-api-proxy.ts index d703d2114..cfd75db9e 100644 --- a/packages/core/src/figma-api-proxy.ts +++ b/packages/core/src/figma-api-proxy.ts @@ -8,6 +8,7 @@ import type { Effect, LayoutMode, } from './scene-graph' +import { normalizeColor } from './color' import type { Rect } from './types' import { copyFills, copyStrokes, copyEffects } from './copy' @@ -167,7 +168,13 @@ export class FigmaNodeProxy { } set fills(v: readonly Fill[]) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fills: [...v] }) + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + fills: v.map((f) => ({ + ...f, + color: normalizeColor(f.color), + gradientStops: f.gradientStops?.map((s) => ({ ...s, color: normalizeColor(s.color) })) + })) + }) } get strokes(): readonly Stroke[] { @@ -175,7 +182,9 @@ export class FigmaNodeProxy { } set strokes(v: readonly Stroke[]) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes: [...v] }) + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + strokes: v.map((s) => ({ ...s, color: normalizeColor(s.color) })) + }) } get effects(): readonly Effect[] { @@ -183,7 +192,9 @@ export class FigmaNodeProxy { } set effects(v: readonly Effect[]) { - this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { effects: [...v] }) + this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { + effects: v.map((e) => ({ ...e, color: normalizeColor(e.color) })) + }) } get opacity(): number { diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index ccad25b34..32cacab06 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -277,6 +277,7 @@ export interface NodeChange { // Frame clipsContent?: boolean frameMaskDisabled?: boolean + resizeToFit?: boolean // Vector vectorData?: unknown fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }> diff --git a/packages/core/src/kiwi/instance-overrides.ts b/packages/core/src/kiwi/instance-overrides.ts index 17023b434..0533de4cc 100644 --- a/packages/core/src/kiwi/instance-overrides.ts +++ b/packages/core/src/kiwi/instance-overrides.ts @@ -64,38 +64,55 @@ export function populateAndApplyOverrides( guidToNodeId: Map, blobs: Uint8Array[] = [] ): void { - // Work-queue population: seed with empty instances, then discover newly - // cloned nested instances by walking the subtree (avoids full graph scan per round). - const populateQueue: string[] = [] - for (const node of graph.getAllNodes()) { - if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { - populateQueue.push(node.id) - } - } + // Populate empty INSTANCE nodes from their source components. Instances + // must be populated bottom-up: if an instance's source is itself an + // unpopulated instance, populate the source first so cloned children + // are complete. + function ensurePopulated(nodeId: string, visiting: Set): void { + const node = graph.getNode(nodeId) + if (!node || node.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) return + if (visiting.has(nodeId)) return + visiting.add(nodeId) - function collectEmptyInstances(parentId: string, out: string[]) { - const parent = graph.getNode(parentId) - if (!parent) return - for (const childId of parent.childIds) { + const comp = graph.getNode(node.componentId) + if (!comp) return + + // If the source is an unpopulated instance, populate it first + if (comp.type === 'INSTANCE' && comp.componentId && comp.childIds.length === 0) { + ensurePopulated(comp.id, visiting) + } + // Also ensure children of the source are populated (nested instances) + for (const childId of comp.childIds) { const child = graph.getNode(childId) - if (!child) continue - if (child.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) { - out.push(child.id) - } else if (child.childIds.length > 0) { - collectEmptyInstances(childId, out) + if (child?.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) { + ensurePopulated(childId, visiting) } } + + if (comp.childIds.length > 0 && node.childIds.length === 0) { + graph.populateInstanceChildren(nodeId, node.componentId) + } } - while (populateQueue.length > 0) { - const nodeId = populateQueue.pop() - if (!nodeId) continue - const node = graph.getNode(nodeId) - if (node?.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) continue - const comp = graph.getNode(node.componentId) - if (comp && comp.childIds.length > 0) { - graph.populateInstanceChildren(nodeId, node.componentId) - collectEmptyInstances(nodeId, populateQueue) + const visiting = new Set() + for (const node of graph.getAllNodes()) { + if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { + ensurePopulated(node.id, visiting) + } + } + // Second pass: cloning may have introduced new empty instances not seen + // in the first pass (nested clones). Repeat until stable. + let changed = true + while (changed) { + changed = false + for (const node of graph.getAllNodes()) { + if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { + const comp = graph.getNode(node.componentId) + if (comp && comp.childIds.length > 0) { + graph.populateInstanceChildren(node.id, node.componentId) + changed = true + } + } } } @@ -250,9 +267,17 @@ export function populateAndApplyOverrides( const node = graph.getNode(nodeId) if (node?.type !== 'INSTANCE') return + // Only rename when the current name matches the root component name + // (i.e. it wasn't manually overridden by the user). + const rootCompId = node.componentId ? getComponentRoot(node.componentId) : undefined + const rootComp = rootCompId ? graph.getNode(rootCompId) : undefined for (const childId of Array.from(node.childIds)) graph.deleteNode(childId) - graph.updateNode(nodeId, { componentId: compId }) const comp = graph.getNode(compId) + const updates: Partial = { componentId: compId } + if (comp?.name && rootComp?.name && node.name === rootComp.name) { + updates.name = comp.name + } + graph.updateNode(nodeId, updates) if (comp && comp.childIds.length > 0) { graph.populateInstanceChildren(nodeId, compId) } @@ -282,15 +307,24 @@ export function populateAndApplyOverrides( } } - // Also apply from cloned instance sources — after population, cloned - // instances have componentId pointing to the original kiwi node + // Walk the componentId chain to find a kiwi source with assignments. + // Cloned instances may be several levels deep (clone of clone of …), + // so a single-hop lookup is insufficient. if (!node.componentId) continue - const sourceFigmaId = nodeIdToGuid.get(node.componentId) - if (!sourceFigmaId) continue - const assignments = assignmentSources.get(sourceFigmaId) - if (!assignments) continue - - applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap) + let sourceId: string | undefined = node.componentId + for (let depth = 0; sourceId && depth < 20; depth++) { + const figmaId = nodeIdToGuid.get(sourceId) + if (figmaId) { + const assignments = assignmentSources.get(figmaId) + if (assignments) { + applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap) + break + } + } + const n = graph.getNode(sourceId) + if (!n?.componentId || n.componentId === sourceId) break + sourceId = n.componentId + } } } @@ -506,6 +540,11 @@ export function populateAndApplyOverrides( propagateDsdChanges(dsdModified, dsdSizeSet) } + // Tracks INSTANCE nodes whose componentId was changed by a swap override. + // Populated in applySymbolOverrides and recloneChildren, read in syncChildrenDeep + // to propagate swaps transitively through clone chains. + const swappedInstances = new Set() + function applySymbolOverrides(): Set { const overriddenNodes = new Set() componentIdRoot.clear() @@ -528,8 +567,12 @@ export function populateAndApplyOverrides( overriddenNodes.add(targetId) if (ov.overriddenSymbolID) { - const newCompId = guidToNodeId.get(guidToString(ov.overriddenSymbolID)) - if (newCompId) repopulateInstance(targetId, newCompId) + const swapGuid = guidToString(ov.overriddenSymbolID) + const newCompId = guidToNodeId.get(swapGuid) + if (newCompId) { + repopulateInstance(targetId, newCompId) + swappedInstances.add(targetId) + } } const { guidPath: _, overriddenSymbolID: _s, componentPropAssignments: _c, ...fields } = ov @@ -559,6 +602,19 @@ export function populateAndApplyOverrides( if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates) } + function recloneChildren(srcChildId: string, tgtNode: SceneNode) { + const srcChild = graph.getNode(srcChildId) + if (!srcChild) return + + for (const childId of [...tgtNode.childIds]) graph.deleteNode(childId) + graph.updateNode(tgtNode.id, { name: srcChild.name, componentId: srcChild.componentId }) + syncNodeProps(srcChild, tgtNode) + if (srcChild.childIds.length > 0) { + graph.populateInstanceChildren(tgtNode.id, srcChildId) + } + swappedInstances.add(tgtNode.id) + } + function syncChildrenDeep(sourceId: string, targetId: string, skip?: Set) { const src = graph.getNode(sourceId) const tgt = graph.getNode(targetId) @@ -569,6 +625,12 @@ export function populateAndApplyOverrides( const srcNode = graph.getNode(src.childIds[i]) const tgtNode = graph.getNode(tgt.childIds[i]) if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue + + if (srcNode.type === 'INSTANCE' && swappedInstances.has(src.childIds[i]) && srcNode.componentId !== tgtNode.componentId) { + recloneChildren(src.childIds[i], tgtNode) + continue + } + syncNodeProps(srcNode, tgtNode) syncChildrenDeep(src.childIds[i], tgt.childIds[i], skip) } @@ -635,6 +697,8 @@ export function populateAndApplyOverrides( function propagateOverridesTransitively(seeds: Set) { if (seeds.size === 0) return + // Stale after applySymbolOverrides changed componentIds via repopulateInstance + componentIdRoot.clear() const clonesOf = buildClonesMap() const expandedSeeds = expandSeedsToParents(seeds) const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf) @@ -666,11 +730,9 @@ export function populateAndApplyOverrides( } // Order matters: - // 1. symbolOverrides — set property values and swap instances - // 2. transitive sync — propagate overrides through clone chains (may - // repopulate INSTANCE children, wiping any earlier property changes) + // 1. symbolOverrides — set property values and swap instances (kiwi + clones) + // 2. transitive sync — propagate overrides through remaining clone chains // 3. componentProperties — toggle visibility / swap via prop assignments - // (must run AFTER sync so repopulated children aren't lost) // 4. derivedSymbolData — apply Figma's pre-computed sizes last const overriddenNodes = applySymbolOverrides() @@ -678,7 +740,5 @@ export function populateAndApplyOverrides( applyComponentProperties() - // DSD resolution runs AFTER overrides so guidPaths can reach children - // of instance-swapped nodes (repopulateInstance replaces children). applyDerivedSymbolData() } diff --git a/packages/core/src/kiwi/kiwi-convert.ts b/packages/core/src/kiwi/kiwi-convert.ts index 46004bf4c..22ec9c517 100644 --- a/packages/core/src/kiwi/kiwi-convert.ts +++ b/packages/core/src/kiwi/kiwi-convert.ts @@ -605,7 +605,7 @@ export function nodeChangeToProps( expanded: true, autoRename: (nc.autoRename ?? true) as boolean, boundVariables: extractBoundVariables(nc), - clipsContent: nc.frameMaskDisabled === false, + clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true, componentId: extractSymbolId(nc) } } diff --git a/packages/core/src/renderer/fills.ts b/packages/core/src/renderer/fills.ts index d91975448..f7295c2b1 100644 --- a/packages/core/src/renderer/fills.ts +++ b/packages/core/src/renderer/fills.ts @@ -101,40 +101,35 @@ export function applyGradientFill(r: SkiaRenderer, fill: Fill, node: SceneNode): r.ck.TileMode.Clamp ) r.fillPaint.setShader(shader) - } else if (fill.type === 'GRADIENT_RADIAL') { - const cx = t.m02 * w - const cy = t.m12 * h - const radius = Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * Math.max(w, h) - const shader = r.ck.Shader.MakeRadialGradient( - [cx, cy], - radius, - colors, - positions, - r.ck.TileMode.Clamp + } else if (fill.type === 'GRADIENT_RADIAL' || fill.type === 'GRADIENT_DIAMOND') { + // Figma's gradientTransform maps gradient space (center 0.5,0.5, radius 0.5) + // to the node's normalized [0,1] coordinate space. The full local matrix + // converts to pixel coordinates: scale(w, h) * gradientTransform. + const localMatrix = r.ck.Matrix.multiply( + r.ck.Matrix.scaled(w, h), + [t.m00, t.m01, t.m02, t.m10, t.m11, t.m12, 0, 0, 1] ) - r.fillPaint.setShader(shader) - } else if (fill.type === 'GRADIENT_ANGULAR') { - const cx = t.m02 * w - const cy = t.m12 * h - const shader = r.ck.Shader.MakeSweepGradient( - cx, - cy, + const shader = r.ck.Shader.MakeRadialGradient( + [0.5, 0.5], + 0.5, colors, positions, r.ck.TileMode.Clamp, - undefined + localMatrix ) r.fillPaint.setShader(shader) - } else if (fill.type === 'GRADIENT_DIAMOND') { - const cx = t.m02 * w - const cy = t.m12 * h - const radius = Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * Math.max(w, h) - const shader = r.ck.Shader.MakeRadialGradient( - [cx, cy], - radius, + } else if (fill.type === 'GRADIENT_ANGULAR') { + const localMatrix = r.ck.Matrix.multiply( + r.ck.Matrix.scaled(w, h), + [t.m00, t.m01, t.m02, t.m10, t.m11, t.m12, 0, 0, 1] + ) + const shader = r.ck.Shader.MakeSweepGradient( + 0.5, + 0.5, colors, positions, - r.ck.TileMode.Clamp + r.ck.TileMode.Clamp, + localMatrix ) r.fillPaint.setShader(shader) } diff --git a/packages/core/src/renderer/scene.ts b/packages/core/src/renderer/scene.ts index 64ff0e51f..cde1a56e8 100644 --- a/packages/core/src/renderer/scene.ts +++ b/packages/core/src/renderer/scene.ts @@ -141,7 +141,9 @@ export function renderNode( canvas.saveLayer(r.opacityPaint) } - const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR') + const layerBlur = node.effects.find( + (e) => e.visible && (e.type === 'LAYER_BLUR' || e.type === 'FOREGROUND_BLUR') + ) if (layerBlur) { r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2)) canvas.saveLayer(r.effectLayerPaint) @@ -443,10 +445,7 @@ export function renderEffects( } } - if ( - (pass === 'behind' && effect.type === 'BACKGROUND_BLUR') || - (pass === 'front' && effect.type === 'FOREGROUND_BLUR') - ) { + if (pass === 'behind' && effect.type === 'BACKGROUND_BLUR') { r.applyClippedBlur(canvas, node, rect, hasRadius, effect.radius / 2) } diff --git a/tests/e2e/design-panel.spec.ts b/tests/e2e/design-panel.spec.ts index 60c2d7861..906eb281a 100644 --- a/tests/e2e/design-panel.spec.ts +++ b/tests/e2e/design-panel.spec.ts @@ -87,6 +87,31 @@ test('fill item shows color swatch', async () => { await expect(swatch).toBeVisible() }) +test('clicking color area changes fill color', async () => { + const id = await getSelectedId() + const before = await getNode(id!) + + const swatch = fillSection().locator('[data-test-id="fill-picker-swatch"]').first() + await swatch.click() + + const colorArea = page.locator('.cursor-crosshair').first() + await expect(colorArea).toBeVisible({ timeout: 5000 }) + + const box = await colorArea.boundingBox() + await page.mouse.click(box!.x + box!.width - 10, box!.y + 10) + await canvas.waitForRender() + await page.waitForTimeout(100) + + const after = await getNode(id!) + const c1 = before!.fills[0].color + const c2 = after!.fills[0].color + expect(c1.r !== c2.r || c1.g !== c2.g || c1.b !== c2.b).toBe(true) + + // Close popover — click the swatch again to toggle it off + await swatch.click() + await canvas.waitForRender() +}) + test('adding a stroke creates stroke section item', async () => { const addBtn = strokeSection().locator('[data-test-id="stroke-section-add"]') await addBtn.click() diff --git a/tests/engine/fig-import.test.ts b/tests/engine/fig-import.test.ts index 736de4ad3..fed4fe890 100644 --- a/tests/engine/fig-import.test.ts +++ b/tests/engine/fig-import.test.ts @@ -391,3 +391,32 @@ describe('fig-import: component set detection', () => { expect(frame.type).toBe('FRAME') }) }) + +describe('fig-import: clipsContent with resizeToFit', () => { + test('regular FRAME with frameMaskDisabled=false clips content', () => { + const graph = importNodeChanges([ + doc(), canvas(), + node('FRAME', 10, 1, { frameMaskDisabled: false }), + ]) + const frame = graph.getChildren(graph.getPages()[0].id)[0] + expect(frame.clipsContent).toBe(true) + }) + + test('resizeToFit FRAME does not clip even with frameMaskDisabled=false', () => { + const graph = importNodeChanges([ + doc(), canvas(), + node('FRAME', 10, 1, { frameMaskDisabled: false, resizeToFit: true }), + ]) + const frame = graph.getChildren(graph.getPages()[0].id)[0] + expect(frame.clipsContent).toBe(false) + }) + + test('FRAME with frameMaskDisabled=true does not clip', () => { + const graph = importNodeChanges([ + doc(), canvas(), + node('FRAME', 10, 1, { frameMaskDisabled: true }), + ]) + const frame = graph.getChildren(graph.getPages()[0].id)[0] + expect(frame.clipsContent).toBe(false) + }) +}) diff --git a/tests/engine/instance-overrides.test.ts b/tests/engine/instance-overrides.test.ts new file mode 100644 index 000000000..a2853dec4 --- /dev/null +++ b/tests/engine/instance-overrides.test.ts @@ -0,0 +1,241 @@ +import { describe, test, expect } from 'bun:test' + +import { importNodeChanges, type NodeChange } from '@open-pencil/core' + +const ID = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } +const SIZE = { x: 100, y: 100 } + +function nc( + sessionID: number, + localID: number, + type: string, + parentSessionID: number, + parentLocalID: number, + name: string, + extra: Record = {} +): NodeChange { + return { + guid: { sessionID, localID }, + parentIndex: { + guid: { sessionID: parentSessionID, localID: parentLocalID }, + position: String.fromCharCode(33 + localID), + }, + type, + name, + visible: true, + opacity: 1, + phase: 'CREATED', + size: SIZE, + transform: ID, + ...extra, + } as NodeChange +} + +function guid(s: number, l: number) { + return { sessionID: s, localID: l } +} + +/** + * Build a minimal fig structure for testing instance swap overrides: + * + * Document (0:0) + * ├── InternalPage (0:1) [internalOnly] + * │ ├── IconA COMPONENT (0:10) — children: VectorA (0:11) + * │ ├── IconB COMPONENT (0:20) — children: VectorB (0:21) + * │ └── Button COMPONENT (0:30) — children: Icon INSTANCE→IconA (0:31) + * └── Page1 (0:2) + * └── ButtonInstance INSTANCE→Button (0:40) + * symbolOverride: swap Icon to IconB + */ +function swapOverrideFixture(opts?: { customName?: string }): NodeChange[] { + return [ + nc(0, 0, 'DOCUMENT', 0, 0, 'Document'), + nc(0, 1, 'CANVAS', 0, 0, 'InternalPage', { internalOnly: true }), + nc(0, 2, 'CANVAS', 0, 0, 'Page1'), + + // IconA component with a vector child + nc(0, 10, 'COMPONENT', 0, 1, 'IconA'), + nc(0, 11, 'VECTOR', 0, 10, 'VectorA'), + + // IconB component with a vector child + nc(0, 20, 'COMPONENT', 0, 1, 'IconB'), + nc(0, 21, 'VECTOR', 0, 20, 'VectorB'), + + // Button component containing an Icon instance pointing to IconA + nc(0, 30, 'COMPONENT', 0, 1, 'Button'), + nc(0, 31, 'INSTANCE', 0, 30, opts?.customName ?? 'IconA', { + symbolData: { symbolID: guid(0, 10) }, + }), + + // Visible-page instance of Button with a swap override: Icon→IconB + nc(0, 40, 'INSTANCE', 0, 2, 'ButtonInstance', { + symbolData: { + symbolID: guid(0, 30), + symbolOverrides: [ + { + guidPath: { guids: [guid(0, 31)] }, + overriddenSymbolID: guid(0, 20), + }, + ], + }, + }), + ] +} + +describe('instance swap overrides', () => { + test('swap override renames icon and reclones children', () => { + const graph = importNodeChanges(swapOverrideFixture()) + const page = graph.getPages().find((p) => p.name === 'Page1')! + const button = graph.getChildren(page.id)[0] + expect(button.name).toBe('ButtonInstance') + + const icon = graph.getChildren(button.id)[0] + expect(icon.name).toBe('IconB') + expect(icon.type).toBe('INSTANCE') + + const iconChildren = graph.getChildren(icon.id) + expect(iconChildren.length).toBe(1) + expect(iconChildren[0].name).toBe('VectorB') + }) + + test('swap override preserves user-given name', () => { + const graph = importNodeChanges(swapOverrideFixture({ customName: 'MyCustomIcon' })) + const page = graph.getPages().find((p) => p.name === 'Page1')! + const button = graph.getChildren(page.id)[0] + const icon = graph.getChildren(button.id)[0] + + // Name was "MyCustomIcon" which doesn't match root component "IconA", + // so it should NOT be renamed to "IconB" + expect(icon.name).toBe('MyCustomIcon') + + // Children should still be swapped to IconB's children + const iconChildren = graph.getChildren(icon.id) + expect(iconChildren.length).toBe(1) + expect(iconChildren[0].name).toBe('VectorB') + }) + + test('swap propagates transitively through 2-level clone chain', () => { + // Add a second instance that clones the first button instance + const nodes = [ + ...swapOverrideFixture(), + // Wrapper COMPONENT on internal page that contains a Button instance + nc(0, 50, 'COMPONENT', 0, 1, 'Wrapper'), + nc(0, 51, 'INSTANCE', 0, 50, 'ButtonInstance', { + symbolData: { symbolID: guid(0, 30) }, + }), + // WrapperInstance on visible page with swap override on the button's icon + nc(0, 60, 'INSTANCE', 0, 2, 'WrapperInstance', { + symbolData: { + symbolID: guid(0, 50), + symbolOverrides: [ + { + guidPath: { guids: [guid(0, 51), guid(0, 31)] }, + overriddenSymbolID: guid(0, 20), + }, + ], + }, + }), + ] + + const graph = importNodeChanges(nodes) + const page = graph.getPages().find((p) => p.name === 'Page1')! + const children = graph.getChildren(page.id) + + const wrapper = children.find((c) => c.name === 'WrapperInstance')! + expect(wrapper).toBeDefined() + + // WrapperInstance > ButtonInstance > icon + const wrapperButton = graph.getChildren(wrapper.id)[0] + expect(wrapperButton.name).toBe('ButtonInstance') + + const icon = graph.getChildren(wrapperButton.id)[0] + expect(icon.name).toBe('IconB') + + const iconChildren = graph.getChildren(icon.id) + expect(iconChildren.length).toBe(1) + expect(iconChildren[0].name).toBe('VectorB') + }) + + test('swap propagates to sibling clone at same level', () => { + // Two Button instances on the visible page, both cloning the same component. + // Only one has a swap override — the other should keep the default icon. + const nodes: NodeChange[] = [ + ...swapOverrideFixture(), + // Second ButtonInstance with NO swap override + nc(0, 41, 'INSTANCE', 0, 2, 'ButtonDefault', { + symbolData: { symbolID: guid(0, 30) }, + }), + ] + + const graph = importNodeChanges(nodes) + const page = graph.getPages().find((p) => p.name === 'Page1')! + const children = graph.getChildren(page.id) + + const swapped = children.find((c) => c.name === 'ButtonInstance')! + const swappedIcon = graph.getChildren(swapped.id)[0] + expect(swappedIcon.name).toBe('IconB') + + const defaultBtn = children.find((c) => c.name === 'ButtonDefault')! + const defaultIcon = graph.getChildren(defaultBtn.id)[0] + expect(defaultIcon.name).toBe('IconA') + }) + + test('transitive propagation: clone of swapped clone gets swap', () => { + // ButtonInstance (swap Icon→IconB) on internal page, + // then ButtonClone on visible page clones ButtonInstance. + // ButtonClone's icon should also be IconB. + const nodes: NodeChange[] = [ + nc(0, 0, 'DOCUMENT', 0, 0, 'Document'), + nc(0, 1, 'CANVAS', 0, 0, 'InternalPage', { internalOnly: true }), + nc(0, 2, 'CANVAS', 0, 0, 'Page1'), + + nc(0, 10, 'COMPONENT', 0, 1, 'IconA'), + nc(0, 11, 'VECTOR', 0, 10, 'VectorA'), + nc(0, 20, 'COMPONENT', 0, 1, 'IconB'), + nc(0, 21, 'VECTOR', 0, 20, 'VectorB'), + + // Button component: children = Icon INSTANCE→IconA + nc(0, 30, 'COMPONENT', 0, 1, 'Button'), + nc(0, 31, 'INSTANCE', 0, 30, 'IconA', { + symbolData: { symbolID: guid(0, 10) }, + }), + + // ButtonSwapped: instance of Button on internal page with swap + nc(0, 40, 'INSTANCE', 0, 1, 'ButtonSwapped', { + symbolData: { + symbolID: guid(0, 30), + symbolOverrides: [ + { + guidPath: { guids: [guid(0, 31)] }, + overriddenSymbolID: guid(0, 20), + }, + ], + }, + }), + + // Container component on internal page wrapping ButtonSwapped + nc(0, 50, 'COMPONENT', 0, 1, 'Container'), + nc(0, 51, 'INSTANCE', 0, 50, 'ButtonSwapped', { + symbolData: { symbolID: guid(0, 40) }, + }), + + // Visible-page instance of Container + nc(0, 60, 'INSTANCE', 0, 2, 'ContainerInstance', { + symbolData: { symbolID: guid(0, 50) }, + }), + ] + + const graph = importNodeChanges(nodes) + const page = graph.getPages().find((p) => p.name === 'Page1')! + const container = graph.getChildren(page.id)[0] + + // ContainerInstance > ButtonSwapped > icon + const button = graph.getChildren(container.id)[0] + const icon = graph.getChildren(button.id)[0] + expect(icon.name).toBe('IconB') + + const iconChildren = graph.getChildren(icon.id) + expect(iconChildren.length).toBe(1) + expect(iconChildren[0].name).toBe('VectorB') + }) +}) diff --git a/tests/engine/renderer-effects.test.ts b/tests/engine/renderer-effects.test.ts index fb10c65f6..ae910d3ff 100644 --- a/tests/engine/renderer-effects.test.ts +++ b/tests/engine/renderer-effects.test.ts @@ -44,8 +44,8 @@ describe('Renderer handles all effect types', () => { expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'") }) - test('handles FOREGROUND_BLUR', () => { - expect(rendererSource).toContain("effect.type === 'FOREGROUND_BLUR'") + test('handles FOREGROUND_BLUR as layer blur', () => { + expect(rendererSource).toContain("e.type === 'FOREGROUND_BLUR'") }) }) @@ -107,9 +107,8 @@ describe('Blur effects use saveLayer pattern', () => { expect(layerBlurSection).toContain('saveLayer') }) - test('background and foreground blur use applyClippedBlur', () => { + test('background blur uses applyClippedBlur', () => { expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'") - expect(rendererSource).toContain("effect.type === 'FOREGROUND_BLUR'") expect(rendererSource).toContain('applyClippedBlur') })