Skip to content

fix: Figma Section export의 벡터 로고 누락 수정 - #9

Merged
owjs3901 merged 2 commits into
mainfrom
owjs3901/fix-figma-vector-export
Sep 8, 2026
Merged

fix: Figma Section export의 벡터 로고 누락 수정#9
owjs3901 merged 2 commits into
mainfrom
owjs3901/fix-figma-vector-export

Conversation

@owjs3901

@owjs3901 owjs3901 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

변경 내용

Section의 화면을 export할 때 첫 페이지 이후의 커서를 읽지 않아 Boolean 로고의 벡터 자식 12개가 누락되고 단색 사각형으로 출력되던 문제를 수정합니다.

  • 화면별 페이지 커서를 끝까지 따라가며 노드와 리소스를 병합하고, 잘못된 커서 및 후속 페이지 실패를 안전하게 처리합니다.
  • BOOLEAN_OPERATION을 SVG 자산으로 인식하고 Figma가 export한 원본 형상을 DevupUI TSX의 maskImage로 참조합니다.
  • 자산으로도 표현되지 않는 누락 자식은 fidelity의 coverage·lossy·uncoveredNodeIds에 반영합니다. 숨김 노드와 SVG로 표현되는 자식은 구분합니다.
  • 최소 재현 fixture와 페이지 수집·실패 처리·fidelity 회귀 테스트를 추가했습니다.

실제 재현 검증

Figma 파일 85CgSws3o5XsLv7aAwWJyS, Section 4279:7810, 화면 3831:10708, 3831:10723을 수정한 release 바이너리에서 캐시 없이 refresh: true로 export했습니다.

항목 수정 전 수정 후
상태 partial complete
노드 18 30
수집된 자식 16/28 28/28
누락 자식 12 0
로고 단색 Box 원본 SVG 2개 exported

두 SVG는 각각 606바이트이며 manifest·파일 SHA-256·sourceMap·TSX 참조가 일치합니다. 테마 출력은 동일하며, 같은 frameIds로 artifact를 재사용하면 추가 Figma 호출 없이 동일한 snapshot과 TSX를 반환합니다. 설치된 MCP와 다른 프로젝트 워크트리는 변경하지 않았습니다.

검증

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace --all-features: 476 passed, 2 opt-in live tests ignored
  • cargo insta test --workspace --all-features --check
  • node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs: 4 passed
  • cargo build --workspace --release

자세한 원인과 실행 바이너리 정보는 docs/figma-vector-export-regression.md에 기록했습니다. 대용량 실제 export JSON과 바이너리는 커밋하지 않았습니다.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Changepacks

devup-mcp@0.2.1 → 0.3.0 - crates/devup-mcp/Cargo.toml

Minor

  • Narrow what devup-mcp asks for and what it sends back, and make a refusal say which of the two kinds it is. Nine tools become seven: devup_figma_to_ui and devup_figma_to_json were devup_figma_export with a single entry in outputs, so every client carried three schemas in its context and had to decide between them on every call, and the export tool's own description had to spend a sentence saying which to prefer. Use outputs: [tsx] and outputs: [devupJson] instead. sourcePolicy is gone from all of them: auto and direct both meant the direct connection and the parameter never branched, so it only ever offered a caller something to get wrong, and it was also part of the artifact cache key it could not affect. Every remaining closed-set input now publishes its accepted values in the JSON schema - action, scope, rootLayout, delivery, match, project context scope and stack diff layers - from one shared constant the parser reads too, so the schema cannot drift from what is accepted and a caller stops discovering the set one rejection at a time; outputs and the asset format were the only two that already did this. Errors are no longer all INTERNAL_ERROR. A mistake in the call itself - an unknown scope, a node that is not in the file, an expired artifactId - is now JSON-RPC INVALID_PARAMS, and everything behind the call stays INTERNAL_ERROR, so an agent can tell 'fix the arguments and retry' from 'stop and report' without parsing the message; the exact code and retryable are unchanged in data. Pure argument validation that had been reported as DEVUP_THEME_CONFLICT or DEVUP_SNAPSHOT_UNSUPPORTED is DEVUP_INVALID_INPUT, so a real theme conflict is no longer confused with a typo. The response is lighter for the same content. Measured on one export, a tsx-only call went from 2,411 to 1,405 bytes and the part every response carries regardless from 1,911 to 994. fidelity and completenessReport are the drill-down beneath quality and on a clean result restate it - 100% across six axes, six empty arrays - so they are sent when the result is not exact or complete, or when includeDiagnostics asks for them; on a Section export that was per screen. imports, usedTokens, componentImports, responsiveImports and responsiveComponents restated the tsx's own import line and its $tokens and are gone. deliverable stays: it was removed on the reasoning that the needs_figma handoff it guarded against is gone, and a consumer reported relying on it to know which value is the answer, which settles it. The server instructions were the larger cost and are corrected: they told every agent to take tsx, rawSnapshot and sourceMap together, which on the same measurement is about eight times the bytes of the code, and they now say to ask for an output only when it will be read. completeness and themeCompleteness stay - they grade how far token resolution reached, which quality does not say. Releases stop shipping devup-mcp-visual: the render harness builds it from source with cargo and nothing downloads it, so three of six assets were binaries no consumer used, built on every platform of every release. Cargo.lock is committed at the released version, which stops every release binary reporting its build id as -dirty and losing the ability to tell a release from a developer's working tree. Also removes two ErrorCode variants no production code ever constructed, whose only reference was a test pinning their wire strings - a test that would have passed forever whether or not they were reachable. A binding the resource catalog never named is now reported rather than quietly resolved. A fill bound to a variable, or a text carrying a style, is the design saying this is a token; the generator writes the token when the catalog carried that resource and the resolved value when it did not, and it has to write something because the module still has to compile. It said nothing when it did, so a hardcoded #7d7f83 could sit where the design says - identical today, no longer following the theme tomorrow - inside a response graded exact. Each such binding now raises DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED carrying the node, the property and the resource id, which also stops quality.projection reading exact. The check runs as a pass over the collected subtree rather than inside rendering, so it can name the node without threading an argument through the render functions, and the 268 plugin-parity goldens are unaffected because the generated bytes do not change.

Patch

  • Keep the vector operands of a Section screen that does not fit one page. A multi-root snapshot answers with a cursor when it has more nodes to give, and the collector read the first page and stopped, so on the two Loading screens of section 4279:7810 the twelve operands of two Boolean logos never arrived and the logos came out as grey boxes. The continuation is now followed to the end with the same Section and the same root set, and the cursor is checked rather than trusted: the offset has to be the one that was asked for, the next offset has to be the requested offset plus the nodes that came back, it may not pass the total, the complete flag has to agree with it, and a cursor that does not advance is refused. A continuation can no longer fall back to a legacy restart either, because the pages already accepted would be mixed with a second capture, and a first page is never cached as a finished design while operands remain unread. BOOLEAN_OPERATION is recognised as a vector asset, in asset discovery and in the single-colour test that decides between a masked Box and an Image, so the logo is exported as SVG and the generated TSX refers to those bytes. Fidelity gains uncoveredNodeIds: a declared child absent from both the snapshot and any asset projection is a hole nothing represents, so it counts against node coverage and as a lossy impact, where an operand deliberately flattened into an SVG does not. Verified against file 85CgSws3o5XsLv7aAwWJyS with cache bypassed - status partial to complete, 18 nodes to 30, 16 of 28 formats collected to 28 of 28, twelve missing formats to none, and both logos exported as SVG whose manifest, file hash, source map and TSX reference agree.

devup-mcp-devup-ui@0.2.1 → 0.3.0 - crates/devup-mcp-devup-ui/Cargo.toml

Minor

  • Keep the vector operands of a Section screen that does not fit one page. A multi-root snapshot answers with a cursor when it has more nodes to give, and the collector read the first page and stopped, so on the two Loading screens of section 4279:7810 the twelve operands of two Boolean logos never arrived and the logos came out as grey boxes. The continuation is now followed to the end with the same Section and the same root set, and the cursor is checked rather than trusted: the offset has to be the one that was asked for, the next offset has to be the requested offset plus the nodes that came back, it may not pass the total, the complete flag has to agree with it, and a cursor that does not advance is refused. A continuation can no longer fall back to a legacy restart either, because the pages already accepted would be mixed with a second capture, and a first page is never cached as a finished design while operands remain unread. BOOLEAN_OPERATION is recognised as a vector asset, in asset discovery and in the single-colour test that decides between a masked Box and an Image, so the logo is exported as SVG and the generated TSX refers to those bytes. Fidelity gains uncoveredNodeIds: a declared child absent from both the snapshot and any asset projection is a hole nothing represents, so it counts against node coverage and as a lossy impact, where an operand deliberately flattened into an SVG does not. Verified against file 85CgSws3o5XsLv7aAwWJyS with cache bypassed - status partial to complete, 18 nodes to 30, 16 of 28 formats collected to 28 of 28, twelve missing formats to none, and both logos exported as SVG whose manifest, file hash, source map and TSX reference agree.
  • Narrow what devup-mcp asks for and what it sends back, and make a refusal say which of the two kinds it is. Nine tools become seven: devup_figma_to_ui and devup_figma_to_json were devup_figma_export with a single entry in outputs, so every client carried three schemas in its context and had to decide between them on every call, and the export tool's own description had to spend a sentence saying which to prefer. Use outputs: [tsx] and outputs: [devupJson] instead. sourcePolicy is gone from all of them: auto and direct both meant the direct connection and the parameter never branched, so it only ever offered a caller something to get wrong, and it was also part of the artifact cache key it could not affect. Every remaining closed-set input now publishes its accepted values in the JSON schema - action, scope, rootLayout, delivery, match, project context scope and stack diff layers - from one shared constant the parser reads too, so the schema cannot drift from what is accepted and a caller stops discovering the set one rejection at a time; outputs and the asset format were the only two that already did this. Errors are no longer all INTERNAL_ERROR. A mistake in the call itself - an unknown scope, a node that is not in the file, an expired artifactId - is now JSON-RPC INVALID_PARAMS, and everything behind the call stays INTERNAL_ERROR, so an agent can tell 'fix the arguments and retry' from 'stop and report' without parsing the message; the exact code and retryable are unchanged in data. Pure argument validation that had been reported as DEVUP_THEME_CONFLICT or DEVUP_SNAPSHOT_UNSUPPORTED is DEVUP_INVALID_INPUT, so a real theme conflict is no longer confused with a typo. The response is lighter for the same content. Measured on one export, a tsx-only call went from 2,411 to 1,405 bytes and the part every response carries regardless from 1,911 to 994. fidelity and completenessReport are the drill-down beneath quality and on a clean result restate it - 100% across six axes, six empty arrays - so they are sent when the result is not exact or complete, or when includeDiagnostics asks for them; on a Section export that was per screen. imports, usedTokens, componentImports, responsiveImports and responsiveComponents restated the tsx's own import line and its $tokens and are gone. deliverable stays: it was removed on the reasoning that the needs_figma handoff it guarded against is gone, and a consumer reported relying on it to know which value is the answer, which settles it. The server instructions were the larger cost and are corrected: they told every agent to take tsx, rawSnapshot and sourceMap together, which on the same measurement is about eight times the bytes of the code, and they now say to ask for an output only when it will be read. completeness and themeCompleteness stay - they grade how far token resolution reached, which quality does not say. Releases stop shipping devup-mcp-visual: the render harness builds it from source with cargo and nothing downloads it, so three of six assets were binaries no consumer used, built on every platform of every release. Cargo.lock is committed at the released version, which stops every release binary reporting its build id as -dirty and losing the ability to tell a release from a developer's working tree. Also removes two ErrorCode variants no production code ever constructed, whose only reference was a test pinning their wire strings - a test that would have passed forever whether or not they were reachable. A binding the resource catalog never named is now reported rather than quietly resolved. A fill bound to a variable, or a text carrying a style, is the design saying this is a token; the generator writes the token when the catalog carried that resource and the resolved value when it did not, and it has to write something because the module still has to compile. It said nothing when it did, so a hardcoded #7d7f83 could sit where the design says - identical today, no longer following the theme tomorrow - inside a response graded exact. Each such binding now raises DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED carrying the node, the property and the resource id, which also stops quality.projection reading exact. The check runs as a pass over the collected subtree rather than inside rendering, so it can name the node without threading an argument through the render functions, and the 268 plugin-parity goldens are unaffected because the generated bytes do not change.

devup-mcp-figma@0.2.1 → 0.3.0 - crates/devup-mcp-figma/Cargo.toml

Minor

  • Narrow what devup-mcp asks for and what it sends back, and make a refusal say which of the two kinds it is. Nine tools become seven: devup_figma_to_ui and devup_figma_to_json were devup_figma_export with a single entry in outputs, so every client carried three schemas in its context and had to decide between them on every call, and the export tool's own description had to spend a sentence saying which to prefer. Use outputs: [tsx] and outputs: [devupJson] instead. sourcePolicy is gone from all of them: auto and direct both meant the direct connection and the parameter never branched, so it only ever offered a caller something to get wrong, and it was also part of the artifact cache key it could not affect. Every remaining closed-set input now publishes its accepted values in the JSON schema - action, scope, rootLayout, delivery, match, project context scope and stack diff layers - from one shared constant the parser reads too, so the schema cannot drift from what is accepted and a caller stops discovering the set one rejection at a time; outputs and the asset format were the only two that already did this. Errors are no longer all INTERNAL_ERROR. A mistake in the call itself - an unknown scope, a node that is not in the file, an expired artifactId - is now JSON-RPC INVALID_PARAMS, and everything behind the call stays INTERNAL_ERROR, so an agent can tell 'fix the arguments and retry' from 'stop and report' without parsing the message; the exact code and retryable are unchanged in data. Pure argument validation that had been reported as DEVUP_THEME_CONFLICT or DEVUP_SNAPSHOT_UNSUPPORTED is DEVUP_INVALID_INPUT, so a real theme conflict is no longer confused with a typo. The response is lighter for the same content. Measured on one export, a tsx-only call went from 2,411 to 1,405 bytes and the part every response carries regardless from 1,911 to 994. fidelity and completenessReport are the drill-down beneath quality and on a clean result restate it - 100% across six axes, six empty arrays - so they are sent when the result is not exact or complete, or when includeDiagnostics asks for them; on a Section export that was per screen. imports, usedTokens, componentImports, responsiveImports and responsiveComponents restated the tsx's own import line and its $tokens and are gone. deliverable stays: it was removed on the reasoning that the needs_figma handoff it guarded against is gone, and a consumer reported relying on it to know which value is the answer, which settles it. The server instructions were the larger cost and are corrected: they told every agent to take tsx, rawSnapshot and sourceMap together, which on the same measurement is about eight times the bytes of the code, and they now say to ask for an output only when it will be read. completeness and themeCompleteness stay - they grade how far token resolution reached, which quality does not say. Releases stop shipping devup-mcp-visual: the render harness builds it from source with cargo and nothing downloads it, so three of six assets were binaries no consumer used, built on every platform of every release. Cargo.lock is committed at the released version, which stops every release binary reporting its build id as -dirty and losing the ability to tell a release from a developer's working tree. Also removes two ErrorCode variants no production code ever constructed, whose only reference was a test pinning their wire strings - a test that would have passed forever whether or not they were reachable. A binding the resource catalog never named is now reported rather than quietly resolved. A fill bound to a variable, or a text carrying a style, is the design saying this is a token; the generator writes the token when the catalog carried that resource and the resolved value when it did not, and it has to write something because the module still has to compile. It said nothing when it did, so a hardcoded #7d7f83 could sit where the design says - identical today, no longer following the theme tomorrow - inside a response graded exact. Each such binding now raises DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED carrying the node, the property and the resource id, which also stops quality.projection reading exact. The check runs as a pass over the collected subtree rather than inside rendering, so it can name the node without threading an argument through the render functions, and the 268 plugin-parity goldens are unaffected because the generated bytes do not change.

Patch

  • Keep the vector operands of a Section screen that does not fit one page. A multi-root snapshot answers with a cursor when it has more nodes to give, and the collector read the first page and stopped, so on the two Loading screens of section 4279:7810 the twelve operands of two Boolean logos never arrived and the logos came out as grey boxes. The continuation is now followed to the end with the same Section and the same root set, and the cursor is checked rather than trusted: the offset has to be the one that was asked for, the next offset has to be the requested offset plus the nodes that came back, it may not pass the total, the complete flag has to agree with it, and a cursor that does not advance is refused. A continuation can no longer fall back to a legacy restart either, because the pages already accepted would be mixed with a second capture, and a first page is never cached as a finished design while operands remain unread. BOOLEAN_OPERATION is recognised as a vector asset, in asset discovery and in the single-colour test that decides between a masked Box and an Image, so the logo is exported as SVG and the generated TSX refers to those bytes. Fidelity gains uncoveredNodeIds: a declared child absent from both the snapshot and any asset projection is a hole nothing represents, so it counts against node coverage and as a lossy impact, where an operand deliberately flattened into an SVG does not. Verified against file 85CgSws3o5XsLv7aAwWJyS with cache bypassed - status partial to complete, 18 nodes to 30, 16 of 28 formats collected to 28 of 28, twelve missing formats to none, and both logos exported as SVG whose manifest, file hash, source map and TSX reference agree.

devup-mcp-visual@0.2.1 → 0.2.2 - crates/devup-mcp-visual/Cargo.toml

Patch

  • Narrow what devup-mcp asks for and what it sends back, and make a refusal say which of the two kinds it is. Nine tools become seven: devup_figma_to_ui and devup_figma_to_json were devup_figma_export with a single entry in outputs, so every client carried three schemas in its context and had to decide between them on every call, and the export tool's own description had to spend a sentence saying which to prefer. Use outputs: [tsx] and outputs: [devupJson] instead. sourcePolicy is gone from all of them: auto and direct both meant the direct connection and the parameter never branched, so it only ever offered a caller something to get wrong, and it was also part of the artifact cache key it could not affect. Every remaining closed-set input now publishes its accepted values in the JSON schema - action, scope, rootLayout, delivery, match, project context scope and stack diff layers - from one shared constant the parser reads too, so the schema cannot drift from what is accepted and a caller stops discovering the set one rejection at a time; outputs and the asset format were the only two that already did this. Errors are no longer all INTERNAL_ERROR. A mistake in the call itself - an unknown scope, a node that is not in the file, an expired artifactId - is now JSON-RPC INVALID_PARAMS, and everything behind the call stays INTERNAL_ERROR, so an agent can tell 'fix the arguments and retry' from 'stop and report' without parsing the message; the exact code and retryable are unchanged in data. Pure argument validation that had been reported as DEVUP_THEME_CONFLICT or DEVUP_SNAPSHOT_UNSUPPORTED is DEVUP_INVALID_INPUT, so a real theme conflict is no longer confused with a typo. The response is lighter for the same content. Measured on one export, a tsx-only call went from 2,411 to 1,405 bytes and the part every response carries regardless from 1,911 to 994. fidelity and completenessReport are the drill-down beneath quality and on a clean result restate it - 100% across six axes, six empty arrays - so they are sent when the result is not exact or complete, or when includeDiagnostics asks for them; on a Section export that was per screen. imports, usedTokens, componentImports, responsiveImports and responsiveComponents restated the tsx's own import line and its $tokens and are gone. deliverable stays: it was removed on the reasoning that the needs_figma handoff it guarded against is gone, and a consumer reported relying on it to know which value is the answer, which settles it. The server instructions were the larger cost and are corrected: they told every agent to take tsx, rawSnapshot and sourceMap together, which on the same measurement is about eight times the bytes of the code, and they now say to ask for an output only when it will be read. completeness and themeCompleteness stay - they grade how far token resolution reached, which quality does not say. Releases stop shipping devup-mcp-visual: the render harness builds it from source with cargo and nothing downloads it, so three of six assets were binaries no consumer used, built on every platform of every release. Cargo.lock is committed at the released version, which stops every release binary reporting its build id as -dirty and losing the ability to tell a release from a developer's working tree. Also removes two ErrorCode variants no production code ever constructed, whose only reference was a test pinning their wire strings - a test that would have passed forever whether or not they were reachable. A binding the resource catalog never named is now reported rather than quietly resolved. A fill bound to a variable, or a text carrying a style, is the design saying this is a token; the generator writes the token when the catalog carried that resource and the resolved value when it did not, and it has to write something because the module still has to compile. It said nothing when it did, so a hardcoded #7d7f83 could sit where the design says - identical today, no longer following the theme tomorrow - inside a response graded exact. Each such binding now raises DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED carrying the node, the property and the resource id, which also stops quality.projection reading exact. The check runs as a pass over the collected subtree rather than inside rendering, so it can name the node without threading an argument through the render functions, and the 268 plugin-parity goldens are unaffected because the generated bytes do not change.

The gate refuses a pull request that edits crates without one, because a
change that never moves the version never releases - it would have
landed on main and stayed there.
@owjs3901
owjs3901 merged commit 4eba833 into main Sep 8, 2026
8 checks passed
owjs3901 added a commit that referenced this pull request Sep 8, 2026
Both sides changed validate_fidelity: #9 counts a declared child that no
asset represents against node coverage, this branch stops counting a
variable binding that a multi-colour asset baked into a file. They are
the same rule read from two directions - expect what the generator could
have produced, and nothing else - so the merge keeps both. Verified on
the merged tree: fmt, clippy -D warnings, the whole suite and insta.
owjs3901 added a commit that referenced this pull request Sep 8, 2026
…ose from (#13)

A Section link answers with candidates rather than screens, and the list
gave a name, a type and a URL. That is not enough to tell two frames
apart when a designer named them alike, so the choice was a guess and
the wrong guess costs a full export.

Each candidate now carries a preview of the visible text under it,
bounded to 120 characters per candidate, 64 nodes walked, and 2 KiB
across the whole list, so the index stays compact. An empty or short
preview means the walk found little text, not that the screen is empty.

The list also says how complete it is - selection.status and
selection.count - with truncated read from the index rather than
inferred from a round count of 100, which called a Section holding
exactly that many partial. And nextAction carries an example built from
this call's own artifactId and a candidate actually in the list, so the
next step is a call to run rather than a shape to assemble.

Taken from #10, which also re-implemented the multi-root cursor handling
that #9 already landed; only the selection-list half is here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant