Add conformance schemas, golden fixtures, and UI/layout improvements#30
Merged
Merged
Conversation
Turns the audit's verified §2–§7 deferrals into a prioritized, phased plan: verification of unverified findings (F1–F7), CRDT round-trip integrity, the loki-file-access patch upstreaming, Spec 02 conformance foundation, editor completion (Spec 03–05), fidelity/perf/quality backlogs, plus an upstream-gated watch list and explicit out-of-scope boundary. The audit's §8 doc-hygiene actions were already applied and are excluded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Adds the F1-F7 verification addendum (§9) to the deferred-features audit: F1/F2/F4 resolved, F3 largely resolved (dirty-tab close confirmation remains), F5 resolved by removal of the retier pipeline in favor of viewport-window mounting, F6/F7 partial with per-sub-item verdicts. Corrects the 2026-06-10 audit's inaccurate claim that off-screen pages downsample by viewport distance, points its stale F1-F7 note at the new addendum, folds the confirmed-open items into the remediation plan (tasks 4b.6/4b.7, 4c.5, 6.7, watch list), and fixes two stale in-code comments found during verification (TODO(undo-dirty) 'Save not implemented'; hit_test.rs 'scroll_offset = 0.0'). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Both fields were written to the CRDT as Debug strings — tab_stops had no reader at all, and the paragraph background_color reader parsed hex only, so both were silently dropped on the first document_to_loro → loro_to_document cycle (deferred-features audit §6). - tab_stops: structured 'pos:Alignment:Leader;…' codec in loro_bridge/decode.rs, written under PROP_TAB_STOPS and read back in reconstruct_para_props. Pre-codec Debug strings decode as absent. - background_color: new total DocumentColor codec (loro_bridge/color_codec.rs) covering Rgb/Cmyk/Theme/Transparent, so non-Rgb paragraph backgrounds no longer collapse or drop. - Extracted the LoroMap accessors into loro_bridge/map_get.rs to keep props_read.rs under the 300-line ceiling. - Round-trip tests: bridge_tab_stops_roundtrip, bridge_para_background_color_roundtrip (all four color variants). Phase 1.1/1.2 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
BulletList/OrderedList/BlockQuote/Div/Figure previously round-tripped only as opaque JSON snapshots (single blob, no concurrent merge) and their legacy read arms were debug-log stubs collapsing to HorizontalRule. They now get native mappings following the table.rs pattern (loro_bridge/containers.rs): rarely-changing structural metadata as a small JSON snapshot under container_meta, content in live nested movable block lists (list_items / child_blocks / caption_blocks) written through the shared block path — so paragraph text inside list items, quotes, divs, and figure captions lives in real LoroText containers and concurrent edits to different items merge. Legacy pre-opaque stubs (no content lists) still read as HorizontalRule — nothing recoverable. DefinitionList and inline fields/math stay on the opaque path. Round-trip + no-collapse regression tests in loro_bridge_container_tests.rs; audit §6 and plan Phase 1.3 updated. Phase 1.3 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
…ote/span attrs
Closes three inline-level round-trip gaps (deferred-features audit §2,
loro-bridge topic; plan Phase 1.4):
- Character colors now use the total DocumentColor codec on both the
text-mark path (MARK_COLOR) and the block-map direct-char-props path,
so Theme/Cmyk/Transparent survive instead of being dropped for lack
of a hex form. Old hex strings still decode.
- Comment range markers and bookmark start/end markers are preserved
as OBJECT_REPLACEMENT_CHAR anchors with MARK_COMMENT/MARK_BOOKMARK
snapshot marks (same mechanism as images), staying positioned
between text runs; previously silently dropped by text flattening.
- Inline::Quoted keeps its quote type (MARK_QUOTE_TYPE) and
Inline::Span its NodeAttr (MARK_SPAN_ATTR) as range marks; both
write children recursively so nested marks survive too.
- loro_schema.rs crossed the 300-line ceiling and was split into
loro_schema/{mod,marks}.rs (pub-use keeps all paths working).
- roundtrip_transparent_color_graceful_drop is now
roundtrip_transparent_color_survives — the drop was the workaround,
not the contract.
Still deferred with TODO(loro-bridge): non-Rgb border colors (the
colon-delimited border format cannot carry the codec strings), Cite
metadata, structural-table CRDT semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
The oplog grows ~2 ops per keystroke and was never compacted, so a long editing session retained unbounded history (observed >3 GB in the memory audit). The save point is the natural horizon: the file is then the durable state, so history behind it only serves undo. - loki-doc-model: new loro_bridge::compact with compact_in_place (re-encode the change store + free checkout caches; history and undo preserved) and compact_history (minimal-history StateOnly snapshot re-imported into a fresh doc with the mark expand config re-registered — extracted as configure_text_style and shared with document_to_loro). - loki-text: editor_compact::compact_after_save runs after every successful save — in-place below 20k ops, full truncation above it (swap the loro_doc signal, recreate the UndoManager so undo restarts at the save point, drop the IncrementalReader seed). The ribbon Save button now routes through the shared Ctrl+S handler instead of duplicating the save logic, so compaction and the clean baseline live in one place; editor_ribbon.rs dropped below the 300-line ceiling and leaves the ratchet baseline (35 → 34). - Bench acceptance (leak_loro_history, new compacted phase, asserted): 5000 keystrokes = 19208 ops uncompacted vs 1 op with periodic compaction; memory-audit Finding 6 marked fixed with the on-device long-session validation noted as the remaining follow-up (BM-14). Phase 1.5 of docs/deferred-features-plan-2026-07-04.md — Phase 1 complete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
The only non-upstream-gated dependency patch is gone (plan Phase 2). The full patch content — Android NativeActivity file-picking fixes, the FilePickerActivity/ImeInsetsListener Java shims and their dexing build.rs, the insets/IME bridges, and FileAccessToken::delete()/ copy_bytes_to() — was pushed to appthere/loki-file-access as v0.1.3 (commit d2b7bc5, fast-forwarded to main; 43 tests + clippy clean). - Drop the [patch."…/loki-file-access"] entry and the patches/loki-file-access/ vendored tree; the existing branch="main" git dependency now resolves to 0.1.3 directly, with zero 'Patch … was not used' warnings. - The three Android build scripts staged the Java shims from the deleted patch path; they now resolve the crate's checkout directory from cargo metadata (single source of truth stays upstream). - docs/patches.md: section moved to 'Removed patches' with the removal record (5 active patches remain, all upstream-gated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
…hema-validated (Spec 02 B-10 + M2) Phase 3.1 — Gelasio (B-10): - Bundle the missing metric-compatible Georgia face: Gelasio Regular/Bold/Italic/BoldItalic in loki-fonts/fonts/ (SIL OFL 1.1, no Reserved Font Name; license in OFL-Gelasio.txt). Reconstructed at full upstream coverage (791 codepoints) from the @fontsource/gelasio npm distribution by merging its latin + latin-ext + vietnamese subsets with fonttools. - Register Georgia → Gelasio in the substitution engine (loki-layout/src/font.rs) and embed the faces in fallback_font_blobs. - New dedicated substitution suite (Spec 02 §7.3/D4): loki-layout/tests/font_substitution_suite.rs — proprietary names resolve to bundled substitutes, substitutions are recorded for the warning banner, resolved families are actually layout-usable. Phase 3.2 — schemas (B-6 remainder, M2): - Vendored sets (each with a PROVENANCE.txt: source, version, license, per-file sha256): ISO/IEC 29500-4:2016 Transitional XSDs + mce/mc.xsd; ECMA-376 Part-2 OPC XSDs; OASIS ODF 1.3 OS RELAX NG (schema/manifest/ dsig) + W3C MathML3 RNG (via Maven org.odftoolkit:odfvalidator:0.12.0). - loki-odf/tests/schema_validation.rs: real ODT exports validate against the official ODF 1.3 schema — content.xml, styles.xml, meta.xml, and META-INF/manifest.xml all pass; a deliberately malformed part fails the gate (M2 acceptance). - loki-ooxml/tests/schema_validation.rs: real DOCX exports validate against ISO 29500 Transitional — word/document.xml + word/styles.xml pass, plus the OPC layer ([Content_Types].xml, _rels/.rels) against ECMA-376 Part 2; malformed part fails. Schema paths are canonicalized (libxml2 treats two spellings of one path as duplicate schema docs). - Documented tails: Strict XSDs and the Dublin Core imports of opc-coreProperties.xsd (no in-policy source reachable) — see schemas/README.md. Phases 3.1 + 3.2 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
…differ Phase 3.3 (B-5/D3): appthere_conformance::raster::PdfRasterizer — the one shared PDF->PNG stage both reference apps rasterize through, so golden and candidate differ only in the layout/render engine. Backend is poppler pdftoppm at the fixed CONFORMANCE_DPI (144) with anti-aliasing pinned explicitly; the binary's absence fails loudly and its version string is captured for golden/calibration metadata. Tests include byte-determinism of two runs over the same PDF. Phase 3.4 (B-4/§7.4): golden::diff — the perceptual differ. Windowed SSIM promoted from loki-acid; new CIEDE2000 dE (sRGB->Lab D65 + Sharma 2005 formulation, verified against the published reference pairs to 1e-4); 64px regional scoring where the WORST region drives the result (test proves a 16px defect fails a page whose page-wide mean would have passed); Tolerance carries the thresholds (calibrated record lands with M5 — no guessed default constant); failure heatmaps (dE-scaled red over dimmed golden). All pure Rust, GPU-free. Phases 3.3 + 3.4 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
New crate loki-render-cpu: deterministic in-process CPU rasterization of loki-layout output — the conformance candidate side. Renders the same renderer-agnostic PositionedItem stream the GPU path paints, via vello_cpu (pinned =0.0.9) with no graphics adapter: glyph runs (baseline origin + per-glyph offsets, .notdef skipped, hinting off, VF default instance — each arm mirrors its loki-vello twin), filled/ border rects, decorations incl. the spelling squiggle wave, clipped and rotated groups. Editor chrome (page shadow, cursor, selection) is deliberately not document content and not rendered. Image items paint loki-vello's grey placeholder (TODO(conformance-render): decode). M5 acceptance smoke tests: a real Document laid out by Parley renders headless with actual glyph ink at the conformance DPI, byte-identical across runs. Phase 3.5 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
… ceiling) diff.rs had crossed the ceiling in the previous commit; the sRGB->Lab and CIEDE2000 functions move to their own module, re-used by diff.rs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
…(Spec 02 M4+M5)
Golden generation (B-2/M4): committed first golden set —
goldens/odt/{para-carlito,styles-tinos,para-gelasio}/page-1.png,
generated by scripts/generate-odf-goldens.sh (LibreOffice 24.2
headless -> PDF -> the pinned rasterizer at CONFORMANCE_DPI), each
with a GENERATION.txt (reference app, rasterizer version, date,
operator). Fixtures are produced by a checked-in generator
(loki-odf --example gen_conformance_fixtures) referencing
metric-compatible font names directly (D4), and the same loki-fonts
TTF bytes are installed for fontconfig so both engines shape
identical faces.
Calibration (B-3/D5): goldens/CALIBRATION.md records the measured
cross-renderer noise floor (1500 regions over 3 fixtures) and derives
Tolerance::calibrated() = {min_ssim 0.60, max_delta_e 10.0} from the
agreeing fixtures — replacing the folklore 0.98 with data. The pass
independently FOUND a real fidelity gap: para-carlito's heatmap shows
cumulative advance drift per line = LibreOffice applies Carlito kern
pairs, loki-layout does not yet (known fidelity gap #23), quantified
at region SSIM 0.23 / dE 19.8.
Visual axis (M5 acceptance): loki-render-cpu/tests/visual_golden.rs
compares candidate renders against the committed goldens at the
calibrated tolerance — styles-tinos and para-gelasio PASS; the
para-carlito divergence is pinned as an expected-failure canary that
flips when kerning lands (gate stays advisory until then, per the
record). calibrate_odf example re-runs the measurement.
Phase 3.6 of docs/deferred-features-plan-2026-07-04.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
…pdated - build-and-test installs poppler-utils alongside libxml2-utils and documents which suites form which gate: schema (M2) and round-trip (M3) hard, visual (M5) advisory-by-construction via the pinned kerning-gap canary (goldens/CALIBRATION.md). - Plan Phase 3 rows and audit §4 Spec-02 row updated: B-1..B-6, B-10, B-11 are BUILT; B-8 (Fixture/Consumer traits), B-9 (corpus reorg), OOXML manual goldens, and Strict XSDs remain the tracked tail. Phase 3.7 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
…t (fidelity gap #23) The Spec 02 calibration pass flagged para-carlito diverging from its LibreOffice golden (region SSIM 0.23, dE 19.8) and initially blamed missing kerning. Root-causing inverted that: Parley 0.10's harfrust shaper already applies GPOS kern pairs (verified against Carlito's tables — the historical gap had been silently closed by the Parley 0.8->0.10 upgrade), and per-line ink measurement showed the GOLDEN consistently wider by exactly the kern amount. LibreOffice was not kerning the document at all: the fixture ODT carries no style:letter-kerning, and both LO-on-import and Word (w:kern threshold 0) default pair kerning OFF — while loki kerned unconditionally, flipping borderline line wraps into whole-line diffs. The real gap #23 was therefore the missing kerning toggle: - StyleSpan gains a `kerning: Option<bool>` field; resolve.rs forwards CharProps.kerning (already mapped by both readers: OOXML w:kern>0, ODF style:letter-kerning). Unless a run explicitly enables kerning, layout pushes a `"kern" 0` font-feature so the shaper matches the reference apps' default-off behaviour. - Regression locks in loki-layout/tests/kerning_applied.rs: kern-on advance matches the font's (A,V) pair to 0.05pt, default keeps natural advances, pairs stay contextual, ligatures unaffected. - Recalibration: para-carlito region floor improved SSIM 0.23->0.69, dE 19.8->7.5 — all three fixtures now in one noise band and GREEN at the calibrated tolerance; the expected-failure canary is promoted to a passing golden check. CALIBRATION.md records the re-measured distributions and keeps the full mis-attribution/root-cause trail as a worked example. para-carlito's fixture text also de-knife-edged (varied sentences instead of a repeated pangram). - To stay under para.rs's ratcheted ceiling, the self-contained list-marker synthesis block moved to loki-layout/src/list_marker.rs (para.rs 1979 -> 1856, baseline ratcheted down; existing para::format_counter / format_list_marker paths re-exported). - fidelity-status.md Kerning row -> resolved; audit/plan rows updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phase 4b.7a of the deferred-features plan (audit F6c): the editor previously ignored an active range selection — typing inserted at the focus and Backspace deleted a single grapheme, leaving the selected text in place. Model layer (loki-doc-model): - New loro_mutation::selection module with delete_selection_at(): removes the text between two (BlockPath, byte) endpoints in either order, collapsing any blocks the range spans by composing the existing merge_block_at + delete_text_at primitives. The surviving paragraph keeps the first block's style (Word behaviour). - The whole range is validated BEFORE the first mutation: endpoints in different containers (body/cell/note, or different sections) return the new MutationError::CrossContainerSelection, and a non-text block inside the range (e.g. a table) returns TextNotFound — nothing is half-applied in either case. - 10 integration tests (loro_selection_delete_tests.rs): single-block, three-block collapse, both endpoint orders, inside a table cell, and the no-mutation rejection cases. Editor wiring (loki-text): - New editor_keydown_text.rs hosting the Character and Backspace arms (moved from editor_keydown.rs, which was at 288/300 lines) plus the shared selection-removal helper; Delete falls back to the existing grapheme handler only when no selection is active. - Replace-typing batches the range delete and the insert into one relayout + commit, so it is a single undo entry. - A selection the model rejects swallows the key instead of falling through to a single-cursor edit. - 7 unit tests via the #[path] sibling-file idiom. Docs: audit F6 row + plan 4b.7 updated (clipboard and off-thread I/O remain open); fidelity-status reflow row no longer lists selection-delete as missing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phase 4b.3 of the deferred-features plan (TODO(undo-dirty)): the tab dirty indicator compared a forward-only generation counter against a baseline, so undoing every edit back to the saved state still showed the document as dirty. New crate::editing::saved_state module (loki-text): - SavedStateHandle tracks the undo-stack depth of the last save via the loro UndoManager on_push/on_pop hooks, giving classic clean-index semantics: save records the depth, undo/redo returning the stack to that depth means the document equals the file again, and a fresh edit made below the save point (which truncates the redo path back to it) marks the saved state permanently unreachable until the next save. - The fresh-edit vs redo-replay discriminator is loro's third on_push argument (Some(DiffEvent) for local edits, None for the pushes undo()/redo() perform internally) — locked in by 6 integration tests against a real UndoManager (saved_state_tests.rs), including the truncation case and saving with a live redo stack. Wiring: - The tracker is attached alongside every UndoManager creation (load path, post-save compaction swap) and rides with it through the tab-switch session stash (DocSession/PathSyncSignals). - Ctrl+S success calls record_new_checkpoint() and marks the clean depth before compaction; the dirty effect now reads dirty = untitled || (generation-differs && !undo_clean). To keep editor_inner.rs inside the file-ceiling ratchet, the Save As callback moved to editor_save_callbacks.rs (use_save_as_callback) — editor_inner.rs shrinks 870 -> 833 and the baseline is ratcheted. Remaining 4b.3 tail (noted in the plan): typed SaveError residual and disabling the ribbon Save button when the document is clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phases 4b.6 (F3c) and 4c.1 of the deferred-features plan: closing a dirty tab silently discarded its unsaved edits (and, in loki-text, its stashed session), and the recents "Delete file" menu action deleted the file immediately with no confirmation. appthere-ui: - New AtConfirmDialog primitive: a blocking confirmation overlay for destructive actions — absolute full-area backdrop (dim + click-blocks; backdrop click cancels) with a centred card, per the confirmed-working Blitz absolute-positioning pattern (position: fixed collapses in stylo_taffy). Both action buttons meet the 44 px WCAG 2.5.8 touch target; danger styling on the confirm button. Mounting contract (positioned ancestor) documented on the module. Shells (loki-text, loki-spreadsheet, loki-presentation): - on_tab_close now routes dirty tabs through the dialog; the actual close logic is extracted to a close_tab helper shared by the handler and the dialog's confirm. Shell roots gain position: relative to anchor the overlay. Homes (all three apps): - The recents Delete action only *requests* deletion (captures path + title); AtConfirmDialog performs the delete-on-confirm. The pre-existing behaviour (delete via capability token, surface errors, close matching tab, drop recents entry) is unchanged. - home.rs helper functions split into home_util.rs in each app to stay inside the 300-line ceiling ratchet. i18n: new shell-close-dirty-* and home-delete-confirm-* keys (en-US). Remaining 4b.6 tail (noted in the plan): the presentation editor still discards in-memory edits on tab *switch* — it needs a loki-text-style session stash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phase 4b.6 tail (audit F1 residual): switching away from a presentation tab silently discarded all in-memory edits — the editor reset its document signal on every path change. The spreadsheet had the identical hole (its sync_path_and_reset dropped the live CRDT). Both apps now mirror loki-text's session machinery: - New sessions.rs (per app): an app-level Signal<DocSessions> map, keyed by the serialised file token, holding the live editing state of inactive tabs. Presentation stashes doc + active slide index + dirty; spreadsheet stashes the Loro doc + undo manager + workbook snapshot + selection (its dirty flag already lives on the tab). - Stash/restore runs at three seams: synchronous path change (tab switch reuses the component), Editor -> Home unmount (use_drop), and remount (use_hook). Stashing is skipped when no tab points at the old path any more, so a closed or Save-As-repointed tab cannot resurrect stale state. - Both shells' close_tab now drops the closed tab's session, matching loki-text. - The presentation load resource now returns (path, result) and the populate effect guards on it — the stale-value clobber guard the other two apps already had, which matters more now that a restored session must not be overwritten by a lagging disk load. File-ceiling ratchet: spreadsheet editor_inner.rs shrank 1244 -> ~1110 (Loro cell/column/style mutations extracted to editor_mutate.rs; the path-sync handover lives in editor_path_sync.rs); presentation editor_inner.rs stays under 300 by extracting editor_path_sync.rs and the Save/Save As flows (editor_save_flows.rs). Note: like loki-text's session machinery, the stash/restore seams are Dioxus-component logic with no headless test harness in this repo — verified by compile + clippy + the existing suites; no new tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phase 4b.4 (TODO(nested-nav)): paginated arrow navigation was blind to nested positions. Three defects, one root cause — everything addressed paragraphs by flat block index alone: - find_para_data matched only block_index, so every paragraph of a table resolved to the FIRST cell's layout entry (wrong cursor rects for Up/Down/Home/End inside any other cell). It now matches (block_index, path). - The get_text closures took a flat index, so grapheme moves inside a cell read the root table block's (empty) text. navigate_left/right/ end now take Fn(&BlockPath) and the keydown handler passes a get_block_text_at-backed getter (reflow navigation keeps the flat getter - tables carry no reflow editing data). - At a nested paragraph's edges, Left/Right constructed a top-level position, teleporting the cursor out of the cell. They now cross to the sibling block within the same container (verified to exist in the page layout) and clamp at the container's first/last block; crossing out of a cell is a separate future feature. The inline test module moved to navigation_tests.rs via the #[path] idiom (navigation.rs ratchets 593 -> 367 in the ceiling baseline), and 6 new regression tests cover sibling crossing, edge clamping (no teleporting to adjacent top-level paragraphs), nested grapheme moves, and End inside a cell. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phase 4b.1 (TODO(3b-3) tail): two related gaps in cursor/page integrity. Cross-page navigation: - navigate_left/right previously clamped at the page edge (the prev/next block was only accepted if it sat on the same page). They now use the existing find_prev/next_para_data walks, which search the whole layout, so Left at the top of a page moves to the end of the previous page's last paragraph and Right at the bottom moves to the next page's first. Entering a table block from above/below lands in its first/last cell paragraph (the entry searches return the first/last matching entry and carry its path). Stale page_index: - A DocumentPosition's page_index names the page whose editing data holds the caret's paragraph, but any relayout could invalidate it: a split pushing the new paragraph to the next page, a merge pulling one back, typing near a page boundary, or moving within a paragraph that spans pages (fragments share one ParagraphLayout with shifted origins across pages). New editing/page_locate.rs re-derives the page from the layout - for page-spanning paragraphs it picks the page whose content band contains the byte's line-centre. - Wired at both seams: set_collapsed_cursor (editor_keydown_text.rs) now places the caret after every mutation (character, Backspace merge/grapheme, selection removal, Enter split - removing the two TODO(3b-3) comments), and the paginated navigation arm re-derives the page after every move. Tests: 4 page_locate tests (including a two-page split-fragment case proving byte 0 resolves to the first page and the last byte to the second) and 4 cross-page navigation tests. navigation.rs's lookup helpers moved to navigation_find.rs, taking navigation.rs out of the file-ceiling baseline (34 -> 33 entries). Remaining 3b-3 tail (noted in plan/audit): the double-Enter list-exit heuristic (clear_para_props). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phase 4b.2 (TODO(formatting)): the six character-formatting toggles (bold, italic, underline, strikethrough, superscript, subscript) previously clamped a selection spanning several paragraphs to the focus paragraph. - New editor_format_range.rs: resolve_format_ranges maps the cursor state to one (BlockPath, byte_start, byte_end) per paragraph — the selection's tail of the first paragraph, every middle paragraph in full, and the head of the last, for both top-level runs and blocks within one table cell / note body (the sibling ordering mirrors the model layer's delete_selection_at). Text-less blocks inside the range (a table between two paragraphs) contribute an empty range and are skipped, leaving their nested content untouched. Cross-container selections keep the previous clamp-to-focus rule; point cursors keep word expansion. resolve_format_range (singular, used by hyperlink insertion) is now defined as the first range. - editor_formatting.rs toggles loop over the ranges: the mark state at the document-order start of the selection decides apply-vs-clear and the whole selection is made uniform, matching Word/LibreOffice. 8 tests: range resolution (single/multi paragraph, reversed endpoints, offset-0 end skipped, in-cell spans, cross-container clamp, table-skipping) plus an end-to-end toggle_bold double-toggle across three paragraphs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Phase 4c.2 (TODO(a11y)): the status bar's interactive controls (notice chip, view-mode toggle, zoom badge) had touch targets the size of their small visual chips. Each control is now a transparent button at least TOUCH_MIN (44 px) wide filling the bar's full height, with the visual chip as a nested span (hover styling unchanged). The hit-area and chip styles are shared helpers. Honest constraint, documented on AtStatusBar: STATUS_BAR_HEIGHT is 24 px, so the vertical target meets WCAG 2.5.8 AA's 24 px minimum but not the suite's 44 px convention - that needs a taller bar on touch platforms, a design decision recorded as the 4c.2 tail in the plan rather than solved here with unverifiable overflow hit-testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Three items from the plan's 4c.5 batch (audit F6d/F7b/F7c): Zoom (F6d, 2 of 3 apps): - The status-bar zoom badge now cycles 50/75/100/125/150/200% (shared appthere_ui::next_zoom). - loki-text: real GPU zoom. DocPageSource carries a zoom factor; DocumentView scales the page tiles' CSS size and LokiPageSource scales the paint transform in step, so pages render sharp at every zoom; PageTile divides zoom back out of tile-local hit-test coordinates. The layout (in points) is untouched and reflow mode deliberately ignores zoom (its "zoom" is the layout width). - loki-presentation: the slide canvas scales its box, padding, and font sizes by the factor. - loki-spreadsheet: deferred with TODO(zoom) - grid zoom needs zoom-aware col_px plus the column-resize px<->pt math in one pass. Word count (F7c, loki-text): - editing/word_count.rs: allocation-free streaming counter over the document's display text; adjacent inline runs continue a word (styling inside a word doesn't split it), block boundaries and break inlines end one. Word-matching semantics: table cells and captions counted, footnote bodies and generated content excluded. Memoised per mutation into the existing plural editor-word-count key. 8 tests. Stable slide list state (F7b, loki-presentation): - Thumbnails key on the stable SlideId and bullet rows on shape id + paragraph (index keys made Dioxus reuse the wrong editable inputs on insert/delete). - Deleting the active slide now clamps active_idx explicitly instead of relying on render-time clamping. Ceiling ratchet: document_view.rs split (view_types.rs) resolves its baseline entry, and the reflow hit-test helpers moved to doc_page_source_reflow.rs so the zoom state didn't push doc_page_source.rs over; next_zoom lives in components/zoom.rs for the same reason. Baseline 34 -> 32 entries, nothing new baselined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
…tch, part 2) Three more 4c.5 items from the plan (audit F6a/F6d/F7a): F6a — hooks in conditionals/loops (appthere-ui): - AtRecentFileList wrapped its per-row hover use_signal inside the document `for` loop and the empty/full-list `if` arms, so the component's hook count varied with its props (a Dioxus rule-of-hooks violation). The row is now a RecentRow #[component] and the two Open-File buttons an OpenFileButton #[component], each owning its hook scope; the parent only holds the shared open-menu index. Rows key on the document path. - AtTemplateGallery had the same shape (a hover signal per card in the `for` loop plus one in the browse `if`). The cards are now TemplateCard / BrowseCard #[component]s. F6d ribbon (loki-spreadsheet): - The ribbon listed five tabs (Home/Insert/Format/Review/View) but only Home had content and both on_tab_select and on_toggle_collapse were no-ops. It now lists just the implemented Home tab and wires the active-tab + collapse signals for real (the loki-text convention: only tabs with content are listed). F7a (appthere-ui): - AtHomeTab derived its side-by-side-vs-stacked layout from a fixed `viewport_width = 375.0` signal, locking every surface to the phone layout. It now reads use_breakpoint() (Compact = stacked, Medium/Expanded = side-by-side). loki-text already provides the responsive context; the two smaller apps don't push a measured width yet, so use_breakpoint falls back to Expanded there (noted as the plan tail). Ceiling: the spreadsheet save flow moved to editor_save.rs so the ribbon-chrome signals didn't push editor_inner.rs over; baseline unchanged at 32. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
The whole point of the undo-stack clean checkpoint (4b.3) was to know when there is nothing to save; the Save button was still hardcoded is_disabled: false. - New reactive is_dirty signal in EditorState, set by the existing dirty-tracking effect alongside the tab indicator (a peek-guard avoids a needless ribbon re-render when the value is unchanged). - Threaded into write_tab_content; the Save button now uses is_disabled = !is_dirty(). Untitled documents read as dirty (Save routes to Save As), so their button stays enabled; a clean titled document disables it. Ceiling: condensed the dirty-tracking + Save-As comments so editor_inner.rs stays under its baseline and editor_ribbon.rs stays at exactly 300. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
Addresses the 15 confirmed findings from the high-effort review of the Phase 4 editor changes (10 reported + 5 cut-by-cap items). Correctness: - delete_selection_at: pre-validate endpoint byte offsets before any merge runs, so a stale offset rejects cleanly instead of leaving a half-applied mutation, and can no longer underflow the final delete. - Paginated zoom: thread the zoom factor into the window-relative drag/touch hit-testing and the onscroll page-index math, so drag-selection, long-press, and the status-bar page indicator are correct at zoom != 100% (previously only per-tile click was). - stash_outgoing (loki-text): guard the session stash on tab liveness (moved into the shared helper) so a discarded/close-repointed tab no longer resurrects its old state on reopen. - Recents delete (all three apps): drop the stashed DocSession when a file is deleted from Home, fixing a LoroDoc/layout leak and stale restore at the same token key. - Enter now replaces the active selection with a paragraph break (was splitting at the focus and keeping the selected text). - Nested navigation: Arrow Left/Right find cell/note siblings across a page break, and escape the container to the adjacent top-level block at its first/last block (Arrow keys could enter a table but not leave). - Hyperlinks apply across every paragraph of a multi-block selection, not just the first; the unused singular range helper is removed. - Undo/redo and mid-paragraph forward-Delete now recompute the cursor's page_index from the fresh layout. - has_selection compares the logical caret (ignoring page_index) so a page-spanning fragment no longer forms a phantom zero-width selection that swallows a Backspace/Delete. Cleanup/efficiency: - Word count: gate the full-document recount on a narrow generation memo (was re-walking on every cursor move), and count figure captions per the module contract. - Dirty tracking: peek before tabs.write() so a keystroke no longer re-renders the tab bar when nothing changed. - Add the missing line-2 copyright header to nine files. Splits (300-line ceiling): extract cursor tests, handle_enter_key, the canvas loading view, and the dirty-tracking effect into sibling modules; editor_canvas.rs and editor_inner.rs drop below their baselines (ratchet tightened). Tests added for stale-offset rejection, zoomed hit-testing, cross-page and cell-escape navigation, multi-paragraph hyperlinks, figure-caption counting, and the phantom-selection case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
The branch's first CI run surfaced two latent failures in the Phase 3.5 crate loki-render-cpu (added earlier, never linted until now): - Dependency-direction gate: loki-render-cpu was not assigned a layer. It is a deterministic CPU rasterizer of loki-layout (L3) output — a render-layer crate parallel to loki-vello — so register it at L4 in scripts/check-dependency-direction.py and ADR-0009's tables. - clippy -D clippy::expect_used: render_page used `.expect()` on RgbaImage::from_raw in library code. Replace it with a typed RenderCpuError::BufferMismatch via ok_or (CLAUDE.md: no expect() in library code). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This change adds comprehensive document format validation schemas (ODF, OOXML, MathML), conformance testing infrastructure with visual golden fixtures, new UI components for the home tab, and targeted layout/rendering improvements across the document editors.
Key Changes
Schemas & Conformance Infrastructure
appthere-conformance/schemas/golden/diff.rs(image diffing),golden/ciede.rs(CIEDE2000 color distance),golden/calibration.rs(visual calibration),raster.rs(PDF rasterization)loki-odf/tests/andloki-ooxml/tests/UI Components & Home Tab
confirm_dialog.rs,zoom.rs,recent_row.rsfor home tabrecent_files.rsandtemplate_gallery.rsnow use extracted row/dialog componentsDocument Model & Loro Bridge
loro_mutation/selection.rs(selection state),loro_schema/marks.rs(mark definitions),loro_bridge/containers.rs,loro_bridge/color_codec.rs,loro_bridge/compact.rs,loro_bridge/map_get.rsloro_bridge/props_read.rssimplified by extracting logicloro_selection_delete_tests.rsfor delete operation validationLayout & Rendering
list_marker.rsmodule for list marker layout (extracted from para.rs)loki-layout/src/font.rsview_types.rs,doc_page_source_reflow.rsfor document view managementText Editor Enhancements
editor_keydown_enter.rsandeditor_keydown_text.rsfrom monolithic handlercursor.rs,navigation_find.rs,page_locate.rs,saved_state.rs,word_count.rswith corresponding test suiteseditor_canvas_loading.rs,editor_compact.rs,editor_dirty.rs,editor_format_range.rsPresentation & Spreadsheet Apps
editor_path_sync.rsmodules for both appssessions.rsmodules for document session trackinghome_util.rsfor shared home screen logiceditor_save_flows.rsfor presentation appInfrastructure & Documentation
loki-render-cpu/) with calibration and visual golden test support.github/workflows/rust.yml) with additional test stepsdeferred-features-plan-2026-07-04.md,CALIBRATION.mdfor conformance testingpatches/loki-file-access/(no longer needed)Code Organization
https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7