diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs
index 1a896fc25b..28574ebbfc 100644
--- a/render-wasm/src/render/svg/frames.rs
+++ b/render-wasm/src/render/svg/frames.rs
@@ -72,7 +72,10 @@ fn render_frame_body(
scale: f32,
) -> Result<()> {
let spread = builder.silhouette_spread;
- let clipped = element.clip_content;
+ // Clip only on the real content pass. Drop silhouettes are painted outside
+ // the content clip (GPU), so spread/offset rings are not truncated by an
+ // un-outset / unshifted clipPath.
+ let clipped = element.clip_content && !builder.suppress_filters;
if clipped {
let clip_id = builder.unique("clip");
builder.push_clip_path(&clip_id, element, tree);
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_silhouette_skips_content_clip.snap
similarity index 83%
rename from render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap
rename to render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_silhouette_skips_content_clip.snap
index 9b8a4c38bf..1d6d089d32 100644
--- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_silhouette_skips_content_clip.snap
@@ -4,13 +4,11 @@ expression: svg
---
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_spread_is_not_truncated_by_content_clip.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_spread_is_not_truncated_by_content_clip.snap
new file mode 100644
index 0000000000..b46d121b1b
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_spread_is_not_truncated_by_content_clip.snap
@@ -0,0 +1,14 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs
index cae1ccfefa..1ecdc699c0 100644
--- a/render-wasm/src/render/svg/tests.rs
+++ b/render-wasm/src/render/svg/tests.rs
@@ -500,10 +500,10 @@ fn exports_frame_drop_shadow_wrapping_children() {
}
#[test]
-fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() {
- // clip=ON + drop offset: silhouette fills/children move with
- // silhouette_draw_matrix, so the board clipPath must move too — otherwise
- // the unshifted clip truncates the shadow (F1a / show-content=false).
+fn clipped_frame_drop_shadow_silhouette_skips_content_clip() {
+ // clip=ON + drop offset: GPU paints the drop outside the content clip, so
+ // the silhouette must not use clipPath (F1a). Otherwise an unshifted clip
+ // truncates the offset shadow.
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
@@ -543,10 +543,6 @@ fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() {
svg.contains("filter=\"url(#fx"),
"clipped frame drop shadow must emit a filter: {svg}"
);
- assert!(
- svg.matches("= 2,
- "silhouette and content each need a clipPath: {svg}"
- );
let filter_open = svg.find("filter=\"url(#fx").expect("frame filter");
let filter_close = svg[filter_open..]
@@ -554,29 +550,78 @@ fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() {
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
- let clip_ref = silhouette
- .find("clip-path=\"url(#")
- .and_then(|i| {
- let start = i + "clip-path=\"url(#".len();
- let end = silhouette[start..].find(')')?;
- Some(&silhouette[start..start + end])
- })
- .expect("silhouette must reference a clipPath");
-
- let clip_def_start = svg
- .find(&format!("")
- .map(|i| clip_def_start + i)
- .expect("clipPath close");
- let clip_geom = &svg[clip_def_start..clip_def_end];
-
- // Content clip (second clipPath) stays unshifted; silhouette clip must
- // carry the local drop offset (0, 24) like silhouette fills.
assert!(
- clip_geom.contains("translate(") && clip_geom.contains(" 24"),
- "silhouette clipPath must follow drop offset (0,24): {clip_geom}\nfull: {svg}"
+ !silhouette.contains("clip-path="),
+ "drop silhouette must not use content clip: {silhouette}"
+ );
+ assert!(
+ silhouette.contains(r#"translate(0 24)"#),
+ "silhouette fills must still apply drop offset: {silhouette}"
+ );
+ assert!(
+ svg.contains("")
+ .map(|i| filter_open + i)
+ .expect("silhouette group close");
+ let silhouette = &svg[filter_open..=filter_close];
+ assert!(
+ !silhouette.contains("clip-path="),
+ "spread silhouette must not be content-clipped: {silhouette}"
+ );
+ // 200×120 + 2×24 spread, and child 160×80 + 2×24.
+ assert!(
+ silhouette.contains(r#"width="248""#) && silhouette.contains(r#"height="168""#),
+ "frame fill must outset by spread 24: {silhouette}"
+ );
+ assert!(
+ silhouette.contains(r#"width="208""#) && silhouette.contains(r#"height="128""#),
+ "child fill must outset by inherited spread 24: {silhouette}"
);
insta::assert_snapshot!(svg);
}