- Merge from master: 19 commits (AI chat + Code tab) - Update specs: editor-ui (Properties panel → Design|Code|AI tabs, AI chat panel, model selector, 10 AI tools, DirectChatTransport, Code panel with JSX export), scene-graph (sceneNodeToJsx), testing (AI chat Playwright, 14 JSX export tests) - Update docs: features (AI Chat + Code Panel sections, Properties Panel tabs), figma-comparison (AI tools 🟡, Dev Mode 🟡, Code snippets 🟡, 83/150), roadmap (Phase 5 AI + Code), keyboard-shortcuts (⌘J ✅, ⌘B/I/U ✅) - Archive sync-ai-chat-code-tab change
16 KiB
scene-graph Specification
Purpose
Hierarchical data structure managing all design nodes. Provides flat Map<GUID, Node> storage with parent-child tree via parentIndex, CRUD operations, hit testing, and spatial queries.
Requirements
Requirement: Flat node storage with GUID keys
The scene graph SHALL store all nodes in a flat Map<string, SceneNode> keyed by GUID string. Tree structure SHALL be maintained via parentIndex references on each node. Each SceneNode SHALL include componentId (nullable reference to main component for instances) and overrides (record of instance-level property overrides).
Scenario: Create and retrieve a node
- WHEN a node is created with type RECTANGLE and a parent GUID
- THEN the node is stored in the map and retrievable by its GUID
Scenario: Parent-child relationship
- WHEN a child node references a parent via parentIndex
- THEN
getChildren(parentId)returns the child sorted by position string
Scenario: Node has component fields
- WHEN a SceneNode is created
- THEN it has
componentId: nullandoverrides: {}by default
Requirement: Node types
The scene graph SHALL support node types: FRAME, RECTANGLE, ELLIPSE, LINE, TEXT, VECTOR, STAR, POLYGON, GROUP, BOOLEAN_OPERATION, DOCUMENT, CANVAS.
Scenario: Create each supported node type
- WHEN a node of each supported type is created
- THEN the node is stored with the correct type enum value
Requirement: CRUD operations
The scene graph SHALL support create, read, update, and delete operations on nodes.
Scenario: Delete a node
- WHEN a node is deleted by GUID
- THEN the node is removed from the map and no longer returned by queries
Scenario: Update node properties
- WHEN a node's properties (size, fill, rotation) are updated
- THEN subsequent reads return the updated values
Requirement: Hit testing
The scene graph SHALL support hit testing — given a point in canvas coordinates, return the topmost visible node at that position.
Scenario: Hit test on overlapping nodes
- WHEN two rectangles overlap and a point inside the overlap area is tested
- THEN the node with higher z-order (later in position string sorting) is returned
Requirement: Rectangular area query
The scene graph SHALL support querying all nodes that intersect a given rectangle (for marquee selection).
Scenario: Marquee selection
- WHEN a rectangle area query is performed
- THEN all nodes whose bounds intersect the rectangle are returned
Requirement: Unit test coverage
The scene graph SHALL have bun:test unit tests covering CRUD, parent-child, z-ordering, and hit testing.
Scenario: Unit tests pass
- WHEN
bun test ./tests/engineis run - THEN all scene graph unit tests pass in <50ms
Requirement: Multi-page document support
The scene graph SHALL support multiple CANVAS (page) nodes as children of the DOCUMENT root. Each page has its own child tree. Pages can be added, deleted, and renamed.
Scenario: Add a new page
- WHEN user adds a page
- THEN a new CANVAS node is created as a child of the DOCUMENT root and becomes the active page
Scenario: Delete a page
- WHEN user deletes a page and at least two pages exist
- THEN the page is removed and the adjacent page becomes active
Scenario: Rename a page
- WHEN user renames a page
- THEN the CANVAS node's name property updates
Requirement: Section node type
The scene graph SHALL support SECTION nodes. Sections are top-level only (direct children of CANVAS), cannot nest inside frames or groups. Sections auto-adopt overlapping siblings on creation.
Scenario: Create section
- WHEN user draws a section on the canvas
- THEN a SECTION node is created as a direct child of the current page
Scenario: Section auto-adopt
- WHEN a section is created overlapping existing nodes
- THEN overlapping sibling nodes are reparented into the section
Scenario: Section nesting restriction
- WHEN user attempts to move a section inside a frame or group
- THEN the section remains at top-level (direct child of CANVAS)
Requirement: Extended node type union
The NodeType union SHALL include: CANVAS, FRAME, RECTANGLE, ROUNDED_RECTANGLE, ELLIPSE, TEXT, LINE, STAR, POLYGON, VECTOR, GROUP, SECTION, COMPONENT, COMPONENT_SET, INSTANCE, CONNECTOR, SHAPE_WITH_TEXT.
Scenario: NodeType covers all Figma types used in import
- WHEN a .fig file containing ROUNDED_RECTANGLE, COMPONENT, INSTANCE, CONNECTOR, or SHAPE_WITH_TEXT nodes is imported
- THEN each node is created with its correct type in the scene graph
Requirement: Hover state tracking
The scene graph state SHALL track a hoveredNodeId for the node currently under the cursor.
Scenario: Hover a node
- WHEN user moves the cursor over a node
- THEN
hoveredNodeIdupdates to that node's ID and a render is requested
Requirement: Extended fill types
The Fill interface SHALL support types: SOLID, GRADIENT_LINEAR, GRADIENT_RADIAL, GRADIENT_ANGULAR, GRADIENT_DIAMOND, IMAGE. Gradient fills include gradientStops and gradientTransform. Image fills include imageHash, imageScaleMode, and imageTransform.
Scenario: Gradient fill on a node
- WHEN a node has a GRADIENT_LINEAR fill with two stops
- THEN the fill stores both gradient stops with position and color, plus a 2×3 gradient transform
Requirement: Extended stroke properties
The Stroke interface SHALL support cap (NONE, ROUND, SQUARE, ARROW_LINES, ARROW_EQUILATERAL), join (MITER, BEVEL, ROUND), and dashPattern (array of dash/gap lengths).
Scenario: Dashed stroke
- WHEN a node has a stroke with dashPattern [10, 5]
- THEN the stroke renders as a dashed line with 10px dashes and 5px gaps
Requirement: Per-page viewport state
Each page SHALL maintain independent viewport state (panX, panY, zoom, pageColor). Switching pages restores the viewport.
Scenario: Switch page restores viewport
- WHEN user switches from Page 1 (zoomed to 200%) to Page 2 and back
- THEN Page 1's viewport returns to 200% zoom at the previous pan position
Requirement: Clone tree
The scene graph SHALL support deep-cloning a node subtree to a new parent via cloneTree(sourceId, parentId), copying all properties and recursively cloning children.
Scenario: Clone subtree
- WHEN
cloneTreeis called on a frame with two children - THEN a new frame with two new children is created under the target parent
Requirement: Create instance from component
The scene graph SHALL support creating an INSTANCE node from a COMPONENT via createInstance(componentId, parentId), copying visual properties and deep-cloning children.
Scenario: Instance copies component children
- WHEN
createInstanceis called for a component with three children - THEN the instance has three cloned children matching the component's
Requirement: Detach instance
The scene graph SHALL support converting an INSTANCE to FRAME via detachInstance(instanceId), clearing componentId and overrides.
Scenario: Detach changes type
- WHEN
detachInstanceis called on an instance - THEN its type becomes FRAME and componentId becomes null
Requirement: Get main component
The scene graph SHALL support getMainComponent(instanceId) returning the source COMPONENT for an instance.
Scenario: Retrieve main component
- WHEN
getMainComponentis called on an instance - THEN the linked COMPONENT node is returned
Requirement: Deep hit testing
The scene graph SHALL support hitTestDeep(px, py) that traverses into COMPONENT and INSTANCE containers, unlike standard hitTest which treats them as opaque.
Scenario: Deep hit test enters component
- WHEN
hitTestDeepis called at a position inside a component's child - THEN the child is returned, not the component
Requirement: Instance sync method
The SceneGraph SHALL expose a method to synchronize all INSTANCE nodes linked to a given COMPONENT, copying synced properties (width, height, fills, strokes, effects, opacity, cornerRadius, topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius, independentCorners, layoutMode, layoutWrap, primaryAxisAlign, counterAxisAlign, primaryAxisSizing, counterAxisSizing, itemSpacing, counterAxisSpacing, paddingTop, paddingRight, paddingBottom, paddingLeft, clipsContent). Array properties are shallow-copied. Override-marked properties are skipped.
Scenario: Sync propagates fills
- WHEN a component's fill is changed and sync is triggered
- THEN all instances of that component receive the new fill (unless overridden)
Requirement: Clone children with mapping
The SceneGraph SHALL support recursively cloning children from a source parent to a destination parent, setting each clone's componentId to the source child's ID. This enables sync matching between component and instance children.
Scenario: Clone with mapping
- WHEN a component with nested frames is cloned to an instance
- THEN each cloned child (recursively) has
componentIdpointing to the original
Requirement: Child sync
The SceneGraph SHALL support syncing instance children to component children by matching via componentId. Synced child properties include the standard synced property list plus name, text, fontSize, fontWeight, fontFamily. New children from the component are cloned into the instance. Instance children are reordered to match component child order.
Scenario: Sync matches children by componentId
- WHEN a component has children [A, B] and an instance has cloned children with
componentIdpointing to A and B - THEN sync updates each instance child's non-overridden properties from the corresponding component child
Requirement: Independent corner radius fields
SceneNode SHALL include independentCorners (boolean), topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius (numbers). When independentCorners is true, each corner radius is controlled independently.
Scenario: Independent corners
- WHEN a rectangle has
independentCorners: truewith topLeftRadius=8 and topRightRadius=0 - THEN the rectangle renders with rounded top-left and sharp top-right
Requirement: Polygon and Star node properties
SceneNode SHALL include pointCount: number (field default 5) and starInnerRadius: number (default 0.38). POLYGON nodes use pointCount as the number of sides (minimum 3); the Polygon tool overrides pointCount to 3 at creation. STAR nodes use pointCount as the number of outer points (default 5) and starInnerRadius as the ratio of inner to outer radius.
Scenario: Create polygon node
- WHEN a POLYGON node is created with pointCount=6
- THEN the node stores pointCount=6 and renders as a regular hexagon
Scenario: Create star node
- WHEN a STAR node is created with pointCount=5 and starInnerRadius=0.38
- THEN the node stores both properties and renders as a 5-pointed star
Requirement: Tool type includes Polygon and Star
The Tool type union SHALL include POLYGON and STAR. The TOOLS array SHALL include POLYGON and STAR in the Rectangle shapes flyout. No dedicated keyboard shortcuts are assigned to Polygon or Star.
Requirement: Variable types
SceneGraph SHALL support Variable, VariableCollection, and VariableCollectionMode types. VariableType SHALL be COLOR | FLOAT | STRING | BOOLEAN. VariableValue SHALL be Color | number | string | boolean | { aliasId: string }. Variables are stored in a Map keyed by ID. Collections group variables and define modes.
Scenario: Create color variable
- WHEN a COLOR variable is added with value { r: 1, g: 0, b: 0, a: 1 }
- THEN the variable is stored and resolvable by ID
Requirement: Variable collections and modes
SceneGraph SHALL support variable collections with multiple modes (e.g., Light/Dark). Each collection has an activeMode. Mode switching changes which values are resolved for variables in that collection.
Scenario: Switch mode
- WHEN a collection has Light and Dark modes and activeMode is switched to Dark
- THEN resolveVariable returns the Dark mode value for variables in that collection
Requirement: Variable alias resolution
Variables SHALL support alias values ({ aliasId: string }) that reference other variables. Resolution follows the alias chain. Circular aliases SHALL be detected and return undefined (no infinite loop).
Scenario: Alias chain
- WHEN variable A aliases variable B which has value 42
- THEN resolving A returns 42
Scenario: Circular alias
- WHEN variable A aliases B and B aliases A
- THEN resolving A returns undefined
Requirement: Variable binding to node properties
SceneGraph SHALL support binding variables to node properties via bindVariable(nodeId, field, variableId). Bound fields are stored in the node's variableBindings record. unbindVariable removes a binding.
Scenario: Bind color variable to fill
- WHEN a COLOR variable is bound to a node's fill
- THEN the node's variableBindings record contains the binding
Requirement: Variable import from .fig files
The .fig import pipeline SHALL parse VARIABLE type NodeChanges and extract paint variable bindings (fills/strokes), creating corresponding Variable and VariableCollection objects in the scene graph.
Scenario: Import .fig with variables
- WHEN a .fig file containing color variables and paint bindings is imported
- THEN the variables, collections, and bindings are restored in the scene graph
Requirement: Variable removal cleans up bindings
Removing a variable SHALL clean up all bindings referencing it across all nodes.
Scenario: Remove variable with bindings
- WHEN a variable bound to three nodes is removed
- THEN all three nodes' variableBindings no longer reference that variable
Requirement: StyleRun model
SceneNode SHALL support a styleRuns array of {start, length, style} where style is CharacterStyleOverride (fontWeight, italic, textDecoration). The scene graph SHALL provide helpers to apply/toggle styles on ranges and adjust runs on insert/delete.
Scenario: Apply bold to range
- WHEN applyStyleToRange is called with fontWeight 700 on characters 6–11
- THEN the styleRuns array contains a run for that range with fontWeight 700
Requirement: JSX renderer
@open-pencil/core SHALL export TreeNode builder functions (Frame, Text, Rectangle, Ellipse, etc.) and two rendering paths: renderTreeNode() (tree → scene graph, no deps) and renderJsx() (JSX string → esbuild → tree → scene graph, for CLI/headless). Builder functions SHALL accept Tailwind-like shorthand props: w, h, bg, rounded, flex, gap, p/px/py, justify, items, shadow, blur.
Scenario: Build tree from functions
- WHEN Frame({w: 200, h: 100, bg: '#f00'}, [Text({children: 'Hello'})]) is called
- THEN a TreeNode with type FRAME, width 200, height 100, and red fill is created with a TEXT child
Scenario: Render JSX string
- WHEN renderJsx('Hi') is called
- THEN a scene graph with a FRAME parent and TEXT child is produced
Requirement: Scene node to JSX export
@open-pencil/core SHALL export a sceneNodeToJsx() function that converts a SceneNode subtree into JSX string using builder function syntax (Frame, Text, Rectangle, etc.) with Tailwind-like shorthand props. The output is valid JSX consumable by renderJsx().
Scenario: Export frame with children
- WHEN sceneNodeToJsx is called on a frame with a rectangle and text child
- THEN a JSX string with Frame wrapping Rectangle and Text is returned
Scenario: Export with effects
- WHEN sceneNodeToJsx is called on a node with drop shadow
- THEN the JSX includes shadow props in the output