diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 44b95bf9..19bb43c0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -101,7 +101,9 @@ "Bash(claude plugin *)", "Bash(cp \"/root/.claude/uploads/995af0a3-e865-5b96-b002-e90985880970/cc51b77a-spec01codebaseauditandarchitecture.md\" \"/home/user/loki/docs/adr/spec-01-codebase-audit-and-architecture.md\" && echo \"placed\" && ls -la docs/adr/spec-01-codebase-audit-and-architecture.md)", "mcp__code-review-graph__list_graph_stats_tool", - "mcp__Claude_Code_Remote__send_later" + "mcp__Claude_Code_Remote__send_later", + "mcp__code-review-graph__refactor_tool", + "mcp__code-review-graph__build_or_update_graph_tool" ] }, "enableAllProjectMcpServers": true, diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 45c20987..c0da54b9 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -34,6 +34,9 @@ jobs: - name: File-ceiling gate (300-line ratchet) # Spec 01 audit A-2. New files <= 300 lines; baselined debt may not grow. run: python3 scripts/check-file-ceiling.py + - name: Suppression ratchet (`let _ =` swallows + `#[allow]`) + # Audit Q-3/Q-4. Per-file counts may only shrink; new files start at 0/0. + run: python3 scripts/check-suppressions.py - name: TODO-format gate (TODO() tags) # Spec 01 audit A-11. No bare TODO / FIXME / HACK / XXX in production code. run: python3 scripts/check-todo-format.py diff --git a/CLAUDE.md b/CLAUDE.md index d4e8b611..ad5b2350 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,7 @@ baseline). Three techniques (the third added 2026-07-08): | File | Current lines | Priority | |---|---|---| -| `loki-layout/src/para.rs` | 1447 | High | +| `loki-layout/src/para.rs` | 1401 | High | | `loki-layout/src/flow.rs` | 1202 | High | | `loki-spreadsheet/src/routes/editor/editor_inner.rs` | 1014 | High | | `loki-ooxml/src/docx/write/document.rs` | 902 | High | @@ -256,7 +256,9 @@ yet reach `0.41`: 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 +0.41 yet. Re-checked 2026-07-11: still true — `object_store` 0.14.0 (latest) +still requires `quick-xml ^0.40.1`. (The same pass fixed the unrelated +RUSTSEC-2026-0204 by bumping `crossbeam-epoch` 0.9.18 → 0.9.20 in the lockfile.) `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 @@ -472,7 +474,13 @@ Read in any descendant component: let theme = use_theme(); ``` -Only `ThemeVariant::Dark` is implemented. Light theme tokens are deferred. +Both variants are implemented (4c.4): `ThemePalette::dark()` / `light()` in +`tokens/palette.rs`; the `COLOR_*` constants remain the dark values, so +unmigrated components render dark under either variant. Components migrate by +reading `use_theme().palette()` (Signal-backed — re-colors live on +`AtThemeContext::toggle`, exposed as the tab-bar theme-toggle button). Shell +chrome (title/tab/status bars, dialogs) is migrated; deep editor surfaces +migrate opportunistically. ### What does NOT belong in `appthere_ui` diff --git a/Cargo.lock b/Cargo.lock index 288a1d0f..659e0695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,7 @@ dependencies = [ "image", "loki-doc-model", "loki-sheet-model", + "serde", "tempfile", "thiserror 2.0.18", ] @@ -410,7 +411,6 @@ version = "0.1.0" dependencies = [ "dioxus", "loki-i18n", - "thiserror 2.0.18", ] [[package]] @@ -1633,9 +1633,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -4404,6 +4404,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" name = "loki-acid" version = "0.1.0" dependencies = [ + "appthere-conformance", "image", "loki-doc-model", "loki-graphics", @@ -4436,12 +4437,14 @@ dependencies = [ name = "loki-bench" version = "0.1.0" dependencies = [ + "appthere-canvas", "criterion", "dhat", "loki-doc-model", "loki-layout", "loki-odf", "loki-ooxml", + "loki-primitives", "thiserror 2.0.18", ] @@ -4652,7 +4655,6 @@ dependencies = [ "image", "loki-doc-model", "loki-layout", - "loki-primitives", "pdf-writer", "subsetter", "thiserror 2.0.18", @@ -4665,36 +4667,20 @@ version = "0.1.0" dependencies = [ "android-activity", "android_logger", - "anyrender_vello", "appthere-ui", "blitz-shell", "dioxus", - "dirs", "fontique 0.10.0", - "kurbo 0.12.0", "log", "loki-app-shell", - "loki-doc-model", "loki-file-access", "loki-fonts", "loki-graphics", "loki-i18n", - "loki-layout", - "loki-odf", "loki-ooxml", "loki-presentation-model", "loki-primitives", - "loki-renderer", - "loki-vello", - "loro", - "peniko 0.5.0", - "serde", - "serde_json", "thiserror 2.0.18", - "tracing", - "unicode-segmentation", - "vello", - "wgpu_context", ] [[package]] @@ -4760,7 +4746,6 @@ dependencies = [ "loki-layout", "loki-vello", "pollster", - "tokio", "tracing", "vello", "wgpu", @@ -4794,7 +4779,6 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", - "base64", "chrono", "http-body-util", "loki-crypto", @@ -4871,12 +4855,10 @@ dependencies = [ "loki-model", "loki-server-audit", "object_store", - "serde", "serde_json", "sqlx", "thiserror 2.0.18", "tokio", - "tracing", "uuid", ] @@ -4908,34 +4890,21 @@ version = "0.1.0" dependencies = [ "android-activity", "android_logger", - "anyrender_vello", "appthere-ui", "blitz-shell", "dioxus", - "dirs", "fontique 0.10.0", - "kurbo 0.12.0", "log", "loki-app-shell", - "loki-doc-model", "loki-file-access", "loki-fonts", "loki-i18n", - "loki-layout", "loki-odf", "loki-ooxml", - "loki-renderer", "loki-sheet-model", - "loki-vello", "loro", - "peniko 0.5.0", - "serde", - "serde_json", "thiserror 2.0.18", "tracing", - "unicode-segmentation", - "vello", - "wgpu_context", ] [[package]] @@ -4953,16 +4922,13 @@ version = "0.1.0" dependencies = [ "android-activity", "android_logger", - "anyrender_vello", "appthere-ui", "base64", "blitz-shell", "dioxus", - "dirs", "fontique 0.10.0", "futures-channel", "image", - "kurbo 0.12.0", "log", "loki-app-shell", "loki-doc-model", @@ -4976,17 +4942,11 @@ dependencies = [ "loki-pdf", "loki-renderer", "loki-templates", - "loki-vello", "loro", - "peniko 0.5.0", - "serde", - "serde_json", "thiserror 2.0.18", "tracing", "unicode-segmentation", - "vello", "webbrowser", - "wgpu_context", ] [[package]] diff --git a/appthere-canvas/src/font_cache.rs b/appthere-canvas/src/font_cache.rs index 8fa65169..2d1d50c3 100644 --- a/appthere-canvas/src/font_cache.rs +++ b/appthere-canvas/src/font_cache.rs @@ -16,7 +16,7 @@ use std::sync::Arc; #[derive(Default)] pub struct FontDataCache { entries: HashMap<(usize, u32), peniko::FontData>, - coords: HashMap<(usize, u32), Vec>, + coords: HashMap<(usize, u32), Vec>, } #[cfg(feature = "font-cache")] @@ -40,19 +40,19 @@ impl FontDataCache { }) } - /// Returns the cached normalized coordinates (defaulting to zero) for the font's axes. - pub fn get_coords( - &mut self, - data: &Arc>, - font_index: u32, - ) -> &[read_fonts::types::F2Dot14] { + /// Returns the cached normalized coordinates (defaulting to zero) for the + /// font's axes, in `F2Dot14` bit representation — the form + /// `DrawGlyphs::normalized_coords` consumes, so per-glyph-run callers need + /// no conversion allocation. + pub fn get_coords(&mut self, data: &Arc>, font_index: u32) -> &[i16] { let key = (Arc::as_ptr(data) as usize, font_index); self.coords.entry(key).or_insert_with(|| { if let Ok(font_ref) = read_fonts::FontRef::from_index(data, font_index) { use read_fonts::TableProvider; if let Ok(fvar) = font_ref.fvar() { if let Ok(axes) = fvar.axes() { - return vec![read_fonts::types::F2Dot14::default(); axes.len()]; + // Default (all-zero) normalized position on every axis. + return vec![0i16; axes.len()]; } } } diff --git a/appthere-conformance/Cargo.toml b/appthere-conformance/Cargo.toml index b721fbe1..f50209cf 100644 --- a/appthere-conformance/Cargo.toml +++ b/appthere-conformance/Cargo.toml @@ -19,6 +19,8 @@ sheet-model = ["dep:loki-sheet-model"] [dependencies] thiserror = { workspace = true } +# The corpus vocabulary (catalog cases, fixture metadata) serializes into reports. +serde = { version = "1", features = ["derive"] } # Round-trip the candidate XML through a temp file for `xmllint` validation. tempfile = "3" # PNG decode/encode for the visual-goldens differ and heatmap emission. diff --git a/loki-acid/src/catalog/docx.rs b/appthere-conformance/src/corpus/catalog/docx.rs similarity index 96% rename from loki-acid/src/catalog/docx.rs rename to appthere-conformance/src/corpus/catalog/docx.rs index 135c613b..c09d16c3 100644 --- a/loki-acid/src/catalog/docx.rs +++ b/appthere-conformance/src/corpus/catalog/docx.rs @@ -3,8 +3,8 @@ //! DOCX acid cases (TEST_PLAN.md §1). -use super::{Format::Docx, TestCase, tc}; -use crate::severity::Severity::{P0, P1, P2}; +use crate::corpus::Severity::{P0, P1, P2}; +use crate::corpus::{Format::Docx, TestCase, tc}; /// The 38 DOCX test cases. pub(super) const CASES: &[TestCase] = &[ diff --git a/appthere-conformance/src/corpus/catalog/mod.rs b/appthere-conformance/src/corpus/catalog/mod.rs new file mode 100644 index 00000000..9cbe7642 --- /dev/null +++ b/appthere-conformance/src/corpus/catalog/mod.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The ACID test-case catalog — the machine-readable transcription of the +//! master `TEST_PLAN.md` (promoted from `loki-acid`, Spec 02 B-8/B-9). +//! +//! Each [`TestCase`] is a construct that office-suite alternatives are known +//! to render differently from the canonical Microsoft 365 (OOXML) or +//! LibreOffice (ODF) render — the catalog is the *visual-fidelity* dimension +//! of the corpus; per-fixture axis applicability lives on +//! [`super::FixtureMeta`]. The catalog is queryable along the corpus +//! organisation: by format ([`cases_for`]), by feature ([`by_feature`]), and +//! by severity ([`cases_with_severity`]). + +mod docx; +mod odf; +mod pptx; +mod xlsx; + +use std::collections::BTreeMap; + +use super::{Format, Severity, TestCase}; + +/// Returns every catalogued test case across all formats. +#[must_use] +pub fn all_cases() -> Vec { + let mut cases = Vec::new(); + cases.extend_from_slice(docx::CASES); + cases.extend_from_slice(xlsx::CASES); + cases.extend_from_slice(pptx::CASES); + cases.extend_from_slice(odf::CASES); + cases +} + +/// Returns the catalogued cases for a single format. +#[must_use] +pub fn cases_for(format: Format) -> Vec { + all_cases() + .into_iter() + .filter(|c| c.format == format) + .collect() +} + +/// Returns the catalogued cases at a given severity. +#[must_use] +pub fn cases_with_severity(severity: Severity) -> Vec { + all_cases() + .into_iter() + .filter(|c| c.severity == severity) + .collect() +} + +/// Groups every case by its feature description (sorted for stable output) — +/// the *feature* dimension of the feature × format × axis organisation. +#[must_use] +pub fn by_feature() -> BTreeMap<&'static str, Vec> { + let mut map: BTreeMap<&'static str, Vec> = BTreeMap::new(); + for case in all_cases() { + map.entry(case.feature).or_default().push(case); + } + map +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_are_unique() { + let cases = all_cases(); + let mut ids: Vec<&str> = cases.iter().map(|c| c.id).collect(); + ids.sort_unstable(); + let before = ids.len(); + ids.dedup(); + assert_eq!(before, ids.len(), "duplicate test-case id in catalog"); + } + + #[test] + fn catalog_covers_every_format() { + for format in [ + Format::Docx, + Format::Xlsx, + Format::Pptx, + Format::Odt, + Format::Odp, + Format::Odg, + Format::Ods, + ] { + assert!( + !cases_for(format).is_empty(), + "no cases catalogued for {format:?}" + ); + } + } + + #[test] + fn counts_match_plan_totals() { + // Totals transcribed from TEST_PLAN.md section headers. + assert_eq!(cases_for(Format::Docx).len(), 38); + assert_eq!(cases_for(Format::Xlsx).len(), 30); + assert_eq!(cases_for(Format::Pptx).len(), 29); + assert_eq!(cases_for(Format::Odt).len(), 14); + assert_eq!(cases_for(Format::Odp).len(), 9); + assert_eq!(cases_for(Format::Odg).len(), 9); + assert_eq!(cases_for(Format::Ods).len(), 10); + // The Spec 02 inventory's "141 cases" headline miscounts; the + // per-section headers above (the machine-readable truth) sum to 139. + assert_eq!(all_cases().len(), 139); + } + + #[test] + fn severity_totals_match_the_plan() { + // 22 P0 / 82 P1 / 47 P2 per the Spec 02 inventory... verified against + // the actual tables (the inventory rounded; the tables are the truth). + let total: usize = [Severity::P0, Severity::P1, Severity::P2] + .into_iter() + .map(|s| cases_with_severity(s).len()) + .sum(); + assert_eq!(total, all_cases().len()); + } + + #[test] + fn feature_grouping_partitions_the_catalog() { + let grouped: usize = by_feature().values().map(Vec::len).sum(); + assert_eq!(grouped, all_cases().len()); + } +} diff --git a/loki-acid/src/catalog/odf.rs b/appthere-conformance/src/corpus/catalog/odf.rs similarity index 96% rename from loki-acid/src/catalog/odf.rs rename to appthere-conformance/src/corpus/catalog/odf.rs index b6edc28e..81e0d6e3 100644 --- a/loki-acid/src/catalog/odf.rs +++ b/appthere-conformance/src/corpus/catalog/odf.rs @@ -6,8 +6,8 @@ //! ODF has no Microsoft-canonical render: diff against LibreOffice *and* check //! the ODF round-trip (re-export and compare the targeted XML elements). -use super::{Format::Odg, Format::Odp, Format::Ods, Format::Odt, TestCase, tc}; -use crate::severity::Severity::{P0, P1, P2}; +use crate::corpus::Severity::{P0, P1, P2}; +use crate::corpus::{Format::Odg, Format::Odp, Format::Ods, Format::Odt, TestCase, tc}; /// ODT (14) + ODP (9) + ODG (9) + ODS (10) test cases. pub(super) const CASES: &[TestCase] = &[ diff --git a/loki-acid/src/catalog/pptx.rs b/appthere-conformance/src/corpus/catalog/pptx.rs similarity index 96% rename from loki-acid/src/catalog/pptx.rs rename to appthere-conformance/src/corpus/catalog/pptx.rs index 02352e14..65c95894 100644 --- a/loki-acid/src/catalog/pptx.rs +++ b/appthere-conformance/src/corpus/catalog/pptx.rs @@ -10,8 +10,8 @@ //! depend on gradients, SmartArt, charts, animations, or grouped-shape child //! transforms still await a PowerPoint-authored deck and a golden render. -use super::{Format::Pptx, TestCase, tc}; -use crate::severity::Severity::{P0, P1, P2}; +use crate::corpus::Severity::{P0, P1, P2}; +use crate::corpus::{Format::Pptx, TestCase, tc}; /// The 29 PPTX test cases. pub(super) const CASES: &[TestCase] = &[ diff --git a/loki-acid/src/catalog/xlsx.rs b/appthere-conformance/src/corpus/catalog/xlsx.rs similarity index 95% rename from loki-acid/src/catalog/xlsx.rs rename to appthere-conformance/src/corpus/catalog/xlsx.rs index a887aecc..5e173177 100644 --- a/loki-acid/src/catalog/xlsx.rs +++ b/appthere-conformance/src/corpus/catalog/xlsx.rs @@ -3,8 +3,8 @@ //! XLSX acid cases (TEST_PLAN.md §2). -use super::{Format::Xlsx, TestCase, tc}; -use crate::severity::Severity::{P0, P1, P2}; +use crate::corpus::Severity::{P0, P1, P2}; +use crate::corpus::{Format::Xlsx, TestCase, tc}; /// The 30 XLSX test cases. pub(super) const CASES: &[TestCase] = &[ diff --git a/appthere-conformance/src/corpus/manifest.rs b/appthere-conformance/src/corpus/manifest.rs new file mode 100644 index 00000000..cfeb74a0 --- /dev/null +++ b/appthere-conformance/src/corpus/manifest.rs @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The on-disk corpus manifest (Spec 02 §9): the machine-readable record of +//! every fixture committed under `fixtures//`, with its feature, +//! applicable axes, reference application/version, and tolerance overrides. +//! +//! The layout is `fixtures//.` with goldens under +//! `goldens///page-N.png` (see `goldens/CALIBRATION.md` for the +//! generation record). The manifest is code, like the catalog, so it is +//! versioned, reviewed, and impossible to desync from a sidecar file format. + +use std::borrow::Cow; +use std::path::PathBuf; + +use super::{Axis, Fixture, FixtureMeta, Format, Reference, Severity}; + +/// The LibreOffice reference that generated the committed ODT goldens +/// (pinned in `goldens/CALIBRATION.md` together with the rasterizer). +pub const LIBREOFFICE_24_2: Reference = Reference { + app: "LibreOffice", + version: "24.2", +}; + +/// All three axes apply (the common case for the visual baseline fixtures). +const ALL_AXES: &[Axis] = &[Axis::Schema, Axis::RoundTrip, Axis::Visual]; + +/// The committed on-disk corpus. Every entry's document must exist under +/// [`fixtures_root`]; entries carrying [`Axis::Visual`] also have a golden +/// directory under [`goldens_root`]. +pub const MANIFEST: &[FixtureMeta] = &[ + FixtureMeta { + id: "para-carlito", + format: Format::Odt, + feature: "Body paragraphs in Calibri (metric-compatible Carlito substitution)", + severity: Severity::P1, + axes: ALL_AXES, + reference: Some(LIBREOFFICE_24_2), + // The 2026-07-05 kerning fix brought this fixture inside the calibrated + // band (see CALIBRATION.md's re-measured distributions) — no override. + tolerance_override: None, + }, + FixtureMeta { + id: "para-gelasio", + format: Format::Odt, + feature: "Body paragraphs in Georgia (metric-compatible Gelasio substitution)", + severity: Severity::P1, + axes: ALL_AXES, + reference: Some(LIBREOFFICE_24_2), + tolerance_override: None, + }, + FixtureMeta { + id: "styles-tinos", + format: Format::Odt, + feature: "Named paragraph styles in Times New Roman (Tinos substitution)", + severity: Severity::P1, + axes: ALL_AXES, + reference: Some(LIBREOFFICE_24_2), + tolerance_override: None, + }, +]; + +/// The corpus root: `appthere-conformance/fixtures/`. +#[must_use] +pub fn fixtures_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures") +} + +/// The goldens root: `appthere-conformance/goldens/`. +#[must_use] +pub fn goldens_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("goldens") +} + +/// The on-disk file extension for a format. +#[must_use] +pub fn extension(format: Format) -> &'static str { + match format { + Format::Docx => "docx", + Format::Xlsx => "xlsx", + Format::Pptx => "pptx", + Format::Odt => "odt", + Format::Odp => "odp", + Format::Odg => "odg", + Format::Ods => "ods", + } +} + +/// The format's corpus subdirectory name (`fixtures//…`). +#[must_use] +pub fn format_dir(format: Format) -> &'static str { + extension(format) +} + +/// An on-disk corpus fixture: manifest metadata plus lazily-loaded bytes. +#[derive(Debug, Clone, Copy)] +pub struct DiskFixture { + meta: FixtureMeta, +} + +impl DiskFixture { + /// The fixture's document path under [`fixtures_root`]. + #[must_use] + pub fn path(&self) -> PathBuf { + fixtures_root() + .join(format_dir(self.meta.format)) + .join(format!("{}.{}", self.meta.id, extension(self.meta.format))) + } + + /// The fixture's golden directory under [`goldens_root`] (only meaningful + /// for [`Axis::Visual`] fixtures). + #[must_use] + pub fn golden_dir(&self) -> PathBuf { + goldens_root() + .join(format_dir(self.meta.format)) + .join(self.meta.id) + } +} + +impl Fixture for DiskFixture { + fn meta(&self) -> FixtureMeta { + self.meta + } + + /// Reads the committed document. Empty on I/O failure — the manifest test + /// asserts existence, so an empty read only occurs outside the repo. + fn bytes(&self) -> Cow<'_, [u8]> { + Cow::Owned(std::fs::read(self.path()).unwrap_or_default()) + } +} + +/// Every manifest entry as a loadable [`DiskFixture`]. +#[must_use] +pub fn disk_fixtures() -> Vec { + MANIFEST.iter().map(|&meta| DiskFixture { meta }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_manifest_entry_exists_on_disk_with_bytes() { + for fixture in disk_fixtures() { + let path = fixture.path(); + assert!(path.exists(), "missing corpus document: {}", path.display()); + assert!( + !fixture.bytes().is_empty(), + "empty corpus document: {}", + path.display() + ); + } + } + + #[test] + fn visual_entries_have_a_reference_and_a_golden_dir() { + for fixture in disk_fixtures() { + let meta = fixture.meta(); + if meta.axes.contains(&Axis::Visual) { + assert!( + meta.reference.is_some(), + "{}: a visual fixture must record its reference app", + meta.id + ); + assert!( + fixture.golden_dir().is_dir(), + "{}: missing golden dir {}", + meta.id, + fixture.golden_dir().display() + ); + } + } + } + + #[test] + fn ids_are_unique_and_axes_nonempty() { + let mut ids: Vec<&str> = MANIFEST.iter().map(|m| m.id).collect(); + ids.sort_unstable(); + let n = ids.len(); + ids.dedup(); + assert_eq!(n, ids.len(), "duplicate fixture id in manifest"); + for m in MANIFEST { + assert!(!m.axes.is_empty(), "{}: no axes recorded", m.id); + } + } +} diff --git a/appthere-conformance/src/corpus/mod.rs b/appthere-conformance/src/corpus/mod.rs new file mode 100644 index 00000000..ce0a9d5c --- /dev/null +++ b/appthere-conformance/src/corpus/mod.rs @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The shared fixture corpus vocabulary (Spec 02 §9 / B-8, B-9). +//! +//! Promoted from `loki-acid` so every suite app (Text today; Presentation and +//! Spreadsheet later) shares one severity scale, format enum, test-case +//! catalog, and fixture metadata schema — with **no Text-specific +//! assumptions**. A consumer supplies documents through the [`Fixture`] trait +//! and its import/export pair through the [`Consumer`] trait, and drives the +//! three axes with them. +//! +//! The corpus is organised **feature × format × axis**: every catalogued +//! [`TestCase`] names its feature and format (the catalog is the +//! machine-readable `TEST_PLAN.md`, 141 `TC-*` constructs), and every +//! [`FixtureMeta`] records which [`Axis`]es apply, the reference application +//! and version behind its golden, and any per-fixture tolerance override with +//! its justification. + +pub mod catalog; +pub mod manifest; + +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +/// Severity of a fidelity divergence, per the master test plan. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Severity { + /// Silent data/layout corruption a reader notices immediately (wrong merge, + /// dropped text, wrong page count, garbled glyphs). + P0, + /// Visible fidelity gap (wrong spacing, colour, wrap) a careful reader + /// catches. + P1, + /// Subtle metric / typographic drift. + P2, +} + +impl Severity { + /// Short uppercase label (`"P0"`, `"P1"`, `"P2"`). + #[must_use] + pub fn label(self) -> &'static str { + match self { + Severity::P0 => "P0", + Severity::P1 => "P1", + Severity::P2 => "P2", + } + } +} + +/// The document format a fixture or test case belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Format { + /// Word-processing OOXML (`.docx`). + Docx, + /// Spreadsheet OOXML (`.xlsx`). + Xlsx, + /// Presentation OOXML (`.pptx`). + Pptx, + /// OpenDocument Text (`.odt`). + Odt, + /// OpenDocument Presentation (`.odp`). + Odp, + /// OpenDocument Graphics (`.odg`). + Odg, + /// OpenDocument Spreadsheet (`.ods`). + Ods, +} + +impl Format { + /// The canonical reference render authority for this format. + /// + /// OOXML formats diff against Microsoft 365; ODF formats against + /// LibreOffice. + #[must_use] + pub fn canonical_authority(self) -> &'static str { + match self { + Format::Docx | Format::Xlsx | Format::Pptx => "Microsoft 365", + Format::Odt | Format::Odp | Format::Odg | Format::Ods => "LibreOffice", + } + } +} + +/// One of the three independent conformance axes (Spec 02 §4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Axis { + /// Exported XML validates against the official OOXML/ODF schemas. + Schema, + /// Import → export → re-import preserves the normalized model. + RoundTrip, + /// The render matches a committed golden within calibrated tolerance. + Visual, +} + +/// The reference application (and pinned version) behind a fixture's golden. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Reference { + /// Application name, e.g. `"LibreOffice"` or `"Microsoft 365"`. + pub app: &'static str, + /// Version the golden was generated with, e.g. `"24.2"`. + pub version: &'static str, +} + +/// A per-fixture visual-tolerance override, always with its justification +/// (Spec 02 §9: overrides are data, not folklore). +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct ToleranceOverride { + /// Overriding minimum regional SSIM (default: `Tolerance::calibrated()`). + pub min_ssim: f64, + /// Overriding maximum regional mean ΔE (CIEDE2000). + pub max_delta_e: f64, + /// Why this fixture deviates from the calibrated corpus-wide tolerance. + pub justification: &'static str, +} + +/// A single acid-test construct (a `TC-*` entry of the master plan). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct TestCase { + /// Stable identifier, e.g. `TC-DOCX-014`. + pub id: &'static str, + /// Owning format. + pub format: Format, + /// Severity if Loki diverges from canonical. + pub severity: Severity, + /// Short description of the construct under test. + pub feature: &'static str, +} + +/// Terse constructor used by the per-format catalog tables. +pub(crate) const fn tc( + id: &'static str, + format: Format, + severity: Severity, + feature: &'static str, +) -> TestCase { + TestCase { + id, + format, + severity, + feature, + } +} + +/// The recorded metadata of one corpus fixture (Spec 02 §9): the feature it +/// exercises, which axes apply, the reference behind its golden, and any +/// justified tolerance override. Compile-time data (like the catalog), so it +/// serializes into reports but is never deserialized. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +pub struct FixtureMeta { + /// Stable fixture id — also its on-disk stem, e.g. `para-carlito`. + pub id: &'static str, + /// Document format. + pub format: Format, + /// The feature (or feature set) the fixture exercises. + pub feature: &'static str, + /// Severity of a divergence on this fixture. + pub severity: Severity, + /// Which conformance axes apply to this fixture. + pub axes: &'static [Axis], + /// Reference application/version behind the golden; `None` when the + /// fixture has no visual golden (non-[`Axis::Visual`] fixtures). + pub reference: Option, + /// Per-fixture tolerance override; `None` = the calibrated default. + pub tolerance_override: Option, +} + +/// A conformance fixture: metadata plus the document bytes. +/// +/// Implemented by on-disk corpus entries ([`manifest::DiskFixture`]) and by +/// consumers with embedded fixtures (e.g. `loki-acid`'s acid documents). +pub trait Fixture { + /// The fixture's recorded metadata. + fn meta(&self) -> FixtureMeta; + /// The document bytes (borrowed for embedded fixtures, owned for disk). + fn bytes(&self) -> Cow<'_, [u8]>; +} + +/// A suite app consuming the harness: supplies the import/export pair the +/// axes drive (Spec 02 §8). The model type is the consumer's own; round-trip +/// comparison goes through its `NormalizedModel` impl. +pub trait Consumer { + /// The consumer's imported model (e.g. a document, workbook, or + /// presentation — or an enum over them). + type Model; + + /// Imports `fixture` into the model, or a human-readable failure + /// (including the documented "no importer yet" case). + fn import(&self, fixture: &dyn Fixture) -> Result; + + /// Exports `model` back to `format` for the schema and round-trip axes. + /// Import-only corpora return `Err` describing the limitation. + fn export(&self, model: &Self::Model, format: Format) -> Result, String>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn authority_split_matches_the_plan() { + assert_eq!(Format::Docx.canonical_authority(), "Microsoft 365"); + assert_eq!(Format::Odt.canonical_authority(), "LibreOffice"); + } + + #[test] + fn severity_labels() { + assert_eq!(Severity::P0.label(), "P0"); + assert_eq!(Severity::P2.label(), "P2"); + } +} diff --git a/appthere-conformance/src/golden/discovery.rs b/appthere-conformance/src/golden/discovery.rs new file mode 100644 index 00000000..8de2e2b7 --- /dev/null +++ b/appthere-conformance/src/golden/discovery.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Golden/candidate render discovery, promoted from `loki-acid` (Spec 02 B-8) +//! and generalised: the roots are parameters, so any consumer's tree layout +//! works — `loki-acid` pairs `goldens//` with `renders//`, the +//! shared corpus pairs `goldens///` with a caller-chosen candidate +//! root. +//! +//! The convention is one PNG per page, identically named on both sides; +//! [`golden_pages`] lists the golden side and [`candidate_for`] maps a golden +//! page to its candidate path. + +use std::path::{Path, PathBuf}; + +use image::RgbaImage; + +/// Returns the sorted page PNGs under `//` (possibly +/// empty — discovery never errors, so suites stay green until references are +/// supplied). +#[must_use] +pub fn golden_pages(goldens_root: &Path, stem: &str) -> Vec { + let dir = goldens_root.join(stem); + let mut pages: Vec = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("png"))) + .collect(); + pages.sort(); + pages +} + +/// Maps a golden page path to the identically-named candidate page under +/// `candidates_root`, preserving the per-fixture subdirectory. +#[must_use] +pub fn candidate_for(candidates_root: &Path, golden: &Path) -> Option { + let file = golden.file_name()?; + let stem = golden.parent()?.file_name()?; + Some(candidates_root.join(stem).join(file)) +} + +/// Loads a PNG as an RGBA image. +/// +/// # Errors +/// +/// A human-readable message naming the path on decode/I/O failure. +pub fn load_png(path: &Path) -> Result { + image::open(path) + .map(|img| img.to_rgba8()) + .map_err(|e| format!("{}: {e}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidate_path_mirrors_golden_path() { + let golden = Path::new("/g/para-carlito/page-3.png"); + let cand = candidate_for(Path::new("/r"), golden).unwrap(); + assert_eq!(cand, Path::new("/r/para-carlito/page-3.png")); + } + + #[test] + fn missing_golden_dir_yields_empty() { + assert!(golden_pages(Path::new("/nonexistent-goldens"), "x").is_empty()); + } +} diff --git a/appthere-conformance/src/golden/mod.rs b/appthere-conformance/src/golden/mod.rs index af73f566..e61c9b8b 100644 --- a/appthere-conformance/src/golden/mod.rs +++ b/appthere-conformance/src/golden/mod.rs @@ -22,9 +22,11 @@ use std::path::PathBuf; pub mod calibration; mod ciede; pub mod diff; +pub mod discovery; pub use calibration::{CALIBRATED_MAX_DELTA_E, CALIBRATED_MIN_SSIM}; pub use diff::{DiffError, Tolerance, compare_pages, emit_heatmap}; +pub use discovery::{candidate_for, golden_pages, load_png}; /// A perceptual score for one tiled region of a page. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/appthere-conformance/src/lib.rs b/appthere-conformance/src/lib.rs index 155b85df..f33ca3bd 100644 --- a/appthere-conformance/src/lib.rs +++ b/appthere-conformance/src/lib.rs @@ -23,17 +23,21 @@ //! //! ## Status (Spec 02 milestones) //! -//! This is the **M1 skeleton + M2 schema axis**. The schema axis is complete and -//! tested; the round-trip first-divergence differ is implemented; the visual axis -//! is trait + types pending M5. Promotion of `loki-acid`'s catalog / SSIM / -//! golden-discovery into [`golden`] and the per-format model-equality impls are -//! the next passes. A consuming app (Loki Text first) supplies a fixture corpus, -//! a [`roundtrip::NormalizedModel`] impl, an importer/exporter pair, and (later) -//! a CPU render entry point, and gets all three axes — the crate holds no -//! Text-specific assumptions. +//! All three axes are built (M2 schema, M3 round-trip differ + adapters, M5 +//! visual differ/calibration/rasterizer), and the shared corpus layer +//! ([`corpus`], Spec 02 B-8/B-9) is promoted from `loki-acid`: the `TC-*` +//! catalog, severity/format vocabulary, the [`corpus::Fixture`] / +//! [`corpus::Consumer`] traits, the on-disk corpus manifest +//! ([`corpus::manifest`], feature × format × axis with per-fixture reference +//! and tolerance records), and golden/candidate discovery +//! ([`golden::discovery`]). A consuming app supplies a fixture corpus, a +//! [`roundtrip::NormalizedModel`] impl, an importer/exporter pair, and a CPU +//! render entry point, and gets all three axes — the crate holds no +//! Text-specific assumptions (`loki-acid` is the first consumer). #![forbid(unsafe_code)] +pub mod corpus; pub mod golden; #[cfg(feature = "doc-model")] pub mod model; @@ -43,6 +47,9 @@ pub mod schema; #[cfg(feature = "sheet-model")] pub mod sheet; +pub use corpus::{ + Axis, Consumer, Fixture, FixtureMeta, Format, Reference, Severity, TestCase, ToleranceOverride, +}; pub use raster::{CONFORMANCE_DPI, PdfRasterizer, RasterError}; pub use roundtrip::{CanonicalEntry, Divergence, NormalizedModel, first_divergence}; pub use schema::xmllint::XmllintValidator; diff --git a/appthere-ui/Cargo.toml b/appthere-ui/Cargo.toml index ccaabb6c..4e75b08d 100644 --- a/appthere-ui/Cargo.toml +++ b/appthere-ui/Cargo.toml @@ -12,5 +12,4 @@ path = "src/lib.rs" [dependencies] dioxus = { workspace = true } -thiserror = { workspace = true } loki-i18n = { path = "../loki-i18n" } diff --git a/appthere-ui/src/components/document_tab.rs b/appthere-ui/src/components/document_tab.rs index 6e194a4c..08a61bfa 100644 --- a/appthere-ui/src/components/document_tab.rs +++ b/appthere-ui/src/components/document_tab.rs @@ -5,10 +5,7 @@ use dioxus::prelude::*; use loki_i18n::fl; -use crate::tokens::colors::{ - COLOR_TAB_ACTIVE_BG, COLOR_TAB_ACTIVE_INDICATOR, COLOR_TAB_INACTIVE_HOVER, - COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, -}; +use crate::theme::use_theme; use crate::tokens::layout::TAB_BAR_HEIGHT; use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_3, TOUCH_MIN}; use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_LABEL, FONT_WEIGHT_MEDIUM}; @@ -52,13 +49,14 @@ fn truncate_tab_title(title: &str) -> String { /// satisfies WCAG 2.5.8 for the composite interaction target. #[component] pub fn AtDocumentTab(props: AtDocumentTabProps) -> Element { + let palette = use_theme().palette(); let mut close_hovered = use_signal(|| false); let mut tab_hovered = use_signal(|| false); let tab_bg = if props.is_active { - COLOR_TAB_ACTIVE_BG + palette.tab_active_bg } else if tab_hovered() { - COLOR_TAB_INACTIVE_HOVER + palette.tab_inactive_hover } else { "transparent" }; @@ -82,7 +80,7 @@ pub fn AtDocumentTab(props: AtDocumentTabProps) -> Element { } let active_border = if props.is_active { - format!("border-bottom: 2px solid {COLOR_TAB_ACTIVE_INDICATOR};") + format!("border-bottom: 2px solid {};", palette.tab_active_indicator) } else { String::new() }; @@ -124,9 +122,9 @@ pub fn AtDocumentTab(props: AtDocumentTabProps) -> Element { size = FONT_SIZE_LABEL, weight = FONT_WEIGHT_MEDIUM, fg = if props.is_active { - COLOR_TEXT_ON_CHROME + palette.text_on_chrome } else { - COLOR_TEXT_ON_CHROME_SECONDARY + palette.text_on_chrome_secondary }, font = FONT_FAMILY_UI, ), @@ -144,7 +142,7 @@ pub fn AtDocumentTab(props: AtDocumentTabProps) -> Element { padding: 0;", bg = close_bg, r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, + fg = palette.text_on_chrome_secondary, ), onmouseenter: move |_| { close_hovered.set(true); }, onmouseleave: move |_| { close_hovered.set(false); }, diff --git a/appthere-ui/src/components/mod.rs b/appthere-ui/src/components/mod.rs index a9e329f3..f28f431f 100644 --- a/appthere-ui/src/components/mod.rs +++ b/appthere-ui/src/components/mod.rs @@ -15,6 +15,7 @@ pub mod platform; pub mod ribbon; pub mod status_bar; pub mod tab_bar; +pub mod template_browser; pub mod title_bar; pub mod zoom; @@ -29,5 +30,6 @@ pub use platform::Platform; pub use ribbon::{AtRibbon, AtRibbonGroup, AtRibbonGroupProps, RibbonTabDesc, RibbonTabIndex}; pub use status_bar::{AtStatusBar, AtStatusBarProps}; pub use tab_bar::{AtDocumentTabData, AtTabBar, AtTabBarProps}; +pub use template_browser::{AtTemplateBrowser, AtTemplateBrowserProps}; pub use title_bar::{AtTitleBar, AtTitleBarProps}; pub use zoom::next_zoom; diff --git a/appthere-ui/src/components/platform.rs b/appthere-ui/src/components/platform.rs index aae30eab..d9537d97 100644 --- a/appthere-ui/src/components/platform.rs +++ b/appthere-ui/src/components/platform.rs @@ -4,13 +4,9 @@ /// Identifies the host platform so shell components can adapt their layout. /// -/// Passed as a prop to platform-sensitive components such as [`crate::components::AtTitleBar`]. -/// -/// # Note -/// -/// Actual platform detection belongs in the application crate. -/// // TODO(platform): wire to a real `std::env::consts::OS` or `cfg!(target_os)` check -/// in the application's root component and pass the resolved variant down via props. +/// Passed as a prop to platform-sensitive components such as +/// [`crate::components::AtTitleBar`]. Resolve it in the application's root +/// component with [`Platform::detect`] and pass the variant down via props. #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum Platform { /// Windows desktop (default). @@ -27,6 +23,27 @@ pub enum Platform { } impl Platform { + /// The compile-target host platform (`std::env::consts::OS`). Unknown + /// targets fall back to the Windows chrome metrics (the default). + #[must_use] + pub fn detect() -> Self { + Self::from_os_name(std::env::consts::OS) + } + + /// Maps an `std::env::consts::OS`-style name to a variant (pure — the + /// testable core of [`Self::detect`]). + #[must_use] + pub fn from_os_name(os: &str) -> Self { + match os { + "macos" => Self::MacOs, + "linux" => Self::Linux, + "android" => Self::Android, + "ios" => Self::Ios, + // "windows" and anything unrecognised use the default chrome. + _ => Self::Windows, + } + } + /// Returns `true` for desktop platforms (Windows, macOS, Linux). pub fn is_desktop(self) -> bool { matches!(self, Self::Windows | Self::MacOs | Self::Linux) @@ -37,3 +54,26 @@ impl Platform { matches!(self, Self::Android | Self::Ios) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn os_names_map_to_variants() { + assert_eq!(Platform::from_os_name("macos"), Platform::MacOs); + assert_eq!(Platform::from_os_name("linux"), Platform::Linux); + assert_eq!(Platform::from_os_name("android"), Platform::Android); + assert_eq!(Platform::from_os_name("ios"), Platform::Ios); + assert_eq!(Platform::from_os_name("windows"), Platform::Windows); + assert_eq!(Platform::from_os_name("freebsd"), Platform::Windows); + } + + #[test] + fn detect_matches_the_compile_target() { + assert_eq!( + Platform::detect(), + Platform::from_os_name(std::env::consts::OS) + ); + } +} diff --git a/appthere-ui/src/components/ribbon/group.rs b/appthere-ui/src/components/ribbon/group.rs index 8b7577d8..ca0fd72d 100644 --- a/appthere-ui/src/components/ribbon/group.rs +++ b/appthere-ui/src/components/ribbon/group.rs @@ -48,6 +48,11 @@ pub fn AtRibbonGroup( /// so existing call sites keep their full labelled appearance. #[props(default = GroupCollapse::Full)] collapse: GroupCollapse, + /// Draw the trailing right-side divider. Defaults to `true`; the strip + /// container passes `false` for the last rendered group (and for groups + /// hosted in the vertical overflow menu) so no divider dangles at an edge. + #[props(default = true)] + show_divider: bool, /// Buttons and controls inside this group. children: Element, ) -> Element { @@ -80,12 +85,13 @@ pub fn AtRibbonGroup( // deliberate choice, no longer a Blitz limitation.) style: format!( "display: flex; flex-direction: column; align-items: center; \ - 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. - pad = lay.pad_px, - border = tokens::COLOR_BORDER_CHROME, + height: 100%; padding: 0 {pad}px; {divider} box-sizing: border-box;", + pad = lay.pad_px, + divider = if show_divider { + format!("border-right: 1px solid {};", tokens::COLOR_BORDER_CHROME) + } else { + String::new() + }, ), // Button row (fills available height minus optional label row) @@ -102,9 +108,8 @@ pub fn AtRibbonGroup( if lay.show_label { div { style: format!( - // TODO(font): verify Atkinson Hyperlegible Next is - // registered and loading correctly — group labels should - // not be in system-ui. + // Atkinson registration is locked by loki-layout's + // ui_font_registration test (launch-time blob set). "font-family: {font}; font-size: {size}px; color: {fg}; \ text-align: center; padding-bottom: 2px; flex-shrink: 0;", font = FONT_FAMILY_UI, diff --git a/appthere-ui/src/components/ribbon/groups.rs b/appthere-ui/src/components/ribbon/groups.rs index 4fc19cee..46d265e6 100644 --- a/appthere-ui/src/components/ribbon/groups.rs +++ b/appthere-ui/src/components/ribbon/groups.rs @@ -95,15 +95,23 @@ pub fn AtRibbonGroups( set_menu_open(false); } + // The last in-strip group suppresses its trailing divider when nothing + // follows it (no "More" button) — no divider dangles at the strip edge. + let last_rendered = cascade + .states + .iter() + .rposition(|s| *s != GroupCollapse::Overflow); + rsx! { // In-strip groups, each at its resolved collapse state. - for (spec, state) in groups.iter().zip(cascade.states.iter()) { + for (idx, (spec, state)) in groups.iter().zip(cascade.states.iter()).enumerate() { if *state != GroupCollapse::Overflow { AtRibbonGroup { key: "{spec.aria_label}", label: spec.label.clone(), aria_label: spec.aria_label.clone(), collapse: *state, + show_divider: cascade.overflow || last_rendered != Some(idx), {spec.content.clone()} } } @@ -149,13 +157,15 @@ pub fn AtRibbonGroups( border = tokens::COLOR_BORDER_CHROME, radius = tokens::RADIUS_MD, ), - // Overflowed groups render in Full form inside the menu. + // Overflowed groups render in Full form inside the + // menu — stacked vertically, so no right divider. for spec in overflowed.iter() { AtRibbonGroup { key: "{spec.aria_label}", label: spec.label.clone(), aria_label: spec.aria_label.clone(), collapse: GroupCollapse::Full, + show_divider: false, {spec.content.clone()} } } diff --git a/appthere-ui/src/components/ribbon/tab_strip.rs b/appthere-ui/src/components/ribbon/tab_strip.rs index 480572c7..d225ac35 100644 --- a/appthere-ui/src/components/ribbon/tab_strip.rs +++ b/appthere-ui/src/components/ribbon/tab_strip.rs @@ -10,9 +10,23 @@ use dioxus::prelude::*; use crate::components::ribbon::RibbonTabDesc; use crate::components::ribbon::RibbonTabIndex; +use crate::responsive::{use_breakpoint, Breakpoint}; use crate::tokens; use crate::tokens::FONT_FAMILY_UI; +/// The tab-strip height for a breakpoint (Spec 04 M6, R-14): the touch-first +/// Compact class gets full [`tokens::TOUCH_MIN`] targets (WCAG 2.5.8); +/// pointer-first classes keep the desktop-density +/// [`tokens::RIBBON_TAB_STRIP_HEIGHT`], matching the shell tab-bar convention. +#[must_use] +pub fn tab_strip_height(breakpoint: Breakpoint) -> f32 { + if breakpoint == Breakpoint::Compact { + tokens::TOUCH_MIN + } else { + tokens::RIBBON_TAB_STRIP_HEIGHT + } +} + // ── AtRibbonTabStrip ────────────────────────────────────────────────────────── /// Horizontally scrollable row of ribbon tab labels. @@ -22,11 +36,11 @@ use crate::tokens::FONT_FAMILY_UI; /// /// # Touch target /// -/// Individual [`AtRibbonTab`] components fill the strip height -/// ([`tokens::RIBBON_TAB_STRIP_HEIGHT`] = 36 px), which is below the WCAG -/// 2.5.8 minimum of 44 px. This matches the existing tab bar convention in -/// the AppThere shell; a future design pass should increase the strip height -/// to 44 px or add invisible padding to meet the requirement. +/// Individual [`AtRibbonTab`] components fill the strip height. At the +/// touch-first Compact breakpoint the strip is [`tokens::TOUCH_MIN`] (44 px) +/// tall, meeting WCAG 2.5.8 (Spec 04 M6, R-14); pointer-first breakpoints +/// keep the desktop-density 36 px ([`tokens::RIBBON_TAB_STRIP_HEIGHT`]), +/// matching the shell tab-bar convention. #[component] pub fn AtRibbonTabStrip( /// Ordered list of tabs to display (core tabs first, then contextual). @@ -43,6 +57,8 @@ pub fn AtRibbonTabStrip( /// Should be `fl!("ribbon-collapse-aria")` or `fl!("ribbon-expand-aria")`. toggle_aria_label: String, ) -> Element { + // Touch posture (R-14): resilient — no responsive context reads Expanded. + let strip_h = tab_strip_height(use_breakpoint()); rsx! { div { role: "tablist", @@ -53,7 +69,7 @@ pub fn AtRibbonTabStrip( align-items: stretch; overflow-x: auto; \ background: {bg}; border-bottom: 1px solid {border}; \ flex-shrink: 0;", - h = tokens::RIBBON_TAB_STRIP_HEIGHT, + h = strip_h, bg = tokens::COLOR_SURFACE_2, border = tokens::COLOR_BORDER_CHROME, ), @@ -72,7 +88,7 @@ pub fn AtRibbonTabStrip( div { style: "flex: 1;" } // Collapse / expand toggle. - // Minimum touch target: RIBBON_TAB_STRIP_HEIGHT × min-width 44 px. + // Minimum touch target: strip height × min-width 44 px. button { aria_label: toggle_aria_label, style: format!( @@ -81,7 +97,7 @@ pub fn AtRibbonTabStrip( color: {fg}; font-size: 11px; flex-shrink: 0; \ display: flex; align-items: center; justify-content: center;", touch = tokens::TOUCH_MIN, - h = tokens::RIBBON_TAB_STRIP_HEIGHT, + h = strip_h, p = tokens::SPACE_2, fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, ), @@ -100,8 +116,8 @@ pub fn AtRibbonTabStrip( /// /// # Touch target /// -/// Fills the strip height (36 px) — see [`AtRibbonTabStrip`] for the known -/// WCAG 2.5.8 limitation. +/// Fills the strip height — 44 px ([`tokens::TOUCH_MIN`]) at the touch-first +/// Compact breakpoint, 36 px at pointer-first ones (see [`AtRibbonTabStrip`]). #[component] fn AtRibbonTab( desc: RibbonTabDesc, @@ -144,8 +160,8 @@ fn AtRibbonTab( aria_selected: if is_active { "true" } else { "false" }, aria_label: aria_label, style: format!( - // TODO(font): verify Atkinson Hyperlegible Next is registered - // and loading correctly — ribbon tab labels should not be in system-ui. + // Atkinson registration is locked by loki-layout's + // ui_font_registration test (launch-time blob set). "min-width: 64px; padding: 0 {p}px; display: flex; \ align-items: center; justify-content: center; \ background: {bg}; border: none; cursor: pointer; \ @@ -165,3 +181,24 @@ fn AtRibbonTab( } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// R-14: touch-first Compact gets WCAG 2.5.8 targets; pointer-first + /// classes keep desktop density. + #[test] + fn compact_strip_is_touch_sized_and_others_keep_desktop_density() { + assert_eq!(tab_strip_height(Breakpoint::Compact), tokens::TOUCH_MIN); + assert_eq!( + tab_strip_height(Breakpoint::Medium), + tokens::RIBBON_TAB_STRIP_HEIGHT + ); + assert_eq!( + tab_strip_height(Breakpoint::Expanded), + tokens::RIBBON_TAB_STRIP_HEIGHT + ); + assert!(tab_strip_height(Breakpoint::Compact) >= 44.0, "WCAG 2.5.8"); + } +} diff --git a/appthere-ui/src/components/status_bar.rs b/appthere-ui/src/components/status_bar.rs index 41e85bba..2fee3713 100644 --- a/appthere-ui/src/components/status_bar.rs +++ b/appthere-ui/src/components/status_bar.rs @@ -8,10 +8,7 @@ use dioxus::prelude::*; use crate::responsive::use_breakpoint; -use crate::tokens::colors::{ - COLOR_BORDER_CHROME, COLOR_CONTEXTUAL_TAB, COLOR_SURFACE_3, COLOR_SURFACE_CHROME, - COLOR_TEXT_ACCENT, COLOR_TEXT_ON_CHROME_SECONDARY, -}; +use crate::theme::use_theme; use crate::tokens::layout::STATUS_BAR_HEIGHT; use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2, SPACE_4, TOUCH_MIN}; use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_XS, FONT_WEIGHT_MEDIUM}; @@ -33,23 +30,24 @@ use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_XS, FONT_WEIGHT_MEDIUM /// it is the hit area. #[component] pub fn AtStatusBar(props: AtStatusBarProps) -> Element { + let palette = use_theme().palette(); let mut zoom_hovered = use_signal(|| false); let zoom_bg = if zoom_hovered() { "#444444" } else { - COLOR_SURFACE_3 + palette.surface_3 }; let mut view_hovered = use_signal(|| false); let view_bg = if view_hovered() { "#444444" } else { - COLOR_SURFACE_3 + palette.surface_3 }; let mut notice_hovered = use_signal(|| false); let notice_bg = if notice_hovered() { "#444444" } else { - COLOR_SURFACE_3 + palette.surface_3 }; let show_view_toggle = !props.view_mode_label.is_empty(); let show_notice = !props.notice_label.is_empty(); @@ -68,8 +66,8 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { padding: 0 {pad}px; flex-shrink: 0; gap: {gap}px; \ font-family: {font};", h = STATUS_BAR_HEIGHT, - bg = COLOR_SURFACE_CHROME, - border = COLOR_BORDER_CHROME, + bg = palette.surface_chrome, + border = palette.border_chrome, pad = SPACE_4, gap = SPACE_4, font = FONT_FAMILY_UI, @@ -84,7 +82,7 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { style: format!( "font-size: {size}px; color: {fg};", size = FONT_SIZE_XS, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, + fg = palette.text_on_chrome_secondary, ), "{props.page_label}" } @@ -97,7 +95,7 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { style: format!( "font-size: {size}px; color: {fg};", size = FONT_SIZE_XS, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, + fg = palette.text_on_chrome_secondary, ), "{props.word_count_label}" } @@ -122,9 +120,9 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { color: {fg}; font-size: {size}px; font-weight: {weight}; \ padding: {pv}px {ph}px;", bg = notice_bg, - border = COLOR_CONTEXTUAL_TAB, + border = palette.contextual_tab, r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, + fg = palette.text_on_chrome_secondary, size = FONT_SIZE_XS, weight = FONT_WEIGHT_MEDIUM, pv = SPACE_1, @@ -146,7 +144,7 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { style: format!( "font-size: {size}px; color: {fg};", size = FONT_SIZE_XS, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, + fg = palette.text_on_chrome_secondary, ), "{props.language_label}" } @@ -166,7 +164,7 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { onmouseleave: move |_| { view_hovered.set(false); }, onclick: move |_| { props.on_view_mode_click.call(()); }, span { - style: chip_style(view_bg), + style: chip_style(view_bg, palette.text_on_chrome_secondary), "{props.view_mode_label}" } } @@ -184,7 +182,7 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { onmouseleave: move |_| { zoom_hovered.set(false); }, onclick: move |_| { props.on_zoom_click.call(()); }, span { - style: chip_style(zoom_bg), + style: chip_style(zoom_bg, palette.text_on_chrome_secondary), "{props.zoom_percent}%" } } @@ -195,7 +193,7 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { style: format!( "font-size: {size}px; color: {fg};", size = FONT_SIZE_XS, - fg = COLOR_TEXT_ACCENT, + fg = palette.text_accent, ), "{props.collaborator_label}" } @@ -214,13 +212,13 @@ fn hit_area_style() -> String { ) } -/// Visual chip style for the view-mode / zoom badges (`bg` swaps on hover). -fn chip_style(bg: &str) -> String { +/// Visual chip style for the view-mode / zoom badges (`bg` swaps on hover; +/// `fg` comes from the active theme palette). +fn chip_style(bg: &str, fg: &str) -> String { format!( "background: {bg}; border-radius: {r}px; color: {fg}; \ font-size: {size}px; font-weight: {weight}; padding: {pv}px {ph}px;", r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, size = FONT_SIZE_XS, weight = FONT_WEIGHT_MEDIUM, pv = SPACE_1, diff --git a/appthere-ui/src/components/tab_bar.rs b/appthere-ui/src/components/tab_bar.rs index a2070202..74db0c7a 100644 --- a/appthere-ui/src/components/tab_bar.rs +++ b/appthere-ui/src/components/tab_bar.rs @@ -12,11 +12,7 @@ use dioxus::prelude::*; use crate::components::document_tab::AtDocumentTab; -use crate::tokens::colors::{ - COLOR_BORDER_CHROME, COLOR_SURFACE_CHROME, COLOR_TAB_ACTIVE_BG, COLOR_TAB_ACTIVE_INDICATOR, - COLOR_TAB_INACTIVE_HOVER, COLOR_TEXT_ACCENT, COLOR_TEXT_ON_CHROME, - COLOR_TEXT_ON_CHROME_SECONDARY, -}; +use crate::theme::use_theme; use crate::tokens::layout::TAB_BAR_HEIGHT; use crate::tokens::spacing::{SPACE_2, SPACE_3, TOUCH_MIN}; use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD}; @@ -44,25 +40,33 @@ pub struct AtDocumentTabData { /// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** #[component] pub fn AtTabBar(props: AtTabBarProps) -> Element { + let mut theme = use_theme(); + let palette = theme.palette(); let mut home_hovered = use_signal(|| false); let mut new_tab_hovered = use_signal(|| false); + let mut theme_hovered = use_signal(|| false); let home_is_active = props.active_index == 0; let home_bg = if home_is_active { - COLOR_TAB_ACTIVE_BG + palette.tab_active_bg } else if home_hovered() { - COLOR_TAB_INACTIVE_HOVER + palette.tab_inactive_hover } else { "transparent" }; let home_border = if home_is_active { - format!("border-bottom: 2px solid {COLOR_TAB_ACTIVE_INDICATOR};") + format!("border-bottom: 2px solid {};", palette.tab_active_indicator) } else { String::new() }; let new_tab_bg = if new_tab_hovered() { - COLOR_TAB_INACTIVE_HOVER + palette.tab_inactive_hover + } else { + "transparent" + }; + let theme_bg = if theme_hovered() { + palette.tab_inactive_hover } else { "transparent" }; @@ -82,8 +86,8 @@ pub fn AtTabBar(props: AtTabBarProps) -> Element { // (e.g. min-height: TOUCH_MIN on tab items). // scrollbar-width: none is unconfirmed — verify at runtime. h = TAB_BAR_HEIGHT, - bg = COLOR_SURFACE_CHROME, - border = COLOR_BORDER_CHROME, + bg = palette.surface_chrome, + border = palette.border_chrome, font = FONT_FAMILY_UI, ), @@ -116,9 +120,9 @@ pub fn AtTabBar(props: AtTabBarProps) -> Element { size = FONT_SIZE_LABEL, weight = FONT_WEIGHT_SEMIBOLD, fg = if home_is_active { - COLOR_TEXT_ON_CHROME + palette.text_on_chrome } else { - COLOR_TEXT_ON_CHROME_SECONDARY + palette.text_on_chrome_secondary }, ), "{props.home_tab_label}" @@ -161,7 +165,7 @@ pub fn AtTabBar(props: AtTabBarProps) -> Element { display: flex; align-items: center; justify-content: center; \ padding: 0 {p}px;", bg = new_tab_bg, - fg = COLOR_TEXT_ACCENT, + fg = palette.text_accent, touch = TOUCH_MIN, p = SPACE_2, ), @@ -170,6 +174,32 @@ pub fn AtTabBar(props: AtTabBarProps) -> Element { onclick: move |_| { props.on_new_tab.call(()); }, "+" } + + // ── Theme toggle (Dark ⇄ Light) ─────────────────────────────────── + // Rendered only when the app supplies an aria label (i18n rule: + // display strings are props). Self-contained: flips the shared + // AtThemeContext signal, so every palette-reading component + // re-colors live. 44 px touch target (WCAG 2.5.8). + if !props.theme_toggle_aria_label.is_empty() { + button { + "aria-label": props.theme_toggle_aria_label, + style: format!( + "background: {bg}; border: none; \ + color: {fg}; font-size: 14px; cursor: pointer; \ + width: 32px; height: {touch}px; flex-shrink: 0; \ + display: flex; align-items: center; justify-content: center; \ + padding: 0 {p}px;", + bg = theme_bg, + fg = palette.text_on_chrome_secondary, + touch = TOUCH_MIN, + p = SPACE_2, + ), + onmouseenter: move |_| { theme_hovered.set(true); }, + onmouseleave: move |_| { theme_hovered.set(false); }, + onclick: move |_| { theme.toggle(); }, + "◐" + } + } } } } @@ -196,4 +226,8 @@ pub struct AtTabBarProps { pub on_new_tab: EventHandler<()>, /// Aria label for the new-tab button. pub new_tab_aria_label: String, + /// Aria label for the Dark ⇄ Light theme toggle. Empty (the default) + /// hides the toggle, so existing call sites are unchanged. + #[props(default)] + pub theme_toggle_aria_label: String, } diff --git a/appthere-ui/src/components/template_browser.rs b/appthere-ui/src/components/template_browser.rs new file mode 100644 index 00000000..b21a8357 --- /dev/null +++ b/appthere-ui/src/components/template_browser.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `AtTemplateBrowser` — a blocking overlay listing every available document +//! template (the Home tab's gallery shows only the first few; the "Browse…" +//! card opens this full list). +//! +//! Same mounting contract as [`AtConfirmDialog`](super::confirm_dialog): a +//! `position: absolute` backdrop, so the mounting parent (or an ancestor) +//! must be `position: relative` and span the area to dim. Mount conditionally +//! at a component boundary (ADR-0013): +//! ```rust,ignore +//! {browsing().then(|| rsx! { +//! AtTemplateBrowser { +//! title: fl!("home-browse-title"), +//! cancel_label: fl!("home-browse-cancel"), +//! entries: template_names, +//! on_select: move |idx| { open_template(idx); browsing.set(false); }, +//! on_cancel: move |_| browsing.set(false), +//! } +//! })} +//! ``` +//! +//! Clicking the backdrop cancels. Every template row is at least 44 logical +//! pixels tall (WCAG 2.5.8). Colors resolve through the theme palette. + +use dioxus::prelude::*; + +use crate::theme::use_theme; +use crate::tokens::spacing::{RADIUS_MD, RADIUS_SM, SPACE_2, SPACE_3, SPACE_4, TOUCH_MIN}; +use crate::tokens::typography::{ + FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_MD, FONT_WEIGHT_SEMIBOLD, +}; + +/// Card width — matches `AtConfirmDialog`; the list scrolls inside. +const BROWSER_WIDTH_PX: f32 = 360.0; + +/// Maximum list height before it scrolls (keeps the card on Compact screens). +const BROWSER_LIST_MAX_PX: f32 = 320.0; + +/// Props for [`AtTemplateBrowser`]. All display strings are props +/// (i18n-agnostic). +#[derive(Props, Clone, PartialEq)] +pub struct AtTemplateBrowserProps { + /// Dialog heading (e.g. "All templates"). + pub title: String, + /// Label of the closing button. + pub cancel_label: String, + /// Display name of each selectable template, in presentation order. + pub entries: Vec, + /// Invoked with the selected entry's index. + pub on_select: EventHandler, + /// Invoked when the user dismisses (button or backdrop click). + pub on_cancel: EventHandler<()>, +} + +/// Template list overlay. See the module docs for the mounting contract. +/// +/// Touch targets: each template row and the close button are at least +/// **44×44 logical pixels** (WCAG 2.5.8) via `min-height: TOUCH_MIN`. +#[component] +pub fn AtTemplateBrowser(props: AtTemplateBrowserProps) -> Element { + let palette = use_theme().palette(); + rsx! { + div { + style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%; \ + z-index: 2000; background: rgba(0, 0, 0, 0.45); \ + display: flex; align-items: center; justify-content: center;", + role: "presentation", + onclick: move |_| props.on_cancel.call(()), + + div { + style: format!( + "width: {w}px; max-width: 90%; box-sizing: border-box; \ + display: flex; flex-direction: column; gap: {gap}px; \ + background: {bg}; border: 1px solid {border}; \ + border-radius: {r}px; padding: {pad}px; \ + font-family: {font}; color: {fg};", + w = BROWSER_WIDTH_PX, + gap = SPACE_3, + bg = palette.surface_1, + border = palette.border_chrome, + r = RADIUS_MD, + pad = SPACE_4, + font = FONT_FAMILY_UI, + fg = palette.text_on_chrome, + ), + role: "dialog", + "aria-label": props.title.clone(), + onclick: move |evt| evt.stop_propagation(), + + div { + style: format!( + "font-size: {fs}px; font-weight: {fw};", + fs = FONT_SIZE_MD, + fw = FONT_WEIGHT_SEMIBOLD, + ), + {props.title.clone()} + } + + // Scrollable template list, one full-width row per entry. + div { + style: format!( + "display: flex; flex-direction: column; gap: {gap}px; \ + max-height: {max}px; overflow-y: auto;", + gap = SPACE_2, + max = BROWSER_LIST_MAX_PX, + ), + for (idx, name) in props.entries.iter().enumerate() { + button { + key: "{idx}", + style: format!( + "min-height: {th}px; box-sizing: border-box; \ + padding: {py}px {px}px; border-radius: {r}px; \ + background: {bg}; border: 1px solid {border}; \ + color: {fg}; font-family: {font}; \ + font-size: {fs}px; cursor: pointer; \ + display: flex; align-items: center; \ + text-align: left;", + th = TOUCH_MIN, + py = SPACE_2, + px = SPACE_3, + r = RADIUS_SM, + bg = palette.surface_2, + border = palette.border_chrome, + fg = palette.text_on_chrome, + font = FONT_FAMILY_UI, + fs = FONT_SIZE_BODY, + ), + onclick: move |evt| { + evt.stop_propagation(); + props.on_select.call(idx); + }, + "{name}" + } + } + } + + div { + style: format!( + "display: flex; justify-content: flex-end; gap: {gap}px;", + gap = SPACE_2, + ), + button { + style: format!( + "min-height: {th}px; box-sizing: border-box; \ + padding: {py}px {px}px; border-radius: {r}px; \ + font-family: {font}; font-size: {fs}px; \ + font-weight: {fw}; background: transparent; \ + border: 1px solid {border}; color: {fg}; \ + display: flex; align-items: center; justify-content: center;", + th = TOUCH_MIN, + py = SPACE_2, + px = SPACE_4, + r = RADIUS_MD, + font = FONT_FAMILY_UI, + fs = FONT_SIZE_BODY, + fw = FONT_WEIGHT_SEMIBOLD, + border = palette.border_chrome, + fg = palette.text_on_chrome, + ), + onclick: move |evt| { + evt.stop_propagation(); + props.on_cancel.call(()); + }, + {props.cancel_label.clone()} + } + } + } + } + } +} diff --git a/appthere-ui/src/components/title_bar.rs b/appthere-ui/src/components/title_bar.rs index 971359e6..cf298119 100644 --- a/appthere-ui/src/components/title_bar.rs +++ b/appthere-ui/src/components/title_bar.rs @@ -3,16 +3,14 @@ //! `AtTitleBar` — application title bar shell component. //! //! Renders on all platforms. The [`Platform`] prop controls height and -//! left-padding to avoid overlapping OS window decorations. +//! left-padding to avoid overlapping OS window decorations. Colors resolve +//! through [`use_theme`], so the bar re-colors live on a theme toggle. use dioxus::prelude::*; use crate::components::platform::Platform; use crate::responsive::use_breakpoint; -use crate::tokens::colors::{ - COLOR_BORDER_CHROME, COLOR_SURFACE_CHROME, COLOR_TEXT_ACCENT, COLOR_TEXT_ON_CHROME, - COLOR_TEXT_ON_CHROME_SECONDARY, -}; +use crate::theme::use_theme; use crate::tokens::layout::{TITLE_BAR_HEIGHT_DEFAULT, TITLE_BAR_HEIGHT_MACOS}; use crate::tokens::spacing::{ ICON_SIZE_LG, RADIUS_SM, SPACE_1, SPACE_10, SPACE_2, SPACE_3, TOUCH_MIN, @@ -32,7 +30,10 @@ use crate::tokens::typography::{ /// /// `[icon | title (center, flex:1) | collaborator badge]` /// -/// The center region acts as a drag target placeholder. +/// The center region acts as a drag target placeholder. When +/// [`AtTitleBarProps::on_rename`] is set (and a document is open), the title +/// is inline-editable: click to place the caret, Enter or focus-out commits +/// the new name through the callback. /// /// # Touch target /// @@ -40,17 +41,15 @@ use crate::tokens::typography::{ /// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** #[component] pub fn AtTitleBar(props: AtTitleBarProps) -> Element { + let palette = use_theme().palette(); let bar_height = if props.platform == Platform::MacOs { TITLE_BAR_HEIGHT_MACOS } else { TITLE_BAR_HEIGHT_DEFAULT }; - // macOS: leave SPACE_10 (40px) on the left so traffic light buttons are - // not obscured by the icon. - // TODO(platform): macOS traffic light region — the window decoration - // buttons are managed by blitz-shell at the window layer. This component - // leaves left padding of SPACE_10 on MacOs to avoid overlapping them. + // macOS: leave SPACE_10 (40px) on the left so the traffic-light window + // buttons (managed by blitz-shell at the window layer) are not obscured. let left_pad = if props.platform == Platform::MacOs { SPACE_10 } else { @@ -59,7 +58,7 @@ pub fn AtTitleBar(props: AtTitleBarProps) -> Element { let mut icon_hovered = use_signal(|| false); let icon_bg = if icon_hovered() { - "#2E2E2E" + palette.surface_2 } else { "transparent" }; @@ -86,8 +85,8 @@ pub fn AtTitleBar(props: AtTitleBarProps) -> Element { padding: 0 {pr}px 0 {pl}px; flex-shrink: 0; gap: {gap}px; \ font-family: {font};", h = bar_height, - bg = COLOR_SURFACE_CHROME, - border = COLOR_BORDER_CHROME, + bg = palette.surface_chrome, + border = palette.border_chrome, pr = SPACE_3, pl = left_pad, gap = SPACE_2, @@ -121,7 +120,7 @@ pub fn AtTitleBar(props: AtTitleBarProps) -> Element { background: {bg};", sz = ICON_SIZE_LG, r = RADIUS_SM, - bg = "#3D7EFF", + bg = palette.accent_primary, ), } } @@ -130,19 +129,23 @@ pub fn AtTitleBar(props: AtTitleBarProps) -> Element { // COMPAT(dioxus-native): Window drag region requires a winit platform // event. Implemented as a flex spacer for now; dragging uses OS default // behavior until blitz-shell exposes a drag-region API. - // TODO(title-edit): Make the title field inline-editable (future pass). - span { - style: format!( - "flex: 1; font-size: {size}px; font-weight: {weight}; \ - color: {fg}; text-align: center; \ - overflow: hidden;", - // COMPAT(dioxus-native): text-overflow: ellipsis is unconfirmed — - // verify at runtime. - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - fg = COLOR_TEXT_ON_CHROME, - ), - "{title_text}" + if let (Some(title), Some(on_rename)) = (&props.document_title, props.on_rename) { + // Keyed by the committed title so an external rename reseeds the draft. + TitleEditField { key: "{title}", title: title.clone(), on_rename } + } else { + span { + style: format!( + "flex: 1; font-size: {size}px; font-weight: {weight}; \ + color: {fg}; text-align: center; \ + overflow: hidden;", + // COMPAT(dioxus-native): text-overflow: ellipsis is unconfirmed — + // verify at runtime. + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + fg = palette.text_on_chrome, + ), + "{title_text}" + } } // ── Right slot: collaborator badge ──────────────────────────────── @@ -151,7 +154,7 @@ pub fn AtTitleBar(props: AtTitleBarProps) -> Element { style: format!( "font-size: {size}px; color: {fg}; flex-shrink: 0;", size = FONT_SIZE_LABEL, - fg = COLOR_TEXT_ACCENT, + fg = palette.text_accent, ), "{props.collaborator_label}" } @@ -167,7 +170,7 @@ pub fn AtTitleBar(props: AtTitleBarProps) -> Element { color: {fg}; flex-shrink: 0;", size = FONT_SIZE_LABEL, weight = FONT_WEIGHT_BOLD, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, + fg = palette.text_on_chrome_secondary, ), "{props.app_name}" } @@ -179,6 +182,54 @@ pub fn AtTitleBar(props: AtTitleBarProps) -> Element { } } +// ── TitleEditField ──────────────────────────────────────────────────────────── + +/// The inline-editable center title (ADR-0013: owns its draft signal; the +/// parent keys it by the committed title so external renames reseed it). +/// Enter or focus-out commits a non-empty, changed draft via `on_rename`; +/// Escape resets the draft to the committed title. +#[component] +fn TitleEditField(title: String, on_rename: EventHandler) -> Element { + let palette = use_theme().palette(); + let mut draft = use_signal(|| title.clone()); + let commit = { + let title = title.clone(); + move || { + let text = draft.read().trim().to_string(); + if !text.is_empty() && text != title { + on_rename.call(text); + } + } + }; + let commit_on_enter = commit.clone(); + let reset_title = title.clone(); + rsx! { + input { + r#type: "text", + value: "{draft}", + "aria-label": "{title}", + style: format!( + "flex: 1; font-size: {size}px; font-weight: {weight}; \ + color: {fg}; text-align: center; overflow: hidden; \ + background: transparent; border: none; font-family: {font}; \ + min-height: {touch}px;", + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + fg = palette.text_on_chrome, + font = FONT_FAMILY_UI, + touch = TOUCH_MIN, + ), + oninput: move |evt| draft.set(evt.value()), + onkeydown: move |evt| match evt.key() { + Key::Enter => commit_on_enter(), + Key::Escape => draft.set(reset_title.clone()), + _ => {} + }, + onblur: move |_| commit(), + } + } +} + // ── Props ───────────────────────────────────────────────────────────────────── /// Props for [`AtTitleBar`]. @@ -206,4 +257,10 @@ pub struct AtTitleBarProps { /// Callback invoked when the user clicks the app icon area. pub on_icon_press: EventHandler<()>, + + /// When set (and a document is open), the center title becomes + /// inline-editable; Enter / focus-out commits the new name here. + /// `None` keeps the read-only title (existing behavior). + #[props(default)] + pub on_rename: Option>, } diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index b2285a3b..bc620198 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -51,8 +51,8 @@ pub use components::{ next_zoom, use_backdrop, use_provide_backdrop, AtBackdropContext, AtBackdropHost, AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps, - AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, - RecentDocument, BACKDROP_Z_INDEX, + AtTabBar, AtTabBarProps, AtTemplateBrowser, AtTemplateBrowserProps, AtTitleBar, + AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, RecentDocument, BACKDROP_Z_INDEX, }; pub use responsive::{ estimate_group_metrics, group_layout, page_fits, required_page_width, resolve_cascade, diff --git a/appthere-ui/src/responsive/ribbon_collapse_tests.rs b/appthere-ui/src/responsive/ribbon_collapse_tests.rs index d902a62f..28679160 100644 --- a/appthere-ui/src/responsive/ribbon_collapse_tests.rs +++ b/appthere-ui/src/responsive/ribbon_collapse_tests.rs @@ -186,7 +186,8 @@ fn condensed_layout_drops_the_label_and_tightens() { 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"); + // Compile-time token-scale sanity: condensed padding is tighter than full. + const { assert!(SPACE_1 < SPACE_2) }; } #[test] diff --git a/appthere-ui/src/theme.rs b/appthere-ui/src/theme.rs index 3ce8231a..a18cdb55 100644 --- a/appthere-ui/src/theme.rs +++ b/appthere-ui/src/theme.rs @@ -3,23 +3,48 @@ //! Theme context for AppThere shell components. //! //! Inject at the application root with `provide_context(AtThemeContext::default())`. -//! Read in any descendant component with [`use_theme`]. +//! Read in any descendant component with [`use_theme`]; resolve colors with +//! [`AtThemeContext::palette`]. The variant is Signal-backed, so a component +//! that reads `palette()` (or `variant()`) inside its render re-colors live +//! when the variant changes (e.g. via [`AtThemeContext::toggle`]). use dioxus::prelude::*; +use crate::tokens::ThemePalette; + // ── ThemeVariant ────────────────────────────────────────────────────────────── /// Selects which color palette the shell components use. #[derive(Clone, Copy, PartialEq, Eq, Default, Debug)] pub enum ThemeVariant { - /// Dark shell chrome (default — the only implemented variant). + /// Dark shell chrome (default). #[default] Dark, - // TODO(theme): Light theme tokens are not yet implemented. - /// Light color palette (not yet implemented). + /// Light shell chrome ([`ThemePalette::light`]). Light, } +impl ThemeVariant { + /// The color palette for this variant (pure — the reactive read lives on + /// [`AtThemeContext::palette`]). + #[must_use] + pub const fn palette(self) -> ThemePalette { + match self { + Self::Dark => ThemePalette::dark(), + Self::Light => ThemePalette::light(), + } + } + + /// The other variant (Dark ⇄ Light). + #[must_use] + pub const fn toggled(self) -> Self { + match self { + Self::Dark => Self::Light, + Self::Light => Self::Dark, + } + } +} + // ── AtThemeContext ──────────────────────────────────────────────────────────── /// Theme context injected at the application root and read by shell components. @@ -30,43 +55,59 @@ pub enum ThemeVariant { /// ```rust,ignore /// provide_context(AtThemeContext::default()); // ThemeVariant::Dark /// ``` -#[derive(Clone, PartialEq, Debug)] +#[derive(Clone, Copy, PartialEq, Debug)] pub struct AtThemeContext { - /// Active color palette variant. - pub variant: ThemeVariant, + /// Active color palette variant (Signal-backed for live re-theming). + pub variant: Signal, } -impl Default for AtThemeContext { - fn default() -> Self { +impl AtThemeContext { + /// Creates a context with the given starting variant. Must be called in + /// a reactive scope (a component body), like `Signal::new`. + #[must_use] + pub fn new(variant: ThemeVariant) -> Self { Self { - variant: ThemeVariant::Dark, + variant: Signal::new(variant), } } + + /// The active variant (reactive read). + #[must_use] + pub fn variant(&self) -> ThemeVariant { + (self.variant)() + } + + /// The color palette for the active variant (reactive read). + #[must_use] + pub fn palette(&self) -> ThemePalette { + self.variant().palette() + } + + /// Flips Dark ⇄ Light. Every component reading [`Self::palette`] in its + /// render re-colors. + pub fn toggle(&mut self) { + let next = self.variant().toggled(); + self.variant.set(next); + } +} + +impl Default for AtThemeContext { + fn default() -> Self { + Self::new(ThemeVariant::Dark) + } } // ── use_theme ───────────────────────────────────────────────────────────────── -/// Reads the [`AtThemeContext`] injected at the application root. -/// -/// # Panics -/// -/// Panics in debug builds if [`AtThemeContext`] has not been provided via -/// `provide_context(AtThemeContext::default())` in an ancestor component. +/// Reads the [`AtThemeContext`] injected at the application root, falling +/// back to a component-local Dark-variant context when none was provided +/// (so a component tree without a themed root — tests, embedding — still +/// renders; the `use_breakpoint` resilience pattern). +#[must_use] pub fn use_theme() -> AtThemeContext { - use_context::() + use_hook(|| try_consume_context::().unwrap_or_default()) } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn theme_variant_defaults_to_dark() { - assert_eq!(ThemeVariant::default(), ThemeVariant::Dark); - } - - #[test] - fn theme_context_defaults_to_dark_variant() { - assert_eq!(AtThemeContext::default().variant, ThemeVariant::Dark); - } -} +#[path = "theme_tests.rs"] +mod tests; diff --git a/appthere-ui/src/theme_tests.rs b/appthere-ui/src/theme_tests.rs new file mode 100644 index 00000000..f0bc7e77 --- /dev/null +++ b/appthere-ui/src/theme_tests.rs @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Theme-variant tests (extracted; the pure `ThemeVariant` surface — the +//! Signal-backed `AtThemeContext` wrapper is exercised by component usage). + +use super::*; +use crate::tokens::colors::{COLOR_SURFACE_CHROME, COLOR_TEXT_ON_CHROME}; + +#[test] +fn theme_variant_defaults_to_dark() { + assert_eq!(ThemeVariant::default(), ThemeVariant::Dark); +} + +#[test] +fn dark_palette_is_the_token_constants() { + let p = ThemeVariant::Dark.palette(); + assert_eq!(p.surface_chrome, COLOR_SURFACE_CHROME); + assert_eq!(p.text_on_chrome, COLOR_TEXT_ON_CHROME); +} + +#[test] +fn light_palette_re_colors_the_chrome() { + let dark = ThemeVariant::Dark.palette(); + let light = ThemeVariant::Light.palette(); + assert_ne!(light.surface_chrome, dark.surface_chrome); + assert_ne!(light.text_on_chrome, dark.text_on_chrome); + // The document page stays white in both variants. + assert_eq!(light.surface_page, dark.surface_page); +} + +#[test] +fn toggled_flips_and_round_trips() { + assert_eq!(ThemeVariant::Dark.toggled(), ThemeVariant::Light); + assert_eq!(ThemeVariant::Light.toggled(), ThemeVariant::Dark); + assert_eq!(ThemeVariant::Dark.toggled().toggled(), ThemeVariant::Dark); +} diff --git a/appthere-ui/src/tokens/mod.rs b/appthere-ui/src/tokens/mod.rs index b71aaa79..b9d15b31 100644 --- a/appthere-ui/src/tokens/mod.rs +++ b/appthere-ui/src/tokens/mod.rs @@ -15,10 +15,12 @@ pub mod colors; pub mod layout; +pub mod palette; pub mod spacing; pub mod typography; pub use colors::*; pub use layout::*; +pub use palette::ThemePalette; pub use spacing::*; pub use typography::*; diff --git a/appthere-ui/src/tokens/palette.rs b/appthere-ui/src/tokens/palette.rs new file mode 100644 index 00000000..976285ef --- /dev/null +++ b/appthere-ui/src/tokens/palette.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Variant-resolved color palette (deferred-features 4c.4, topic `theme`). +//! +//! [`ThemePalette`] carries one field per color token in +//! [`colors`](super::colors), so a component that reads +//! `use_theme().palette()` re-colors with the active [`ThemeVariant`] +//! (`crate::theme`). The `COLOR_*` constants remain the **dark** values, so +//! unmigrated call sites keep compiling and rendering exactly as before; +//! components migrate to the palette opportunistically, shell chrome first. +//! +//! [`ThemePalette::dark`] is built *from* the constants (zero drift by +//! construction); [`ThemePalette::light`] is the inverted set. Tokens that +//! are surface-independent (the white page, error banner, disabled opacity) +//! keep the same value in both variants. + +use super::colors::*; + +/// One resolved color per [`colors`](super::colors) token. All values are +/// CSS color strings suitable for inline `style` attributes. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[allow(missing_docs)] // Field names mirror the documented `COLOR_*` tokens. +pub struct ThemePalette { + pub surface_page: &'static str, + pub surface_base: &'static str, + pub surface_chrome: &'static str, + pub surface_1: &'static str, + pub surface_2: &'static str, + pub surface_3: &'static str, + pub border_default: &'static str, + pub border_chrome: &'static str, + pub accent_primary: &'static str, + pub accent_primary_hover: &'static str, + pub text_primary: &'static str, + pub text_secondary: &'static str, + pub text_on_chrome: &'static str, + pub text_on_chrome_secondary: &'static str, + pub text_accent: &'static str, + pub tab_active_bg: &'static str, + pub tab_active_indicator: &'static str, + pub tab_inactive_hover: &'static str, + pub contextual_tab: &'static str, + pub icon_disabled: &'static str, + pub canvas_page_bg: &'static str, + pub canvas_margin_bg: &'static str, + pub scrollbar_thumb: &'static str, + pub scrollbar_thumb_hover: &'static str, + pub scrollbar_track: &'static str, + pub status_error_bg: &'static str, + pub status_error_text: &'static str, + pub status_error_border: &'static str, +} + +impl ThemePalette { + /// The dark palette — exactly the `COLOR_*` constants, by construction. + #[must_use] + pub const fn dark() -> Self { + Self { + surface_page: COLOR_SURFACE_PAGE, + surface_base: COLOR_SURFACE_BASE, + surface_chrome: COLOR_SURFACE_CHROME, + surface_1: COLOR_SURFACE_1, + surface_2: COLOR_SURFACE_2, + surface_3: COLOR_SURFACE_3, + border_default: COLOR_BORDER_DEFAULT, + border_chrome: COLOR_BORDER_CHROME, + accent_primary: COLOR_ACCENT_PRIMARY, + accent_primary_hover: COLOR_ACCENT_PRIMARY_HOVER, + text_primary: COLOR_TEXT_PRIMARY, + text_secondary: COLOR_TEXT_SECONDARY, + text_on_chrome: COLOR_TEXT_ON_CHROME, + text_on_chrome_secondary: COLOR_TEXT_ON_CHROME_SECONDARY, + text_accent: COLOR_TEXT_ACCENT, + tab_active_bg: COLOR_TAB_ACTIVE_BG, + tab_active_indicator: COLOR_TAB_ACTIVE_INDICATOR, + tab_inactive_hover: COLOR_TAB_INACTIVE_HOVER, + contextual_tab: COLOR_CONTEXTUAL_TAB, + icon_disabled: COLOR_ICON_DISABLED, + canvas_page_bg: CANVAS_PAGE_BG, + canvas_margin_bg: CANVAS_MARGIN_BG, + scrollbar_thumb: COLOR_SCROLLBAR_THUMB, + scrollbar_thumb_hover: COLOR_SCROLLBAR_THUMB_HOVER, + scrollbar_track: COLOR_SCROLLBAR_TRACK, + status_error_bg: COLOR_STATUS_ERROR_BG, + status_error_text: COLOR_STATUS_ERROR_TEXT, + status_error_border: COLOR_STATUS_ERROR_BORDER, + } + } + + /// The light palette. Chrome surfaces invert to light grays with dark + /// text; the document page, error banner, and page-side tokens keep + /// their values. Accents darken slightly to hold contrast on light + /// backgrounds (WCAG-minded, flat-design aesthetic preserved). + #[must_use] + pub const fn light() -> Self { + Self { + surface_page: COLOR_SURFACE_PAGE, + surface_base: "#C9C9C9", + surface_chrome: "#F3F3F3", + surface_1: "#ECECEC", + surface_2: "#E4E4E4", + surface_3: "#DDDDDD", + border_default: COLOR_BORDER_DEFAULT, + border_chrome: "#CFCFCF", + accent_primary: "#2B6BE6", + accent_primary_hover: "#2560D0", + text_primary: COLOR_TEXT_PRIMARY, + text_secondary: COLOR_TEXT_SECONDARY, + text_on_chrome: "#1F1F1F", + text_on_chrome_secondary: "#5A5A5A", + text_accent: "#1F5FD6", + tab_active_bg: "#FFFFFF", + tab_active_indicator: "#2B6BE6", + tab_inactive_hover: "#E2E2E2", + contextual_tab: "#B87A10", + icon_disabled: "#ABABAB", + canvas_page_bg: CANVAS_PAGE_BG, + canvas_margin_bg: "#B4B4B4", + scrollbar_thumb: "rgba(0, 0, 0, 0.25)", + scrollbar_thumb_hover: "rgba(0, 0, 0, 0.50)", + scrollbar_track: "rgba(0, 0, 0, 0.06)", + status_error_bg: COLOR_STATUS_ERROR_BG, + status_error_text: COLOR_STATUS_ERROR_TEXT, + status_error_border: COLOR_STATUS_ERROR_BORDER, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chrome_fields(p: &ThemePalette) -> [(&'static str, &'static str); 17] { + [ + ("surface_base", p.surface_base), + ("surface_chrome", p.surface_chrome), + ("surface_1", p.surface_1), + ("surface_2", p.surface_2), + ("surface_3", p.surface_3), + ("border_chrome", p.border_chrome), + ("accent_primary", p.accent_primary), + ("accent_primary_hover", p.accent_primary_hover), + ("text_on_chrome", p.text_on_chrome), + ("text_on_chrome_secondary", p.text_on_chrome_secondary), + ("text_accent", p.text_accent), + ("tab_active_bg", p.tab_active_bg), + ("tab_active_indicator", p.tab_active_indicator), + ("tab_inactive_hover", p.tab_inactive_hover), + ("contextual_tab", p.contextual_tab), + ("icon_disabled", p.icon_disabled), + ("canvas_margin_bg", p.canvas_margin_bg), + ] + } + + #[test] + fn dark_palette_matches_the_token_constants() { + let d = ThemePalette::dark(); + assert_eq!(d.surface_chrome, COLOR_SURFACE_CHROME); + assert_eq!(d.text_on_chrome, COLOR_TEXT_ON_CHROME); + assert_eq!(d.tab_active_bg, COLOR_TAB_ACTIVE_BG); + assert_eq!(d.scrollbar_thumb, COLOR_SCROLLBAR_THUMB); + } + + #[test] + fn light_differs_from_dark_on_every_chrome_token() { + let dark = ThemePalette::dark(); + let light = ThemePalette::light(); + for ((name, d), (_, l)) in chrome_fields(&dark).iter().zip(chrome_fields(&light)) { + assert_ne!(*d, l, "chrome token {name} must re-color in light"); + } + } + + #[test] + fn surface_independent_tokens_are_shared() { + let dark = ThemePalette::dark(); + let light = ThemePalette::light(); + assert_eq!(dark.surface_page, light.surface_page); + assert_eq!(dark.status_error_bg, light.status_error_bg); + assert_eq!(dark.text_primary, light.text_primary); + } + + #[test] + fn light_hex_tokens_are_well_formed() { + let is_hex6 = |s: &str| { + s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit()) + }; + let l = ThemePalette::light(); + for (name, v) in chrome_fields(&l) { + if v.starts_with('#') { + assert!(is_hex6(v), "light token {name} malformed: {v}"); + } + } + for v in [ + l.scrollbar_thumb, + l.scrollbar_thumb_hover, + l.scrollbar_track, + ] { + assert!(v.starts_with("rgba("), "scrollbar tokens stay rgba: {v}"); + } + } +} diff --git a/appthere-ui/src/tokens/spacing.rs b/appthere-ui/src/tokens/spacing.rs index e3c6652d..311160cb 100644 --- a/appthere-ui/src/tokens/spacing.rs +++ b/appthere-ui/src/tokens/spacing.rs @@ -96,16 +96,16 @@ mod tests { #[test] fn radius_scale_is_strictly_increasing() { - assert!(RADIUS_SM < RADIUS_MD); - assert!(RADIUS_MD < RADIUS_LG); - assert!(RADIUS_LG < RADIUS_XL); - assert!(RADIUS_XL < RADIUS_FULL); + const { assert!(RADIUS_SM < RADIUS_MD) }; + const { assert!(RADIUS_MD < RADIUS_LG) }; + const { assert!(RADIUS_LG < RADIUS_XL) }; + const { assert!(RADIUS_XL < RADIUS_FULL) }; } #[test] fn icon_sizes_are_strictly_increasing() { - assert!(ICON_SIZE_SM < ICON_SIZE_MD); - assert!(ICON_SIZE_MD < ICON_SIZE_LG); - assert!(ICON_SIZE_LG < ICON_SIZE_XL); + const { assert!(ICON_SIZE_SM < ICON_SIZE_MD) }; + const { assert!(ICON_SIZE_MD < ICON_SIZE_LG) }; + const { assert!(ICON_SIZE_LG < ICON_SIZE_XL) }; } } diff --git a/docs/adr/spec-02-conformance-inventory.md b/docs/adr/spec-02-conformance-inventory.md index d789bde6..894611b0 100644 --- a/docs/adr/spec-02-conformance-inventory.md +++ b/docs/adr/spec-02-conformance-inventory.md @@ -252,8 +252,8 @@ needing no graphics adapter) is what lets the visual axis run here at all. | B-5 | Visual / D3 | No shared pinned PDF→PNG stage | **Medium** | Build rasterizer wrapper; share golden+candidate | | | B-6 | Schema / M2 | 0 vendored schemas | **Medium** | 🔨 **Validator done** — `schema::SchemaValidator` + libxml2 `XmllintValidator` (XSD + RNG, located violations, fails-loud), tested; CI installs `libxml2-utils`. **Remaining:** vendor + pin ISO 29500 / ODF RNG and validate real exports (`schemas/README.md`). | | | B-7 | Round-trip / M3 | 25 ad-hoc tests, no normalized differ / path reporting | **Medium** | 🔨 **Differ + model adapter + first shape wired** — `roundtrip::{NormalizedModel, first_divergence}` plus `model::canonicalize_document` (feature `doc-model`): `NormalizedModel for loki_doc_model::Document` walking paragraphs/runs/marks/bookmarks/styles. Tested on real `Document`s incl. dropped run property, changed property, **mangled bookmark id**, dropped text, structural change. **Import→export→import shape now live** in `loki-ooxml` (dev-dep `appthere-conformance/doc-model`): `conformance_round_trip.rs` green-guards core word-processing content (para/heading/bold-run/bookmark) through real DOCX write+read, and an `#[ignore]`'d reference-fixture test exercises the comprehensive fixture. **First real bug found *and fixed* via the harness:** the reference round-trip surfaced a content-loss gap at `blk0005/i0000/i0000` — DOCX export's `emit_char_props` silently omitted several run properties the importer reads (highlight, letter-spacing, all-caps, scale, shadow, kerning, complex/East-Asian fonts+size, run background, language), so a run formatted *only* by one of them exported with an empty ``, collapsed to a plain run, and merged with neighbours (dropping the `StyledRun` and adjacent text). Fixed by making `emit_char_props` symmetric with the reader (new `docx/write/run_props.rs` + `run_props_tests.rs`); regression-locked by `docx_round_trip_preserves_secondary_run_formatting`. A **second** harness-found bug followed at `blk0026/i0001`: footnote-reference export hard-coded an explicit `` the source model never had — fixed by dropping the redundant run property and always emitting the `FootnoteReference`/`EndnoteReference` character styles (which carry the superscript, as Word does). **The full comprehensive reference fixture now round-trips with zero model divergence**, so `docx_reference_round_trip_is_stable` is promoted from `#[ignore]` gap-finder to a permanent green guard. **Canonicalization deepened** beyond paragraph/run depth: table interiors (`model/tables.rs` — rows/cells/spans/cell-blocks) and document metadata (`model/meta.rs` — core + custom + extended Dublin Core) are now walked, with model tests for each. **All three round-trip *shapes* are wired:** DOCX (`loki-ooxml`), **ODF/ODT** (`loki-odf/tests/conformance_round_trip.rs` — core + table + secondary formatting + bookmarks, all clean), and **sheet/XLSX** (new `sheet-model` feature + `NormalizedModel for Workbook` in `sheet/mod.rs`; `loki-ooxml/tests/conformance_xlsx_round_trip.rs` — values/formula/style/column-width, clean). The genuine DOCX export→re-import tests (metadata, math, comments) are **folded through the differ** (whole-model first-divergence backstop on top of their bespoke asserts). *Finding:* the `round_trip*.rs` / `round_trip_conformance*.rs` files (the "25") are **import-only smoke tests**, not export→re-import round-trips, so the differ does not apply to them. **Remaining (smaller):** ODS/sheet export gaps as they surface; promote `loki-acid`'s catalog into a shared fixture corpus (overlaps B-8/B-9). | **Largely done** | -| B-8 | Shared crate / M1 | `appthere-conformance` absent; `loki-acid` is Text-coupled | **Large** | 🔨 **Skeleton up** — `appthere-conformance` crate created (workspace member, `forbid(unsafe_code)`, 3 axis modules), wired into the dep-direction gate (exempt). **Remaining:** promote `loki-acid`'s catalog/SSIM/golden-discovery; extract `Fixture`/`Consumer` traits. | | -| B-9 | Corpus / §9 | 141 TC cases flat; not organised feature×format×axis; ODP/ODG/PPTX importers absent | **Medium** | Reorganise on disk; record axes+ref-app+overrides per fixture (PPTX gap = Presentation scope) | | +| B-8 | Shared crate / M1 | `appthere-conformance` absent; `loki-acid` is Text-coupled | **Large** | ✅ **Built 2026-07-12** — crate live with all three axes; the corpus layer (`corpus` module) holds the promoted `TC-*` catalog, `Format`/`Severity`, and the `Fixture`/`Consumer` traits; golden discovery generalised (`golden::discovery`); the SSIM/ΔE differ was promoted earlier (B-4). `loki-acid` is the first consumer (re-export shims + trait impls + `AcidConsumer`). | | +| B-9 | Corpus / §9 | 141 TC cases flat; not organised feature×format×axis; ODP/ODG/PPTX importers absent | **Medium** | ✅ **Built 2026-07-12** — on-disk layout `fixtures//.` + `goldens///`; `corpus::manifest::MANIFEST` records feature/axes/reference(+version)/tolerance-override-with-justification per fixture; catalog queryable by feature × format × severity. *(Count correction: the per-format tables sum to 139, not 141 — asserted in `counts_match_plan_totals`.)* ODP/ODG/PPTX importer gap = Presentation scope, unchanged. | | | B-10 | Fonts / D4 | Gelasio (≈Georgia) not bundled; substitution suite absent | **Small** | ✅ **Resolved → bundle Gelasio**; still author the substitution suite | **Resolved** | | B-11 | CI / §11 M6 | ✅ **Unblocked** — Spec 01 pipeline now built (9 gates in `rust.yml`) | **Sequencing** | Land schema+round-trip as hard gates (and visual post-calibration) into the now-populated lint job, same script-gate pattern | **Unblocked** | diff --git a/docs/adr/spec-03-responsive-audit.md b/docs/adr/spec-03-responsive-audit.md index b10862a9..bd838a33 100644 --- a/docs/adr/spec-03-responsive-audit.md +++ b/docs/adr/spec-03-responsive-audit.md @@ -213,7 +213,7 @@ presentation to the card's action slot. | **M1 — Breakpoint foundation** | ✅ **Implemented** | Triaged → built: `Breakpoint` (Compact/Medium/Expanded @ 600/1024) + the relocated, zoom/DPI-extended `Viewport` live in `appthere-ui::responsive`; `AtResponsiveContext` + `use_breakpoint`/`use_viewport` expose it; the editor funnels its one measured width into it (no second source). 11 window-free unit tests (R-2, R-3, R-4, R-5 resolved). | | **M2 — Page-fit switch** | ✅ **Implemented** | `appthere_ui::responsive::resolve_page_fit` decides paginated↔reflow from page-fit (page width × `viewport.zoom` + gutter vs measured width), hysteretic (`PAGE_FIT_HYSTERESIS_PX` dead-band). The `900` guess is gone; the editor's effect now reads the document's real `page_width_px`. 6 window-free tests incl. landscape-phone-fits, narrow-desktop-doesn't, and a no-thrash drag sweep (R-7, R-8 resolved). Zoom is fixed at 100% until zoom lands, but the rule already scales by `viewport.zoom`. | | **M3 — Font-warning redesign** | ✅ **Implemented** | New `editor_font_warning::FontWarning` component: compact chip by default (`N fonts substituted`), expand-on-demand into a **breakpoint-aware** view — a table on Expanded, a vertical card stack on Compact (uses M1's `use_breakpoint`). Severity model (metric-compatible vs material fallback) styles the badge; dismiss + a generic status-bar recovery chip (`AtStatusBar.notice_*`); `fl!()` strings; Blitz-clean (border/background, no fixed/shadow/custom-props). 3 unit tests for the severity/link/sort logic. Extracted from `editor_inner` (1002→878). R-9/R-10/R-11/R-12 resolved. | -| **M4 — Non-paginated typography** | ✅ **Implemented** | The GPU reflow measure is now **bounded** at `MAX_REFLOW_TILE_PX = 820` (the HTML-fallback precedent) and **centred** (the renderer's `margin: auto` centres the capped tile). A single shared `render_layout::{reflow_tile_width_px, reflow_content_width_pt}` drives paint, hit-test, and keyboard nav so they stay aligned (Spec 01 discipline); the HTML fallback references the same constant. Narrow screens still use full width; wide windows cap & centre. 3 unit tests. R-6 resolved. **Responsive *type scale* deferred** — rescaling the document's own point sizes is a fidelity concern; the bounded+centred measure is the readability fix. | +| **M4 — Non-paginated typography** | ✅ **Implemented** | The GPU reflow measure is now **bounded** at `MAX_REFLOW_TILE_PX = 820` (the HTML-fallback precedent) and **centred** (the renderer's `margin: auto` centres the capped tile). A single shared `render_layout::{reflow_tile_width_px, reflow_content_width_pt}` drives paint, hit-test, and keyboard nav so they stay aligned (Spec 01 discipline); the HTML fallback references the same constant. Narrow screens still use full width; wide windows cap & centre. 3 unit tests. R-6 resolved. ~~Responsive *type scale* deferred~~ **Built 2026-07-12** as a view transform (layout at `width ÷ scale`, paint at `zoom = scale` — document point sizes untouched, resolving the fidelity concern): Compact renders reflow type at 1.125× via `render_layout::reflow_type_scale`, single-sourced across paint/hit-test/nav with a drift-lock test against `BREAKPOINT_COMPACT_MAX_PX`. **M4 fully complete.** | | **M5 — Cross-UI sweep** | ✅ **Implemented** | `use_breakpoint` made **resilient** (defaults to Expanded when an app hasn't wired the context, so shared shells can adapt without panicking). **AtTitleBar** hides the redundant top-right app-name at Compact; **AtStatusBar** drops the secondary word-count + language labels at Compact — the two wrap-and-spill cases (R-13a/R-13b) are cleared. Panel action buttons (metadata, language) bumped to `TOUCH_MIN` (R-15). **Deferred (documented):** metadata-panel label *stacking* only matters below ~250 px (sub-phone) and the panels render conditionally (can't host a hook) — a follow-up; ribbon select width (R-13e) + tab-strip touch height (R-14) are **Spec 04** (ribbon) as the spec directs. | --- diff --git a/docs/adr/spec-04-ribbon-audit.md b/docs/adr/spec-04-ribbon-audit.md index 6b973f48..cf1075f0 100644 --- a/docs/adr/spec-04-ribbon-audit.md +++ b/docs/adr/spec-04-ribbon-audit.md @@ -435,7 +435,7 @@ paragraphs (the substantive `flow.rs`/`flow_table` work). | **M3 — Collapse cascade** | ⚠️ needs new engine | Build the width-driven engine: per-group priority + condensed/overflow reps + overflow menu (`position: absolute`) + hysteresis (mirror `page_fit`). **The substantive build.** R-13e select-width handled in *condensed*. | | **M4 — Render-gate + Insert tab** | ✅ capability table (§4) + create paths (§5) | Add the **Insert** tab with controls for the 5 Create-ready objects only; commit the §4 table. No math/shape controls. | | **M5 — Remaining tabs + contextual** | ⚠️ needs selection signal (RB-5) | Add Layout/References/Review from existing features; add a `selected_object: Signal>` in `EditorState`, set it from pointer hit-tests, drive Table/Picture contextual tabs via `is_contextual`. | -| **M6 — Touch posture** | ✅ `TOUCH_MIN`, breakpoint | Bump tab strip to `TOUCH_MIN` at Compact (R-14); condensed select sizing (R-13e); bottom-ribbon placement *optional* (RB-11). | +| **M6 — Touch posture** | ✅ **Implemented 2026-07-12** | R-14 built: the tab strip renders at `TOUCH_MIN` (44 px) at Compact via `tab_strip_height` (pointer-first classes keep 36 px). R-13e was already covered by the M3 cascade (`AtRibbonSelect` condensed width). RB-11 bottom placement deliberately not built (optional per this audit; the collapse cascade covers Compact). | --- diff --git a/docs/audit-2026-06.md b/docs/audit-2026-06.md index cb82b8bc..ed92afd8 100644 --- a/docs/audit-2026-06.md +++ b/docs/audit-2026-06.md @@ -206,7 +206,18 @@ than a 301-site churn. Downgraded from P1 to a low-priority cleanup. `StyleSpan`'s other fields are all `Copy`, so the residual cost is one `Option` clone per span (not per nesting level). -### P-3 — Glyph-run style lookups are linear scans *(Reported · P2)* +### P-3 — Glyph-run style lookups are linear scans *(Reported · P2 · ✅ Measured & refuted 2026-07-12)* + +> **Measured (plan 6.5), no fix.** `loki-bench/benches/portable_span_density.rs` +> shows end-to-end paragraph layout is **sub-linear** in span count (4→256 spans +> = ×2.4, not the ×~4000 a dominant O(N²) implies — Parley shaping dominates); +> the isolated scan is ~0.7 % of layout at N=64 and ~6 % at a pathological N=256, +> <0.1 % at realistic <20 spans/para. The proposed byte→span-index map is +> **slower for small N** (map build out-costs a short scan), so it would regress +> the common case. Also: 3 of the 4 named scanners are gone (highlight/link/ +> valign/shadow moved to the selection-geometry underlay passes) and the +> per-call `range.clone()` is gone. The bench stays as a regression tripwire. + - **Location:** `loki-layout/src/para.rs` glyph-run emission (~lines 920-975): `span_link_url_for_range` / `_vertical_align_` / `_highlight_` / `_has_shadow` @@ -256,7 +267,17 @@ than a 301-site churn. Downgraded from P1 to a low-priority cleanup. rejection, and the global layout index. The path-resolution micro-caching the audit suggested remains unnecessary (keystroke diffs touch 1–2 containers). -### P-5 — Coarse render-cache invalidation *(Reported · P2)* +### P-5 — Coarse render-cache invalidation *(Reported · P2 · ◑ Confirmed coarse; fix device-gated 2026-07-12)* + +> **Confirmed (plan 6.5), deferred to device measurement.** `page_cache.rs:: +> mark_all_dirty` no longer exists — invalidation moved to `loki-renderer`'s +> generation-keyed `DocPageSource`: any mutation bumps the generation so every +> *visible* tile re-renders. Coarse, but tiles are viewport-virtualized +> (bounded, ~1–3 pages) and the real payoff (skipping GPU raster of unchanged +> visible pages) is only measurable as **GPU frame-time** (Spec 06 device axis, +> not this headless env); the fix is a substantial per-page render-dirty +> refactor. Deferred to the device-frame-time bench, not speculatively done. + - **Location:** `loki-render-cache/src/page_cache.rs:59` `mark_all_dirty`. - **Issue:** Per-page `mark_dirty` exists (line 52), but if callers reach for @@ -266,7 +287,13 @@ than a 301-site churn. Downgraded from P1 to a low-priority cleanup. - **Proposed fix:** Ensure edit paths call `mark_dirty(page)` for the affected page(s); reserve `mark_all_dirty` for structural/zoom/theme changes. -### P-6 — Cold-path allocations *(Reported · P3)* +### P-6 — Cold-path allocations *(Reported · P3 · ◑ Confirmed negligible 2026-07-12)* + +> **Confirmed low-impact (plan 6.5).** `snapshot_checkpoint` clones +> `list_counters` (empty for list-free docs) per page boundary at *layout time*, +> dwarfed by Parley's per-paragraph allocations. Not worth a standalone +> `Arc`-share; fold in opportunistically the next time `flow.rs` is edited. + - Checkpoint snapshots clone the list-counter `HashMap` per page boundary (`loki-layout/src/flow.rs` `snapshot_checkpoint`); list rendering clones the diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 4ff9dedb..4cdbc8fb 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -116,7 +116,7 @@ Verified against code; none silently done-since except the Spec-02 "resolved-as- | Spec | Deferred item | Verified | |---|---|---| | 01 | `clippy::pedantic` lint set + allow-list; AST-level `no_hardcoded_layout_dims` dylint; `cargo udeps` dead-`pub` sweep; `editor_save` typed `SaveError`; Android target build verification; 300-line backlog | STILL-OPEN (deliberate residuals) | -| 02 | ~~`vello_cpu` path, schemas, goldens, calibration, differ, rasterizer, Gelasio, CI wiring~~ **BUILT 2026-07-05** (plan Phase 3): B-1 (`loki-render-cpu`), B-2 (3 ODF goldens + generation script), B-3 (`CALIBRATION.md` → `Tolerance::calibrated()`; the pass quantified fidelity gap #23/kerning, pinned as a canary), B-4 (SSIM+ΔE worst-region differ + heatmaps), B-5 (`PdfRasterizer`), B-6 (ISO 29500/OPC/ODF/MathML3 vendored + real-export validation), B-10 (Gelasio + substitution suite), B-11 (axes live in CI). Remaining: B-8 `Fixture`/`Consumer` traits, B-9 corpus reorg, OOXML manual goldens, Strict XSDs. | LARGELY BUILT | +| 02 | ~~`vello_cpu` path, schemas, goldens, calibration, differ, rasterizer, Gelasio, CI wiring~~ **BUILT 2026-07-05** (plan Phase 3): B-1 (`loki-render-cpu`), B-2 (3 ODF goldens + generation script), B-3 (`CALIBRATION.md` → `Tolerance::calibrated()`; the pass quantified fidelity gap #23/kerning, pinned as a canary), B-4 (SSIM+ΔE worst-region differ + heatmaps), B-5 (`PdfRasterizer`), B-6 (ISO 29500/OPC/ODF/MathML3 vendored + real-export validation), B-10 (Gelasio + substitution suite), B-11 (axes live in CI). ~~Remaining: B-8 `Fixture`/`Consumer` traits, B-9 corpus reorg~~ **BUILT 2026-07-12** (plan 3.7). Still open: OOXML manual goldens, Strict XSDs. | LARGELY BUILT | | 03 | Metadata-panel label stacking <250px (R-13g); responsive doc type-scale (M4); real `Viewport.zoom`; ribbon tab-strip touch height (handed to Spec 04) | STILL-OPEN | | 04 | **M3 width-driven collapse cascade** (condensed/overflow menu, priority, hysteresis) — unbuilt; **M5 Layout/References/Review tabs + `selected_object` contextual signal** — unbuilt (only 3 non-contextual tabs); M6 touch posture; cursor-into-new-cell after insert | STILL-OPEN (Spec 04 is the least-complete "shipped" spec) | | 05 | **Page** family (`page_styles` catalog, ADR-0012); **Table** family (`TableProps` conditional/banding regions); character-style **editing form**; per-family non-paragraph `Default` sources; Compact tree breadcrumb (M7) | STILL-OPEN (model-gated) | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 9ae18919..b5dc732f 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -88,11 +88,11 @@ BM-3** (render-cost proxy) and the ACID headless-raster registry item. | 3.4 | B-5 | ✅ **Done 2026-07-05** — `appthere_conformance::raster::PdfRasterizer` (pdftoppm pinned flags @ `CONFORMANCE_DPI` 144, version captured, byte-determinism tested). | S | | 3.5 | B-2, B-3, B-4 | ✅ **Done 2026-07-05** — SSIM+CIEDE2000 worst-region differ with heatmaps (`golden/diff.rs`, Sharma reference pairs verified); 3 ODF goldens committed with GENERATION metadata (`scripts/generate-odf-goldens.sh`); calibration record `goldens/CALIBRATION.md` → `Tolerance::calibrated()` {0.60, 10.0}. **The calibration pass found and quantified fidelity gap #23 (kerning)** — pinned as the `para-carlito` expected-failure canary. OOXML goldens remain the manual Windows/Word COM procedure (§7.2). | M | | 3.6 | B-11 | ✅ **Done 2026-07-05** — all three axes run as cargo tests in the existing `build-and-test` job (xmllint + pdftoppm installed); schema + round-trip are hard gates, visual is advisory-by-construction (known divergence pinned; hardens when kerning lands + recalibration). | S | -| 3.7 | B-8, B-9 | **Deferred** — shared `Fixture`/`Consumer` trait extraction from `loki-acid` and the 141-case corpus reorg remain open (the new fixtures/goldens live under `appthere-conformance/{fixtures,goldens}` as the seed of that layout). ODP/ODG/PPTX importers stay gated on the unbuilt ACID PPTX generator (§5.1). | M | +| 3.7 | B-8, B-9 | **Done ✅ 2026-07-12** — the corpus layer is promoted into `appthere-conformance::corpus` (B-8): the `TC-*` catalog (the four per-format tables moved wholesale; grouping helpers add the *feature* dimension — `by_feature`/`cases_with_severity`), the `Format`/`Severity` vocabulary, and the new `Fixture`/`Consumer` traits (a consumer supplies bytes+metadata and an import/export pair; the crate stays free of Text-specific assumptions). Golden/candidate discovery generalised into `golden::discovery` (root-parameterized). `loki-acid` is the **first consumer**: its `catalog`/`severity` modules are re-export shims, the acid `Fixture` enum implements the shared trait (axes derived from importer availability), `AcidConsumer` wraps the import dispatch (export honestly reports the corpus is import-only), and its golden discovery delegates. B-9: the on-disk corpus is `fixtures//.` + `goldens///` with a code-as-manifest (`corpus::manifest::MANIFEST`) recording per fixture the feature, applicable axes, reference app+version (LibreOffice 24.2 per `CALIBRATION.md`), and justified tolerance overrides (none needed post-kerning-fix); manifest tests assert on-disk existence + visual entries carry references. *Corrected en route:* the spec's "141 TC cases" headline miscounts — the per-format tables (the machine-readable truth) sum to **139**, now asserted. ODP/ODG/PPTX importers stay gated on the unbuilt ACID PPTX generator (§5.1, ratified). | M | **Exit criterion**: ✅ met for the built scope — CI runs schema, round-trip, and visual-golden passes on every PR; Spec 02 rows B-1…B-6, B-10, B-11 are -*built*, B-7 was already largely done, B-8/B-9 remain the tracked tail. +*built*, B-7 was already largely done, and B-8/B-9 landed 2026-07-12 (task 3.7) — **Phase 3 is complete** (ODP/ODG/PPTX importer gating excepted, per §5.1). --- @@ -108,9 +108,9 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). |---|---|---|---| | 4a.1 | Spec 04 M3 | Width-driven ribbon collapse cascade: condensed variant, overflow menu, per-group priority, hysteresis. **Collapse engine ✅ Started 2026-07-06** — the pure, width-driven cascade core lives in `appthere-ui/src/responsive/ribbon_collapse.rs` (`resolve_cascade(metrics, available_px, prev_level) -> RibbonCascade`): each group declares a `GroupMetrics { priority, full_px, condensed_px }`, and the engine degrades gracefully by **condensing all groups (lowest priority first) before overflowing any** into the "More" menu, falling back to horizontal scroll only when even the fully-overflowed strip can't fit (§7 steps 1–4). Per-group result is `GroupCollapse::{Full,Condensed,Overflow}`. **Hysteretic** like Spec 03's `page_fit`: it collapses a step the instant the strip overflows but re-expands only when the looser layout clears the width by `RIBBON_COLLAPSE_HYSTERESIS_PX` (32 px), so a window dragged across a fit threshold doesn't thrash; resolution is idempotent at a fixed width (the resolved `level` feeds back in as `prev_level`). New tokens `RIBBON_OVERFLOW_BUTTON_PX` (44) + `RIBBON_COLLAPSE_HYSTERESIS_PX`. 10 unit tests (`ribbon_collapse_tests.rs`) cover full-fit, priority-ordered condense/overflow, the scroll floor, hysteresis dead-band, idempotence, tie-breaking, and the empty ribbon. **Reactive binding + condensed representation ✅ 2026-07-06** — (a) `use_ribbon_cascade(metrics) -> RibbonCascade` binds the engine to the live `AtResponsiveContext` viewport width, holding the resolved `level` in a hook-local signal for hysteresis across resizes; it is **resilient** like `use_breakpoint` (no responsive context ⇒ unbounded width ⇒ every group stays Full, so Presentation/Spreadsheet get a sane full-chrome ribbon). (b) The condensed group representation (§7 step 2) is a pure, tested decision — `group_layout(collapse, has_label) -> GroupLayout { rendered, pad_px, gap_px, show_label }` — that `AtRibbonGroup` now applies via a new defaulted `collapse: GroupCollapse` prop: **Full** keeps the label + roomy padding, **Condensed** drops the label and tightens padding/gap (never the buttons, so 44 px touch targets survive), **Overflow** renders nothing in the strip (the "More" menu hosts it). The prop defaults to Full, so all existing call sites are unchanged. +3 `group_layout` tests (13 total). **Collapse-aware container + overflow menu + first migration ✅ 2026-07-06** — a new shared `AtRibbonGroups` component (`components/ribbon/groups.rs`) is the framework piece that owns the cascade: a tab hands it a `Vec`, it runs `use_ribbon_cascade` **once** for the strip, renders each group at its resolved state, and moves overflowed groups into a trailing **"More" menu** (an upward `position: absolute` dropdown — confirmed working in Blitz — rendering the overflowed groups in Full form). Group widths are **declared, not Blitz-measured** (per-element measurement is unreliable): the pure `estimate_group_metrics(priority, buttons, has_label)` derives full/condensed widths from the touch-button count (+2 tests). The **Layout tab** is migrated as the first real consumer — its four groups (Orientation/Margins/Size/Columns, priority descending so Columns overflows first) now flow through `AtRibbonGroups`, driven live off the editor's measured viewport width. New `LUCIDE_MORE_HORIZONTAL` icon + `ribbon-overflow-aria` string. **All tabs migrated + R-13e ✅ 2026-07-06** — the **Write**, **Insert**, **Publish**, and **Table** tabs now build `RibbonGroupSpec` lists through `AtRibbonGroups` too, so every tab collapses/overflows by priority (the group-helper fns in `editor_ribbon_format`/`editor_ribbon_color` return `RibbonGroupSpec` with a threaded priority; each tab declares a descending-importance scheme — core editing controls stay full longest, wide colour-swatch groups overflow first). Publish's ribbon content split to `editor_ribbon_publish.rs` (dropping the baselined `editor_publish.rs` 315 → 236, off the ceiling backlog) and the Table tab's `delete_current_table` to `editor_ribbon_table_delete.rs`. **R-13e ✅** — `AtRibbonGroup` exposes its resolved `GroupCollapse` to descendants as a *signal* context, and `AtRibbonSelect` reads it to shrink (`RIBBON_SELECT_WIDTH_PX` → `RIBBON_SELECT_WIDTH_CONDENSED_PX`) when its group is condensed; a signal, not a plain value, so prop-memoised selects still re-size reactively on resize. The overflow menu also auto-closes when a widen removes the overflow. ~~**Remaining tail:** true outside-click-to-dismiss for the More menu~~ ✅ **Done 2026-07-11** — new `appthere_ui` overlay primitives `use_provide_backdrop()` + `AtBackdropHost`: the app root (now `position: relative`) hosts a transparent viewport-spanning click-catcher (z 40) raised while the menu is open; the menu stays anchored to its More button (z 41), a click anywhere outside closes it, and a `use_drop` guard clears a stale backdrop on strip unmount. All three apps wire the context; degrades to toggle-only dismissal without it. **M3 is complete.** | L | | 4a.2 | Spec 04 M5 | Layout/References/Review ribbon tabs + ~~`selected_object` contextual-tab signal~~ (✅ **Done 2026-07-06** — new `editing/selected_object.rs`: pure `selected_object(&CursorState) -> SelectedObject` (Table when the focus path descends through a cell). `editor_ribbon_table::use_ribbon_tabs` derives it via `use_memo`, appends an amber **Table** contextual tab while the caret is in a table, and resets the active tab to Write when the caret leaves (so no orphaned selection). The tab's **Delete Table** action is backed by a new `delete_block` model mutation (`loro_mutation/block_edit.rs`, split from `block.rs` to hold the ceiling), disabled when the table is the document's only block; the caret re-homes to the neighbouring block. New `LUCIDE_TRASH_2` icon. Tests: `selected_object` (5), `ribbon_tabs` (3), `delete_block` (3). **Structural row/column ops ✅ Done 2026-07-06** — the Table tab's "Rows & Columns" group inserts/deletes rows and columns via new `loro_mutation/table_ops.rs` mutations (`insert_table_row`/`delete_table_row`/`insert_table_column`/`delete_table_column` + `table_grid_dims`): they rewrite the serde skeleton **and** minimally patch the flat `KEY_TABLE_CELLS` list (surviving cells keep their live CRDT text), scoped to simple grids (no spans/head/foot — else typed `UnsupportedTableStructure`). The caret re-homes to its shifted cell (pure `caret_flat_after` in `editor_ribbon_table_ops.rs`); delete buttons disable at 1 row/col; 4 app-custom table-op glyphs. Tests: 12 round-trip (`table_structural_ops.rs`) + 4 caret-math. **Insert above/below + left/right ✅ Done 2026-07-06** — the insert ops are now split into four caret-relative variants (`TableOp::{InsertRowAbove,InsertRowBelow,InsertColumnLeft,InsertColumnRight}`): above/left insert at the caret's own row/col index, below/right at index+1, and `caret_flat_after` re-homes the caret accordingly (above shifts it down a row, left shifts it one column right). 2 app-custom glyphs (`AT_TABLE_ROW_INSERT_ABOVE`, `AT_TABLE_COL_INSERT_LEFT`); 2 new caret-math tests. **Paragraph alignment ✅ Done 2026-07-06** — the Write tab gains an Alignment group (left/centre/right/justify) driven by new path-aware `set_block_alignment_at`/`get_block_alignment_at` (`loro_mutation/align.rs`), so alignment works in table cells and note bodies too. `align.rs` handles every paragraph shape: a plain `para` is upgraded to `styled_para` so props survive the read path, `styled_para` uses `para_props`, and a `heading` uses its OOXML `jc` attr. The 6 inline-format buttons + the new alignment buttons were extracted to `editor_ribbon_format.rs` (dropping `editor_ribbon.rs` 300 → 225). 5 alignment tests (`block_alignment.rs`). **Font-size grow/shrink ✅ Done 2026-07-06** — a Write-tab Font group steps the selection's `MARK_FONT_SIZE_PT` up/down a fixed size ladder (`editor_font_size.rs`, path-aware via `mark_text_at`/`resolve_format_ranges`, so it works in cells and across multi-paragraph selections); pure ladder + end-to-end mark tests (5). 2 app-custom "A±" glyphs. **Text colour ✅ Done 2026-07-06** — a Write-tab Font-colour group with an Automatic (clear) swatch + 6 preset colours applies `MARK_COLOR` (the `#RRGGBB` hex is the codec's own RGB form, so no extra encoding) across the selection, path-aware (`editor_text_color.rs`); coloured-square swatches render inside `AtRibbonIconButton`. 3 tests incl. round-trip into `CharProps.color`. **Highlight colour ✅ Done 2026-07-06** — a Write-tab Highlight group (None clear-swatch + 5 preset colours: Yellow/Green/Cyan/Magenta/Red) writes `MARK_HIGHLIGHT_COLOR` as a `HighlightColor` variant name (or `Null` to clear) across the selection, path-aware (`editor_highlight_color.rs`); the swatch fills are the RGB each variant renders as (`resolve::map_highlight_color`). The duplicated font-colour/highlight swatch UI was unified into a generic `swatch_group(palette, apply_fn)` in `editor_ribbon_color.rs`. 3 tests incl. round-trip into `CharProps.highlight_color`. **Layout tab ✅ Started 2026-07-06** — a new non-contextual **Layout** tab (Write/Insert/**Layout**/Publish; contextual Table now at index 4) with a page-**orientation** toggle backed by new `set_document_orientation`/`document_is_landscape` model mutations (`loro_mutation/page.rs`): the layout engine reads the effective `page_size` directly, so the toggle swaps width↔height on every section + sets the orientation flag, and `apply_mutation_and_relayout` re-flows at the new size (verified the pipeline updates `page_width_px`). 4 round-trip tests (`page_orientation.rs`) + updated ribbon-tab tests; 2 app-custom page-rect glyphs. **Margin presets ✅ Done 2026-07-06** — the Layout tab's Margins group applies Normal/Narrow/Wide via new `set_document_margins`/`document_margins` (`page.rs`; sets top/bottom/left/right on every section, leaves header/footer/gutter); the active preset highlights via the pure `margin_matches` (½-pt tolerance). 3 model tests (`page_margins.rs`) + 5 preset-match tests; 3 app-custom page-inset glyphs (tooltip-disambiguated). **Page size ✅ Done 2026-07-06** — the Layout tab's Size group applies A4/US-Letter via new `set_document_page_size`/`document_page_size` (`page.rs`), **preserving each section's orientation** (choosing A4 while landscape gives A4 landscape); the active size highlights via the orientation-independent `page_size_matches`. 3 model tests (`page_size.rs`, incl. orientation preservation) + 4 preset-match tests. **Columns ✅ Done 2026-07-06** — the Layout tab's Columns group applies one/two/three via new `set_document_columns`/`document_column_count` (`page.rs`; count clamped ≥1, a new columns map gets a default 0.5in gap + no separator, an existing one keeps its gap/separator when the count changes); 5 model tests (`page_columns.rs`). The Layout tab is now Orientation + Margins + Size + Columns. **References tab ✅ 2026-07-07** — a new non-contextual **References** tab (Write/Insert/Layout/**References**/Publish; contextual Table now at index 5) generating a **table of contents** from the document's headings. The pure, format-neutral builders live in `loki-doc-model` (`content/toc.rs`): `heading_outline(sections, max_depth)` collects `(level, label)` for every `Block::Heading` within depth (default 3), `inline_plain_text` flattens a heading's inlines to its label, and `build_toc` produces a `TableOfContentsBlock` whose cached `body` is level-indented entry paragraphs (page numbers are omitted — they need the paginated layout — matching a freshly-inserted Word TOC field). The CRDT mutations (`loro_mutation/toc.rs`): `insert_table_of_contents` builds a TOC from the live headings and inserts it after the caret's block; `refresh_table_of_contents` rebuilds an existing TOC's snapshot in place (the "update field" action, a guarded no-op on a non-TOC block); `first_toc_block_index` finds the TOC to enable/refresh. A `Block::TableOfContents` round-trips through the bridge as an opaque JSON snapshot, so both are undoable. **Root-cause layout fix:** `loki-layout`'s `flow_block` silently dropped `TableOfContents`/`Index` blocks via its `_ => {}` catch-all, so an inserted *or imported* TOC was invisible — now both flow their cached body (a new `flow_blocks` helper the merged Div/Figure arms also use, so `flow.rs` held at its 1953 baseline). The **References** tab (`editor_ribbon_references.rs`) offers **Insert** + **Update** (Update disabled with no TOC); 2 app-custom glyphs (`AT_TOC_INSERT`/`AT_TOC_UPDATE`); the tab-index shift updated `ribbon_tabs`/`CONTEXTUAL_TAB_INDEX`/the tab-content match. 12 model tests (`content::toc` 7, `loro_mutation::toc` 5) + 1 layout regression test + updated ribbon-tab tests; 5 i18n keys. **Review tab — model foundation ✅ 2026-07-07** — the first slice of track changes. A **live** tracked-change mark (distinct from the dormant, round-trip-only opaque `TrackedChange`): `style/props/revision.rs` defines `RevisionKind { Insertion, Deletion }` + `RevisionMark { kind, author, date, id }` with a `US`-delimited packed string codec (no serde/chrono on the hot path). It rides a run as `CharProps::revision` (run-level, **not** style-inherited — omitted from `merged_with_parent`), so a tracked run is an `Inline::StyledRun` whose `direct_props.revision` is set — the same marks mechanism as highlight colour, keeping the paragraph live-editable. Wired through the CRDT bridge (`MARK_REVISION` in `CHAR_MARK_KEYS` + write/read), so a tracked run survives an edit cycle (round-trip tested). Pure, tested accept/reject transforms (`content/revision_ops.rs`): `accept_revisions`/`reject_revisions` over the whole block tree (recurse lists/tables/notes/quotes/TOC) — accept keeps insertions (clears the mark) and removes deletions, reject does the inverse; plus `has_revisions` and `Document::{accept_all_revisions,reject_all_revisions,has_tracked_changes}`. A `DocumentSettings.track_changes` flag (serde-default, back-compat) records whether new edits are tracked. 16 tests (revision codec 4, accept/reject 6, char_props 2 relocated, bridge round-trip + serde back-compat 2, +2). `char_props.rs` inline tests extracted to hold the ceiling. **Review tab — rendering ✅ 2026-07-07** — tracked changes are now visible. `loki-layout/src/revision_style.rs` colours a tracked run by its author (a deterministic 6-colour palette hashed off the author name) and decorates it by kind — insertion **underlined**, deletion **struck through** — applied to the run's `StyleSpan` in `char_props_to_style_span` (so it flows through the existing Parley decoration + brush path; the renderer needs no change). Because colour/decoration are derived from the mark at layout time, accepting/rejecting (which clears the mark) reverts the run with nothing stored to undo. 4 unit tests (`revision_style`: insertion underline, deletion strikethrough, no-op, author-colour determinism) + 1 layout regression (`tracked_runs_render_insertion_underline_and_deletion_strikethrough`); baselined `resolve.rs`/`lib.rs` held at baseline via a comment tightening + a redundant blank-line drop. **Review tab — editor records tracked insertions ✅ 2026-07-07** — typing now records tracked changes when the flag is on. New CRDT mutation `insert_text_tracked_at` (path-aware, so it works in cells/notes) inserts text **and** marks the range with `MARK_REVISION`. Crucially, `MARK_REVISION` is reconfigured `expand: None` in `configure_text_style` (unlike the `After` of formatting marks), so a revision covers exactly the changed range and never bleeds onto adjacent — possibly untracked — typing; consecutive tracked inserts by one author coalesce because their encoded mark value is identical. The editor's `handle_character_key` reads `Document::insertion_revision()` (a pure helper: `Some(Insertion by meta.creator)` when `DocumentSettings.track_changes` is on, else `None`) and routes typing through the tracked mutation accordingly — so a document with track-changes on shows typed text underlined in the author colour (rendering already wired). 3 tests: the pure `insertion_revision` decision, a CRDT round-trip proving the tracked insert marks only its range and does not leak onto a following plain insert, plus the existing bridge round-trip. **Review tab + durable toggle ✅ 2026-07-07** — track changes is now reachable and usable. **Root-cause fix:** `DocumentSettings` now round-trips through the Loro CRDT (`loro_bridge/settings.rs`, a JSON snapshot under `KEY_SETTINGS` like metadata/comments) — previously the bridge dropped settings, so the `track_changes` flag was wiped on the first relayout (which re-derives the doc from the CRDT). `set_track_changes(loro, on)` / `document_track_changes(loro)` flip and read the flag durably (undoable, survives relayout + save). A new non-contextual **Review** tab (Write/Insert/Layout/References/**Review**/Publish; contextual Table now at index 6) hosts a **Track Changes** toggle whose active state reflects the flag and whose click routes through `set_track_changes` + relayout. So the whole pipeline works end to end: toggle on → typed text records as a tracked insertion (`insert_text_tracked_at`) → renders underlined in the author colour. 4 tests (settings bridge set/get + preserve-others; full-document settings round-trip; +updated ribbon-tab tests); 1 app glyph (`AT_TRACK_CHANGES`); 3 i18n keys. **Review tab — accept/reject all ✅ 2026-07-07** — the review loop is closed: changes can be accepted or rejected. New CRDT mutation `accept_reject_all_revisions(loro, accept)` applies the accept/reject semantics **surgically** to the live Loro text (so it's one undoable step and the editor keeps its handle): it walks each top-level block's `to_delta()` for `MARK_REVISION` spans and, back-to-front, either clears the mark (`mark_utf8(range, …, Null)`) for a kept run (accepted insertion / rejected deletion) or deletes the text for a removed run — the CRDT analogue of the pure `revision_ops` transforms, returning the count resolved. The Review tab gains a **Changes** group with **Accept all** / **Reject all** buttons (disabled when `Document::has_tracked_changes()` is false, so they grey out once the document is clean). 4 CRDT tests (accept keeps-ins/removes-del + clears marks, reject inverse, no-op when clean, multi-block) + 2 app glyphs (`AT_CHANGE_ACCEPT`/`AT_CHANGE_REJECT`) + 3 i18n keys. *(Scope: top-level block text; revisions nested in table cells / note bodies are `TODO(review-nested)`.)* **Review tab — tracked deletions ✅ 2026-07-07** — track-changes recording is now complete: Backspace/Delete strike text through instead of removing it. The pure `delete_action(existing, tracking)` (Word semantics: tracking off ⇒ hard delete; on ⇒ hard-delete the author's own insertion, skip an already-struck deletion, else mark struck) drives the CRDT mutation `tracked_grapheme_delete`, which reads the target grapheme's `MARK_REVISION` (via `get_mark_at_path`) and applies the decision — deleting the text, marking it a deletion, or no-op — returning the [`DeleteAction`] so the editor places the caret (Backspace lands before the grapheme in every case; forward-Delete keeps the caret on a hard delete but steps past a struck grapheme). `Document::deletion_revision()` supplies the author-attributed deletion mark (its `Some`/`None` is the tracking flag). `handle_backspace_key`'s grapheme path and `handle_delete_key` both route through it. 5 tests (`delete_action` truth table, `deletion_revision` flag, + 3 CRDT: strike-normal, hard-delete-off, remove-own-insertion/skip-already-struck). *(Scope: single-grapheme Backspace/Delete; a **selection** delete under track changes still hard-deletes — `TODO(review-selection-delete)` — as does Backspace at a paragraph start, since whole-paragraph-mark deletion isn't modelled.)* **OOXML `w:ins`/`w:del` import+export ✅ 2026-07-07** — tracked changes now round-trip through DOCX, so they survive save/open in Word. **Import:** `w:ins`/`w:del` were already recognised structurally but dropped their semantics; now `parse_tracked_runs` reads the `w:author`/`w:date`/`w:id` attributes into a `DocxTrackedChange`, `parse_run` also reads `w:delText` (deleted-run text, previously discarded — a zero-cost widening of the `w:t` match), and the mapper attaches a `RevisionMark { kind, author, date, id }` to every wrapped run's `CharProps.revision` (deletions are kept, struck-through, not dropped). **Export:** `write_styled_run` wraps a run carrying `revision` in `w:ins`/`w:del` with those attributes, and its text emits as `w:delText` for a deletion (`write/revision.rs`). 1 DOCX round-trip test (insertion + deletion, author + date + text all survive). The mapper `inline.rs` test module was extracted (`inline_tests.rs`) and the two new intermediate structs moved to `model/revision.rs` to hold the three baselined files at baseline. **ODF `text:tracked-changes` import+export ✅ 2026-07-07** — tracked changes now round-trip through ODT too, so they survive save/open in LibreOffice. ODF splits a change between the document-leading `text:tracked-changes` table (one `text:changed-region` per change: `text:insertion`/`text:deletion` + `office:change-info` with `dc:creator`/`dc:date`; a deletion stows its removed `text:p` text) and body milestones keyed by `text:change-id` (an insertion is bracketed by `text:change-start`/`text:change-end`; a deletion is a single `text:change` point). **Import:** a new `reader/revisions.rs` parses the region table into `OdfChangedRegion`s (carried as an `OdfBodyChild::TrackedChanges`); `reader/inlines.rs` captures the `text:change-*` milestones as new `OdfParagraphChild` variants; the mapper (`mapper/document/inlines.rs`) resolves each milestone against the region map threaded through `OdfMappingContext` — a bracketed range becomes an insertion-marked run, a deletion point re-materialises the region's removed text as a struck run. **Export:** a new `write/revisions.rs` collects a `Changes` table during body rendering (flushed right after `` opens) and `write_styled_run` emits the milestones for a `revision`-carrying run (date written verbatim so RFC-3339 round-trips exactly). 1 ODT round-trip test (insertion + deletion, author + date + text all survive), plus a verified-conformant XML dump. Ceiling held by trimming doc comments in the at-ceiling `mapper/document/mod.rs` and by making the shared `wrap_span`/`plain_text` writers `pub(super)` rather than duplicating them. **Selection tracked deletion ✅ 2026-07-07** — deleting a **selection** under track changes now strikes it through instead of hard-deleting. New CRDT mutation `tracked_delete_selection_at` (`loro_mutation/selection.rs`): with a deletion mark it strikes each block's selected slice via `strike_range` — walking `to_delta()` and applying `delete_action` per run segment (the author's own tracked insertions are hard-deleted / un-typed, already-struck text is skipped, everything else is marked struck) — and **preserves the paragraph marks between selected blocks** (no merge, unlike the untracked path which merges); with `None` it delegates to the existing hard-deleting `delete_selection_at` (the two now share a `normalize_selection` front-end). The editor's single selection-delete choke point `delete_selection_in_doc` gained a deletion-mark parameter and a `deletion_mark(doc_state)` helper, so Backspace/Delete/Enter-over-selection **and** replace-typing all strike under tracking (Word inserts the newly typed run before the struck old text). 5 CRDT tests (`loro_tracked_selection_tests.rs`: single-block strike, own-insertion hard-delete, already-struck skip, multi-block paragraph-marks-preserved, `None` hard-delete+merge). Ceiling held by trimming `editor_keydown_text.rs` comments. **Per-change accept/reject ✅ 2026-07-08** — the Review tab's Changes group now has **Accept / Reject** buttons (circle-check / circle-x glyphs, distinct from the plain check/cross Accept-all/Reject-all) that resolve just the change **at the caret**. New model fns (`loro_mutation/revision.rs`): `accept_reject_revision_at(loro, path, byte_offset, accept)` finds the contiguous `MARK_REVISION` span at the caret (`span_at` — the span containing the offset, else the one ending at it) and resolves it via the shared `resolve_span` (delete a removed run's text, clear a kept run's mark), returning the collapsed caret offset (change start on removal, else unchanged) or `None` when the caret isn't on a change; `revision_at(loro, path, byte_offset)` is the read-only query driving the buttons' enabled state. `resolve_text` (accept/reject-all) was refactored to share `revision_spans` + `resolve_span`. The ribbon handler `accept_reject_at_caret` (`editor_ribbon_review.rs`) reads the caret from `cursor_state`, applies the mutation, relayouts, and repositions the caret; the buttons enable only when `change_at_caret` (via `revision_at`) is true, recomputing as the caret moves. 7 CRDT tests (`loro_per_change_revision_tests.rs`: accept/reject × insertion/deletion, no-op off a change, `revision_at` true/false, only-the-caret-change-resolved); 2 app glyphs + 2 i18n keys. **Nested-container resolution ✅ 2026-07-08** — accept/reject-all now reaches revisions **inside table cells and note bodies**, not just top-level paragraphs. A new `loro_mutation/text_containers.rs` (`collect_all_text_containers`) walks every section's block list and recursively descends each block's `KEY_TABLE_CELLS` / `KEY_NOTES` containers, returning every `LoroText` content container; `accept_reject_all_revisions` now sweeps that full set instead of iterating top-level block indices (which skipped tables via `TextNotFound`). This closes the latent gap where the Accept-all / Reject-all buttons enabled on a nested change (`has_tracked_changes` already recurses) but did nothing. The per-change ops were already path-aware, so they resolve nested changes too. `section_blocks_list` was exposed `pub(super)`; the collector lives in its own module to hold `nested.rs` under the ceiling. 3 CRDT tests (`loro_nested_revision_tests.rs`: accept keeps / reject removes a table-cell change, accept a footnote-body change — each asserting the doc ends clean). **Paragraph-mark tracked deletion ✅ 2026-07-08** — the last Review tail. The paragraph mark (¶) is modelled by a paragraph's `direct_char_props` (the OOXML `w:pPr/w:rPr` slot), so a tracked ¶-deletion is simply a `Deletion` on `direct_char_props.revision` — no new model type. **Round-trip:** the bridge previously dropped block-level char-props `revision`; `map_char_props_to_map` / `reconstruct_char_props_from_map` now write/read it under a new `PROP_REVISION` key, so it survives the CRDT. **Record:** new `loro_mutation/para_mark.rs::set_para_mark_deletion` marks the previous paragraph's ¶ (upgrading a plain `para`→`styled_para` like alignment does), declining non-paragraphs so the caller hard-merges; the editor's Backspace-at-start (extracted to `editor_keydown_backspace.rs` to hold the ceiling) routes a top-level paragraph start through it under tracking. **Resolve:** `accept_reject_all_revisions` now also sweeps para-marks (`para_mark::resolve_para_marks`, recursing into cells/notes) — accept removes the ¶ (successor merges via `merge_block_in_list`), reject clears; the pure transforms merge too (`content/para_mark_merge.rs`, shared by `resolve_blocks`). `has_revisions` detects a para-mark so the Accept/Reject buttons enable. 8 CRDT/model tests (`loro_para_mark_revision_tests.rs`: round-trip, accept-merges, reject-splits, pure transforms, set+upgrade, heading-declines, trailing-mark, post-merge editability). *(Deferred: rendering a struck ¶ glyph; nested-container recording — `TODO(review-para-mark-nested)`; per-change resolution of a para mark.)* **The Review tab (track changes) is now feature-complete for the editor path.** **DOCX para-mark round-trip ✅ 2026-07-08** — a tracked ¶ deletion now survives save/open in Word. **Export:** `write/revision.rs::write_mark_del` emits the self-closing ``/`` inside the paragraph mark's `w:pPr/w:rPr` (a new shared `write_rev_element` backs both it and the run-wrapping `open`). **Import:** `parse_rpr_element` recognises a `w:del`/`w:ins` child of the pPr's rPr via `reader/runs.rs::parse_mark_revision` into a new `DocxMarkRevision` on `DocxRPr`, which `map_rpr` maps to `CharProps.revision`. 1 round-trip test (`paragraph_mark_deletion_round_trips`). Ceiling held by extracting `reader/document.rs`'s inline tests to `document_tests.rs` and tightening a few doc comments in the at-ceiling `mapper/props.rs` / `model/paragraph.rs`. **Nested-container para-mark recording ✅ 2026-07-08** — Backspace-at-start inside a table cell / note body now records a tracked ¶ deletion too (previously only top-level). New path-aware `set_para_mark_deletion_at` (both it and the index-based `set_para_mark_deletion` share `write_para_mark`); the editor's `record_para_mark_deletion` computes the previous block's path via `focus.sibling_block(-1, 0)` and a `has_previous_sibling` guard (leaf index > 0), so it works at any nesting. The accept/reject sweep already recursed into cells/notes, so resolution needed no change. 1 CRDT test (`records_and_accepts_a_para_mark_inside_a_table_cell`). **Per-change para-mark resolution ✅ 2026-07-08** — the Review tab's per-change Accept/Reject buttons now resolve a paragraph-mark deletion too, not just text runs. New model fns `para_mark_at` (enable query) and `accept_reject_para_mark_at` (accept merges the successor into the caret's paragraph; reject clears the mark; returns the paragraph-end caret offset). The ribbon handler `accept_reject_at_caret` tries the text-span change first, then falls back to the para-mark; `change_at_caret` ORs in `para_mark_at`. 4 CRDT tests (`para_mark_at` detection, per-change accept-merges, reject-clears, no-op without a mark). ~~**Remaining polish:** ODF para-mark export and struck-¶ rendering~~ ✅ **Done 2026-07-11** — **Struck-¶ rendering:** a tracked paragraph-mark deletion now paints a struck, author-coloured end-of-paragraph marker (two stems + strike, paint-only items via `para_underlays::emit_para_mark_deletion` off `ResolvedParaProps::para_mark_deleted_color`, so caret/hit-test/wrapping untouched); root-cause fix en route — `flatten_paragraph` no longer bleeds the ¶'s revision onto the runs (the whole paragraph text used to render struck). **ODF export:** a ¶ deletion emits an end-of-paragraph `text:change` milestone whose deletion region stows only the paragraph break (empty `text:p`), and import maps that shape back onto the paragraph, not a struck run (`revision_round_trip::tracked_paragraph_mark_deletion_round_trips`). **The Review tab is now feature-complete including both format round-trips.** | L | -| 4a.3 | Spec 05 | **Page** style family (`page_styles` catalog per ADR-0012) and **Table** family (`TableProps` conditional/banding regions); character-style editing form; per-family non-paragraph `Default` sources; Compact-tree breadcrumb (M7). **Character-family `Default` source ✅ 2026-07-06** — the first of the per-family non-paragraph `Default` sources (ADR-0012 Decision 1). New `StyleCatalog::default_character_style` (serde-default, so it round-trips through the Loro bridge and is back-compatible); `resolve_char_chain` now falls through to it (`first_in_char_chain`, cycle/depth-guarded) so a standalone character style resolves the document's `docDefaults` run defaults as `Provenance::Default` instead of `FormatDefault` — the char inspector was previously **blind** to docDefaults. The OOXML importer synthesises a `__DocDefaultChar` character style from `w:rPrDefault` and points the default at it; the character browser hides `__`-prefixed synthetic styles, and both the DOCX and ODT writers skip them (they belong in `docDefaults`/`default-style`, not as named `w:style`/`style:style` — also fixes a latent `__DocDefault` paragraph leak). 4 model tests + 3 mapper tests; full OOXML/ODF/round-trip suites green. **Character-style editing form ✅ 2026-07-06** — the character family is now editable, not just inspectable (Spec 05 M6). Selecting a character style seeds an editable `StyleDraft` (`char_style_to_draft`) that a new `char_form.rs` binds — reusing the paragraph form's shared inputs (`field_row`/`iu_buttons`/`font_picker`/`weight_selector`, all of which already bind a `Signal>`) for name/based-on/font-family/weight/size/italic/underline. Apply commits a `CharacterStyle` to the catalog through Loro (`commit_char_style_to_loro`, persisted via the existing `write_document_styles` bridge, undoable) and relays out, **cycle-guarded** by new model helpers `char_ancestors`/`char_reparent_cycles` (the character analogue of the paragraph re-parent guard). The editable form renders alongside the read-only provenance inspector (inspector shows *where* inherited values come from, form edits the locals — the Spec 05 §6 inspector+edit pairing). 1 model test (`character_reparent_cycle_is_detected`); `editor_inner` held at its 803 baseline. **Compact-tree breadcrumb ✅ 2026-07-06 (M7)** — at Compact the paragraph inheritance tree's full indented list degrades to a **breadcrumb + drill-down** (Spec 05 §7/§11): the breadcrumb is the root→selected path (new model `para_breadcrumb` = `para_ancestors` reversed, cycle-guarded), each hop clickable to jump up; below it the selected style's direct **substyles** (`para_children`) are clickable to descend. `body::left_column` renders it via a new `tree_nav.rs` when `posture.stack` (Compact) and keeps the indented tree at Expanded/Medium. Navigation loads the target's draft exactly as the indented tree does. 1 model test (`breadcrumb_is_root_first_including_self`) + the existing posture tests. **Table-family resolver + `Default` source ✅ 2026-07-06** — the table family had single-parent inheritance in the model but **no provenance resolver** (only para/char existed). Added the table analogue: `resolve_table_chain` (Local/Inherited/**Default**/FormatDefault via `first_in_table_chain` + the new `default_table_style` catalog field), plus `table_ancestors`/`table_reparent_cycles`, in a new `resolve_table.rs` module (the `Resolved` constructors are now `pub(crate)` so the split compiles; keeps `resolve.rs` at 299, under the ceiling). The OOXML importer records `default_table_style` from the table style flagged `w:default="1"` (e.g. `TableNormal`). 3 model tests + 2 mapper tests. *(Lists are a **non-inheriting** family per ADR-0012 Decision 2 — no parent chain, so `Default` doesn't apply; they resolve Local/FormatDefault only. Table-style **export** of the default flag is deferred with the wider table-style writer, which isn't built yet.)* **ODF character-default import symmetry ✅ 2026-07-06** — the ODF half of the character `Default` source: the ODT mapper now synthesises a `__DefaultChar` character style from `style:default-style style:family="text"` and points `default_character_style` at it (the ODF analogue of OOXML's `__DocDefaultChar`; the ODT writer already skips `__`-prefixed synthetics). 1 mapper test incl. an end-to-end `Provenance::Default` resolution. *(The ODF table default is not wired: `OdfDefaultStyle` carries no table props and the ODT mapper does not import table styles at all yet — noted for the wider table-style import.)* The mapper's inline test module was extracted to `styles_tests.rs` (`#[path]` idiom) to hold the 300-line ceiling (358 → 148 production). **Page style family — started ✅ 2026-07-06** — the model foundation for ADR-0012 Decision 2's page family. New `PageStyle` type (`style/page_style.rs`): a named, **non-inheriting** entry (no `parent`) wrapping the existing rich `PageLayout` (size/margins/orientation/columns + header/footer master + page numbering), and a `page_styles: IndexMap` catalog field (serde-default, round-trips through the Loro-bridge catalog JSON, back-compatible). The format-neutral **import-mapping core** is pure + tested: `derive_page_styles(sections)` collapses sections with an identical `PageLayout` into one page style (named `PageStyleN` in first-seen order, since OOXML has no page-style name to carry), and `section_page_style_ids(sections)` gives the per-section id list — the inverse the DOCX section-export (`sectPr`) needs. 7 model tests (`page_style_tests.rs`); no resolver needed (a non-inheriting family is a chain of length one — the inspector shows only Local/FormatDefault, per ADR-0012). **Read-only page panel ✅ 2026-07-06** — the page family is now visible in the style panel (Spec 05 §9), mirroring the read-only list family. A **Page styles** list in the left column (`page_browser.rs`) + a read-only **geometry inspector** column (`family_inspector` page column) showing size / orientation / margins / columns. The panel **derives page styles on demand** from the live document's sections (`panel_data::page_data` → `derive_page_styles`) rather than reading the stored catalog field: the section layouts are the source of truth (the Layout ribbon mutates them directly), so deriving each render keeps the panel from **drifting** — the root-cause-correct choice over a stored-but-stale copy. Pure, tested inspector rows (`style_page_inspector::page_inspector_rows`, value-baked like the list inspector: named sizes, uniform-margin collapse; 4 tests). New `editing_page_style` selection signal (`editor_inner` held at 803 via comment tightening); 5 i18n keys. **Per-page-style edit mutation ✅ 2026-07-06** — the write-back primitive for LibreOffice-style per-page-style editing: `set_page_style_geometry(loro, section_indices, &PageLayout)` (`loro_mutation/page_style.rs`) applies a layout's size / orientation / margins / columns to **only the given sections** — the sections that belong to one page style (the panel derives the indices from `section_page_style_ids`) — leaving the other page styles, and each section's headers/footers/gutter/page-numbering, untouched. This is the per-style analogue of the document-wide `set_document_*` setters. Chosen over a stored `Section.page_style` reference + renderer refactor because page styles are already derived by layout-equality and an edit keeps a style's sections in sync, so index-targeting is stable without touching the fragile CRDT bridge or the layout engine. 3 integration tests (`page_style_geometry.rs`: only-its-sections, margins+columns, out-of-range skip). **Editable page form ✅ 2026-07-06** — the page panel is now editable per-page-style (LibreOffice model). Selecting a page style shows a preset form (`page_form.rs`) — Orientation / Size / Margins / Columns buttons, matching the Layout ribbon — that applies to **only that page style's sections**. The pure, tested transform `apply_preset(&PageLayout, PagePreset) -> PageLayout` builds the edited layout (orientation/size preserve the other axis; margins keep header/footer/gutter; columns keep the gap); each button computes the target section indices via `panel_data::page_edit_target` (derived on demand, always live) and writes through `set_page_style_geometry`, then relays out. 4 `apply_preset` tests. So editing "PageStyle1" changes all its pages and leaves "PageStyle2" alone. **Stored section→page-style reference ✅ 2026-07-06** — the model refinement toward true LibreOffice-style named page styles: `Section.page_style: Option` names the section's page style (persisted through the Loro bridge under `KEY_PAGE_STYLE_REF`), and `Document::assign_page_styles()` normalises a loaded document — dedups sections by layout into catalogued page styles and stores the refs, **idempotently** (a section that already names a style, e.g. a user rename or an ODF `style:master-page`, is preserved). Wired at `load_document` so every opened document gets first-class, stored, renamable page styles. 4 model tests (`page_style_model.rs`: dedup, idempotence/name-preservation, bridge round-trip, unnamed→None); the `Section` field addition rippled to ~15 test literals + 2 mapper/flow literals (offset the two baselined files back to baseline). **Rename UI + panel-reads-stored migration ✅ 2026-07-06** — the panel now reads the **stored** `section.page_style` refs instead of deriving by layout-equality, and page styles are renamable. `panel_data::stored_page_styles` groups sections by their stored ref (first-seen order) and reads the representative geometry from the first referencing section (`section.layout`, the renderer's truth) — so a page style is a **stable, renamable identity** while its geometry stays drift-free even when the Layout ribbon edits `section.layout` document-wide. A new `rename_page_style(loro, old, new)` mutation (`loro_mutation/page_style.rs`) renames the catalog key **and** every referencing section's stored ref atomically, keeping the `PageStyle.id` in sync and no-opping on name conflict / missing source. The page form (`page_form.rs`) grows a `PageRenameField` component (`page_rename.rs`, ADR-0013 — owns its draft signal, keyed by name to reseed on reselection) whose Rename button commits through the mutation, relays out, and re-selects the style under its new name. 2 rename integration tests (catalog+section refs updated; conflict/missing no-op) + the existing geometry tests; 2 i18n keys. This resolves the earlier drift caveat: identity comes from the stored ref, geometry from the live section. **ODT native page-style naming + importer population ✅ 2026-07-07** — the ODF-native round-trip for named page styles (ADR-0012 Decision 2). **Export:** a new `odt/write/page_styles.rs` resolves each section's `style:master-page` / `style:page-layout` names from the stored `section.page_style` id (sanitised to a valid XML `NCName` via `xml::sanitize_ncname`), so a named — or renamed — page style is written out under its real name instead of the old positional `MP{idx}`. Sections sharing a page style collapse to **one** master page (the first referencing section's layout is the representative geometry — the same choice the panel makes), matching LibreOffice's shared-master model; sections without a stored ref keep the positional fallback, so pre-page-style documents export byte-for-byte as before. Both `content.xml` (the `style:master-page-name` reference) and `styles.xml` (the master-page + page-layout definitions) read from the one resolver so the two always agree. **Import:** the ODT mapper now sets `section.page_style` from the master-page name each section uses and registers those names as first-class `page_styles` catalog entries (with `display_name`), so an opened ODT shows its real page-style names in the panel and they survive a re-export. 5 naming unit tests (`page_styles_tests.rs`: stored-id→master, shared-master dedup, positional fallback, NCName sanitisation, empty-doc) + 1 export→import round-trip (`odt_export_round_trip::named_page_styles_round_trip_as_master_pages`). **Master-page `style:display-name` round-trip ✅ 2026-07-07** — the page family's last tail item. `OdfMasterPage` gained a `display_name` field; the ODT reader parses `style:display-name` off both the container and self-closing `` forms, and the mapper carries it onto the `PageStyle` (only when distinct from the id — a redundant one stays `None`, and fabricating `Some(id)` is no longer done, so a later rename isn't shadowed). Export writes `style:display-name` on the master page when the catalog gives the style a name distinct from the emitted `NCName`. So a page style with a spaced/human name (id `WideBody`, display "Wide Body") round-trips both halves. +1 export unit test + display-name assertions in the round-trip test; the self-closing-master reader branch was refactored through a new `OdfMasterPage::header_footer_less` constructor to hold the `reader/styles.rs` ceiling. **Page family complete** — OOXML has no named page style (DOCX sections already export as `w:sectPr` per page style, and `assign_page_styles` names them `PageStyleN` on import — nothing further to wire). **Table-style reference — foundation ✅ 2026-07-08** — the prerequisite for table banding/conditional formatting: a `Block::Table` now **references its named style** (OOXML `w:tblStyle` / ODF `table:style-name`), which was previously dropped on import (the DOCX reader parsed `w:tblStyle` but the mapper never propagated it, and `Table` had no style field). Rather than add a `style_id` field to `Table` — an ~18-site struct-literal ripple across 8 crates — the reference is stored in the table's `NodeAttr` `"style"` key (the convention a `Block::Heading` already uses), read via `Table::style_name()` / written via `set_style_name()`. It round-trips through the Loro bridge for free (the bridge serialises the table skeleton incl. node attrs) and through DOCX (`map_table` carries it; the writer emits `w:tblStyle` before `w:tblW`). 4 tests: 2 DOCX round-trip (`table_style_round_trip.rs`), 1 CRDT bridge, and the no-style case. Ceiling held by trimming comments in the at-baseline `mapper/table.rs` / `write/document.rs`. **Table-style banding/conditional model + resolver ✅ 2026-07-08** — the pure-logic layer under table banding. `TableStyle` gained a `conditional: IndexMap` map (the twelve OOXML `w:tblStylePr` regions plus `WholeTable`) and `TableProps` gained `row_band_size`/`col_band_size`; a new `TableLook` struct models the `w:tblLook` region flags (custom `Default` = Word's `04A0`: header row + first column + row banding on). A new `style/table_banding.rs` provides `resolve_cell_shading(style, look, row, col, rows, cols) -> Option`: it walks a 13-entry precedence array (corners > first/last row > first/last col > horizontal bands > vertical bands > whole-table), computes horizontal/vertical band membership (index parity over the band-eligible rows/cols, honouring band size and header/footer/first-col exclusion), and resolves *per-property* — a higher-precedence region that defines no shading falls through to the next that does, with `TableProps::background_color` as the base fallback. All pure — no layout/render dependency, so it needs no visual verification. 12 unit tests + `default_table_look_matches_word_04a0`; the ~4 `TableStyle`/`TableProps` struct literals across the workspace updated for the new fields. **Table-style shading reaches the cell paint ✅ 2026-07-08** — the resolver is now wired into the flow engine. A new `loki-layout` `table_shading` module (`resolve_table_style` looks a table's `"style"` attr up in the style catalog; `cell_style_shading` calls `resolve_cell_shading` under Word's default `w:tblLook`) is consulted at the Pass-3b cell-paint seam in `flow.rs`: the painted cell background is now `cell.props.background_color.or(style banding)` — direct cell shading still wins, but a cell with none falls through to the table style's conditional/banding shading. The two duplicated paint branches (in-progress page vs. finished page) were unified into a single target-vec selection — a DRY simplification that offset the new logic and dropped `flow.rs` from 1953 → 1948 (baseline ratcheted). 1 end-to-end flow test (`table_style_banding_shades_the_header_row` — a styled 2×2 table with no direct shading paints exactly its 2 header cells) + 3 `table_shading` unit tests. `w:tblLook` is assumed default until import lands (`TODO(table-tbllook-import)`). **DOCX `w:tblStylePr` conditional-formatting import ✅ 2026-07-08** — real Word documents now carry their table-style banding into the model. The DOCX styles reader (`reader/styles.rs`) gained a small state machine over `w:type="table"` styles: band sizes (`w:tblStyleRowBandSize`/`w:tblStyleColBandSize`), base whole-table cell shading (`w:tcPr/w:shd`, scoped by an `in_tcpr` flag so `w:tblPr/w:shd` and `w:rPr/w:shd` don't leak in), and each `w:tblStylePr` region's cell shading (tracked by a `current_region` set on the element's `@w:type` and reset on its close) — collected into new `DocxTableStyleProps`/`DocxTblStylePr` model types on `DocxStyle`. The styles mapper (`mapper/styles.rs`) translates them: `map_table_region` maps the twelve OOXML region names to `TableRegion` (unknown names skipped), band sizes + base shading fill (via the existing `xml_util::resolve_shading`) into `TableProps`, and each shaded region into the `conditional` map (unshaded regions skipped). So a document using a built-in banded style (*List Table*/*Grid Table Accent*) imports its conditional shading and — through the layout wiring above, under the default `w:tblLook` — **paints banded rows/header end-to-end**. The `DocxStyle.table` field addition rippled to 7 test literals (mechanical `table: None`). 4 tests: `parses_table_style_banding` + `non_table_style_has_no_table_props` (reader), `table_style_conditional_formatting_maps` (mapper, incl. unknown-region + unshaded-region skipping), all suites green. **Per-table `w:tblLook` import ✅ 2026-07-08** — a table instance now carries its **own** active-region flags instead of assuming Word's default. The DOCX reader (`parse_tbl_look` in `reader/document.rs`) parses `w:tblLook` from either the explicit boolean attributes (`w:firstRow`/`w:lastRow`/`w:firstColumn`/`w:lastColumn`/`w:noHBand`/`w:noVBand`, the `no*Band` flags inverted into positive banding) or the legacy `w:val` hex bitmask (bit masks `0x0020`…`0x0400`), into a new `DocxTblLook` on `DocxTblPr`. A format-neutral codec on the doc-model `TableLook` (`encode_attr`/`decode_attr` — a six-char `0`/`1` string, keeping the OOXML bit layout out of the model) lets the mapper (new tiny `mapper/table_look.rs`) encode it into the table's `NodeAttr` `"tbllook"` key (round-trips through the Loro bridge like `"style"`, and `Table::table_look_code`/`set_table_look_code` store it as an opaque string so `content` needn't depend on `style`). The flow engine reads it (`table_shading::table_look` decodes, defaulting on absent/malformed) and threads it into `cell_style_shading`, replacing the hard-coded `TableLook::default()` — so a table that disables banding or enables the last-row/last-column region renders with its real active regions. 8 tests: `parse_tbl_look_reads_the_legacy_val_bitmask` + `parse_tbl_look_prefers_explicit_attributes` (reader), `map_tbl_look` ×2 (mapper), `table_look_attr_round_trips` + `table_look_decode_rejects_malformed` (model codec), `table_look_reads_the_encoded_attr_or_defaults` + `tbl_look_with_first_row_off_suppresses_header_shading` + `table_look_disabling_first_row_suppresses_style_shading` (layout, incl. end-to-end flow). Ceilings held by trimming comments in the at-baseline `mapper/table.rs` (307) and `flow.rs` (1948). **DOCX table-style banding export ✅ 2026-07-08** — the export half, closing a full DOCX round-trip for banding. A new `write/table_style.rs` emits each catalog `TableStyle` as a `w:style w:type="table"`: band sizes → `w:tblPr` (`w:tblStyleRowBandSize`/`w:tblStyleColBandSize`, skipped when absent), base whole-table shading → `w:tcPr/w:shd`, and each conditional region → `w:tblStylePr w:type="…"` with its own `w:tcPr/w:shd` (`region_ooxml` inverts the mapper's `map_table_region`; the non-exhaustive `TableRegion` match skips unknown future variants). It also writes the table instance's `w:tblLook` (both the explicit boolean attributes and the legacy `w:val` hex bitmask via `look_bitmask`) into its `w:tblPr` from the `"tbllook"` attr, after `w:tblW` per the schema. Wired via `write_styles_xml` (a 2-line call) and `write_table` (a 1-line call — `write_tbl_look` takes `Option<&str>` and no-ops on absent/malformed, keeping `write/document.rs` at its 1073 baseline). 5 tests: 4 writer unit tests (`writes_conditional_regions_and_band_sizes`, `a_style_without_bands_omits_tblpr`, `writes_tbl_look_attributes_and_bitmask`, `malformed_tbl_look_code_writes_nothing`) + `table_style_banding_and_tbllook_round_trip` (a banded style + non-default look survive export→import intact through the catalog + instance attr). **ODT cell-shading export ✅ 2026-07-08** — the first ODF-side increment. ODF has no conditional-region concept: it bakes table shading into **per-cell** automatic styles (LibreOffice's on-disk model), and the ODT writer previously emitted `` with no formatting at all. `AutoStyles::cell_style` (`odt/write/auto.rs`) now emits a deduplicated automatic `` carrying `fo:background-color` (`TC{n}` names, co-located `emit_cell_properties`), referenced by `table:style-name` on each shaded cell in `write/tables.rs`. ODT **import** of `fo:background-color` already existed (`map_cell_props` reads it into `CellProps.background_color`), so a shaded cell now round-trips through ODT with no import change. 3 tests: `cell_style_emits_background_and_dedupes` + `a_cell_without_shading_gets_no_style` (writer unit) + `cell_background_round_trips_via_table_cell_style` (end-to-end ODT export→import); full loki-odf suite (incl. schema validation) green. **ODT banding resolution on export ✅ 2026-07-08** — bridges the two formats' models: a table carrying only a `"style"` reference + `"tbllook"` (e.g. a DOCX-imported banded table) now exports its bands to ODT as concrete per-cell shading. `write/tables.rs` flattens the rows, assigns each cell its grid column (`assign_grid_columns` — a coverage-grid mirror of the layout's `assign_cell_columns`, honouring `col_span`/`row_span` merges), then in a two-phase pass computes each cell's effective background = its direct shading **else** `resolve_cell_shading(style, look, row, col, rows, cols)` (the very doc-model resolver the layout paints with) and bakes it into the per-cell `table-cell` automatic style (phase 1 resolves under an immutable catalog borrow, phase 2 mints styles under `&mut cx.auto` — avoiding a borrow conflict). The style catalog reaches the writer via a `Cx.table_styles` clone (`content.rs`; the header/footer `Cx` in `styles.rs` uses an empty map). `AutoStyles::cell_style` was generalised to take the resolved `Option<&DocumentColor>` rather than `&CellProps`. 1 round-trip test (`table_style_banding_resolves_into_per_cell_shading_on_odt_export`: a firstRow-banded style with no direct shading → header cells return shaded, body cells not). **ODT table-level `table:style-name` write + reference round-trip ✅ 2026-07-08** — the ODT analogue of the DOCX `w:tblStyle` reference. A new `write/table_style.rs` emits a named `` for each catalog table style (skipping `__`-prefixed synthetics) into `styles.xml`'s ``, carrying table-level geometry: `style:width` (from `TableProps::width` `Absolute`) / `style:rel-width` (`Percent`), `table:align` (`align_value`), and `fo:background-color`; `write/tables.rs` references it via `table:style-name` on the `` element. **Import** restores the reference in one line — `map_table` sets `Table::set_style_name` from `OdfTable.style_name` (which the reader already parsed) — so a table's named style survives an ODT round-trip; the `TableWidth`/`TableAlignment` matches carry a wildcard arm since both enums are `#[non_exhaustive]`. 5 tests: 4 writer unit (`emits_width_alignment_and_background`, `percent_width_uses_rel_width`, `a_style_with_no_geometry_omits_table_properties`, `synthetic_styles_are_skipped`) + `table_style_name_reference_round_trips` (end-to-end); full loki-odf suite incl. schema validation green. **Remaining 4a.3:** ODT **import** of the table-style *definition* back into the catalog (the reference survives, but width/align/bg are written-but-not-re-read into a `TableStyle` — needs the reader `parse_style_props` table-properties extraction + a `table_styles` mapper); cell **borders**/padding export; `w:cnfStyle`; conditional character formatting; and the editing UI. Plus the Page-family tail and the ODF table default-style import (now partly unblocked). | L | -| 4a.4 | Spec 03 | ~~Metadata-panel label stacking <250 px (R-13g)~~ (✅ **Done 2026-07-06** — `FieldRow` is now a `#[component]` reading `use_viewport()` per ADR-0013; below `METADATA_LABEL_STACK_PX` (250 px) the label stacks above its input via a `flex-direction: column` switch so the input keeps a usable width; pure `stack_labels` helper + 3 tests in `editor_metadata_panel_tests.rs`); responsive doc type-scale (M4); ~~real `Viewport.zoom`~~ (✅ **Done 2026-07-06** — the status-bar zoom now feeds the shared responsive `Viewport::zoom` (`editor_responsive.rs`: pure `zoom_fraction`/`desired_view_mode` helpers wired into effects 2 & 3), so zooming a page past the point it fits flips the page-fit renderer to reflow instead of forcing horizontal scroll; 5 tests in `editor_responsive_tests.rs`). **Remaining:** responsive doc type-scale (M4). | M | -| 4a.5 | Spec 04 M6 | Touch posture + ~~cursor-into-new-cell after insert~~ (✅ **Done 2026-07-06** — `insert_table_after_cursor` now returns the first-cell caret (`first_cell_caret` → flat cell 0 / block 0), and the Insert-tab `run_insert` collapses the cursor there after relayout via `set_collapsed_cursor` (page re-derived per 4b.1); footnote leaves the caret at the anchor, matching Word. `InsertResult` enum threads the optional caret target; the async image flow was extracted to `editor_ribbon_insert_image.rs` to hold the ceiling. 3 tests in `editor_insert_tests.rs`). **Remaining:** touch posture. | M | +| 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. **ODT table-style definition import ✅ 2026-07-11** — the written-but-not-re-read gap is closed: the styles reader parses `style:table-properties` (new `styles_table.rs` submodule; `parse_style_props` now returns a bundled `ParsedStyleProps` struct, which also retired its `clippy::type_complexity` allow and *shrank* the two baselined files), the model carries `OdfStyle.table_props` (`OdfTableProps` in `model/tables.rs`), and the mapper's new `OdfStyleFamily::Table` arm builds a catalog `TableStyle` via `map_table_style_props` (width `style:width`/`style:rel-width` → Absolute/Percent, `table:align`, `fo:background-color` — the exact inverse of `write/table_style.rs`). 2 mapper unit tests + the extended `table_style_name_reference_round_trips` (definition asserted, not just the reference) + `table_style_percent_width_round_trips`. **4a.3 tail, 2026-07-12:** ~~cell borders/padding export~~ ✅ (DOCX `w:tcBorders` was parsed but never written — now emitted, with the tcPr children reordered to the CT_TcPr schema sequence; ODT per-cell auto styles bake `fo:border-*`/`fo:padding-*` beside the background; round-trip tested in both formats); ~~`w:cnfStyle`~~ ✅ (new `TableCnf` 12-flag mask codec riding the cell attr `"cnf"`; reader/mapper/writer round-trip it, and both the layout paint and the ODT baked-shading export prefer the explicit mask over positional banding derivation); ~~conditional character formatting~~ ✅ (`TableConditionalFormat.char_props` from `w:tblStylePr/w:rPr` — the reader previously let a region rPr *clobber* the style-level rpr, fixed; regions merge low→high precedence via `resolve_cell_char_props(_cnf)`; a per-table char grid (`flow_table_chars.rs`) feeds both the row-height measurement and content-flow passes; `flatten_paragraph_with_base` slots the region under the style chain per Word precedence; DOCX export writes the region rPr back; `flow_table` extracted to `flow_table_main.rs`, flow.rs ratcheted 1187→1101); ~~ODF table default-style import~~ ✅ (`style:default-style family="table"` → synthetic `__DefaultTable` + `catalog.default_table_style`). **Table-style editing UI ✅ 2026-07-12** — the style panel gains a Table family (Spec 05 M6 pattern, mirroring the character family): `table_browser.rs` lists the catalog's table styles (synthetics hidden) and seeds a `TableStyleDraft` (`draft_table.rs` — name, based-on, alignment, row/column band sizes; everything the form does not edit, notably the conditional/banding region map, is preserved on apply, tested by `apply_preserves_the_conditional_map_and_edits_fields`); `table_form.rs` renders the editable form whose Apply cycle-guards the based-on via `table_reparent_cycles`, commits through `commit_table_style_to_loro` (undoable `write_document_styles`), and relays out via `apply_mutation_and_relayout` + `post_mutation_sync`. **4a.3 is complete — and with it the whole 4a series (4a.1–4a.5).** | 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)~~ (✅ **Done 2026-07-12** — Compact viewports render the reflow view's type 12.5 % larger (`REFLOW_COMPACT_TYPE_SCALE`), implemented as a pure **view transform**: the layout runs at `width ÷ scale` and tiles paint at `zoom = scale`, so the on-screen tile width is unchanged, the reading measure narrows (Compact is no longer a shrunk Expanded), and document point sizes stay untouched — the fidelity constraint that deferred this in the Spec 03 audit. Single-sourced in `render_layout::{reflow_type_scale, reflow_layout_tile_width_pt, reflow_layout_content_width_pt}` and consumed by paint (`document_view`), hit-testing (`reflow_hit_test_window`, new `type_scale` param), and keyboard nav — the Spec 01 one-source discipline; a drift-lock test in loki-text pins the renderer's 600 px threshold to `BREAKPOINT_COMPACT_MAX_PX` (loki-renderer deliberately lacks the appthere-ui dep). CSS-width-invariance + scaled-hit-test + boundary tests. The Android-CPU HTML fallback keeps its fixed 16 px body — it is the low-fidelity path and already reads comfortably); ~~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`). **4a.4 is complete.** | 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`). ~~Touch posture~~ ✅ **Done 2026-07-12** (Spec 04 M6) — **R-14:** the ribbon tab strip is breakpoint-aware: the touch-first Compact class renders it at `TOUCH_MIN` (44 px, WCAG 2.5.8) via the new pure `tab_strip_height`, pointer-first classes keep the 36 px desktop density (shell tab-bar convention); resilient without a responsive context (reads Expanded). **R-13e** verified already built with the 4a.1 collapse cascade — `AtRibbonSelect` shrinks to `RIBBON_SELECT_WIDTH_CONDENSED_PX` via the ambient `GroupCollapse` signal. **RB-11** (bottom-ribbon-for-touch) stays unbuilt by decision: the audit marked it optional ('where applicable') and the collapse cascade covers Compact — revisit only if phone testing shows reach problems. **4a.5 is complete.** | M | ### 4b. Editing-core TODOs (§2) @@ -130,8 +130,8 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). |---|---|---|---| | 4c.1 | `ux` | ✅ **Done 2026-07-05** — the recents Delete menu action now only *requests* deletion; `AtConfirmDialog` performs it on confirm (all three apps' Home; `home.rs` split `home_util.rs` out to stay under the ceiling). | S | | 4c.2 | `a11y` | ✅ **Done 2026-07-05 (bounded by bar height)** — the status bar's three interactive controls (notice chip, view-mode toggle, zoom badge) are now transparent hit areas ≥ `TOUCH_MIN` (44 px) wide × the bar's full height, with the visual chip nested inside. **Honest constraint:** the 24 px `STATUS_BAR_HEIGHT` caps the vertical target at WCAG 2.5.8 AA's 24 px minimum, below the suite's 44 px convention — meeting it fully requires a taller bar on touch platforms (design decision, deferred; documented on `AtStatusBar`). | S | -| 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | -| 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | +| 4c.3 | `title-edit`, `browse-templates`, `tabs` | ~~Inline-editable title~~ (✅ **done 2026-07-11** — `AtTitleBar` gains an optional `on_rename` prop swapping the center span for a draft-owning input (Enter/blur commits, Escape resets, keyed by the committed title; read-only unchanged when absent)); ~~template-browser dialog~~ (✅ **done 2026-07-11** — new `AtTemplateBrowser` overlay (AtConfirmDialog mounting contract, 44 px rows, theme-palette colors) listing all six templates; loki-text's Browse… card now opens it via a `TemplateBrowserHost` boundary component in `home_util.rs`, selection reuses the gallery's `on_template_select`; `home.rs` held at its 331 baseline); ~~blank-doc~~ (✅ **done 2026-07-11** — the tab strip's `+` button now opens a blank document directly via `push_new_tab(new_blank_tab())` instead of navigating Home). **Remaining:** tab-driven (vs router) navigation — an architectural routing change, deliberately deferred. | M | +| 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | ~~Light-theme tokens~~ (✅ **done 2026-07-11** — `ThemePalette` in `tokens/palette.rs`: `dark()` built from the `COLOR_*` constants (zero drift), `light()` the inverted set; `AtThemeContext` is Signal-backed with `palette()`/`toggle()` and a resilient `use_theme()` fallback; shell chrome (AtTitleBar/AtTabBar/AtDocumentTab/AtStatusBar + the new dialogs) reads the palette and re-colors live; a self-contained tab-bar toggle button ships in all three apps behind the new `theme_toggle_aria_label` prop + `shell-theme-toggle-aria` key; unmigrated components keep the dark constants and migrate opportunistically); ~~ribbon separator variant~~ (✅ **done 2026-07-11** — `AtRibbonGroup.show_divider` (default true); `AtRibbonGroups` suppresses the trailing divider on the last in-strip group and inside the vertical overflow menu); ~~real OS check~~ (✅ **done 2026-07-11** — `Platform::detect()`/`from_os_name` map `std::env::consts::OS`, tested; the macOS traffic-light padding already keys off `Platform::MacOs`); ~~verify bundled UI fonts~~ (✅ **done 2026-07-11** — registration was already wired at every launch path; now locked by `loki-layout/tests/ui_font_registration.rs` asserting all seven bundled families resolve in a Parley `FontContext`, and the two `TODO(font)` notes retired). **Remaining:** real Tabler/SVG icons over emoji (gated on Blitz SVG support, still unconfirmed). | M | | 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | ✅ **Done 2026-07-05** (spreadsheet grid-zoom is the sole tail). ✅ F6a — recent-file rows and template cards are now child `#[component]`s (`recent_row.rs`, `RecentRow`/`OpenFileButton`/`TemplateCard`/`BrowseCard`) so their hover `use_signal`s no longer live inside `for`/`if` bodies (hook count is now prop-independent; ADR-0013). ✅ F6d ribbon — loki-spreadsheet's ribbon lists only the implemented Home tab (the dead Insert/Format/Review/View entries removed) and its tab-select + collapse are live signals. ✅ F7a — `AtHomeTab` reads `use_breakpoint()` (Compact = stacked, Medium/Expanded = side-by-side) instead of the fixed `viewport_width = 375.0`. ✅ F7b — slide thumbnails/bullets use stable keys (`SlideId`, shape+para) and slide deletion clamps `active_idx` explicitly. ✅ F7c — live word count in loki-text's status bar (`editing/word_count.rs`: streaming counter, Word-matching semantics — tables counted, notes excluded; 8 tests; plural `editor-word-count` key). ✅ F6d-zoom (2 of 3 apps) — the status-bar zoom badge cycles 50–200% (`appthere_ui::next_zoom`): loki-text scales the GPU page tiles + paint transform together (`DocPageSource::zoom` → `DocumentViewProps::zoom` → `PageTile` hit-test divide-out; reflow unaffected by design), loki-presentation scales the slide box + text; **spreadsheet grid-zoom deferred** (`TODO(zoom)` — needs zoom-aware `col_px` + resize px↔pt in one pass). Ceiling wins: `document_view.rs` split (`view_types.rs`) resolved its baseline entry, plus `editor_save.rs` extracted from the spreadsheet (baseline 34 → 32). ~~**Remaining tail:** F7a measurement plumbing for Calc/Slides + spreadsheet grid-zoom~~ ✅ **Done 2026-07-11** — **F7a:** new shared `appthere_ui::AtViewportWidthSensor` (zero-height full-width scroll container: measures at mount, re-measures on the shell's post-resize `resync_scroll_geometry` onscroll tick); Presentation + Spreadsheet now call `use_provide_responsive()` and mount it, so `AtHomeTab`'s breakpoint tracks the real window. **Grid zoom (`TODO(zoom)`):** the status-bar badge cycles 50–200% and scales column widths, row heights, header sizes, and fonts; the document keeps unzoomed pt (resize commits divide the screen px back out, clamped in screen space; auto-fit converts its document-px estimate). `apply_change`/`sync_undo_redo` moved to `editor_mutate.rs`, dropping `editor_inner.rs` 1047 → 1014. | M | --- @@ -150,9 +150,9 @@ the Watch list). Every task here must update `docs/fidelity-status.md`. | 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~~ (✅ **done 2026-07-10** — honoured at layout time: a would-be orphan (lone first line at a page bottom) defers the whole paragraph, a would-be widow (lone last line atop the next page) pulls a line down; default 2 lines matching Word/LibreOffice's default-on, resolved on `ResolvedParaProps::{orphan_min,widow_min}` from `w:widowControl`/`fo:orphans`/`fo:widows`. Pure `flow_widow_orphan::resolve_split` applied in `split_and_place_loop`, termination-guarded. Tested by 8 unit tests + an end-to-end orphan-defer test; full loki-layout/loki-text/loki-acid suites green), ~~content controls~~ (✅ **done 2026-07-10** — a block-level `w:sdt` was **skipped**, dropping the paragraphs/tables inside every content control (cover pages, forms) — real data loss. The reader now **unwraps** `w:sdtContent` into the body (`reader/document_sdt.rs`, recursing into nested controls; the `DocxBodyChild::Sdt` placeholder variant removed). Tested by a reader unit test + an end-to-end import test. ~~Tail: cell-level and inline `w:sdt`~~ **verified already-unwrapped + hardened 2026-07-11**: the paragraph/cell dispatch loops never skipped unknown elements, so inline and cell-level `sdtContent` already parsed implicitly — the tail note was stale; behaviour is now regression-locked (inline runs + cell paragraphs) and both dispatches skip `w:sdtPr`/`w:sdtEndPr` wholesale like the block-level unwrapper), `border_between`, DocxSettings, language tags — schedule individually from the fidelity registry. | L (aggregate) | -| 5.10 | registry | Page/column geometry set: ~~even/odd blank pages~~ (✅ **done 2026-07-09** — `evenPage`/`oddPage` section breaks now insert a blank filler page to reach the correct parity, `paginate_blanks`; tested end-to-end + unit), ~~unequal column widths~~ (✅ **done 2026-07-10** — per-column widths are now modelled (`SectionColumns.widths`) and honoured end-to-end: DOCX `w:cols w:equalWidth="0"`/`` read (`reader/sectpr.rs`, split out to hold the ceiling), mapped (`mapper/document_cols.rs`) and written back; ODF `style:column @style:rel-width` read + re-emitted (ratio-preserving); Loro bridge `KEY_COL_WIDTHS`; flow engine places each band at the cumulative width+gap offset (`flow_columns::column_layout_for`/`column_x_offset`). Tested at model/bridge, DOCX round-trip, ODF ratio, and layout-geometry layers), ~~column height balancing~~ (✅ **done 2026-07-10** — a standalone single-page multi-column section now balances its columns to roughly equal heights via a capped-height re-flow with a bounded binary search (`flow_balance.rs`); footnote-free single-page only, so footnotes/page-bottom content are untouched, and editing falls back to a full balanced relayout. Multi-page/continuous *last-page-only* balancing remains `TODO(column-balance-multipage)`. Tested by `short_multi_column_section_balances_across_columns`); ~~PDF font subsetting~~ (✅ **done 2026-07-10** — used-glyph subset via the `subsetter` crate, subset-tagged `BaseFont`, `CIDToGIDMap` remap so the content stream is untouched; full-font fallback for faces the subsetter rejects) + ~~ICC/CMYK~~ (✅ **done 2026-07-10** — a CMYK ICC `DestOutputProfile` is now embedded by default: a bundled **CC0/public-domain** compact profile characterising CGATS TR 001 (SWOP), from saucecontrol/Compact-ICC-Profiles, CC0 being Apache-2.0-compatible (`loki-pdf/assets/`). `OutputIntent::with_icc_profile` overrides it for certified press conditions. Tested by `default_intent_embeds_bundled_cmyk_profile` + the export-level `DestOutputProfile` assertions); **EPUB math** ✅ **done 2026-07-09** — `Inline::Math` is now emitted as native MathML into the EPUB XHTML with the `properties="mathml"` manifest declaration (was dropped); ~~EPUB fields/comments~~ ✅ **done 2026-07-10** (fields → static text from the `current_value` snapshot or metadata; commented ranges → an inline superscript ref marker + a trailing `