fix: default TEXT nodes to solid black fill

TEXT nodes created via SceneGraph.createNode() defaulted to empty
fills, causing text to be invisible when .fig files are opened in
Figma. Now defaults to a solid black fill matching Figma's behavior.

Closes #133
This commit is contained in:
Shai Rubinstein 2026-03-16 20:15:02 +02:00 committed by Danila Poyarkov
parent 2efd63ea09
commit 5139836816
3 changed files with 71 additions and 1 deletions

View file

@ -17,6 +17,7 @@
- Fix browser/headless rendering mismatch for imported toolbar/instance content by correcting runtime imported layout recomputation instead of diverging browser rendering behavior
- Fix `set_layout` tool not defaulting to HUG sizing when enabling auto-layout — frames now shrink/grow to fit children instead of keeping fixed dimensions
- Normalize font family names on `.fig` export — strip optical size suffixes (e.g. "DM Sans 9pt" → "DM Sans") so Figma recognizes the font
- TEXT nodes now default to a solid black fill — previously exported with no fill, making text invisible when opened in Figma
- Fix save crash when COLOR variable is missing alpha channel
- Fix console error spam on deployed web app from automation WebSocket reconnect loop
- Fix headless CLI font fallback — bundled Inter font now ships with `@open-pencil/core` and loads without a web server

View file

@ -371,7 +371,9 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
width: 100,
height: 100,
rotation: 0,
fills: [],
fills: type === 'TEXT'
? [{ type: 'SOLID' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
: [],
strokes: [],
effects: [],
opacity: 1,

View file

@ -82,3 +82,70 @@ describe('Font family normalization on .fig export', () => {
expect(textNode.fontFamily).toBe('Roboto')
})
})
describe('TEXT node default fill', () => {
test('TEXT node without explicit fills exports with solid black fill', async () => {
const graph = new SceneGraph()
graph.createNode('TEXT', pageId(graph), {
name: 'No Fill Specified',
x: 0,
y: 0,
width: 100,
height: 20,
text: 'Hello',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 14,
})
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const nodes = [...reimported.nodes.values()]
const textNode = nodes.find((n) => n.type === 'TEXT')!
expect(textNode.fills.length).toBe(1)
expect(textNode.fills[0].type).toBe('SOLID')
expect(textNode.fills[0].color.r).toBe(0)
expect(textNode.fills[0].color.g).toBe(0)
expect(textNode.fills[0].color.b).toBe(0)
expect(textNode.fills[0].color.a).toBe(1)
})
test('TEXT node with explicit fill preserves it', async () => {
const graph = new SceneGraph()
graph.createNode('TEXT', pageId(graph), {
name: 'Gold Text',
x: 0,
y: 0,
width: 100,
height: 20,
text: 'Hello',
fontFamily: 'Inter',
fontWeight: 400,
fontSize: 14,
fills: [{ type: 'SOLID', color: { r: 0.96, g: 0.72, b: 0, a: 1 }, opacity: 1, visible: true }],
})
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const nodes = [...reimported.nodes.values()]
const textNode = nodes.find((n) => n.type === 'TEXT')!
expect(textNode.fills.length).toBe(1)
expect(textNode.fills[0].color.r).toBeCloseTo(0.96, 1)
expect(textNode.fills[0].color.g).toBeCloseTo(0.72, 1)
expect(textNode.fills[0].color.b).toBeCloseTo(0, 1)
})
test('non-TEXT node still defaults to empty fills', async () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
name: 'Rect',
x: 0,
y: 0,
width: 100,
height: 100,
})
expect(graph.getNode(node.id)!.fills.length).toBe(0)
})
})