From 4fbd86733733d3fdea1a232ad7a7c8c6b857244b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:56:14 +0000 Subject: [PATCH 1/6] Threading: Shift-click autoflow from a frame on a parent spread is an error Autoflow fell back to the last document page when the story's last frame was on a parent spread, threading a parent frame to new document pages. It now refuses a story that ends on a parent spread. --- crates/engine/src/cmd/place_text.rs | 5 +++++ crates/engine/src/cmd/thread.rs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 97917ee3b..b93f2b9ff 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -124,7 +124,12 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { } /// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added. +/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result { + let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad("file.place", "the story has no frame"))?; + if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) { + return Err(bad("file.place", "autoflow works only from a frame on a document page")); + } let mut added = 0; loop { let (overset, held, full) = fit(d, sid); diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs index ff360e00a..71c877bf8 100644 --- a/crates/engine/src/cmd/thread.rs +++ b/crates/engine/src/cmd/thread.rs @@ -280,6 +280,24 @@ mod tests { assert_eq!(s.doc().unwrap().doc.page_count(), 2); } + #[test] + fn autoflow_from_a_parent_frame_is_an_error() { + let mut s = session(); + let parent = json!({"kind": "parent", "index": 0}); + let r = s.execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}), + ); + let a = r.unwrap()["id"].as_u64().unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let doc = s.doc().unwrap().doc.clone(); + let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err(); + assert!(e.to_string().contains("document page"), "{e}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(*s.doc().unwrap().doc, *doc); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + } + #[test] fn unthread_after_and_before() { let mut s = session(); From ddc93d84572105e05a7b4dd5b62eb8f01e2eba89 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:57:01 +0000 Subject: [PATCH 2/6] Threading: autoflow errors name the command that autoflows text.thread with autoflow reported its autoflow errors as file.place. --- crates/engine/src/cmd/place_text.rs | 17 +++++++++-------- crates/engine/src/cmd/thread.rs | 4 ++-- crates/engine/src/lib.rs | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index b93f2b9ff..5bbb59ac1 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -124,24 +124,25 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { } /// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added. -/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. -pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result { - let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad("file.place", "the story has no frame"))?; +/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors +/// name `cmd`, the command that autoflows. +pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result { + let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?; if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) { - return Err(bad("file.place", "autoflow works only from a frame on a document page")); + return Err(bad(cmd, "autoflow works only from a frame on a document page")); } let mut added = 0; loop { let (overset, held, full) = fit(d, sid); let Some(at) = overset else { return Ok(added) }; if added >= max_pages { - return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages"))); + return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages"))); } let len = d.story(sid).map_or(0, |s| s.len()); // Frames still needed at the rate the full ones are filled (at least one); with no full // frame to go by yet, one more page. let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added); - let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?; + let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); d.insert_pages(Some(page), want, parent)?; @@ -149,7 +150,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu let mut prev = last; for _ in 0..want { page += 1; - let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?; + let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?; let r = d.spreads[si].pages[pi].margin_rect(); let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?; d.thread(prev, fid)?; @@ -235,7 +236,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) - *sel = designcraft_doc::Selection::items(vec![fid]); sid }; - let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 }; + let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 }; let overset = fit(d, sid).0.is_some(); Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings})) }) diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs index 71c877bf8..f215492eb 100644 --- a/crates/engine/src/cmd/thread.rs +++ b/crates/engine/src/cmd/thread.rs @@ -92,7 +92,7 @@ fn thread(s: &mut Session, p: &Value) -> Result { d.check_thread(from, to)?; d.thread_keeping_story(from, to)?; let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?; - let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES)? } else { 0 }; + let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 }; *sel = Selection::items(vec![joined]); Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages})) }) @@ -292,7 +292,7 @@ mod tests { let undo = s.doc().unwrap().history.undo.len(); let doc = s.doc().unwrap().doc.clone(); let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err(); - assert!(e.to_string().contains("document page"), "{e}"); + assert!(e.to_string().contains("text.thread"), "{e}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo); assert_eq!(*s.doc().unwrap().doc, *doc); assert_eq!(s.doc().unwrap().doc.page_count(), 2); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f89aefa7..f24750c64 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -468,7 +468,7 @@ impl Session { } let mut d = (*st.doc).clone(); if overset { - let _ = cmd::place_text_autoflow(&mut d, sid, 500); + let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow"); } else { // Drop empty trailing frames on the last pages (one frame, nothing else there). while let Some(story) = d.story(sid) { From 710ab70abe23e064963f3eb63922e8ee0fe3cc3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:57:41 +0000 Subject: [PATCH 3/6] Threading: locked frames, locked layers and a locked or hidden active layer refuse threads check_thread rejects a locked frame or a frame on a locked layer, and text.thread refuses to make a new frame on a locked or hidden active layer. --- crates/doc/src/edit.rs | 3 +++ crates/engine/src/cmd/thread.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 6eb4c501c..51e17abbb 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -300,6 +300,9 @@ impl Document { if !matches!(it.content, Content::Text(_) | Content::Unassigned) || it.shape == Shape::GraphicLine { return Err(DocError::Invalid(format!("{id} is not a text frame or an empty frame"))); } + if it.locked || self.layer(it.layer).is_some_and(|l| l.locked) { + return Err(DocError::Invalid(format!("{id} is locked or on a locked layer"))); + } } let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) }; let same_place = match (fl.spread, tl.spread) { diff --git a/crates/engine/src/cmd/thread.rs b/crates/engine/src/cmd/thread.rs index f215492eb..8cedac277 100644 --- a/crates/engine/src/cmd/thread.rs +++ b/crates/engine/src/cmd/thread.rs @@ -74,6 +74,9 @@ fn thread(s: &mut Session, p: &Value) -> Result { s.edit(|d, sel| { let mut new_frame = || -> Result { let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?; + if d.layer(layer).is_none_or(|l| l.locked || !l.visible) { + return Err(bad("text.thread", "the active layer is locked or hidden")); + } let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() }; Ok(d.add_text_frame(sr, r, layer, "", para)?.0) }; @@ -241,6 +244,29 @@ mod tests { assert_eq!(*s.doc().unwrap().doc, *doc); } + #[test] + fn locked_frames_and_layers_refuse_threads() { + let mut s = session(); + let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8)); + let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], ""); + let layer = s.doc().unwrap().active_layer.0; + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.lock", &json!({"ids": [b]})).unwrap(); + assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame"); + assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame"); + s.execute("edit.undo", &json!({})).unwrap(); + for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] { + let mut p = json!({"id": layer}); + p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone()); + s.execute("layer.set", &p).unwrap(); + let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) }; + assert!(s.execute("text.thread", &t).is_err(), "{p} {t}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(frames(&mut s, a), [a]); + } + #[test] fn thread_a_new_frame_after_or_in_front() { let mut s = session(); From aa4f85c8e1de4e0905b37f8057d7875defaa519e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 03:59:05 +0000 Subject: [PATCH 4/6] Threading: a frame dragged with the loaded text cursor snaps like frame drawing The press point and the moving edges snap to guides and smart guides, and the drag shows the snapped frame and its snap guides. --- crates/tools/src/select.rs | 9 +++++++-- crates/tools/src/tests.rs | 20 ++++++++++++++++++++ crates/tools/src/thread.rs | 24 +++++++++++++++++++----- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 84e12d3b3..63f46f553 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -800,9 +800,14 @@ impl Tool for SelectionTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { match &self.drag { - Drag::Marquee { start, cur } | Drag::Place { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], + Drag::Place { start, cur } if crate::thread::is_drag(cx, *start, *cur) => { + let Some((sr, r, guides)) = crate::thread::drag_frame(cx, *start, *cur) else { return vec![] }; + let r = Rect::from_points(cx.layout.to_canvas(sr, Point::new(r.x0, r.y0)), cx.layout.to_canvas(sr, Point::new(r.x1, r.y1))); + std::iter::once(Overlay::Marquee(r)).chain(guides).collect() + } + Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], Drag::Move { .. } | Drag::Resize { .. } | Drag::Rotate { .. } | Drag::Anchor { .. } => self.guides.clone(), _ => vec![], } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index f898755bf..17fc4a9c2 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -822,6 +822,26 @@ fn loaded_drag_makes_a_frame_of_that_size_and_shift_autoflows() { assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::Arrow, "autoflow unloads"); } +#[test] +fn loaded_drag_snaps_like_frame_drawing() { + let td = thread_doc(); + let cx = ToolContext { snap: SnapView::FACTORY, ..td.cx() }; + let mut t = create("selection"); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + let col = td.d.spreads[0].pages[0].column_rects()[0]; + // Press and release a point or two inside the column's edges: both sides snap to them. + let (p0, p1) = (td.at(col.x0 + 1.5, 560.0), td.at(col.x1 - 2.0, 650.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, p0.x, p0.y)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, p1.x, p1.y)); + let Some(Overlay::Marquee(m)) = t.overlays(&cx).first().cloned() else { panic!("no marquee") }; + assert!((m.x0 - td.at(col.x0, 0.0).x).abs() < 1e-9 && (m.x1 - td.at(col.x1, 0.0).x).abs() < 1e-9, "{m:?}"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, p1.x, p1.y)); + let [Action::Exec(id, p)] = a.as_slice() else { panic!("{a:?}") }; + assert_eq!(id, "text.thread"); + let r: Vec = p["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert_eq!((r[0], r[2]), (col.x0, col.x1), "{p}"); +} + #[test] fn alt_click_stays_loaded() { let td = thread_doc(); diff --git a/crates/tools/src/thread.rs b/crates/tools/src/thread.rs index 7e6500fbb..001fd154e 100644 --- a/crates/tools/src/thread.rs +++ b/crates/tools/src/thread.rs @@ -10,7 +10,7 @@ use designcraft_doc::{Document, Item, ItemId, SpreadRef}; use designcraft_geom::{Point, Rect}; use serde_json::{Value, json}; -use crate::{CanvasLayout, Cursor, Mods, ToolContext, rect_json, spread_json}; +use crate::{CanvasLayout, Cursor, Mods, Overlay, ToolContext, rect_json, spread_json}; /// Port square size, in screen pixels. pub const PORT_SIZE_PX: f64 = 8.5; @@ -87,6 +87,21 @@ pub fn click_frame_rect(doc: &Document, sr: SpreadRef, p: Point) -> Option [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r) } +/// Is a press at `start` released at `p` (canvas) a drag rather than a click? +pub(crate) fn is_drag(cx: &ToolContext, start: Point, p: Point) -> bool { + (p - start).hypot() >= cx.tol(3.0) +} + +/// The frame a drag with the loaded cursor from `start` to `p` (canvas) makes, in spread +/// coordinates, and the snap guides to draw. It is drawn like the frame tools (the press point +/// and the moving edges snap) without their modifiers: Shift and Alt autoflow here. +pub(crate) fn drag_frame(cx: &ToolContext, start: Point, p: Point) -> Option<(SpreadRef, Rect, Vec)> { + let (sr, sa) = cx.layout.spread_at(start)?; + let sa = if cx.snap.any() { crate::snap::snap_point(cx, sr, sa).0 } else { sa }; + let (r, guides) = crate::frame::snapped_drag_rect(cx, sr, sa, cx.layout.to_spread(sr, p), Mods::default()); + Some((sr, r, guides)) +} + /// The loaded text cursor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct Loaded { @@ -156,10 +171,9 @@ impl Loaded { /// The command for a click at `p`, or a drag from `start` to `p` (canvas). None: nothing to do. pub fn click(self, cx: &ToolContext, start: Point, p: Point, mods: Mods) -> Option<(&'static str, Value)> { - let dragged = (p - start).hypot() >= cx.tol(3.0); - let (pair, new_rect) = if dragged { - let (sr, a) = cx.layout.spread_at(start)?; - (self.pair(cx.doc, None), Some((sr, Rect::from_points(a, cx.layout.to_spread(sr, p))))) + let (pair, new_rect) = if is_drag(cx, start, p) { + let (sr, r, _) = drag_frame(cx, start, p)?; + (self.pair(cx.doc, None), Some((sr, r))) } else { match self.target(cx, p) { Target::Unthread => { From 7adc0be491c41d394ae5c5b9931984d8d295ba9e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 04:01:20 +0000 Subject: [PATCH 5/6] Threading: Alt-click moves the loaded cursor on only to the frame the click added The click records the frame it expects to join (the clicked frame, or a new one in place of the old neighbour). The load moves on only when the document has that frame there, so a failed thread or an undo leaves the cursor on the frame it was loaded from; an undo after moving on goes back to it. --- crates/tools/src/select.rs | 11 +++--- crates/tools/src/tests.rs | 39 +++++++++++++++++--- crates/tools/src/thread.rs | 74 ++++++++++++++++++++++++++++++-------- 3 files changed, 99 insertions(+), 25 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 63f46f553..c80c75973 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -132,7 +132,7 @@ impl SelectionTool { self.loaded = self.loaded.and_then(|l| l.resolve(cx.doc)); match (ev.kind, self.loaded) { (PointerKind::Down | PointerKind::DoubleClick, _) if let Some((frame, out)) = crate::thread::port_at(cx, p) => { - self.loaded = Some(Loaded { frame, out, advance: false }); + self.loaded = Some(Loaded::new(frame, out)); self.drag = Drag::None; Some(vec![]) } @@ -148,13 +148,12 @@ impl SelectionTool { } (PointerKind::Up, Some(l)) => { let Drag::Place { start, .. } = std::mem::replace(&mut self.drag, Drag::None) else { return Some(vec![]) }; - let cmd = l.click(cx, start, p, ev.mods); + let clicked = l.click(cx, start, p, ev.mods); // Alt (semi-autoflow) stays loaded with what is left; an invalid target keeps the load. - if cmd.is_some() { - self.loaded = (ev.mods.alt && !ev.mods.shift && cmd.as_ref().is_some_and(|c| c.0 == "text.thread")) - .then_some(Loaded { advance: true, ..l }); + if let Some(c) = &clicked { + self.loaded = c.joins.filter(|_| ev.mods.alt && !ev.mods.shift).map(|j| Loaded { advance: Some(j), ..l }); } - Some(exec(cmd)) + Some(exec(clicked.map(|c| (c.id, c.params)))) } (PointerKind::Move, Some(_)) => Some(vec![]), _ => None, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 17fc4a9c2..213ccc68c 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -844,13 +844,42 @@ fn loaded_drag_snaps_like_frame_drawing() { #[test] fn alt_click_stays_loaded() { - let td = thread_doc(); - let cx = td.cx(); + let mut td = thread_doc(); + let original = td.d.clone(); let mut t = create("selection"); - click(&mut t, &cx, td.port(td.a, true), Mods::default()); let alt = Mods { alt: true, ..Default::default() }; - assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0}))); - assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText); + let col = td.d.spreads[0].pages[0].column_rects()[0]; + let empty = td.at(200.0, 600.0); + let new_frame_from = + |f: ItemId| thread_exec(serde_json::json!({"from": f.0, "rect": [col.x0, 600.0, col.x1, col.y1], "spread": {"kind": "doc", "index": 0}})); + { + let cx = td.cx(); + click(&mut t, &cx, td.port(td.a, true), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.a.0, "to": td.e.0}))); + assert_eq!(t.cursor(&cx, td.at(500.0, 700.0), Mods::default()), Cursor::LoadedText); + // The thread failed (the document is unchanged): still loaded from a. + assert_eq!(click(&mut t, &cx, empty, alt), new_frame_from(td.a)); + } + // That click's command ran: the next click threads after the frame it added. + let (n, _) = + td.d.add_text_frame(SpreadRef::Doc(0), Rect::new(col.x0, 600.0, col.x1, col.y1), td.d.default_layer(), "", ParaFormat::default()).unwrap(); + td.d.thread(td.a, n).unwrap(); + { + let cx = td.cx(); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": n.0, "to": td.e.0}))); + } + // Undone: back on a, not on a's old neighbour or the frame the undo removed. + td.d = original; + { + let cx = td.cx(); + assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.a)); + } + // From x, which threads into y: when threading e in between fails, x stays loaded, not y. + let td = ThreadDoc { s: Selection::items(vec![td.x]), ..td }; + let cx = td.cx(); + click(&mut t, &cx, td.port(td.x, true), Mods::default()); + assert_eq!(click(&mut t, &cx, td.at(175.0, 450.0), alt), thread_exec(serde_json::json!({"from": td.x.0, "to": td.e.0}))); + assert_eq!(click(&mut t, &cx, empty, Mods::default()), new_frame_from(td.x)); } #[test] diff --git a/crates/tools/src/thread.rs b/crates/tools/src/thread.rs index 001fd154e..0a1d69f9e 100644 --- a/crates/tools/src/thread.rs +++ b/crates/tools/src/thread.rs @@ -105,12 +105,33 @@ pub(crate) fn drag_frame(cx: &ToolContext, start: Point, p: Point) -> Option<(Sp /// The loaded text cursor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct Loaded { - /// The frame whose port was clicked. + /// The frame whose port was clicked, or the frame semi-autoflow moved on to. pub frame: ItemId, /// Loaded from the out port (thread after `frame`) or the in port (in front of it). pub out: bool, - /// Semi-autoflow: the frame just threaded on that side takes over once the document has it. - pub advance: bool, + /// Semi-autoflow: the frame expected to join on this side once the click's command has run. + /// The load moves on to it only if the document has it there. + pub advance: Option, + /// The frame semi-autoflow moved on from: loaded again once `frame` is no longer its + /// neighbour on this side (undo). + pub back: Option, +} + +/// The frame a `text.thread` from the loaded cursor adds on the loaded side. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Joins { + /// This existing frame. + Frame(ItemId), + /// A new frame: any frame other than the neighbour there before the click (`None`: none). + NewFrameReplacing(Option), +} + +/// A click with the loaded cursor: the command, and for `text.thread` the frame it adds. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Clicked { + pub id: &'static str, + pub params: Value, + pub joins: Option, } /// What a click with the loaded cursor does at a point. @@ -127,11 +148,35 @@ pub(crate) enum Target { } impl Loaded { - /// The loaded frame now (an advancing load moves on to the frame threaded last), or None - /// when that frame is gone (undo, delete). + pub fn new(frame: ItemId, out: bool) -> Self { + Self { frame, out, advance: None, back: None } + } + + /// The loaded frame now, or None when that frame is gone (undo, delete). A semi-autoflow load + /// moves on to the frame its click added once the document has it there, and goes back to + /// the frame it came from once an undo takes that frame out again. pub fn resolve(self, doc: &Document) -> Option { - let frame = if self.advance { self.neighbour(doc).unwrap_or(self.frame) } else { self.frame }; - doc.item(frame).filter(|i| i.is_text_frame()).map(|_| Loaded { frame, out: self.out, advance: false }) + let usable = |id: ItemId| doc.item(id).is_some_and(Item::is_text_frame); + let mut l = self; + if let Some(b) = l.back + && usable(b) + && Loaded::new(b, l.out).neighbour(doc) != Some(l.frame) + { + l = Loaded::new(b, l.out); + } + if let Some(joins) = l.advance.take() + && let Some(n) = l.neighbour(doc) + && usable(n) + { + let arrived = match joins { + Joins::Frame(f) => n == f, + Joins::NewFrameReplacing(before) => Some(n) != before, + }; + if arrived { + l = Loaded { frame: n, out: l.out, advance: None, back: Some(l.frame) }; + } + } + usable(l.frame).then_some(l) } /// The next frame from an out port, the previous one from an in port. @@ -170,21 +215,22 @@ impl Loaded { } /// The command for a click at `p`, or a drag from `start` to `p` (canvas). None: nothing to do. - pub fn click(self, cx: &ToolContext, start: Point, p: Point, mods: Mods) -> Option<(&'static str, Value)> { - let (pair, new_rect) = if is_drag(cx, start, p) { + pub fn click(self, cx: &ToolContext, start: Point, p: Point, mods: Mods) -> Option { + let new_frame = Joins::NewFrameReplacing(self.neighbour(cx.doc)); + let (pair, new_rect, joins) = if is_drag(cx, start, p) { let (sr, r, _) = drag_frame(cx, start, p)?; - (self.pair(cx.doc, None), Some((sr, r))) + (self.pair(cx.doc, None), Some((sr, r)), new_frame) } else { match self.target(cx, p) { Target::Unthread => { let side = if self.out { "after" } else { "before" }; - return Some(("text.unthread", json!({"frame": self.frame.0, "side": side}))); + return Some(Clicked { id: "text.unthread", params: json!({"frame": self.frame.0, "side": side}), joins: None }); } Target::Invalid => return None, - Target::Thread(t) => (self.pair(cx.doc, Some(t)), None), + Target::Thread(t) => (self.pair(cx.doc, Some(t)), None, Joins::Frame(t)), Target::Empty => { let (sr, sp) = cx.layout.spread_at(p)?; - (self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?))) + (self.pair(cx.doc, None), Some((sr, click_frame_rect(cx.doc, sr, sp)?)), new_frame) } } }; @@ -202,7 +248,7 @@ impl Loaded { if mods.shift { params.insert("autoflow".into(), json!(true)); } - Some(("text.thread", Value::Object(params))) + Some(Clicked { id: "text.thread", params: Value::Object(params), joins: Some(joins) }) } } From 9d3581929a41e7bf9baba3cd8722782266115e38 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 04:02:35 +0000 Subject: [PATCH 6/6] Threading: progress docs for threading by hand The alpha gate's threading row and gap 0 shrink to the Primary Text Frame page fixes (#165). New gaps: autoflow onto the existing following pages, and threading into a grouped frame with the loaded text cursor. --- ROADMAP.md | 31 ++++++++++++++++++------------ docs/gaps.md | 40 +++++++++++++++++++++++---------------- docs/roadmap.md | 16 ++++++++-------- docs/target-app-parity.md | 11 ++++++----- 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ec8eb0920..69c49ed1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # DesignCraft roadmap -**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) +**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -25,15 +25,14 @@ format + IDML), scriptable by agents (MCP), and available on the web. | Essentials user | ~54% | 135–250 (~50% parallel) | start-up robustness, context menus and copy/paste, threading, fonts, Windows printing | **Why pre-alpha:** it fails one of InDesign's core workflows, the alpha gate in -[docs/roadmap.md](docs/roadmap.md#alpha-gate). A user can't thread a story from one frame to -another: the in and out ports are drawn, but clicking the out port does nothing and there is no -thread or unthread command. Long text flows only through autoflow on Place and Primary Text -Frame, which users report adding and keeping pages wrongly (#165, #140, #194, #352). Everything -else in the gate works end to end on macOS (document setup, styles, images, object editing, PDF -export, save and reopen), and ready for real work (~47%) is above the alpha bar, so manual -threading is the whole distance to alpha. Beyond that, beta needs INDD opening, IDML proven on -real files, composition compared with InDesign's, and basic editing fixed on Windows and Linux. -Details: [docs/target-app-parity.md](docs/target-app-parity.md). +[docs/roadmap.md](docs/roadmap.md#alpha-gate). Text threads by hand (click a port, then click or +draw a frame; thread and unthread commands), but Primary Text Frame autoflow adds pages users +can't delete (#165). Everything else in the gate works end to end on macOS (document setup, +styles, images, object editing, PDF export, save and reopen), and ready for real work (~47%) is +above the alpha bar, so the Primary Text Frame page fixes are the whole distance to alpha. +Beyond that, beta needs INDD opening, IDML proven on real files, composition compared with +InDesign's, and basic editing fixed on Windows and Linux. Details: +[docs/target-app-parity.md](docs/target-app-parity.md). ## By dimension @@ -106,7 +105,7 @@ Also shipped: Italian (85%), Ukrainian (83%). Ranked; detail and estimates in [docs/roadmap.md](docs/roadmap.md), the full list in [docs/gaps.md](docs/gaps.md). -1. **Alpha gate:** manual text threading (out-port click, thread/unthread commands) and Primary Text Frame fixes (#165) (15–25 h) +1. **Alpha gate:** Primary Text Frame page fixes: autoflow adds pages users can't delete (#165) (5–7 h) 2. Basic editing on every platform: copy/paste, context menus, caret formatting (30–50 h) 3. CI running `cargo xtask ci` on pull requests (4–8 h) 4. Open INDD through a clean-room converter, owner decision on #114 (20–60 h) @@ -129,6 +128,13 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text threads by hand: click a selected text frame's in or out port to load + the text cursor, then click a frame to thread it, click empty space for a column-wide frame or + drag one (it snaps like frame drawing); Shift-click autoflows, Alt-click stays loaded on the + frame just added; click the linked frame or double-click a port to break the thread. + `text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers + refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). 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 (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. @@ -169,6 +175,7 @@ Newest first. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Manual text threading landed; alpha distance 15–25 h → 5–7 h (Primary Text Frame page fixes, #165) | | 2026-10-10 | minor | Full number 42% → 47% (method aligned with the standard: plain weighted sum, no new evidence); readiness table with hours per audience | | 2026-10-10 | minor | Headline adds mainstream practitioner (~42%) and essentials user (~54%); method in docs/target-app-parity.md | | 2026-10-10 | minor | Stage alpha → pre-alpha under the new core-workflow gate: manual text threading is missing; alpha gate table added to docs/roadmap.md | diff --git a/docs/gaps.md b/docs/gaps.md index 6f92c5500..31c443d64 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DesignCraft falls short of InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added) · **Target:** Adobe InDesign 2026 (21.6) Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an InDesign professional from doing real work. This is the work list: agents pick from the top. @@ -14,20 +14,14 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ## Alpha blocker (the core-workflow gate in [roadmap.md](roadmap.md#alpha-gate)) -### 0. Text can't be threaded by hand (F, U) · **ALPHA BLOCKER** -- **Missing:** threading a story from one frame to another. The canvas draws in and out ports - (`crates/ui-egui/src/canvas.rs`), but clicking the out port does nothing: no loaded text - cursor, no click-a-frame or draw-a-frame to continue the story, no unthread by double-clicking - a port, and no `frame.thread` / `frame.unthread` command for the control channel or MCP - (`Document::thread` exists in `crates/doc/src/edit.rs` but only autoflow, IDML import and the - sample call it). Long text flows only through autoflow on Place and Primary Text Frame, which - users report adding pages they can't delete (#165). -- **Evidence:** #352, #194, #165, #140 (Windows and unspecified platforms; the code shows it's - missing everywhere). -- **Impact:** a story can't run from page 2 to page 7, or from one column frame to the next - that the designer drew. That is InDesign's core text workflow. -- **Estimate:** 15–25 h (commands with undo and tests 4–6, out-port loaded cursor and - click/draw/unthread 6–12, Primary Text Frame page add/delete fixes 5–7). +### 0. Primary Text Frame autoflow adds pages users can't delete (F) · **ALPHA BLOCKER** +- **Missing:** fixes to Smart Text Reflow on the Primary Text Frame, which users report adding + pages they can't delete (#165). Threading by hand works (ports, loaded text cursor, `text.thread` / `text.unthread` / + `text.removeFromThread`). +- **Evidence:** #165. +- **Impact:** a long story in a Primary Text Frame document can leave pages the designer can't + remove. +- **Estimate:** 5–7 h. - **Doc:** [ui-parity.md](ui-parity.md) ## Blocking (beta can't be declared while these are open) @@ -35,7 +29,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ### 1. Basic editing fails for many users (U, S) - **Missing:** object copy/paste on Windows and Linux (#351, #348, #312, #185, #164, #201, text RTF paste #212); Paste in Place onto the selected page (#348); right-click context menus that don't - open (#238, #187, #160, #312); threading (gap 0); double-click word selection in fields + open (#238, #187, #160, #312); double-click word selection in fields (#189); character formatting set at a caret is discarded (#289). - **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds. - **Impact:** a layout artist can't finish the simplest job. This dominates the @@ -189,6 +183,19 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · parallel story composition. - **Estimate:** 40–80 h. +### 26. Autoflow fills the following pages before it adds pages (F) +- **Missing:** Shift-click autoflow (and autoflow on Place) always adds new pages after the + story's last frame, one margin-sized frame each. InDesign first threads a frame per column on + the existing pages that follow, then adds pages at the end of the document. +- **Impact:** autoflowing into a document that already has pages inserts new pages in the + middle of it instead of using the empty pages. +- **Estimate:** 4–8 h. + +### 27. Loaded text cursor can't thread into a frame inside a group (F, U) +- **Missing:** a click with the loaded text cursor on a text frame inside a group hits the group, + so the cursor shows it can't thread there. +- **Estimate:** 2–4 h. + ## Lower ### 21. Localization breadth (L) @@ -224,5 +231,6 @@ Cue. These are excluded from every percentage. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 0 shrunk to the Primary Text Frame page fixes (#165): text threads by hand; gaps added for autoflow onto existing pages and threading into grouped frames | | 2026-10-10 | minor | Gap 0 added and marked alpha blocker: manual text threading is missing (stage re-normalized to pre-alpha) | | 2026-10-10 | major | Created: 25 ranked gaps from the full re-measure against InDesign 2026 21.6 and issues #1–#352 | diff --git a/docs/roadmap.md b/docs/roadmap.md index d80fe7aaa..85424132d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stage re-normalized to pre-alpha by the core-workflow gate; earlier today: major, milestones re-assessed; beta plan from the full re-measure) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are @@ -18,7 +18,7 @@ but are next in line. | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| | Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | -| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | out/in ports drawn but no out-port click, loaded text cursor or `thread`/`unthread` command; only autoflow on Place and Primary Text Frame, which adds pages users can't delete (#165, #140, #194, #352) | 15–25 | +| Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 | | Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | | Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | | Arrange and edit objects: select, move, transform, duplicate, copy/paste, align, layers | partial (not blocking on macOS) | all present and working on macOS; Paste in Place lands on page 1 (#348); right-click menus fail for some macOS users (#160, #238); copy/paste and context menus fail on Windows/Linux (#351, #185, #164) | 10–20 | @@ -27,16 +27,15 @@ but are next in line. Exchanging files with InDesign users (open INDD, round-trip IDML) is a beta requirement, not part of the alpha gate: IDML works both ways today and INDD is gap 2. -**Distance to alpha:** ~15–25 h (the threading row). Ready for real work (~47%) is already above +**Distance to alpha:** ~5–7 h (the threading row: Primary Text Frame page fixes). Ready for real work (~47%) is already above the ~40% alpha bar. ## Current focus In order. Each closes a beta blocker from [gaps.md](gaps.md). -0. **Alpha gate: manual text threading** (gap 0): `frame.thread` / `frame.unthread` commands, - out-port click with a loaded text cursor (click a frame or draw one), unthread by - double-clicking a port, Primary Text Frame page fixes (#165). 15–25 h. +0. **Alpha gate: Primary Text Frame page fixes** (gap 0): autoflow adds pages users can't + delete (#165). 5–7 h. 1. **Basic editing works on every platform** (gap 1): object copy/paste, Paste in Place, context menus, caret formatting, reproduced and pinned with control-channel tests on macOS, Windows and Linux. 30–50 h. 2. **CI on pull requests** (gap 7): `cargo xtask ci` on every PR, at least macOS and Linux; @@ -58,7 +57,7 @@ set exists; depth gaps are in gaps.md. |---|---|---| | M0 | Skeleton + vertical slice | ✅ | | M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) | -| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool and composer ✅; **manual threading missing** (alpha blocker); caret formatting (#289); no IME (#322) | +| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool, composer and threading ✅; Primary Text Frame pages (#165, alpha blocker); caret formatting (#289); no IME (#322) | | M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) | | M4 | Color & effects | ✅ core; colour management depth | | M5 | Graphics & links | ✅ core; EPS preview only | @@ -71,7 +70,7 @@ set exists; depth gaps are in gaps.md. | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | present; HTML basic (#314), fixed-layout EPUB as images | | M13 | Automation (scripts, batch) | ✅ own command scripts, CLI, MCP; ExtendScript/UXP compatibility ✗ | | M14 | 1.0 polish & packaging | packaging ✅ (macOS, Windows x64/ARM64, Linux deb/AppImage/Flatpak, FreeBSD, web); polish not started | -| **A1** | **Alpha** (core-workflow gate) | blocked by manual text threading; see Alpha gate | +| **A1** | **Alpha** (core-workflow gate) | blocked by the Primary Text Frame page fixes (#165); see Alpha gate | | **B1** | **Beta** (ready ≥ 75%, INDD/IDML reliable) | not started as a milestone; see below | ## To beta @@ -110,6 +109,7 @@ output intent · EPS place") is done except Books depth and EPS (preview only). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Threading row: threading by hand works; the gate waits on the Primary Text Frame page fixes (#165), 5–7 h | | 2026-10-10 | trivial | Ready figure 42% → 47% (method aligned with the standard); beta distance 28 points | | 2026-10-10 | minor | Alpha gate table added (6 core workflows); gate fails on manual threading, stage pre-alpha; A1 milestone and focus item 0 | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md and re-assessed; Current focus and the beta plan from the full re-measure | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5018aa963..9457774e4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DesignCraft parity with Adobe InDesign -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS) The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature @@ -16,7 +16,7 @@ presence checklist is [parity-checklist.md](parity-checklist.md) (`cargo xtask p | Essentials user (flyers, newsletters: the core only) | **~54%** | 135–250 h to ~95%; estimated, [method below](#essentials-user-54) | | Remaining effort to **beta** (~75% ready, main format reliable; 28 points away) | **420–750 Opus 5.5 agent-hours** | estimated | | Remaining effort to **full parity** | **1,150–1,950 Opus 5.5 agent-hours** | estimated | -| Stage | **pre-alpha** (fails the core-workflow gate: no manual text threading); ~15–25 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | +| Stage | **pre-alpha** (fails the core-workflow gate: Primary Text Frame adds pages users can't delete, #165); ~5–7 h to alpha | see [roadmap.md](roadmap.md#alpha-gate) | ## How breadth was measured @@ -77,7 +77,7 @@ standard: 25×55 + 15×45 + 20×40 + 15×45 + 10×45 + 5×65 + 3×45 + 3×55 + 2 today applied a further ×0.9 for cross-cutting core-path failures and reported 42%. That multiplicative step was dropped to match the standard (discounts belong only to the mainstream and essentials numbers). Those failures are already in the UI/UX (45%) and Stability (45%) rows, -and the missing manual threading is what holds the stage at pre-alpha through the gate. +and the Primary Text Frame page fixes (#165) are what hold the stage at pre-alpha through the gate. ## Mainstream practitioner and essentials user @@ -108,7 +108,7 @@ twice. | Area | Weight | Depth | Evidence | |---|---|---|---| | Document setup: pages, margins, columns, parents, sections | 10% | 65% | features area table | -| Text frames: type, select, edit, thread, autoflow | 15% | 45% | manual threading missing; autoflow and Primary Text Frame only | +| Text frames: type, select, edit, thread, autoflow | 15% | 45% | threading by hand, autoflow; Primary Text Frame adds pages users can't delete (#165) | | Typography: composer, H&J, OpenType, tabs, lists, keeps | 15% | 65% | typography-parity.md: 18 ✓ / 16 ~; Tabs panel missing; InDesign-identical breaks counted under exchange | | Paragraph, character and object styles | 10% | 65% | dialogs, based-on, nested/GREP, Quick Apply | | Frames, shapes, transform, arrange, align | 10% | 70% | full tool set; copy/paste problems counted under interaction | @@ -211,7 +211,7 @@ table is the one summed. | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | | Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | -| Type & text frames | 99% | 45% | 40–60 | **no manual threading** (ports drawn, no out-port click, no thread/unthread command: alpha blocker; #352, #194, #165, #140); caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | +| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | | Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | | Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer | @@ -443,6 +443,7 @@ Task notes that headed ROADMAP.md: | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text threads by hand; stage note and distance to alpha follow the gate (5–7 h, #165); percentages unchanged | | 2026-10-10 | minor | Full number 42% → 47%: method aligned with the standard (plain weighted sum, the ×0.9 discount dropped), no new evidence; readiness table with hours for full, mainstream and essentials; beta distance 33 → 28 points | | 2026-10-10 | minor | Added mainstream practitioner (~42%) and essentials user (~54%) numbers with written weights and discounts and a user-evidence count; full number (42%) now written as 47.2% × 0.9, value unchanged | | 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate (manual threading missing); Type & text frames row updated |