From 81cc75ef3e16a699f88686d3ca4e0f2e9c6ff794 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Wed, 15 Jul 2026 18:24:06 -0700 Subject: [PATCH] =?UTF-8?q?ui-polish=20group=205:=20bullet=20threading=20?= =?UTF-8?q?=E2=80=94=20static=20indent=20guides=20(per-row=20ancestor=20da?= =?UTF-8?q?ta,=20inset=20starts,=20terminal=20segments=20through=20the=20l?= =?UTF-8?q?ast=20child's=20line)=20plus=20the=20focused-path=20accent=20th?= =?UTF-8?q?read=20=C3=A0=20la=20the=20Logseq=20plugin:=20rounded=20elbows?= =?UTF-8?q?=20threading=20the=20bullet=20icons=20from=20the=20highest=20vi?= =?UTF-8?q?sible=20ancestor=20to=20the=20focused=20block,=20path=20bullets?= =?UTF-8?q?=20adopting=20the=20thread=20color.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fixes backspace-at-start merge (found in review, pre-existing): the target is now the visually previous row — a previous sibling's deepest visible descendant, or the parent for first children (previously undeletable) — guarded so a non-empty block never merges into a page title; three UI tests pin the cases. --- crates/trawler/src/main.rs | 309 +++++++++++++++++- crates/trawler/src/ui_tests.rs | 134 ++++++++ openspec/changes/ui-polish/design.md | 14 + .../ui-polish/specs/outline-editor/spec.md | 52 ++- openspec/changes/ui-polish/tasks.md | 6 +- 5 files changed, 488 insertions(+), 27 deletions(-) diff --git a/crates/trawler/src/main.rs b/crates/trawler/src/main.rs index 92beb32..eaa9d0d 100644 --- a/crates/trawler/src/main.rs +++ b/crates/trawler/src/main.rs @@ -179,6 +179,15 @@ struct VisibleRow { /// source, shown verbatim rather than through Markdown) and cheap /// enough to always fill in. raw_content: String, + /// Threading data (openspec change ui-polish, design D4): one entry per + /// ancestor-path level below the pushed root — entry `j` is whether the + /// path node at relative depth `j + 1` has a following sibling, i.e. + /// whether that level's guide line continues below this row. The last + /// entry describes this row itself. + guides: Vec, + /// Whether this row is its parent's first child — where its parent's + /// guide line begins, which gets a small top inset. + is_first_child: bool, } /// One entry in the backlinks panel: a referencing block, its containing @@ -1465,13 +1474,20 @@ impl TrawlerApp { } /// Backspace at the start of a block (no selection): merge it into the - /// end of its previous sibling's content and focus that sibling there - /// (spec: "delete/merge with previous"). No-op if there's no previous - /// sibling, the block has children (matching `Outline::merge_block`'s - /// precondition — merging a block with descendants would orphan them), - /// or the block is a page root (its "siblings" are other top-level - /// pages — merging those together would silently delete a whole page - /// into another page's title, not merge outline content). + /// end of the *visually previous* block — the row directly above in + /// the rendered outline (a previous sibling's deepest visible + /// descendant, or the parent when this is a first child), matching the + /// Logseq convention (spec: "delete/merge with previous"). No-op if the + /// block has children (matching `Outline::merge_block`'s precondition — + /// merging a block with descendants would orphan them), if the block is + /// a page root, or if the target would be a page root while this block + /// still has content — appending text into a page *title* would + /// silently rename the page; an empty block, though, merges harmlessly + /// (pure deletion), which is how the first block of a page is removed. + /// + /// Fold-awareness falls out of using `self.rows`: a folded sibling's + /// hidden descendants are never rows, so the merge target is exactly + /// the block the user sees above the cursor. /// /// `BlockEditor` itself already confirmed the cursor was at position 0 /// with no selection before emitting `MergeWithPreviousRequested` — @@ -1487,17 +1503,18 @@ impl TrawlerApp { if !outline.children(Some(block)).is_empty() { return; } - let parent = outline.parent(block); - if parent.is_none() { + if outline.parent(block).is_none() { return; } - let siblings = outline.children(parent); - let Some(pos) = siblings.iter().position(|&s| s == block) else { + let Some(row_ix) = self.rows.iter().position(|r| r.id == block) else { return; }; - let Some(prev) = pos.checked_sub(1).map(|i| siblings[i]) else { + let Some(prev) = row_ix.checked_sub(1).map(|i| self.rows[i].id) else { return; }; + if outline.parent(prev).is_none() && !live_content.is_empty() { + return; + } // `merge_block` reads content from the doc, not the live editor // buffer — sync first so nothing typed since focus is lost. @@ -1820,6 +1837,20 @@ fn push_subtree( depth: usize, folded: &HashSet, rows: &mut Vec, +) { + let mut continues = Vec::new(); + push_subtree_inner(outline, id, depth, folded, rows, &mut continues, false); +} + +#[allow(clippy::too_many_arguments)] +fn push_subtree_inner( + outline: &Outline, + id: TreeID, + depth: usize, + folded: &HashSet, + rows: &mut Vec, + continues: &mut Vec, + is_first_child: bool, ) { let content = outline.content(id).unwrap_or_default(); let children = outline.children(Some(id)); @@ -1836,12 +1867,22 @@ fn push_subtree( folded: folded.contains(&id), is_query, raw_content: content, + guides: continues.clone(), + is_first_child, }); if folded.contains(&id) { return; } - for child in children { - push_subtree(outline, child, depth + 1, folded, rows); + let last = children.len().saturating_sub(1); + for (i, child) in children.into_iter().enumerate() { + // Threading data (openspec change ui-polish, design D4): entry `j` + // of a row's `guides` says whether the node at depth + // `root_depth + j + 1` on this row's ancestor path has a following + // sibling — i.e. whether the guide line through that ancestor's + // indent column continues below this row. + continues.push(i < last); + push_subtree_inner(outline, child, depth + 1, folded, rows, continues, i == 0); + continues.pop(); } } @@ -1890,6 +1931,28 @@ const PAGE_HEADING_TEXT_SIZE: f32 = 22.0; /// Vertical padding above/below a page-root heading row. const PAGE_HEADING_PAD_TOP: f32 = 10.0; const PAGE_HEADING_PAD_BOTTOM: f32 = 2.0; +/// Bullet-threading guide lines (openspec change ui-polish, design D4): +/// hairlines in the indent columns connecting each parent's bullet to its +/// descendants. Width and color are the knobs; the x position derives from +/// the bullet column (each guide runs through its ancestor's bullet +/// center, half the 14px bullet box). +const GUIDE_WIDTH: f32 = 1.0; +const GUIDE_COLOR: u32 = 0x3a3a3a; +/// Inset below the first child's row top where its parent's guide begins. +const GUIDE_TOP_INSET: f32 = 4.0; +/// Horizontal center of the bullet column (half its 14px width) — where a +/// guide line vertically aligns under its bullet. +const BULLET_COLUMN_CENTER: f32 = 7.0; +/// The focused-path thread (design D4 as revised in review): an accent +/// line threading the bullet icons from the highest visible ancestor down +/// to the focused block's bullet, over the quiet gray guides. Bullets on +/// the path adopt the thread color. `THREAD_WIDTH` must stay in step with +/// the elbow's `border_l_2`/`border_b_2` calls (gpui's per-side border +/// helpers are fixed-step). +const THREAD_WIDTH: f32 = 2.0; +const THREAD_COLOR: u32 = 0x7c9dd9; +/// Corner radius of the thread's elbow bend into a bullet. +const THREAD_BEND_RADIUS: f32 = 6.0; /// A query block's own row gets this background tint — without it a query /// block was indistinguishable from an ordinary one. Deliberately applied /// only to the row, not the result section below it too: tinting both @@ -2521,6 +2584,26 @@ struct RowSnapshot { content: RowContent, is_query: bool, query_outcome: Option, + guides: Vec, + is_first_child: bool, + accent: RowAccent, +} + +/// The focused-path thread's contribution to one row (design D4 as +/// revised): precomputed in `render` from the focused block's ancestor +/// chain, so each virtualized row draws its accent segments without +/// knowing the rest of the path. +#[derive(Clone, Default)] +struct RowAccent { + /// Tree-depth columns whose accent line passes full-height through + /// this row (the thread descending between two path nodes). + verticals: Vec, + /// This row is a path node: the thread arrives from its parent's + /// column (the value, a tree depth) and elbows horizontally into this + /// row's own bullet. + elbow_from: Option, + /// This row is a path ancestor: the thread starts below its bullet. + stub: bool, } /// A window caption button (min/max/close) for the client-drawn titlebar. @@ -2564,10 +2647,58 @@ impl Render for TrawlerApp { .as_tree_id() .is_some_and(|t| Outline::new(self.storage.doc()).parent(t).is_none()), }; + // Focused-path thread (design D4 as revised): walk the focused + // block's visible ancestor chain top-down and record, per row, + // which accent segments pass through it — a descending vertical + // between consecutive path nodes, an elbow into each path node's + // bullet, and a stub below each ancestor's bullet. Rows outside + // the path get the default (empty) accent. + let mut accents: HashMap = HashMap::new(); + if let Some(focused) = focused_block { + let row_ix_by_id: HashMap = self + .rows + .iter() + .enumerate() + .map(|(i, r)| (r.id, i)) + .collect(); + if row_ix_by_id.contains_key(&focused) { + let outline = Outline::new(self.storage.doc()); + let mut path = vec![focused]; + let mut cursor = focused; + while let Some(parent) = outline.parent(cursor) { + if !row_ix_by_id.contains_key(&parent) { + break; // above the view (zoomed) — thread starts here + } + path.push(parent); + cursor = parent; + } + path.reverse(); + // Headings have no bullet to thread from: drop a depth-0 + // page root from the front under the heading layout. + if heading_layout + && path + .first() + .and_then(|id| row_ix_by_id.get(id)) + .is_some_and(|&ix| self.rows[ix].depth == 0) + { + path.remove(0); + } + for pair in path.windows(2) { + let (from_ix, to_ix) = (row_ix_by_id[&pair[0]], row_ix_by_id[&pair[1]]); + let col = self.rows[from_ix].depth; + accents.entry(from_ix).or_default().stub = true; + for ix in (from_ix + 1)..to_ix { + accents.entry(ix).or_default().verticals.push(col); + } + accents.entry(to_ix).or_default().elbow_from = Some(col); + } + } + } let rows: Vec = self .rows .iter() - .map(|r| { + .enumerate() + .map(|(row_ix, r)| { let content = if Some(r.id) == focused_block { RowContent::Editor(editor_input.clone().expect("focused block has an editor")) } else if r.is_query { @@ -2583,6 +2714,9 @@ impl Render for TrawlerApp { content, is_query: r.is_query, query_outcome: self.query_results.get(&r.id).cloned(), + guides: r.guides.clone(), + is_first_child: r.is_first_child, + accent: accents.get(&row_ix).cloned().unwrap_or_default(), } }) .collect(); @@ -3073,6 +3207,9 @@ impl Render for TrawlerApp { content, is_query, query_outcome, + guides, + is_first_child, + accent, } = &rows[ix]; let id = *id; let is_query = *is_query; @@ -3142,6 +3279,134 @@ impl Render for TrawlerApp { } else { *depth }; + // Threading guides (design D4): a hairline in + // each indent column whose ancestor chain + // continues below this row, computed per row so + // virtualization never needs a whole-tree pass. + // Bullets at tree depth `t` sit in visual + // column `t - shift` (headings occupy no + // column); the guide through them is drawn on + // this row iff the path node at depth `t + 1` + // (`guides[t]`) has a following sibling — + // except the innermost (parent) column, which + // always draws: full-height when this row has + // later siblings, half-height into a last + // child's bullet line so the thread visibly + // terminates there. + let shift = usize::from(heading_layout); + // A terminating guide runs to the bottom of the + // last child's first text line (through its + // bullet — the dot paints over the line), not + // just to its center. + let terminal_guide_height = + f32::from(line_height) + BULLET_OPTICAL_NUDGE + 4.0; + // X of the bullet-dot center for a bullet at + // `tree_depth`; every line centers on this + // (subtracting half its own width). + let column_x = |tree_depth: usize| { + INDENT_PER_LEVEL * tree_depth.saturating_sub(shift) as f32 + + OUTLINE_LEFT_PAD + + BULLET_COLUMN_CENTER + }; + // Y of the bullet-dot center within the row + // (py_1 top padding + half the first line box). + let bullet_center_y = + 4.0 + BULLET_OPTICAL_NUDGE + f32::from(line_height) / 2.0; + // Guides start at the top edge of the first + // child's row (no stub below the parent's own + // bullet), and end through the last child's + // first line. + let guide_segments: Vec = (shift..*depth) + .filter_map(|t| { + let continues = guides.get(t).copied().unwrap_or(false); + let is_parent_col = t == depth - 1; + if !continues && !is_parent_col { + return None; + } + // Where the thread's elbow lands on a + // terminating line, the accent bend + // replaces the gray tail entirely. + if !continues && accent.elbow_from == Some(t) { + return None; + } + // The guide's very first segment (on + // the parent's first child) starts a + // touch below the row edge. + let top = if is_parent_col && *is_first_child { + GUIDE_TOP_INSET + } else { + 0.0 + }; + let seg = div() + .absolute() + .left(px(column_x(t) - GUIDE_WIDTH / 2.0)) + .top(px(top)) + .w(px(GUIDE_WIDTH)) + .bg(rgb(GUIDE_COLOR)); + Some( + if continues { + seg.bottom_0() + } else { + seg.h(px(terminal_guide_height - top)) + } + .into_any_element(), + ) + }) + .collect(); + // Focused-path thread (design D4 as revised): + // accent segments over the gray guides — a + // descending vertical per traversed column, a + // rounded elbow into a path node's own bullet, + // and a stub starting at a path ancestor's + // bullet center (the dot, itself thread- + // colored, paints over the joint so the line + // visibly touches it). + let mut thread_segments: Vec = Vec::new(); + for &col in &accent.verticals { + thread_segments.push( + div() + .absolute() + .left(px(column_x(col) - THREAD_WIDTH / 2.0)) + .top_0() + .bottom_0() + .w(px(THREAD_WIDTH)) + .bg(rgb(THREAD_COLOR)) + .into_any_element(), + ); + } + if accent.stub { + thread_segments.push( + div() + .absolute() + .left(px(column_x(*depth) - THREAD_WIDTH / 2.0)) + .top(px(bullet_center_y)) + .bottom_0() + .w(px(THREAD_WIDTH)) + .bg(rgb(THREAD_COLOR)) + .into_any_element(), + ); + } + if let Some(from_col) = accent.elbow_from { + // One box whose left + bottom borders form + // the elbow, with a rounded bottom-left + // corner — the horizontal run ends under + // this row's own bullet. + let from_left = column_x(from_col) - THREAD_WIDTH / 2.0; + let own_x = column_x(*depth); + thread_segments.push( + div() + .absolute() + .left(px(from_left)) + .top_0() + .w(px(own_x - from_left)) + .h(px(bullet_center_y + THREAD_WIDTH / 2.0)) + .border_l_2() + .border_b_2() + .rounded_bl(px(THREAD_BEND_RADIUS)) + .border_color(rgb(THREAD_COLOR)) + .into_any_element(), + ); + } // `items_start` (not `items_center`) so the // fold arrow/bullet line up with the first line // of the content instead of the vertical center @@ -3150,6 +3415,7 @@ impl Render for TrawlerApp { // block, a code fence, a heading). let mut row = div() .id(ix) + .relative() .w_full() .pl(px(INDENT_PER_LEVEL * indent_depth as f32 + OUTLINE_LEFT_PAD)) .pr_2() @@ -3157,7 +3423,9 @@ impl Render for TrawlerApp { .flex() .flex_row() .items_start() - .gap(px(BULLET_TEXT_GAP)); + .gap(px(BULLET_TEXT_GAP)) + .children(guide_segments) + .children(thread_segments); if is_query { // A query block otherwise looks exactly // like a plain outline row containing @@ -3183,7 +3451,12 @@ impl Render for TrawlerApp { // height so the dot centers on the row's first // text line, not the whole (possibly // multi-line) row. - let dot = div().size(px(6.0)).rounded_full().bg(rgb(MUTED_COLOR)); + // Bullets on the focused path adopt the thread + // color — the thread literally strings the + // colored icons together. + let on_thread = accent.stub || accent.elbow_from.is_some(); + let bullet_color = if on_thread { THREAD_COLOR } else { MUTED_COLOR }; + let dot = div().size(px(6.0)).rounded_full().bg(rgb(bullet_color)); let mut bullet = div() .id(("bullet", ix)) .w(px(14.0)) @@ -3206,7 +3479,7 @@ impl Render for TrawlerApp { .size(px(12.0)) .rounded_full() .border_1() - .border_color(rgb(MUTED_COLOR)) + .border_color(rgb(bullet_color)) .flex() .items_center() .justify_center() diff --git a/crates/trawler/src/ui_tests.rs b/crates/trawler/src/ui_tests.rs index 31e2c2e..721b911 100644 --- a/crates/trawler/src/ui_tests.rs +++ b/crates/trawler/src/ui_tests.rs @@ -515,3 +515,137 @@ async fn sidebar_hosts_calendar_and_similar_panels(cx: &mut gpui::TestAppContext ); }); } + +// --- bullet threading (openspec change ui-polish, design D4) --------------- + +/// The per-row guide data (which ancestor chains continue below each row) +/// computed for the fixture journal page's known shape. Rendering is +/// checked via dev-loop screenshots; this pins the data the hairlines are +/// drawn from. +#[gpui::test] +async fn threading_guides_match_fixture_structure(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("threading-guides", cx); + cx.run_until_parked(); + + let guides_of = |app: &Entity, cx: &mut VisualTestContext, content: &str| { + app.update(cx, |app, _| { + let row = app + .rows + .iter() + .find(|r| r.raw_content == content) + .unwrap_or_else(|| panic!("row {content:?} not visible")); + row.guides.clone() + }) + }; + + // "Started..." is the journal page's first child with siblings after it. + assert_eq!( + guides_of(&app, cx, "Started the trawler dogfood log #trawler"), + vec![true] + ); + // "Outlined..." sits under Deep work (the page's LAST child → false) + // and is followed by a sibling (→ true). + assert_eq!( + guides_of(&app, cx, "Outlined the fixture graph #project"), + vec![false, true] + ); + // "Follow up..." is a last child of a last child of a last child. + assert_eq!( + guides_of(&app, cx, "Follow up in [[reading-list]]"), + vec![false, false, false] + ); +} + +// --- visually-previous merge (backspace at start) --------------------------- + +/// Backspace at the start of a block whose previous *sibling* has visible +/// descendants merges into the deepest such descendant — the row the user +/// sees directly above — not into the sibling itself. +#[gpui::test] +async fn backspace_merges_into_visually_previous_row(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("backspace-visual-prev", cx); + open_page(cx, "trawler-design"); + + // The query block follows "Tasks", whose last child is "Wire dev + // automation #project" — the visually previous row. + let query = block_by_content(&app, cx, trawler_core::fixtures::FIXTURE_QUERY_EXPR); + let target = block_by_content(&app, cx, "Wire dev automation #project"); + + focus_block(&app, cx, query); + cx.simulate_keystrokes("home"); + cx.simulate_keystrokes("backspace"); + + assert_eq!( + stored_content(&app, cx, target), + format!( + "Wire dev automation #project{}", + trawler_core::fixtures::FIXTURE_QUERY_EXPR + ), + "content joins the visually previous row, not the previous sibling" + ); + assert_eq!(focused_block(&app, cx), target); + assert_eq!( + focused_cursor_offset(&app, cx), + "Wire dev automation #project".len() + ); +} + +/// Backspace at the start of a *first child* merges into its parent — the +/// visually previous row — instead of no-op'ing (previously undeletable). +#[gpui::test] +async fn backspace_on_first_child_merges_into_parent(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("backspace-first-child", cx); + open_page(cx, "trawler-design"); + + let first_child = block_by_content(&app, cx, "Keyboard-first outlining #project"); + let parent = block_by_content(&app, cx, "Goals"); + + focus_block(&app, cx, first_child); + cx.simulate_keystrokes("home"); + cx.simulate_keystrokes("backspace"); + + assert_eq!( + stored_content(&app, cx, parent), + "GoalsKeyboard-first outlining #project" + ); + assert_eq!(focused_block(&app, cx), parent); +} + +/// The first block of a page: an *empty* one deletes into the page root +/// (harmless — nothing is appended to the title); a non-empty one is a +/// no-op, since merging real content into a page title would silently +/// rename the page. +#[gpui::test] +async fn backspace_on_page_first_block_deletes_only_when_empty(cx: &mut gpui::TestAppContext) { + let (app, cx) = open_app("backspace-into-page-root", cx); + cx.run_until_parked(); + + // The app opens focused on today's page's single empty block. + let block = focused_block(&app, cx); + let page = parent_of(&app, cx, block).expect("today's block has a page parent"); + + // Non-empty: refused. + cx.simulate_input("keep me"); + cx.simulate_keystrokes("home"); + cx.simulate_keystrokes("backspace"); + assert!( + children_of(&app, cx, Some(page)).contains(&block), + "non-empty first block must not merge into the page title" + ); + + // Emptied: backspace deletes the block and lands on the page root. + cx.simulate_keystrokes("end"); + for _ in 0.."keep me".len() { + cx.simulate_keystrokes("backspace"); + } + cx.simulate_keystrokes("backspace"); // now at start, empty + assert!( + children_of(&app, cx, Some(page)).is_empty(), + "empty first block deletes into the page root" + ); + assert_eq!(focused_block(&app, cx), page); + assert!( + !stored_content(&app, cx, page).contains("keep me"), + "page title must be untouched" + ); +} diff --git a/openspec/changes/ui-polish/design.md b/openspec/changes/ui-polish/design.md index e39a14c..41b672d 100644 --- a/openspec/changes/ui-polish/design.md +++ b/openspec/changes/ui-polish/design.md @@ -82,6 +82,20 @@ time — if the sidebar feels right, it follows). ### D4 — Threading: per-row ancestor guides, virtualization-friendly +> **Revised during review.** The static guides below shipped as the quiet +> background layer, refined per review: guides start slightly inset below +> the first child's row top (`GUIDE_TOP_INSET`), terminating segments run +> through the last child's bullet to the bottom of its first line, and the +> gray tail is suppressed where the thread's elbow terminates. The +> centerpiece the proposer actually intended (à la the Logseq +> bullet-threading plugin) was added on top: a **focused-path thread** — +> an accent line (`THREAD_COLOR`/`THREAD_WIDTH`, rounded elbows via +> `THREAD_BEND_RADIUS`) threading the bullet icons from the highest +> visible bulleted ancestor to the focused block's bullet, with path +> bullets adopting the thread color. Precomputed per render from the +> focused block's visible ancestor chain into per-row segments +> (`RowAccent`), so virtualization still needs no whole-tree pass. + Standard tree-guide algorithm, computed per row so it works inside the virtualized `list`: for each visible row, for each ancestor level, draw a vertical segment in that level's indent column if the ancestor chain diff --git a/openspec/changes/ui-polish/specs/outline-editor/spec.md b/openspec/changes/ui-polish/specs/outline-editor/spec.md index 9033266..0882694 100644 --- a/openspec/changes/ui-polish/specs/outline-editor/spec.md +++ b/openspec/changes/ui-polish/specs/outline-editor/spec.md @@ -2,18 +2,58 @@ ## ADDED Requirements -### Requirement: Hierarchy guides -Rendered outline rows SHALL display vertical guide lines in their indent -columns connecting each parent's bullet to its descendant rows, so a block's -ancestor chain is visually traceable at any depth. Guides MUST render +### Requirement: Hierarchy guides and the focused-path thread +Rendered outline rows SHALL display two layers of threading (revised during +review from a single-guide design). **Static guides**: vertical hairlines in +each indent column whose ancestor chain continues below the row, starting +slightly inset below the first child's row top and, on a terminating line, +running through the last child's bullet to the bottom of its first text +line. **Focused-path thread**: when a block is focused, an accent-colored +line SHALL thread the bullet icons from the highest visible bulleted +ancestor down to the focused block's bullet — descending verticals through +traversed columns, a rounded elbow into each path node's bullet, the line +visibly touching the bullets it connects — and bullets on the path SHALL +adopt the thread color. Where a thread elbow lands on a terminating guide, +the gray tail is suppressed in favor of the bend. Both layers MUST render correctly under virtualization (computed per visible row, no whole-tree pass) and respect the outline's named layout knobs. #### Scenario: Deep block is visually anchored - **WHEN** a block nested four levels deep is visible - **THEN** each of its four indent columns shows a guide segment exactly when - the corresponding ancestor chain continues at that level, and the innermost - guide connects toward its parent's bullet + the corresponding ancestor chain continues at that level + +#### Scenario: Thread follows focus +- **WHEN** a block nested several levels deep is focused +- **THEN** an accent line runs from the highest visible bulleted ancestor + through each intermediate ancestor's bullet, bending into the focused + block's bullet, with every bullet on that path tinted the thread color — + and moving focus elsewhere re-threads accordingly + +## MODIFIED Requirements + +### Requirement: Keyboard-complete outline manipulation +All outline operations SHALL be executable without the mouse: create sibling (Enter), insert a newline within the block (Shift+Enter), split block at cursor, indent/outdent (Tab/Shift+Tab), move block up/down among siblings, delete/merge with previous (Backspace at start), and fold/unfold subtree. Backspace at the start of a childless block SHALL merge it into the *visually previous* row — a previous sibling's deepest visible descendant, or the parent when the block is a first child — with the exception that merging into a page root is permitted only when the block is empty (pure deletion), never appending content into a page title. + +#### Scenario: Indent under previous sibling +- **WHEN** the cursor is in a block and the user presses Tab +- **THEN** the block (with its subtree) becomes the last child of its previous sibling, and the cursor position within the text is preserved + +#### Scenario: Newline within a block vs. new block +- **WHEN** the user presses Shift+Enter mid-block, types a second paragraph, then presses Enter +- **THEN** the block contains both paragraphs, and a new empty sibling block is created and focused + +#### Scenario: Fold hides descendants +- **WHEN** the user folds a block with descendants +- **THEN** descendants are hidden, a fold indicator is shown, and keyboard navigation skips the hidden blocks + +#### Scenario: Merge follows the eye +- **WHEN** the cursor is at the start of a block whose previous sibling has visible descendants and the user presses Backspace +- **THEN** the block's content joins the end of the deepest visible descendant — the row rendered directly above — and focus lands at the join point + +#### Scenario: First children are deletable +- **WHEN** the cursor is at the start of a first child block and the user presses Backspace +- **THEN** the block merges into its parent (an empty block simply disappears), except when the parent is a page root and the block still has content, which is refused rather than renaming the page ### Requirement: Animated programmatic scrolling Programmatic scroll changes (navigation history restore, follow-reference, diff --git a/openspec/changes/ui-polish/tasks.md b/openspec/changes/ui-polish/tasks.md index 98c4fa6..a8038d5 100644 --- a/openspec/changes/ui-polish/tasks.md +++ b/openspec/changes/ui-polish/tasks.md @@ -23,10 +23,10 @@ - [x] 4.2 Heading is the same editable root block: renames render in the heading at heading scale; quick-open navigation focuses it; row order unchanged so Up from first child lands on it. Fixed a latent BlockEditor bug this exposed: the Taffy measure closure read `window.text_style()` outside the ancestor style scope, so an editor in any styled wrapper measured at the base style while painting at the cascaded one (~8px row shrink on heading focus, children shifting up) — request_layout now captures the style inside the scope and the closure uses the captured values - [x] 4.3 No UI-test assertion churn needed: depth stays tree depth everywhere (`VisibleRow`, dump) because the shift is render-only — verified via dump (pages 0, children 1); dev-loop screenshots of journal and page views, including focused-vs-unfocused layout stability -## 5. Bullet threading (design D4) — after the heading change, so guides are built against final indent geometry +## 5. Bullet threading (design D4, revised in review) — after the heading change, so guides are built against final indent geometry -- [ ] 5.1 Per-row ancestor-guide computation (segment per indent column where the chain continues below) and rendering in the indent columns; named knobs for guide color/width; innermost segment meets the parent bullet position -- [ ] 5.2 UI test / dump-based assertions over a fixture page with known nesting; visual check via dev-loop screenshots at several depths +- [x] 5.1 Two layers shipped. **Static guides**: per-row ancestor-guide computation (`VisibleRow.guides` from `push_subtree`), hairlines centered on bullet columns, start inset below the first child's row top (`GUIDE_TOP_INSET`), terminating segments through the last child's bullet to its line bottom, tails suppressed under thread elbows. **Focused-path thread** (the proposer's actual intent, à la the Logseq plugin): accent line threading the bullet icons from the highest visible bulleted ancestor to the focused bullet — per-render precomputed `RowAccent` segments, rounded elbows (`border_l_2`+`border_b_2`+`rounded_bl`), path bullets (and fold rings) adopt `THREAD_COLOR`. Knobs: `GUIDE_WIDTH/COLOR/TOP_INSET`, `THREAD_WIDTH/COLOR/BEND_RADIUS`, `BULLET_COLUMN_CENTER` +- [x] 5.2 UI test pins the guide data against the fixture's known shape (`threading_guides_match_fixture_structure`); dev-loop verification at depth 4 with a live-built nest covering continuing, terminating, and focused-thread cases across five review iterations of screenshots ## 6. Smooth scrolling (design D5) -- 2.51.2