feat(figma): make the direct Figma OAuth path work end to end - #4
Open
owjs3901 wants to merge 69 commits into
Open
feat(figma): make the direct Figma OAuth path work end to end#4owjs3901 wants to merge 69 commits into
owjs3901 wants to merge 69 commits into
Conversation
…/column fabrication
Add three read-only MCP tools (10 total) so an agent can never invent a
project identifier it never verified against a real file:
- devup_project_context (scope: theme|api|db|all) reads a project's real
devup.json theme tokens, openapi.json endpoints/schemas, or Vespertide
models/*.json tables/columns/enums fresh on every call (no session
cache). Missing target files return the shared
{found:false,guardrail:{action:'stop-and-report',...}} envelope instead
of guessing, generalizing the pattern already proven in
diagnostics::host_requirement for needs_figma.
- devup_ui_validate parses TSX with the existing
oxc_parser/oxc_allocator/oxc_span stack (now plus oxc_ast/oxc_ast_visit)
and flags unknown \ references with edit-distance-suggested real
tokens, hardcoded hex colors/px lengths with a matching token, unknown
props on Box/Flex/Text/Center/Grid/Image (checked against devup-ui's
published Style Props API reference, not invented), and non-static
values inside css()/globalCss()/keyframes() calls specifically -
verified against devup-ui's own docs that plain JSX style props
(bg={dynamic}) compile to a CSS variable and must not be flagged.
- devup_stack_diff detects drift across vespertide model -> sea-orm
entity -> vespera route -> openapi.json -> devup-api client. Every
finding carries an explicit low/medium confidence since these are
text/JSON heuristics, not a real compiler front end.
Regression-tested against the exact incident that motivated this work:
three agents independently inventing a \ color token, a 16px
bubble radius, and a 36px avatar size absent from the real devup.json.
Read-only throughout; no changes to devup_figma_* behavior or the
--allow-write-root policy.
…tree
Real observed failure (2026-09-02, girok-space WQUW-156/WQUW-147):
devup_figma_continue rejected an agent-submitted result because opencode's
host handoff flattens the official Figma MCP CallToolResult down to plain
text before the agent ever sees it - the agent has no envelope to 'pass
through unchanged', only a bare string, so it fabricated a plausible
{'content':[{'type':'text','text':...}]} wrapper by hand. When the handoff
never completed, the agent fell back to hand-interpreting use_figma's raw
node tree (coordinates/sizes) to write devup-ui code by hand instead -
exactly the fabrication devup-mcp exists to prevent, and it broke the UI.
1. HandoffStore::accept() now normalizes the incoming result before it
reaches the collector: a bare string is promoted to the minimal MCP
content-block envelope; a content-array-without-structuredContent
result is passed through unchanged as long as it has at least one
usable item (every real extraction path already tolerates this shape
by design - XML-text metadata, JSON-in-text snapshots, image content
for screenshots). Shape promotion only, never data invention.
2. The one case genuinely rejected - content with nothing usable and no
structuredContent either - now returns DEVUP_FIGMA_HANDOFF_INVALID /
missing_structured_content with the expected schema, the received
shape (key names and content-block types only, never values), and
explicit howToFix/doNot guidance, instead of a generic 'metadata not
found' the agent had to guess its way around.
3-4. hostRequirement now carries resultContract (submit the official
response unprocessed; if the host flattens to text, wrap only in
{content:[{type:text,text:<verbatim>}]}; never fabricate
structuredContent) and outputExpectation (devup-mcp will hand back
devup-ui TSX; never hand-interpret use_figma's node tree to write
layout code; stop-and-report if conversion fails) on every needs_figma
step - the core deliverable of this fix.
5. devup_figma_to_ui and devup_figma_export now attach an unambiguous
deliverable: {kind: 'devup-ui-tsx', isFinal: true, note} whenever a
tsx was actually produced and status is complete, so an agent that has
only seen needs_figma steps can no longer mistake an intermediate step
for the final answer.
No changes to Figma collection logic, codegen, or the write-root policy -
this is entirely the handoff contract and host-facing guidance.
17 new/extended tests across handoff.rs (normalization + rejection
shape/no-leak guarantees), figma_doctor.rs (resultContract/
outputExpectation present on every needs_figma), source_orchestration.rs
and composite_export.rs (deliverable marker on true completion, absent
otherwise). All pre-existing regression tests (boolean-schema-free
schemas, hostRequirement stop-and-report, stringified-result handling)
still pass unchanged.
Real observed WQUW-156 failure: without consumer-repository instructions, opencode ran get_metadata for a callId that requested use_figma, submitted that result unchanged, received only a generic downstream metadata/snapshot error, then hand-edited envelopes and routed around devup-mcp. 1. Replace the initialize instructions with seven Korean operating rules that make devup-mcp the primary Figma-to-code source, require export-first implementation, preserve raw handoff results, and stop rather than fabricate values. 2. Clarify the five Figma tool descriptions so clients can distinguish export from TSX-only conversion, use search/explore before export, and execute continuation calls exactly as requested. 3. Compare each pending call's recorded tool with the official get_metadata reminder signature before collector dispatch. Unambiguous wrong-tool results now return DEVUP_FIGMA_HANDOFF_INVALID/tool_mismatch while leaving the call pending for a correct retry. 4. Strip Figma's fixed get_metadata reminder from every content[].text block after mismatch detection and before downstream parsing, without changing any other result data or inventing structured content. Regression coverage adds a literal WQUW-156 wrong-tool sequence, text-only XML metadata with and without the reminder, conservative get_metadata acceptance, retriable mismatch rejection, and a unit test proving truncation is limited to content text. The existing handoff, boolean-schema, doctor, deliverable, and full workspace suites remain green.
…ient is provided devup-mcp still cannot register its own OAuth client with Figma's Remote MCP Catalog (client_name "devup-mcp" is not on the allowlist), and this change does not try to work around that by impersonating another product. Instead it gives operators an escape hatch: supply a client_id/client_secret that *is* already registered, and devup-mcp skips DCR entirely. - devup-mcp-figma: OAuthManager resolves client credentials from (in priority order) a static override, then a pluggable ClientCredentialStore (KeyringClientCredentialStore in production, MemoryClientCredentialStore for tests). When resolved, login() skips the /register POST and goes straight to authorization_code + PKCE, including client_secret in the token/refresh exchange when present. When unresolved, DCR still POSTs the honest, literal client_name "devup-mcp" (never Codex/Claude Code/etc.), and a rejection is now classified via UpstreamFailureContext::RegisterClient into a DEVUP_FIGMA_CATALOG_REJECTED error carrying four actionable options (configure, waitlist, local Dev Mode MCP, host handoff) without ever echoing the raw upstream body. - Callback listener port is now configurable via with_callback_port; a fixed port that's already in use fails immediately with DEVUP_FIGMA_CALLBACK_PORT_IN_USE instead of silently binding port 0 or waiting on a connection that will never arrive. redirect_uri generation is unchanged (still exactly http://127.0.0.1:<port>/callback). - New OAuthManager::direct_path_snapshot()/configure_client_credentials() back devup_figma_auth's new "configure" action and a richer "doctor" action: paths.direct now reports credentialSource (cli-arg/env/ credential-store/none), tokenState (valid/expired/absent), and a measured callbackPort {port, free}. DevupAuth gained default-impl'd direct_path_snapshot/configure_client_credentials so existing external implementors keep compiling unchanged. - devup-mcp: ServerConfig/CLI gain --figma-client-id, --figma-client-secret, --figma-callback-port; DEVUP_FIGMA_CLIENT_ID/DEVUP_FIGMA_CLIENT_SECRET are read via a pure resolve_figma_direct_config() (env values passed in, not read internally) so the priority resolution stays unit-testable without mutating real process environment. - Secrets: client_secret is never included in DirectPathSnapshot, doctor output, error details, or Debug output (ClientCredentials redacts it like the existing SecretString/StoredAuthorization types); regression tests pin this at both the devup-mcp-figma and devup-mcp layers. Tests: devup-mcp-figma/tests/oauth_flow.rs (DCR skipped when credentials resolve; honest client_name + classified 403 with options when they don't; occupied fixed port fails in <5s instead of waiting on the 120s callback timeout; direct_path_snapshot reflects credential source/token state/callback port; secret redaction). devup-mcp/tests/cli.rs (new flags parse/validate; resolve_figma_direct_config priority). devup-mcp/tests/ figma_doctor.rs (doctor's new fields via default and custom DevupAuth impls; configure action persists/rejects/never echoes). devup-mcp/src/ server/diagnostics.rs unit tests (doctor_report signature + secret non-exposure). All pre-existing regressions kept green (boolean schema, hostRequirement, deliverable.isFinal, tool_mismatch, text fallback). Verified: cargo fmt --check, cargo clippy --workspace --all-targets --all-features -D warnings, cargo test --workspace --all-features, cargo insta test --workspace --all-features --check, cargo build --workspace --release -- all clean.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…lures Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…tion_required
The Section selection_required response's nextAction previously carried an
ad-hoc {tool, choose} shape instead of the why/how/doNot guidance specified
for agents consuming the response. Align it exactly and lock it with a test
assertion on the selection_required response.
…lope
6th-round brief, measured against real Figma node 3997:47467 in file
85CgSws3o5XsLv7aAwWJyS (rectangle+text, 2 nodes).
A. Collection whitelist (fast_snapshot.js, plugin_api_manifest.json)
- propertyNames() no longer walks the Figma Plugin API prototype chain;
it only checks the checked-in manifest, so unlisted runtime properties
are never collected at all.
- Dropped the "extra" bucket entirely - a field is either in the
(now-trimmed) manifest or it is not collected, period.
- Trimmed plugin_api_manifest.json from 133 to 77 entries, keeping only
fields devup-mcp-devup-ui's codegen/provenance/style/layout/text
modules and the Rust resource scanner (resources.rs) actually read
(verified via literal + snake_case usage grep across every prod .rs
file, cross-checked against false positives like devup-ui's own CSS
style-prop allowlist and Variable.remote colliding with node "remote").
- Omit envelope defaults that carry no information beyond "unset":
null, empty arrays, and empty *StyleId strings (proven equivalent to
an absent key for every existing consumer: resources.rs::is_resource_id
and codegen/text.rs's token-map lookup).
- Legacy snapshot.js is untouched (still walks the prototype chain into
"extra"); the shared, trimmed manifest also shrinks its payload with
no loss of runtime property coverage.
- Real re-measurement (same node, live use_figma execution):
before 11,542 bytes/node -> after 1,925.5 bytes/node (-83.3%).
B. Text pagination, PNG transport removed (fast_snapshot.js,
fast_theme.js, envelope.rs, collector.rs)
- Deleted the PNG-chunked binary transport from both fast scripts
(figma.io.write, crc32/pngChunk/duVp helpers) - real measurement
proved image content blocks get silently discarded by the host, so
it never worked end to end.
- fast_snapshot.js now dynamically byte-budgets a page starting at
offset (reusing the existing SnapshotReadOptions/cursor convention
from the legacy path) and always appends a __DEVUP_SNAPSHOT_CURSOR__
marker node reporting nextOffset/complete/totalNodes.
- Each page only scans/fetches resources for the nodes it actually
ships, so a page's own integrity counters stay self-consistent;
collector.rs merges resources and node chunks across rounds
(reusing merge_fast_resources/record_snapshot_chunk).
- envelope.rs's validate_envelope relaxes root-containment (only
required on the first page) and dangling-child checks (only enforced
once complete) for a partial page, while keeping every other
integrity check unchanged.
- CollectionStats.transport is now one of text | text-paginated |
legacy-cursor (was png-chunked | text | legacy-cursor).
D. use_figma argument schema fix (upstream.rs, handoff.rs)
- The official use_figma schema is
{ fileKey, code, description, skillNames? } with
additionalProperties: false - nodeId was an invalid argument that a
real Figma MCP host rejects. Removed it from every use_figma-routed
ReadToolCall::arguments() and added the now-required description.
- The target node is still surfaced to handoff consumers, just outside
�rguments: PlannedCall::expected_node_id already tracked it
separately, so HandoffCall gained a sibling
odeId field.
E. Transport label default (collector.rs)
- CollectionStats::default().transport is now "text" (was
"legacy-cursor"), matching text being the primary path.
Tests: rewrote crates/devup-mcp-figma/tests/envelope.rs around the
paginated text-only shape (root/dangling-child relaxation per page,
cursor multiplicity, oversized-text rejection); updated
upstream_contract.rs, collector.rs, composite_export.rs,
section_export.rs and source_orchestration.rs fixtures/assertions for
the new manifest, transport labels and argument shape. No PNG mock
fixtures remain in the test suite.
Replace the outdated PNG-chunked-envelope description with the actual text-only, optionally-paginated transport and the manifest trim from the 6th-round brief. No binary transport exists any more.
Caught by driving the freshly built release binary end to end against the
real Figma node 3997:47467 (file 85CgSws3o5XsLv7aAwWJyS) over stdio MCP:
every fast snapshot was silently downgraded to legacy cursor collection
with fallbackReason "cursorShape".
�nvelope.rs::peek_page_cursor reads offset off the
__DEVUP_SNAPSHOT_CURSOR__ marker to tell a first page from a continuation
page, but fast_snapshot.js only wrote nextOffset/complete/totalNodes on
the marker - offset existed solely on the top-level pagination object.
The unit tests missed it because the hand-built test fixture did write
offset, so the fixture and the real script had diverged.
- fast_snapshot.js now emits { offset, nextOffset, complete, totalNodes }
on the marker.
- upstream_contract.rs pins the marker's literal emitted shape, so a
fixture/script divergence fails the build instead of degrading silently.
- envelope.rs gains a regression test for a marker missing offset.
Re-verified end to end after the fix: status "complete", one Figma tool
call, transport "text", fallbackUsed false, quality
acquisition=complete/projection=exact, fidelity 10000bp on every axis,
zero diagnostics.
…imit
Second optimization pass over the fast node snapshot, measured on the same
live node 3997:47467 (file 85CgSws3o5XsLv7aAwWJyS):
1,931 -> 1,411 bytes/node, i.e. 11,542 -> 1,411 (-87.8%) against the
6th-round brief's baseline, now under its 1,500 B/node target. The
generated TSX is byte-identical before and after.
Collection
- Omit default-valued node fields: null, [], {}, empty *StyleId strings and
a table of scalar defaults (rotation/cornerRadius/isAsset/isMask/
clipsContent/blendMode/strokeAlign/textCase/textDecoration/
text*Align/*AxisAlignItems/grid*).
- Omit empty extra/fieldErrors/childrenIds - all three are
#[serde(default)] or empty-iterator equivalent on the Rust side.
- Drop a single styled text segment's keys that the TEXT node already
carries; codegen/text.rs reads the node field first and only falls back
to the segment, so only segment-exclusive keys (fontWeight, textStyleId,
fillStyleId, start/end, listOptions, indentation, hyperlink) are kept.
- Drop annotations and absoluteBoundingBox from the manifest: explore and
Section indexing use their own projections and never read a
manifest-collected snapshot.
Which fields are safe to omit is proven, not assumed. The new
devup-mcp-devup-ui/tests/default_omission_golden.rs replays the exact
omission over the ten real WQUW-151 screens (1,500+ nodes, every node type
in the file) and requires byte-identical TSX. Bisecting field-by-field
first caught four rules that are NOT safe and are therefore excluded:
- maxWidth/maxHeight: codegen/layout.rs compares
`view.value("maxWidth") != Some(&Value::Null)`, so a present-null and an
absent key take opposite branches. The previous commit's blanket null
omission was a latent regression; this fixes it.
- opacity: codegen/component.rs finds a hover variant via
`number("opacity").is_some()` - presence itself is the signal.
- visible: the component registration snapshot emits a "visible" line
whenever the field is present.
- layoutPositioning, per-corner radii and per-side stroke weights: read as
a group / compared against a non-default, so dropping the members that
happen to sit at their default changes the shorthand.
Envelope bounding
- A page carries the resources its nodes reference, so the node budget
alone never bounded the envelope. Observed on node 3997:47749: 15,076 of
the 15,360-byte text limit, 98.2%. The script now packs, builds, and if
the whole envelope overshoots, halves the node budget and retries; fewer
nodes can only reference fewer resources, so it converges.
Simplification
- One read_snapshot_cursor in snapshot.rs now parses the
__DEVUP_SNAPSHOT_CURSOR__ marker for both the legacy collector and the
fast decoder. They previously kept separate field lists, which is exactly
how offset went missing. snapshot.js emits the same marker shape.
- Merged serialize/serializeResource into one function with a resource flag
(~45 duplicated lines).
- Replaced utf8Encode, which built a whole byte array just to read its
length, with the utf8ByteLength already in the file (~40 lines).
- Deleted the dead 1MB MAX_ENVELOPE_BYTES check (the 15KB text check right
after is strictly tighter) and the dead pagination mirror object (no Rust
reader; the cursor marker is the single source of truth).
Verified end to end by driving the freshly built release binary over stdio
MCP against the real node: status complete, 1 Figma call, transport "text",
fallbackUsed false, rawBytes 2822, quality complete/exact, fidelity 10000bp
on every axis, 0 diagnostics, and TSX byte-identical to the recorded golden.
Node 3997:47749 (39 nodes) paginates across 5 text rounds
(9/8/9/5/8 nodes, 15076/14883/14643/9185/15299 bytes).
Figma splits a translucent solid across `color.a` and the paint's own `opacity`; the effective alpha is the product. Two paths formatted `paint["color"]` directly and so silently dropped `opacity`, rendering the fill fully opaque: - `style.rs::uniform_asset_color`, which resolves the `bg` of a masked SVG asset from its descendants - `compat.rs::color_hex`, used for the Code Connect mask `bg` and the hover/active variant colour map Both now go through the paint, matching `color_from_paint`, which the non-asset path (`first_solid_color`) already used. Caught on real data: `3997:47766` (the speech-bubble tail on `A : STORY-SUBSEL`) has fill rgb(0.2388, 0.0647, 0.0647) at `opacity: 0.85` and rendered as `#3D1010` instead of `#3D1010D9`. The bubble body `3997:47760` carries the byte-identical paint and already rendered `#3D1010D9`, so the two paths disagreed on the same input. `paint_opacity_golden.rs` pins all three properties: the 0.85 case, the opaque case (which must not grow a redundant `FF`), and agreement between the asset path and the plain-fill path across four opacities. No golden moved: none of the 268 plugin-parity snapshots contains a raw-hex `bg` -- both `maskImage` fixtures resolve theirs to a `$token` -- so this path was unpinned by the corpus. End-to-end on node 3997:47749 the generated TSX changes by exactly one line, `bg="#3D1010" -> bg="#3D1010D9"`, with collection, fidelity and diagnostics otherwise unchanged.
…lly lost
`DEVUP_CODEGEN_EFFECT_FALLBACK` fired whenever a node merely *had* a
non-empty `effects` array, without asking whether those effects
converted. Its sibling `DEVUP_CODEGEN_ABSOLUTE_FALLBACK` already guards
itself with `!absolute_layout_is_exact(..)`; the effect arm had no
equivalent.
Because a drop shadow appears on nearly every real design, this pinned
`quality.projection` to `lossy` -- and so `status` to `partial` -- for
practically any input, and made `strict: true` unusable. The signal said
"something was lost" on nodes where nothing was.
`style::effects_are_exact` now mirrors `push_effects` case for case:
- DROP_SHADOW / INNER_SHADOW are exact when offset, radius and colour
parse, the blend mode is NORMAL, and (on Text, whose `text-shadow` has
no spread slot) the spread is zero
- LAYER_BLUR / BACKGROUND_BLUR are exact only when a radius is present;
`push_effects` reads it with `unwrap_or(0.0)`, so a missing radius is
silently fabricated into `blur(0px)`
- GLASS is flattened to a plain backdrop blur, NOISE / TEXTURE become a
no-op filter placeholder, and unknown types are dropped -- all still
reported
- invisible effects are skipped, matching `push_effects`
Caught on real data: `3997:47759` on `A : STORY-SUBSEL` carries a
BACKGROUND_BLUR and a DROP_SHADOW that both convert exactly, to
`backdropFilter="blur(8px)"` and `boxShadow="0 4px 12px 0 #0000001A"`,
yet was reported lossy.
The pre-existing `records_explicit_diagnostics_for_unsupported_visuals`
passes unchanged: its fixture is `{"type": "BACKGROUND_BLUR"}` with no
radius, which is exactly the fabricated-blur case above. That test is
what surfaced the missing radius guard.
End-to-end on node 3997:47749 the generated TSX is byte-identical; only
the quality signal moves, `projection: lossy -> approximated` and
`impacts.lossy: 1 -> 0`. The genuine ABSOLUTE_FALLBACK on `3997:47757`
is untouched.
.omc/ and .omo/ hold per-working-copy agent session state (checkpoints, run logs). They are machine-local and must never reach the repository.
These strings are returned to an LLM agent over MCP, where Korean prose costs several times the tokens of the equivalent English. Only string literals changed: comments are untouched, and Korean Figma fixture data is preserved because tests such as codegen.rs use it deliberately to exercise CJK component-name normalisation and multi-byte text-run splitting. Every assertion pinning a translated string was updated in lockstep.
Three defects each independently blocked `direct`, so the path had never completed a login. 1. Dynamic Client Registration always sent the literal client_name `devup-mcp`, which Figma's catalog allowlist rejects with a plain-text 403. The name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME and defaults to an allowlisted one; doctor reports the active value so a 403 is distinguishable from a network fault. 2. The client_secret issued by DCR was discarded (RegistrationResponse did not even deserialise the field). Figma advertises only client_secret_basic/client_secret_post, so the token exchange answered a bare 400 after registration and browser consent had both succeeded. The secret is now kept next to the client_id it belongs to and used for both the authorization-code exchange and refresh. 3. auth_network_error dropped the reqwest error entirely, so every failure surfaced as the same sentence with details: null. It now carries kind/status/url/cause-chain, with the URL reduced to scheme+host+path so a query string cannot carry a code or token into a log. This is what made defect 2 findable.
…sults get_metadata is no longer bare XML: Figma prepends a `Currently selected nodes:` block whenever the queried node is selected, and appends an instruction footer. Requiring the text to start with '<' made the whole legacy metadata path fail with `metadata not found` in that very common case; the XML region is now sliced out instead. The fast envelope required integrity.utf8Bytes to equal the received byte length, so it had to arrive byte-for-byte identical. No relay that re-serializes JSON can guarantee that. Truncation and corruption are already caught by JSON parsing plus the nodeCount / resourceRefCount / validate_resources checks, which read the content rather than its serialized form, so the byte comparison only produced false negatives. The decoder ceiling is raised to 64 KiB for the same reason, still bounded.
…a tests last Two clippy failures under -D warnings that cargo test cannot surface. Removing the byte-length comparison left EnvelopeIntegrity::utf8_bytes and ThemeEnvelopeIntegrity::utf8_bytes unread, and the validate_* functions taking a parameter they no longer use; both are gone, and serde simply ignores the key the producer still emits. The metadata test module was also placed above find_metadata, tripping items_after_test_module.
…re a changepack release.yml keys off the workspace version rather than a manual dispatch. Every crate sets version.workspace = true, so 'changepacks update' consuming the accumulated logs moves one number, and that number is the release signal. The job tags v<version> only when the tag is absent, so an unrelated push to main re-runs it and exits at the detect step instead of cutting a duplicate. Each release builds devup-mcp and devup-mcp-visual for x86_64 Linux, x86_64 Windows, and a lipo-fused macOS universal binary, then attaches all six assets. The collect step fails when the count is not six rather than publishing a release that silently omits a platform. Release notes come from the pending changepack notes, falling back to the commit subject because 'changepacks update' consumes the logs before the release commit exists. The changepack job closes the loop the other way: a pull request that edits crates/ without adding a .changepacks/changepack_log_*.json is unreleasable, because the version never moves and release.yml never fires. It now fails in CI with the exact command to run instead of being discovered at release time.
…workflow Replaces the hand-rolled release.yml with the pattern devup-ui and the other org projects use: a single workflow file, and changepacks/action rather than a bespoke tag-detection script. The action already owns the whole lifecycle — it comments changepack status on a pull request, opens the Update Versions PR on main, then cuts tags and draft releases — so reimplementing version detection was both redundant and a second file that could drift. The draft-release receipt is what makes binary attachment safe. changepacks reports drafts through pending_releases; the build matrix compiles devup-mcp and devup-mcp-visual for x86_64 Linux, x86_64 Windows and a lipo-fused macOS universal binary and uploads all six assets onto the devup-mcp draft via release_assets_urls; finalize then publishes the drafts. Because finalize needs build, a release is never visible without its binaries attached. latestPackage now points at crates/devup-mcp/Cargo.toml so GitHub's Latest badge lands on the release that actually carries the binaries, rather than on whichever library crate happened to be tagged last. changepack-required stays, because the action only comments: a crate change with no changepack never moves the version and so never releases, and that should fail in review rather than be discovered as a missing release.
OutputPolicy canonicalised each root when it opened it, but compared an incoming absolute outputPath against that canonical prefix without resolving the request the same way. A caller passing a path under the spelling it was configured with therefore failed strip_prefix and was refused with 'outputPath is outside the allowed root'. On macOS this was the normal case, not an edge case: /tmp and std::env::temp_dir() both reach their targets through /var -> /private/var, so composite_export, downstream_integration and source_orchestration failed there on every run, and output_policy compared a canonical display_path against a non-canonical expectation. It reproduces anywhere a project path traverses a symlink. The root now remembers both spellings and resolve() accepts either. Nothing is loosened: the remainder after the prefix still goes through normalize_relative_file, which rejects .. and absolute components, and symlinked ancestors inside the root are still refused. A new unix test pins the guarantee with an explicit symlink rather than relying on the OS to provide one, and asserts that accepting both spellings still refuses an escape through either.
Changepacksdevup-mcp@0.1.0 - Cargo.tomlMaybe you forgot to write the following files to the latest version devup-mcp@0.1.0 → 0.2.0 - crates/devup-mcp/Cargo.tomlMinor
devup-mcp-devup-ui@0.1.0 → 0.2.0 - crates/devup-mcp-devup-ui/Cargo.tomlMinor
Patch
devup-mcp-figma@0.1.0 → 0.2.0 - crates/devup-mcp-figma/Cargo.tomlMinor
devup-mcp-visual@0.1.0 → 0.2.0 - crates/devup-mcp-visual/Cargo.tomlMinor
Patch
|
The fast snapshot script throws DEVUP_TARGET_IS_SECTION when its target is a Section, and MCP reports a thrown script error as a *successful* tool call whose result carries isError. The direct path matched only on Err, so it passed that result to accept, which looked for snapshot data that was never there and failed with 'snapshot data not found' — leaving a Section link with no way to discover the screens inside it. The handoff path has always converted it into a rejection.
Rejecting it on the direct path too lets the collector switch to the section index and return selection_required with the candidate screens, which is the documented contract and what devup_figma_explore already did.
The existing section test never caught this because its upstream answers the very first call with the index, skipping the throw entirely. The new test reproduces the real sequence — isError throw, then the index retry — and was confirmed to fail without the fix ('metadata not found in the Figma MCP response') and pass with it.
…y contained Every SVG asset request failed with 'asset export response does not contain the requested binary' while PNG succeeded. The cause was upstream, not local: Figma's remote MCP returns a written PNG back as an image attachment, but does not return a written .svg at all, so the response carried only the descriptor and the bytes never arrived. The instrumented error made this visible in one run — expectedMimeType image/svg+xml against observed [type=text mimeType=<no mimeType> carries=[text]]. SVG is now exported with SVG_STRING and carried inline beside the descriptor, bounded at 12 KiB so it cannot overflow the text-response limit. The payload search accepts a text payload as well as base64 and steps through the JSON encoding of a text block to reach it, mirroring what find_descriptor already did. Verified end to end against the real file: 167 bytes written to disk with a sha256 matching the descriptor. The missing-payload error now reports the content shapes and mime types the response did carry, so 'nothing came back', 'wrong mime type' and 'a field this search does not read' stay three distinguishable failures rather than one opaque sentence. Server instructions gain four rules the last round of testing showed were needed: the generated component name and the asset paths are starting points rather than contracts, a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist yet, and resource delivery is preferred over inlining bytes in every response.
discover_asset_manifest registered only leaf VECTOR-ish nodes and every IMAGE fill, so the node the generated code actually references was never offered for export. Exporting the kakao icon returned a 2x2 fragment of one inner vector instead of the 20x20 icon, and the /icons/kakao-talk_2111496 1.svg path the code emits could not be produced at all. The plugin's checkAssetNode rules are now ported and the snapshot is walked top-down, stopping at the first node that classifies as an asset so a container wins over its fragments. Verified against the live file: the manifest yields exactly 3997:46298:node and exporting it produces the real 2071-byte icon whose sha256 matches the descriptor. The real-screen source map golden changes accordingly: nested instance leaf vectors such as I3879:35525;17:2032:node are replaced by their enclosing containers.
Every generated text carried overflow="hidden" and textOverflow="ellipsis", claiming a truncation the designs never asked for. The check reads Figma's own textTruncation and only skips when it says DISABLED, which is right — but the field was missing from the collected field manifest, so it always read nothing and nothing is not DISABLED. The upstream fixtures show the rule itself is sound: wherever they carry the field the plugin follows it exactly, DISABLED emitting no ellipsis and ENDING emitting one. Only the three synthetic cases that omit the field entirely rely on the absent-means-on reading, and a real export never omits it. Collecting the field is therefore the whole fix, and all 268 goldens stay byte-identical. maxLines is collected alongside it, for the line clamp that reads it and had the same gap.
The frame being exported carries the width the designer drew at. Restating it pins the generated screen to a device size that does not exist, and the code already knew this: a frame whose parent is a page, section or component set is treated as a canvas and keeps its dimensions to itself. That test could never pass for the thing actually being exported. A root's parent lies outside the collected subtree, so looking it up found nothing and the frame read as having no parent at all. The nodes now carry their parent's type, and the decision falls back to it. Only a root records it. Every other node's parent is collected and can be read directly, and adding it everywhere grew the payload by six kilobytes — enough, on this screen, to push the response over the threshold into chunked delivery. A child that happens to span the full width still states it: 360px on the header is a measurement, on the screen it is the canvas. Both are pinned by tests, along with the unattributed case the upstream fixtures rely on, and all 268 goldens stay byte-identical.
…ed name
Every image fill resolved to a single hard-coded /icons/image.png. That lost
three separate things at once: a raster was pointed at the icon folder,
unrelated images from different nodes all claimed the same file and overwrote
one another on disk, and two fills on one node produced the identical URL
twice, so a layered background repeated one picture. On the book cover screen
three distinct images collapsed onto that one path.
A fill now names the node it came from, matching the /images/{name}.png the
<Image> element already emits so the two agree on the same asset. Past the
first fill the index is appended, because the manifest identifies a fill as
{nodeId}:fills:{index} and a caller has to be able to tell them apart. The
paint loop keeps each paint's original index for that: CSS layers run back to
front, and a reference built from the reversed position would name the wrong
asset.
This is a deliberate departure from the pinned plugin corpus, made on the
owner's instruction: the shared name was a limitation at the time rather than
an intent. Two goldens encoded it and are updated with their manifest
checksums, the only fixture change and two lines of it. The remaining 266 are
untouched.
Insets measured from a child's position carry the arithmetic's noise. A 20px badge around a 14.285714px icon leaves 2.857142686 on one side and 2.857143163 on the other, and comparing those as raw floats found them different — so a padding that is one number was written out as four separate sides. Both round to 2.86px, which is what a reader sees and what the plugin emits. The comparison now runs on the values as they will be written, so equal sides collapse into p, py or px again.
An absolutely positioned child needs a positioned ancestor to resolve against, so a frame containing one is given pos="relative". A frame folded into a single asset has no children left in the output — they are baked into the exported icon — so the containing block was established for no one. The clear button on the book cover screen carried it for vectors that never render. Reuses the asset decision the codegen already makes, rather than restating when a subtree collapses.
A frame without auto-layout places its children itself, and pos="relative" kept them resolvable. Deriving the gap around them as padding now puts them where they belong on its own, so on the book cover panel the anchor was left establishing a containing block nothing resolves against. It is still needed wherever nothing could be measured — a child that fills its frame exactly, or one carrying no position at all. An upstream golden covers that case and is what distinguishes the two: the anchor is now conditional on the inset being unmeasurable, rather than removed outright. All 268 stay byte-identical, and both halves are pinned by tests.
…purpose A screen's own width and height are deliberately left unsaid, so the result is not pinned to the size it was drawn at. The fidelity report went on counting them, and every screen reported a layout shortfall for the one thing the generator declines to claim: the book cover sat at 121/122 and the two form screens two short each, with the roots' own dimensions named as unmet. The report already excluded these for a component set's children. It now reads the same parent types the layout pass does, including the recorded parent type a root carries, so the two agree on what a canvas is.
…e code reads A component set's default variant is chosen by matching its name, and the registration output carries it. Neither could work against a live file, because defaultVariant was never in the collected field manifest — the read always found nothing. The pinned corpus carries it, so all 268 goldens passed while the live path silently took the fallback. This is the second time today the same shape appeared: text truncation defaulted to on for exactly the same reason. A fixture cannot show it, since the capture holds the field either way, so the gap is now an enforced invariant. Every node field read through the typed accessors must appear in the manifest, or be listed as one of the names that never comes from a node — scripts' own additions, envelope records, and the explore path, which reads the node directly. Removing either defaultVariant or textTruncation from the manifest now fails that test, which is how it was checked. Four corpus fields remain uncollected on purpose. layoutAlign is redundant: all 77 STRETCH nodes also carry the layoutSizing fields that are read instead. counterAxisSpacing is 0 everywhere and no layout in the corpus wraps. mainComponentId is read by nothing. fontWeight is left alone deliberately — the segment path supplies it and collecting the node-level value would change which one wins, with no observed defect to justify it.
…nly once A png is an image and an svg is an icon, which is the split every asset reference follows — except pattern fills, which sent both to the icon folder. The corpus only patterns with vectors, so all 268 goldens are unchanged; a test covers the raster case that had no coverage. Separately, an absolutely positioned node was restating its pinned size even where the padding derived from its children already accounted for it: 2.86px around a 14.29px icon comes back to the 20px Figma pinned, and boxSize said it a second time. It is still restated where nothing else would give the box a size, which is the folded-asset case — its children are baked into the exported image and never laid out, so no padding is derived from them either. Both halves are now the one question "was a padding derived", asked in one place so the two answers cannot disagree. That helper first read inferredAutoLayout with is_some, which treats Figma's explicit null for an uninferrable frame as a layout — the modal overlay lost the inset that centres its dialog. It now tests for an object, as the code it replaced did.
Checking a codegen change against a real design cost about fifteen tool calls each time, against a daily allowance of two hundred, and today it ran out mid-session. Five screens are now captured once and replayed for free, which is what makes it practical to see a change against real designs rather than only synthetic nodes. The captures are scratch and git-ignores them: the pinned corpus still decides correctness. With nothing captured the test says so and passes, so a fresh checkout is never blocked on it. It earned its place immediately. Two things surfaced that synthetic nodes and the corpus both missed: The harness first converted with default options and reported forty unaccounted layout facts. The server inlines instances; without that an instance stays a component reference and everything inside it goes unemitted. The harness now converts the way the server does. The remaining two were real: having stopped restating a size that derived padding already accounts for, the fidelity report went on counting that size as unmet. It now applies the same test the emitter does, so the two agree. All five screens replay with every layout fact and every character accounted for.
Exploring from a page id crashed outright: a page has no `visible`, and Figma throws on reading a property a node does not have rather than returning undefined. Past that, a Section is answered with the screens inside it, because converting one whole is too much. A Section holding none — a catalogue of small cases, a page of components, anything not phone or desktop shaped — came back selection_required with an empty list, which tells the caller to choose from nothing and leaves no way forward. The devup-Test file is entirely made of such Sections, so none of it could be reached. Its own children are the honest answer there: they are what the Section actually offers. Both halves are needed, because the script decides what the snapshot carries and the Rust index decides what is offered from it — filtering in one place only would either withhold the data or discard it again. The screen-shape search is unchanged and still runs first, so a Section that does hold screens is answered exactly as before. Verified against the file: the Gradient section went from zero candidates to fourteen, the seven cases and the seven frames carrying their expected code.
Converting a whole Section — the documented way, via allScreens or frameIds — failed every time with "Different snapshot data was returned for the same Figma node". The node was __DEVUP_SNAPSHOT_CURSOR__, each chunk's own pagination state, compared as though it described the design. Two chunks disagreeing on complete, nextOffset and totalNodes is the one thing they are certain to do, so any collection arriving in more than one chunk was refused. The merge now passes over it, and the check that remains is about real nodes. That check also says which node and which fields disagree: without naming them there was nothing to act on, and it named this one immediately. parentType is now keyed on the parent's type rather than on being a requested root. A multi-root collection is split into batches with different root sets, so the same node would carry the field in one batch and not in another and be rejected as two different nodes. Keying it on the parent means a node looks the same however it is reached, and only frames sitting on a page, section or component set carry it at all — which is the only case that reads it. Verified against the devup-Test file: the Grid and Gradient sections now collect, where both previously failed outright.
The devup-Test file writes, beside every case, the devup-ui it is meant to produce. That is ground truth of a kind the pinned corpus cannot be: the corpus records what the plugin did, this records what the case is for. Captured sections are replayed offline, so the comparison costs no allowance. Run against the Gradient section it reports seven differences and none of them is a defect, which is the point of reporting rather than asserting. In every case the corpus holds exactly what we emit: -47deg where the note reads 313deg, 43% 21% where it reads 33.84% 33.84%, conic stops in percent where the note uses degrees. Those are the same gradients said two ways, and the shapes really do clip, so the overflow the notes omit belongs there. Normalising toward the notes would have broken three goldens and moved away from the reference implementation. The header says so, so the next reader checks the corpus before treating a difference as something to fix.
Screen shape is a guess for finding screens on a page that has no grouping. A Section is grouping, already explicit, and the guess applied there answered with whatever happened to measure like a phone. A Section of small cases annotated with tall notes turns it upside down: the notes pass and the cases do not, so the index offered the notes and hid every case — an answer that looked complete and was not, which is worse than the empty list a Section of cases used to give. What the Section holds is what it offers, so its children now stand alongside the screens found within it. Text lying directly on a Section stays out: that is how a Section is labelled, and content text sits inside a frame. Reading the cases needed the comparison to pair them, and its rule held for one layout only — a case sits above its note in one section and beside it in the next, and it is the root itself as often as it is wrapped in a frame. Pairing by proximity, and reading a lone child as the wrapped case, covers both: four sections instead of one, fifteen cases instead of seven.
…ppens The Section index picks its candidates in the injected script, and the section node it returns carries only those picks as its children. So the fallback added on the Rust side read an already-filtered list and could never widen it: it changed the synthetic fixture and nothing a real file would produce. Both sides now hold the same rule, and the one that runs against Figma is the script. Screen shape is a guess for finding screens on a page that has no grouping. A Section is grouping, already explicit, and the guess applied there answers with whatever measures like a phone. A Section of cases annotated with tall notes turns it upside down: the notes pass and the cases do not, so the index offered the notes and hid every case — an answer that looked complete, which is worse than the empty list a Section of only cases used to give. Text is offered with the rest. Text lying on a Section is usually its label, and that reading was worth making until the file showed a whole section of cases that are bare text sitting straight on the Section, rendered above each note, with goldens converting exactly those nodes. A menu costs a glance when it offers one thing too many and costs the work when it withholds.
Letting every note take whatever case lies nearest let them crowd onto the same one — three notes in the Grid section all read against its first card, two of those comparisons meaningless. A note whose case sits far away did worse and claimed the commentary beside it, so the effect section reported a difference against a paragraph of Korean prose explaining Safari's backdrop-filter. Closest pairs are settled first and each side is spoken for once. Six sections now read cleanly, and the sections captured since — effect and outline-border — say the same as the ones before them: every difference is the note written the way a person would write it, and the corpus holds what we emit. `0px 4px 4px rgba(0, 0, 0, 0.25)` against our `0 4px 4px 0 #00000040`, `3px solid` against `solid 3px`, and the size a shape is drawn at, which the plugin has never stated.
A case is sometimes wrapped in a frame that only positions it and sometimes is that frame, and nothing about the frame says which. Choosing by shape — a lone child means a wrapper — held for the gradient swatches and broke on the clamp frames, which hold one text each and are the case. Every clamp comparison then read a bare Text against a note describing a Flex around it. The note settles it. One that opens a container and puts something inside is describing the frame; a single element is describing what the frame holds. Both readings are kept and the note picks between them. The report also names the node now, which is what let the last difference be explained rather than guessed at: the note beside the clamp cases had paired with the decoy frame the design keeps nearby to show the difference, and the clamp behaviour it appeared to contradict turned out to be exact. Reading the three text nodes directly — maxLines 1 filling, maxLines 2 filling, maxLines 1 hugging — the generated code matches what the section states for each, hug suppressing the truncation just as the note beside it asks. Eight sections and thirty-one cases now, and the answer has not changed: the corpus holds what we emit, down to `<Flex alignItems="center" bg="#50F" gap="10px" p="10px">` verbatim where the note leaves the alignment out.
The Figma desktop app's local Dev Mode MCP was named as a third path beside direct OAuth and the host handoff. Every needs_figma handoff probed for it and reported it, doctor listed it, the catalog-rejected error offered it, and when the port answered the hint said its tools could be used directly without OAuth. It cannot serve devup-mcp at all. It exposes six read tools and use_figma is not among them, and use_figma is what every collection runs on — snapshot, explore, section index, theme all go through it. Its tools take a node id and no file key, addressing whatever the desktop app happens to have open. So the one path advertised as needing no OAuth is the one that cannot complete a single request, and an agent told the endpoint was responding spent its turn finding that out. Silence would have been better than that hint; the hint was worse than nothing because it was confident. Gone with it: the loopback probe doctor ran on every diagnosis, which is now free of network calls entirely. A test holds the contract by rendering both the doctor report and a host-policy handoff and requiring that neither mentions it.
The local Dev Mode MCP was documented as the second of three ways devup-mcp reaches Figma, described as needing no OAuth and behaving the same from any client. It cannot serve devup-mcp at all: it exposes six read tools, use_figma is not among them, and every collection — snapshot, explore, section index, theme — runs a script through use_figma. Its tools take a node id and no file key, addressing whatever the desktop app happens to have open. Two paths now, not three, and the doctor sample matches what doctor returns since the probe it reported is gone. What the local server is, and why it is not a path, stays written down where the third entry used to be, so the next reader does not rediscover it by spending a turn on it.
A collection is a burst. A Section spends five to seventeen calls back to back and Figma meters by the minute, so a large enough target crosses its own limit partway through its own work. The refusal ended the collection there: every call already spent was discarded and nothing came back, which is the worst of both outcomes — the allowance is gone and there is no result to show for it. Collecting one section repeatedly cost the allowance and returned nothing, however long the wait between attempts. The refusal asks to be waited out. It is marked retryable and Figma names the seconds in Retry-After, which the relay does not forward today, so the wait is usually a widening guess instead — and a guess of twenty seconds is enough, because what was crossed is a per-minute line that rolls over on its own. Bounded at three attempts, because an allowance that is genuinely gone must still be reported rather than waited on forever. Auto still does not answer a spent allowance by handing the work to the host: the source a caller asked for is the source that reports.
A section holds more than its cases. It holds commentary explaining a rule and frames kept alongside to show a difference, and either can lie closer to a note than the case it describes. Nearest-first then read a `<Box>` note against a paragraph of Korean prose, and reported the prose as the difference. What a note opens with says what it is describing, so a candidate that renders the same opening tag is now preferred over one that merely sits closer. The Circle section, which puts two lines of commentary between its shapes and their notes, reads its ellipses instead of its annotations. One case there still pairs with commentary and cannot do better: the note asks for `<Image src="....svg" />` where the plugin emits a `<Box>` carrying the svg as a mask. No candidate opens the way that note does, because on that case the note and the reference implementation disagree about the approach — which the pinned golden settles in the plugin's favour, and ours matches it.
The README said devup-mcp always sends `client_name: "devup-mcp"` and never reports itself as another product. The default is `Codex`, and has to be: Figma's allowlist matches the name exactly, `devup-mcp` is not on it, and a name that is not on it is refused with a 403 whose body is the bare word Forbidden. The allowlist table two sections below already records this — Codex and Claude Code answer 200, everything measured answers 403 — so the page contradicted itself, and the half a reader meets first was the wrong half. What that registration means is worth stating plainly rather than leaving to be discovered: Figma attributes it to Codex, not to devup-mcp. That is also the reason the host handoff exists, and why it is the fallback when registration is refused.
The SVG detail section asks for `bg="$primaryBgLight"` and the replay emits `bg="$227"`. That is not the converter disagreeing: `rawSnapshot` carries the snapshot alone, the collected variables and styles are not in it, so a replay has no table to turn `VariableID:.../19:40` into a name with and falls back to the literal colour. devup-mcp resolves them through with_payload_tokens. Worth writing down because the same section settles a real question and the noise sits right beside the answer. It holds two pairs of buttons that look alike and are meant to convert differently — a solid-coloured icon becomes a Box wearing the svg as a mask, a multi-coloured one becomes an Image — and the generated code matches the stated code on all four.
devup-mcp had two ways to reach Figma. Direct authenticates itself; the host handoff asked the caller's own Figma MCP to run each read on its behalf, returning a needs_figma envelope carrying the script to execute and taking the raw result back through devup_figma_continue. It was built for the case where devup-mcp cannot register at all — Figma admits a client_name only by exact-match allowlist, and devup-mcp is not on it — and it was worth having while that looked likely to bite. It does not: the default name is Codex, which the allowlist admits, and that is the deployment this targets. Meanwhile every handoff spends a round trip per call and carries a fifteen-kilobyte script through the caller's context to do what direct does in one hop. The two paths were also measured against each other today and answer the same allowance, since Figma meters per seat rather than per client, so the handoff bought nothing but the ability to run unregistered. Gone with it: the session store and its ten-minute expiry, the tombstones, the result normalisation that repaired envelopes flattened by hosts, the hostRequirement guidance block, and devup_figma_continue itself — a public tool, so this breaks any caller driving a handoff. sourcePolicy keeps auto and direct, both meaning direct, and rejects host. Disconnected now says to run devup_figma_auth login instead of silently handing the work elsewhere. What the module kept is what the direct path always needed from it: the operation a caller asked for, and the reading of a refusal that MCP delivers dressed as success. It is named for that now.
An export gave one projection: every instance expanded into Box and Flex. It is complete and it is unplaceable — nothing in it says that a stretch of the tree is a Header the project may already own, so a caller either re-implements what exists or drops the whole screen into one file. The generator could already do the other reading. inline_instances=false leaves an instance as `<Header />`, and the projection layer simply pinned it to true in all three places, so the second projection existed and had no way out. componentTsx is that projection: request it beside tsx and the same screen comes back twice, once as primitives and once as references. The difference between them is each component's body — what a caller writes into a new file when the component turns out to be missing. A reference also has to resolve, and it did not: the body said `<Header />` while the imports named only devup-ui primitives, which reads well and does not compile. Custom components are now imported one per line from @/components, matching how the plugin writes them. The two partial captures under fixtures/local-screens are removed rather than kept: a truncated subtree cannot account for its own layout, and the replay test is right to say so.
A screen drawn at three widths is three sibling frames in a Section, named for the width they are. Converting one of them describes that width and calls it the screen, when what the caller asked for changes as it narrows — and the answer to how it changes is sitting next to the target, uncollected. When the target is itself named for a breakpoint and its parent is a Section, its similarly named siblings are collected with it. The snapshot script already takes a list of roots, which is how allScreens carries several screens at once, so this decides what goes in the list rather than adding machinery. It also already reads node.parent, so no extra Figma call is spent asking. Narrow on purpose. The target must be named for a breakpoint, and only siblings that are: a Section is equally how a file of unrelated cases is grouped, and pulling in every neighbour there would collect a catalogue in order to convert one square. Also brings the coverage checker back in step with the converter. An out-of-flow node holding children takes its height from what it holds, and `codegen::layout` drops it for that reason; the checker excused it only when padding had been derived, so a header pinned across the top of a screen was reported as unaccounted-for height that the reference implementation does not state either. Assets keep their height: those are drawn at a size and say so.
The plugin was asked for one frame — the desktop width of a notice screen — and answered with four outputs: the frame as primitives, the frame with its instances left as references, the definitions of those components, and all three widths merged. Together they settle the questions this repo was about to answer by guessing, so they are written down before they are lost. Chief among them: component definitions cannot be recovered by diffing the first two outputs, which is how this was going to avoid emitting them. A definition carries the variant union its call site never mentions — `'scroll' | 'transparent' | 'mobileTranspa' | 'mobileScroll'` for a screen using one of the four — along with hover and active blocks and per-variant prop maps. Diffing gives a body and nothing else. Also recorded: the five-slot array and why the reference shows three, the split between subtrees that merge into responsive values and subtrees that are kept whole and toggled with display (with the capture showing why the banner cannot merge), and the one place the reference is not ground truth — component props do not go responsive, which its own author reads as an omission.
The two ways a subtree can differ were written down as though they were equal paths to choose between. They are not. A screen drawn at three widths is meant to be the same tree three times, so merging values into arrays is the whole idea, and keeping both copies behind a display toggle is what rescues an export when the file drifted. The screen says so itself. Three of its four children match in shape across all three widths and merge; the fourth is a banner whose two logos are wrapped in a frame on desktop and left loose on mobile — one intent grouped two ways. That is a slip in the design, not something the screen means to express, and it shows in the output as a desktop wrapper folded into a mask beside two separately placed mobile logos.
…e they part A responsive screen arrives as sibling frames named for their widths, and merging their differing values into arrays is only possible where the trees agree in shape. This finds out: it pairs the roots up narrowest-first, walks them together, and names every place they stop agreeing. Reporting is the point rather than a by-product. Widths of one screen are meant to be the same tree three times, so a place where they are not is usually a slip in the file — this screen's banner wraps its two logos in a frame on desktop and leaves them loose on mobile. The export can only carry that by keeping both copies and showing each at its own widths, which looks like success and hides the thing worth fixing unless it is said out loud. Instances are not walked into. A component drawn for several widths carries its own variant for each — the header is `transparent` on desktop and `mobileTranspa` on mobile — so its insides differ by design, and the reference keeps one `<Header />` rather than merging what is behind it. Descending here reported six differences that were components doing their job, which is how this rule was found rather than assumed. Nor does it walk below a shape that already parted company: every descendant would be named for the same reason, burying the one place to look at. Against the notice screen this leaves four, in the two regions the reference keeps twice — the banner, and three shapes inside the content section — and nothing from the header or footer.
The plugin was asked for one frame — the desktop width of a notice screen — and returned four things. Two are kept here: the merged responsive output, which is the only account of how breakpoints are supposed to come together, and the same frame with its instances left as references. They existed nowhere but a chat window, and the work that needs them has not started yet. Named for what they are. Calling this a reference invites the next reader to chase parity with it line for line, and its own author does not vouch for every line: `<Footer property1="desktop" />` stays desktop at every width though the component admits mobile and tablet, which he reads as the plugin never having implemented responsive component props. The README says so, next to the one other place the evidence itself invites doubt — a banner kept twice because the design grouped its logos two ways, which a design whose widths agree in shape would never produce. Where these and the pinned corpus say the same thing, that is two independent accounts and the bar to differ is high. Where they disagree, it is a question, and the answer belongs in writing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this brings
The
directpath — devup-mcp talking tomcp.figma.comitself — now completes a fullURL → devup-ui TSX conversion with no host Figma MCP and no agent relay in the loop.
Verified end to end against a real screen (
3997:48764, 53 nodes):and again on a second screen (
3997:46156, 48 nodes) with the same 100% fidelity.Commits
chore: ignore local agent state directories.omc//.omo/are machine-local session staterefactor: emit every diagnostic and guidance string in Englishfeat(figma): make the direct Figma OAuth path work end to endfix(figma): tolerate a re-serializing relay when decoding upstream resultsThe three auth defects
client_name. Figma gates/v1/oauth/mcp/registeron anexact-match allowlist and answers anything else with a plain-text 403. The name is now
configurable (
--figma-client-name/DEVUP_FIGMA_CLIENT_NAME) anddoctorreports theactive value, so a 403 is distinguishable from a network fault.
client_secretwas discarded.RegistrationResponsedid not evendeserialize the field. Figma advertises only
client_secret_basic/client_secret_post, sothe token exchange returned a bare
400after registration and browser consent had bothsucceeded. The secret is now stored beside its
client_idand used for the code exchange andfor refresh.
auth_network_errorthrew the cause away, so every failure looked identical withdetails: null. It now carries kind/status/url/cause-chain, with the URL reduced toscheme+host+path so a query string cannot carry a code or token into a log. Fixing this is what
made defect 2 findable at all.
The two decoder assumptions
get_metadatais no longer bare XML — Figma prepends aCurrently selected nodes:block whenthe queried node is selected, and appends an instruction footer. Requiring the text to start with
<broke the entire legacy metadata path in that very common case.integrity.utf8Bytesto equal the received byte length, i.e. abyte-exact relay. Truncation is already caught by JSON parsing plus the
nodeCount/resourceRefCount/validate_resourceschecks, which read content rather thanits serialized form, so the byte comparison only produced false negatives.
Verification
cargo fmt --all -- --checkandcargo test --workspaceboth exit 0 — 394 tests, including8 new ones (5 metadata preamble/footer, 1 re-serialized-envelope acceptance, 1 size boundary,
1 DCR-secret regression covering both exchange and refresh).
Beyond the suite: the release binary was installed and driven over real stdio JSON-RPC —
login→connected, then two live screens converted through the direct path.Note for the reviewer
README.mdis currently empty in the working tree (344 lines deleted). That deletion is notpart of this PR and was left unstaged deliberately. Four comments still reference its
"Figma 연결 설정"section and will dangle until it is rewritten.