openpencil/packages/docs/development/roadmap.md

299 lines
55 KiB
Markdown
Raw Normal View History

2026-05-22 16:31:06 +00:00
---
2026-05-22 17:02:05 +00:00
title: Roadmap
description: OpenPencil product roadmap and Figma compatibility tracking.
2026-05-22 16:31:06 +00:00
---
2026-05-22 17:02:05 +00:00
# Roadmap
2026-05-22 16:31:06 +00:00
2026-05-22 17:54:10 +00:00
OpenPencil is moving toward production-grade Figma compatibility while keeping design documents programmable, local-first, and fast on large files.
2026-05-22 17:02:05 +00:00
## Current focus
- Improve `.fig` import/export fidelity against real Figma files and Figma's own rendering.
- Make saves, recovery, storage synchronization, and automation resilient to crashes, interrupted writes, expired credentials, and temporary provider failures.
2026-05-22 17:02:05 +00:00
- Keep large design systems responsive in the browser and desktop app.
- Make international text reliable across platforms, starting with non-Latin font discovery, Arabic/Persian shaping, RTL layout, and broader CJK fixtures.
2026-05-22 17:54:10 +00:00
- Treat the scene graph as a programmable design document: every important read, write, export, diff, and validation operation should be reachable through UI, CLI, MCP, and SDK surfaces.
- Keep local files and local-first workflows first-class while making an optional OpenPencil Cloud backend and self-hosted deployments practical.
## Recently delivered
v0.14.0 established several foundations that earlier versions of this roadmap treated as future work:
- A searchable Assets panel, component details, instance insertion, frame presets, richer component properties, layout grids, constraints, and deeper typography controls.
- A local-first Storage Workspace for S3-compatible providers with background synchronization and remote document previews.
- Editable PowerPoint export; editable HTML, CSS, Tailwind, JSX, SVG, and image-vectorization workflows.
- Private local MCP transport discovery, an installable OpenPencil agent skill, and stronger CLI/MCP support for large and multi-document sessions.
- Published `@open-pencil/scene-graph`, `@open-pencil/pen`, `@open-pencil/kiwi`, `@open-pencil/fig`, `@open-pencil/dom-css`, and `@open-pencil/vue` packages with documented public boundaries.
2026-05-22 17:02:05 +00:00
### Current development version
Since v0.14.0, the development branch adds editable ruler guides and snapping preferences, Option/Alt distance measurements, multidimensional variant authoring, component-library publication and revision review, local document recovery, saved AI conversations, CLI font diagnostics, and opt-in browser WebMCP access. These workflows are implemented on the development branch but are not part of v0.14.0.
See [canvas navigation](../user-guide/canvas-navigation), [components and libraries](../user-guide/components), [document recovery](../user-guide/layers-and-pages#documents-and-recovery), [AI chat](../programmable/ai-chat), and [font diagnostics](../programmable/cli/inspecting#font-diagnostics).
2026-05-22 17:02:05 +00:00
## Near-term work
### Figma fidelity
- Preserve and round-trip more Figma metadata safely.
- Add visual regression coverage for full multi-page `.fig` documents. `bun tools/generate/visual-oracles/src/cli.ts export-fixtures` exports current smoke fixture pages to `/tmp` for manual comparison without committing large images; `tests/fixtures/figma-oracles/visual-comparison-report.json` records the current Figma-vs-OpenPencil oracle diff findings.
- Close high-impact renderer gaps: remaining mask edge cases, blend isolation, pattern fills, and broader variable-font fixtures.
- Improve boolean operation editing/export now that imported Figma `BOOLEAN_OPERATION` nodes remain boolean operations.
2026-05-22 17:02:05 +00:00
### Editor depth
- Complete variable inspector coverage for common numeric/text/layout fields.
- Extend component and instance authoring with deeper override inspection and slots. Multidimensional variant definitions, instance property editing, and component-library publish/update workflows are already supported.
- Treat variables, styles, components, and libraries as governed design-system assets with proposal, review, publish, update, migration, and conformance workflows.
- Complete layout-grid style parity beyond existing grid geometry controls and editable page/frame guides.
- Extend inspection aids beyond the existing Option/Alt measurements between selected and hovered layers.
2026-05-22 17:02:05 +00:00
- Expand vector editing workflows without regressing imported vector fidelity.
### Reliability and international text
- Make document saves atomic and recoverable, including unsaved documents created through MCP and interrupted local or remote writes.
- Surface provider, model-budget, renderer, and automation failures with actionable recovery paths instead of silent stalls.
- Make desktop automation recover from orphaned processes and renderer crashes without manual cleanup.
- Prevent non-Latin font discovery and rendering crashes across platforms; add Arabic/Persian shaping and RTL layout, then broaden CJK and mixed-script visual fixtures.
- Build a portable-font strategy for reproducible documents across machines on top of existing substitution visibility and agent-readable font status, including curated redistributable fonts, embedded or linked document fonts, and licensing metadata ([#502](https://github.com/open-pencil/open-pencil/issues/502), [#503](https://github.com/open-pencil/open-pencil/issues/503)).
### Cloud and self-hosting
- Provide an optional OpenPencil Cloud backend for account-based workspace sync, sharing, collaboration, comments, and managed team libraries without making cloud accounts mandatory for the editor.
- Support organizations and teams with invitations, viewer/editor/admin roles, link-sharing policies, service accounts, API tokens, and enterprise identity through standard OIDC/SSO integrations.
- Publish documented backend APIs and webhooks for workspace, document, membership, comment, version, and automation events so Cloud and self-hosted deployments integrate with existing developer workflows.
- Add workspace organization for projects, folders, templates, search, indexing, and server-generated previews while preserving stable document identity.
- Add version history with automatic snapshots, named checkpoints, restore, retention controls, and an auditable record of important document and membership changes.
- Provide a durable collaboration relay for deterministic initial sync, presence, reconnects, and restricted-network environments while keeping direct local/P2P workflows available where practical.
- Publish a production-ready self-hosted deployment for teams that need their own storage, identity, network boundary, data location, and retention policy.
- Make self-hosting maintainable with guided deployment, upgrades, backups, health monitoring, observability, and documented recovery procedures.
- Provide explicit data-governance controls for export, deletion, encryption, retention, auditability, and deployment-region or residency requirements.
- Keep AI and media capabilities BYOK so Cloud and self-hosted users connect and control their own model and provider credentials.
- Let documents move between device-only, OpenPencil Cloud, self-hosted, and user-owned storage without losing identity or history.
- Add explicit conflict, offline, sync-health, migration, backup, quota, and recovery UX for every remote deployment mode.
2026-05-22 17:54:10 +00:00
### Agent workflows
2026-05-22 17:02:05 +00:00
2026-05-22 17:54:10 +00:00
- Polish the official `SKILL.md` guidance for OpenPencil so agents use the full inspect → act → render/measure → compare → iterate loop instead of relying on one-shot prompting.
- Publish tested AI workflow recipes for common tasks: create from prompt, edit a selected design, compare against a screenshot or Figma reference, fix visual regressions, extract tokens, and batch-migrate files.
- Accept screenshots and reference images as first-class agent inputs, and return selection/page/viewport renders as native image content to vision-capable MCP and chat clients.
- Support opt-in web retrieval, external MCP connectors, and sandboxed code execution through explicit capability and permission boundaries rather than granting every model ambient access.
- Make structured node-tree diffs a first-class, Git-friendly review artifact for UI, CLI, MCP, SDK, and CI edits instead of relying only on screenshot comparisons.
- Expand design lint findings with expected/actual evidence and safe autofixes; measure rule precision before enabling lint rules as blocking gates.
- Keep deterministic golden renders and replayable edit-operation histories so regressions can be reproduced from document state and actions, not only from final pixels.
2026-05-22 17:54:10 +00:00
- Make agent workflows measurable by default: every substantial operation should be able to produce a render, structured diff, lint result, or comparison artifact.
- Keep MCP, CLI, and SDK operations aligned so agent skills can run the same workflow in desktop, browser, CI, or headless file mode.
### Tooling and API parity
- Maintain a public tool/API reference that maps editor operations to CLI commands, MCP tools, SDK APIs, and Figma Plugin API-compatible eval usage.
- Add coverage tests that detect when a core editor capability exists in the UI but is missing from CLI/MCP/SDK, or vice versa.
- Keep tool outputs structured enough for agents to chain safely: node IDs, bounds, diffs, render artifacts, diagnostics, and machine-readable error details.
2026-05-22 17:02:05 +00:00
- Improve deterministic CLI/MCP export and comparison tools for CI.
- Add more design linting and migration helpers for batch `.fig` and `.pen` workflows.
- Make `.pen` a first-class editable save target across app, CLI, MCP, and SDK workflows rather than an import-only or automation-specific format.
- Extend HTML, CSS, Tailwind, and JSX support from editable import toward URL-based website capture, direct JSX paste/edit workflows, and component-aware design↔code updates that preserve intentional code structure instead of regenerating whole files.
- Provide an adapter contract for additional design sources such as Stitch and Pixso, prioritizing formats with documented or testable semantics over brittle UI scraping.
2026-05-22 17:02:05 +00:00
- Package desktop-side MCP integration so local agent workflows do not require global installs.
### Performance and scale
- Incremental layout and render invalidation for large documents.
- Better renderer profiling surfaces for slow nodes, effects, masks, and imported files.
- Smarter raster/retained caching that preserves fidelity during zoom and pan.
2026-05-22 17:18:43 +00:00
### Interactive shader layers
- Add Unicorn Studio-style shader scenes as first-class design layers: animated gradients, particles, noise fields, metaballs, lighting, displacement, and pointer-reactive backgrounds.
- Provide a preset-first editor for common generative visuals before exposing raw shader code.
- Support timeline and interaction inputs such as time, pointer position, scroll, layer bounds, colors, variables, and imported image textures.
- Render shader layers through CanvasKit/WebGL while keeping deterministic raster export for PNG/JPG/WEBP and thumbnails.
- Store shader layer configuration in OpenPencil documents and export graceful fallbacks when a target format cannot preserve the live effect.
2026-05-22 17:02:05 +00:00
## Later
2026-05-22 17:54:10 +00:00
### SDK and embedded editor
- Expand the documented Vue SDK and core package platform with complete example applications for custom editor shells, embedded design surfaces, and automation-specific UIs.
- Provide maintained examples for read-only previews, editable canvases, design review surfaces, and agent-controlled editors.
- Ship an official VS Code/Cursor extension ([#81](https://github.com/open-pencil/open-pencil/issues/81)) for previewing and opening `.fig`/`.pen` documents, connecting to the running editor, invoking CLI/MCP workflows, handing selections between code and canvas, and navigating between generated code and design nodes. Reuse the app, SDK, and automation bridge rather than implementing a second editor inside the extension.
- Define public API stability and migration expectations across the reusable npm packages.
2026-05-22 17:54:10 +00:00
- Keep the renderer, editor core, and tool registry framework-agnostic enough for headless and embedded use.
### Product depth
- Prototyping: frame connections, triggers, overlays, transitions, preview mode, and AI/JSX-authorable interaction definitions.
- Motion: reusable code-authored animation presets, timelines, easing, and deterministic playback/export that compose with prototype interactions and shader inputs.
- Tables and data grids: structured rows, columns, headers, resizing, merged cells, and normal scene nodes inside cells instead of drawing tables as unrelated rectangles.
2026-05-22 17:02:05 +00:00
- Comments: pins, threads, resolution state, and collaboration-aware display.
- Extend shared libraries beyond the existing component publish/consume/update workflow to broader style management and governance.
- Platform asset libraries: use licensed system-native and third-party icon sources, including SF Symbols where platform and redistribution rules allow, alongside the existing Iconify/Lucide workflow.
- Figma Slides (`.deck`) interoperability: import/export, slide editing, presentation, speaker notes, and filmstrip workflows. This ranks below core Figma Design fidelity, reliability, Cloud/self-hosting, and international-text work.
2026-05-22 17:18:43 +00:00
- Platform polish: Windows code signing, PWA support, packaged updater improvements, and desktop-side MCP bundling.
2026-05-22 17:02:05 +00:00
## Non-goals
- Mandatory accounts or a cloud-only document model. OpenPencil Cloud, self-hosted backends, and user-owned remote storage must remain optional alongside local files.
- A hosted service that requires OpenPencil to proxy users' AI provider keys; AI and media integrations remain BYOK even when a backend provides identity, sync, or collaboration.
2026-05-22 17:02:05 +00:00
- Read-only automation surfaces that cannot modify documents.
- Feature work that sacrifices `.fig` import/export fidelity for convenience.
This section tracks OpenPencil's current compatibility with Figma Design features. It is based on Figma's public Help Center feature areas and the current OpenPencil scene graph, Kiwi import/export, CanvasKit renderer, UI panels, CLI, and MCP tools.
2026-05-22 16:31:06 +00:00
Legend:
- **✅ Supported** — implemented for common files and expected to work directly.
- **◐ Partial** — implemented for important cases, but missing parity, UI, or edge-case behavior.
- **↩ Round-trip only** — imported/preserved/exported for `.fig` fidelity, but not rendered or editable as a first-class OpenPencil feature.
- **— Not supported** — not currently modeled or intentionally out of scope.
2026-05-27 09:19:51 +00:00
Support tiers used for prioritization:
1. **Visual fidelity** — fields that change pixels in normal design exports. These get real Figma oracle fixtures, renderer tests, and visual metrics first.
2. **Round-trip fidelity** — fields that should survive read → write → Figma import but do not need OpenPencil UI/rendering yet. These need raw-preservation and invalidation tests.
3. **Product/runtime systems** — prototypes, libraries, FigJam, Slides, Dev Mode, CMS/AI, and media timelines. These stay schema-only or raw-preserved until OpenPencil has matching product concepts.
4. **Unsafe/internal metadata** — fields that can corrupt Figma import or overwrite user edits when stale. These are filtered or preserved only with fixture evidence.
2026-05-22 16:31:06 +00:00
## Official Figma feature areas
Figma's design documentation groups features into these areas:
- Layers, frames, groups, sections, shape layers, text, vectors, and boolean operations.
- Fills, gradients, images, patterns, blend modes, strokes, effects, corner radius, and corner smoothing.
- Auto layout: vertical, horizontal, wrap, grid, padding, gap, hug/fill/fixed/min/max, and ignore auto layout.
- Components, instances, variants, component properties, slots, libraries, and library updates.
- Variables: color, number, string, boolean, collections, modes, aliases, scopes, and prototype variables.
- Prototyping: flows, hotspots, triggers, actions, overlays, smart animate, easing, conditionals, expressions, and variable actions.
- Dev Mode: inspect, measurements, annotations, Code Connect, dev resources, ready-for-dev states, and Figma MCP.
- Collaboration/file workflows: comments, version history, thumbnails, branches, library publishing, and multiplayer metadata.
2026-05-22 17:02:05 +00:00
## Figma compatibility matrix
2026-05-22 16:31:06 +00:00
| Area | Import | Render | UI edit | Export round-trip | CLI/MCP | Notes |
| ---------------------------------------------------- | -----: | -----: | ------: | ----------------: | ------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pages / canvases | ✅ | ✅ | ✅ | ✅ | ✅ | Multi-page documents and per-page viewport are supported. |
| Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. |
| Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. |
| Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. |
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed corners render for fills, strokes, clips, masks, and effects. |
| Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. |
| Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps, joins, dashes, alignment, and miter limits render and have inspector controls. |
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
| Text | ✅ | ✅ | ✅ | ✅ | ✅ | Case, justification, vertical alignment, truncation/max lines, common OpenType features, and derived Figma glyph fallback are supported; uncommon typography metadata remains round-trip only. |
| Vectors / vector networks | ✅ | ✅ | ◐ | ✅ | ✅ | Vector edit support exists; Figma Draw tools are not fully replicated. |
| Boolean operations | ✅ | ✅ | ◐ | ✅ | ✅ | Figma `BOOLEAN_OPERATION` nodes import/export as boolean operations; inspector editing remains limited. |
| Components | ✅ | ✅ | ◐ | ✅ | ✅ | Component metadata, descriptions, links, and publish fields mostly round-trip. |
feat: create, fill, and edit slots (#862) * feat(app): add slot property controls and a shared picker AppPicker is a searchable, grouped list that opens beside the properties panel, built on Reka's popover and listbox so search keeps arrow-key navigation. It has comfortable rows with a thumbnail and description and compact rows for plain names, plus an optional footer action. The slot property row shows whether an instance's slot is Default or Modified, its item count, its limits with a checklist popover, Add instances on AppPicker, and Reset slot and Delete contents. These are presentational; wiring them to the editor follows. Strings are English only until the locale files catch up. * feat(app): open variable, style, and instance-swap choices in the shared picker The variable binding picker, the shared style fields, and instance-swap properties now open AppPicker: a titled panel beside the properties panel with search that keeps arrow-key navigation, a check on the current choice, and footer actions. Variable binding keeps its detach and create-variable actions. Instance-swap choices list the property's preferred components first; instanceSwapOptions keeps the preferred flag it already computed. AppPickerField gives select-shaped fields the same picker with a combobox trigger. * feat(core): edit instance slot content Only an instance's slots take layers now. slotScope classifies a parent as free, a slot of an instance, or the locked rest of an instance. Moves, reorders, layer-panel drops, paste, duplicate, and instance creation claim an untouched slot first, as Figma does on the first edit, and refuse the locked part; drops over it land in the instance's parent. Claiming keeps the layers but unlinks them from the component and moves the instance's overrides on nested instances onto those instances. Reset slot, Delete contents, and Add instance are editor actions, each one undo step that restores the instance's subtree. A canvas drop or reorder that claims a slot undoes together with the move. * feat(app): show and edit slot properties of the selected instance The component properties section lists each slot of the selected instance with its state, item count and limits, and adds instances, resets or clears its content through the editor's slot actions. The slot model lives in the Vue SDK as useSlotProperties. * feat(app): outline slots on the canvas and mark them in the layers panel Hovering or selecting a component, an instance, or a layer inside a slot draws its slots with a dashed pink outline and tints empty ones. Slot frames are selected and hovered in pink and show a dashed-square icon in the layers panel. * test(app): cover slot outlines with canvas snapshots * feat(app): translate slot and picker strings * docs: note slot editing and the shared picker * refactor: share slot test and story setup * refactor(app): name the picker's header prop heading * feat(core): create, configure, and remove slots on main components A frame of a main component becomes a slot through a SLOT property named after it; other sibling layers are first wrapped in an auto layout frame. Slot settings and removal are single undo steps. Instances now follow the component's property bindings on sync, so a slot created or removed on the component reaches existing instances. Slot helpers move to a slots domain folder in Scene Graph and Core. * feat(app): create and configure slots from the menu and properties panel Create slot joins the canvas context menu for layers of a main component. A Slots section on main components and their frames renames slots, sets their description, layer limits, and preferred components, and removes them. * fix: keep slot claims in the same undo step and refuse wraps inside instances Creating an instance and pasting HTML now batch the slot claim with the edit. Undoing an added slot instance restores the previous selection. Grouping or wrapping layers in the locked part of an instance is refused, and a section that cannot move no longer claims a slot. The picker clears its search however it closes, and its close and slot actions labels are translated. * feat(core): create and inspect slots through the plugin API component.createSlot() adds a 100x100 frame named Slot, Slot 2, and so on, bound to a new SLOT property, as live Figma does. Slot frames read type SLOT, resetSlot() restores an instance slot's component content, and limitViolations reports BELOW_MIN, ABOVE_MAX, and HAS_NON_PREFERRED for instance slots. addComponentProperty and editComponentProperty take a description and slotSettings, a cloned slot is a plain frame, and componentPropertyReferences uses property keys in both directions. The limit checks move to Scene Graph so the Vue SDK and the plugin API share them. * fix: keep nested slot content across swaps and read nested instance properties A nested instance points at the instance it was cloned from, so its component properties resolved to nothing. Properties now resolve through those links to the main component, and a swap or variant switch parks the slot content nested instances own and restores it into nested instances of the same names, as live Figma does. Plugin appendChild and insertChild claim the slot they add to and refuse the locked part of an instance with Figma's error. * test(core): record resetSlot on a main component slot; fix the Slots roadmap row * fix(core): refuse deleting layers outside an instance's slots As in Figma, delete and plugin remove() leave an instance's own layers and its slot frames alone, while removing slot content claims the slot in the same undo step as the delete. * test(app): wait for the bulk rename dialog to close before the next shortcut * feat(app): unify add, remove, and settings controls in the properties panel A section's + adds an item and a row's - removes it everywhere: grid tracks and variant properties now follow the fill and effect lists, and the header + of a component set adds Property 1 ready to rename instead of an inline form. Removing a variant is Delete, as for any layer. Row settings share the sliders icon, the variables button opens with its own icon, every icon button requires a label, and the instance header buttons use sentence case. Create Slot joins the app menu. * docs: note the unified properties panel controls * feat(app): animate floating panels alike and share the severity icon Popovers, menus, selects, comboboxes, and pickers now fade and grow in from the side they open on and fade out on close, from one motion preset that respects reduced motion; tooltips keep the tooltip preset. SeverityIcon and its colours move from the design check to shared feedback UI, and slot limits use them, so a broken limit reads like a design-check warning. * docs: note consistent popover and menu animation * feat(app): give every floating panel one surface and close it at once Popovers, menus, selects, comboboxes, pickers, presence cards, chat history, and the issue tooltip share one rounded surface with a 1px ring that outlines it in both themes; one-line tooltips keep a compact shape with the same edge. The select theme's radius and elevation options and local shadow overrides are gone. Panels still grow in from their side but now close immediately: a fading modal menu kept blocking the canvas and shortcuts until it unmounted. * fix(app): keep a reopened context menu open and name option-drag undo Duplicate Closing the canvas menu hands focus back to the canvas; when that landed just after a quick reopen, the new menu closed as focus moved outside it. Shortcuts no longer wait for a panel that is already closing, and an option-drag duplicate that claims a slot is undone as Duplicate rather than Move. * test(app): wait for menus and popovers to close before the next key
2026-10-04 17:02:28 +00:00
| Component sets / variants | ✅ | ✅ | ◐ | ✅ | ✅ | Multidimensional variant definitions and common instance properties are editable; broader Figma-specific property parity remains incomplete. |
| Instances / overrides | ✅ | ✅ | ◐ | ✅ | ✅ | Component-property refs and typed assignments are modeled and editable; raw symbol overrides and derived data remain preserved for fidelity. |
feat: create, fill, and edit slots (#862) * feat(app): add slot property controls and a shared picker AppPicker is a searchable, grouped list that opens beside the properties panel, built on Reka's popover and listbox so search keeps arrow-key navigation. It has comfortable rows with a thumbnail and description and compact rows for plain names, plus an optional footer action. The slot property row shows whether an instance's slot is Default or Modified, its item count, its limits with a checklist popover, Add instances on AppPicker, and Reset slot and Delete contents. These are presentational; wiring them to the editor follows. Strings are English only until the locale files catch up. * feat(app): open variable, style, and instance-swap choices in the shared picker The variable binding picker, the shared style fields, and instance-swap properties now open AppPicker: a titled panel beside the properties panel with search that keeps arrow-key navigation, a check on the current choice, and footer actions. Variable binding keeps its detach and create-variable actions. Instance-swap choices list the property's preferred components first; instanceSwapOptions keeps the preferred flag it already computed. AppPickerField gives select-shaped fields the same picker with a combobox trigger. * feat(core): edit instance slot content Only an instance's slots take layers now. slotScope classifies a parent as free, a slot of an instance, or the locked rest of an instance. Moves, reorders, layer-panel drops, paste, duplicate, and instance creation claim an untouched slot first, as Figma does on the first edit, and refuse the locked part; drops over it land in the instance's parent. Claiming keeps the layers but unlinks them from the component and moves the instance's overrides on nested instances onto those instances. Reset slot, Delete contents, and Add instance are editor actions, each one undo step that restores the instance's subtree. A canvas drop or reorder that claims a slot undoes together with the move. * feat(app): show and edit slot properties of the selected instance The component properties section lists each slot of the selected instance with its state, item count and limits, and adds instances, resets or clears its content through the editor's slot actions. The slot model lives in the Vue SDK as useSlotProperties. * feat(app): outline slots on the canvas and mark them in the layers panel Hovering or selecting a component, an instance, or a layer inside a slot draws its slots with a dashed pink outline and tints empty ones. Slot frames are selected and hovered in pink and show a dashed-square icon in the layers panel. * test(app): cover slot outlines with canvas snapshots * feat(app): translate slot and picker strings * docs: note slot editing and the shared picker * refactor: share slot test and story setup * refactor(app): name the picker's header prop heading * feat(core): create, configure, and remove slots on main components A frame of a main component becomes a slot through a SLOT property named after it; other sibling layers are first wrapped in an auto layout frame. Slot settings and removal are single undo steps. Instances now follow the component's property bindings on sync, so a slot created or removed on the component reaches existing instances. Slot helpers move to a slots domain folder in Scene Graph and Core. * feat(app): create and configure slots from the menu and properties panel Create slot joins the canvas context menu for layers of a main component. A Slots section on main components and their frames renames slots, sets their description, layer limits, and preferred components, and removes them. * fix: keep slot claims in the same undo step and refuse wraps inside instances Creating an instance and pasting HTML now batch the slot claim with the edit. Undoing an added slot instance restores the previous selection. Grouping or wrapping layers in the locked part of an instance is refused, and a section that cannot move no longer claims a slot. The picker clears its search however it closes, and its close and slot actions labels are translated. * feat(core): create and inspect slots through the plugin API component.createSlot() adds a 100x100 frame named Slot, Slot 2, and so on, bound to a new SLOT property, as live Figma does. Slot frames read type SLOT, resetSlot() restores an instance slot's component content, and limitViolations reports BELOW_MIN, ABOVE_MAX, and HAS_NON_PREFERRED for instance slots. addComponentProperty and editComponentProperty take a description and slotSettings, a cloned slot is a plain frame, and componentPropertyReferences uses property keys in both directions. The limit checks move to Scene Graph so the Vue SDK and the plugin API share them. * fix: keep nested slot content across swaps and read nested instance properties A nested instance points at the instance it was cloned from, so its component properties resolved to nothing. Properties now resolve through those links to the main component, and a swap or variant switch parks the slot content nested instances own and restores it into nested instances of the same names, as live Figma does. Plugin appendChild and insertChild claim the slot they add to and refuse the locked part of an instance with Figma's error. * test(core): record resetSlot on a main component slot; fix the Slots roadmap row * fix(core): refuse deleting layers outside an instance's slots As in Figma, delete and plugin remove() leave an instance's own layers and its slot frames alone, while removing slot content claims the slot in the same undo step as the delete. * test(app): wait for the bulk rename dialog to close before the next shortcut * feat(app): unify add, remove, and settings controls in the properties panel A section's + adds an item and a row's - removes it everywhere: grid tracks and variant properties now follow the fill and effect lists, and the header + of a component set adds Property 1 ready to rename instead of an inline form. Removing a variant is Delete, as for any layer. Row settings share the sliders icon, the variables button opens with its own icon, every icon button requires a label, and the instance header buttons use sentence case. Create Slot joins the app menu. * docs: note the unified properties panel controls * feat(app): animate floating panels alike and share the severity icon Popovers, menus, selects, comboboxes, and pickers now fade and grow in from the side they open on and fade out on close, from one motion preset that respects reduced motion; tooltips keep the tooltip preset. SeverityIcon and its colours move from the design check to shared feedback UI, and slot limits use them, so a broken limit reads like a design-check warning. * docs: note consistent popover and menu animation * feat(app): give every floating panel one surface and close it at once Popovers, menus, selects, comboboxes, pickers, presence cards, chat history, and the issue tooltip share one rounded surface with a 1px ring that outlines it in both themes; one-line tooltips keep a compact shape with the same edge. The select theme's radius and elevation options and local shadow overrides are gone. Panels still grow in from their side but now close immediately: a fading modal menu kept blocking the canvas and shortcuts until it unmounted. * fix(app): keep a reopened context menu open and name option-drag undo Duplicate Closing the canvas menu hands focus back to the canvas; when that landed just after a quick reopen, the new menu closed as focus moved outside it. Shortcuts no longer wait for a panel that is already closing, and an option-drag duplicate that claims a slot is undone as Duplicate rather than Move. * test(app): wait for menus and popovers to close before the next key
2026-10-04 17:02:28 +00:00
| Slots | ✅ | ✅ | ✅ | ✅ | ✅ | Slot properties, settings, and instance slot content import, render, and round-trip. The editor creates, configures, and removes slots on main components and fills them in instances; scripts do the same through `createSlot()`, `resetSlot()`, and `limitViolations`. |
| Connectors | ◐ | ◐ | — | ◐ | ◐ | Type exists, but Figma connector semantics are weak. |
| Shape-with-text / FigJam shapes | ◐ | ◐ | — | ◐ | ◐ | Type exists, but not a full FigJam feature implementation. |
| Slices | ◐ | — | ◐ | ◐ | ✅ | Slice-like export regions exist via tooling, not as true Figma slice nodes. |
| FigJam / Slides / Code / CMS / Buzz node families | ↩ | — | — | ↩ | — | Current Kiwi schema recognizes many newer Figma node families (`TABLE`, `SLIDE`, `CODE_COMPONENT`, `CMS_RICH_TEXT`, `REPEATER`, `WEBPAGE`, etc.), but OpenPencil only preserves/round-trips them where safe; they are not first-class scene nodes. |
| Solid fills | ✅ | ✅ | ✅ | ✅ | ✅ | Color variables supported for common fill cases. |
| Gradients | ✅ | ✅ | ✅ | ✅ | ✅ | Linear/radial/angular/diamond support; Figma edge cases may differ. |
| Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; imported crop/tile affine transforms are applied, but exact Figma parity is still partial. |
| Pattern / noise / custom fills | ✅ | ◐ | — | ✅ | — | Schema metadata imports/exports; Figma pattern fills with a referenced source node render as repeated source tiles with scale, spacing, alignment, and basic hex offsets. Noise/custom paints still render with a solid fallback pending real paint payload samples; Figma-authored noise/texture/glass effect payloads are captured separately. |
| Video/GIF/media fills | ↩ | — | — | ↩ | — | Kiwi schema includes media paint/export enums, but OpenPencil has no video/GIF playback or media layer support. |
| Layer/fill/effect blend modes | ✅ | ◐ | ✅ | ✅ | ✅ | Appearance, fill, and effect controls are exposed; Canvas applies common modes, while Figma isolation edge cases remain partial. |
| Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. |
| Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. |
| Stroke caps / joins / miter limit | ✅ | ✅ | ✅ | ✅ | ✅ | Inspector controls support mixed cap/join/miter editing; CanvasKit rendering and `.fig` roundtrips preserve miter limits. |
| Effects: shadows and blurs | ✅ | ✅ | ✅ | ✅ | ✅ | `showShadowBehindNode` is rendered but not exposed in UI. |
| Fill / stroke / effect styles | ✅ | ✅ | ◐ | ✅ | ✅ | Imported local definitions are modeled and selectable with undo-safe detach; creating and publishing styles still needs a style manager. |
| Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. |
| Corner smoothing | ✅ | ✅ | ✅ | ✅ | ✅ | The inspector supports mixed smoothing percentages with undo; uniform and independent-radius corners render, while exact Figma parity still needs broader fixture tuning. |
| Masks | ✅ | ◐ | — | ✅ | ✅ | Figma schema `mask`, `maskType`, and `maskIsOutline` fields import and export; common sibling alpha/vector/luminance mask stacks render, including consecutive mask layers. UI controls and deeper Figma edge cases remain incomplete. |
| Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. |
| Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. |
| Auto layout: grid | ✅ | ◐ | ◐ | ✅ | ✅ | CSS-grid-like support is partial; newer schema fields for grid child alignment and auto tracks are not fully exposed. |
| Padding / gaps / alignment | ✅ | ✅ | ✅ | ✅ | ✅ | Common flex controls are exposed. |
| Hug / fill / fixed sizing | ✅ | ✅ | ✅ | ✅ | ✅ | Min/max support is partial in UI. |
| Ignore auto layout / absolute positioning | ✅ | ✅ | ◐ | ✅ | ✅ | Mode is modeled; UI coverage is partial. |
| Strokes included in layout | ✅ | ◐ | — | ✅ | ✅ | Stored/exported and used in layout paths, but no obvious panel control. |
| Reverse z-index / align-content | ✅ | ◐ | — | ✅ | ✅ | Modeled and exported; UI is limited. |
| Constraints | ✅ | ◐ | ✅ | ✅ | ✅ | Horizontal and vertical pin, center, stretch, and scale modes are editable; imported edge-case parity remains partial. |
| Layout grids / guides | ✅ | ✅ | ◐ | ✅ | ✅ | Layout-grid geometry and page/frame guides are editable, including guide creation, movement, duplication, transfer, and removal. Full grid-style management remains incomplete. |
| Text styles | ✅ | ✅ | ◐ | ✅ | ✅ | Imported local text styles are modeled, selectable, and detachable; authoring and publishing style definitions still needs a style manager. |
| Rich style runs | ✅ | ✅ | ◐ | ✅ | ✅ | Import/render/export support; editing mixed runs is partial. |
| Text auto resize | ✅ | ✅ | ◐ | ✅ | ✅ | Used by renderer/layout; UI does not expose every mode. |
| Text truncation / max lines | ✅ | ✅ | ✅ | ✅ | ✅ | Ending truncation and maximum-line controls are available in the inspector. |
| Text case | ✅ | ◐ | ✅ | ✅ | ✅ | Original, upper, lower, and title case are editable; broader render parity still needs fixtures. |
| Vertical text alignment | ✅ | ◐ | ✅ | ✅ | ✅ | Top, center, and bottom alignment are editable; imported edge-case parity needs more coverage. |
| Justified text | ✅ | ◐ | ✅ | ✅ | ✅ | Justification is exposed in the typography inspector; render parity remains partial. |
| Font variations / OpenType features | ✅ | ✅ | — | ✅ | — | Imported `fontVariations`, common ligature/caps/numeric OpenType fields, and raw `toggledOnOTFeatures` / `toggledOffOTFeatures` are applied to CanvasKit text styles and exported; UI controls are not exposed. |
| Variables: collections/modes/aliases | ✅ | ◐ | ◐ | ✅ | ✅ | Color/number/string/boolean model exists; inspector coverage is still incomplete. |
| Variables bound to fills/strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Common color bindings render and edit. |
feat(fig): occurrence-scoped instance interpretation as the single .fig reader * refactor(fig): introduce occurrence-scoped instance interpreter * refactor(fig): add direct occurrence materialization and render diagnostics * fix(scene-graph): preserve nested edits and invalidate text layout caches * refactor(fig): assemble indexed documents with occurrence provenance * refactor(fig): validate document assembly against live scene oracles * fix(text): preserve saved glyphs and supported run paints * test(fig): share typed GUID fixture helper * fix(fig): resolve component root keys in instance overrides * fix(kiwi): reject malformed byte arrays before encoding * fix(components): target properties by source identity through undo * fix(fig): preserve editable occurrence export contracts * refactor(fig): construct live component dependency closures * fix(fig): invalidate inherited text geometry after occurrence overrides * perf(fig): reuse component expansions and narrow payload copies * perf(fig): avoid discarded metadata and instance definition copies * perf(fig): transfer parsed records into archive reader ownership * feat(fig): add incremental page sessions with load rollback * test(fig): verify page deltas and stale revision rejection * feat(fig): wire reader worker sessions and compact recovery checkpoints * docs(fig): organize reader architecture and visual examples * chore(fig): checkpoint WIP reader and writer overhaul Preserve in-progress FIG reader, instance interpretation, editable export, and validation work on its feature branch. This is a backup checkpoint, not a release-ready or fully validated change. * refactor(fig): resolve instance structure before expansion Route swaps and property assignments down to the instance they configure so each occurrence expands once with its effective component and complete assignment list. Owners then apply property claims onto the built subtree, which keeps values in the declaring owner's coordinate space and orders inner owners before outer ones without re-expansion, recipes, or patch restoration. Track the components an occurrence expanded before an outer decision replaced them, including intermediate swap assignments, so a claim that resolved against a superseded component is retired while a genuinely missing target still reports. Precedence is one rule: an explicit claim keeps a field unless a strictly outer owner assigned it. Drop the detached-lineage remap heuristic; unresolved assignments report through the existing diagnostic instead of guessing a replacement target. The Accordion source-closure fixture reports two stale overrides, not three: the third came from a subtree the old interpreter expanded and discarded. * refactor(fig): derive override field handling from one registry Describe each claimable raw field once, with its SceneGraph fields, kind, and whether it is a length, and derive claim recording, layout-distance scaling, and export serialization from it instead of maintaining parallel tables. Restore every field the uniform scaler touches from the instance record after scaling. The record already describes the placed result, but corner radii, dash patterns, and effects were previously scaled without being restored, so a scaled instance with its own corner radius rendered it doubled. * fix(fig): retire nested swaps under a replaced component A structural layer routed through an instance whose component an outer owner replaced may still address the original component's children. Such a layer is stale in the same way a property claim is: it resolved before the outer decision and has no target now. Carry the replaced components across that boundary and skip the layer instead of failing the file. material3's List swaps a list item to another variant while the item's own saved swap of a trailing checkbox still names the original variant's child. * fix(core): report stale Figma override records instead of refusing the file Figma keeps override, assignment, and binding records that address nodes it later deleted, and material3.fig could not open because the reader ran the document session strictly. Share one set of session options across the reader and recovery sessions that collects those records as diagnostics and skips them; a swap whose replacement is missing remains a structural failure. The component-metadata expectation follows the visible Buttons page copy of the component set, which the dependency closure now resolves instead of an internal-only copy. * fix(fig): keep instances of deleted components when opening a document Figma retains instances whose main component was deleted, and material3's Internal Only Canvas has 56 of them, so an edited document could not be exported: export loads every page and the reader refused the page over missing reachable sources. The dependency closure now separates deleted components from broken hierarchy, which remains fatal. With the new onMissingComponent option the interpreter keeps such an instance as a childless occurrence that retains its saved reference, applies only its root claims, and reports the owner; strict interpretation still fails. The core reader opts in, shares one diagnostics sink with recovery and export sessions, and exposes it through readerDiagnostics(). Property defaults naming a deleted component are kept the same way, so an edited export no longer rejects them. * fix(fig): resolve variant property values through the component set A variant's saved specs name variant definitions that its component set owns, so occurrence conversion left them keyed by definition id. Resolve them to names once the set is in the graph, as the previous importer did. The component-metadata expectation follows the visible Buttons set's axes; the Style axis belonged to an internal-only copy. * refactor(fig): satisfy type-aware lint in the interpreter and export * fix(fig): keep an instance fill override's variable alias across export A fill or stroke override on an instance descendant lost its colour variable on export: the paint claim was written without the alias, and a boundVariables override for a paint colour produced no claim at all because paint colours are not node-level consumption fields. The reopened paint therefore bound to the component's default variable. Write override paints through the same alias-aware builder as node paints, serialize a paint colour binding override as the paint claim itself, and on import record the binding claim alongside a claimed paint that carries an alias so a later component sync cannot restore the component's binding. On an edited material3.fig round trip this removes all 10,329 fill differences; 2,217 of 78,425 nodes still change, almost all text metadata Figma keeps on outlined vectors. * docs(fig): describe the single reader, its diagnostics policy, and paint claims The status documents still said the replacement reader covered only some worker paths and that old-reader removal was pending. Every import path now uses it and the previous importer is deleted, so state that and move the open items to fidelity and performance. Record the contracts added recently: strict-by-default interpretation with per-session diagnostic handlers that the application reader opts into, instances of deleted components kept as childless instances, the shared override field registry, paint colour aliases serialized inside paint claims, and variant values resolved through the component set. Correct the clipboard ownership rule in AGENTS.md: the envelope belongs to fig, pasted records go through the same reader as documents. * docs: note exported instance overrides in the changelog * refactor(fig): share record indexing and symbol data access Five modules built their own GUID-to-record index with the same idiom; they now use the source index, or indexRecords when child order is not needed. The Kiwi codec types only symbolID, so every reader cast symbolData to reach overrides and the uniform scale; symbolDataOf, symbolOverridesOf, and uniformScaleOf replace those casts. idOf and parentIdOf name the record identity conversions used by ancestry walks. * refactor(fig): share tree search and traversal across records and occurrences The rule that a path segment may pass through ordinary containers but never implicitly into an instance existed three times, once per tree. findWithinBoundary owns it now, parameterized by a tree shape; the occurrence resolver and the static record resolver are two callers. An occurrences() iterator replaces hand-rolled recursion in the component planner, closure, layout scaler, and correspondence linker, forEachOverrideRecord replaces the record-plus-overrides walks in the dependency scans, and one child-pairing generator serves both source-children matchers. * refactor(fig): serialize override claims from the field registry Split export-node.ts: export-context.ts owns the serialization context, GUID allocation, and paint builders; override-claims.ts owns instance override serialization. The override serializer was a chain of field checks that had to agree with the registry materialization records claims from; it is now one switch over the registry's field kinds, the export side of that table, with swaps and variable bindings as the two cases the registry does not describe. Decoded record streams for an edited gold-preview export and a synthetic bound-fill export are identical before and after. * fix(core): record instance overrides for FigmaAPI rename and resize The name setter and resize() wrote to the graph directly, so a rename or resize of an instance child through the Figma API was never recorded as an override: component sync reverted it and export did not write it. Route both through the shared recording update like every other setter. * fix(fig): address overrides inside nested instances by the definition child An override on a child of a nested instance was addressed through the enclosing component's own copy of that child. That node lives inside an instance and is never written as a record, so Figma could not resolve the path and dropped the override. Follow the correspondence until it leaves every instance, which yields the nested component's child, the record Figma itself names in the same situation (verified against Figma's clipboard encoding of the identical edit and by reopening the export). * test(fig): record the Figma reopen of reader exports * test(fig): compare reopened exports with the oracle tool The interpreted-document comparison already reads Figma's interpretation of an archive against the reader's; pointing it at an exported archive and its imported Figma file makes it the reopen check. Captures need the imported file to be the active document, so add an activate-tab operation that brings a desktop tab to the front through the shell page. Record the comparison results for the three reopened exports and document the procedure. * fix(scene-graph): keep a nested instance's correspondence across a swap Children populated by cloning link to the enclosing component's record through componentId. Swapping a nested instance replaced that field with the new component, so the swap was exported against the replacement component's GUID instead of the nested instance record and Figma could not apply it. Record the correspondence as the owner's sourceComponentId override and the swap as its componentId override, as materialized documents already carry them. * fix(core): treat applied shared styles as instance overrides Style references were not instance sync fields, so a text style applied inside an instance was neither recorded as an override nor exported, and a component's style change did not reach its instances, although the reader records styleIdForText claims from Figma. Add the style reference fields to the sync set and expose them on the Figma API proxy under Figma's names so assignments through the API record overrides. * test: record the second Figma reopen round for the reader export Figma confirmed stroke and corner-radius variable bindings, an applied text style, nested-frame layout distances and sizing modes, visibility, and a nested swap. A size claim on an auto-layout child inside an instance is not applied, matching Figma's own resize refusal there. * chore: format the merged structural export test * refactor: group export and instance sync modules into domain folders The node-change export context, node serializer, runtime, and override claims move under node-change/export/, and the scene graph's instance child sync and sync field lists move under instances/, keeping the public instances module to its API. * fix(fig): address exported instance overrides by override key Figma resolves an override path segment through the target record's override key, never its GUID: in gold-preview.fig all 10,341 override and 12,838 derived-geometry segments resolve that way and none resolve to a node GUID. A component imported from Figma keeps its keys, but one authored here has none, so the writer addressed its descendants by GUID. Figma tolerated that for most fields and silently dropped the geometry, so a descendant resized inside an instance reopened at the component's size. Definition records — a component and everything inside it — now carry an override key, minted from the shared identity counter when the node has none, and paths name that key. One map spans the document because the serializer runs once per top-level child. The library content hash ignores the key, which identifies a record rather than the component's content, and the clipboard export passes its variable mode map as modeIdToGuid instead of propertyIdToGuid. * docs: record how Figma resolves an override path * Revert "fix(fig): address exported instance overrides by override key" This reverts commit 38eebb2e5, except its clipboard argument fix. The change came from gold-preview.fig, where every override path segment resolves through a record's override key. material3.fig shows the opposite: 51,332 of its segments are node GUIDs against 24 keys, and only 16 of 87,237 records carry a key at all. gold-preview is a file of library instances, where the key is the cross-file identity; addressing by GUID is what Figma writes for locally authored components, which is what the writer already did. It was also not the reason Figma ignored a descendant's size claim, which is still open. The clipboard export keeps passing its variable mode map as modeIdToGuid rather than propertyIdToGuid, which was an unrelated defect in the same call. * docs: correct the override addressing note and record the size gap * docs: settle the descendant size gap as a Figma constraint * chore: format the JSON fixtures this branch adds format:check runs the formatter and fails on any change, so the fixtures have to be committed as oxfmt writes them. * test(tools): smoke the instance override subpath's current exports populateAndApplyOverrides belonged to the importer this branch removes. * perf(fig): index the archive once per document, not once per page Selecting a page rebuilt both whole-document source indexes, so opening material3.fig with its 33 pages indexed 87,237 records 33 times and 86,888 records another 33 times: 102 index builds where 36 are needed. Only the page's own subset varies, so the full index and the component interpreter move into state shared across selections, and the initial read path passes its index to inheritance, style lookup, the dependency closure and component planning rather than each building its own. The paint and component-property passes iterate keys directly instead of materializing an entry array for every node, most of which bind nothing. Loading material3.fig goes from about 9.5s to about 7.5s on the same machine, measured back to back with the machine otherwise idle. * docs: note the faster multi-page .fig load * perf(fig): apply document passes to the nodes a page materialized Linking component property values, resolving variant values and applying layout and paint bindings each walked the whole graph and skipped what was already there, so every page load re-visited every node the earlier pages had produced. On nuxtui.fig, 121 pages over a graph that reaches 354,000 nodes, those four passes were 22.7% of the profile after only six pages and grew from there. Each pass now takes the nodes just materialized. Component property types are remembered across page loads instead, because an assignment on a new node can name a definition an earlier page introduced; seeding that cache is the only pass that still reads the whole graph, once per document rather than once per page. Pages 3 to 20 of nuxtui.fig fall from 90.0s to 51.6s. The first page is unchanged: it materializes 256,354 nodes and is dominated by that. * docs: note the per-page load improvement * test(fig): keep the fig package suite off Core Twenty package tests reached for Core's writer and editor through @open-pencil/core, a package that depends on fig. Nothing declared that edge, so the suite passed only because the workspace root hoists Core. Their subject is the writer, so they move to tests/engine/io/fig, where half the domain already spans both packages. The package no longer escapes its own root: tsconfig drops the #tests/* mapping, expectDefined is three lines beside the other helpers, and the gold archive is read through the LFS-guarded fixture helper instead of a hand-built ../../../../tests/fixtures URL. #fig/ and #fig-tests/ join the steiger alias tables and the AGENTS.md list, so the foreign-alias rule can see them. Fig's tests mirror its source tree rather than sitting flat like kiwi's, so they address it by alias instead of drilling, and the guid helper is imported one way. * refactor(fig): drop code the reader replacement left behind resolveDsdGeometry lost every production importer when the old derived symbol data modules went, so it and the three tests that only exercised it go too, and the folder collapses to one file. validateVariableAliases was called only by its own test and wiring it in would mean a new public diagnostic handler; it is removed rather than left dangling. recordInstanceOverrideValue had no caller in either base or head, and its comment began mid-sentence. SymbolOverrideFields had no consumers, and savedTextEligibility is used only inside its module. The clipboard's NON_VISUAL_TYPES was a hand-copied union of the two sets behind isFigClipboardVisualType, which had no consumer of its own; the classifier now serves both and leaves the root export. FIG_PACKAGE_STATUS reads document-reader, and assertFigPackageReady is gone: the package reads archives into a SceneGraph rather than telling callers to use Core. sceneNodeToKiwi takes its ten optional maps as an options object. That removes the signature Core's wrapper had to restate, which was the last clone blocking packages/fig/src from the duplication gate, and the undefined holes at the clipboard's two call sites. * refactor(core): share identity allocation between the two .fig writers The clipboard allocated variable, mode and shared-style GUIDs its own way while the document exporter did the same work in assignVariableGuids and appendInternalResources. The two already disagreed: the exporter reuses an id that is already GUID-shaped and dedupes against node source GUIDs, the clipboard always minted a fresh sessionID 1. Both now call one pair of helpers in variable-export.ts, so a change to how a document names its resources reaches the clipboard too. * refactor(fig): name the values that were spelled out in several places exportSizing existed to name the HUG ternary but the inline layout branch still wrote it out. The winding-rule conversions become toKiwiWindingRule and fromKiwiWindingRule rather than the same ternary three times and its inverse once. sameId duplicated sameGuid. The style reference field list existed twice, and one site built a GUID string by hand instead of calling guidToString. The opacity percent-to-unit factor and the alias-or-expression test each have a name now. fig.kiwi declares parameterConsumptionMap as a VariableDataMap and PropRefValue as a variable value, but the codec typed neither, so four call sites cast. Typing them in kiwi removes the casts, and the merge that spread two maps now builds the only field the message has. Schema coverage counts one more modeled field and one fewer raw-preserved. * refactor(fig): require the index instead of rebuilding it behind a default createScopedReader is private and always receives the shared state, and the closure, component planning and property inheritance always get an index from it; the optional parameters existed only so two tests could omit them, and each hid a second full pass over every record. They are required now, and the tests build an index the way production does. materializeReader returned a fresh object that dropped definitionTypes, so the first loadPage after createFigDocumentSession reseeded the cache it was meant to reuse; it returns the state it was given. The shared style reference shape is a named type built with the rest of the export context rather than written inline twice and filled lazily inside a getter, and the population client derives its two responses from FigSessionResponse instead of restating one and casting to it. * refactor(fig): give materializeInstance named options Three of its seven parameters were defaulted maps that call sites passed unnamed, so a call read as a list of empty collections. They become an options object, matching how InterpretInstanceOptions is passed in the same folder. That change also caught a latent hazard: an empty array satisfies an all-optional interface structurally, so a call site left on the old positional form type-checked while silently dropping its source-child map. Converting the remaining call sites fixed a component sync test that had started failing for exactly that reason. The DOCUMENT/VARIABLE guard is one assertion function rather than two copies, and it narrows the node type for the creation that follows. * refactor: group the prefixed siblings this PR left behind instance-overrides kept layout-scale, text-scale, interpret-bindings and variable-bindings as prefixed siblings while the same PR introduced scene-graph/src/{scaling,variables}/. They become scale/{layout,text} and bindings/{properties,variables}. The empty derived-symbol-data folder is gone now that it holds one file. STRING_BINDING_FIELDS and BOOLEAN_BINDING_FIELDS stayed in variables.ts after NUMERIC_FIELDS moved to variables/fields.ts; all three live together. * docs(fig): describe the reader as it is, not as a replacement The README, document-sessions, validation notes and several comments still framed the work as pending: an old reader to delete, a migration to finish, variables and lazy loading not yet integrated. All of that landed. Error messages and a worker adapter that called themselves "replacement reader" and "format-neutral" say what they are. Comments that described the wrong function are reattached: the root layer note belonged to resolveRoot rather than bindingHistory, the expand note was duplicated onto bindRecord, the owner-scope note sat on pairSourceChildren instead of linkInstanceSourceChildren, sync.ts put its module summary on setSceneProp, and transfer/history.ts ended with an orphan. The visual oracle's interpret-instance and compare interpreted-document are citty subcommands like the rest, its SCREAMING-CASE note folds into packages/fig/docs/validation.md without the benchmark observation, and its two tests mirror the source tree using the package alias. * docs(fig): keep Figma observation records out of the fixture tree Ten JSON records, twelve notes and a screenshot under tests/fixtures had no code consumer: they are what Figma reported for a given document, cited by packages/fig/docs. They move to packages/fig/docs/observations beside the prose that reads them. The three JSON files tests do load, and the eight screenshots the raster comparisons load, stay where the tests expect them. Fixture READMEs follow their fixtures: the gold layout and shared scale notes to tests/engine/io/fig/instance, the export contract note to tests/engine/io/fig/export. Numbers fused to the words before them are separated throughout the notes. Path failures assert the diagnostic reason through one helper rather than matching 'found 0' or a full sentence, which is the pattern materialize.test.ts already used. * refactor(core): name the reader state module for what it owns session/recovery.ts holds the per-graph reader state and, with it, page population, diagnostics and export population as well as recovery. The functions cannot move out without exporting that state map, so the file takes an accurate name instead, and the state type follows. io/formats/fig/index.ts keeps its aliased re-export: the relative path is three levels up, which no-deep-parent-relative-imports rejects. * chore: adopt the js-base64 rule master added * test: move the new tests to the homes master's gate requires #790 added check:test-homes: a new test under tests/engine is rejected, and the baseline of existing ones shrinks. This branch had added 46. Their owner is whichever package the test's subject lives in, not the directory the old shard map implies. Forty test Core's writer, editor or reader session and move to packages/core/tests, which gains the test tsconfig and scripts the other packages already have; six test Fig alone and move to packages/fig/tests. verifier-contracts covers the roundtrip helpers that eight grandfathered engine tests share, so it stays beside them and joins the baseline. Package tests no longer reach outside their package for support: each has local assert, guid, fixture and nested-binding helpers, and shared archives under tests/fixtures are read through a helper path rather than imported as modules across the root. interpretComponent, materializeComponentClosure and the source-children helpers are public, because tests outside Fig legitimately need them. The steiger owner for #core/ and #fig/ is the package rather than its src, since a package's own tests mirror the source tree and would otherwise drill through ../../src. * test: mirror each package's source tree in its test tree The relocated tests kept their tests/engine directory names, which do not match the packages they landed in: figma/api against src/figma-api, render/canvas against src/canvas, io/fig against src/io/formats/fig, and a fig tests/io and tests/text with no counterpart in that package. Each now mirrors its source domain. Two had no home in the package they were put in. The derived-text layout invalidation test only exercises Scene Graph, so it moves there, and the transfer plan test spans Scene Graph and Fig with neither owning it, so it becomes the first tests/integration spec, which is what that directory is for. tests/AGENTS.md named a baseline path the tools reorganization moved, and packages/fig/AGENTS.md now records its own test alias. * fix(fig): open a file whose swap names a layer its component lost Preline UI's `_header/navbar` keeps a swap addressing 4473:100430, a node the archive no longer contains, while the replacement it names is still there. Figma opens that file and so did the previous importer; this reader refused it. The rule was written for a swap whose replacement is missing, which nothing can resolve, but the code threw for any unresolved swap. A path that matches no record is a record Figma kept after deleting the layer it named, which is the case the property and assignment diagnostics already cover. A path that matches more than one record is a wrong address rather than a stale one and still fails. * fix(fig): address an override through the variant that holds its layer An instance path names a layer by the identity it had in the variant the override was written against. Switching variants keeps the override in Figma, so a segment that names no layer of the variant an occurrence expands now addresses the layer at the same position there, when the two agree on type and name. Resolution reports the path it took, so a claim recorded after a translated segment stays addressable when the instance materializes. Each component set's addressable layers are indexed once on first use rather than rescanning every sibling variant per segment. * fix(fig): read text bound to a string variable Figma stores a bound layer's resolved characters, but an instance override carries the binding alone, and a literal override of a bound layer is retired rather than applied. Reading neither left the badge on Preline's navbar showing its component's own text where Figma shows the variable's value, and the input placeholder showing a literal override Figma ignores. Text joins font family as a bindable string field, the reader records a TEXT_DATA alias like any other binding, and a post-pass resolves it once hierarchy and modes exist, next to the paint bindings it mirrors. Resolving after property claims is what makes a binding win over a literal, the way Figma retires the override. Validated by reopening an exported file in Figma: the collection, the string variable, and the binding on both the component and its instance survive the round trip. * fix(fig): take a bound paint's transparency from its variable A solid fill draws at its paint opacity, not its colour's alpha, so a colour variable carrying transparency has to supply that opacity. Resolving the binding into the colour alone left a translucent token applied twice on Preline's navbar links, and left a Divider at the opacity of an override the binding supersedes. The variable now owns the whole colour: its alpha becomes the paint's opacity and the colour keeps none of its own. * test(tools): compare paint in the interpreted-document oracle The oracle checked type, name, visibility, text, main component and box, so every fill and stroke a reader produced went unchecked. A wrong fill transparency on Preline's navbar passed it. Paints are captured on both sides as the alpha drawing actually uses, which is the paint's opacity for a solid, and reported as visible-paint or hidden-paint like geometry. A Scene Graph stroke is always solid, so it is encoded as one rather than through a type it does not carry. * perf(fig): synchronise a component once per page load, not once per instance Materializing an instance into an open document re-synchronised every instance of its component, and synchronising walks each one's subtree. A page that places a component many times therefore paid that walk once per placement. Opening Preline's CMS page ran 954 synchronisations over 39225 instances for the 954 it placed. Components are collected while the page is built and synchronised once each afterwards: 31 calls over 1283 instances, and the page loads in 3.9s rather than 11.6s. The resulting graph is unchanged, by digest over every node's geometry, text, paint, bindings and override keys for that page and for a second page loaded on top of it. * Revert "fix(fig): address an override through the variant that holds its layer" This reverts commit fcdc7660f. Figma does not carry an override onto the corresponding layer of another variant, so translating a segment that way applies overrides it drops. On Preline's Alerts frame the translation raises semantic differences against live Figma from 2 to 54: 127 buttons read their own label where Figma reads the component's. It fixed nothing visible — the five text differences it was written for turned out to be string variable bindings, fixed separately — so it only ever added wrong overrides. * docs(fig): restore the guide rules the master merges dropped Splitting the root guide into nested ones lost three rules this branch had added, and left the fig guide claiming clipboard records are converted to a SceneGraph in `@open-pencil/fig/clipboard`, which is now `materializeFigFragment` driven from Core. Records what the reader cannot do as well: a string binding resolves once at read time, so text bound to a variable goes stale when the variable or the node's mode changes, unlike a numeric or colour one. Groups the four `*-bindings` siblings under `document/bindings/`, the convention the branch already applied to `instance-overrides/bindings/`. * perf(fig): copy archive records directly instead of structurally Every expanded record is deep-copied so an occurrence shares no mutable data with the archive, a contract two tests state. `structuredClone` was a third of the time spent opening a page, and records are plain Kiwi data, so copying them field by field is several times quicker — 43944 records of Preline UI clone identically either way, 218ms against 26ms. Byte buffers and anything else that is not an object literal keep the structured algorithm. Preline's CMS page now loads in 2.8s rather than 5.6s, and with the per-component synchronisation fix in 0d1854a3a, 11.6s before either. * test(tools): compare a reader's whole output, not one frame `compare interpreted-document` checks one frame against live Figma. A rule can leave that frame untouched and still change pages it does not cover: addressing an override through a sibling variant reported no difference on the frame under test while rewriting 127 button labels elsewhere, and was reverted only after a whole-document comparison found them. `compare digest` captures every page a reader produces and diffs it against an earlier capture, reusing the same node capture and difference categories, so a before-and-after needs no Figma. Replaying the reverted change against a baseline reports 110 semantic differences. Unresolved-override counts are reported beside the nodes, since a reader change usually moves those too.
2026-10-01 07:20:27 +00:00
| Variables bound to text/layout/visibility/effects | ◐ | ◐ | ◐ | ◐ | ✅ | Text content and numeric layout bindings resolve on read; colour and numeric bindings also re-resolve when a variable or mode changes, string ones do not. Effect bindings are not modelled. |
| Variables in prototypes / expressions / conditionals | — | — | — | — | — | Depends on prototype system, which is not implemented. |
| Libraries / publish / update review | ↩ | — | ◐ | ↩ | — | Figma library metadata survives round-trip. OpenPencil supports its own component-library publishing, catalogs, revision previews, and linked-instance updates, not synchronization with Figma-hosted libraries. |
| Prototype flows / starting points | — | — | — | — | — | Not modeled. |
| Prototype hotspots / triggers / actions | — | — | — | — | — | Not modeled. |
| Prototype overlays / scroll-to | — | — | — | — | — | Not modeled. |
| Smart animate / easing / spring / duration | — | — | — | — | — | Not modeled. |
feat: behaviours and preview mode (#893) * feat: author behaviours on main components A main component or component set can behave as a Switch, Checkbox, Slider, or Tabs, after Reka UI's primitives. The behaviour lives in OpenPencil plugin data: boolean values bind to variant or boolean properties with the values meaning on and off, a number keeps its own range since Figma has no number property, and the control's subcomponents bind to the component's slots. A Behaviour section in the properties panel adds, binds, and removes it, each as one undo step, and flags required bindings that are missing. The canvas-only layout's pill becomes a component that preview will reuse. * feat: preview instances with behaviours on the canvas View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas switches to the canvas-only layout with a Previewing pill, and a split canvas previews on its own side. Clicking a Switch or Checkbox flips it, dragging a Slider moves its thumb and range, and clicking a Tabs trigger shows its panel. Preview keeps its state on copies of the instances it touched, in a private graph with the document's ids, and the canvas draws those copies in place of the originals, so the document, undo, autosave, and collaborators never see it. Escape or the pill leaves preview, Reset restores every control, and editing shortcuts, labels, and outlines stay off while previewing. * feat: translate behaviour and preview strings; cover preview with an e2e flow * refactor(vue): reuse VariantDefinitionControl for behaviour property options * refactor: split variant actions and preview interactions by domain Variant authoring was one 706-line closure; it is now graph queries (model), undo snapshots (history), property definition edits (definitions), and the editor facade (index). Preview interactions move into play/kinds, one module per control, registered by behaviour kind so a new kind cannot ship without its contract and interaction. Behaviour contracts are keyed by kind. In the Vue SDK, slot and variant authoring controls get their own folders beside component-props and behaviour, and the app's variant section joins slot/ and behaviour/. * refactor: keep the behaviour model in scene-graph's plugin-data registry Master now defines every OpenPencil plugin-data key in one typed registry in scene-graph. The behaviour schema registers there as a field, and the model and contracts move beside slots, exported from the package root; the @open-pencil/core/behaviours subpath is gone. * feat: interaction states and keyboard focus in preview A behaviour can bind a variant property to the default, hover, pressed, focus, and disabled states; binding it maps values named like those states. Preview switches the instance's copy to the matching variant as the pointer hovers, presses, and releases, keeps other values when the set draws the combination and falls back to rest otherwise, and skips disabled instances. Tab moves visible keyboard focus between controls, Space, Enter, arrows, Home, and End use the focused one, and Escape takes visible focus off before leaving preview. A Button kind covers controls that only have states. * feat: toggle, radio, group, progress, collapsible, and accordion behaviours Radio group, toggle group, and accordion hold their items in a slot; each item is an instance with its own behaviour, so a press inside the slot goes to the group, which turns the pressed item on and the others off through the item's own interaction. Progress shares the slider's number handling through rangeControl, and a collapsible shows and hides its content slot from its trigger, remembering its open state even when no property draws it. Tabs and groups share arrow-key navigation. * feat: text field, textarea, and number field behaviours A behaviour value can now be text, bound to a text property, so preview types into a copy of the field through the same property path the editor uses. A bound Filled value switches to the placeholder variant when the field empties. A number field keeps its own range, shows its value through a text property, and steps from its increment and decrement slots and the arrow keys. Text fields show focus from a click, and the focused control receives every key; Option still types, and only Cmd or Ctrl combinations stay shortcuts. * fix: keep behaviour bindings when saving as .fig Saving as .fig gives component properties new GUIDs, but behaviours kept the old ids in their plugin data, so every binding read as missing after reopening. The export now renames the ids behaviours bind with the same GUIDs, on its own copy of the document. * fix: let previewed controls resize layout imported from .fig Layers from a .fig keep the sizes Figma computed, and auto layout prefers them, so an opened collapsible or accordion item kept its closed height in preview. When preview shows, hides, or retypes a layer in a copy, it drops those sizes from the layer's copied ancestors so auto layout sizes them again; untouched layers keep Figma's sizes. * fix: publish behaviours and other plugin content with library assets Every OpenPencil plugin-data field now declares its role: content that exists only as plugin data (behaviours, OkHCL picks), format copies of node fields written for files, or bookkeeping about where a document or node came from. Library snapshots keep a node's content plugin data, including other plugins' entries, and drop the rest; the asset hash counts the same entries, so a behaviour-only change is offered as an update while a .fig round trip still changes nothing. * feat: name behaviour rows by meaning and create what they need The Behaviour section named every main value "Value" under a "Values" heading, and a component without matching properties left an empty picker with no way forward. Rows are now named for the control (On, Checked, Pressed, Text), rows the control needs or already uses come first, and the optional rest folds under More options; a button keeps its states in view. An empty row creates what it needs in one undo step: a text layer and text property, Off and On variants on a set, or a slot frame for a part. The missing chip names the row it means and takes you there. * fix(dom-css): position free layers, hug content, and round ellipses HTML and Tailwind export stacked the layers of frames without auto layout in block flow, wrote fixed pixel sizes for auto layout frames set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers a parent does not lay out are now absolutely positioned at their coordinates inside a relative frame, hugging axes are left to the content, and ellipses get a 50% radius. * feat: run preview as live Reka UI islands over the canvas Preview simulated controls on the canvas: copies of instances, a handler per kind, its own key routing, and append-only text. It now runs them as real components. Each top-level layer that holds an instance with a behaviour becomes an island: its layers are projected to DOM through dom-css into a shadow root laid over the pane at its pan and zoom, and each behaviour mounts its Reka UI primitives on its layers, so text fields are real inputs and focus, keys, and layout are the browser's. Core's resolvePlayState shows instances in a state on a private graph, so the component's variants draw it, and controls are keyed by layer path so a variant switch keeps their DOM. The canvas leaves island layers to the islands, and the canvas play runtime and its key routing are gone. * fix: derive variant properties from Property=Value component names figma.combineAsVariants and Combine as variants only derived variant properties from slash-separated names, so components named as Figma names variants, such as State=On, Size=Large, became a set with no properties. Both now derive each named property and its values, after the slash form. * feat: script and tool access to behaviours by name Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers in the list slot and their content panels in a panels slot, and a slot of repeated parts names the Reka part of its children. A behaviour spec names component properties and slots instead of ids and resolves to the stored behaviour and back, with errors that list what the component has. Scripts get an `openpencil` global next to `figma`, in the Figma API's style: setBehaviour, getBehaviour with bindValue, bindPart, states, and missing, behaviourKinds, and createSlot. The eval tool, the CLI, and app automation compile scripts through one compileScript, so the CLI now returns the last expression as the others do. MCP and AI chat get set_behaviour, get_behaviour, and create_slot. * feat: write controls in design JSX with Reka UI's element names `<Switch.Root modelValue="State">` renders a main component, or a set when its children are variants, that behaves as a switch, and `<Switch.Thumb>` the slot that draws its thumb, one slot across the set's variants. Inputs become the text property of a field, tab triggers and panels go in their List and Panels slots, and a group's items are `<RadioGroup.Item of={…} />` instances in its Items slot. JSX export writes components with behaviours the same way, so they render back unchanged. The authoring reference documents controls, and the codegen and chat prompts now include it verbatim instead of dedenting its code examples. * chore: format the CLI export test * docs: document slots, behaviours, preview, and the openpencil API The components guide covers slots, behaviours, and preview with its shortcut; scripting covers the openpencil global and eval's last- expression result; the MCP and AI chat pages list the new tools; the features overview, README, and roadmap mention working controls. The chat prompt says how to build a control, and the codegen prompt builds components with behaviours on their Reka UI primitives. * chore: format the eval CLI test * docs: explain behaviours and preview islands, and guide the openpencil API A development page explains the behaviour model, the four authoring surfaces, how preview islands turn a control's state into live Reka UI components, and how to add a kind; the architecture page links it. The Core guide sets the rules for OpenPencilAPI: Figma-only `figma`, OpenPencil features on `openpencil` in the same style, one compileScript, names over ids, and docs with every member. Package READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and the behaviour model. Design JSX's behaviour modules move into a behaviours folder instead of a suffixed sibling. * refactor: center pasted layers through translate centerNodesAt repeated translate's loop, which test:dupes reports on master too. * fix: validate behaviour ranges and guess on and off by name A number value now needs max above min and a positive step: the schema, specs, and the panel reject a range a slider cannot step through. Binding a variant property guesses on and off by value name, as specs do, and a boolean property gets no on/off pair. Part bindings are read through partBinding, a replaced document restarts preview from its designed state, and the e2e preview shortcut uses ControlOrMeta. * feat: make the Behaviour section say what to do next A slider's range fields now carry inline Min, Max, Step, and Start labels. States offers Add state variants, which adds a Default, Hover, Pressed, Focus, and Disabled variant and binds them; Add Off and On variants and Add state variants turn a lone main component into a component set first, and a part's slot can be added to a set, in every variant under one slot id. Rows that could do nothing are gone: no empty pickers and no hints to combine variants by hand, and an unbound Disabled is left to the states. A warning line names what is still needed and replaces the missing chip, and the Switch's main value is called Checked. * fix: keep each slot to one part and keep creating slots at hand A slot draws one part, so the Behaviour section no longer offers a slot another part uses, and specs (the openpencil API, tools, and JSX) reject binding one slot to two parts. A part's picker keeps an action to add a new slot in its footer, so adding the first slot no longer hides it for the other parts.
2026-10-06 13:23:05 +00:00
| Interactive components | — | — | — | — | — | Figma's component-level prototype connections are not supported; OpenPencil's own behaviours make components run as Reka UI controls in preview. |
| Dev Mode inspect / measurements / annotations | — | ◐ | ◐ | — | ◐ | OpenPencil has CLI/MCP inspection and Option/Alt distance overlays, but not Figma Dev Mode annotations or its full UI. |
| Code Connect / dev resources / ready-for-dev | — | — | — | — | — | Not modeled. |
| Comments | — | — | — | — | — | Not modeled. |
| Version history / branches | — | — | — | — | — | Not modeled. |
| Real-time collaboration | — | ✅ | ✅ | — | — | OpenPencil has its own P2P collaboration, not Figma-compatible metadata. |
2026-05-22 16:31:06 +00:00
## Raw Kiwi metadata coverage
2026-05-26 08:33:52 +00:00
OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not rendered or editable. These live under `SceneNode.source.fig` and are applied late during `.fig` export. A schema coverage test compares the current `fig.kiwi` `NodeChange` fields against modeled codec fields, raw-preserved fields, and intentionally schema-only metadata buckets so drift stays visible.
2026-05-22 16:31:06 +00:00
| Field group | Import/export | Render | UI | Fidelity impact |
| ----------------------------------------------------------------------- | ------------: | -------: | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source.fig.rawSize` | ✅ | Indirect | — | Preserves original Figma size for round-trip. Cleared when size is edited. |
| `source.fig.rawTransform` | ✅ | Indirect | — | Preserves exact Figma transform. Cleared when transform is edited. |
| `source.fig.rawNodeFields` | ✅ | Mixed | — | Late-applied to exported NodeChange for round-trip fidelity; modeled edits invalidate only matching stale raw fields, while unrelated prototype/library metadata survives. Raw-field and schema coverage tests guard preservation drift. |
| `source.fig.layout` | ✅ | ✅ | ◐ | Preserves original Figma stack metadata while using normalized layout fields. |
| `source.fig.symbolOverrides` | ✅ | Indirect | — | Important for instance override fidelity. |
| `source.fig.componentPropAssignments` | ✅ | Indirect | ◐ | Used for component property fidelity; not raw-editable. |
| `source.fig.derivedSymbolData` | ✅ | Indirect | — | Critical for instance-derived geometry/layout/text. |
| `source.fig.derivedSymbolDataLayoutVersion` | ✅ | — | — | Figma bookkeeping. |
| `source.fig.uniformScaleFactor` | ✅ | Indirect | — | Important for scaled instances. |
| Style IDs: fill/stroke/text/effect/grid | ↩ | — | — | Preserves style linkage for Figma, but OpenPencil has no style manager yet. |
| Component property refs/defs/specs | ✅ | Indirect | ◐ | Full Figma component-property authoring is incomplete. |
| State-group metadata | ↩ | — | — | Preserved only. |
| Version/sort/publish/library metadata | ↩ | — | ◐ | Figma metadata is preserved; OpenPencil's component-library publish/update workflow uses its own stable asset identities and revision review. |
| Variable and parameter consumption maps | ✅ | ◐ | ◐ | Filtered/preserved for safe round-trip; normalized bindings cover common cases. |
| Page fields: background, page type, guides | ↩ | ◐ | ◐ | Background color, background paints, page type, and guides round-trip for imported pages. Normalized page/frame guides render as overlays and support undoable editing. |
| Text internals: `textData`, layout versions, font version, derived data | ✅ | ✅ | — | Important for text fidelity; most internals are not editable. Imported derived text data, leading trim, decoration style, underline decoration paint/offset/thickness/skip-ink, semantic font metadata, and raw OpenType feature toggles are preserved for round-trip when safe; decoration style/thickness/color and leading trim now render through CanvasKit, and raster export bounds account for decoration overflow. |
| `fontVariations` | ✅ | ✅ | — | Variable font axes are imported, rendered, and exported for text nodes and style runs. |
| Raw paint/effect/vector/geometry payloads | ✅ | ✅ | ◐ | Converted fields render; raw payloads preserve Figma import/export details, including mask, background paint, layout grid, export setting, and prototype interaction metadata where safe. |
2026-05-22 16:31:06 +00:00
## Highest-priority visual gaps
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
1. **Masks** — tune remaining exact Figma stack semantics beyond common alpha/vector/luminance and consecutive-mask paths. `tests/fixtures/figma-oracles/masks.json` records live Figma API values for alpha, vector, and luminance masks.
2. **Corner smoothing** — expand Figma fixture comparisons and tune remaining stroke/effect edge cases.
3. **Pattern/noise/custom fills** — tune first-class pattern rendering for nested/effectful pattern sources and exact Figma hex spacing. `tests/fixtures/figma-oracles/pattern-noise-custom-paints.json` captures a real async Figma `PATTERN` payload and Figma-authored noise/texture/glass effect payloads; real `NOISE` / `CUSTOM` paint payloads remain blocked on Figma-authored samples.
4. **Variable-font and rich text fixtures** — broaden real-file coverage for variable axes, derived text data, leading trim, decoration style, underline offset/skip-ink, semantic font metadata, and raw OpenType feature metadata; `tests/fixtures/figma-oracles/rich-text-decoration.json` captures the first live Figma rich-text oracle.
5. **Boolean operation editing** — improve inspector/tooling workflows for imported boolean-operation nodes.
6. **Layout grids and guides** — broaden grid-style parity and Figma edge-case coverage; common grid geometry and page/frame guide editing are already supported.
7. **Full component property and slot workflows** — support authoring, not just preserving imported payloads.
8. **Prototype/media/interaction metadata** — schema now includes more interaction, media runtime, animation, and slide fields; start by preserving flows/connections/runtime metadata before building playback.
2026-05-22 16:31:06 +00:00
## Code map
| Concern | Files |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Scene graph fields | `packages/scene-graph/src/types.ts` |
| Source edit tracking | `packages/scene-graph/src/source-metadata.ts` |
| `.fig` metadata policy | `packages/fig/src/source-metadata.ts` |
| Kiwi import mapping | `packages/fig/src/node-change/convert.ts` |
feat(fig): occurrence-scoped instance interpretation as the single .fig reader * refactor(fig): introduce occurrence-scoped instance interpreter * refactor(fig): add direct occurrence materialization and render diagnostics * fix(scene-graph): preserve nested edits and invalidate text layout caches * refactor(fig): assemble indexed documents with occurrence provenance * refactor(fig): validate document assembly against live scene oracles * fix(text): preserve saved glyphs and supported run paints * test(fig): share typed GUID fixture helper * fix(fig): resolve component root keys in instance overrides * fix(kiwi): reject malformed byte arrays before encoding * fix(components): target properties by source identity through undo * fix(fig): preserve editable occurrence export contracts * refactor(fig): construct live component dependency closures * fix(fig): invalidate inherited text geometry after occurrence overrides * perf(fig): reuse component expansions and narrow payload copies * perf(fig): avoid discarded metadata and instance definition copies * perf(fig): transfer parsed records into archive reader ownership * feat(fig): add incremental page sessions with load rollback * test(fig): verify page deltas and stale revision rejection * feat(fig): wire reader worker sessions and compact recovery checkpoints * docs(fig): organize reader architecture and visual examples * chore(fig): checkpoint WIP reader and writer overhaul Preserve in-progress FIG reader, instance interpretation, editable export, and validation work on its feature branch. This is a backup checkpoint, not a release-ready or fully validated change. * refactor(fig): resolve instance structure before expansion Route swaps and property assignments down to the instance they configure so each occurrence expands once with its effective component and complete assignment list. Owners then apply property claims onto the built subtree, which keeps values in the declaring owner's coordinate space and orders inner owners before outer ones without re-expansion, recipes, or patch restoration. Track the components an occurrence expanded before an outer decision replaced them, including intermediate swap assignments, so a claim that resolved against a superseded component is retired while a genuinely missing target still reports. Precedence is one rule: an explicit claim keeps a field unless a strictly outer owner assigned it. Drop the detached-lineage remap heuristic; unresolved assignments report through the existing diagnostic instead of guessing a replacement target. The Accordion source-closure fixture reports two stale overrides, not three: the third came from a subtree the old interpreter expanded and discarded. * refactor(fig): derive override field handling from one registry Describe each claimable raw field once, with its SceneGraph fields, kind, and whether it is a length, and derive claim recording, layout-distance scaling, and export serialization from it instead of maintaining parallel tables. Restore every field the uniform scaler touches from the instance record after scaling. The record already describes the placed result, but corner radii, dash patterns, and effects were previously scaled without being restored, so a scaled instance with its own corner radius rendered it doubled. * fix(fig): retire nested swaps under a replaced component A structural layer routed through an instance whose component an outer owner replaced may still address the original component's children. Such a layer is stale in the same way a property claim is: it resolved before the outer decision and has no target now. Carry the replaced components across that boundary and skip the layer instead of failing the file. material3's List swaps a list item to another variant while the item's own saved swap of a trailing checkbox still names the original variant's child. * fix(core): report stale Figma override records instead of refusing the file Figma keeps override, assignment, and binding records that address nodes it later deleted, and material3.fig could not open because the reader ran the document session strictly. Share one set of session options across the reader and recovery sessions that collects those records as diagnostics and skips them; a swap whose replacement is missing remains a structural failure. The component-metadata expectation follows the visible Buttons page copy of the component set, which the dependency closure now resolves instead of an internal-only copy. * fix(fig): keep instances of deleted components when opening a document Figma retains instances whose main component was deleted, and material3's Internal Only Canvas has 56 of them, so an edited document could not be exported: export loads every page and the reader refused the page over missing reachable sources. The dependency closure now separates deleted components from broken hierarchy, which remains fatal. With the new onMissingComponent option the interpreter keeps such an instance as a childless occurrence that retains its saved reference, applies only its root claims, and reports the owner; strict interpretation still fails. The core reader opts in, shares one diagnostics sink with recovery and export sessions, and exposes it through readerDiagnostics(). Property defaults naming a deleted component are kept the same way, so an edited export no longer rejects them. * fix(fig): resolve variant property values through the component set A variant's saved specs name variant definitions that its component set owns, so occurrence conversion left them keyed by definition id. Resolve them to names once the set is in the graph, as the previous importer did. The component-metadata expectation follows the visible Buttons set's axes; the Style axis belonged to an internal-only copy. * refactor(fig): satisfy type-aware lint in the interpreter and export * fix(fig): keep an instance fill override's variable alias across export A fill or stroke override on an instance descendant lost its colour variable on export: the paint claim was written without the alias, and a boundVariables override for a paint colour produced no claim at all because paint colours are not node-level consumption fields. The reopened paint therefore bound to the component's default variable. Write override paints through the same alias-aware builder as node paints, serialize a paint colour binding override as the paint claim itself, and on import record the binding claim alongside a claimed paint that carries an alias so a later component sync cannot restore the component's binding. On an edited material3.fig round trip this removes all 10,329 fill differences; 2,217 of 78,425 nodes still change, almost all text metadata Figma keeps on outlined vectors. * docs(fig): describe the single reader, its diagnostics policy, and paint claims The status documents still said the replacement reader covered only some worker paths and that old-reader removal was pending. Every import path now uses it and the previous importer is deleted, so state that and move the open items to fidelity and performance. Record the contracts added recently: strict-by-default interpretation with per-session diagnostic handlers that the application reader opts into, instances of deleted components kept as childless instances, the shared override field registry, paint colour aliases serialized inside paint claims, and variant values resolved through the component set. Correct the clipboard ownership rule in AGENTS.md: the envelope belongs to fig, pasted records go through the same reader as documents. * docs: note exported instance overrides in the changelog * refactor(fig): share record indexing and symbol data access Five modules built their own GUID-to-record index with the same idiom; they now use the source index, or indexRecords when child order is not needed. The Kiwi codec types only symbolID, so every reader cast symbolData to reach overrides and the uniform scale; symbolDataOf, symbolOverridesOf, and uniformScaleOf replace those casts. idOf and parentIdOf name the record identity conversions used by ancestry walks. * refactor(fig): share tree search and traversal across records and occurrences The rule that a path segment may pass through ordinary containers but never implicitly into an instance existed three times, once per tree. findWithinBoundary owns it now, parameterized by a tree shape; the occurrence resolver and the static record resolver are two callers. An occurrences() iterator replaces hand-rolled recursion in the component planner, closure, layout scaler, and correspondence linker, forEachOverrideRecord replaces the record-plus-overrides walks in the dependency scans, and one child-pairing generator serves both source-children matchers. * refactor(fig): serialize override claims from the field registry Split export-node.ts: export-context.ts owns the serialization context, GUID allocation, and paint builders; override-claims.ts owns instance override serialization. The override serializer was a chain of field checks that had to agree with the registry materialization records claims from; it is now one switch over the registry's field kinds, the export side of that table, with swaps and variable bindings as the two cases the registry does not describe. Decoded record streams for an edited gold-preview export and a synthetic bound-fill export are identical before and after. * fix(core): record instance overrides for FigmaAPI rename and resize The name setter and resize() wrote to the graph directly, so a rename or resize of an instance child through the Figma API was never recorded as an override: component sync reverted it and export did not write it. Route both through the shared recording update like every other setter. * fix(fig): address overrides inside nested instances by the definition child An override on a child of a nested instance was addressed through the enclosing component's own copy of that child. That node lives inside an instance and is never written as a record, so Figma could not resolve the path and dropped the override. Follow the correspondence until it leaves every instance, which yields the nested component's child, the record Figma itself names in the same situation (verified against Figma's clipboard encoding of the identical edit and by reopening the export). * test(fig): record the Figma reopen of reader exports * test(fig): compare reopened exports with the oracle tool The interpreted-document comparison already reads Figma's interpretation of an archive against the reader's; pointing it at an exported archive and its imported Figma file makes it the reopen check. Captures need the imported file to be the active document, so add an activate-tab operation that brings a desktop tab to the front through the shell page. Record the comparison results for the three reopened exports and document the procedure. * fix(scene-graph): keep a nested instance's correspondence across a swap Children populated by cloning link to the enclosing component's record through componentId. Swapping a nested instance replaced that field with the new component, so the swap was exported against the replacement component's GUID instead of the nested instance record and Figma could not apply it. Record the correspondence as the owner's sourceComponentId override and the swap as its componentId override, as materialized documents already carry them. * fix(core): treat applied shared styles as instance overrides Style references were not instance sync fields, so a text style applied inside an instance was neither recorded as an override nor exported, and a component's style change did not reach its instances, although the reader records styleIdForText claims from Figma. Add the style reference fields to the sync set and expose them on the Figma API proxy under Figma's names so assignments through the API record overrides. * test: record the second Figma reopen round for the reader export Figma confirmed stroke and corner-radius variable bindings, an applied text style, nested-frame layout distances and sizing modes, visibility, and a nested swap. A size claim on an auto-layout child inside an instance is not applied, matching Figma's own resize refusal there. * chore: format the merged structural export test * refactor: group export and instance sync modules into domain folders The node-change export context, node serializer, runtime, and override claims move under node-change/export/, and the scene graph's instance child sync and sync field lists move under instances/, keeping the public instances module to its API. * fix(fig): address exported instance overrides by override key Figma resolves an override path segment through the target record's override key, never its GUID: in gold-preview.fig all 10,341 override and 12,838 derived-geometry segments resolve that way and none resolve to a node GUID. A component imported from Figma keeps its keys, but one authored here has none, so the writer addressed its descendants by GUID. Figma tolerated that for most fields and silently dropped the geometry, so a descendant resized inside an instance reopened at the component's size. Definition records — a component and everything inside it — now carry an override key, minted from the shared identity counter when the node has none, and paths name that key. One map spans the document because the serializer runs once per top-level child. The library content hash ignores the key, which identifies a record rather than the component's content, and the clipboard export passes its variable mode map as modeIdToGuid instead of propertyIdToGuid. * docs: record how Figma resolves an override path * Revert "fix(fig): address exported instance overrides by override key" This reverts commit 38eebb2e5, except its clipboard argument fix. The change came from gold-preview.fig, where every override path segment resolves through a record's override key. material3.fig shows the opposite: 51,332 of its segments are node GUIDs against 24 keys, and only 16 of 87,237 records carry a key at all. gold-preview is a file of library instances, where the key is the cross-file identity; addressing by GUID is what Figma writes for locally authored components, which is what the writer already did. It was also not the reason Figma ignored a descendant's size claim, which is still open. The clipboard export keeps passing its variable mode map as modeIdToGuid rather than propertyIdToGuid, which was an unrelated defect in the same call. * docs: correct the override addressing note and record the size gap * docs: settle the descendant size gap as a Figma constraint * chore: format the JSON fixtures this branch adds format:check runs the formatter and fails on any change, so the fixtures have to be committed as oxfmt writes them. * test(tools): smoke the instance override subpath's current exports populateAndApplyOverrides belonged to the importer this branch removes. * perf(fig): index the archive once per document, not once per page Selecting a page rebuilt both whole-document source indexes, so opening material3.fig with its 33 pages indexed 87,237 records 33 times and 86,888 records another 33 times: 102 index builds where 36 are needed. Only the page's own subset varies, so the full index and the component interpreter move into state shared across selections, and the initial read path passes its index to inheritance, style lookup, the dependency closure and component planning rather than each building its own. The paint and component-property passes iterate keys directly instead of materializing an entry array for every node, most of which bind nothing. Loading material3.fig goes from about 9.5s to about 7.5s on the same machine, measured back to back with the machine otherwise idle. * docs: note the faster multi-page .fig load * perf(fig): apply document passes to the nodes a page materialized Linking component property values, resolving variant values and applying layout and paint bindings each walked the whole graph and skipped what was already there, so every page load re-visited every node the earlier pages had produced. On nuxtui.fig, 121 pages over a graph that reaches 354,000 nodes, those four passes were 22.7% of the profile after only six pages and grew from there. Each pass now takes the nodes just materialized. Component property types are remembered across page loads instead, because an assignment on a new node can name a definition an earlier page introduced; seeding that cache is the only pass that still reads the whole graph, once per document rather than once per page. Pages 3 to 20 of nuxtui.fig fall from 90.0s to 51.6s. The first page is unchanged: it materializes 256,354 nodes and is dominated by that. * docs: note the per-page load improvement * test(fig): keep the fig package suite off Core Twenty package tests reached for Core's writer and editor through @open-pencil/core, a package that depends on fig. Nothing declared that edge, so the suite passed only because the workspace root hoists Core. Their subject is the writer, so they move to tests/engine/io/fig, where half the domain already spans both packages. The package no longer escapes its own root: tsconfig drops the #tests/* mapping, expectDefined is three lines beside the other helpers, and the gold archive is read through the LFS-guarded fixture helper instead of a hand-built ../../../../tests/fixtures URL. #fig/ and #fig-tests/ join the steiger alias tables and the AGENTS.md list, so the foreign-alias rule can see them. Fig's tests mirror its source tree rather than sitting flat like kiwi's, so they address it by alias instead of drilling, and the guid helper is imported one way. * refactor(fig): drop code the reader replacement left behind resolveDsdGeometry lost every production importer when the old derived symbol data modules went, so it and the three tests that only exercised it go too, and the folder collapses to one file. validateVariableAliases was called only by its own test and wiring it in would mean a new public diagnostic handler; it is removed rather than left dangling. recordInstanceOverrideValue had no caller in either base or head, and its comment began mid-sentence. SymbolOverrideFields had no consumers, and savedTextEligibility is used only inside its module. The clipboard's NON_VISUAL_TYPES was a hand-copied union of the two sets behind isFigClipboardVisualType, which had no consumer of its own; the classifier now serves both and leaves the root export. FIG_PACKAGE_STATUS reads document-reader, and assertFigPackageReady is gone: the package reads archives into a SceneGraph rather than telling callers to use Core. sceneNodeToKiwi takes its ten optional maps as an options object. That removes the signature Core's wrapper had to restate, which was the last clone blocking packages/fig/src from the duplication gate, and the undefined holes at the clipboard's two call sites. * refactor(core): share identity allocation between the two .fig writers The clipboard allocated variable, mode and shared-style GUIDs its own way while the document exporter did the same work in assignVariableGuids and appendInternalResources. The two already disagreed: the exporter reuses an id that is already GUID-shaped and dedupes against node source GUIDs, the clipboard always minted a fresh sessionID 1. Both now call one pair of helpers in variable-export.ts, so a change to how a document names its resources reaches the clipboard too. * refactor(fig): name the values that were spelled out in several places exportSizing existed to name the HUG ternary but the inline layout branch still wrote it out. The winding-rule conversions become toKiwiWindingRule and fromKiwiWindingRule rather than the same ternary three times and its inverse once. sameId duplicated sameGuid. The style reference field list existed twice, and one site built a GUID string by hand instead of calling guidToString. The opacity percent-to-unit factor and the alias-or-expression test each have a name now. fig.kiwi declares parameterConsumptionMap as a VariableDataMap and PropRefValue as a variable value, but the codec typed neither, so four call sites cast. Typing them in kiwi removes the casts, and the merge that spread two maps now builds the only field the message has. Schema coverage counts one more modeled field and one fewer raw-preserved. * refactor(fig): require the index instead of rebuilding it behind a default createScopedReader is private and always receives the shared state, and the closure, component planning and property inheritance always get an index from it; the optional parameters existed only so two tests could omit them, and each hid a second full pass over every record. They are required now, and the tests build an index the way production does. materializeReader returned a fresh object that dropped definitionTypes, so the first loadPage after createFigDocumentSession reseeded the cache it was meant to reuse; it returns the state it was given. The shared style reference shape is a named type built with the rest of the export context rather than written inline twice and filled lazily inside a getter, and the population client derives its two responses from FigSessionResponse instead of restating one and casting to it. * refactor(fig): give materializeInstance named options Three of its seven parameters were defaulted maps that call sites passed unnamed, so a call read as a list of empty collections. They become an options object, matching how InterpretInstanceOptions is passed in the same folder. That change also caught a latent hazard: an empty array satisfies an all-optional interface structurally, so a call site left on the old positional form type-checked while silently dropping its source-child map. Converting the remaining call sites fixed a component sync test that had started failing for exactly that reason. The DOCUMENT/VARIABLE guard is one assertion function rather than two copies, and it narrows the node type for the creation that follows. * refactor: group the prefixed siblings this PR left behind instance-overrides kept layout-scale, text-scale, interpret-bindings and variable-bindings as prefixed siblings while the same PR introduced scene-graph/src/{scaling,variables}/. They become scale/{layout,text} and bindings/{properties,variables}. The empty derived-symbol-data folder is gone now that it holds one file. STRING_BINDING_FIELDS and BOOLEAN_BINDING_FIELDS stayed in variables.ts after NUMERIC_FIELDS moved to variables/fields.ts; all three live together. * docs(fig): describe the reader as it is, not as a replacement The README, document-sessions, validation notes and several comments still framed the work as pending: an old reader to delete, a migration to finish, variables and lazy loading not yet integrated. All of that landed. Error messages and a worker adapter that called themselves "replacement reader" and "format-neutral" say what they are. Comments that described the wrong function are reattached: the root layer note belonged to resolveRoot rather than bindingHistory, the expand note was duplicated onto bindRecord, the owner-scope note sat on pairSourceChildren instead of linkInstanceSourceChildren, sync.ts put its module summary on setSceneProp, and transfer/history.ts ended with an orphan. The visual oracle's interpret-instance and compare interpreted-document are citty subcommands like the rest, its SCREAMING-CASE note folds into packages/fig/docs/validation.md without the benchmark observation, and its two tests mirror the source tree using the package alias. * docs(fig): keep Figma observation records out of the fixture tree Ten JSON records, twelve notes and a screenshot under tests/fixtures had no code consumer: they are what Figma reported for a given document, cited by packages/fig/docs. They move to packages/fig/docs/observations beside the prose that reads them. The three JSON files tests do load, and the eight screenshots the raster comparisons load, stay where the tests expect them. Fixture READMEs follow their fixtures: the gold layout and shared scale notes to tests/engine/io/fig/instance, the export contract note to tests/engine/io/fig/export. Numbers fused to the words before them are separated throughout the notes. Path failures assert the diagnostic reason through one helper rather than matching 'found 0' or a full sentence, which is the pattern materialize.test.ts already used. * refactor(core): name the reader state module for what it owns session/recovery.ts holds the per-graph reader state and, with it, page population, diagnostics and export population as well as recovery. The functions cannot move out without exporting that state map, so the file takes an accurate name instead, and the state type follows. io/formats/fig/index.ts keeps its aliased re-export: the relative path is three levels up, which no-deep-parent-relative-imports rejects. * chore: adopt the js-base64 rule master added * test: move the new tests to the homes master's gate requires #790 added check:test-homes: a new test under tests/engine is rejected, and the baseline of existing ones shrinks. This branch had added 46. Their owner is whichever package the test's subject lives in, not the directory the old shard map implies. Forty test Core's writer, editor or reader session and move to packages/core/tests, which gains the test tsconfig and scripts the other packages already have; six test Fig alone and move to packages/fig/tests. verifier-contracts covers the roundtrip helpers that eight grandfathered engine tests share, so it stays beside them and joins the baseline. Package tests no longer reach outside their package for support: each has local assert, guid, fixture and nested-binding helpers, and shared archives under tests/fixtures are read through a helper path rather than imported as modules across the root. interpretComponent, materializeComponentClosure and the source-children helpers are public, because tests outside Fig legitimately need them. The steiger owner for #core/ and #fig/ is the package rather than its src, since a package's own tests mirror the source tree and would otherwise drill through ../../src. * test: mirror each package's source tree in its test tree The relocated tests kept their tests/engine directory names, which do not match the packages they landed in: figma/api against src/figma-api, render/canvas against src/canvas, io/fig against src/io/formats/fig, and a fig tests/io and tests/text with no counterpart in that package. Each now mirrors its source domain. Two had no home in the package they were put in. The derived-text layout invalidation test only exercises Scene Graph, so it moves there, and the transfer plan test spans Scene Graph and Fig with neither owning it, so it becomes the first tests/integration spec, which is what that directory is for. tests/AGENTS.md named a baseline path the tools reorganization moved, and packages/fig/AGENTS.md now records its own test alias. * fix(fig): open a file whose swap names a layer its component lost Preline UI's `_header/navbar` keeps a swap addressing 4473:100430, a node the archive no longer contains, while the replacement it names is still there. Figma opens that file and so did the previous importer; this reader refused it. The rule was written for a swap whose replacement is missing, which nothing can resolve, but the code threw for any unresolved swap. A path that matches no record is a record Figma kept after deleting the layer it named, which is the case the property and assignment diagnostics already cover. A path that matches more than one record is a wrong address rather than a stale one and still fails. * fix(fig): address an override through the variant that holds its layer An instance path names a layer by the identity it had in the variant the override was written against. Switching variants keeps the override in Figma, so a segment that names no layer of the variant an occurrence expands now addresses the layer at the same position there, when the two agree on type and name. Resolution reports the path it took, so a claim recorded after a translated segment stays addressable when the instance materializes. Each component set's addressable layers are indexed once on first use rather than rescanning every sibling variant per segment. * fix(fig): read text bound to a string variable Figma stores a bound layer's resolved characters, but an instance override carries the binding alone, and a literal override of a bound layer is retired rather than applied. Reading neither left the badge on Preline's navbar showing its component's own text where Figma shows the variable's value, and the input placeholder showing a literal override Figma ignores. Text joins font family as a bindable string field, the reader records a TEXT_DATA alias like any other binding, and a post-pass resolves it once hierarchy and modes exist, next to the paint bindings it mirrors. Resolving after property claims is what makes a binding win over a literal, the way Figma retires the override. Validated by reopening an exported file in Figma: the collection, the string variable, and the binding on both the component and its instance survive the round trip. * fix(fig): take a bound paint's transparency from its variable A solid fill draws at its paint opacity, not its colour's alpha, so a colour variable carrying transparency has to supply that opacity. Resolving the binding into the colour alone left a translucent token applied twice on Preline's navbar links, and left a Divider at the opacity of an override the binding supersedes. The variable now owns the whole colour: its alpha becomes the paint's opacity and the colour keeps none of its own. * test(tools): compare paint in the interpreted-document oracle The oracle checked type, name, visibility, text, main component and box, so every fill and stroke a reader produced went unchecked. A wrong fill transparency on Preline's navbar passed it. Paints are captured on both sides as the alpha drawing actually uses, which is the paint's opacity for a solid, and reported as visible-paint or hidden-paint like geometry. A Scene Graph stroke is always solid, so it is encoded as one rather than through a type it does not carry. * perf(fig): synchronise a component once per page load, not once per instance Materializing an instance into an open document re-synchronised every instance of its component, and synchronising walks each one's subtree. A page that places a component many times therefore paid that walk once per placement. Opening Preline's CMS page ran 954 synchronisations over 39225 instances for the 954 it placed. Components are collected while the page is built and synchronised once each afterwards: 31 calls over 1283 instances, and the page loads in 3.9s rather than 11.6s. The resulting graph is unchanged, by digest over every node's geometry, text, paint, bindings and override keys for that page and for a second page loaded on top of it. * Revert "fix(fig): address an override through the variant that holds its layer" This reverts commit fcdc7660f. Figma does not carry an override onto the corresponding layer of another variant, so translating a segment that way applies overrides it drops. On Preline's Alerts frame the translation raises semantic differences against live Figma from 2 to 54: 127 buttons read their own label where Figma reads the component's. It fixed nothing visible — the five text differences it was written for turned out to be string variable bindings, fixed separately — so it only ever added wrong overrides. * docs(fig): restore the guide rules the master merges dropped Splitting the root guide into nested ones lost three rules this branch had added, and left the fig guide claiming clipboard records are converted to a SceneGraph in `@open-pencil/fig/clipboard`, which is now `materializeFigFragment` driven from Core. Records what the reader cannot do as well: a string binding resolves once at read time, so text bound to a variable goes stale when the variable or the node's mode changes, unlike a numeric or colour one. Groups the four `*-bindings` siblings under `document/bindings/`, the convention the branch already applied to `instance-overrides/bindings/`. * perf(fig): copy archive records directly instead of structurally Every expanded record is deep-copied so an occurrence shares no mutable data with the archive, a contract two tests state. `structuredClone` was a third of the time spent opening a page, and records are plain Kiwi data, so copying them field by field is several times quicker — 43944 records of Preline UI clone identically either way, 218ms against 26ms. Byte buffers and anything else that is not an object literal keep the structured algorithm. Preline's CMS page now loads in 2.8s rather than 5.6s, and with the per-component synchronisation fix in 0d1854a3a, 11.6s before either. * test(tools): compare a reader's whole output, not one frame `compare interpreted-document` checks one frame against live Figma. A rule can leave that frame untouched and still change pages it does not cover: addressing an override through a sibling variant reported no difference on the frame under test while rewriting 127 button labels elsewhere, and was reverted only after a whole-document comparison found them. `compare digest` captures every page a reader produces and diffs it against an earlier capture, reusing the same node capture and difference categories, so a before-and-after needs no Figma. Replaying the reverted change against a baseline reports 110 semantic differences. Unresolved-override counts are reported beside the nodes, since a reader change usually moves those too.
2026-10-01 07:20:27 +00:00
| Kiwi export mapping | `packages/fig/src/node-change/export/node.ts`, `packages/fig/src/node-change/serialize.ts` |
| Kiwi schema | `packages/kiwi/src/fig/schema/fig.kiwi`, `tests/engine/io/fig/import/schema-coverage.test.ts` |
| Renderer dispatch | `packages/core/src/canvas/scene.ts` |
| Fills / images / gradients | `packages/core/src/canvas/fills.ts` |
| Strokes | `packages/core/src/canvas/strokes.ts` |
| Effects / shadows | `packages/core/src/canvas/shadows.ts` |
| Text rendering | `packages/core/src/canvas/text.ts`, `packages/core/src/canvas/text-derived.ts` |
| Layout engine | `packages/core/src/layout/**` |
| Property panels | `src/components/properties/**`, `packages/vue/src/controls/**` |
| CLI | `packages/cli/src/index.ts`, `packages/cli/src/commands/**` |
| MCP/tools | `packages/core/src/tools/**`, `packages/mcp/src/tool/registration.ts` |