- Move svg-export-{defs,paths}.ts into svg-export/ folder
(defs.ts, paths.ts, index.ts) instead of prefix-based split
- Remove duplicate getChildren lambda in fig-import.ts importPages
- Remove dead dashPattern local in kiwi-convert.ts nodeChangeToProps
- Restore useful explanatory comments in instance-overrides.ts
(DSD propagation, direct vs cloned assignments, seed skipping)
- Rename applyEffectOverrides → applyShapeAndEffectOverrides
(handles polygon props + shadow + blur, not just effects)
- Format src/ with oxfmt
Layer tree shows layout-specific icons for auto-layout frames:
vertical → rows-3, horizontal → columns-3, grid → grid-3x3.
Only applies to FRAME nodes — components/instances keep their
purple diamond icon.
Clicking a selected top-level frame's name label on the canvas
now starts a drag instead of deselecting (hitTestFrameTitle).
Fix toolbar disappearing — TOOLS was incorrectly moved into
import type by consistent-type-imports, erasing it from the
runtime bundle.
Three oxlint JS plugin rules in lint/plugin.js:
- no-inline-named-types: configurable shape→name map catches inline
{ x: number; y: number } etc. and suggests Vector, Color, GUID,
Rect, Matrix from @open-pencil/core
- no-structuredclone-scene-arrays: flags structuredClone on
fills/strokes/effects — use typed copy helpers instead
- no-math-random: bans Math.random() in favor of crypto
Fixed 40 violations across 13 files: replaced inline type literals
with named imports, switched figma-api.ts structuredClone calls to
copyFills/copyStrokes/copyEffects.
Deep-copy gradient and image transforms in copyFill to prevent
shared-reference mutations. Add mutation-isolation tests for all
copy helpers. Fix benchmark to use precomputed IDs and actual
copyFills function.
copyFill/copyStroke/copyEffect/copyStyleRun use spread-based shallow
copies instead of structuredClone (~24× faster for fills). Applied in
SceneGraph.copyProp and instance override syncNodeProps.
When pasting from Figma, instances may reference components that aren't
included in the clipboard data (e.g. components on a different page).
These instances stayed empty and invisible on canvas.
Convert them to FRAME so their own fills, strokes, corner radius, and
layout properties render visually.
Fixes#36
- Handle RAW lineHeight units (multiplier × fontSize) — was returning null
- Convert letterSpacing with percent units in style overrides — was using raw value
- Normalize variable font names (e.g. 'Inter Variable' → 'Inter') for Google Fonts and local font lookup
Fixes#36
Figma needs derivedTextData (layoutSize, fontMetaData with font digest)
and textUserLayoutVersion to render text in .fig files. Without these
fields, text nodes appear as empty boxes when opened in Figma.
- Add DerivedTextData message to kiwi schema (field 359)
- Add derivedTextData and textUserLayoutVersion to NodeChange codec
- Compute SHA-1 font digests from loaded font binaries at export time
- Emit textData.lines with lineType: PLAIN for each paragraph
- Expose getLoadedFontData() from fonts module for digest computation
Closes#64
- Define VariableAnyValue, VariableDataEntry, VariableConsumptionEntry,
VariableDataValuesEntry in codec.ts — remove inline type monsters
- Extract VARIABLE_BINDING_FIELDS / VARIABLE_BINDING_FIELDS_INVERSE to
kiwi-convert.ts — single source of truth for export and import
- Track internal canvas GUID during page loop instead of recalculating
with incorrect idx+2 formula (broke when scene nodes existed)
- Remove unused modeGuidToId map, unnecessary as-casts
* Add multi-provider AI support (Anthropic, OpenAI, Google AI, OpenAI-compatible)
- Add AI_PROVIDERS registry with per-provider model lists, key placeholders, and URLs
- Refactor use-chat composable: provider factory creates the right AI SDK model
- Per-provider API key storage in localStorage with automatic legacy migration
- New ProviderSetup.vue replaces APIKeySetup.vue with provider selector
- New ProviderSettings.vue popover accessible from gear icon in chat input
- OpenAI-compatible provider with custom base URL and model ID fields
- Install @ai-sdk/anthropic and @ai-sdk/google dependencies
- Update E2E tests for new provider setup flow
* Fix reactivity, key masking, missing model ID field, and chat reset on model change
- Use ref instead of computed for apiKey — computed getter over localStorage
wasn't triggering reactivity when the key was set
- Load stored key for new provider in providerId watcher
- Reset chat on modelId and customModelId change, not just provider switch
- Add custom model ID field to ProviderSetup.vue for OpenAI-compatible
- Simplify ProviderSettings key field — empty input with contextual placeholder
instead of fragile dot-masking that broke on partial edits
- Use data-test-id locator for model selector in E2E test
* Use useLocalStorage from vueuse instead of manual ref + watch + localStorage
Replaces 5 hand-rolled ref/watch/localStorage sync pairs with useLocalStorage.
API key uses a computed storage key that rebinds when the provider changes.
* Use useLocalStorage for collab name persistence
* Review fixes: changelog placement, remove dead class, cursor-pointer
* Extract ProviderSelect component and uiInput helper
- ProviderSelect.vue: shared select dropdown for AI providers
- ui/input.ts: shared input styling (sm/md sizes)
- Remove 7 duplicated input class strings across chat components
* Use uppercase acronyms: keyURL, customBaseURL, supportsCustomBaseURL
* Match select dropdown width to trigger via --reka-select-trigger-width
Move min-w-[var(--reka-select-trigger-width)] into selectContent base
style so all selects get it by default. Remove per-component overrides.
* Uppercase acronyms in variable names, improve setup form layout
- providerID, modelID, customModelID, setAPIKey, AIProviderID
- Keep apiKey lowercase at start (standard JS convention)
- Compact setup form: single column, full-width Connect button
- Fix grammar: 'a OpenRouter' → 'an OpenRouter'
- Shorter promo text
* Allow text selection in chat panel
* Fix chat E2E test: Save → Connect button text
* Disable mermaid in chat markdown renderer
Alias mermaid and beautiful-mermaid to empty shims in Vite config,
preventing vue-stream-markdown from attempting to load them.
* Interleave text and tool calls in chat messages
Render message parts in order instead of grouping all tool calls
first then all text. Use SDK's isToolUIPart/isTextUIPart/getToolName
instead of custom type guards. Fix error state: output-error, not error.
* Implement figma.viewport.scrollAndZoomIntoView()
Figma Plugin API method that centers the viewport on given nodes.
Reuses the same bounding box logic as the viewport_zoom_to_fit tool.
* Catch tool execution errors and return them to the AI
Instead of crashing with an unhandled exception, tool errors are
caught and returned as { error: message } so the AI can retry or
explain the failure. UI detects error outputs and shows them in red
with the error message when expanded.
* Replace esbuild with sucrase for JSX transform
Sucrase is a pure JS transform (201 KB / 46 KB gzip) that works in
both Node/Bun and the browser. Replaces esbuild (13 MB WASM) which
only worked in Node/Bun.
- buildComponent() and renderJSX() are now synchronous
- render tool works in browser AI chat (no more 'esbuild required')
- Handles full JS expressions (map, ternaries, Array.from, etc.)
* Update system prompt to prioritize render tool with JSX
Document available tags, props, layout, text, and sizing options.
Instruct the AI to use full JS expressions in JSX for complex layouts.
* Fix zoom shortcuts to match Figma
Cmd+0: Zoom to 100% (was incorrectly mapped to Zoom to fit)
Cmd+1: Zoom to fit
Cmd+2: Zoom to selection
Shift+1 / Shift+2: same as Cmd+1 / Cmd+2
Add zoomTo100() and zoomToSelection() to editor store.
Refactor zoomToFit() to use shared zoomToBounds() helper.
* Refactor keyboard shortcuts to useMagicKeys
Replace manual keydown handler with VueUse useMagicKeys + whenever
for declarative shortcut registration.
- mod() helper for cross-platform Meta/Control shortcuts
- plain() helper for modifier-free keys
- Proper modifier exclusion (⌘G vs ⌘⇧G no longer conflict)
- Add E2E tests: duplicate, zoom (⌘0/⌘1/⌘2/⇧1/⇧2), auto-layout (⇧A)
- All 26 keyboard shortcut tests pass
* Fix scrollAndZoomIntoView to actually zoom
Match Figma Plugin API behavior (equivalent to Shift-1): compute
zoom level that fits all nodes with padding, capped at 100%.
Previously only set center without adjusting zoom.
Add VARIABLE_SET node type, variable-related messages (VariableSetMode,
VariableSetID, VariableDataValues, VariableScope, VariableDataMap, etc.)
to the Kiwi schema.
Export: emit VARIABLE_SET and VARIABLE nodeChanges on an internal-only
canvas page, include variableConsumptionMap on nodes with boundVariables.
Import: rewrite variable import to use variableSetModes, variableSetID,
variableResolvedType, and variableDataValues (multi-mode COLOR/FLOAT/
BOOLEAN/STRING/ALIAS). Import variableConsumptionMap bindings.
Closes#65
* Switch to @open-pencil/yoga-layout with CSS Grid support
Use our fork (open-pencil/yoga, grid branch) which cherry-picks the
upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings
are ported from the old embind approach to the new wasm_bridge.c API.
npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'.
Also handle FinalizationRegistry change (upstream #1908) — node.free()
no longer exists, nodes are garbage collected automatically.
* Add CSS Grid layout mode
Scene graph:
- LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL
- GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions
- GridPosition type (column, row, columnSpan, rowSpan) for children
- New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap,
gridPosition
Layout engine:
- buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's
setGridTemplateColumns/Rows API with FR/Points/Auto track types
- Grid children use gridPosition for column/row placement with span
- Flex layout path unchanged
Store:
- setLayoutMode('GRID') auto-creates NxM track grid based on child
count (sqrt heuristic), defaults to 1fr tracks
UI (LayoutSection.vue):
- Grid button (grid-2x2 icon) added to flow direction row
- Columns/Rows track editors: ScrubInput for value + AppSelect for
sizing mode (Fill fr / Fixed px / Auto), add/remove buttons
- Separate column gap and row gap ScrubInputs
- Wrap button hidden when grid is active
- Flex alignment grid hidden when grid is active
Kiwi serialization skips GRID mode for now (no .fig codec support).
* Add grid layout integration tests
12 new test scenarios covering:
- Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights
- Column gap, row gap, both gaps combined
- Padding offsets
- Explicit gridPosition placement, column span, row span
- Absolute children skipped
- Hidden children collapsed
- Nested grid inside flex parent (computeAllLayouts)
Also fix: grid frames as children of flex parents now correctly use
Display.Grid via configureChildAsGrid() instead of falling through
to configureChildAsAutoLayout() which set FlexDirection.
* Deduplicate grid yoga configuration
Extract configureAsGrid() and createGridChildNode() shared by
buildGridTree (root-level grid) and configureChildAsGrid (grid
nested inside flex parent).
* Add grid layout to changelog
* Add grid support to JSX and Tailwind CSS export
OpenPencil format: grid → columns/rows/columnGap/rowGap props,
child colStart/rowStart/colSpan/rowSpan.
Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*,
mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]),
child col-start-*/row-start-*/col-span-*/row-span-*.
Both formats: padding emitted for grid frames (shared with flex),
flex-only props (justify/items/wrap/gap) scoped to isFlex.
* Remove duplicate gridTrackToTw, use shared formatTrack
Also drop unused GridTrackSizing import from LayoutSection.vue.
* Fix grid icon: use layout-grid instead of grid-2x2
unplugin-icons can't resolve icon names with digits after hyphens
(grid-2x2 → grid2x2 which doesn't exist).
* Polish layout UI and fix auto-layout behavior
- Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding
- Direction-aware gap icon: ↕ for vertical, ↔ for horizontal
- Pin +/− padding toggle button right of gap input
- Fix alignment grid axes for vertical layout (transpose primary/counter)
- Fix grid switch: set FIXED sizing, compute frame size from children
- Remove hardcoded white fill from Shift+A wrap
- Auto-detect horizontal vs vertical from selection bounds
* Add flex-to-grid switch integration test
Verify HUG frame expands and children are placed in 2x2 grid
when switching from vertical flex to grid layout.
* Update changelog for grid layout
Bugs fixed:
- Export response now wrapped in {ok, result} like all other commands
- btoa(String.fromCharCode(...data)) replaced with loop to avoid
RangeError on large images
- JSX export uses dedicated export_jsx RPC command instead of eval
with undefined selectionToJSX reference
Issues fixed:
- connectAutomation cleanup on component unmount via onUnmounted
- Bridge state encapsulated in startAutomationBridge closure instead
of module-level lets
- Previous browser WS closed on new connection to prevent leaks
- walkNodes returns boolean for early termination, find stops at limit
- Unused imports removed from node.ts (fmtList, nodeToData)
- Hand-rolled hex in node.ts replaced with colorToHex from core
- makeFigmaFromStore extracted to shared figma-factory.ts, used by
both ai/tools.ts and automation/server.ts
- Unused limit param removed from AnalyzeColorsArgs
- Unused groupBy param removed from AnalyzeTypographyArgs
The render tool needs esbuild to transform JSX → createElement calls, but
esbuild only runs in Node, not in the browser where the automation server
executes commands. The bridge now:
1. Intercepts render tool calls with JSX
2. Uses Vite's ssrLoadModule to import @open-pencil/core (handles TS source)
3. Transforms JSX → tree on the Node side via buildComponent + resolveToTree
4. Sends the tree to the browser which calls renderTreeNode directly
Also fixes @open-pencil/core exports: remove "node" condition that pointed
to non-existent dist/ during development. Published consumers get dist/ via
publishConfig.exports override.
The render tool needs esbuild to transform JSX, but esbuild only runs in
Node — not in the browser where the automation server executes. The bridge
now intercepts render tool calls, converts JSX to a tree on the Node side
using buildComponent + resolveToTree from core, and sends the tree to the
browser which calls renderTreeNode directly.
Also exports createElement and resolveToTree from core for bridge use.
- Use colorToHex/colorDistance from color.ts instead of hand-rolled hex math
in rpc/commands.ts (removes toHex, hexToRgb, colorDistance duplicates)
- Extract AUTOMATION_HTTP_PORT/AUTOMATION_WS_PORT to core constants, import
in bridge.ts, server.ts, app-client.ts instead of duplicating magic numbers
- Add requireFile() helper in app-client.ts, replace all file! non-null
assertions across 12 CLI command files
- Document same-machine trust security model in bridge.ts header comment
- Replace (globalThis as any).Bun with typed isBunRuntime() helper
- Add changelog entry for CLI-to-app RPC bridge feature
- Add colorToHex8 (hex with alpha via culori formatHex8)
- Add colorToCSSCompact (no-space rgba for Tailwind shadow classes)
- Add normalizeColor (partial Color → full Color with defaults)
- Add colorDistance (Euclidean RGB via culori differenceEuclidean)
- Replace colorToCSS body with culori formatRgb
- Change rgba255ToColor to positional args (r, g, b, a)
- Remove duplicate formatColor from svg-export.ts and export-jsx.ts
- Remove duplicate colorDistance from tools/analyze.ts
- Remove duplicate convertColor from kiwi/kiwi-convert.ts
- Replace hand-rolled hex+alpha in HsvColorArea.vue with colorToHex8
- Replace hand-rolled rgba255→Color in HsvColorArea.vue with rgba255ToColor
- Expand color.test.ts from 16 to 34 tests
- Strip tools/schema.ts from 1831 to 65 lines (remove duplicate tool defs)
- Fix tools/index.ts to export ALL_TOOLS from registry instead of schema
- Fix renderJsx → renderJSX typo in tools/create.ts and structure.ts
- Add geometry.ts with shared rotation/coordinate math utilities
- Add isArrayMixed() helper to use-multi-props.ts, deduplicate FillSection/StrokeSection/EffectsSection
- Fix broken test imports: use @open-pencil/core instead of relative paths into packages/core/src/
- Merge duplicate import statements across all test files
- Export profiler internals and style-run helpers from core index.ts
- Add 6 new test files: color, undo, snap, vector, style-runs, text-editor (98 new tests)
- Update renderer-effects test for renderer/ directory split
- Extract shared RPC command handlers into packages/core/src/rpc/
(info, pages, tree, find, node, variables, analyze colors/typography/spacing/clusters)
- Add automation bridge (src/automation/bridge.ts) that runs in Vite/Bun process,
listens on localhost:7600 (HTTP) and :7601 (WebSocket)
- Add browser-side handler (src/automation/server.ts) that connects via WebSocket,
executes commands against live EditorStore with requestRender()/flashNodes()
- Supports eval, tool execution, and image export via RPC
- Refactor all CLI commands: file arg is now optional — omit it to connect
to the running app instead of loading a .fig file
- Bearer token auth generated per session, exposed via /health endpoint
- Vite plugin starts the bridge automatically during dev
- Add index signature to NodeChange for Kiwi codec fields
- Add blobs field to FigmaMessage
- Use 'in' type guards instead of Record casts in ai-adapter
- Use Object.fromEntries for Partial<SceneNode> property copying
- Use satisfies instead of as for handle position map
- Type node() to accept { children?: unknown; [key: string]: unknown }
- Remove ext() helper in kiwi-convert (direct property access)
37 occurrences → 1 (Kiwi codec boundary only).
Remove hardcoded manifest link from index.html (VitePWA injects
it in production builds). Catch FontFace.load() rejections from
invalid font data with a warning.
* Add SVG export
Scene graph → SVG serializer with support for rectangles, ellipses,
lines, stars, polygons, vectors (fill/stroke geometry and vector
networks), text with style runs, gradients (linear, radial, angular),
image fills, effects (drop shadow, inner shadow, blur), opacity,
rotation, flips, blend modes, clip paths, and nested groups.
- packages/core: svg-node.ts (minimal XML builder), svg-export.ts
(serializer), ExportFormat now includes 'SVG', computeContentBounds
extracted for reuse
- packages/cli: export command accepts --format svg
- UI: SVG added to ExportSection format picker (scale hidden for SVG),
context menu 'Export as SVG' option
- 47 new tests covering SVGNode builder, geometry blob → path, vector
network → path, and full node export for all shape types
* Update changelog
* Add export_svg tool to ToolDefs
Available via MCP, AI chat, and CLI eval command.
* Replace standalone export items with Copy/Paste as submenu
Figma-style submenu: Copy as text, Copy as SVG, Copy as PNG (⇧⌘C),
Copy as JSX. Keeps Export as PNG (⇧⌘E) as a separate item.
* Remove standalone Export as PNG from context menu
* Update changelog
Load a system CJK font (PingFang SC, Microsoft YaHei, Noto Sans CJK, etc.)
at startup and pass it as a fallback in CanvasKit's fontFamilies array.
Falls back to Google Fonts (Noto Sans SC) when no system font is available.
Also fixes loadFont to only cache fonts that successfully register with
CanvasKit's TypefaceFontProvider, preventing invalid font data from being
treated as loaded.
Closes#48
ESM dynamic imports require explicit file extensions for resolution on
Windows + Bun. The extensionless '../render/render-jsx' path worked on
macOS/Linux but failed on Windows with 'Cannot find module' error.
Fixes#43
- Scope hover hit-test to current page (was searching all pages including
internal component page, causing ghost hover outlines)
- Frames/sections without visible fills or strokes are click-through
- Groups are always click-through (only children are hittable)
- Clipping parents reject hits outside their bounds
- Instances/components check children before falling back to fill check
- Switch test fixtures from material3.fig (55MB) / nuxtui.fig (82MB) to
gold-preview.fig (537KB) for fast dev runs, gate heavy fixtures behind
BUN_HEAVY_TESTS env var (enabled in CI)
vectorNetworkToPath drew all segments forward (to seg.end), which
broke when buildChains traversed a segment in reverse. Closed shapes
like parallelograms had degenerate last segments.
Fix: track current vertex through the chain and draw each segment in
the correct direction (forward or reverse), swapping bezier control
points when reversed.
Region loops (addLoopToPath) are left unchanged — Figma stores loop
segments pre-oriented so they should always be drawn forward.
- Replace O(N²) iterative convergence loop with BFS that correctly
handles intermediate DSD-targeted clones (push to queue regardless
of visited, only skip size update for dsdSizeSet nodes)
- Fix preComputedRoot cache lookup: was using childId instead of
child.componentId as key
- Remove stray blank line
findNodeByComponentId used root-matching which returned the first
child when multiple siblings shared the same component root. This
caused all symbolOverride guidPaths targeting different siblings
(e.g., 3 _stepper instances inside a Stepper component) to resolve
to the first sibling.
Fix: separate into 3 passes — exact componentId match first, then
unambiguous root match (only when exactly one child shares the root),
then deep recursion.
Previously only applied CPA from cloned instance sources (via
componentId chain). Instances created directly from kiwi data (e.g.,
_datepicker with boolValue=false for 'Date Range List') never had
their own CPA processed, leaving children visible that should be
hidden.
syncChildrenDeep was syncing all children from source to clone,
overwriting nodes that were directly targeted by symbolOverrides
(e.g., white stroke on a Vector set by _icon-xs SO being overwritten
with dark gray from the check component's default).
Fix: pass the seeds set through to syncChildrenDeep so it skips
nodes that were explicitly overridden.