From ca0b4f8659aa308908d8d9034b0f104683d932df Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:55 +0000 Subject: [PATCH 01/12] Open hyperlinks on Ctrl/Cmd-click in reflow mode + real DocumentViewProps eq (5.11 tail, 6.7) Reflow half of feature 5.11: DocumentView resolves the URL against its continuous layout (reflow_link_at plumbing through RenderLayout and DocPageSource) and reports it via a new on_open_link handler; loki-text opens it with webbrowser, keeping the browser dependency in the app layer. Resolves TODO(link-click-reflow). Also replaces the hardwired-false DocumentViewProps PartialEq with a real field comparison (plan 6.7): doc/paginated_layout by Arc identity, scalars by value; event handlers deliberately omitted (Dioxus EventHandlers always compare equal). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-renderer/src/doc_page_source_reflow.rs | 16 ++++++++++ loki-renderer/src/document_view.rs | 12 ++++++-- loki-renderer/src/render_layout.rs | 8 +++++ loki-renderer/src/view_types.rs | 31 ++++++++++++++++++-- loki-text/src/routes/editor/editor_canvas.rs | 6 ++++ 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/loki-renderer/src/doc_page_source_reflow.rs b/loki-renderer/src/doc_page_source_reflow.rs index 9b439bc3..266af7cf 100644 --- a/loki-renderer/src/doc_page_source_reflow.rs +++ b/loki-renderer/src/doc_page_source_reflow.rs @@ -28,6 +28,22 @@ impl DocPageSource { layout.reflow_hit_test(canvas_x, canvas_y) } + /// Hyperlink URL under a tile-local point in the reflow layout, or `None` + /// in paginated mode / over plain text. Coordinates as in + /// [`Self::reflow_hit_test`]. + pub fn reflow_link_at( + &self, + tile_index: usize, + tile_x_pt: f32, + tile_y_pt: f32, + ) -> Option { + let guard = self.layout_for_generation(self.current_generation()); + let (_, layout) = guard.as_ref()?; + let canvas_x = tile_x_pt - REFLOW_PADDING_PT; + let canvas_y = tile_y_pt + tile_index as f32 * REFLOW_TILE_HEIGHT_PT; + layout.reflow_link_at(canvas_x, canvas_y) + } + /// The reflow band (tile) index containing the caret for `(block_index, /// byte_offset)`, or `None` in paginated mode / when not found. /// diff --git a/loki-renderer/src/document_view.rs b/loki-renderer/src/document_view.rs index 210303c2..9f681922 100644 --- a/loki-renderer/src/document_view.rs +++ b/loki-renderer/src/document_view.rs @@ -157,6 +157,7 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { let gap_px = if is_reflow { 0.0 } else { props.page_gap_px }; let on_tile_click = props.on_tile_click; let on_reflow_click = props.on_reflow_click; + let on_open_link = props.on_open_link; let on_reflow_drag = props.on_reflow_drag; let on_tile_context = props.on_tile_context; @@ -238,8 +239,15 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { let source = renderer.source.clone(); move |(i, x, y, open_link): (usize, f32, f32, bool)| { if is_reflow { - // TODO(link-click-reflow): reflow link-open - // is a follow-up; place the caret as before. + // Ctrl/Cmd+click over a link opens it instead + // of placing the caret (feature 5.11); the + // app supplies the actual opener. + if open_link + && let Some(url) = source.reflow_link_at(i, x, y) + { + on_open_link.call(url); + return; + } if let Some((para, byte)) = source.reflow_hit_test(i, x, y) { diff --git a/loki-renderer/src/render_layout.rs b/loki-renderer/src/render_layout.rs index 7bc1e474..1c86199c 100644 --- a/loki-renderer/src/render_layout.rs +++ b/loki-renderer/src/render_layout.rs @@ -184,6 +184,14 @@ impl RenderLayout { self.continuous()?.hit_test(canvas_x, canvas_y) } + /// Hyperlink URL under a point in **canvas** coordinates, or `None` in + /// paginated mode / over plain text (feature 5.11, reflow half). + pub fn reflow_link_at(&self, canvas_x: f32, canvas_y: f32) -> Option { + self.continuous()? + .link_at(canvas_x, canvas_y) + .map(str::to_owned) + } + /// Caret rectangle in **canvas** coordinates for `(block_index, /// byte_offset)`, or `None` in paginated mode / when not found. pub(crate) fn reflow_cursor_canvas( diff --git a/loki-renderer/src/view_types.rs b/loki-renderer/src/view_types.rs index 25c14f4b..f02d056a 100644 --- a/loki-renderer/src/view_types.rs +++ b/loki-renderer/src/view_types.rs @@ -112,6 +112,13 @@ pub struct DocumentViewProps { /// **reflow** mode. This component owns the reflow layout, so it hit-tests /// the click itself and reports the resolved document position. pub on_reflow_click: EventHandler<(usize, usize)>, + /// Called with the resolved hyperlink URL when the user Ctrl/Cmd-clicks a + /// link in **reflow** mode (feature 5.11). This component owns the reflow + /// layout so it resolves the URL itself; the app decides how to open it + /// (keeping the browser/OS dependency out of the render layer). Paginated + /// link opens flow through `on_tile_click`'s modifier flag instead. + #[props(default)] + pub on_open_link: EventHandler, /// Called with `(block_index, byte_offset)` while drag-selecting in /// **reflow** mode (mouse moved with a button held). The caller extends the /// selection focus to this position. @@ -131,7 +138,27 @@ pub struct DocumentViewProps { } impl PartialEq for DocumentViewProps { - fn eq(&self, _other: &Self) -> bool { - false // Conservatively always re-render + fn eq(&self, other: &Self) -> bool { + // Real field comparison (deferred-features plan 6.7; was a hardwired + // `false`). `doc` / `paginated_layout` compare by Arc identity — every + // mutation republishes a fresh Arc, so pointer equality is the correct + // (and cheap) change signal. Event-handler props are deliberately + // omitted: Dioxus `EventHandler`s always compare equal by design, and + // the closures only capture stable signal handles. + Arc::ptr_eq(&self.doc, &other.doc) + && match (&self.paginated_layout, &other.paginated_layout) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + } + && self.viewport_height_px == other.viewport_height_px + && self.viewport_top_px == other.viewport_top_px + && self.cursor_pos == other.cursor_pos + && self.selection_anchor == other.selection_anchor + && self.view_mode == other.view_mode + && self.reflow_width_px == other.reflow_width_px + && self.zoom == other.zoom + && self.page_gap_px == other.page_gap_px + && self.content_padding_bottom_px == other.content_padding_bottom_px } } diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index fe084f24..5666b53d 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -403,6 +403,12 @@ pub(super) fn render_canvas_area( cs.anchor = Some(pos.clone()); cs.focus = Some(pos); }, + // Reflow Ctrl/Cmd+click on a link: DocumentView resolved + // the URL against its continuous layout; open it here so + // the browser dependency stays in the app layer. + on_open_link: move |url: String| { + let _ = webbrowser::open(&url); + }, // Reflow drag-select: move only the focus, keeping the // anchor so a range selection grows under the pointer. on_reflow_drag: move |(para, byte): (usize, usize)| { From e16de2f096c0932150a5313d882f53d6e18d374b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:56 +0000 Subject: [PATCH 02/12] Tilt the caret and route arrow navigation through the cell rotation (4b.5 tail) Resolves TODO(rotated-cell-caret). The caret line, selection fills, and selection handles now paint through a rotation-aware affine (scene_cursor::cursor_paint_transform composes the CellRotation the cell's content is painted with), so the caret tilts with rotated table-cell text instead of staying upright. Cursor painting moved to a new scene_cursor.rs (scene.rs is baselined and shrank instead). Up/down arrow navigation maps the caret position through PageParagraphData::local_to_page and aims at neighbouring paragraphs via a new visual_y_span (post-rotation bounding box), replacing the raw rect+origin math. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-layout/src/flow_table_cells.rs | 11 +- loki-layout/src/result_rotation.rs | 24 ++++ loki-text/src/editing/navigation.rs | 25 ++-- loki-vello/src/lib.rs | 4 +- loki-vello/src/scene.rs | 134 ++------------------ loki-vello/src/scene_cursor.rs | 183 +++++++++++++++++++++++++++ loki-vello/src/scene_cursor_tests.rs | 119 +++++++++++++++++ 7 files changed, 358 insertions(+), 142 deletions(-) create mode 100644 loki-vello/src/scene_cursor.rs create mode 100644 loki-vello/src/scene_cursor_tests.rs diff --git a/loki-layout/src/flow_table_cells.rs b/loki-layout/src/flow_table_cells.rs index 56c599ac..b8626590 100644 --- a/loki-layout/src/flow_table_cells.rs +++ b/loki-layout/src/flow_table_cells.rs @@ -112,14 +112,9 @@ pub(super) fn flow_row_cells( // applies to the RotatedGroup (content-local → page), so a click // resolves to the right character in the rotated cell. Pivots mirror // `loki-vello` scene.rs cx/cy_local + cx/cy_physical (90/270 branch). - // - // TODO(rotated-cell-caret): hit-testing (click → offset) and caret - // POSITION are rotation-correct via `PageParagraphData::hit_local` / - // `local_to_page`. Still upright: the rendered caret *line* (a tilted - // caret needs `CursorRect` + the vello caret to carry rotation) and - // loki-text's up/down arrow navigation across rotated cells (the - // `rect + origin` sites in editing/navigation.rs, which should route - // through `local_to_page`). See docs/fidelity-status.md §rotated-cells. + // Hit-testing, the caret (painted tilted via `cursor_paint_transform` + // in loki-vello), and up/down arrow navigation (`visual_y_span`) are + // all rotation-aware. See docs/fidelity-status.md §rotated-cells. let rotation = CellRotation { degrees, pivot_local: (cell_height / 2.0, cell_content_width / 2.0), diff --git a/loki-layout/src/result_rotation.rs b/loki-layout/src/result_rotation.rs index a0503557..6a5d81f6 100644 --- a/loki-layout/src/result_rotation.rs +++ b/loki-layout/src/result_rotation.rs @@ -94,6 +94,30 @@ impl PageParagraphData { (self.origin.1, self.origin.1 + self.layout.height) } + /// Visual (post-rotation) vertical extent `[top, bottom]` of this paragraph + /// in page content-area coordinates. For an unrotated paragraph this equals + /// [`local_y_span`](Self::local_y_span); for rotated table-cell content it + /// is the y-extent of the rotated bounding box, so arrow-key navigation can + /// aim above/below the paragraph as the user actually sees it + /// (deferred-features 4b.5 tail). + pub fn visual_y_span(&self) -> (f32, f32) { + match self.rotation { + None => self.local_y_span(), + Some(_) => { + let (w, h) = (self.layout.width, self.layout.height); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let mut min = f32::INFINITY; + let mut max = f32::NEG_INFINITY; + for (x, y) in corners { + let (_, py) = self.local_to_page(x, y); + min = min.min(py); + max = max.max(py); + } + (min, max) + } + } + } + /// The hyperlink URL under a page-coordinate point, if the point lands on a /// hyperlinked glyph run in this paragraph (feature 5.11). Inverts the cell /// rotation like [`hit_local`](Self::hit_local), then tests each run's box. diff --git a/loki-text/src/editing/navigation.rs b/loki-text/src/editing/navigation.rs index 57f41599..a7b76b57 100644 --- a/loki-text/src/editing/navigation.rs +++ b/loki-text/src/editing/navigation.rs @@ -151,9 +151,12 @@ pub fn navigate_up(focus: &DocumentPosition, layout: &PaginatedLayout) -> Option let rect = para_data.layout.cursor_rect(focus.byte_offset)?; let margins = &layout.pages.get(focus.page_index)?.margins; - // Convert paragraph-local → page-canvas-local (what hit_test_page expects). - let canvas_x = rect.x + para_data.origin.0 + margins.left; - let canvas_y = rect.y + para_data.origin.1 + margins.top; + // Convert paragraph-local → page-canvas-local (what hit_test_page expects), + // mapping through the cell rotation when the caret is in a rotated cell so + // the move aims at the caret's *visual* position (4b.5 tail). + let (vis_x, vis_y) = para_data.local_to_page(rect.x, rect.y); + let canvas_x = vis_x + margins.left; + let canvas_y = vis_y + margins.top; // Aim for the vertical centre of the line above. let target_y = canvas_y - rect.height; @@ -175,8 +178,9 @@ pub fn navigate_up(focus: &DocumentPosition, layout: &PaginatedLayout) -> Option let (prev_pi, prev_para) = find_prev_para_data(layout, focus.page_index, focus.paragraph_index)?; let prev_margins = &layout.pages[prev_pi].margins; - // Hit the last line of the previous paragraph at the same horizontal position. - let prev_bottom_content_y = prev_para.origin.1 + prev_para.layout.height - 0.5; + // Hit the last line of the previous paragraph at the same horizontal + // position (visual span, so a rotated cell's box is aimed at correctly). + let prev_bottom_content_y = prev_para.visual_y_span().1 - 0.5; let prev_canvas_y = prev_bottom_content_y + prev_margins.top; hit_test_page(prev_pi, canvas_x, prev_canvas_y, layout) } @@ -197,8 +201,10 @@ pub fn navigate_down( let margins = &layout.pages.get(focus.page_index)?.margins; let page = layout.pages.get(focus.page_index)?; - let canvas_x = rect.x + para_data.origin.0 + margins.left; - let canvas_y = rect.y + para_data.origin.1 + margins.top; + // As in `navigate_up`: rotation-aware visual caret position (4b.5 tail). + let (vis_x, vis_y) = para_data.local_to_page(rect.x, rect.y); + let canvas_x = vis_x + margins.left; + let canvas_y = vis_y + margins.top; // Aim for the vertical centre of the line below (1.5 × line height down). let target_y = canvas_y + rect.height * 1.5; @@ -217,8 +223,9 @@ pub fn navigate_down( let (next_pi, next_para) = find_next_para_data(layout, focus.page_index, focus.paragraph_index)?; let next_margins = &layout.pages[next_pi].margins; - // Hit slightly inside the first line of the next paragraph. - let next_top_content_y = next_para.origin.1 + 0.5; + // Hit slightly inside the first line of the next paragraph (visual span, + // so a rotated cell's box is aimed at correctly). + let next_top_content_y = next_para.visual_y_span().0 + 0.5; let next_canvas_y = next_top_content_y + next_margins.top; hit_test_page(next_pi, canvas_x, next_canvas_y, layout) } diff --git a/loki-vello/src/lib.rs b/loki-vello/src/lib.rs index 368fe6a3..132d6846 100644 --- a/loki-vello/src/lib.rs +++ b/loki-vello/src/lib.rs @@ -35,11 +35,13 @@ pub mod glyph; pub mod image; pub mod rect; pub mod scene; +pub mod scene_cursor; pub use band::{content_max_x, paint_continuous_band}; pub use error::{VelloError, VelloResult}; pub use font_cache::FontDataCache; pub use scene::{ CursorPaint, SelectionHandle, SelectionHandleKind, SelectionRect, paint_continuous, - paint_cursor, paint_layout, paint_paginated, paint_single_page, + paint_layout, paint_paginated, paint_single_page, }; +pub use scene_cursor::paint_cursor; diff --git a/loki-vello/src/scene.rs b/loki-vello/src/scene.rs index dab2ee6f..dce4ae2e 100644 --- a/loki-vello/src/scene.rs +++ b/loki-vello/src/scene.rs @@ -9,7 +9,7 @@ //! commands appended to a [`vello::Scene`]. use vello::kurbo::Affine; -use vello::peniko::{BlendMode, Brush, Color, Fill}; +use vello::peniko::BlendMode; use loki_layout::{ ContinuousLayout, CursorRect, DocumentLayout, LayoutColor, LayoutPage, LayoutRect, @@ -19,10 +19,7 @@ use loki_layout::{ use crate::font_cache::FontDataCache; // ── Cursor and selection rendering types ───────────────────────────────────── - -// Selection-handle dimensions (in layout points). -const HANDLE_STEM_HEIGHT: f32 = 24.0; -const HANDLE_CIRCLE_RADIUS: f32 = 8.0; +// (The painting functions live in `scene_cursor.rs`.) /// Whether a selection handle is at the anchor (start) or focus (end) of the /// selection. @@ -266,24 +263,21 @@ pub fn paint_single_page( .find(|p| p.block_index == cp.paragraph_index) }); - let para_origin = para_data.map(|p| p.origin).unwrap_or((0.0, 0.0)); - - let para_offset = ( - content_origin.0 + para_origin.0, - content_origin.1 + para_origin.1, - ); + // Rotation-aware: a rotated table cell's caret/selection tilt with the + // text (the transform composes the cell's rotation affine). + let transform = + crate::scene_cursor::cursor_paint_transform(para_data, content_origin, scale); if let Some(cr) = cp.cursor_rect.as_ref() { - paint_cursor( + crate::scene_cursor::paint_cursor_transformed( scene, cr, &cp.selection_rects, &cp.selection_handles, - para_offset, - scale, + transform, ); } else if !cp.selection_rects.is_empty() || !cp.selection_handles.is_empty() { - paint_cursor( + crate::scene_cursor::paint_cursor_transformed( scene, // Dummy zero-size rect when only selection highlights / handles are needed. &CursorRect { @@ -293,120 +287,12 @@ pub fn paint_single_page( }, &cp.selection_rects, &cp.selection_handles, - para_offset, - scale, + transform, ); } } } -/// Paint a cursor line, optional selection highlight rects, and optional mobile -/// selection handles into the scene. -/// -/// All coordinates are in paragraph-local layout points. `offset` is the -/// paragraph's origin in scene coordinates (content-area origin + paragraph -/// origin from `PageEditingData`). `scale` converts layout points to physical -/// pixels. -/// -/// The cursor is a 2-point-wide vertical line in the document accent colour. -/// Each selection rect is a semi-transparent blue fill. -/// Selection handles (teardrop: stem + circle) are drawn on mobile only — -/// the caller controls this via `#[cfg(target_os)]` before populating -/// `selection_handles`. -pub fn paint_cursor( - scene: &mut vello::Scene, - cursor_rect: &CursorRect, - selection_rects: &[SelectionRect], - selection_handles: &[SelectionHandle], - offset: (f32, f32), - scale: f32, -) { - let accent_brush = Brush::Solid(Color::new([ - 30.0 / 255.0, - 100.0 / 255.0, - 200.0 / 255.0, - 1.0, - ])); - - // ── Selection highlight rects ───────────────────────────────────────────── - // Painted before the cursor so the cursor line appears on top. - let sel_brush = Brush::Solid(Color::new([ - 30.0 / 255.0, - 100.0 / 255.0, - 200.0 / 255.0, - 60.0 / 255.0, - ])); - for sel in selection_rects { - let x = (offset.0 + sel.x) * scale; - let y = (offset.1 + sel.y) * scale; - let w = sel.width * scale; - let h = sel.height * scale; - if w <= 0.0 || h <= 0.0 { - continue; - } - scene.fill( - Fill::NonZero, - Affine::IDENTITY, - &sel_brush, - None, - &vello::kurbo::Rect::new(x as f64, y as f64, (x + w) as f64, (y + h) as f64), - ); - } - - // ── Cursor line ─────────────────────────────────────────────────────────── - // 2-point-wide vertical bar in the document accent colour. - if cursor_rect.height > 0.0 { - let x = (offset.0 + cursor_rect.x) * scale; - let y = (offset.1 + cursor_rect.y) * scale; - let h = cursor_rect.height * scale; - let w = 2.0 * scale; - scene.fill( - Fill::NonZero, - Affine::IDENTITY, - &accent_brush, - None, - &vello::kurbo::Rect::new(x as f64, y as f64, (x + w) as f64, (y + h) as f64), - ); - } - - // ── Mobile selection handles ────────────────────────────────────────────── - // Each handle is a teardrop: a 2-pt-wide vertical stem descending from the - // selection edge, with a filled circle at the bottom. Rendered only when - // the caller populates `selection_handles` (iOS/Android only). - for handle in selection_handles { - let tip_x = (offset.0 + handle.tip_x) * scale; - let tip_y = (offset.1 + handle.tip_y) * scale; - let stem_h = HANDLE_STEM_HEIGHT * scale; - let stem_w = 2.0 * scale; - let r = (HANDLE_CIRCLE_RADIUS * scale) as f64; - - // Stem: 2-pt wide rectangle descending from (tip_x, tip_y). - scene.fill( - Fill::NonZero, - Affine::IDENTITY, - &accent_brush, - None, - &vello::kurbo::Rect::new( - tip_x as f64, - tip_y as f64, - (tip_x + stem_w) as f64, - (tip_y + stem_h) as f64, - ), - ); - - // Circle: centred horizontally on the stem, at the bottom. - let cx = (tip_x + stem_w / 2.0) as f64; - let cy = (tip_y + stem_h) as f64 + r; - scene.fill( - Fill::NonZero, - Affine::IDENTITY, - &accent_brush, - None, - &vello::kurbo::Circle::new((cx, cy), r), - ); - } -} - /// Paint a paginated layout. /// /// Pages are arranged vertically with [`PAGE_GAP_PT`] points of space between diff --git a/loki-vello/src/scene_cursor.rs b/loki-vello/src/scene_cursor.rs new file mode 100644 index 00000000..7232abbb --- /dev/null +++ b/loki-vello/src/scene_cursor.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Caret, selection-highlight, and selection-handle painting. Split from +//! `scene.rs` (file-size ceiling) when the caret gained rotation support +//! (deferred-features 4b.5 tail: the caret *line* now tilts with rotated +//! table-cell text instead of staying upright). + +use vello::kurbo::Affine; +use vello::peniko::{Brush, Color, Fill}; + +use loki_layout::{CursorRect, PageParagraphData}; + +use crate::scene::{SelectionHandle, SelectionRect}; + +// Selection-handle dimensions (in layout points). +const HANDLE_STEM_HEIGHT: f32 = 24.0; +const HANDLE_CIRCLE_RADIUS: f32 = 8.0; + +/// Affine mapping paragraph-local caret/selection coordinates to physical +/// scene pixels for `para_data`'s paragraph. +/// +/// For plain paragraphs this is the familiar `scale · translate(content_origin +/// + origin)`. For rotated table-cell content it composes the cell's +/// [`CellRotation`](loki_layout::CellRotation) affine (`page = pivot_page + +/// Rot(deg)·(local − pivot_local)`, the same transform the content itself is +/// painted with), so the caret line and selection fills tilt with the text. +pub(crate) fn cursor_paint_transform( + para_data: Option<&PageParagraphData>, + content_origin: (f32, f32), + scale: f32, +) -> Affine { + let origin = para_data.map(|p| p.origin).unwrap_or((0.0, 0.0)); + match para_data.and_then(|p| p.rotation) { + Some(rot) => { + Affine::scale(scale as f64) + * Affine::translate((content_origin.0 as f64, content_origin.1 as f64)) + * Affine::translate((rot.pivot_page.0 as f64, rot.pivot_page.1 as f64)) + * Affine::rotate((rot.degrees as f64).to_radians()) + * Affine::translate(( + (origin.0 - rot.pivot_local.0) as f64, + (origin.1 - rot.pivot_local.1) as f64, + )) + } + None => { + Affine::scale(scale as f64) + * Affine::translate(( + (content_origin.0 + origin.0) as f64, + (content_origin.1 + origin.1) as f64, + )) + } + } +} + +/// Paint a cursor line, optional selection highlight rects, and optional mobile +/// selection handles into the scene. +/// +/// All coordinates are in paragraph-local layout points. `offset` is the +/// paragraph's origin in scene coordinates (content-area origin + paragraph +/// origin from `PageEditingData`). `scale` converts layout points to physical +/// pixels. Rotation-aware callers build the affine with +/// [`cursor_paint_transform`] and call [`paint_cursor_transformed`] directly. +/// +/// The cursor is a 2-point-wide vertical line in the document accent colour. +/// Each selection rect is a semi-transparent blue fill. +/// Selection handles (teardrop: stem + circle) are drawn on mobile only — +/// the caller controls this via `#[cfg(target_os)]` before populating +/// `selection_handles`. +pub fn paint_cursor( + scene: &mut vello::Scene, + cursor_rect: &CursorRect, + selection_rects: &[SelectionRect], + selection_handles: &[SelectionHandle], + offset: (f32, f32), + scale: f32, +) { + let transform = + Affine::scale(scale as f64) * Affine::translate((offset.0 as f64, offset.1 as f64)); + paint_cursor_transformed( + scene, + cursor_rect, + selection_rects, + selection_handles, + transform, + ); +} + +/// [`paint_cursor`] with an explicit paragraph-local → scene transform, so a +/// rotated table cell's caret and selection render tilted with its text. +pub(crate) fn paint_cursor_transformed( + scene: &mut vello::Scene, + cursor_rect: &CursorRect, + selection_rects: &[SelectionRect], + selection_handles: &[SelectionHandle], + transform: Affine, +) { + let accent_brush = Brush::Solid(Color::new([ + 30.0 / 255.0, + 100.0 / 255.0, + 200.0 / 255.0, + 1.0, + ])); + + // ── Selection highlight rects ───────────────────────────────────────────── + // Painted before the cursor so the cursor line appears on top. + let sel_brush = Brush::Solid(Color::new([ + 30.0 / 255.0, + 100.0 / 255.0, + 200.0 / 255.0, + 60.0 / 255.0, + ])); + for sel in selection_rects { + if sel.width <= 0.0 || sel.height <= 0.0 { + continue; + } + scene.fill( + Fill::NonZero, + transform, + &sel_brush, + None, + &vello::kurbo::Rect::new( + sel.x as f64, + sel.y as f64, + (sel.x + sel.width) as f64, + (sel.y + sel.height) as f64, + ), + ); + } + + // ── Cursor line ─────────────────────────────────────────────────────────── + // 2-point-wide vertical bar in the document accent colour. + if cursor_rect.height > 0.0 { + scene.fill( + Fill::NonZero, + transform, + &accent_brush, + None, + &vello::kurbo::Rect::new( + cursor_rect.x as f64, + cursor_rect.y as f64, + (cursor_rect.x + 2.0) as f64, + (cursor_rect.y + cursor_rect.height) as f64, + ), + ); + } + + // ── Mobile selection handles ────────────────────────────────────────────── + // Each handle is a teardrop: a 2-pt-wide vertical stem descending from the + // selection edge, with a filled circle at the bottom. Rendered only when + // the caller populates `selection_handles` (iOS/Android only). + for handle in selection_handles { + let stem_w = 2.0_f32; + + // Stem: 2-pt wide rectangle descending from (tip_x, tip_y). + scene.fill( + Fill::NonZero, + transform, + &accent_brush, + None, + &vello::kurbo::Rect::new( + handle.tip_x as f64, + handle.tip_y as f64, + (handle.tip_x + stem_w) as f64, + (handle.tip_y + HANDLE_STEM_HEIGHT) as f64, + ), + ); + + // Circle: centred horizontally on the stem, at the bottom. + let cx = (handle.tip_x + stem_w / 2.0) as f64; + let cy = (handle.tip_y + HANDLE_STEM_HEIGHT + HANDLE_CIRCLE_RADIUS) as f64; + scene.fill( + Fill::NonZero, + transform, + &accent_brush, + None, + &vello::kurbo::Circle::new((cx, cy), HANDLE_CIRCLE_RADIUS as f64), + ); + } +} + +#[cfg(test)] +#[path = "scene_cursor_tests.rs"] +mod tests; diff --git a/loki-vello/src/scene_cursor_tests.rs b/loki-vello/src/scene_cursor_tests.rs new file mode 100644 index 00000000..dc02057b --- /dev/null +++ b/loki-vello/src/scene_cursor_tests.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the caret/selection paint transform (rotated-cell caret, +//! deferred-features 4b.5 tail). + +use std::sync::Arc; + +use vello::kurbo::Point; + +use loki_layout::{CellRotation, CursorRect, PageParagraphData, ParagraphLayout}; + +use super::{cursor_paint_transform, paint_cursor}; +use crate::scene::{SelectionHandle, SelectionHandleKind, SelectionRect}; + +/// Minimal paragraph editing entry at `origin`, optionally rotated. +fn para(origin: (f32, f32), rotation: Option) -> PageParagraphData { + let layout = ParagraphLayout { + height: 16.0, + width: 35.0, + items: vec![], + first_baseline: 10.0, + last_baseline: 10.0, + line_boundaries: Vec::new(), + parley_layout: None, + orig_to_clean: Vec::new(), + clean_to_orig: Vec::new(), + indent_start: 0.0, + indent_hanging: 0.0, + drop_lines: 0, + drop_shift: 0.0, + }; + PageParagraphData { + block_index: 0, + path: Vec::new(), + layout: Arc::new(layout), + origin, + rotation, + } +} + +#[test] +fn unrotated_transform_is_translate_then_scale() { + let p = para((3.0, 4.0), None); + let t = cursor_paint_transform(Some(&p), (10.0, 20.0), 2.0); + let mapped = t * Point::new(1.0, 2.0); + // (content_origin + origin + local) * scale. + assert!((mapped.x - 28.0).abs() < 1e-6, "{mapped:?}"); + assert!((mapped.y - 52.0).abs() < 1e-6, "{mapped:?}"); +} + +#[test] +fn missing_paragraph_falls_back_to_content_origin() { + let t = cursor_paint_transform(None, (10.0, 20.0), 1.0); + let mapped = t * Point::new(5.0, 6.0); + assert!((mapped.x - 15.0).abs() < 1e-6, "{mapped:?}"); + assert!((mapped.y - 26.0).abs() < 1e-6, "{mapped:?}"); +} + +#[test] +fn rotated_transform_matches_the_cell_rotation_affine() { + // The paint transform must send a paragraph-local point to exactly where + // CellRotation::local_to_page puts it (plus content origin, times scale) — + // the same affine the rotated cell's content is painted with. + let rot = CellRotation { + degrees: 90.0, + pivot_local: (20.0, 5.0), + pivot_page: (60.0, 40.0), + }; + let origin = (2.0, 3.0); + let p = para(origin, Some(rot)); + let (content_x, content_y) = (12.0, 34.0); + let scale = 1.5_f64; + let t = cursor_paint_transform(Some(&p), (content_x, content_y), scale as f32); + + let (local_x, local_y) = (7.0_f32, 2.0_f32); + let (page_x, page_y) = rot.local_to_page(origin.0 + local_x, origin.1 + local_y); + let expected = ( + (content_x + page_x) as f64 * scale, + (content_y + page_y) as f64 * scale, + ); + let mapped = t * Point::new(local_x as f64, local_y as f64); + assert!( + (mapped.x - expected.0).abs() < 1e-3, + "{mapped:?} vs {expected:?}" + ); + assert!( + (mapped.y - expected.1).abs() < 1e-3, + "{mapped:?} vs {expected:?}" + ); +} + +#[test] +fn paint_cursor_smoke_all_shapes() { + // The public offset/scale wrapper still paints every shape kind without + // panicking (behavioural equivalence guard for the transform refactor). + let mut scene = vello::Scene::new(); + paint_cursor( + &mut scene, + &CursorRect { + x: 1.0, + y: 2.0, + height: 14.0, + }, + &[SelectionRect { + x: 0.0, + y: 0.0, + width: 30.0, + height: 16.0, + }], + &[SelectionHandle { + tip_x: 5.0, + tip_y: 16.0, + kind: SelectionHandleKind::Focus, + }], + (10.0, 20.0), + 2.0, + ); +} From 5bc0cd6bd97f93c81fbb181965455274b7c54d14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:56 +0000 Subject: [PATCH 03/12] Render a struck end-of-paragraph marker for tracked pilcrow deletions (4a.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves TODO(review-para-mark-render). A tracked paragraph-mark deletion (direct_char_props.revision of kind Deletion) now paints a struck, author-coloured marker after the paragraph's last line: two short stems + a strikethrough segment, emitted as paint-only items in para_underlays so caret placement, hit-testing, and wrapping are untouched. The colour rides ResolvedParaProps.para_mark_deleted_color, set by resolve_para_props via revision_style::para_mark_deletion_color. Root-cause fix found while wiring: flatten_paragraph merged the paragraph's direct_char_props into the run base without clearing revision, so a pilcrow-deleted paragraph rendered its entire text struck in the author colour. The run base now drops the ¶'s revision. Regression-locked by tracked_para_mark_deletion_renders_struck_marker_without_striking_text. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-layout/src/flow_tests.rs | 43 +++++++++++++++++++++++++++ loki-layout/src/para.rs | 23 ++++++++------- loki-layout/src/para_props_map.rs | 3 ++ loki-layout/src/para_underlays.rs | 49 +++++++++++++++++++++++++++++++ loki-layout/src/resolve.rs | 31 ++++++++----------- loki-layout/src/revision_style.rs | 22 ++++++++++---- 6 files changed, 136 insertions(+), 35 deletions(-) diff --git a/loki-layout/src/flow_tests.rs b/loki-layout/src/flow_tests.rs index 3e9241cc..e3d1508d 100644 --- a/loki-layout/src/flow_tests.rs +++ b/loki-layout/src/flow_tests.rs @@ -1809,4 +1809,47 @@ mod page_fields { "a deleted run must be struck through" ); } + + /// A tracked deletion of the paragraph mark (¶) paints a struck marker + /// after the last line — exactly one strikethrough decoration (the marker): + /// the ¶'s revision must NOT bleed onto the paragraph's runs (which would + /// strike the text too), and a plain paragraph paints no marker at all + /// (4a.2 para-mark rendering). + #[test] + fn tracked_para_mark_deletion_renders_struck_marker_without_striking_text() { + use loki_doc_model::style::props::char_props::CharProps; + use loki_doc_model::style::props::revision::{RevisionKind, RevisionMark}; + + let strikes = |items: &[PositionedItem]| { + items + .iter() + .filter(|i| { + matches!(i, PositionedItem::Decoration(d) + if d.kind == DecorationKind::Strikethrough) + }) + .count() + }; + + let mut r = test_resources(); + let mut para = make_para("word"); + para.direct_char_props = Some(Box::new(CharProps { + revision: Some(RevisionMark::new(RevisionKind::Deletion)), + ..CharProps::default() + })); + let sec = Section::with_layout_and_blocks(tiny_layout(), vec![Block::StyledPara(para)]); + let (items, _, _) = flow_pageless(&mut r, &sec); + assert_eq!( + strikes(&items), + 1, + "exactly the struck ¶ marker — a second strike means the ¶'s \ + revision bled onto the text runs" + ); + + let plain = Section::with_layout_and_blocks( + tiny_layout(), + vec![Block::StyledPara(make_para("word"))], + ); + let (plain_items, _, _) = flow_pageless(&mut r, &plain); + assert_eq!(strikes(&plain_items), 0, "no marker on a plain paragraph"); + } } diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index c754ba3e..ea740c86 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -255,7 +255,7 @@ pub struct ResolvedParaProps { /// First-line additional indent in points. pub indent_first_line: f32, /// Paragraph-level line-height specification, or `None` to use Parley's - /// natural font metrics (always correct for body text). + /// natural font metrics. pub line_height: Option, /// Optional paragraph background fill. pub background_color: Option, @@ -296,18 +296,19 @@ pub struct ResolvedParaProps { /// Tab-stop grid interval (points) used once `tab_stops` is exhausted; /// `36.0` (½ inch) unless `DocumentSettings::default_tab_stop_pt` overrides. pub default_tab_stop: f32, - /// Break an over-long word at any character (CSS `overflow-wrap: anywhere`). - /// Set for table-cell content so a long unbreakable word wraps to the fixed - /// column width (Word) instead of overflowing; body paragraphs leave `false`. + /// Break an over-long word at any character (`overflow-wrap: anywhere`); + /// set for table-cell content so it wraps to the fixed column width (Word). pub break_long_words: bool, /// Dropped-initial spec, or `None`. When set (and the paragraph qualifies — /// see [`layout_paragraph`]), the leading character(s) span `lines` rows with /// body text beside them. OOXML `w:framePr`/`w:dropCap`, ODF `style:drop-cap`. pub drop_cap: Option, - /// A leading side band the first lines must clear (a floating image the text - /// wraps around). Set by the flow engine; the banded path narrows the lines - /// beside it and reflows the rest at full width. `None` for normal paragraphs. + /// A leading side band the first lines must clear (a floating image the + /// text wraps around). Set by the flow engine; `None` for normal paragraphs. pub wrap_band: Option, + /// Author colour of a tracked paragraph-mark (¶) deletion on this block — + /// paints a struck end-of-paragraph marker (Review, 4a.2). `None` = none. + pub para_mark_deleted_color: Option, } /// A side band (a floating object) the first lines of a paragraph wrap around. @@ -355,6 +356,7 @@ impl Default for ResolvedParaProps { break_long_words: false, drop_cap: None, wrap_band: None, + para_mark_deleted_color: None, } } } @@ -1200,10 +1202,8 @@ fn layout_paragraph_uncached( let mut items: Vec = Vec::new(); let mut line_index: usize = 0; - // Track the lowest point reached by any inline equation. Its baseline is on - // the text baseline, so a deep denominator can hang below the line's own - // descent; we grow the paragraph height to cover it so the next block does - // not overlap it. + // Track the lowest point reached by any inline equation: a deep denominator + // can hang below the line's descent; grow the paragraph height to cover it. let mut content_bottom = total_height; // OOXML lineRule="exact" (ODF fixed line height): the line box is a fixed @@ -1234,6 +1234,7 @@ fn layout_paragraph_uncached( drop_lines, drop_shift, ); + underlays::emit_para_mark_deletion(&mut items, &layout, para_props, drop_lines, drop_shift); for line in layout.lines() { // Index into `items` where this line's emitted items begin (used to wrap diff --git a/loki-layout/src/para_props_map.rs b/loki-layout/src/para_props_map.rs index 15e931c9..8af80d3b 100644 --- a/loki-layout/src/para_props_map.rs +++ b/loki-layout/src/para_props_map.rs @@ -132,5 +132,8 @@ pub(super) fn map_para_props(p: &ParaProps) -> ResolvedParaProps { drop_cap: p.drop_cap, // Float wrap band is injected by the flow engine, not the model. wrap_band: None, + // Set by `resolve_para_props` from the block's `direct_char_props` + // revision (`ParaProps` does not carry the paragraph mark's CharProps). + para_mark_deleted_color: None, } } diff --git a/loki-layout/src/para_underlays.rs b/loki-layout/src/para_underlays.rs index a3ec8043..4bf34ec5 100644 --- a/loki-layout/src/para_underlays.rs +++ b/loki-layout/src/para_underlays.rs @@ -83,6 +83,55 @@ pub(super) fn emit_highlight_underlays( } } +/// Emit a struck, author-coloured end-of-paragraph marker when the paragraph's +/// ¶ carries a tracked deletion (Review tab, 4a.2 para-mark rendering). +/// +/// Not a shaped ¶ glyph: two short stems + a strikethrough segment placed just +/// after the last line's text — paint-only items, so caret placement, +/// hit-testing, and text wrapping are untouched (the constraint that deferred +/// this). Accepting/rejecting the change clears the mark and the marker +/// disappears with the next layout. +pub(super) fn emit_para_mark_deletion( + items: &mut Vec, + layout: &Layout, + para_props: &ResolvedParaProps, + drop_lines: usize, + drop_shift: f32, +) { + let Some(color) = para_props.para_mark_deleted_color else { + return; + }; + let line_count = layout.lines().count(); + let Some(line) = layout.lines().last() else { + return; + }; + let m = line.metrics(); + let indent = line_indent(para_props, line_count - 1, drop_lines, drop_shift); + // Marker box after the line's text: ~0.5 em wide, stems rising ~0.6 of the + // ascent from the baseline, struck through the middle in the author colour. + let em = (m.ascent + m.descent).max(1.0); + let x0 = m.advance + indent + em * 0.15; + let width = em * 0.5; + let stem_h = m.ascent * 0.6; + let stem_w = (em * 0.06).clamp(0.6, 1.2); + for stem_x in [x0 + width * 0.25, x0 + width * 0.65] { + items.push(PositionedItem::FilledRect(PositionedRect { + rect: LayoutRect::new(stem_x, m.baseline - stem_h, stem_w, stem_h), + color, + })); + } + items.push(PositionedItem::Decoration(PositionedDecoration { + x: x0, + // `y` is the TOP of the decoration band (see loki-vello `decor.rs`). + y: m.baseline - stem_h * 0.5 - stem_w / 2.0, + width, + thickness: stem_w, + kind: DecorationKind::Strikethrough, + style: DecorationStyle::Solid, + color, + })); +} + /// Emit a `DecorationKind::Spelling` wave under each misspelled word the /// supplied checker flags in `clean_text`. Thickness scales with line height; /// the wave is anchored just below the text descender (the run underline zone), diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 2a863bfb..3347fd04 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -18,21 +18,11 @@ //! renders only data URIs (external URLs → grey placeholder). `walk_inlines` //! emits a `CollectedImage`, placed post-Parley as a `PositionedImage`. //! -//! ## Q2 — Inline::Link current behaviour +//! ## Q2 — Inline::Link (implemented via option (b)) //! -//! `Inline::Link` is `Link(NodeAttr, Vec, LinkTarget)`. The OOXML mapper -//! (`loki-ooxml/src/docx/mapper/inline.rs:57–64`) resolves `w:hyperlink -//! r:id` relationship IDs against `ctx.hyperlinks` to produce a resolved HTTP -//! URL; bookmark-only anchors become `"#anchor_name"`. -//! `walk_inlines` (`resolve.rs:351`) recurses into display children and discards -//! the URL — identical to the image arm. No `PositionedItem::Link` variant -//! exists; hyperlink metadata is completely lost after flattening. -//! Fixing gap #11 requires either: (a) adding `PositionedItem::Link` with a -//! URL-annotated byte-range rect produced after glyph layout, or (b) threading -//! URL metadata through `StyleSpan` and attaching it to glyph runs so the -//! renderer can emit clickable regions. Option (b) is simpler — `StyleSpan` -//! already carries per-run metadata; adding `link_url: Option` keeps -//! the URL co-located with the text run that displays it. +//! The OOXML mapper resolves `w:hyperlink r:id` to a URL; `StyleSpan.link_url` +//! carries it onto the run's glyphs, the renderer paints the link hint, and +//! `PageParagraphData::link_at` hit-tests it (feature 5.11). //! //! ## Q3 — Image placement model for inline images //! @@ -151,10 +141,8 @@ pub struct CollectedNote { } /// Resolve the effective [`ResolvedParaProps`] for a [`StyledParagraph`]. -/// -/// Resolution order (child wins): -/// 1. Named style chain via [`StyleCatalog::resolve_para`]. -/// 2. Direct paragraph formatting on the paragraph itself. +/// Resolution order (child wins): named style chain via +/// [`StyleCatalog::resolve_para`], then direct formatting on the paragraph. pub fn resolve_para_props(block: &StyledParagraph, catalog: &StyleCatalog) -> ResolvedParaProps { let mut base: ParaProps = catalog .effective_paragraph_style(block.style_id.as_ref()) @@ -163,7 +151,9 @@ pub fn resolve_para_props(block: &StyledParagraph, catalog: &StyleCatalog) -> Re if let Some(direct) = &block.direct_para_props { base = direct.as_ref().clone().merged_with_parent(&base); } - para_map::map_para_props(&base) + let mut resolved = para_map::map_para_props(&base); + resolved.para_mark_deleted_color = crate::revision_style::para_mark_deletion_color(block); + resolved } /// Resolve the effective [`StyleSpan`] properties for a [`StyledRun`]. @@ -218,6 +208,9 @@ pub fn flatten_paragraph( // styled run) to avoid cloning `CharProps` per formatting span; `base` is a // throwaway local, so the mutation is not observable outside this call. let mut base = base; + // A tracked ¶-mark deletion on `direct_char_props` must not bleed onto the + // runs: it belongs to the paragraph mark (struck ¶ marker), not the text. + base.revision = None; let mut buf = String::new(); let mut spans: Vec = Vec::new(); let mut images: Vec = Vec::new(); diff --git a/loki-layout/src/revision_style.rs b/loki-layout/src/revision_style.rs index 95e817b5..b5919a0a 100644 --- a/loki-layout/src/revision_style.rs +++ b/loki-layout/src/revision_style.rs @@ -9,12 +9,13 @@ //! layout time, so accepting/rejecting a change (which clears the mark) reverts //! the run to its normal appearance with no stored styling to undo. //! -//! TODO(review-para-mark-render): a tracked deletion of a *paragraph mark* (¶) -//! — a `StyledParagraph.direct_char_props.revision` — is recorded and resolved -//! but not yet painted here (no struck ¶ glyph), so it stays invisible until -//! reviewed. Deferred: it is a paragraph-end decoration touching caret / -//! hit-test / wrapping and needs interactive visual verification. +//! A tracked deletion of the *paragraph mark* (¶) — a +//! `StyledParagraph.direct_char_props.revision` — is surfaced via +//! [`para_mark_deletion_color`]: the paragraph layout paints a struck, +//! author-coloured end-of-paragraph marker (`para_underlays`), a paint-only +//! item so caret placement, hit-testing, and wrapping are untouched. +use loki_doc_model::content::block::StyledParagraph; use loki_doc_model::style::props::char_props::CharProps; use loki_doc_model::style::props::revision::RevisionKind; @@ -48,6 +49,17 @@ fn author_color(author: Option<&str>) -> LayoutColor { LayoutColor::new(r, g, b, 1.0) } +/// Author colour of a tracked paragraph-mark (¶) deletion recorded on `block` +/// (`direct_char_props.revision` of kind `Deletion`), or `None`. +pub(crate) fn para_mark_deletion_color(block: &StyledParagraph) -> Option { + block + .direct_char_props + .as_deref() + .and_then(|cp| cp.revision.as_ref()) + .filter(|rev| rev.kind == RevisionKind::Deletion) + .map(|rev| author_color(rev.author.as_deref())) +} + /// Applies tracked-change colouring + decoration to `span` from `props.revision` /// (a no-op when the run carries no revision). An existing underline/strikethrough /// is preserved; only an absent one is added for the change kind. From aa9bce7e3b60344ad53e104d992be430efcb401d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:56 +0000 Subject: [PATCH 04/12] Round-trip tracked paragraph-mark deletions through ODT (4a.2 polish tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining Review-tab polish item: a tracked pilcrow deletion now survives save/open through ODF. Export emits an end-of-paragraph text:change milestone whose deletion region stows only the paragraph break (an empty text:p — LibreOffice's shape for a deleted paragraph mark); import maps an empty-deletion change point back onto the paragraph's CharProps.revision instead of materialising a struck empty run. The revision-mapping helpers moved to a new inlines_revision.rs sibling module to hold the 300-line ceiling. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-odf/src/odt/mapper/document/inlines.rs | 75 +++++------------ .../odt/mapper/document/inlines_revision.rs | 84 +++++++++++++++++++ loki-odf/src/odt/write/content.rs | 19 +++-- loki-odf/src/odt/write/revisions.rs | 22 +++++ loki-odf/tests/revision_round_trip.rs | 46 +++++++++- 5 files changed, 187 insertions(+), 59 deletions(-) create mode 100644 loki-odf/src/odt/mapper/document/inlines_revision.rs diff --git a/loki-odf/src/odt/mapper/document/inlines.rs b/loki-odf/src/odt/mapper/document/inlines.rs index 908a8948..74b79c3b 100644 --- a/loki-odf/src/odt/mapper/document/inlines.rs +++ b/loki-odf/src/odt/mapper/document/inlines.rs @@ -14,13 +14,13 @@ use loki_doc_model::content::field::types::{CrossRefFormat, Field, FieldKind}; use loki_doc_model::content::inline::{BookmarkKind, Inline, LinkTarget, NoteKind, StyledRun}; use loki_doc_model::style::catalog::StyleId; use loki_doc_model::style::props::char_props::CharProps; -use loki_doc_model::style::props::revision::{RevisionKind, RevisionMark}; +use loki_doc_model::style::props::revision::RevisionMark; use crate::limits::MAX_REPEATED_SPACES; use crate::odt::model::fields::OdfField; use crate::odt::model::notes::{OdfNote, OdfNoteClass}; use crate::odt::model::paragraph::{OdfHyperlink, OdfParagraph, OdfParagraphChild, OdfSpan}; -use crate::odt::model::revision::{OdfChangeKind, OdfChangedRegion}; +use crate::odt::model::revision::OdfChangeKind; use super::OdfMappingContext; use super::frames::map_frame; @@ -47,16 +47,30 @@ pub(super) fn map_paragraph(para: &OdfParagraph, ctx: &mut OdfMappingContext<'_> Block::Heading(level, attr, inlines) } else { let style_id = para.style_name.as_deref().map(StyleId::new); + // A ¶-mark tracked deletion (an empty-deletion `text:change` point in + // this paragraph) lands on the paragraph mark's CharProps slot. + let direct_char_props = para_mark_revision(para, ctx).map(|mark| { + Box::new(CharProps { + revision: Some(mark), + ..CharProps::default() + }) + }); Block::StyledPara(StyledParagraph { style_id, direct_para_props: None, - direct_char_props: None, + direct_char_props, inlines, attr: NodeAttr::default(), }) } } +// Revision-mapping helpers (changed-region → RevisionMark, run wrapping, +// deleted-text re-materialisation, ¶-mark detection). +#[path = "inlines_revision.rs"] +mod revision_map; +use revision_map::{deletion_run, para_mark_revision, region_mark, wrap_revision}; + // ── Inlines ──────────────────────────────────────────────────────────────────── pub(super) fn map_inline_children( @@ -74,8 +88,12 @@ pub(super) fn map_inline_children( OdfParagraphChild::RevisionEnd { .. } => active = None, // A deletion point: re-materialise the removed text (kept in the // changed-region table) as a struck run so it renders and re-exports. + // An empty deletion region is a ¶-mark deletion — carried on the + // paragraph itself by `map_paragraph`, not as a run. OdfParagraphChild::RevisionPoint { change_id } => { - if let Some(region) = ctx.changed_regions.get(change_id) { + if let Some(region) = ctx.changed_regions.get(change_id) + && !(region.kind == OdfChangeKind::Deletion && region.deleted_text.is_empty()) + { out.push(deletion_run(region)); } } @@ -92,55 +110,6 @@ pub(super) fn map_inline_children( out } -/// Builds a [`RevisionMark`] from a parsed changed-region (author/date verbatim, -/// so the RFC-3339 text round-trips exactly). -fn region_mark(region: &OdfChangedRegion) -> RevisionMark { - RevisionMark { - kind: match region.kind { - OdfChangeKind::Insertion => RevisionKind::Insertion, - OdfChangeKind::Deletion => RevisionKind::Deletion, - }, - author: region.creator.clone(), - date: region.date.clone(), - id: Some(region.change_id.clone()), - } -} - -/// Wraps an inline in the given revision mark, folding it onto an existing -/// styled run's direct props or a fresh single-child run otherwise. -fn wrap_revision(inl: Inline, mark: &RevisionMark) -> Inline { - match inl { - Inline::StyledRun(mut sr) => { - let mut cp = sr.direct_props.map(|b| *b).unwrap_or_default(); - cp.revision = Some(mark.clone()); - sr.direct_props = Some(Box::new(cp)); - Inline::StyledRun(sr) - } - other => Inline::StyledRun(revision_run(mark.clone(), vec![other])), - } -} - -/// Builds the struck run standing in for a tracked deletion's removed text. -fn deletion_run(region: &OdfChangedRegion) -> Inline { - Inline::StyledRun(revision_run( - region_mark(region), - vec![Inline::Str(region.deleted_text.clone())], - )) -} - -/// A `StyledRun` carrying only a revision mark over `content`. -fn revision_run(mark: RevisionMark, content: Vec) -> StyledRun { - StyledRun { - style_id: None, - direct_props: Some(Box::new(CharProps { - revision: Some(mark), - ..CharProps::default() - })), - content, - attr: NodeAttr::default(), - } -} - fn map_inline(child: &OdfParagraphChild, ctx: &mut OdfMappingContext<'_>) -> Option { match child { OdfParagraphChild::Text(s) => { diff --git a/loki-odf/src/odt/mapper/document/inlines_revision.rs b/loki-odf/src/odt/mapper/document/inlines_revision.rs new file mode 100644 index 00000000..d7179dab --- /dev/null +++ b/loki-odf/src/odt/mapper/document/inlines_revision.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tracked-change (revision) mapping helpers for [`super`] (`inlines.rs`): +//! changed-region → [`RevisionMark`] conversion, run wrapping, deleted-text +//! re-materialisation, and ¶-mark deletion detection. Split out to hold the +//! file-size ceiling (cohesive-cluster extraction, Phase 7.1 technique 3). + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::style::props::char_props::CharProps; +use loki_doc_model::style::props::revision::{RevisionKind, RevisionMark}; + +use crate::odt::model::paragraph::{OdfParagraph, OdfParagraphChild}; +use crate::odt::model::revision::{OdfChangeKind, OdfChangedRegion}; + +use super::super::OdfMappingContext; + +/// Builds a [`RevisionMark`] from a parsed changed-region (author/date verbatim, +/// so the RFC-3339 text round-trips exactly). +pub(super) fn region_mark(region: &OdfChangedRegion) -> RevisionMark { + RevisionMark { + kind: match region.kind { + OdfChangeKind::Insertion => RevisionKind::Insertion, + OdfChangeKind::Deletion => RevisionKind::Deletion, + }, + author: region.creator.clone(), + date: region.date.clone(), + id: Some(region.change_id.clone()), + } +} + +/// Wraps an inline in the given revision mark, folding it onto an existing +/// styled run's direct props or a fresh single-child run otherwise. +pub(super) fn wrap_revision(inl: Inline, mark: &RevisionMark) -> Inline { + match inl { + Inline::StyledRun(mut sr) => { + let mut cp = sr.direct_props.map(|b| *b).unwrap_or_default(); + cp.revision = Some(mark.clone()); + sr.direct_props = Some(Box::new(cp)); + Inline::StyledRun(sr) + } + other => Inline::StyledRun(revision_run(mark.clone(), vec![other])), + } +} + +/// Builds the struck run standing in for a tracked deletion's removed text. +pub(super) fn deletion_run(region: &OdfChangedRegion) -> Inline { + Inline::StyledRun(revision_run( + region_mark(region), + vec![Inline::Str(region.deleted_text.clone())], + )) +} + +/// A `StyledRun` carrying only a revision mark over `content`. +fn revision_run(mark: RevisionMark, content: Vec) -> StyledRun { + StyledRun { + style_id: None, + direct_props: Some(Box::new(CharProps { + revision: Some(mark), + ..CharProps::default() + })), + content, + attr: NodeAttr::default(), + } +} + +/// The ¶-mark deletion recorded in this paragraph, if any: a `text:change` +/// point whose deletion region stows **no** text — the deleted paragraph +/// break itself (the shape the ODT writer's `para_mark_change` emits). A +/// point with removed text is a run deletion, not a ¶ deletion. +pub(super) fn para_mark_revision( + para: &OdfParagraph, + ctx: &OdfMappingContext<'_>, +) -> Option { + para.children.iter().find_map(|c| match c { + OdfParagraphChild::RevisionPoint { change_id } => ctx + .changed_regions + .get(change_id) + .filter(|r| r.kind == OdfChangeKind::Deletion && r.deleted_text.is_empty()) + .map(region_mark), + _ => None, + }) +} diff --git a/loki-odf/src/odt/write/content.rs b/loki-odf/src/odt/write/content.rs index f2881944..4362f7c1 100644 --- a/loki-odf/src/odt/write/content.rs +++ b/loki-odf/src/odt/write/content.rs @@ -15,6 +15,7 @@ use super::auto::AutoStyles; use super::inlines::write_inlines; use super::media::{MathPart, Media, Rendered}; use super::page_styles::resolve_page_style_names; +use super::revisions; use super::tables::table; use super::xml::{attr, escape}; @@ -120,7 +121,7 @@ pub(crate) fn content_xml(doc: &Document) -> Rendered { /// Writes a single block element. pub(super) fn write_block(out: &mut String, block: &Block, cx: &mut Cx) { match block { - Block::Para(inl) | Block::Plain(inl) => paragraph(out, None, inl, cx), + Block::Para(inl) | Block::Plain(inl) => paragraph(out, None, inl, "", cx), Block::Heading(level, _, inl) => { let lvl = (*level).clamp(1, 6); out.push_str(&format!( @@ -138,7 +139,7 @@ pub(super) fn write_block(out: &mut String, block: &Block, cx: &mut Cx) { Block::BulletList(items) | Block::OrderedList(_, items) => list(out, items, cx), Block::DefinitionList(items) => { for (term, defs) in items { - paragraph(out, None, term, cx); + paragraph(out, None, term, "", cx); for blocks in defs { for b in blocks { write_block(out, b, cx); @@ -194,14 +195,15 @@ fn write_block_with_master(out: &mut String, block: &Block, master: &str, cx: &m &CharProps::default(), master, ); - paragraph(out, Some(&style), inl, cx); + paragraph(out, Some(&style), inl, "", cx); } Block::StyledPara(sp) => { let base = sp.style_id.as_ref().map(StyleId::as_str); let pp = sp.direct_para_props.as_deref().cloned().unwrap_or_default(); let cp = sp.direct_char_props.as_deref().cloned().unwrap_or_default(); let style = cx.auto.para_style_master(base, &pp, &cp, master); - paragraph(out, Some(&style), &sp.inlines, cx); + let suffix = revisions::para_mark_change(sp, cx); + paragraph(out, Some(&style), &sp.inlines, &suffix, cx); } Block::Heading(level, _, inl) => { let lvl = (*level).clamp(1, 6); @@ -232,10 +234,13 @@ fn write_block_with_master(out: &mut String, block: &Block, master: &str, cx: &m } /// Writes a `` with optional automatic style and inline content. +/// `suffix` is trailing markup appended after the inlines, just before the +/// closing tag (the ¶-mark tracked-deletion `` milestone). fn paragraph( out: &mut String, style: Option<&str>, inl: &[loki_doc_model::content::inline::Inline], + suffix: &str, cx: &mut Cx, ) { out.push_str("'); write_inlines(out, inl, cx); + out.push_str(suffix); out.push_str(""); } @@ -258,7 +264,10 @@ fn styled_paragraph(out: &mut String, sp: &StyledParagraph, cx: &mut Cx) { } else { base.map(str::to_string) }; - paragraph(out, name.as_deref(), &sp.inlines, cx); + // A tracked ¶-mark deletion emits its `` milestone at the + // paragraph end (ODF has no pPr/rPr slot; the region stows the break). + let suffix = revisions::para_mark_change(sp, cx); + paragraph(out, name.as_deref(), &sp.inlines, &suffix, cx); } /// Writes a `` from a list of items (each a block sequence). diff --git a/loki-odf/src/odt/write/revisions.rs b/loki-odf/src/odt/write/revisions.rs index b562943a..e2d723af 100644 --- a/loki-odf/src/odt/write/revisions.rs +++ b/loki-odf/src/odt/write/revisions.rs @@ -10,6 +10,7 @@ //! once at the start of `office:text`). The `office:change-info` carries //! `dc:creator` / `dc:date`, mirroring [`super::inlines`]'s comment writer. +use loki_doc_model::content::block::StyledParagraph; use loki_doc_model::content::inline::StyledRun; use loki_doc_model::style::props::revision::{RevisionKind, RevisionMark}; @@ -125,6 +126,27 @@ pub(super) fn write_styled_run(out: &mut String, sr: &StyledRun, cx: &mut Cx) { } } +/// The end-of-paragraph `` milestone for a tracked ¶-mark +/// deletion on a styled paragraph's `direct_char_props`, or `""` when the +/// paragraph mark carries no tracked deletion. The registered region stows an +/// empty `` — the deleted paragraph break, LibreOffice's shape for a +/// ¶ deletion — which the importer maps back onto the paragraph. +pub(super) fn para_mark_change(sp: &StyledParagraph, cx: &mut Cx) -> String { + let Some(rev) = sp + .direct_char_props + .as_deref() + .and_then(|p| p.revision.as_ref()) + .filter(|r| r.kind == RevisionKind::Deletion) + else { + return String::new(); + }; + let id = cx.changes.register(rev, Some(String::new())); + let mut out = String::from(""); + out +} + /// Resolves the automatic text-style name for a styled run (its direct props, or /// its named style). Revision-only props yield `None` (no formatting to emit). fn span_style(sr: &StyledRun, cx: &mut Cx) -> Option { diff --git a/loki-odf/tests/revision_round_trip.rs b/loki-odf/tests/revision_round_trip.rs index 135b4b8b..03a5da51 100644 --- a/loki-odf/tests/revision_round_trip.rs +++ b/loki-odf/tests/revision_round_trip.rs @@ -8,7 +8,7 @@ use std::io::Cursor; use loki_doc_model::content::attr::NodeAttr; -use loki_doc_model::content::block::Block; +use loki_doc_model::content::block::{Block, StyledParagraph}; use loki_doc_model::content::inline::{Inline, StyledRun}; use loki_doc_model::document::Document; use loki_doc_model::io::{DocumentExport, DocumentImport}; @@ -112,3 +112,47 @@ fn tracked_insertion_and_deletion_round_trip() { assert!(back.has_tracked_changes()); } + +/// A tracked deletion of the *paragraph mark* (¶) — `direct_char_props.revision` +/// on the block — exports as an end-of-paragraph `text:change` point whose +/// deletion region stows only the paragraph break (an empty `text:p`), and +/// re-imports onto the paragraph, not as a struck text run (4a.2 polish tail). +#[test] +fn tracked_paragraph_mark_deletion_round_trips() { + let mut mark = RevisionMark::new(RevisionKind::Deletion).with_author("Cara"); + mark.date = Some("2026-07-08T09:00:00Z".to_string()); + let mut doc = Document::new(); + doc.sections[0].blocks = vec![ + Block::StyledPara(StyledParagraph { + style_id: None, + direct_para_props: None, + direct_char_props: Some(Box::new(CharProps { + revision: Some(mark), + ..CharProps::default() + })), + inlines: vec![Inline::Str("First".into())], + attr: NodeAttr::default(), + }), + Block::Para(vec![Inline::Str("Second".into())]), + ]; + + let back = round_trip(&doc); + + let Block::StyledPara(first) = &back.sections[0].blocks[0] else { + panic!("first block must stay a styled paragraph"); + }; + let rev = first + .direct_char_props + .as_ref() + .and_then(|cp| cp.revision.as_ref()) + .expect("¶-mark deletion survives on the paragraph"); + assert_eq!(rev.kind, RevisionKind::Deletion); + assert_eq!(rev.author.as_deref(), Some("Cara")); + assert_eq!(rev.date.as_deref(), Some("2026-07-08T09:00:00Z")); + // The ¶ deletion must not be re-materialised as a struck (empty) text run. + assert!( + find_revision(&back, RevisionKind::Deletion).is_none(), + "no struck run stands in for the paragraph break" + ); + assert!(back.has_tracked_changes()); +} From c958d9a158f56dfcda35a2fd1b3e1616ad7ce354 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:57 +0000 Subject: [PATCH 05/12] Filter split-fragment items to their y-range (Option B, plan 6.3) Resolves TODO(split-optimise). Each ClippedGroup fragment of a page-spanning paragraph now carries only the items whose (conservatively over-estimated) vertical extent intersects the fragment's y-range (ParagraphLayout::items_in_y_range in para_query.rs), instead of a full copy of every paragraph item per page. The Option-A clip rect is kept, so any conservatively-retained extra item is still masked and rendering is pixel-identical; an item with unknown extent is always kept. Also retires the stale TODO(link-click) notes (5.11 shipped both halves). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-layout/src/flow_para.rs | 16 +++++----- loki-layout/src/flow_tests.rs | 40 +++++++++++++++++++++++++ loki-layout/src/items.rs | 13 +++++---- loki-layout/src/para.rs | 10 +++---- loki-layout/src/para_query.rs | 55 +++++++++++++++++++++++++++++++---- loki-layout/src/resolve.rs | 4 +-- 6 files changed, 113 insertions(+), 25 deletions(-) diff --git a/loki-layout/src/flow_para.rs b/loki-layout/src/flow_para.rs index 2901a72a..0fe70e43 100644 --- a/loki-layout/src/flow_para.rs +++ b/loki-layout/src/flow_para.rs @@ -557,13 +557,14 @@ fn split_and_place_loop( state.current_items.push(item); } } else { - // Continuation fragment: clip to hide content from prior pages. + // Continuation fragment: clip to hide content from prior pages, + // carrying only the items near its y-range (Option B, 6.3). if let Some(ref al) = arc_layout { push_editing_para(state, block_index, al.clone(), (0.0, ty)); } let clip_rect = LayoutRect::new(0.0, state.cursor_y, state.content_width, frag_height); - let mut items = para_layout.items.clone(); + let mut items = para_layout.items_in_y_range(frag_start, para_layout.height); for item in &mut items { item.translate(dx, ty); } @@ -672,10 +673,9 @@ fn split_and_place_loop( } } -/// Emit a [`PositionedItem::ClippedGroup`] covering para-local y ∈ `[frag_start, split_y)`. -/// -/// Items are translated so para-local `frag_start` maps to page `state.cursor_y`. -/// Advances `state.cursor_y` by the fragment height. +/// Emit a [`PositionedItem::ClippedGroup`] covering para-local y ∈ `[frag_start, split_y)`; +/// items translate so `frag_start` maps to `state.cursor_y`, which advances by +/// the fragment height. fn emit_fragment( state: &mut FlowState, para_layout: &ParagraphLayout, @@ -697,7 +697,9 @@ fn emit_fragment( if let Some(al) = arc_layout { push_editing_para(state, block_index, al, (0.0, ty)); } - let mut items = para_layout.items.clone(); + // Option B (6.3): only the items near this fragment's y-range travel with + // it; the clip masks the conservative slop, so rendering is unchanged. + let mut items = para_layout.items_in_y_range(frag_start, split_y); for item in &mut items { item.translate(dx, ty); } diff --git a/loki-layout/src/flow_tests.rs b/loki-layout/src/flow_tests.rs index e3d1508d..bbe96bff 100644 --- a/loki-layout/src/flow_tests.rs +++ b/loki-layout/src/flow_tests.rs @@ -568,6 +568,46 @@ fn paragraph_split_produces_clipped_groups_on_multiple_pages() { let _ = warnings; } +/// Option B (6.3): each split fragment carries only the items near its own +/// y-range — not a full copy of every paragraph item per page (the pre-filter +/// behaviour). The clip rect keeps rendering pixel-identical, so this asserts +/// the item *count* per fragment against the unsplit paragraph's total. +#[test] +fn split_fragments_carry_only_their_own_y_range_items() { + fn clip_counts(items: &[PositionedItem]) -> Vec { + items + .iter() + .filter_map(|i| match i { + PositionedItem::ClippedGroup { items, .. } => Some(items.len()), + _ => None, + }) + .collect() + } + + let mut r = test_resources(); + // ~28 wrapped lines in tiny_layout's 90 pt content area → 4+ pages, so the + // middle fragments' kept windows (fragment range ± the conservative glyph + // slop) are well under the paragraph's full item count. + let text = "Lorem ipsum dolor sit amet consectetur adipiscing. ".repeat(16); + + let unsplit = section_of(vec![make_para(&text)], tiny_layout()); + let (total_items, _, _) = flow_pageless(&mut r, &unsplit); + let total = total_items.len(); + + let section = section_of(vec![make_para(&text)], tiny_layout()); + let (pages, _) = flow_paginated(&mut r, §ion); + assert!(pages.len() >= 3, "need 3+ pages, got {}", pages.len()); + for (i, page) in pages.iter().enumerate() { + for count in clip_counts(&page.content_items) { + assert!( + count < total, + "page {i} fragment carries {count} of {total} items — the \ + y-range filter should keep a strict subset" + ); + } + } +} + #[test] fn orphan_control_defers_a_would_be_orphan_paragraph() { use loki_doc_model::style::props::para_props::{LineHeight, ParaProps}; diff --git a/loki-layout/src/items.rs b/loki-layout/src/items.rs index 8c0eff8c..2ddb100d 100644 --- a/loki-layout/src/items.rs +++ b/loki-layout/src/items.rs @@ -36,8 +36,9 @@ pub enum PositionedItem { /// /// `clip_rect` is in page-content-area-local coordinates (same space as /// the child items' origins). Used to render paragraph fragments that - /// span a page boundary: each fragment carries all paragraph items but - /// the clip rect masks lines belonging to the other page. + /// span a page boundary: each fragment carries the items near its own + /// y-range (`ParagraphLayout::items_in_y_range`, feature 6.3) and the + /// clip rect masks anything belonging to the other page. ClippedGroup { /// Clip rectangle in page-content-area coordinates. clip_rect: LayoutRect, @@ -122,10 +123,10 @@ pub struct PositionedGlyphRun { pub synthesis: GlyphSynthesis, /// Hyperlink URL if this run is part of a link. `None` for non-link text. /// - /// A blue-tint underlay hint is rendered by `loki-vello`, and a point can be - /// resolved to its URL via `ContinuousLayout::link_at` / - /// `PageEditingData::link_at` (feature 5.11). TODO(link-click): the editor's - /// open-on-click gesture (modifier + URL opener) is the remaining wiring. + /// A blue-tint underlay hint is rendered by `loki-vello`, a point resolves + /// to its URL via `ContinuousLayout::link_at` / `PageEditingData::link_at`, + /// and Ctrl/Cmd+click opens it in both paginated and reflow modes + /// (feature 5.11). pub link_url: Option, } diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index ea740c86..c15af0cb 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -198,8 +198,8 @@ pub struct StyleSpan { /// Hyperlink URL if this run belongs to a link inline. `None` otherwise. /// /// Set by `resolve.rs` `walk_inlines` when recursing into `Inline::Link` - /// children. Used to render a visual link hint and (eventually) hit-test - /// regions. TODO(link-click): interactive hit-testing deferred. + /// children. Renders the visual link hint and backs `link_at` hit-testing + /// + Ctrl/Cmd+click opening (feature 5.11). pub link_url: Option, /// MathML markup for an [`Inline::Math`][loki_doc_model::content::inline::Inline::Math] /// placeholder. When `Some`, this span has an empty `range` marking the @@ -422,9 +422,9 @@ pub struct ParagraphLayout { /// Empty for empty paragraphs. /// /// Used by `flow_section` to find clean split points at line boundaries. - /// `TODO(split-optimise)`: Option B y-range item filter can use this field - /// to avoid rendering clipped content to the GPU once the Option A baseline - /// is stable and profiled. + /// The Option-B y-range item filter (`items_in_y_range`, feature 6.3) + /// complements it: each split fragment carries only the items near its + /// own y-range instead of a full copy of the paragraph's items. pub line_boundaries: Vec<(f32, f32)>, /// Parley layout object retained for hit testing and cursor positioning. /// diff --git a/loki-layout/src/para_query.rs b/loki-layout/src/para_query.rs index 8f0faf22..e2f238bc 100644 --- a/loki-layout/src/para_query.rs +++ b/loki-layout/src/para_query.rs @@ -2,11 +2,9 @@ // Copyright 2026 AppThere Loki contributors //! Read-only geometry queries on a [`super::ParagraphLayout`]: point hit-test, -//! line-end offset, caret rect, and selection rects. Split out of `para.rs` -//! (Phase 7.1). This is the home for the pending Option-B y-range item filter -//! (deferred-feature 6.3), which reads `ParagraphLayout::line_boundaries` to -//! skip painting off-viewport content — the module has headroom under the -//! 300-line ceiling for it, unlike the (baselined) `para.rs`. +//! line-end offset, caret rect, selection rects, and the Option-B y-range item +//! filter (`items_in_y_range`, deferred-feature 6.3) used by split-paragraph +//! fragment emission. //! //! A child module of `para`, so it reaches `ParagraphLayout`'s private fields //! (`parley_layout`, `line_boundaries`, `orig_to_clean`, `clean_to_orig`). @@ -177,6 +175,53 @@ impl super::ParagraphLayout { .collect() } + /// Clones only the items likely to intersect para-local y ∈ `[y0, y1)` — + /// the Option-B y-range filter (deferred-feature 6.3). Used when emitting a + /// split-paragraph page fragment so each fragment carries roughly its own + /// lines' items instead of the whole paragraph's (Option A clips the full + /// copy on the GPU; the clip stays, so over-inclusion here is harmless). + /// + /// The filter is deliberately conservative: each item's vertical extent is + /// over-estimated (a glyph run's ink is bounded by ±[`Self::GLYPH_Y_SLOP`] + /// font-sizes around its baseline; groups use their clip/content boxes), and + /// an item with an unknown extent is always kept — dropping a visible item + /// would be a rendering bug, keeping an invisible one only wastes the clip. + pub fn items_in_y_range(&self, y0: f32, y1: f32) -> Vec { + use crate::items::PositionedItem as PI; + self.items + .iter() + .filter(|item| { + let (top, bottom) = match item { + PI::GlyphRun(r) => ( + r.origin.y - r.font_size * Self::GLYPH_Y_SLOP, + r.origin.y + r.font_size * Self::GLYPH_Y_SLOP, + ), + PI::FilledRect(r) | PI::HorizontalRule(r) => { + (r.rect.origin.y, r.rect.origin.y + r.rect.size.height) + } + PI::BorderRect(r) => (r.rect.origin.y, r.rect.origin.y + r.rect.size.height), + PI::Image(r) => (r.rect.origin.y, r.rect.origin.y + r.rect.size.height), + PI::Decoration(d) => (d.y, d.y + d.thickness), + PI::ClippedGroup { clip_rect, .. } => ( + clip_rect.origin.y, + clip_rect.origin.y + clip_rect.size.height, + ), + // Unknown extent (rotated content, future variants): keep. + _ => return true, + }; + bottom >= y0 && top < y1 + }) + .cloned() + .collect() + } + + /// How far a glyph run's ink may plausibly extend above/below its baseline, + /// in multiples of the font size. Generous on purpose (real ascent+descent + /// stay within ~1.5 em even with stacked diacritics): the cost of keeping an + /// extra clipped-away run is trivial, the cost of dropping a visible one is + /// a rendering bug. + const GLYPH_Y_SLOP: f32 = 3.0; + /// Horizontal indent (points) applied to the drawn glyphs of visual line /// `line_index`, matching the `indent_x` used when emitting glyph runs: the /// first line of a hanging-indent paragraph starts `indent_hanging` to the diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 3347fd04..143b7528 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -679,8 +679,8 @@ fn walk_inlines( notes, ); } - // Link (gap #11): thread the resolved URL into child spans. - // TODO(link-click): interactive hit-testing deferred; only visual hint rendered. + // Link (gap #11): thread the resolved URL into child spans (hint + + // hit-test + Ctrl/Cmd+click open all ride on it; feature 5.11). Inline::Link(_, ch, target) => { let url = target.url.as_str(); walk_inlines( From f9fe213517adebddf301808a63d52251c8fd8f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:57 +0000 Subject: [PATCH 06/12] Unwrap inline and cell-level w:sdt content controls (5.9 tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification first: the paragraph and table-cell dispatch loops never skipped unknown elements, so inline (w:p/w:sdt) and cell-level (w:tc/w:sdt) sdtContent was already implicitly unwrapped — the deferral note was stale. This pins that behaviour with reader regression tests (inline runs + sdtPr chrome; cell paragraphs before/inside a control) and hardens both dispatches by skipping w:sdtPr/w:sdtEndPr wholesale, matching the explicit block-level unwrapper, so control chrome internals can never leak into the content dispatch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-ooxml/src/docx/model/paragraph.rs | 2 +- loki-ooxml/src/docx/reader/document.rs | 22 ++--- loki-ooxml/src/docx/reader/document_cell.rs | 9 +- loki-ooxml/src/docx/reader/document_tests.rs | 86 ++++++++++++++++++++ 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/loki-ooxml/src/docx/model/paragraph.rs b/loki-ooxml/src/docx/model/paragraph.rs index 033c0403..ac736650 100644 --- a/loki-ooxml/src/docx/model/paragraph.rs +++ b/loki-ooxml/src/docx/model/paragraph.rs @@ -308,7 +308,7 @@ pub struct DocxHyperlink { } /// An inline drawing from `w:drawing` (ECMA-376 §17.3.3.9). -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct DocxDrawing { /// The relationship id from `a:blip @r:embed`. pub rel_id: Option, diff --git a/loki-ooxml/src/docx/reader/document.rs b/loki-ooxml/src/docx/reader/document.rs index 9015c7dd..5a8abde1 100644 --- a/loki-ooxml/src/docx/reader/document.rs +++ b/loki-ooxml/src/docx/reader/document.rs @@ -75,8 +75,7 @@ pub fn parse_document(xml: &[u8]) -> OoxmlResult { Ok(doc) } -/// Parses a `w:p` element from the current reader position. -/// Called when the `Start("p")` event has already been consumed. +/// Parses a `w:p` element; called after `Start("p")` has been consumed. pub(crate) fn parse_paragraph(reader: &mut Reader<&[u8]>) -> OoxmlResult { let mut para = DocxParagraph::default(); let mut buf = Vec::new(); @@ -150,6 +149,15 @@ pub(crate) fn parse_paragraph(reader: &mut Reader<&[u8]>) -> OoxmlResult { + depth -= 1; + let name = local_name(e.local_name().as_ref()).to_vec(); + skip_element(reader, &name)?; + continue; + } _ => {} } } @@ -622,15 +630,7 @@ fn parse_tabs(reader: &mut Reader<&[u8]>, out: &mut Vec) -> OoxmlResult /// Parses a `w:drawing` element. Called after Start("drawing") is consumed. fn parse_drawing(reader: &mut Reader<&[u8]>) -> OoxmlResult { - let mut drawing = DocxDrawing { - rel_id: None, - cx: None, - cy: None, - descr: None, - name: None, - is_anchor: false, - wrap: None, - }; + let mut drawing = DocxDrawing::default(); // Wrap mode/side are carried on a `wp:wrap*` child; `behindDoc` lives on the // `wp:anchor` element. Collect both, then assemble the `FloatWrap` at the end. let mut wrap_mode: Option = None; diff --git a/loki-ooxml/src/docx/reader/document_cell.rs b/loki-ooxml/src/docx/reader/document_cell.rs index d1e24320..ed9417f0 100644 --- a/loki-ooxml/src/docx/reader/document_cell.rs +++ b/loki-ooxml/src/docx/reader/document_cell.rs @@ -16,8 +16,8 @@ use crate::docx::model::styles::{ use crate::docx::reader::util::{attr_val, local_name}; use crate::error::{OoxmlError, OoxmlResult}; -use super::parse_paragraph; use super::table::parse_table; +use super::{parse_paragraph, skip_element}; /// Parses a `w:tc` element. Called after Start("tc") is consumed. pub(super) fn parse_table_cell(reader: &mut Reader<&[u8]>) -> OoxmlResult { @@ -38,6 +38,13 @@ pub(super) fn parse_table_cell(reader: &mut Reader<&[u8]>) -> OoxmlResult { + let name = local_name(e.local_name().as_ref()).to_vec(); + skip_element(reader, &name)?; + } _ => {} }, Ok(Event::End(ref e)) if local_name(e.local_name().as_ref()) == b"tc" => { diff --git a/loki-ooxml/src/docx/reader/document_tests.rs b/loki-ooxml/src/docx/reader/document_tests.rs index 2d036467..a01a3ddd 100644 --- a/loki-ooxml/src/docx/reader/document_tests.rs +++ b/loki-ooxml/src/docx/reader/document_tests.rs @@ -130,3 +130,89 @@ fn block_sdt_content_is_unwrapped_into_the_body() { // content control's children are unwrapped in order, nothing dropped. assert_eq!(kinds, ["p", "p", "p", "tbl", "p"]); } + +/// Plain text of every run in a paragraph, concatenated in order. +fn para_text(p: &crate::docx::model::paragraph::DocxParagraph) -> String { + use crate::docx::model::paragraph::{DocxParaChild, DocxRunChild}; + p.children + .iter() + .filter_map(|c| match c { + DocxParaChild::Run(r) => Some(r), + _ => None, + }) + .flat_map(|r| &r.children) + .filter_map(|c| match c { + DocxRunChild::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() +} + +/// An *inline* content control (`w:sdt` inside `w:p`) — its runs must survive +/// into the paragraph, and the control's own `w:sdtPr` chrome (which carries a +/// `w:rPr` and placeholder machinery) must not leak anything (5.9 tail). +#[test] +fn inline_sdt_runs_are_unwrapped_into_the_paragraph() { + let xml: &[u8] = br#" + + + + Before + + + + + + + controlled + + after + + +"#; + let doc = parse_document(xml).unwrap(); + let Some(DocxBodyChild::Paragraph(p)) = doc.body.children.first() else { + panic!("expected paragraph"); + }; + assert_eq!(para_text(p), "Before controlled after"); +} + +/// A *cell-level* content control (`w:sdt` inside `w:tc`) — its paragraphs +/// must survive into the cell (5.9 tail). +#[test] +fn cell_sdt_paragraphs_are_unwrapped_into_the_cell() { + let xml: &[u8] = br#" + + + + + + + + + + In control + Second + + + Plain + + + + +"#; + let doc = parse_document(xml).unwrap(); + let Some(DocxBodyChild::Table(t)) = doc.body.children.first() else { + panic!("expected table"); + }; + let cell = &t.rows[0].cells[0]; + let texts: Vec = cell + .children + .iter() + .filter_map(|c| match c { + DocxBodyChild::Paragraph(p) => Some(para_text(p)), + _ => None, + }) + .collect(); + assert_eq!(texts, ["In control", "Second", "Plain"]); +} From 9af15e509ee4d84ea763ee540dbca9dff7836fad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:57 +0000 Subject: [PATCH 07/12] Carry Theme/Cmyk border colors through the Loro CRDT (loro-bridge tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the border codec to a v2 layout (Style:width:spacing:color) whose color field comes last, so the total color codec's colon-delimited encodings fit unescaped — non-Rgb border colors no longer collapse to auto. The decoder accepts both layouts (v2's third field is always a number, v1's never is), so pre-migration CRDT snapshots keep decoding. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-doc-model/src/loro_bridge/decode.rs | 37 +++++++------ .../src/loro_bridge/decode_tests.rs | 51 ++++++++++++++++++ loki-doc-model/tests/loro_bridge_gap_tests.rs | 54 +++++++++++++++++++ 3 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 loki-doc-model/src/loro_bridge/decode_tests.rs diff --git a/loki-doc-model/src/loro_bridge/decode.rs b/loki-doc-model/src/loro_bridge/decode.rs index fa2f97d4..95f3c9ca 100644 --- a/loki-doc-model/src/loro_bridge/decode.rs +++ b/loki-doc-model/src/loro_bridge/decode.rs @@ -3,9 +3,7 @@ //! Codec helpers: string encode/decode functions shared between the Loro //! bridge write path (`write.rs`) and read path (`props_read.rs`, `inlines.rs`). -//! -//! Split from `inlines.rs` and `read.rs` to keep individual files under the -//! 300-line ceiling. +//! Split from `inlines.rs` / `read.rs` to hold the 300-line ceiling. use crate::style::props::border::{Border, BorderStyle}; use crate::style::props::char_props::{ @@ -224,11 +222,10 @@ pub(super) fn decode_tab_stops(s: &str) -> Option> { // ── Border codec ───────────────────────────────────────────────────────────── -/// Encode a [`Border`] as a compact `"Style:width_pt:color:spacing_pt"` string. -/// -/// TODO(loro-bridge): non-Rgb border colors (Theme/Cmyk) collapse to `auto` -/// here — the colon-delimited format cannot carry the `color_codec` encodings -/// (they contain `:`), so lifting this needs a format migration. +/// Encode a [`Border`] as `"Style:width_pt:spacing_pt:color"` (v2): the color +/// comes **last** so the total [`super::color_codec`] encodings — which contain +/// `:` themselves — fit unescaped, and Theme/Cmyk/Transparent border colors +/// survive the CRDT (formerly collapsed to `auto` = no explicit color). pub(super) fn encode_border(b: &Border) -> String { let style = match b.style { BorderStyle::None => "None", @@ -245,19 +242,22 @@ pub(super) fn encode_border(b: &Border) -> String { let color = b .color .as_ref() - .and_then(|c| c.to_hex()) + .map(super::color_codec::encode_document_color) .unwrap_or_else(|| "auto".to_string()); let spacing = b.spacing.map_or(0.0, |s| s.value()); - format!("{}:{}:{}:{}", style, b.width.value(), color, spacing) + format!("{}:{}:{}:{}", style, b.width.value(), spacing, color) } -/// Decode a border string produced by [`encode_border`]. +/// Decode a border string in either layout: v2 (`Style:width:spacing:color`, +/// color last and possibly colon-carrying) or the pre-migration v1 +/// (`Style:width:color:spacing`, color `auto`/`#RRGGBB` only). Unambiguous — +/// v2's third field is always a number, v1's never is. pub(super) fn decode_border(s: &str) -> Option { let mut parts = s.splitn(4, ':'); let style_str = parts.next()?; let width_str = parts.next()?; - let color_str = parts.next()?; - let spacing_str = parts.next()?; + let third = parts.next()?; + let fourth = parts.next()?; let style = match style_str { "None" => BorderStyle::None, @@ -273,12 +273,15 @@ pub(super) fn decode_border(s: &str) -> Option { _ => return None, }; let width: f64 = width_str.parse().ok()?; + let (spacing, color_str) = match third.parse::() { + Ok(sp) => (sp, fourth), + Err(_) => (fourth.parse().ok()?, third), + }; let color = if color_str == "auto" { None } else { - DocumentColor::from_hex(color_str).ok() + super::color_codec::decode_document_color(color_str) }; - let spacing: f64 = spacing_str.parse().ok()?; Some(Border { style, width: Points::new(width), @@ -290,3 +293,7 @@ pub(super) fn decode_border(s: &str) -> Option { }, }) } + +#[cfg(test)] +#[path = "decode_tests.rs"] +mod tests; diff --git a/loki-doc-model/src/loro_bridge/decode_tests.rs b/loki-doc-model/src/loro_bridge/decode_tests.rs new file mode 100644 index 00000000..62c21644 --- /dev/null +++ b/loki-doc-model/src/loro_bridge/decode_tests.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unit tests for the border codec's v2 layout and v1 back-compat decode. + +use loki_primitives::color::{DocumentColor, ThemeColorSlot}; + +use super::{decode_border, encode_border}; +use crate::style::props::border::{Border, BorderStyle}; +use loki_primitives::units::Points; + +#[test] +fn v1_border_strings_still_decode() { + // Pre-migration layout: Style:width:color:spacing (color = auto | #hex). + let b = decode_border("Solid:1:#FF0000:2").expect("v1 hex decodes"); + assert_eq!(b.style, BorderStyle::Solid); + assert_eq!( + b.color.as_ref().and_then(|c| c.to_hex()).as_deref(), + Some("#FF0000") + ); + assert!((b.spacing.map_or(0.0, |s| s.value()) - 2.0).abs() < 1e-9); + + let b = decode_border("Dashed:0.5:auto:0").expect("v1 auto decodes"); + assert_eq!(b.style, BorderStyle::Dashed); + assert!(b.color.is_none()); + assert!(b.spacing.is_none()); +} + +#[test] +fn v2_border_round_trips_theme_color() { + let border = Border { + style: BorderStyle::Double, + width: Points::new(1.5), + color: Some(DocumentColor::Theme { + slot: ThemeColorSlot::Accent3, + tint: 0.5, + }), + spacing: Some(Points::new(4.0)), + }; + let s = encode_border(&border); + let back = decode_border(&s).expect("v2 decodes"); + assert_eq!(back.style, BorderStyle::Double); + match back.color { + Some(DocumentColor::Theme { slot, tint }) => { + assert_eq!(slot, ThemeColorSlot::Accent3); + assert!((tint - 0.5).abs() < 1e-6); + } + other => panic!("theme color must survive, got {other:?}"), + } + assert!((back.spacing.map_or(0.0, |s| s.value()) - 4.0).abs() < 1e-9); +} diff --git a/loki-doc-model/tests/loro_bridge_gap_tests.rs b/loki-doc-model/tests/loro_bridge_gap_tests.rs index 1eff244f..5e6baba7 100644 --- a/loki-doc-model/tests/loro_bridge_gap_tests.rs +++ b/loki-doc-model/tests/loro_bridge_gap_tests.rs @@ -168,6 +168,60 @@ fn bridge_border_roundtrip() { ); } +/// Non-Rgb border colors (Theme / Cmyk) must survive the CRDT — formerly they +/// collapsed to `auto` because the v1 border string could not carry the +/// colon-delimited total color codec (loro-bridge tail, format migrated to v2). +#[test] +fn bridge_border_theme_and_cmyk_colors_roundtrip() { + use loki_primitives::color::CmykColor; + + let mut para_props = ParaProps::default(); + para_props.border_top = Some(Border { + style: BorderStyle::Solid, + width: Points::new(1.5), + color: Some(DocumentColor::Theme { + slot: ThemeColorSlot::Accent2, + tint: 0.25, + }), + spacing: Some(Points::new(3.0)), + }); + para_props.border_bottom = Some(Border { + style: BorderStyle::Double, + width: Points::new(2.0), + color: Some(DocumentColor::Cmyk(CmykColor::new(0.1, 0.2, 0.3, 0.4))), + spacing: None, + }); + + let block = Block::StyledPara(loki_doc_model::content::block::StyledParagraph { + style_id: None, + direct_para_props: Some(Box::new(para_props)), + direct_char_props: None, + inlines: vec![Inline::Str("themed borders".into())], + attr: NodeAttr::default(), + }); + let recovered = round_trip(&single_block_doc(block)); + + let pp = match &recovered.sections[0].blocks[0] { + Block::StyledPara(p) => p.direct_para_props.as_deref().expect("para props"), + other => panic!("expected StyledPara, got {other:?}"), + }; + match pp.border_top.as_ref().and_then(|b| b.color.as_ref()) { + Some(DocumentColor::Theme { slot, tint }) => { + assert_eq!(*slot, ThemeColorSlot::Accent2); + assert!((tint - 0.25).abs() < 1e-6); + } + other => panic!("theme border color must survive, got {other:?}"), + } + let top = pp.border_top.as_ref().unwrap(); + assert!((top.spacing.map_or(0.0, |s| s.value()) - 3.0).abs() < 0.001); + match pp.border_bottom.as_ref().and_then(|b| b.color.as_ref()) { + Some(DocumentColor::Cmyk(c)) => { + assert!((c.cyan() - 0.1).abs() < 1e-4 && (c.key() - 0.4).abs() < 1e-4); + } + other => panic!("cmyk border color must survive, got {other:?}"), + } +} + // ── bridge_tab_stops_roundtrip ──────────────────────────────────────────────── fn styled_para_with_para(props: ParaProps) -> Block { From 8bdc03f4666e4cf9bff1037e81ae23f2eb036347 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:57 +0000 Subject: [PATCH 08/12] Wire spreadsheet grid zoom to the status-bar badge (TODO(zoom), 4c.5 tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zoom badge now cycles 50-200% (appthere_ui::next_zoom) and scales the rendered grid: column widths, row heights, header sizes, and cell/header fonts. The document keeps unzoomed pt widths — col_px multiplies the zoom in, resize commits divide the screen px back out (clamped in screen space), and auto-fit passes its document-px estimate through the same conversion. apply_change/sync_undo_redo moved to editor_mutate.rs (their natural home) to keep editor_inner.rs under its ceiling baseline. Also drops an unused import left by the border-codec migration. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- loki-doc-model/src/loro_bridge/decode.rs | 1 - .../src/routes/editor/editor_inner.rs | 115 +++++++----------- .../src/routes/editor/editor_mutate.rs | 66 +++++++++- 3 files changed, 102 insertions(+), 80 deletions(-) diff --git a/loki-doc-model/src/loro_bridge/decode.rs b/loki-doc-model/src/loro_bridge/decode.rs index 95f3c9ca..9d43a6da 100644 --- a/loki-doc-model/src/loro_bridge/decode.rs +++ b/loki-doc-model/src/loro_bridge/decode.rs @@ -11,7 +11,6 @@ use crate::style::props::char_props::{ }; use crate::style::props::para_props::{LineHeight, ParagraphAlignment, Spacing}; use crate::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; -use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; /// Encode section column widths (points) as a `;`-joined string, or `None` diff --git a/loki-spreadsheet/src/routes/editor/editor_inner.rs b/loki-spreadsheet/src/routes/editor/editor_inner.rs index dac5cb8e..2cbb38c8 100644 --- a/loki-spreadsheet/src/routes/editor/editor_inner.rs +++ b/loki-spreadsheet/src/routes/editor/editor_inner.rs @@ -13,7 +13,9 @@ use std::collections::HashSet; use super::cell_ref::{col_to_label, grid_dimensions}; use super::editor_load::load_document; -use super::editor_mutate::{mutate_cell, mutate_cell_style, mutate_column_width}; +use super::editor_mutate::{ + apply_change, mutate_cell, mutate_cell_style, mutate_column_width, sync_undo_redo, +}; use super::editor_path_sync::{ PathSyncSignals, restore_session, stash_outgoing, sync_path_and_reset, }; @@ -45,62 +47,6 @@ struct ColResize { current_px: f64, } -/// Dispatches changes to Loro, commits, deserializes back, and marks the active tab as dirty -fn apply_change( - loro_doc: Signal>, - mut workbook_snap: Signal, - mut tabs: Signal>, - active_tab_idx: usize, - mutate_fn: F, -) where - F: FnOnce(&loro::LoroDoc) -> Result<(), loro::LoroError>, -{ - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - - if let Err(e) = mutate_fn(ldoc) { - tracing::error!("Failed to mutate LoroDoc: {:?}", e); - return; - } - - ldoc.commit(); - - match loki_sheet_model::loro_bridge::loro_to_workbook(ldoc) { - Ok(new_wb) => { - workbook_snap.set(new_wb); - } - Err(e) => { - tracing::error!("Failed to sync workbook from LoroDoc: {:?}", e); - } - } - - if active_tab_idx > 0 { - let mut tabs_mut = tabs.write(); - if let Some(tab) = tabs_mut.get_mut(active_tab_idx - 1) { - tab.is_dirty = true; - } - } -} - -/// Syncs can_undo and can_redo from Loro's UndoManager -fn sync_undo_redo( - loro_doc: Signal>, - undo_manager: Signal>, - mut can_undo: Signal, - mut can_redo: Signal, -) { - if let Some(ldoc) = loro_doc.read().as_ref() { - ldoc.commit(); - } - let um_guard = undo_manager.read(); - if let Some(um) = um_guard.as_ref() { - can_undo.set(um.can_undo()); - can_redo.set(um.can_redo()); - } -} - /// Spreadsheet editor inner component. #[component] pub(super) fn EditorInner(path: String) -> Element { @@ -225,24 +171,39 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Column widths & drag-to-resize ───────────────────────────────────────── let mut col_resize = use_signal(|| Option::::None); - // The rendered width (CSS px) of `col`: the live drag width while resizing, - // else the document's width, else the default. + // Grid zoom (TODO(zoom) resolved, plan 4c.5): the status-bar badge cycles + // 50–200%; the factor scales the rendered geometry (column widths, row + // heights, header sizes, fonts) while the *document* stores unzoomed pt — + // resize commits divide the screen px back out. + let mut zoom_percent = use_signal(|| 100u32); + let zf = zoom_percent() as f64 / 100.0; + let row_h = 26.0 * zf; + let head_w = 50.0 * zf; + let font_head = 11.0 * zf; + let font_cell = 12.0 * zf; + + // The rendered width (screen px) of `col`: the live drag width while + // resizing, else the document's width (scaled by zoom), else the default. let col_px = move |col: usize| -> f64 { if let Some(r) = col_resize() && r.col == col { return r.current_px; } + let zf = *zoom_percent.read() as f64 / 100.0; workbook_snap .read() .get_sheet(0) .and_then(|s| s.column_width(col as u32)) .map_or(DEFAULT_COL_PX, pt_to_px) + * zf }; - // Commit a column width (px) to the model through Loro. + // Commit a column width (screen px) to the model through Loro, dividing + // the zoom back out so the document stores the unzoomed width. let commit_col_width = move |col: usize, px: f64| { - let pt = px_to_pt(px.clamp(MIN_COL_PX, MAX_COL_PX)); + let zf = *zoom_percent.peek() as f64 / 100.0; + let pt = px_to_pt((px / zf).clamp(MIN_COL_PX, MAX_COL_PX)); apply_change(loro_doc, workbook_snap, tabs, active_tab_idx, |ldoc| { mutate_column_width(ldoc, 0, col as u32, pt) }); @@ -262,7 +223,8 @@ pub(super) fn EditorInner(path: String) -> Element { } drop(wb); let px = (max_chars as f64 * 7.5 + 16.0).clamp(MIN_COL_PX, MAX_COL_PX); - commit_col_width(col, px); + // `commit_col_width` takes *screen* px; the estimate is document px. + commit_col_width(col, px * *zoom_percent.peek() as f64 / 100.0); }; let ref_text = match active_coords { @@ -690,7 +652,10 @@ pub(super) fn EditorInner(path: String) -> Element { onmousemove: move |e| { if let Some(mut r) = col_resize() { let x = e.client_coordinates().x; - r.current_px = (r.start_px + (x - r.start_x)).clamp(MIN_COL_PX, MAX_COL_PX); + // Clamp in screen px (the drag space) at the current zoom. + let zf = *zoom_percent.peek() as f64 / 100.0; + r.current_px = (r.start_px + (x - r.start_x)) + .clamp(MIN_COL_PX * zf, MAX_COL_PX * zf); col_resize.set(Some(r)); } }, @@ -764,7 +729,7 @@ pub(super) fn EditorInner(path: String) -> Element { // Corner Header div { style: format!( - "width: 50px; height: 26px; background: #E1E1E1; \ + "width: {head_w}px; height: {row_h}px; background: #E1E1E1; \ border-right: 1px solid {border}; border-bottom: 1px solid {border}; \ box-sizing: border-box; flex-shrink: 0;", border = tokens::COLOR_BORDER_DEFAULT, @@ -774,11 +739,11 @@ pub(super) fn EditorInner(path: String) -> Element { for col_idx in 0..num_cols { div { style: format!( - "width: {w}px; height: 26px; background: {bg}; \ + "width: {w}px; height: {row_h}px; background: {bg}; \ border-right: 1px solid {border}; border-bottom: 1px solid {border}; \ box-sizing: border-box; \ display: flex; align-items: center; \ - font-size: 11px; font-weight: bold; color: {fg}; \ + font-size: {font_head}px; font-weight: bold; color: {fg}; \ flex-shrink: 0;", w = col_px(col_idx), bg = if is_col_selected(col_idx) { "#CADAFC" } else { "#F0F0F0" }, @@ -819,11 +784,11 @@ pub(super) fn EditorInner(path: String) -> Element { // Sticky Row Header div { style: format!( - "width: 50px; height: 26px; background: {bg}; \ + "width: {head_w}px; height: {row_h}px; background: {bg}; \ border-right: 1px solid {border}; border-bottom: 1px solid {border}; \ box-sizing: border-box; \ display: flex; align-items: center; justify-content: center; \ - font-size: 11px; font-weight: bold; color: {fg}; \ + font-size: {font_head}px; font-weight: bold; color: {fg}; \ flex-shrink: 0; position: sticky; left: 0; z-index: 5;", bg = if is_row_selected(row_idx) { "#CADAFC" } else { "#F0F0F0" }, border = tokens::COLOR_BORDER_DEFAULT, @@ -863,13 +828,13 @@ pub(super) fn EditorInner(path: String) -> Element { rsx! { div { style: format!( - "width: {w}px; height: 26px; \ + "width: {w}px; height: {row_h}px; \ border-right: 1px solid {border}; border-bottom: 1px solid {border}; \ display: flex; align-items: center; \ padding: 0 6px; box-sizing: border-box; overflow: hidden; \ flex-shrink: 0; position: relative; \ background: {bg_color}; cursor: cell; \ - font-size: 12px; \ + font-size: {font_cell}px; \ font-weight: {font_weight}; \ font-style: {font_style}; \ text-decoration: {text_decoration}; \ @@ -900,7 +865,7 @@ pub(super) fn EditorInner(path: String) -> Element { if is_edit { input { - style: "width: 100%; height: 100%; border: none; padding: 0; margin: 0; outline: none; font-size: 12px;", + style: format!("width: 100%; height: 100%; border: none; padding: 0; margin: 0; outline: none; font-size: {font_cell}px;"), value: "{edit_val}", autofocus: true, oninput: move |e| { @@ -1035,12 +1000,14 @@ pub(super) fn EditorInner(path: String) -> Element { page_label: fl!("editor-sheet-label", current = 1i64, total = 1i64), word_count_label: "".to_string(), language_label: fl!("editor-language"), - zoom_percent: 100, + zoom_percent: zoom_percent(), collaborator_count: 0, collaborator_label: String::new(), zoom_aria_label: fl!("editor-zoom-aria"), - // TODO(zoom): needs zoom-aware col_px + resize px↔pt (plan 4c.5). - on_zoom_click: |_| {}, + on_zoom_click: move |_| { + let next = appthere_ui::next_zoom(*zoom_percent.peek()); + zoom_percent.set(next); + }, } } } diff --git a/loki-spreadsheet/src/routes/editor/editor_mutate.rs b/loki-spreadsheet/src/routes/editor/editor_mutate.rs index 0cea04c4..76a12ed9 100644 --- a/loki-spreadsheet/src/routes/editor/editor_mutate.rs +++ b/loki-spreadsheet/src/routes/editor/editor_mutate.rs @@ -2,11 +2,67 @@ // Copyright 2026 AppThere Loki contributors //! In-place Loro mutations for the spreadsheet CRDT: cell value/formula, -//! column width, and cell style writes. -//! -//! Extracted from `editor_inner.rs` (an oversized file) — see -//! [`super::editor_inner`] for the dispatch (`apply_change`) that commits and -//! re-snapshots after each of these mutations. +//! column width, and cell style writes — plus the dispatch (`apply_change`) +//! that commits and re-snapshots after each of these mutations and the +//! undo/redo availability sync. Extracted from `editor_inner.rs` (oversized). + +use dioxus::prelude::*; + +/// Dispatches changes to Loro, commits, deserializes back, and marks the active tab as dirty +pub(super) fn apply_change( + loro_doc: Signal>, + mut workbook_snap: Signal, + mut tabs: Signal>, + active_tab_idx: usize, + mutate_fn: F, +) where + F: FnOnce(&loro::LoroDoc) -> Result<(), loro::LoroError>, +{ + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + + if let Err(e) = mutate_fn(ldoc) { + tracing::error!("Failed to mutate LoroDoc: {:?}", e); + return; + } + + ldoc.commit(); + + match loki_sheet_model::loro_bridge::loro_to_workbook(ldoc) { + Ok(new_wb) => { + workbook_snap.set(new_wb); + } + Err(e) => { + tracing::error!("Failed to sync workbook from LoroDoc: {:?}", e); + } + } + + if active_tab_idx > 0 { + let mut tabs_mut = tabs.write(); + if let Some(tab) = tabs_mut.get_mut(active_tab_idx - 1) { + tab.is_dirty = true; + } + } +} + +/// Syncs can_undo and can_redo from Loro's UndoManager +pub(super) fn sync_undo_redo( + loro_doc: Signal>, + undo_manager: Signal>, + mut can_undo: Signal, + mut can_redo: Signal, +) { + if let Some(ldoc) = loro_doc.read().as_ref() { + ldoc.commit(); + } + let um_guard = undo_manager.read(); + if let Some(um) = um_guard.as_ref() { + can_undo.set(um.can_undo()); + can_redo.set(um.can_redo()); + } +} /// Helper to mutate Loro cells in-place pub(super) fn mutate_cell( From d26905eb276a3db20bb37a3b4013d3204328304f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:58 +0000 Subject: [PATCH 09/12] Push a measured root width into Calc/Slides' responsive context (F7a tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New shared appthere_ui::AtViewportWidthSensor: an invisible zero-height, full-width scroll container that measures its own width into the responsive context at mount and re-measures on every shell resync_scroll_geometry tick (blitz re-emits onscroll to scroll containers after a window resize). Presentation and Spreadsheet now call use_provide_responsive() and mount the sensor, so AtHomeTab's breakpoint tracks the real window there instead of falling back to Expanded — loki-text keeps funnelling its editor scroll-container width as before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- appthere-ui/src/lib.rs | 4 +- appthere-ui/src/responsive/mod.rs | 2 + appthere-ui/src/responsive/width_sensor.rs | 56 ++++++++++++++++++++++ loki-presentation/src/app.rs | 9 +++- loki-spreadsheet/src/app.rs | 9 +++- 5 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 appthere-ui/src/responsive/width_sensor.rs diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index 8c0f267b..87d4a654 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -56,8 +56,8 @@ pub use components::{ pub use responsive::{ estimate_group_metrics, group_layout, page_fits, required_page_width, resolve_cascade, resolve_page_fit, use_breakpoint, use_provide_responsive, use_responsive, use_ribbon_cascade, - use_viewport, AtResponsiveContext, Breakpoint, GroupCollapse, GroupLayout, GroupMetrics, - PageFit, RibbonCascade, Viewport, DEFAULT_DPI, + use_viewport, AtResponsiveContext, AtViewportWidthSensor, Breakpoint, GroupCollapse, + GroupLayout, GroupMetrics, PageFit, RibbonCascade, Viewport, DEFAULT_DPI, }; pub use safe_area::{set_safe_area_insets, update_safe_area_insets, use_safe_area, SafeAreaInsets}; pub use theme::{use_theme, AtThemeContext, ThemeVariant}; diff --git a/appthere-ui/src/responsive/mod.rs b/appthere-ui/src/responsive/mod.rs index 0f1780df..57fe6563 100644 --- a/appthere-ui/src/responsive/mod.rs +++ b/appthere-ui/src/responsive/mod.rs @@ -31,6 +31,7 @@ mod breakpoint; mod page_fit; mod ribbon_collapse; mod viewport; +mod width_sensor; pub use breakpoint::Breakpoint; pub use page_fit::{page_fits, required_page_width, resolve_page_fit, PageFit}; @@ -39,6 +40,7 @@ pub use ribbon_collapse::{ GroupMetrics, RibbonCascade, }; pub use viewport::{Viewport, DEFAULT_DPI}; +pub use width_sensor::AtViewportWidthSensor; use dioxus::prelude::*; diff --git a/appthere-ui/src/responsive/width_sensor.rs b/appthere-ui/src/responsive/width_sensor.rs new file mode 100644 index 00000000..a01a34f7 --- /dev/null +++ b/appthere-ui/src/responsive/width_sensor.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! [`AtViewportWidthSensor`] — pushes the root width into the responsive +//! context for apps without another measured width source (audit F7a). + +use dioxus::prelude::*; + +use super::{use_responsive, Viewport}; + +/// Invisible, zero-height, full-width scroll container that measures its own +/// width into the shared responsive context: once at mount, and again on every +/// shell `resync_scroll_geometry` tick (the blitz shell re-emits `onscroll` to +/// every scroll container after a window resize), so [`super::use_breakpoint`] +/// tracks the live window width without a dedicated resize event. +/// +/// Mount once at the app root in apps that have no other measured width source +/// (Presentation / Spreadsheet — loki-text funnels its editor scroll-container +/// width instead; audit F7a). Requires [`super::use_provide_responsive`] in an +/// ancestor. +/// +/// Not an interactive element (zero-size, no pointer targets), so the 44 px +/// touch-target convention does not apply. +#[component] +pub fn AtViewportWidthSensor() -> Element { + let responsive = use_responsive(); + let mut mounted = use_signal(|| Option::::None); + let measure = move || { + let Some(evt) = mounted.peek().clone() else { + return; + }; + let mut viewport = responsive.viewport; + spawn(async move { + if let Ok(rect) = evt.get_client_rect().await { + let width = rect.size.width as f32; + let prev = *viewport.peek(); + if width > 0.0 && (prev.inner_width_px - width).abs() > 0.5 { + viewport.set(Viewport { + inner_width_px: width, + ..prev + }); + } + } + }); + }; + rsx! { + div { + style: "width: 100%; height: 0px; overflow: auto;", + onmounted: move |e| { + mounted.set(Some(e)); + measure(); + }, + onscroll: move |_| measure(), + } + } +} diff --git a/loki-presentation/src/app.rs b/loki-presentation/src/app.rs index edf77f27..7e3f930b 100644 --- a/loki-presentation/src/app.rs +++ b/loki-presentation/src/app.rs @@ -2,7 +2,7 @@ //! Root application component for loki-presentation. -use appthere_ui::{AtThemeContext, use_safe_area}; +use appthere_ui::{AtThemeContext, AtViewportWidthSensor, use_provide_responsive, use_safe_area}; use dioxus::prelude::*; use crate::recent_documents::RecentDocuments; @@ -68,6 +68,11 @@ pub fn App() -> Element { // Inject the theme context before any shell component renders. provide_context(AtThemeContext::default()); + // Shared responsive context (Spec 03 M1 / audit F7a): seeded unmeasured; + // the AtViewportWidthSensor below funnels the measured root width in, so + // AtHomeTab's breakpoint tracks the real window instead of a fallback. + use_provide_responsive(); + // Spell-check service (bundled English; dictionary cache shared across the // suite). Provided into context so this app's editor can query spelling and // offer corrections. Visible in-shape squiggles are a follow-up in the @@ -139,6 +144,8 @@ pub fn App() -> Element { // Re-query safe-area insets on resize (orientation change) and while // the soft keyboard animates in/out (Android). SafeAreaResizeSensor {} + // Measure the root width into the responsive context (F7a). + AtViewportWidthSensor {} Router:: {} } diff --git a/loki-spreadsheet/src/app.rs b/loki-spreadsheet/src/app.rs index 0c859a29..f72fca05 100644 --- a/loki-spreadsheet/src/app.rs +++ b/loki-spreadsheet/src/app.rs @@ -2,7 +2,7 @@ //! Root application component for loki-spreadsheet. -use appthere_ui::{AtThemeContext, use_safe_area}; +use appthere_ui::{AtThemeContext, AtViewportWidthSensor, use_provide_responsive, use_safe_area}; use dioxus::prelude::*; use crate::recent_documents::RecentDocuments; @@ -68,6 +68,11 @@ pub fn App() -> Element { // Inject the theme context before any shell component renders. provide_context(AtThemeContext::default()); + // Shared responsive context (Spec 03 M1 / audit F7a): seeded unmeasured; + // the AtViewportWidthSensor below funnels the measured root width in, so + // AtHomeTab's breakpoint tracks the real window instead of a fallback. + use_provide_responsive(); + // Spell-check service (bundled English; dictionary cache shared across the // suite). Provided into context so this app's editor can query spelling and // offer corrections. Visible in-cell squiggles are a follow-up in the @@ -139,6 +144,8 @@ pub fn App() -> Element { // Re-query safe-area insets on resize (orientation change) and while // the soft keyboard animates in/out (Android). SafeAreaResizeSensor {} + // Measure the root width into the responsive context (F7a). + AtViewportWidthSensor {} Router:: {} } From 4d18fef0ed78424f55034d69203fcdcf0e7779c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:58 +0000 Subject: [PATCH 10/12] Update use_ribbon_cascade resilience note after F7a wiring Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- appthere-ui/src/responsive/mod.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/appthere-ui/src/responsive/mod.rs b/appthere-ui/src/responsive/mod.rs index 57fe6563..26f498e7 100644 --- a/appthere-ui/src/responsive/mod.rs +++ b/appthere-ui/src/responsive/mod.rs @@ -110,10 +110,11 @@ pub fn use_breakpoint() -> Breakpoint { /// avoid thrash — see [`resolve_cascade`]). /// /// **Resilient:** like [`use_breakpoint`], if no responsive context is present -/// (an app that has not wired [`use_provide_responsive`], e.g. Presentation / -/// Spreadsheet) the available width is treated as unbounded, so every group -/// stays [`GroupCollapse::Full`] — a sane full-chrome ribbon rather than a -/// broken one. +/// (an app that has not wired [`use_provide_responsive`]) the available width +/// is treated as unbounded, so every group stays [`GroupCollapse::Full`] — a +/// sane full-chrome ribbon rather than a broken one. (All three suite apps now +/// wire the context; Presentation/Spreadsheet measure via +/// [`AtViewportWidthSensor`].) /// /// The hysteresis state (the resolved collapse `level`) lives in a hook-local /// signal, so call this once per ribbon content strip. From 9e204d1341102a2c5c038336e05a23885f8d8d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:58 +0000 Subject: [PATCH 11/12] Dismiss the ribbon overflow menu on outside click via a window-level backdrop (4a.1 tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves TODO(ribbon). New appthere_ui overlay primitives: use_provide_backdrop() + AtBackdropHost render a transparent viewport-spanning click-catcher inside the app's (now position: relative) root container — the host lives at the window level because position: fixed collapses to absolute in stylo_taffy, so a backdrop rendered inside the ribbon could never span the viewport. The overflow menu itself stays anchored to its More button (no coordinate plumbing); opening it raises the backdrop (menu z 41 > backdrop z 40), clicking anywhere outside closes it, and a use_drop guard clears a stale backdrop if the strip unmounts while open. All three apps wire the context for suite consistency; degrades to toggle-only dismissal without it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- appthere-ui/src/components/mod.rs | 4 + appthere-ui/src/components/overlay.rs | 93 +++++++++++++++++++++ appthere-ui/src/components/ribbon/groups.rs | 44 +++++++--- appthere-ui/src/lib.rs | 9 +- loki-presentation/src/app.rs | 14 +++- loki-spreadsheet/src/app.rs | 14 +++- loki-text/src/app.rs | 17 +++- 7 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 appthere-ui/src/components/overlay.rs diff --git a/appthere-ui/src/components/mod.rs b/appthere-ui/src/components/mod.rs index 6be3368e..a9e329f3 100644 --- a/appthere-ui/src/components/mod.rs +++ b/appthere-ui/src/components/mod.rs @@ -9,6 +9,7 @@ pub mod confirm_dialog; pub mod document_tab; pub mod home_tab; pub mod icons; +pub mod overlay; pub mod panel_host; pub mod platform; pub mod ribbon; @@ -20,6 +21,9 @@ pub mod zoom; pub use confirm_dialog::{AtConfirmDialog, AtConfirmDialogProps}; pub use document_tab::{AtDocumentTab, AtDocumentTabProps}; pub use home_tab::{AtHomeTab, AtHomeTabProps, BuiltinTemplate, RecentDocument}; +pub use overlay::{ + use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost, BACKDROP_Z_INDEX, +}; pub use panel_host::{AtPanelHost, AtPanelHostProps, PanelPosture}; pub use platform::Platform; pub use ribbon::{AtRibbon, AtRibbonGroup, AtRibbonGroupProps, RibbonTabDesc, RibbonTabIndex}; diff --git a/appthere-ui/src/components/overlay.rs b/appthere-ui/src/components/overlay.rs new file mode 100644 index 00000000..aa8c3e81 --- /dev/null +++ b/appthere-ui/src/components/overlay.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Window-level dismiss backdrop: [`use_provide_backdrop`] + [`AtBackdropHost`] +//! + [`use_backdrop`]. +//! +//! `position: fixed` collapses to `absolute` in the current Blitz stack, so a +//! component deep in the tree (e.g. the ribbon's overflow menu) cannot render +//! its own full-viewport click-catcher — an absolute backdrop only spans its +//! nearest positioned ancestor. Instead the app root provides this context and +//! mounts [`AtBackdropHost`] inside its **positioned** root container; any +//! descendant can then request a transparent viewport-spanning backdrop whose +//! click dismisses whatever the requester has open. +//! +//! The popup itself stays wherever the requester rendered it (anchored to its +//! trigger) — only the backdrop is hosted at the window level. The popup must +//! carry a `z-index` above [`BACKDROP_Z_INDEX`] to stay clickable. + +use dioxus::prelude::*; + +/// z-index of the backdrop click-catcher. Popups that use the backdrop must +/// render above this (the ribbon overflow menu uses 41). +pub const BACKDROP_Z_INDEX: i32 = 40; + +/// Context handle for the window-level dismiss backdrop. +#[derive(Clone, Copy)] +pub struct AtBackdropContext { + /// `Some(on_dismiss)` while a requester has the backdrop up. + dismiss: Signal>>, +} + +impl AtBackdropContext { + /// Raises the backdrop; a click anywhere on it calls `on_dismiss` (the + /// requester closes its own popup state) and lowers the backdrop. + /// A second `show` replaces the previous requester's callback. + pub fn show(mut self, on_dismiss: Callback<()>) { + self.dismiss.set(Some(on_dismiss)); + } + + /// Lowers the backdrop without invoking the dismiss callback (the popup + /// closed through its own affordance). + pub fn hide(mut self) { + if self.dismiss.peek().is_some() { + self.dismiss.set(None); + } + } +} + +/// Provides the backdrop context at the application root. Call once, before +/// any component that may request a backdrop; mount [`AtBackdropHost`] inside +/// the app's positioned root container to actually render it. +pub fn use_provide_backdrop() -> AtBackdropContext { + let dismiss = use_signal(|| None); + let ctx = AtBackdropContext { dismiss }; + provide_context(ctx); + ctx +} + +/// The backdrop requester handle, or `None` when the app has not wired +/// [`use_provide_backdrop`] (callers degrade to their local dismissal). +#[must_use] +pub fn use_backdrop() -> Option { + try_consume_context::() +} + +/// Renders the active backdrop as a transparent, viewport-spanning +/// click-catcher. Mount once, inside the app's `position: relative` root +/// container (typically after the router, so the backdrop paints above the +/// regular content and below the requesting popup's higher `z-index`). +/// +/// # Touch target +/// +/// The backdrop is itself the (viewport-sized) target; the 44 × 44 px minimum +/// (WCAG 2.5.8) is trivially met while it is shown. +#[component] +pub fn AtBackdropHost() -> Element { + let ctx = use_context::(); + let mut dismiss = ctx.dismiss; + let Some(cb) = *dismiss.read() else { + return rsx! {}; + }; + rsx! { + div { + style: format!( + "position: absolute; top: 0; left: 0; right: 0; bottom: 0; \ + z-index: {BACKDROP_Z_INDEX};" + ), + onclick: move |_| { + cb.call(()); + dismiss.set(None); + }, + } + } +} diff --git a/appthere-ui/src/components/ribbon/groups.rs b/appthere-ui/src/components/ribbon/groups.rs index ddde9f46..4fc19cee 100644 --- a/appthere-ui/src/components/ribbon/groups.rs +++ b/appthere-ui/src/components/ribbon/groups.rs @@ -17,6 +17,7 @@ use dioxus::prelude::*; use super::button::AtRibbonIconButton; use super::group::AtRibbonGroup; use crate::components::icons::{AtIcon, LUCIDE_MORE_HORIZONTAL}; +use crate::components::overlay::use_backdrop; use crate::responsive::{use_ribbon_cascade, GroupCollapse, GroupMetrics}; use crate::tokens; @@ -54,6 +55,31 @@ pub fn AtRibbonGroups( let cascade = use_ribbon_cascade(metrics); let mut menu_open = use_signal(|| false); + // Outside-click dismissal: while the menu is open, the app's window-level + // backdrop host (when wired — `use_provide_backdrop` + `AtBackdropHost`) + // shows a viewport-spanning click-catcher that closes it. Degrades to + // toggle-only dismissal in an app without the host. + let backdrop = use_backdrop(); + let mut set_menu_open = move |open: bool| { + menu_open.set(open); + if let Some(b) = backdrop { + if open { + b.show(Callback::new(move |()| menu_open.set(false))); + } else { + b.hide(); + } + } + }; + // Never leave a stale backdrop if this strip unmounts with the menu open + // (e.g. a ribbon tab switch). + use_drop(move || { + if let Some(b) = backdrop { + if *menu_open.peek() { + b.hide(); + } + } + }); + // Partition into in-strip groups (with their state) and overflowed groups. let overflowed: Vec<&RibbonGroupSpec> = groups .iter() @@ -66,7 +92,7 @@ pub fn AtRibbonGroups( // stale-open menu — its "More" button is gone, so it could never be toggled // shut. Reconcile in-render; it converges in one frame. if !cascade.overflow && *menu_open.peek() { - menu_open.set(false); + set_menu_open(false); } rsx! { @@ -96,7 +122,7 @@ pub fn AtRibbonGroups( is_disabled: false, on_click: move |_| { let open = menu_open(); - menu_open.set(!open); + set_menu_open(!open); }, AtIcon { path_d: LUCIDE_MORE_HORIZONTAL.to_string() } } @@ -106,15 +132,11 @@ pub fn AtRibbonGroups( // anchored to the More button. `position: absolute` (block-level) // is confirmed working in the current Blitz stack (see CLAUDE.md). // - // Dismissal: the More button toggles it shut, and a resize that - // removes the overflow auto-closes it (above). True - // outside-click-to-dismiss needs a full-viewport backdrop, which - // must be hosted at a positioned window-level ancestor (like the - // editor-root overlay the spell panel uses) — `position: fixed` - // collapses to `absolute` in stylo_taffy, so a backdrop rendered - // here would only cover this small wrapper. TODO(ribbon): host the - // overflow menu in a shared window-level overlay so a backdrop can - // span the viewport. + // Dismissal: outside-click via the window-level backdrop host + // (raised in `set_menu_open`; the menu's z-index 41 sits above + // `BACKDROP_Z_INDEX` 40 so its own controls stay clickable), + // plus the More-button toggle and the auto-close on a widen + // that removes the overflow. div { style: format!( "position: absolute; bottom: 100%; right: 0; z-index: 41; \ diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index 87d4a654..b2285a3b 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -48,10 +48,11 @@ pub use components::ribbon::{ RibbonTabDesc, RibbonTabIndex, }; pub use components::{ - next_zoom, AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, - AtDocumentTabProps, AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, - AtStatusBarProps, AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, - PanelPosture, Platform, RecentDocument, + next_zoom, use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost, + AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, + AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps, + AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, + RecentDocument, BACKDROP_Z_INDEX, }; pub use responsive::{ estimate_group_metrics, group_layout, page_fits, required_page_width, resolve_cascade, diff --git a/loki-presentation/src/app.rs b/loki-presentation/src/app.rs index 7e3f930b..eef4aca6 100644 --- a/loki-presentation/src/app.rs +++ b/loki-presentation/src/app.rs @@ -2,7 +2,10 @@ //! Root application component for loki-presentation. -use appthere_ui::{AtThemeContext, AtViewportWidthSensor, use_provide_responsive, use_safe_area}; +use appthere_ui::{ + AtBackdropHost, AtThemeContext, AtViewportWidthSensor, use_provide_backdrop, + use_provide_responsive, use_safe_area, +}; use dioxus::prelude::*; use crate::recent_documents::RecentDocuments; @@ -73,6 +76,10 @@ pub fn App() -> Element { // AtHomeTab's breakpoint tracks the real window instead of a fallback. use_provide_responsive(); + // Window-level dismiss-backdrop context (kept identical to loki-text for + // suite consistency; used by ribbon overflow menus and anchored popups). + use_provide_backdrop(); + // Spell-check service (bundled English; dictionary cache shared across the // suite). Provided into context so this app's editor can query spelling and // offer corrections. Visible in-shape squiggles are a follow-up in the @@ -129,7 +136,7 @@ pub fn App() -> Element { // high-density displays). Kept identical to loki-text for suite // consistency. style: format!( - "margin: 0; \ + "margin: 0; position: relative; \ padding: {top}px {right}px {bottom}px {left}px; \ width: 100vw; height: 100vh; \ overflow: hidden; box-sizing: border-box; \ @@ -148,6 +155,9 @@ pub fn App() -> Element { AtViewportWidthSensor {} Router:: {} + + // Window-level dismiss backdrop; renders nothing while unused. + AtBackdropHost {} } } } diff --git a/loki-spreadsheet/src/app.rs b/loki-spreadsheet/src/app.rs index f72fca05..e2479946 100644 --- a/loki-spreadsheet/src/app.rs +++ b/loki-spreadsheet/src/app.rs @@ -2,7 +2,10 @@ //! Root application component for loki-spreadsheet. -use appthere_ui::{AtThemeContext, AtViewportWidthSensor, use_provide_responsive, use_safe_area}; +use appthere_ui::{ + AtBackdropHost, AtThemeContext, AtViewportWidthSensor, use_provide_backdrop, + use_provide_responsive, use_safe_area, +}; use dioxus::prelude::*; use crate::recent_documents::RecentDocuments; @@ -73,6 +76,10 @@ pub fn App() -> Element { // AtHomeTab's breakpoint tracks the real window instead of a fallback. use_provide_responsive(); + // Window-level dismiss-backdrop context (kept identical to loki-text for + // suite consistency; used by ribbon overflow menus and anchored popups). + use_provide_backdrop(); + // Spell-check service (bundled English; dictionary cache shared across the // suite). Provided into context so this app's editor can query spelling and // offer corrections. Visible in-cell squiggles are a follow-up in the @@ -129,7 +136,7 @@ pub fn App() -> Element { // high-density displays). Kept identical to loki-text for suite // consistency. style: format!( - "margin: 0; \ + "margin: 0; position: relative; \ padding: {top}px {right}px {bottom}px {left}px; \ width: 100vw; height: 100vh; \ overflow: hidden; box-sizing: border-box; \ @@ -148,6 +155,9 @@ pub fn App() -> Element { AtViewportWidthSensor {} Router:: {} + + // Window-level dismiss backdrop; renders nothing while unused. + AtBackdropHost {} } } } diff --git a/loki-text/src/app.rs b/loki-text/src/app.rs index 6002e1cf..53766514 100644 --- a/loki-text/src/app.rs +++ b/loki-text/src/app.rs @@ -18,7 +18,9 @@ //! window vertically scrollable. use appthere_ui::tokens; -use appthere_ui::{AtThemeContext, use_provide_responsive, use_safe_area}; +use appthere_ui::{ + AtBackdropHost, AtThemeContext, use_provide_backdrop, use_provide_responsive, use_safe_area, +}; use dioxus::prelude::*; use crate::recent_documents::RecentDocuments; @@ -100,6 +102,11 @@ pub fn App() -> Element { // derived breakpoint via `appthere_ui::use_breakpoint`. use_provide_responsive(); + // Window-level dismiss-backdrop context (outside-click-to-close for the + // ribbon overflow menu and future anchored popups); AtBackdropHost below + // renders the active backdrop inside this positioned root. + use_provide_backdrop(); + // Spell-check service — starts on the bundled English dictionary so checking // works offline. Provided into context for any component (e.g. a future // language picker), and installed into the editor's ambient layout state so @@ -172,8 +179,10 @@ pub fn App() -> Element { // areas (notification bar at top, gesture strip at bottom) are filled // with the tab-bar chrome color instead of the default white. // COMPAT(dioxus-native): box-sizing: border-box confirmed working. + // position: relative hosts the window-level dismiss backdrop + // (AtBackdropHost) and any future root-anchored overlay. style: format!( - "margin: 0; \ + "margin: 0; position: relative; \ padding: {top}px {right}px {bottom}px {left}px; \ width: 100vw; height: 100vh; \ overflow: hidden; box-sizing: border-box; \ @@ -195,6 +204,10 @@ pub fn App() -> Element { SafeAreaResizeSensor {} Router:: {} + + // Window-level dismiss backdrop (e.g. the ribbon overflow menu's + // outside-click-to-close). Renders nothing while no popup is open. + AtBackdropHost {} } } } From 3520a2bc12f87a97f7a403f84654442cf725c35b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:48:59 +0000 Subject: [PATCH 12/12] Sync deferred-features docs, ratchet ceilings, fix clippy 1.97 nits - deferred-features plan/audit: mark the tails completed this pass (4a.1 More-menu dismissal, 4a.2 struck-pilcrow render + ODF pilcrow round-trip, 4b.5 rotated caret/nav, 4c.5 grid zoom + F7a width plumbing, 5.9 sdt verification, 5.11 reflow open, 6.3 Option B filter, 6.7 props eq, loro-bridge border colors) and record two stale entries verified as already built (typed SaveError; inline/cell w:sdt were never dropped). - fidelity-status: Hyperlinks, Text Direction (rotated cells), and Track Changes rows updated to their completed state. - file-ceiling baseline ratcheted (scene.rs 727->613, spreadsheet editor_inner 1047->1014, resolve.rs 865->858, mapper files); CLAUDE.md worst-offenders table refreshed from it. - clippy 1.97 nits: doc_markdown/doc_lazy_continuation in new docs, needless_borrow in loki-vello's visual_conformance test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017mBbeUqSR6utoozCfLoswr --- CLAUDE.md | 23 +++++++------- docs/deferred-features-audit-2026-07-04.md | 8 ++--- docs/deferred-features-plan-2026-07-04.md | 20 ++++++------ docs/fidelity-status.md | 6 ++-- loki-odf/src/odt/write/revisions.rs | 4 +-- loki-renderer/src/render_layout.rs | 33 +++++++++----------- loki-text/src/routes/editor/editor_canvas.rs | 22 +++++-------- loki-vello/src/scene_cursor.rs | 11 ++++--- loki-vello/tests/visual_conformance.rs | 4 +-- scripts/file-ceiling-baseline.txt | 10 +++--- 10 files changed, 66 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dd85ec09..d4e8b611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,17 +205,18 @@ baseline). Three techniques (the third added 2026-07-08): | File | Current lines | Priority | |---|---|---| -| `loki-layout/src/para.rs` | 1626 | High | -| `loki-layout/src/flow.rs` | 1362 | High | -| `loki-ooxml/src/docx/write/document.rs` | 1073 | High | -| `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1047 | High | -| `loki-ooxml/src/docx/reader/document.rs` | 1004 | High | -| `loki-odf/src/odt/reader/styles.rs` | 892 | Med | -| `loki-layout/src/resolve.rs` | 865 | Med | -| … 22 more — see `scripts/file-ceiling-baseline.txt` (29 entries after the 2026-07-08 pass) | | | - -*(Sizes above are from `scripts/file-ceiling-baseline.txt`, refreshed 2026-07-08; -the earlier numbers were stale — several files grew since first baselined.)* +| `loki-layout/src/para.rs` | 1447 | High | +| `loki-layout/src/flow.rs` | 1202 | High | +| `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1014 | High | +| `loki-ooxml/src/docx/write/document.rs` | 902 | High | +| `loki-layout/src/resolve.rs` | 858 | Med | +| `loki-text/src/routes/editor/editor_inner.rs` | 800 | Med | +| `loki-odf/src/odt/reader/styles.rs` | 764 | Med | +| … 22 more — see `scripts/file-ceiling-baseline.txt` (29 entries) | | | + +*(Sizes above are from `scripts/file-ceiling-baseline.txt`, refreshed 2026-07-11; +the deferred-features tail pass also ratcheted `loki-vello/src/scene.rs` +727 → 613 by splitting caret painting into `scene_cursor.rs`.)* (`odt/mapper/document.rs` (1094 lines) was split into the `odt/mapper/document/` directory on 2026-06-26 — each module is now under the ceiling.) diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 6ce4b82c..4ff9dedb 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -43,21 +43,21 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | Topic | file:line(s) | Defers what | |---|---|---| | `3b-3` (partial) | `navigation.rs` (many) | ~~Left/right at page edges + `page_index` recompute after split/merge~~ **fixed 2026-07-05** (plan 4b.1: cross-page Left/Right + `page_locate::recompute_page_index` after every mutation/move). Remaining: double-Enter list-exit heuristic (`clear_para_props`) | -| `loro-bridge` | `loro_bridge/decode.rs` (borders), `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). Remaining: non-Rgb *border* colors (format migration), `Cite` metadata, structural-table CRDT semantics | +| `loro-bridge` | `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). ~~Non-Rgb *border* colors~~ **fixed 2026-07-11** (v2 border codec, color field last so the total color codec fits; v1 strings still decode). Remaining: `Cite` metadata, structural-table CRDT semantics | | `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | ~~Compact the CRDT oplog~~ **fixed 2026-07-04** (plan Phase 1.5): `loro_bridge::compact` + save-point wiring in loki-text; bench asserts the flattened curve. On-device validation pending (BM-14) | | `omml` | `docx/omml/mod.rs:20` | OMML↔MathML for delimiters, n-ary, matrices, accents | -| `link-click` | `resolve.rs:689`, `items.rs:125`, `para.rs:203`, `scene.rs:519` | Interactive hyperlink hit-testing (only a visual hint today) | +| `link-click` | — | ~~Interactive hyperlink hit-testing~~ **fixed 2026-07-09/11** (5.11: hint + hit-test + Ctrl/Cmd+click open in paginated *and* reflow modes; all TODO notes retired) | | `shadow` | `para_emit.rs:187`, `para.rs:196`, `scene.rs:93` | True soft text shadow (Vello blur) — hard grey offset copy today | | `partial-render` | `scene.rs:148`, `editor_pointer.rs:139` | Viewport clipping / direct `node.scroll_offset` | | `inline-image-flow` | `resolve.rs:706`, `flow_para.rs:213` | Parley inline image boxes (images prepended block-level today) | | `floating-image` | `resolve.rs:705` | Detect "floating" class for inline images (gap #12) | | `underline-style` / `strikethrough-style` | `para.rs:164,170` | Double/Dotted/Dash/Wave underline; double strikethrough (all render Single) | | `super-sub` (partial) | `para.rs:177,870` | Native Parley `BaselineShift` (effect already applied — see S-3) | -| `split-optimise` | `para.rs:409` | Y-range item filter to avoid GPU clipping (Option B; Option A shipped) | +| `split-optimise` | — | ~~Y-range item filter (Option B)~~ **fixed 2026-07-11** (`ParagraphLayout::items_in_y_range`; each split fragment carries only its own y-range's items, clip retained) | | `tab-default` | `para.rs:648` | Honour `DocumentSettings.default_tab_stop_pt` (hardcoded 36pt) | | `spell-baseline` | `para.rs:1619` | Tighten squiggle to run underline offset | | `list-picture-bullet` | `para.rs:1795` | Picture bullets (falls back to `•`) | -| `rotated-cell-editing` | `flow.rs:1676` | Editing data for rotated table cells (read-only today) | +| `rotated-cell-editing` | — | ~~Editing data for rotated table cells~~ **fixed 2026-07-08/11** (hit-test + tilted caret paint + rotation-aware arrow navigation; 4b.5 complete) | | `pdf-rotate` | `pdf/src/page.rs:83` | Rotation transform in PDF export | | `odf-master-page` | `odf/reader/styles.rs:200` | ODF master-page transitions | | `odt-fidelity` | `editor_load.rs:84,88` | Tracked DOCX/ODT import gaps | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 9e3a1c99..9ae18919 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -46,7 +46,7 @@ closest thing to data loss in the suite. These are all code-confirmed in audit | 1.1 | §6 | ✅ **Done 2026-07-04** — structured tab-stop codec (`loro_bridge/decode.rs`) + reader; `bridge_tab_stops_roundtrip`. | S–M | | 1.2 | §6 | ✅ **Done 2026-07-04** — total `DocumentColor` codec (`loro_bridge/color_codec.rs`, Rgb/Cmyk/Theme/Transparent) + reader; `bridge_para_background_color_roundtrip`. | S | | 1.3 | §6 | ✅ **Done 2026-07-04** — native mappings for `BulletList`/`OrderedList`/`BlockQuote`/`Div`/`Figure` (`loro_bridge/containers.rs`, table.rs pattern); `loro_bridge_container_tests.rs`. `DefinitionList` + inline fields/math stay opaque. | M | -| 1.4 | §2 | ✅ **Done 2026-07-04** — char colors use the total `DocumentColor` codec (Theme/Cmyk/Transparent survive, mark + block-map paths); comment/bookmark anchors preserved as `OBJECT_REPLACEMENT_CHAR` snapshot marks; `Quoted` quote-type and `Span` attrs carried as range marks with recursive child writes; `loro_bridge_inline_tail_tests.rs`. Remaining TODO(loro-bridge): non-Rgb *border* colors (colon-format migration), `Cite` metadata, structural-table CRDT semantics. | M | +| 1.4 | §2 | ✅ **Done 2026-07-04** — char colors use the total `DocumentColor` codec (Theme/Cmyk/Transparent survive, mark + block-map paths); comment/bookmark anchors preserved as `OBJECT_REPLACEMENT_CHAR` snapshot marks; `Quoted` quote-type and `Span` attrs carried as range marks with recursive child writes; `loro_bridge_inline_tail_tests.rs`. Remaining TODO(loro-bridge): ~~non-Rgb *border* colors~~ (✅ **done 2026-07-11** — v2 border codec `Style:width:spacing:color` with the color last so the total color codec's colon-carrying encodings fit; Theme/Cmyk border colors round-trip, v1 strings still decode), `Cite` metadata, structural-table CRDT semantics. | M | | 1.5 | §2, §5 | ✅ **Done 2026-07-04** — `loro_bridge::compact` (`compact_in_place` / `compact_history`) wired at the save point in loki-text (`editor_compact.rs`; ribbon Save unified into the Ctrl+S handler). Bench acceptance passed: 5 000 keystrokes = 19 208 ops uncompacted → 1 op compacted, asserted in `leak_loro_history`. On-device long-session validation still pending (BM-14). | M | **Exit criteria**: `metadata_round_trip.rs`-style tests pass for 1.1–1.3; the @@ -106,8 +106,8 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | Task | Source | Detail | Effort | |---|---|---|---| -| 4a.1 | Spec 04 M3 | Width-driven ribbon collapse cascade: condensed variant, overflow menu, per-group priority, hysteresis. **Collapse engine ✅ Started 2026-07-06** — the pure, width-driven cascade core lives in `appthere-ui/src/responsive/ribbon_collapse.rs` (`resolve_cascade(metrics, available_px, prev_level) -> RibbonCascade`): each group declares a `GroupMetrics { priority, full_px, condensed_px }`, and the engine degrades gracefully by **condensing all groups (lowest priority first) before overflowing any** into the "More" menu, falling back to horizontal scroll only when even the fully-overflowed strip can't fit (§7 steps 1–4). Per-group result is `GroupCollapse::{Full,Condensed,Overflow}`. **Hysteretic** like Spec 03's `page_fit`: it collapses a step the instant the strip overflows but re-expands only when the looser layout clears the width by `RIBBON_COLLAPSE_HYSTERESIS_PX` (32 px), so a window dragged across a fit threshold doesn't thrash; resolution is idempotent at a fixed width (the resolved `level` feeds back in as `prev_level`). New tokens `RIBBON_OVERFLOW_BUTTON_PX` (44) + `RIBBON_COLLAPSE_HYSTERESIS_PX`. 10 unit tests (`ribbon_collapse_tests.rs`) cover full-fit, priority-ordered condense/overflow, the scroll floor, hysteresis dead-band, idempotence, tie-breaking, and the empty ribbon. **Reactive binding + condensed representation ✅ 2026-07-06** — (a) `use_ribbon_cascade(metrics) -> RibbonCascade` binds the engine to the live `AtResponsiveContext` viewport width, holding the resolved `level` in a hook-local signal for hysteresis across resizes; it is **resilient** like `use_breakpoint` (no responsive context ⇒ unbounded width ⇒ every group stays Full, so Presentation/Spreadsheet get a sane full-chrome ribbon). (b) The condensed group representation (§7 step 2) is a pure, tested decision — `group_layout(collapse, has_label) -> GroupLayout { rendered, pad_px, gap_px, show_label }` — that `AtRibbonGroup` now applies via a new defaulted `collapse: GroupCollapse` prop: **Full** keeps the label + roomy padding, **Condensed** drops the label and tightens padding/gap (never the buttons, so 44 px touch targets survive), **Overflow** renders nothing in the strip (the "More" menu hosts it). The prop defaults to Full, so all existing call sites are unchanged. +3 `group_layout` tests (13 total). **Collapse-aware container + overflow menu + first migration ✅ 2026-07-06** — a new shared `AtRibbonGroups` component (`components/ribbon/groups.rs`) is the framework piece that owns the cascade: a tab hands it a `Vec`, it runs `use_ribbon_cascade` **once** for the strip, renders each group at its resolved state, and moves overflowed groups into a trailing **"More" menu** (an upward `position: absolute` dropdown — confirmed working in Blitz — rendering the overflowed groups in Full form). Group widths are **declared, not Blitz-measured** (per-element measurement is unreliable): the pure `estimate_group_metrics(priority, buttons, has_label)` derives full/condensed widths from the touch-button count (+2 tests). The **Layout tab** is migrated as the first real consumer — its four groups (Orientation/Margins/Size/Columns, priority descending so Columns overflows first) now flow through `AtRibbonGroups`, driven live off the editor's measured viewport width. New `LUCIDE_MORE_HORIZONTAL` icon + `ribbon-overflow-aria` string. **All tabs migrated + R-13e ✅ 2026-07-06** — the **Write**, **Insert**, **Publish**, and **Table** tabs now build `RibbonGroupSpec` lists through `AtRibbonGroups` too, so every tab collapses/overflows by priority (the group-helper fns in `editor_ribbon_format`/`editor_ribbon_color` return `RibbonGroupSpec` with a threaded priority; each tab declares a descending-importance scheme — core editing controls stay full longest, wide colour-swatch groups overflow first). Publish's ribbon content split to `editor_ribbon_publish.rs` (dropping the baselined `editor_publish.rs` 315 → 236, off the ceiling backlog) and the Table tab's `delete_current_table` to `editor_ribbon_table_delete.rs`. **R-13e ✅** — `AtRibbonGroup` exposes its resolved `GroupCollapse` to descendants as a *signal* context, and `AtRibbonSelect` reads it to shrink (`RIBBON_SELECT_WIDTH_PX` → `RIBBON_SELECT_WIDTH_CONDENSED_PX`) when its group is condensed; a signal, not a plain value, so prop-memoised selects still re-size reactively on resize. The overflow menu also auto-closes when a widen removes the overflow. **Remaining tail:** true outside-click-to-dismiss for the More menu — it needs the menu hosted in a shared **window-level overlay** (like the editor-root overlay the spell panel uses) so a full-viewport backdrop can span the viewport; a backdrop rendered in-place can't (`position: fixed` collapses to `absolute` in stylo_taffy). Toggle-close + auto-close-on-widen ship today; `TODO(ribbon)` marks the overlay-host follow-up. **M3 is otherwise complete.** | L | -| 4a.2 | Spec 04 M5 | Layout/References/Review ribbon tabs + ~~`selected_object` contextual-tab signal~~ (✅ **Done 2026-07-06** — new `editing/selected_object.rs`: pure `selected_object(&CursorState) -> SelectedObject` (Table when the focus path descends through a cell). `editor_ribbon_table::use_ribbon_tabs` derives it via `use_memo`, appends an amber **Table** contextual tab while the caret is in a table, and resets the active tab to Write when the caret leaves (so no orphaned selection). The tab's **Delete Table** action is backed by a new `delete_block` model mutation (`loro_mutation/block_edit.rs`, split from `block.rs` to hold the ceiling), disabled when the table is the document's only block; the caret re-homes to the neighbouring block. New `LUCIDE_TRASH_2` icon. Tests: `selected_object` (5), `ribbon_tabs` (3), `delete_block` (3). **Structural row/column ops ✅ Done 2026-07-06** — the Table tab's "Rows & Columns" group inserts/deletes rows and columns via new `loro_mutation/table_ops.rs` mutations (`insert_table_row`/`delete_table_row`/`insert_table_column`/`delete_table_column` + `table_grid_dims`): they rewrite the serde skeleton **and** minimally patch the flat `KEY_TABLE_CELLS` list (surviving cells keep their live CRDT text), scoped to simple grids (no spans/head/foot — else typed `UnsupportedTableStructure`). The caret re-homes to its shifted cell (pure `caret_flat_after` in `editor_ribbon_table_ops.rs`); delete buttons disable at 1 row/col; 4 app-custom table-op glyphs. Tests: 12 round-trip (`table_structural_ops.rs`) + 4 caret-math. **Insert above/below + left/right ✅ Done 2026-07-06** — the insert ops are now split into four caret-relative variants (`TableOp::{InsertRowAbove,InsertRowBelow,InsertColumnLeft,InsertColumnRight}`): above/left insert at the caret's own row/col index, below/right at index+1, and `caret_flat_after` re-homes the caret accordingly (above shifts it down a row, left shifts it one column right). 2 app-custom glyphs (`AT_TABLE_ROW_INSERT_ABOVE`, `AT_TABLE_COL_INSERT_LEFT`); 2 new caret-math tests. **Paragraph alignment ✅ Done 2026-07-06** — the Write tab gains an Alignment group (left/centre/right/justify) driven by new path-aware `set_block_alignment_at`/`get_block_alignment_at` (`loro_mutation/align.rs`), so alignment works in table cells and note bodies too. `align.rs` handles every paragraph shape: a plain `para` is upgraded to `styled_para` so props survive the read path, `styled_para` uses `para_props`, and a `heading` uses its OOXML `jc` attr. The 6 inline-format buttons + the new alignment buttons were extracted to `editor_ribbon_format.rs` (dropping `editor_ribbon.rs` 300 → 225). 5 alignment tests (`block_alignment.rs`). **Font-size grow/shrink ✅ Done 2026-07-06** — a Write-tab Font group steps the selection's `MARK_FONT_SIZE_PT` up/down a fixed size ladder (`editor_font_size.rs`, path-aware via `mark_text_at`/`resolve_format_ranges`, so it works in cells and across multi-paragraph selections); pure ladder + end-to-end mark tests (5). 2 app-custom "A±" glyphs. **Text colour ✅ Done 2026-07-06** — a Write-tab Font-colour group with an Automatic (clear) swatch + 6 preset colours applies `MARK_COLOR` (the `#RRGGBB` hex is the codec's own RGB form, so no extra encoding) across the selection, path-aware (`editor_text_color.rs`); coloured-square swatches render inside `AtRibbonIconButton`. 3 tests incl. round-trip into `CharProps.color`. **Highlight colour ✅ Done 2026-07-06** — a Write-tab Highlight group (None clear-swatch + 5 preset colours: Yellow/Green/Cyan/Magenta/Red) writes `MARK_HIGHLIGHT_COLOR` as a `HighlightColor` variant name (or `Null` to clear) across the selection, path-aware (`editor_highlight_color.rs`); the swatch fills are the RGB each variant renders as (`resolve::map_highlight_color`). The duplicated font-colour/highlight swatch UI was unified into a generic `swatch_group(palette, apply_fn)` in `editor_ribbon_color.rs`. 3 tests incl. round-trip into `CharProps.highlight_color`. **Layout tab ✅ Started 2026-07-06** — a new non-contextual **Layout** tab (Write/Insert/**Layout**/Publish; contextual Table now at index 4) with a page-**orientation** toggle backed by new `set_document_orientation`/`document_is_landscape` model mutations (`loro_mutation/page.rs`): the layout engine reads the effective `page_size` directly, so the toggle swaps width↔height on every section + sets the orientation flag, and `apply_mutation_and_relayout` re-flows at the new size (verified the pipeline updates `page_width_px`). 4 round-trip tests (`page_orientation.rs`) + updated ribbon-tab tests; 2 app-custom page-rect glyphs. **Margin presets ✅ Done 2026-07-06** — the Layout tab's Margins group applies Normal/Narrow/Wide via new `set_document_margins`/`document_margins` (`page.rs`; sets top/bottom/left/right on every section, leaves header/footer/gutter); the active preset highlights via the pure `margin_matches` (½-pt tolerance). 3 model tests (`page_margins.rs`) + 5 preset-match tests; 3 app-custom page-inset glyphs (tooltip-disambiguated). **Page size ✅ Done 2026-07-06** — the Layout tab's Size group applies A4/US-Letter via new `set_document_page_size`/`document_page_size` (`page.rs`), **preserving each section's orientation** (choosing A4 while landscape gives A4 landscape); the active size highlights via the orientation-independent `page_size_matches`. 3 model tests (`page_size.rs`, incl. orientation preservation) + 4 preset-match tests. **Columns ✅ Done 2026-07-06** — the Layout tab's Columns group applies one/two/three via new `set_document_columns`/`document_column_count` (`page.rs`; count clamped ≥1, a new columns map gets a default 0.5in gap + no separator, an existing one keeps its gap/separator when the count changes); 5 model tests (`page_columns.rs`). The Layout tab is now Orientation + Margins + Size + Columns. **References tab ✅ 2026-07-07** — a new non-contextual **References** tab (Write/Insert/Layout/**References**/Publish; contextual Table now at index 5) generating a **table of contents** from the document's headings. The pure, format-neutral builders live in `loki-doc-model` (`content/toc.rs`): `heading_outline(sections, max_depth)` collects `(level, label)` for every `Block::Heading` within depth (default 3), `inline_plain_text` flattens a heading's inlines to its label, and `build_toc` produces a `TableOfContentsBlock` whose cached `body` is level-indented entry paragraphs (page numbers are omitted — they need the paginated layout — matching a freshly-inserted Word TOC field). The CRDT mutations (`loro_mutation/toc.rs`): `insert_table_of_contents` builds a TOC from the live headings and inserts it after the caret's block; `refresh_table_of_contents` rebuilds an existing TOC's snapshot in place (the "update field" action, a guarded no-op on a non-TOC block); `first_toc_block_index` finds the TOC to enable/refresh. A `Block::TableOfContents` round-trips through the bridge as an opaque JSON snapshot, so both are undoable. **Root-cause layout fix:** `loki-layout`'s `flow_block` silently dropped `TableOfContents`/`Index` blocks via its `_ => {}` catch-all, so an inserted *or imported* TOC was invisible — now both flow their cached body (a new `flow_blocks` helper the merged Div/Figure arms also use, so `flow.rs` held at its 1953 baseline). The **References** tab (`editor_ribbon_references.rs`) offers **Insert** + **Update** (Update disabled with no TOC); 2 app-custom glyphs (`AT_TOC_INSERT`/`AT_TOC_UPDATE`); the tab-index shift updated `ribbon_tabs`/`CONTEXTUAL_TAB_INDEX`/the tab-content match. 12 model tests (`content::toc` 7, `loro_mutation::toc` 5) + 1 layout regression test + updated ribbon-tab tests; 5 i18n keys. **Review tab — model foundation ✅ 2026-07-07** — the first slice of track changes. A **live** tracked-change mark (distinct from the dormant, round-trip-only opaque `TrackedChange`): `style/props/revision.rs` defines `RevisionKind { Insertion, Deletion }` + `RevisionMark { kind, author, date, id }` with a `US`-delimited packed string codec (no serde/chrono on the hot path). It rides a run as `CharProps::revision` (run-level, **not** style-inherited — omitted from `merged_with_parent`), so a tracked run is an `Inline::StyledRun` whose `direct_props.revision` is set — the same marks mechanism as highlight colour, keeping the paragraph live-editable. Wired through the CRDT bridge (`MARK_REVISION` in `CHAR_MARK_KEYS` + write/read), so a tracked run survives an edit cycle (round-trip tested). Pure, tested accept/reject transforms (`content/revision_ops.rs`): `accept_revisions`/`reject_revisions` over the whole block tree (recurse lists/tables/notes/quotes/TOC) — accept keeps insertions (clears the mark) and removes deletions, reject does the inverse; plus `has_revisions` and `Document::{accept_all_revisions,reject_all_revisions,has_tracked_changes}`. A `DocumentSettings.track_changes` flag (serde-default, back-compat) records whether new edits are tracked. 16 tests (revision codec 4, accept/reject 6, char_props 2 relocated, bridge round-trip + serde back-compat 2, +2). `char_props.rs` inline tests extracted to hold the ceiling. **Review tab — rendering ✅ 2026-07-07** — tracked changes are now visible. `loki-layout/src/revision_style.rs` colours a tracked run by its author (a deterministic 6-colour palette hashed off the author name) and decorates it by kind — insertion **underlined**, deletion **struck through** — applied to the run's `StyleSpan` in `char_props_to_style_span` (so it flows through the existing Parley decoration + brush path; the renderer needs no change). Because colour/decoration are derived from the mark at layout time, accepting/rejecting (which clears the mark) reverts the run with nothing stored to undo. 4 unit tests (`revision_style`: insertion underline, deletion strikethrough, no-op, author-colour determinism) + 1 layout regression (`tracked_runs_render_insertion_underline_and_deletion_strikethrough`); baselined `resolve.rs`/`lib.rs` held at baseline via a comment tightening + a redundant blank-line drop. **Review tab — editor records tracked insertions ✅ 2026-07-07** — typing now records tracked changes when the flag is on. New CRDT mutation `insert_text_tracked_at` (path-aware, so it works in cells/notes) inserts text **and** marks the range with `MARK_REVISION`. Crucially, `MARK_REVISION` is reconfigured `expand: None` in `configure_text_style` (unlike the `After` of formatting marks), so a revision covers exactly the changed range and never bleeds onto adjacent — possibly untracked — typing; consecutive tracked inserts by one author coalesce because their encoded mark value is identical. The editor's `handle_character_key` reads `Document::insertion_revision()` (a pure helper: `Some(Insertion by meta.creator)` when `DocumentSettings.track_changes` is on, else `None`) and routes typing through the tracked mutation accordingly — so a document with track-changes on shows typed text underlined in the author colour (rendering already wired). 3 tests: the pure `insertion_revision` decision, a CRDT round-trip proving the tracked insert marks only its range and does not leak onto a following plain insert, plus the existing bridge round-trip. **Review tab + durable toggle ✅ 2026-07-07** — track changes is now reachable and usable. **Root-cause fix:** `DocumentSettings` now round-trips through the Loro CRDT (`loro_bridge/settings.rs`, a JSON snapshot under `KEY_SETTINGS` like metadata/comments) — previously the bridge dropped settings, so the `track_changes` flag was wiped on the first relayout (which re-derives the doc from the CRDT). `set_track_changes(loro, on)` / `document_track_changes(loro)` flip and read the flag durably (undoable, survives relayout + save). A new non-contextual **Review** tab (Write/Insert/Layout/References/**Review**/Publish; contextual Table now at index 6) hosts a **Track Changes** toggle whose active state reflects the flag and whose click routes through `set_track_changes` + relayout. So the whole pipeline works end to end: toggle on → typed text records as a tracked insertion (`insert_text_tracked_at`) → renders underlined in the author colour. 4 tests (settings bridge set/get + preserve-others; full-document settings round-trip; +updated ribbon-tab tests); 1 app glyph (`AT_TRACK_CHANGES`); 3 i18n keys. **Review tab — accept/reject all ✅ 2026-07-07** — the review loop is closed: changes can be accepted or rejected. New CRDT mutation `accept_reject_all_revisions(loro, accept)` applies the accept/reject semantics **surgically** to the live Loro text (so it's one undoable step and the editor keeps its handle): it walks each top-level block's `to_delta()` for `MARK_REVISION` spans and, back-to-front, either clears the mark (`mark_utf8(range, …, Null)`) for a kept run (accepted insertion / rejected deletion) or deletes the text for a removed run — the CRDT analogue of the pure `revision_ops` transforms, returning the count resolved. The Review tab gains a **Changes** group with **Accept all** / **Reject all** buttons (disabled when `Document::has_tracked_changes()` is false, so they grey out once the document is clean). 4 CRDT tests (accept keeps-ins/removes-del + clears marks, reject inverse, no-op when clean, multi-block) + 2 app glyphs (`AT_CHANGE_ACCEPT`/`AT_CHANGE_REJECT`) + 3 i18n keys. *(Scope: top-level block text; revisions nested in table cells / note bodies are `TODO(review-nested)`.)* **Review tab — tracked deletions ✅ 2026-07-07** — track-changes recording is now complete: Backspace/Delete strike text through instead of removing it. The pure `delete_action(existing, tracking)` (Word semantics: tracking off ⇒ hard delete; on ⇒ hard-delete the author's own insertion, skip an already-struck deletion, else mark struck) drives the CRDT mutation `tracked_grapheme_delete`, which reads the target grapheme's `MARK_REVISION` (via `get_mark_at_path`) and applies the decision — deleting the text, marking it a deletion, or no-op — returning the [`DeleteAction`] so the editor places the caret (Backspace lands before the grapheme in every case; forward-Delete keeps the caret on a hard delete but steps past a struck grapheme). `Document::deletion_revision()` supplies the author-attributed deletion mark (its `Some`/`None` is the tracking flag). `handle_backspace_key`'s grapheme path and `handle_delete_key` both route through it. 5 tests (`delete_action` truth table, `deletion_revision` flag, + 3 CRDT: strike-normal, hard-delete-off, remove-own-insertion/skip-already-struck). *(Scope: single-grapheme Backspace/Delete; a **selection** delete under track changes still hard-deletes — `TODO(review-selection-delete)` — as does Backspace at a paragraph start, since whole-paragraph-mark deletion isn't modelled.)* **OOXML `w:ins`/`w:del` import+export ✅ 2026-07-07** — tracked changes now round-trip through DOCX, so they survive save/open in Word. **Import:** `w:ins`/`w:del` were already recognised structurally but dropped their semantics; now `parse_tracked_runs` reads the `w:author`/`w:date`/`w:id` attributes into a `DocxTrackedChange`, `parse_run` also reads `w:delText` (deleted-run text, previously discarded — a zero-cost widening of the `w:t` match), and the mapper attaches a `RevisionMark { kind, author, date, id }` to every wrapped run's `CharProps.revision` (deletions are kept, struck-through, not dropped). **Export:** `write_styled_run` wraps a run carrying `revision` in `w:ins`/`w:del` with those attributes, and its text emits as `w:delText` for a deletion (`write/revision.rs`). 1 DOCX round-trip test (insertion + deletion, author + date + text all survive). The mapper `inline.rs` test module was extracted (`inline_tests.rs`) and the two new intermediate structs moved to `model/revision.rs` to hold the three baselined files at baseline. **ODF `text:tracked-changes` import+export ✅ 2026-07-07** — tracked changes now round-trip through ODT too, so they survive save/open in LibreOffice. ODF splits a change between the document-leading `text:tracked-changes` table (one `text:changed-region` per change: `text:insertion`/`text:deletion` + `office:change-info` with `dc:creator`/`dc:date`; a deletion stows its removed `text:p` text) and body milestones keyed by `text:change-id` (an insertion is bracketed by `text:change-start`/`text:change-end`; a deletion is a single `text:change` point). **Import:** a new `reader/revisions.rs` parses the region table into `OdfChangedRegion`s (carried as an `OdfBodyChild::TrackedChanges`); `reader/inlines.rs` captures the `text:change-*` milestones as new `OdfParagraphChild` variants; the mapper (`mapper/document/inlines.rs`) resolves each milestone against the region map threaded through `OdfMappingContext` — a bracketed range becomes an insertion-marked run, a deletion point re-materialises the region's removed text as a struck run. **Export:** a new `write/revisions.rs` collects a `Changes` table during body rendering (flushed right after `` opens) and `write_styled_run` emits the milestones for a `revision`-carrying run (date written verbatim so RFC-3339 round-trips exactly). 1 ODT round-trip test (insertion + deletion, author + date + text all survive), plus a verified-conformant XML dump. Ceiling held by trimming doc comments in the at-ceiling `mapper/document/mod.rs` and by making the shared `wrap_span`/`plain_text` writers `pub(super)` rather than duplicating them. **Selection tracked deletion ✅ 2026-07-07** — deleting a **selection** under track changes now strikes it through instead of hard-deleting. New CRDT mutation `tracked_delete_selection_at` (`loro_mutation/selection.rs`): with a deletion mark it strikes each block's selected slice via `strike_range` — walking `to_delta()` and applying `delete_action` per run segment (the author's own tracked insertions are hard-deleted / un-typed, already-struck text is skipped, everything else is marked struck) — and **preserves the paragraph marks between selected blocks** (no merge, unlike the untracked path which merges); with `None` it delegates to the existing hard-deleting `delete_selection_at` (the two now share a `normalize_selection` front-end). The editor's single selection-delete choke point `delete_selection_in_doc` gained a deletion-mark parameter and a `deletion_mark(doc_state)` helper, so Backspace/Delete/Enter-over-selection **and** replace-typing all strike under tracking (Word inserts the newly typed run before the struck old text). 5 CRDT tests (`loro_tracked_selection_tests.rs`: single-block strike, own-insertion hard-delete, already-struck skip, multi-block paragraph-marks-preserved, `None` hard-delete+merge). Ceiling held by trimming `editor_keydown_text.rs` comments. **Per-change accept/reject ✅ 2026-07-08** — the Review tab's Changes group now has **Accept / Reject** buttons (circle-check / circle-x glyphs, distinct from the plain check/cross Accept-all/Reject-all) that resolve just the change **at the caret**. New model fns (`loro_mutation/revision.rs`): `accept_reject_revision_at(loro, path, byte_offset, accept)` finds the contiguous `MARK_REVISION` span at the caret (`span_at` — the span containing the offset, else the one ending at it) and resolves it via the shared `resolve_span` (delete a removed run's text, clear a kept run's mark), returning the collapsed caret offset (change start on removal, else unchanged) or `None` when the caret isn't on a change; `revision_at(loro, path, byte_offset)` is the read-only query driving the buttons' enabled state. `resolve_text` (accept/reject-all) was refactored to share `revision_spans` + `resolve_span`. The ribbon handler `accept_reject_at_caret` (`editor_ribbon_review.rs`) reads the caret from `cursor_state`, applies the mutation, relayouts, and repositions the caret; the buttons enable only when `change_at_caret` (via `revision_at`) is true, recomputing as the caret moves. 7 CRDT tests (`loro_per_change_revision_tests.rs`: accept/reject × insertion/deletion, no-op off a change, `revision_at` true/false, only-the-caret-change-resolved); 2 app glyphs + 2 i18n keys. **Nested-container resolution ✅ 2026-07-08** — accept/reject-all now reaches revisions **inside table cells and note bodies**, not just top-level paragraphs. A new `loro_mutation/text_containers.rs` (`collect_all_text_containers`) walks every section's block list and recursively descends each block's `KEY_TABLE_CELLS` / `KEY_NOTES` containers, returning every `LoroText` content container; `accept_reject_all_revisions` now sweeps that full set instead of iterating top-level block indices (which skipped tables via `TextNotFound`). This closes the latent gap where the Accept-all / Reject-all buttons enabled on a nested change (`has_tracked_changes` already recurses) but did nothing. The per-change ops were already path-aware, so they resolve nested changes too. `section_blocks_list` was exposed `pub(super)`; the collector lives in its own module to hold `nested.rs` under the ceiling. 3 CRDT tests (`loro_nested_revision_tests.rs`: accept keeps / reject removes a table-cell change, accept a footnote-body change — each asserting the doc ends clean). **Paragraph-mark tracked deletion ✅ 2026-07-08** — the last Review tail. The paragraph mark (¶) is modelled by a paragraph's `direct_char_props` (the OOXML `w:pPr/w:rPr` slot), so a tracked ¶-deletion is simply a `Deletion` on `direct_char_props.revision` — no new model type. **Round-trip:** the bridge previously dropped block-level char-props `revision`; `map_char_props_to_map` / `reconstruct_char_props_from_map` now write/read it under a new `PROP_REVISION` key, so it survives the CRDT. **Record:** new `loro_mutation/para_mark.rs::set_para_mark_deletion` marks the previous paragraph's ¶ (upgrading a plain `para`→`styled_para` like alignment does), declining non-paragraphs so the caller hard-merges; the editor's Backspace-at-start (extracted to `editor_keydown_backspace.rs` to hold the ceiling) routes a top-level paragraph start through it under tracking. **Resolve:** `accept_reject_all_revisions` now also sweeps para-marks (`para_mark::resolve_para_marks`, recursing into cells/notes) — accept removes the ¶ (successor merges via `merge_block_in_list`), reject clears; the pure transforms merge too (`content/para_mark_merge.rs`, shared by `resolve_blocks`). `has_revisions` detects a para-mark so the Accept/Reject buttons enable. 8 CRDT/model tests (`loro_para_mark_revision_tests.rs`: round-trip, accept-merges, reject-splits, pure transforms, set+upgrade, heading-declines, trailing-mark, post-merge editability). *(Deferred: rendering a struck ¶ glyph; nested-container recording — `TODO(review-para-mark-nested)`; per-change resolution of a para mark.)* **The Review tab (track changes) is now feature-complete for the editor path.** **DOCX para-mark round-trip ✅ 2026-07-08** — a tracked ¶ deletion now survives save/open in Word. **Export:** `write/revision.rs::write_mark_del` emits the self-closing ``/`` inside the paragraph mark's `w:pPr/w:rPr` (a new shared `write_rev_element` backs both it and the run-wrapping `open`). **Import:** `parse_rpr_element` recognises a `w:del`/`w:ins` child of the pPr's rPr via `reader/runs.rs::parse_mark_revision` into a new `DocxMarkRevision` on `DocxRPr`, which `map_rpr` maps to `CharProps.revision`. 1 round-trip test (`paragraph_mark_deletion_round_trips`). Ceiling held by extracting `reader/document.rs`'s inline tests to `document_tests.rs` and tightening a few doc comments in the at-ceiling `mapper/props.rs` / `model/paragraph.rs`. **Nested-container para-mark recording ✅ 2026-07-08** — Backspace-at-start inside a table cell / note body now records a tracked ¶ deletion too (previously only top-level). New path-aware `set_para_mark_deletion_at` (both it and the index-based `set_para_mark_deletion` share `write_para_mark`); the editor's `record_para_mark_deletion` computes the previous block's path via `focus.sibling_block(-1, 0)` and a `has_previous_sibling` guard (leaf index > 0), so it works at any nesting. The accept/reject sweep already recursed into cells/notes, so resolution needed no change. 1 CRDT test (`records_and_accepts_a_para_mark_inside_a_table_cell`). **Per-change para-mark resolution ✅ 2026-07-08** — the Review tab's per-change Accept/Reject buttons now resolve a paragraph-mark deletion too, not just text runs. New model fns `para_mark_at` (enable query) and `accept_reject_para_mark_at` (accept merges the successor into the caret's paragraph; reject clears the mark; returns the paragraph-end caret offset). The ribbon handler `accept_reject_at_caret` tries the text-span change first, then falls back to the para-mark; `change_at_caret` ORs in `para_mark_at`. 4 CRDT tests (`para_mark_at` detection, per-change accept-merges, reject-clears, no-op without a mark). **Remaining polish:** ODF para-mark export and struck-¶ rendering. | L | +| 4a.1 | Spec 04 M3 | Width-driven ribbon collapse cascade: condensed variant, overflow menu, per-group priority, hysteresis. **Collapse engine ✅ Started 2026-07-06** — the pure, width-driven cascade core lives in `appthere-ui/src/responsive/ribbon_collapse.rs` (`resolve_cascade(metrics, available_px, prev_level) -> RibbonCascade`): each group declares a `GroupMetrics { priority, full_px, condensed_px }`, and the engine degrades gracefully by **condensing all groups (lowest priority first) before overflowing any** into the "More" menu, falling back to horizontal scroll only when even the fully-overflowed strip can't fit (§7 steps 1–4). Per-group result is `GroupCollapse::{Full,Condensed,Overflow}`. **Hysteretic** like Spec 03's `page_fit`: it collapses a step the instant the strip overflows but re-expands only when the looser layout clears the width by `RIBBON_COLLAPSE_HYSTERESIS_PX` (32 px), so a window dragged across a fit threshold doesn't thrash; resolution is idempotent at a fixed width (the resolved `level` feeds back in as `prev_level`). New tokens `RIBBON_OVERFLOW_BUTTON_PX` (44) + `RIBBON_COLLAPSE_HYSTERESIS_PX`. 10 unit tests (`ribbon_collapse_tests.rs`) cover full-fit, priority-ordered condense/overflow, the scroll floor, hysteresis dead-band, idempotence, tie-breaking, and the empty ribbon. **Reactive binding + condensed representation ✅ 2026-07-06** — (a) `use_ribbon_cascade(metrics) -> RibbonCascade` binds the engine to the live `AtResponsiveContext` viewport width, holding the resolved `level` in a hook-local signal for hysteresis across resizes; it is **resilient** like `use_breakpoint` (no responsive context ⇒ unbounded width ⇒ every group stays Full, so Presentation/Spreadsheet get a sane full-chrome ribbon). (b) The condensed group representation (§7 step 2) is a pure, tested decision — `group_layout(collapse, has_label) -> GroupLayout { rendered, pad_px, gap_px, show_label }` — that `AtRibbonGroup` now applies via a new defaulted `collapse: GroupCollapse` prop: **Full** keeps the label + roomy padding, **Condensed** drops the label and tightens padding/gap (never the buttons, so 44 px touch targets survive), **Overflow** renders nothing in the strip (the "More" menu hosts it). The prop defaults to Full, so all existing call sites are unchanged. +3 `group_layout` tests (13 total). **Collapse-aware container + overflow menu + first migration ✅ 2026-07-06** — a new shared `AtRibbonGroups` component (`components/ribbon/groups.rs`) is the framework piece that owns the cascade: a tab hands it a `Vec`, it runs `use_ribbon_cascade` **once** for the strip, renders each group at its resolved state, and moves overflowed groups into a trailing **"More" menu** (an upward `position: absolute` dropdown — confirmed working in Blitz — rendering the overflowed groups in Full form). Group widths are **declared, not Blitz-measured** (per-element measurement is unreliable): the pure `estimate_group_metrics(priority, buttons, has_label)` derives full/condensed widths from the touch-button count (+2 tests). The **Layout tab** is migrated as the first real consumer — its four groups (Orientation/Margins/Size/Columns, priority descending so Columns overflows first) now flow through `AtRibbonGroups`, driven live off the editor's measured viewport width. New `LUCIDE_MORE_HORIZONTAL` icon + `ribbon-overflow-aria` string. **All tabs migrated + R-13e ✅ 2026-07-06** — the **Write**, **Insert**, **Publish**, and **Table** tabs now build `RibbonGroupSpec` lists through `AtRibbonGroups` too, so every tab collapses/overflows by priority (the group-helper fns in `editor_ribbon_format`/`editor_ribbon_color` return `RibbonGroupSpec` with a threaded priority; each tab declares a descending-importance scheme — core editing controls stay full longest, wide colour-swatch groups overflow first). Publish's ribbon content split to `editor_ribbon_publish.rs` (dropping the baselined `editor_publish.rs` 315 → 236, off the ceiling backlog) and the Table tab's `delete_current_table` to `editor_ribbon_table_delete.rs`. **R-13e ✅** — `AtRibbonGroup` exposes its resolved `GroupCollapse` to descendants as a *signal* context, and `AtRibbonSelect` reads it to shrink (`RIBBON_SELECT_WIDTH_PX` → `RIBBON_SELECT_WIDTH_CONDENSED_PX`) when its group is condensed; a signal, not a plain value, so prop-memoised selects still re-size reactively on resize. The overflow menu also auto-closes when a widen removes the overflow. ~~**Remaining tail:** true outside-click-to-dismiss for the More menu~~ ✅ **Done 2026-07-11** — new `appthere_ui` overlay primitives `use_provide_backdrop()` + `AtBackdropHost`: the app root (now `position: relative`) hosts a transparent viewport-spanning click-catcher (z 40) raised while the menu is open; the menu stays anchored to its More button (z 41), a click anywhere outside closes it, and a `use_drop` guard clears a stale backdrop on strip unmount. All three apps wire the context; degrades to toggle-only dismissal without it. **M3 is complete.** | L | +| 4a.2 | Spec 04 M5 | Layout/References/Review ribbon tabs + ~~`selected_object` contextual-tab signal~~ (✅ **Done 2026-07-06** — new `editing/selected_object.rs`: pure `selected_object(&CursorState) -> SelectedObject` (Table when the focus path descends through a cell). `editor_ribbon_table::use_ribbon_tabs` derives it via `use_memo`, appends an amber **Table** contextual tab while the caret is in a table, and resets the active tab to Write when the caret leaves (so no orphaned selection). The tab's **Delete Table** action is backed by a new `delete_block` model mutation (`loro_mutation/block_edit.rs`, split from `block.rs` to hold the ceiling), disabled when the table is the document's only block; the caret re-homes to the neighbouring block. New `LUCIDE_TRASH_2` icon. Tests: `selected_object` (5), `ribbon_tabs` (3), `delete_block` (3). **Structural row/column ops ✅ Done 2026-07-06** — the Table tab's "Rows & Columns" group inserts/deletes rows and columns via new `loro_mutation/table_ops.rs` mutations (`insert_table_row`/`delete_table_row`/`insert_table_column`/`delete_table_column` + `table_grid_dims`): they rewrite the serde skeleton **and** minimally patch the flat `KEY_TABLE_CELLS` list (surviving cells keep their live CRDT text), scoped to simple grids (no spans/head/foot — else typed `UnsupportedTableStructure`). The caret re-homes to its shifted cell (pure `caret_flat_after` in `editor_ribbon_table_ops.rs`); delete buttons disable at 1 row/col; 4 app-custom table-op glyphs. Tests: 12 round-trip (`table_structural_ops.rs`) + 4 caret-math. **Insert above/below + left/right ✅ Done 2026-07-06** — the insert ops are now split into four caret-relative variants (`TableOp::{InsertRowAbove,InsertRowBelow,InsertColumnLeft,InsertColumnRight}`): above/left insert at the caret's own row/col index, below/right at index+1, and `caret_flat_after` re-homes the caret accordingly (above shifts it down a row, left shifts it one column right). 2 app-custom glyphs (`AT_TABLE_ROW_INSERT_ABOVE`, `AT_TABLE_COL_INSERT_LEFT`); 2 new caret-math tests. **Paragraph alignment ✅ Done 2026-07-06** — the Write tab gains an Alignment group (left/centre/right/justify) driven by new path-aware `set_block_alignment_at`/`get_block_alignment_at` (`loro_mutation/align.rs`), so alignment works in table cells and note bodies too. `align.rs` handles every paragraph shape: a plain `para` is upgraded to `styled_para` so props survive the read path, `styled_para` uses `para_props`, and a `heading` uses its OOXML `jc` attr. The 6 inline-format buttons + the new alignment buttons were extracted to `editor_ribbon_format.rs` (dropping `editor_ribbon.rs` 300 → 225). 5 alignment tests (`block_alignment.rs`). **Font-size grow/shrink ✅ Done 2026-07-06** — a Write-tab Font group steps the selection's `MARK_FONT_SIZE_PT` up/down a fixed size ladder (`editor_font_size.rs`, path-aware via `mark_text_at`/`resolve_format_ranges`, so it works in cells and across multi-paragraph selections); pure ladder + end-to-end mark tests (5). 2 app-custom "A±" glyphs. **Text colour ✅ Done 2026-07-06** — a Write-tab Font-colour group with an Automatic (clear) swatch + 6 preset colours applies `MARK_COLOR` (the `#RRGGBB` hex is the codec's own RGB form, so no extra encoding) across the selection, path-aware (`editor_text_color.rs`); coloured-square swatches render inside `AtRibbonIconButton`. 3 tests incl. round-trip into `CharProps.color`. **Highlight colour ✅ Done 2026-07-06** — a Write-tab Highlight group (None clear-swatch + 5 preset colours: Yellow/Green/Cyan/Magenta/Red) writes `MARK_HIGHLIGHT_COLOR` as a `HighlightColor` variant name (or `Null` to clear) across the selection, path-aware (`editor_highlight_color.rs`); the swatch fills are the RGB each variant renders as (`resolve::map_highlight_color`). The duplicated font-colour/highlight swatch UI was unified into a generic `swatch_group(palette, apply_fn)` in `editor_ribbon_color.rs`. 3 tests incl. round-trip into `CharProps.highlight_color`. **Layout tab ✅ Started 2026-07-06** — a new non-contextual **Layout** tab (Write/Insert/**Layout**/Publish; contextual Table now at index 4) with a page-**orientation** toggle backed by new `set_document_orientation`/`document_is_landscape` model mutations (`loro_mutation/page.rs`): the layout engine reads the effective `page_size` directly, so the toggle swaps width↔height on every section + sets the orientation flag, and `apply_mutation_and_relayout` re-flows at the new size (verified the pipeline updates `page_width_px`). 4 round-trip tests (`page_orientation.rs`) + updated ribbon-tab tests; 2 app-custom page-rect glyphs. **Margin presets ✅ Done 2026-07-06** — the Layout tab's Margins group applies Normal/Narrow/Wide via new `set_document_margins`/`document_margins` (`page.rs`; sets top/bottom/left/right on every section, leaves header/footer/gutter); the active preset highlights via the pure `margin_matches` (½-pt tolerance). 3 model tests (`page_margins.rs`) + 5 preset-match tests; 3 app-custom page-inset glyphs (tooltip-disambiguated). **Page size ✅ Done 2026-07-06** — the Layout tab's Size group applies A4/US-Letter via new `set_document_page_size`/`document_page_size` (`page.rs`), **preserving each section's orientation** (choosing A4 while landscape gives A4 landscape); the active size highlights via the orientation-independent `page_size_matches`. 3 model tests (`page_size.rs`, incl. orientation preservation) + 4 preset-match tests. **Columns ✅ Done 2026-07-06** — the Layout tab's Columns group applies one/two/three via new `set_document_columns`/`document_column_count` (`page.rs`; count clamped ≥1, a new columns map gets a default 0.5in gap + no separator, an existing one keeps its gap/separator when the count changes); 5 model tests (`page_columns.rs`). The Layout tab is now Orientation + Margins + Size + Columns. **References tab ✅ 2026-07-07** — a new non-contextual **References** tab (Write/Insert/Layout/**References**/Publish; contextual Table now at index 5) generating a **table of contents** from the document's headings. The pure, format-neutral builders live in `loki-doc-model` (`content/toc.rs`): `heading_outline(sections, max_depth)` collects `(level, label)` for every `Block::Heading` within depth (default 3), `inline_plain_text` flattens a heading's inlines to its label, and `build_toc` produces a `TableOfContentsBlock` whose cached `body` is level-indented entry paragraphs (page numbers are omitted — they need the paginated layout — matching a freshly-inserted Word TOC field). The CRDT mutations (`loro_mutation/toc.rs`): `insert_table_of_contents` builds a TOC from the live headings and inserts it after the caret's block; `refresh_table_of_contents` rebuilds an existing TOC's snapshot in place (the "update field" action, a guarded no-op on a non-TOC block); `first_toc_block_index` finds the TOC to enable/refresh. A `Block::TableOfContents` round-trips through the bridge as an opaque JSON snapshot, so both are undoable. **Root-cause layout fix:** `loki-layout`'s `flow_block` silently dropped `TableOfContents`/`Index` blocks via its `_ => {}` catch-all, so an inserted *or imported* TOC was invisible — now both flow their cached body (a new `flow_blocks` helper the merged Div/Figure arms also use, so `flow.rs` held at its 1953 baseline). The **References** tab (`editor_ribbon_references.rs`) offers **Insert** + **Update** (Update disabled with no TOC); 2 app-custom glyphs (`AT_TOC_INSERT`/`AT_TOC_UPDATE`); the tab-index shift updated `ribbon_tabs`/`CONTEXTUAL_TAB_INDEX`/the tab-content match. 12 model tests (`content::toc` 7, `loro_mutation::toc` 5) + 1 layout regression test + updated ribbon-tab tests; 5 i18n keys. **Review tab — model foundation ✅ 2026-07-07** — the first slice of track changes. A **live** tracked-change mark (distinct from the dormant, round-trip-only opaque `TrackedChange`): `style/props/revision.rs` defines `RevisionKind { Insertion, Deletion }` + `RevisionMark { kind, author, date, id }` with a `US`-delimited packed string codec (no serde/chrono on the hot path). It rides a run as `CharProps::revision` (run-level, **not** style-inherited — omitted from `merged_with_parent`), so a tracked run is an `Inline::StyledRun` whose `direct_props.revision` is set — the same marks mechanism as highlight colour, keeping the paragraph live-editable. Wired through the CRDT bridge (`MARK_REVISION` in `CHAR_MARK_KEYS` + write/read), so a tracked run survives an edit cycle (round-trip tested). Pure, tested accept/reject transforms (`content/revision_ops.rs`): `accept_revisions`/`reject_revisions` over the whole block tree (recurse lists/tables/notes/quotes/TOC) — accept keeps insertions (clears the mark) and removes deletions, reject does the inverse; plus `has_revisions` and `Document::{accept_all_revisions,reject_all_revisions,has_tracked_changes}`. A `DocumentSettings.track_changes` flag (serde-default, back-compat) records whether new edits are tracked. 16 tests (revision codec 4, accept/reject 6, char_props 2 relocated, bridge round-trip + serde back-compat 2, +2). `char_props.rs` inline tests extracted to hold the ceiling. **Review tab — rendering ✅ 2026-07-07** — tracked changes are now visible. `loki-layout/src/revision_style.rs` colours a tracked run by its author (a deterministic 6-colour palette hashed off the author name) and decorates it by kind — insertion **underlined**, deletion **struck through** — applied to the run's `StyleSpan` in `char_props_to_style_span` (so it flows through the existing Parley decoration + brush path; the renderer needs no change). Because colour/decoration are derived from the mark at layout time, accepting/rejecting (which clears the mark) reverts the run with nothing stored to undo. 4 unit tests (`revision_style`: insertion underline, deletion strikethrough, no-op, author-colour determinism) + 1 layout regression (`tracked_runs_render_insertion_underline_and_deletion_strikethrough`); baselined `resolve.rs`/`lib.rs` held at baseline via a comment tightening + a redundant blank-line drop. **Review tab — editor records tracked insertions ✅ 2026-07-07** — typing now records tracked changes when the flag is on. New CRDT mutation `insert_text_tracked_at` (path-aware, so it works in cells/notes) inserts text **and** marks the range with `MARK_REVISION`. Crucially, `MARK_REVISION` is reconfigured `expand: None` in `configure_text_style` (unlike the `After` of formatting marks), so a revision covers exactly the changed range and never bleeds onto adjacent — possibly untracked — typing; consecutive tracked inserts by one author coalesce because their encoded mark value is identical. The editor's `handle_character_key` reads `Document::insertion_revision()` (a pure helper: `Some(Insertion by meta.creator)` when `DocumentSettings.track_changes` is on, else `None`) and routes typing through the tracked mutation accordingly — so a document with track-changes on shows typed text underlined in the author colour (rendering already wired). 3 tests: the pure `insertion_revision` decision, a CRDT round-trip proving the tracked insert marks only its range and does not leak onto a following plain insert, plus the existing bridge round-trip. **Review tab + durable toggle ✅ 2026-07-07** — track changes is now reachable and usable. **Root-cause fix:** `DocumentSettings` now round-trips through the Loro CRDT (`loro_bridge/settings.rs`, a JSON snapshot under `KEY_SETTINGS` like metadata/comments) — previously the bridge dropped settings, so the `track_changes` flag was wiped on the first relayout (which re-derives the doc from the CRDT). `set_track_changes(loro, on)` / `document_track_changes(loro)` flip and read the flag durably (undoable, survives relayout + save). A new non-contextual **Review** tab (Write/Insert/Layout/References/**Review**/Publish; contextual Table now at index 6) hosts a **Track Changes** toggle whose active state reflects the flag and whose click routes through `set_track_changes` + relayout. So the whole pipeline works end to end: toggle on → typed text records as a tracked insertion (`insert_text_tracked_at`) → renders underlined in the author colour. 4 tests (settings bridge set/get + preserve-others; full-document settings round-trip; +updated ribbon-tab tests); 1 app glyph (`AT_TRACK_CHANGES`); 3 i18n keys. **Review tab — accept/reject all ✅ 2026-07-07** — the review loop is closed: changes can be accepted or rejected. New CRDT mutation `accept_reject_all_revisions(loro, accept)` applies the accept/reject semantics **surgically** to the live Loro text (so it's one undoable step and the editor keeps its handle): it walks each top-level block's `to_delta()` for `MARK_REVISION` spans and, back-to-front, either clears the mark (`mark_utf8(range, …, Null)`) for a kept run (accepted insertion / rejected deletion) or deletes the text for a removed run — the CRDT analogue of the pure `revision_ops` transforms, returning the count resolved. The Review tab gains a **Changes** group with **Accept all** / **Reject all** buttons (disabled when `Document::has_tracked_changes()` is false, so they grey out once the document is clean). 4 CRDT tests (accept keeps-ins/removes-del + clears marks, reject inverse, no-op when clean, multi-block) + 2 app glyphs (`AT_CHANGE_ACCEPT`/`AT_CHANGE_REJECT`) + 3 i18n keys. *(Scope: top-level block text; revisions nested in table cells / note bodies are `TODO(review-nested)`.)* **Review tab — tracked deletions ✅ 2026-07-07** — track-changes recording is now complete: Backspace/Delete strike text through instead of removing it. The pure `delete_action(existing, tracking)` (Word semantics: tracking off ⇒ hard delete; on ⇒ hard-delete the author's own insertion, skip an already-struck deletion, else mark struck) drives the CRDT mutation `tracked_grapheme_delete`, which reads the target grapheme's `MARK_REVISION` (via `get_mark_at_path`) and applies the decision — deleting the text, marking it a deletion, or no-op — returning the [`DeleteAction`] so the editor places the caret (Backspace lands before the grapheme in every case; forward-Delete keeps the caret on a hard delete but steps past a struck grapheme). `Document::deletion_revision()` supplies the author-attributed deletion mark (its `Some`/`None` is the tracking flag). `handle_backspace_key`'s grapheme path and `handle_delete_key` both route through it. 5 tests (`delete_action` truth table, `deletion_revision` flag, + 3 CRDT: strike-normal, hard-delete-off, remove-own-insertion/skip-already-struck). *(Scope: single-grapheme Backspace/Delete; a **selection** delete under track changes still hard-deletes — `TODO(review-selection-delete)` — as does Backspace at a paragraph start, since whole-paragraph-mark deletion isn't modelled.)* **OOXML `w:ins`/`w:del` import+export ✅ 2026-07-07** — tracked changes now round-trip through DOCX, so they survive save/open in Word. **Import:** `w:ins`/`w:del` were already recognised structurally but dropped their semantics; now `parse_tracked_runs` reads the `w:author`/`w:date`/`w:id` attributes into a `DocxTrackedChange`, `parse_run` also reads `w:delText` (deleted-run text, previously discarded — a zero-cost widening of the `w:t` match), and the mapper attaches a `RevisionMark { kind, author, date, id }` to every wrapped run's `CharProps.revision` (deletions are kept, struck-through, not dropped). **Export:** `write_styled_run` wraps a run carrying `revision` in `w:ins`/`w:del` with those attributes, and its text emits as `w:delText` for a deletion (`write/revision.rs`). 1 DOCX round-trip test (insertion + deletion, author + date + text all survive). The mapper `inline.rs` test module was extracted (`inline_tests.rs`) and the two new intermediate structs moved to `model/revision.rs` to hold the three baselined files at baseline. **ODF `text:tracked-changes` import+export ✅ 2026-07-07** — tracked changes now round-trip through ODT too, so they survive save/open in LibreOffice. ODF splits a change between the document-leading `text:tracked-changes` table (one `text:changed-region` per change: `text:insertion`/`text:deletion` + `office:change-info` with `dc:creator`/`dc:date`; a deletion stows its removed `text:p` text) and body milestones keyed by `text:change-id` (an insertion is bracketed by `text:change-start`/`text:change-end`; a deletion is a single `text:change` point). **Import:** a new `reader/revisions.rs` parses the region table into `OdfChangedRegion`s (carried as an `OdfBodyChild::TrackedChanges`); `reader/inlines.rs` captures the `text:change-*` milestones as new `OdfParagraphChild` variants; the mapper (`mapper/document/inlines.rs`) resolves each milestone against the region map threaded through `OdfMappingContext` — a bracketed range becomes an insertion-marked run, a deletion point re-materialises the region's removed text as a struck run. **Export:** a new `write/revisions.rs` collects a `Changes` table during body rendering (flushed right after `` opens) and `write_styled_run` emits the milestones for a `revision`-carrying run (date written verbatim so RFC-3339 round-trips exactly). 1 ODT round-trip test (insertion + deletion, author + date + text all survive), plus a verified-conformant XML dump. Ceiling held by trimming doc comments in the at-ceiling `mapper/document/mod.rs` and by making the shared `wrap_span`/`plain_text` writers `pub(super)` rather than duplicating them. **Selection tracked deletion ✅ 2026-07-07** — deleting a **selection** under track changes now strikes it through instead of hard-deleting. New CRDT mutation `tracked_delete_selection_at` (`loro_mutation/selection.rs`): with a deletion mark it strikes each block's selected slice via `strike_range` — walking `to_delta()` and applying `delete_action` per run segment (the author's own tracked insertions are hard-deleted / un-typed, already-struck text is skipped, everything else is marked struck) — and **preserves the paragraph marks between selected blocks** (no merge, unlike the untracked path which merges); with `None` it delegates to the existing hard-deleting `delete_selection_at` (the two now share a `normalize_selection` front-end). The editor's single selection-delete choke point `delete_selection_in_doc` gained a deletion-mark parameter and a `deletion_mark(doc_state)` helper, so Backspace/Delete/Enter-over-selection **and** replace-typing all strike under tracking (Word inserts the newly typed run before the struck old text). 5 CRDT tests (`loro_tracked_selection_tests.rs`: single-block strike, own-insertion hard-delete, already-struck skip, multi-block paragraph-marks-preserved, `None` hard-delete+merge). Ceiling held by trimming `editor_keydown_text.rs` comments. **Per-change accept/reject ✅ 2026-07-08** — the Review tab's Changes group now has **Accept / Reject** buttons (circle-check / circle-x glyphs, distinct from the plain check/cross Accept-all/Reject-all) that resolve just the change **at the caret**. New model fns (`loro_mutation/revision.rs`): `accept_reject_revision_at(loro, path, byte_offset, accept)` finds the contiguous `MARK_REVISION` span at the caret (`span_at` — the span containing the offset, else the one ending at it) and resolves it via the shared `resolve_span` (delete a removed run's text, clear a kept run's mark), returning the collapsed caret offset (change start on removal, else unchanged) or `None` when the caret isn't on a change; `revision_at(loro, path, byte_offset)` is the read-only query driving the buttons' enabled state. `resolve_text` (accept/reject-all) was refactored to share `revision_spans` + `resolve_span`. The ribbon handler `accept_reject_at_caret` (`editor_ribbon_review.rs`) reads the caret from `cursor_state`, applies the mutation, relayouts, and repositions the caret; the buttons enable only when `change_at_caret` (via `revision_at`) is true, recomputing as the caret moves. 7 CRDT tests (`loro_per_change_revision_tests.rs`: accept/reject × insertion/deletion, no-op off a change, `revision_at` true/false, only-the-caret-change-resolved); 2 app glyphs + 2 i18n keys. **Nested-container resolution ✅ 2026-07-08** — accept/reject-all now reaches revisions **inside table cells and note bodies**, not just top-level paragraphs. A new `loro_mutation/text_containers.rs` (`collect_all_text_containers`) walks every section's block list and recursively descends each block's `KEY_TABLE_CELLS` / `KEY_NOTES` containers, returning every `LoroText` content container; `accept_reject_all_revisions` now sweeps that full set instead of iterating top-level block indices (which skipped tables via `TextNotFound`). This closes the latent gap where the Accept-all / Reject-all buttons enabled on a nested change (`has_tracked_changes` already recurses) but did nothing. The per-change ops were already path-aware, so they resolve nested changes too. `section_blocks_list` was exposed `pub(super)`; the collector lives in its own module to hold `nested.rs` under the ceiling. 3 CRDT tests (`loro_nested_revision_tests.rs`: accept keeps / reject removes a table-cell change, accept a footnote-body change — each asserting the doc ends clean). **Paragraph-mark tracked deletion ✅ 2026-07-08** — the last Review tail. The paragraph mark (¶) is modelled by a paragraph's `direct_char_props` (the OOXML `w:pPr/w:rPr` slot), so a tracked ¶-deletion is simply a `Deletion` on `direct_char_props.revision` — no new model type. **Round-trip:** the bridge previously dropped block-level char-props `revision`; `map_char_props_to_map` / `reconstruct_char_props_from_map` now write/read it under a new `PROP_REVISION` key, so it survives the CRDT. **Record:** new `loro_mutation/para_mark.rs::set_para_mark_deletion` marks the previous paragraph's ¶ (upgrading a plain `para`→`styled_para` like alignment does), declining non-paragraphs so the caller hard-merges; the editor's Backspace-at-start (extracted to `editor_keydown_backspace.rs` to hold the ceiling) routes a top-level paragraph start through it under tracking. **Resolve:** `accept_reject_all_revisions` now also sweeps para-marks (`para_mark::resolve_para_marks`, recursing into cells/notes) — accept removes the ¶ (successor merges via `merge_block_in_list`), reject clears; the pure transforms merge too (`content/para_mark_merge.rs`, shared by `resolve_blocks`). `has_revisions` detects a para-mark so the Accept/Reject buttons enable. 8 CRDT/model tests (`loro_para_mark_revision_tests.rs`: round-trip, accept-merges, reject-splits, pure transforms, set+upgrade, heading-declines, trailing-mark, post-merge editability). *(Deferred: rendering a struck ¶ glyph; nested-container recording — `TODO(review-para-mark-nested)`; per-change resolution of a para mark.)* **The Review tab (track changes) is now feature-complete for the editor path.** **DOCX para-mark round-trip ✅ 2026-07-08** — a tracked ¶ deletion now survives save/open in Word. **Export:** `write/revision.rs::write_mark_del` emits the self-closing ``/`` inside the paragraph mark's `w:pPr/w:rPr` (a new shared `write_rev_element` backs both it and the run-wrapping `open`). **Import:** `parse_rpr_element` recognises a `w:del`/`w:ins` child of the pPr's rPr via `reader/runs.rs::parse_mark_revision` into a new `DocxMarkRevision` on `DocxRPr`, which `map_rpr` maps to `CharProps.revision`. 1 round-trip test (`paragraph_mark_deletion_round_trips`). Ceiling held by extracting `reader/document.rs`'s inline tests to `document_tests.rs` and tightening a few doc comments in the at-ceiling `mapper/props.rs` / `model/paragraph.rs`. **Nested-container para-mark recording ✅ 2026-07-08** — Backspace-at-start inside a table cell / note body now records a tracked ¶ deletion too (previously only top-level). New path-aware `set_para_mark_deletion_at` (both it and the index-based `set_para_mark_deletion` share `write_para_mark`); the editor's `record_para_mark_deletion` computes the previous block's path via `focus.sibling_block(-1, 0)` and a `has_previous_sibling` guard (leaf index > 0), so it works at any nesting. The accept/reject sweep already recursed into cells/notes, so resolution needed no change. 1 CRDT test (`records_and_accepts_a_para_mark_inside_a_table_cell`). **Per-change para-mark resolution ✅ 2026-07-08** — the Review tab's per-change Accept/Reject buttons now resolve a paragraph-mark deletion too, not just text runs. New model fns `para_mark_at` (enable query) and `accept_reject_para_mark_at` (accept merges the successor into the caret's paragraph; reject clears the mark; returns the paragraph-end caret offset). The ribbon handler `accept_reject_at_caret` tries the text-span change first, then falls back to the para-mark; `change_at_caret` ORs in `para_mark_at`. 4 CRDT tests (`para_mark_at` detection, per-change accept-merges, reject-clears, no-op without a mark). ~~**Remaining polish:** ODF para-mark export and struck-¶ rendering~~ ✅ **Done 2026-07-11** — **Struck-¶ rendering:** a tracked paragraph-mark deletion now paints a struck, author-coloured end-of-paragraph marker (two stems + strike, paint-only items via `para_underlays::emit_para_mark_deletion` off `ResolvedParaProps::para_mark_deleted_color`, so caret/hit-test/wrapping untouched); root-cause fix en route — `flatten_paragraph` no longer bleeds the ¶'s revision onto the runs (the whole paragraph text used to render struck). **ODF export:** a ¶ deletion emits an end-of-paragraph `text:change` milestone whose deletion region stows only the paragraph break (empty `text:p`), and import maps that shape back onto the paragraph, not a struck run (`revision_round_trip::tracked_paragraph_mark_deletion_round_trips`). **The Review tab is now feature-complete including both format round-trips.** | L | | 4a.3 | Spec 05 | **Page** style family (`page_styles` catalog per ADR-0012) and **Table** family (`TableProps` conditional/banding regions); character-style editing form; per-family non-paragraph `Default` sources; Compact-tree breadcrumb (M7). **Character-family `Default` source ✅ 2026-07-06** — the first of the per-family non-paragraph `Default` sources (ADR-0012 Decision 1). New `StyleCatalog::default_character_style` (serde-default, so it round-trips through the Loro bridge and is back-compatible); `resolve_char_chain` now falls through to it (`first_in_char_chain`, cycle/depth-guarded) so a standalone character style resolves the document's `docDefaults` run defaults as `Provenance::Default` instead of `FormatDefault` — the char inspector was previously **blind** to docDefaults. The OOXML importer synthesises a `__DocDefaultChar` character style from `w:rPrDefault` and points the default at it; the character browser hides `__`-prefixed synthetic styles, and both the DOCX and ODT writers skip them (they belong in `docDefaults`/`default-style`, not as named `w:style`/`style:style` — also fixes a latent `__DocDefault` paragraph leak). 4 model tests + 3 mapper tests; full OOXML/ODF/round-trip suites green. **Character-style editing form ✅ 2026-07-06** — the character family is now editable, not just inspectable (Spec 05 M6). Selecting a character style seeds an editable `StyleDraft` (`char_style_to_draft`) that a new `char_form.rs` binds — reusing the paragraph form's shared inputs (`field_row`/`iu_buttons`/`font_picker`/`weight_selector`, all of which already bind a `Signal>`) for name/based-on/font-family/weight/size/italic/underline. Apply commits a `CharacterStyle` to the catalog through Loro (`commit_char_style_to_loro`, persisted via the existing `write_document_styles` bridge, undoable) and relays out, **cycle-guarded** by new model helpers `char_ancestors`/`char_reparent_cycles` (the character analogue of the paragraph re-parent guard). The editable form renders alongside the read-only provenance inspector (inspector shows *where* inherited values come from, form edits the locals — the Spec 05 §6 inspector+edit pairing). 1 model test (`character_reparent_cycle_is_detected`); `editor_inner` held at its 803 baseline. **Compact-tree breadcrumb ✅ 2026-07-06 (M7)** — at Compact the paragraph inheritance tree's full indented list degrades to a **breadcrumb + drill-down** (Spec 05 §7/§11): the breadcrumb is the root→selected path (new model `para_breadcrumb` = `para_ancestors` reversed, cycle-guarded), each hop clickable to jump up; below it the selected style's direct **substyles** (`para_children`) are clickable to descend. `body::left_column` renders it via a new `tree_nav.rs` when `posture.stack` (Compact) and keeps the indented tree at Expanded/Medium. Navigation loads the target's draft exactly as the indented tree does. 1 model test (`breadcrumb_is_root_first_including_self`) + the existing posture tests. **Table-family resolver + `Default` source ✅ 2026-07-06** — the table family had single-parent inheritance in the model but **no provenance resolver** (only para/char existed). Added the table analogue: `resolve_table_chain` (Local/Inherited/**Default**/FormatDefault via `first_in_table_chain` + the new `default_table_style` catalog field), plus `table_ancestors`/`table_reparent_cycles`, in a new `resolve_table.rs` module (the `Resolved` constructors are now `pub(crate)` so the split compiles; keeps `resolve.rs` at 299, under the ceiling). The OOXML importer records `default_table_style` from the table style flagged `w:default="1"` (e.g. `TableNormal`). 3 model tests + 2 mapper tests. *(Lists are a **non-inheriting** family per ADR-0012 Decision 2 — no parent chain, so `Default` doesn't apply; they resolve Local/FormatDefault only. Table-style **export** of the default flag is deferred with the wider table-style writer, which isn't built yet.)* **ODF character-default import symmetry ✅ 2026-07-06** — the ODF half of the character `Default` source: the ODT mapper now synthesises a `__DefaultChar` character style from `style:default-style style:family="text"` and points `default_character_style` at it (the ODF analogue of OOXML's `__DocDefaultChar`; the ODT writer already skips `__`-prefixed synthetics). 1 mapper test incl. an end-to-end `Provenance::Default` resolution. *(The ODF table default is not wired: `OdfDefaultStyle` carries no table props and the ODT mapper does not import table styles at all yet — noted for the wider table-style import.)* The mapper's inline test module was extracted to `styles_tests.rs` (`#[path]` idiom) to hold the 300-line ceiling (358 → 148 production). **Page style family — started ✅ 2026-07-06** — the model foundation for ADR-0012 Decision 2's page family. New `PageStyle` type (`style/page_style.rs`): a named, **non-inheriting** entry (no `parent`) wrapping the existing rich `PageLayout` (size/margins/orientation/columns + header/footer master + page numbering), and a `page_styles: IndexMap` catalog field (serde-default, round-trips through the Loro-bridge catalog JSON, back-compatible). The format-neutral **import-mapping core** is pure + tested: `derive_page_styles(sections)` collapses sections with an identical `PageLayout` into one page style (named `PageStyleN` in first-seen order, since OOXML has no page-style name to carry), and `section_page_style_ids(sections)` gives the per-section id list — the inverse the DOCX section-export (`sectPr`) needs. 7 model tests (`page_style_tests.rs`); no resolver needed (a non-inheriting family is a chain of length one — the inspector shows only Local/FormatDefault, per ADR-0012). **Read-only page panel ✅ 2026-07-06** — the page family is now visible in the style panel (Spec 05 §9), mirroring the read-only list family. A **Page styles** list in the left column (`page_browser.rs`) + a read-only **geometry inspector** column (`family_inspector` page column) showing size / orientation / margins / columns. The panel **derives page styles on demand** from the live document's sections (`panel_data::page_data` → `derive_page_styles`) rather than reading the stored catalog field: the section layouts are the source of truth (the Layout ribbon mutates them directly), so deriving each render keeps the panel from **drifting** — the root-cause-correct choice over a stored-but-stale copy. Pure, tested inspector rows (`style_page_inspector::page_inspector_rows`, value-baked like the list inspector: named sizes, uniform-margin collapse; 4 tests). New `editing_page_style` selection signal (`editor_inner` held at 803 via comment tightening); 5 i18n keys. **Per-page-style edit mutation ✅ 2026-07-06** — the write-back primitive for LibreOffice-style per-page-style editing: `set_page_style_geometry(loro, section_indices, &PageLayout)` (`loro_mutation/page_style.rs`) applies a layout's size / orientation / margins / columns to **only the given sections** — the sections that belong to one page style (the panel derives the indices from `section_page_style_ids`) — leaving the other page styles, and each section's headers/footers/gutter/page-numbering, untouched. This is the per-style analogue of the document-wide `set_document_*` setters. Chosen over a stored `Section.page_style` reference + renderer refactor because page styles are already derived by layout-equality and an edit keeps a style's sections in sync, so index-targeting is stable without touching the fragile CRDT bridge or the layout engine. 3 integration tests (`page_style_geometry.rs`: only-its-sections, margins+columns, out-of-range skip). **Editable page form ✅ 2026-07-06** — the page panel is now editable per-page-style (LibreOffice model). Selecting a page style shows a preset form (`page_form.rs`) — Orientation / Size / Margins / Columns buttons, matching the Layout ribbon — that applies to **only that page style's sections**. The pure, tested transform `apply_preset(&PageLayout, PagePreset) -> PageLayout` builds the edited layout (orientation/size preserve the other axis; margins keep header/footer/gutter; columns keep the gap); each button computes the target section indices via `panel_data::page_edit_target` (derived on demand, always live) and writes through `set_page_style_geometry`, then relays out. 4 `apply_preset` tests. So editing "PageStyle1" changes all its pages and leaves "PageStyle2" alone. **Stored section→page-style reference ✅ 2026-07-06** — the model refinement toward true LibreOffice-style named page styles: `Section.page_style: Option` names the section's page style (persisted through the Loro bridge under `KEY_PAGE_STYLE_REF`), and `Document::assign_page_styles()` normalises a loaded document — dedups sections by layout into catalogued page styles and stores the refs, **idempotently** (a section that already names a style, e.g. a user rename or an ODF `style:master-page`, is preserved). Wired at `load_document` so every opened document gets first-class, stored, renamable page styles. 4 model tests (`page_style_model.rs`: dedup, idempotence/name-preservation, bridge round-trip, unnamed→None); the `Section` field addition rippled to ~15 test literals + 2 mapper/flow literals (offset the two baselined files back to baseline). **Rename UI + panel-reads-stored migration ✅ 2026-07-06** — the panel now reads the **stored** `section.page_style` refs instead of deriving by layout-equality, and page styles are renamable. `panel_data::stored_page_styles` groups sections by their stored ref (first-seen order) and reads the representative geometry from the first referencing section (`section.layout`, the renderer's truth) — so a page style is a **stable, renamable identity** while its geometry stays drift-free even when the Layout ribbon edits `section.layout` document-wide. A new `rename_page_style(loro, old, new)` mutation (`loro_mutation/page_style.rs`) renames the catalog key **and** every referencing section's stored ref atomically, keeping the `PageStyle.id` in sync and no-opping on name conflict / missing source. The page form (`page_form.rs`) grows a `PageRenameField` component (`page_rename.rs`, ADR-0013 — owns its draft signal, keyed by name to reseed on reselection) whose Rename button commits through the mutation, relays out, and re-selects the style under its new name. 2 rename integration tests (catalog+section refs updated; conflict/missing no-op) + the existing geometry tests; 2 i18n keys. This resolves the earlier drift caveat: identity comes from the stored ref, geometry from the live section. **ODT native page-style naming + importer population ✅ 2026-07-07** — the ODF-native round-trip for named page styles (ADR-0012 Decision 2). **Export:** a new `odt/write/page_styles.rs` resolves each section's `style:master-page` / `style:page-layout` names from the stored `section.page_style` id (sanitised to a valid XML `NCName` via `xml::sanitize_ncname`), so a named — or renamed — page style is written out under its real name instead of the old positional `MP{idx}`. Sections sharing a page style collapse to **one** master page (the first referencing section's layout is the representative geometry — the same choice the panel makes), matching LibreOffice's shared-master model; sections without a stored ref keep the positional fallback, so pre-page-style documents export byte-for-byte as before. Both `content.xml` (the `style:master-page-name` reference) and `styles.xml` (the master-page + page-layout definitions) read from the one resolver so the two always agree. **Import:** the ODT mapper now sets `section.page_style` from the master-page name each section uses and registers those names as first-class `page_styles` catalog entries (with `display_name`), so an opened ODT shows its real page-style names in the panel and they survive a re-export. 5 naming unit tests (`page_styles_tests.rs`: stored-id→master, shared-master dedup, positional fallback, NCName sanitisation, empty-doc) + 1 export→import round-trip (`odt_export_round_trip::named_page_styles_round_trip_as_master_pages`). **Master-page `style:display-name` round-trip ✅ 2026-07-07** — the page family's last tail item. `OdfMasterPage` gained a `display_name` field; the ODT reader parses `style:display-name` off both the container and self-closing `` forms, and the mapper carries it onto the `PageStyle` (only when distinct from the id — a redundant one stays `None`, and fabricating `Some(id)` is no longer done, so a later rename isn't shadowed). Export writes `style:display-name` on the master page when the catalog gives the style a name distinct from the emitted `NCName`. So a page style with a spaced/human name (id `WideBody`, display "Wide Body") round-trips both halves. +1 export unit test + display-name assertions in the round-trip test; the self-closing-master reader branch was refactored through a new `OdfMasterPage::header_footer_less` constructor to hold the `reader/styles.rs` ceiling. **Page family complete** — OOXML has no named page style (DOCX sections already export as `w:sectPr` per page style, and `assign_page_styles` names them `PageStyleN` on import — nothing further to wire). **Table-style reference — foundation ✅ 2026-07-08** — the prerequisite for table banding/conditional formatting: a `Block::Table` now **references its named style** (OOXML `w:tblStyle` / ODF `table:style-name`), which was previously dropped on import (the DOCX reader parsed `w:tblStyle` but the mapper never propagated it, and `Table` had no style field). Rather than add a `style_id` field to `Table` — an ~18-site struct-literal ripple across 8 crates — the reference is stored in the table's `NodeAttr` `"style"` key (the convention a `Block::Heading` already uses), read via `Table::style_name()` / written via `set_style_name()`. It round-trips through the Loro bridge for free (the bridge serialises the table skeleton incl. node attrs) and through DOCX (`map_table` carries it; the writer emits `w:tblStyle` before `w:tblW`). 4 tests: 2 DOCX round-trip (`table_style_round_trip.rs`), 1 CRDT bridge, and the no-style case. Ceiling held by trimming comments in the at-baseline `mapper/table.rs` / `write/document.rs`. **Table-style banding/conditional model + resolver ✅ 2026-07-08** — the pure-logic layer under table banding. `TableStyle` gained a `conditional: IndexMap` map (the twelve OOXML `w:tblStylePr` regions plus `WholeTable`) and `TableProps` gained `row_band_size`/`col_band_size`; a new `TableLook` struct models the `w:tblLook` region flags (custom `Default` = Word's `04A0`: header row + first column + row banding on). A new `style/table_banding.rs` provides `resolve_cell_shading(style, look, row, col, rows, cols) -> Option`: it walks a 13-entry precedence array (corners > first/last row > first/last col > horizontal bands > vertical bands > whole-table), computes horizontal/vertical band membership (index parity over the band-eligible rows/cols, honouring band size and header/footer/first-col exclusion), and resolves *per-property* — a higher-precedence region that defines no shading falls through to the next that does, with `TableProps::background_color` as the base fallback. All pure — no layout/render dependency, so it needs no visual verification. 12 unit tests + `default_table_look_matches_word_04a0`; the ~4 `TableStyle`/`TableProps` struct literals across the workspace updated for the new fields. **Table-style shading reaches the cell paint ✅ 2026-07-08** — the resolver is now wired into the flow engine. A new `loki-layout` `table_shading` module (`resolve_table_style` looks a table's `"style"` attr up in the style catalog; `cell_style_shading` calls `resolve_cell_shading` under Word's default `w:tblLook`) is consulted at the Pass-3b cell-paint seam in `flow.rs`: the painted cell background is now `cell.props.background_color.or(style banding)` — direct cell shading still wins, but a cell with none falls through to the table style's conditional/banding shading. The two duplicated paint branches (in-progress page vs. finished page) were unified into a single target-vec selection — a DRY simplification that offset the new logic and dropped `flow.rs` from 1953 → 1948 (baseline ratcheted). 1 end-to-end flow test (`table_style_banding_shades_the_header_row` — a styled 2×2 table with no direct shading paints exactly its 2 header cells) + 3 `table_shading` unit tests. `w:tblLook` is assumed default until import lands (`TODO(table-tbllook-import)`). **DOCX `w:tblStylePr` conditional-formatting import ✅ 2026-07-08** — real Word documents now carry their table-style banding into the model. The DOCX styles reader (`reader/styles.rs`) gained a small state machine over `w:type="table"` styles: band sizes (`w:tblStyleRowBandSize`/`w:tblStyleColBandSize`), base whole-table cell shading (`w:tcPr/w:shd`, scoped by an `in_tcpr` flag so `w:tblPr/w:shd` and `w:rPr/w:shd` don't leak in), and each `w:tblStylePr` region's cell shading (tracked by a `current_region` set on the element's `@w:type` and reset on its close) — collected into new `DocxTableStyleProps`/`DocxTblStylePr` model types on `DocxStyle`. The styles mapper (`mapper/styles.rs`) translates them: `map_table_region` maps the twelve OOXML region names to `TableRegion` (unknown names skipped), band sizes + base shading fill (via the existing `xml_util::resolve_shading`) into `TableProps`, and each shaded region into the `conditional` map (unshaded regions skipped). So a document using a built-in banded style (*List Table*/*Grid Table Accent*) imports its conditional shading and — through the layout wiring above, under the default `w:tblLook` — **paints banded rows/header end-to-end**. The `DocxStyle.table` field addition rippled to 7 test literals (mechanical `table: None`). 4 tests: `parses_table_style_banding` + `non_table_style_has_no_table_props` (reader), `table_style_conditional_formatting_maps` (mapper, incl. unknown-region + unshaded-region skipping), all suites green. **Per-table `w:tblLook` import ✅ 2026-07-08** — a table instance now carries its **own** active-region flags instead of assuming Word's default. The DOCX reader (`parse_tbl_look` in `reader/document.rs`) parses `w:tblLook` from either the explicit boolean attributes (`w:firstRow`/`w:lastRow`/`w:firstColumn`/`w:lastColumn`/`w:noHBand`/`w:noVBand`, the `no*Band` flags inverted into positive banding) or the legacy `w:val` hex bitmask (bit masks `0x0020`…`0x0400`), into a new `DocxTblLook` on `DocxTblPr`. A format-neutral codec on the doc-model `TableLook` (`encode_attr`/`decode_attr` — a six-char `0`/`1` string, keeping the OOXML bit layout out of the model) lets the mapper (new tiny `mapper/table_look.rs`) encode it into the table's `NodeAttr` `"tbllook"` key (round-trips through the Loro bridge like `"style"`, and `Table::table_look_code`/`set_table_look_code` store it as an opaque string so `content` needn't depend on `style`). The flow engine reads it (`table_shading::table_look` decodes, defaulting on absent/malformed) and threads it into `cell_style_shading`, replacing the hard-coded `TableLook::default()` — so a table that disables banding or enables the last-row/last-column region renders with its real active regions. 8 tests: `parse_tbl_look_reads_the_legacy_val_bitmask` + `parse_tbl_look_prefers_explicit_attributes` (reader), `map_tbl_look` ×2 (mapper), `table_look_attr_round_trips` + `table_look_decode_rejects_malformed` (model codec), `table_look_reads_the_encoded_attr_or_defaults` + `tbl_look_with_first_row_off_suppresses_header_shading` + `table_look_disabling_first_row_suppresses_style_shading` (layout, incl. end-to-end flow). Ceilings held by trimming comments in the at-baseline `mapper/table.rs` (307) and `flow.rs` (1948). **DOCX table-style banding export ✅ 2026-07-08** — the export half, closing a full DOCX round-trip for banding. A new `write/table_style.rs` emits each catalog `TableStyle` as a `w:style w:type="table"`: band sizes → `w:tblPr` (`w:tblStyleRowBandSize`/`w:tblStyleColBandSize`, skipped when absent), base whole-table shading → `w:tcPr/w:shd`, and each conditional region → `w:tblStylePr w:type="…"` with its own `w:tcPr/w:shd` (`region_ooxml` inverts the mapper's `map_table_region`; the non-exhaustive `TableRegion` match skips unknown future variants). It also writes the table instance's `w:tblLook` (both the explicit boolean attributes and the legacy `w:val` hex bitmask via `look_bitmask`) into its `w:tblPr` from the `"tbllook"` attr, after `w:tblW` per the schema. Wired via `write_styles_xml` (a 2-line call) and `write_table` (a 1-line call — `write_tbl_look` takes `Option<&str>` and no-ops on absent/malformed, keeping `write/document.rs` at its 1073 baseline). 5 tests: 4 writer unit tests (`writes_conditional_regions_and_band_sizes`, `a_style_without_bands_omits_tblpr`, `writes_tbl_look_attributes_and_bitmask`, `malformed_tbl_look_code_writes_nothing`) + `table_style_banding_and_tbllook_round_trip` (a banded style + non-default look survive export→import intact through the catalog + instance attr). **ODT cell-shading export ✅ 2026-07-08** — the first ODF-side increment. ODF has no conditional-region concept: it bakes table shading into **per-cell** automatic styles (LibreOffice's on-disk model), and the ODT writer previously emitted `` with no formatting at all. `AutoStyles::cell_style` (`odt/write/auto.rs`) now emits a deduplicated automatic `` carrying `fo:background-color` (`TC{n}` names, co-located `emit_cell_properties`), referenced by `table:style-name` on each shaded cell in `write/tables.rs`. ODT **import** of `fo:background-color` already existed (`map_cell_props` reads it into `CellProps.background_color`), so a shaded cell now round-trips through ODT with no import change. 3 tests: `cell_style_emits_background_and_dedupes` + `a_cell_without_shading_gets_no_style` (writer unit) + `cell_background_round_trips_via_table_cell_style` (end-to-end ODT export→import); full loki-odf suite (incl. schema validation) green. **ODT banding resolution on export ✅ 2026-07-08** — bridges the two formats' models: a table carrying only a `"style"` reference + `"tbllook"` (e.g. a DOCX-imported banded table) now exports its bands to ODT as concrete per-cell shading. `write/tables.rs` flattens the rows, assigns each cell its grid column (`assign_grid_columns` — a coverage-grid mirror of the layout's `assign_cell_columns`, honouring `col_span`/`row_span` merges), then in a two-phase pass computes each cell's effective background = its direct shading **else** `resolve_cell_shading(style, look, row, col, rows, cols)` (the very doc-model resolver the layout paints with) and bakes it into the per-cell `table-cell` automatic style (phase 1 resolves under an immutable catalog borrow, phase 2 mints styles under `&mut cx.auto` — avoiding a borrow conflict). The style catalog reaches the writer via a `Cx.table_styles` clone (`content.rs`; the header/footer `Cx` in `styles.rs` uses an empty map). `AutoStyles::cell_style` was generalised to take the resolved `Option<&DocumentColor>` rather than `&CellProps`. 1 round-trip test (`table_style_banding_resolves_into_per_cell_shading_on_odt_export`: a firstRow-banded style with no direct shading → header cells return shaded, body cells not). **ODT table-level `table:style-name` write + reference round-trip ✅ 2026-07-08** — the ODT analogue of the DOCX `w:tblStyle` reference. A new `write/table_style.rs` emits a named `` for each catalog table style (skipping `__`-prefixed synthetics) into `styles.xml`'s ``, carrying table-level geometry: `style:width` (from `TableProps::width` `Absolute`) / `style:rel-width` (`Percent`), `table:align` (`align_value`), and `fo:background-color`; `write/tables.rs` references it via `table:style-name` on the `` element. **Import** restores the reference in one line — `map_table` sets `Table::set_style_name` from `OdfTable.style_name` (which the reader already parsed) — so a table's named style survives an ODT round-trip; the `TableWidth`/`TableAlignment` matches carry a wildcard arm since both enums are `#[non_exhaustive]`. 5 tests: 4 writer unit (`emits_width_alignment_and_background`, `percent_width_uses_rel_width`, `a_style_with_no_geometry_omits_table_properties`, `synthetic_styles_are_skipped`) + `table_style_name_reference_round_trips` (end-to-end); full loki-odf suite incl. schema validation green. **Remaining 4a.3:** ODT **import** of the table-style *definition* back into the catalog (the reference survives, but width/align/bg are written-but-not-re-read into a `TableStyle` — needs the reader `parse_style_props` table-properties extraction + a `table_styles` mapper); cell **borders**/padding export; `w:cnfStyle`; conditional character formatting; and the editing UI. Plus the Page-family tail and the ODF table default-style import (now partly unblocked). | L | | 4a.4 | Spec 03 | ~~Metadata-panel label stacking <250 px (R-13g)~~ (✅ **Done 2026-07-06** — `FieldRow` is now a `#[component]` reading `use_viewport()` per ADR-0013; below `METADATA_LABEL_STACK_PX` (250 px) the label stacks above its input via a `flex-direction: column` switch so the input keeps a usable width; pure `stack_labels` helper + 3 tests in `editor_metadata_panel_tests.rs`); responsive doc type-scale (M4); ~~real `Viewport.zoom`~~ (✅ **Done 2026-07-06** — the status-bar zoom now feeds the shared responsive `Viewport::zoom` (`editor_responsive.rs`: pure `zoom_fraction`/`desired_view_mode` helpers wired into effects 2 & 3), so zooming a page past the point it fits flips the page-fit renderer to reflow instead of forcing horizontal scroll; 5 tests in `editor_responsive_tests.rs`). **Remaining:** responsive doc type-scale (M4). | M | | 4a.5 | Spec 04 M6 | Touch posture + ~~cursor-into-new-cell after insert~~ (✅ **Done 2026-07-06** — `insert_table_after_cursor` now returns the first-cell caret (`first_cell_caret` → flat cell 0 / block 0), and the Insert-tab `run_insert` collapses the cursor there after relayout via `set_collapsed_cursor` (page re-derived per 4b.1); footnote leaves the caret at the anchor, matching Word. `InsertResult` enum threads the optional caret target; the async image flow was extracted to `editor_ribbon_insert_image.rs` to hold the ceiling. 3 tests in `editor_insert_tests.rs`). **Remaining:** touch posture. | M | @@ -118,9 +118,9 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). |---|---|---|---| | 4b.1 | `3b-3` | ✅ **Done 2026-07-05** — (1) Left/Right cross page boundaries (the prev/next-entry searches walk the whole layout; entering a table from above/below lands in its first/last cell). (2) New `editing/page_locate.rs`: `recompute_page_index` re-derives a position's page from the layout, picking the page whose content band shows the byte's line for page-spanning paragraphs; wired into every mutation caret placement (`set_collapsed_cursor` — split, merge, typing, selection removal) and after every navigation move, replacing the stale-page TODOs. 4 page-locate tests (incl. a split-fragment band test) + 4 cross-page nav tests; `navigation.rs` split (`navigation_find.rs`) dropped it from the ceiling baseline (34 → 33). ~~**Remaining `3b-3` tail:** the double-Enter list-exit heuristic (`clear_para_props`).~~ ✅ **Done 2026-07-06** — pressing Enter on an *empty, top-level list item* now removes its list props (`list_id`/`list_level`) instead of inserting another bullet (Word / LibreOffice behaviour): new model mutations `get_block_list_id` / `clear_block_list` (`loro_mutation/style.rs`), a pure `is_empty_list_item_exit` predicate + wiring in `editor_keydown_enter.rs` (caret held in the now-plain paragraph, page re-derived), 4 model tests + 5 predicate tests. Nested list items (in a table cell / note) keep the split — the list block API is top-level only. | M | | 4b.2 | `formatting` | ✅ **Done 2026-07-05** — the six toggles (bold/italic/underline/strikethrough/super/subscript) apply across every paragraph of a same-container multi-block selection: new `editor_format_range.rs` `resolve_format_ranges` yields per-paragraph `(BlockPath, start, end)` ranges (first-paragraph tail, full middles, last-paragraph head; text-less blocks like tables inside the range are skipped, their cells untouched); the state at the selection start decides apply-vs-clear and the whole selection is made uniform (Word behaviour). Cross-container selections keep the clamp-to-focus rule. 8 tests incl. an end-to-end double-toggle. | M | -| 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Ribbon Save disables when clean ✅ 2026-07-05:** the dirty state is now a reactive `is_dirty` signal (set by the dirty-tracking effect) threaded into `write_tab_content`; the Save button's `is_disabled = !is_dirty()` (untitled reads as dirty, so Save→Save-As stays enabled). **Remaining tail:** Spec 01's typed `SaveError` residual. | M | +| 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Ribbon Save disables when clean ✅ 2026-07-05:** the dirty state is now a reactive `is_dirty` signal (set by the dirty-tracking effect) threaded into `write_tab_content`; the Save button's `is_disabled = !is_dirty()` (untitled reads as dirty, so Save→Save-As stays enabled). ~~**Remaining tail:** Spec 01's typed `SaveError` residual~~ — **verified already built 2026-07-11** (stale entry): `editor_save.rs` has a typed `thiserror` `SaveError` (NoDocument / Export / Io / InvalidToken / UnsupportedFormat) on every save path, surfaced via the save banner. | M | | 4b.4 | `nested-nav` | ✅ **Done 2026-07-05** — paginated navigation is path-aware end-to-end: `find_para_data` matches `(block_index, path)` (index alone returned the first cell's entry for every table paragraph), the `get_text` closures take a `BlockPath` (grapheme moves inside cells previously read the root block's empty text), and Left/Right at a nested paragraph's edges cross to the sibling within the same cell / note body, clamping at the container's first/last block. Inline tests extracted to `navigation_tests.rs` (`#[path]` idiom, file ratcheted 593 → 367) + 6 nested-nav regression tests. Reflow navigation stays top-level-only (tables have no reflow editing data). | S | -| 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells. **Core implemented ✅ 2026-07-08** (on the sub-ceiling `flow_table_cells.rs` the split-pass created): rotated cells now emit editing data — `flow_cell_blocks` returns the per-paragraph editing entries (tagged with the cell's `NestedEditing` path) alongside items, and the rotated branch records them with a `CellRotation` (new, in `loki-layout/src/result_rotation.rs`) that mirrors the paint-time `RotatedGroup` affine (`page = pivot_page + Rot(deg)·(local − pivot_local)`). `PageParagraphData::hit_local` / `local_to_page` centralise the (inverse) transform; the loki-layout canvas hit-test + caret/selection rects and loki-text's page hit-test all route through them, so **clicking a rotated cell resolves to the correct character** (was read-only). Tested: `result_tests` (transform round-trip + `hit_local`) and `flow_tests::rotated_cell_emits_editing_data_with_rotation` (end-to-end); loki-layout 192 lib + loki-text 195 lib green. **Follow-up** (`TODO(rotated-cell-caret)` in `flow_table_cells.rs`): the rendered caret *line* stays upright (a tilted caret needs `CursorRect` + the vello caret to carry rotation) and up/down arrow navigation across rotated cells still uses raw `origin` translation (route those `editing/navigation.rs` sites through `local_to_page`). | M | +| 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells. **Core implemented ✅ 2026-07-08** (on the sub-ceiling `flow_table_cells.rs` the split-pass created): rotated cells now emit editing data — `flow_cell_blocks` returns the per-paragraph editing entries (tagged with the cell's `NestedEditing` path) alongside items, and the rotated branch records them with a `CellRotation` (new, in `loki-layout/src/result_rotation.rs`) that mirrors the paint-time `RotatedGroup` affine (`page = pivot_page + Rot(deg)·(local − pivot_local)`). `PageParagraphData::hit_local` / `local_to_page` centralise the (inverse) transform; the loki-layout canvas hit-test + caret/selection rects and loki-text's page hit-test all route through them, so **clicking a rotated cell resolves to the correct character** (was read-only). Tested: `result_tests` (transform round-trip + `hit_local`) and `flow_tests::rotated_cell_emits_editing_data_with_rotation` (end-to-end); loki-layout 192 lib + loki-text 195 lib green. ~~**Follow-up** (`TODO(rotated-cell-caret)`)~~ ✅ **Done 2026-07-11** — the caret line, selection fills, and handles now paint through a rotation-aware affine (`loki-vello` `scene_cursor::cursor_paint_transform` composes the cell's `CellRotation`, so the caret tilts with the text; cursor painting split to the new `scene_cursor.rs`, shrinking the baselined `scene.rs` 727 → 613), and up/down arrow navigation maps the caret through `PageParagraphData::local_to_page` + aims at neighbours via the new `visual_y_span` (post-rotation bbox) instead of raw `rect + origin`. Tested: transform unit tests (`scene_cursor_tests.rs`) incl. equality with `CellRotation::local_to_page`. **4b.5 is complete.** | M | | 4b.6 | F3c + F1 residual (audit §9) | **Dirty-close protection ✅ Done 2026-07-05:** closing a dirty tab now raises a confirmation dialog in **all three apps** (new `appthere_ui::AtConfirmDialog` overlay primitive — absolute backdrop + centred card, backdrop-cancel, 44 px touch targets; shells gain `pending_close` state + an extracted `close_tab` helper). **Tab-switch retention ✅ Done 2026-07-05 (F1 residual):** loki-presentation *and* loki-spreadsheet now stash the live editing state (presentation: doc + slide index + dirty; spreadsheet: CRDT + undo manager + grid snapshot + selection) into an app-level session map on tab switch / Editor→Home unmount and restore it on return — mirroring loki-text's `sessions.rs`; closing a tab drops the session. The presentation load path also gained the `(path, result)` stale-value guard the other apps already had. | S–M | | 4b.7 | F6c + F6f (audit §9) | **Selection editing ✅ Done 2026-07-05:** typing replaces the active selection, Backspace/Delete remove it, incl. multi-block ranges — `loki_doc_model::delete_selection_at` (merge-then-delete composition, whole range pre-validated so cross-container / table-spanning selections are rejected untouched), editor wiring in `editor_keydown_text.rs` (replace-typing is one undo entry); tests: `loro_selection_delete_tests.rs` (10) + editor unit tests (7). **Remaining:** clipboard copy/cut/paste (partially gated on the unimplemented dioxus-native-dom clipboard converter), and moving save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | @@ -132,7 +132,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4c.2 | `a11y` | ✅ **Done 2026-07-05 (bounded by bar height)** — the status bar's three interactive controls (notice chip, view-mode toggle, zoom badge) are now transparent hit areas ≥ `TOUCH_MIN` (44 px) wide × the bar's full height, with the visual chip nested inside. **Honest constraint:** the 24 px `STATUS_BAR_HEIGHT` caps the vertical target at WCAG 2.5.8 AA's 24 px minimum, below the suite's 44 px convention — meeting it fully requires a taller bar on touch platforms (design decision, deferred; documented on `AtStatusBar`). | S | | 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | | 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | -| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | ✅ **Done 2026-07-05** (spreadsheet grid-zoom is the sole tail). ✅ F6a — recent-file rows and template cards are now child `#[component]`s (`recent_row.rs`, `RecentRow`/`OpenFileButton`/`TemplateCard`/`BrowseCard`) so their hover `use_signal`s no longer live inside `for`/`if` bodies (hook count is now prop-independent; ADR-0013). ✅ F6d ribbon — loki-spreadsheet's ribbon lists only the implemented Home tab (the dead Insert/Format/Review/View entries removed) and its tab-select + collapse are live signals. ✅ F7a — `AtHomeTab` reads `use_breakpoint()` (Compact = stacked, Medium/Expanded = side-by-side) instead of the fixed `viewport_width = 375.0`. ✅ F7b — slide thumbnails/bullets use stable keys (`SlideId`, shape+para) and slide deletion clamps `active_idx` explicitly. ✅ F7c — live word count in loki-text's status bar (`editing/word_count.rs`: streaming counter, Word-matching semantics — tables counted, notes excluded; 8 tests; plural `editor-word-count` key). ✅ F6d-zoom (2 of 3 apps) — the status-bar zoom badge cycles 50–200% (`appthere_ui::next_zoom`): loki-text scales the GPU page tiles + paint transform together (`DocPageSource::zoom` → `DocumentViewProps::zoom` → `PageTile` hit-test divide-out; reflow unaffected by design), loki-presentation scales the slide box + text; **spreadsheet grid-zoom deferred** (`TODO(zoom)` — needs zoom-aware `col_px` + resize px↔pt in one pass). Ceiling wins: `document_view.rs` split (`view_types.rs`) resolved its baseline entry, plus `editor_save.rs` extracted from the spreadsheet (baseline 34 → 32). **Remaining tail:** F7a measurement plumbing for Calc/Slides (they never push a measured width, so `use_breakpoint` falls back to Expanded there) + spreadsheet grid-zoom. | M | +| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | ✅ **Done 2026-07-05** (spreadsheet grid-zoom is the sole tail). ✅ F6a — recent-file rows and template cards are now child `#[component]`s (`recent_row.rs`, `RecentRow`/`OpenFileButton`/`TemplateCard`/`BrowseCard`) so their hover `use_signal`s no longer live inside `for`/`if` bodies (hook count is now prop-independent; ADR-0013). ✅ F6d ribbon — loki-spreadsheet's ribbon lists only the implemented Home tab (the dead Insert/Format/Review/View entries removed) and its tab-select + collapse are live signals. ✅ F7a — `AtHomeTab` reads `use_breakpoint()` (Compact = stacked, Medium/Expanded = side-by-side) instead of the fixed `viewport_width = 375.0`. ✅ F7b — slide thumbnails/bullets use stable keys (`SlideId`, shape+para) and slide deletion clamps `active_idx` explicitly. ✅ F7c — live word count in loki-text's status bar (`editing/word_count.rs`: streaming counter, Word-matching semantics — tables counted, notes excluded; 8 tests; plural `editor-word-count` key). ✅ F6d-zoom (2 of 3 apps) — the status-bar zoom badge cycles 50–200% (`appthere_ui::next_zoom`): loki-text scales the GPU page tiles + paint transform together (`DocPageSource::zoom` → `DocumentViewProps::zoom` → `PageTile` hit-test divide-out; reflow unaffected by design), loki-presentation scales the slide box + text; **spreadsheet grid-zoom deferred** (`TODO(zoom)` — needs zoom-aware `col_px` + resize px↔pt in one pass). Ceiling wins: `document_view.rs` split (`view_types.rs`) resolved its baseline entry, plus `editor_save.rs` extracted from the spreadsheet (baseline 34 → 32). ~~**Remaining tail:** F7a measurement plumbing for Calc/Slides + spreadsheet grid-zoom~~ ✅ **Done 2026-07-11** — **F7a:** new shared `appthere_ui::AtViewportWidthSensor` (zero-height full-width scroll container: measures at mount, re-measures on the shell's post-resize `resync_scroll_geometry` onscroll tick); Presentation + Spreadsheet now call `use_provide_responsive()` and mount it, so `AtHomeTab`'s breakpoint tracks the real window. **Grid zoom (`TODO(zoom)`):** the status-bar badge cycles 50–200% and scales column widths, row heights, header sizes, and fonts; the document keeps unzoomed pt (resize commits divide the screen px back out, clamped in screen space; auto-fit converts its document-px estimate). `apply_change`/`sync_undo_redo` moved to `editor_mutate.rs`, dropping `editor_inner.rs` 1047 → 1014. | M | --- @@ -151,9 +151,9 @@ the Watch list). Every task here must update `docs/fidelity-status.md`. | 5.6 | gap #12 / `floating-image` | **Done ✅ 2026-07-09.** External-URL images already render a grey placeholder (`loki-vello/src/image.rs`). The remaining piece — detecting the `"floating"` class — is now honoured: an image tagged with `FLOATING_CLASS` but **no** explicit wrap keys (e.g. an anchored DOCX `wp:anchor` whose wrap child was absent/unrecognised — the mapper still adds the class) was previously read as `None` by `FloatWrap::read` and laid out **inline**. New `FloatWrap::read_or_class_default` falls back to a square/both-sides float for a class-only attr; `resolve.rs` uses it, so such images now flow as side-wrapping floats via the existing `flow_float` path. Tested: `class_only_attr_reads_as_default_float` + `inline_attr_reads_or_class_default_is_none` + `explicit_wrap_wins_over_class_default` (doc-model) and `flatten_class_only_floating_image_is_collected_as_float` (layout). | M | | 5.7 | `odf-master-page` | **Done ✅ 2026-07-09.** ODF master-page transitions — a paragraph whose style resolves a `style:master-page-name` different from the running master page begins a **new section on a new page** (the ODF equivalent of a Word section break) — are now imported end-to-end. The section-splitting loop was extracted from `document/mod.rs` into a cohesive `document/sections.rs` (`build_sections`), and a **root-cause bug** was fixed: a *leading* master-page declaration (the very first paragraph naming a non-default master) no longer emits a spurious empty preceding section — the flush is skipped when no blocks have accumulated. Each transitioned section carries the new master's page geometry + `page_style` ref (registered as a named page style, ADR-0012 Decision 2). Export already writes `style:master-page-name` on each section's first paragraph, so the transition round-trips. Tested: `master_page_transition_splits_into_sections`, `leading_master_page_declaration_does_not_emit_empty_section`, plus the existing `resolve_master_page_name` unit tests. | M | | 5.8 | `omml` | OMML↔MathML: delimiters, n-ary, matrices, accents (`docx/omml/mod.rs:20`). | L | -| 5.9 | gaps #23–#30 tail | ~~Kerning~~ (✅ **#23 done 2026-07-05**: root-caused by the Phase 3 calibration pass — loki kerned unconditionally while Word/LO default off; `CharProps.kerning` now drives a shaper feature toggle with reference-matching default, regression-locked, all three visual goldens green), ~~orphan/widow control~~ (✅ **done 2026-07-10** — honoured at layout time: a would-be orphan (lone first line at a page bottom) defers the whole paragraph, a would-be widow (lone last line atop the next page) pulls a line down; default 2 lines matching Word/LibreOffice's default-on, resolved on `ResolvedParaProps::{orphan_min,widow_min}` from `w:widowControl`/`fo:orphans`/`fo:widows`. Pure `flow_widow_orphan::resolve_split` applied in `split_and_place_loop`, termination-guarded. Tested by 8 unit tests + an end-to-end orphan-defer test; full loki-layout/loki-text/loki-acid suites green), ~~content controls~~ (✅ **done 2026-07-10** — a block-level `w:sdt` was **skipped**, dropping the paragraphs/tables inside every content control (cover pages, forms) — real data loss. The reader now **unwraps** `w:sdtContent` into the body (`reader/document_sdt.rs`, recursing into nested controls; the `DocxBodyChild::Sdt` placeholder variant removed). Tested by a reader unit test + an end-to-end import test. Tail: cell-level and inline `w:sdt`), `border_between`, DocxSettings, language tags — schedule individually from the fidelity registry. | L (aggregate) | +| 5.9 | gaps #23–#30 tail | ~~Kerning~~ (✅ **#23 done 2026-07-05**: root-caused by the Phase 3 calibration pass — loki kerned unconditionally while Word/LO default off; `CharProps.kerning` now drives a shaper feature toggle with reference-matching default, regression-locked, all three visual goldens green), ~~orphan/widow control~~ (✅ **done 2026-07-10** — honoured at layout time: a would-be orphan (lone first line at a page bottom) defers the whole paragraph, a would-be widow (lone last line atop the next page) pulls a line down; default 2 lines matching Word/LibreOffice's default-on, resolved on `ResolvedParaProps::{orphan_min,widow_min}` from `w:widowControl`/`fo:orphans`/`fo:widows`. Pure `flow_widow_orphan::resolve_split` applied in `split_and_place_loop`, termination-guarded. Tested by 8 unit tests + an end-to-end orphan-defer test; full loki-layout/loki-text/loki-acid suites green), ~~content controls~~ (✅ **done 2026-07-10** — a block-level `w:sdt` was **skipped**, dropping the paragraphs/tables inside every content control (cover pages, forms) — real data loss. The reader now **unwraps** `w:sdtContent` into the body (`reader/document_sdt.rs`, recursing into nested controls; the `DocxBodyChild::Sdt` placeholder variant removed). Tested by a reader unit test + an end-to-end import test. ~~Tail: cell-level and inline `w:sdt`~~ **verified already-unwrapped + hardened 2026-07-11**: the paragraph/cell dispatch loops never skipped unknown elements, so inline and cell-level `sdtContent` already parsed implicitly — the tail note was stale; behaviour is now regression-locked (inline runs + cell paragraphs) and both dispatches skip `w:sdtPr`/`w:sdtEndPr` wholesale like the block-level unwrapper), `border_between`, DocxSettings, language tags — schedule individually from the fidelity registry. | L (aggregate) | | 5.10 | registry | Page/column geometry set: ~~even/odd blank pages~~ (✅ **done 2026-07-09** — `evenPage`/`oddPage` section breaks now insert a blank filler page to reach the correct parity, `paginate_blanks`; tested end-to-end + unit), ~~unequal column widths~~ (✅ **done 2026-07-10** — per-column widths are now modelled (`SectionColumns.widths`) and honoured end-to-end: DOCX `w:cols w:equalWidth="0"`/`` read (`reader/sectpr.rs`, split out to hold the ceiling), mapped (`mapper/document_cols.rs`) and written back; ODF `style:column @style:rel-width` read + re-emitted (ratio-preserving); Loro bridge `KEY_COL_WIDTHS`; flow engine places each band at the cumulative width+gap offset (`flow_columns::column_layout_for`/`column_x_offset`). Tested at model/bridge, DOCX round-trip, ODF ratio, and layout-geometry layers), ~~column height balancing~~ (✅ **done 2026-07-10** — a standalone single-page multi-column section now balances its columns to roughly equal heights via a capped-height re-flow with a bounded binary search (`flow_balance.rs`); footnote-free single-page only, so footnotes/page-bottom content are untouched, and editing falls back to a full balanced relayout. Multi-page/continuous *last-page-only* balancing remains `TODO(column-balance-multipage)`. Tested by `short_multi_column_section_balances_across_columns`); ~~PDF font subsetting~~ (✅ **done 2026-07-10** — used-glyph subset via the `subsetter` crate, subset-tagged `BaseFont`, `CIDToGIDMap` remap so the content stream is untouched; full-font fallback for faces the subsetter rejects) + ~~ICC/CMYK~~ (✅ **done 2026-07-10** — a CMYK ICC `DestOutputProfile` is now embedded by default: a bundled **CC0/public-domain** compact profile characterising CGATS TR 001 (SWOP), from saucecontrol/Compact-ICC-Profiles, CC0 being Apache-2.0-compatible (`loki-pdf/assets/`). `OutputIntent::with_icc_profile` overrides it for certified press conditions. Tested by `default_intent_embeds_bundled_cmyk_profile` + the export-level `DestOutputProfile` assertions); **EPUB math** ✅ **done 2026-07-09** — `Inline::Math` is now emitted as native MathML into the EPUB XHTML with the `properties="mathml"` manifest declaration (was dropped); ~~EPUB fields/comments~~ ✅ **done 2026-07-10** (fields → static text from the `current_value` snapshot or metadata; commented ranges → an inline superscript ref marker + a trailing `