Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_bridge_asset_delivery.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"crates/devup-mcp-figma/Cargo.toml": "Patch",
"crates/devup-mcp/Cargo.toml": "Patch"
},
"note": "Return native bridge asset bytes without the remote MCP file writer. The native Plugin API has no figma.io, so successful small PNG and SVG exports were converted to DEVUP_ASSET_EXPORT_FAILED when the script tried to write an attachment. The script now carries bounded binary bytes and MIME directly in the bridge JSON response, while preserving remote attachment and fragment limits and existing hash validation. The plugin bundle is rebuilt from the same source. This fixes delivery, not whole-node versus isolated-fill composition; the about hero contract and the outstanding live-pixel validation are documented separately, and no visual improvement or eleven-second latency improvement is claimed.",
"date": "2026-09-14T01:13:00+09:00"
}
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_wave_24.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"crates/devup-mcp-devup-ui/Cargo.toml": "Patch",
"crates/devup-mcp-figma/Cargo.toml": "Minor"
},
"note": "Three screens looked at in parallel; two produced a fix and one produced a boundary. A responsive module folds the same node across breakpoints into one tree, and where the widths supplied different images it wrote the source as a responsive array. An image source is not a responsive value in the way a length is: the element takes one URL, so the array made a source no width had asked for. Differing child images now emit scalar URLs and lean on the visibility merging that already exists for nodes present at only some widths. Separately, the bridge's asset export called a remote figma.io writer that does not exist on the plugin path, which is why every asset request failed at a fixed delay; the transport now delivers bridge assets without it, with the decoder tests written red first and the committed plugin bundle rebuilt because assets.js is compiled into it. The isolated-fill contract the about hero needs is now defined and its boundary validated - all eleven existing crops and all three flattened hero exports match their reference regions - but isolating the hero itself still requires authenticated source pixels and representation-aware export continuation, so it is specified rather than claimed. The grid screen is a documented negative: all 41,234 differing pixels sit in three photographs and source-coordinate placement reproduces every one of them, which rules out the grid track projection outright and leaves density-aware raster mapping as the named next lead. No Rust changed for it. Measured against Figma's reference PNG the three popup screens improve from 3.64, 2.06 and 0.85 percent to 3.58, 2.02 and 0.84, with every other screen byte-identical and all 268 plugin goldens unchanged.",
"date": "2026-09-14T02:30:00+09:00"
}
22 changes: 21 additions & 1 deletion crates/devup-mcp-devup-ui/src/codegen/responsive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,11 +561,19 @@ fn children_to_map(tree: &Tree) -> Vec<(String, Vec<&Tree>)> {
let mut grouped: Vec<(String, Vec<&Tree>)> = Vec::new();
for child in &tree.children {
let signature = structure_signature(child);
let key = if counts.get(&signature) == Some(&1) {
let mut key = if counts.get(&signature) == Some(&1) {
format!("sig:{signature}")
} else {
child.node_name.clone()
};
// `src` is an HTML attribute, not a responsive CSS property. Keep
// distinct sources as distinct children; the existing missing-child
// merge supplies their visibility slots without array-valued URLs.
if child.component == "Image"
&& let Some(source) = child.props.get("src")
{
key = serde_json::to_string(&(key, source)).expect("image identity is serializable");
}
if let Some((_, bucket)) = grouped.iter_mut().find(|(existing, _)| *existing == key) {
bucket.push(child);
} else {
Expand Down Expand Up @@ -1033,6 +1041,18 @@ fn merge_children(
let mut children: BySlot<Tree> = std::array::from_fn(|slot| {
bucket(slot).and_then(|list| list.get(index).cloned().cloned())
});
if (0..SLOTS).any(|slot| by_slot[slot].is_some() && children[slot].is_none()) {
for child in children.iter_mut().flatten() {
if child.component == "Image" {
// A present source must restore visibility even when
// it is absent on both sides of this drawn slot.
child
.props
.entry("display".to_owned())
.or_insert_with(|| natural_display("Image").to_owned());
}
}
}
// A width that does not draw this child is given a copy of the
// first one that does — first in the Section's order — hidden. The
// copies then merge like anything else: the `display` array falls
Expand Down
84 changes: 84 additions & 0 deletions crates/devup-mcp-devup-ui/tests/responsive_image_sources.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_breakpoints};
use devup_mcp_figma::Snapshot;
use serde_json::json;

fn screen(widths: [u32; 3], names: [&str; 3]) -> String {
let mut nodes = serde_json::Map::new();
let mut roots = Vec::new();
for (index, (width, name)) in widths.into_iter().zip(names).enumerate() {
let root = format!("frame:{index}");
let image = format!("asset:{index}");
let text = format!("text:{index}");
roots.push(root.clone());
nodes.insert(
root.clone(),
json!({"id":root,"type":"FRAME","fields":{
"name":(["mobile","tablet","desktop"][index]),"width":width,"height":200,
"layoutMode":"HORIZONTAL","layoutSizingHorizontal":"FIXED",
"layoutSizingVertical":"FIXED","childrenIds":[image,text]
}}),
);
nodes.insert(
image.clone(),
json!({"id":image,"type":"RECTANGLE","fields":{
"name":name,"parentId":root,"width":24,"height":24,"isAsset":true,
"layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED",
"fills":[{"type":"IMAGE","visible":true,"scaleMode":"FILL","imageHash":name}]
}}),
);
nodes.insert(
text.clone(),
json!({"id":text,"type":"TEXT","fields":{
"name":"label","parentId":root,"characters":"label","fontSize":14,
"lineHeight":{"unit":"PIXELS","value":20},"width":40,"height":20
}}),
);
}
let snapshot: Snapshot = serde_json::from_value(json!({
"fileKey":"images","version":null,"roots":roots,"nodes":nodes,"diagnostics":[]
}))
.unwrap();
merge_breakpoints(&snapshot, &CodegenOptions::default())
.unwrap()
.unwrap()
.tsx
}

#[test]
fn different_image_sources_stay_scalar_through_responsive_merging() {
for widths in [[320, 700, 1600], [450, 900, 1400]] {
let tsx = screen(widths, ["first", "second", "third"]);
assert!(
!tsx.contains("src={["),
"HTML src cannot receive a CSS array: {tsx}"
);
for name in ["first", "second", "third"] {
assert!(
tsx.contains(&format!("src=\"/images/{name}.png\"")),
"{tsx}"
);
}
assert_eq!(tsx.matches("<Image").count(), 3, "{tsx}");
assert_eq!(tsx.matches("display={[").count(), 3, "{tsx}");
}
}

#[test]
fn identical_sources_still_share_one_image() {
let tsx = screen([330, 710, 1500], ["shared", "shared", "shared"]);
assert_eq!(tsx.matches("<Image").count(), 1, "{tsx}");
assert!(tsx.contains("src=\"/images/shared.png\""), "{tsx}");
assert!(!tsx.contains("display={["), "{tsx}");
}

#[test]
fn a_source_that_returns_keeps_its_existing_visibility_slots() {
let tsx = screen([330, 710, 1500], ["shared", "other", "shared"]);
assert_eq!(tsx.matches("<Image").count(), 2, "{tsx}");
let compact = tsx.split_whitespace().collect::<String>();
assert!(
compact.contains("display={[\"inline\",\"none\",null,null,\"inline\"]}"),
"{tsx}"
);
assert!(!tsx.contains("src={["), "{tsx}");
}
19 changes: 14 additions & 5 deletions crates/devup-mcp-figma/src/scripts/assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,19 @@ try {
return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE");
}
const sha256 = devupSha256(bytes);
// figma.io is an extension of the remote MCP, not the native Plugin API.
// The bridge returns JSON over its local socket and has no attachment
// writer. Carry its bounded bytes inline, without exporting again for each
// fragment; the Rust decoder still validates length, MIME type and hash.
const hasFileWriter = figma.io && typeof figma.io.write === "function";
// A PNG past what one attachment carries is not written here either.
// 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, a
// 950 KB one was written, reported exported, and never arrived - the
// devup-ui landing page's hero. Past 768 KiB, which is exactly one MiB
// encoded, it is announced and read back in fragments like a large SVG.
const pngTooLargeToAttach = format === "PNG" && bytes.length > 768 * 1024;
if ((svgText !== null && bytes.length > 12 * 1024) || pngTooLargeToAttach) {
if (hasFileWriter && ((svgText !== null && bytes.length > 12 * 1024) || pngTooLargeToAttach)) {
// An SVG past what one text response holds is not written here at all:
// it is announced with its length and hash, and read back in fragments
// through the large-value script, which re-exports it and slices — the
Expand All @@ -94,7 +99,9 @@ try {
errorCode: null,
};
}
figma.io.write(`devup-asset-${options.assetId.replace(/[^A-Za-z0-9_-]/g, "_")}.${String(options.format).toLowerCase()}`, bytes);
if (hasFileWriter) {
figma.io.write(`devup-asset-${options.assetId.replace(/[^A-Za-z0-9_-]/g, "_")}.${String(options.format).toLowerCase()}`, bytes);
}
return {
kind: "devupAssetExport",
fileKey: figma.fileKey || "",
Expand All @@ -108,9 +115,11 @@ try {
status: "exported",
byteLength: bytes.length,
sha256,
// Present only for SVG. `mimeType` is what lets the Rust side recognise
// this as the payload rather than as ordinary descriptor prose.
mimeType: svgText === null ? null : "image/svg+xml",
// MIME identifies inline bridge bytes or remote SVG text as a payload.
mimeType: !hasFileWriter
? { PNG: "image/png", JPG: "image/jpeg", SVG: "image/svg+xml", PDF: "application/pdf" }[format]
: svgText === null ? null : "image/svg+xml",
...(!hasFileWriter && svgText === null ? { data: figma.base64Encode(bytes) } : {}),
text: svgText,
errorCode: null,
};
Expand Down
136 changes: 136 additions & 0 deletions crates/devup-mcp-figma/tests/asset_script_transport.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
use std::io::Write;
use std::process::{Command, Stdio};

use base64::{Engine as _, engine::general_purpose::STANDARD};
use devup_mcp_figma::{
AssetExportOutcome, AssetFormat, AssetRequest, AssetStatus, ReadToolCall, UpstreamResult,
asset_export_from_result,
};
use serde_json::{Value, json};

fn execute(
format: AssetFormat,
length: usize,
writer: bool,
export_fails: bool,
) -> (AssetRequest, Value) {
let request = AssetRequest {
asset_id: "1:2:node".to_owned(),
node_id: "1:2".to_owned(),
field: "node".to_owned(),
image_hash: None,
format,
scale: 2,
};
let call = ReadToolCall::asset_export("fixture", Some("v1".to_owned()), request.clone());
let code = call.arguments()["code"].as_str().unwrap().to_owned();
let input =
json!({"code": code, "length": length, "writer": writer, "exportFails": export_fails});
// The native Plugin API provides base64Encode, but no figma.io. Keep the
// source bytes deterministic and exercise the actual compiled export script
// and Rust response decoder, including JSON nested in the bridge envelope.
let js = r#"
const input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
const bytes = Uint8Array.from({length: input.length}, (_, i) => 65 + i % 26);
let exports = 0, writes = 0;
const figma = {
fileKey: 'fixture',
base64Encode: value => Buffer.from(value).toString('base64'),
getNodeByIdAsync: async () => ({exportAsync: async settings => {
exports++;
if (input.exportFails) throw new Error('renderer failed');
return settings.format === 'SVG_STRING' ? Buffer.from(bytes).toString() : bytes;
}}),
};
if (input.writer) figma.io = {write: () => { writes++; }};
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
new AsyncFunction('figma', input.code)(figma).then(data => {
process.stdout.write(JSON.stringify({exports, writes, data}));
}).catch(error => { console.error(error); process.exitCode = 1; });
"#;
let mut child = Command::new("node")
.args(["-e", js])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Node is required to execute the asset script contract");
child
.stdin
.take()
.unwrap()
.write_all(input.to_string().as_bytes())
.unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
(request, serde_json::from_slice(&output.stdout).unwrap())
}

#[test]
fn native_plugin_exports_without_a_remote_file_writer() {
for (format, length) in [
(AssetFormat::Png, 7),
(AssetFormat::Png, 800_000),
(AssetFormat::Jpg, 257),
(AssetFormat::Pdf, 258),
(AssetFormat::Svg, 11),
(AssetFormat::Svg, 13_000),
] {
let (request, result) = execute(format, length, false, false);
assert_eq!(result["exports"], 1);
assert_eq!(result["writes"], 0);
let response = UpstreamResult {
raw: json!({"content": [{"type": "text", "text": result["data"].to_string()}]}),
};
let AssetExportOutcome::Entry(asset) =
asset_export_from_result(&response, "fixture", Some("v1"), &request).unwrap()
else {
panic!("a bridge response can carry the bounded bytes without re-exporting fragments")
};
assert_eq!(
asset.status,
AssetStatus::Exported,
"{format:?}/{length}: {result}"
);
let expected: Vec<u8> = (0..length).map(|i| 65 + (i % 26) as u8).collect();
assert_eq!(
STANDARD.decode(asset.data_base64.unwrap()).unwrap(),
expected
);
assert_eq!(asset.byte_length, Some(length));
assert_eq!(asset.mime_type.as_deref(), Some(format.mime_type()));
}
}

#[test]
fn remote_asset_delivery_retains_attachment_and_fragment_limits() {
for (format, length, status, writes) in [
(AssetFormat::Png, 7, "exported", 1),
(AssetFormat::Png, 800_000, "chunked", 0),
(AssetFormat::Svg, 11, "exported", 1),
(AssetFormat::Svg, 13_000, "chunked", 0),
] {
let (_, result) = execute(format, length, true, false);
assert_eq!(result["data"]["status"], status);
assert_eq!(result["exports"], 1);
assert_eq!(result["writes"], writes);
}
}

#[test]
fn bridge_does_not_hide_export_failure_or_bypass_byte_limit() {
for (length, fails, error) in [
(3, true, "DEVUP_ASSET_EXPORT_FAILED"),
(0, false, "DEVUP_ASSET_RESPONSE_TOO_LARGE"),
(8 * 1024 * 1024 + 1, false, "DEVUP_ASSET_RESPONSE_TOO_LARGE"),
] {
let (_, result) = execute(AssetFormat::Png, length, false, fails);
assert_eq!(result["data"]["status"], "failed");
assert_eq!(result["data"]["errorCode"], error);
assert_eq!(result["writes"], 0);
}
}
Loading