- New oxlint rule: open-pencil/no-silent-catch — errors on empty catch blocks
- Replace all 8 empty catch blocks with console.warn() logging
- Add worker timeout (30s) and main-thread fallback for .fig parsing
- Fix null crash in renderer filter/picture cache cleanup
- Buffer copy before worker transfer for safe fallback
- Load all font weights including default family (Inter 500/600/700 were skipped)
- Fix weightToStyle mapping: 400 → Regular, not Medium
- Block render loop during file loading to prevent stale fallback renders
- Prefer Figma textPicture when available over buildParagraph
- Clear textPicture when text properties change
- Await font loading before first render on file open and page switch
* Fix per-character text fill colors and vector region loop direction
* Replace rotation handle with corner rotation zones
Remove the rotation handle circle/stem above the selection bounding box.
Rotation is now triggered from corner zones outside resize handles,
matching Figma's behavior. Cursor uses native CSS directional arrows
(n-resize, ne-resize, etc.) that rotate with the node, avoiding
cross-platform SVG cursor issues.
Drop the OpenPencil clipboard format from the editor — Figma's Kiwi
format is the single clipboard path now. Fix the remaining roundtrip
losses:
- textAutoResize: was hardcoded to WIDTH_AND_HEIGHT, now uses actual value
- autoRename: now serialized (was always defaulting to true on import)
OpenPencil format: zero property differences, compressed under 1MB.
Figma format: node count, clipsContent, constraints, arcData,
layoutAlignSelf all verified against the fixture file.
Browser clipboard has size limits — the 4MB uncompressed OpenPencil
format was being silently truncated, leaving only the lossy Figma
format. Now:
- Deflate-compress the JSON payload (3.94MB → 597KB for gold-preview)
- Put OpenPencil format first in the HTML so it survives truncation
- Parse side tries inflate first, falls back to raw for compat
Properties lost during copy→paste via Figma Kiwi format:
- clipsContent: frameMaskDisabled now set for all node types, not just FRAME/GROUP
- horizontalConstraint/verticalConstraint: now serialized (SCALE, CENTER, etc.)
- arcData: now serialized (startingAngle, endingAngle, innerRadius)
- strokeCap, strokeJoin, strokeMiterLimit, dashPattern: now serialized
- layoutAlignSelf: now serialized (STRETCH was lost, defaulting to AUTO)
* Perf: cache label collection, offload .fig compression to worker
Label cache: collect sections/components once per scene change,
filter by viewport on each frame. Eliminates full tree walk during
pan/zoom (~17ms/frame → <1ms on large files).
Export worker: move fflate compression off the main thread to prevent
frame drops during save (451ms+ → non-blocking).
* Perf: worker-based .fig parsing, instance index, non-blocking font loading
- Offload .fig parsing (unzip + Kiwi decode) to a Web Worker
- Add instance index (componentId → Set<nodeId>) for O(1) getInstances()
- Defer graph event subscription during file open to skip redundant syncs
- Make font loading non-blocking — render immediately, load fonts in background
- Copy image buffers before worker transfer to prevent detached ArrayBuffer crash
- Show toast on font load failure and file open errors
- Cache failed Google Fonts families to avoid repeated network requests
- Fix missing ref import in FillPicker
- Yield to UI between parse and layout for responsive loading spinner
- Replace non-null assertions with guards/optional chaining
- Replace useless spreads with Array.from() where mutation-safe
- Remove unused imports, variables, and functions
- Fix no-base-to-string with proper type narrowing
- Fix sort without comparator, new Array() pattern
- Prefix unused parameter with underscore
Warnings: 76 → 42 (remaining are max-lines, justified any casts, vendored code)
* Image drag-and-drop and clipboard paste onto canvas
* Paste images at cursor position, track canvas cursor in state
* Paste nodes at cursor position (Figma and internal clipboard)
* Fix review: center images at cursor, fix hasImageFiles, clean up hash/undo
* Paste at viewport center when cursor is outside canvas
* Fix Figma paste positioning: center nodes at cursor after import
* Image support: clipboard, export, drag-and-drop, paste, renderer, tools, UI
- Clipboard: embed image bytes (base64) in OpenPencil clipboard payload so
copy/paste between documents preserves image fills
- Fig export: write images/ folder to .fig zip (both fflate and Tauri paths)
- Tauri: extend build_fig_file to accept image entries
- Renderer: implement CROP (with imageTransform) and TILE (TileMode.Repeat)
scale modes, fix FIT to center the fitted image
- FigmaAPI: add createImage(bytes) with sync FNV-1a hash
- Tools: add set_image_fill tool for AI/MCP
- FillPicker: replace placeholder with file picker, preview, scale mode selector
- Drag-and-drop: new use-image-drop composable creates image nodes from dropped files
- Paste: keyboard paste handler detects image clipboard items
- Shared utils: extract hashImageBytes and getImageDimensions to src/utils/image.ts
* Add image tests and Yjs image sync for collaboration
Tests (18 new):
- FigmaAPI.createImage: deterministic hash, storage, format
- set_image_fill tool: all scale modes, error handling, storage
- Clipboard roundtrip: image bytes preserved, multiple images, children
- Fig export/import: zip contains images/, full round-trip
Collab:
- Add yimages Y.Map to sync graph.images via Yjs
- Observer applies remote image adds/deletes to local graph
- syncNodeToYjs pushes referenced image data alongside node props
- syncAllNodesToYjs bulk-syncs all images on room share
* Fix FillPicker: remove deleted utils/image import, use SHA-1 inline
* Extract storeImage() on editor store, use in FillPicker and placeImageNode
* Unify image hash: use sync FNV-1a everywhere, export computeImageHash
Editor store's hashBytes (async SHA-1) produced different hashes than
FigmaAPI.createImage (sync FNV-1a) for the same bytes. This meant
drag-and-drop images couldn't be deduplicated against AI tool images.
Replace hashBytes with computeImageHash from core. storeImage() is
now sync.
* Skip drawing IMAGE fills when image data is missing
When pasting from Figma, image fills reference a CDN hash but no pixel
data is included in the clipboard. Previously this rendered as a solid
black rectangle because applyImageFill bailed without setting a shader,
leaving stale paint state.
applyFill now returns false when the fill can't be applied, and callers
skip the draw call. The node still exists with the correct imageHash —
if the image data is later provided (e.g. via file re-open), it will
render correctly.
* Warn when Figma paste has missing image data
Show amber warning toast when pasted nodes reference image fills
without available bytes (Figma clipboard limitation).
Add 'warning' toast variant with tailwind-variants, extract toast
styles to src/components/ui/toast.ts.
* Use useFileDialog and useObjectUrl in FillPicker
Replace manual file input ref, click(), createObjectURL/revokeObjectURL
with vueuse composables. Remove hidden <input type=file> from template.
---------
Co-authored-by: Danila Poyarkov <dev@dannote.net>
Text nodes with textAutoResize=WIDTH_AND_HEIGHT kept their 100×100
default SceneNode size when MeasureFunc was unavailable (no CanvasKit),
blowing up every HUG container.
Add fallback estimator (~0.6 × fontSize per char) in layout.ts so
headless layout produces sane sizes.
DO NOT change textAutoResize defaults in renderer.ts without testing
headless layout — see comments there and in layout.ts.
* Fix CJK text garbled when font unavailable (#69)
Always use buildParagraph for text rendering when fonts are loaded,
even when the node's specific font isn't available. The paragraph
shaper falls back to the CJK font in fontFamilies, instead of the
previous drawText fallback which used Inter (no CJK glyphs).
* Add renderText unit tests for CJK font fallback behavior
* Add CJK visual regression test with Noto Sans SC fixture
- Download NotoSansSC-Regular.ttf as LFS-tracked test fixture
- Add setCJKFallbackFamily() export for headless/test use
- Visual test verifies CJK text renders through buildParagraph fallback
when node font is unavailable (the exact PR #89 scenario)
- Assert dark pixel count > 500 to distinguish real glyphs from tofu
Always use buildParagraph for text rendering when fonts are loaded,
even when the node's specific font isn't available. The paragraph
shaper falls back to the CJK font in fontFamilies, instead of the
previous drawText fallback which used Inter (no CJK glyphs).
* Fix auto-layout overflow: MeasureFunc, min/max, absolute positioning, text wrap
Layout engine (layout.ts):
- Use Yoga MeasureFunc for text nodes instead of static pre-measurement,
so text wraps correctly when width is determined by flex layout
- Add min/max width/height constraint support (minWidth, maxWidth, etc.)
- Translate counterAxisAlignContent to Yoga setAlignContent for wrap layouts
- Extract configureFlexContainer to share between root and nested frames
JSX renderer (render/renderer.ts):
- Auto-set layoutPositioning ABSOLUTE for elements with x/y inside auto-layout
- Fix text auto-resize: set HEIGHT when text fills parent in auto-layout
Text measurer (renderer/renderer.ts):
- Accept optional maxWidth parameter for constraint-based measurement
* AI prompt: prefer describe over export_image for post-render verification
* Strip TS casts from AI-generated JSX before sucrase parse
AI models sometimes emit `as any`, `as const` etc. in JSX props.
Sucrase with jsx-only transform can't handle these, causing parse errors.
- Strip TypeScript cast expressions before passing to sucrase
- Add forbidden patterns to AI prompt: as any, template literals for
sizes, Math.random()
* Cache text MeasureFunc results to avoid repeated buildParagraph calls
Yoga calls MeasureFunc multiple times per node during calculateLayout.
Each call was creating a full CanvasKit Paragraph — expensive for layouts
with many text nodes. Cache by rounded constraint width.
* AI chat: fix provider settings popover, add max output tokens, fix paste in chat input
- Fix ProviderSettings popover not visible (remove tooltip/popover trigger
conflict, add collision-padding, isolate z-[51])
- Add configurable max output tokens (default 16384) in provider settings
- Pass maxOutputTokens to ToolLoopAgent to prevent truncated tool calls
- Add system prompt size limits: split render calls at ~40 elements
- Fix paste/copy/cut in chat input (stop event propagation to canvas handler)
* Fix 6 layout engine bugs: hidden size, FILL basis, SPACE_EVENLY, alignSelf range, grid stretch, absolute children
Layout engine (layout.ts):
- Guard applyYogaLayout to preserve hidden children dimensions
- Add setFlexBasis(0) for FILL sizing so children share space from zero
- Add SPACE_EVENLY to mapJustify via Justify.SpaceEvenly
- Replace hardcoded STRETCH checks with mapAlignSelf for full range
- Use setWidthStretch/setHeightStretch for grid children instead of flexGrow
- Insert absolute children in Yoga tree via configureAbsoluteChild with
PositionType.Absolute, keeping manual x/y (no write-back)
Types (scene-graph.ts):
- Add SPACE_EVENLY to LayoutAlign union
- New LayoutAlignSelf type: AUTO | MIN | CENTER | MAX | STRETCH | BASELINE
Import (kiwi-convert.ts):
- Map SPACE_EVENLY correctly instead of collapsing to SPACE_BETWEEN
- New mapAlignSelf function for full StackCounterAlign range
Tools (modify.ts):
- Expand set_layout align enum with SPACE_EVENLY
- Expand set_layout_child align_self enum with MIN/CENTER/MAX/BASELINE
Tests: 86 pass (+13 new covering all 6 fixes)
* Grid layout support in JSX renderer, auto-height grids, flex children stretch in cells
Renderer:
- Parse grid/columns/rows/columnGap/rowGap/gap props
- Parse grid child positioning: colStart/col/rowStart/row/colSpan/rowSpan
- Grid prop takes precedence over padding-triggered auto-layout
- SPACE_EVENLY added to ALIGN_MAP
- Numeric columns shorthand (columns={3} → 3×1fr)
- Auto-height: grid without rows sets height=0 for Yoga auto-sizing
- fill sizing in grid children → layoutAlignSelf: STRETCH
- Component builders accept variadic children: Frame(props, ...children)
Layout:
- Grid auto-height: skip setHeight when no gridTemplateRows
- Write back computed height for auto-height grids
- Grid children with layoutMode stretch width to fill cell
- recomputeGridChild: re-run flex layout after grid assigns cell size
(temporarily sets sizing to FIXED so HUG doesn't override grid width)
Export:
- Skip height export for auto-height grids (no rows template)
Tests: 18 new grid render tests covering all paths
* Auto-enable flex for justify/items props, discourage export_image in AI prompt
* Address review: use sucrase typescript transform, don't reset chat on maxOutputTokens change
* Fix lint errors: extract applyFrameSize, wrap nested ternary, remove unnecessary cast
* Remove SPACE_EVENLY — not a valid Figma layout value
* CI: debug LFS pull from R2
* Apply maxOutputTokens dynamically via prepareCall instead of at agent creation
* Fix heavy .fig parse: restore GUID/defID guards, fix optional types
Commit f3eac5a removed null guards from fig-import and
instance-overrides as 'unnecessary conditions', but Kiwi-decoded data
can have NodeChanges without GUIDs and ComponentPropAssignments/Refs
without defIDs. Made the types optional to match reality.
Also fix CI LFS: add git lfs install --force before pull (R2 endpoint
needs the filter registered first).
* Fix Google AI model IDs: gemini-3.1-pro → gemini-3.1-pro-preview
Closes#91
---------
Co-authored-by: Danila Poyarkov <dev@dannote.net>
Move requestRender() and component instance sync from manual call sites
to graph event subscriptions. Removes 79 manual requestRender() calls
(94→22) and all 9 syncIfInsideComponent calls.
Batching: component sync uses queueMicrotask — mutated node IDs
accumulate during a synchronous block, then deduplicate to ancestor
component IDs and call syncInstances once per component. requestRender()
is already rAF-batched (just increments counters checked by animation
frame loop), so multiple event-triggered calls in one sync block
collapse to a single repaint.
Event subscriptions:
- node:updated → invalidate render cache, schedule component sync, requestRender
- node:created/deleted/reparented/reordered → schedule component sync, requestRender
- Re-subscribes when graph instance is replaced (file open/reload)
Also fixes collab sync gap: subscribe to node:reordered in use-collab.ts
(same-parent reorder was never synced to Yjs peers).
Remaining requestRender() calls are for pure UI state changes (pen tool,
viewport pan/zoom, text editing, selection) and methods that bypass
events (detachInstance, bringToFront/sendToBack, restorePageFromSnapshot).
- Add nanoevents to @open-pencil/core, emit typed events from
SceneGraph mutation methods: node:created, node:updated,
node:deleted, node:reparented, node:reordered
- Subscribe to node:updated in editor store for renderer
invalidation (vector path + node picture cache) instead of
inlining it in updateNode()
- Re-subscribe to graph events after file open/reload (graph
instance is replaced)
- Replace monkey-patching of graph.updateNode in use-collab with
event subscriptions for node:created, node:updated, node:deleted,
node:reparented — fixes sync gaps for create/delete/reparent that
the monkey-patch missed
- Clean up event subscriptions on collab disconnect
Rendering & layout:
- Fix COUNTER_ALIGN_MAP stretch mapping
- Direction-aware fill sizing based on parent flex direction
- Text without explicit width defaults to WIDTH_AND_HEIGHT auto-resize
- Padding auto-enables vertical auto-layout
- clipsContent propagated as Overflow.Hidden to Yoga
- Text height measurement for HEIGHT auto-resize mode
- Export x/y for absolute children, text w/h per textAutoResize
- Export STRETCH as fill on cross axis
Undo system:
- Undo for auto-layout and layer tree reorder
- Atomic undo for drag + reparent
- Page snapshot undo for AI tool operations
- mouseleave no longer terminates active drag
AI tools & vision:
- Add get_jsx, diff_jsx, and describe tools
- export_image returns image via toModelOutput
- Chunked uint8ArrayToBase64 (stack overflow fix)
- computeAllLayouts after AI tool execution
- onBeforeExecute/onAfterExecute receive ToolDef
Provider & UI:
- Add anthropic-compatible provider
- API type toggle for OpenAI-compatible
- Debug toolbar (dev only)
- Preserve chat on UI toggle
- Enhanced system prompt with JSX reference
Co-authored-by: Anton A S <eddclyde@yandex.ru>
Sort classes using the same algorithm as prettier-plugin-tailwindcss,
configured for Tailwind v4 (reads src/app.css for theme).
Also sorts class strings inside twMerge() and tv() calls.
Eliminates 2 nested ternaries that conflicted with the formatter
(oxfmt strips parentheses that the lint rule requires).
- 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.
Ignore optional members instead of skipping the entire type.
{ x: number; y: number; label?: string } now correctly flags as
Vector, while { x?: number; y?: number } is still ignored.
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.