From 74489b86a30c9fb84122b6995ac69cfd1c1a3572 Mon Sep 17 00:00:00 2001 From: leader Date: Tue, 10 Nov 2026 11:51:54 +0900 Subject: [PATCH 1/2] fix(codegen): open a blended decoration's stacking context on its backdrop owner A transparent wrapper inside a gradient owner received pos relative + zIndex 0 for its negative-z multiply decoration, so the decoration composited against transparency and drew white blobs (Prime contact banner, instance 32:1929). The context now lives on the nearest ancestor that paints the backdrop when the wrapper paints nothing and opens no context of its own and nothing in the ancestor is drawn before it. Unblended children, painting wrappers and earlier siblings keep the existing containment. Adds Rust regressions for the lift and its fallbacks, a harness browser pixel check with a control, and records 005/009 as resolved in the report. Co-Authored-By: Claude Opus 5.5 --- .../devup-mcp-devup-ui/src/codegen/layout.rs | 1 + .../src/codegen/layout/blend_backdrop.rs | 150 ++++++++++++++++++ .../src/codegen/layout/layout_properties.rs | 18 ++- .../tests/blend_backdrop_context.rs | 134 ++++++++++++++++ .../test/codegen-layout.test.mjs | 43 +++++ reports/prime-feedback-2026-11.md | 14 +- 6 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/src/codegen/layout/blend_backdrop.rs create mode 100644 crates/devup-mcp-devup-ui/tests/blend_backdrop_context.rs 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). | From 1e2205d7385cf2ecb3359f5bac29cdb962b8bc8d Mon Sep 17 00:00:00 2001 From: leader Date: Tue, 10 Nov 2026 11:54:22 +0900 Subject: [PATCH 2/2] chore: add changepack for blend backdrop stacking context Co-Authored-By: Claude Opus 5.5 --- .changepacks/changepack_log_Bd5qL8mXz3RwT7nKv2YhJ.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changepacks/changepack_log_Bd5qL8mXz3RwT7nKv2YhJ.json 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" +}