Skip to content

feat(figma): make the direct Figma OAuth path work end to end - #4

Open
owjs3901 wants to merge 69 commits into
mainfrom
owjs3901/project-groundtruth
Open

feat(figma): make the direct Figma OAuth path work end to end#4
owjs3901 wants to merge 69 commits into
mainfrom
owjs3901/project-groundtruth

Conversation

@owjs3901

@owjs3901 owjs3901 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What this brings

The direct path — devup-mcp talking to mcp.figma.com itself — now completes a full
URL → 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):

status: complete   source: direct   transport: text-paginated   fallbackUsed: false
fidelity: nodes 53/53 · text 8/8 · variables 18/18 · typography 8/8 · assets 21/21 · layout 69/69
impacts: none:0 approximated:0 lossy:0 failed:0     failures: []

and again on a second screen (3997:46156, 48 nodes) with the same 100% fidelity.

Commits

Commit Why
chore: ignore local agent state directories .omc/ / .omo/ are machine-local session state
refactor: emit every diagnostic and guidance string in English These go to an LLM over MCP, where Korean costs several times the tokens. String literals only
feat(figma): make the direct Figma OAuth path work end to end Three defects that each independently blocked login
fix(figma): tolerate a re-serializing relay when decoding upstream results Two decoder assumptions that no longer hold

The three auth defects

  1. DCR sent a non-allowlisted client_name. Figma gates /v1/oauth/mcp/register on an
    exact-match allowlist and answers anything else with a plain-text 403. The name is now
    configurable (--figma-client-name / DEVUP_FIGMA_CLIENT_NAME) and doctor reports the
    active value, so a 403 is distinguishable from a network fault.
  2. The DCR-issued client_secret was discarded. RegistrationResponse did not even
    deserialize the field. Figma advertises only client_secret_basic/client_secret_post, so
    the token exchange returned a bare 400 after registration and browser consent had both
    succeeded. The secret is now stored beside its client_id and used for the code exchange and
    for refresh.
  3. auth_network_error threw the cause away, so every failure looked identical 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. Fixing this is what
    made defect 2 findable at all.

The two decoder assumptions

  • get_metadata is no longer bare XML — Figma prepends a Currently selected nodes: block when
    the 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.
  • The fast envelope required integrity.utf8Bytes to equal the received byte length, i.e. a
    byte-exact relay. Truncation is already caught by JSON parsing plus the
    nodeCount/resourceRefCount/validate_resources checks, which read content rather than
    its serialized form, so the byte comparison only produced false negatives.

Verification

cargo fmt --all -- --check and cargo test --workspace both exit 0 — 394 tests, including
8 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 —
loginconnected, then two live screens converted through the direct path.

Note for the reviewer

README.md is currently empty in the working tree (344 lines deleted). That deletion is not
part of this PR and was left unstaged deliberately. Four comments still reference its
"Figma 연결 설정" section and will dangle until it is rewritten.

owjs3901 and others added 26 commits September 2, 2026 21:44
…/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.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Changepacks

devup-mcp@0.1.0 - Cargo.toml

Maybe you forgot to write the following files to the latest version

devup-mcp@0.1.0 → 0.2.0 - crates/devup-mcp/Cargo.toml

Minor

  • Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though 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, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens. Fixes SVG asset export, which failed for every request while PNG worked: Figma's remote MCP returns a written PNG as an image attachment but does not return a written .svg at all, so the bytes never reached devup-mcp. SVG is now exported as a string and carried inline beside the descriptor under a bounded size, and the payload search steps through the JSON encoding of a text block and accepts a text payload as well as base64. The missing-payload error now reports which content shapes and mime types the response actually carried, so an absent attachment, a wrong mime type and an unread field stay distinguishable. Adds server instructions covering that the generated component name and asset paths are starting points rather than contracts, that a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist, and that resource delivery should be preferred over inlining bytes.
  • Add a Rust-native private Figma Remote MCP with OAuth and verified official-host fallback; exact-node and full-theme one-call acquisition with JSON-string host normalization, atomic failed-root-only legacy fallback, observable fallback progress and a bounded shared upstream session with safe stage deadlines; bounded, ancestor-preserving linked-screen exploration with executable stress fixtures, refresh control, exact/related/superset cache reuse, compatible-request singleflight and current-versus-origin call diagnostics; cancellation-safe bounded memory-only artifact/resource reuse, URI-accurate native MCP resource links, independently readable binary assets and transactional file/resource delivery with verified rollback and retained recovery backups; transactional host-handoff acceptance with bounded consumed/expired tombstones so an invalid continuation call can no longer destroy a session; Section screen selection and deterministic batch export; syntax-validated DevupUI TSX, devup.json, raw snapshot, source maps, requested assets and strictly decoded bounded reference PNG; source-derived typed fidelity impact requiring zero approximated/lossy/failed impacts for strict compatibility, evidence-based absolute-layout fallback reporting, final-output provenance for non-default variants, one-to-one repeated text coverage and strict reports; a pure Rust PNG comparator plus renderer contract; loss-aware legacy large-field continuation; dirty-aware build identity with a local --self-check diagnostic and a real-stdio smoke test; all ten WQUW-151 screens; 268/268 plugin snapshot parity and a zero-not-ported 978-entry ledger; focused server module boundaries; non-duplicating CI snapshot drift enforcement; Rust 1.88-compatible dependency updates; an opencode-compatible MCP tool schema fix replacing boolean JSON Schema with object schemas across all 7 tools plus a raw-stdio schema regression test; and a further oxc/rmcp dependency refresh with the pinned toolchain raised to Rust 1.98
  • Add three read-only ground-truth tools so an agent can never fabricate a project identifier it never verified: devup_project_context 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), returning a shared {found:false,guardrail:{action:'stop-and-report',...}} envelope instead of guessing when the target file is missing; devup_ui_validate parses DevupUI TSX with the existing oxc_parser/oxc_allocator/oxc_span stack via a new oxc_ast_visit-based walker and flags unknown $token references (with edit-distance-suggested existing tokens), hardcoded hex colors/px lengths that match an existing token, unknown props on Box/Flex/Text/Center/Grid/Image (checked against the published devup-ui Style Props API reference, not invented), and non-static values inside css()/globalCss()/keyframes() calls specifically -- verified against devup-ui's own docs and css-utils-literal-only ESLint rule that plain JSX style props (bg={dynamic}) are valid devup-ui and must not be flagged; devup_stack_diff detects drift across vespertide model -> sea-orm entity -> vespera route -> openapi.json -> devup-api client with every finding carrying an explicit low/medium confidence since none of the checks is a real compiler front end. Regression-tested against the exact incident that motivated this work: three agents independently inventing a $gray100 color token, a 16px bubble radius, and a 36px avatar size that did not exist in the real project devup.json.

devup-mcp-devup-ui@0.1.0 → 0.2.0 - crates/devup-mcp-devup-ui/Cargo.toml

Minor

  • Add a Rust-native private Figma Remote MCP with OAuth and verified official-host fallback; exact-node and full-theme one-call acquisition with JSON-string host normalization, atomic failed-root-only legacy fallback, observable fallback progress and a bounded shared upstream session with safe stage deadlines; bounded, ancestor-preserving linked-screen exploration with executable stress fixtures, refresh control, exact/related/superset cache reuse, compatible-request singleflight and current-versus-origin call diagnostics; cancellation-safe bounded memory-only artifact/resource reuse, URI-accurate native MCP resource links, independently readable binary assets and transactional file/resource delivery with verified rollback and retained recovery backups; transactional host-handoff acceptance with bounded consumed/expired tombstones so an invalid continuation call can no longer destroy a session; Section screen selection and deterministic batch export; syntax-validated DevupUI TSX, devup.json, raw snapshot, source maps, requested assets and strictly decoded bounded reference PNG; source-derived typed fidelity impact requiring zero approximated/lossy/failed impacts for strict compatibility, evidence-based absolute-layout fallback reporting, final-output provenance for non-default variants, one-to-one repeated text coverage and strict reports; a pure Rust PNG comparator plus renderer contract; loss-aware legacy large-field continuation; dirty-aware build identity with a local --self-check diagnostic and a real-stdio smoke test; all ten WQUW-151 screens; 268/268 plugin snapshot parity and a zero-not-ported 978-entry ledger; focused server module boundaries; non-duplicating CI snapshot drift enforcement; Rust 1.88-compatible dependency updates; an opencode-compatible MCP tool schema fix replacing boolean JSON Schema with object schemas across all 7 tools plus a raw-stdio schema regression test; and a further oxc/rmcp dependency refresh with the pinned toolchain raised to Rust 1.98
  • Add three read-only ground-truth tools so an agent can never fabricate a project identifier it never verified: devup_project_context 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), returning a shared {found:false,guardrail:{action:'stop-and-report',...}} envelope instead of guessing when the target file is missing; devup_ui_validate parses DevupUI TSX with the existing oxc_parser/oxc_allocator/oxc_span stack via a new oxc_ast_visit-based walker and flags unknown $token references (with edit-distance-suggested existing tokens), hardcoded hex colors/px lengths that match an existing token, unknown props on Box/Flex/Text/Center/Grid/Image (checked against the published devup-ui Style Props API reference, not invented), and non-static values inside css()/globalCss()/keyframes() calls specifically -- verified against devup-ui's own docs and css-utils-literal-only ESLint rule that plain JSX style props (bg={dynamic}) are valid devup-ui and must not be flagged; devup_stack_diff detects drift across vespertide model -> sea-orm entity -> vespera route -> openapi.json -> devup-api client with every finding carrying an explicit low/medium confidence since none of the checks is a real compiler front end. Regression-tested against the exact incident that motivated this work: three agents independently inventing a $gray100 color token, a 16px bubble radius, and a 36px avatar size that did not exist in the real project devup.json.

Patch

  • Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though 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, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens. Fixes SVG asset export, which failed for every request while PNG worked: Figma's remote MCP returns a written PNG as an image attachment but does not return a written .svg at all, so the bytes never reached devup-mcp. SVG is now exported as a string and carried inline beside the descriptor under a bounded size, and the payload search steps through the JSON encoding of a text block and accepts a text payload as well as base64. The missing-payload error now reports which content shapes and mime types the response actually carried, so an absent attachment, a wrong mime type and an unread field stay distinguishable. Adds server instructions covering that the generated component name and asset paths are starting points rather than contracts, that a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist, and that resource delivery should be preferred over inlining bytes.

devup-mcp-figma@0.1.0 → 0.2.0 - crates/devup-mcp-figma/Cargo.toml

Minor

  • Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though 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, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens. Fixes SVG asset export, which failed for every request while PNG worked: Figma's remote MCP returns a written PNG as an image attachment but does not return a written .svg at all, so the bytes never reached devup-mcp. SVG is now exported as a string and carried inline beside the descriptor under a bounded size, and the payload search steps through the JSON encoding of a text block and accepts a text payload as well as base64. The missing-payload error now reports which content shapes and mime types the response actually carried, so an absent attachment, a wrong mime type and an unread field stay distinguishable. Adds server instructions covering that the generated component name and asset paths are starting points rather than contracts, that a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist, and that resource delivery should be preferred over inlining bytes.
  • Add a Rust-native private Figma Remote MCP with OAuth and verified official-host fallback; exact-node and full-theme one-call acquisition with JSON-string host normalization, atomic failed-root-only legacy fallback, observable fallback progress and a bounded shared upstream session with safe stage deadlines; bounded, ancestor-preserving linked-screen exploration with executable stress fixtures, refresh control, exact/related/superset cache reuse, compatible-request singleflight and current-versus-origin call diagnostics; cancellation-safe bounded memory-only artifact/resource reuse, URI-accurate native MCP resource links, independently readable binary assets and transactional file/resource delivery with verified rollback and retained recovery backups; transactional host-handoff acceptance with bounded consumed/expired tombstones so an invalid continuation call can no longer destroy a session; Section screen selection and deterministic batch export; syntax-validated DevupUI TSX, devup.json, raw snapshot, source maps, requested assets and strictly decoded bounded reference PNG; source-derived typed fidelity impact requiring zero approximated/lossy/failed impacts for strict compatibility, evidence-based absolute-layout fallback reporting, final-output provenance for non-default variants, one-to-one repeated text coverage and strict reports; a pure Rust PNG comparator plus renderer contract; loss-aware legacy large-field continuation; dirty-aware build identity with a local --self-check diagnostic and a real-stdio smoke test; all ten WQUW-151 screens; 268/268 plugin snapshot parity and a zero-not-ported 978-entry ledger; focused server module boundaries; non-duplicating CI snapshot drift enforcement; Rust 1.88-compatible dependency updates; an opencode-compatible MCP tool schema fix replacing boolean JSON Schema with object schemas across all 7 tools plus a raw-stdio schema regression test; and a further oxc/rmcp dependency refresh with the pinned toolchain raised to Rust 1.98

devup-mcp-visual@0.1.0 → 0.2.0 - crates/devup-mcp-visual/Cargo.toml

Minor

  • Add a Rust-native private Figma Remote MCP with OAuth and verified official-host fallback; exact-node and full-theme one-call acquisition with JSON-string host normalization, atomic failed-root-only legacy fallback, observable fallback progress and a bounded shared upstream session with safe stage deadlines; bounded, ancestor-preserving linked-screen exploration with executable stress fixtures, refresh control, exact/related/superset cache reuse, compatible-request singleflight and current-versus-origin call diagnostics; cancellation-safe bounded memory-only artifact/resource reuse, URI-accurate native MCP resource links, independently readable binary assets and transactional file/resource delivery with verified rollback and retained recovery backups; transactional host-handoff acceptance with bounded consumed/expired tombstones so an invalid continuation call can no longer destroy a session; Section screen selection and deterministic batch export; syntax-validated DevupUI TSX, devup.json, raw snapshot, source maps, requested assets and strictly decoded bounded reference PNG; source-derived typed fidelity impact requiring zero approximated/lossy/failed impacts for strict compatibility, evidence-based absolute-layout fallback reporting, final-output provenance for non-default variants, one-to-one repeated text coverage and strict reports; a pure Rust PNG comparator plus renderer contract; loss-aware legacy large-field continuation; dirty-aware build identity with a local --self-check diagnostic and a real-stdio smoke test; all ten WQUW-151 screens; 268/268 plugin snapshot parity and a zero-not-ported 978-entry ledger; focused server module boundaries; non-duplicating CI snapshot drift enforcement; Rust 1.88-compatible dependency updates; an opencode-compatible MCP tool schema fix replacing boolean JSON Schema with object schemas across all 7 tools plus a raw-stdio schema regression test; and a further oxc/rmcp dependency refresh with the pinned toolchain raised to Rust 1.98

Patch

  • Make the direct Figma OAuth path work end to end, so a URL converts to DevupUI TSX without a host Figma MCP or an agent relay in the loop. Three defects each independently blocked it: Dynamic Client Registration always sent a client_name that Figma's catalog allowlist rejects with a plain-text 403, and the name is now configurable through --figma-client-name / DEVUP_FIGMA_CLIENT_NAME with doctor reporting the active value; the client_secret issued by registration was discarded even though 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, and the secret is now kept beside its client_id for the authorization-code exchange and refresh; and auth_network_error dropped the underlying transport error entirely, so every failure surfaced identically with no details, and it now carries kind/status/url/cause-chain with the URL reduced to scheme, host and path so a query string cannot carry a code or token into a log. Also tolerate a relay that re-serializes upstream results: get_metadata is no longer bare XML because Figma prepends a selected-nodes block and appends an instruction footer, and the fast envelope no longer requires integrity.utf8Bytes to equal the received byte length, since truncation is already caught by JSON parsing plus the node, resource-reference and resource-presence checks that read content rather than its serialized form. Every diagnostic and guidance string is now emitted in English, because these are returned to an LLM agent over MCP where Korean prose costs several times the tokens; Korean Figma fixture data is preserved where tests use it deliberately to exercise CJK handling. Consolidates CI and release into the single workflow the other org projects use, driven by changepacks/action: the action cuts draft releases and reports them through pending_releases, a build matrix compiles devup-mcp and devup-mcp-visual for Linux, Windows and a macOS universal binary and uploads them onto those drafts, and a finalize step publishes the drafts only after the uploads succeed, so a release is never visible without its binaries. A changepack-required gate fails any pull request that edits a crate without leaving a changepack log, since such a change never moves the version and therefore never releases. Also fixes output-root resolution for a root reached through a symlink: the root was canonicalised when the policy opened it while the requested outputPath was not, so a caller passing a path under the spelling it was given was refused with outputPath is outside the allowed root. On macOS that was the normal case rather than an edge case, because /tmp and the system temp directory both resolve through /var to /private/var. Fixes Section targets on the direct path: the fast snapshot script throws DEVUP_TARGET_IS_SECTION and MCP delivers a thrown error as a successful call carrying isError, which the direct path handed to accept and then failed with snapshot data not found, so a Section link had no way to reveal the screens inside it. It is now rejected exactly as the handoff path already did, so the collector switches to the section index and answers with selectable screens. Fixes SVG asset export, which failed for every request while PNG worked: Figma's remote MCP returns a written PNG as an image attachment but does not return a written .svg at all, so the bytes never reached devup-mcp. SVG is now exported as a string and carried inline beside the descriptor under a bounded size, and the payload search steps through the JSON encoding of a text block and accepts a text payload as well as base64. The missing-payload error now reports which content shapes and mime types the response actually carried, so an absent attachment, a wrong mime type and an unread field stay distinguishable. Adds server instructions covering that the generated component name and asset paths are starting points rather than contracts, that a fixed asset must be exported through assetRequests with an outputPath instead of referenced by a path that does not exist, and that resource delivery should be preferred over inlining bytes.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant