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.
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.
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.
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.
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.
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.
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
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).
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.
Replace hand-rolled input element creation with VueUse's useFileDialog,
which creates the input once and reuses it. Creating a new <input>
element and calling .click() immediately was being swallowed by Safari
on the first attempt.
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.
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.
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>
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).
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.
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.
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
- 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
- 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
The fill color bug was caused by a missing `ref` import in FillPicker.vue,
introduced in 37da83a and fixed in 3b32b4d. Add a regression test that
opens the color picker, clicks the color area, and verifies the node's
fill color updates.
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.
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.
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.
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)
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.
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).
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
Previously the typing indicator only showed during 'submitted' status
(before first token). Once streaming began, gaps between tool calls
had no visual feedback — user saw silence and thought AI stopped.
Now isThinking is true when:
- status is submitted (waiting for first token)
- status is streaming and last part is a completed tool call or
step-start (AI deciding what to do next)
- 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.
- 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
- Remove docs/mcp-codegen-plan.md (internal planning doc, not for repo)
- Remove lol.fig test fixture left in repo root
- Fix inline type imports in codegen.ts — use top-level SceneGraph import
- Move get_codegen_prompt out of core/ALL_TOOLS into MCP server only
(core must be browser-safe, node:fs is not available in browser)
- Remove skill from packages/mcp/skills/ — content saved to #97
- Slim down codegen.md overlap note (canonical reference stays there)
Closes review comments. Skill npm publishing tracked in #97.
- Zero icon frame x/y after appendChild to parent — fixes icons appearing
at viewport origin instead of inside their parent container
- Add name→names fallback to insert_icon for backward compatibility with
single-icon calls (name param still works, returns legacy format)
- Add emoji section to system prompt: emoji show as □ tofu on canvas,
use insert_icon for UI iconography, emoji only in user content/reactions
- Strengthen flex requirement in layout docs: every parent with fill
children MUST have flex='col'|'row'
- Track per-run step count, warn AI when 5 steps remain via _warning
field injected into tool results
- Show 'Continue' button in chat when step limit (50) is exhausted
- Add step budget section to system prompt so AI plans ahead
- Reset run step counter on each new agent call via prepareCall
- Add calc tool (recursive descent parser) so AI uses exact math
instead of error-prone mental arithmetic for layout sizing
- Make set_layout direction param optional — keeps current layoutMode
when only changing spacing/padding (eliminates ~50% of NO-OP mutations)
- Add set_layout to SKIP_ARGS in NO-OP detection
- Update system prompt: calc in workflow, mandatory before rendering
into fixed-size containers