← Back to Plan

Editing Flow

Note Editing Flow

How a keystroke becomes markdown on disk, how markdown becomes styled text on

screen, and how the formatting toolbar decides what state to show. Covers the

iOS live editor (iosApp/SteelNotes/Features/NoteEditor/) and the KMP editor

core (shared/src/commonMain/kotlin/com/steelnotes/editor/).

The cast

LayerFileResponsibility
UnifiedEditorView + CoordinatorEditor/UnifiedEditorView.swiftSwiftUI ⇄ UIKit bridge. UITextViewDelegate: routes every keystroke, owns the isUpdating re-entrancy guard and the lastReconciledDisplay diff baseline.
UnifiedEditorViewControllerEditor/UnifiedEditorViewController.swiftUIKit shell: outer UIScrollView + header fields + non-scrolling SelfSizingTextView. Selection mapping (safeSelection), caret scrolling, drag/drop.
MarkdownEditorStorageEditor/MarkdownEditorStorage.swift**The only writer of markdownSource.** Owns the canonical markdown string, the OffsetMap, undo history, and all display rebuilds.
NoteTitleTextView + TitleCoordinatorEditor/NoteTitleTextView.swift, Editor/UnifiedEditorView.swiftThe note title. A non-scrolling UITextView so long titles **wrap** instead of ellipsizing (a UITextField cannot). Its own delegate, deliberately separate from Coordinator, whose UITextViewDelegate methods all assume bodyTextView. Return moves focus to the body; pasted newlines flatten to spaces.
OffsetMapEditor/OffsetMap.swiftBidirectional source-offset ⇄ display-offset mapping, built from visible / hidden / attachment segments.
ParagraphBuilderEditor/ParagraphBuilder.swiftRenders one markdown line → NSAttributedString + offset segments. Hides syntax markers, styles list prefixes, creates attachments.
MarkdownLineClassifier (KMP)editor/MarkdownLineClassifier.ktThe single definition of the **line grammar**: blank / image / transclusion / rule / fence / quote / heading / checkbox / ul / ol / paragraph, plus list prefix + ordinal + heading level.
InlineTokenizer / InlineSerializer (KMP)editor/InlineTokenizer.ktThe single definition of the **inline grammar**: flat styled runs (bold / italic / highlight / code), atomic tokens (wikilink, link), marker tokens.
FormattingEngine (KMP)editor/FormattingEngine.ktPure text transforms: toggle bold/italic/highlight, toggle list, indent/dedent, marker-aware deletion. Takes (text, selection) → returns (newText, newSelection).
MarkdownASTCursorEditor/MarkdownASTCursor.swiftFormatting state at the caret (drives toolbar) via the same KMP tokenizer, plus a local bullet-mode line check.
EditorToolbarController + FormattingToolbarManagerEditor/EditorToolbarController.swift, Components/FormattingToolbar.swiftToolbar buttons, active-state flags, keyboard tracking.

Two invariants define the design:

1. Resource is authoritative. The display attributed string and the offset

map are always derived from markdownSource (never patched

independently, except the audited native fast path below). "Derive, don't

patch."

2. One grammar. Line classification and inline tokenization live in KMP

and are shared by rendering, toolbar state, Enter handling, and formatting

actions — they cannot disagree with each other.

1. Typing → markdown resource

Every keystroke enters through

Coordinator.textView(_:shouldChangeTextIn:replacementText:)

(UnifiedEditorView.swift:215),

which picks one of four routes:

```

keystroke

├─ deleting an attachment char? → mutate source line, setMarkdownSource, return false

├─ selection spans an attachment? → applyDisplayEdit (full re-derive), return false

├─ NATIVE FAST PATH (plain char, no grammar chars "*=`#>![]-", not "\n",

│ not marker-adjacent, inside one visible run)

│ → return true: UIKit mutates the text view itself.

│ textViewDidChange then DIFFS textView.text against

│ lastReconciledDisplay and patches the same delta into

│ markdownSource + offset map (reconcile()). This also catches

│ autocorrect / predictive-text mutations that never went through

│ shouldChangeTextIn.

└─ INTERCEPTED (grammar char, Enter, deletion, marker-adjacent edit)

→ return false; we mutate storage ourselves:

• Enter → performReturnKey (list continuation, wrapper split,

empty-item dedent/clear — see below)

• everything else → editorStorage.applyDisplayEdit

```

applyDisplayEdit maps the display range to a source range through the

OffsetMap, splices the replacement into markdownSource, and calls

rederiveDisplay:

  • **Token-diff splice** (fastest, inside rebuildParagraphs): for a plain
  • single-line paragraph whose previous tokens are cached, the new line is

    re-tokenized (KMP, authoritative — the tokens are never patched) and diffed

    against the cache (TokenDiffer, shared Kotlin). When only a small window

    of plain text/marker tokens changed, just that window's attributed runs,

    offset-map segments, and display characters are spliced — the rest of the

    paragraph is proven unchanged and left alone. Any miss (atomic or

    zero-width tokens in the window, non-paragraph line, stale cache) falls

    through to the scoped rebuild.

  • **Scoped rebuild** when the diff sits inside a single paragraph containing
  • no fence/image/transclusion: only that paragraph's display + segments are

    rebuilt (rebuildParagraphs).

  • **Full rebuild** (rebuildAll) otherwise: classify every line (KMP), run
  • ParagraphBuilder per line, rebuild the whole attributed string and offset

    map, and replace the text storage wholesale.

    Deletions off the fast path go through FormattingEngine.deleteRange, which

    is marker-aware: it deletes styled-run content and re-serializes, so a

    backspace can never orphan half of a ** pair. Deletions interior to a

    single text run (or ending flush at its boundary, away from empty style

    slots) take an early-out inside the engine — a plain splice proven

    byte-identical to the full path by the splice-parity tests — so typical

    backspaces skip the decompose/re-serialize cost. When the deleted range spans a

    newline (a whole item removed, or a join that pulls a blank/nested line onto a

    run) it also renumbers the affected ordered run — deleting 2. two from a

    1./2./3. list closes the gap to 1./2.. Content-only deletions are left

    byte-for-byte, so a list authored 1., 5., 9. survives an in-item backspace.

    Deleting a link

    Only a link's name is on screen ( and the tail are hidden

    segments), so deleteRange judges the deletion against the name rather than

    the raw text — for both plain links and steel:// note links:

  • the name fully covered → the whole link goes;
  • part of the name covered → the name is edited in place and the link
  • survives (hello minus ellho);

  • a backspace with the caret just past the link eats the name's last
  • character — the deletion mapping is left-biased, so this arrives as a range

    inside the name;

  • a range covering only hidden syntax leaves the link untouched — the user
  • cannot select what they cannot see;

  • emptying the name removes the link rather than leaving an invisible,
  • untappable [](…).

    The rebuilt raw is deliberately not title-sanitized: this runs mid-edit,

    and collapsing whitespace would rewrite text the user is still typing.

    Escaping a link (caret at the end of its name)

    A link's ](…) is hidden, so the display has no position past a link

    at the end of a line the caret cannot get out, and typing could only ever

    grow the name. So an insertion at the end of a link's name lands after the

    whole link: OffsetMap.Segment.closesAtomicLink marks the trailing hidden

    segment and sourceOffsetForInsertion steps over it.

    The name stays editable from one character earlier, which is the same trade

    the wrapper-escape machinery makes for bold. The alternative considered

    — padding the resource with a trailing space so there is something to tap past

    — was rejected: it mutates the user's markdown for a UI problem, accumulates

    on every link, and syncs everywhere.

    Deleting an attachment (two-stage)

    A photo, image row, or transclusion card is a whole block of content behind a

    single display character, so one stray backspace must not destroy it. The

    first backspace only arms it (Coordinator.pendingAttachmentDelete): the

    attachment is selected and its view draws a 2pt blue border. Only a second

    backspace at the same range deletes. Moving the caret, typing, starting a

    drag, or an external body update cancels the arming.

    The armed flag lives on EditorAttachment, not the view — UIKit owns view

    lifetime and may rebuild one at any time, so the providers re-apply the

    highlight in loadView via registerHighlightTarget, which also captures

    the view's resting border so cancelling restores it.

    Deleting the reference does not immediately delete the file: the attachment

    GC holds an unreferenced file for a grace period (AttachmentGcPolicy) so

    undo — which restores the markdown reference but never the bytes — still

    works.

    Tables

    A GFM pipe table renders as one attachment per row, not one attachment for

    the whole block. That is the decision the rest of the feature falls out of: a

    row is a display character, so dragging a selection across the table

    highlights it row by row through the same syncAttachmentSelectionBorders

    that borders photos, and deleting rows reuses the attachment delete path.

    The header row is the one place the mapping is not one source line per

    display row: it owns both the header line and the | --- | --- |

    delimiter under it, with the newline between them and the delimiter itself

    emitted as hidden segments — the same trick the image row uses to fuse

    several source lines into one visual line.

    MarkdownTable (shared KMP) is the single definition of what a table is.

    MarkdownLineClassifier.classify promotes a pipe paragraph to TABLE_ROW /

    TABLE_DELIMITER only when the two-line header+delimiter pairing holds, which

    is why that pairing lives in classify beside the code-fence state rather

    than in the stateless classifyLine. The read-only article renderer parses

    the same model into a TableBlock and draws it as a SwiftUI Grid.

    Every edit is derive-don't-patch: TableGroup holds the parsed table plus the

    measured column widths and the shared horizontal scroll offset, a mutation

    returns a new MarkdownTable, and

    UnifiedEditorViewController.applyTableEdit re-serializes it over the

    table's source lines and lets the storage rebuild. Nothing edits the rendered

    grid in place.

    Cell text commits on end-editing (Return, focus loss, or any structural

    action) — never per keystroke, which would tear down the cell's field

    mid-word. While one cell is being edited it shows its raw markdown; every

    other cell renders its formatting with the markers hidden, as everywhere else

    in the editor.

    Two v1 rules worth knowing: deleting a selection that includes the header row

    blanks the header and keeps the remaining rows (only a selection covering

    every row deletes the table), and | is in the fast-path grammar set, since

    typing one is how a paragraph becomes a table.

    Image / transclusion lines are atomic at their boundaries

    An image or embed only renders as one while it owns its whole line, so

    merging it into a neighbour destroys it: hello + !alt becomes

    hello!alt, which no longer classifies as IMAGE and re-parses as

    text plus an inline link — the photo silently turns into a blue link, on

    disk.

    The rule is enforced in FormattingEngine.deleteRange, which every

    display-driven deletion funnels through: a line break beside a surviving

    image/embed line survives the deletion even when the selection covered it,

    while everything else in the selection still goes. Deleting such a line

    whole is unaffected — only merging is forbidden — so backspace, range

    delete, forward delete and paste-over all inherit the protection. Fenced code

    is exempt, since an image line inside a fence is just text.

    On top of that, the delegate handles the caret UX for the plain-backspace

    case: with the caret at the start of an image/embed line it steps back to the

    end of the previous line rather than sitting still. A blank line above is

    still removable — the caret lands on it, and the next backspace collapses

    it.

    verifyConsistency checks after every commit that

    offsetMap.totalSourceLength == markdownSource.length and

    offsetMap.totalDisplayLength == textStorage.length. It runs in all

    configurations: a violation self-heals with a full rebuild from the

    canonical resource and logs an os_log fault once per session (a desync left

    in place silently corrupts every later edit). Tests install

    consistencyViolationHandler to record the failing shape instead of healing.

    The Return key

    Coordinator.performReturnKey (UnifiedEditorView.swift:393):

    1. Inside a code fence → plain \n.

    2. Active inline wrappers at the caret → close them before the newline and

    reopen after (foo| + Enter → foo\n****).

    3. On an empty heading line (# with no content) → clear the prefix,

    leaving a blank line. A heading with content needs no branch: it has no

    continuation prefix, so it falls through to the plain \n at step 5 and

    the user lands in body text — which is what they meant to write next.

    4. On a list line (per the KMP classifier):

  • line has content → insert \n + continuation prefix (- , n+1. ,
  • - [ ] , preserving leading indent);

  • empty nested item → dedent one level (renumbering ordered siblings);
  • empty top-level item → clear the prefix, leaving a blank line.
  • 5. Otherwise plain \n.

    Resource → SwiftUI → .md file

    After any programmatic mutation, Coordinator.commitEditorState() pushes

    markdownSource into the SwiftUI bodyText binding via pushBodyToBinding,

    which stamps the bodyRevision binding with the coordinator's generation

    counter, and refreshes lastReconciledDisplay. Fast-path keystrokes do the

    same push at the end of textViewDidChange.

    From there (NoteEditorContent.swift):

    fields.bodyText changes → .onChange(of: fields)scheduleAutoSave()

    (debounced) → SharedCoreService.applyEdit / createNote → KMP vault layer

    writes the .md file (frontmatter + body) → indexer + sync pick it up.

    Coordinator.reconcileRenderedBody lets updateUIViewController distinguish

    an external change (sync landed → reload editor) from the editor's own binding

    lag (push editor state out, don't clobber). SwiftUI delivers renders in order,

    so a render whose bodyRevision is behind the coordinator's counter was

    captured before the last keystroke and is stale; one carrying the latest stamp

    saw that keystroke, so any difference in it came from outside the editor.

    2. Markdown → styled display (formatting as you type)

    rebuildAll / rebuildParagraphs render each line:

    1. Classify the line with KMP MarkdownLineClassifier (one pass tracks

    code-fence state document-wide).

    2. Build with ParagraphBuilder.build, which emits an attributed

    fragment plus OffsetMap segments:

  • hidden segments (displayLength 0): syntax the user shouldn't see —
  • leading indent spaces on list lines, > , inline markers * ==

    ` ``, wikilink brackets, link URLs.

  • visible segments (1:1): content text, list prefixes (- , 1. ,
  • - [ ] stay visible but styled grey via Styles.listPrefix, tagged

    with .steelListPrefix). The grey prefix is inert: styling clamps

    off it (FormattingEngine.toggleFlag never wraps a prefix in markers, so

    a bulleted line can't be turned into - item and lose its list-ness),

    and the caret snaps out of it (selectionSnappedOutOfListPrefix) so

    typing lands in the content, not ahead of the marker.

  • attachment segments (source line → 1 display char): images,
  • image rows, transclusion cards (EditorAttachment +

    NSTextAttachmentViewProvider).

    3. Inline styling: content is tokenized by KMP InlineTokenizer into flat

    styled runs; flags compose (==x== = one run, bold+highlight).

    Markers become hidden segments; runs get composed

    font/background attributes.

    4. Paragraph styles control block indentation

    (ParagraphBuilder.Styles): lists use

    firstLineHeadIndent = indentLevel × 20 and a headIndent for wrapped

    lines; blockquotes indent 20pt with a vertical bar drawn by

    SelfSizingTextView.

    This is the hidden-marker WYSIWYG model: the display never shows markdown

    syntax, and the offset map is what keeps caret positions honest between the

    two coordinate spaces. A zero-length caret at a hidden-marker boundary is

    disambiguated by sourceNSRangeForEdit (insertion-aware mapping) plus the

    one-shot pendingInsertionSourceOverride ("wrapper escape") that a toggle

    sets when it parks the caret just past a span's closing markers.

    3. Formatting toolbar

    Visibility (when the format capsule shows)

    The left format capsule is shown only while the body is being edited; the

    undo/redo capsule is always visible. That decision is a stored flag,

    EditorToolbarController.isEditingBody, moved only by the two focus edges

    (textViewDidBeginEditing / textViewDidEndEditingbodyFocusChanged).

    Keyboard-frame notifications re-apply that flag but can never change it. They

    used to derive it by polling bodyTextView.isFirstResponder, and a

    notification landing while the responder chain was mid-handoff (selection

    gestures raise their own responder) read "not editing" and hid the capsule for

    the rest of the session — the note had to be closed and reopened to get it

    back. Focus alone is still the test: with a hardware keyboard the reported

    frame has no height, so keyboard height must not gate visibility either.

    One asymmetric safety net: a live isFirstResponder promotes a stale false

    back to editing. The reverse is deliberately absent — nothing may hide the bar

    out from under an editing session except a real end-editing event.

    Position (where the bar sits)

    The bar has exactly one vertical constraint: its bottom is pinned to the

    editor view's bottom edge, and the constant is

    EditorToolbarController.barClearance(keyboardHeight:safeAreaBottom:)

    max(keyboard height, home-indicator inset) + rowGap. The keyboard height is

    the one keyboardChanged already derives from the

    keyboardWillChangeFrame notification frame, so the only thing that can move

    the bar is a keyboard notification the editor actually received.

    It used to ride view.keyboardLayoutGuide.topAnchor at priority .required - 1,

    capped by a required <= safeAreaLayoutGuide.bottomAnchor. Both halves of that

    were failure modes: the guide is UIKit-owned and can quietly stop tracking an

    open keyboard, and a 999-priority constraint is one the layout engine is free to

    break — either way the bar resolved to the bottom of the screen, behind the

    keyboard, with nothing in the editor able to tell. That is what the user saw as

    "the formatting bar drops down under the keyboard a few seconds after I bold

    something". Folding the home-indicator floor into the same constant removes the

    second constraint too, so there is nothing left to conflict.

    safeAreaChanged() re-applies the position as well as the scroll inset:

    rotating changes the home-indicator inset (34 → 21) without producing any

    keyboard notification.

    State display (what lights up)

    Every caret move fires textViewDidChangeSelection

    EditorToolbarController.updateActiveStates():

    1. vc.safeSelection() maps the display selection to source offsets — the

    same mapping formatting actions use, so button state and button action

    always see the same position ("mapping parity").

    2. MarkdownASTCursor.formatting(cursor:selEnd:):

  • **Inline flags** (bold/italic/highlight/code): tokenize the caret's line
  • with the KMP tokenizer; a bare caret reports the flags of the run whose

    content range contains it (preferring the preceding run at boundaries);

    a selection reports a flag only when every selected run has it (so an

    active button always means "tap will clear").

  • **Bullet mode** (off / bullet / numbered): a deliberately local
  • string check on the caret's line (bulletModeAtCursor) — not the KMP

    classifier, as a per-selection-change perf concession.

    3. Flags land on FormattingToolbarManager (isBold, isItalic,

    isHighlight, bulletMode, headingLevel), which restyles buttons; the

    bullets button swaps its icon between list.bullet and list.number, and

    the heading button relabels itself H / H1 / H2 / H3.

    Programmatic edits run under isUpdating (no selection callback), so

    commitEditorState calls updateToolbarActiveStates() explicitly.

    Scrolling during a selection drag

    textViewDidChangeSelection also keeps the interesting part of the selection

    on screen, and which part depends on the selection:

  • **Caret** → scrollToCursorIfNeeded(), but only when
  • shouldAutoScrollToCaret() allows it (EditorAutoScrollPolicy): a touch

    in flight on the body suppresses it, because scrolling mid-drag slides the

    text out from under a caret drag and can cancel it.

  • **Range** → Coordinator.movingSelectionEdge(previous:current:) resolves the
  • edge that just moved (start moved and end didn't → start; otherwise end) and

    only that edge is revealed. Revealing the selection start on every change

    — what the caret path does — yanked the note back to the top of a long

    selection the moment a handle drag extended it. The range path is not

    gated on the caret policy: a selection-handle drag is a touch in flight,

    and it is exactly the gesture asking the note to move.

    Both paths end in scrollRectToVisibleIfNeeded(_:padding:), whose band/clamp

    math lives in the pure EditorScrollGeometry.scrollTarget(...) (returns nil

    when no scroll is needed).

    Following the moving edge only scrolls while the finger is moving: a finger

    parked at the edge fires no selection changes. SelectionAutoscrollController

    covers that case — a zero-duration long press observes touches on the body

    (simultaneous with everything, cancels nothing), and while a ranged selection's

    touch sits inside a 44 pt zone at either end of the visible content (measured

    from adjustedContentInset, so above the keyboard and toolbar) a CADisplayLink

    creeps the scroll view and extends the selection to the text now under the

    finger. UIKit's own drag-to-edge autoscroll never runs here because the body

    text view doesn't scroll itself.

    Tests: SelectionAutoscrollTests.swift (scroll target, moving edge, speed

    curve, extension); the gesture glue itself is simulator-verified.

    Actions (what a tap does)

    Buttons call EditorToolbarController.apply*FormattingEngine (pure KMP

    transform on (markdownSource, sourceSelection)) → vc.applyResult

    editorStorage.applyFormattingResult (undo snapshot, re-derive display, map

    the returned resource selection back to display coordinates, park the wrapper

    escape if the engine's caret isn't display-expressible).

    The bullets button is a tri-state cycle driven by the current mode:

    off → add unordered; bulletnumbered → switch kinds. Once a list is

    active the button never removes it (that's backspace/dedent's job), and a

    line switching to ordered continues numbering from the previous same-indent

    ordered sibling. Indent/dedent prepend/remove two spaces; indenting an

    ordered item resets it to 1. (new sub-list), dedenting renumbers it after

    its new siblings. Backspace at a list line's content start steps the item

    back: dedent one level when indented, otherwise remove the whole prefix

    (Coordinator.performListBackspace).

    Headings (h1–h3)

    A heading is a whole-line property, stored as genuine markdown (# , ## ,

    ### ). h4+ is deliberately unsupported: #### x never classifies as a

    heading, so a fourth # stays literal text instead of silently producing a

    level nothing downstream renders or cycles to.

    Three redundant ways to apply one, all landing on the same KMP transform

    (FormattingEngine.setHeading, which takes an explicit level — the callers

    decide what "again" means):

  • **Markdown shortcut** — typing # at a line start converts it. This needs
  • no keystroke handling of its own: # is already a grammar char that forces

    the intercept path, and editMayCompleteBlockPrefix already intercepts the

    completing space, so conversion falls out of the normal re-derive.

  • **Toolbar** — one H button cycling body → h1 → h2 → h3 → body
  • (applyHeadingCycle), labelled H / H₁ / H₂ / H₃ to show the caret

    line's current level. The label uses Unicode subscript digits in a single

    font run: a second run with a negative .baselineOffset is the obvious way

    to build it and UIButton silently drops the whole title when you do. The

    retitle runs inside performWithoutAnimation — a system button crossfades

    its title, and this one is retitled on every selection change that crosses a

    heading boundary.

  • **Cmd+1/2/3** — set that level, or return the line to body when it already
  • has it (applyHeading(level:)).

    Headings and lists are mutually exclusive, and each button converts the

    other's line rather than refusing it. Promoting a list item strips the whole

    prefix (- [ ] included — a box without its bullet is meaningless) and its

    indent. A heading is top-level structure, so it severs any ordered run it

    lands in: the items below the new heading are a fresh list and restart at 1,

    while the run above keeps its numbering (renumberOrderedRun treats a heading

    like a blank line — it ends the run). The list button on a heading line strips

    the hashes and applies the list, continuing the numbering of the run above it,

    and the trailing renumber pass — now flowing through where the heading was —

    pulls the restarted run below back into one sequence. Both are whole-line

    structure, so either tap reads as

    "make this line the other thing" — there is nothing to disambiguate and no

    reason to make the user demote by hand first. Blockquotes, images,

    transclusions, rules and fenced code are left untouched, and inside a fence

    # x stays literal text.

    A heading holds one inline style: highlight. Its own face carries the

    hierarchy, so bold/italic/code inside one would compete with the thing it

    exists to express — and a heading is top-level structure, so it takes no

    indent either. That rule is enforced twice over: the engine refuses

    (toggleBold/toggleItalic/toggleCode treat a heading line as

    non-formattable; indent/dedent no-op on one, and their selection forms

    skip heading lines while transforming the rest), and the toolbar renders B, I,

    indent and dedent disabled rather than merely inactive, so the affordance

    itself says "not here". Highlight, bullets and the H button stay live — the

    one style that combines, the alternative structure, and the way back out.

    Markers that arrive from elsewhere (## a b) are preserved in the resource

    and render inert: buildHeading hides them as usual but honors only the

    highlight flag, so the line reads as one uniform face and still round-trips.

    Promoting a styled body line clears the markers a heading cannot hold, so the

    line is what it renders as. Leaving a heading with Enter at the end of a

    styled run steps past those closing markers rather than splitting them — the

    ordinary close-and-reopen split would strand the line's real closers at the

    head of the new line.

    The #-prefix renders hidden (like > , unlike a list's grey - ): the

    hierarchy is carried by size, weight and the space above, per

    ParagraphBuilder.Styles.heading(level:) — h1 26pt bold, h2 21pt semibold

    italic, h3 19pt bold. Size alone left h2 and h3 too easy to confuse, hence

    the slant on h2; h3 is deliberately quiet, sitting just above body size and

    leaning on the space above it rather than a loud face. (Small caps were tried

    here and rejected in the field.) Display text is never transformed —

    uppercased() would make the display diverge from the resource and break every

    offset in the map.

    Two gestures let the user out of a heading, and both matter because the caret

    at a heading's display line start sits past its hidden prefix — so anything

    typed there joins the heading and inherits its face. Backspace at that

    position demotes the line to body before it will delete into the line above

    (Coordinator.performHeaderBackspace); Enter there opens a plain empty

    line above and puts the caret on it, leaving the heading itself untouched.

    Without the latter there was no way to write ordinary text above a heading —

    the text silently became part of it (reported from the field).

    Heading style can never spill past its line because

    normalizeTypingAttributes derives the caret's typing attributes from the

    caret line's own classification, in both directions.

    4. Undo

    UndoHistory (KMP) stores markdown-resource snapshots: pushed before every

    formatting action, every Enter, and debounced 2 s after typing. Undo/redo

    restore a snapshot via setMarkdownSource (full rebuild).

    Fixed failure modes (regression-tested)

    Each of these shipped as a red TDD test in

    NoteEditingRegressionTests.swift

    and is now fixed (tests green):

    1. Enter at the end of a screen-filling note lost the caret (reported as

    "scrolls to top"). Three stacked faults, reproduced and verified fixed in

    the simulator with the software keyboard up:

    (a) intercepted edits suppress the selection-change callback

    (isUpdating), so nothing scrolled at all — finishIntercept now calls

    scrollToCursorIfNeeded();

    (b) scrollRectToVisible treats the area behind the keyboard/toolbar

    as visible (inset-blind), so even when called it left the new line under

    the keyboard — the scroll target is now computed manually against

    adjustedContentInset;

    (c) TextKit answers caret-rect queries for a line added this runloop

    turn with a null/.zero rect until layout runs — a .zero rect

    converted to scroll-view coordinates is the top of the note, which is

    the "scrolls to top" symptom. scrollToCursorIfNeeded now forces layout

    first and safeCaretRectInTextView rejects degenerate rects (with a

    caretRect(for:) primary / firstRect(for:) fallback). The

    keyboard-appearance scroll in EditorToolbarController routes through

    the same hardened path. (The unit harness cannot reproduce the TextKit

    timing — the simulator repro is the verification of record.)

    2. Wrapped bullet lines misaligned: headIndent used fixed constants

    (20 pt / prefixLen × 8.5). Styles.listParagraph(prefix:indentLevel:)

    now measures the rendered prefix width, so the second visual line starts

    exactly where the text after the bullet starts.

    3. No auto-capitalization on a new bullet line: the grey - prefix

    precedes the caret on the display line, so UIKit's .sentences

    autocapitalization never fires there.

    Coordinator.listStartAutocapitalized uppercases a single lowercase

    letter inserted at a list line's content start.

    4. Bullet button lit up on the trailing empty line:

    bulletModeAtCursor clamped end-of-document to the previous line, so

    an empty last line after 1. a\n reported numbered. A caret past a

    trailing newline now reports off; mixed ordered/unordered nesting

    reports the caret line's own kind.

    5. Backspace on a bullet line (caret at content start) now steps the

    item back — dedent one level when indented, else remove the whole prefix

    (list off) — via Coordinator.performListBackspace; it never leaves

    -hello fragments. Relatedly, normalizeListTypingAttributes now

    derives the caret's paragraph-level typing attributes from the current

    line's classification instead of patching the prefix tag once — so after

    a dedent or prefix removal the caret (and the next typed run) drops the

    old level's indent instead of rendering phantom bullet indentation that

    no amount of dedenting could remove.

    6. Ordinals reset after an unordered sub-list: toggleListItem now

    continues ordered numbering from the previous same-indent ordered sibling

    (scanning past deeper-nested sub-lists), and the bullets button only

    switches between ordered/unordered once a list is active — removal is

    backspace/dedent's job.

    7. Note duplicated below the cursor after an edit (field report: select

    lines → backspace → cursor mid-note, everything duplicated below it).

    Root cause: setBodyText (the path an external body update takes — a

    sync landing while the note is open) rebuilt the display without

    refreshing lastReconciledDisplay; the next UIKit-originated

    textViewDidChange then diffed the new display against the old one

    and spliced the whole difference back into the resource. Three fixes:

    setBodyText now refreshes the baseline; display deletions map with a

    deletion-biased range (sourceNSRangeForDeletion) so a selection

    starting at a display line start consumes the line's hidden indent

    instead of orphaning it; and verifyConsistency's length-check +

    self-heal now runs in release builds too, so any residual desync

    rebuilds from resource instead of compounding across edits.

    8. Indent/dedent on normal text now indents by a full list level and

    wraps flush. Markdown representation: keep the leading spaces (2 per

    level) in the resource — this vault's grammar has no indented-code-block

    rule so ≥4 spaces is safe internally (note: strict CommonMark renderers

    would treat 4+ leading spaces as code; if external interop ever matters,

    revisit). Styles.indentedParagraph aligns text indented N levels with

    the text of a bullet indented N times — " text" starts where

    " - item"'s "item" starts (~30pt), a full bullet-level step per

    indent. (Two smaller anchors were rejected in the field as "barely

    indented": the drawn width of two spaces ~9pt, and a top-level bullet's

    ~10pt text column.) The leading spaces stay visible (unlike a list

    line's hidden

    indent) so the caret can walk them and dedent operates on real

    characters; the first line's indent is therefore the target column minus

    their drawn width, and headIndent is the target so wrapped lines stay

    flush.

    9. Typing at the start of a block's text escaped the block (found while

    adding headings; blockquotes had it latent since they shipped). At a

    mid-document line start the insertion mapping stopped at the end of the

    preceding newline segment and returned the source offset before the

    line's hidden block prefix, so typing at the display start of

    prev\n> quoted produced prev\nX> quoted — the quote (or heading) broke.

    At the document start the same caret mapped correctly, which is why it

    went unnoticed. OffsetMap.Segment now carries isBlockPrefix (set by

    ParagraphBuilder for leading indent, > and # — deliberately NOT for

    inline markers, where typing at a line start still means plain text before

    the span), and sourceOffsetForInsertion skips past such a run at a line

    boundary. performHeaderBackspace depends on the corrected mapping to

    recognize "caret at the heading's content start" at all.

    10. **Ordered lists didn't renumber when an item left via deletion or a

    heading conversion.** Renumbering only ran from the deliberate list

    transforms; the deletion routes bypassed it and renumberOrderedRun

    treated a heading as prose (so a run flowed through it). Now

    deleteRange renumbers newline-spanning deletions (backspacing an item,

    or joining a blank away), performListBackspace routes prefix removal

    through the shared FormattingEngine.removeListPrefix (which renumbers)

    instead of a raw splice, and a heading ends an ordered run — promoting an

    item to a heading restarts the list below it at 1, converting back

    rejoins the runs.

    11. Grey list prefixes were styleable and the caret could rest in one.

    Selecting through a - and hitting bold produced - item, which

    stopped classifying as a list. FormattingEngine.toggleFlag now clamps

    styling to each line's content start (the prefix is never wrapped), and

    Coordinator.selectionSnappedOutOfListPrefix snaps a bare caret out of

    the prefix run (ranges are left intact so copy keeps the - ). This also

    closed the latent case of typing at a bullet's display line start, which

    inserted ahead of the marker in resource.