Commit graph

102 commits

Author SHA1 Message Date
Shai Rubinstein 2e723caba0 fix: kiwi serialization bugs causing broken auto-layout in Figma import
- Zero transforms for auto-layout children (Figma computes positions)
- Write bordersTakeSpace from strokesIncludedInLayout
- Always write explicit lineHeight, defaulting to ceil(fontSize * 1.2)
- Normalize font family in derivedTextData fontMetaData and fontName
- Extend normalizeFontFamily to strip optical size suffixes (9pt, 18px)
- Extract computeExportTransform() helper for lint compliance
- Add 20 unit tests covering all fixes

Fixes #134
2026-03-24 23:47:56 +03:00
Shai Rubinstein 5139836816 fix: default TEXT nodes to solid black fill
TEXT nodes created via SceneGraph.createNode() defaulted to empty
fills, causing text to be invisible when .fig files are opened in
Figma. Now defaults to a solid black fill matching Figma's behavior.

Closes #133
2026-03-24 23:47:56 +03:00
Shai Rubinstein 2efd63ea09 fix: normalize font family names on .fig export
Strip optical size suffixes (e.g. "DM Sans 9pt" → "DM Sans") and
"Variable" suffixes when writing fontName.family to .fig files.
This ensures Figma recognizes the font instead of showing a
"Missing font" dialog.

Closes #131
2026-03-24 23:47:56 +03:00
Danila Poyarkov 2956507783 Fix switch_page not persisting across MCP tool calls
Each RPC tool call created a fresh FigmaAPI instance, so switch_page
set currentPageId on a throwaway object. Sync the page change back
to the editor store after tool execution.

Fixes #112
2026-03-24 23:47:55 +03:00
Danila Poyarkov 07af72ab24 Fix imported fig rendering and file open regressions 2026-03-24 16:52:45 +03:00
Anton A S da97f13040 Update group hit-test assertion to match Figma-style behavior 2026-03-16 14:27:46 +03:00
Anton A S edd9a8e398 Entered container border, rotation support, INSTANCE/COMPONENT enterable, tests 2026-03-16 14:26:52 +03:00
Anton A S 6d3e2d20cd Scope checklist: marquee in scope, layer panel sync, delete locked guard, undo/redo validation, tests 2026-03-16 14:26:52 +03:00
Danila Poyarkov 7572d3a54e Add roundtrip tests for COLOR variable alpha handling
Co-authored-by: Shai Rubinstein <rubinsh@gmail.com>
2026-03-16 14:23:19 +03:00
Danila Poyarkov 283b836d50 Add unit tests for font helpers: isVariableFont, normalizeFontFamily, styleToVariant, fetchBundledFont
21 new tests covering:
- normalizeFontFamily: Variable suffix stripping, edge cases
- styleToVariant: all weight→Google variant mappings
- isVariableFont: fvar table detection with synthetic TTF buffers
- fetchBundledFont: headless asset resolution, TTF magic validation
2026-03-16 13:40:16 +03:00
rubinsh a8fc8f1ea8
fix(set_layout): default to HUG sizing when enabling auto-layout (#125)
* fix(set_layout): default to HUG sizing when enabling auto-layout

When `set_layout` transitions a frame from `layoutMode: 'NONE'` to
auto-layout, it now sets `primaryAxisSizingMode` and
`counterAxisSizingMode` to `'AUTO'` (which maps to HUG internally).

Previously these remained at `'FIXED'`, causing the frame to keep its
original dimensions instead of shrinking/growing to fit children. This
made `h="hug"`, `justify="end"`, and `grow={1}` appear broken when
containers were created via the `set_layout` MCP tool (as opposed to
the JSX `render` path, which already defaulted to HUG).

The fix only applies when `direction` is provided and the frame was
previously in `layoutMode: 'NONE'` — updating spacing or alignment on
an existing auto-layout frame does not reset sizing modes.

* docs: add changelog entry for set_layout HUG sizing fix
2026-03-16 13:27:02 +03:00
Anton A S 583b03eb3b Harden ACP transport, MCP server, and add permission dialog
- Extract mapUpdate to testable module, dynamic import for @tauri-apps/plugin-shell
- Add warnings for unhandled ACP content types and empty tool titles
- MCP server: session limit (max 10), fix null WS comparison, guard JSX preprocessing
- MCP server: read version from package.json instead of hardcoded 0.0.0
- MCP tests: use port 0 (OS-assigned) to prevent collision
- Connection error handling with user-friendly messages, stale session recovery
- Agent crash detection via close handler, destroying flag, buildCrashChunks
- Port collision: detect EADDRINUSE in vite-plugin stderr and log clear error
- Production Tauri: spawn openpencil-mcp via shell plugin, orphan reuse via health check
- Permission confirmation dialog (reka-ui AlertDialog) with queue, 60s auto-reject timeout
- Health check in ProviderSelect hides ACP agents when MCP server unavailable
- Move DESIGN_CONTEXT to app constants, add ACP_PERMISSION_TIMEOUT_MS
- 36 tests across 3 files (acp-transport, acp-permission, mcp-server)
- Update CHANGELOG, README, CONTRIBUTING, AGENTS.md
2026-03-15 16:49:43 +03:00
Danila Poyarkov 1092e6b9f5 Unify MCP server: always proxy to live editor via WebSocket
Remove headless SceneGraph mode — the MCP server now always proxies
tool calls to the browser via WebSocket. One server, one architecture:

  HTTP :7600 — /health, /rpc (CLI), /mcp (MCP Streamable HTTP)
  WS   :7601 — browser connects, executes tool calls against live editor

- Delete http.ts (merged into server.ts)
- Delete headless SceneGraph, open_file, save_file, new_document
- Delete canvaskit-wasm dependency (no headless rendering)
- Add ws dependency for WebSocket server
- Rewrite tests with mock browser over real HTTP+WebSocket
- Simplify bridge.ts to 6 lines (just imports and starts the server)
2026-03-15 16:42:46 +03:00
Danila Poyarkov 1a86f3fa16 Remove packages/acp and its tests
Redundant — the MCP server already provides external tool access.
The desktop app uses acp-transport.ts (ACP client) + automation
bridge MCP endpoint instead of the standalone ACP agent.
2026-03-15 16:40:32 +03:00
Danila Poyarkov 19505665f5 Add ACP transport unit tests for mapUpdate chunk mapping
Tests cover: text deltas (empty skip, start/continue), reasoning
chunks, tool call lifecycle (input/output/error), title fallback,
and unhandled update types.
2026-03-15 16:40:31 +03:00
Danila Poyarkov ba31fe7920 Add tests for MCP server makeFigma option
Test that external FigmaAPI skips file lifecycle tools and allows
tool execution without loading a document first.
2026-03-15 16:40:30 +03:00
Anton Soldatov 7305dbe634
AI chat polishing: skeleton workflow, batched tools, visual feedback, stock photos (#111)
* Fix text rendering: always use paragraph wrapping, font fallback, batch tools, severity levels

Rendering:
- renderText: always use paragraph when fontProvider available, not just
  when node-specific font loaded — fixes single-line overflow for Georgia/
  non-Inter fonts
- buildParagraph: add Inter as fallback fontFamily so paragraph wraps text
  even when requested font not yet loaded
- measureTextNode/buildTextPicture: remove isNodeFontLoaded gate — measure
  with fallback font for consistent layout↔rendering
- TEXT_PICTURE_KEYS: add width/height so textPicture invalidates on resize
- renderText drawText fallback: clip to node bounds
- onAfterExecute: invalidate textPictures after font load

Layout:
- estimateTextSize: accept maxWidth, compute multi-line height
- toJSON: depth parameter with childCount fallback

AI tools:
- batch_update tool: multiple set_layout/set_layout_child in one call
- describe: accept ids array for multi-node inspection
- Issue severity (error/warning/info) with pattern classification
- checkNestedFlexWithoutFill: cross-axis fill tolerance
- CORE_TOOLS trimmed 34→27, rest moved to EXTENDED
- System prompt: batch_update, describe ids, severity rules, font/text
  wrapping guidance, card grid patterns

* Add Pexels stock photo integration for AI chat

- stock_photo tool: search Pexels by query, download image, apply as
  IMAGE fill on any node. Picks resolution based on node dimensions.
- Pexels API key stored in localStorage, synced to core via
  setPexelsApiKey on change
- ProviderSettings UI: Pexels API key input with link to get free key
- System prompt: stock photo usage guidance
- Added to CORE_TOOLS so AI can use it in every session

* Batch stock_photo + calc, 4-phase workflow, fix wrap false positives

- stock_photo: accepts JSON array of requests, all fetched in parallel.
  Rejects nodes with children (leaf shapes only). One call replaces 14.
- calc: accepts JSON array of expressions or single string. One call
  replaces 3 separate calc invocations.
- System prompt: 4-phase workflow (plan → skeleton → content → polish).
  Batch stock_photo and calc examples. Updated step budget.
- describe-layout-issues: skip 'nested flex may collapse' for wrap
  containers (pill/tag frames are intentionally HUG). Extract
  childNeedsFill helper to reduce complexity.

* Fix nested flex false positive for pills, single-root render rule

- describe-layout-issues: skip 'nested flex may collapse' when a sibling
  already has grow or fill — pill/badge frames next to flex-grow content
  are intentionally small (e.g. BreakingLabel next to ticker text)
- System prompt: render must have exactly one root element. Stock_photo
  401 fallback: tell user, don't eval gradients.

* Rework workflow: render complete sections, not empty frames

Phase 2 now prescribes 5-7 renders, each producing a complete section
(nav, hero, stories, opinions, sidebar, footer) with placeholder images,
real text, proper auto-layout. No more empty frames to fill later.
Removes the fill content phase — skeleton IS the content, just without
real photos. Phase 3 is now just polish (batch stock_photo + fixes).
Step budget: 12-15 steps instead of 15-30.

* Pexels key input: match AI key behavior

Same pattern as the AI provider key — intermediate ref, 'Key saved —
enter new to replace' placeholder, Clear button, save on change. No
longer exposes the raw key in the input field.

* No empty frames in skeleton, select nodes after each tool

- System prompt: explicit rules against empty frames, no pre-creating
  wrapper frames for future content. Each render = complete section with
  visible placeholders. Render 1 = only nav+ticker, not the whole page
  with empty containers.
- tools.ts: set selection to flash node IDs after each mutating tool,
  so user sees which section AI is working on (selection border persists
  vs flash which is transient).

* AI overlay: pulsing blue while working, green flash on done; skeleton workflow

Rendering:
- ai-overlays.ts: new module with pulsing blue border (sinusoidal
  opacity 0.3-0.8 at 1.5s period) for nodes AI is working on, green
  fade-out flash (800ms) when done
- renderer.ts: aiMarkActive/aiMarkDone/aiClearAll methods, drawn after
  regular flashes
- constants.ts: AI_ACTIVE_COLOR, AI_DONE_COLOR, timing constants

Integration:
- ai-adapter.ts: onBeforeExecute now receives args (for target ID)
- tools.ts: marks target node (id or parent_id) active before tool
  executes, marks done via onFlashNodes after completion
- editor.ts: aiMarkActive/aiMarkDone/aiClearAll forwarded to renderer

Workflow:
- System prompt: 3-phase skeleton workflow. Phase 2 renders gray
  placeholder blocks (Rectangle bg=#E2E8F0) for every section — page
  looks like wireframe before any real content. Phase 3 replaces
  skeletons with real content. Phase 4 batch stock_photo + polish.

* render replace_id: atomic skeleton-to-content swap, fix AI overlay lifecycle

render tool:
- New replace_id parameter — renders new content, places it at the
  skeleton's position in parent, then deletes the skeleton. No visual
  gap between skeleton removal and content appearance.

AI overlay fixes:
- Track lastActiveIds across before/after execute — aiMarkDone clears
  the exact nodes that were marked active, not just the result IDs
- aiFlashDone: direct green flash on result nodes (not gated by
  _aiActiveNodes membership)
- replace_id included in target ID detection for active marking

System prompt:
- Phase 3 uses render({ replace_id }) instead of delete_node + render

* Fix gradient fills, nested flex false positives, render insert_index, h=hug prompt

- set_fill: gradient support with color_end + direction (top-bottom,
  left-right, etc). Uses proper Matrix type. No more eval for gradients.
- describe-layout-issues: skip nested flex warning for all non-MIN
  alignments (SPACE_BETWEEN, CENTER, MAX) — not just SPACE_BETWEEN
- render: insert_index parameter for controlling child order
- system prompt: page frame h="hug" not fixed height
- AI overlay: mark active on any tool with target ID (not just
  mutating), clear previous active in onBeforeExecute

* Add desktop business news site example to system prompt

Full workflow example: skeleton with gray placeholders → replace_id
content fills → batch stock_photo. Shows 12-col grid pattern, card
components, section headers, sidebar widgets, h=hug page frame.
Complements the existing mobile app UI example.

* Drop blue active pulse, keep only green done flash

Blue active marking was invisible — tool execution is 20-80ms (one
frame), AI thinking happens between tool calls where no hooks fire.
Green done flash on result nodes works and is visible.

* Blue pulse on skeleton during AI tool streaming

Watch streaming message parts in ChatPanel — when a tool part appears
with state 'call'/'partial-call' and has replace_id or parent_id in
args, mark that node active (blue pulse). When tool transitions to
'output-available', mark done (green flash). This shows the pulsing
blue border on the skeleton wireframe frame while AI generates JSX,
and switches to green when the content replaces it.

* AI overlay debug log section, watch tool parts by toolCallId

- chat-debug.ts: aiOverlayLog array + AI OVERLAY LOG section in debug
  output showing every part/mark-active/mark-done event with timestamps
- ChatPanel.vue: check parts by toolCallId instead of isToolUIPart,
  log all overlay events to aiOverlayLog for debugging

* Fix AI overlay: use 'input' not 'args' for tool part params

Vercel AI SDK UIToolInvocation uses 'input' field (not 'args') for
tool call parameters. The watch was checking p.args which was always
undefined, so no overlay events ever fired. Also use p.type instead
of p.toolName for the log.

* Move AI overlay to onBeforeExecute/onFlashNodes, reorder render params

- onBeforeExecute: mark replace_id/parent_id active (blue pulse)
- onFlashNodes: clear active, flash done on new nodes (green)
- Reorder render tool params: replace_id first so streaming partial
  input reveals target ID before JSX body
- Remove ChatPanel watch — all overlay logic in tools.ts now
- Add aiClearActive() to renderer (clears active without touching
  done flashes)

* Wait 2 rAF frames after marking active before tool execute

onBeforeExecute marks the target node active (blue pulse), but
execute ran immediately after without yielding to the render loop.
The blue frame was never painted because markActive → execute →
clearActive all happened in the same JS task. Now await two
requestAnimationFrame callbacks so the canvas renders at least one
blue-pulse frame before execution starts.

* Add comprehensive AI overlay debug logging to chat debug log

Both streaming watch (ChatPanel) and tool execution hooks (tools.ts)
now log every event to aiOverlayLog with full state: renderer exists,
activeNodes count, hasFlashes, node existence. This will show exactly
what happens and why the blue pulse is or isn't visible.

* Filter partial node IDs in streaming overlay watch

During input-streaming, replace_id may be partially received (e.g.
'0:' without the number part). Guard with isValidNodeId check.

Also refactor watch callback into collectPendingTargets +
syncOverlayState to stay under complexity limit.

Analysis of last debug log:
- Hero: only 235ms active (streaming didn't deliver replace_id early)
- Stories: 14.8s active — should be clearly visible
- Opinions: 12.5s, LatestNews: 11.5s, Stocks: 9.8s — all long enough
- Hero was the only one too fast to notice

* Fix undo race, JSX pragma conflict, partial ID overlay, add deletion log

- Fix undo snapshot race condition: replace shared `let beforeSnapshot`
  with a per-tool stack. Vercel AI SDK runs tool calls from one step via
  Promise.all — concurrent mutating tools overwrote the shared snapshot,
  causing wrong undo entries and potential content loss.

- Fix 'h is not a function' JSX error: rename sucrase pragma from `h`
  to `__h`. When AI writes `.map((h, i) => ...)`, the loop variable
  shadowed the pragma, crashing render.

- Fix partial node ID `0:1` in overlay: strengthen `isValidNodeId` to
  require both parts ≥2 chars and verify node exists in graph. During
  streaming, `replace_id: "0:1811"` arrives as `0:1` → `0:18` → etc.

- Add deletion log to SceneGraph: `_deletionLog` records id, name,
  type, parentId, timestamp, and truncated stack for last 200 deletions.
  Included in debug log as NODE DELETION LOG section to diagnose the
  intermittent frame disappearance bug.

* Reduce find_nodes spam and describe bloat in system prompt

- Instruct AI to reuse IDs from render results and describe output
  instead of calling find_nodes to rediscover them (saves 8+ calls)
- Recommend depth=1 for full-page describe, depth=2 only for sections

* Auto-depth describe + validate-after-render workflow

Describe tool now auto-picks depth based on subtree size when depth
is omitted: ≤15 nodes → depth 4, ≤40 → 3, ≤100 → 2, larger → 1.
Max raised to 5 for explicit overrides.

Prompt updated: after each render replace_id, call describe on that
block (auto depth gives full detail on small sections). Every 2-3
content renders, describe root at depth=1 to catch cross-section
layout issues early.

* Enforce describe-after-every-render in system prompt

Show explicit render → describe → fix pattern with code example.
AI was skipping per-block validation and batching all describes
at the end, causing compounding layout errors.

* Add describe rules: nested text, missing padding, button padding, row centering

Four new layout issue detectors in describe-issues.ts:

- Nested Text (error): Text node inside Text causes overflow.
  Suggestion: split into separate Text elements in flex row.

- Zero padding container (warning): Frame with fill + text children
  but no padding — content touches edges.

- Button without padding (warning): Button-like frame with children
  but zero horizontal padding.

- Row without vertical centering (warning): Horizontal flex with
  items=start, tall container, and short children — suggests
  items=center.

All verified with bun -e tests including false positive checks.

* Fix gradient alpha loss, add undersize child detection

Gradient fix: set_fill with color_end was overwriting alpha to 1
on both gradient stops (`{ ...c, a: 1 }`). Now preserves original
alpha from parsed hex — #00000000 → a:0, #000000AA → a:0.667.
This caused transparent-to-dark overlays to render as solid black.

New describe rule: detect child significantly narrower than parent
in a container without auto-layout (e.g. 100px Rectangle inside
896px Frame). Suggests adding flex + w=fill. Caught the HeroImg
bug that describe previously missed.

* Auto-include issues in render result, no separate describe needed

Render tool now scans the created subtree for errors/warnings and
includes them in the response. AI gets issues automatically without
needing to call describe — eliminates the pattern where AI skips
describe on 'simple' sidebar blocks.

Example response:
  { id: '0:42', name: 'Card', issues: [
    { node: 'Text', id: '0:45', message: 'no color', severity: 'error' }
  ] }

* Run layout before collecting render issues

Issues were checked on raw node sizes (default 100px) before
computeAllLayouts ran in onAfterExecute. Now render calls
computeAllLayouts inline before collectSubtreeIssues, so issues
reflect actual computed dimensions.

This fixes hundreds of false 'X 100px overflows parent' warnings
that made AI chase nonexistent problems.

* Revert auto-issues in render and layout-before-issues

Reverts 09a9b89 and 70c116a. Auto-including issues in render
result caused false positives (100px default sizes before layout)
and made AI chase nonexistent problems, degrading output quality.

* Clean up AI chat PR: remove debug scaffolding, add stock photo provider adapter

- Delete _deletionLog (stack traces on every node deletion)
- Delete aiOverlayLog and all debug push() calls
- Delete succesful_prompt_diffucult_ux.md (raw debug dump) and tokens.md (empty)
- Remove double-rAF hack from ai-adapter (added 32ms to every tool call)
- Restore simple beforeSnapshot for undo (was matching by tool name)
- Remove 70-line overlay state tracker from ChatPanel.vue
- Remove Math.* regex hack from calc (expr-eval has builtins)
- Refactor stock-photo into provider adapter: Pexels + Unsplash
- Add Unsplash key management to settings UI and use-chat composable

---------

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-15 15:42:41 +03:00
mcdmags c847eab75c
feat(mcp): add export_image_file tool and fix headless PNG rendering (#108)
* feat(mcp): add export_image_file tool — saves PNG/JPG/WEBP to disk

The existing export_image tool returns base64 inline, which MCP clients
display in chat but don't save to disk. This new tool writes the image
directly to a file path, making exports usable for documentation,
mockup reviews, and CI pipelines.

Parameters:
- path: absolute file path to save (required)
- ids: node IDs to export (optional, defaults to all top-level nodes)
- format: PNG | JPG | WEBP (default: PNG)
- scale: 0.1–4 (default: 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(mcp): add export_image_file tool and fix headless PNG rendering

**New tool: `export_image_file`**
Saves rendered PNG/JPG/WEBP images directly to a file path on disk.
The existing `export_image` returns base64 inline — useful for chat
but doesn't produce files. AI agents and CI pipelines need actual files.

Path validation runs before the expensive render call. The tool
respects `fileRoot` restrictions.

**Fix: headless PNG rendering now matches the editor**

1. `computeAllLayouts()` called after `render` creates nodes via JSX.
2. Bundled Inter font loaded from disk — tries `../fonts/` (installed
   package) then `../../public/` (project root). The web-based
   `fetch('/Inter-Regular.ttf')` fails in Node.js.
3. Text measurement uses `renderer.measureTextNode()` — the same
   ParagraphBuilder the renderer uses to draw text. Measurement and
   rendering use the same SkiaRenderer instance, same fontProvider,
   same font shaping. Zero hacks.

Export pipeline:
  ensureInterFont() → SkiaRenderer + loadFonts() →
  setTextMeasurer(renderer.measureTextNode) →
  computeAllLayouts() → renderNodesToImage()

**Tests added** (6 new tests in mcp-server.test.ts):
- export_image_file produces valid PNG after render
- export_image_file without ids exports all page children
- export_image_file without document returns error
- export_image_file with out-of-root path returns error
- JPG and WEBP formats produce non-empty output
- export_image_file is listed in tools

* Remove .cue directory and revert .gitignore changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Move computeAllLayouts into renderTree, reuse exportImage in MCP

computeAllLayouts was in the render tool but renderJSX has multiple
callers (render, replace_node, automation server). Moving it into
renderTree ensures layout is always computed after JSX rendering.

export_image_file in MCP server was duplicating export_image logic.
Now delegates to exportImage.execute and just handles the file write.

---------

Co-authored-by: Michael Magsuci <mcdmag@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-14 18:43:50 +03:00
Danila Poyarkov f70338d18e Use child shape for drop shadows on transparent containers
When a frame/instance has no visible fills, Figma renders drop shadows
using the first child's shape instead of the node's own rectangle.
This makes shadows follow rounded children (e.g. pill-shaped badges
inside a rectangular wrapper).

Pass the first visible child as shadowShapeChild to renderEffects
when the container has no visible fills.
2026-03-14 12:25:51 +03:00
Danila Poyarkov a4524c88fb
Merge pull request #94 from open-pencil/feat/mcp-codegen
MCP codegen pipeline: tools, prompt, and pi skill
2026-03-13 22:56:23 +03:00
Danila Poyarkov e954fdabff Treat FOREGROUND_BLUR as layer blur wrapping children
Figma's kiwi format uses FOREGROUND_BLUR for what the Plugin API calls
LAYER_BLUR — a blur that applies to the entire layer including children.

Previously FOREGROUND_BLUR was handled in the per-effect 'front' pass via
applyClippedBlur, which only blurred the node's own fills (drawn before
children). For nodes with no fills but with children (like groups),
the blur had no visible effect.

Now FOREGROUND_BLUR is handled alongside LAYER_BLUR in renderNode,
using a saveLayer that wraps both the node content and its children.
This fixes gradient blobs that should appear soft/blurred (e.g. the
decorative gradients in gold-preview.fig's Preview Thumbnail).
2026-03-13 21:09:43 +03:00
Danila Poyarkov 73668530b2 Don't clip content on group-like frames (resizeToFit)
Frames with resizeToFit=true are Figma's internal representation of
Groups. They auto-resize to fit children and never clip content,
regardless of the frameMaskDisabled flag.

Previously all frames with frameMaskDisabled=false were imported with
clipsContent=true, causing group-like frames to incorrectly clip their
children. This was visible as hard edges on gradient fills that should
extend beyond the group bounds.
2026-03-13 19:43:52 +03:00
Danila Poyarkov ef0b45f0c1 Fix instance swap override propagation through clone chains
Instance swap overrides (symbolOverrides with overriddenSymbolID) now
correctly propagate through multi-level clone chains:

- Track swapped instances in a dedicated set to distinguish real swaps
  from normal clone-chain componentId differences
- Reclone children when a source instance was swapped, copying name,
  componentId, and re-populating children from the swapped component
- Mark recloned targets as swapped too, enabling transitive propagation
  to deeper clone levels (e.g. Preview Thumbnail toolbar icons)
- Only rename on swap when the current name matches the root component
  name, preserving user-given names like 'Static Icon'
- Use getComponentRoot (with cycle detection) instead of manual chain walk
- Null-check source before deleting target children in recloneChildren
2026-03-13 18:58:33 +03:00
Anton A S d688d04bef Address PR #94 review: dedup, replace hand-rolled code, cleanup
- Replace calc.ts (150 lines) with expr-eval library
- Replace rgbLuminance with culori wcagLuminance
- Use @iconify/utils iconToSVG for viewBox normalization in iconify.ts
- Extract describe-shared.ts: findAncestorBackground, looksLikeButton, CONTAINER_TYPES
- Extract icon-render.ts: shared createIconFromPaths for create.ts and renderer.ts
- Inline codegen prompt as TS constant, remove runtime fs.readFile from MCP server
- Fix blur effect color alpha (a:1 → a:0)
- Encapsulate module-level mutable state in src/ai/tools.ts into RunState class
- Add network check to iconify tests (skip gracefully offline)
- Remove Caddyfile.dev and components.d.ts from tracking, add to .gitignore
2026-03-13 14:46:18 +03:00
Anton A S 68b5cfcea1 Fix .fig export roundtrip: variable GUIDs colliding with document
Variable/collection IDs (e.g. 'VariableCollectionID:42:1') parsed via
stringToGuid produced NaN sessionID, which Kiwi encodes as 0 — same
GUID as the DOCUMENT node. This overwrote DOCUMENT in changeMap during
re-import, causing all pages to be lost.

Fix: generate unique GUIDs for collections, variables, and modes via
localIdCounter instead of parsing string IDs. Pass varIdToGuid map
through to serializeVariableBindings for consistent references.
2026-03-13 13:27:15 +03:00
Anton A S 1efb2e3734 Add diagnostic logging to fig export roundtrip test
Test has never passed since it was added (3b32b4d). Add console.log
to show page counts, node counts, and internal-only flags so the CI
log reveals what the export→parse roundtrip actually produces.
2026-03-13 13:18:56 +03:00
Anton A S d46b4b11a4 Fix layout axis mismatch, reduce describe noise, add bail-out strategy
Engine:
- layoutSizingVertical/Horizontal setters now use child's own layout
  direction (not parent's) to map primary/counter axis — matches Yoga
- FILL on cross-axis uses alignSelf:Stretch instead of flexGrow
  (flexGrow only works on main axis, caused even distribution bug)
- FILL on main axis auto-sets layoutGrow=1 for leaf nodes

This fixes the 20-step debugging spiral where AI set FIXED heights
on HORIZONTAL children inside VERTICAL parent but Yoga ignored them
(wrote primaryAxisSizing via parent axis, Yoga read counterAxisSizing
via child axis — never saw the FIXED value).

Describe noise reduction:
- Suppress 'absolutely positioned inside flex' — intentional for
  decorative overlays (selection handles, badges)
- Suppress 'justify=between with HUG' when node fills parent width
- Suppress 'fixed size + grow' when sizing is FILL (standard pattern)
- 'Inconsistent padding' requires 3+ siblings (not 2)

Prompt:
- Bail-out: delete and re-render after 2 failed set_* attempts
- eval restricted to operations, not layout debugging
2026-03-13 10:21:46 +03:00
Anton A S 1454afc036 Optimize AI token usage: ~61% reduction in per-step context
- System prompt: 19.5KB → 6.3KB (67% smaller), removed redundant
  rules, condensed spacing/typography/layout docs
- Tool tiers: CORE_TOOLS (34) vs EXTENDED_TOOLS (64), AI chat uses
  core only (~2.8K tokens vs ~6.5K). ALL_TOOLS for MCP/CLI unchanged.
- <Icon> element in render JSX: <Icon name="lucide:heart" size={20}
  color="#FFF" /> fetches and renders inline. Eliminates the
  search→fetch→insert×N→delete×N chain (~8 tool calls per design).
- search_icons default limit 20 → 5 (~300 fewer tokens per query)
- get_page_tree returns compact tree (id, type, name, w, h) instead
  of full recursive nodeToResult dump
- eval returns { ok: true } instead of null, saves a follow-up
  describe call
- renderTree/renderJSX/renderTreeNode now async to support Icon fetch
2026-03-13 10:20:56 +03:00
Anton A S 733321fbb5 Add step budget warnings and Continue button for AI chat
- 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
2026-03-13 10:18:20 +03:00
Anton A S 6ff1560621 Fix text w="fill" stuck at 100px in flex="col" parent
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).
2026-03-13 10:17:16 +03:00
Anton A S 8300ebb807 Fix text w="fill" exported as w={computed}, add wrap rowGap check
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.
2026-03-13 10:17:15 +03:00
Anton A S 633469f61f Add text props (lineHeight, letterSpacing, textCase, textDecoration, maxLines), spacing checks, update prompt
Renderer + export-jsx:
- lineHeight={N}, letterSpacing={N}
- textDecoration="underline"|"strikethrough"
- textCase="upper"|"lower"|"title"
- maxLines={N} (auto-enables truncation), truncate (standalone)

Describe:
- Spacing consistency checks (inconsistent padding/gap among siblings)
- Off-grid spacing values (not on 4px grid)
- Gap larger than padding warning
- Corner radius: child radius vs parent − padding rule

Prompt:
- Complete props reference with explicit "nothing else exists" guard
- Typography: type scale, line height, letter spacing, hierarchy rules
- Spacing: 4px grid, Gestalt proximity, consistency rule
- All new text props documented

Tests:
- 7 round-trip tests for new text props (render → SceneNode → export JSX)
2026-03-13 10:17:11 +03:00
Anton A S 036f486571 Fix layout blowup from 100×100 text fallback, skip compute for read-only AI tools
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.
2026-03-13 10:17:07 +03:00
Danila Poyarkov b00af4c98f Fix text rendering with wrong fonts on file open
- 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
2026-03-12 22:05:59 +03:00
Danila Poyarkov f54fcc32f9 Add gold-preview.fig clipboard roundtrip tests
OpenPencil format: zero property differences, compressed under 1MB.
Figma format: node count, clipsContent, constraints, arcData,
layoutAlignSelf all verified against the fixture file.
2026-03-12 17:05:00 +03:00
Danila Poyarkov 3b32b4d80c
Perf: 5.4× faster file open, worker-based parsing, instance index (#96)
* 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
2026-03-12 14:49:28 +03:00
Anton Soldatov 37da83aec3
Image drag-and-drop, clipboard paste, and paste-at-cursor (#92)
* 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>
2026-03-11 07:52:04 +03:00
Anton A S 9caa89e6f8 Fix text layout without CanvasKit: estimate size instead of 100×100 default
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.
2026-03-10 22:59:15 +03:00
Danila Poyarkov 0d1f42f0b1
Fix CJK text garbled when font unavailable (#89)
* 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
2026-03-10 16:58:57 +03:00
Anton Soldatov e939025f7d
Fix auto-layout overflow, grid layout, AI chat UX (#86)
* 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>
2026-03-10 15:43:04 +03:00
Danila Poyarkov f2ef9c976d
XPath query for node selection (#82)
* Add XPath query for node selection

- packages/core/src/xpath.ts: XPath engine using fontoxpath with custom
  DOM facade over SceneGraph nodes
- RPC command: 'query' with selector, page, limit params
- CLI command: 'open-pencil query <file> <xpath>' with --json, --page, --limit
- AI/MCP tool: 'query_nodes' with XPath examples in description
- Queryable attributes: name, width, height, x, y, visible, opacity,
  cornerRadius, fontSize, fontFamily, fontWeight, layoutMode, itemSpacing,
  padding*, strokeWeight, rotation, locked, blendMode, text, lineHeight,
  letterSpacing

* Add tests and docs for XPath query

- 9 unit tests for queryByXPath/matchByXPath core functions
- 6 tool tests for query_nodes AI/MCP tool
- README: query examples in CLI section
- VitePress docs: patterns, queryable attributes, example output
- CHANGELOG: feature entry

* Lazy-load fontoxpath to avoid bundling in desktop app

- Dynamic import() instead of static import for fontoxpath
- queryByXPath/matchByXPath now async (they were sync before)
- RpcCommand.execute allows R | Promise<R> for async commands
- Fix 2 lint errors (unnecessary optional chain, always-truthy)
- Update all tests and CLI to await results
2026-03-09 15:32:27 +03:00
Danila Poyarkov 7c584db1b7 Fix copyFill missing gradientTransform/imageTransform, add copy tests
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.
2026-03-09 08:50:28 +03:00
Danila Poyarkov 2f03f12210 Add performance benchmark for parse/export/node operations
mitata-based benchmarks covering parseFigFile, exportFigFile,
SceneGraph operations, and structuredClone vs typed copy comparison.
2026-03-09 02:58:24 +03:00
Danila Poyarkov 3cd0e47fc8
Merge pull request #78 from open-pencil/detach-orphaned-instances
Detach orphaned instances to FRAME on clipboard paste
2026-03-09 00:11:05 +03:00
Danila Poyarkov 97fb51d0ec
Merge pull request #77 from open-pencil/fix-paste-figma
Fix text typography import from Figma clipboard
2026-03-09 00:11:02 +03:00
Danila Poyarkov f5ea2a43cf Detach orphaned instances to FRAME on clipboard paste
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
2026-03-09 00:01:04 +03:00
Danila Poyarkov 0f0f2137e5 Fix text typography import from Figma clipboard
- 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
2026-03-08 23:52:20 +03:00
Danila Poyarkov 4a6dd703c8 Emit derivedTextData and textUserLayoutVersion for text nodes
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
2026-03-08 23:28:54 +03:00
Danila Poyarkov d9fbc6fb92 Serialize variables, collections, and bindings to .fig files
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
2026-03-08 21:44:01 +03:00
Danila Poyarkov 45df90c5c7
CSS Grid layout mode (#73)
* 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
2026-03-08 18:23:53 +03:00