diff --git a/ROADMAP.md b/ROADMAP.md index 02bd401da..28b37ec39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,8 @@ Newest first. - **2026-10-12** · Properties › Transform shows rotation, shear and X/Y scale, and Rotate 90° and Flip Horizontal/Vertical buttons, as InDesign's does. No readiness percentage change. - **2026-10-12** · Underlines and strikethroughs are drawn as one bar per stretch of text on screen, as in PDF export: a bar per letter left faint seams between the letters. No readiness percentage change. - **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change. +- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change. +- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change. - **2026-10-11 (printing):** `file.printers` and `file.print` use craft-libs/craft-print for CUPS and Windows. DesignCraft retains PDF export, page selection, spreads, marks and bleed; dry runs create no temporary PDF or print job. Windows selects matching or default driver paper and shrinks oversized sheets without enlarging smaller ones; queue duplex defaults are preserved. Headless tests exercise exported bytes, job settings, dry runs and refusals; shared native Windows CI passes, including paused-queue submission and cleanup. PostScript, production print controls and physical output validation remain open. No readiness percentage change. - **2026-10-11** · The View menu follows InDesign's: proofing first, then zoom, Rotate Spread, Screen Mode, Display Performance, rulers, Extras, Grids & Guides and Structure; the view items that sat at the end of the Type menu moved there. No readiness percentage change. - **2026-10-11** · Properties › Appearance's Fill and Stroke menus gain InDesign's Tint field and, for a text frame, Apply To: Frame / Text. No readiness percentage change. diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index b4280b2db..188c0d35b 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -158,6 +158,7 @@ fn change(s: &mut Session, p: &Value) -> Result { over.set_json(k, v).map_err(|e| bad("find.change", e))?; } } + super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let targets = scope(s, p)?; s.edit(|d, sel| { let mut count = 0usize; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index ef46dbe1b..208455ea8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -467,6 +467,7 @@ fn create_para(s: &mut Session, p: &Value) -> Result { chars = full; } } + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; let based_on = str_param(p, "basedOn").map(str::to_string); let next = str_param(p, "nextStyle").map(str::to_string); s.edit(|d, _| { @@ -534,6 +535,7 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); s.edit(|d, _| { @@ -739,6 +741,7 @@ fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { fn create_char(s: &mut Session, p: &Value) -> Result { let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; // Based on [None] is based on nothing. let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); s.edit(|d, _| { @@ -759,6 +762,7 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { return Err(bad("style.character.edit", "[None] can't be edited")); } let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; // `null` unsets an attribute (the style no longer sets it). let unset: Vec = p .get("chars") @@ -1247,6 +1251,154 @@ fn swatch_options(s: &mut Session, p: &Value) -> Result { mod color_tests { use super::*; + fn fill_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); + session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); + session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); + session.execute("type.char", &json!({"size": 18})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + session + } + + fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let fill = params + .pointer("/chars/fill") + .or_else(|| params.pointer("/attrs/fill")) + .or_else(|| params.get("fill")) + .and_then(Value::as_str) + .unwrap_or("Missing Ink"); + + let result = session.execute(command, ¶ms); + + match result { + Err(crate::EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, command); + assert!(msg.contains(fill), "{msg}"); + assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); + } + other => panic!("expected BadParams for {command}, got {other:?}"), + } + let after = session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(before.selection, after.selection); + assert_eq!(before.revision, after.revision); + assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); + assert_eq!(journal, session.journal); + } + + #[test] + fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { + for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { + for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "type.char", params); + } + } + } + + #[test] + fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { + for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); + assert_fill_rejected(&mut session, command, json!({"name": "Existing", "rename": "Renamed", "chars": {"fill": fill, "size": 24}})); + } + } + } + + #[test] + fn char_fill_rejects_inherited_unknown_name_when_creating_from_selection() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + assert_fill_rejected(&mut session, "style.paragraph.create", json!({"name": "Inherited", "fromSelection": true})); + } + + #[test] + fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + + assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); + } + + #[test] + fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { + for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); + } + } + } + + #[test] + fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { + for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { + let mut session = fill_session(); + if !fill.starts_with('[') { + let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); + assert_eq!(created["name"], fill); + } + for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { + session.execute("type.char", ¶ms).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + } + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); + let document = &session.doc().unwrap().doc; + for name in ["Colored", "Existing"] { + let attrs = if kind == "paragraph" { + &document.styles.para(name).unwrap().chars + } else { + &document.styles.char_style(name).unwrap().chars + }; + assert_eq!(attrs.fill.as_deref(), Some(fill)); + } + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); + let selected = session.doc().unwrap().selection.text.unwrap(); + let story = session.doc().unwrap().doc.story(selected.story).unwrap(); + assert_eq!(story.text, "Bye"); + assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); + } + } + + #[test] + fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { + for chars in [json!({"size": 18}), json!({"fill": null})] { + let mut session = fill_session(); + session.execute("type.char", &json!({"attrs": chars})).unwrap(); + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); + } + } + #[test] fn percent_components_at_most_one_stay_percent() { use designcraft_color::Color; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index e4c5141ff..54ae8631f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1297,6 +1297,18 @@ fn ruby_cmd(s: &mut Session, p: &Value) -> Result { format_chars(s, &Value::Object(body)) } +pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { + if let Some(fill) = &attrs.fill + && document.swatch(fill).is_none() + { + return Err(bad( + command_id, + format!("unknown character fill `{fill}`; create a swatch with `swatch.create` and use its returned name as `fill`"), + )); + } + Ok(()) +} + pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { let mut a = CharAttrs::default(); if let Some(o) = attrs.as_object() { @@ -1304,6 +1316,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { a.set_json(k, v).map_err(|e| bad("type.char", e))?; } } + validate_char_fill(&s.doc()?.doc, &a, "type.char")?; let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); let targets = format_targets(s); // A caret in text: the format is for the next typing. diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 0b940aa3a..c4930cc99 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -78,15 +78,11 @@ impl Backend for Remote { let id = self.next_id; self.next_id += 1; let line = json!({"id": id, "method": method, "params": params}).to_string(); - // One retry with a fresh connection (the app may have restarted). let reply = match self.roundtrip(&line) { Ok(r) => r, - Err(_) => { + Err(e) => { self.conn = None; - self.roundtrip(&line).map_err(|e| { - self.conn = None; - format!("DesignCraft app at {} is not reachable: {e}", self.addr) - })? + return Err(format!("DesignCraft app at {}: {e}; operation may have completed; inspect state before retrying", self.addr)); } }; let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; @@ -105,3 +101,55 @@ impl Backend for Remote { format!("connected to the DesignCraft app at {}", self.addr) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn remote_does_not_replay_when_reply_is_lost() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let server = std::thread::spawn(move || { + let first = { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream); + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "engine.execute"); + assert_eq!(request["params"]["command"], "frame.create"); + request + }; + let mut applied = 1; + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + assert!(reader.read_line(&mut line).unwrap() > 0); + let request: Value = serde_json::from_str(&line).unwrap(); + if request == first { + applied += 1; + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + } else { + assert_eq!(request, json!({"id": 2, "method": "document.inspect", "params": {}})); + writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": {"applied": applied}})).unwrap(); + return applied; + } + } + }); + let mut remote = Remote::connect(&addr).unwrap(); + + let result = remote.call("engine.execute", json!({"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}})); + let next = remote.call("document.inspect", json!({})); + let applied = server.join().unwrap(); + + assert_eq!(applied, 1, "lost reply must not replay the applied edit; client returned {result:?}"); + assert!(result.is_err(), "lost reply must report an uncertain outcome: {result:?}"); + assert_eq!(next, Ok(json!({"applied": 1})), "the next explicit request must reconnect successfully"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index c42aade1a..82fdc60bf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -51,7 +51,8 @@ claude mcp add designcraft -- designcraft-cli mcp | UI-only commands via `execute` (`view.*`, `window.*`, `help.*`, `edit.dynamicSpelling`, and `app.*` except `app.links`) | Error | Work | In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so -what the agent does shows up live in the window. The connection is re-established once if the app restarts. +what the agent does shows up live in the window. A failed request is not resent: the operation may have completed, +so inspect the document before retrying. The next request re-establishes the connection. ## Coordinates