openpencil/packages/fig/docs/instance-evaluation.md

141 lines
6.7 KiB
Markdown
Raw Normal View History

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
# Instance evaluation
```mermaid
flowchart TD
Layers[Owner layers: swaps, assignments, property claims] --> Route[Route structural layers to the instance they configure]
Route --> Expand[Expand each occurrence once with its effective component and bindings]
Expand --> Claims[Owners apply property claims onto the built subtree, inner to outer]
Claims --> Derived[Saved occurrence-derived data]
Derived --> Result[Effective values and provenance]
```
The model has three parts. An instance expands its component's subtree. Every owner
contributes *layers*: a partial record at a path relative to that owner. Where layers
overlap, the outermost owner wins.
A layer is *structural* when it carries a swap (`overriddenSymbolID`) or component-property
assignments; it selects what a nested instance expands. Everything else is a *property* layer.
Structural layers are routed down to the instance they address before it expands, so each
occurrence expands exactly once with its effective component and complete assignment list.
Property layers are applied by their declaring owner after its subtree is built, which keeps
their values in that owner's coordinate space and orders inner owners before outer ones.
## Addressing
A property path is relative to its declaring owner. Ordinary containers may be traversed
without adding an instance boundary; nested instances require an explicit segment.
```text
Owner
+-- instance A -> Label (source L)
+-- instance B -> Label (source L)
[A, L] != [B, L] valid distinct addresses
[L] across an instance boundary not an implicit recursive search
```
Component GUIDs and component override keys can address the root. A binding-driven replacement
can retain the source component's root identity without retaining its old descendant targets.
A placed instance's explicit size remains separate from unscaled root-override size.
## Provenance
| Origin | Interpretation |
| --- | --- |
| Component default | Inherited unless superseded. |
| Property assignment | Supplies a binding value; not equivalent to its default. |
| Explicit path override | Declared by an owner against a complete path. |
| Saved derived data | Effective geometry/typography; not automatically a user override. |
| Editor mutation | Recorded through the shared editing domains after materialization. |
```text
default Label = "Badge"
+-- untouched instance -> inherits later component changes
+-- explicit "Badge" -> remains overridden despite equal text
```
Definitions with `parentPropDefId` inherit semantics within source ancestry while retaining
local property identity. Current typed `varValue` and `PROP_REF` parameter records are
normalized alongside older property forms.
**Implemented:** an outer owner's assignment supersedes an inner owner's explicit claim on the
same field; unrelated fields of that claim are kept. A claim whose path passes through a swapped
instance and resolved in the replaced component is stale and dropped. A missing target that did
not resolve there either is reported, never remapped onto a similarly named replacement child.
## Worked precedence example
```text
Contribution text opacity
-------------------------- -------------- -------
Component default "Badge" 1.0
Intermediate explicit claim "Custom" 0.4
Outer text assignment "Assigned" --
Effective result "Assigned" 0.4
Retained intermediate claim -- 0.4
```
The outer assignment supersedes the intermediate text claim without erasing unrelated opacity.
Within one owner, its assignments bind before its explicit claims, so an explicit child claim
wins over that owner's own assignment to the same field.
```text
BEFORE SWAP AFTER SWAP
Component A Component B
A/Label [explicit text claim] B/Icon
A/Icon B/Caption
A/Label does NOT become B/Caption merely because both contain text.
```
## Names
Stored `name` alone is not proof of an explicit rename. Figma save captures distinguish
root-targeted name claims. Untouched replacement instances use the component name, or the
component-set name for variants. Initial stored names are not globally rewritten.
**Known limitation:** complex compositions of inherited renames, bindings, and structural
swaps still have unresolved oracle cases. Direct `swapComponent()` behavior must not be assumed
to describe every saved-record composition.
## Derived text
Changing text or shaping properties invalidates inherited glyph caches unless a patch supplies
replacement data. Later occurrence-derived glyph data can replace that invalidated cache.
Paint-only changes do not alter glyph positions; every claim application applies the same
validity rule.
See [materialization](./materialization.md) for rendering and editing boundaries.
## Diagnostics
The library is strict by default: a missing or ambiguous target throws. `InterpretInstanceOptions`
lets a caller skip and report instead: `onUnresolvedProperty` and `onUnresolvedAssignment` for
records that address nodes the archive no longer contains, and `onMissingComponent` for an
instance of a deleted component, which then stays a childless instance with its saved
reference. A swap reports the same way when its target layer no longer exists, which Figma
retains as readily as a stale property override; an address that matches more than one record
is a wrong path rather than a stale one and stays fatal. Reports retain owner, effective
component context, complete path, and assignment payload where applicable.
Figma retains such records after deletions, so the application reader opts into all three
(`readerSessionOptions` in Core) and exposes the collected records through
`readerDiagnostics(graph)`. Oracle tooling stays strict unless a flag names the concession.
## Implementation and tests
- [Interpreter](../src/instance-overrides/interpret.ts)
- [Static source routing](../src/instance-overrides/source-index.ts)
fix(fig): read, render, and write Figma slots (#850) * fix(fig): read, render, and write Figma slots Instances of a component with a slot showed the component's default content instead of their own, and saving to .fig dropped slot properties, their settings, and every instance's content. Figma stores an instance's slot content as a frame on the internal canvas and assigns the slot property that frame's GUID. The reader follows the assignment while expanding the slot frame and pulls content frames into the dependency closure without making them layers. The scene graph gains a SLOT property type with its settings, a SLOT_CONTENT binding, and the rule that an assigned slot's content belongs to the instance, which component sync now leaves alone. The writer emits content frames on the internal canvas and binds slot frames through the parameter map, as Figma does. The occurrence and diagnostic types move from the interpreter to instance-overrides/occurrence.ts to keep it under the size limit. * fix(fig): keep slot content through swaps and missing content frames A slot assignment whose content frame the archive lacks no longer refuses the document: the reader reports it through onMissingSlotContent, like a missing component, and the slot keeps its component's content. Swapping an instance's component, which variant switches do, now carries the instance's own slot content to the new component's slot of the same name instead of dropping it, as Figma does. Component sync reads which slot a frame is from the component, whose bindings instance copies do not receive. Clipboard export numbers slot content after the other records on its dependency canvas, and the reader and writer share Figma's default slot value. * docs: note slot content kept across variant switches * fix(fig): pair clipboard text by record and drop dangling slot assignments The Figma clipboard paired text records with source text nodes by traversal order, but instance-owned slot content is written after the selected layers, so slotted text and the text after it swapped shaping data. Records are now paired with their nodes through their GUIDs. An instance assignment whose slot content frame is missing is dropped along with the reported diagnostic, so the slot keeps following its component instead of looking instance-owned. * ci: pull the slots fixture for unit tests * test: use GUID and Array.from in the slot tests * refactor(fig): group occurrence types and paths in one folder occurrence.ts and occurrence-path.ts became sibling prefixes when the occurrence types moved out of the interpreter; they now live in instance-overrides/occurrence/ as types.ts and path.ts.
2026-10-03 20:45:10 +00:00
- [Occurrence path resolution](../src/instance-overrides/occurrence/path.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
- [Binding evaluation](../src/instance-overrides/interpret-bindings.ts)
- [Text provenance](../src/instance-overrides/text-provenance.ts)
- [Addressing tests](../tests/instance/addressing.test.ts)
- [Binding precedence tests](../tests/instance/bindings.test.ts)
- [Root-key tests](../tests/instance/root-key.test.ts)
- [Swap provenance tests](../tests/instance/swap-provenance.test.ts)
- [Name capture provenance](../tests/instance/fixtures/README.md)
**Known limitation:** field coverage and provenance transitions are not complete. Assignments
whose definition only exists on a detached ancestor are reported as unresolved rather than
remapped through `detachedSymbolId` lineage.