collective

Browse / github.com/storytold/deckcraft / patch

Format: Line has a Transparency slider for solid lines

Format: Line has a Transparency slider for solid lines Format Shape ▸ Line had Color, Width and Dash but no Transparency, and `shape.line` had no parameter for it. The renderer and the PPTX writer already handled a line color's alpha. The Line Color pane documentation lists Transparency under Solid line: "move the Transparency slider or enter a number in the box next to the slider. You can vary the percentage of transparency from 0 (fully opaque, the default) to 100% (fully transparent)." ([Format Object (Line Color pane)](https://web.archive.org/web/20210127073218/https://support.microsoft.com/en-us/office/format-object-line-color-pane-dba8e3fd-

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #1398, open)
Base
a78d9a9a190013ce96fc46249a87a1167c23646a
Size
+317 −16 in 6 files
Review
pending
Shared
2026-10-28
Id
2f8c0a3eb2ba86bce4686982

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/cmd/shape.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panes.rs crates/ui-egui/src/pattern_fill_tests.rs docs/ui-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -154,6 +154,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-24 | Slide Show: Ctrl+wheel (Cmd+wheel on a Mac) over the slide zooms in and out around the pointer, four notches per doubling, up to 4×, as in PowerPoint; zooming back to the whole slide leaves the zoom, and the plain wheel still turns the page (#1286) | | 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) | | 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |+| 2026-10-25 | Format Shape ▸ Line: a Transparency slider for a solid or automatic line, 0 to 100 %, in one undo step (`shape.line {transparency}`). An automatic line becomes a solid line in its effective color | | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide | | 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again | | 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs--- a/crates/engine/src/cmd/shape.rs+++ b/crates/engine/src/cmd/shape.rs@@ -5,7 +5,7 @@ use std::collections::HashMap;  use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};-use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};+use deckcraft_model::{ColorBase, ColorRef, Effects, Fill, Geom, Line, ShapeStyle}; use serde_json::{Value, json};  use super::{@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Shape Outline",             ["Shape Format", "Shape Styles"],             None,-            "{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",+            "{color?, none?, transparency?: 0..1 (a solid line), width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",             has_selection,             line         ),@@ -685,6 +685,18 @@ fn line_end(v: &str) -> Option<LineEnd> {     if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } } +/// The line fill `shape` draws with: its own, or the one it inherits from its layout, master or+/// theme style, with a solid color resolved to RGB (alpha included). `None` when nothing gives+/// it one.+pub fn effective_line_fill(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Option<Fill> {+    let ctx = super::text::ctx_for(doc, sel)?;+    let (l, ph) = deckcraft_model::resolve::line(&ctx, shape);+    match l.fill? {+        Fill::Solid { color } => Some(Fill::solid(ColorRef::rgb(ctx.color(&color, ph)))),+        other => Some(other),+    }+}+ fn line(s: &mut Session, p: &Value) -> Result<Value> {     let reset = bool_or(p, "reset", false);     let none = bool_or(p, "none", false);@@ -712,11 +724,29 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {         "miter" => deckcraft_model::style::LineJoin::Miter,         _ => deckcraft_model::style::LineJoin::Round,     });+    let transparency = f64_param(p, "transparency");+    // Transparency on an inherited (style) line needs the effective color.+    let ctx_line = match transparency {+        None => HashMap::new(),+        Some(_) => {+            let st = s.doc()?;+            let mut lines = HashMap::new();+            for id in targets(s, p)? {+                if let Some(sh) = st.shape(id) {+                    deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| {+                        lines.insert(sh.id, effective_line_fill(&st.doc, &st.selection, sh));+                    });+                }+            }+            lines+        }+    };     edit_group_appearance(s, p, "shape.line", |sh| {         if reset {             sh.line = None;             return Ok(());         }+        let automatic = sh.line.is_none();         let l = sh.line.get_or_insert_with(Line::default);         if none {             l.fill = Some(Fill::None);@@ -764,6 +794,28 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {                 e.len.clone_from(sz);             }         }+        // Format Shape ▸ Line ▸ Transparency: only a solid line has one.+        if let Some(t) = transparency {+            let base = l.fill.clone().or_else(|| ctx_line.get(&sh.id).cloned().flatten());+            if let Some(Fill::Solid { mut color }) = base {+                // The new transparency replaces any alpha: the alpha transforms, and the alpha an+                // inherited color carries in its resolved RGBA. A theme, preset or system color+                // (`phClr` included) resolves to an RGBA that can carry an alpha of its own, so 0%+                // sets an explicit opaque alpha on it.+                color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_) | ColorTransform::AlphaMod(_) | ColorTransform::AlphaOff(_)));+                if let ColorBase::Rgb { rgb } = &mut color.base {+                    rgb.a = 255;+                }+                if t > 0.0 || !matches!(color.base, ColorBase::Rgb { .. }) {+                    color.mods.push(ColorTransform::Alpha(((1.0 - t.clamp(0.0, 1.0)) * 100_000.0).round() as i32));+                }+                l.fill = Some(Fill::Solid { color });+            }+        }+        // A parameter that changed nothing leaves an automatic line automatic.+        if automatic && sh.line.as_ref().is_some_and(|l| *l == Line::default()) {+            sh.line = None;+        }         Ok(())     }) }@@ -1592,6 +1644,118 @@ mod tests {         assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.is_none()));     } +    #[test]+    fn line_transparency_changes_solid_and_automatic_lines_in_one_undo_step() {+        let mut s = Session::with_new();+        let rect = |s: &mut Session, x: i32| {+            ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap() as u32)+        };+        let (auto, solid, none) = (rect(&mut s, 40), rect(&mut s, 140), rect(&mut s, 240));+        s.execute("shape.line", &json!({"ids": [solid], "color": "#ff0000", "width": 4})).unwrap();+        s.execute("shape.line", &json!({"ids": [none], "none": true})).unwrap();+        let auto_color = {+            let st = s.doc().unwrap();+            let ctx = super::super::text::ctx_for(&st.doc, &st.selection).unwrap();+            let (line, ph) = deckcraft_model::resolve::line(&ctx, st.shape(auto).unwrap());+            let Some(Fill::Solid { color }) = line.fill else { panic!("solid theme line, got {:?}", line.fill) };+            ctx.color(&color, ph)+        };+        let line = |s: &Session, id: ShapeId| s.doc().unwrap().shape(id).unwrap().line.clone();+        // A call that changes nothing (no end arrow to size) leaves the line automatic.+        s.execute("shape.line", &json!({"ids": [auto], "tailSize": "lg"})).unwrap();+        assert_eq!(line(&s, auto), None);+        let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();++        s.execute("shape.line", &json!({"ids": [auto, solid, none], "transparency": 0.4})).unwrap();+        let red = ColorRef::rgb(Rgba::rgb(255, 0, 0));+        let solid_line = line(&s, solid).unwrap();+        assert_eq!(solid_line.fill, Some(Fill::solid(red.clone().with(ColorTransform::Alpha(60_000)))));+        assert_eq!(solid_line.width, Some(4.0));+        assert_eq!(line(&s, auto).unwrap().fill, Some(Fill::solid(ColorRef::rgb(auto_color).with(ColorTransform::Alpha(60_000)))));+        assert_eq!(line(&s, none).unwrap().fill, Some(Fill::None));+        let changed = serde_json::to_value(&s.doc().unwrap().doc).unwrap();+        s.execute("edit.undo", &json!({})).unwrap();+        assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);+        s.execute("edit.redo", &json!({})).unwrap();+        assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), changed);++        // The transparency survives a PPTX save and reopen.+        let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();+        let back = deckcraft_pptx::import(&bytes).unwrap();+        let slide = s.doc().unwrap().selection.slide;+        let saved = back.slides[slide].shape(solid).and_then(|sh| sh.line.clone()).and_then(|l| l.fill);+        assert_eq!(saved, Some(Fill::solid(red.clone().with(ColorTransform::Alpha(60_000)))));++        // 0% takes the alpha off again.+        s.execute("shape.line", &json!({"ids": [solid], "transparency": 0.0})).unwrap();+        assert_eq!(line(&s, solid).unwrap().fill, Some(Fill::solid(red)));+    }++    #[test]+    fn line_transparency_replaces_the_alpha_an_automatic_line_inherits() {+        let mut s = Session::with_new();+        let id = ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [40, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap() as u32);+        // Every theme line style draws blue at 50% transparency.+        s.edit(|doc, _| {+            for l in &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.format.lines {+                l.fill = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 0, 255)).with(ColorTransform::Alpha(50_000))));+            }+            Ok(())+        })+        .unwrap();+        let effective = |s: &Session| {+            let st = s.doc().unwrap();+            effective_line_fill(&st.doc, &st.selection, st.shape(id).unwrap())+        };+        let Some(Fill::Solid { color }) = effective(&s) else { panic!("solid inherited line, got {:?}", effective(&s)) };+        assert!(matches!(color.base, ColorBase::Rgb { rgb } if (127..=128).contains(&rgb.a)), "{color:?}");++        s.execute("shape.line", &json!({"ids": [id], "transparency": 0.0})).unwrap();+        let own = s.doc().unwrap().shape(id).unwrap().line.clone().unwrap().fill;+        assert_eq!(own, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 0, 255)))));+        s.execute("shape.line", &json!({"ids": [id], "transparency": 0.25})).unwrap();+        let own = s.doc().unwrap().shape(id).unwrap().line.clone().unwrap().fill;+        assert_eq!(own, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 0, 255)).with(ColorTransform::Alpha(75_000)))));++        // A line's own relative alpha transforms go too, so 0% is opaque.+        s.edit(|doc, sel| {+            let sh = std::sync::Arc::make_mut(&mut doc.slides[sel.slide]).shape_mut(id).unwrap();+            let color = ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::AlphaMod(50_000)).with(ColorTransform::AlphaOff(-10_000));+            sh.line.as_mut().unwrap().fill = Some(Fill::solid(color));+            Ok(())+        })+        .unwrap();+        s.execute("shape.line", &json!({"ids": [id], "transparency": 0.0})).unwrap();+        let own = s.doc().unwrap().shape(id).unwrap().line.clone().unwrap().fill;+        assert_eq!(own, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(100_000)))));++        // A theme color can carry an alpha of its own, so 0% on a theme color is opaque as well.+        s.execute("design.colors", &json!({"colors": {"accent1": "#0000FF80"}})).unwrap();+        s.edit(|doc, sel| {+            let sh = std::sync::Arc::make_mut(&mut doc.slides[sel.slide]).shape_mut(id).unwrap();+            sh.line.as_mut().unwrap().fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent1)));+            Ok(())+        })+        .unwrap();+        s.execute("shape.line", &json!({"ids": [id], "transparency": 0.0})).unwrap();+        let Some(Fill::Solid { color }) = effective(&s) else { panic!("solid line, got {:?}", effective(&s)) };+        assert!(matches!(color.base, ColorBase::Rgb { rgb } if rgb.a == 255), "{color:?}");++        // `phClr` takes the style's color and its alpha, so 0% sets an explicit opaque alpha.+        s.edit(|doc, sel| {+            let sh = std::sync::Arc::make_mut(&mut doc.slides[sel.slide]).shape_mut(id).unwrap();+            sh.line.as_mut().unwrap().fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::PhClr).with(ColorTransform::Alpha(40_000))));+            sh.style.as_mut().unwrap().line_ref.1 = ColorRef::rgb(Rgba::rgb(0, 0, 255)).with(ColorTransform::Alpha(50_000));+            Ok(())+        })+        .unwrap();+        s.execute("shape.line", &json!({"ids": [id], "transparency": 0.0})).unwrap();+        let own = s.doc().unwrap().shape(id).unwrap().line.clone().unwrap().fill;+        assert_eq!(own, Some(Fill::solid(ColorRef::scheme(SchemeSlot::PhClr).with(ColorTransform::Alpha(100_000)))));+        let Some(Fill::Solid { color }) = effective(&s) else { panic!("solid line, got {:?}", effective(&s)) };+        assert!(matches!(color.base, ColorBase::Rgb { rgb } if rgb.a == 255), "{color:?}");+    }+     #[test]     fn group_fill_transparency_keeps_each_members_inherited_colour() {         let (mut s, members, group, _) = nested_group();diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -5295,8 +5295,10 @@ mod tests {         harness.run_steps(3);         harness.check_accessibility();         let sliders: Vec<_> = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").collect();-        assert_eq!(sliders.len(), 1, "one fill Transparency slider");-        assert!(sliders[0].rect().width() > 40.0, "the slider keeps room next to its one visible label: {:?}", sliders[0].rect());+        assert_eq!(sliders.len(), 2, "the fill's and the line's Transparency sliders");+        for s in &sliders {+            assert!(s.rect().width() > 40.0, "the slider keeps room next to its one visible label: {:?}", s.rect());+        }     }      /// A control `ui.screenshot` is admitted once, issued from a settled real UI pass, completed bydiff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs--- a/crates/ui-egui/src/panes.rs+++ b/crates/ui-egui/src/panes.rs@@ -1,7 +1,8 @@ //! Task panes on the right: Format Shape, Format Background, Animation, Selection, Comments and //! Design Ideas. -use deckcraft_model::{AnimClass, AnimStart, Fill, ShapeKind};+use deckcraft_color::ColorTransform;+use deckcraft_model::{AnimClass, AnimStart, ColorBase, Fill, ShapeKind}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -165,6 +166,33 @@ fn solid_fill_transparency_percent(fill: Option<&Fill>) -> f32 {     ((1.0 - color.alpha()) * 100.0) as f32 } +/// Percent transparency (0 = opaque) of the line a shape draws: its own fill when it has one,+/// otherwise `effective` (the line resolved to RGBA, inherited or not). `None` when that line is+/// not solid, so Format Shape ▸ Line shows no Transparency row.+fn line_transparency_percent(own: Option<&Fill>, effective: Option<&Fill>) -> Option<f32> {+    let Some(Fill::Solid { color }) = own.or(effective) else {+        return None;+    };+    let rgba_alpha = |c: &deckcraft_model::ColorRef| match &c.base {+        ColorBase::Rgb { rgb } => f64::from(rgb.a) / 255.0,+        _ => 1.0,+    };+    let set = color.mods.iter().any(|m| matches!(m, ColorTransform::Alpha(_)));+    let relative = color.mods.iter().any(|m| matches!(m, ColorTransform::AlphaMod(_) | ColorTransform::AlphaOff(_)));+    let alpha = if set && !relative {+        color.alpha()+    } else {+        // `alphaMod`, `alphaOff` and the alpha of the style color `phClr` takes apply only in+        // the resolved color.+        let resolved = match effective {+            Some(Fill::Solid { color: c }) => c,+            _ => color,+        };+        rgba_alpha(resolved)+    };+    Some(((1.0 - alpha) * 100.0) as f32)+}+ /// Fraction to pass to `shape.fill` when the slider moved. An unchanged value (a focus /// loss, or switching to another shape) is `None`, so the pane does not rewrite the fill. fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) -> Option<f64> {@@ -213,6 +241,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {         return;     };     let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh);+    let effective_line = deckcraft_engine::cmd::shape::effective_line_fill(&st.doc, &st.selection, &sh);     let uid = st.uid;     // Tabs as icons.     ui.horizontal(|ui| {@@ -551,6 +580,25 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) {                 Some(c) => ("shape.line".into(), json!({"color": cref_param(&c)})),                 None => ("shape.line".into(), json!({"none": true})),             });+            // `shape.line {transparency}` changes a solid line (an automatic one through its+            // effective color).+            let line_fill = sh.line.as_ref().and_then(|l| l.fill.as_ref());+            if let Some(seed) = line_transparency_percent(line_fill, effective_line.as_ref()) {+                ui.horizontal(|ui| {+                    let label = ui.label("Transparency");+                    let id = ui.scope_id().with(("ltr", sh.id.0));+                    let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed));+                    let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id);+                    if r.drag_stopped() || r.lost_focus() {+                        if let Some(t) = fill_transparency_to_apply(v, seed) {+                            run(app, "shape.line", json!({"transparency": t}));+                        }+                        ui.data_mut(|d| d.remove::<f32>(id));+                    } else if r.dragged() || r.changed() {+                        ui.data_mut(|d| d.insert_temp(id, v));+                    }+                });+            }             ui.horizontal(|ui| {                 ui.label("Width");                 let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75);@@ -1589,13 +1637,97 @@ mod tests {         // 0.37 exposes the slider's numeric entry as a SpinButton with the same name; target the         // Slider itself so the click lands on the slider, not its text field. A click at its         // middle is a real edit (50%), not a side effect of opening the pane or selecting b.-        h.get_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").click();+        // The Fill section's slider comes before the Line section's.+        h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").next().unwrap().click();         h.run_steps(2);         assert_eq!(solid_alpha(h.state(), b), Some(50_000));         let changed: Vec<f64> = slider_values(&h);         assert!(changed.iter().any(|v| (*v - 50.0).abs() < 0.5), "the changed slider shows 50%, got {changed:?}");     } +    /// Format Shape ▸ Line ▸ Transparency shows a solid line's transparency and sets it in one+    /// undo step; No line has no Transparency row.+    #[test]+    fn line_transparency_slider_shows_and_sets_the_lines_transparency() {+        let mut h = fill_pane(Some(json!({"none": true})));+        let line_alpha = |h: &Harness<'_, SlideApp>| {+            let doc = h.state().session.active().unwrap();+            match doc.selected_shapes()[0].line.as_ref().and_then(|l| l.fill.as_ref()) {+                Some(Fill::Solid { color }) => color.mods.iter().find_map(|m| match m {+                    ColorTransform::Alpha(v) => Some(*v),+                    _ => None,+                }),+                other => panic!("expected a solid line, got {other:?}"),+            }+        };+        h.state_mut().session.execute("shape.line", &json!({"color": "#ff0000", "transparency": 0.4})).unwrap();+        h.run_steps(3);+        let rows = || h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count();+        assert_eq!(rows(), 1, "no fill, so the one Transparency row is the line's");+        let shown = slider_values(&h);+        assert!(shown.iter().any(|v| (*v - 40.0).abs() < 0.5), "a 40% transparent line should show 40%, got {shown:?}");++        h.get_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").click();+        h.run_steps(2);+        assert_eq!(line_alpha(&h), Some(50_000));+        h.state_mut().session.execute("edit.undo", &json!({})).unwrap();+        h.run_steps(2);+        assert_eq!(line_alpha(&h), Some(60_000));++        h.state_mut().session.execute("shape.line", &json!({"none": true})).unwrap();+        h.run_steps(3);+        assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(), 0, "No line");+    }++    /// An automatic line shows the transparency it inherits from the theme's line style.+    #[test]+    fn line_transparency_slider_shows_an_inherited_lines_transparency() {+        let mut h = fill_pane(Some(json!({"none": true})));+        h.state_mut()+            .session+            .edit(|doc, _| {+                for l in &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.format.lines {+                    l.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)).with(ColorTransform::Alpha(50_000))));+                }+                Ok(())+            })+            .unwrap();+        h.run_steps(3);+        assert!(h.state().session.active().unwrap().selected_shapes()[0].line.is_none(), "the line is automatic");+        let shown = slider_values(&h);+        assert!(shown.iter().any(|v| (*v - 50.0).abs() < 0.5), "a line inheriting 50% should show 50%, got {shown:?}");++        // A line's own `alphaMod` shows as the transparency it draws with.+        h.state_mut()+            .session+            .edit(|doc, sel| {+                let id = sel.shapes[0];+                let sh = std::sync::Arc::make_mut(&mut doc.slides[sel.slide]).shape_mut(id).unwrap();+                let color = ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::AlphaMod(30_000));+                sh.line = Some(deckcraft_model::Line { fill: Some(Fill::solid(color)), ..Default::default() });+                Ok(())+            })+            .unwrap();+        h.run_steps(3);+        let shown = slider_values(&h);+        assert!(shown.iter().any(|v| (*v - 70.0).abs() < 0.5), "alphaMod 30% should show 70%, got {shown:?}");++        // A `phClr` line shows the alpha of the style color it takes.+        h.state_mut()+            .session+            .edit(|doc, sel| {+                let id = sel.shapes[0];+                let sh = std::sync::Arc::make_mut(&mut doc.slides[sel.slide]).shape_mut(id).unwrap();+                sh.line = Some(deckcraft_model::Line { fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::PhClr))), ..Default::default() });+                sh.style.as_mut().unwrap().line_ref.1 = ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)).with(ColorTransform::Alpha(25_000));+                Ok(())+            })+            .unwrap();+        h.run_steps(3);+        let shown = slider_values(&h);+        assert!(shown.iter().any(|v| (*v - 75.0).abs() < 0.5), "a phClr line on a 25% opaque style color should show 75%, got {shown:?}");+    }+     /// A blank slide with one selected rectangle, its fill set by `fill` (none for an automatic fill),     /// in Format Shape ▸ Fill.     fn fill_pane(fill: Option<serde_json::Value>) -> Harness<'static, SlideApp> {@@ -1635,12 +1767,13 @@ mod tests {     fn fill_color_and_transparency_show_only_where_they_change_the_fill() {         let gradient = json!({"gradient": {"stops": [[0, "accent1"], [1, "accent2"]], "angle": 90}});         for (name, fill, color, transparency) in [-            ("automatic", None, true, 1),-            ("solid", Some(json!({"color": "accent2"})), true, 1),-            ("no fill", Some(json!({"none": true})), false, 0),-            // The one Transparency row left is the selected gradient stop's.-            ("gradient", Some(gradient), false, 1),-            ("picture", Some(picture_fill(0.0)), false, 1),+            // Each count includes the Line section's row for the rectangle's automatic line.+            ("automatic", None, true, 2),+            ("solid", Some(json!({"color": "accent2"})), true, 2),+            ("no fill", Some(json!({"none": true})), false, 1),+            // The other Transparency row is the selected gradient stop's.+            ("gradient", Some(gradient), false, 2),+            ("picture", Some(picture_fill(0.0)), false, 2),         ] {             let h = fill_pane(fill);             let fill_color = color_buttons(&h) == 2;diff --git a/crates/ui-egui/src/pattern_fill_tests.rs b/crates/ui-egui/src/pattern_fill_tests.rs--- a/crates/ui-egui/src/pattern_fill_tests.rs+++ b/crates/ui-egui/src/pattern_fill_tests.rs@@ -79,7 +79,8 @@ fn shape_pattern_fill_picks_a_preset_from_the_gallery() {     let (s, id) = patterned_shape();     let mut h = harness(SlideApp::new(s, Services::default()), "format");     let _ = h.get_by_label("Pattern fill");-    assert!(h.query_by_label("Transparency").is_none(), "a pattern has no solid-fill transparency");+    // The one Transparency slider left is the automatic line's.+    assert_eq!(h.query_all_by_role_and_label(Role::Slider, "Transparency").count(), 1, "a pattern has no solid-fill transparency");     assert_eq!(crate::fillui::pattern_gallery().count(), 48);     for name in crate::fillui::pattern_gallery() {         assert!(deckcraft_render::pattern_presets().any(|p| p == name), "{name}");@@ -143,8 +144,8 @@ fn shape_solid_fill_keeps_its_transparency_without_a_pattern_gallery() {     s.execute("shape.fill", &json!({"color": "accent2"})).unwrap();     let h = harness(SlideApp::new(s, Services::default()), "format");     // 0.37 exposes the slider's numeric entry as a SpinButton with the same name; the slider is-    // the control the user drags.-    let _ = h.get_by_role_and_label(Role::Slider, "Transparency");+    // the control the user drags. The second slider is the automatic line's.+    assert_eq!(h.query_all_by_role_and_label(Role::Slider, "Transparency").count(), 2);     assert!(h.query_by_label("Foreground color").is_none());     assert!(h.query_by_label("Zig zag").is_none()); }diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -65,7 +65,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).  | Pane / dialog | PowerPoint | DeckCraft | |---|---|---|-| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), pattern fill (gallery of the 48 presets PowerPoint offers, foreground and background colors), line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) |+| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), pattern fill (gallery of the 48 presets PowerPoint offers, foreground and background colors), line (transparency, dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) | | Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master | | Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |