diff --git a/.changepacks/changepack_log_Bd5qL8mXz3RwT7nKv2YhJ.json b/.changepacks/changepack_log_Bd5qL8mXz3RwT7nKv2YhJ.json new file mode 100644 index 00000000..e0116669 --- /dev/null +++ b/.changepacks/changepack_log_Bd5qL8mXz3RwT7nKv2YhJ.json @@ -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" +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 101e6d9c..8d3daafd 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -9,3 +9,4 @@ include!("layout/reading_order.rs"); include!("layout/fixed_rows.rs"); include!("layout/suspicious_values.rs"); +include!("layout/blend_backdrop.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/blend_backdrop.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/blend_backdrop.rs new file mode 100644 index 00000000..31dda0c1 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/blend_backdrop.rs @@ -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, ¤t.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 +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs index e08c6a6c..76f882b5 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs @@ -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"); } diff --git a/crates/devup-mcp-devup-ui/tests/blend_backdrop_context.rs b/crates/devup-mcp-devup-ui/tests/blend_backdrop_context.rs new file mode 100644 index 00000000..8bb8a818 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/blend_backdrop_context.rs @@ -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 { + 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}"); +} diff --git a/harness/runtime-audit/test/codegen-layout.test.mjs b/harness/runtime-audit/test/codegen-layout.test.mjs index 5ad7de7d..ecf5fa1c 100644 --- a/harness/runtime-audit/test/codegen-layout.test.mjs +++ b/harness/runtime-audit/test/codegen-layout.test.mjs @@ -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(); + }); +}); diff --git a/reports/prime-feedback-2026-11.md b/reports/prime-feedback-2026-11.md index fb79f534..8a34c4d3 100644 --- a/reports/prime-feedback-2026-11.md +++ b/reports/prime-feedback-2026-11.md @@ -105,7 +105,13 @@ regression compiles with the installed Devup UI and checks computed `word-break: keep-all`, `text-wrap-style: pretty`, a `nowrap` mode for intrinsic-width text, line ends on word boundaries and a 0px `h2` margin. -Out of scope, unchanged: 005 (general blend/backdrop compositor), 009 (Devup UI -compiler transform of polymorphic Link), 013 (Vite watcher/HMR behavior) and -029 (the application's destructive postprocessor). These belong to other -components or the application and still need their own evidence. +Out of scope, unchanged: 013 (Vite watcher/HMR behavior) and 029 (the +application's destructive postprocessor). These belong to other components or +the application and still need their own evidence. + +### 005 and 009 resolved (2026-11-10) + +| Item | Resolution | +|---|---| +| 005 | Fixed in devup-mcp codegen. The devup-ui investigation (main 675f7e20) showed Devup UI emits `mix-blend-mode`, `filter`, `z-index`, `isolation`, `position` and `transform` faithfully: Chromium screenshots are byte-identical to the plain-CSS equivalent. The defect was where codegen opened the stacking context. In the SEO-GEO contact banner (instance 32:1929, decorations I32:1929;32:1876 and I32:1929;32:1877) the transparent content wrapper inside the gradient owner received `pos="relative" zIndex="0"`, so the `multiply` decorations with `zIndex="-1"` composited against a transparent backdrop and drew white blobs (center pixel 224,224,224 instead of 77,44,208). A blended negative-z child of a wrapper that paints nothing and opens no context of its own (no fill, stroke, effect, opacity, blend, rotation or own negative z-index) now has its stacking context on the nearest ancestor that paints the backdrop, provided nothing in that ancestor is drawn before it. The wrapper keeps `pos="relative"` for the decoration's coordinates. Unblended negative-z children, wrappers with their own fill and earlier painted siblings keep the existing containment. Rust regressions cover the lift and all three fallbacks; the harness compiles the fixture with the installed Devup UI and samples the decoration pixel (multiplied into the purple gradient), with a control that moves the context back to the wrapper and must show the light blob. | +| 009 | No MCP change. The polymorphic Link transform was a Devup UI compiler bug, fixed in `@devup-ui/wasm` 1.0.82 (devup-ui PR #681). |