prism branch T-1929/bugfix-note-nav-duplicate-block commits 2 files 8 touched (4 production) lines +539 / -3 targeted tests 78 / 78 passed

Pre-push review: T-1929 note navigation

Two commits ahead of origin/main (PR #415). Note taps in the compact Notes panel and the regular Notes sidebar now resolve to the occurrence the note is actually anchored to, using the headingPath T-209 already stored on every note.

At a glance

  • The bug: both note UIs called onNavigate(note.blockId) with a bare SHA-256 content hash. BlockDOMID.navigationDOMID resolves a bare hash to the first (or first-visible) occurrence — by design, since a bare hash carries no occurrence information. A note on the second identical paragraph therefore always scrolled to the first.
  • The fix: BlockDOMID.noteNavigationTarget(blockId:headingPath:structure:) reverse-looks-up the note's own occurrence via the new DocumentStructure.sourceIndex(forBlockId:headingPath:) and emits the verified composite {parentBlockId}-{sourceIndex}. No signature changed; the views just compute a more specific string.
  • Verified aligned index spaces. documentStructure is built from the full parsedBlocks (DocumentSession.swift:74-92), and WebDocumentStateSynchronizer.computePass() maps that same array. A composite is re-verified downstream by restoreDOMID (hash must still sit at that index), so a stale composite degrades to no scroll, never a wrong one.
  • Verified no missed call sites. coordinator.noteNavigationTarget is written in exactly two places, both converted; the sidebar's orphaned/resolved/document-notes rows carry no navigation gesture at all.
  • Sub-block handling is subtly right. The lookup runs before the suffix strip — indexPathsInSection files row/item ids under the parent's source index, so {hash}-row-0 resolves to the table's index and only then is stripped to {hash}-{n}.
  • Open trade-off: a composite goes down the restoreDOMID branch, which ignores visibleSourceIndices. Nothing on the notes path expands a collapsed section (unlike TOC and search), so a note inside one now lands on the enclosing collapsed heading.

Verdict

Ready to push

The fix is correct, minimal and well-targeted. It invents no new target format — it produces the same verified composite id ({hash}-{sourceIndex}) that TOC navigation, search and scroll restore already feed to BlockDOMID.navigationDOMID, and every failure mode degrades to the exact pre-fix behaviour rather than to a wrong or absent scroll. I independently verified the two things that could have made it silently wrong: the sourceIndex values in headingPathByBlockId and the indices BlockDOMID.map walks are the same index space (both derive from session.parsedBlocks in one synchronous didSet), and no production note-navigation call site was left unconverted. The second commit's determinism fix (lowest matching source index, replacing an unspecified Dictionary enumeration order) is right and pinned by tests.

Nothing here blocks the push. What is left is one behaviour trade-off worth a follow-up ticket — note navigation still does not expand a collapsed section, so a note inside one now lands on the enclosing collapsed heading rather than on a visible duplicate elsewhere — plus two comments in WebDocumentStateSynchronizer that this change made untrue, and a small pile of reuse nits. Per CLAUDE.md the pre-push gate (make build-ios, make build-macos, make test, make test-ui) has not been cleared in full; this review ran a targeted 78/78 instead.

Review findings

9 raised · 0 fixed · 9 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 11

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

Prism lets you attach notes to blocks of a markdown document. The Notes panel (iPhone) and Notes sidebar (iPad/Mac) list every note; tapping one scrolls the document to the block that note belongs to.

Internally, a block is identified by a hash of its content — two identical paragraphs get the identical id. So when you tapped a note, the app said "take me to the block with id abc123", and the renderer, having several blocks with that id, picked the first one. If your note was on the second copy, you were sent to the first copy: same text, wrong place.

Why it matters

Templated and repetitive documents — a table skeleton repeated under every heading, a boilerplate warning paragraph — are common, and every note on anything but the first copy sent you somewhere else in the document.

Key concepts

  • Notes have already stored the chain of headings they live under (their headingPath) since ticket T-209. Nothing new had to be stored.
  • The document keeps an index mapping "{blockId}-{position}" to a heading path.
  • The fix runs that index backwards: given a note's block id and heading path, find the position. Then it asks the renderer for "block abc123 at position 7" instead of just "block abc123".
  • If it cannot work out the position — an old note with no heading path, or a note whose heading has since been renamed — it asks the old question, so nothing gets worse.

Architecture

Prism renders documents through WebKit. Native code stays the source of truth: BlockHTMLEmitter stamps each block with an occurrence-qualified DOM id b-{hash}-{occurrence}, and BlockDOMID is the single place that both writes and resolves that scheme. navigationDOMID(forTarget:) already accepted four target shapes: a DOM id verbatim, a composite {hash}-{sourceIndex} (verified against the block array), a sub-block id (-row-N/-item-N, resolved to its parent), and a bare content hash (first, or first-visible, occurrence).

Note navigation was the only surface still producing the ambiguous fourth shape. TOC, search and scroll restore all produce composites.

The change

Two additions plus three one-line call-site edits:

  • DocumentStructure.sourceIndex(forBlockId:headingPath:) — an O(n) reverse scan of headingPathByBlockId (keyed "{blockId}-{sourceIndex}") for the entry whose path equals the note's. Returns nil for a nil path (legacy notes) or no match (relocated block).
  • BlockDOMID.noteNavigationTarget(blockId:headingPath:structure:) — resolves through the above, strips any sub-block suffix, and returns "{parentBlockId}-{sourceIndex}"; returns the bare blockId unchanged on failure.

Patterns worth noting

  • Resolve before you strip. Sub-block ids are indexed under their parent's source index, so the lookup must run on the unstripped id. Reversing the two lines would break every table-row and list-item note.
  • Total fallback. Every failure path returns the exact string the old code passed, so the change is strictly additive in behaviour.
  • Double verification. The composite is re-checked downstream by restoreDOMID, which bounds-checks the index and confirms the hash still sits there. A bogus composite yields no scroll, not a wrong scroll.

Trade-offs

The composite branch bypasses visibleSourceIndices, the parameter that made a bare-hash target prefer an occurrence not hidden inside a collapsed section. See the Double-check section.

Deep dive

The defect class is information loss at a boundary: T-209 added BlockNote.headingPath to disambiguate note storage and display across duplicate-content blocks, but the navigation call sites kept passing the pre-T-209 bare id. navigationDOMID resolves that ambiguity successfully and silently via firstOccurrenceDOMID, so the bug produced a plausible-looking scroll rather than a failure — nothing short-circuited to surface the gap. The pre-WebKit SharedBlockViews.scrollIdForBlock had the same bug, so this predates the cutover.

Index-space alignment (the thing that could have made this silently wrong)

headingPathByBlockId is built by MarkdownSectionBuilder.build(from: blocks) over blocks.enumerated(); BlockDOMID.map(blocks:) walks the same array in order assigning per-hash occurrence indices. Both are driven from DocumentSession.parsedBlocksdocumentStructure in its didSet, pass.mapped from computePass(), cached on parseRevision. Same array, same index space, no skew window. Note that sourceIndex (array position) and occurrence (per-hash counter) are different numbers; the composite carries the former and restoreDOMID converts.

Prefix-aliasing analysis

hasPrefix("\(blockId)-") over the index is safe because block hashes are dash-free hex and every longer key sharing a prefix has a non-numeric tail: {hash}-row-0-3 and {hash}-item-0-item-1-3 both fail the Int parse and are skipped, and H-item-12-5 does not carry the prefix H-item-1-. The second commit converts the inner if let to guard … else { continue } and takes the minimum, so a skipped key can no longer terminate the scan early either.

Determinism (commit 2)

Heading-path identity genuinely cannot separate two occurrences of the same content under the same heading path. The first commit returned whichever Dictionary entry enumerated first — unspecified, and varying with Swift's per-process hash seed, so the same note could navigate differently between launches. The follow-up takes the lowest matching source index: arbitrary but defined, which is the property a navigation target needs. The doc comment states the limitation and points at the occurrence-identity cluster (T-2045, T-2084–T-2088) that owns the rest.

Edge cases confirmed

  • Preamble blocks are not indexed at all (buildHeadingPathIndex(from: sections)), so their notes carry a nil path and take the fallback.
  • Empty heading path is unreachable: prefix + [text] is never empty.
  • Imported sub-block notes get a nil headingPath by construction (ImportedNotesProcessor.swift:298), and imported notes on ambiguous blocks get nil from the ambiguity-safe headingPath(forBlockId:). Both take the fallback; unchanged behaviour.
  • Negative / overflowing index: unreachable here, and re-verified downstream by restoreDOMID's sourceIndex >= 0 and bounds check.

Actor-isolation note

BlockDOMID is declared nonisolated and CLAUDE.md documents the emit path it serves as running off-MainActor on a Task.detached. Its new member takes DocumentStructure, which carries no nonisolated and is therefore implicitly @MainActor under this project's SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. It compiles only because SWIFT_VERSION = 5.0 leaves concurrency checking minimal, and the only callers are MainActor views, so there is no live defect — but the file's stated contract is now weaker than it reads.

Completeness assessment

Fully implemented: occurrence-correct navigation for paragraph, heading, table-row and list-item note anchors, from both the compact panel and the regular sidebar, with total fallback for legacy and relocated notes, and a defined answer for same-heading duplicates.

Partially implemented: collapsed sections — the note's occurrence is now resolved correctly but the section is not expanded and the visible-occurrence preference no longer applies, so the scroll degrades to the nearest rendered section.

Deliberately out of scope (named in the report, separate open tickets): indicators/bubbles ignoring headingPath (T-2045), export at every occurrence (T-2084), grouping ignoring occurrence scope (T-2085), section metadata (T-2086), fuzzy relocation (T-2087), replies losing occurrence identity (T-2088).

Important changes — detailed

MarkdownSection: reverse lookup from (blockId, headingPath) to source index

prism/Models/MarkdownSection.swift

Why it matters. This is the whole fix in one function. It is also the third near-identical prefix scan over headingPathByBlockId in this file, and the one place where the composite key format is parsed rather than built.

What to look at. prism/Models/MarkdownSection.swift:193-228 (sourceIndex(forBlockId:headingPath:))

Takeaway. When an index is keyed on a composite you build, you can usually run it backwards for free rather than adding a second index. Just keep the parse total: here a key whose tail is not an Int (a sub-block entry sharing the prefix) must be skipped and the scan continued, not treated as a miss.
Rationale. The report rejected changing navigationDOMID to take a headingPath, because that would push a DocumentStructure dependency onto TOC, search and scroll-restore callers that do not need it; and rejected resolving inside WebDocumentStateSynchronizer, because the coordinator only carries the string, so the note's headingPath would have to be carried as a second piece of state kept in sync with the block id.

BlockDOMID.noteNavigationTarget: build the verified composite, strip the sub-block suffix after

prism/Services/WebRendering/BlockDOMID.swift

Why it matters. The ordering here is load-bearing and easy to get backwards. Row and item ids are indexed under their PARENT block's source index, so the lookup must run on the unstripped id; only the composite's left half is stripped. Reversing the two statements silently breaks every table-row and list-item note.

What to look at. prism/Services/WebRendering/BlockDOMID.swift:124-155 (noteNavigationTarget)

Takeaway. A resolver that falls back by returning its own input unchanged is strictly additive: every path that used to work still works byte-for-byte, so the blast radius is exactly the cases the fix targets. Worth reaching for when retrofitting precision onto an ambiguous identifier.
Rationale. Reuses the composite-id verification path that TOC navigation, search and scroll restore already exercise, rather than adding a fifth target shape to navigationDOMID's grammar.

Three call sites converted; onNavigate keeps its (String) -> Void signature

prism/Views/NotesPanel.swift

Why it matters. The two note UIs were the only producers of the ambiguous bare-hash target. I confirmed coordinator.noteNavigationTarget is written in exactly two places and both are converted, and that the sidebar's orphaned, resolved and document-notes rows carry no navigation gesture.

What to look at. prism/Views/NotesPanel.swift:355-359 and :420-424; prism/Views/SidebarNotesView.swift:212-216

Takeaway. Resolving at the tap site, where both the note and the structure are already in scope, avoids threading a second correlated value (headingPath) through the coordinator where it could drift out of sync with the block id.
Rationale. Stated in the bugfix report's Approach rationale: no call-site signature changed, the views just compute a more specific string.

Determinism follow-up: lowest matching source index, not first enumerated

prism/Models/MarkdownSection.swift

Why it matters. Dictionary iteration order is unspecified and varies with Swift's per-process hash seed. Two identical blocks under the SAME heading both match the lookup, so the first commit could send the same note to a different occurrence on each launch.

What to look at. prism/Models/MarkdownSection.swift:201-228 (earliest accumulator; guard/continue replaces if-let/return)

Takeaway. "Any of the matches" is not a specification when the container is unordered. If a tie cannot be broken on merit, break it on a stable, documented rule (document order) and say in the comment which identity would actually be needed to break it properly.
Rationale. Commit message and the report's new Known limitation section: the choice is arbitrary but must be defined; genuinely separating those occurrences needs an identity headingPathByBlockId does not carry, which is the T-2045 / T-2084-T-2088 cluster.

Key decisions

Reuse the existing composite-id format rather than extend navigationDOMID's grammar.

navigationDOMID already accepts {hash}-{sourceIndex} and verifies it against the block array. Emitting that shape means note navigation now shares one resolution path with TOC, search and scroll restore instead of getting a bespoke one. It also means the downstream verification (restoreDOMID bounds-checks the index and confirms the hash still sits there) applies for free, so a stale composite yields no scroll rather than a wrong scroll.

Fall back to the bare block id on every failure, rather than failing the navigation.

A nil headingPath (legacy notes, preamble blocks, imported sub-block anchors) or a path that no longer matches (relocated block) returns the input unchanged, which reproduces the pre-fix behaviour exactly. The change can therefore only improve a case, never remove a working one.

Resolve at the tap site, not in the coordinator or the synchronizer.

Rejected in the report: the coordinator carries only the target string, so resolving downstream would mean carrying the note's headingPath alongside the block id as a second piece of state kept in sync, for no benefit over resolving once where both the note and the DocumentStructure are already in scope.

Break the same-heading tie on document order.

Heading-path identity cannot separate two occurrences of the same content under one heading. The follow-up commit takes the lowest matching source index — arbitrary, but defined — and the doc comment names the occurrence-identity cluster (T-2045, T-2084–T-2088) as the owner of a real solution.

Collapsed sections were not considered.

Neither the report nor the code comments mention visibleSourceIndices or collapsed sections, and the composite branch bypasses the visible-occurrence preference the bare-hash branch honours. Reading this as an oversight rather than a decision, since the effect is not stated anywhere.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorBlockDOMID / WebDocumentStateSynchronizer — collapsed sectionsA composite target goes down restoreDOMID, which ignores visibleSourceIndices entirely; the bare-hash branch honours it. Nothing on the notes path expands the enclosing section (contrast DocumentSession.navigateToHeading for TOC and expandAncestorsForCurrentMatch for search). Collapsed blocks stay in the DOM but are display:none via data-prism-section-hidden, so prism-scroll.js falls through isSectionRendered to nearestRenderedSection and lands the user on the enclosing collapsed heading. Previously, a note whose own occurrence was collapsed but which had a visible duplicate scrolled to that visible duplicate.Not fixed — read-only review. Judged non-blocking: a note on UNIQUE content inside a collapsed section already behaved exactly this way, so the change makes duplicate-content notes consistent with every other note rather than introducing a new failure class, and landing on the right (collapsed) heading is arguably better than landing on a visually identical wrong block elsewhere. Recommend a follow-up ticket to expand the section on note navigation, mirroring navigateToHeading — that fixes both cases at once.
minorWebDocumentStateSynchronizer.swift — stale commentsLine 342 still describes note targets as "bare content hashes or row/item sub-ids"; they can now be composites. Lines 384-385, above scrollToTarget, still claim "Collapsed sections hide their blocks in the DOM, so duplicate-content targets prefer a visible occurrence", which is no longer true for the note path the comment sits above.Not fixed — read-only review. Both are one-line comment edits and are the cheapest thing in this list; worth doing before merge so the next reader is not misled about which branch runs.
minorBlockDOMID.swift — actor isolationBlockDOMID is declared `nonisolated` and CLAUDE.md documents the emit path it serves as running off-MainActor on a Task.detached. Its new member takes DocumentStructure, which carries no `nonisolated` annotation and is therefore implicitly @MainActor under this project's SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. It compiles only because SWIFT_VERSION = 5.0 leaves concurrency checking minimal.Not fixed — read-only review. No live defect: the only callers are MainActor views. But the file's stated purity contract is now weaker than it reads, and this becomes an error under Swift 6 mode. Cheapest fix is to mark MarkdownSection / DocumentStructure / VisibleBlock `nonisolated` — they are pure value types over an already-nonisolated MarkdownBlock, so it costs nothing.
minorMarkdownSection.swift — duplicationheadingPath(forBlockId:), sourceIndex(forBlockId:headingPath:) and allHeadingPaths(forBlockId:) each spell out `let prefix = "\(blockId)-"` and the same `for (key, path) in headingPathByBlockId where key.hasPrefix(prefix)` scan. Separately, allHeadingPaths has exactly one caller (RelocationEngine.swift:127-131) which uses it as `candidatePaths.contains(noteHeadingPath)` — that is now exactly `sourceIndex(forBlockId:headingPath:) != nil`, so the new function subsumes it.Not fixed — read-only review. Suggest a private `forEachOccurrence(ofBlockId:_:)` helper all three build on, which also centralises the key-parse convention that only sourceIndex currently knows. Retiring allHeadingPaths in favour of the new lookup would take three scans down to two. I checked RelocationEngine: there was no pre-existing blockId+headingPath lookup to reuse, so the new function itself is not redundant.
nitBlockDOMID.swift — hand-rolled id formatnoteNavigationTarget builds "\(parentBlockId)-\(sourceIndex)" inline, inside the file whose header claims to be "the single source of truth for the occurrence-qualified DOM id scheme" and which already exposes id(contentHash:occurrence:) for the b- form. The composite form is hand-written in at least nine places across the codebase; the inverse parse is hand-rolled twice within this same file.Not fixed — read-only review. Suggest `compositeID(contentHash:sourceIndex:)` (and a matching parse) next to the existing builder. The project has explicit precedent: MarkdownBlock.nestedListItemId is documented as the single source of truth for the dotted-id format with "callers should delegate to this helper rather than reconstruct the format inline (T-1144)".
nitNotesPanel.swift / SidebarNotesView.swift — call-site duplicationThe identical five-line noteNavigationTarget(blockId:headingPath:structure:) call appears three times. The pairing of note.blockId with note.headingPath is precisely the thing that must not drift, so it should be expressed once.Not fixed — read-only review. A `noteNavigationTarget(for note: BlockNote, in structure: DocumentStructure)` overload reduces all three sites to one line.
nitMarkdownSection.swift — where-clause ordering`where path == headingPath && key.hasPrefix(prefix)` evaluates the element-wise [String] compare against every key in the document before the cheap, highly selective prefix test.Not fixed — read-only review. Swapping the operands also matches the two sibling scans in the same file, which both filter on the prefix first.
nitBlockDOMIDNoteNavigationTests.swiftFour tests are declared `throws` but contain no `try`. listItemNoteResolvesParentOccurrence uses `list.listItemId(at: 0)!`, where `#require` would be idiomatic and would justify the keyword.Not fixed — read-only review, and test files are out of scope for review fixes anyway.
nitProcess — pre-push gateThe bugfix report leaves `make test-quick` / `make test` unrun and does not mention `make build-ios` or `make test-ui`. CLAUDE.md's before-pushing rule requires both builds plus test and test-ui, and since PR #414 no push or pull request runs any test in CI, so a green PR says nothing.Partially covered: this review ran the four affected classes from an isolated git-archive export and got 78/78 with the result bundle verified by Tools/check-test-results.sh. That run predates the determinism commit, so its two new tests were not executed here. Clear the full gate before pushing.

Tests

Source: local run at 2026-09-06T14:45:00+10:00 · snapshot f32a5ab9f8de5657e032d0bf896c7a656fa7ea66

Baseline: none

Execution: passed · JUnit: none · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

No test results

No test results were read from the inputs.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 118ef1da0dd53d5036b3740c96cedd3578b6643d.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

prism/Models/MarkdownSection.swift Modified +35 / -0
diff --git a/prism/Models/MarkdownSection.swift b/prism/Models/MarkdownSection.swiftindex bf73e69d..4a3d96aa 100644--- a/prism/Models/MarkdownSection.swift+++ b/prism/Models/MarkdownSection.swift@@ -190,6 +190,41 @@ extension DocumentStructure {         return found     } +    /// Returns the source index of the specific occurrence of `blockId` whose+    /// heading path equals `headingPath`, disambiguating duplicate-content+    /// blocks that live under different headings (T-1929).+    ///+    /// Returns nil when `headingPath` is nil (legacy notes carry no heading+    /// path to disambiguate with) or when no occurrence's heading path+    /// matches — the block has since been relocated out from under that+    /// heading, so the caller falls back to first-occurrence behaviour.+    ///+    /// Known limitation, stated plainly: heading-path identity CANNOT+    /// distinguish two occurrences of the same content under the SAME heading+    /// path (the identical paragraph pasted twice directly under one `##`).+    /// Both occurrences match equally, so the earliest in document order — the+    /// lowest source index — is chosen deliberately. The choice is arbitrary+    /// but it must be DEFINED: `headingPathByBlockId` is a `Dictionary`, whose+    /// iteration order is unspecified and varies with Swift's per-process hash+    /// seed, so returning the first match encountered made a note navigate to+    /// a different occurrence from one launch to the next. Telling those+    /// occurrences apart needs an identity this index does not carry; that is+    /// the occurrence-identity cluster (T-2045, T-2084–T-2088), not this+    /// lookup.+    func sourceIndex(forBlockId blockId: String, headingPath: [String]?) -> Int? {+        guard let headingPath else { return nil }+        let prefix = "\(blockId)-"+        var earliest: Int?+        for (key, path) in headingPathByBlockId+        where path == headingPath && key.hasPrefix(prefix) {+            guard let sourceIndex = Int(key.dropFirst(prefix.count)) else { continue }+            if sourceIndex < (earliest ?? Int.max) {+                earliest = sourceIndex+            }+        }+        return earliest+    }+     /// Returns all heading paths for blocks with the given ID.     ///     /// Used by `RelocationEngine` to check if a note's stored heading path
prism/Services/WebRendering/BlockDOMID.swift Modified +31 / -0
diff --git a/prism/Services/WebRendering/BlockDOMID.swift b/prism/Services/WebRendering/BlockDOMID.swiftindex edc0540c..1ed3cd36 100644--- a/prism/Services/WebRendering/BlockDOMID.swift+++ b/prism/Services/WebRendering/BlockDOMID.swift@@ -121,6 +121,37 @@ nonisolated enum BlockDOMID {         )     } +    /// Resolves a note's stored `(blockId, headingPath)` to an occurrence-aware+    /// navigation target, disambiguating duplicate-content blocks that live+    /// under different headings (T-1929). Feed the result to+    /// `navigationDOMID(forTarget:...)` exactly as a bare block id was fed+    /// before this existed.+    ///+    /// `blockId` may carry a sub-block suffix (`-row-N`, `-row-header`,+    /// `-item-N`); the suffix is stripped before building the composite+    /// target, since navigation always resolves a sub-block id to its+    /// PARENT block's DOM id — table rows and list items are not+    /// independently scrollable sections.+    ///+    /// Falls back to the bare `blockId` unchanged — leaving+    /// `navigationDOMID` to pick the first (or first-visible) occurrence,+    /// the pre-fix behaviour — when `headingPath` is nil (legacy notes+    /// carry no heading path to disambiguate with) or when no occurrence's+    /// heading path matches (the block was relocated since the note was+    /// anchored, `DocumentStructure.sourceIndex(forBlockId:headingPath:)`+    /// has nothing to verify against).+    static func noteNavigationTarget(+        blockId: String,+        headingPath: [String]?,+        structure: DocumentStructure+    ) -> String {+        guard let sourceIndex = structure.sourceIndex(forBlockId: blockId, headingPath: headingPath) else {+            return blockId+        }+        let parentBlockId = strippingSubBlockSuffix(blockId)+        return "\(parentBlockId)-\(sourceIndex)"+    }+     /// Strips a trailing sub-block suffix (`-row-N`, `-row-header`, `-item-N`,     /// nested `-item-N-item-M`) from a note-anchor id, yielding the parent     /// block's content hash. Returns the input unchanged when no suffix matches
prism/Views/NotesPanel.swift Modified +10 / -2
diff --git a/prism/Views/NotesPanel.swift b/prism/Views/NotesPanel.swiftindex 3729dce6..6138024c 100644--- a/prism/Views/NotesPanel.swift+++ b/prism/Views/NotesPanel.swift@@ -352,7 +352,11 @@ struct NotesPanel: View {                         .contentShape(Rectangle())                         .onTapGesture {                             onDismiss()-                            onNavigate(note.blockId)+                            onNavigate(BlockDOMID.noteNavigationTarget(+                                blockId: note.blockId,+                                headingPath: note.headingPath,+                                structure: structure+                            ))                         }                         .noteActions(for: note, using: notesManager, onEdit: { editingNote = note })                 }@@ -413,7 +417,11 @@ struct NotesPanel: View {                                         // Navigate unconditionally — if the block no longer                                         // exists, the scroll handler safely no-ops.                                         onDismiss()-                                        onNavigate(note.blockId)+                                        onNavigate(BlockDOMID.noteNavigationTarget(+                                            blockId: note.blockId,+                                            headingPath: note.headingPath,+                                            structure: structure+                                        ))                                     }                                     .noteActions(for: note, using: notesManager, onEdit: { editingNote = note })                             }
prism/Views/SidebarNotesView.swift Modified +5 / -1
diff --git a/prism/Views/SidebarNotesView.swift b/prism/Views/SidebarNotesView.swiftindex 402d2e10..42e05041 100644--- a/prism/Views/SidebarNotesView.swift+++ b/prism/Views/SidebarNotesView.swift@@ -209,7 +209,11 @@ struct SidebarNotesView: View {                             .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))                             .contentShape(Rectangle())                             .onTapGesture {-                                onNavigate(note.blockId)+                                onNavigate(BlockDOMID.noteNavigationTarget(+                                    blockId: note.blockId,+                                    headingPath: note.headingPath,+                                    structure: structure+                                ))                             }                             .noteActions(for: note, using: notesManager, onEdit: { editingNote = note })                             .accessibilityHint(LocalizedStringKey("Navigate to this note in the document"))
prismTests/BlockDOMIDNoteNavigationTests.swift Added +153 / -0
diff --git a/prismTests/BlockDOMIDNoteNavigationTests.swift b/prismTests/BlockDOMIDNoteNavigationTests.swiftnew file mode 100644index 00000000..ce316c92--- /dev/null+++ b/prismTests/BlockDOMIDNoteNavigationTests.swift@@ -0,0 +1,153 @@+//+//  BlockDOMIDNoteNavigationTests.swift+//  prismTests+//+//  Regression coverage for T-1929: note navigation from NotesPanel and+//  SidebarNotesView must resolve to the SPECIFIC occurrence a note's stored+//  headingPath identifies, not always the first occurrence of duplicate+//  content. Before the fix, `BlockDOMID.navigationDOMID` received a bare+//  content hash and always resolved via `firstOccurrenceDOMID`, so tapping a+//  note on a later duplicate scrolled to the first one instead.+//++import Foundation+import Testing+@testable import prism++@Suite("BlockDOMID.noteNavigationTarget (T-1929)")+struct BlockDOMIDNoteNavigationTests {++    @Test("A note on the second occurrence of duplicate content resolves to that occurrence, not the first")+    func resolvesSecondOccurrence() throws {+        let para = MarkdownBlock.paragraph(markdown: "Duplicate content")+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section A"),+            para,+            .heading(level: 1, text: "Section B"),+            para+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        let target = BlockDOMID.noteNavigationTarget(+            blockId: para.id,+            headingPath: ["Section B"],+            structure: structure+        )+        #expect(target == "\(para.id)-3")++        let mapping = BlockDOMID.map(blocks: blocks)+        let resolved = BlockDOMID.navigationDOMID(forTarget: target, blocks: blocks)+        #expect(resolved == mapping[3].domID, "must land on the second occurrence's DOM id")+        #expect(resolved != mapping[1].domID, "must not land on the first occurrence")+    }++    @Test("A note on the first occurrence of duplicate content resolves to that occurrence")+    func resolvesFirstOccurrence() throws {+        let para = MarkdownBlock.paragraph(markdown: "Duplicate content")+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section A"),+            para,+            .heading(level: 1, text: "Section B"),+            para+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        let target = BlockDOMID.noteNavigationTarget(+            blockId: para.id,+            headingPath: ["Section A"],+            structure: structure+        )+        #expect(target == "\(para.id)-1")++        let mapping = BlockDOMID.map(blocks: blocks)+        let resolved = BlockDOMID.navigationDOMID(forTarget: target, blocks: blocks)+        #expect(resolved == mapping[1].domID)+    }++    @Test("A legacy note with no headingPath falls back to the bare block id")+    func legacyNoteFallsBackToBareBlockId() {+        let para = MarkdownBlock.paragraph(markdown: "Some content")+        let blocks: [MarkdownBlock] = [para]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        let target = BlockDOMID.noteNavigationTarget(+            blockId: para.id,+            headingPath: nil,+            structure: structure+        )+        #expect(target == para.id)+    }++    @Test("A stale headingPath that matches no current occurrence falls back to the bare block id")+    func staleHeadingPathFallsBack() {+        let para = MarkdownBlock.paragraph(markdown: "Some content")+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section A"),+            para+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        let target = BlockDOMID.noteNavigationTarget(+            blockId: para.id,+            headingPath: ["No Longer There"],+            structure: structure+        )+        #expect(target == para.id)+    }++    @Test("A table-row note anchor resolves to its parent block's specific occurrence")+    func tableRowNoteResolvesParentOccurrence() throws {+        let table = MarkdownBlock.table(+            headers: ["A", "B"],+            rows: [["1", "2"]],+            alignments: [.leading, .leading]+        )+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section A"),+            table,+            .heading(level: 1, text: "Section B"),+            table+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        let rowId = "\(table.id)-row-0"+        let target = BlockDOMID.noteNavigationTarget(+            blockId: rowId,+            headingPath: ["Section B"],+            structure: structure+        )+        // The suffix is stripped: rows aren't independently scrollable, so+        // the target is the PARENT table's composite id at its own occurrence.+        #expect(target == "\(table.id)-3")++        let mapping = BlockDOMID.map(blocks: blocks)+        let resolved = BlockDOMID.navigationDOMID(forTarget: target, blocks: blocks)+        #expect(resolved == mapping[3].domID)+    }++    @Test("A list-item note anchor resolves to its parent block's specific occurrence")+    func listItemNoteResolvesParentOccurrence() throws {+        let list = MarkdownBlock.list(ordered: false, start: 1, items: [+            ListItem(content: "Duplicate item", checkbox: nil)+        ])+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section A"),+            list,+            .heading(level: 1, text: "Section B"),+            list+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        let itemId = list.listItemId(at: 0)!+        let target = BlockDOMID.noteNavigationTarget(+            blockId: itemId,+            headingPath: ["Section B"],+            structure: structure+        )+        #expect(target == "\(list.id)-3")++        let mapping = BlockDOMID.map(blocks: blocks)+        let resolved = BlockDOMID.navigationDOMID(forTarget: target, blocks: blocks)+        #expect(resolved == mapping[3].domID)+    }+}
prismTests/MarkdownSectionBuilderTests.swift Modified +83 / -0
diff --git a/prismTests/MarkdownSectionBuilderTests.swift b/prismTests/MarkdownSectionBuilderTests.swiftindex 0125b765..ab318e20 100644--- a/prismTests/MarkdownSectionBuilderTests.swift+++ b/prismTests/MarkdownSectionBuilderTests.swift@@ -954,6 +954,89 @@ struct HeadingPathIndexTests {         let structure = MarkdownSectionBuilder.build(from: blocks)         #expect(structure.headingPath(forBlockId: "nonexistent-id-1234") == nil)     }++    // MARK: - sourceIndex(forBlockId:headingPath:) (T-1929)++    @Test("sourceIndex finds the occurrence whose heading path matches, among duplicates")+    func sourceIndexFindsMatchingOccurrence() {+        let para = MarkdownBlock.paragraph(markdown: "Duplicate content")+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section A"),+            para,+            .heading(level: 1, text: "Section B"),+            para+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        #expect(structure.sourceIndex(forBlockId: para.id, headingPath: ["Section A"]) == 1)+        #expect(structure.sourceIndex(forBlockId: para.id, headingPath: ["Section B"]) == 3)+    }++    @Test("sourceIndex returns nil when headingPath is nil")+    func sourceIndexReturnsNilForNilHeadingPath() {+        let para = MarkdownBlock.paragraph(markdown: "Body")+        let blocks: [MarkdownBlock] = [.heading(level: 1, text: "Section"), para]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        #expect(structure.sourceIndex(forBlockId: para.id, headingPath: nil) == nil)+    }++    @Test("sourceIndex picks the first occurrence in document order among same-heading duplicates")+    func sourceIndexPicksFirstOccurrenceUnderSameHeading() {+        // Heading-path identity cannot tell these two apart — both live under+        // ["Section"], so both are equally valid matches. The lookup must pick+        // the earliest in document order rather than whichever Dictionary entry+        // happens to enumerate first, or the same note navigates somewhere+        // different from one launch to the next (per-process hash seeding).+        let para = MarkdownBlock.paragraph(markdown: "Duplicate content")+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section"),+            para,+            .paragraph(markdown: "Separator"),+            para+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        #expect(structure.sourceIndex(forBlockId: para.id, headingPath: ["Section"]) == 1)++        // Rebuilding must not change the answer: the result is a property of+        // document order, not of dictionary iteration order.+        for _ in 0..<8 {+            let rebuilt = MarkdownSectionBuilder.build(from: blocks)+            #expect(rebuilt.sourceIndex(forBlockId: para.id, headingPath: ["Section"]) == 1)+        }+    }++    @Test("noteNavigationTarget is deterministic for same-heading duplicates")+    func noteNavigationTargetDeterministicUnderSameHeading() {+        let para = MarkdownBlock.paragraph(markdown: "Duplicate content")+        let blocks: [MarkdownBlock] = [+            .heading(level: 1, text: "Section"),+            para,+            .paragraph(markdown: "Separator"),+            para+        ]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        let target = BlockDOMID.noteNavigationTarget(+            blockId: para.id,+            headingPath: ["Section"],+            structure: structure+        )+        #expect(target == "\(para.id)-1")++        let mapping = BlockDOMID.map(blocks: blocks)+        #expect(BlockDOMID.navigationDOMID(forTarget: target, blocks: blocks) == mapping[1].domID)+    }++    @Test("sourceIndex returns nil when no occurrence's heading path matches")+    func sourceIndexReturnsNilForNonMatchingHeadingPath() {+        let para = MarkdownBlock.paragraph(markdown: "Body")+        let blocks: [MarkdownBlock] = [.heading(level: 1, text: "Section"), para]+        let structure = MarkdownSectionBuilder.build(from: blocks)++        #expect(structure.sourceIndex(forBlockId: para.id, headingPath: ["Elsewhere"]) == nil)+    } }  private struct SectionSeededRNG: RandomNumberGenerator {
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex f75bfbce..4d1c62ae 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Tapping a note in the compact Notes panel or the regular Notes sidebar now scrolls to the block that note is actually anchored to, instead of always the first occurrence of identical content elsewhere in the document (T-1929). Both note UIs navigated by passing the note's bare content-hash block id, which `BlockDOMID` resolves to its first (or first-visible) occurrence by design — the note's own stored heading path, already recorded for note storage/display disambiguation (T-209), was never consulted. Navigation now resolves the note's specific occurrence against that heading path and builds the same verified composite target TOC/search/scroll-restore already use, falling back to the previous first-occurrence behaviour only when a note carries no heading path (legacy notes) or its heading path no longer matches any occurrence (the block was relocated). A table-row or list-item note anchor resolves to its parent block's correct occurrence, since rows and items are not independently scrollable. - `HTMLImageSourceRewriter` no longer re-emits a mediated `src`/`srcset` value with an embedded quote character left unescaped (T-1942). The rewriter always re-emits these attributes double-quoted, but a value could still contain a raw `"`: the `rewrite` closure's `data:` passthrough hands unencoded `data:` URIs back unchanged, and `rewriteSrcset` re-joins a candidate's descriptor half — never passed through `rewrite` — verbatim. Either one could terminate the re-emitted attribute early, splicing the remainder into attribute position (e.g. `srcset='a.png 1x" onerror=… z='`). The value is now escaped for its double-quoted context before being written, so any quote characters it carries round-trip intact instead of breaking out. The escape leaves a character reference the value already carries alone (the `data:` passthrough re-emits the attribute's text as written, references undecoded, and the browser decodes it once), so an SVG `data:` URI whose author correctly wrote `&amp;amp;` for a literal ampersand still renders that ampersand rather than the reference. This is defence in depth rather than a live exploit: the subsequent `HTMLSanitizer` (SwiftSoup) pass is the actual security boundary and already reduced the broken-out remainder to non-allowlisted junk. - A document opened from a URL that redirects now resolves its relative images and links against the address the content was actually served from, not the one you typed (T-1810). A version alias, a shortened link or a page that has moved to another folder or host sends the request on to its final address, and the app kept the original one as the base for everything relative in the document, so every image beside it was looked up in the wrong place and showed the error placeholder. The address shown in the title, in recents and in the notes store is unchanged: it stays the one you opened. Refreshing such a document asks for the address you opened again — not for wherever it redirected to last time — and follows the redirect afresh, so an alias that has been re-pointed since the document was opened picks up its new target, and the image base moves with it; the refresh costs one page reload, not one for the new content and another for the new base. - SVG diagrams that adapt to light/dark mode via CSS `@media (prefers-color-scheme: dark)` now rasterize using the appearance Prism actually asked for, instead of whatever the offscreen render window (macOS) or app (iOS) happened to be reporting at the time (T-1896). `SVGRenderer` accepted a `colorScheme` parameter but never used it past the cache key, so a light-appearance SVG could render with its dark-mode colors — or vice versa — while being cached under the *other* scheme's key, compounding the mismatch on the next lookup. The renderer now forces its WebView's own appearance (`overrideUserInterfaceStyle` on iOS, `NSAppearance` on macOS) to match the requested scheme before every render, not only when the WebView is first created — the pool reuses WebViews across calls that can request a different scheme each time.
specs/bugfixes/note-nav-duplicate-block/report.md Added +221 / -0
diff --git a/specs/bugfixes/note-nav-duplicate-block/report.md b/specs/bugfixes/note-nav-duplicate-block/report.mdnew file mode 100644index 00000000..c159e755--- /dev/null+++ b/specs/bugfixes/note-nav-duplicate-block/report.md@@ -0,0 +1,221 @@+# Bugfix Report: Note navigation targets the wrong duplicate-content block++**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++Tapping a note in the compact Notes panel (`NotesPanel`) or the regular+Notes sidebar (`SidebarNotesView`) scrolled the document to the FIRST+occurrence of the note's block content, even when the note was created on a+later occurrence of identical content appearing under a different heading.++**Reproduction steps:**+1. Create two identical paragraphs (or tables/lists) under different headings.+2. Add a note to the second occurrence.+3. Select that note in the compact Notes panel or regular Notes sidebar.++**Actual:** the document scrolls to the first visible occurrence of the+identical content.++**Expected:** it scrolls to the occurrence the note was actually anchored+to (identified by its stored heading path).++**Impact:** Any document with duplicate-content blocks (a common pattern in+templated or repetitive markdown, e.g. repeated table skeletons or+boilerplate paragraphs under multiple headings) sends the user to the wrong+place when navigating from a note anchored to anything but the first such+occurrence.++## Investigation Summary++- **Symptoms examined:** `NotesPanel.swift:355` and `SidebarNotesView.swift:212`+  call `onNavigate(note.blockId)`, passing only the note's bare SHA-256+  content-hash block id.+- **Code inspected:** `DocumentLayoutCoordinator.noteNavigationTarget`+  (stores the bare string unchanged), `WebDocumentStateSynchronizer.scrollToTarget`+  (feeds it straight to `BlockDOMID.navigationDOMID`), and+  `BlockDOMID.navigationDOMID`/`firstOccurrenceDOMID` (resolves a bare+  content hash to the first, or first-visible, occurrence -- by design, for+  ids that carry no occurrence information).+- **Hypotheses tested:** whether the resolution bug lived in `BlockDOMID`+  itself -- ruled out: `BlockDOMID` already supports a verified composite+  target format (`{hash}-{sourceIndex}`, used by TOC/search/scroll-restore)+  that resolves to an exact occurrence. The defect is that notes never+  produced one of these; they always produced the ambiguous bare-hash form.+- Confirmed that `BlockNote.headingPath` (added by T-209 to disambiguate+  note *display*/*storage*) was available on every note but never consulted+  by navigation, and that `DocumentStructure.headingPathByBlockId` (keyed+  `"{blockId}-{sourceIndex}"`) already indexes every occurrence, including+  sub-block (row/item) ids under their parent's source index -- everything+  needed to resolve the correct occurrence was already present, just+  unused by the navigation path.++## Discovered Root Cause++**Defect type:** Missing information propagation -- a value (occurrence+identity) that was already computed and stored was discarded before it+reached the one place that needed it.++**Why it occurred:** T-209 added `headingPath` to `BlockNote` to+disambiguate note *storage and display* for duplicate-content blocks, but+the navigation call sites (`onNavigate(note.blockId)`) were never updated+to use it -- they kept passing the bare block id that pre-dates T-209 (and,+per the ticket, pre-dates the WebKit cutover: the retired+`SharedBlockViews.scrollIdForBlock` had the identical bug).++**Contributing factors:** `BlockDOMID.navigationDOMID` silently and+correctly resolves a bare hash to *a* valid occurrence rather than failing,+so the bug produces a plausible-looking (just wrong) scroll target instead+of a visible error -- nothing short-circuited to surface the gap.++## Resolution for the Issue++**Changes made:**+- `prism/Models/MarkdownSection.swift` -- added+  `DocumentStructure.sourceIndex(forBlockId:headingPath:)`, an O(n) reverse+  lookup over `headingPathByBlockId` that finds the specific occurrence of+  `blockId` (including sub-block/row/item ids, which are indexed under+  their parent's source index) whose heading path equals the one supplied.+  Returns `nil` when `headingPath` is `nil` (legacy notes) or no occurrence+  matches (the block was relocated).+- `prism/Services/WebRendering/BlockDOMID.swift` -- added+  `BlockDOMID.noteNavigationTarget(blockId:headingPath:structure:)`, which+  calls the above, strips any sub-block suffix (rows/items resolve to their+  parent block's DOM id -- they are not independently scrollable), and+  builds the verified composite target `"{parentBlockId}-{sourceIndex}"`+  that `navigationDOMID` already knows how to resolve exactly. Falls back+  to the original bare `blockId` -- i.e. the pre-fix, first-occurrence+  behaviour -- when the occurrence can't be determined.+- `prism/Views/NotesPanel.swift` -- both note-tap call sites+  (`activeNotesSection`, `resolvedNotesSection`) now call+  `BlockDOMID.noteNavigationTarget(blockId:headingPath:structure:)` instead+  of passing `note.blockId` directly.+- `prism/Views/SidebarNotesView.swift` -- the grouped-notes tap call site+  does the same.++**Known limitation (deliberate, PR review round 1):** heading-path identity+cannot distinguish two occurrences of the same content under the SAME+heading path -- the identical paragraph pasted twice directly under one+`##`. Both occurrences match the lookup equally. The first review round+found that the lookup returned whichever `Dictionary` entry enumerated+first, which is unspecified and varies with Swift's per-process hash seed,+so the same note could navigate to a different occurrence from one launch+to the next. The lookup now returns the LOWEST matching source index --+first in document order. That choice is arbitrary, but defined, which is+what a navigation target needs. Telling those occurrences apart needs an+identity `headingPathByBlockId` does not carry, and that is the+occurrence-identity cluster (T-2045, T-2084--T-2088), not this fix.++**Approach rationale:** The fix reuses the existing, already-tested+composite-id verification path in `BlockDOMID` (the same mechanism TOC+navigation, search, and scroll restore use) rather than inventing a new+target format or changing `navigationDOMID`'s resolution rules. No call+site signature changed -- `onNavigate: (String) -> Void` still takes a+plain string; the views just compute a more specific string before calling+it.++**Alternatives considered:**+- **Change `BlockDOMID.navigationDOMID` to accept a heading path+  parameter directly** -- rejected: it would need to take on the+  `DocumentStructure` dependency it currently doesn't have, widening its+  surface for every caller (TOC, search, scroll-restore) that doesn't need+  heading-path disambiguation.+- **Resolve the target inside `WebDocumentStateSynchronizer`** -- rejected:+  the synchronizer only sees the string already stored on+  `coordinator.noteNavigationTarget`; resolving there would mean carrying+  the note's `headingPath` through the coordinator as a second piece of+  state kept in sync with the block id, for no benefit over resolving once+  at the tap site where both the note and the `structure` are already in+  scope.++## Regression Test++**Test file:** `prismTests/BlockDOMIDNoteNavigationTests.swift`+**Test names:** `resolvesSecondOccurrence`, `resolvesFirstOccurrence`,+`legacyNoteFallsBackToBareBlockId`, `staleHeadingPathFallsBack`,+`tableRowNoteResolvesParentOccurrence`, `listItemNoteResolvesParentOccurrence`++**What it verifies:** that `BlockDOMID.noteNavigationTarget` resolves a+note anchored to the second of two identical blocks to that second+occurrence's composite id (and the first note to the first occurrence),+that it falls back to the bare block id for legacy notes (`headingPath ==+nil`) and for a stale heading path that matches no current occurrence, and+that table-row/list-item note anchors resolve to their PARENT block's+correct occurrence with the sub-block suffix stripped.++Also added to `prismTests/MarkdownSectionBuilderTests.swift`:+`sourceIndexFindsMatchingOccurrence`, `sourceIndexReturnsNilForNilHeadingPath`,+`sourceIndexReturnsNilForNonMatchingHeadingPath` -- direct coverage of the+new `DocumentStructure.sourceIndex(forBlockId:headingPath:)` lookup -- plus+`sourceIndexPicksFirstOccurrenceUnderSameHeading` and+`noteNavigationTargetDeterministicUnderSameHeading`, which pin the+same-heading duplicate case to the first occurrence in document order so a+future change to the index cannot silently reintroduce a hash-order-dependent+answer.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -configuration Debug \+  -testPlan prism -only-test-configuration "en (base)" \+  -only-testing:prismTests/BlockDOMIDNoteNavigationTests \+  -only-testing:prismTests/MarkdownSectionBuilderTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Models/MarkdownSection.swift` | Added `DocumentStructure.sourceIndex(forBlockId:headingPath:)` |+| `prism/Services/WebRendering/BlockDOMID.swift` | Added `BlockDOMID.noteNavigationTarget(blockId:headingPath:structure:)` |+| `prism/Views/NotesPanel.swift` | Both note-tap navigation call sites use the new resolver |+| `prism/Views/SidebarNotesView.swift` | The grouped-notes tap navigation call site uses the new resolver |+| `prismTests/BlockDOMIDNoteNavigationTests.swift` | New regression test file |+| `prismTests/MarkdownSectionBuilderTests.swift` | Added coverage for the new `sourceIndex` lookup |+| `CHANGELOG.md` | Added `[Unreleased] / Fixed` entry |++## Verification++**Automated:**+- [x] Regression tests pass (`BlockDOMIDNoteNavigationTests`, new+      `MarkdownSectionBuilderTests` cases)+- [x] Targeted suite run (`BlockDOMIDNoteNavigationTests`,+      `MarkdownSectionBuilderTests`, `SidebarNotesViewTests`,+      `NotesPanelDocumentNotesTests`) passes: 78/78, confirmed via+      `Tools/check-test-results.sh`+- [x] `make lint` passes+- [x] `make build-macos` passes+- [x] `make verify-test-isolation` passes+- [ ] Full `make test-quick`/`make test` -- not run in this session per+      instruction (concurrent sibling test runs in other worktrees); the+      change is additive (new methods) plus three narrow call-site edits,+      and the affected areas' full suites were run targeted.++**Manual verification:** Not performed in this session (no simulator/app+launch); the fix is covered structurally by the composite-id path shared+with TOC/search/scroll-restore navigation, which is exercised more broadly+elsewhere (e.g. `TOCNavigationDetailsIndexTests`).++## Prevention++**Recommendations to avoid similar bugs:**+- When a model gains disambiguating identity (like T-209's `headingPath`),+  grep every consumer of the ambiguous field it's meant to supplement+  (here, every `.blockId` navigation use) rather than only updating the+  consumers that prompted the addition.+- `BlockDOMID`'s composite-id format is the general mechanism for+  "resolve to a specific occurrence" -- new navigation sources should reach+  for it before inventing bespoke resolution.++## Related++- T-209: introduced `BlockNote.headingPath` for note storage/display+  disambiguation (this fix is the missing navigation half).+- T-300: fixed details/TOC composite IDs (same composite-id mechanism,+  different call site).+- Out of scope (separate open tickets on the same occurrence-identity+  cluster, deliberately not touched): T-2045 (indicators/bubbles ignore+  headingPath), T-2084 (export at every occurrence), T-2085 (grouping+  ignores occurrence scope), T-2086 (inconsistent section metadata), T-2087+  (fuzzy relocation), T-2088 (replies lose occurrence identity).

Things to double-check

A note inside a collapsed section.

Collapse a section containing a noted block, then tap that note. Expect to land on the enclosing collapsed heading rather than on the block, and the section to stay closed. Confirm that reads as acceptable — if not, expanding the section on the notes path (mirroring DocumentSession.navigateToHeading) fixes this and the pre-existing unique-content case together.

Two identical blocks under the SAME heading.

The follow-up commit pins this to the first in document order. A note on the second copy will still navigate to the first — correct per the documented limitation, but worth confirming it is the behaviour you want shipped rather than, say, refusing to navigate. The doc comment and report both state it plainly, which is the right handling for a limitation you are choosing to carry.

A note created before T-209, or imported.

Legacy notes (nil headingPath), preamble notes, and imported sub-block anchors (ImportedNotesProcessor.swift:298 hard-codes nil) all take the bare-id fallback. Behaviour is unchanged for them by construction, but a spot check on a document with imported inline comments is cheap insurance.

Full pre-push gate.

make build-ios, make build-macos, make test, make test-ui — none of which this review ran in full, and none of which CI will run for you since PR #414. Also make test-locales before merging.