Commit graph

315 commits

Author SHA1 Message Date
Anton A S a76d20b27b Fix ACP agent commands: claude-agent-acp, remove stale ts-expect-error 2026-03-15 17:06:57 +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 5641554ab7 Clean up automation bridge: direct imports, upgrade zod to v4
- Replace ssrLoadModule hacks with direct imports from @open-pencil/core
- Replace isBunRuntime() hack with @hono/node-server (works everywhere)
- Remove ViteServer type parameter from startAutomationBridge
- Upgrade @open-pencil/mcp zod dependency from v3 to v4
- Add @open-pencil/mcp as root devDependency for bridge imports
2026-03-15 16:40:41 +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 ff78d8d57b Bridge MCP server to live editor via automation WebSocket
ACP agents now get an MCP server at http://127.0.0.1:7600/mcp that
proxies tool calls through the automation WebSocket to the browser,
where they execute against the live editor store. No separate
subprocess, no stale npm package — tools operate on the open canvas.

- Add /mcp endpoint to automation bridge (Streamable HTTP transport)
- Each MCP tool call → sendToBrowser({command:'tool'}) → browser executes
- Strip mcp__open-pencil__ prefix from tool names in chat UI
- Export paramToZod from MCP package for bridge reuse
2026-03-15 16:40:27 +03:00
Danila Poyarkov e79fb5ac5d Connect ACP agents to OpenPencil MCP tools
Pass openpencil-mcp as a stdio MCP server to the ACP session so
agents get all design tools (create shapes, render JSX, export
images, etc.) instead of just plain conversation.

- Add makeFigma option to createServer for external FigmaAPI injection
- Skip file lifecycle tools (open/save/new) when using external FigmaAPI
- Pass MCP server config in ACP newSession mcpServers
- Add bun to Tauri shell scope for MCP server subprocess
2026-03-15 16:40:26 +03:00
Danila Poyarkov c953305fe9 Fix ACP agent commands to use adapter binaries
Agents don't speak ACP natively — they need adapter wrappers:
- Claude Code: `claude-code-acp` (@zed-industries/claude-code-acp)
- Codex: `codex-acp` (@zed-industries/codex-acp)
- Gemini CLI: native `gemini --acp`

Add zod dependency (required by @agentclientprotocol/sdk).

Tested: codex-acp successfully completes full ACP flow
(initialize → session/new → prompt → agent_message_chunk → end_turn)
2026-03-15 16:40:21 +03:00
Danila Poyarkov 30430433c6 Add ACP client support for using external coding agents
Implement Agent Client Protocol (ACP) integration so users can use their
existing Claude Code, Codex, or Gemini CLI subscriptions directly in
OpenPencil's AI chat instead of managing separate API keys.

- Add ACPAgentDef/ACPAgentID types and ACP_AGENTS registry to core constants
- Create ACPChatTransport implementing Vercel AI SDK ChatTransport over ACP
- Spawn agent as subprocess via tauri-plugin-shell, communicate over stdio
- Map ACP session/update notifications to UIMessageChunk stream
- Update provider selector to show ACP agents alongside API-key providers
- Hide API key fields for ACP providers in setup/settings UI
- Add tauri-plugin-shell dependency and capability permissions
- ACP agents only appear in Tauri desktop builds (subprocess required)
2026-03-15 16:40:18 +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
Danila Poyarkov 7ac4c4c1a9 Remove Math.* regex hack from calc tool
expr-eval already has floor/ceil/round/abs/sqrt/min/max as builtins.
Drop the .replace(/Math\./g, '') that mangled arbitrary strings and
add a note to the AI prompt's Prohibited section instead.
2026-03-15 13:48:25 +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 46355bb221 Resolve variable-bound fill colors through alias chains
When kiwi paints have colorVar with a variable alias, resolve the
color through the variable's valuesByMode instead of using the stale
paint.color fallback. This fixes fills that reference design tokens
(Tokens → Primitives alias chain) where the paint.color contains
the library's published value but the local variable resolves to
a different color.

Build a variable color resolver from VARIABLE NCs during import,
walk alias chains through default mode values, and use the resolved
color in convertFills/convertStrokes.

Fixes Indicator, Link, Placeholder, and other variable-bound fill
colors in Preline UI and similar files with design token systems.
2026-03-14 17:25:52 +03:00
Danila Poyarkov d513312b54 Narrow kiwiPropertyNodes: only flag nodes whose fills/radius/visible DIFFER from component source\n\nResult: {"status":"keep","total_diffs":62,"visibility":1,"text":0,"fills":11,"radius":0,"size":50,"unmatched":0} 2026-03-14 16:17:32 +03:00
Danila Poyarkov 4f3df8309a Skip SCALE constraint resizing for auto-layout instances (fixes Avatar distortion)\n\nResult: {"status":"keep","total_diffs":62,"visibility":1,"text":0,"fills":11,"radius":0,"size":50,"unmatched":0} 2026-03-14 15:15:11 +03:00
Danila Poyarkov 97ba6751e0 Apply SCALE constraint resizing to instance children + propagate through clone chains\n\nResult: {"status":"keep","total_diffs":70,"visibility":1,"text":0,"fills":11,"radius":0,"size":58,"unmatched":0} 2026-03-14 14:58:49 +03:00
Danila Poyarkov 2858d944bd Skip self-referencing symbolOverrides on nodes with explicit kiwi properties (fixes bold toolbar button fill)\n\nResult: {"status":"keep","total_diffs":84,"visibility":1,"text":0,"fills":9,"radius":0,"size":74,"unmatched":99} 2026-03-14 13:29:42 +03:00
Danila Poyarkov d93c4752cb Fix DSD resolution for swapped instance children
After an instance swap, the DSD guidPath still references the
pre-swap slot's overrideKey (e.g. bold icon), but the actual child
is the swapped-in component (e.g. code-2). resolveOverrideTarget
couldn't find the target because the child's componentId no longer
matched the guidPath element.

When findNodeByComponentId fails, fall back to the single child
of the parent node — after a swap, it occupies the same slot.

Fixes cropped toolbar icons (bold, italic, underline, strikethrough,
link, lists, code) in the WYSIWYG editor.
2026-03-14 12:47:51 +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 49423e406a Clip children to rounded corners when clipsContent is true
clipsContent was always using clipRect (sharp corners), ignoring the
node's corner radius. Frames and instances with rounded corners now
use clipRRect, matching Figma's behavior where content is clipped to
the rounded shape.

Fixes avatar images appearing as squares inside circular containers.
2026-03-14 12:17:08 +03:00
Danila Poyarkov 7d9eb2931c Move font loading and layout into SkiaRenderer.prepareForExport
The headless render path duplicated font collection, text measurer
setup, and layout recomputation separately from the renderer. Move
this into SkiaRenderer.prepareForExport() so browser and headless
share the same code path for export preparation.

Remove the now-redundant loadFonts from CLI headless and the manual
setTextMeasurer/collectFontKeys/computeAllLayouts from headless-render.
2026-03-14 12:06:46 +03:00
Danila Poyarkov b9d6afcdbc Fix headless layout: set text measurer and recompute after font load
Layout was computed before fonts were loaded, so Yoga used a rough
character-width approximation instead of real CanvasKit text
measurements. Also, setTextMeasurer was never called in headless mode,
so even a second layout pass would still use the approximation.

Set the text measurer from the renderer after loadFonts, then
recompute layout before rendering.
2026-03-14 11:51:59 +03:00
Danila Poyarkov a2b56ef15a Fix headless font rendering and deduplicate CLI/MCP setup
CLI headless only loaded one 'Regular' style per font family and never
initialized the CanvasKit font provider, so all text rendered with
fallback glyphs or wrong weights.

Extract headlessRenderNodes/headlessRenderThumbnail into core's
headless-render.ts — handles CanvasKit init, renderer creation, font
provider setup, per-weight font loading via collectFontKeys, and
rendering in one call. Both CLI and MCP server now use this shared
module instead of duplicating the setup.
2026-03-14 11:45:38 +03:00
Danila Poyarkov d7945694dd Fix set_radius returning unserializable Symbol for mixed corners
When corners have different radii, node.cornerRadius returns
Symbol('mixed') which can't be serialized to JSON, breaking AI SDK
tool results. Return individual radius values instead.

Also remove unused initFontService import from MCP server.

Co-authored-by: Anton Soldatov <eddclyde@yandex.ru>
Closes #109
2026-03-14 11:35:51 +03:00
Danila Poyarkov 0d33a14940 Skip variable fonts from local font access, use Google Fonts instead
CanvasKit-WASM renders variable fonts at their default weight (400)
regardless of the requested FontStyle weight. This caused all Inter
text in Chrome to appear as Regular instead of Medium/SemiBold/Bold
when Inter was installed locally as a variable font.

Detect variable fonts by checking for the fvar table in the font binary.
When found, skip the local font and fall through to Google Fonts, which
serves per-weight static files that CanvasKit handles correctly.

Also extract loadLocalFont() and registerAndCache() helpers to reduce
loadFont() complexity (was 21, now under limit).
2026-03-14 11:31:22 +03:00
Danila Poyarkov 72ec3ee576 Fix text/property overrides clobbered by second transitive sync
The second propagateOverridesTransitively (for component property
changes) was overwriting nodes that had explicit symbolOverride values
(text, fills, etc.) set in the first pass. The second sync's skip set
only contained propModified nodes, not the original symbolOverride
targets.

Pass overriddenNodes as a 'protect' set to the second sync. These
nodes are skipped at both the top-level clone check and during
recursive syncChildrenDeep.

Fixes: Step text ('Details'/'Terms'/'Members'), Input placeholder
('Enter name'), Tooltip title ('Bold') in gold-preview.fig.
2026-03-14 11:17:56 +03:00
Danila Poyarkov 204b225c64 Split instance-overrides.ts into focused modules
Break the 781-line single-function file into a kiwi/instance-overrides/
folder with domain files:

- types.ts — shared types and OverrideContext
- index.ts — thin orchestrator (99 lines)
- populate.ts — populate empty instances from components
- resolve.ts — componentId root, override target, repopulateInstance
- symbol-overrides.ts — apply kiwi symbolOverrides
- sync.ts — transitive sync through clone chains
- props.ts — component property assignments
- dsd.ts — derived symbol data (pre-computed sizes)

The shared closure state (6 maps) is now bundled in OverrideContext,
passed explicitly to all functions. No behavior change.

Fixes: complexity lint error (was 25, max 20), prefer-optional-chain,
nested ternary parentheses, import() type annotation.
2026-03-14 10:36:48 +03:00
Danila Poyarkov 2cecdff876 Fix component property override resolution through clone chains
Three changes to correctly resolve component property values:

1. Resolve empty symbolOverride values to component defaults.
   In kiwi binary, an empty ComponentPropValue {} (all fields absent)
   in a symbolOverride's componentPropAssignments means 'reset to
   the component's initialValue default'. This is distinct from
   {boolValue: false} which is an explicit false (field present).
   Build a propDefaults map from componentPropDefs and resolve empty
   values in symbolOverride context.

2. Remove componentId chain walk from applyInstanceDirectAssignments.
   Walking the chain applied base-component assignments to clones
   that should have different values from higher-level symbolOverrides.
   Only apply assignments from the instance's own kiwi data — clones
   inherit correct values via transitive sync.

3. Run a second transitive sync after componentProperties.
   The first sync propagates symbolOverride changes. Component
   property changes (visibility toggles, instance swaps) happen after
   and need their own propagation pass to reach visible-page clones.

Fixes Badge Avatar and Close-Icon visibility in gold-preview.fig
(was false, now correctly true). All 914 tests pass.
2026-03-14 10:21:30 +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 ec741a9388 Remove dead files: codegen.md duplicate and planning doc
codegen.md is identical to codegen-prompt.ts (the TS export is what's
actually used — no code references codegen.md).

design-to-code-overview.md is a planning doc superseded by the actual
implementation. Same category as mcp-codegen-plan.md which was already
moved to discussion #99.
2026-03-13 22:29:14 +03:00
mcdmags e3f0d2422d
Merge branch 'master' into fix/mcp-headless-export 2026-03-14 08:11:23 +13:00
Michael Magsuci 530af668bf Fix MCP headless export: font loading and window guard
Guard `window.queryLocalFonts` for non-browser runtimes (Bun/Node),
load fonts in MCP `export_image` handler before rasterization, and
ensure `stackChildAlignSelf` serialization block is properly scoped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 07:19:31 +13: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 4dbb583218 Fix radial, angular, and diamond gradient rendering
Use the full gradientTransform affine matrix via CanvasKit's localMatrix
parameter instead of manually extracting center and radius. The previous
approach ignored rotation and aspect ratio, producing circular gradients
where Figma specifies rotated ellipses.

Define gradients in Figma's unit gradient space (center 0.5,0.5, radius
0.5) and let the localMatrix (scale(w,h) * gradientTransform) handle the
transform to pixel coordinates. Also merge the GRADIENT_DIAMOND branch
with GRADIENT_RADIAL since they use the same shader.
2026-03-13 19:05:48 +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
Danila Poyarkov c5e8bb431f Fix component override resolution for nested instances
- Fix population ordering: ensure source instances are populated
  before their clones (bottom-up via ensurePopulated recursive walk)
- Fix componentPropAssignment resolution: walk full componentId chain
  to find kiwi source with assignments, not just single-hop lookup
- Fixes visibility overrides not applied in deeply nested instances
- Fixes text content overrides not applied through clone chains
- Reduces missing nodes from 41 to 16 in gold-preview.fig test
2026-03-13 17:29:36 +03:00
Anton A S aa6a8fa5a9 Guard set_layout on NONE frames, remove implicit auto-layout on childCount >= 2
- set_layout returns error if direction omitted on layoutMode: NONE frame
- Remove shouldEnableAutoLayout childCount >= 2 heuristic — require explicit flex prop
2026-03-13 14:54:12 +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
Danila Poyarkov 7c63fc3b22 Normalize colors at FigmaAPI proxy boundary
Root cause of 'Missing required field a' crash: fills/strokes/effects
set via the Figma Plugin API proxy were stored without color validation.
Colors missing the 'a' field (e.g. from AI tools or eval) caused Kiwi
encodeColor to crash on save/copy.

Fix: normalize colors in the proxy setters for fills, strokes, effects,
and gradient stops using normalizeColor() which defaults a to 1.
2026-03-13 13:04:29 +03:00
Anton A S 48abcf10e6 Fix all typecheck and lint errors (79 errors → 0)
Remove duplicate FigmaNodeProxy class from figma-api.ts — use the one
from figma-api-proxy.ts. The local class shadowed the imported proxy,
causing TS2440/TS2739 Symbol conflicts across all tool files.

Sync proxy's layoutSizingHorizontal/Vertical setters with the local
version (adds layoutGrow toggle on FILL, adds _parentLayout helper).

FigmaAPI now implements NodeProxyHost explicitly.

Other fixes:
- kiwi-serialize: safeColor returns Color (not {a?: number})
- chat-debug: eliminate nested ternary
- use-canvas-input: formatting

figma-api.ts: 1295 → 379 lines (under 600-line limit)
2026-03-13 12:42:07 +03:00
Anton A S 933511f43e Improve AI design quality: auto-infer flex, reduce false warnings, prompt patterns
Renderer: Frames with 2+ children auto-infer flex="col" when no explicit
flex/grid prop is set. Children with x/y keep ABSOLUTE positioning.
Extract shouldEnableAutoLayout() to stay under complexity limit.

Describe issues: suppress false positives that waste AI steps—
- checkNestedFlexWithoutFill: skip when parent uses justify=between/center
- checkGrowInHug/checkHugCollapse: skip when node will get concrete size
  from its own parent (grow>0, STRETCH, or effectively filling)
- checkSiblingHeightConsistency: skip when parent uses items=center

System prompt: add mandatory flex rule for multi-child frames,
tab-bar pattern, remove 'nested flex may collapse' from fix list.
2026-03-13 12:21:10 +03:00
Anton A S b5fd31b557 Fix Kiwi encodeColor crash on colors missing alpha field
Defensive safeColor() guard at serialization boundary — fills, strokes,
effects, and gradient stops all go through it before Kiwi encoding.
Also fix const→let for execResult in AI adapter (reassigned by step budget).
2026-03-13 11:48:43 +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 417aaedcb3 Fix set_layout padding reset, enforce final describe after fixes
- set_layout: only change spacing/align/padding when explicitly passed.
  Previously spacing/align/padding defaulted to 0/MIN, wiping existing
  values when AI only wanted to change one property.
- search_icons: client-side slice to enforce limit (API ignores small
  limits, returned 31 results for limit=1)
- system-prompt: issues from describe are mandatory fixes, not advisory.
  Final describe after set_* calls is required workflow step.
2026-03-13 10:20:57 +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 fed6ea5734 Fix icon SVG primitives, harden describe issues, ban justify=evenly
- iconify: convert <circle>, <rect>, <ellipse>, <line>, <polygon>,
  <polyline> to path d-strings. Fixes lucide:search (missing circle),
  lucide:user (missing head), lucide:mail (missing envelope), etc.
- describe: show justify/items/sizing in layout description, detect
  icon role, recurse issues into children at every depth level
- describe-issues: split layout checks into describe-layout-issues.ts
- New checks: fill without flex parent, absolute in flex, nested flex
  without fill, duplicate sibling names, stroke color/weight mismatch,
  circular frame without clip, excessive nesting, same fill as parent,
  image placeholder without content, sibling height inconsistency,
  text wrapping detection, alignment issues (between+HUG, between<2,
  stretch with all fixed, equal children packed without gap)
- system-prompt: remove justify="evenly" — not supported
2026-03-13 10:20:55 +03:00
Anton A S 160d5474c9 Fix lint errors: eliminate all any casts, fix complexity and nested ternaries 2026-03-13 10:20:54 +03:00
Anton A S 9daf7ce6ea Fix icon vector positioning: zero x/y after appendChild 2026-03-13 10:18:47 +03:00