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
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_notice_layout.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"crates/devup-mcp-devup-ui/Cargo.toml": "Minor"
},
"note": "Two layout defects on the notice screens, found the week the harness stopped hiding them. A horizontal row can overflow collectively even when every child fits on its own, and CSS min-width:auto then refuses to let a FILL sibling shrink to its share: a navigation item Figma draws at 95px expanded to 316px, pushed an address block onto three extra lines, and made the tablet page 72px taller than the design. The existing minW=0 rule already covered the case where a single child is wider than its parent; it now also covers the row that only overflows once its children are added together, still measured from the snapshot rather than assumed, still skipped when the design states its own minWidth, and applied only to non-wrapping horizontal rows because a wrapping row distributes that content over more lines instead. Separately, an SVG mask with an export offset was refused a folded layout size outright, so a social icon the design gives 82x32 was generated at zero width and everything below it shifted. The refusal exists because a FILL axis would scale the layout box while the pixel mask offsets stay where they are, and because a positioned or free-layout asset is placed from its export box; none of that applies to an in-flow HUG or FIXED axis, which keeps its measured box while maskSize and maskPos go on describing the larger painted bounds. Both conditions read node fields only - layout mode, wrap, sizing, positioning, and measured widths - so neither carries a viewport, a breakpoint, a node id, or a constant fitted to the current capture. Measured against Figma's own PNG, the three notice screens improve from 5.54 / 3.35 / 2.22 to 5.25 / 3.22 / 2.20 percent, the tablet page height falls from 72 pixels over the design to 3, and the other twelve screens are byte-identical. All 268 plugin byte-parity goldens are unchanged.",
"date": "2026-09-13T13:05:00+09:00"
}
36 changes: 27 additions & 9 deletions crates/devup-mcp-devup-ui/src/codegen/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,19 +591,23 @@ pub(super) fn push_layout_props(
// the share to 132 and took the 9px from the hugging sibling, whose
// `Devup-ui` then broke across two lines. `minW="0"` lets the share
// be the share, and the content spill as Figma draws it. Written
// only where the snapshot shows content wider than the box, which is
// the only place the two layouts part.
if let Some(own) = view.number("width")
&& view
.child_ids()
// only where the snapshot shows content wider than the box. A row
// can overflow collectively even when each child fits individually.
// Wrapping rows can distribute that content over multiple lines.
let child_overflows = view.number("width").is_some_and(|own| {
view.child_ids()
.filter_map(|id| snapshot.nodes.get(id))
.any(|child| {
let child = child.typed_view();
child.bool("visible") != Some(false)
&& child.string("layoutPositioning") != Some("ABSOLUTE")
&& child.number("width").is_some_and(|width| width > own + 0.5)
})
{
});
let row_overflows = view.string("layoutMode") == Some("HORIZONTAL")
&& view.string("layoutWrap") != Some("WRAP")
&& line_overflows(snapshot, node, "width", "paddingLeft", "paddingRight");
if view.number("minWidth").is_none() && (child_overflows || row_overflows) {
string_prop(props, "minW", "0");
}
}
Expand Down Expand Up @@ -1583,15 +1587,29 @@ pub(crate) fn folded_mask_dimensions(snapshot: &Snapshot, node: &RawNode) -> Opt
let view = node.typed_view();
if super::style::asset_kind(snapshot, node) != Some(super::style::AssetKind::SvgMask)
|| view.child_ids().next().is_none()
// Rotated exports and pixel mask offsets need a different transform
// proof. Leave them unresolved instead of inventing responsive sizes.
// Rotated exports need a separate transform proof.
|| view.number("rotation").is_some_and(|rotation| rotation.abs() > 0.01)
|| export_offset(node).is_some()
|| !(view.string("layoutSizingHorizontal") == Some("HUG")
|| view.string("layoutSizingVertical") == Some("HUG"))
{
return None;
}
if export_offset(node).is_some() {
// In-flow HUG/FIXED axes retain the measured layout box. The export's
// larger painted bounds are independently preserved by maskSize and
// maskPos. A FILL axis would scale that box while leaving pixel mask
// offsets unchanged; positioned assets instead use the export box.
let parent = view
.string("parentId")
.and_then(|id| snapshot.nodes.get(id));
if !matches!(view.string("layoutSizingHorizontal"), Some("HUG" | "FIXED"))
|| !matches!(view.string("layoutSizingVertical"), Some("HUG" | "FIXED"))
|| view.string("layoutPositioning") == Some("ABSOLUTE")
|| placed_by_a_free_layout(snapshot, node, parent, false)
{
return None;
}
}
let measured = |axis| {
view.number(axis)
.or_else(|| view.value("absoluteBoundingBox")?.get(axis)?.as_f64())
Expand Down
8 changes: 7 additions & 1 deletion crates/devup-mcp-devup-ui/src/provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1585,7 +1585,13 @@ pub(crate) fn finalize_tsx(
} else {
"raw-fallback"
};
let source_property = if *prop == "fontWeight"
let source_property = if *prop == "minW"
&& value == "0"
&& node.typed_view().number("minWidth").is_none()
&& node.typed_view().string("layoutSizingHorizontal") == Some("FILL")
{
"layoutSizingHorizontal"
} else if *prop == "fontWeight"
&& node.node_type == "TEXT"
&& node.typed_view().number(property).is_none()
{
Expand Down
153 changes: 153 additions & 0 deletions crates/devup-mcp-devup-ui/tests/folded_asset_size.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,159 @@ fn wquw119() -> devup_mcp_figma::Snapshot {
serde_json::from_str(include_str!("../../../fixtures/wquw-119-snapshot.json")).unwrap()
}

fn offset_hug_mask(horizontal: &str, vertical: &str) -> devup_mcp_figma::Snapshot {
let mut snapshot = wquw119();
let node = snapshot.nodes.get_mut("3997:46317").unwrap();
for (field, value) in [
("layoutSizingHorizontal", json!(horizontal)),
("layoutSizingVertical", json!(vertical)),
("width", json!(137)),
("height", json!(29)),
(
"absoluteBoundingBox",
json!({"x":10,"y":20,"width":137,"height":29}),
),
(
"absoluteRenderBounds",
json!({"x":7,"y":18,"width":143,"height":33}),
),
] {
node.fields.insert(field.into(), value);
}
snapshot
}

#[test]
fn offset_hug_mask_restores_layout_size_without_replacing_painted_bounds() {
for (horizontal, vertical) in [("HUG", "FIXED"), ("FIXED", "HUG"), ("HUG", "HUG")] {
let snapshot = offset_hug_mask(horizontal, vertical);
let output =
generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap();
for expected in [
"w=\"137px\"",
"h=\"29px\"",
"maskSize=\"143px 33px\"",
"maskPos=\"-3px -2px\"",
] {
assert!(
output.tsx.contains(expected),
"missing {expected}: {}",
output.tsx
);
}
assert!(!output.tsx.contains("aspectRatio="));
}
}

#[test]
fn offset_hug_mask_maps_restored_width_and_rejects_wrong_size() {
let snapshot = offset_hug_mask("HUG", "FIXED");
let output = generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap();
for field in ["width", "layoutSizingHorizontal", "childrenIds"] {
assert!(
output.source_map.entries.iter().any(|entry| {
entry.node_id.as_deref() == Some("3997:46317")
&& entry.property.as_deref() == Some(field)
&& entry.resolution == "restored-hug-after-mask-child-folding"
&& entry
.generated_range
.as_ref()
.is_some_and(|r| output.tsx.get(r.start..r.end) == Some("w=\"137px\""))
}),
"missing {field} restoration evidence"
);
}
assert!(
!output
.fidelity_report
.uncovered_layout
.contains(&"3997:46317#width".to_owned())
);
let mut wrong = output.clone();
wrong.tsx = wrong.tsx.replace("w=\"137px\"", "w=\"143px\"");
let report =
devup_mcp_devup_ui::provenance::validate_fidelity(&snapshot, "3997:46315", &wrong).unwrap();
assert!(
report
.uncovered_layout
.contains(&"3997:46317#width".to_owned())
);
}

#[test]
fn offset_hug_mask_can_restore_layout_bounding_box_fallback() {
let mut snapshot = offset_hug_mask("HUG", "HUG");
let node = snapshot.nodes.get_mut("3997:46317").unwrap();
node.fields.remove("width");
node.fields.remove("height");
let output = generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap();
assert!(output.tsx.contains("w=\"137px\""), "{}", output.tsx);
assert!(output.tsx.contains("h=\"29px\""));
assert!(
output
.source_map
.entries
.iter()
.any(|entry| entry.node_id.as_deref() == Some("3997:46317")
&& entry.property.as_deref() == Some("width")
&& entry.resolution
== "restored-hug-after-mask-child-folding-from-absoluteBoundingBox")
);
}

#[test]
fn offset_mask_restoration_does_not_claim_fluid_rotated_or_positioned_geometry() {
for excluded in [
"horizontal-fill",
"vertical-fill",
"rotation",
"absolute",
"free-layout",
"missing-size",
] {
let mut snapshot = offset_hug_mask("HUG", "HUG");
let node = snapshot.nodes.get_mut("3997:46317").unwrap();
match excluded {
"horizontal-fill" => {
node.fields
.insert("layoutSizingHorizontal".into(), json!("FILL"));
}
"vertical-fill" => {
node.fields
.insert("layoutSizingVertical".into(), json!("FILL"));
}
"rotation" => {
node.fields.insert("rotation".into(), json!(30));
}
"absolute" => {
node.fields
.insert("layoutPositioning".into(), json!("ABSOLUTE"));
}
"missing-size" => {
node.fields.remove("width");
node.fields.remove("absoluteBoundingBox");
}
"free-layout" => {
let parent = snapshot.nodes.get_mut("3997:46316").unwrap();
parent.fields.insert("layoutMode".into(), json!("NONE"));
parent.fields.remove("inferredAutoLayout");
}
_ => unreachable!(),
}
let output =
generate_component(&snapshot, "3997:46315", &CodegenOptions::default()).unwrap();
assert!(
!output
.source_map
.entries
.iter()
.any(|entry| entry.node_id.as_deref() == Some("3997:46317")
&& entry.resolution.starts_with("restored-hug")),
"unexpected restoration for {excluded}"
);
}
}

#[test]
fn r5_wquw119_hug_mask_preserves_render_size_and_source_map() {
let snapshot = wquw119();
Expand Down
140 changes: 140 additions & 0 deletions crates/devup-mcp-devup-ui/tests/horizontal_fill_overflow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use devup_mcp_devup_ui::codegen::{CodegenOptions, CodegenOutput, generate_component};
use devup_mcp_figma::Snapshot;
use serde_json::json;

fn scene() -> Snapshot {
serde_json::from_value(json!({
"fileKey": "fill-overflow", "roots": ["root"], "diagnostics": [],
"nodes": {
"root": {"id":"root", "type":"FRAME", "fields":{
"layoutMode":"HORIZONTAL", "layoutSizingHorizontal":"FIXED",
"layoutSizingVertical":"HUG", "width":320, "height":40,
"itemSpacing":40, "childrenIds":["hug","fill"]
}},
"hug": {"id":"hug", "type":"FRAME", "fields":{
"parentId":"root", "layoutMode":"VERTICAL",
"layoutSizingHorizontal":"HUG", "layoutSizingVertical":"HUG",
"width":160, "height":40, "childrenIds":[]
}},
"fill": {"id":"fill", "type":"FRAME", "fields":{
"parentId":"root", "layoutMode":"HORIZONTAL",
"layoutSizingHorizontal":"FILL", "layoutSizingVertical":"HUG",
"width":120, "height":40, "itemSpacing":20,
"primaryAxisAlignItems":"MAX", "childrenIds":["a","b"]
}},
"a": {"id":"a", "type":"RECTANGLE", "fields":{
"parentId":"fill", "width":70, "height":40,
"layoutSizingHorizontal":"FIXED", "layoutSizingVertical":"FIXED"
}},
"b": {"id":"b", "type":"RECTANGLE", "fields":{
"parentId":"fill", "width":70, "height":40,
"layoutSizingHorizontal":"FIXED", "layoutSizingVertical":"FIXED"
}}
}
}))
.unwrap()
}

fn generate(snapshot: &Snapshot) -> CodegenOutput {
let mut snapshot = snapshot.clone();
for node in snapshot.nodes.values_mut() {
if node.fields.contains_key("layoutMode") {
node.fields
.insert("inferredAutoLayout".into(), json!(node.fields));
}
}
generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap()
}

fn fill_tag(output: &CodegenOutput) -> &str {
let entry = output
.source_map
.entries
.iter()
.find(|entry| entry.node_id.as_deref() == Some("fill") && entry.property.is_none())
.unwrap();
let range = entry.generated_range.as_ref().unwrap();
output.tsx[range.start..range.end]
.split('>')
.next()
.unwrap()
}

#[test]
fn collectively_overflowing_row_keeps_its_fill_allocation() {
let output = generate(&scene());
// Neither 70px child exceeds the 120px allocation; together with the
// 20px gap they do. CSS must allow the FILL box to remain 120px wide.
assert!(fill_tag(&output).contains("minW=\"0\""), "{}", output.tsx);
assert!(fill_tag(&output).contains("flex=\"1\""));
assert!(!fill_tag(&output).contains("120px"));
}

#[test]
fn derived_minimum_maps_to_fill_intent_not_an_absent_min_width() {
let output = generate(&scene());
let mappings: Vec<_> = output
.source_map
.entries
.iter()
.filter(|entry| {
entry.node_id.as_deref() == Some("fill")
&& entry.generated_range.as_ref().is_some_and(|range| {
output.tsx.get(range.start..range.end) == Some("minW=\"0\"")
})
})
.collect();
assert!(
mappings
.iter()
.any(|entry| entry.property.as_deref() == Some("layoutSizingHorizontal"))
);
assert!(
!mappings
.iter()
.any(|entry| entry.property.as_deref() == Some("minWidth"))
);
}

#[test]
fn fitting_wrapping_and_out_of_flow_children_do_not_force_a_reset() {
for (field, value) in [
("width", json!(180)),
("layoutWrap", json!("WRAP")),
("layoutMode", json!("VERTICAL")),
] {
let mut snapshot = scene();
snapshot
.nodes
.get_mut("fill")
.unwrap()
.fields
.insert(field.into(), value);
assert!(!fill_tag(&generate(&snapshot)).contains("minW="), "{field}");
}
for (field, value) in [
("visible", json!(false)),
("layoutPositioning", json!("ABSOLUTE")),
] {
let mut snapshot = scene();
snapshot
.nodes
.get_mut("b")
.unwrap()
.fields
.insert(field.into(), value);
assert!(!fill_tag(&generate(&snapshot)).contains("minW="), "{field}");
}
}

#[test]
fn an_explicit_minimum_is_preserved() {
let mut snapshot = scene();
snapshot
.nodes
.get_mut("fill")
.unwrap()
.fields
.insert("minWidth".into(), json!(100));
assert!(fill_tag(&generate(&snapshot)).contains("minW=\"100px\""));
}
Loading