- Merge from master: 22 commits (rich text, JSX renderer, CLI, dedup, tests) - Update specs: text-editing (style runs, ⌘B/I/U, .fig roundtrip, double/triple-click, selectLine), canvas-rendering (mixed-style ParagraphBuilder), editor-ui (B/I/U/S buttons), cli (analyze, node, pages, variables), tooling (jscpd, kiwi-serialize, test:coverage), testing (.fig roundtrip, import perf, JSX tests), scene-graph (StyleRun model, JSX renderer) - Update docs: features (rich text formatting, JSX renderer, expanded CLI, code quality), figma-comparison (Text styles 🔲→🟡, 80/150), roadmap (Phase 4+5 delivered items) - Archive sync-style-runs-jsx-cli-tests change
11 KiB
canvas-rendering Specification
Purpose
Skia CanvasKit WASM rendering pipeline. Draws all scene graph nodes (shapes, text, effects) with transforms, selection outlines, and visual feedback onto an HTML canvas element.
Requirements
Requirement: CanvasKit WASM rendering
The renderer SHALL use Skia CanvasKit WASM to draw all scene graph nodes onto an HTML canvas element.
Scenario: Canvas initialization
- WHEN the editor loads
- THEN CanvasKit WASM (canvaskit.wasm) is loaded and a GPU-accelerated surface is created
Requirement: Shape rendering
The renderer SHALL draw RECTANGLE, ELLIPSE, LINE, STAR, POLYGON, and VECTOR nodes with their fill paints, stroke paints, opacity, and transforms.
Scenario: Rectangle with fill and stroke
- WHEN a rectangle node has a solid fill (#3B82F6) and a 2px stroke (#000)
- THEN the rectangle is rendered with the correct fill color, stroke color, and stroke width
Requirement: Text rendering
The renderer SHALL render TEXT nodes using CanvasKit's Paragraph API with font family, weight, size, line height, and letter spacing.
Scenario: Text with Inter font
- WHEN a text node with fontName "Inter", fontSize 16, and characters "Hello" exists
- THEN the text is rendered at the correct position with the Inter font
Requirement: Nested coordinate systems
The renderer SHALL apply parent transforms (position + rotation) when rendering child nodes, supporting arbitrary nesting depth.
Scenario: Rotated parent with child
- WHEN a frame is rotated 45° and contains a child rectangle
- THEN the child rectangle is rendered in the frame's rotated coordinate system
Requirement: Selection outlines
The renderer SHALL draw selection outlines (blue rectangles) around selected nodes with resize handles at corners and edge midpoints.
Scenario: Select a node
- WHEN a node is selected
- THEN a blue outline with 8 resize handles appears around the node's bounds
Requirement: Surface resize
The renderer SHALL recreate the Skia surface when the window is resized, maintaining correct dimensions.
Scenario: Window resize
- WHEN the browser window is resized
- THEN the canvas and Skia surface update to the new dimensions without artifacts
Requirement: Silent crash prevention
The renderer SHALL detect and report rendering errors instead of silently failing.
Scenario: Rendering error
- WHEN a rendering operation encounters an error
- THEN the error is detected and reported rather than silently producing a blank canvas
Requirement: Gradient rendering
The renderer SHALL draw gradient fills: GRADIENT_LINEAR, GRADIENT_RADIAL, GRADIENT_ANGULAR, and GRADIENT_DIAMOND using CanvasKit shaders with gradient stops and transform matrices.
Scenario: Linear gradient on rectangle
- WHEN a rectangle has a GRADIENT_LINEAR fill with stops at 0% (#FF0000) and 100% (#0000FF)
- THEN the rectangle renders with a smooth red-to-blue linear gradient
Requirement: Image fill rendering
The renderer SHALL draw IMAGE fills using CanvasKit image decoding. Image transforms (scale, position) and scale modes (FILL, FIT, CROP, TILE) SHALL be applied.
Scenario: Image fill on frame
- WHEN a frame has an IMAGE fill with imageHash referencing a blob
- THEN the image is decoded and rendered within the frame bounds
Requirement: Effect rendering
The renderer SHALL draw effects: DROP_SHADOW, INNER_SHADOW, LAYER_BLUR, BACKGROUND_BLUR, and FOREGROUND_BLUR using CanvasKit filters.
Scenario: Drop shadow on rectangle
- WHEN a rectangle has a DROP_SHADOW effect with offset (4, 4), radius 8, color rgba(0,0,0,0.25)
- THEN a shadow is rendered below the rectangle with the specified offset, blur, and color
Requirement: Stroke cap, join, and dash rendering
The renderer SHALL apply stroke cap (NONE, ROUND, SQUARE, ARROW_LINES, ARROW_EQUILATERAL), join (MITER, BEVEL, ROUND), and dash pattern to strokes.
Scenario: Round cap dashed stroke
- WHEN a line has strokeCap ROUND and dashPattern [10, 5]
- THEN the line renders with rounded dash ends and 10px-on/5px-off pattern
Requirement: Arc rendering
The renderer SHALL draw ellipses with arcData (startAngle, endAngle, innerRadius) as partial arcs or donuts.
Scenario: Semi-circle arc
- WHEN an ellipse has arcData with startAngle 0 and endAngle π
- THEN only the top half of the ellipse is rendered
Requirement: Hover highlight rendering
The renderer SHALL draw a shape-aware hover outline for the node under the cursor. The outline follows the actual shape geometry (ellipses, rounded rects, vectors), not just the bounding box.
Scenario: Hover over ellipse
- WHEN the cursor hovers over an ellipse
- THEN a thin outline matching the ellipse shape is drawn
Requirement: Section rendering
The renderer SHALL draw SECTION nodes with a title pill showing the section name. Title text color inverts based on pill background luminance. Frame name labels are shown for direct children of sections.
Scenario: Section with title
- WHEN a section named "Desktop" exists on the canvas
- THEN a title pill reading "Desktop" is rendered above the section bounds
Scenario: Section title luminance inversion
- WHEN a section has a dark fill color
- THEN the title text renders in white for readability
Requirement: Canvas background color
The renderer SHALL fill the canvas background with the current page's pageColor property.
Scenario: Custom canvas background
- WHEN the user sets the page background color to dark gray
- THEN the canvas renders with a dark gray background instead of the default
Requirement: Viewport culling
The renderer SHALL skip drawing nodes whose bounding boxes are entirely outside the visible viewport. Export rendering SHALL disable culling to render all nodes.
Scenario: Off-screen node skipped
- WHEN a node is completely outside the visible viewport
- THEN the renderer does not draw it
Scenario: Export renders all nodes
- WHEN
renderSceneToCanvasis called for export - THEN all nodes are rendered regardless of viewport position
Requirement: Component label rendering
The renderer SHALL draw purple labels for COMPONENT, COMPONENT_SET, and INSTANCE nodes. Labels appear above the node (or inside for COMPONENT_SET children) with a diamond icon and node name.
Scenario: Instance label
- WHEN an INSTANCE named "Button" is on canvas
- THEN a purple label "Button" with diamond icon is rendered above it
Requirement: Component set border rendering
The renderer SHALL draw COMPONENT_SET nodes with a dashed purple border (6px dash, 4px gap, 1.5px stroke width).
Scenario: Dashed border
- WHEN a COMPONENT_SET is visible on canvas
- THEN its border is rendered as dashed purple lines
Requirement: Component label hit testing
The renderer SHALL support hitTestComponentLabel(graph, x, y) returning the node whose label was clicked, enabling click-to-select via label.
Scenario: Click component label
- WHEN user clicks on a component's purple label
- THEN the component is selected
Requirement: Paint object reuse
The renderer SHALL reuse Skia Paint objects across frames instead of allocating new ones per render call.
Scenario: Multiple renders
- WHEN the scene is rendered 60 times per second
- THEN Paint objects are reused, not reallocated each frame
Requirement: RAF render coalescing
The renderer SHALL coalesce multiple render requests within a single frame using requestAnimationFrame, rendering at most once per animation frame.
Scenario: Rapid state changes
- WHEN 10 state changes trigger renders within one frame
- THEN only one actual render occurs
Requirement: Polygon and Star rendering
The renderer SHALL draw POLYGON and STAR nodes as regular polygon paths. For POLYGON, a path with pointCount vertices (minimum 3) is generated equidistant around the node's center. For STAR, pointCount * 2 vertices alternate between outer radius and inner radius (scaled by starInnerRadius). Both types support fill, stroke, hover highlight, and selection outline. The starting vertex is at the top (−π/2 rotation offset).
Scenario: Render polygon
- WHEN a POLYGON node with pointCount=3 exists on canvas
- THEN an equilateral triangle is rendered within the node's bounding box
Scenario: Render star
- WHEN a STAR node with pointCount=5 and starInnerRadius=0.38 exists
- THEN a 5-pointed star is rendered with inner points at 38% of the outer radius
Requirement: Variable binding resolution in renderer
The renderer SHALL resolve variable bindings before painting fills and strokes. When a fill or stroke has a bound variable, the renderer resolves the variable value using the collection's active mode and uses the resolved color.
Scenario: Render bound fill
- WHEN a rectangle's fill is bound to a COLOR variable with value red in Light mode
- THEN the rectangle renders with red fill
Requirement: Image export rendering
The renderer SHALL support offscreen rendering to PNG, JPG, and WEBP via renderNodesToImage. Export uses an offscreen CanvasKit surface at the specified scale. JPG renders with opaque white background, PNG and WEBP render with transparency. Re-encoding for JPG/WEBP uses OffscreenCanvas.
Scenario: Export at 2× scale
- WHEN a 100×100 node is exported at 2× scale as PNG
- THEN the output image is 200×200 pixels with transparency
Requirement: Scene version vs render version
The renderer SHALL distinguish between scene changes (increments sceneVersion + renderVersion) and viewport-only changes like pan/zoom (increments renderVersion only). UI panels and export preview observe sceneVersion to avoid unnecessary updates during navigation.
Scenario: Pan does not trigger export preview update
- WHEN user pans the canvas
- THEN sceneVersion does not change, so export preview is not re-rendered
Requirement: Text editing overlay rendering
During text editing, the renderer SHALL draw on the canvas: a blue outline around the text node bounds, translucent blue rectangles for the text selection range, and a blinking black caret at the cursor position. The caret SHALL blink at ~530ms intervals and become solid (non-blinking) immediately after any input. Selection rects and caret position are obtained from TextEditor via getSelectionRects() and getCaretRect().
Scenario: Caret rendering
- WHEN user is editing text with no selection
- THEN a blinking black caret is drawn at the cursor position within the text node
Scenario: Selection rendering
- WHEN user has selected a range of text during editing
- THEN translucent blue rectangles are drawn behind the selected text glyphs
Scenario: Blue outline during editing
- WHEN user is editing a text node
- THEN a blue outline is drawn around the text node bounds (at selection color)
Requirement: Mixed-style text rendering
The renderer SHALL render text nodes with per-character formatting using CanvasKit ParagraphBuilder.pushStyle/pop. Each StyleRun segment pushes its own TextStyle (weight, slant, decoration) before adding text, then pops to restore the default style.
Scenario: Render mixed bold and regular text
- WHEN a text node has "Hello " (regular) + "world" (bold)
- THEN the renderer outputs a single Paragraph with two style segments, "world" visually bold