Skip to content

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

Merged
owjs3901 merged 170 commits into
mainfrom
owjs3901/project-groundtruth
Sep 8, 2026
Merged

feat(figma): make the direct Figma OAuth path work end to end#4
owjs3901 merged 170 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
Contributor

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.
  • Render the generated screens against Figma's own PNGs and fix what that turned up. A render harness (harness/render) builds each acquired screen with devup-ui at the frame's own size and compares the capture with Figma's render of the same frame, each screen under its own node-scope theme and @devup-ui/reset-css, holding every screen to a recorded threshold so a change that moves it further from Figma fails the run. Measured against that, the notice screen went from 29% to 2% different at 1920, and every fix below keeps the plugin corpus at 268 of 268. devup_figma_export gains assetNamesPerNode, on by default: named after their layer, as the plugin names them, one file served every node sharing that name, so eight nodes on one screen claimed a file holding five different drawings and a photograph drawn at three widths kept whichever width was exported last and was stretched into the other two; named per node each gets its own file, while assetNamesPerNode false and the CodegenOptions library default keep the plugin's naming byte for byte. Where two different drawings still claim one file the first is written and the rest are reported as DEVUP_ASSET_NAME_SHARED rather than the export being refused. A layer name no file system takes, such as one carrying a colon, is made writable at delivery with the generated code and the manifest renamed together so the two cannot drift apart. The asset manifest now lists the image fills a layout container paints itself, which the code refers to as a background and nothing could previously deliver, and names them through image_fill_path; a fully transparent node is no longer advertised as exportable, since Figma refuses it as it refuses a hidden one; and a frame that binds no variable and uses no style now yields an empty theme instead of a refusal. A cropped image fill honours its imageTransform instead of being painted center/cover. Two layout facts CSS cannot reach on its own are now written: a node filling its parent's main axis while holding a positioned child, which CSS would otherwise hug to its text, and the pinned height of a positioned frame that fills its parent's width, which CSS would otherwise size by its children - the first put a hero picture 143px low, the second put a header row 18px high. Three invariants guard the result: a per-screen regression gate on rendered distance from Figma, a test that every picture the generated code points at is one the manifest lists, and a check that the words the page prints are the words the design says, which uses the browser as the oracle for JSX whitespace rather than reimplementing its rules.

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.
  • Render the generated screens against Figma's own PNGs and fix what that turned up. A render harness (harness/render) builds each acquired screen with devup-ui at the frame's own size and compares the capture with Figma's render of the same frame, each screen under its own node-scope theme and @devup-ui/reset-css, holding every screen to a recorded threshold so a change that moves it further from Figma fails the run. Measured against that, the notice screen went from 29% to 2% different at 1920, and every fix below keeps the plugin corpus at 268 of 268. devup_figma_export gains assetNamesPerNode, on by default: named after their layer, as the plugin names them, one file served every node sharing that name, so eight nodes on one screen claimed a file holding five different drawings and a photograph drawn at three widths kept whichever width was exported last and was stretched into the other two; named per node each gets its own file, while assetNamesPerNode false and the CodegenOptions library default keep the plugin's naming byte for byte. Where two different drawings still claim one file the first is written and the rest are reported as DEVUP_ASSET_NAME_SHARED rather than the export being refused. A layer name no file system takes, such as one carrying a colon, is made writable at delivery with the generated code and the manifest renamed together so the two cannot drift apart. The asset manifest now lists the image fills a layout container paints itself, which the code refers to as a background and nothing could previously deliver, and names them through image_fill_path; a fully transparent node is no longer advertised as exportable, since Figma refuses it as it refuses a hidden one; and a frame that binds no variable and uses no style now yields an empty theme instead of a refusal. A cropped image fill honours its imageTransform instead of being painted center/cover. Two layout facts CSS cannot reach on its own are now written: a node filling its parent's main axis while holding a positioned child, which CSS would otherwise hug to its text, and the pinned height of a positioned frame that fills its parent's width, which CSS would otherwise size by its children - the first put a hero picture 143px low, the second put a header row 18px high. Three invariants guard the result: a per-screen regression gate on rendered distance from Figma, a test that every picture the generated code points at is one the manifest lists, and a check that the words the page prints are the words the design says, which uses the browser as the oracle for JSX whitespace rather than reimplementing its rules.

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

Patch

  • Render the generated screens against Figma's own PNGs and fix what that turned up. A render harness (harness/render) builds each acquired screen with devup-ui at the frame's own size and compares the capture with Figma's render of the same frame, each screen under its own node-scope theme and @devup-ui/reset-css, holding every screen to a recorded threshold so a change that moves it further from Figma fails the run. Measured against that, the notice screen went from 29% to 2% different at 1920, and every fix below keeps the plugin corpus at 268 of 268. devup_figma_export gains assetNamesPerNode, on by default: named after their layer, as the plugin names them, one file served every node sharing that name, so eight nodes on one screen claimed a file holding five different drawings and a photograph drawn at three widths kept whichever width was exported last and was stretched into the other two; named per node each gets its own file, while assetNamesPerNode false and the CodegenOptions library default keep the plugin's naming byte for byte. Where two different drawings still claim one file the first is written and the rest are reported as DEVUP_ASSET_NAME_SHARED rather than the export being refused. A layer name no file system takes, such as one carrying a colon, is made writable at delivery with the generated code and the manifest renamed together so the two cannot drift apart. The asset manifest now lists the image fills a layout container paints itself, which the code refers to as a background and nothing could previously deliver, and names them through image_fill_path; a fully transparent node is no longer advertised as exportable, since Figma refuses it as it refuses a hidden one; and a frame that binds no variable and uses no style now yields an empty theme instead of a refusal. A cropped image fill honours its imageTransform instead of being painted center/cover. Two layout facts CSS cannot reach on its own are now written: a node filling its parent's main axis while holding a positioned child, which CSS would otherwise hug to its text, and the pinned height of a positioned frame that fills its parent's width, which CSS would otherwise size by its children - the first put a hero picture 143px low, the second put a header row 18px high. Three invariants guard the result: a per-screen regression gate on rendered distance from Figma, a test that every picture the generated code points at is one the manifest lists, and a check that the words the page prints are the words the design says, which uses the browser as the oracle for JSX whitespace rather than reimplementing its rules.

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.
Rendering the generated screens against Figma's own PNGs turned up four ways the code and the bytes disagreed about a picture.

A layer name is the file name, as the plugin names it, and a designer names three logos 'Logo'. Named that way one file serves every node that shares the name: on the notice screen eight nodes claim one file that holds five different drawings, and a photograph drawn at three widths keeps whichever width was exported last, so at the other two it is the wrong size for its box and the browser stretches it into place. `assetNamesPerNode` names each asset after the node it came from instead. It is off by default, so what the generator writes is the plugin's own naming and the goldens are untouched.

A cropped fill carries its crop as a matrix over the image's own 0..1 space. It was painted `center/cover`, which shows the whole picture rather than the part the designer framed; the crop is now read off `imageTransform`. No golden emits a url for a cropped fill, so this costs no parity - the about answer differs on purpose, with the reason recorded.

An image fill on a layout box could not be named at all: `asset_path` only answers for a node the code draws entirely from a file. `image_fill_path` names one from the node and which fill it is, so the manifest can say where those pictures go.

A layer named 'ic:round-arrow-left' is a file name Windows refuses, and the export was refused with it. The names are made writable at delivery, where the code and the manifest are renamed together so the two cannot drift apart; the generator keeps the plugin's name. Where two different drawings still claim one file the first is written and the rest are reported as `DEVUP_ASSET_NAME_SHARED`, rather than refusing the whole export.

The style pass now carries its variable tokens and the naming option together, so threading the option did not push three signatures past clippy's argument ceiling.
Comparing the generated code line by line against the plugin's answers says the two agree; it cannot say either one draws what Figma draws. This builds each acquired screen with devup-ui, opens it at the frame's own size, and compares the capture with the PNG Figma renders of that frame.

`scripts/acquire.py` fetches what a screen needs from a running devup-mcp - the module, its theme, every asset it points at, and the reference PNG - banking the Figma calls so a re-run costs nothing already paid for. `scripts/render.mjs` builds, captures and compares.

Each screen is rendered with its own theme, taken at node scope. One theme for every screen has to pick a winner where two of the file's collections both define `primary`, which drew the notice screen violet where Figma draws it blue; scoped to the node the same token resolves to that screen's own value. Devup UI bakes the theme in at build time, so screens are grouped by the theme they need and each group is built once.

The reset is `@devup-ui/reset-css`, the one the generated code is written against. Guessing at one got the two things it settles wrong: the root line-height is 1.5, not the browser's own, and images stay inline on the baseline rather than being forced to blocks.

`bands.mjs`, `drift.mjs` and `elements.mjs` say where a difference is, whether it is a shift or a real difference, and what size each picture actually came out at - a tall screen shrunk to fit a screenshot shows nothing. `probe_manifest_gap.py` checks the invariant that every asset the code points at is one the manifest lists.
The manifest promised an export Figma would always refuse. A node is unexportable when it draws no pixel, and the check for that only asked whether the node was hidden - so two icons left at zero opacity were advertised as available, and the caller learned otherwise only when the refusal surfaced from inside Figma, one asset request at a time.

They were the only two exports Figma turned down across every screen the render harness acquires, and the only two nodes in that file at zero opacity.
A node set to fill its parent's main axis is stretched by Figma to the space left over. Left unsaid, CSS lets it hug its content instead, which usually agrees - a column of in-flow children adds up to the height Figma gave it. A positioned child adds nothing to the height of what holds it, so there hugging can never reach it.

The about page's hero column is 440 tall in a 520 tall section and came out 155, the height of its text alone. The section centred that, pushing the column 143px down and dropping the picture hung off it over the heading it is meant to sit above. Rendered against Figma's own PNG the screen moved from 13.79% to 11.26% different, with every other screen unchanged.

Only a node holding a positioned child is affected, which is why the plugin corpus is untouched: the one golden that pairs a filled height with a fixed parent fills it from in-flow children, and hugging already reaches its height.

README documents `assetNamesPerNode` alongside this, and `scripts/boxes.mjs` reports the DOM boxes down a screen - which is how the 143px was found.
A tall screen shrunk to fit a screenshot shows nothing. `bands.mjs` writes crops of the bands that differ most, which is the wrong slice when the question is about a band that is only third worst - the about page's paragraph wrapping, or the join where its height went wrong.
…ched

Every figure here was won against a particular defect - a theme resolved at the wrong scope, a picture stretched into a box it did not fit, a column that hugged its text where Figma filled a section. Nothing stopped the next change from giving one back quietly.

Each screen now carries the most it may differ. A run that exceeds it fails; a run that comes in under it says so, which is the cue to tighten the figure. Verified both ways: tightening one screen's figure fails the run, restoring it passes.

The popup-answer-* entries are the plugin's own answer rendered the same way. They are a baseline to measure against, not a screen to improve.
… lists

The code refers to an asset by path; the manifest is what tells a caller to export it. A picture the code points at that the manifest does not list can never be delivered, and the screen renders with a hole where it belongs - which is what happened to five photographs on the about page, painted as backgrounds on layout boxes the asset walk stepped straight past. Nothing was checking the two agreed.

Across the 37 captures, 446 generated modules, both with the asset naming per node and without, sixteen pictures are still unlisted:

  * a raster painted from a PATTERN fill, which the code sends to the icon folder and the walk does not visit at all;
  * a vector shape - ellipse, star, polygon, plain vector - drawn from a file the walk never reaches, because an ancestor answered for the subtree while the code named the child.

Those are recorded per capture rather than hidden. A capture that grows a new one fails; one that loses a gap says so, which is the cue to lower its figure. The captures are not committed, so the test says what it checked instead of pretending to have checked.
…ove with it

Re-acquiring every screen with the asset naming per node turned on changed the responsive popup module: where it pointed all three widths at one file, it now writes an array with a file per width, and the manifest and the code agree on all six.

The gate caught what that cost - popup at 390 went from 3.53% to 3.59%, in the bands the icon occupies. Each width now draws its own export rather than whichever width happened to be exported last, which is the more faithful thing to do even where this one width's difference ticks up. Recorded rather than absorbed silently, which is what the gate is for.
The test added in c3c92ca reported sixteen pictures the manifest did not
list, and its message named two causes: rasters painted from a PATTERN
fill, and vector shapes an ancestor answered for. Both were wrong, and so
was the debt list recorded alongside them.

Some of these captures are documentation screens: they display JSX samples
as text, naming files of their own. The generator escapes the brackets of
such a sample, and the reader walked straight past that and lifted the
paths out of text the screen merely prints. Reading the generated lines
showed it plainly - a caption showing an Image tag with a src of
/icons/image.png is a caption, not a reference.

Skipping escaped lines, the count across all 37 captures and 446 generated
modules is zero. There was no debt: the one real gap, the about page's five
photographs, was already closed by listing the pictures a container paints
itself. The baseline is gone and the test asserts what it should have
asserted from the start.
Comparing the generated code with the plugin's answer line by line says the two agree; it cannot say either one draws what Figma draws. The README now says what the harness does, how to run it, why each screen is built with its own node-scope theme, that the reset is the one the generated code is written against, and what each screen currently measures against Figma's own PNG.

It also lists what the harness found, since that is the argument for having it: theme scope, a container's own pictures missing from the manifest, the crop matrix of a cropped fill, layer names no file system takes, one file shared by widths that need different sizes, a fully transparent node's export, and a height CSS cannot reach past a positioned child.
A positioned frame is out of flow, so its children size it in CSS. Where Figma pinned its height, the two disagree: the notice header is 60 tall around a 24px row of logo and menu, and centring them in 24 rather than 60 put the whole row 18px high of where Figma draws it. On the desktop frame the same header is 80.

The height was already restored for a frame pinned on both axes. A frame that fills its parent's width is given one by the width branch, so that guard never fired for it - this adds the height alone for exactly that case.

An asset is left out, as it already was: it has no children to measure, and two goldens carry a full-width rotated mask that wants its height unsaid. A frame whose spare room became padding is left out too, since the padding already adds back up. The plugin corpus is untouched at 268 of 268; the notice answer differs by this one line, recorded with its reason.

Measured against Figma's own PNGs: notice 8.29 to 7.36 at 360, 4.19 to 3.24 at 992, 2.33 to 2.15 at 1920, with every other screen unchanged.
The generator writes a text node's characters into JSX, and JSX has rules of
its own about whitespace: a run of text broken across two source lines is
joined with a single space. Where the design has no space at that point, the
screen prints a word the design does not contain, and the paragraph wraps
somewhere Figma never wraps it. That is what put the about page's paragraph
onto an extra line and cost 35px of layout.

Reading the JSX to work out what it renders means reimplementing those rules,
and guessing at them is exactly how the asset check invented sixteen faults
that were not there. The browser already implements them, so this asks the
rendered page: every characters string of every text node under the frame has
to appear in what the page prints.

Validated both ways before being trusted. The notice screens, where no such
join occurs, report nothing at all three widths. The about screens report
exactly one text each, and it is the paragraph already proved wrong by pixel
comparison - node 422:3496 at 360, 422:3301 at 992, 422:3107 at 1920, all the
same paragraph.

Three of 245 texts across the corpus. That is the size of the problem on real
production screens: one paragraph, not a widespread fault.
Named after their layer, as the plugin names them, one file serves every
node a designer gave that name. That is a loss wherever the two are not
the same picture: eight nodes on the notice screen claimed one file
holding five different drawings, and a photograph drawn at three widths
kept whichever width was exported last, so at the other two it was the
wrong size for its box and the browser stretched it in.

The option to name each asset after its own node has been there since
34dc2d6, off by default, which meant a caller only got correct files by
knowing to ask. Rendering says which default is right: notice at 992 went
from 6.48% to 4.19% different from Figma's own PNG with it on, about at
992 from 10.79% to 6.94%.

It costs no parity. The plugin corpus drives the generator through
CodegenOptions directly, and that library default is unchanged, so all
268 goldens pass. Only the tool's request default moves; assetNamesPerNode
false still gives the plugin's naming byte for byte.

The harness no longer asks for it either, so the figures it reports are
what a caller actually receives. Every screen measures the same as before
and every asset the generated code points at is still one the manifest
lists.
The changepack-required gate only checks that a pull request carrying crate changes also carries a changepack log, and this branch already carried two - so the gate passed while neither log said a word about this session's work. The release notes would have shipped a public behaviour change, assetNamesPerNode on by default, without mentioning it.

devup-mcp and devup-mcp-devup-ui take a Minor bump: a new request option that changes what every caller receives, a new diagnostic, a new public naming function, and two layout fixes. devup-mcp-figma takes a Patch: the manifest lists what it should always have listed, a transparent node is no longer advertised as exportable, and an empty variable snapshot is an empty theme rather than a refusal.
Figma's remote MCP returns a written PNG as an attachment only up to
about a megabyte once base64-encoded. A 665 KB photograph came back; the
devup-ui landing page's hero, 950 KB at 1232x1232, was written, reported
exported, and never arrived - the descriptor said exported and the answer
carried text alone, so the manifest ended with DEVUP_SNAPSHOT_UNSUPPORTED
and the screen rendered without its largest picture.

Past 768 KiB - exactly one MiB encoded - the export script now announces
the PNG as chunked with its length and hash, the same way an SVG over the
inline cap is, and the collector reads it back through the large-value
script under `$export:png@<scale>`. The scale rides on the field so the
re-export behind each fragment is the same bytes that were announced,
hash for hash; the assembler already checks both. A PDF is still refused
where the announcement is read.
… lacks

A frame that draws nothing of its own and holds a single picture is that
picture: both the code generator and the plugin name the file after the
frame and collapse the child away. Discovery agreed the frame was the
asset but kept the child's fill index, so it listed `<frame>:fills:0` -
a fill the frame does not have. Figma answered every such export with
DEVUP_ASSET_SOURCE_CHANGED, because the fill it was asked to check was
not there. The devup-ui landing page's footer logo sits in exactly such a
frame and was the one asset of 182 that never arrived, leaving the footer
pointing at a file nothing could ever write.

Such a frame is now listed as the node itself, which renders to the same
picture and is a request the node can answer. The name the code writes is
unchanged - it was always the frame's - so no generated file moves.

`source_kind` is now derived in one place from what the request already
says, since the entry an export produces replaces the one discovery
listed and the two have to agree: a fill index is `image-fill`, a node
with an image behind it `image-node`, and a node without `vector-node`.
…arent

Figma's default counter-axis alignment is MIN, and it writes that by
leaving the field out. CSS's default for the same thing is `stretch`,
its opposite. So a child that hugs across its parent's axis was drawn as
wide as the parent unless something else happened to settle its size:
the devup-ui landing page's `Get started` button, 247px in the 1360px
column that holds it, was drawn 1360px wide, a black bar across the
hero. The plugin has the same gap.

Most hugging children do not care, and saying it for all of them would
bury the few that do - the notice desktop alone holds twenty that would
look identical either way. So the alignment is written only where the
wider box would show: the node is drawn narrower than the room it has,
and it either paints across that box - a fill, a stroke, a shadow - or
places its own content by it. Which of `primaryAxisAlignItems` and
`counterAxisAlignItems` answers for the stretched axis depends on which
way the node itself runs, so both are read.

Text is left alone. A centred line would move, and the pinned corpus
holds three such texts, but no screen in it renders one - so there is
nothing to show it helps, and it would cost byte parity on all three.

Rendered against Figma's own PNGs: the landing page goes from 4.16% to
3.11% and about at 360 from 11.26% to 11.25%, with the other ten screens
unchanged to the hundredth. Plugin parity holds at 268 of 268. Three
`Center` lines on the about screen and eight `VStack` lines across the
WQUW-151 frames now carry the alignment; the about ones are recorded
with their reason.
The first answer that is not from `devup-Test`, and the first with a
third source to check against: the design, the plugin's six outputs, and
the site actually deployed from `dev-five-git/devup-ui`. Frame 793:6361
in its own file, 1920x2746, 216 nodes, 183 assets.

It has already earned its place. Two of its assets could not be fetched
at all - the 950KB hero, past what Figma returns as an attachment, and a
footer logo asked for by a fill index its frame does not carry - and
neither failure is visible in the code the plugin writes, which names the
files and stops. Its `Get started` button, 247px wide, was drawn 1360px
wide by both this repo and the plugin. All three are fixed; the README
says what else the page has settled and what is still open.

`acquire.py` learns that a target may live in another file, so a frame's
URL is built from its target rather than from the one file key the
harness had assumed. `probe_refusal.py` asks for a single asset and
prints what the answer actually carried, which is how the two transport
failures were told apart.
…wing line

Figma keeps a fixed-size child at its size and lets the line spill past
the frame, which clips it. CSS shrinks flex children to fit instead, and
the two only agree while the children fit. The devup-ui landing page's
comparison row is seven 240px cards in a 912px frame - 1,800px of
content - and every card was squeezed to about 120px, its `Bulid Time`
label wrapped to two lines, and the row came out 58px taller than the
design, carrying everything below it down the page with it.

`flexShrink="0"` is written for a fixed child, and only where the line
actually overflows: children that fit are not shrunk by CSS either, so
saying it for every fixed child in the file would be noise. Out-of-flow
and undrawn children take no room in the measurement, as they take none
in either layout.

Rendered against Figma's own PNGs, the landing tablet goes from 11.60%
to 5.55% - its height error from 58px to 6px - the landing mobile from
11.64% to 11.56%, and the about desktop from 4.54% to 4.42%. The other
screens are unchanged. Plugin parity holds at 268 of 268; the two about
lines that now carry it are recorded with their reason, and nine WQUW-151
snapshots gain one line each.
A node pushed entirely outside an ancestor that clips is visible and
opaque and still draws nothing. Figma says so by leaving
`absoluteRenderBounds` off it - that field is the bounds of what the node
actually renders, and there are none - and it refuses to export one. The
manifest offered it anyway, so the refusal arrived from inside Figma
after the request, and because an export call carries up to sixteen
assets, one such node took the other fifteen down with it.

This is the same fact the hidden and fully transparent cases already
report, and it is reported the same way: the entry stays in the manifest,
so code pointing at it is still accounted for, but it says up front that
it cannot be exported.

The devup-ui landing page's mobile and tablet each carry one such icon,
pushed past the edge of a clipped panel; they were the only two exports
of 215 that Figma turned down, and both frames now collect without a
single refusal. All eighteen screens render identically.
…ally has

The section was written when only one frame was known, and named a PC
frame that is not the one the harness reads. It now names the mobile,
tablet and PC frames the user identified, records the two layout facts
and the third transport failure the page has since settled, and says
plainly that the six plugin files do not line up with these frames -
pure-mobile.tsx has no join-us section where the frame does - so a
difference against them is a question and the render is the judge.
A shadow's colour can be bound to a variable exactly as a fill or a
stroke can, and then the token is what the design means. The landing
page's GNB and its benchmark card are both `$shadow`, one value the theme
can move for dark mode; they were written as the resolved `#87878740`,
a colour nothing could reach. The plugin's answer for the same frame
writes `$shadow`, and writes the raw `#8787870F` for the feature cards,
whose shadows are bound to nothing - which is the rule, and is now what
both sides say.

The colour is read with the same `bound_paint_token` a stroke uses, and
the token joins the used set so the theme keeps it.

The pixels do not move - the token resolves to the colour that was
already there - so this is a correctness fix the render cannot show, like
the gradient alpha the report screen found. All eighteen screens render
identically and plugin parity holds at 268 of 268.

`pure-pc.tsx` is replaced with the plugin's answer for 832:2975, the
frame the harness actually reads; the README now says which of the six
answers is current and which are still from an earlier state of the file.
… them

The devup-ui landing page's join-us panel holds a group of ten circles -
concentric arcs, three coloured badges - pinned to the card at -277,-187.
None of it was drawn. Three faults stacked up, and the plugin's answer
sidesteps all of them by folding the whole group into one SVG.

The group is `ABSOLUTE`, and `placed_by_a_free_layout` asked the parent
to be `AUTO` before placing its children, so they were read as being in
flow, stacked from the group's corner, and were clipped away. Whether a
parent is itself placed or pinned says nothing about whether it lays its
children out, so the free-layout decision now reads the parent's layout
alone; `lays_nothing_out` still answers for a node about itself, where
being pinned does matter, and is left as it was.

Once placed, the group was told `pos="relative"` for holding positioned
children - over the `absolute` it already had - and went back into flow,
1,102px of it. A node that is itself positioned is already the containing
block, and is not told again.

A group's children carry `x` and `y` in the group's parent's space: the
outermost circle, which is exactly the group, reads `-277,-187` where the
group's own space would say `0,0`. Placed as read, every circle sat 277px
left and 187px high of Figma. The absolute boxes settle it, as an asset's
export already did. And a shape in a group is its own size: `h="100%"`
with no width, the plugin's rule for a positioned shape, is no circle.
That rule is as wrong for a small shape pinned in a frame, and the pinned
corpus holds four; no rendered screen shows one, so it keeps byte parity
until one does.

Rendered against Figma's own PNGs: the landing PC goes from 3.22% to
3.00% and the tablet from 5.55% to 5.28%, with the arcs and badges now
where the design has them. The mobile goes from 11.56% to 11.82%: the
badges it now draws sit above the buttons where Figma has them behind,
because a positioned element paints over in-flow siblings - a stacking
fault the plugin's answer shares, recorded and left for its own change.
The other fifteen screens are unchanged and plugin parity holds at 268
of 268.
… frames

Pure Code and the component-referencing output at mobile 833:3640, tablet
833:3322 and PC 832:2975, pasted by the author from the plugin. The
earlier six were for another state of the file and never lined up with
the frames the harness reads.
Figma paints children in order, so a pinned picture drawn first sits
under everything after it. CSS paints a positioned element after every
in-flow sibling whatever the order. The devup-ui landing page's hero
picture came out over its headline at tablet width and the join-us
badges over their buttons at mobile - and the plugin's answer has the
same fault, as does every pinned background in the pinned corpus.

Such a child is sent behind with `zIndex="-1"`, inside a stacking context
its parent opens with `zIndex="0"` alongside the `relative` it already
has, so it clears the parent's own background and rests under the
content, where Figma has it.

Two limits, both found by measuring. `-1` goes behind every in-flow
sibling, not only the later ones, so it is written only for a child with
nothing in flow before it: the notice page's header is pinned second,
after its banner, and sent behind it vanished under the banner it sits
on - 7.36% became 7.82% at mobile. And a page root opens no stacking
context, so a child of the root is left alone, where `-1` would drop it
behind the root's own background.

Rendered against Figma's own PNGs: the landing mobile goes from 11.82%
to 11.37% and the tablet from 5.28% to 5.05%; the other sixteen screens
are unchanged. One golden in the pinned corpus moves, by two added
lines, and its manifest checksum with it - the report page's rotated
background, which the plugin draws over the page's cards. The corpus has
been moved this way once before, for the same kind of reason, and
`DEVUP_FIXTURE_UPDATE=1` is now the one way to move it, so each such
change is a reviewed diff. The about and report answers record the lines.
Figma writes a node's opacity into its export: into an SVG as
`<g opacity>`, and into a PNG's alpha - the landing page's hero, drawn at
0.8, exports with its opaque pixels at alpha 204. The code wrote the
same opacity on the element as well, so an asset was faded twice. The
benchmark card's lavender decoration at 0.2 came out at 0.04, which is
nothing, and the mobile hero at 0.64. The plugin writes it twice too.

An asset's opacity is now left to its export. A node that is not an
asset still carries its own.

Rendered against Figma's own PNGs: the landing tablet goes from 5.05% to
4.90% and the mobile from 11.35% to 11.30%; notice at 360 and about at
992 each move a hundredth in the same direction; the other fourteen
screens are unchanged. Five goldens in the pinned corpus lose the second
opacity - three PNG images at 0.8, two masked backgrounds at 0.2 - and
their manifest checksums move with them; the about answer records the
lines the plugin still writes.

Along the way: a filling child whose content is wider than its share
gets `minW="0"`, so `flex: 1` can give it exactly the share Figma gives
it and let the content spill as Figma draws it. CSS will not shrink a
flex item under its content, so a 132px row inside a 123px share widened
the share and took 9px from the hugging sibling, whose `Devup-ui` broke
across two lines. Written only where the snapshot shows content wider
than the box. And `probe_stack.mjs`, which asks the browser what is on
top at a point and how an image's ancestors stack - how the doubled
opacity was told apart from a stacking fault.
Figma paints an inside stroke over the padding: a card 20px in from its
edge with a 1px stroke inside is still 20px in, stroke and all, and its
height is the content plus 40. CSS adds a border around the padding
instead, so every such card came out 2px taller and its content sat 1px
further in. The landing page's four feature cards put the join-us panel
8px down at every width, and the benchmark and star cards added their
own; the plugin writes the same border beside the same padding.

The stroke now comes out of the padding, on every axis: the content then
starts `p` in, as Figma has it, and a hugging box is content plus `2p`.
Only an inside solid stroke on a node that is not a line, which is the
one case the two models disagree on.

Rendered against Figma's own PNGs, all three landing pages come out at
exactly the height Figma drew - 2955, 2964 and 3084 - where they had
been 3 to 6px over. The tablet goes from 4.90% to 3.10% and the PC from
2.45% to 1.84%; the other fifteen screens are unchanged to the hundredth.

The mobile goes from 11.30% to 11.41%, and that is the fix working. Its
join-us panel is 18px short of Figma because the Discord line is drawn
with the `Desktop/textL` style on the 360 frame - 18px, three lines -
where every other mobile text uses `Mobile/textL`, and both collapse to
the one `textL` token, which is 16px at that width: two lines. That
error used to be cancelled by the +11px the cards added above it. The
plugin writes the same token; reproducing a design slip would mean a
hardcoded size beside a token, so it is recorded and left.

Three goldens move by the stroke, and their manifest checksums with
them: a card's `py` from 30 to 29, a box's `p` from 8 to 7, and the
button set's `px`, where `white` is the one variant with a stroke and is
now padded a pixel less - which also turns `tag` into a map. Ten
WQUW-151 snapshots move the same way, all by a 1px or 2px stroke.

Along the way, `sections.mjs`: the DOM's section heights beside Figma's,
level by level, which is what said "the features section is 11px tall"
before any crop was looked at.
… is left

Six findings the answers helped settle, each one a thing the plugin does
the same way, and the one that is left: a mobile text drawn with the
desktop style, which the token cannot say and a hardcoded size should
not.
@owjs3901
owjs3901 merged commit a08c4ba into main Sep 8, 2026
7 checks passed
@owjs3901
owjs3901 deleted the owjs3901/project-groundtruth branch September 8, 2026 02:41
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