From 1a0ffc6413c61594ab2d57e3e745bedfcbf28e7b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:56:23 +0300 Subject: [PATCH] Draw: pen pressure scales ink width A pen reading of 1 is the full pen width. A lighter touch draws a thinner stroke, and a zero reading stays visible at 8% of the pen. A mouse, a missing or non-finite reading, and a stroke whose samples are all the old placeholder 0.5 still draw at the pen width. PPTX keeps one width per stroke: the mean of the samples for a pressured stroke, the pen width for a legacy one. Tilt is not read. Co-authored-by: Cursor --- ROADMAP.md | 4 ++- crates/engine/src/cmd/selection_tests.rs | 2 +- crates/engine/src/tests.rs | 41 +++++++++++++++++----- crates/engine/src/tools.rs | 23 +++++++++++-- crates/mcp/src/headless.rs | 3 +- crates/model/src/lib.rs | 21 +++++++++++- crates/model/src/tests.rs | 13 +++++++ crates/pptx/src/write/shapes.rs | 11 +++++- crates/pptx/tests/roundtrip.rs | 35 +++++++++++++++++-- crates/render/src/lib.rs | 29 +++++++++------- crates/render/src/tests.rs | 43 ++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 29 ++++++++++++++-- crates/ui-egui/src/control.rs | 7 ++-- docs/control-protocol.md | 2 +- docs/gaps.md | 5 +-- docs/hardware-parity.md | 9 ++--- docs/roadmap.md | 10 +++--- docs/target-app-parity.md | 7 ++-- 18 files changed, 244 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..49390d16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: pen pressure scales ink width; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | @@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: pen pressure scales ink width | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | diff --git a/crates/engine/src/cmd/selection_tests.rs b/crates/engine/src/cmd/selection_tests.rs index 11d99b7c..69bcc3c8 100644 --- a/crates/engine/src/cmd/selection_tests.rs +++ b/crates/engine/src/cmd/selection_tests.rs @@ -201,7 +201,7 @@ fn locked_objects_are_picked_on_the_canvas_but_not_dragged() { s.execute("shape.lock", &json!({"id": a, "locked": true})).unwrap(); s.execute("edit.select", &json!({"ids": []})).unwrap(); let n = s.doc().unwrap().history.undo.len(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // A: x 20..120, y 50..130. s.pointer(ev(PointerKind::Down, 60.0, 90.0)).unwrap(); assert_eq!(selected(&s), vec![a], "a click selects a locked object"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..09a0f0d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -126,7 +126,7 @@ fn align_and_distribute() { fn pointer_draw_move_resize() { let mut s = session(); s.set_tool(ToolKind::Shape { preset: "rect".into() }); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 100.0, 100.0)).unwrap(); s.pointer(ev(PointerKind::Drag, 200.0, 180.0)).unwrap(); let r = s.pointer(ev(PointerKind::Up, 200.0, 180.0)).unwrap(); @@ -157,7 +157,7 @@ fn pointer_draw_move_resize() { #[test] fn click_into_placeholder_starts_editing() { let mut s = session(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::Down, 480.0, 200.0)).unwrap(); s.pointer(ev(PointerKind::Up, 480.0, 200.0)).unwrap(); assert!(s.doc().unwrap().selection.text.is_some()); @@ -290,7 +290,7 @@ fn drawing_a_line_between_shapes_glues_it() { let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap(); s.execute("edit.deselect", &json!({})).unwrap(); s.tool.kind = tools::ToolKind::Shape { preset: "straightConnector1".into() }; - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None }; // Right site of the ellipse (200,150) → left site of the rect (400,150), a little off. s.pointer(ev(tools::PointerKind::Down, 202.0, 151.0)).unwrap(); s.pointer(ev(tools::PointerKind::Drag, 300.0, 150.0)).unwrap(); @@ -323,7 +323,7 @@ fn freeform_tools_and_command() { s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().shapes().len(), before + 1); - let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0 }; + let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None }; // Polygon: click, click, click, click on the start → closed, filled. s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() }); for (x, y) in [(300.0, 300.0), (400.0, 300.0), (400.0, 400.0)] { @@ -376,7 +376,7 @@ fn double_click_in_table_uses_grown_row_heights() { for row in 0..3 { s.execute("table.rowHeight", &json!({"id": id, "row": row, "height": 4.0})).unwrap(); } - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; s.pointer(ev(PointerKind::DoubleClick, 120.0, 106.0)).unwrap(); assert_eq!(s.doc().unwrap().selection.text.as_ref().and_then(|t| t.cell), Some((0, 0))); } @@ -580,7 +580,7 @@ fn shapes_off_the_slide_can_be_selected_and_moved() { // Inserting off the slide keeps the box where it was asked for. assert_eq!(s.execute("shape.inspect", &json!({"id": off})).unwrap()["box"]["x"], 1000.0); s.execute("edit.select", &json!({"ids": []})).unwrap(); - let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }; // Hover and click wholly off the slide. assert!(matches!(s.hit_test(deckcraft_geom::Point::new(1060.0, 140.0), 3.0), Some(tools::Hit::Shape { id, .. }) if id.0 as u64 == off)); s.pointer(ev(PointerKind::Down, 1060.0, 140.0)).unwrap(); @@ -698,9 +698,9 @@ fn ime_composition_is_kept_by_a_key_a_click_or_another_command() { assert_eq!(s.doc().unwrap().history.undo.len(), undo0 + 1); s.compose_text("녕", None).unwrap(); - s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Move, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_some(), "hovering keeps composing"); - s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0 }).unwrap(); + s.pointer(PointerEvent { kind: PointerKind::Down, x: 5.0, y: 5.0, mods: Mods::default(), tol: 4.0, pressure: None }).unwrap(); assert!(s.doc().unwrap().composition.is_none(), "a click settles it"); assert_eq!(title_text(&s), "안녕"); @@ -835,3 +835,28 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() { + let mut s = session(); + s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 }); + let ev = |kind, x, y, pressure| tools::PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure }; + s.pointer(ev(PointerKind::Down, 40.0, 80.0, Some(0.2))).unwrap(); + s.pointer(ev(PointerKind::Drag, 80.0, 80.0, Some(1.0))).unwrap(); + s.pointer(ev(PointerKind::Drag, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Up, 120.0, 80.0, Some(0.4))).unwrap(); + s.pointer(ev(PointerKind::Down, 40.0, 160.0, None)).unwrap(); + s.pointer(ev(PointerKind::Drag, 100.0, 160.0, Some(f32::NAN))).unwrap(); + s.pointer(ev(PointerKind::Up, 100.0, 160.0, None)).unwrap(); + let got = ink_pressures(&s); + assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]); +} + +fn ink_pressures(s: &Session) -> Vec> { + let st = s.doc().unwrap(); + let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else { + return vec![]; + }; + let deckcraft_model::ShapeKind::Ink { strokes } = &sh.kind else { return vec![] }; + strokes.iter().map(|stroke| stroke.points.iter().map(|p| p.2).collect()).collect() +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..915eb440 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -40,6 +40,10 @@ pub struct PointerEvent { pub mods: Mods, /// Hit tolerance in points (≈ 4 screen px at the current zoom). pub tol: f64, + /// Pen pressure `0..=1` when the device reported one. `None` is a mouse: the stroke + /// is stored at full pen width. + #[serde(default)] + pub pressure: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -178,6 +182,8 @@ pub struct ToolState { pub path_preview: Vec, pub path_smooth: bool, pub path_closing: bool, + /// Pressure from the pointer event being handled, if the device reported one. + pressure: Option, } impl ToolState { @@ -267,6 +273,7 @@ impl Session { pub fn pointer(&mut self, ev: PointerEvent) -> Result { let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 }; let p = Point::new(ev.x, ev.y); + self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); if !(p.x.is_finite() && p.y.is_finite()) { return Ok(Value::Null); } @@ -355,8 +362,9 @@ impl Session { if mode == "eraser" { return self.erase_at(p, tol); } - self.tool.drag = Some(Drag::Ink { points: vec![(p.x, p.y, 0.5)] }); - self.tool.ink_preview = vec![(p.x, p.y, 0.5)]; + let sample = self.ink_sample(p); + self.tool.drag = Some(Drag::Ink { points: vec![sample] }); + self.tool.ink_preview = vec![sample]; return Ok(Value::Null); } ToolKind::Select => {} @@ -653,7 +661,7 @@ impl Session { self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); } Drag::Ink { mut points } => { - points.push((p.x, p.y, 0.5)); + points.push(self.ink_sample(p)); self.tool.ink_preview = points.clone(); self.tool.drag = Some(Drag::Ink { points }); } @@ -1040,6 +1048,15 @@ impl Session { nx } + /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. + fn ink_sample(&self, p: Point) -> (f64, f64, f32) { + let pressure = match self.tool.pressure { + Some(v) if v.is_finite() => v.clamp(0.0, 1.0), + _ => 1.0, + }; + (p.x, p.y, pressure) + } + fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 512e7cc4..2f126af0 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -88,7 +88,8 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?; let mods = e.get("mods").map(mods_from).unwrap_or(base); - last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0 }).map_err(|e| e.to_string())?; + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0, pressure }).map_err(|e| e.to_string())?; } self.session.ui_requests.clear(); Ok(json!({"result": last, "selection": self.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())})) diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..a4f7a098 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -249,7 +249,8 @@ pub struct MediaClip { pub height: u32, } -/// One ink stroke (Draw tab): points in slide coordinates with pressure. +/// One ink stroke (Draw tab): points in slide coordinates with pressure (`0..=1`, +/// where `1` is [`InkStroke::width`]). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct InkStroke { @@ -259,6 +260,24 @@ pub struct InkStroke { pub highlighter: bool, } +/// True when every sample is the placeholder `0.5` written before a device reported pressure. +/// Those strokes were drawn at [`InkStroke::width`] and still are. +pub fn ink_pressure_is_legacy(points: &[(f64, f64, f32)]) -> bool { + !points.is_empty() && points.iter().all(|p| (p.2 - 0.5).abs() <= 1.0e-3) +} + +/// Width of one ink sample. `pressure` of `1` is the pen width. A legacy flat stroke +/// (`legacy`) draws at the pen width. A missing reading draws at the pen width too. +/// A light touch stays visible (at least 8% of the pen). +pub fn ink_sample_width(pen: f64, pressure: f32, legacy: bool) -> f64 { + let pen = if pen.is_finite() { pen.abs() } else { 0.0 }; + if legacy { + return pen; + } + let p = if pressure.is_finite() { pressure.clamp(0.08, 1.0) } else { 1.0 }; + pen * f64::from(p) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "type")] pub enum ShapeKind { diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 2c3b4ced..4b8589d0 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -231,3 +231,16 @@ fn sibling_lists_and_parents_reach_into_groups() { assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); } + +#[test] +fn ink_width_follows_pressure_and_keeps_the_old_placeholder() { + assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)])); + assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)])); + assert!(!ink_pressure_is_legacy(&[])); + assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9); + let floor = ink_sample_width(8.0, 0.0, false); + assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}"); + assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9); +} diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..836f9f5c 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -408,6 +408,15 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { return None; } let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0)); + // PPTX keeps one line width. A pressured stroke exports at its mean sample width; + // a legacy flat `0.5` stroke stays at the pen width. + let legacy = deckcraft_model::ink_pressure_is_legacy(&st.points); + let width = if legacy { + st.width + } else { + let n = st.points.len().max(1) as f64; + st.points.iter().map(|p| deckcraft_model::ink_sample_width(st.width, p.2, false)).sum::() / n + }; let mut d = String::new(); for (k, (px, py)) in pts.iter().enumerate() { d.push_str(if k == 0 { "M " } else { "L " }); @@ -426,7 +435,7 @@ fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { line: Some(deckcraft_model::Line { cap: Some(deckcraft_model::style::LineCap::Round), join: Some(deckcraft_model::style::LineJoin::Round), - ..deckcraft_model::Line::solid(c, st.width.max(0.25)) + ..deckcraft_model::Line::solid(c, width.max(0.25)) }), ..Default::default() }) diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..3b3cd5de 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -487,9 +487,40 @@ fn pictures_media_and_ink_round_trip() { assert_eq!((m.trim_start_ms, m.fade_in_ms), (500, 250)); assert!(q.media(m.media).is_some_and(|x| x.data.starts_with(b"\x00\x00\x00\x18ftyp"))); assert!(m.poster.is_some()); - // Ink comes back as freeform lines in a group. + // Ink comes back as freeform lines in a group. A flat 0.5 stroke keeps the pen width. let ink = by_name(&q.slides[2], "Ink"); - assert!(matches!(&ink.kind, ShapeKind::Group { children, .. } if children.len() == 1 && matches!(children[0].geom, Geom::Custom { .. }))); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("ink is not a group: {:?}", ink.kind) }; + assert_eq!(children.len(), 1); + assert!(matches!(children[0].geom, Geom::Custom { .. })); + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + assert!((w - 3.0).abs() < 0.05, "a flat 0.5 stroke keeps the pen width, got {w}"); +} + +#[test] +fn pressured_ink_exports_at_its_mean_width() { + let mut p = Presentation::default(); + let id = ShapeId(p.alloc_id()); + let slide = std::sync::Arc::make_mut(&mut p.slides[0]); + slide.shapes.push(Shape { + id, + name: "Ink".into(), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![InkStroke { + points: vec![(10.0, 10.0, 1.0), (40.0, 10.0, 0.5), (80.0, 10.0, 0.0)], + color: Rgba::rgb(0, 0, 0), + width: 10.0, + highlighter: false, + }], + }, + ..Default::default() + }); + let q = round(&p); + let ink = by_name(&q.slides[0], "Ink"); + let ShapeKind::Group { children, .. } = &ink.kind else { panic!("{:?}", ink.kind) }; + let w = children[0].line.as_ref().and_then(|l| l.width).unwrap_or(0.0); + // Full, half, and the 8% floor: (10 + 5 + 0.8) / 3. + assert!((w - (10.0 + 5.0 + 0.8) / 3.0).abs() < 0.05, "{w}"); } #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..5f243e9f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -844,24 +844,29 @@ impl Renderer { } ShapeKind::Ink { strokes } => { for sk in strokes { - let w = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; - for (px, py, _) in &sk.points { + let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); + let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; + for (px, py, pr) in &sk.points { + let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); } - let mut bp = BezPath::new(); - for (i, (px, py, _)) in sk.points.iter().enumerate() { - if i == 0 { - bp.move_to((*px, *py)); - } else { - bp.line_to((*px, *py)); - } + let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); + if pts.len() < 2 { + continue; } let (base, rel) = self.line_space(parent, depth); - bp.apply_affine(rel); ctx.set_transform(base); ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); - ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); + for pair in pts.windows(2) { + let mut bp = BezPath::new(); + bp.move_to((pair[0].0, pair[0].1)); + bp.line_to((pair[1].0, pair[1].1)); + bp.apply_affine(rel); + let a = deckcraft_model::ink_sample_width(pen, pair[0].2, legacy); + let b = deckcraft_model::ink_sample_width(pen, pair[1].2, legacy); + ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + } } } _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..175b9a54 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -35,6 +35,49 @@ fn shape_fill_uses_theme_accent() { assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); } +#[test] +fn ink_pressure_tapers_the_stroke_and_a_flat_half_stays_full_width() { + let opts = RenderOpts { scale: 1.0, ..Default::default() }; + let tapered = render_slide(&ink_line(1.0, 0.12), 0, &opts); + let flat = render_slide(&ink_line(0.5, 0.5), 0, &opts); + let full = render_slide(&ink_line(1.0, 1.0), 0, &opts); + let heavy = dark_in(&tapered, 50, 140, 70, 130); + let light = dark_in(&tapered, 280, 360, 70, 130); + assert!(heavy > light * 3, "a firm point covers more of the slide than a light one ({heavy} vs {light})"); + let (a, b) = (dark_in(&flat, 40, 370, 70, 130), dark_in(&full, 40, 370, 70, 130)); + assert!(a > 100 && (a as i32 - b as i32).unsigned_abs() * 10 < a as u32, "the old constant 0.5 still draws at the pen width ({a} vs {b})"); +} + +fn ink_line(left: f32, right: f32) -> Presentation { + let points = (0..=40) + .map(|i| { + let pressure = if i < 20 { left } else { right }; + (40.0 + f64::from(i) * 8.0, 100.0, pressure) + }) + .collect(); + let sh = Shape { + id: ShapeId(7), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Ink { + strokes: vec![deckcraft_model::InkStroke { points, color: deckcraft_color::Rgba::rgb(0, 0, 0), width: 24.0, highlighter: false }], + }, + ..Default::default() + }; + deck_with(vec![sh]) +} + +fn dark_in(img: &Image, x0: u32, x1: u32, y0: u32, y1: u32) -> usize { + let mut n = 0; + for y in y0..y1 { + for x in x0..x1 { + if img.pixel(x, y)[0] < 200 { + n += 1; + } + } + } + n +} + #[test] fn text_draws_ink() { let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..2041acda 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -371,7 +371,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; let p = xf.to_slide(pos); - let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol }; + let pressure = reported_pressure(ui); + let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol, pressure }; let primary = ui.input(|i| i.pointer.primary_pressed()); if resp.hovered() && !app.session.tool.dragging() { let _ = app.session.pointer(ev(PointerKind::Move)); @@ -581,13 +582,35 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To if app.session.tool.ink_preview.len() > 1 && let ToolKind::Ink { color, width, mode } = &app.session.tool.kind { - let pts: Vec = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect(); + let pts = &app.session.tool.ink_preview; + let legacy = deckcraft_engine::model::ink_pressure_is_legacy(pts); let c = theme::to_color32(*color); let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c }; - painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c))); + for pair in pts.windows(2) { + let a = deckcraft_engine::model::ink_sample_width(*width, pair[0].2, legacy); + let b = deckcraft_engine::model::ink_sample_width(*width, pair[1].2, legacy); + let sw = ((a + b) * 0.5 * f64::from(xf.scale)).max(1.0) as f32; + let p0 = xf.to_screen(Point::new(pair[0].0, pair[0].1)); + let p1 = xf.to_screen(Point::new(pair[1].0, pair[1].1)); + painter.add(egui::Shape::line(vec![p0, p1], Stroke::new(sw, c))); + } } } +/// Pressure from a touch or pen this frame (`0..=1`). A mouse reports none. +fn reported_pressure(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), phase, .. } + if matches!(phase, egui::TouchPhase::Start | egui::TouchPhase::Move) && f.is_finite() => + { + Some(f.clamp(0.0, 1.0)) + } + _ => None, + }) + }) +} + fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) { p.circle_filled(c, 6.0, t.handle_fill); p.add(egui::Shape::Path(egui::epaint::PathShape { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d912..2672c37d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -3,8 +3,8 @@ //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): //! - `engine.execute {command, params}` / `ui.menu.invoke {command, params}`: run any command (engine or UI) //! - `engine.commands`: engine + UI commands with enablement; `document.inspect`; `ui.inspect` -//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y}], mods}`: drive the active tool in -//! slide points (same path as the mouse) +//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, pressure?}], mods}`: drive the active tool in +//! slide points (same path as the mouse; `pressure` is `0..=1` when a pen reported one) //! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: keyboard through the engine //! - `ui.tool.select {tool, preset?}` //! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}` / `ui.drag {x, y, toX, toY, steps?}`: real egui pointer @@ -125,7 +125,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> return err("pointer events need x and y"); }; let mods = e.get("mods").map(mods_from).unwrap_or(base); - match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64 }) { + let pressure = e.get("pressure").and_then(Value::as_f64).map(|v| v as f32); + match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64, pressure }) { Ok(v) => last = v, Err(e) => return err(e), } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 07741fde..7db87ec9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note | `document.inspect` | — | Slides, sections, layouts, shapes, selection | | `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | -| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?, pressure?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse. `pressure` is `0..=1` for a pen; omit it for a mouse | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | | `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels | | `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..ccbe7e95 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt, pinch and trackpad gestures remain; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -217,7 +217,7 @@ Detail in [hardware-parity.md](hardware-parity.md). or wgpu path). - **Software-only video decode**; PowerPoint uses VideoToolbox/Media Foundation. 8–14 h (copy FilmCraft's hardware decode). -- **No pen pressure or tilt for ink**; no pinch-zoom or trackpad gestures. 3–6 h. +- **Pen pressure scales ink width** when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat `0.5` still draws at the pen width). Tilt is not read. No pinch-zoom or other trackpad gestures. 2–4 h. ## Localization @@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Pen pressure scales ink width when the pointer reports it; tilt, pinch-zoom and other trackpad gestures remain | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..2ebbfa20 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (pen pressure scales ink width; tilt remains; previous: minor, IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -44,7 +44,7 @@ because it mostly follows from hardware use. | Keyboard and presentation clickers (Page Up/Down, B, Esc) | yes | yes | | Mouse and trackpad scroll | yes | yes | | Pinch to zoom, two-finger gestures | yes | no | -| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | no: ink strokes have constant width | +| Pen pressure and tilt for ink (Apple Pencil via Sidecar, Windows Ink) | yes | pressure scales the stroke when the pointer reports it (a mouse or a missing reading draws at the pen width); tilt is not read | | Touch screens (Windows) | yes | basic pointer only | | IME input methods for CJK text | yes | yes: composed in place, underlined, candidate window at the caret (#101) | | Dictation | yes (macOS) | no | @@ -63,13 +63,14 @@ outlines every frame (#67, fix open), and the decoders run in software. | GPU slide rasterization (vello's GPU/hybrid path) | 12–20 | | Hardware video decode (copy FilmCraft's VideoToolbox / Media Foundation / VA-API paths) | 8–14 | | Windows GPU startup robustness: backend probing, software fallback (#40, #59) | 4–8 (counted under Stability) | -| Pen pressure, pinch zoom | 3–6 | +| Pen tilt, pinch zoom, trackpad gestures | 2–5 | | Benchmark suite (`cargo xtask bench`) and a first performance pass | 6–12 (Performance dimension) | -| **Hardware total** | **~26–45** | +| **Hardware total** | **~25–44** | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 97953034..b8f3e986 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (after beta: pen pressure scales ink width, tilt remains; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -60,9 +60,10 @@ reference renders, a Windows tester with the affected GPUs, native speakers to r SmartArt editing, chartex chart types, the remaining animation effects and motion paths, Notes and Handout masters, edit points, text warp and 3-D, proofing dictionaries, Zoom, recording, video and -GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization and hardware video -decode, the other 21 languages with RTL mirroring, a larger original template gallery, and local AI -features (owner decision on models). Itemized with hours in [gaps.md](gaps.md). +GIF export, legacy `.ppt` import and `.odp` (owner decision), GPU rasterization, hardware video +decode, pen tilt (pressure already scales ink width) and pinch zoom, the other 21 languages with +RTL mirroring, a larger original template gallery, and local AI features (owner decision on +models). Itemized with hours in [gaps.md](gaps.md). ## Milestones @@ -109,5 +110,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 99653dc7..3fa6283e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (pen pressure scales ink; percentages unchanged; previous: minor, hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -69,7 +69,7 @@ The weights are what those users actually depend on, written down so the total c | UI/UX fidelity | 15% | ~60% | 30–50 | [ui-parity.md](ui-parity.md) | All 10 ribbon tabs and the contextual tabs exist; Format Shape pane, Animation Pane, dialogs and menus are thinner than PowerPoint's; no native macOS menu bar; user reports #17, #71, #73, #76 | | Stability | 10% | ~60% | 10–20 | [gaps.md](gaps.md) | Never-crash rules, a panic guard round every command, PPTX fuzz/malformed tests. But startup crashes on some Windows GPUs (#40, #59) and Close discards edits without asking (#60) | | Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) | -| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom | +| Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen tilt, no pinch zoom | | Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps | | Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist | | Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes vs PowerPoint's large template gallery; no add-ins or VBA (out of scope by policy) | @@ -197,7 +197,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor | Animations | 6 | 79% | ~65% | 10–16 | 64 of PowerPoint's ~190 effects, Animation Pane timeline, preview, rewind lost on save (#77) | [animation-parity.md](animation-parity.md) | | Slide show and presenter view | 8 | 66% | ~60% | 10–16 | Second-display placement, laser pointer, recording, subtitles, ink depth | [slideshow-parity.md](slideshow-parity.md) | | Review | 3 | 60% | ~45% | 8–14 | Modern comment threads on round trip (#70), compare, version history, real spelling dictionaries | [file-format-parity.md](file-format-parity.md) | -| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen pressure, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | +| Draw (ink) | 2 | 33% | ~30% | 5–9 | Pen tilt, ink to shape/text/math, lasso | [hardware-parity.md](hardware-parity.md) | | Undo and clipboard | 3 | 88% | ~75% | 3–5 | Paste Special, image paste (#53) | [ui-parity.md](ui-parity.md) | | Automation ✱ | — | 100% | ahead | 0 | | [mcp.md](mcp.md) | | Platforms ✱ | — | 67% | ~80% | see platforms | | [ROADMAP.md](../ROADMAP.md) | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Draw and hardware blurbs: pen pressure scales ink width when reported; percentages and hour ranges unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |