Commit graph

131 commits

Author SHA1 Message Date
Danila Poyarkov 1d5558ddc3 feat: JSX reference, multi-root JSX, prompts as markdown files
- Move CODEGEN_PROMPT to codegen.md, loaded via raw-md bundler plugin
- Add JSX_REFERENCE as jsx-reference.md with full prop/tag/example docs
- Copy JSX Reference button (book icon) in Code panel header
- Multi-root JSX: try parsing as-is, wrap in fragment on failure
- Component and Instance tag aliases in JSX renderer
- renderJSX returns RenderResult[] to support fragments
- raw-md plugin for both tsdown and Vite

Co-authored-by: sld0Ant <sld0Ant@users.noreply.github.com>
2026-04-22 17:51:41 +03:00
Danila Poyarkov d692953d62 test: fix pre-existing failures and add coverage for new features
- Fix mcp-stdio.test.ts: defer heavy SDK imports to avoid describe()
  registration race when running full suite
- Fix mcp-server.test.ts: add server.close() to properly clean up
  sessions and pending requests between tests (eliminates 8 unhandled
  errors)
- Add mcp-path-scoping tests: path traversal, sibling dir, root prefix
  trick, valid paths
- Add editor-store-path tests: setPlannedFilePath with Unix/Windows
  paths
- Add MCP server tests: open_file/new_document registered when mcpRoot
  is set, absent when null

0 fail, 0 errors, 1108 pass
2026-04-22 17:20:09 +03:00
Danila Poyarkov d0b5c51c5a fix(tools): set_font_range now produces valid style runs
- Use applyStyleToRange from text/style-runs.ts instead of naively
  appending runs (which created overlaps and invalid state)
- Apply fontWeight from style name (e.g. 'Bold' → 700)
- Apply color as fills on the style override
- Previously the color param was accepted but silently ignored

Fixes #214
2026-04-22 16:01:03 +03:00
Danila Poyarkov 7513399fce fix(mcp): coerce string-encoded numbers in tool parameters
MCP clients often serialize numeric arguments as JSON strings.
Previously this caused validation errors on tools like node_move.
Now both the MCP server (zod) and AI adapter (valibot) coerce
string-encoded numbers to numbers, rejecting only genuinely
non-numeric values like 'abc'.

Fixes #207
2026-04-22 14:40:11 +03:00
Danila Poyarkov ab1ff69f99 fix(vector): validate VectorNetwork input in create_vector
create_vector now validates the path JSON before accepting it:
checks vertices have numeric coordinates and segment indices are
in range. Returns a clear error message instead of silently
creating a malformed node that crashes on save.

The scene graph also normalizes vectorNetwork on updateNode as
a safety net for other code paths.
2026-04-22 14:35:25 +03:00
Danila Poyarkov 3727fa19d0 test(vector): add normalizeVectorNetwork tests
Cover missing tangent defaults, partial tangent, and encode/decode
round-trip for segments created without tangent handles.
2026-04-22 14:31:49 +03:00
Danila Poyarkov 226542cfcf fix(layout): use stored .fig dimensions for headless text measurement
The estimateTextSize() fallback (fontSize × 0.6 × charCount) produced
wildly inaccurate results, causing 26K node mismatches on material3.fig.
Nodes imported from .fig files already carry Figma's computed dimensions —
use those instead. Falls back to the estimate only for newly-created
nodes with the 100×100 default size.

Reduces headless layout mismatches by ~56% (26,299 → ~11,669), with
TEXT node mismatches dropping 92%.

Fixes #212

Co-authored-by: Alexander Bonin <3462438+alex4o@users.noreply.github.com>
2026-04-22 14:00:07 +03:00
Lay Flags 7ae97935e2
fix(layout): resolve design token variables before passing to yoga-layout (#202)
* fix(layout): resolve design token variables before passing to yoga-layout

applyPadding() and applyAutoLayout() passed variable references like
$--spacing-lg directly to yoga-layout's setPadding()/setGap() which
expects numeric values, causing a crash.

Now resolves variable references via ctx.resolveNumber() before
assignment, following the same pattern already used by parseSize()
and applyCornerRadius() in the same file.

Fixes open-pencil/open-pencil#201

* test(layout): add regression tests for variable padding/gap resolution (#201)
2026-04-16 10:11:28 +03:00
jongwong 5f30ae39b7
fix(hit): fix hit area when parent rotation (#195) 2026-04-15 10:53:47 +03:00
Danila Poyarkov 714390b54b feat: copy node ID / XPath from context menu, CLI selection command
Add nodeToXPath() to core — builds a unique XPath selector for any node
by walking up the tree and using name predicates (or positional index
when names collide among same-type siblings). Properly escapes names
containing quotes using XPath concat() or double-quote delimiters.

UI: "Copy node ID" and "Copy XPath" items in the canvas right-click
"Copy/Paste as" submenu. Also i18n the entire submenu — all labels now
use menuMessages with translations for de, es, fr, it, pl, ru, zh-CN.

CLI: `open-pencil selection` command (app-mode only) returns selected
nodes with id, name, type, size, and xpath. Connects to the running
app via the automation WebSocket.

Automation: new `selection` RPC handler that returns selected nodes
with computed XPath selectors.
2026-04-15 10:09:52 +03:00
Danila Poyarkov cd4e1a2271 feat(mcp): add stdio transport for Claude Code / Cursor
- Add packages/mcp/src/stdio.ts — proper stdio MCP server that
  connects to the running app via WebSocket
- openpencil-mcp bin now points to stdio entry (was HTTP)
- openpencil-mcp-http bin added for the HTTP server
- Extract registerTools() from server.ts to share between transports
- Move index.ts banner output to stderr (prevents stdout corruption)
- Update Tauri shell spawn to use openpencil-mcp-http
- Update all docs (EN + 6 translations): correct binary names, source
  paths, port number

Fixes #194
2026-04-13 11:51:01 +03:00
Danila Poyarkov a2a5376b74 refactor(vue): extract useUndoBatch composable and add batch tests
- Move idle-timer batch logic from PropertyListRoot into reusable useUndoBatch composable
- Add unit tests for batch undo behavior (collapse, flush, redo, no-op)
2026-04-13 09:46:09 +03:00
Danila Poyarkov b3053601f7 Add arrow key nudge and fix undo/redo bugs
Nudge: Arrow keys move selection by 1px, Shift+Arrow by 10px.
Consecutive nudges within 300ms are batched into a single undo
entry so Cmd+Z undoes the entire sequence at once.

Fix duplicate redo creating nodes with new IDs, breaking
subsequent undo entries that reference the original IDs. Now
snapshotSubtree/restoreSubtree preserve node IDs across redo.

Fix delete undo not restoring children — deleteSelected only
snapshotted top-level nodes, so undoing a frame delete lost all
its children. Now snapshots the full subtree.

Fix draw tool not committing resize to undo — shapes created via
draw had their final dimensions lost on redo because handleDrawUp
never recorded the resize. Now uses beginBatch/commitBatch to
combine create+resize into a single undo entry.

Clean up use-keyboard.ts — remove shouldPreventDefault and opaque
PREVENT_* sets, move tool switch and nudge to dedicated keydown
listener, add shift() helper.

Closes #182
2026-04-10 00:26:13 +03:00
Danila Poyarkov 28272ebda0
Add clipboard text v4 export (#164)
* chore: remove unused pi context file

- Delete .pi/context.md
- Remove an unused local context artifact from the repository

* Add clipboard text v4 export

* fix(clipboard): rebase v4 export branch onto master

- Resolve conflicts after the core text module reorganization
- Fix clipboard text export imports and bundled font asset loading
- Update raw binary expectations for textUserLayoutVersion 4
- Re-run check and unit tests after the rebase
2026-04-08 15:35:00 +03:00
Danila Poyarkov 3be01dfdeb Include styleRuns in text edit undo
styleRuns were updated on the graph during editing (via syncText) but
commitTextEdit only captured text changes. Now both text and styleRuns
are snapshotted at startTextEditing and restored on undo.
2026-04-06 15:59:57 +03:00
Danila Poyarkov f77c42d798 Fix text edit not pushing to undo history
commitTextEdit compared prevText from the scene graph node, but
syncText already updates the node on every keystroke during editing.
This made prevText === newText, so the undo entry was never pushed.

Fix: capture the original text when startTextEditing is called and
use that snapshot for the undo comparison.
2026-04-06 15:57:11 +03:00
Danila Poyarkov 07674d1919 Restructure @open-pencil/core into domain modules
Move 30+ loose files at src/ root into domain directories:
- scene-graph/ — SceneGraph class, instances, hit-test, copy, snap, undo
- color/ — parse/format, color management, OkHCL
- text/ — text editor, style runs, direction, fonts
- vector/ — vector network encode/decode, bezier math
- figma-api/ — FigmaAPI class, FigmaNodeProxy
- icons/ — Iconify API client, icon rendering
- canvas/ — SkiaRenderer (was renderer/)
- design-jsx/ — JSX-to-design renderer (was render/)

Also:
- Move fig-compress.ts into io/formats/fig/compress.ts
- Delete re-export shims (headless-render.ts, svg-export/)
- Convert all self-referencing @open-pencil/core/* imports to relative
- Clean up package.json exports (26 subpaths, no internal leaks)
- Update AGENTS.md subpath table

Fixes #179
2026-04-06 14:39:53 +03:00
Danila Poyarkov 1c99e6a9c2
fix(acp): show install errors for missing agent CLIs (#176)
* fix(acp): show install errors for missing agent CLIs (#172)

* Use npm for install commands, toasts for errors, tighten error matching

- Replace bun add -g with npm i -g (universal baseline)
- Replace initError inline banner with toast.show() (consistent with rest of app)
- Remove overly broad 'not found' / 'no such file' from isMissingCommandError

* Refactor toast API to toast.info/warning/error
2026-04-06 14:19:38 +03:00
Danila Poyarkov b826202b09 Fix inline rename and rotated hit testing 2026-03-31 16:44:35 +03:00
Danila Poyarkov fd0124b6f8 Add Arabic and RTL support
# Conflicts:
#	packages/core/src/renderer/text.ts
2026-03-29 21:06:29 +03:00
Danila Poyarkov d0d6ba9def Move reusable color picker logic into Vue SDK 2026-03-29 18:54:14 +03:00
Danila Poyarkov e65b56820b Add color picker regression tests 2026-03-29 18:47:02 +03:00
Mahmoud Almontasser 9ed102b706 feat: add Arabic and RTL support 2026-03-29 11:42:30 +02:00
Danila Poyarkov 04c4c208e7 Add OkHCL color picker support 2026-03-28 20:33:35 +03:00
Danila Poyarkov 049a13a081 Add .fig plugin data support 2026-03-28 18:18:55 +03:00
Danila Poyarkov c57e636dcb Port design linter from figma-use 2026-03-28 16:33:40 +03:00
Danila Poyarkov e0878c1e68 Rename format files to match io folders 2026-03-28 14:33:28 +03:00
Danila Poyarkov e396df3224 Add .pen document import
Co-authored-by: Anton Soldatov <eddclyde@yandex.ru>
2026-03-28 13:57:32 +03:00
Ilya Nikitin b1997e413f feat(vector): add vector curve editor with enhanced pen tool
- Add bezier math utilities for VectorNetwork manipulation
- Enable resuming pen drawing from existing open path endpoints
- Allow closing open paths by dragging endpoints together
- Add comprehensive vector editing capabilities for curves and paths
2026-03-26 02:28:55 +05:00
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