feat(figma): make the direct Figma OAuth path work end to end - #4
Merged
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.
Contributor
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
Patch
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.
Rendering the generated screens against Figma's own PNGs turned up four ways the code and the bytes disagreed about a picture. A layer name is the file name, as the plugin names it, and a designer names three logos 'Logo'. Named that way one file serves every node that shares the name: on the notice screen eight nodes claim one file that holds five different drawings, and a photograph drawn at three widths keeps whichever width was exported last, so at the other two it is the wrong size for its box and the browser stretches it into place. `assetNamesPerNode` names each asset after the node it came from instead. It is off by default, so what the generator writes is the plugin's own naming and the goldens are untouched. A cropped fill carries its crop as a matrix over the image's own 0..1 space. It was painted `center/cover`, which shows the whole picture rather than the part the designer framed; the crop is now read off `imageTransform`. No golden emits a url for a cropped fill, so this costs no parity - the about answer differs on purpose, with the reason recorded. An image fill on a layout box could not be named at all: `asset_path` only answers for a node the code draws entirely from a file. `image_fill_path` names one from the node and which fill it is, so the manifest can say where those pictures go. A layer named 'ic:round-arrow-left' is a file name Windows refuses, and the export was refused with it. The names are made writable at delivery, where the code and the manifest are renamed together so the two cannot drift apart; the generator keeps the plugin's name. Where two different drawings still claim one file the first is written and the rest are reported as `DEVUP_ASSET_NAME_SHARED`, rather than refusing the whole export. The style pass now carries its variable tokens and the naming option together, so threading the option did not push three signatures past clippy's argument ceiling.
Comparing the generated code line by line against the plugin's answers says the two agree; it cannot say either one draws what Figma draws. This builds each acquired screen with devup-ui, opens it at the frame's own size, and compares the capture with the PNG Figma renders of that frame. `scripts/acquire.py` fetches what a screen needs from a running devup-mcp - the module, its theme, every asset it points at, and the reference PNG - banking the Figma calls so a re-run costs nothing already paid for. `scripts/render.mjs` builds, captures and compares. Each screen is rendered with its own theme, taken at node scope. One theme for every screen has to pick a winner where two of the file's collections both define `primary`, which drew the notice screen violet where Figma draws it blue; scoped to the node the same token resolves to that screen's own value. Devup UI bakes the theme in at build time, so screens are grouped by the theme they need and each group is built once. The reset is `@devup-ui/reset-css`, the one the generated code is written against. Guessing at one got the two things it settles wrong: the root line-height is 1.5, not the browser's own, and images stay inline on the baseline rather than being forced to blocks. `bands.mjs`, `drift.mjs` and `elements.mjs` say where a difference is, whether it is a shift or a real difference, and what size each picture actually came out at - a tall screen shrunk to fit a screenshot shows nothing. `probe_manifest_gap.py` checks the invariant that every asset the code points at is one the manifest lists.
The manifest promised an export Figma would always refuse. A node is unexportable when it draws no pixel, and the check for that only asked whether the node was hidden - so two icons left at zero opacity were advertised as available, and the caller learned otherwise only when the refusal surfaced from inside Figma, one asset request at a time. They were the only two exports Figma turned down across every screen the render harness acquires, and the only two nodes in that file at zero opacity.
A node set to fill its parent's main axis is stretched by Figma to the space left over. Left unsaid, CSS lets it hug its content instead, which usually agrees - a column of in-flow children adds up to the height Figma gave it. A positioned child adds nothing to the height of what holds it, so there hugging can never reach it. The about page's hero column is 440 tall in a 520 tall section and came out 155, the height of its text alone. The section centred that, pushing the column 143px down and dropping the picture hung off it over the heading it is meant to sit above. Rendered against Figma's own PNG the screen moved from 13.79% to 11.26% different, with every other screen unchanged. Only a node holding a positioned child is affected, which is why the plugin corpus is untouched: the one golden that pairs a filled height with a fixed parent fills it from in-flow children, and hugging already reaches its height. README documents `assetNamesPerNode` alongside this, and `scripts/boxes.mjs` reports the DOM boxes down a screen - which is how the 143px was found.
A tall screen shrunk to fit a screenshot shows nothing. `bands.mjs` writes crops of the bands that differ most, which is the wrong slice when the question is about a band that is only third worst - the about page's paragraph wrapping, or the join where its height went wrong.
…ched Every figure here was won against a particular defect - a theme resolved at the wrong scope, a picture stretched into a box it did not fit, a column that hugged its text where Figma filled a section. Nothing stopped the next change from giving one back quietly. Each screen now carries the most it may differ. A run that exceeds it fails; a run that comes in under it says so, which is the cue to tighten the figure. Verified both ways: tightening one screen's figure fails the run, restoring it passes. The popup-answer-* entries are the plugin's own answer rendered the same way. They are a baseline to measure against, not a screen to improve.
… lists The code refers to an asset by path; the manifest is what tells a caller to export it. A picture the code points at that the manifest does not list can never be delivered, and the screen renders with a hole where it belongs - which is what happened to five photographs on the about page, painted as backgrounds on layout boxes the asset walk stepped straight past. Nothing was checking the two agreed. Across the 37 captures, 446 generated modules, both with the asset naming per node and without, sixteen pictures are still unlisted: * a raster painted from a PATTERN fill, which the code sends to the icon folder and the walk does not visit at all; * a vector shape - ellipse, star, polygon, plain vector - drawn from a file the walk never reaches, because an ancestor answered for the subtree while the code named the child. Those are recorded per capture rather than hidden. A capture that grows a new one fails; one that loses a gap says so, which is the cue to lower its figure. The captures are not committed, so the test says what it checked instead of pretending to have checked.
…ove with it Re-acquiring every screen with the asset naming per node turned on changed the responsive popup module: where it pointed all three widths at one file, it now writes an array with a file per width, and the manifest and the code agree on all six. The gate caught what that cost - popup at 390 went from 3.53% to 3.59%, in the bands the icon occupies. Each width now draws its own export rather than whichever width happened to be exported last, which is the more faithful thing to do even where this one width's difference ticks up. Recorded rather than absorbed silently, which is what the gate is for.
The test added in c3c92ca reported sixteen pictures the manifest did not list, and its message named two causes: rasters painted from a PATTERN fill, and vector shapes an ancestor answered for. Both were wrong, and so was the debt list recorded alongside them. Some of these captures are documentation screens: they display JSX samples as text, naming files of their own. The generator escapes the brackets of such a sample, and the reader walked straight past that and lifted the paths out of text the screen merely prints. Reading the generated lines showed it plainly - a caption showing an Image tag with a src of /icons/image.png is a caption, not a reference. Skipping escaped lines, the count across all 37 captures and 446 generated modules is zero. There was no debt: the one real gap, the about page's five photographs, was already closed by listing the pictures a container paints itself. The baseline is gone and the test asserts what it should have asserted from the start.
Comparing the generated code with the plugin's answer line by line says the two agree; it cannot say either one draws what Figma draws. The README now says what the harness does, how to run it, why each screen is built with its own node-scope theme, that the reset is the one the generated code is written against, and what each screen currently measures against Figma's own PNG. It also lists what the harness found, since that is the argument for having it: theme scope, a container's own pictures missing from the manifest, the crop matrix of a cropped fill, layer names no file system takes, one file shared by widths that need different sizes, a fully transparent node's export, and a height CSS cannot reach past a positioned child.
A positioned frame is out of flow, so its children size it in CSS. Where Figma pinned its height, the two disagree: the notice header is 60 tall around a 24px row of logo and menu, and centring them in 24 rather than 60 put the whole row 18px high of where Figma draws it. On the desktop frame the same header is 80. The height was already restored for a frame pinned on both axes. A frame that fills its parent's width is given one by the width branch, so that guard never fired for it - this adds the height alone for exactly that case. An asset is left out, as it already was: it has no children to measure, and two goldens carry a full-width rotated mask that wants its height unsaid. A frame whose spare room became padding is left out too, since the padding already adds back up. The plugin corpus is untouched at 268 of 268; the notice answer differs by this one line, recorded with its reason. Measured against Figma's own PNGs: notice 8.29 to 7.36 at 360, 4.19 to 3.24 at 992, 2.33 to 2.15 at 1920, with every other screen unchanged.
The generator writes a text node's characters into JSX, and JSX has rules of its own about whitespace: a run of text broken across two source lines is joined with a single space. Where the design has no space at that point, the screen prints a word the design does not contain, and the paragraph wraps somewhere Figma never wraps it. That is what put the about page's paragraph onto an extra line and cost 35px of layout. Reading the JSX to work out what it renders means reimplementing those rules, and guessing at them is exactly how the asset check invented sixteen faults that were not there. The browser already implements them, so this asks the rendered page: every characters string of every text node under the frame has to appear in what the page prints. Validated both ways before being trusted. The notice screens, where no such join occurs, report nothing at all three widths. The about screens report exactly one text each, and it is the paragraph already proved wrong by pixel comparison - node 422:3496 at 360, 422:3301 at 992, 422:3107 at 1920, all the same paragraph. Three of 245 texts across the corpus. That is the size of the problem on real production screens: one paragraph, not a widespread fault.
Named after their layer, as the plugin names them, one file serves every node a designer gave that name. That is a loss wherever the two are not the same picture: eight nodes on the notice screen claimed one file holding five different drawings, and a photograph drawn at three widths kept whichever width was exported last, so at the other two it was the wrong size for its box and the browser stretched it in. The option to name each asset after its own node has been there since 34dc2d6, off by default, which meant a caller only got correct files by knowing to ask. Rendering says which default is right: notice at 992 went from 6.48% to 4.19% different from Figma's own PNG with it on, about at 992 from 10.79% to 6.94%. It costs no parity. The plugin corpus drives the generator through CodegenOptions directly, and that library default is unchanged, so all 268 goldens pass. Only the tool's request default moves; assetNamesPerNode false still gives the plugin's naming byte for byte. The harness no longer asks for it either, so the figures it reports are what a caller actually receives. Every screen measures the same as before and every asset the generated code points at is still one the manifest lists.
The changepack-required gate only checks that a pull request carrying crate changes also carries a changepack log, and this branch already carried two - so the gate passed while neither log said a word about this session's work. The release notes would have shipped a public behaviour change, assetNamesPerNode on by default, without mentioning it. devup-mcp and devup-mcp-devup-ui take a Minor bump: a new request option that changes what every caller receives, a new diagnostic, a new public naming function, and two layout fixes. devup-mcp-figma takes a Patch: the manifest lists what it should always have listed, a transparent node is no longer advertised as exportable, and an empty variable snapshot is an empty theme rather than a refusal.
Figma's remote MCP returns a written PNG as an attachment only up to about a megabyte once base64-encoded. A 665 KB photograph came back; the devup-ui landing page's hero, 950 KB at 1232x1232, was written, reported exported, and never arrived - the descriptor said exported and the answer carried text alone, so the manifest ended with DEVUP_SNAPSHOT_UNSUPPORTED and the screen rendered without its largest picture. Past 768 KiB - exactly one MiB encoded - the export script now announces the PNG as chunked with its length and hash, the same way an SVG over the inline cap is, and the collector reads it back through the large-value script under `$export:png@<scale>`. The scale rides on the field so the re-export behind each fragment is the same bytes that were announced, hash for hash; the assembler already checks both. A PDF is still refused where the announcement is read.
… lacks A frame that draws nothing of its own and holds a single picture is that picture: both the code generator and the plugin name the file after the frame and collapse the child away. Discovery agreed the frame was the asset but kept the child's fill index, so it listed `<frame>:fills:0` - a fill the frame does not have. Figma answered every such export with DEVUP_ASSET_SOURCE_CHANGED, because the fill it was asked to check was not there. The devup-ui landing page's footer logo sits in exactly such a frame and was the one asset of 182 that never arrived, leaving the footer pointing at a file nothing could ever write. Such a frame is now listed as the node itself, which renders to the same picture and is a request the node can answer. The name the code writes is unchanged - it was always the frame's - so no generated file moves. `source_kind` is now derived in one place from what the request already says, since the entry an export produces replaces the one discovery listed and the two have to agree: a fill index is `image-fill`, a node with an image behind it `image-node`, and a node without `vector-node`.
…arent Figma's default counter-axis alignment is MIN, and it writes that by leaving the field out. CSS's default for the same thing is `stretch`, its opposite. So a child that hugs across its parent's axis was drawn as wide as the parent unless something else happened to settle its size: the devup-ui landing page's `Get started` button, 247px in the 1360px column that holds it, was drawn 1360px wide, a black bar across the hero. The plugin has the same gap. Most hugging children do not care, and saying it for all of them would bury the few that do - the notice desktop alone holds twenty that would look identical either way. So the alignment is written only where the wider box would show: the node is drawn narrower than the room it has, and it either paints across that box - a fill, a stroke, a shadow - or places its own content by it. Which of `primaryAxisAlignItems` and `counterAxisAlignItems` answers for the stretched axis depends on which way the node itself runs, so both are read. Text is left alone. A centred line would move, and the pinned corpus holds three such texts, but no screen in it renders one - so there is nothing to show it helps, and it would cost byte parity on all three. Rendered against Figma's own PNGs: the landing page goes from 4.16% to 3.11% and about at 360 from 11.26% to 11.25%, with the other ten screens unchanged to the hundredth. Plugin parity holds at 268 of 268. Three `Center` lines on the about screen and eight `VStack` lines across the WQUW-151 frames now carry the alignment; the about ones are recorded with their reason.
The first answer that is not from `devup-Test`, and the first with a third source to check against: the design, the plugin's six outputs, and the site actually deployed from `dev-five-git/devup-ui`. Frame 793:6361 in its own file, 1920x2746, 216 nodes, 183 assets. It has already earned its place. Two of its assets could not be fetched at all - the 950KB hero, past what Figma returns as an attachment, and a footer logo asked for by a fill index its frame does not carry - and neither failure is visible in the code the plugin writes, which names the files and stops. Its `Get started` button, 247px wide, was drawn 1360px wide by both this repo and the plugin. All three are fixed; the README says what else the page has settled and what is still open. `acquire.py` learns that a target may live in another file, so a frame's URL is built from its target rather than from the one file key the harness had assumed. `probe_refusal.py` asks for a single asset and prints what the answer actually carried, which is how the two transport failures were told apart.
…wing line Figma keeps a fixed-size child at its size and lets the line spill past the frame, which clips it. CSS shrinks flex children to fit instead, and the two only agree while the children fit. The devup-ui landing page's comparison row is seven 240px cards in a 912px frame - 1,800px of content - and every card was squeezed to about 120px, its `Bulid Time` label wrapped to two lines, and the row came out 58px taller than the design, carrying everything below it down the page with it. `flexShrink="0"` is written for a fixed child, and only where the line actually overflows: children that fit are not shrunk by CSS either, so saying it for every fixed child in the file would be noise. Out-of-flow and undrawn children take no room in the measurement, as they take none in either layout. Rendered against Figma's own PNGs, the landing tablet goes from 11.60% to 5.55% - its height error from 58px to 6px - the landing mobile from 11.64% to 11.56%, and the about desktop from 4.54% to 4.42%. The other screens are unchanged. Plugin parity holds at 268 of 268; the two about lines that now carry it are recorded with their reason, and nine WQUW-151 snapshots gain one line each.
A node pushed entirely outside an ancestor that clips is visible and opaque and still draws nothing. Figma says so by leaving `absoluteRenderBounds` off it - that field is the bounds of what the node actually renders, and there are none - and it refuses to export one. The manifest offered it anyway, so the refusal arrived from inside Figma after the request, and because an export call carries up to sixteen assets, one such node took the other fifteen down with it. This is the same fact the hidden and fully transparent cases already report, and it is reported the same way: the entry stays in the manifest, so code pointing at it is still accounted for, but it says up front that it cannot be exported. The devup-ui landing page's mobile and tablet each carry one such icon, pushed past the edge of a clipped panel; they were the only two exports of 215 that Figma turned down, and both frames now collect without a single refusal. All eighteen screens render identically.
…ally has The section was written when only one frame was known, and named a PC frame that is not the one the harness reads. It now names the mobile, tablet and PC frames the user identified, records the two layout facts and the third transport failure the page has since settled, and says plainly that the six plugin files do not line up with these frames - pure-mobile.tsx has no join-us section where the frame does - so a difference against them is a question and the render is the judge.
A shadow's colour can be bound to a variable exactly as a fill or a stroke can, and then the token is what the design means. The landing page's GNB and its benchmark card are both `$shadow`, one value the theme can move for dark mode; they were written as the resolved `#87878740`, a colour nothing could reach. The plugin's answer for the same frame writes `$shadow`, and writes the raw `#8787870F` for the feature cards, whose shadows are bound to nothing - which is the rule, and is now what both sides say. The colour is read with the same `bound_paint_token` a stroke uses, and the token joins the used set so the theme keeps it. The pixels do not move - the token resolves to the colour that was already there - so this is a correctness fix the render cannot show, like the gradient alpha the report screen found. All eighteen screens render identically and plugin parity holds at 268 of 268. `pure-pc.tsx` is replaced with the plugin's answer for 832:2975, the frame the harness actually reads; the README now says which of the six answers is current and which are still from an earlier state of the file.
… them The devup-ui landing page's join-us panel holds a group of ten circles - concentric arcs, three coloured badges - pinned to the card at -277,-187. None of it was drawn. Three faults stacked up, and the plugin's answer sidesteps all of them by folding the whole group into one SVG. The group is `ABSOLUTE`, and `placed_by_a_free_layout` asked the parent to be `AUTO` before placing its children, so they were read as being in flow, stacked from the group's corner, and were clipped away. Whether a parent is itself placed or pinned says nothing about whether it lays its children out, so the free-layout decision now reads the parent's layout alone; `lays_nothing_out` still answers for a node about itself, where being pinned does matter, and is left as it was. Once placed, the group was told `pos="relative"` for holding positioned children - over the `absolute` it already had - and went back into flow, 1,102px of it. A node that is itself positioned is already the containing block, and is not told again. A group's children carry `x` and `y` in the group's parent's space: the outermost circle, which is exactly the group, reads `-277,-187` where the group's own space would say `0,0`. Placed as read, every circle sat 277px left and 187px high of Figma. The absolute boxes settle it, as an asset's export already did. And a shape in a group is its own size: `h="100%"` with no width, the plugin's rule for a positioned shape, is no circle. That rule is as wrong for a small shape pinned in a frame, and the pinned corpus holds four; no rendered screen shows one, so it keeps byte parity until one does. Rendered against Figma's own PNGs: the landing PC goes from 3.22% to 3.00% and the tablet from 5.55% to 5.28%, with the arcs and badges now where the design has them. The mobile goes from 11.56% to 11.82%: the badges it now draws sit above the buttons where Figma has them behind, because a positioned element paints over in-flow siblings - a stacking fault the plugin's answer shares, recorded and left for its own change. The other fifteen screens are unchanged and plugin parity holds at 268 of 268.
… frames Pure Code and the component-referencing output at mobile 833:3640, tablet 833:3322 and PC 832:2975, pasted by the author from the plugin. The earlier six were for another state of the file and never lined up with the frames the harness reads.
Figma paints children in order, so a pinned picture drawn first sits under everything after it. CSS paints a positioned element after every in-flow sibling whatever the order. The devup-ui landing page's hero picture came out over its headline at tablet width and the join-us badges over their buttons at mobile - and the plugin's answer has the same fault, as does every pinned background in the pinned corpus. Such a child is sent behind with `zIndex="-1"`, inside a stacking context its parent opens with `zIndex="0"` alongside the `relative` it already has, so it clears the parent's own background and rests under the content, where Figma has it. Two limits, both found by measuring. `-1` goes behind every in-flow sibling, not only the later ones, so it is written only for a child with nothing in flow before it: the notice page's header is pinned second, after its banner, and sent behind it vanished under the banner it sits on - 7.36% became 7.82% at mobile. And a page root opens no stacking context, so a child of the root is left alone, where `-1` would drop it behind the root's own background. Rendered against Figma's own PNGs: the landing mobile goes from 11.82% to 11.37% and the tablet from 5.28% to 5.05%; the other sixteen screens are unchanged. One golden in the pinned corpus moves, by two added lines, and its manifest checksum with it - the report page's rotated background, which the plugin draws over the page's cards. The corpus has been moved this way once before, for the same kind of reason, and `DEVUP_FIXTURE_UPDATE=1` is now the one way to move it, so each such change is a reviewed diff. The about and report answers record the lines.
Figma writes a node's opacity into its export: into an SVG as `<g opacity>`, and into a PNG's alpha - the landing page's hero, drawn at 0.8, exports with its opaque pixels at alpha 204. The code wrote the same opacity on the element as well, so an asset was faded twice. The benchmark card's lavender decoration at 0.2 came out at 0.04, which is nothing, and the mobile hero at 0.64. The plugin writes it twice too. An asset's opacity is now left to its export. A node that is not an asset still carries its own. Rendered against Figma's own PNGs: the landing tablet goes from 5.05% to 4.90% and the mobile from 11.35% to 11.30%; notice at 360 and about at 992 each move a hundredth in the same direction; the other fourteen screens are unchanged. Five goldens in the pinned corpus lose the second opacity - three PNG images at 0.8, two masked backgrounds at 0.2 - and their manifest checksums move with them; the about answer records the lines the plugin still writes. Along the way: a filling child whose content is wider than its share gets `minW="0"`, so `flex: 1` can give it exactly the share Figma gives it and let the content spill as Figma draws it. CSS will not shrink a flex item under its content, so a 132px row inside a 123px share widened the share and took 9px from the hugging sibling, whose `Devup-ui` broke across two lines. Written only where the snapshot shows content wider than the box. And `probe_stack.mjs`, which asks the browser what is on top at a point and how an image's ancestors stack - how the doubled opacity was told apart from a stacking fault.
Figma paints an inside stroke over the padding: a card 20px in from its edge with a 1px stroke inside is still 20px in, stroke and all, and its height is the content plus 40. CSS adds a border around the padding instead, so every such card came out 2px taller and its content sat 1px further in. The landing page's four feature cards put the join-us panel 8px down at every width, and the benchmark and star cards added their own; the plugin writes the same border beside the same padding. The stroke now comes out of the padding, on every axis: the content then starts `p` in, as Figma has it, and a hugging box is content plus `2p`. Only an inside solid stroke on a node that is not a line, which is the one case the two models disagree on. Rendered against Figma's own PNGs, all three landing pages come out at exactly the height Figma drew - 2955, 2964 and 3084 - where they had been 3 to 6px over. The tablet goes from 4.90% to 3.10% and the PC from 2.45% to 1.84%; the other fifteen screens are unchanged to the hundredth. The mobile goes from 11.30% to 11.41%, and that is the fix working. Its join-us panel is 18px short of Figma because the Discord line is drawn with the `Desktop/textL` style on the 360 frame - 18px, three lines - where every other mobile text uses `Mobile/textL`, and both collapse to the one `textL` token, which is 16px at that width: two lines. That error used to be cancelled by the +11px the cards added above it. The plugin writes the same token; reproducing a design slip would mean a hardcoded size beside a token, so it is recorded and left. Three goldens move by the stroke, and their manifest checksums with them: a card's `py` from 30 to 29, a box's `p` from 8 to 7, and the button set's `px`, where `white` is the one variant with a stroke and is now padded a pixel less - which also turns `tag` into a map. Ten WQUW-151 snapshots move the same way, all by a 1px or 2px stroke. Along the way, `sections.mjs`: the DOM's section heights beside Figma's, level by level, which is what said "the features section is 11px tall" before any crop was looked at.
… is left Six findings the answers helped settle, each one a thing the plugin does the same way, and the one that is left: a mobile text drawn with the desktop style, which the token cannot say and a hardcoded size should not.
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.