Skip to content

Refactor large modules into focused submodules (300-line ceiling)#31

Merged
kevincarlson merged 109 commits into
mainfrom
claude/phase-4-deferred-features-32kmbn
Jul 11, 2026
Merged

Refactor large modules into focused submodules (300-line ceiling)#31
kevincarlson merged 109 commits into
mainfrom
claude/phase-4-deferred-features-32kmbn

Conversation

@kevincarlson

Copy link
Copy Markdown
Member

Summary

This PR splits several large modules across the codebase to comply with the 300-line file ceiling convention. The changes are primarily structural refactoring—extracting cohesive functionality into dedicated submodules without altering behavior or APIs.

Key Changes

Layout Engine (loki-layout)

  • flow.rs: Extracted table and column logic into dedicated submodules:

    • flow_balance.rs — column-height balancing for multi-column layouts
    • flow_table_cells.rs — per-cell block flowing and padding
    • flow_table_geom.rs — cell-height measurement and grid-column assignment
    • flow_table_paint.rs — row-height measurement and cell decoration emission
    • flow_list_marker.rs — list marker layout
    • flow_page_fields.rs — PAGE/NUMPAGES field detection and substitution
  • para.rs: Extracted paragraph-level utilities:

    • para_tabs.rs — tab-stop resolution and leader fills
    • para_query.rs — read-only geometry queries (hit-test, line-end offset)
    • para_props_map.rs — model property mapping
    • para_underlays.rs — selection-geometry underlays
    • para_emit.rs — glyph run emission (expanded with rotation support)
  • New modules: options.rs, result_rotation.rs, flow_widow_orphan.rs, incremental_notes.rs, revision_style.rs, paginate_blanks.rs, table_shading.rs

Document Model (loki-doc-model)

  • document.rs: Extracted document_paper.rs for paper-size utilities

  • style/: Added focused modules:

    • page_style.rs — named page style definitions
    • table_banding.rs — pure table-style conditional formatting resolver
    • resolve_table.rs — table-specific style resolution
  • loro_mutation/: Extracted mutation operations into dedicated modules:

    • page.rs — page-layout mutations (orientation)
    • page_style.rs — per-page-style geometry edits
    • table_ops.rs — structural table mutations (insert/delete rows/columns)
    • para_mark.rs — paragraph-mark tracked deletion
    • revision.rs — tracked-change CRDT mutations
    • align.rs — paragraph alignment mutations
    • text_containers.rs — text container mutations
    • block_edit.rs — block-level edits
  • content/: Added:

    • revision_ops.rs — accept/reject of tracked changes
    • toc.rs — table-of-contents handling
    • para_mark_merge.rs — paragraph-mark merge logic

ODF/ODT (loki-odf)

  • odt/reader/document.rs: Extracted frame and table parsing:

    • document_frame.rsdraw:frame and frame-kind parsing
    • document_table.rs — table parsing
  • odt/mapper/: Extracted style and document mapping:

    • styles_tests.rs — stylesheet mapper tests
    • document/sections.rs — section mapping
    • document/page.rs — page-style mapping (expanded)
  • odt/write/: Added page-style and table-style writers:

    • page_styles.rs — page-style serialization
    • revisions.rs — tracked-change serialization
    • table_style.rs — table-style serialization

OOXML/DOCX (loki-ooxml)

  • docx/mapper/: Extracted property and document mapping:
    • props_rpr.rs — run-property mapping
    • document_cols.rs — column mapping
    • `table

https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL

claude added 30 commits July 6, 2026 06:13
Implement two Phase 4 / 4a.4 (Spec 03) responsive sub-items from the
deferred-features plan:

Real Viewport.zoom — the status-bar zoom now drives the shared responsive
Viewport::zoom. `editor_responsive` gains pure `zoom_fraction` /
`desired_view_mode` helpers wired into the page-fit effect and the
context-publish effect, so zooming a page past the point a full column
fits the viewport flips the editor into the reflow renderer (and back on
zoom-out) instead of forcing horizontal scroll. Hit-testing uses a
separate local Viewport and is unaffected. 5 regression tests.

Metadata-panel label stacking (R-13g) — FieldRow becomes a #[component]
that reads use_viewport() per ADR-0013; below 250 px the field label
stacks above its input (flex column) so the input keeps a usable width on
narrow/split-screen viewports. Pure `stack_labels` helper + 3 tests.

Docs: plan 4a.4 rows and fidelity-status reflow row updated.
editor_inner.rs kept net-neutral against the file-ceiling ratchet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.5)

Insert → Table left the caret in the paragraph *before* the table; the
user had to click into a cell to start typing. It now lands in the first
cell automatically (Word behaviour).

- `insert_table_after_cursor` returns the first-cell caret via the new
  `first_cell_caret` helper (flat cell 0 / block 0 — top-left, since a
  `Table::grid` has no head/foot), instead of a bare block index.
- The Insert-tab `run_insert` gains an `InsertResult` enum carrying an
  optional caret target; after relayout it collapses the cursor there via
  `set_collapsed_cursor`, which re-derives the page from the fresh layout
  (plan 4b.1). Footnote insert leaves the caret at the anchor, matching
  Word.
- Extracted the async Insert → Image flow into
  `editor_ribbon_insert_image.rs` so `editor_ribbon_insert.rs` stays under
  the 300-line ceiling.

Tests: 3 new in editor_insert_tests.rs (first-cell caret shape, table
lands at block 1 with an empty first cell, no-cursor no-op). Full
loki-text suite green; fmt + clippy clean; file-ceiling gate passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Pressing Enter on an empty list item used to insert another empty bullet;
it now removes the list formatting and drops the paragraph back to body
text, matching Word / LibreOffice.

Model (loki-doc-model):
- `get_block_list_id` reads a block's direct `list_id` para-prop.
- `clear_block_list` removes `list_id` + `list_level` from a block's
  para_props (a no-op when absent). Both top-level, section 0.

Editor (loki-text):
- `is_empty_list_item_exit` — a pure predicate: plain caret (no
  selection) on an empty, top-level list item. `handle_enter_key` calls
  it, then clears the list props, relays out, and keeps the caret in the
  now-plain paragraph (page re-derived per 4b.1) instead of splitting.
  Nested list items (table cell / note) keep the split — the block API is
  top-level only.

Tests: 4 model tests (read/clear + round-trip + no-op) and 5 predicate
tests (empty/non-empty/plain/selection/nested). Both crates' suites green
(441 tests); fmt + clippy clean; file-ceiling gate passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.2)

Adds the contextual-tab half of Spec 04 M5 / plan 4a.2: a ribbon tab that
appears only while the relevant object is selected.

- `editing/selected_object.rs`: pure `selected_object(&CursorState) ->
  SelectedObject` — `Table` when the caret's path descends through a table
  cell, else `None`. Unit-tested (5) without a Dioxus runtime.
- `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 the ribbon never
  shows an orphaned contextual selection. Pure `ribbon_tabs` tested (3).
- Table tab action "Delete Table" removes the whole table block via a new
  `delete_block` model mutation, disabled when the table is the document's
  only block; the caret re-homes to the neighbouring block. `delete_block`
  tested (3, incl. cross-section + out-of-range).
- New `LUCIDE_TRASH_2` icon + `ribbon-tab-table` / `ribbon-group-table` /
  `ribbon-table-delete-aria` strings.

Ceiling: `delete_block`/`insert_block_after` moved into a new
`loro_mutation/block_edit.rs` (block.rs was over 300); `editor_inner.rs`
kept net-neutral by routing the wiring through `use_ribbon_tabs` and
collapsing two single-expression ribbon closures.

Both crates' suites green (481 tests); fmt + clippy clean; ceiling passes.
Remaining 4a.2 tail: the Layout/References/Review non-contextual tabs
(need page-setup / TOC / track-changes backing that doesn't exist yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds insert/delete row and column, wired into the Table contextual tab's
new "Rows & Columns" group.

Model (loki-doc-model, `loro_mutation/table_ops.rs`):
- `insert_table_row` / `delete_table_row` / `insert_table_column` /
  `delete_table_column`, plus `table_grid_dims`.
- Each rewrites the serde skeleton *and* minimally patches the flat
  `KEY_TABLE_CELLS` movable list — surviving cells keep their live CRDT
  text (the list is patched, never rebuilt), and skeleton/cell order stay
  in sync.
- Scoped to simple grids (one body, no head/foot, no row/col spans,
  uniform width); any other shape returns the new typed
  `MutationError::UnsupportedTableStructure`. Deletes refuse the last
  remaining row/column.
- 12 round-trip tests (`tests/table_structural_ops.rs`) covering shape,
  text preservation, bounds, merged-cell rejection, and composed edits.

Editor (loki-text):
- `editor_ribbon_table_ops.rs`: `run_table_op` derives the target
  row/column from the caret's cell, applies the mutation, and re-homes the
  caret to its shifted cell via the pure, tested `caret_flat_after`
  (a structural edit changes the flat cell indexing the cursor stores).
- Table tab gains four buttons; row/column deletes disable at 1 row/col,
  and all four disable on a non-simple-grid table (only Delete Table
  remains). 4 app-custom table-op glyph icons + i18n strings.

Both crates' suites green; fmt + clippy clean; file-ceiling gate passes
(`delete_table_*` etc. live in their own module).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The Write tab gains an Alignment group (left / centre / right / justify).
Alignment was a gap: the model helpers existed but were unused and
top-level only.

Model (loki-doc-model, new `loro_mutation/align.rs`):
- Path-aware `set_block_alignment_at` / `get_block_alignment_at` (plus the
  moved top-level `set_block_alignment` / `get_block_alignment`), so
  alignment works inside table cells and note bodies.
- Type-aware: a plain `para` is upgraded to `styled_para` (a bare `para`
  drops props on read), `styled_para` uses `para_props`, and a `heading`
  uses its OOXML `jc` attribute. 5 tests in `block_alignment.rs` cover
  top-level, cell (path-aware), heading, round-trips, and the invalid-path
  error.

Editor (loki-text):
- `editor_alignment.rs`: `current_alignment` / `apply_alignment` resolve
  the caret's paragraph(s) via `resolve_format_ranges` (same per-paragraph
  mapping the inline toggles use), so a multi-paragraph selection aligns
  uniformly.
- The six inline-format buttons and the four alignment buttons live in the
  new `editor_ribbon_format.rs`; `write_tab_content` calls the two group
  builders, dropping `editor_ribbon.rs` from the 300-line ceiling to 225.

Both crates' suites green (473); fmt + clippy clean; ceiling passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The Write tab gains a Font group that grows/shrinks the selection's font
size by one step through a fixed ladder of common point sizes.

- `editor_font_size.rs`: font size is a character mark
  (`MARK_FONT_SIZE_PT`), so grow/shrink apply through the same path-aware
  `mark_text_at` + `resolve_format_ranges` path the inline toggles use —
  it works in table cells and across a multi-paragraph selection. The
  current size is read from the selection's direct size mark, stepping
  from an 11pt default when a range has no explicit size.
- Pure ladder stepping (`grow`/`shrink`) plus end-to-end mark tests (5):
  ladder edges, off-ladder snapping, apply-over-selection-only, and
  grow-then-shrink round trip.
- `font_group` builder in `editor_ribbon_format.rs`; 2 app-custom "A±"
  glyph icons + i18n strings.

Both crates' suites green (204); fmt + clippy clean; ceiling passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The Write tab gains a Font-colour group: an Automatic (clear) swatch plus
six preset colours.

- `editor_text_color.rs`: colour is a character mark (`MARK_COLOR`); for an
  RGB colour the stored value is just its `#RRGGBB` hex — the same form the
  bridge's colour codec writes — so a preset swatch needs no extra
  encoding. Apply/clear go through the path-aware `mark_text_at` +
  `resolve_format_ranges` path, so colour works in table cells and across a
  multi-paragraph selection; Automatic writes `Null` to drop the direct
  mark and revert to the style colour.
- `editor_ribbon_color.rs`: `font_color_group` renders coloured-square
  swatches inside `AtRibbonIconButton` (which takes arbitrary children);
  the active swatch reflects the caret's direct colour. `RibbonEditCtx::
  finish` is now `pub(super)` so the colour module shares the relayout/sync
  path.
- 3 tests: apply-over-selection-only, Automatic-clears, and round-trip into
  `CharProps.color`.

Verified the renderer paints `CharProps.color` (resolve → StyleProperty::
Brush), so the colour is visible, not just stored. Both crates' suites
green (207); fmt + clippy clean; ceiling passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds the first non-contextual Layout tab (Write / Insert / Layout /
Publish; the contextual Table tab moves to index 4) with a page
orientation toggle.

Model (loki-doc-model, new `loro_mutation/page.rs`):
- `set_document_orientation(loro, landscape)` and `document_is_landscape`.
  The layout engine reads a section's effective `page_size` directly and
  treats orientation as a flag, so the toggle swaps width↔height on every
  section's page-size map (when it doesn't already match) and writes the
  orientation flag — idempotent. `apply_mutation_and_relayout` re-derives
  the geometry and updates `page_width_px`, so the document re-flows at the
  new size immediately.
- 4 round-trip tests (`page_orientation.rs`): fresh doc is portrait,
  to-landscape swaps dims + sets the flag, toggle-back restores, and
  same-orientation is idempotent.

Editor (loki-text):
- `editor_ribbon_layout.rs`: `layout_tab_content` with Portrait/Landscape
  buttons (active state from `document_is_landscape`).
- `ribbon_tabs` gains the Layout entry; `CONTEXTUAL_TAB_INDEX` 3 → 4;
  `editor_inner`'s content match adds the Layout arm and shifts Publish to
  index 3 (kept net-neutral against the ceiling). Ribbon-tab tests updated.
- 2 app-custom page-rect glyph icons + i18n strings.

All three crates' suites green (514); fmt + clippy clean; ceiling passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds a Margins group to the Layout tab, alongside orientation.

Model (loki-doc-model, `loro_mutation/page.rs`):
- `set_document_margins(loro, top, bottom, left, right)` sets those four
  edges on every section's margins map (creating it when absent) and
  leaves header/footer/gutter distances untouched; `document_margins`
  reads section 0's margins for the active-preset check. Same relayout
  pipeline as orientation.
- 3 round-trip tests (`page_margins.rs`): all-edges update, per-edge
  differences, header distance preserved.

Editor (loki-text, `editor_ribbon_layout.rs`):
- Margins group with Normal (72pt) / Narrow (36pt) / Wide (72/72/144/144)
  presets; the active preset highlights via the pure `margin_matches`
  (½-pt tolerance for import rounding). The orientation and margin buttons
  now share a single `apply_and_sync` helper (mutation → relayout → sync).
- `layout_tab_content` keeps its 6-arg signature so the ceiling-bound
  `editor_inner` call site is unchanged. 5 `margin_matches`/preset tests.
- 3 app-custom page-inset glyph icons (tooltip-disambiguated) + i18n.

All three crates' suites green (522); fmt + clippy clean; ceiling passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Adds a Size group to the Layout tab, alongside orientation and margins.

Model (loki-doc-model, `loro_mutation/page.rs`):
- `set_document_page_size(loro, portrait_w, portrait_h)` sets every
  section's page size, **preserving each section's orientation** — a
  landscape section keeps the long edge as its width, so choosing "A4"
  while landscape gives A4 landscape, not portrait. `document_page_size`
  reads section 0's size for the active-preset check. Same relayout
  pipeline as orientation/margins.
- 3 round-trip tests (`page_size.rs`): portrait A4, orientation
  preservation, and Letter→A4 switch.

Editor (loki-text, `editor_ribbon_layout.rs`):
- Size group with A4 (595.28×841.89pt) / US Letter (612×792pt) presets;
  the active size highlights via the pure, orientation-independent
  `page_size_matches` (compares short/long edges within a point). Reuses
  the shared `apply_and_sync` helper. 4 preset-match tests.
- 2 app-custom page-rect glyph icons (aspect-distinguished, tooltip-backed)
  + i18n.

All three crates' suites green (529); fmt + clippy clean; ceiling passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Completes the Layout tab's page-geometry controls with a Columns group.

Model (loki-doc-model, `loro_mutation/page.rs`):
- `set_document_columns(loro, count)` sets every section's column count
  (clamped ≥1). A newly-created columns map gets a default 0.5in gap and no
  separator; an existing one keeps its gap/separator so only the count
  changes. `document_column_count` reads section 0's count (1 when absent).
  Same relayout pipeline — in paginated mode the flow engine divides the
  content into that many columns.
- 5 round-trip tests (`page_columns.rs`): fresh doc is single-column,
  two-columns creates a gap, changing count preserves the gap, back-to-one,
  and zero clamps to one.

Editor (loki-text, `editor_ribbon_layout.rs`):
- Columns group with One/Two/Three presets; active state is the exact
  count. Reuses the shared `apply_and_sync` helper. 3 app-custom page +
  divider-line glyph icons + i18n.

The Layout tab now offers Orientation + Margins + Size + Columns. All three
crates' suites green (534); fmt + clippy clean; ceiling passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Write tab: a new Highlight group (None + Yellow/Green/Cyan/Magenta/Red)
writes the MARK_HIGHLIGHT_COLOR character mark across the selection,
path-aware so it works inside table cells, round-tripping into
CharProps.highlight_color. The duplicated font-colour / highlight swatch
UI is unified into a generic swatch_group(palette, apply_fn).

Table tab: the insert ops are split into four caret-relative variants
(InsertRowAbove/Below, InsertColumnLeft/Right). Above/left insert at the
caret's own row/col index, below/right at index+1; caret_flat_after
re-homes the caret (above shifts it down a row, left shifts it one
column right). Two app-custom glyphs for the above/left buttons.

Tests: 3 highlight apply/read/round-trip + 2 new caret-math cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Start the progressive collapse cascade with its pure, testable core in
the shared responsive layer: resolve_cascade(metrics, available_px,
prev_level) -> RibbonCascade. Each group declares GroupMetrics
{ priority, full_px, condensed_px }; the engine degrades gracefully by
condensing all groups (lowest priority first) before overflowing any
into the "More" menu, and falls back to horizontal scroll only when even
the fully-overflowed strip cannot fit (§7 steps 1-4).

The decision is hysteretic, mirroring 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, so dragging a
window across a fit threshold does not thrash; resolution is idempotent
at a fixed width (the resolved level feeds back in as prev_level).

New tokens RIBBON_OVERFLOW_BUTTON_PX and RIBBON_COLLAPSE_HYSTERESIS_PX;
10 unit tests covering full-fit, priority-ordered condense/overflow, the
scroll floor, the hysteresis dead-band, idempotence, tie-breaking, and
the empty ribbon.

Remaining for M3: per-group width measurement wiring into AtRibbon, the
condensed + overflow-menu UI representations, and R-13e select-width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Two framework increments on top of the pure cascade engine (Spec 04 M3):

1. use_ribbon_cascade(metrics) -> RibbonCascade binds resolve_cascade to
   the live AtResponsiveContext viewport width, holding the resolved
   collapse level in a hook-local signal so hysteresis carries across
   resizes. It is resilient like use_breakpoint: with no responsive
   context the width is treated as unbounded, so every group stays Full
   and Presentation/Spreadsheet get a sane full-chrome ribbon.

2. The condensed group representation (§7 step 2) is a pure, tested
   decision — group_layout(collapse, has_label) -> GroupLayout — that
   AtRibbonGroup applies through a new defaulted `collapse` prop: Full
   keeps the label and 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 prop defaults to
   Full, so every existing call site is unchanged.

+3 group_layout tests (13 total in ribbon_collapse_tests).

Remaining for M3: per-group width measurement into GroupMetrics + the
AtRibbon API change threading a structured group list through the hook,
the overflow ("More") menu UI, and R-13e select-width in the condensed
state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The framework piece that ties the cascade to real tab content:
AtRibbonGroups (components/ribbon/groups.rs). A tab hands it a
Vec<RibbonGroupSpec { metrics, label, aria_label, content }>; it runs
use_ribbon_cascade once for the whole strip, renders each group at its
resolved Full/Condensed state, and moves overflowed groups into a
trailing "More" menu — an upward position: absolute dropdown (confirmed
working in Blitz) that renders the overflowed groups in Full form.

Group widths are declared, not Blitz-measured (per-element measurement
is unreliable): estimate_group_metrics(priority, buttons, has_label)
derives the full/condensed widths from the touch-button count.

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 and
ribbon-overflow-aria string. +2 estimator tests (15 in ribbon_collapse).

Remaining for M3: migrate Write/Insert/Publish/Table to RibbonGroupSpec;
outside-click dismiss for the More menu (needs a window-level backdrop
host — position: fixed collapses to absolute in stylo_taffy); R-13e
select-width in the condensed state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Both tabs now build their groups as RibbonGroupSpec lists wrapped in
AtRibbonGroups, so the width-driven cascade + overflow "More" menu drive
them live. The group-helper fns in editor_ribbon_format / editor_ribbon_color
return RibbonGroupSpec (with a threaded priority) instead of a wrapped
AtRibbonGroup. Priorities keep the core editing controls (Inline,
Alignment, Font, Styles) full the longest; the wide colour-swatch groups
overflow first (they also reclaim the most strip width per overflow).

R-13e (select-width in the condensed state): AtRibbonGroup now exposes
its resolved GroupCollapse to descendants as a *signal* context, and
AtRibbonSelect reads it to shrink from RIBBON_SELECT_WIDTH_PX to
RIBBON_SELECT_WIDTH_CONDENSED_PX when its group is condensed. A signal
(not a plain context value) so prop-memoised selects still re-size
reactively when the cascade changes on resize.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…bustness

Completes the tab migrations for Spec 04 M3: every Loki Text ribbon tab
now builds RibbonGroupSpec lists through AtRibbonGroups, so the
width-driven cascade + overflow "More" menu drive them all.

- Publish: ribbon content extracted to editor_ribbon_publish.rs, which
  drops the baselined editor_publish.rs 315 → 236 (off the ceiling
  backlog); run_export is now pub(super).
- Table: delete_current_table extracted to editor_ribbon_table_delete.rs
  to hold the ceiling after the spec conversion.
- The overflow menu auto-closes when a resize removes the overflow, so a
  widened window can't strand an open menu whose More button is gone.

editor_inner.rs held at its baseline (a comment tightened to offset the
publish-import split).

Remaining M3 tail: true outside-click-to-dismiss for the More menu needs
the menu hosted in a shared window-level overlay so a full-viewport
backdrop can span the viewport — position: fixed collapses to absolute
in stylo_taffy, so an in-place backdrop can't. Toggle-close and
auto-close-on-widen ship today; TODO(ribbon) marks the follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
First of the per-family non-paragraph `Default` sources from ADR-0012
Decision 1. Until now a standalone character style's inspector was blind
to the document's docDefaults run defaults — a property unset along the
style's own chain resolved to FormatDefault, hiding that Word/ODF supply
it from docDefaults.

Model (loki-doc-model):
- New StyleCatalog::default_character_style (serde-default, so it
  round-trips through the Loro-bridge catalog JSON and stays
  back-compatible).
- resolve_char_chain falls through to it (first_in_char_chain,
  cycle/depth-guarded), resolving as Provenance::Default — the
  character-family analogue of the existing paragraph default path.

Importer (loki-ooxml): synthesise a `__DocDefaultChar` character style
from w:rPrDefault and point default_character_style at it.

Leak fixes: the character browser hides `__`-prefixed synthetic styles,
and both the DOCX and ODT writers skip them (docDefaults are written as
w:docDefaults / style:default-style, never as named styles) — this also
closes a latent `__DocDefault` paragraph-style export leak.

Tests: 4 model (Default fall-through, local-wins) + 3 mapper (synthesis,
absence, end-to-end Default provenance). OOXML/ODF/round-trip suites green.

Remaining 4a.3: table/list Default sources, character-style editing form,
the Page style family, Table conditional/banding, and the Compact-tree
breadcrumb (M7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The character family was inspect-only; this adds an editable form, the
character analogue of the existing paragraph style editor.

- Selecting a character style seeds an editable StyleDraft
  (char_style_to_draft); its character fields are a superset of a
  character style's properties, so the shared paragraph-form inputs
  (field_row / iu_buttons / font_picker / weight_selector, all binding a
  Signal<Option<StyleDraft>>) are reused verbatim in a new char_form.rs
  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 write_document_styles, so it
  is durable and undoable) and relays out.
- Re-parenting is cycle-guarded by new model helpers char_ancestors /
  char_reparent_cycles (the character analogue of the paragraph guard).
- The editable form renders alongside the read-only provenance inspector
  — the Spec 05 §6 inspector+edit pairing (inspector shows where
  inherited values come from, the form edits the locals).

1 model test (character_reparent_cycle_is_detected); doc-model + loki-text
suites green. editor_inner held at its 803 baseline (offset a threaded
arg with a comment tighten).

Remaining 4a.3: table/list Default sources, the Page style family, Table
conditional/banding, and the Compact-tree breadcrumb (M7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
At Compact the paragraph inheritance tree's full indented list is
impractical, so it degrades to a breadcrumb + drill-down (Spec 05
§7/§11):

- Breadcrumb: the root→selected path (new model para_breadcrumb =
  para_ancestors reversed, cycle-guarded), each hop clickable to jump up.
- Drill-down: the selected style's direct substyles (para_children),
  clickable to descend.

body::left_column renders this via a new tree_nav.rs when posture.stack
(Compact); Expanded/Medium keep the indented tree. Navigation loads the
target style's draft exactly as the indented tree does.

1 model test (breadcrumb_is_root_first_including_self); doc-model +
loki-text suites green.

Remaining 4a.3: table/list Default sources, the Page style family, and
Table conditional/banding editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.3)

The table family had single-parent inheritance in the model but no
provenance resolver — only paragraph and character families did. This
adds the table analogue, the last of the per-family non-paragraph
Default sources (ADR-0012 Decision 1):

- resolve_table_chain: Local / Inherited / Default / FormatDefault over
  the table parent chain, with the Default level supplied by a new
  StyleCatalog::default_table_style (serde-default, round-trips through
  the Loro-bridge catalog JSON). Plus table_ancestors /
  table_reparent_cycles for a future table-style editor.
- Lives in a new resolve_table.rs so resolve.rs stays at 299 (under the
  300 ceiling); the shared Resolved constructors are now pub(crate) for
  the split.
- The OOXML importer records default_table_style from the table style
  flagged w:default="1" (e.g. TableNormal).

Lists are a non-inheriting family per ADR-0012 Decision 2, so a list
Default source doesn't apply (Local/FormatDefault only). Table-style
export of the default flag is deferred with the wider table-style
writer, which isn't built yet.

3 model tests + 2 mapper tests; doc-model + ooxml suites green.

Remaining 4a.3: ODF table default-style import symmetry, the Page style
family, and Table conditional/banding editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…a.3)

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. Page
  styling adopts the ODF model as the unified representation.
- New StyleCatalog::page_styles (IndexMap<StyleId, PageStyle>,
  serde-default so it round-trips through the Loro-bridge catalog JSON
  and stays back-compatible).
- The format-neutral import-mapping core, pure and 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), and section_page_style_ids gives
  the per-section id list — the inverse the DOCX sectPr export needs.

No resolver is needed: a non-inheriting family is a chain of length one,
so the inspector shows only Local / FormatDefault (per ADR-0012).

7 model tests; full doc-model suite green; loki-ooxml / loki-odf /
loki-text / loki-layout all still build against the new catalog field.

Remaining page-family work: wire derive_page_styles into the OOXML/ODF
importers, the DOCX section-export inverse, ODT native export
(style:page-layout + style:master-page), and the flat page panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The page family is now visible in the style panel, mirroring the
read-only list family: a "Page styles" list in the left column
(page_browser.rs) plus a geometry inspector column (family_inspector)
showing size / orientation / margins / columns.

Drift-free by design: the panel DERIVES page styles on demand from the
live document's sections (panel_data::page_data -> derive_page_styles)
rather than 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 showing stale geometry, the root-cause
choice over a stored-but-drifting copy.

The inspector rows are pure and tested (style_page_inspector, value-baked
like the list inspector: named sizes, uniform-margin collapse). New
editing_page_style selection signal wired through the panel; editor_inner
held at its 803 baseline via comment tightening; 5 i18n keys.

4 UI tests + the existing model tests; doc-model + loki-text suites green.

Remaining page-family work: editing page geometry through the panel
(write-back), the DOCX/ODT export naming, and importer population of the
stored page_styles field (awaits the section->page-style reference the
editing path needs; the panel derives on demand until then).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The ODF half of the character family's Default source, symmetric to the
OOXML w:rPrDefault work. The ODT stylesheet mapper now synthesises a
`__DefaultChar` character style from `style:default-style
style:family="text"` and points default_character_style at it — so a
standalone character style resolves the ODF text defaults as
Provenance::Default instead of FormatDefault, exactly as on the DOCX
side. The ODT writer already skips `__`-prefixed synthetic styles, so it
does not leak.

The ODF table default is not wired: OdfDefaultStyle carries no table
properties and the ODT mapper does not import table styles at all yet —
noted for the wider table-style import, not silently skipped.

1 mapper test incl. an end-to-end Provenance::Default resolution. The
mapper's inline test module was extracted to styles_tests.rs (the
#[path] idiom) to hold the 300-line ceiling (358 -> 148 production).

Full loki-odf suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The write-back primitive for LibreOffice-style per-page-style editing:
set_page_style_geometry(loro, section_indices, &PageLayout) applies a
layout's size / orientation / margins / columns to ONLY the given
sections — the ones belonging to a single page style — leaving the other
page styles, and each section's headers/footers/gutter/page-numbering,
untouched. The per-style analogue of the document-wide set_document_*
setters.

Design: index-targeting rather than a stored Section.page_style
reference + renderer refactor. Page styles are already derived by
layout-equality (section_page_style_ids), and an edit keeps a style's
sections in sync, so targeting by section index is stable — without
touching the fragile CRDT section bridge or the layout engine.

3 integration tests (page_style_geometry.rs): edits only its own
sections, applies margins + columns, skips out-of-range indices.

Next: the panel edit form that computes the target indices + new layout
and calls this mutation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The page panel is now editable per-page-style, the 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.

- apply_preset(&PageLayout, PagePreset) -> PageLayout is a pure, tested
  transform (orientation/size preserve the other axis; margins keep
  header/footer/gutter; columns keep the gap).
- Each button derives the target section indices on demand
  (panel_data::page_edit_target -> section_page_style_ids) and writes the
  edited layout through set_page_style_geometry, then relays out. So
  editing PageStyle1 changes all its pages and leaves PageStyle2 alone.
- Buttons highlight the page style's current geometry (is_active).

4 apply_preset tests; full loki-text suite green; every touched file
under the 300-line ceiling.

Remaining: user-facing page-style naming/rename (auto-named PageStyleN;
real ODF master-page names need a stored section->page-style reference —
the deferred model refinement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The model refinement toward true LibreOffice-style named page styles:
Section now names its page style instead of only carrying inline
geometry.

- Section.page_style: Option<StyleId> — the named reference, persisted
  through the Loro bridge under KEY_PAGE_STYLE_REF (round-trip tested).
- 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 (a user rename or
  an ODF style:master-page) is preserved across re-runs.
- Wired at load_document, so every opened document gets first-class,
  stored, renamable page styles.

The Section field rippled to ~15 test literals plus the DOCX-mapper and
reflow synthetic-section literals; the two baselined files (docx
document.rs, flow.rs) were offset back to their baselines with genuine
comment tightening, and assign_page_styles lives in style/page_style.rs
so document.rs stays at its baseline.

4 model tests (page_style_model.rs); full doc-model / ooxml / odf /
layout suites green; loki-text builds; ceiling gate OK.

The panel + edit mutation still derive on demand (consistent names), so
these stored refs are the foundation the rename UI and the
panel-reads-stored migration build on next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Migrate the style panel's page family off derive-by-layout-equality onto
the stored `section.page_style` references, and make page styles renamable
(LibreOffice-style named page styles, Spec 05 M6 / ADR-0012 Decision 2).

- `panel_data::stored_page_styles` groups sections by their stored ref in
  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. `page_data`
  and `page_edit_target` now read the stored refs.
- New `rename_page_style(loro, old, new)` mutation renames the catalog key and
  every referencing section's stored ref atomically, keeps `PageStyle.id` in
  sync, and no-ops on name conflict / missing source.
- New `PageRenameField` component (owns its draft signal per ADR-0013, keyed by
  name to reseed on reselection); the page form's Rename button commits through
  the mutation, relays out, and re-selects under the new name.
- 2 rename integration tests + 2 i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Complete the ODF-native round-trip for named page styles (ADR-0012
Decision 2), the page-family tail.

Export: 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 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. `content.xml` and `styles.xml` both read
from the one resolver so their names always agree.

Import: the ODT mapper 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 re-export.

OOXML needs nothing further (DOCX sections already export as `w:sectPr`
per page style, and `assign_page_styles` names them on import).

5 naming unit tests (`page_styles_tests.rs`) + 1 export->import round-trip
(`named_page_styles_round_trip_as_master_pages`). Docs + fidelity registry
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
claude added 29 commits July 9, 2026 02:21
… 7.1)

styles.rs cut #4. The paragraph-property parsers (parse_para_props_element,
parse_para_props_with_children, parse_tab_stops, ~129 lines) move into a
new styles_para.rs as the para_props #[path] submodule.

The two entry points called by parse_style_props are pub(super);
parse_tab_stops stays private (called only by
parse_para_props_with_children). The module calls super::skip_element and
carries #![allow(dropping_references)]. Two style types (OdfDropCap,
OdfTabStop) moved out of styles.rs's imports.

styles.rs 892 -> 764 (baseline ratcheted); new file excluded (<=300).
loki-odf lib suite green (156), clippy clean, fmt clean.

Running total: twenty-four cuts, -4222 lines across fifteen new
production submodules + seven test-file extractions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Refresh the plan's 7.1 running total for the format-crate reader/writer
cluster cuts (docx write/reader table + odt frame + odt para-props).
Full-workspace clippy re-verified clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Extract flow_table's pass 3a (the per-cell content-flowing loop: padding,
rotation -> RotatedGroup, vertical alignment, single-page clipping) into a
new flow_table_cells.rs as the table_cells submodule (flow_row_cells).

flow_table now calls table_cells::flow_row_cells once per row and feeds
the returned cell_starts (page + item index per cell) to the pass-3b
decoration emitter. row_y_start / row_page were pass-3a-local and became
fn locals; cell_flat threads as &mut usize; cell_cols[row_idx] is passed
as the row slice. Verbatim move otherwise.

This is the concrete unblock for deferred-feature 4b.5
(rotated-cell-editing): its TODO and the RotatedGroup branch now live in
flow_table_cells.rs (200 lines, ~100 of headroom), so the editing-data
work can be added there without growing the still-baselined flow.rs.

flow.rs 1362 -> 1209 (baseline ratcheted); new file excluded (<=300). All
188 lib + 11 integration binaries green (table rotation/spanning/vMerge/
vertical-align/clip are the regression guard), clippy -D warnings clean,
fmt clean. Plan 4b.5 row + CLAUDE.md updated.

Running total: twenty-five cuts, -4373 lines across sixteen new
production submodules + seven test-file extractions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…Phase 7.1)

Extract ParagraphLayout's read-only geometry query methods
(hit_test_point, line_end_offset, cursor_rect, selection_rects, and the
private line_indent helper) out of para.rs into a new para_query.rs as an
`impl super::ParagraphLayout` block. As a child module of para it reaches
ParagraphLayout's private fields (parley_layout, line_boundaries,
orig_to_clean, clean_to_orig) directly; the methods stay pub so all
callers are unaffected. Cursor/Selection dropped from para.rs's parley
import (only the moved impl used them).

This is the concrete unblock for deferred-feature 6.3 (the Option-B
y-range item filter that reads line_boundaries to skip painting
off-viewport content): para_query.rs is its natural home, at 198 lines
with ~100 of headroom under the ceiling, so the filter can be added there
without growing the still-baselined para.rs.

para.rs 1626 -> 1447 (baseline ratcheted); new file excluded (<=300). All
188 lib + 11 integration binaries green (the hit-test / cursor /
selection-rect tests exercise the moved methods), clippy -D warnings
clean, fmt clean. Plan 6.3 row updated.

Running total: twenty-six cuts, -4552 lines across seventeen new
production submodules + seven test-file extractions; deferred-features
4b.5 and 6.3 both unblocked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Rotated table cells were read-only: `flow_cell_blocks` flowed their
content into a throwaway state and discarded the editing paragraphs, so a
click never resolved to the cell. Implement the core of feature 4b.5 on
the sub-ceiling `flow_table_cells.rs` module the Phase-7.1 split created.

Model (loki-layout):
- New `CellRotation` (result_rotation.rs) mirrors the paint-time
  `RotatedGroup` affine: `page = pivot_page + Rot(deg)·(local −
  pivot_local)`, with `local_to_page` / `page_to_local` inverses.
- `PageParagraphData` gains a `rotation: Option<CellRotation>` field;
  when set, `origin` is in the cell's content-local frame. New
  `hit_local` / `local_to_page` helpers centralise the (inverse)
  transform so rotation lives in exactly one place.

Producer:
- `flow_cell_blocks` now tags each cell paragraph with its
  `NestedEditing` path and returns the editing entries alongside items;
  the rotated branch records them with a `CellRotation` built from the
  same pivots the renderer uses.

Consumers (no regression — upright cells hit the identity path):
- loki-layout `ContinuousLayout::{hit_test, cursor_rect_canvas,
  selection_rects}` and loki-text `hit_test_page` route through the
  centralised helpers. Clicking a rotated cell now resolves to the
  correct character; caret/selection positions are rotation-correct.

Tests: `result_tests` (transform round-trip, `hit_local`) and
`flow_tests::rotated_cell_emits_editing_data_with_rotation` (end-to-end).
loki-layout 192 lib + 11 integration binaries and loki-text 195 lib green;
clippy -D warnings clean; result.rs kept under the ceiling by extracting
the rotation types to result_rotation.rs.

Follow-up (TODO(rotated-cell-caret), documented in fidelity-status.md):
the rendered caret *line* stays upright (a tilted caret needs CursorRect
+ vello to carry rotation) and up/down arrow navigation across rotated
cells still uses raw origin translation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Underlines and strikethroughs previously rendered as a single solid line
regardless of the `w:u` / `w:strike` variant: we emit our own decorations
(para_emit) but read the geometry from Parley's run decoration, which
carries no variant. Recover the variant and draw it.

- loki-layout items.rs: `PositionedDecoration` gains a `DecorationStyle`
  (Solid / Double / Dotted / Dashed / Wave / Thick), orthogonal to
  `DecorationKind` (which says where the line sits).
- para_emit: `span_underline` / `span_strike` look up the variant for the
  glyph run's text range from our spans (the info Parley dropped) and set
  it on the underline / strikethrough decoration; spelling squiggles carry
  `Wave`.
- loki-vello decor.rs: `paint_decoration` strokes each style — double is
  two parallel lines, dotted/dashed use kurbo dash patterns (round caps
  for dots), wave reuses the squiggle path, thick doubles the width.

To respect the 300-line ceiling the two span helpers live in para_emit
(their only caller) and `DecorationStyle` is consumed via
`loki_layout::items::` rather than growing the baselined lib.rs root
re-export; para.rs's stale "renders as Single" comments are corrected.

Tested: underline_variant_carries_to_decoration_style +
double_strikethrough_carries_double_style (loki-layout) and
paint_decoration_every_style_does_not_panic across both kinds and two
zooms (loki-vello). loki-layout 194 lib + loki-vello 16 green, clippy
-D warnings clean, fmt clean, ceiling holds. Plan 5.2 + fidelity-status
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
A paragraph that exhausts its explicit tab stops now advances on the
document's configured default tab-stop grid instead of a hardcoded 36 pt.

- Add `LayoutOptions::default_tab_stop_pt`; `layout_document` /
  `layout_paginated_full` fold it in from `doc.settings` via a new
  `effective_options` helper (caller-supplied value wins).
- Add `ResolvedParaProps::default_tab_stop` (built-in 36 pt fallback);
  `flow_para` overrides it from the layout options.
- `para_tabs::compute_tab_plans` / `next_tab_stop_resolved` take the grid
  as a parameter, guarding a non-positive interval.
- Extract `LayoutOptions` / `SpellState` / `FieldContext` from `lib.rs`
  into a new `options.rs` module (frees the lib.rs ceiling; keeps public
  paths stable via re-export).
- Tests: default-grid fallback, custom grid via options, and
  document-settings folding (`tab_stops_tests.rs`).
- Update the deferred-features plan (5.1) and fidelity-status Tab Stops row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The spelling squiggle previously anchored to the selection-geometry
line-box bottom, which includes leading below the glyphs, so with a
generous line height it floated in the inter-line gap.

`emit_spelling_squiggles` (para_underlays.rs) now precomputes each line's
descender bottom (`baseline + descent`) from Parley `LineMetrics` and
positions the wave band there — the run underline zone — so it hugs the
text at any line height. Falls back to the line-box bottom if the line
index is out of range.

Tested by `spelling_squiggle_hugs_the_descender_not_the_line_box` (a 3×
line height makes the old anchor a ~2-em drop; the new anchor stays within
one em of the baseline). Plan 5.3 + fidelity-status updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
PDF export previously rendered a RotatedGroup (rotated table-cell text)
axis-aligned at the group origin, dropping the rotation. It now emits a
content-matrix transform so the text exports at its true angle, matching
the on-screen loki-vello renderer.

The PDF renderer bakes a per-leaf y-flip F into every leaf, so children
are already in y-up space. New `page_rotate::rotated_group_ctm` builds the
CTM `C = F·M·F`, where `M = T(pivot)·R(θ)·T(-pivot_local)` is the exact
loki-vello rotation; rendering children under C with a zero offset places
each child at `F(M·p_local)` — the screen placement flipped into PDF
space. `page.rs` wraps it in `q cm … Q`.

Tested: `page_rotate` matrix unit tests (θ=0 degenerate reproduces the old
offset-and-flip, hand-computed 90° matrix, four-corner round-trip against
the screen transform) and `rotated_group_emits_transform_and_children`
(the content stream carries `cm` + the child fill inside save/restore).
Plan 5.5 + fidelity-status "Clipping / rotation in PDF" updated to Yes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…ion (5.7)

An ODF document expresses a section break implicitly: a paragraph whose
style resolves a `style:master-page-name` different from the running master
page begins a new section on a new page (there is no explicit break
element). Import already detected these, but had a root-cause bug and no
end-to-end coverage.

- Fix: a *leading* master-page declaration (the first paragraph naming a
  non-default master) no longer emits a spurious empty preceding section —
  the running section is flushed only when blocks have accumulated.
- Refactor: extract the body→sections partitioning loop from
  `document/mod.rs` into a cohesive `document/sections.rs`
  (`build_sections` + `flush_master_section`), keeping mod.rs under the
  file-size ceiling.
- Each transitioned section carries the new master's page geometry and its
  `page_style` reference (registered as a named page style, ADR-0012
  Decision 2); export already writes `style:master-page-name` per section,
  so the transition round-trips.
- Remove the stale reader TODO; add `master_page_transition_splits_into_sections`
  and `leading_master_page_declaration_does_not_emit_empty_section` tests.

Plan 5.7 + fidelity-status Section Breaks row updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The external-URL grey placeholder was already implemented; this closes the
remaining half of 5.6 — detecting the floating class.

An image tagged with `FLOATING_CLASS` but carrying no explicit wrap keys
(e.g. an anchored DOCX `wp:anchor` whose wrap child was absent or
unrecognised — the mapper still adds the class) was read as `None` by
`FloatWrap::read` and laid out inline, not floating.

- Add `FloatWrap::read_or_class_default`: falls back to a square/both-sides
  float when the attr is marked floating but has no wrap keys; returns None
  for a genuinely inline attr.
- `resolve.rs` collects images via the new method, so such images now flow
  as side-wrapping floats through the existing `flow_float` path.
- Refresh the stale `float` module doc (the engine does flow text around
  floats now) and drop the resolved `TODO(floating-image)`.

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).
Plan 5.6 + fidelity-status Floating Images row updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Link URLs were already threaded onto glyph runs and painted with a blue
hint underlay, but there was no way to resolve a point to its link — the
layout half of "interactive hyperlink hit-testing".

- `PageParagraphData::link_at` scans a paragraph's glyph runs (recursing
  into exact-line-height ClippedGroups) for one whose hint box — matching
  loki-vello's `paint_link_hint` (width = summed advances, y from
  0.8·font_size above the baseline to 0.2 below) — contains the point,
  inverting any enclosing cell rotation via the existing `hit_local`.
- `ContinuousLayout::link_at` (canvas coords) and `PageEditingData::link_at`
  (content-area-local, the paginated hit-test frame) delegate to it.
- Refresh the `link_url` / renderer TODOs to point at the new API.

Tested by 5 `result_tests` cases: a hit over a link, horizontal and
vertical misses, plain-text None, rotation-inverted resolution, and the two
layout wrappers.

The editor's open-on-click gesture (modifier + URL opener) remains as
app-side wiring. Plan 5.11 + fidelity-status Hyperlinks row updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Wires the interactive gesture on top of the layout `link_at` primitive.

- The page tile reports the Ctrl/Cmd (Meta) modifier through `on_tile_click`,
  whose payload gains a `bool` (`(usize, f32, f32, bool)`); threaded through
  `DocumentView`/`view_types`.
- On a modifier-click, the loki-text handler calls `hit_test::open_hyperlink_at`
  — which subtracts the page margins to reach the content-area-local frame,
  resolves the URL via `PageEditingData::link_at`, and opens it with the
  cross-platform `webbrowser` crate — then skips caret placement. A plain click
  still positions the caret, so hyperlink text stays editable.
- Reflow-mode open-on-click is left as a small follow-up
  (`TODO(link-click-reflow)`); the blue-tint hint renders in both modes.

Verified: loki-renderer + loki-text build and `cargo clippy -p loki-renderer
-p loki-text -- -D warnings` are clean; fmt + file-ceiling green. The URL-open
side effect is isolated in `open_hyperlink_at`; the resolution core (`link_at`)
is covered by the layout tests. Plan 5.11 + fidelity-status updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
…l+render)

`BulletChar::Image` was a dead unit variant that fell back to `•`. This makes
picture bullets first-class in the model and renders them.

- Model: `BulletChar::Image { src: String }` carries the bullet's image
  reference (a `data:` URI or part path). Serde/Loro round-trip is automatic.
- Layout: since Parley cannot inline an image, the bullet is placed
  out-of-band. Extracted the list-marker synthesis from `flow_para` into a new
  `flow_list_marker` module; `synthesize` reports a picture bullet's `src` (and
  emits no marker glyph text), and `picture_bullet_item` builds a square image
  item sized to line 0, left-aligned in the hanging label box `[indent_start −
  label_w, indent_start]`, injected into the paragraph's items so it translates
  with the paragraph. (The extraction also dropped `flow_para` 741 → 710.)
- `format_list_marker` returns an empty label for an image bullet (was `•`);
  the list inspector still labels it "Image bullet".

Tested: `picture_bullet_emits_image_in_the_hanging_label_box` (end-to-end flow:
the image carries the src, is a square within the label box, and sits one
hanging-indent left of the wrapped text) + `format_marker_picture_bullet_has_no_text`.

Import of the bullet image (OOXML `w:numPicBullet` / ODF
`text:list-level-style-image`) follows in the next commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Resolve `w:numPicBullet` / `w:lvlPicBulletId` into `BulletChar::Image`.

- Model: `DocxNumbering.pic_bullets` (`DocxNumPicBullet { id, rel_id, src }`)
  and `DocxLevel.lvl_pic_bullet_id`, plus `pic_bullet_src`.
- Reader: parse `w:numPicBullet` (capturing the nested `v:imagedata @r:id` /
  `a:blip @r:embed`) and `w:lvlPicBulletId`.
- Import: `import_pic_bullets::resolve` looks each bullet's relationship up in
  the *separate* `word/numbering.xml.rels`, resolves the image part, and bakes
  a `data:` URI onto the numbering model (gated by `embed_images`).
- Mapper: a level referencing a resolved picture bullet maps to
  `BulletChar::Image { src }`; an unresolved reference falls back to the
  `w:numFmt="bullet"` text char.

Held the ceilings by extracting `reader/numbering.rs`'s inline tests to
`reader/numbering_tests.rs` and the resolver to `docx/import_pic_bullets.rs`.

Tested: `parses_num_pic_bullet_and_lvl_reference` (reader),
`picture_bullet_level_maps_to_image_bullet` +
`picture_bullet_without_resolved_src_falls_back_to_text_bullet` (mapper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Map `text:list-level-style-image` to `BulletChar::Image`.

- Model: `OdfListLevelKind::Image { href, style_name }`.
- Reader: parse `<text:list-level-style-image>` (both the container and
  self-closing forms) via new `styles_list::parse_image_level` /
  `image_level_empty` helpers, capturing `xlink:href`.
- Mapper: `map_list_styles` now takes the package image map and resolves the
  href to a `data:` URI (`image_data_uri`, mirroring the frame-image path),
  producing `BulletChar::Image { src }`; a missing image falls back to `•`.

Held the `reader/styles.rs` ceiling by extracting the image-level parsing to
`styles_list.rs` (the arms are now one-liners).

Tested: `read_stylesheet_list_style_image_level` (reader),
`picture_bullet_resolves_to_image_data_uri` +
`picture_bullet_missing_image_falls_back_to_dot` (mapper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
An `evenPage`/`oddPage` section break must start the section on the next
even/odd page; previously these behaved like `nextPage`, so a section could
begin on the wrong parity.

- New `paginate_blanks` module: `needs_blank_before` decides (from the section
  start type and the running page count) whether a filler is required, and
  `blank_page` builds a content-free page carrying the section's geometry (no
  header/footer, no editing data).
- `layout_paginated_full` consults it per group in Pass 1 (counting the filler
  so the section's own pages — and NUMPAGES — number correctly) and emits the
  filler ahead of the section's pages in Pass 2. The document's first section
  never gets a filler (its break type is immaterial).

Tested: `paginate_blanks` unit tests (parity for even/odd/newpage/continuous,
first-section guard, blank-page emptiness) + `odd_page_section_inserts_a_blank_
filler_when_parity_is_wrong` (end-to-end: an oddPage section after one page
yields a 3-page layout with an empty middle page; the evenPage control stays 2).

Plan 5.10 + fidelity-status Section Breaks row updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
EPUB export dropped `Inline::Math`; EPUB 3.3 renders MathML natively, so the
model's namespaced `<math>` element is now written verbatim into the content
XHTML (as markup, not escaped).

- `render_inline` emits the MathML string and sets a new `RenderCtx.has_math`
  flag; `RenderedContent` carries it out.
- `build_package_opf` adds `properties="mathml"` to the content document's
  manifest item when the content embeds math (EPUB 3.3 §5.4 conformance).

Extracted `content.rs`'s inline tests to `content_tests.rs` to hold the
300-line ceiling.

Tested: `math_is_emitted_as_mathml_and_flags_the_document` (body carries the
verbatim MathML + the flag) and `content_item_declares_mathml_when_present`
(the manifest property). Plan 5.10 + fidelity-status EPUB content row updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Per-column widths for multi-column sections (DOCX w:cols w:equalWidth="0"
and ODF style:column) are now modelled and rendered at their true widths
instead of collapsing to an equal split.

- Model: SectionColumns.widths (Vec<Points>); empty = equal columns.
- Loro bridge: widths round-trip as a ;-joined string (KEY_COL_WIDTHS),
  encoded via decode::encode_col_widths, split back on read.
- Layout: column_layout_for resolves per-column widths (equal-split
  fallback); flow places each band at the cumulative width+gap offset
  (column_x_offset / break_column / emit_column_separators). Moved
  column_layout_for into flow_columns.
- OOXML: reader captures w:col @w:w (twips); parse_sect_pr extracted to
  reader/sectpr.rs to hold the file ceiling. Mapper twips->points via
  new mapper/document_cols.rs. Writer emits w:equalWidth="0" + per-col
  <w:col w:w>.
- ODF: OdfColumns.rel_widths from style:column @Style:rel-width; mapper
  distributes the page content area proportionally; writer re-emits
  rel-width shares (ratio-preserving).
- page_form column editor preserves widths only when count is unchanged.

Tests: DOCX + Loro-bridge exact-width round-trips, ODF ratio round-trip,
and a layout-geometry test placing the second band past the equal split.
Docs: fidelity-status (Section Breaks + Multi-column Sections) and the
deferred-features plan updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The EPUB exporter previously dropped Inline::Field and Inline::Comment on
the no-op arm. Both now render:

- Fields resolve to static text (a reflowable EPUB has no live page/date
  context): the current_value import snapshot, else a metadata-backed
  value for Title/Author/Subject, wrapped in <span class="field">.
  Page/date/reference fields with no snapshot render nothing. A FieldEnv
  captured from DocumentMeta backs the metadata lookups.
- A commented range emits an inline superscript ref marker at its start
  anchor (<sup class="comment-ref"><a epub:type="noteref">), and each
  referenced comment becomes an <aside epub:type="annotation"> (id,
  "[n] author — date" byline, body paragraphs) collected in a trailing
  <section epub:type="annotations">. The end anchor and unknown ids
  render nothing; markers are numbered in first-reference order.

RenderCtx gains field_env, comments, and comment_seq (built in
render_content from the document, mirroring the ODT Cx comment map).

Tested: current_value snapshot, metadata-backed Title, unresolvable
field renders nothing, and comment marker+aside with end-anchor
de-duplication. Docs: fidelity-status EPUB content row updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The PDF exporter embedded the full font program for every used face. It now
embeds a glyph subset (via the subsetter crate), typically shrinking the
font streams by orders of magnitude.

- subset_program reduces the face to its used glyphs; the subsetter
  renumbers glyphs to a contiguous range and returns the remapper.
- Because the subsetter renumbers, the content stream (which addresses
  glyphs by their original id under Identity-H) is left untouched: a
  CIDToGIDMap stream remaps each original glyph id (used as the CID) to the
  subset's new gid. /W widths and ToUnicode stay keyed by CID, so no other
  code changes.
- The BaseFont gains the mandatory six-letter subset tag (ABCDEF+…),
  derived deterministically from the used-glyph set.
- Metrics are read from the original face (the subset strips cmap/OS/2).
- A face the subsetter rejects (e.g. CFF2) falls back to the full program
  under an un-tagged name, so output is always valid and fully embedded.

Tests: fonts_subset unit tests (tag shape/determinism, CIDToGIDMap
remapping) + the integration test now asserts the subset tag and
CIDToGIDMap are present. Docs: fidelity-status Font embedding row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
A standalone multi-column section that fits on one page (its own last page)
now has its columns balanced to roughly equal heights instead of packing
column 1 to the page bottom while column 2 sits empty — matching Word.

- flow_balance.rs re-flows the section with the per-page content height
  capped to the smallest value that still fits every column on one page,
  found by a bounded binary search (feasibility is monotonic in the cap).
  Capping only the balancing re-flow leaves flow_para untouched.
- Gated to single-page, footnote-free sections so footnote position and
  page-bottom content are never displaced. Multi-page, continuous-group,
  and footnote-bearing sections keep fill-first (TODO(column-balance-
  multipage)).
- flow_section's paginated branch was extracted into flow_balance
  (a child module, so it reaches flow's private helpers), which also
  shrank flow.rs below its baseline.
- Editing a multi-column section falls back to a full (balanced) relayout
  instead of the fill-first incremental resume (incremental.rs guard). To
  make room there, the note-detection cluster moved to incremental_notes.rs.

Baseline ratcheted down for the files that shrank this session.

Tested: short_multi_column_section_balances_across_columns; the full
loki-layout suite (incl. the incremental==full property test) stays green.
Docs: fidelity-status Multi-column Sections + deferred-features plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The PDF/X OutputIntent already embeds a CMYK ICC profile when one is
supplied; the gap was ergonomics and an unclear conformance story.

- Add OutputIntent::with_icc_profile builder for the integrator-supplied
  DestOutputProfile path.
- Clarify in docs that strict PDF/X-3 / X-4 conformance requires the
  embedded profile, and that the default (no bundled CMYK profile) yields
  a valid X-1a-style intent referencing the registered condition only.
- Record TODO(pdf-icc-default-profile): bundling a CMYK ICC profile is a
  licensing/asset decision; a build that bundles an approved ECI/FOGRA
  profile can default icc_profile to it.

Tests: with_icc_profile builder; an integration test confirms a supplied
profile is embedded and referenced as the DestOutputProfile, and that the
default embeds none. Docs: fidelity-status CMYK/OutputIntent row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The PDF/X OutputIntent now embeds a CMYK DestOutputProfile out of the box,
resolving TODO(pdf-icc-default-profile). The bundled profile is
CGATS001Compat-v2-micro.icc from saucecontrol/Compact-ICC-Profiles,
released under CC0 1.0 (public domain) — compatible with this crate's
Apache-2.0 license. It characterises CGATS TR 001-1995 (U.S. SWOP coated
web); data color space CMYK, with the desc/cprt/wtpt/A2B0 tags (A2B0 is
the direction an output intent needs to characterise the DeviceCMYK
content this crate emits).

- assets/CGATS001Compat-v2-micro.icc (verbatim, MD5 profile id intact),
  assets/CC0-1.0.txt (license text), assets/README.md (provenance/scope).
- OutputIntent::default() now embeds it via include_bytes! and sets the
  condition to CGATS TR 001; with_icc_profile overrides it for a certified
  press condition.

Tests updated to the new default (default_intent_embeds_bundled_cmyk_profile
asserts a valid CMYK 'acsp' profile; the export test now asserts the
default carries a DestOutputProfile). Docs: fidelity-status + plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
Widow/orphan control was modelled and round-tripped but ignored at layout
time. The flow engine now avoids stranding a lone line when a paragraph
splits across a page/column boundary, matching Word/LibreOffice (default 2
lines, on):

- Orphan: a would-be lone first line at a page bottom defers the whole
  paragraph to the next page.
- Widow: a would-be lone last line atop the next page pulls a line down so
  the tail keeps >= widow_min lines.

ResolvedParaProps gains orphan_min/widow_min, resolved in para_props_map
(OOXML's single w:widowControl governs both; ODF fo:orphans/fo:widows set
each; explicit 0 disables). The decision is a pure resolver
(flow_widow_orphan::resolve_split) applied in split_and_place_loop, guarded
against the degenerate page-top case so pagination always terminates.

Tested: 8 unit tests over the resolver (widow pull-down, orphan defer,
page-top no-defer, widow-creates-orphan, disabled, continuation, empty) +
an end-to-end orphan-defer test using exact line heights. Full loki-layout
(217 lib + integration), loki-text, and loki-acid suites stay green.
Docs: fidelity-status Widow/Orphan row + deferred-features 5.9.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
A block-level w:sdt (structured document tag / content control) was skipped
wholesale at import (skip_element), silently dropping every paragraph and
table inside it — real data loss for the many Word documents whose cover
pages, forms, and bound placeholders sit in content controls.

The reader now unwraps the control: reader/document_sdt.rs descends into
w:sdtContent and appends its children (paragraphs, tables, nested controls)
to the surrounding body, so the content survives. The control's chrome
(binding/placeholder/tag in w:sdtPr) is discarded — Loki has no
content-control model. The now-dead DocxBodyChild::Sdt placeholder variant
(and its two no-op mapper arms) is removed.

Tested: block_sdt_content_is_unwrapped_into_the_body (reader, with a nested
control + table) and the existing conformance test, updated from asserting
the old drop-on-import to asserting the content is unwrapped
(ooxml8_sdt_content_between_paragraphs_is_unwrapped). Full loki-ooxml suite
green. Docs: fidelity-status Content Controls row + deferred-features 5.9.

Tail (documented): w:sdt inside a table cell, inline (run-level) w:sdt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
CI's stable toolchain (1.97) flags the redundant `&` in
`format!("{} ", &footnote_mark(..))` under
`-D clippy::useless_borrows_in_formatting` (a lint absent from the local
1.94 toolchain, so the branch's first CI run surfaced this pre-existing
line). Remove the borrow — `format!` borrows its arguments already.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
The branch's first CI run (stable 1.97, vs the older local 1.94) surfaced
issues the local toolchain and my narrower local checks didn't catch. CI
runs `cargo clippy --workspace --all-features -D warnings -D
clippy::unwrap_used -D clippy::expect_used` and `cargo test --workspace
--all-features`; reproduced both locally on 1.97.

lint (clippy):
- useless_borrows_in_formatting: redundant `&` in a footnote `format!`
  (loki-layout/flow.rs).
- question_mark: manual Option unwrap in `parse_length` (loki-odf).
- expect_used: infallible `.expect()` in `panel_data` reworked to
  `filter_map` (loki-text) — no panic in library code.

build-and-test (test compile):
- PageParagraphData literal in a loki-renderer test missed the `rotation`
  field added by the rotated-cell work.

Verified with a `--keep-going` clippy sweep and a full `cargo test
--all-features --no-run` on 1.97.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5ufvex8RV1yUo1x42FJXL
@kevincarlson kevincarlson merged commit 44aaf9e into main Jul 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants