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_Bd5qL8mXz3RwT7nKv2YhJ.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"Cargo.toml": "Patch"
},
"note": "Open a blended negative-z decoration's stacking context on the ancestor that paints its backdrop, so multiply decorations inside a transparent wrapper composite against the gradient instead of turning white.",
"date": "2026-11-10T00:00:00.000000000Z"
}
1 change: 1 addition & 0 deletions crates/devup-mcp-devup-ui/src/codegen/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ include!("layout/reading_order.rs");

include!("layout/fixed_rows.rs");
include!("layout/suspicious_values.rs");
include!("layout/blend_backdrop.rs");
150 changes: 150 additions & 0 deletions crates/devup-mcp-devup-ui/src/codegen/layout/blend_backdrop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// A blended decoration composites against whatever its stacking context has
// painted so far. Figma blends a layer with everything drawn beneath it on the
// canvas, so a `multiply` blob over a purple gradient comes out deep purple.
//
// The negative-z policy in `push_layout_props` gives the parent of a
// sent-behind child `zIndex="0"`, opening a stacking context there. When that
// parent is a transparent wrapper inside the element that paints the
// gradient, the context holds nothing beneath the decoration: `multiply`
// composites against transparency, and the Prime SEO-GEO contact banner
// (instance 32:1929) drew white blobs (224,224,224) where Figma has purple
// (77,44,208). Devup UI emits every property faithfully; the context was on
// the wrong element.
//
// So the context moves up to the nearest ancestor that paints the backdrop,
// across wrappers that paint nothing and open no context of their own. The
// decoration still sits above that ancestor's background and below the
// content, which is the containment the negative-z policy guarantees.

/// The node is blended with what is under it, not drawn over it.
fn blends_with_backdrop(view: &TypedNode<'_>) -> bool {
view.string("blendMode")
.and_then(super::style::blend_mode)
.is_some()
}

fn visible_paints(view: &TypedNode<'_>, field: &str) -> bool {
view.value(field).and_then(Value::as_array).is_some_and(|paints| {
paints.iter().any(|paint| {
paint.get("visible").and_then(Value::as_bool) != Some(false)
&& paint.get("opacity").and_then(Value::as_f64) != Some(0.0)
})
})
}

/// The node paints a backdrop for its descendants: a visible fill.
fn paints_backdrop(snapshot: &Snapshot, node: &RawNode) -> bool {
super::style::asset_kind(snapshot, node).is_none() && visible_paints(&node.typed_view(), "fills")
}

/// A wrapper that paints nothing and opens no stacking context: no fill,
/// stroke or effect, full opacity, normal or pass-through blending, no
/// rotation, and not itself sent behind with a negative z-index. Blending
/// passes through such a wrapper in Figma and must pass through it in CSS.
fn passes_blending_through(snapshot: &Snapshot, node: &RawNode, parent: Option<&RawNode>) -> bool {
let view = node.typed_view();
super::style::asset_kind(snapshot, node).is_none()
&& !visible_paints(&view, "fills")
&& !visible_paints(&view, "strokes")
&& !visible_paints(&view, "effects")
&& view.number("opacity").is_none_or(|opacity| opacity >= 0.999)
&& !blends_with_backdrop(&view)
&& view.bool("isMask") != Some(true)
&& view
.number("rotation")
.is_none_or(|rotation| rotation.abs() <= 0.01)
&& !parent.is_some_and(|parent| {
holds_positioned_children(snapshot, parent)
&& sits_behind_in_flow_siblings(snapshot, parent, &node.id)
})
}

/// Nothing in `parent` is drawn before `child`. Lifting the context paints
/// the decoration under all of the ancestor's content, so anything Figma
/// draws earlier than it would wrongly end up above it.
fn first_visible_child(snapshot: &Snapshot, parent: &RawNode, child_id: &str) -> bool {
parent
.typed_view()
.child_ids()
.find(|id| {
snapshot
.nodes
.get(*id)
.is_some_and(|node| node.typed_view().bool("visible") != Some(false))
})
== Some(child_id)
}

fn has_blended_child_behind(snapshot: &Snapshot, node: &RawNode) -> bool {
holds_positioned_children(snapshot, node)
&& node.typed_view().child_ids().any(|child| {
sits_behind_in_flow_siblings(snapshot, node, child)
&& snapshot
.nodes
.get(child)
.is_some_and(|child| blends_with_backdrop(&child.typed_view()))
})
}

fn parent_in_snapshot<'a>(snapshot: &'a Snapshot, node: &RawNode) -> Option<&'a RawNode> {
snapshot
.nodes
.values()
.find(|candidate| candidate.typed_view().child_ids().any(|child| child == node.id))
}

/// The ancestor that should hold the stacking context of `node`'s blended
/// negative-z children instead of `node`, if `node` is a transparent wrapper
/// below the element that paints their backdrop.
fn blend_context_host<'a>(snapshot: &'a Snapshot, node: &RawNode) -> Option<&'a RawNode> {
if !has_blended_child_behind(snapshot, node) {
return None;
}
let mut current = snapshot.nodes.get(&node.id)?;
let mut parent = parent_in_snapshot(snapshot, current)?;
if !passes_blending_through(snapshot, current, Some(parent)) {
return None;
}
loop {
if !first_visible_child(snapshot, parent, &current.id) {
return None;
}
if paints_backdrop(snapshot, parent) {
return Some(parent);
}
let grandparent = parent_in_snapshot(snapshot, parent)?;
if !passes_blending_through(snapshot, parent, Some(grandparent)) {
return None;
}
current = parent;
parent = grandparent;
}
}

/// Whether `node` holds the stacking context lifted from a transparent
/// wrapper below it. Walks down through wrappers only, so `node` is the
/// nearest painting ancestor of whatever it finds.
fn hosts_lifted_blend_context(snapshot: &Snapshot, node: &RawNode) -> bool {
if !paints_backdrop(snapshot, node) {
return false;
}
let mut stack = vec![node];
while let Some(parent) = stack.pop() {
let Some(first) = parent
.typed_view()
.child_ids()
.filter_map(|id| snapshot.nodes.get(id))
.find(|child| child.typed_view().bool("visible") != Some(false))
else {
continue;
};
if !passes_blending_through(snapshot, first, Some(parent)) {
continue;
}
if has_blended_child_behind(snapshot, first) {
return true;
}
stack.push(first);
}
false
}
18 changes: 12 additions & 6 deletions crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,17 +735,23 @@ pub(super) fn push_layout_props(
// A node that is itself positioned is already that ancestor, and saying
// `relative` over its `absolute` would put it back in flow: the join-us
// group of circles, pinned at -277,-187, took 1,102px of the page.
if containing_block && (!absolute || embedded_root) {
// A transparent wrapper's blended negative-z children composite against
// the backdrop its painting ancestor draws, so that ancestor holds their
// stacking context instead (see `blend_backdrop.rs`).
let lifted_blend_context = hosts_lifted_blend_context(snapshot, node);
if (containing_block || lifted_blend_context) && (!absolute || embedded_root) {
string_prop(props, "pos", "relative");
}
// Position (relative or absolute) establishes a containing block, but
// needs an explicit z-index to contain negative-z children above its own
// background. Keep an existing z-index that places this entire subtree.
if containing_block
&& !props.iter().any(|(name, _)| name == "zIndex")
&& view
.child_ids()
.any(|child| sits_behind_in_flow_siblings(snapshot, node, child))
if !props.iter().any(|(name, _)| name == "zIndex")
&& (lifted_blend_context
|| (containing_block
&& blend_context_host(snapshot, node).is_none()
&& view
.child_ids()
.any(|child| sits_behind_in_flow_siblings(snapshot, node, child))))
{
string_prop(props, "zIndex", "0");
}
Expand Down
134 changes: 134 additions & 0 deletions crates/devup-mcp-devup-ui/tests/blend_backdrop_context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! Feedback 005: a blended negative-z decoration must composite against the
//! backdrop Figma blends it with. Mirrors the Prime SEO-GEO contact banner
//! (instance 32:1929): a gradient owner holds a transparent content wrapper
//! whose first child is a `multiply` decoration sent behind the content.
//! With `zIndex="0"` on the wrapper, the decoration composited against
//! transparency and drew a white blob where Figma has deep purple.

use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component};
use devup_mcp_figma::Snapshot;
use serde_json::{Value, json};

fn banner(mutate: impl FnOnce(&mut Value)) -> Snapshot {
let gradient = json!([{
"type": "GRADIENT_LINEAR",
"gradientTransform": [[0, 1, 0], [-1, 0, 1]],
"gradientStops": [
{"position": 0, "color": {"r": 0.267, "g": 0.114, "b": 0.878, "a": 1}},
{"position": 1, "color": {"r": 0.447, "g": 0.306, "b": 1, "a": 1}}
]
}]);
let mut value = json!({
"fileKey": "blend-backdrop", "version": null, "diagnostics": [], "roots": ["banner"],
"nodes": {
"banner": {"id": "banner", "type": "FRAME", "fields": {
"name": "ContactBanner", "width": 1280, "height": 320,
"layoutMode": "VERTICAL", "layoutSizingHorizontal": "FIXED", "layoutSizingVertical": "HUG",
"paddingTop": 64, "paddingRight": 0, "paddingBottom": 48, "paddingLeft": 0, "clipsContent": true,
"fills": gradient, "childrenIds": ["content"]
}},
"content": {"id": "content", "type": "FRAME", "fields": {
"name": "Content", "parentId": "banner", "width": 1280, "height": 208,
"layoutMode": "VERTICAL", "layoutSizingHorizontal": "FILL", "layoutSizingVertical": "HUG",
"itemSpacing": 36, "inferredAutoLayout": {"layoutMode": "VERTICAL", "itemSpacing": 36},
"fills": [], "childrenIds": ["blob", "title"]
}},
"blob": {"id": "blob", "type": "RECTANGLE", "fields": {
"name": "Glass blob", "parentId": "content", "x": -80, "y": -83, "width": 230, "height": 247,
"layoutPositioning": "ABSOLUTE", "blendMode": "MULTIPLY",
"constraints": {"horizontal": "MIN", "vertical": "MIN"},
"fills": [{"type": "SOLID", "color": {"r": 0.8, "g": 0.8, "b": 0.85}}]
}},
"title": {"id": "title", "type": "TEXT", "fields": {
"name": "Title", "parentId": "content", "characters": "Talk to us",
"width": 1280, "height": 52, "fontSize": 40, "textAutoResize": "HEIGHT",
"layoutSizingHorizontal": "FILL",
"fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}]
}}
}
});
mutate(&mut value);
serde_json::from_value(value).unwrap()
}

/// The opening tag of each element, in document order.
fn tags(tsx: &str) -> Vec<String> {
tsx.split('<')
.skip(1)
.filter(|tag| !tag.starts_with('/'))
.map(|tag| tag.split('>').next().unwrap_or_default().to_owned())
.collect()
}

fn tag_with<'a>(tags: &'a [String], marker: &str) -> &'a str {
tags.iter()
.find(|tag| tag.contains(marker))
.unwrap_or_else(|| panic!("no element with {marker} in {tags:#?}"))
}

fn generate(snapshot: &Snapshot) -> String {
generate_component(snapshot, "banner", &CodegenOptions::default())
.unwrap()
.tsx
}

#[test]
fn multiply_decoration_stacks_on_the_gradient_owner_not_the_transparent_wrapper() {
let tsx = generate(&banner(|_| {}));
let tags = tags(&tsx);
let owner = tag_with(&tags, "linear-gradient");
let wrapper = tag_with(&tags, "gap=\"36px\"");
let blob = tag_with(&tags, "mixBlendMode=\"multiply\"");
assert!(owner.contains("pos=\"relative\""), "{tsx}");
assert!(owner.contains("zIndex=\"0\""), "{tsx}");
// The wrapper still anchors the decoration's coordinates, but opens no
// stacking context between it and the gradient.
assert!(wrapper.contains("pos=\"relative\""), "{tsx}");
assert!(!wrapper.contains("zIndex"), "{tsx}");
assert!(blob.contains("zIndex=\"-1\""), "{tsx}");
assert!(blob.contains("pos=\"absolute\""), "{tsx}");
}

#[test]
fn a_wrapper_that_paints_its_own_backdrop_keeps_the_context() {
let tsx = generate(&banner(|value| {
value["nodes"]["content"]["fields"]["fills"] =
json!([{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}]);
}));
let tags = tags(&tsx);
let owner = tag_with(&tags, "linear-gradient");
let wrapper = tag_with(&tags, "gap=\"36px\"");
assert!(wrapper.contains("zIndex=\"0\""), "{tsx}");
assert!(!owner.contains("zIndex"), "{tsx}");
}

#[test]
fn an_unblended_decoration_keeps_the_existing_containment() {
let tsx = generate(&banner(|value| {
value["nodes"]["blob"]["fields"]["blendMode"] = json!("NORMAL");
}));
let tags = tags(&tsx);
let owner = tag_with(&tags, "linear-gradient");
let wrapper = tag_with(&tags, "gap=\"36px\"");
assert!(wrapper.contains("zIndex=\"0\""), "{tsx}");
assert!(!owner.contains("zIndex"), "{tsx}");
}

#[test]
fn content_drawn_before_the_wrapper_keeps_the_context_on_the_wrapper() {
// Lifting would paint the decoration under this earlier sibling, which
// Figma draws beneath it.
let tsx = generate(&banner(|value| {
value["nodes"]["banner"]["fields"]["childrenIds"] = json!(["eyebrow", "content"]);
value["nodes"]["eyebrow"] = json!({"id": "eyebrow", "type": "TEXT", "fields": {
"name": "Eyebrow", "parentId": "banner", "characters": "Prime",
"width": 1280, "height": 20, "fontSize": 14, "textAutoResize": "HEIGHT",
"layoutSizingHorizontal": "FILL"
}});
}));
let tags = tags(&tsx);
let owner = tag_with(&tags, "linear-gradient");
let wrapper = tag_with(&tags, "gap=\"36px\"");
assert!(wrapper.contains("zIndex=\"0\""), "{tsx}");
assert!(!owner.contains("zIndex"), "{tsx}");
}
43 changes: 43 additions & 0 deletions harness/runtime-audit/test/codegen-layout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,46 @@ test('Korean keep-all with textWrap pretty compiles, and generated headings rese
await page.close();
});
});

test('a multiply decoration behind a transparent wrapper composites against the gradient owner (feedback 005)', {timeout:360000}, async()=> {
// Mirrors the Prime SEO-GEO contact banner, instance 32:1929: gradient owner
// > transparent content wrapper > multiply decoration sent behind the title.
const snapshot={fileKey:'prime-blend',version:null,roots:['root'],diagnostics:[],nodes:{
root:{id:'root',type:'FRAME',fields:{name:'RenderFixture',width:400,height:300,layoutMode:'VERTICAL',
layoutSizingHorizontal:'FIXED',layoutSizingVertical:'HUG',paddingTop:64,paddingRight:0,paddingBottom:48,paddingLeft:0,clipsContent:true,childrenIds:['content'],
inferredAutoLayout:{layoutMode:'VERTICAL',paddingTop:64,paddingRight:0,paddingBottom:48,paddingLeft:0},
fills:[{type:'GRADIENT_LINEAR',gradientTransform:[[0,1,0],[-1,0,1]],gradientStops:[
{position:0,color:{r:0.267,g:0.114,b:0.878,a:1}},{position:1,color:{r:0.447,g:0.306,b:1,a:1}}]}]}},
content:{id:'content',type:'FRAME',fields:{name:'Content',parentId:'root',width:400,height:188,layoutMode:'VERTICAL',
layoutSizingHorizontal:'FILL',layoutSizingVertical:'HUG',itemSpacing:36,inferredAutoLayout:{layoutMode:'VERTICAL',itemSpacing:36},
fills:[],childrenIds:['blob','title']}},
blob:{id:'blob',type:'RECTANGLE',fields:{name:'Glass blob',parentId:'content',x:-80,y:-83,width:230,height:247,
layoutPositioning:'ABSOLUTE',blendMode:'MULTIPLY',constraints:{horizontal:'MIN',vertical:'MIN'},
fills:[{type:'SOLID',color:{r:0.8,g:0.8,b:0.85}}]}},
title:{id:'title',type:'TEXT',fields:{name:'Title',parentId:'content',characters:'Talk to us',width:400,height:52,fontSize:40,
textAutoResize:'HEIGHT',layoutSizingHorizontal:'FILL',fills:[{type:'SOLID',color:{r:1,g:1,b:1}}]}},
}};
await rendered(snapshot,async(browser,url)=> {
const page=await browser.newPage({viewport:{width:400,height:400},deviceScaleFactor:1});
await page.goto(url);await page.evaluate(()=>document.fonts.ready);
// Inside the decoration, below the title: (35,150) on the page.
const pixel=async()=>{
const png=(await page.screenshot({clip:{x:35,y:150,width:1,height:1}})).toString('base64');
return page.evaluate(async(data)=>{const image=new Image();image.src=`data:image/png;base64,${data}`;await image.decode();
const canvas=document.createElement('canvas');canvas.width=1;canvas.height=1;const context=canvas.getContext('2d');
context.drawImage(image,0,0);return [...context.getImageData(0,0,1,1).data.slice(0,3)];},png);
};
const owner=page.locator('[data-figma-node-id="root"]'),wrapper=page.locator('[data-figma-node-id="content"]');
assert.equal(await owner.evaluate(element=>getComputedStyle(element).zIndex),'0');
assert.equal(await wrapper.evaluate(element=>getComputedStyle(element).zIndex),'auto');
// Multiplied into the purple gradient, as Figma draws it.
const blended=await pixel();
assert.ok(blended[0]<150&&blended[1]<150&&blended[2]>blended[0],`decoration must be multiplied into the gradient: ${blended}`);
// Control: the context on the transparent wrapper (the former output)
// composites against nothing and leaves the light blob the check rejects.
await wrapper.evaluate(element=>{element.style.zIndex='0';});
const isolated=await pixel();
assert.ok(isolated[0]>190&&isolated[1]>190,`control must show the unblended blob: ${isolated}`);
await page.close();
});
});
Loading
Loading