diff --git a/ROADMAP.md b/ROADMAP.md index 79c0765c..a9936925 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,6 +8,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (accessibility contrast checks now include every text run, paragraph and nested group descendant; parity summary unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -152,6 +153,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) | | 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide | | 2026-10-26 | A space, digit or punctuation after a symbol-font character (`a:sym`, e.g. a Wingdings arrow typed in a run) draws in the run's own font, as PowerPoint does; it stayed in the symbol font, whose space is about four times as wide (synthetic probe) | +| 2026-10-29 | Review ▸ Accessibility checks text runs in every paragraph and nested group descendants for low contrast, reporting each affected shape once; the all-black control stays clear | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, 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-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits | | 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 | @@ -318,6 +320,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. | | 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged | | 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged | +| 2026-10-29 | trivial | Review: accessibility contrast checks all runs, paragraphs and nested group descendants; checklist score and headline figures unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index e9801232..6a18db56 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -272,19 +272,31 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result { _ => None, }; if let Some(bg) = bg { - for sh in &sl.shapes { - let Some(t) = &sh.text else { continue }; - let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue }; - let rp = deckcraft_model::resolve::run(&ctx, sh, para, ¶.runs.first().map(|r| r.props.clone()).unwrap_or_default()); + deckcraft_model::walk(&sl.shapes, &mut |sh, _| { + let Some(t) = &sh.text else { return }; let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh); if fill.as_ref().is_some_and(|f| !f.is_none()) { - continue; + return; } - let fg = deckcraft_model::resolve::text_color(&ctx, &rp); - if fg.contrast(bg) < 3.0 { - out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))})); + let low = t + .paragraphs + .iter() + .flat_map(|para| { + // A line break or blank run shows no glyphs, so its colour can't be hard to read. + para.runs.iter().filter(|run| run.kind != deckcraft_model::text::RunKind::Break && !run.text.trim().is_empty()).map( + |run| { + let rp = deckcraft_model::resolve::run(&ctx, sh, para, &run.props); + deckcraft_model::resolve::text_color(&ctx, &rp).contrast(bg) + }, + ) + }) + .find(|contrast| *contrast < 3.0); + if let Some(contrast) = low { + out.push( + json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({contrast:.1}:1)")}), + ); } - } + }); } } } @@ -868,3 +880,81 @@ mod spelling_change_range_tests { assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}"); } } + +#[cfg(test)] +mod accessibility_contrast_tests { + use super::*; + use deckcraft_model::text::{Paragraph, Run, RunKind, RunProps}; + use deckcraft_model::{ColorRef, Fill, Rgba, Shape, ShapeId, ShapeKind, TextBody}; + use serde_json::json; + + fn run(text: &str, color: Rgba) -> Run { + Run::with(text, RunProps { fill: Some(Fill::solid(ColorRef::rgb(color))), ..Default::default() }) + } + + fn text_shape(id: u32, name: &str, paragraphs: Vec) -> Shape { + Shape { id: ShapeId(id), name: name.into(), text: Some(TextBody { paragraphs, ..Default::default() }), ..Default::default() } + } + + #[test] + fn contrast_checks_mixed_runs_later_paragraphs_and_group_descendants_once() { + let mut s = Session::with_new(); + s.edit(|doc, _| { + let black = Rgba::BLACK; + let white = Rgba::WHITE; + let mixed = Paragraph { runs: vec![run("visible", black), run("hidden", white)], ..Default::default() }; + let later = vec![ + Paragraph { runs: vec![run("visible", black)], ..Default::default() }, + Paragraph { runs: vec![run("hidden", white)], ..Default::default() }, + ]; + let child = text_shape(5, "nested text", vec![Paragraph { runs: vec![run("hidden", white)], ..Default::default() }]); + let group = Shape { + id: ShapeId(4), + name: "group".into(), + descr: "group of text".into(), + kind: ShapeKind::Group { + children: vec![Shape { + id: ShapeId(7), + name: "nested group".into(), + descr: "nested group of text".into(), + kind: ShapeKind::Group { children: vec![child], child: Default::default() }, + ..Default::default() + }], + child: Default::default(), + }, + ..Default::default() + }; + let sl = Arc::make_mut(doc.slides.get_mut(0).ok_or_else(|| bad("test", "no slide"))?); + sl.shapes = vec![ + text_shape(2, "mixed", vec![mixed]), + text_shape(3, "later paragraph", later), + group, + text_shape(6, "black control", vec![Paragraph { runs: vec![run("visible", black)], ..Default::default() }]), + text_shape( + 8, + "invisible white runs", + vec![Paragraph { + runs: vec![run("visible", black), Run { kind: RunKind::Break, ..run("\n", white) }, run(" ", white), run("visible", black)], + ..Default::default() + }], + ), + ]; + Ok(()) + }) + .unwrap(); + + let issues = s.execute("review.accessibility", &json!({})).unwrap(); + let contrasts: Vec<_> = issues + .as_array() + .unwrap() + .iter() + .filter(|issue| issue["issue"].as_str().unwrap_or("").starts_with("Hard-to-read text contrast")) + .collect(); + assert_eq!(contrasts.len(), 3, "{issues}"); + for id in [2, 3, 5] { + assert!(contrasts.iter().any(|issue| issue["shape"] == id), "missing shape {id}: {issues}"); + } + assert!(!contrasts.iter().any(|issue| issue["shape"] == 6), "black control warned: {issues}"); + assert!(!contrasts.iter().any(|issue| issue["shape"] == 8), "white break or spaces warned: {issues}"); + } +}