* fix(export): prevent GUID collisions and file corruption on .fig round-trip
Nodes sharing the same imported source.id (component instance children,
cloned subtrees) silently overwrote each other on export because the
GUID assignment reused imported GUID values without checking for
duplicates. This caused data loss on reimport — only the last node
with a given GUID survived.
- Track all assigned GUID values in a Set for O(1) collision detection.
- Scan imported source.ids for both sessionID 0 and 1 before assigning
any new GUIDs, so the counter starts past every imported value.
- Fall back to counter-based GUIDs when source.id collides with an
already-assigned value.
Additional fixes in the same change set:
- cloneTree now deep-copies source.fig via structuredClone, preventing
mutations on a clone from corrupting the original node's kiwi payload.
- Removed decompressFigKiwiData sync wrapper (zero callers) and the
silent try/catch fallback in parseFigKiwiContainer that masked
corrupt data as raw bytes.
- buildFigKiwi uses Bun.zstdCompressSync when available, matching the
zstd decompression path already used on import.
- Fixed setSavedVersion ordering in read.ts — must run after
requestRender to capture the post-bump version, preventing spurious
dirty-state immediately after file reload.
Tests: GUID collision (2 and 3 node), clone isolation, parse failure,
text export zstd compatibility, gold-preview round-trip.
* fix(export): handle EXCLUDE boolean operation and BOOLEAN_OPERATION node type
The internal representation uses EXCLUDE for exclude boolean operations,
but Figma's kiwi schema uses XOR. Map EXCLUDE back to XOR on export so
round-trips through .fig files don't fail. Also add BOOLEAN_OPERATION to
VALID_NODE_TYPES and increase timeout for heavy material3 fixture test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* style: format export-node.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test: add type guard after null assertion in guid-collision test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): detect zstd-compressed data before zlib inflate
fflate's inflateSync silently accepts zstd-compressed data and returns
garbage instead of throwing. Check for the zstd magic bytes (28 b5 2f
fd) before attempting zlib decompression. Also revert the EXCLUDE enum
addition to the kiwi schema since the export-node.ts mapping is
sufficient.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(clipboard): add length guard before zstd magic byte check
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(scene-graph): group clone regression coverage
* ci: retrigger CI checks
* test: increase timeouts for heavy .fig fixture tests on slow CI runners
Gold-preview.fig and material3.fig parsing/export tests consistently
exceed the 5s bun:test default timeout on GitHub Actions runners.
Increase to 30s for: beforeAll codec init, clipboard roundtrip,
glyph blob roundtrip, group reclassification, and text measurement.
* test: add individual test timeouts for heavy .fig fixture tests
The beforeAll timeout helped but individual test() calls also need
explicit 30s timeouts since bun:test applies the 5s default per-test.
Fixes remaining CI flakes in glyph-blob roundtrip and clipboard
roundtrip tests.
* test: increase beforeAll timeout for render cache test
The canvas/render cache test loads gold-preview.fig AND initializes
CanvasKit (Skia WASM), which is much slower than the other fixture
tests. Use 60s timeout to account for slow CI runners.
* fix(export): reserve document GUID to prevent 0:0 namespace collision
- Add docGuid (0:0) to assignedGuidValues before processing imported
node source.ids, preventing an imported node with source.id "0:0"
from reusing the document's GUID slot
- Add regression test using session-0 source.ids to verify nodes
survive roundtrip without document GUID collision
- Remove unnecessary async keyword from synchronous component
metadata test
* fix(export): guard canvas GUID reuse with assignedGuidValues check
- Mirror getOrCreateNodeGuid() collision logic in buildCanvasEntries():
if an imported page's source.id maps to a GUID already in
assignedGuidValues, generate a fresh counter-based GUID instead
- Prevents canvas-level last-write-wins when multiple pages share
the same source.id or a page uses 0:0
* fix(test): use explicit little-endian writes and fix misleading test title
- Replace host-endian Uint32Array writes with DataView.setUint32(offset, value, true) in parse-failures.test.ts to ensure platform-independent fig-kiwi container assembly
- Rename test title from "clone clears source.id from the original" to "clone clears source.id from the clone" to accurately reflect what the assertions verify
* test(io): use file-level timeout for heavy fixture
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Danila Poyarkov <dev@dannote.net>
CodeRabbit found the RotatedPlain control node was created on
instead of , so the unstroked control assertion was not actually
validating the same setup without a stroke.
- Export OverlapScope, AnalyzeOverlapsSummary, and OverlapIntersection
from the analyze barrel so consumers don't deep-import.
- Expand the local rectangle by stroke overflow before transforming
through the world matrix, so stroked rotated nodes get their true
rotated footprint instead of an underestimated canvas-space expansion.
- Add clipPolygon to geometry and preserve the clipped polygon through
the full clip chain in computeNodeBounds, so a later outer clip can't
reintroduce corners an inner clip already removed.
- Cap returned overlaps to an empty list when limit is 0 or negative;
summary totals still reflect the complete result set.
- Trim whitespace from scope and severity inputs so " major " resolves.
- Add geometry and overlap tests covering each fix.
Port the timeout fixes from PR #351 that resolved the unit-test CI
timeouts. Add HEAVY_TEST_TIMEOUT_MS (30s) in tests/helpers/test-utils.ts
and apply it to the slow gold-preview.fig fixture tests/hooks:
- clipboard roundtrip
- group reclassification
- glyph blob preservation
- auto-layout text measurement
- render/canvas/cache
Implement the analyze overlaps CLI command, RPC endpoint, and ToolDef
to enable heuristic detection of visual overlaps, parent overflows, and
overlay patterns. Geometry computation now uses world-matrix visual
bounds to handle nested ancestor rotations and clipping frames correctly.
Update file-backed CLI commands to use Node fs/promises instead of Bun
runtime file APIs. This is required for the published CLI to function
when installed and executed in a standard Node environment. Add the
package.json subpath export to the core package for metadata consumers.
* fix(ui): anchor AppSelect dropdown to its trigger
AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger
as-child). reka-ui's Select popper captures its trigger element via
useForwardExpose on mount, but that capture resolves to null when the
trigger sits inside another primitive's as-child slot. With no anchor,
floating-ui positioned the menu at the viewport origin and flipped it
off-screen (~y:-412), so clicking the dropdown appeared to do nothing.
Wrap the trigger in a layout-neutral span, keep the styled <Tip>
tooltip, and pass that span to SelectContent's `reference` prop so the
popper anchors explicitly on every open (PopperContent prefers
props.reference over the broken auto-capture, and re-reads it on each
open/reopen).
Fixes every dropdown built on AppSelect across the inspector (export
scale/format, typography, effects, stroke, flex/grid layout, variants,
fills, gradients, color format).
Verified live: menu opens directly below the trigger, fully on-screen,
across repeated open/select/reopen cycles; tooltip still shows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(export): editable custom scale with 1024x cap
The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like:
an editable field where you can type any multiplier (e.g. 9x, 1.5x) in
addition to picking a preset from the chevron menu. Custom values are used
as-is and are never added to the preset list.
Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would
allocate an enormous canvas and crash the renderer. The clamp lives in the
export data model (clampExportScale, applied in updateScale) so it defends
every caller, and is reused by the input for immediate display feedback
(9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts.
New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for
presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions
correctly). The active preset shows a checkmark.
Verified live across custom entry, clamping (both bounds), decimals,
invalid input, and preset selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(export): store export settings per node
Export settings were global and transient: every selection showed a
default 1x PNG row, and nothing was remembered per layer or persisted
with the document.
Store export settings on each SceneNode (exportSettings: ExportSetting[]),
defaulting to empty so the panel shows only its header and add button
until the user adds a row. Settings apply across multi-select and target
the current page when nothing is selected; add/edit/remove are undoable.
Persist settings with the document via open-pencil pluginData in .fig
(lossless, including webp). On import, prefer app pluginData; otherwise
map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without
overwriting raw native fields on re-export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(export): working format dropdown, JPG/WEBP export, and zip bundling
- Anchor AppSelect dropdown via native title so the format selector is
clickable in multi-row export panels (was rendering off-screen)
- Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which
CanvasKit's encodeToBytes returns null for in this build (fixes the
"Nothing to export" error)
- Bundle multi-format exports into a single zip; a single export still
downloads the file directly
- Default each added export row to 2x the previous scale (1x -> 2x -> 4x)
- Add e2e coverage for the multi-row dropdown, zip bundling, and direct
single-file download
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* revert(export): drop AppSelect native-title workaround, superseded by #325
The shared <Tip> path is now handled generally by #325; #321 should not carry
the AppSelect change. Reverts src/components/ui/AppSelect.vue to master.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(export): address review feedback (#321)
- Clear raw native exportSettings when the user edits/clears export rows so they
don't resurrect from the import fallback on reopen (scene-graph/source-metadata).
- Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE),
not just in the UI; centralize the bounds in core/scene-graph/export-scale and
reuse them from the vue helpers (single source of truth).
- Export the rows the panel shows (activeSettings) for every target so a
multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows
whenever targets diverge.
- Sanitize zip entry names (strip separators, parent refs, control chars) so layer
names can't escape or corrupt the archive.
- Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a
silent PNG so we never write PNG bytes under a .jpg/.webp extension.
Add regression tests for the native-settings clear and import-scale clamp.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>