Personal outliner built with Rust.
4

Configure Feed

Select the types of activity you want to include in your feed.

ui-polish group 4: page roots render as 22px headings with children one visual level shallower (render-only heading_layout flag — tree depths and dump semantics untouched); fix latent BlockEditor measure bug where the Taffy closure read text_style outside the ancestor scope, shrinking styled editors ~8px vs their painted text

graham.systems (Jul 15, 2026, 4:47 PM -0700) 314f3b52 c6da35c3

+94 -9
+16 -4
crates/trawler/src/editor.rs
··· 1039 1039 let mut style = Style::default(); 1040 1040 style.size.width = relative(1.).into(); 1041 1041 let editor = self.editor.clone(); 1042 + // Capture the text style HERE, inside the ancestor style scope — 1043 + // the measure closure below runs later, during Taffy's layout 1044 + // compute, where `window.text_style()` no longer reflects this 1045 + // element's ancestors. Reading it lazily inside the closure made 1046 + // the editor *measure* at the window's base style while *painting* 1047 + // (prepaint runs back inside the scope) at the cascaded one — an 1048 + // editor inside a styled wrapper (e.g. a 22px page heading) came 1049 + // out ~8px shorter than its own painted text and shifted the rows 1050 + // below it on focus. 1051 + let text_style = window.text_style(); 1052 + let captured_font_size = text_style.font_size.to_pixels(window.rem_size()); 1053 + let captured_line_height = window.line_height(); 1042 1054 // Wrapped height depends on the width this element ends up with, 1043 1055 // which isn't known yet at this point in layout — `Taffy` (via 1044 1056 // `request_measured_layout`) calls this closure back once it has ··· 1057 1069 AvailableSpace::MinContent | AvailableSpace::MaxContent => px(400.0), 1058 1070 }); 1059 1071 let editor = editor.read(cx); 1060 - let style = window.text_style(); 1061 - let font_size = style.font_size.to_pixels(window.rem_size()); 1062 - let line_height = window.line_height(); 1072 + let style = &text_style; 1073 + let font_size = captured_font_size; 1074 + let line_height = captured_line_height; 1063 1075 let (text, runs) = full_text_and_runs( 1064 1076 &editor.content, 1065 1077 editor.highlight_scheme, 1066 - editor_font(&style, editor.highlight_scheme), 1078 + editor_font(style, editor.highlight_scheme), 1067 1079 style.color, 1068 1080 ); 1069 1081 let lines = window
+75 -2
crates/trawler/src/main.rs
··· 1883 1883 /// line box's geometric center. Tuned by eye; zero at the current line 1884 1884 /// height. 1885 1885 const BULLET_OPTICAL_NUDGE: f32 = 0.0; 1886 + /// Page-root heading text size (openspec change ui-polish, design D6): a 1887 + /// page's title renders as a large heading, not a top-level bullet, when 1888 + /// the view's roots are pages. 1889 + const PAGE_HEADING_TEXT_SIZE: f32 = 22.0; 1890 + /// Vertical padding above/below a page-root heading row. 1891 + const PAGE_HEADING_PAD_TOP: f32 = 10.0; 1892 + const PAGE_HEADING_PAD_BOTTOM: f32 = 2.0; 1886 1893 /// A query block's own row gets this background tint — without it a query 1887 1894 /// block was indistinguishable from an ordinary one. Deliberately applied 1888 1895 /// only to the row, not the result section below it too: tinting both ··· 2544 2551 let row_count = self.rows.len(); 2545 2552 let focused_block = self.editor.as_ref().map(|e| e.block); 2546 2553 let editor_input = self.editor.as_ref().map(|e| e.input.clone()); 2554 + // Whether this view's depth-0 rows are page roots (openspec change 2555 + // ui-polish, design D6): the journal stacks whole pages, and a 2556 + // `Node` view of a tree root is a page view. Those roots render as 2557 + // headings with their children pulled one indent level shallower — 2558 + // a rendering decision only; `VisibleRow`/dump depths stay tree 2559 + // depths. A zoomed (non-root) block keeps the ordinary bullet 2560 + // treatment. 2561 + let heading_layout = match &self.view { 2562 + View::Journal => true, 2563 + View::Node(id) => id 2564 + .as_tree_id() 2565 + .is_some_and(|t| Outline::new(self.storage.doc()).parent(t).is_none()), 2566 + }; 2547 2567 let rows: Vec<RowSnapshot> = self 2548 2568 .rows 2549 2569 .iter() ··· 3069 3089 { 3070 3090 this.schedule_query_eval(id, Duration::ZERO, cx); 3071 3091 } 3092 + // Page-root heading (openspec change ui-polish, 3093 + // design D6): no bullet, no fold affordance, 3094 + // heading-scale text. Still the same editable 3095 + // root block — the editor variant renders here 3096 + // too, so renames happen in place, and Up from 3097 + // the first child lands on this row exactly as 3098 + // it landed on the old root bullet. A page 3099 + // toggled into a query block falls through to 3100 + // the ordinary row so its source/result UI 3101 + // stays intact. 3102 + if heading_layout && *depth == 0 && !is_query { 3103 + let heading = div() 3104 + .id(ix) 3105 + .w_full() 3106 + .pl(px(OUTLINE_LEFT_PAD)) 3107 + .pr_2() 3108 + .pt(px(PAGE_HEADING_PAD_TOP)) 3109 + .pb(px(PAGE_HEADING_PAD_BOTTOM)) 3110 + .text_size(px(PAGE_HEADING_TEXT_SIZE)) 3111 + .font_weight(FontWeight::BOLD); 3112 + return match content { 3113 + RowContent::Editor(input) => heading 3114 + .child(div().min_h(line_height).child(input.clone())), 3115 + RowContent::Markdown(blocks) => heading 3116 + .child( 3117 + div() 3118 + .id(("content", ix)) 3119 + .min_w_0() 3120 + .min_h(line_height) 3121 + .cursor_pointer() 3122 + .on_click(cx.listener( 3123 + move |this, _event: &ClickEvent, window, cx| { 3124 + this.focus_block(id, window, cx); 3125 + }, 3126 + )) 3127 + .children(render_blocks(blocks, ix, cx)), 3128 + ), 3129 + // Unreachable while `!is_query` gates 3130 + // this branch; kept total for safety. 3131 + RowContent::Source(source) => { 3132 + heading.child(render_scheme_source(source)) 3133 + } 3134 + } 3135 + .into_any_element(); 3136 + } 3137 + // Descendants of heading roots render one 3138 + // level shallower — the heading replaced the 3139 + // root bullet's level (design D6). 3140 + let indent_depth = if heading_layout { 3141 + depth.saturating_sub(1) 3142 + } else { 3143 + *depth 3144 + }; 3072 3145 // `items_start` (not `items_center`) so the 3073 3146 // fold arrow/bullet line up with the first line 3074 3147 // of the content instead of the vertical center ··· 3078 3151 let mut row = div() 3079 3152 .id(ix) 3080 3153 .w_full() 3081 - .pl(px(INDENT_PER_LEVEL * *depth as f32 + OUTLINE_LEFT_PAD)) 3154 + .pl(px(INDENT_PER_LEVEL * indent_depth as f32 + OUTLINE_LEFT_PAD)) 3082 3155 .pr_2() 3083 3156 .py_1() 3084 3157 .flex() ··· 3223 3296 // the indented region itself. 3224 3297 // 3225 3298 div() 3226 - .ml(px(INDENT_PER_LEVEL * (*depth as f32 + 1.0) 3299 + .ml(px(INDENT_PER_LEVEL * (indent_depth as f32 + 1.0) 3227 3300 + OUTLINE_LEFT_PAD)) 3228 3301 .mr_2() 3229 3302 .mb_2()
+3 -3
openspec/changes/ui-polish/tasks.md
··· 19 19 20 20 ## 4. Page title as heading (design D6) 21 21 22 - - [ ] 4.1 Render page roots as heading rows (no bullet/fold affordance; heading-scale named knobs); children start at indent level 0 23 - - [ ] 4.2 Preserve focus/edit/rename behavior on the heading row (Up from first child lands on it; editing = root-block editing) 24 - - [ ] 4.3 Update UI-test layout assertions for the shallower indent; confirm devtools dump depth semantics unchanged; dev-loop screenshot of a journal page before/after 22 + - [x] 4.1 Page roots render as heading rows (no bullet/fold affordance; `PAGE_HEADING_TEXT_SIZE`/`_PAD_TOP`/`_PAD_BOTTOM` knobs; 22px bold); children pulled one indent level shallower — implemented as a render-only per-view `heading_layout` flag (journal + page views; zoomed blocks keep bullets; a query-toggled page root falls through to the ordinary row so its source/result UI survives) 23 + - [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 24 + - [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 25 25 26 26 ## 5. Bullet threading (design D4) — after the heading change, so guides are built against final indent geometry 27 27