- Track per-run step count, warn AI when 5 steps remain via _warning
field injected into tool results
- Show 'Continue' button in chat when step limit (50) is exhausted
- Add step budget section to system prompt so AI plans ahead
- Reset run step counter on each new agent call via prepareCall
- Add calc tool (recursive descent parser) so AI uses exact math
instead of error-prone mental arithmetic for layout sizing
- Make set_layout direction param optional — keeps current layoutMode
when only changing spacing/padding (eliminates ~50% of NO-OP mutations)
- Add set_layout to SKIP_ARGS in NO-OP detection
- Update system prompt: calc in workflow, mandatory before rendering
into fixed-size containers
AI was ignoring the soft suggestion and generating 130+ element JSX
in a single render call. Now uses explicit skeleton example, numbered
steps, and 🚫 markers to enforce incremental building.
Complex designs must be built layer by layer: skeleton first,
then fill sections one at a time with describe checks between.
Prevents nested containers missing w="fill" and collapsing.
- Mark transport dirty instead of resetting chat on settings change
- Rebuild Chat with new transport while preserving message history
- Watch apiKey and customBaseURL changes in addition to model/provider
- Fix provider select z-index inside settings popover (z-52 + isolate)
- Prevent popover dismiss when interacting with select dropdown
layout.ts: When text fills width (stretch in col parent), MeasureFunc
now uses Yoga's constraint width directly instead of clamping to
the 100px default fixedWidth.
prompt: Emphasize that ALL intermediate flex containers need w="fill",
not just the first level. grow inside HUG = zero width.
Decorative elements (bokeh, glows, gradients, star dots) must use
absolute positioning (x/y), not flex layout. Putting 10+ tiny shapes
in a flex container wastes gap space and squashes real content.
configureTextLeaf for HEIGHT mode was calling yogaChild.setWidth(child.width)
unconditionally, which locked text at the 100px default. This blocked
Yoga's alignSelf:stretch from expanding text to the parent's cross axis.
Now skips setWidth when text has layoutAlignSelf=STRETCH in a vertical
parent (w="fill" case). Same fix for the headless fallback path.
Test: text with w="fill" in flex="col" stretches to 260px (300 - 2×20 pad).
export-jsx: Text with layoutAlignSelf=STRETCH (w="fill" in col parent) or
layoutGrow>0 (w="fill" in row parent) was emitting w={computedPx} instead
of letting the fill/grow detection below handle it. Now skips fixed w
emission when text stretches or grows.
describe: wrap layout without rowGap flagged — wrapped rows stick together.
prompt: wrap always needs rowGap.
Tests: 2 new round-trip tests for text w="fill" in col and row parents.
Prompt:
- Progress bar pattern with grow bar bg + overflow hidden
- Don't mix w={N} and grow={N} on same node
- Don't wrap Text in Frame just for sizing
- Vertical dividers must be inside flex=row with h=fill
- Horizontal dividers in flex=col
Describe:
- w + grow conflict detection
- Child Rectangle overflowing parent without clipping
- Vertical divider in column layout (wrong parent)
- Horizontal divider in row layout (wrong parent)
Prompt:
- Text inside fixed-width containers must have explicit w
- Wrap layout column width formula: count chars × 0.6 × fontSize
- Don't guess widths in wrap grids
Describe:
- Text wider than parent available width flagged with fix suggestion
Prompt — full typography guide:
- Type scale (6-8 sizes, ratio ~1.25)
- Weight hierarchy (max 2-3 per design)
- Color hierarchy for light/dark backgrounds
- Line height rules (tight for headings, relaxed for body)
- Line length (45-75 chars), spacing between text elements
- Uppercase only for small labels/overlines
- Alignment guidelines
Corner radius — nested radius rule:
- inner = outer − padding, 0 if padding ≥ outer
Describe checks:
- Child radius exceeding parent or not matching inner rule
- Uppercase text at sizes > 13px
describe now catches:
- Text without color (invisible — fills is empty)
- Shapes without fill/stroke (invisible)
- Icon-sized frames with no visible content
- Dark text on dark background
- grow inside HUG parent (no effect)
- Children overflowing fixed parent
- HUG container where all children use grow/stretch (collapses)
- Cross-axis overflow
Prompt changes:
- Text always requires color="#hex" — no fill = invisible
- Shapes/icons always need bg or stroke
- Button icons highlighted as #1 invisible element source
Two fixes:
1. layout.ts: Add estimateTextSize() fallback (~0.6 × fontSize per char)
for when MeasureFunc returns null (font not loaded) or CanvasKit is
unavailable. Previously fell back to child.width/child.height which
defaults to 100×100 for new nodes, blowing up every HUG container.
2. ai/tools.ts: Only call computeAllLayouts + requestRender + push undo
for mutating tools (def.mutates). Read-only tools (describe, get_jsx,
node_bounds, etc.) were triggering full layout recompute on every call,
wasting cycles and potentially destabilizing layouts during AI sessions.
- 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