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/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 8c0f267b..b2285a3b 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -48,16 +48,17 @@ 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, 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..26f498e7 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::*; @@ -108,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. 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/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 `