Skip to content

Commit feb9ccc

Browse files
authored
fix: scalar responsive image sources, bridge asset delivery, and two measured boundaries (#54)
* docs: localize grid residual to raster composition * fix: keep responsive child image sources scalar * fix(figma): deliver bridge assets without remote file writer * docs: define isolated fill contract and validate about export boundary * chore: record wave 24 as a changepack
1 parent 937313e commit feb9ccc

16 files changed

Lines changed: 9342 additions & 10 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"changes": {
3+
"crates/devup-mcp-figma/Cargo.toml": "Patch",
4+
"crates/devup-mcp/Cargo.toml": "Patch"
5+
},
6+
"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.",
7+
"date": "2026-09-14T01:13:00+09:00"
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"changes": {
3+
"crates/devup-mcp-devup-ui/Cargo.toml": "Patch",
4+
"crates/devup-mcp-figma/Cargo.toml": "Minor"
5+
},
6+
"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.",
7+
"date": "2026-09-14T02:30:00+09:00"
8+
}

crates/devup-mcp-devup-ui/src/codegen/responsive.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,11 +561,19 @@ fn children_to_map(tree: &Tree) -> Vec<(String, Vec<&Tree>)> {
561561
let mut grouped: Vec<(String, Vec<&Tree>)> = Vec::new();
562562
for child in &tree.children {
563563
let signature = structure_signature(child);
564-
let key = if counts.get(&signature) == Some(&1) {
564+
let mut key = if counts.get(&signature) == Some(&1) {
565565
format!("sig:{signature}")
566566
} else {
567567
child.node_name.clone()
568568
};
569+
// `src` is an HTML attribute, not a responsive CSS property. Keep
570+
// distinct sources as distinct children; the existing missing-child
571+
// merge supplies their visibility slots without array-valued URLs.
572+
if child.component == "Image"
573+
&& let Some(source) = child.props.get("src")
574+
{
575+
key = serde_json::to_string(&(key, source)).expect("image identity is serializable");
576+
}
569577
if let Some((_, bucket)) = grouped.iter_mut().find(|(existing, _)| *existing == key) {
570578
bucket.push(child);
571579
} else {
@@ -1033,6 +1041,18 @@ fn merge_children(
10331041
let mut children: BySlot<Tree> = std::array::from_fn(|slot| {
10341042
bucket(slot).and_then(|list| list.get(index).cloned().cloned())
10351043
});
1044+
if (0..SLOTS).any(|slot| by_slot[slot].is_some() && children[slot].is_none()) {
1045+
for child in children.iter_mut().flatten() {
1046+
if child.component == "Image" {
1047+
// A present source must restore visibility even when
1048+
// it is absent on both sides of this drawn slot.
1049+
child
1050+
.props
1051+
.entry("display".to_owned())
1052+
.or_insert_with(|| natural_display("Image").to_owned());
1053+
}
1054+
}
1055+
}
10361056
// A width that does not draw this child is given a copy of the
10371057
// first one that does — first in the Section's order — hidden. The
10381058
// copies then merge like anything else: the `display` array falls
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
use devup_mcp_devup_ui::codegen::{CodegenOptions, responsive::merge_breakpoints};
2+
use devup_mcp_figma::Snapshot;
3+
use serde_json::json;
4+
5+
fn screen(widths: [u32; 3], names: [&str; 3]) -> String {
6+
let mut nodes = serde_json::Map::new();
7+
let mut roots = Vec::new();
8+
for (index, (width, name)) in widths.into_iter().zip(names).enumerate() {
9+
let root = format!("frame:{index}");
10+
let image = format!("asset:{index}");
11+
let text = format!("text:{index}");
12+
roots.push(root.clone());
13+
nodes.insert(
14+
root.clone(),
15+
json!({"id":root,"type":"FRAME","fields":{
16+
"name":(["mobile","tablet","desktop"][index]),"width":width,"height":200,
17+
"layoutMode":"HORIZONTAL","layoutSizingHorizontal":"FIXED",
18+
"layoutSizingVertical":"FIXED","childrenIds":[image,text]
19+
}}),
20+
);
21+
nodes.insert(
22+
image.clone(),
23+
json!({"id":image,"type":"RECTANGLE","fields":{
24+
"name":name,"parentId":root,"width":24,"height":24,"isAsset":true,
25+
"layoutSizingHorizontal":"FIXED","layoutSizingVertical":"FIXED",
26+
"fills":[{"type":"IMAGE","visible":true,"scaleMode":"FILL","imageHash":name}]
27+
}}),
28+
);
29+
nodes.insert(
30+
text.clone(),
31+
json!({"id":text,"type":"TEXT","fields":{
32+
"name":"label","parentId":root,"characters":"label","fontSize":14,
33+
"lineHeight":{"unit":"PIXELS","value":20},"width":40,"height":20
34+
}}),
35+
);
36+
}
37+
let snapshot: Snapshot = serde_json::from_value(json!({
38+
"fileKey":"images","version":null,"roots":roots,"nodes":nodes,"diagnostics":[]
39+
}))
40+
.unwrap();
41+
merge_breakpoints(&snapshot, &CodegenOptions::default())
42+
.unwrap()
43+
.unwrap()
44+
.tsx
45+
}
46+
47+
#[test]
48+
fn different_image_sources_stay_scalar_through_responsive_merging() {
49+
for widths in [[320, 700, 1600], [450, 900, 1400]] {
50+
let tsx = screen(widths, ["first", "second", "third"]);
51+
assert!(
52+
!tsx.contains("src={["),
53+
"HTML src cannot receive a CSS array: {tsx}"
54+
);
55+
for name in ["first", "second", "third"] {
56+
assert!(
57+
tsx.contains(&format!("src=\"/images/{name}.png\"")),
58+
"{tsx}"
59+
);
60+
}
61+
assert_eq!(tsx.matches("<Image").count(), 3, "{tsx}");
62+
assert_eq!(tsx.matches("display={[").count(), 3, "{tsx}");
63+
}
64+
}
65+
66+
#[test]
67+
fn identical_sources_still_share_one_image() {
68+
let tsx = screen([330, 710, 1500], ["shared", "shared", "shared"]);
69+
assert_eq!(tsx.matches("<Image").count(), 1, "{tsx}");
70+
assert!(tsx.contains("src=\"/images/shared.png\""), "{tsx}");
71+
assert!(!tsx.contains("display={["), "{tsx}");
72+
}
73+
74+
#[test]
75+
fn a_source_that_returns_keeps_its_existing_visibility_slots() {
76+
let tsx = screen([330, 710, 1500], ["shared", "other", "shared"]);
77+
assert_eq!(tsx.matches("<Image").count(), 2, "{tsx}");
78+
let compact = tsx.split_whitespace().collect::<String>();
79+
assert!(
80+
compact.contains("display={[\"inline\",\"none\",null,null,\"inline\"]}"),
81+
"{tsx}"
82+
);
83+
assert!(!tsx.contains("src={["), "{tsx}");
84+
}

crates/devup-mcp-figma/src/scripts/assets.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,19 @@ try {
6464
return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE");
6565
}
6666
const sha256 = devupSha256(bytes);
67+
// figma.io is an extension of the remote MCP, not the native Plugin API.
68+
// The bridge returns JSON over its local socket and has no attachment
69+
// writer. Carry its bounded bytes inline, without exporting again for each
70+
// fragment; the Rust decoder still validates length, MIME type and hash.
71+
const hasFileWriter = figma.io && typeof figma.io.write === "function";
6772
// A PNG past what one attachment carries is not written here either.
6873
// Figma's remote MCP returns a written PNG as an attachment only up to
6974
// about a megabyte once base64-encoded: a 665 KB photograph came back, a
7075
// 950 KB one was written, reported exported, and never arrived - the
7176
// devup-ui landing page's hero. Past 768 KiB, which is exactly one MiB
7277
// encoded, it is announced and read back in fragments like a large SVG.
7378
const pngTooLargeToAttach = format === "PNG" && bytes.length > 768 * 1024;
74-
if ((svgText !== null && bytes.length > 12 * 1024) || pngTooLargeToAttach) {
79+
if (hasFileWriter && ((svgText !== null && bytes.length > 12 * 1024) || pngTooLargeToAttach)) {
7580
// An SVG past what one text response holds is not written here at all:
7681
// it is announced with its length and hash, and read back in fragments
7782
// through the large-value script, which re-exports it and slices — the
@@ -94,7 +99,9 @@ try {
9499
errorCode: null,
95100
};
96101
}
97-
figma.io.write(`devup-asset-${options.assetId.replace(/[^A-Za-z0-9_-]/g, "_")}.${String(options.format).toLowerCase()}`, bytes);
102+
if (hasFileWriter) {
103+
figma.io.write(`devup-asset-${options.assetId.replace(/[^A-Za-z0-9_-]/g, "_")}.${String(options.format).toLowerCase()}`, bytes);
104+
}
98105
return {
99106
kind: "devupAssetExport",
100107
fileKey: figma.fileKey || "",
@@ -108,9 +115,11 @@ try {
108115
status: "exported",
109116
byteLength: bytes.length,
110117
sha256,
111-
// Present only for SVG. `mimeType` is what lets the Rust side recognise
112-
// this as the payload rather than as ordinary descriptor prose.
113-
mimeType: svgText === null ? null : "image/svg+xml",
118+
// MIME identifies inline bridge bytes or remote SVG text as a payload.
119+
mimeType: !hasFileWriter
120+
? { PNG: "image/png", JPG: "image/jpeg", SVG: "image/svg+xml", PDF: "application/pdf" }[format]
121+
: svgText === null ? null : "image/svg+xml",
122+
...(!hasFileWriter && svgText === null ? { data: figma.base64Encode(bytes) } : {}),
114123
text: svgText,
115124
errorCode: null,
116125
};
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
use std::io::Write;
2+
use std::process::{Command, Stdio};
3+
4+
use base64::{Engine as _, engine::general_purpose::STANDARD};
5+
use devup_mcp_figma::{
6+
AssetExportOutcome, AssetFormat, AssetRequest, AssetStatus, ReadToolCall, UpstreamResult,
7+
asset_export_from_result,
8+
};
9+
use serde_json::{Value, json};
10+
11+
fn execute(
12+
format: AssetFormat,
13+
length: usize,
14+
writer: bool,
15+
export_fails: bool,
16+
) -> (AssetRequest, Value) {
17+
let request = AssetRequest {
18+
asset_id: "1:2:node".to_owned(),
19+
node_id: "1:2".to_owned(),
20+
field: "node".to_owned(),
21+
image_hash: None,
22+
format,
23+
scale: 2,
24+
};
25+
let call = ReadToolCall::asset_export("fixture", Some("v1".to_owned()), request.clone());
26+
let code = call.arguments()["code"].as_str().unwrap().to_owned();
27+
let input =
28+
json!({"code": code, "length": length, "writer": writer, "exportFails": export_fails});
29+
// The native Plugin API provides base64Encode, but no figma.io. Keep the
30+
// source bytes deterministic and exercise the actual compiled export script
31+
// and Rust response decoder, including JSON nested in the bridge envelope.
32+
let js = r#"
33+
const input = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
34+
const bytes = Uint8Array.from({length: input.length}, (_, i) => 65 + i % 26);
35+
let exports = 0, writes = 0;
36+
const figma = {
37+
fileKey: 'fixture',
38+
base64Encode: value => Buffer.from(value).toString('base64'),
39+
getNodeByIdAsync: async () => ({exportAsync: async settings => {
40+
exports++;
41+
if (input.exportFails) throw new Error('renderer failed');
42+
return settings.format === 'SVG_STRING' ? Buffer.from(bytes).toString() : bytes;
43+
}}),
44+
};
45+
if (input.writer) figma.io = {write: () => { writes++; }};
46+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
47+
new AsyncFunction('figma', input.code)(figma).then(data => {
48+
process.stdout.write(JSON.stringify({exports, writes, data}));
49+
}).catch(error => { console.error(error); process.exitCode = 1; });
50+
"#;
51+
let mut child = Command::new("node")
52+
.args(["-e", js])
53+
.stdin(Stdio::piped())
54+
.stdout(Stdio::piped())
55+
.stderr(Stdio::piped())
56+
.spawn()
57+
.expect("Node is required to execute the asset script contract");
58+
child
59+
.stdin
60+
.take()
61+
.unwrap()
62+
.write_all(input.to_string().as_bytes())
63+
.unwrap();
64+
let output = child.wait_with_output().unwrap();
65+
assert!(
66+
output.status.success(),
67+
"{}",
68+
String::from_utf8_lossy(&output.stderr)
69+
);
70+
(request, serde_json::from_slice(&output.stdout).unwrap())
71+
}
72+
73+
#[test]
74+
fn native_plugin_exports_without_a_remote_file_writer() {
75+
for (format, length) in [
76+
(AssetFormat::Png, 7),
77+
(AssetFormat::Png, 800_000),
78+
(AssetFormat::Jpg, 257),
79+
(AssetFormat::Pdf, 258),
80+
(AssetFormat::Svg, 11),
81+
(AssetFormat::Svg, 13_000),
82+
] {
83+
let (request, result) = execute(format, length, false, false);
84+
assert_eq!(result["exports"], 1);
85+
assert_eq!(result["writes"], 0);
86+
let response = UpstreamResult {
87+
raw: json!({"content": [{"type": "text", "text": result["data"].to_string()}]}),
88+
};
89+
let AssetExportOutcome::Entry(asset) =
90+
asset_export_from_result(&response, "fixture", Some("v1"), &request).unwrap()
91+
else {
92+
panic!("a bridge response can carry the bounded bytes without re-exporting fragments")
93+
};
94+
assert_eq!(
95+
asset.status,
96+
AssetStatus::Exported,
97+
"{format:?}/{length}: {result}"
98+
);
99+
let expected: Vec<u8> = (0..length).map(|i| 65 + (i % 26) as u8).collect();
100+
assert_eq!(
101+
STANDARD.decode(asset.data_base64.unwrap()).unwrap(),
102+
expected
103+
);
104+
assert_eq!(asset.byte_length, Some(length));
105+
assert_eq!(asset.mime_type.as_deref(), Some(format.mime_type()));
106+
}
107+
}
108+
109+
#[test]
110+
fn remote_asset_delivery_retains_attachment_and_fragment_limits() {
111+
for (format, length, status, writes) in [
112+
(AssetFormat::Png, 7, "exported", 1),
113+
(AssetFormat::Png, 800_000, "chunked", 0),
114+
(AssetFormat::Svg, 11, "exported", 1),
115+
(AssetFormat::Svg, 13_000, "chunked", 0),
116+
] {
117+
let (_, result) = execute(format, length, true, false);
118+
assert_eq!(result["data"]["status"], status);
119+
assert_eq!(result["exports"], 1);
120+
assert_eq!(result["writes"], writes);
121+
}
122+
}
123+
124+
#[test]
125+
fn bridge_does_not_hide_export_failure_or_bypass_byte_limit() {
126+
for (length, fails, error) in [
127+
(3, true, "DEVUP_ASSET_EXPORT_FAILED"),
128+
(0, false, "DEVUP_ASSET_RESPONSE_TOO_LARGE"),
129+
(8 * 1024 * 1024 + 1, false, "DEVUP_ASSET_RESPONSE_TOO_LARGE"),
130+
] {
131+
let (_, result) = execute(AssetFormat::Png, length, false, fails);
132+
assert_eq!(result["data"]["status"], "failed");
133+
assert_eq!(result["data"]["errorCode"], error);
134+
assert_eq!(result["writes"], 0);
135+
}
136+
}

0 commit comments

Comments
 (0)