diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000..0c80ba6d --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,29 @@ +# cargo-audit configuration. See `cargo audit --help` / the rustsec/rustsec repo +# for the full schema. + +[advisories] +ignore = [ + # RUSTSEC-2023-0071 — "Marvin Attack" timing side-channel in `rsa`'s + # PKCS#1 v1.5 decryption. No patched `rsa` release exists upstream (this + # advisory has carried "No fixed upgrade is available!" since 2023). + # + # Root-cause diagnosis (2026-07-08): `rsa` reaches this workspace only as + # an optional transitive dependency of `sqlx-mysql`, itself an optional + # dependency of the `sqlx` meta-crate. This workspace's `sqlx` dependency + # is declared with `default-features = false` and only the `postgres` + # feature enabled (see the root `Cargo.toml` — no crate here requests + # `mysql`). Confirmed with `cargo tree -i rsa` (and `cargo tree --target + # all -i rsa`) returning "nothing to print" even against a from-scratch + # `cargo generate-lockfile` — Cargo.lock lists `sqlx-mysql`/`rsa` because + # modern Cargo pre-resolves every optional dependency for lockfile + # stability, not because either crate is compiled into any workspace + # binary. The vulnerable `rsa` decryption code path is therefore never + # linked or reachable; there is nothing to vendor-patch. + # + # Re-verify this reasoning (`cargo tree -i rsa`, expect no output) before + # removing this ignore, and remove it immediately if any crate ever + # enables `sqlx`'s `mysql` feature — at that point `rsa` would become + # reachable and would need a real fix (vendored patch or an alternate + # MySQL driver). + "RUSTSEC-2023-0071", +] diff --git a/CLAUDE.md b/CLAUDE.md index 63715013..dd85ec09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,15 +150,22 @@ may not grow, and a file split to ≤300 must be removed from the baseline. So t backlog can only shrink. When you split a file below the ceiling, drop its line with `scripts/check-file-ceiling.py --update` (review the diff). -The split pass is **in progress** — current backlog is the **35** entries in the -baseline file. Two techniques: +The split pass is **in progress** — current backlog is the **29** entries in the +baseline file (a 2026-07-08 pass cut ~20 files: −3600 lines across eleven new +production submodules + seven inline-test extractions, driving `doc-model` +`document.rs` and `docx/mapper/props.rs` fully under the ceiling and off the +baseline). Three techniques (the third added 2026-07-08): 1. *Inline-test extraction* (safest, no production-code change): move a file's `#[cfg(test)] mod tests { … }` into a sibling `_tests.rs` referenced via `#[cfg(test)] #[path = "_tests.rs"] mod tests;`. Done 2026-06-21 for `block.rs`, `docx/mapper/{paragraph,numbering,mod,table}.rs`, `odt/import.rs`, `odt/mapper/lists.rs`, `layout/result.rs`, `renderer/render_layout.rs`, and - 2026-06-28 for `editing/hit_test.rs`, `xml_util.rs`, `pdf/src/page.rs` — each - was over the ceiling only because of a large inline test module. + 2026-06-28 for `editing/hit_test.rs`, `xml_util.rs`, `pdf/src/page.rs`, and + 2026-07-08 for `odt/reader/styles.rs` (1554 → 1298), `odt/reader/document.rs` + (1492 → 1002; ~490-line module), `loki-vello/scene.rs` (948 → 727), + `loki-odf/package.rs` (644 → 410), and `loki-ooxml/docx/mapper/document.rs` + (611 → 448) — each was over the ceiling only because of a large inline + test module (or, as with `styles.rs`, partly so). 2. *Directory split*: convert `foo.rs` → a `foo/` directory with section-cohesive submodules, re-export the public entry points from `foo/mod.rs`, and move the tests via the same `#[path]` idiom. Give each submodule its **own explicit @@ -167,22 +174,47 @@ baseline file. Two techniques: `odt/mapper/props/` and `odt/mapper/document.rs` → `odt/mapper/document/` (`mod`/`inlines`/`frames`/`blocks`/`page`/`meta`; worked examples). +3. *Cohesive-cluster extraction* (for a monolith whose tests are already + extracted, so technique 1 doesn't apply): move a self-contained group of + functions into a new `_helper.rs` sibling declared via + `#[path = "…"] mod ;`, accessing the parent's state through `super::` + (mark the shared items `pub(super)`). The new file must itself be ≤300. + Done 2026-07-08 for `loki-layout/src/flow.rs` (1948 → 1535, two cuts): the + four table-geometry helpers → `flow_table_geom.rs` (`table_geom` submodule), + and the PAGE/NUMPAGES field cluster → `flow_page_fields.rs` (`page_fields` + submodule; a `pub(crate)` item used elsewhere is re-exported from `flow.rs` + so its external path stays stable). Also `loki-layout/src/para.rs` + (1856 → 1698): the tab-stop cluster → `para_tabs.rs` (`tabs` submodule); and + `loki-layout/src/resolve.rs` (978 → 865): the `ParaProps`→`ResolvedParaProps` + mapping → `para_props_map.rs` (`para_map` submodule). A fourth variant is + *function-internal phase extraction* — a single >300-line function split by + moving self-contained phases into helper fns in a sibling module (thread the + captured locals as params; `#[allow(clippy::too_many_arguments)]` at the + narrowest scope is acceptable, per the `flow_cell_blocks` precedent). Done + 2026-07-08 for `flow.rs`'s `flow_table` (~420 lines): row-height measurement + + cell-decoration passes → `flow_table_paint.rs` (`table_paint` submodule), + `flow.rs` 1535 → 1362; and for `para.rs`'s `layout_paragraph_uncached` + (~630 lines): the two selection-geometry underlay passes (highlight fills + + spelling squiggles) → `para_underlays.rs` (`underlays` submodule), + `para.rs` 1698 → 1626; and `flow.rs`'s `flow_table` pass 3a (the per-cell + content-flow loop) → `flow_table_cells.rs` (`table_cells` submodule), + `flow.rs` 1362 → 1209 (this landed the `rotated-cell-editing` path in a + sub-ceiling module, unblocking deferred-feature 4b.5). (Test files are exempt from the production-line count.) | File | Current lines | Priority | |---|---|---| -| `loki-layout/src/para.rs` | 1979 | High | -| `loki-layout/src/flow.rs` | 1953 | High | -| `loki-odf/src/odt/reader/styles.rs` | 1554 | High | -| `loki-odf/src/odt/reader/document.rs` | 1494 | High | -| `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1244 | High | -| `loki-ooxml/src/docx/reader/document.rs` | 1209 | High | +| `loki-layout/src/para.rs` | 1626 | High | +| `loki-layout/src/flow.rs` | 1362 | High | | `loki-ooxml/src/docx/write/document.rs` | 1073 | High | -| `loki-layout/src/resolve.rs` | 984 | High | -| … 27 more (6 over 600, 21 in 300–600) — see `scripts/file-ceiling-baseline.txt` | | | +| `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-04; +*(Sizes above are from `scripts/file-ceiling-baseline.txt`, refreshed 2026-07-08; the earlier numbers were stale — several files grew since first baselined.)* (`odt/mapper/document.rs` (1094 lines) was split into the `odt/mapper/document/` @@ -205,6 +237,34 @@ but are **not perfectly round-tripped through the Loro CRDT**. --- +## Known tech debt — residual vulnerable transitive `quick-xml` copies (2026-07-08) + +This workspace's own `quick-xml` usage (`loki-epub`, `loki-odf`, `loki-ooxml`, +`loki-opc`) is on `0.41.0`, patched against RUSTSEC-2026-0194 (quadratic-time +duplicate-attribute check) and RUSTSEC-2026-0195 (unbounded namespace-allocation +DoS). `cargo audit` still reports both advisories against three *other*, +transitively-pulled `quick-xml` copies that this workspace does not control — +each is pinned by an intermediate crate's own manifest to a range that doesn't +yet reach `0.41`: + +| Locked version | Pulled in by | Actual exposure | +|---|---|---| +| `0.38.4` | `object_store` (`^0.38.0`, via the `aws` feature — `loki-server-store`'s S3 backend) | Parses XML API responses (list-bucket, multipart) from the configured object-storage endpoint. `object_store` 0.14.0 (latest at audit time) still only requires `quick-xml ^0.40.1`, which is *also* unpatched (fix requires `>=0.41.0`) — no released `object_store` version resolves this yet. | +| `0.39.4` | `wayland-scanner` → `smithay-client-toolkit` → `winit` → `blitz-shell` (`loki-presentation`) | Build-time codegen only: generates Rust bindings from the (trusted, locally-vendored) Wayland protocol XML. Not exposed to untrusted runtime input. | +| `0.30.0` | `zbus_xml` → `zbus-lockstep` → `atspi` → `accesskit_unix` → `accesskit_winit` → `blitz-shell` | Parses AT-SPI/D-Bus introspection XML on the local session bus (Linux accessibility stack) — local IPC, not attacker-controlled document content. | + +None of these are fixable by bumping our own `Cargo.toml` requirements — each is +gated behind an upstream crate release that hasn't caught up to `quick-xml` +0.41 yet. `object_store` is the one with real untrusted-network exposure and is +worth re-checking most often. Re-run `cargo audit` (or +`cargo tree -i quick-xml`) periodically and bump `object_store` / +`wayland-scanner`'s dependents the moment a release satisfies `quick-xml +>=0.41` — do not silence these via `.cargo/audit.toml` (unlike the +`rsa`/Marvin-Attack entry there, these three *are* actually compiled and +reachable). + +--- + ## Workspace layout & capabilities The workspace is a set of focused crates (one responsibility each). Key groups: diff --git a/Cargo.lock b/Cargo.lock index 8fde3dbc..288a1d0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4499,7 +4499,7 @@ dependencies = [ "chrono", "loki-doc-model", "loki-primitives", - "quick-xml 0.36.2", + "quick-xml 0.41.0", "thiserror 2.0.18", "zip", ] @@ -4601,7 +4601,7 @@ dependencies = [ "loki-layout", "loki-primitives", "loki-sheet-model", - "quick-xml 0.36.2", + "quick-xml 0.41.0", "thiserror 2.0.18", "zip", ] @@ -4623,7 +4623,7 @@ dependencies = [ "loki-primitives", "loki-sheet-model", "parley 0.10.0", - "quick-xml 0.36.2", + "quick-xml 0.41.0", "read-fonts 0.40.2", "thiserror 2.0.18", "zip", @@ -4635,7 +4635,7 @@ version = "0.1.0" dependencies = [ "approx", "chrono", - "quick-xml 0.36.2", + "quick-xml 0.41.0", "serde", "tempfile", "thiserror 2.0.18", @@ -4654,6 +4654,7 @@ dependencies = [ "loki-layout", "loki-primitives", "pdf-writer", + "subsetter", "thiserror 2.0.18", "ttf-parser", ] @@ -4984,6 +4985,7 @@ dependencies = [ "tracing", "unicode-segmentation", "vello", + "webbrowser", "wgpu_context", ] @@ -5551,9 +5553,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -6754,9 +6756,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -6764,21 +6766,21 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", - "serde", ] [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", + "serde", ] [[package]] @@ -8449,6 +8451,18 @@ dependencies = [ "serde", ] +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "kurbo 0.13.1", + "rustc-hash 2.1.3", + "skrifa 0.42.1", + "write-fonts", +] + [[package]] name = "subtle" version = "2.6.1" @@ -10607,6 +10621,19 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types 0.11.3", + "indexmap", + "kurbo 0.13.1", + "log", + "read-fonts 0.39.2", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/appthere-ui/src/components/icons.rs b/appthere-ui/src/components/icons.rs index c80229bd..2c583df3 100644 --- a/appthere-ui/src/components/icons.rs +++ b/appthere-ui/src/components/icons.rs @@ -85,6 +85,113 @@ pub const LUCIDE_TABLE: &str = /// down-step baseline with a raised reference tick, evoking a footnote marker. pub const LUCIDE_FOOTNOTE: &str = "M4 5h6M4 5v10a3 3 0 0 0 6 0M16 5v6m0-6h4m-4 0-1 1"; +/// Lucide `more-horizontal` — three dots. Used for the ribbon overflow ("More") +/// menu button. Rendered as three round-capped zero-length strokes (Lucide's own +/// dot idiom), so it needs `stroke-linecap: round` (which [`AtIcon`] sets). +pub const LUCIDE_MORE_HORIZONTAL: &str = "M5 12h.01M12 12h.01M19 12h.01"; + +/// Lucide `trash-2` — a waste bin with lid and two vertical bars. Used for the +/// Table contextual tab's Delete Table action. +pub const LUCIDE_TRASH_2: &str = + "M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"; + +// App-custom table-op glyphs (not Lucide): a box for the affected row/column +// plus a `+` / `−`, drawn in the same 24×24 stroked style as the Lucide set. + +/// Insert row below: a horizontal bar with a plus beneath it. +pub const AT_TABLE_ROW_INSERT: &str = "M4 4h16v6h-16zM12 14v6M9 17h6"; + +/// Insert row above: a horizontal bar with a plus above it. +pub const AT_TABLE_ROW_INSERT_ABOVE: &str = "M4 14h16v6h-16zM12 4v6M9 7h6"; + +/// Delete row: a horizontal bar with a minus below it. +pub const AT_TABLE_ROW_DELETE: &str = "M4 4h16v6h-16zM9 17h6"; + +/// Insert column right: a vertical bar with a plus to its right. +pub const AT_TABLE_COL_INSERT: &str = "M4 4h6v16h-6zM17 9v6M14 12h6"; + +/// Insert column left: a vertical bar with a plus to its left. +pub const AT_TABLE_COL_INSERT_LEFT: &str = "M14 4h6v16h-6zM7 9v6M4 12h6"; + +/// Delete column: a vertical bar with a minus to its right. +pub const AT_TABLE_COL_DELETE: &str = "M4 4h6v16h-6zM14 12h6"; + +// App-custom glyphs (not Lucide): an "A" beside an up/down arrow, for the +// grow/shrink font-size buttons. + +/// Increase font size: an "A" with an upward arrow. +pub const AT_FONT_GROW: &str = "M6 15 10 7 14 15M7.5 12h5M18 8v7M15.5 10.5 18 8 20.5 10.5"; + +/// Decrease font size: an "A" with a downward arrow. +pub const AT_FONT_SHRINK: &str = "M6 15 10 7 14 15M7.5 12h5M18 8v7M15.5 12.5 18 15 20.5 12.5"; + +// App-custom page-orientation glyphs (not Lucide): a tall vs. wide page rect. + +/// Portrait orientation: a tall page rectangle. +pub const AT_PAGE_PORTRAIT: &str = "M7 3h10v18H7z"; + +/// Landscape orientation: a wide page rectangle. +pub const AT_PAGE_LANDSCAPE: &str = "M3 7h18v10H3z"; + +// App-custom margin-preset glyphs (not Lucide): a page rectangle with an inner +// content rectangle whose inset shows the margin size. Disambiguated by tooltip. + +/// Normal margins: a page with a moderate inset content area. +pub const AT_MARGIN_NORMAL: &str = "M5 3h14v18H5zM8 6h8v12H8z"; + +/// Narrow margins: a page with a small inset (large content area). +pub const AT_MARGIN_NARROW: &str = "M5 3h14v18H5zM6.5 4.5h11v15h-11z"; + +/// Wide margins: a page with a wide horizontal inset (narrow content area). +pub const AT_MARGIN_WIDE: &str = "M5 3h14v18H5zM9 6h6v12H9z"; + +// App-custom page-size glyphs (not Lucide): page rectangles of the paper's +// aspect ratio. Disambiguated by tooltip. + +/// A4 paper: a tall, narrow page (≈1:1.41). +pub const AT_PAGE_A4: &str = "M7 3h10v18H7z"; + +/// US Letter paper: a slightly wider, shorter page (≈1:1.29). +pub const AT_PAGE_LETTER: &str = "M6 4h12v16H6z"; + +// App-custom column-count glyphs (not Lucide): a page with N-1 vertical +// divider lines. + +/// One column: a plain page. +pub const AT_COLUMNS_ONE: &str = "M5 4h14v16H5z"; + +/// Two columns: a page split by one vertical divider. +pub const AT_COLUMNS_TWO: &str = "M5 4h14v16H5zM12 4v16"; + +/// Three columns: a page split by two vertical dividers. +pub const AT_COLUMNS_THREE: &str = "M5 4h14v16H5zM9.7 4v16M14.3 4v16"; + +// App-custom References-tab glyphs (not Lucide). + +/// Insert table of contents: stacked outline entries of varying, indented width. +pub const AT_TOC_INSERT: &str = "M4 5h16M4 10h10M8 15h12M4 20h9"; + +/// Update table of contents: a clockwise refresh arrow (regenerate the field). +pub const AT_TOC_UPDATE: &str = "M20 11A8 8 0 1 0 18 16M20 5v6h-6"; + +// App-custom Review-tab glyphs (not Lucide). + +/// Track changes: a pencil writing over a baseline (edits are recorded). +pub const AT_TRACK_CHANGES: &str = "M4 21h8M14.5 4.5l5 5L9 20l-5 1 1-5z"; + +/// Accept all changes: a check mark. +pub const AT_CHANGE_ACCEPT: &str = "M20 6 9 17l-5-5"; + +/// Reject all changes: a cross. +pub const AT_CHANGE_REJECT: &str = "M18 6 6 18M6 6l12 12"; + +/// Accept the change at the caret: a check inside a circle. +pub const AT_CHANGE_ACCEPT_ONE: &str = + "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M8.5 12.5l2.5 2.5 4.5-5.5"; + +/// Reject the change at the caret: a cross inside a circle. +pub const AT_CHANGE_REJECT_ONE: &str = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M15 9l-6 6M9 9l6 6"; + // ── AtIcon component ────────────────────────────────────────────────────────── /// Renders a single Lucide SVG icon. diff --git a/appthere-ui/src/components/ribbon/group.rs b/appthere-ui/src/components/ribbon/group.rs index 6f7e16a0..8b7577d8 100644 --- a/appthere-ui/src/components/ribbon/group.rs +++ b/appthere-ui/src/components/ribbon/group.rs @@ -15,25 +15,60 @@ use dioxus::prelude::*; +use crate::responsive::{group_layout, GroupCollapse}; use crate::tokens; use crate::tokens::FONT_FAMILY_UI; /// A labelled cluster of related ribbon buttons with a vertical divider. /// +/// The [`collapse`](AtRibbonGroupProps::collapse) prop drives the group through +/// the Spec 04 M3 §7 cascade: +/// +/// - [`GroupCollapse::Full`] — labelled group, normal padding (the default). +/// - [`GroupCollapse::Condensed`] — the label drops and the buttons pack tighter +/// (narrower padding / gap) to reclaim strip width before any group overflows. +/// - [`GroupCollapse::Overflow`] — the group has moved into the overflow ("More") +/// menu, so it renders **nothing** in the strip (the menu hosts it instead). +/// /// # Minimum touch target /// /// This component is a layout container; buttons inside must individually -/// satisfy the 44 × 44 px WCAG 2.5.8 minimum touch target. +/// satisfy the 44 × 44 px WCAG 2.5.8 minimum touch target. The condensed state +/// only tightens the *inter-control* spacing, never the buttons' own size, so +/// touch targets are preserved. #[component] pub fn AtRibbonGroup( /// Short label shown below the button row (e.g. "Clipboard"). - /// Pass `None` to omit the label. + /// Pass `None` to omit the label. The label is also hidden in the + /// [`GroupCollapse::Condensed`] state regardless of this value. label: Option, /// ARIA group label for accessibility (`role="group"` `aria-label`). aria_label: String, + /// Cascade display state (Spec 04 M3 §7). Defaults to [`GroupCollapse::Full`] + /// so existing call sites keep their full labelled appearance. + #[props(default = GroupCollapse::Full)] + collapse: GroupCollapse, /// Buttons and controls inside this group. children: Element, ) -> Element { + // Expose the collapse state to descendant controls (e.g. `AtRibbonSelect`) + // so they can adapt their own sizing (R-13e). A *signal* context, not a plain + // value: prop-memoised children would otherwise miss the change on resize — + // reading a signal subscribes them, so the select re-sizes reactively. Must + // run before any early return to keep the hook order stable (rules of hooks). + let mut collapse_ctx = use_signal(|| collapse); + if *collapse_ctx.peek() != collapse { + collapse_ctx.set(collapse); + } + use_context_provider(|| collapse_ctx); + + // The pure cascade helper decides render/pad/gap/label for this state. + let lay = group_layout(collapse, label.is_some()); + // Overflowed groups live in the "More" menu, not the strip. + if !lay.rendered { + return rsx! {}; + } + rsx! { div { role: "group", @@ -45,23 +80,26 @@ pub fn AtRibbonGroup( // deliberate choice, no longer a Blitz limitation.) style: format!( "display: flex; flex-direction: column; align-items: center; \ - height: 100%; padding: 0 {p}px; \ + height: 100%; padding: 0 {pad}px; \ border-right: 1px solid {border}; box-sizing: border-box;", // TODO(ribbon): Consider a variant prop to suppress the trailing // divider on the last group in a tab. - p = tokens::SPACE_2, + pad = lay.pad_px, border = tokens::COLOR_BORDER_CHROME, ), // Button row (fills available height minus optional label row) div { - style: "display: flex; flex-direction: row; align-items: center; \ - flex: 1; gap: 2px;", + style: format!( + "display: flex; flex-direction: row; align-items: center; \ + flex: 1; gap: {gap}px;", + gap = lay.gap_px, + ), {children} } - // Optional label row below buttons - if let Some(ref lbl) = label { + // Optional label row below buttons (dropped when condensed) + if lay.show_label { div { style: format!( // TODO(font): verify Atkinson Hyperlegible Next is @@ -73,7 +111,7 @@ pub fn AtRibbonGroup( size = tokens::FONT_SIZE_XS, fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, ), - "{lbl}" + "{label.as_deref().unwrap_or_default()}" } } } diff --git a/appthere-ui/src/components/ribbon/groups.rs b/appthere-ui/src/components/ribbon/groups.rs new file mode 100644 index 00000000..ddde9f46 --- /dev/null +++ b/appthere-ui/src/components/ribbon/groups.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! [`AtRibbonGroups`] — the collapse-aware container for a tab's groups. +//! +//! A tab supplies its groups as a `Vec<`[`RibbonGroupSpec`]`>` (each = its +//! collapse [`GroupMetrics`] plus the group's label / aria / button content). +//! This component runs the width-driven cascade +//! ([`use_ribbon_cascade`](crate::use_ribbon_cascade)) once for the whole strip +//! and renders each group in its resolved [`GroupCollapse`] state, moving +//! overflowed groups into a trailing "More" menu (Spec 04 M3 §6–§7). +//! +//! The framework owns the cascade so every app's tabs get the same behaviour; +//! the app only declares *what* the groups are, never *how* they collapse. + +use dioxus::prelude::*; + +use super::button::AtRibbonIconButton; +use super::group::AtRibbonGroup; +use crate::components::icons::{AtIcon, LUCIDE_MORE_HORIZONTAL}; +use crate::responsive::{use_ribbon_cascade, GroupCollapse, GroupMetrics}; +use crate::tokens; + +/// One group's declaration: its collapse metrics, label, aria label, and the +/// button/control content (the group body, *without* the surrounding +/// [`AtRibbonGroup`] — the container wraps it at the resolved collapse state). +#[derive(Clone, PartialEq)] +pub struct RibbonGroupSpec { + /// Collapse priority + full/condensed widths (see [`GroupMetrics`]; build via + /// [`estimate_group_metrics`](crate::estimate_group_metrics) for icon groups). + pub metrics: GroupMetrics, + /// Group label shown below the buttons in the Full state (`None` = no label). + pub label: Option, + /// ARIA group label. + pub aria_label: String, + /// The group's buttons/controls. + pub content: Element, +} + +/// Renders a tab's [`RibbonGroupSpec`]s through the width-driven collapse cascade. +/// +/// # Touch target +/// +/// Structural container. The "More" button and each group's buttons carry their +/// own 44 × 44 px targets (WCAG 2.5.8). +#[component] +pub fn AtRibbonGroups( + /// The active tab's groups, left-to-right. + groups: Vec, + /// Accessible name for the overflow ("More") button — the translated + /// "More controls" string from the caller. + overflow_aria_label: String, +) -> Element { + let metrics: Vec = groups.iter().map(|g| g.metrics).collect(); + let cascade = use_ribbon_cascade(metrics); + let mut menu_open = use_signal(|| false); + + // Partition into in-strip groups (with their state) and overflowed groups. + let overflowed: Vec<&RibbonGroupSpec> = groups + .iter() + .zip(&cascade.states) + .filter(|(_, s)| **s == GroupCollapse::Overflow) + .map(|(g, _)| g) + .collect(); + + // A widen (or content change) that removes the overflow must not leave a + // 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); + } + + rsx! { + // In-strip groups, each at its resolved collapse state. + for (spec, state) in groups.iter().zip(cascade.states.iter()) { + if *state != GroupCollapse::Overflow { + AtRibbonGroup { + key: "{spec.aria_label}", + label: spec.label.clone(), + aria_label: spec.aria_label.clone(), + collapse: *state, + {spec.content.clone()} + } + } + } + + // Overflow ("More") button + upward dropdown when any group overflowed. + if cascade.overflow { + div { + // Positioned wrapper so the dropdown anchors to the button. + style: "position: relative; display: flex; align-items: center; \ + height: 100%;", + + AtRibbonIconButton { + aria_label: overflow_aria_label, + is_active: menu_open(), + is_disabled: false, + on_click: move |_| { + let open = menu_open(); + menu_open.set(!open); + }, + AtIcon { path_d: LUCIDE_MORE_HORIZONTAL.to_string() } + } + + if menu_open() { + // The menu opens upward (the ribbon sits at the window bottom), + // 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. + div { + style: format!( + "position: absolute; bottom: 100%; right: 0; z-index: 41; \ + display: flex; flex-direction: column; gap: {gap}px; \ + padding: {pad}px; background: {bg}; \ + border: 1px solid {border}; border-radius: {radius}px;", + gap = tokens::SPACE_2, + pad = tokens::SPACE_2, + bg = tokens::COLOR_SURFACE_2, + border = tokens::COLOR_BORDER_CHROME, + radius = tokens::RADIUS_MD, + ), + // Overflowed groups render in Full form inside the menu. + for spec in overflowed.iter() { + AtRibbonGroup { + key: "{spec.aria_label}", + label: spec.label.clone(), + aria_label: spec.aria_label.clone(), + collapse: GroupCollapse::Full, + {spec.content.clone()} + } + } + } + } + } + } + } +} diff --git a/appthere-ui/src/components/ribbon/mod.rs b/appthere-ui/src/components/ribbon/mod.rs index 13d8bffb..d45b8464 100644 --- a/appthere-ui/src/components/ribbon/mod.rs +++ b/appthere-ui/src/components/ribbon/mod.rs @@ -31,12 +31,14 @@ pub mod button; pub mod content_row; pub mod group; +pub mod groups; pub mod select; pub mod tab_strip; pub use button::AtRibbonIconButton; pub use content_row::AtRibbonContent; pub use group::{AtRibbonGroup, AtRibbonGroupProps}; +pub use groups::{AtRibbonGroups, AtRibbonGroupsProps, RibbonGroupSpec}; pub use select::AtRibbonSelect; pub use tab_strip::AtRibbonTabStrip; diff --git a/appthere-ui/src/components/ribbon/select.rs b/appthere-ui/src/components/ribbon/select.rs index 328569f4..d7e52703 100644 --- a/appthere-ui/src/components/ribbon/select.rs +++ b/appthere-ui/src/components/ribbon/select.rs @@ -13,11 +13,13 @@ use dioxus::prelude::*; +use crate::responsive::GroupCollapse; use crate::tokens::{ colors::{ COLOR_BORDER_CHROME, COLOR_SURFACE_3, COLOR_TAB_ACTIVE_INDICATOR, COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, }, + layout::{RIBBON_SELECT_WIDTH_CONDENSED_PX, RIBBON_SELECT_WIDTH_PX}, spacing::{SPACE_2, TOUCH_MIN}, typography::{FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_REGULAR}, }; @@ -60,6 +62,17 @@ pub fn AtRibbonSelect(props: AtRibbonSelectProps) -> Element { } else { "transparent" }; + // R-13e: shrink in a condensed group. The ambient collapse state is provided + // by the enclosing `AtRibbonGroup` as a signal, so reading it here re-sizes + // the select reactively when the collapse cascade changes on resize. Absent + // (used outside a group) → full width. + let condensed = try_consume_context::>() + .is_some_and(|c| *c.read() == GroupCollapse::Condensed); + let width = if condensed { + RIBBON_SELECT_WIDTH_CONDENSED_PX + } else { + RIBBON_SELECT_WIDTH_PX + }; rsx! { button { @@ -70,7 +83,7 @@ pub fn AtRibbonSelect(props: AtRibbonSelectProps) -> Element { font-family: {ff}; font-size: {fs}px; font-weight: {fw}; \ color: {fg}; cursor: pointer; flex-shrink: 0;", gap = SPACE_2, - w = 180, + w = width, h = TOUCH_MIN, p = SPACE_2, bg = bg_color, diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index b9a8da59..8c0f267b 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -32,14 +32,20 @@ pub mod theme; pub mod tokens; pub use components::icons::{ - AtIcon, LUCIDE_ALIGN_CENTER, LUCIDE_ALIGN_JUSTIFY, LUCIDE_ALIGN_LEFT, LUCIDE_ALIGN_RIGHT, - LUCIDE_BOLD, LUCIDE_DOWNLOAD, LUCIDE_FOOTNOTE, LUCIDE_IMAGE, LUCIDE_ITALIC, - LUCIDE_LAYOUT_TEMPLATE, LUCIDE_LINK, LUCIDE_PILCROW, LUCIDE_REDO, LUCIDE_SAVE, - LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, LUCIDE_SUPERSCRIPT, LUCIDE_TABLE, LUCIDE_UNDERLINE, - LUCIDE_UNDO, + AtIcon, AT_CHANGE_ACCEPT, AT_CHANGE_ACCEPT_ONE, AT_CHANGE_REJECT, AT_CHANGE_REJECT_ONE, + AT_COLUMNS_ONE, AT_COLUMNS_THREE, AT_COLUMNS_TWO, AT_FONT_GROW, AT_FONT_SHRINK, + AT_MARGIN_NARROW, AT_MARGIN_NORMAL, AT_MARGIN_WIDE, AT_PAGE_A4, AT_PAGE_LANDSCAPE, + AT_PAGE_LETTER, AT_PAGE_PORTRAIT, AT_TABLE_COL_DELETE, AT_TABLE_COL_INSERT, + AT_TABLE_COL_INSERT_LEFT, AT_TABLE_ROW_DELETE, AT_TABLE_ROW_INSERT, AT_TABLE_ROW_INSERT_ABOVE, + AT_TOC_INSERT, AT_TOC_UPDATE, AT_TRACK_CHANGES, LUCIDE_ALIGN_CENTER, LUCIDE_ALIGN_JUSTIFY, + LUCIDE_ALIGN_LEFT, LUCIDE_ALIGN_RIGHT, LUCIDE_BOLD, LUCIDE_DOWNLOAD, LUCIDE_FOOTNOTE, + LUCIDE_IMAGE, LUCIDE_ITALIC, LUCIDE_LAYOUT_TEMPLATE, LUCIDE_LINK, LUCIDE_MORE_HORIZONTAL, + LUCIDE_PILCROW, LUCIDE_REDO, LUCIDE_SAVE, LUCIDE_STRIKETHROUGH, LUCIDE_SUBSCRIPT, + LUCIDE_SUPERSCRIPT, LUCIDE_TABLE, LUCIDE_TRASH_2, LUCIDE_UNDERLINE, LUCIDE_UNDO, }; pub use components::ribbon::{ - AtRibbon, AtRibbonGroup, AtRibbonIconButton, AtRibbonSelect, RibbonTabDesc, RibbonTabIndex, + AtRibbon, AtRibbonGroup, AtRibbonGroups, AtRibbonIconButton, AtRibbonSelect, RibbonGroupSpec, + RibbonTabDesc, RibbonTabIndex, }; pub use components::{ next_zoom, AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, @@ -48,8 +54,10 @@ pub use components::{ PanelPosture, Platform, RecentDocument, }; pub use responsive::{ - page_fits, required_page_width, resolve_page_fit, use_breakpoint, use_provide_responsive, - use_responsive, use_viewport, AtResponsiveContext, Breakpoint, PageFit, Viewport, DEFAULT_DPI, + 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, }; 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 597111cf..0f1780df 100644 --- a/appthere-ui/src/responsive/mod.rs +++ b/appthere-ui/src/responsive/mod.rs @@ -29,10 +29,15 @@ mod breakpoint; mod page_fit; +mod ribbon_collapse; mod viewport; pub use breakpoint::Breakpoint; pub use page_fit::{page_fits, required_page_width, resolve_page_fit, PageFit}; +pub use ribbon_collapse::{ + estimate_group_metrics, group_layout, resolve_cascade, GroupCollapse, GroupLayout, + GroupMetrics, RibbonCascade, +}; pub use viewport::{Viewport, DEFAULT_DPI}; use dioxus::prelude::*; @@ -96,3 +101,42 @@ pub fn use_breakpoint() -> Breakpoint { None => Breakpoint::Expanded, } } + +/// Resolves the width-driven ribbon collapse cascade (Spec 04 M3 §7) for the +/// given per-group `metrics`, reactively against the measured viewport width and +/// hysteretically (the previously-resolved level is retained across resizes to +/// 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. +/// +/// The hysteresis state (the resolved collapse `level`) lives in a hook-local +/// signal, so call this once per ribbon content strip. +#[must_use] +pub fn use_ribbon_cascade(metrics: Vec) -> RibbonCascade { + let ctx = try_consume_context::(); + // No context → unbounded width → nothing collapses (full-chrome default). + let read_width = move || ctx.map_or(f32::MAX, |c| c.viewport.read().inner_width_px); + let mut level = use_signal(|| 0usize); + + // Advance the hysteretic level whenever the measured width changes. Reading + // the viewport inside the effect subscribes it to width updates. + { + let metrics = metrics.clone(); + use_effect(move || { + let prev = *level.peek(); + let next = resolve_cascade(&metrics, read_width(), prev).level; + if next != prev { + level.set(next); + } + }); + } + + // Build the returned cascade from the settled level and the current width; + // resolution is idempotent at a fixed width, so this agrees with the effect. + let settled = *level.read(); + resolve_cascade(&metrics, read_width(), settled) +} diff --git a/appthere-ui/src/responsive/ribbon_collapse.rs b/appthere-ui/src/responsive/ribbon_collapse.rs new file mode 100644 index 00000000..0c132d48 --- /dev/null +++ b/appthere-ui/src/responsive/ribbon_collapse.rs @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Width-driven ribbon collapse cascade (Spec 04 M3 §7). +//! +//! **Decision D3: collapse is width-driven, not tier-driven.** The breakpoint +//! sets defaults, but this engine measures the available width and collapses +//! groups by declared priority until they fit. Per group, the cascade is +//! Full → Condensed → Overflow → (horizontal-scroll floor). +//! +//! # Cascade policy +//! +//! Groups collapse in **priority order** — a lower [`GroupMetrics::priority`] +//! collapses before a higher one (ties break by original left-to-right order). +//! Degradation is graceful: the engine first *condenses* groups (lowest priority +//! first, preserving as much labelled density as possible), and only once every +//! group is condensed does it start *overflowing* whole groups into the "More" +//! menu (again lowest priority first). This keeps the most-used, highest-priority +//! groups fully visible the longest. +//! +//! # Hysteresis +//! +//! The decision is **hysteretic** (like Spec 03's `page_fit`): the strip +//! collapses one step further the instant it overflows, but re-expands a step +//! only when the less-collapsed layout clears the available width by +//! [`RIBBON_COLLAPSE_HYSTERESIS_PX`]. So dragging a window back and forth across +//! a fit threshold does not thrash. The result is idempotent at a fixed width. +//! +//! # Pure and testable +//! +//! All math is in CSS px and takes caller-measured group widths, so the cascade +//! is unit-testable without a Blitz runtime (Spec 03 D1). Wiring the actual +//! per-group width measurement and the overflow-menu UI into `AtRibbon` builds +//! on top of this engine. + +use crate::tokens::layout::{RIBBON_COLLAPSE_HYSTERESIS_PX, RIBBON_OVERFLOW_BUTTON_PX}; +use crate::tokens::spacing::{SPACE_1, SPACE_2, TOUCH_MIN}; + +/// Inter-control gap (CSS px) inside a full group's button row — matches the +/// `gap` [`group_layout`] returns for [`GroupCollapse::Full`]. +const GROUP_BUTTON_GAP_PX: f32 = 2.0; + +/// How a single ribbon group is displayed at the resolved collapse level. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroupCollapse { + /// Labelled group, full-size controls, group label visible. + Full, + /// Controls pack tighter; the label may drop and low-priority controls may + /// merge into a dropdown. + Condensed, + /// The whole group has moved into the overflow ("More") menu. + Overflow, +} + +/// A group's occupied width (CSS px) in the Full and Condensed states, plus its +/// collapse priority. An overflowed group occupies no strip width (it lives in +/// the "More" menu); the menu button's own width is added once when any group +/// overflows. +/// +/// `condensed_px` should be `<= full_px` and, for graceful degradation, at least +/// [`RIBBON_OVERFLOW_BUTTON_PX`] (a group narrower than the "More" chip would not +/// save strip width by overflowing) — real ribbon groups satisfy both. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroupMetrics { + /// Higher = kept full longer. Lower-priority groups condense and overflow + /// first. + pub priority: u8, + /// Width occupied in [`GroupCollapse::Full`]. + pub full_px: f32, + /// Width occupied in [`GroupCollapse::Condensed`]. + pub condensed_px: f32, +} + +/// The resolved cascade for one ribbon content strip. +#[derive(Clone, Debug, PartialEq)] +pub struct RibbonCascade { + /// Per-group display state, in the caller's original group order. + pub states: Vec, + /// Number of collapse steps applied (0 = all Full; `2 * groups` = all + /// overflowed). Carry this back in as `prev_level` next resize for hysteresis. + pub level: usize, + /// Whether the overflow ("More") menu button is shown (≥1 group overflowed). + pub overflow: bool, + /// Whether even the fully-overflowed strip still exceeds the width — the + /// horizontal-scroll floor (§7 step 4), never the first resort. + pub scroll: bool, +} + +/// A declared width estimate for a group of `buttons` touch-sized icon buttons +/// at collapse `priority`. +/// +/// The ribbon collapse engine is width-driven off *available* space (measured +/// once, at the viewport) but takes each group's own width as a **declaration**, +/// not a Blitz per-element measurement (which is unreliable) — mirroring how +/// desktop ribbons size groups from their control set. This derives that +/// declaration from the button count: full width is the buttons at +/// [`TOUCH_MIN`] plus inter-control gaps and roomy side padding; condensed drops +/// the gaps and tightens the padding (consistent with [`group_layout`]). Groups +/// with wider controls (e.g. a font-family select) should build [`GroupMetrics`] +/// directly instead. +#[must_use] +pub fn estimate_group_metrics(priority: u8, buttons: usize, has_label: bool) -> GroupMetrics { + let _ = has_label; // label width is bounded below the button row in practice + let n = buttons.max(1) as f32; + let content = n * TOUCH_MIN; + let full_px = content + (n - 1.0) * GROUP_BUTTON_GAP_PX + 2.0 * SPACE_2; + let condensed_px = content + 2.0 * SPACE_1; + GroupMetrics { + priority, + full_px, + condensed_px, + } +} + +/// The in-strip layout a group adopts for a given [`GroupCollapse`] state: +/// whether it renders in the strip at all, its horizontal padding and +/// inter-control gap (CSS px), and whether its label row shows. +/// +/// Pure so the ribbon group's visual cascade (§7 step 2) is testable without a +/// Blitz runtime; [`AtRibbonGroup`](crate::AtRibbonGroup) applies it directly. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroupLayout { + /// `false` for [`GroupCollapse::Overflow`] — the group lives in the "More" + /// menu and paints nothing in the strip. + pub rendered: bool, + /// Horizontal padding on each side of the group (CSS px). + pub pad_px: f32, + /// Gap between the group's controls (CSS px). + pub gap_px: f32, + /// Whether the group's label row shows (dropped when condensed). + pub show_label: bool, +} + +/// The strip layout for a group in `collapse`, given whether it declares a label. +/// +/// Condensed reclaims width by dropping the label and tightening padding/gap; it +/// never shrinks the buttons themselves, so touch targets are preserved. +#[must_use] +pub fn group_layout(collapse: GroupCollapse, has_label: bool) -> GroupLayout { + match collapse { + GroupCollapse::Full => GroupLayout { + rendered: true, + pad_px: SPACE_2, + gap_px: 2.0, + show_label: has_label, + }, + GroupCollapse::Condensed => GroupLayout { + rendered: true, + pad_px: SPACE_1, + gap_px: 0.0, + show_label: false, + }, + GroupCollapse::Overflow => GroupLayout { + rendered: false, + pad_px: 0.0, + gap_px: 0.0, + show_label: false, + }, + } +} + +/// Indices of `metrics` in collapse order: ascending priority, ties by original +/// order (a stable sort of the index list). +fn collapse_order(metrics: &[GroupMetrics]) -> Vec { + let mut order: Vec = (0..metrics.len()).collect(); + order.sort_by_key(|&i| (metrics[i].priority, i)); + order +} + +/// The per-group states after applying `level` collapse steps. Steps `1..=n` +/// condense groups in collapse order; steps `n+1..=2n` overflow them (a group is +/// already condensed before it overflows). +fn states_at_level(metrics: &[GroupMetrics], order: &[usize], level: usize) -> Vec { + let n = metrics.len(); + let condense_count = level.min(n); + let overflow_count = level.saturating_sub(n).min(n); + let mut states = vec![GroupCollapse::Full; n]; + for (rank, &idx) in order.iter().enumerate() { + states[idx] = if rank < overflow_count { + GroupCollapse::Overflow + } else if rank < condense_count { + GroupCollapse::Condensed + } else { + GroupCollapse::Full + }; + } + states +} + +/// The width (CSS px) the strip occupies for `states`, including the "More" +/// button once when any group has overflowed. +fn strip_width(metrics: &[GroupMetrics], states: &[GroupCollapse]) -> f32 { + let mut width = 0.0; + let mut any_overflow = false; + for (m, s) in metrics.iter().zip(states) { + match s { + GroupCollapse::Full => width += m.full_px, + GroupCollapse::Condensed => width += m.condensed_px, + GroupCollapse::Overflow => any_overflow = true, + } + } + if any_overflow { + width += RIBBON_OVERFLOW_BUTTON_PX; + } + width +} + +/// Resolves the collapse cascade for `available_px` of strip width, given the +/// previously-resolved `prev_level` (pass `0` on first layout). +/// +/// Hysteretic: collapses further the moment the strip overflows, re-expands only +/// when the less-collapsed layout clears `available_px` by +/// [`RIBBON_COLLAPSE_HYSTERESIS_PX`]. An unmeasured width (`<= 0`) holds +/// `prev_level` unchanged (nothing to decide yet). +#[must_use] +pub fn resolve_cascade( + metrics: &[GroupMetrics], + available_px: f32, + prev_level: usize, +) -> RibbonCascade { + let n = metrics.len(); + let max_level = 2 * n; + let order = collapse_order(metrics); + let width_at = |level: usize| strip_width(metrics, &states_at_level(metrics, &order, level)); + + let mut level = prev_level.min(max_level); + if available_px > 0.0 { + // Collapse further while the strip overflows and steps remain. + while level < max_level && width_at(level) > available_px { + level += 1; + } + // Re-expand while the next-looser level clears the width by the band. + while level > 0 && width_at(level - 1) + RIBBON_COLLAPSE_HYSTERESIS_PX <= available_px { + level -= 1; + } + } + + let states = states_at_level(metrics, &order, level); + let overflow = level > n; + let scroll = level >= max_level && width_at(max_level) > available_px && available_px > 0.0; + RibbonCascade { + states, + level, + overflow, + scroll, + } +} + +#[cfg(test)] +#[path = "ribbon_collapse_tests.rs"] +mod tests; diff --git a/appthere-ui/src/responsive/ribbon_collapse_tests.rs b/appthere-ui/src/responsive/ribbon_collapse_tests.rs new file mode 100644 index 00000000..d902a62f --- /dev/null +++ b/appthere-ui/src/responsive/ribbon_collapse_tests.rs @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for the width-driven ribbon collapse cascade (Spec 04 M3 §7). + +use super::{estimate_group_metrics, group_layout, resolve_cascade, GroupCollapse, GroupMetrics}; +use crate::tokens::layout::{RIBBON_COLLAPSE_HYSTERESIS_PX, RIBBON_OVERFLOW_BUTTON_PX}; +use crate::tokens::spacing::{SPACE_1, SPACE_2, TOUCH_MIN}; + +/// Three groups, priorities low→high left→right, each 100 px full / 50 px +/// condensed. Total full = 300. +fn groups() -> Vec { + vec![ + GroupMetrics { + priority: 0, + full_px: 100.0, + condensed_px: 50.0, + }, + GroupMetrics { + priority: 1, + full_px: 100.0, + condensed_px: 50.0, + }, + GroupMetrics { + priority: 2, + full_px: 100.0, + condensed_px: 50.0, + }, + ] +} + +#[test] +fn everything_full_when_it_all_fits() { + let c = resolve_cascade(&groups(), 400.0, 0); + assert_eq!(c.states, vec![GroupCollapse::Full; 3]); + assert_eq!(c.level, 0); + assert!(!c.overflow); + assert!(!c.scroll); +} + +#[test] +fn condenses_the_lowest_priority_group_first() { + // 300 full doesn't fit in 260, but condensing the priority-0 group (−50 → + // 250) does. Only group 0 condenses; the higher-priority groups stay full. + let c = resolve_cascade(&groups(), 260.0, 0); + assert_eq!( + c.states, + vec![ + GroupCollapse::Condensed, + GroupCollapse::Full, + GroupCollapse::Full, + ], + ); + assert!(!c.overflow); +} + +#[test] +fn condenses_all_before_overflowing_any() { + // At 150 px all three must condense (3×50 = 150) but none need overflow. + let c = resolve_cascade(&groups(), 150.0, 0); + assert_eq!(c.states, vec![GroupCollapse::Condensed; 3]); + assert!(!c.overflow); + assert!(!c.scroll); +} + +#[test] +fn overflows_lowest_priority_group_when_condensing_is_not_enough() { + // 120 px can't hold 3 condensed (150). Overflow the priority-0 group: the + // strip then holds two condensed (100) + the More button (44) = 144 — still + // too wide, so a second group overflows: one condensed (50) + More (44) = 94. + let c = resolve_cascade(&groups(), 120.0, 0); + assert_eq!( + c.states, + vec![ + GroupCollapse::Overflow, + GroupCollapse::Overflow, + GroupCollapse::Condensed, + ], + ); + assert!(c.overflow); + assert!(!c.scroll); +} + +#[test] +fn scroll_floor_when_even_full_overflow_does_not_fit() { + // Narrower than just the More button → everything overflows and the strip + // still can't fit; the horizontal-scroll floor engages. + let avail = RIBBON_OVERFLOW_BUTTON_PX - 10.0; + let c = resolve_cascade(&groups(), avail, 0); + assert_eq!(c.states, vec![GroupCollapse::Overflow; 3]); + assert_eq!(c.level, 6); // 2 × 3 groups = fully collapsed + assert!(c.overflow); + assert!(c.scroll); +} + +#[test] +fn unmeasured_width_holds_the_previous_level() { + // A zero width is "not measured yet" — keep whatever we last resolved. + let c = resolve_cascade(&groups(), 0.0, 2); + assert_eq!(c.level, 2); + // Level 2 = the two lowest-priority groups condensed. + assert_eq!( + c.states, + vec![ + GroupCollapse::Condensed, + GroupCollapse::Condensed, + GroupCollapse::Full, + ], + ); +} + +#[test] +fn hysteresis_keeps_a_condensed_group_from_thrashing() { + // The level 0↔1 boundary sits at 300 px (all three groups full). Collapse + // happens the instant the strip overflows (avail < 300); re-expansion waits + // until the full layout clears 300 by the hysteresis band. + // + // Sitting just above 300 (inside the dead-band) must hold the collapse. + let just_over = 301.0; + assert!(just_over < 300.0 + RIBBON_COLLAPSE_HYSTERESIS_PX); + let c = resolve_cascade(&groups(), just_over, 1); + assert_eq!(c.level, 1, "within the dead-band the collapse holds"); + + // Well past the band it re-expands to all-full. + let clear = 300.0 + RIBBON_COLLAPSE_HYSTERESIS_PX + 1.0; + let c = resolve_cascade(&groups(), clear, 1); + assert_eq!(c.level, 0); + assert_eq!(c.states, vec![GroupCollapse::Full; 3]); +} + +#[test] +fn resolution_is_idempotent_at_a_fixed_width() { + // Feeding a resolved level back in at the same width must not move it. + let avail = 175.0; + let first = resolve_cascade(&groups(), avail, 0); + let second = resolve_cascade(&groups(), avail, first.level); + assert_eq!(first, second); +} + +#[test] +fn priority_ties_break_left_to_right() { + // Two equal-priority groups: the left (lower index) collapses first. + let equal = vec![ + GroupMetrics { + priority: 5, + full_px: 100.0, + condensed_px: 50.0, + }, + GroupMetrics { + priority: 5, + full_px: 100.0, + condensed_px: 50.0, + }, + ]; + let c = resolve_cascade(&equal, 160.0, 0); + assert_eq!( + c.states, + vec![GroupCollapse::Condensed, GroupCollapse::Full], + ); +} + +#[test] +fn empty_ribbon_resolves_to_nothing() { + let c = resolve_cascade(&[], 500.0, 0); + assert!(c.states.is_empty()); + assert_eq!(c.level, 0); + assert!(!c.overflow); + assert!(!c.scroll); +} + +#[test] +fn full_layout_keeps_the_label_and_roomy_padding() { + let lay = group_layout(GroupCollapse::Full, true); + assert!(lay.rendered); + assert_eq!(lay.pad_px, SPACE_2); + assert_eq!(lay.gap_px, 2.0); + assert!(lay.show_label); + // A group without a declared label shows none even when full. + assert!(!group_layout(GroupCollapse::Full, false).show_label); +} + +#[test] +fn condensed_layout_drops_the_label_and_tightens() { + let lay = group_layout(GroupCollapse::Condensed, true); + assert!(lay.rendered); + assert_eq!(lay.pad_px, SPACE_1); + assert_eq!(lay.gap_px, 0.0); + assert!(!lay.show_label, "the label drops to reclaim width"); + assert!(SPACE_1 < SPACE_2, "condensed padding is tighter than full"); +} + +#[test] +fn overflow_layout_renders_nothing_in_the_strip() { + let lay = group_layout(GroupCollapse::Overflow, true); + assert!(!lay.rendered); + assert!(!lay.show_label); +} + +#[test] +fn estimated_metrics_scale_with_button_count_and_condense_smaller() { + let two = estimate_group_metrics(1, 2, true); + let three = estimate_group_metrics(1, 3, true); + assert_eq!(two.priority, 1); + // Two buttons + one gap + both side paddings. + assert_eq!(two.full_px, 2.0 * TOUCH_MIN + 2.0 + 2.0 * SPACE_2); + // Condensed drops the gap and tightens the padding. + assert_eq!(two.condensed_px, 2.0 * TOUCH_MIN + 2.0 * SPACE_1); + assert!(two.condensed_px < two.full_px); + assert!(three.full_px > two.full_px, "more buttons ⇒ wider"); +} + +#[test] +fn estimated_metrics_never_underflow_for_an_empty_group() { + // A zero-button group is treated as one button (never negative gap width). + let m = estimate_group_metrics(0, 0, false); + assert_eq!(m.full_px, TOUCH_MIN + 2.0 * SPACE_2); + assert!(m.condensed_px > 0.0); +} diff --git a/appthere-ui/src/tokens/layout.rs b/appthere-ui/src/tokens/layout.rs index e5cfdac7..ae0a0cb9 100644 --- a/appthere-ui/src/tokens/layout.rs +++ b/appthere-ui/src/tokens/layout.rs @@ -71,6 +71,27 @@ pub const RIBBON_CONTENT_HEIGHT: f32 = 60.0; /// Used by Shell to reserve space and by canvas height calculations. pub const RIBBON_TOTAL_HEIGHT: f32 = RIBBON_TAB_STRIP_HEIGHT + RIBBON_CONTENT_HEIGHT; +/// Width (CSS px) the overflow ("More") button occupies in the ribbon content +/// strip once at least one group has overflowed into its menu. Touch-sized +/// (WCAG 2.5.8) — the collapse engine (Spec 04 M3 §7) adds this to the strip's +/// occupied width whenever any group is in the overflow menu. +pub const RIBBON_OVERFLOW_BUTTON_PX: f32 = 44.0; + +/// Dead-band (CSS px) for the ribbon collapse cascade (Spec 04 M3 §7). A group +/// re-expands only when the less-collapsed layout clears the available width by +/// this margin, so dragging a window across a fit threshold does not thrash +/// between collapse states (same principle as [`PAGE_FIT_HYSTERESIS_PX`]). +pub const RIBBON_COLLAPSE_HYSTERESIS_PX: f32 = 32.0; + +/// Full width (CSS px) of a ribbon select control (e.g. the style picker) in the +/// [`Full`](crate::GroupCollapse::Full) group state. +pub const RIBBON_SELECT_WIDTH_PX: f32 = 180.0; + +/// Narrowed width (CSS px) a ribbon select shrinks to in the +/// [`Condensed`](crate::GroupCollapse::Condensed) group state (Spec 04 M3 §7, +/// R-13e select-width handling). Still wide enough to show a short style name. +pub const RIBBON_SELECT_WIDTH_CONDENSED_PX: f32 = 112.0; + // ── Responsive breakpoints ──────────────────────────────────────────────────── /// Upper bound (exclusive) of the **Compact** window-size class, in CSS px. diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index dc8796c7..9e3a1c99 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -106,21 +106,21 @@ 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. | L | -| 4a.2 | Spec 04 M5 | Layout/References/Review ribbon tabs + `selected_object` contextual-tab signal (only 3 non-contextual tabs exist). | 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). | L | -| 4a.4 | Spec 03 | Metadata-panel label stacking <250 px (R-13g); responsive doc type-scale (M4); real `Viewport.zoom`. | M | -| 4a.5 | Spec 04 M6 | Touch posture + cursor-into-new-cell after insert. | M | +| 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.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 | ### 4b. Editing-core TODOs (§2) | Task | Topic | Detail | Effort | |---|---|---|---| -| 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`). | M | +| 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.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 (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | 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)` 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.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 | @@ -143,17 +143,17 @@ the Watch list). Every task here must update `docs/fidelity-status.md`. | Task | Source | Detail | Effort | |---|---|---|---| -| 5.1 | `tab-default` | Honour `DocumentSettings.default_tab_stop_pt` instead of the hardcoded 36 pt (`para.rs:648`). Pairs naturally with 1.1 (`tab_stops` round-trip). | S | -| 5.2 | `underline-style` / `strikethrough-style` | Double/Dotted/Dash/Wave underline, double strikethrough (all render Single today). Decoration geometry is ours (drawn in `loki-vello`), not Parley-gated. | M | -| 5.3 | `spell-baseline` | Tighten squiggle to the run underline offset (`para.rs:1619`). | S | -| 5.4 | `list-picture-bullet` | Picture bullets (fallback is `•`) — image plumbing already exists for block images. | M | -| 5.5 | `pdf-rotate` | Rotation transform in PDF export (`pdf/src/page.rs:83`); unlocks the "PDF clip/rotate paint" registry row. | M | -| 5.6 | gap #12 / `floating-image` | External-URL images render a grey placeholder (`loki-vello/src/image.rs:34`) + detect "floating" class for inline images (`resolve.rs:705`). | M | -| 5.7 | `odf-master-page` | ODF master-page transitions (`odf/reader/styles.rs:200`); pairs with the `style:default-style` registry row. | M | +| 5.1 | `tab-default` | **Done ✅ 2026-07-09.** A paragraph that runs out of explicit `tab_stops` now advances on the document's `DocumentSettings::default_tab_stop_pt` grid instead of the hardcoded 36 pt. `ResolvedParaProps` gained a `default_tab_stop` field (built-in fallback 36 pt); the flow engine (`flow_para.rs`) overrides it from a new `LayoutOptions::default_tab_stop_pt`, which `layout_document`/`layout_paginated_full` fold in from `doc.settings` via `effective_options` (caller-supplied value wins). `para_tabs::compute_tab_plans`/`next_tab_stop_resolved` take the grid as a parameter (guarding a non-positive interval). Config structs (`LayoutOptions`/`SpellState`/`FieldContext`) were extracted from `lib.rs` into a new `options.rs` to make room. Tested: `default_tab_stop_falls_back_to_36pt`, `custom_default_tab_stop_sets_the_grid_via_options`, `document_settings_default_tab_stop_reaches_layout` (`tab_stops_tests.rs`). | S | +| 5.2 | `underline-style` / `strikethrough-style` | **Done ✅ 2026-07-08.** All underline variants (single/double/dotted/dashed/wave/thick) and double strikethrough now render. `PositionedDecoration` gained a `DecorationStyle` (loki-layout `items.rs`); `para_emit` recovers the `w:u` / `w:strike` variant per run (which Parley's own run decoration drops) via `span_underline`/`span_strike` and carries it on the decoration; `loki-vello`'s `paint_decoration` strokes each style (double = two lines, dotted/dashed via kurbo dash patterns, wave reuses the squiggle path, thick = 2× width). Tested: `underline_variant_carries_to_decoration_style` + `double_strikethrough_carries_double_style` (layout) and `paint_decoration_every_style_does_not_panic` (loki-vello). | M | +| 5.3 | `spell-baseline` | **Done ✅ 2026-07-09.** The spelling squiggle now anchors to the text descender (`baseline + descent`, the run underline zone) instead of the selection-geometry line-box bottom, so it hugs the glyphs rather than floating in the inter-line leading. `emit_spelling_squiggles` (`para_underlays.rs`) precomputes each line's descender bottom from Parley `LineMetrics` and positions the wave band there. Tested by `spelling_squiggle_hugs_the_descender_not_the_line_box` (a 3× line height makes the old line-box anchor a ~2-em drop; the new anchor stays within one em of the baseline). | S | +| 5.4 | `list-picture-bullet` | **Done ✅ 2026-07-09.** Picture bullets render as images end-to-end in both formats (was a dead `BulletChar::Image` unit variant falling back to `•`). **Model:** `BulletChar::Image { src }`. **Layout:** since Parley can't inline an image, the list-marker synthesis was extracted to `flow_list_marker`; `picture_bullet_item` places a square image sized to line 0, left-aligned in the hanging label box, injected into the paragraph's items (`format_list_marker` emits no text for an image bullet). **OOXML import:** `w:numPicBullet`/`w:lvlPicBulletId` — `import_pic_bullets::resolve` looks the bullet image up in the *separate* `word/numbering.xml.rels`, bakes a `data:` URI onto the numbering model, and the mapper produces `BulletChar::Image` (unresolved → text-bullet fallback). **ODF import:** `text:list-level-style-image` — the mapper resolves `xlink:href` to a `data:` URI via the package image map. Tested at every layer (layout flow, OOXML reader+mapper, ODF reader+mapper). Ceilings held via inline-test + helper extractions. | M | +| 5.5 | `pdf-rotate` | **Done ✅ 2026-07-09.** PDF export now rotates a `RotatedGroup` (rotated table-cell text) via a content CTM instead of rendering it axis-aligned at the group origin. New `page_rotate::rotated_group_ctm` builds `C = F·M·F` — the on-screen loki-vello rotation `M = T(pivot)·R(θ)·T(-pivot_local)` conjugated by the PDF renderer's per-leaf y-flip `F` — and `page.rs` emits `q cm … Q`, rendering children at zero offset (position folded into the transform). Tested: `page_rotate` matrix unit tests (θ=0 degenerate reproduces the old offset, hand-computed 90° matrix, four-corner round-trip vs. the screen transform) + `rotated_group_emits_transform_and_children` (content stream carries `cm` + child fill inside `q`/`Q`). Unlocks the "Clipping / rotation in PDF" registry row (now Yes). | M | +| 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, `border_between`, DocxSettings, content controls, language tags — schedule individually from the fidelity registry; orphan/widow is the highest-value (visible in any multi-page doc). | L (aggregate) | -| 5.10 | registry | Page/column geometry set: even/odd blank pages, unequal column widths, column height balancing; PDF font subsetting + ICC/CMYK; EPUB math/fields/comments. | L (aggregate) | -| 5.11 | `link-click` | Interactive hyperlink hit-testing (visual hint only today) — spans layout (`resolve.rs:689`, `items.rs:125`, `para.rs:203`) and renderer (`scene.rs:519`). | M | +| 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.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 `