Browse / github.com/storytold/deckcraft / patch
Format: gradient fill has a Brightness slider for the selected stop
Format: gradient fill has a Brightness slider for the selected stop Format Shape ▸ Fill ▸ Gradient fill had no Brightness control for a gradient stop. PowerPoint's gradient stops have Color, Position, Transparency and Brightness; DeckCraft had the first three. The slider is now on the selected stop, in Format Shape and in Format Background (they share the gradient editor). Brightness is a color transform, not a new field, so no engine command changes: the pane writes the stop color through the existing `shape.fill {gradient}` / `design.background {gradient}` parameters, which already carry `lumMod` and `lumOff`. ## What PowerPoint does (observed) New tempor
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Harrison-Booth (imported from pull request #1432, open)
- Base
27009cd941b22cb0788cd5040f9fad91472230c7- Size
- +182 −8 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
40f63eef26ac59f555ace318
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/engine/src/cmd/mod.rs crates/ui-egui/src/fillui.rs crates/ui-egui/src/gradient_stop_tests.rs crates/ui-egui/src/panes.rs crates/ui-egui/src/ribbon.rs docs/ui-parity.md
@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-26 | Format Shape and Format Background ▸ Gradient fill: a Brightness slider for the selected stop, written as `lumMod` and `lumOff` as PowerPoint writes it | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -377,7 +377,8 @@ pub(crate) fn ok() -> Result<Value> { /// Parse a colour param: `"#RRGGBB"`, `"accent1"`…, or `{"scheme":"accent2","lumMod":75000}`. /// A preset or system colour keeps its base: `{"preset":"red"}`,-/// `{"system":"windowText","last":"#000000"}`.+/// `{"system":"windowText","last":"#000000"}`. Transforms in the order that matters go in+/// `"mods": [["tint", 50000], ["lumMod", 60000]]`; without it the object fields apply in key order. pub(crate) fn color_param(p: &Value, key: &str) -> Option<deckcraft_model::ColorRef> { let v = p.get(key)?; color_value(v)@@ -412,10 +413,12 @@ pub(crate) fn color_value(v: &Value) -> Option<deckcraft_model::ColorRef> { } else { ColorRef::rgb(deckcraft_color::Rgba::from_hex(o.get("rgb").and_then(Value::as_str)?)?) };- for (k, val) in o {- if let Some(t) = deckcraft_color::ColorTransform::from_xml(k, val.as_i64().and_then(|x| i32::try_from(x).ok())) {- c.mods.push(t);- }+ let transform = |k: &str, val: &Value| deckcraft_color::ColorTransform::from_xml(k, val.as_i64().and_then(|x| i32::try_from(x).ok()));+ if let Some(list) = o.get("mods").and_then(Value::as_array) {+ // The ordered form: `[[name, value], …]`, applied in sequence.+ c.mods.extend(list.iter().filter_map(|m| transform(m.get(0)?.as_str()?, m.get(1).unwrap_or(&Value::Null))));+ } else {+ c.mods.extend(o.iter().filter_map(|(k, val)| transform(k, val))); } Some(c) }@@ -1,5 +1,5 @@ //! The gradient editor of the Format pane (shape fill and slide background): preset variations,-//! type, direction, angle and a stop bar (add, drag, recolour, set transparency, remove stops); and+//! type, direction, angle and a stop bar (add, drag, recolour, set transparency and brightness, remove stops); and //! the pattern editor: a gallery of the preset patterns with foreground and background colors. use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot};@@ -52,6 +52,32 @@ fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef { c } +/// Brightness (-1..1) of a stop colour as PowerPoint's slider shows it: `lumMod` and `lumOff` read+/// back as the lighter or darker amount, and a colour without them is 0.+pub fn brightness_of(c: &ColorRef) -> f64 {+ let find = |f: fn(&ColorTransform) -> Option<i32>| c.mods.iter().find_map(f).map(|v| v as f64 / 100_000.0);+ let off = find(|m| if let ColorTransform::LumOff(v) = m { Some(*v) } else { None }).unwrap_or(0.0);+ let modulate = find(|m| if let ColorTransform::LumMod(v) = m { Some(*v) } else { None }).unwrap_or(1.0);+ (if off != 0.0 { off } else { modulate - 1.0 }).clamp(-1.0, 1.0)+}++/// `c` at brightness `b` (-1..1), written as PowerPoint writes it: lighter is `lumMod` 1-b with+/// `lumOff` b, darker is `lumMod` 1+b. Other transforms, such as alpha, are kept.+pub fn with_brightness(mut c: ColorRef, b: f64) -> ColorRef {+ let is_lum = |m: &ColorTransform| matches!(m, ColorTransform::LumMod(_) | ColorTransform::LumOff(_));+ // Transforms apply in order, so the new pair takes the old pair's place (or goes first).+ let at = c.mods.iter().position(is_lum).unwrap_or(0);+ c.mods.retain(|m| !is_lum(m));+ let b = b.clamp(-1.0, 1.0);+ let ppm = |v: f64| (v * 100_000.0).round() as i32;+ if b > 0.0 {+ c.mods.splice(at..at, [ColorTransform::LumMod(ppm(1.0 - b)), ColorTransform::LumOff(ppm(b))]);+ } else if b < 0.0 {+ c.mods.insert(at, ColorTransform::LumMod(ppm(1.0 + b)));+ }+ c+}+ fn c32(c: &ColorRef, sc: &ColorScheme) -> Color32 { theme::to_color32(c.resolve(sc, None)) }@@ -321,6 +347,21 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> out = Some(ng); } });+ ui.horizontal(|ui| {+ let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Brightness"));+ let key = ui.scope_id().with((id, "br", sel));+ let mut br: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or((brightness_of(&s.color) * 100.0).round() as f32));+ let r = ui.add(egui::Slider::new(&mut br, -100.0..=100.0).suffix("%")).labelled_by(label.id);+ if r.changed() {+ ui.data_mut(|d| d.insert_temp(key, br));+ }+ if r.drag_stopped() || (r.changed() && !r.dragged()) {+ let mut ng = g.clone();+ ng.stops[sel].color = with_brightness(s.color.clone(), br as f64 / 100.0);+ ui.data_mut(|d| d.remove::<f32>(key));+ out = Some(ng);+ }+ }); } ui.data_mut(|d| d.insert_temp(sel_id, sel)); outnew file mode 100644@@ -0,0 +1,113 @@+//! Format Shape ▸ Fill ▸ Gradient fill: the Brightness slider of the selected gradient stop.++use deckcraft_color::{ColorTransform, SchemeSlot};+use deckcraft_engine::Session;+use deckcraft_model::{ColorRef, Fill, ShapeId};+use egui::accesskit::Role;+use egui::{Event, Modifiers, PointerButton};+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::json;++use crate::fillui::{brightness_of, with_brightness};+use crate::{Services, SlideApp};++/// The values PowerPoint writes for its Brightness slider, read from a saved deck: +40% on accent 1+/// is `lumMod` 60000 with `lumOff` 40000, -40% on red is `lumMod` 60000 alone.+#[test]+fn brightness_is_written_as_powerpoint_writes_it() {+ let accent = ColorRef::scheme(SchemeSlot::Accent1);+ assert_eq!(with_brightness(accent.clone(), 0.4).mods, vec![ColorTransform::LumMod(60_000), ColorTransform::LumOff(40_000)]);+ assert_eq!(with_brightness(accent.clone(), -0.4).mods, vec![ColorTransform::LumMod(60_000)]);+ assert!(with_brightness(accent, 0.0).mods.is_empty());+}++#[test]+fn brightness_reads_back_what_it_wrote_and_keeps_alpha() {+ let accent = ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(75_000));+ for b in [-1.0, -0.4, 0.0, 0.25, 0.6, 1.0] {+ let c = with_brightness(accent.clone(), b);+ assert!((brightness_of(&c) - b).abs() < 1e-9, "{b}");+ assert!(c.mods.contains(&ColorTransform::Alpha(75_000)), "{b}");+ }+ // Changing brightness replaces the old pair rather than stacking onto it.+ let twice = with_brightness(with_brightness(accent, 0.6), -0.2);+ assert_eq!(twice.mods.iter().filter(|m| matches!(m, ColorTransform::LumMod(_) | ColorTransform::LumOff(_))).count(), 1);+ // Out-of-range input is clamped.+ assert_eq!(brightness_of(&with_brightness(ColorRef::scheme(SchemeSlot::Accent1), 9.0)), 1.0);+}++/// An order-sensitive transform before the pair stays before it, and one after stays after.+#[test]+fn brightness_keeps_the_place_of_the_luminance_transforms() {+ let c = ColorRef::scheme(SchemeSlot::Accent1)+ .with(ColorTransform::Tint(50_000))+ .with(ColorTransform::LumMod(60_000))+ .with(ColorTransform::LumOff(40_000))+ .with(ColorTransform::Alpha(75_000));+ let darker = with_brightness(c, -0.2);+ assert_eq!(darker.mods, vec![ColorTransform::Tint(50_000), ColorTransform::LumMod(80_000), ColorTransform::Alpha(75_000)]);+}++/// The order survives the command boundary: `shape.fill` gets the stop as `cref_param` writes it.+#[test]+fn transform_order_survives_the_gradient_command() {+ let c = ColorRef::scheme(SchemeSlot::Accent1)+ .with(ColorTransform::Tint(50_000))+ .with(ColorTransform::LumMod(60_000))+ .with(ColorTransform::LumOff(40_000))+ .with(ColorTransform::Alpha(75_000));+ let mut s = Session::with_new();+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+ let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();+ s.execute(+ "shape.fill",+ &json!({"id": id, "gradient": {"stops": [[0.0, crate::ribbon::cref_param(&c)], [1.0, "accent2"]], "kind": "linear", "angle": 90}}),+ )+ .unwrap();+ let shape = s.active().unwrap().shape(ShapeId(u32::try_from(id).unwrap())).unwrap();+ let Some(Fill::Gradient(g)) = &shape.fill else { panic!("expected a gradient fill") };+ assert_eq!(g.stops[0].color.mods, c.mods);+ // A colour with one transform, or none, keeps the plain object form.+ assert!(crate::ribbon::cref_param(&ColorRef::scheme(SchemeSlot::Accent1)).get("mods").is_none());+}++fn stop_color(h: &Harness<'_, SlideApp>, id: ShapeId, stop: usize) -> ColorRef {+ match &h.state().session.active().unwrap().shape(id).unwrap().fill {+ Some(Fill::Gradient(g)) => g.stops[stop].color.clone(),+ other => panic!("expected a gradient fill, got {other:?}"),+ }+}++/// The slider on the selected stop (the first) writes a lighter colour, and shows it afterwards.+#[test]+fn the_brightness_slider_lightens_the_selected_stop() {+ let mut s = Session::with_new();+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();+ let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 120.0, 80.0]})).unwrap()["id"].as_u64().unwrap();+ s.execute("edit.select", &json!({"ids": [id]})).unwrap();+ s.execute("shape.fill", &json!({"gradient": {"stops": [[0.0, "accent1"], [1.0, "accent2"]], "kind": "linear", "angle": 90}})).unwrap();+ let mut app = SlideApp::new(s, Services::default());+ app.ui.pane = Some("format".into());+ app.ui.format_tab = "fill".into();+ let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ h.run_steps(3);+ let id = ShapeId(u32::try_from(id).unwrap());+ assert_eq!(brightness_of(&stop_color(&h, id, 0)), 0.0);++ let r = h.get_by_role_and_label(Role::Slider, "Brightness").rect();+ let pos = egui::pos2(r.min.x + r.width() * 0.75, r.center().y);+ for pressed in [true, false] {+ h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });+ h.run_steps(2);+ }+ let lighter = brightness_of(&stop_color(&h, id, 0));+ assert!(lighter > 0.3 && lighter < 0.7, "a click three quarters along lightens the stop, got {lighter}");+ assert_eq!(brightness_of(&stop_color(&h, id, 1)), 0.0, "the other stop is untouched");+}@@ -1499,6 +1499,10 @@ mod smartart_tests; #[path = "background_tests.rs"] mod background_tests; +#[cfg(test)]+#[path = "gradient_stop_tests.rs"]+mod gradient_stop_tests;+ #[cfg(test)] #[path = "pattern_fill_tests.rs"] mod pattern_fill_tests;@@ -656,6 +656,17 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { /// A ColorRef as a command colour parameter. pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value {+ let mut v = cref_fields(c);+ // A JSON object has no order, but transforms apply in sequence, so two or more also go in `mods`.+ if c.mods.len() > 1+ && let Value::Object(o) = &mut v+ {+ o.insert("mods".into(), c.mods.iter().map(|m| json!([m.xml_name(), m.value()])).collect());+ }+ v+}++fn cref_fields(c: &deckcraft_model::ColorRef) -> Value { match &c.base { deckcraft_model::ColorBase::Scheme { slot } => { let mut o = serde_json::Map::new();@@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (gradient stop Brightness; previous: trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,@@ -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 (per-stop color, position, transparency and brightness), 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) | | 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 |@@ -209,6 +209,7 @@ consistency is open (#73, #56). | 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps | | 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas | | 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss |+| 2026-10-26 | trivial | Format Shape and Format Background ▸ Gradient fill: a Brightness slider for the selected stop, written as `lumMod` and `lumOff` as PowerPoint writes it | | 2026-10-14 | minor | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog | | 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T | | 2026-10-13 | minor | Grid Options dialog: spacing, snap, and whether gridlines and guides are drawn; the on-screen grid uses that spacing |