Skip to content

Add conformance schemas, golden fixtures, and UI/layout improvements#30

Merged
kevincarlson merged 27 commits into
mainfrom
claude/deferred-features-audit-plan-0py7ur
Jul 6, 2026
Merged

Add conformance schemas, golden fixtures, and UI/layout improvements#30
kevincarlson merged 27 commits into
mainfrom
claude/deferred-features-audit-plan-0py7ur

Conversation

@kevincarlson

Copy link
Copy Markdown
Member

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

  • Added 50+ schema files for ODF 1.3, OOXML transitional (Word/Excel/PowerPoint), MathML 3.0, and OPC formats to appthere-conformance/schemas/
  • Added visual golden fixtures (3 ODF test documents + rasterized PNG references) for paragraph and style rendering validation
  • New conformance modules: golden/diff.rs (image diffing), golden/ciede.rs (CIEDE2000 color distance), golden/calibration.rs (visual calibration), raster.rs (PDF rasterization)
  • Schema validation tests for ODF and OOXML in loki-odf/tests/ and loki-ooxml/tests/

UI Components & Home Tab

  • New components: confirm_dialog.rs, zoom.rs, recent_row.rs for home tab
  • Refactored home tab: recent_files.rs and template_gallery.rs now use extracted row/dialog components
  • Updated home tab module to export new components and improve organization

Document Model & Loro Bridge

  • New modules: 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.rs
  • Refactored props reading: loro_bridge/props_read.rs simplified by extracting logic
  • Selection handling: New loro_selection_delete_tests.rs for delete operation validation
  • Bridge tests: Added container, compact, gap, and inline tail round-trip tests

Layout & Rendering

  • New list_marker.rs module for list marker layout (extracted from para.rs)
  • Font improvements: Added Gelasio font family (4 variants) with OFL license; updated font substitution in loki-layout/src/font.rs
  • Layout tests: New kerning and font substitution test suites
  • Rendering: New view_types.rs, doc_page_source_reflow.rs for document view management

Text Editor Enhancements

  • Refactored keydown handling: Extracted editor_keydown_enter.rs and editor_keydown_text.rs from monolithic handler
  • New editing modules: cursor.rs, navigation_find.rs, page_locate.rs, saved_state.rs, word_count.rs with corresponding test suites
  • Editor state management: New editor_canvas_loading.rs, editor_compact.rs, editor_dirty.rs, editor_format_range.rs

Presentation & Spreadsheet Apps

  • Refactored editor paths: New editor_path_sync.rs modules for both apps
  • Session management: New sessions.rs modules for document session tracking
  • Home route utilities: New home_util.rs for shared home screen logic
  • Save flows: New editor_save_flows.rs for presentation app

Infrastructure & Documentation

  • New CPU renderer crate (loki-render-cpu/) with calibration and visual golden test support
  • Updated CI workflow (.github/workflows/rust.yml) with additional test steps
  • Documentation: Added deferred-features-plan-2026-07-04.md, CALIBRATION.md for conformance testing
  • Removed patch: Deleted patches/loki-file-access/ (no longer needed)
  • Updated i18n: Added home tab strings for recent documents and templates

Code Organization

  • File ceiling compliance: Split large modules (para.rs, editor keydown handlers, props reading) to stay under 300-line limit

https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7

claude added 27 commits July 4, 2026 23:27
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
@kevincarlson kevincarlson merged commit dd51b7a into main Jul 6, 2026
2 checks passed
@kevincarlson kevincarlson self-assigned this Jul 6, 2026
@AppThere AppThere locked as resolved and limited conversation to collaborators Jul 6, 2026
@kevincarlson kevincarlson deleted the claude/deferred-features-audit-plan-0py7ur branch July 6, 2026 02:12
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants