diff --git a/contributors/people.toml b/contributors/people.toml
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -282,3 +282,7 @@ sync_github_name = false
 [people.jasallen]
 real_name = "Jason DeBoever"
 sync_github_name = true
+
+[people.SofiaAFigueredo]
+real_name = "Sofia Alves Figueredo"
+sync_github_name = false
diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs
--- a/crates/compose/src/effects.rs
+++ b/crates/compose/src/effects.rs
@@ -194,6 +194,9 @@ pub struct FxMaps {
     vdout: Option<Vec<f32>>,
     /// Shape layers: exact Euclidean distance outside the vector outline (from local coverage).
     vdout_exact: Option<Vec<f32>>,
+    /// Shape layers: the vector outline's coverage of each edge pixel, estimated from its
+    /// neighbours (the share of an edge pixel an outside stroke fills beneath the line).
+    vcov: Option<Vec<f32>>,
     /// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`).
     outline: bool,
     /// Frames of gradient strokes by outside width (bits): see [`stroke_frame`].
@@ -217,7 +220,8 @@ impl FxMaps {
             + usize::from(self.dout.is_some())
             + usize::from(self.dout_exact.is_some())
             + usize::from(self.vdout.is_some())
-            + usize::from(self.vdout_exact.is_some()))
+            + usize::from(self.vdout_exact.is_some())
+            + usize::from(self.vcov.is_some()))
             * n
             * 4
     }
diff --git a/crates/compose/src/effects/composite.rs b/crates/compose/src/effects/composite.rs
--- a/crates/compose/src/effects/composite.rs
+++ b/crates/compose/src/effects/composite.rs
@@ -27,8 +27,9 @@ pub(crate) fn map_count(layer: &Layer) -> usize {
     let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));
     let outside = usize::from(out_widths.iter().any(|&w| !stroke_outside_exact(w))) + usize::from(out_widths.iter().any(|&w| stroke_outside_exact(w)));
     let stroke = if has_stroke { 1 + outside } else { 0 };
-    let vector_outline =
-        if has_stroke && matches!(layer.content, photocraft_doc::LayerContent::Shape(_)) && crate::effect_outline(layer).is_none() { outside } else { 0 };
+    // An unfilled shape's vector outline: its distance fields and its estimated edge coverage.
+    let needs_vector_coverage = has_stroke && matches!(layer.content, photocraft_doc::LayerContent::Shape(_)) && crate::effect_outline(layer).is_none();
+    let vector_outline = if needs_vector_coverage { outside + 1 } else { 0 };
     1 + per + stroke + vector_outline
 }
 
@@ -85,11 +86,11 @@ pub(crate) fn build_maps_prepared(
     // Without an outline (unfilled shapes), estimate the vector outline from local coverage; it
     // picks the chamfer or the exact transform the same way.
     let outline = crate::effect_outline(layer).is_some();
-    let (vdout, vdout_exact) = if has_stroke && vector_shape && !outline {
+    let (vdout, vdout_exact, vcov) = if has_stroke && vector_shape && !outline {
         let lc = local_coverage(&shape);
-        (chamfer_out.then(|| dist_outside_by(&lc, Metric::Chamfer)), exact_out.then(|| dist_outside_by(&lc, Metric::Euclidean)))
+        (chamfer_out.then(|| dist_outside_by(&lc, Metric::Chamfer)), exact_out.then(|| dist_outside_by(&lc, Metric::Euclidean)), Some(lc.v))
     } else {
-        (None, None)
+        (None, None, None)
     };
     let mut frames: Vec<(u32, Rect)> = Vec::new();
     for e in &items {
@@ -102,7 +103,7 @@ pub(crate) fn build_maps_prepared(
             }
         }
     }
-    FxMaps { rect, shape, per, bevel_paint, din, dout, dout_exact, vdout, vdout_exact, outline, frames }
+    FxMaps { rect, shape, per, bevel_paint, din, dout, dout_exact, vdout, vdout_exact, vcov, outline, frames }
 }
 
 /// Far outside any shape (distance fill for cropped distance fields).
@@ -362,6 +363,7 @@ pub(crate) fn composite_with_effects_prepared(
     // coverage × opacity not yet taken by strokes above; for Normal strokes this equals stacking
     // them top over bottom.
     if strokes.iter().any(|st| widths(st).1 > 0.0) {
+        let estimated = if vector_outline { maps.vcov.as_ref().map(|c| maps.crop_vec(c, big, 1.0)) } else { None };
         let base = work.clone();
         let mut acc = vec![[0.0f32; 4]; w * h]; // premultiplied Σ share·blended
         let mut cover = vec![0.0f32; w * h];
@@ -384,8 +386,15 @@ pub(crate) fn composite_with_effects_prepared(
             let mut share = vec![0.0f32; w * h];
             for (i, sh) in share.iter_mut().enumerate() {
                 let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
-                let k =
-                    if inside(shape.v[i]) { if vector_shape && !maps.outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band };
+                // An unfilled outline has no path to measure an edge pixel against: its coverage is
+                // estimated from its neighbours, so the stroke fills the rest of the pixel beneath
+                // the edge (a hard 0 here let the backdrop show between a line and its stroke).
+                let k = if inside(shape.v[i]) {
+                    let cov = estimated.as_ref().and_then(|c| c.get(i)).copied().unwrap_or(shape.v[i]);
+                    band * outline_share(cov, lay.px[i][3])
+                } else {
+                    band
+                };
                 let mut k = k * st.common.opacity;
                 if st.common.blend == BlendMode::Dissolve {
                     let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64);
diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs
--- a/crates/compose/src/stroke_tests.rs
+++ b/crates/compose/src/stroke_tests.rs
@@ -323,3 +323,31 @@ fn outside_stroke_on_partial_fill_raster_layer_keeps_interior_free_of_stroke() {
     let stroke_pixel = px(&d, 13, 20);
     assert!(close4(stroke_pixel, [0.0, 0.0, 1.0, 1.0]), "outside stroke must be blue: {stroke_pixel:?}");
 }
+
+/// An open line (no fill) with an outside stroke: the stroke fills the rest of every anti-aliased
+/// edge pixel beneath the line, so the backdrop never shows between them (#9659). Rendered one
+/// pixel at a time, as a tile would: the edge coverage comes from the whole layer.
+#[test]
+fn outside_stroke_fills_the_edge_pixels_beneath_an_unfilled_line() {
+    let mut d = doc(80, 50);
+    let red = ShapeStroke { width: 6.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), align: StrokeAlign::Center, ..ShapeStroke::default() };
+    let path = Path::new(vec![Subpath::polyline(&[(5.0, 12.3), (40.0, 22.7), (75.0, 30.1)])]);
+    let mut sh = ShapeLayer { path, fill: None, stroke: Some(red), live: None, cache: None, psd_raw: None, ..ShapeLayer::default() };
+    sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 80, 50)));
+    let mut layer = Layer::new("line", LayerContent::Shape(sh));
+    layer.effects.items = vec![stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.0, 0.0)))];
+    d.layers.push(layer);
+    let mut light = Vec::new();
+    for y in 0..50 {
+        for x in 8..72 {
+            let xf = x as f32;
+            let centre = if xf < 40.0 { 12.3 + (xf - 5.0) * 10.4 / 35.0 } else { 22.7 + (xf - 40.0) * 7.4 / 35.0 };
+            // On the line and its stroke: red and the stroke's dark red have no green, the white backdrop does.
+            let p = px(&d, x, y);
+            if (y as f32 + 0.5 - centre).abs() < 3.6 && p[1] > 0.12 {
+                light.push((x, y, p));
+            }
+        }
+    }
+    assert!(light.is_empty(), "the backdrop shows between the line and its stroke at {:?}", &light[..light.len().min(6)]);
+}
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -2068,6 +2068,17 @@ fn the_effect_map_estimate_counts_the_maps_building_takes() {
         l.effects.items = fx;
         d.layers.push(l);
     }
+    // An unfilled shape (an open line) with an outside stroke also builds the vector outline's
+    // distance field and coverage (#9659); a filled one doesn't.
+    for (fill, name) in [(None, "unfilled line"), (Some(Fill::Solid(Color::rgb(0.3, 0.6, 0.9))), "filled shape")] {
+        let path = photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(14.0, 110.0), (30.0, 125.0), (44.0, 112.0)])]);
+        let line = photocraft_doc::vector::ShapeStroke { width: 4.0, ..Default::default() };
+        let mut sh = photocraft_doc::vector::ShapeLayer { path, fill, stroke: Some(line), live: None, cache: None, psd_raw: None, ..Default::default() };
+        sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
+        let mut l = Layer::new(name, LayerContent::Shape(sh));
+        l.effects.items = vec![stroke(2.0, StrokePosition::Outside), stroke(60.0, StrokePosition::Outside)];
+        d.layers.push(l);
+    }
     let patterns = pattern::PreparedPatterns::new(&d.patterns, pattern::PREPARED_PATTERN_BYTES);
     let cx = Ctx::for_doc(&d, &patterns);
     let mut todo = Vec::new();
diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl
--- a/crates/gpu/src/compose.wgsl
+++ b/crates/gpu/src/compose.wgsl
@@ -1262,22 +1262,32 @@ fn fs_fxstroke(in: VOut) -> @location(0) vec4<f32> {
     var cover = 0.0;
     var lay_a = 0.0;
     let vector = (op.flags & F_VECTOR) != 0u;
-    if (!first || outline || !vector) {
-        let cv = textureLoad(tex_c, p, 0);
-        cover = cv.r;
-        lay_a = cv.g;
-    }
+    let cv = textureLoad(tex_c, p, 0);
+    cover = cv.r;
+    lay_a = cv.g;
     var k = map_value(d, 0.0);
     if (outline) {
         k = k * outline_share(a, lay_a);
     } else if (a > INSIDE_EPS) {
         // Pixel layers: the stroke shows beneath only through the part the shape leaves
         // (`effects.rs`): (1 - a) / (1 - layer alpha), none beneath a fully covered pixel.
+        // Shape layers without an outline: the same, with the edge pixel's coverage estimated as
+        // its alpha relative to the largest around it (`effects::local_coverage`).
+        var edge = a;
         if (vector) {
-            k = 0.0;
-        } else {
-            k = k * outline_share(a, lay_a);
+            let dims = vec2<i32>(textureDimensions(tex_b));
+            var mx = a;
+            for (var dy = -1; dy <= 1; dy++) {
+                for (var dx = -1; dx <= 1; dx++) {
+                    let q = p + vec2<i32>(dx, dy);
+                    if (q.x >= 0 && q.y >= 0 && q.x < dims.x && q.y < dims.y) {
+                        mx = max(mx, textureLoad(tex_b, q, 0).a);
+                    }
+                }
+            }
+            edge = min(a / mx, 1.0);
         }
+        k = k * outline_share(edge, lay_a);
     }
     var cov = k * op.opacity;
     if (op.mode == M_DISSOLVE) {
diff --git a/crates/gpu/src/plan/planner.rs b/crates/gpu/src/plan/planner.rs
--- a/crates/gpu/src/plan/planner.rs
+++ b/crates/gpu/src/plan/planner.rs
@@ -42,7 +42,8 @@ pub const F_NO_LAYER: u32 = 32;
 /// carries the layer's alpha in `.g`).
 pub const F_OUTLINE: u32 = 131072;
 
-/// Shape layer (outside strokes never show inside it).
+/// Shape layer (outside strokes never show inside it, but for the part of an edge pixel the
+/// outline's estimated coverage leaves).
 pub const F_VECTOR: u32 = 64;
 
 /// Merge onto an opaque clipping base, keeping its alpha.
@@ -982,13 +983,13 @@ impl<'a> Planner<'a> {
             .filter(|(_, st)| stroke_widths(st).1 > 0.0)
             .collect();
         if !outs.is_empty() {
-            let (mut acc, mut cover): (Option<Slot>, Option<Slot>) = (None, None);
-            if outline || !vector_shape {
-                // The layer's alpha rides along the coverage: `effects::outline_share`, and for
-                // pixel layers the stroke's share beneath a partly covering layer (fill < 100 %).
-                let ll = self.retain(l);
-                cover = Some(init(self, ll, None, INIT_COVER, 1.0));
-            }
+            // The layer's alpha rides along the coverage: `effects::outline_share`, the stroke's
+            // share beneath a partly covering layer (fill < 100 %) and under the edge pixels of
+            // a shape's estimated outline. The stroke pass reads it unconditionally, so a shape
+            // layer whose only effect is an outside stroke now pays this one init pass too.
+            let mut acc: Option<Slot> = None;
+            let ll = self.retain(l);
+            let mut cover: Option<Slot> = Some(init(self, ll, None, INIT_COVER, 1.0));
             for (n, &(i, st)) in outs.iter().enumerate() {
                 let paint = self.fx_paint(&st.paint, anchor);
                 let flags = F_STROKE_OUT | if vector_shape { F_VECTOR } else { 0 } | if outline { F_OUTLINE } else { 0 } | if n == 0 { F_FIRST } else { 0 };
diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs
--- a/crates/gpu/tests/it/parity.rs
+++ b/crates/gpu/tests/it/parity.rs
@@ -2122,6 +2122,30 @@ fn stroke_effects_on_filled_and_stroked_shapes() {
     }
 }
 
+/// An open line (no fill) with Outside strokes: the stroke fills the rest of the line's edge
+/// pixels beneath it, as on the CPU (#9659).
+#[test]
+fn outside_strokes_under_the_edge_of_an_unfilled_line() {
+    let Some(mut g) = gpu() else { return };
+    use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
+    for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
+        for opacity in [1.0, 0.5] {
+            let mut d = fx_doc(90, 70, depth);
+            let path = Path::new(vec![Subpath::polyline(&[(8.0, 20.3), (45.0, 38.7), (82.0, 24.1)])]);
+            let line = ShapeStroke { width: 6.0, align: StrokeAlign::Center, opacity, paint: Fill::Solid(Color::rgb(0.9, 0.1, 0.1)), ..ShapeStroke::default() };
+            let mut sh = ShapeLayer { path, fill: None, stroke: Some(line), live: None, cache: None, psd_raw: None, ..ShapeLayer::default() };
+            sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
+            let mut l = Layer::new("line", LayerContent::Shape(sh));
+            l.effects.items = vec![
+                Effect::Stroke(stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)))),
+                Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.1, 0.0, 0.0)))),
+            ];
+            d.layers.push(l);
+            fx_check(&mut g, &d, &format!("{depth:?} unfilled line at {opacity}"));
+        }
+    }
+}
+
 /// Blend If on the GPU matches `compose::apply_blend_if` (#4395): this-layer and underlying
 /// ranges, hard and split points, per channel, on raster, masked, adjustment and clipped layers,
 /// with channel restrictions, and in grayscale. Hard points are compared on 8-bit documents only:
