Restores per-item note display for lists in the WebKit document path: a subID channel end to end (feeder → payload → JS → bridge → router), per-item indicators and bubbles, a dedicated gutter column for the whole-list dot, a nested dotted subID round-trip, and bubble-before-nested-list placement.
section.firstChild) landed in item 1's gutter while suppressAddNote's li > […] fallback stole item 1's +. The persisted anchor was always right.subID is the emitted attribute, not a derived string. NoteStateFeeder ships the anchor id minus its block-hash prefix, which is byte-identical to data-prism-sub because both come from MarkdownBlock.allListItemIds(). Verified: collectItemIds recurses .nestedList only, exactly matching the emitter's subIDPrefix propagation.subID exactly (CSS.escape, querySelector) and drops the entry on a miss; native resolves a tapped subID by membership in allListItemIds() rather than string-building an anchor, so a stale or forged value targets nothing. A stale item-9 anchor is pinned to produce no payload entry at all.parseInt bug is a real second defect, not a hypothetical. item-0-item-1 reads as ordinal 0 — the parent — so a nested item's + filed its note one level up. Latent before this PR (the note was displayed at block level either way); visible the moment notes render per item. Carrying the whole dotted path through SubTarget.listItem(subID:) is the right shape.data-prism-beside-items → -2.5em) rather than keeping the suppression — correct, because the list's note and item 1's + are different anchors and a container may only ever hide its own +.DocumentCSSNoteGutterRulesTests parses document.css, resolves --prism-spacing, and checks the three invariants (shared column, clearance, clip budget). This matters because — as the suite's own header records — the live WebPage harness never applies the stylesheet, so a rect-based assertion there passes vacuously.document.css, prism-notes.js, NoteStateFeeder.swift, WebDocumentMessageRouter.swift, and two test files are touched by both. Whichever merges second owns the reconciliation.Ready to push
The fix is correct, the addressing is fail-closed in both directions, and the coverage is unusually strong for a display bug — the regression tests drive the real feeder payload through the real prism-notes.js in a live WebPage, so native subID and emitted data-prism-sub are proved to agree rather than each asserted against a fixture. Both builds succeed, SwiftLint is clean at 0 violations, and 216 targeted tests pass with zero failures.
Main-advance check (PR #344, T-1852): the regions are disjoint. #344 touches only prism-notes.js's selection block (~line 480+) and WebDocumentController; this PR touches prism-notes.js lines 47–290 and never opens WebDocumentController. git merge-tree auto-merges every code file; the only conflicts are CHANGELOG.md and docs/agent-notes/webview-rendering-status.md, both adjacent-line prose.
Two things to carry forward rather than fix here: no specs/bugfixes/list-item-note-display/report.md was written (the folder exists but is empty and untracked), and the reconciliation contract with sibling PR #346 is real — the two PRs overlap on six files.
1892e41 Fix T-1745: Address list-item notes at the item, not the list block 5b0f1c5 Fix T-1745 review: whole-list note placement and nested-item creation e37d38d Fix T-1745 review: keep an item's bubble above its nested list f6b5bdc Fix T-1745 review: close the round-2 test-pin gaps 64ef101 T-1745 review: refresh two stale NoteStateFeeder doc comments In Prism you can attach a note to a single item of a bulleted list. Until now, when you did that on the second item, two odd things happened: the note appeared underneath the whole list rather than under that item, and the + button beside the first item turned into a filled dot — the mark that means “this item has a note”. So the list claimed the note belonged to an item it did not.
The note itself was never wrong. Copy it, export it, close and reopen the document — it always named the right item. Only the drawing was wrong.
Prism draws its documents as a web page. The Swift side works out which notes exist and hands the web side a list saying “put a note marker here”. That list could only name a whole block — a whole list, a whole paragraph, a whole table. It had no way to say “the second item of this list”. So a note on item 2 was handed over as “this list”, and the web side did the only thing it could: put the dot at the top of the list (which is where item 1 sits) and the note text at the bottom.
The hand-over now carries a second piece of information alongside the block: which item. Both sides already had a name for each item — item-1, or item-0-item-1 for an item inside a nested list — and both sides compute it from the same place, so the names cannot drift apart. With the item named, the dot goes on the right item, the note goes under the right item, and every other item keeps its +.
The WebKit document path keeps native as the source of truth: NoteStateFeeder maps NotesManager state into two JSON payloads (setNoteIndicators, setInlineNotes) and prism-notes.js injects the corresponding chrome. The bug lived entirely in that mapping — both payload entry types were keyed on a block DOM id, with an optional rowOrdinal for tables and nothing at all for list items.
This PR adds a third addressing field, subID, to both payloads and to the two inbound bridge messages that report a tap (noteIndicatorTapped, inlineNoteTapped). Its value is deliberately not a new invention: it is the note anchor id with the block-hash prefix stripped, which is byte-identical to the data-prism-sub attribute BlockHTMLEmitter already wrote, because both derive from MarkdownBlock.allListItemIds().
querySelector("[data-prism-sub=…]") and drops the entry on a miss. Native does block.allListItemIds().first { $0.id == candidate } rather than string-building "\(block.id)-\(subID)" and trusting it. Both are one-line choices that turn “wrong item” into “no item”.<li>; a table row does not, because a <div> inside a <td> would deform the table. Row notes keep rowOrdinal addressing for the dot and stay in the block-level host below the table. That asymmetry is now stated in both buildBubbles and blockLevelSubAnchorIds.left: rules for the + became five --prism-item-gutter declarations that the + and the dot both read. They mark the same anchor and are mutually exclusive, so a level whose offsets drifted apart would make a note look like it jumped sideways when added.<details> case is documented rather than papered over. emitDetails writes data-prism-sub on list children, but allListItemIds() returns nothing for .details, so those attributes have no native counterpart and two lists under one <details> emit item-0 twice. Both sides fail closed today and a comment on each says so.The whole-list note is the one anchor kind still addressed at the block, and its dot genuinely overlaps item 1's chip in the section gutter. Two options: keep suppressing item 1's + (wrong — different anchors), or give the dot its own column. The PR takes the second and pays for it with a JS-set marker attribute (data-prism-beside-items) plus a CSS override that must sit after the base rule because the two have identical specificity. The gutter is finite — main's 1.7em plus body's 1em, then overflow-x: hidden clips — so a further column is not free, which is why a test now asserts the clip budget.
Pre-fix, indicatorsJSON folded hasActiveListItemNote(…) into the block-level branch and buildBubbles flattened [block.id] + subAnchorIds(for: block) into a single per-block host. That single collapse produced both reported symptoms through two independent JS paths: renderIndicators's else-branch does section.insertBefore(dot, section.firstChild) (the gutter beside item 1) and suppressAddNote fell back to container.querySelector("li > [data-prism-add-note]") when the container had no direct-child + — which a <section> wrapping a list never does.
MarkdownBlock.collectItemIds recurses only on case .nestedList; BlockHTMLEmitter.renderListMarkup propagates subIDPrefix only on case .nestedList(let nested) and passes nil for every .block(…) child. The two recursions are structurally identical, so for a .list block the set of ids from allListItemIds() and the set of emitted data-prism-sub values are the same set with the same spellings. A list reached via renderInnerBlock (inside a blockquote, or as a .block child of an item) emits no data-prism-sub and contributes no ids — consistent on both sides.
subElement() returns null for a non-string or unmatched value; both call sites return on null. No positional fallback.BridgeMessageRouter.subTarget now reads different fields per kind — listItem requires a subID with an item- prefix, tableRow requires an ordinal — so a message carrying the wrong one is dropped rather than coerced. WebDocumentMessageRouter.listItem(forSubID:block:) then resolves by membership.listItemSubIDs enumerates the block's current items and filters by active note, so an anchor naming an item the block no longer has yields no payload entry (pinned by outOfRangeListItemAnchorIsDropped).The dot centres on the chip via calc(var(--prism-item-gutter) + (1.0625em + 2px - 0.5em) / 2). Chip box is 1.25em at font-size: 0.85em = 1.0625em, plus 2px of unboxed border. Dot centre = gutter + 0.28125em + 1px + 0.25em; chip centre = gutter + (1.0625em + 2px)/2 = gutter + 0.53125em + 1px. Identical — exact, not approximate. Vertically the same term is added to the +'s own top: 0.1em.
Cross-checked the per-level offsets: each enumerated level's chip lands at exactly -1.775em from the section (1.6em indent per level against -3.375, -4.975, -6.575, -8.175, -9.775). The task-list branch (li.prism-task, margin-left: -1.4em, gutter -1.975em) also resolves to -1.775em. The whole-list dot at -2.5em … -2.0em therefore clears every one of them by 0.225em, and -2.5em is inside the 2.7em reserve.
data-prism-chrome, which prism-search.js's TreeWalker rejects — relocating a host into an <li> does not perturb match text or ordering.resolveEndpoint walks [data-prism-run] ancestors and counts offsets within a run element. The host is a sibling of the item's run spans, not a descendant, so run-internal offsets are untouched and a selection inside a bubble still resolves to null (declined) as before.renderAddNoteControls() runs once at documentEnd; indicator and bubble injection are later commands. The +, the dot and the host are all absolutely positioned or block-flow chrome, so relative DOM order among them is not load-bearing.Encodable uses encodeIfPresent for Optional properties, so a nil subID/rowOrdinal is omitted from the JSON and the JS truthiness checks behave..nestedList children produces duplicate ids (item-0-item-0 twice) on both sides. First-match querySelector wins. Equivalent duplication existed pre-fix in the block-level host, so this is not a regression — but it is now visible per item.prism/Services/WebRendering/NoteStateFeeder.swift
Why it matters. This is the whole fix. Both payload builders stopped folding list-item notes into the block: `indicatorsJSON` emits a per-item entry carrying `subID`, and `buildBubbles` emits one host per noted item instead of one flattened host per block. The de-dup key gained the `subID` term so a block note, a row note and an item note on the same block stay three distinct entries.
What to look at. NoteStateFeeder.swift:150-200 (indicators), :271-315 (bubbles), :345-368 (subID derivation)
prism/ViewModels/WebBridgeContract.swift
Why it matters. A genuine second defect, latent until notes render per item. `item-0-item-1` truncates to `0` under `parseInt` — the *parent* — so a nested item's `+` filed its note one level up. Changing the case payload from `ordinal: Int` to `subID: String` removes the ordinal reading entirely; `BridgeMessageRouter.subTarget` now reads different fields per kind so a mismatched message is dropped rather than coerced.
What to look at. WebBridgeContract.swift:127-139; BridgeMessageRouter.swift:265-278; WebDocumentMessageRouter.swift:182-190, :205-216
prism/Resources/WebRenderer/prism-notes.js
Why it matters. Symptom 2's direct cause. The old `if (!add) { add = container.querySelector("li > [data-prism-add-note]"); }` meant a block dot on a list stole item 1's `+`. Now only the direct-child `+` is suppressed, on the principle that the container getting the dot and the container losing its `+` must be the *same* note anchor. A list has no direct-child `+`, so a whole-list note suppresses nothing.
What to look at. prism-notes.js:68-93 (suppressAddNote, hasGutterItems), :123-155 (renderIndicators)
prism/Resources/WebRenderer/document.css
Why it matters. The item's `+` and the item's dot are mutually exclusive and mark the same anchor, so a note that appeared to jump sideways the moment it was added would read as a layout bug. Five per-level `left:` rules on the `+` became five token declarations on the `li`, read by both affordances. Plus a new `-2.5em` column for the whole-list dot.
What to look at. document.css:846-883 (item dot, beside-items override), :970-1030 (the token ladder)
prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift
Why it matters. Three offsets share one narrow strip and the relationship between them is what the fix depends on, but nothing in the sheet states it — a plausible-looking edit to any one silently reintroduces the bug. The suite parses `document.css`, resolves `--prism-spacing`, and checks the shared column, the clearance, the clip budget, and the source-order dependency of the equal-specificity override.
What to look at. DocumentCSSNoteGutterRulesTests.swift:59-160 (parser), :234-308 (Columns), :343-402 (invariants)
prism/Resources/WebRenderer/prism-notes.js
Why it matters. Symptom 1 one level down. A plain `appendChild` on an item that owns a nested list puts the bubble after that whole subtree — visually below several other items, which is exactly the spatial-association loss the fix set out to remove.
What to look at. prism-notes.js:198-208
JS matches subID exactly via CSS.escape + querySelector and drops the entry on a miss. Native resolves a tapped subID with block.allListItemIds().first { $0.id == candidate } rather than string-building "{block.id}-{subID}" and trusting the result. Both choices convert 'wrong item' into 'no item' — a missing dot is a nuisance, a dot on the wrong line is the bug being fixed.
A <td> is a grid cell, not a flow container — a bubble inside one would deform the table. Row notes therefore keep rowOrdinal addressing for the dot (which does land on the right row) and stay in the block-level host below the table. The asymmetry is stated in buildBubbles, in blockLevelSubAnchorIds, and in the file header.
A whole-list note is legitimately addressed at the block, so its dot belongs in the section gutter — where a top-level item's chip also lives (-1.775em … -0.595em). Rather than let the dot hide item 1's +, prism-notes.js tags it data-prism-beside-items and document.css shifts it to -2.5em. The gutter is finite (2.7em, then overflow-x: hidden clips), so this column is affordable but a further one would not be — hence the clip-budget test.
emitDetails writes data-prism-sub="item-N" on its list children, but allListItemIds() returns nothing for .details, so those attributes have no native counterpart and two lists under one <details> emit item-0 twice into one section. Both halves fail closed: the feeder enumerates sub ids only for a .list block, so nothing ever reaches subElement's first-match querySelector; the router resolves by membership, so a + pressed in there falls back to a block-level note. Anchoring details children properly is tracked as T-2032. A comment on each side forbids deriving an anchor from the attribute string alone.
The + and the dot mark the same anchor and are mutually exclusive, so a level whose two offsets drifted apart would make a note look like it jumped sideways when added. One --prism-item-gutter declaration per level, read by both. The one term the token cannot carry — the chip's own width, needed to centre the dot on it — is spelled as an explicit (chip - dot) / 2 literal and pinned back to the chip rule by itemDotCentringMatchesTheChipBox.
The live WebPage harness never applies document.css, so computed geometry cannot be asserted there. The split is deliberate: WebListItemNoteDisplayTests pins which dot gets marked (JS behaviour, observable without styles), DocumentCSSNoteGutterRulesTests pins the column arithmetic (sheet values, no rendering needed). On-device verification of the rendered result remains manual, and the suites say so.
WebDocumentMessageRouter stopped calling them, but they remain live in ImportedNotesProcessor, NotesManager.handleNoteCreation, and a large body of export/round-trip tests. Not dead code; correctly untouched.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | NoteStateFeeder.swift — doc comments | Two comments went stale with the change. The de-dup comment still said "De-dup per (domID, rowOrdinal)" after `subID` joined the key, and `inlineNotesJSON`'s doc still said "one bubble host per block with active notes, carrying all that block's notes" — precisely the behaviour the PR removes. | Both rewritten to describe the current contract (commit 64ef101, comment-only). Re-linted and re-ran the two affected suites: 36/36 pass. |
| major | specs/bugfixes/list-item-note-display/ | The bugfix folder exists but is empty and untracked — no `report.md`. Every comparable entry (`wrong-text-no-notes`, `tagged-range-offsets-list-items`, `table-row-height-clipping`, …) carries one, and this fix has more worth recording than most: two distinct defects, a latent nested-`parseInt` bug found mid-round, a rejected approach (keeping the suppression), and a documented one-way `<details>` case handed to T-2032. | Not written — this review is report-only and explicitly barred from creating spec files. Flagged for the author; the material is largely present in the commit bodies and the three new agent-note bullets, so writing it is mostly transcription. |
| info | MarkdownBlock.collectItemIds / BlockHTMLEmitter.renderListMarkup | An item with two sibling `.nestedList` children (reachable — the parser appends one per `UnorderedList`/`OrderedList` child) produces duplicate ids: both children get `parentPath [index]`, so both emit `item-0-item-0`. The emitter duplicates the same value into `data-prism-sub`, and `subElement`'s `querySelector` takes the first match. | Not a regression, and not this PR's to fix. The ambiguity is in the anchor id scheme itself and predates the change: before the fix the same duplicate ids caused the note to be listed twice inside the single block-level host. Both sides remain consistent with each other. Noted for whoever next touches the dotted-id format. |
| info | DocumentCSSNoteGutterRulesTests — task-list branch | `Columns.chipLeft` is derived from `listIndent + topLevelItemGutter` using the plain `section > ul > li` token (-3.375em), so the task-list branch (`li.prism-task`, `margin-left: -1.4em`, gutter -1.975em) is not exercised by the clearance test. | Checked by hand: the task branch resolves to the same -1.775em chip edge (0.2em item origin minus 1.975em), so the -2.5em dot clears it identically. The uncovered path is arithmetically equivalent, not a gap in the invariant. Left alone rather than adding a second Columns variant for a value that cannot differ without the enumerated ladder also changing. |
| info | Build warnings | Both platform builds emit one warning: `main actor-isolated conformance of 'ImageDimension' to 'Equatable' cannot be used in nonisolated context`. | Pre-existing and unrelated — `ImageDimension` is untouched by this branch. Not introduced here; noted so the zero-warning gate is not misread as failing on account of this PR. |
Click to expand.
diff --git a/prism/Services/WebRendering/NoteStateFeeder.swift b/prism/Services/WebRendering/NoteStateFeeder.swiftindex 70a5a60..b6a31cd 100644--- a/prism/Services/WebRendering/NoteStateFeeder.swift+++ b/prism/Services/WebRendering/NoteStateFeeder.swift@@ -9,10 +9,10 @@ // onto those commands, so notes did not appear in the web-rendered document. // // Two pure products, derived from (parsed blocks + NotesManager + settings):-// - indicatorsJSON: `[{domID, rowOrdinal?}]` — one entry per block / table row that-// carries a note (Req 5.2). prism-notes.js draws the indicator dot.-// - inlineNotesJSON: `{ bubbles: [{domID, html}], banner: {html, placement} }` — the-// native-rendered (escaped) bubble/banner HTML the JS injects as chrome (Req 5.6).+// - indicatorsJSON: `[{domID, rowOrdinal?, subID?}]` — one entry per block / table row /+// list item that carries a note (Req 5.2). prism-notes.js draws the indicator dot.+// - inlineNotesJSON: `{ bubbles: [{domID, subID?, html}], banner: {html, placement} }` —+// the native-rendered (escaped) bubble/banner HTML the JS injects as chrome (Req 5.6). // Bubbles are produced only when `showInlineNotes` is on; the banner only when there // are active document-level notes, placed per the banner-placement setting. //@@ -23,6 +23,16 @@ // occurrence, matching the SwiftUI path's per-block model lookup (which keyed notes by // content hash and so surfaced them on every matching block). //+// Sub-block addressing (T-1745): a LIST-ITEM note is addressed at the item, not at the+// list. `subID` is the anchor id minus its block-hash prefix — exactly the value the+// emitter writes into that item's `data-prism-sub` (`item-1`, nested `item-0-item-1`),+// because both spellings come from `MarkdownBlock.allListItemIds()`. Before T-1745 a+// list-item note collapsed to a BLOCK-level indicator and a block-level bubble host, so+// the note rendered under the whole list and the block dot landed in the gutter beside+// item 1 — suppressing the FIRST item's "+" whichever item the note actually belonged to.+// Table rows keep addressing by `rowOrdinal`: a `<td>` is not a flow container, so a row+// bubble stays in the block-level host below the table.+// // Localisation (Req 1.9): the banner count label is catalog-resolved natively and // passed in via `Strings`; note text/author/timestamp are user/document data (escaped), // not catalog strings.@@ -115,17 +125,19 @@ enum NoteStateFeeder { // MARK: - Indicators (Req 5.2) - /// An indicator target: a block DOM id, optionally a table-row ordinal within it.+ /// An indicator target: a block DOM id, optionally a table-row ordinal or a+ /// `data-prism-sub` value (list items) identifying a sub-element within it. private struct Indicator: Encodable { let domID: String let rowOrdinal: Int?+ let subID: String? } - /// Builds the `[{domID, rowOrdinal?}]` JSON for every block / table row carrying an- /// active note. Block-level notes (and list-item notes, which have no per-item- /// indicator slot in the JS contract) surface a block-level indicator; table-row- /// notes surface a row indicator. A header-row note (`-row-header`, no JS ordinal)- /// surfaces as a block-level indicator on the table so the note is still visible.+ /// Builds the `[{domID, rowOrdinal?, subID?}]` JSON for every block / table row / list+ /// item carrying an active note. Block-level notes surface a block-level indicator;+ /// table-row notes a row indicator; list-item notes a per-item indicator addressed by+ /// `subID` (T-1745). A header-row note (`-row-header`, no JS ordinal) surfaces as a+ /// block-level indicator on the table so the note is still visible. /// /// Maps the blocks to their DOM ids and delegates; `payloads` uses the `mapped:` /// overload to share one walk across both payloads.@@ -139,16 +151,21 @@ enum NoteStateFeeder { mapped: [(block: MarkdownBlock, domID: String)], notesManager: NotesManager ) -> String { var indicators: [Indicator] = []- // De-dup per (domID, rowOrdinal) so a block with several notes (or both a list-item- // note and a block note) yields a single indicator.+ // De-dup per (domID, rowOrdinal, subID) so a target with several notes yields a single+ // indicator, while a block note, a row note and an item note on the SAME block stay+ // three separate entries — they are three different anchors (T-1745). var seen: Set<String> = [] for (block, domID) in mapped {- // Block-level (and list-item) notes → one indicator on the block itself.- let blockLevel = notesActive(notesManager, for: block.id)- || hasActiveListItemNote(notesManager, block: block)- if blockLevel {- appendIndicator(domID: domID, rowOrdinal: nil, into: &indicators, seen: &seen)+ // The block's OWN note → one indicator on the block itself. A list-item note no+ // longer counts here: it addresses the item (T-1745).+ if notesActive(notesManager, for: block.id) {+ appendIndicator(domID: domID, into: &indicators, seen: &seen)+ }++ // List-item notes → a per-item indicator addressed by data-prism-sub value.+ for subID in activeListItemSubIDs(notesManager, block: block) {+ appendIndicator(domID: domID, subID: subID, into: &indicators, seen: &seen) } // Table-row notes → a row indicator per row ordinal; header-row → block-level.@@ -157,7 +174,7 @@ enum NoteStateFeeder { appendIndicator(domID: domID, rowOrdinal: ordinal, into: &indicators, seen: &seen) } if hasActiveHeaderRowNote(notesManager, block: block) {- appendIndicator(domID: domID, rowOrdinal: nil, into: &indicators, seen: &seen)+ appendIndicator(domID: domID, into: &indicators, seen: &seen) } } }@@ -166,19 +183,21 @@ enum NoteStateFeeder { } private static func appendIndicator(- domID: String, rowOrdinal: Int?,+ domID: String, rowOrdinal: Int? = nil, subID: String? = nil, into indicators: inout [Indicator], seen: inout Set<String> ) {- let key = "\(domID)#\(rowOrdinal.map(String.init) ?? "-")"+ let key = "\(domID)#\(rowOrdinal.map(String.init) ?? "-")#\(subID ?? "-")" guard seen.insert(key).inserted else { return }- indicators.append(Indicator(domID: domID, rowOrdinal: rowOrdinal))+ indicators.append(Indicator(domID: domID, rowOrdinal: rowOrdinal, subID: subID)) } // MARK: - Inline notes (Req 5.6) - /// A bubble payload entry: the target block DOM id and the native-rendered HTML.+ /// A bubble payload entry: the target block DOM id, an optional `data-prism-sub` value+ /// naming a sub-element within it (list items, T-1745), and the native-rendered HTML. private struct Bubble: Encodable { let domID: String+ let subID: String? let html: String } @@ -213,7 +232,8 @@ enum NoteStateFeeder { } /// Builds the inline-notes JSON. Bubbles are emitted only when `showInlineNotes` is on- /// (one bubble host per block with active notes, carrying all that block's notes); the+ /// (one block-level host per block with active block / table-row notes, plus one host per+ /// noted list item addressed by its `subID` — see `buildBubbles`); the /// banner is emitted only when there are active document-level notes. Returns nil when /// neither is present so the controller pushes nothing. Takes the pre-computed /// `(block, domID)` mapping so `payloads` walks the blocks only once.@@ -240,13 +260,16 @@ enum NoteStateFeeder { return encodeJSON(InlineNotes(bubbles: bubbles, banner: banner)) } - /// One bubble host per block with active notes, mapped to every DOM-id occurrence of- /// that block (so duplicated blocks each show the notes). The host gathers the block's- /// own notes AND its sub-anchor notes — list items (`{hash}-item-{n}`) and table rows- /// (`{hash}-row-{n}` / `{hash}-row-header`) — into the single per-block host, matching- /// the indicator path (which surfaces those sub-anchors too). The host HTML stacks each- /// note (thread-aware order), and each note carries `data-prism-note-id` so a tap- /// routes to `inlineNoteTapped` (Req 5.6).+ /// The bubble hosts for a block, mapped to every DOM-id occurrence of that block (so+ /// duplicated blocks each show the notes). The host HTML stacks each note (thread-aware+ /// order), and each note carries `data-prism-note-id` so a tap routes to+ /// `inlineNoteTapped` (Req 5.6).+ ///+ /// A LIST ITEM gets its OWN host, addressed by its `data-prism-sub` value, so the note+ /// renders under the item the reader anchored it to rather than under the whole list+ /// (T-1745). Table rows do NOT: a `<td>` is a grid cell, not a flow container, so a+ /// bubble inside one would deform the table — row notes stay in the block-level host+ /// below the table, matching the indicator path (which surfaces them on the row). private static func buildBubbles( mapped: [(block: MarkdownBlock, domID: String)], notesManager: NotesManager,@@ -254,31 +277,43 @@ enum NoteStateFeeder { ) -> [Bubble] { var bubbles: [Bubble] = [] for (block, domID) in mapped {- // The block's own note key plus any list-item / table-row sub-anchor keys.- let anchorIds = [block.id] + subAnchorIds(for: block)- let active = anchorIds.flatMap { notesManager.allNotes(for: $0) }- .filter { $0.status == .active }- let notes = NoteGrouping.sortNotesThreadAware(active)- guard !notes.isEmpty else { continue }- let html = NoteHTMLBuilder.inlineNotesHost(notes, exportUsername: exportUsername)- bubbles.append(Bubble(domID: domID, html: html))+ // Block-level host: the block's own note key plus the sub-anchors that have no+ // host of their own (table rows).+ let blockLevelIds = [block.id] + blockLevelSubAnchorIds(for: block)+ if let html = hostHTML(+ for: blockLevelIds, notesManager: notesManager, exportUsername: exportUsername+ ) {+ bubbles.append(Bubble(domID: domID, subID: nil, html: html))+ }++ // One host per noted list item, placed inside that item.+ for (subID, anchorId) in listItemSubIDs(for: block) {+ guard let html = hostHTML(+ for: [anchorId], notesManager: notesManager, exportUsername: exportUsername+ ) else { continue }+ bubbles.append(Bubble(domID: domID, subID: subID, html: html))+ } } return bubbles } - /// The sub-anchor note keys a block can carry beyond its own id: list-item ids for a- /// list, and body-row + header-row ids for a table. Empty for every other block kind.- /// Reuses the same id helpers the indicator path relies on so bubble and indicator- /// coverage stay in lock-step.- private static func subAnchorIds(for block: MarkdownBlock) -> [String] {- switch block {- case .list:- return block.allListItemIds().map(\.id)- case .table:- return block.allTableRowIds().map(\.id)- default:- return []- }+ /// The rendered host HTML for the active notes at `anchorIds`, or nil when there are+ /// none (so the caller emits no empty host).+ private static func hostHTML(+ for anchorIds: [String], notesManager: NotesManager, exportUsername: String+ ) -> String? {+ let active = anchorIds.flatMap { notesManager.allNotes(for: $0) }+ .filter { $0.status == .active }+ let notes = NoteGrouping.sortNotesThreadAware(active)+ guard !notes.isEmpty else { return nil }+ return NoteHTMLBuilder.inlineNotesHost(notes, exportUsername: exportUsername)+ }++ /// The sub-anchor note keys that render in a block's own host: body-row + header-row ids+ /// for a table. Empty for every other block kind — list items host their own bubbles.+ private static func blockLevelSubAnchorIds(for block: MarkdownBlock) -> [String] {+ guard case .table = block else { return [] }+ return block.allTableRowIds().map(\.id) } /// The document-level notes banner. Always present when document notes can be added@@ -309,10 +344,29 @@ enum NoteStateFeeder { notesManager.allNotes(for: blockId).contains { $0.status == .active } } - /// Whether any active note targets a list item of `block` (`{hash}-item-…`).- private static func hasActiveListItemNote(_ notesManager: NotesManager, block: MarkdownBlock) -> Bool {- guard case .list = block else { return false }- return block.allListItemIds().contains { notesActive(notesManager, for: $0.id) }+ /// Every list item of `block` as `(subID, anchorId)`, where `subID` is the anchor id+ /// minus the block-hash prefix — the exact string `BlockHTMLEmitter` writes into that+ /// item's `data-prism-sub` (`item-1`, nested `item-0-item-1`). Both spellings derive+ /// from `allListItemIds()`, so emitter and feeder cannot drift (T-1745).+ ///+ /// Empty for a non-list block. An id that somehow lacks the prefix is skipped rather+ /// than guessed at: a wrong `subID` would silently place the note on another item.+ private static func listItemSubIDs(for block: MarkdownBlock) -> [(subID: String, anchorId: String)] {+ guard case .list = block else { return [] }+ let prefix = "\(block.id)-"+ return block.allListItemIds().compactMap { entry in+ guard entry.id.hasPrefix(prefix) else { return nil }+ return (subID: String(entry.id.dropFirst(prefix.count)), anchorId: entry.id)+ }+ }++ /// The `data-prism-sub` values of every list item of `block` carrying an active note.+ private static func activeListItemSubIDs(+ _ notesManager: NotesManager, block: MarkdownBlock+ ) -> [String] {+ listItemSubIDs(for: block)+ .filter { notesActive(notesManager, for: $0.anchorId) }+ .map(\.subID) } /// Active body-row ordinals (`{hash}-row-{n}`) of a table block, sorted ascending.
diff --git a/prism/Resources/WebRenderer/prism-notes.js b/prism/Resources/WebRenderer/prism-notes.jsindex d912029..0b92e26 100644--- a/prism/Resources/WebRenderer/prism-notes.js+++ b/prism/Resources/WebRenderer/prism-notes.js@@ -47,8 +47,9 @@ } // ---- setNoteIndicators (Req 5.2) -------------------------------------- // Payload: { indicators: "[{domID, rowOrdinal?}]" } (JSON string). Renders a- // leading indicator dot on each named block, or in the named table row. Replaces+ // Payload: { indicators: "[{domID, rowOrdinal?, subID?}]" } (JSON string). Renders a+ // leading indicator dot on each named block, in the named table row, or on the named+ // sub-element (a list item, addressed by its data-prism-sub value — T-1745). Replaces // any previously rendered indicators so a re-push is idempotent. function clearIndicators() {@@ -64,19 +65,34 @@ } } - // A block / table-row with a note dot hides its own "+" — done explicitly here rather- // than via CSS :has(), which does not reliably re-evaluate on a cell when the dot is- // inserted live (a note added during the session). Scoped to the direct-child "+" so a- // block dot only suppresses the block "+" and a row dot only the row "+".+ // A container that gets a note dot hides its OWN "+": the dot's tap already manages that+ // container's notes, so the "+" beside it is redundant. Done explicitly here rather than+ // via CSS :has(), which does not reliably re-evaluate on a cell when the dot is inserted+ // live (a note added during the session).+ //+ // Strictly the direct-child "+", never a descendant's: the container that gets the dot+ // and the container that loses its "+" must be the SAME note anchor. A list has no+ // direct-child "+" of its own (its items each carry one), so a whole-list note suppresses+ // nothing — it used to fall back to item 1's "+", which is symptom 2 of T-1745 by another+ // route: the note belongs to the list, not to item 1. The block dot avoids overlapping+ // that "+" by moving one column further out instead (data-prism-beside-items). function suppressAddNote(container) { var add = container.querySelector(":scope > [data-prism-add-note]");- // Lists carry no direct-child "+" (theirs live on each item); the block dot sits in- // the gutter at the first item, so suppress that item's "+" to avoid overlapping it.- if (!add) { add = container.querySelector("li > [data-prism-add-note]"); } if (add) { add.setAttribute("data-prism-suppressed", ""); } } - function makeIndicator(blockID, rowOrdinal) {+ // Whether `section` has list items whose own gutter chrome ("+" / dot) is pulled out to+ // the SECTION gutter — the column a block-level dot would otherwise land in. True only+ // for a list that is a direct child of the section, which is exactly the set document.css+ // pulls out (`section > ul > li`, `section > ol > li`); a list nested inside another+ // block keeps its chrome inside that block, clear of the section gutter.+ function hasGutterItems(section) {+ return !!section.querySelector(+ ":scope > ul > li[data-prism-sub], :scope > ol > li[data-prism-sub]"+ );+ }++ function makeIndicator(blockID, rowOrdinal, subID) { var dot = document.createElement("span"); dot.className = "prism-note-indicator"; dot.setAttribute("data-prism-note-indicator", "");@@ -89,11 +105,21 @@ if (rowOrdinal !== null && rowOrdinal !== undefined) { fields.rowOrdinal = rowOrdinal; }+ if (subID) { fields.subID = subID; } bridge.post("noteIndicatorTapped", fields); }, false); return dot; } + // The element inside `section` a payload entry's `subID` names, or null. `subID` is+ // native-authored (a data-prism-sub value the emitter wrote), so it is matched exactly:+ // a miss means the payload and the DOM disagree, and placing the note somewhere else+ // would put it on the wrong item — the failure T-1745 fixed.+ function subElement(section, subID) {+ if (!subID || typeof subID !== "string") { return null; }+ return section.querySelector("[data-prism-sub=\"" + CSS.escape(subID) + "\"]");+ }+ function renderIndicators(list) { clearIndicators(); if (!Array.isArray(list)) { return; }@@ -106,10 +132,23 @@ var row = section.querySelector("[data-prism-sub='row-" + entry.rowOrdinal + "']"); if (!row) { return; } var cell = row.querySelector("td, th") || row;- cell.insertBefore(makeIndicator(entry.domID, entry.rowOrdinal), cell.firstChild);+ cell.insertBefore(makeIndicator(entry.domID, entry.rowOrdinal, null), cell.firstChild); suppressAddNote(cell);+ } else if (entry.subID) {+ // A list-item note: the dot goes on that item, and only that item's "+" is+ // suppressed (T-1745).+ var sub = subElement(section, entry.subID);+ if (!sub) { return; }+ sub.insertBefore(makeIndicator(entry.domID, null, entry.subID), sub.firstChild);+ suppressAddNote(sub); } else {- section.insertBefore(makeIndicator(entry.domID, null), section.firstChild);+ var blockDot = makeIndicator(entry.domID, null, null);+ // Beside per-item chrome the dot takes its own gutter column (document.css),+ // so a whole-list note leaves every item's "+" visible (T-1745).+ if (hasGutterItems(section)) {+ blockDot.setAttribute("data-prism-beside-items", "");+ }+ section.insertBefore(blockDot, section.firstChild); suppressAddNote(section); } });@@ -121,10 +160,12 @@ }); // ---- setInlineNotes (Req 5.6) ----------------------------------------- // Payload: { notes: "{ bubbles: [{domID, html}], banner: {html, placement} }" }.+ // Payload: { notes: "{ bubbles: [{domID, subID?, html}], banner: {html, placement} }" }. // Native renders the bubble/banner content (escaped) and ships it as HTML; this- // script injects it as chrome in the document flow per current settings. A re-push- // replaces the previously injected bubbles/banner.+ // script injects it as chrome in the document flow per current settings. A bubble with+ // a `subID` is injected inside that sub-element (a list item) rather than at the end of+ // the block, so a list-item note reads under its own item (T-1745). A re-push replaces+ // the previously injected bubbles/banner. function clearInlineNotes() { var bubbles = document.querySelectorAll("[data-prism-inline-note-host]");@@ -145,11 +186,26 @@ if (!entry || !entry.domID || typeof entry.html !== "string") { return; } var section = document.getElementById(entry.domID); if (!section) { return; }+ var target = section;+ if (entry.subID) {+ target = subElement(section, entry.subID);+ if (!target) { return; }+ } var host = document.createElement("div"); host.setAttribute("data-prism-inline-note-host", ""); host.setAttribute("data-prism-chrome", ""); host.innerHTML = entry.html;- section.appendChild(host);+ // Under the item's OWN text, not below everything it contains: an item that owns+ // a nested list would otherwise render its bubble after that whole subtree, which+ // is symptom 1 again one level down (T-1745). A block-level host still goes last.+ var nestedList = target === section+ ? null+ : target.querySelector(":scope > ul, :scope > ol");+ if (nestedList) {+ target.insertBefore(host, nestedList);+ } else {+ target.appendChild(host);+ } }); if (model.banner && typeof model.banner.html === "string") { var placement = model.banner.placement === "bottom" ? "bottom" : "top";@@ -183,11 +239,16 @@ if (!host) { return; } event.preventDefault(); event.stopPropagation();- bridge.post("inlineNoteTapped", {+ var fields = { blockID: blockIDOf(host), noteID: note.getAttribute("data-prism-note-id"), rect: bridge.clientRect(note),- });+ };+ // A bubble hosted inside a list item reports that item, so the popover opens on the+ // item's notes rather than the list's (T-1745).+ var sub = host.closest("[data-prism-sub]");+ if (sub) { fields.subID = sub.getAttribute("data-prism-sub"); }+ bridge.post("inlineNoteTapped", fields); }, true); // ---- Document-notes banner collapse (Req 5.6) ------------------------@@ -221,8 +282,12 @@ if (!sub) { return null; } var value = sub.getAttribute("data-prism-sub"); if (value.indexOf("item-") === 0) {- var itemOrdinal = parseInt(value.slice("item-".length), 10);- if (!isNaN(itemOrdinal)) { return { kind: "listItem", ordinal: itemOrdinal }; }+ // The WHOLE sub id, not a leading ordinal: a nested item is spelled+ // `item-0-item-1`, which reads as ordinal 0 — the PARENT — under parseInt, so a+ // nested item's "+" used to file its note one level up (T-1745). Native+ // validates the string against the block's own item ids, so no ordinal+ // arithmetic is needed on this side.+ return { kind: "listItem", subID: value }; } else if (value.indexOf("row-") === 0 && value !== "row-header") { var rowOrdinal = parseInt(value.slice("row-".length), 10); if (!isNaN(rowOrdinal)) { return { kind: "tableRow", ordinal: rowOrdinal }; }
diff --git a/prism/Resources/WebRenderer/document.css b/prism/Resources/WebRenderer/document.cssindex 4960dc4..4bc81d5 100644--- a/prism/Resources/WebRenderer/document.css+++ b/prism/Resources/WebRenderer/document.css@@ -843,6 +843,45 @@ section[data-prism-block-id]:has(> :is(h1, h2, h3, h4, h5, h6)) > .prism-note-in top: 1.7em; } +/* List-item indicator: a list-item note is addressed at its own item (T-1745), so its dot+ * sits in that item's gutter — the same column as the item's "+", centred on it.+ *+ * The chip's box is NOT 1.25em: `.prism-add-note::before` sets `font-size: 0.85em`, so its+ * `width`/`height: 1.25em` compute to 1.0625em, and with no box-sizing override its 1px+ * border adds 2px on top. Chip box = 1.0625em + 2px. The offsets below spell that centring+ * out as `(chip - dot) / 2` rather than a pre-computed constant, so the derivation is+ * checkable and the px term (a border does not scale with the font size) stays visible.+ * Vertically the "+" button itself sits at top 0.1em, which the dot has to add back. */+li > .prism-note-indicator[data-prism-note-indicator] {+ position: absolute;+ left: calc(var(--prism-item-gutter) + (1.0625em + 2px - 0.5em) / 2);+ top: calc(0.1em + (1.0625em + 2px - 0.5em) / 2);+ width: 0.5em;+ height: 0.5em;+ margin: 0;+ border-radius: 50%;+ background-color: var(--prism-accent);+}++/*+ * A block-level dot on a list needs its OWN column (T-1745).+ *+ * A whole-list note is addressed at the block, so its dot goes in the section gutter at+ * -1.4em … -0.9em — but a list's items each carry their own "+" there too, and a top-level+ * item's chip runs from section -1.775em to -0.595em (1.6em list indent, -3.375em gutter,+ * 1.0625em + 2px chip). Those boxes overlap, which is why the dot used to hide item 1's+ * "+" instead of sitting beside it — the reported symptom, for the one anchor kind still+ * addressed at the block.+ *+ * prism-notes.js marks the dot when the section has direct list children carrying per-item+ * chrome; this moves it one column further out. -2.5em … -2.0em clears the chip by 0.225em+ * and still fits the gutter (main's 1.7em padding-left plus body's 1em). Same specificity+ * as the base rule above (0,3,1), so it must stay AFTER it to win.+ */+section[data-prism-block-id] > .prism-note-indicator[data-prism-beside-items] {+ left: -2.5em;+}+ /* Table-row indicator sits at the row's leading edge. */ td > .prism-note-indicator[data-prism-note-indicator], th > .prism-note-indicator[data-prism-note-indicator] {@@ -928,50 +967,66 @@ section[data-prism-block-id] > .prism-add-note[data-prism-add-note] { display: none; } -/* List-item affordance: positioned out of flow so it reads as beside the item and does- * not inflate the line height (which spaced items too far apart). It sits in the SAME- * leading gutter column as the block-level note dot, so a list's "+" and its note- * indicator share one column (device feedback, T-1542). The base offset is the marker- * gutter (nested / fallback); top-level items are pulled out to the main gutter below. */-li > .prism-add-note[data-prism-add-note] {- position: absolute;- left: -1.5em;- top: 0.1em;- margin: 0;+/*+ * ---- The list-item gutter column ----+ * One offset per list level, declared on the ITEM and consumed by both of the item's+ * leading affordances — its "+" and its note dot (T-1745). A single source of truth is+ * what keeps those two in the same column: they mark the same anchor and are mutually+ * exclusive, so a level whose offsets drifted apart would make a note appear to jump+ * sideways the moment it was added.+ *+ * The base is the marker gutter, used by nested levels past the ones enumerated below.+ * `--prism-item-gutter` inherits, but every level here matches its own items directly and+ * a direct match beats an inherited value, so each level resolves its own offset and+ * anything deeper falls back to this base.+ */+li {+ --prism-item-gutter: -1.5em; } -/* Top-level items: pull the "+" out to the main gutter, CENTRED on the block note dot so- * the "+" and the dot share one column. The dot is a 0.5em circle at section -1.4em- * (centre -1.15em); the 1.25em "+" chip centres there when its left edge is at section- * -1.775em, i.e. -3.375em from a top-level li (1.6em list indent). */-section[data-prism-block-id] > ul > li > .prism-add-note[data-prism-add-note],-section[data-prism-block-id] > ol > li > .prism-add-note[data-prism-add-note] {- left: -3.375em;+/* Top-level items: pull the affordance out to the main gutter, CENTRED on the block note+ * dot so a list's block-level and item-level chrome share one column. The dot is a 0.5em+ * circle at section -1.4em (centre -1.15em); the 1.25em "+" chip centres there when its+ * left edge is at section -1.775em, i.e. -3.375em from a top-level li (1.6em list indent). */+section[data-prism-block-id] > ul > li,+section[data-prism-block-id] > ol > li {+ --prism-item-gutter: -3.375em; } -/* Nested list items: pull each level's "+" out to the SAME main-gutter column (verified- * with headless WebKit), so it never sits on the nested bullet/number. Each nesting level- * adds one list indent (1.6em), so the offset grows by 1.6em per level: -3.375, -4.975,- * -6.575, -8.175, -9.775 … (levels 2-6; deeper lists fall back to the marker-gutter base). */-section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {- left: -4.975em;+/* Nested list items: pull each level out to the SAME main-gutter column (verified with+ * headless WebKit), so it never sits on the nested bullet/number. Each nesting level adds+ * one list indent (1.6em), so the offset grows by 1.6em per level: -3.375, -4.975, -6.575,+ * -8.175, -9.775 … (levels 2-6; deeper lists fall back to the marker-gutter base). */+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li {+ --prism-item-gutter: -4.975em; } -section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {- left: -6.575em;+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li {+ --prism-item-gutter: -6.575em; } -section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {- left: -8.175em;+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li {+ --prism-item-gutter: -8.175em; } -section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > .prism-add-note[data-prism-add-note] {- left: -9.775em;+section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol) > li {+ --prism-item-gutter: -9.775em; } -/* Task-list items carry no bullet and are shifted left 1.4em; centre their "+" on the dot. */-section[data-prism-block-id] > ul > li.prism-task > .prism-add-note[data-prism-add-note] {- left: -1.975em;+/* Task-list items carry no bullet and are shifted left 1.4em; centre their chrome on the dot. */+section[data-prism-block-id] > ul > li.prism-task {+ --prism-item-gutter: -1.975em;+}++/* List-item affordance: positioned out of flow so it reads as beside the item and does+ * not inflate the line height (which spaced items too far apart). It sits in the SAME+ * leading gutter column as the block-level note dot, so a list's "+" and its note+ * indicator share one column (device feedback, T-1542). */+li > .prism-add-note[data-prism-add-note] {+ position: absolute;+ left: var(--prism-item-gutter);+ top: 0.1em;+ margin: 0; } /* Table-row affordance stays inline before the cell content (cells are boxes, no marker). */
diff --git a/prism/ViewModels/WebDocumentMessageRouter.swift b/prism/ViewModels/WebDocumentMessageRouter.swiftindex 201376c..ddedb99 100644--- a/prism/ViewModels/WebDocumentMessageRouter.swift+++ b/prism/ViewModels/WebDocumentMessageRouter.swift@@ -94,11 +94,11 @@ struct WebDocumentMessageRouter { case .diagFailure(let category): Self.logger.error("In-page render failure (category: \(category))") - case .noteIndicatorTapped(let blockID, let rowOrdinal, _):- handleNoteIndicatorTap(blockID: blockID, rowOrdinal: rowOrdinal)+ case .noteIndicatorTapped(let blockID, let rowOrdinal, let subID, _):+ handleNoteIndicatorTap(blockID: blockID, rowOrdinal: rowOrdinal, subID: subID) - case .inlineNoteTapped(let blockID, _, _):- handleInlineNoteTap(blockID: blockID)+ case .inlineNoteTapped(let blockID, _, let subID, _):+ handleInlineNoteTap(blockID: blockID, subID: subID) case .blockContextRequested(let blockID, let subTarget, _): handleBlockContext(blockID: blockID, subTarget: subTarget)@@ -146,14 +146,15 @@ struct WebDocumentMessageRouter { /// A tap on a note indicator opens the existing note popover, mirroring the SwiftUI /// path: for a block-level indicator the popover targets the block; for a table-row- /// indicator it targets the row sub-id (`{blockId}-row-{ordinal}`) (Req 5.2).- private func handleNoteIndicatorTap(blockID: String, rowOrdinal: Int?) {+ /// indicator it targets the row sub-id (`{blockId}-row-{ordinal}`); for a list-item+ /// indicator it targets the item sub-id (`{blockId}-item-{n}`, T-1745) (Req 5.2).+ private func handleNoteIndicatorTap(blockID: String, rowOrdinal: Int?, subID: String?) { guard let resolved = blockAndIndex(forDOMID: blockID) else { return } let block = resolved.block coordinator.notePopoverBlock = block coordinator.notePopoverSourceIndex = resolved.sourceIndex coordinator.notePopoverHeadingPath = headingPath(for: block, sourceIndex: resolved.sourceIndex)- coordinator.notePopoverListItemId = nil+ coordinator.notePopoverListItemId = Self.listItem(forSubID: subID, block: block)?.id if let rowOrdinal { coordinator.notePopoverTableRowId = "\(block.id)-row-\(rowOrdinal)" } else {@@ -161,18 +162,34 @@ struct WebDocumentMessageRouter { } } - /// A tap on an inline note bubble opens the popover/edit flow for that block,+ /// A tap on an inline note bubble opens the popover/edit flow for that block — or for+ /// the list item the bubble is hosted in, when the bubble carries one (T-1745) — /// matching the SwiftUI `onTapInlineNote` handler (Req 5.6).- private func handleInlineNoteTap(blockID: String) {+ private func handleInlineNoteTap(blockID: String, subID: String?) { guard let resolved = blockAndIndex(forDOMID: blockID) else { return } let block = resolved.block coordinator.notePopoverBlock = block coordinator.notePopoverSourceIndex = resolved.sourceIndex coordinator.notePopoverHeadingPath = headingPath(for: block, sourceIndex: resolved.sourceIndex)- coordinator.notePopoverListItemId = nil+ coordinator.notePopoverListItemId = Self.listItem(forSubID: subID, block: block)?.id coordinator.notePopoverTableRowId = nil } + /// The list item a `data-prism-sub` value names on `block` — its note-anchor id and its+ /// context text — or nil when the message carried none.+ ///+ /// Looked up in the block's own items rather than string-built, so a stale or forged+ /// `subID` targets nothing instead of a fabricated anchor. The value is the whole+ /// address including any nesting (`item-0-item-1`); reading a leading ordinal out of it+ /// would resolve to the parent item (T-1745).+ private static func listItem(+ forSubID subID: String?, block: MarkdownBlock+ ) -> (id: String, text: String)? {+ guard let subID, subID.hasPrefix("item-") else { return nil }+ let candidate = "\(block.id)-\(subID)"+ return block.allListItemIds().first { $0.id == candidate }+ }+ /// The block's heading ancestry, for note-lookup disambiguation (T-209). Uses the /// O(1) sourceIndex-keyed lookup the note pipeline relies on. private func headingPath(for block: MarkdownBlock, sourceIndex: Int) -> [String]? {@@ -188,15 +205,16 @@ struct WebDocumentMessageRouter { switch subTarget { case nil: coordinator.showAddNoteSheet(for: block, sourceIndex: sourceIndex)- case .listItem(let ordinal):- guard let itemId = block.listItemId(at: ordinal),- let itemText = block.listItemText(at: ordinal) else {+ case .listItem(let subID):+ // Same resolution as an indicator/bubble tap, so creating a note and reopening+ // it address the identical anchor at every nesting depth (T-1745).+ guard let item = Self.listItem(forSubID: subID, block: block) else { coordinator.showAddNoteSheet(for: block, sourceIndex: sourceIndex) return } coordinator.showAddNoteSheet( for: block, sourceIndex: sourceIndex,- listItemId: itemId, listItemText: itemText+ listItemId: item.id, listItemText: item.text ) case .tableRow(let ordinal): let rowSubId = "\(block.id)-row-\(ordinal)"
diff --git a/prism/ViewModels/BridgeMessageRouter.swift b/prism/ViewModels/BridgeMessageRouter.swiftindex ee6bbd6..9bedf78 100644--- a/prism/ViewModels/BridgeMessageRouter.swift+++ b/prism/ViewModels/BridgeMessageRouter.swift@@ -166,12 +166,17 @@ struct BridgeMessageRouter { case .noteIndicatorTapped: guard let blockID = dict["blockID"] as? String, let rect = clientRect(dict["rect"]) else { return nil }- return .noteIndicatorTapped(blockID: blockID, rowOrdinal: int(dict["rowOrdinal"]), rect: rect)+ return .noteIndicatorTapped(+ blockID: blockID, rowOrdinal: int(dict["rowOrdinal"]),+ subID: dict["subID"] as? String, rect: rect+ ) case .inlineNoteTapped: guard let blockID = dict["blockID"] as? String, let noteID = dict["noteID"] as? String, let rect = clientRect(dict["rect"]) else { return nil }- return .inlineNoteTapped(blockID: blockID, noteID: noteID, rect: rect)+ return .inlineNoteTapped(+ blockID: blockID, noteID: noteID, subID: dict["subID"] as? String, rect: rect+ ) case .copyContent: guard let blockID = dict["blockID"] as? String, let kindString = dict["kind"] as? String,@@ -257,16 +262,23 @@ struct BridgeMessageRouter { return .init(start: start, length: length) } + /// A list item is addressed by its `data-prism-sub` value (a dotted path once nested),+ /// a table row by its ordinal — so the two kinds read different fields and a message+ /// carrying the wrong one is dropped rather than coerced (T-1745). private static func subTarget(_ value: Any?) -> InboundBridgeMessage.SubTarget? { guard let dict = value as? [String: Any],- let kind = dict["kind"] as? String,- let ordinal = int(dict["ordinal"]) else {+ let kind = dict["kind"] as? String else { return nil } switch kind {- case "listItem": return .listItem(ordinal: ordinal)- case "tableRow": return .tableRow(ordinal: ordinal)- default: return nil+ case "listItem":+ guard let subID = dict["subID"] as? String, subID.hasPrefix("item-") else { return nil }+ return .listItem(subID: subID)+ case "tableRow":+ guard let ordinal = int(dict["ordinal"]) else { return nil }+ return .tableRow(ordinal: ordinal)+ default:+ return nil } } }
diff --git a/prism/ViewModels/WebBridgeContract.swift b/prism/ViewModels/WebBridgeContract.swiftindex a6a5cd8..62b7896 100644--- a/prism/ViewModels/WebBridgeContract.swift+++ b/prism/ViewModels/WebBridgeContract.swift@@ -57,10 +57,12 @@ enum InboundBridgeMessage: Equatable, Sendable { case visibleBlock(domID: String, fraction: Double) /// `linkActivated` — href + client rect; routed natively (Req 2.4/7.1). case linkActivated(href: String, rect: ClientRect)- /// `noteIndicatorTapped` — block id (+ optional row ordinal), client rect (Req 5.2).- case noteIndicatorTapped(blockID: String, rowOrdinal: Int?, rect: ClientRect)- /// `inlineNoteTapped` — block id, note id, client rect (Req 5.6).- case inlineNoteTapped(blockID: String, noteID: String, rect: ClientRect)+ /// `noteIndicatorTapped` — block id (+ optional row ordinal, + optional sub-element id+ /// for a list-item indicator, T-1745), client rect (Req 5.2).+ case noteIndicatorTapped(blockID: String, rowOrdinal: Int?, subID: String?, rect: ClientRect)+ /// `inlineNoteTapped` — block id, note id, optional sub-element id (the list item the+ /// bubble is hosted in, T-1745), client rect (Req 5.6).+ case inlineNoteTapped(blockID: String, noteID: String, subID: String?, rect: ClientRect) /// `copyContent` — block id, kind (code/mermaid) → native ClipboardHelper (Req 1.2/3.1). case copyContent(blockID: String, kind: CopyKind) /// `blockContextRequested` — block id, optional sub-target, client rect (Req 5.3).@@ -125,8 +127,13 @@ enum InboundBridgeMessage: Equatable, Sendable { } /// A note sub-target within a block (Req 5.3).+ ///+ /// A list item carries its `data-prism-sub` VALUE, not an ordinal: nesting makes the+ /// address a path (`item-0-item-1`), and any ordinal reading of it collapses to the+ /// outermost index — the parent (T-1745). Table rows are flat (`row-{n}`), so an+ /// ordinal remains the whole address there. enum SubTarget: Equatable, Sendable {- case listItem(ordinal: Int)+ case listItem(subID: String) case tableRow(ordinal: Int) } }
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 3d772e6..42c17ca 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -888,6 +888,20 @@ nonisolated enum BlockHTMLEmitter { // A list child sits past the summary in the details' textContent, and its // sibling children all render unmapped via renderInnerBlock; keep the whole // body unmapped rather than anchoring one child kind (Decision 8).+ //+ // The `data-prism-sub` values written here are ONE-WAY, unlike every other+ // list's (T-1745). The enclosing section is the `.details` block, and+ // `MarkdownBlock.allListItemIds()` returns nothing for `.details` — so no+ // `item-N` emitted here has a native counterpart, and two lists under one+ // `<details>` emit `item-0` twice into one section. Both halves of the note+ // path fail closed on that today and must keep doing so: `NoteStateFeeder`+ // enumerates sub ids only for a `.list` block, so it never emits one that+ // could hit `subElement`'s first-match `querySelector`; and+ // `WebDocumentMessageRouter` resolves a tapped sub id by membership in+ // `allListItemIds()`, so a "+" pressed in here falls back to a block-level+ // note rather than anchoring to a fabricated or ambiguous item. Anchoring+ // details children properly is T-2032; until then neither side may derive an+ // anchor from this string alone. childrenHTML += renderListMarkup(ordered: ordered, start: start, items: items, subIDPrefix: "item", itemOffsets: nil, context: context)
diff --git a/prismTests/WebRendering/WebListItemNoteDisplayTests.swift b/prismTests/WebRendering/WebListItemNoteDisplayTests.swiftnew file mode 100644index 0000000..f33d1c1--- /dev/null+++ b/prismTests/WebRendering/WebListItemNoteDisplayTests.swift@@ -0,0 +1,357 @@+//+// WebListItemNoteDisplayTests.swift+// prismTests+//+// Regression tests for T-1745: "List item note display issues".+//+// Reported scenario, reproduced verbatim below: a two-item list with ONE note on the+// SECOND item. Two display defects followed, both from the same cause — the note+// pipeline had no per-list-item slot, so a list-item note was addressed at the LIST+// block:+// 1. the inline note bubble rendered at the bottom of the whole list rather than+// under item 2;+// 2. item 1's add-note "+" turned into a filled dot, because prism-notes.js drops a+// block-level indicator at `section.firstChild` (the gutter beside the first item)+// and suppresses the nearest `li > [data-prism-add-note]` — item 1's.+// The note's persisted anchor was always correct (`{hash}-item-1`); only the display+// mapping was wrong.+//+// These drive the REAL feeder payload through the REAL prism-notes.js in a live+// WebPage, so the native `subID` and the emitted `data-prism-sub` are proved to agree+// end to end rather than each side being asserted against a fixture.+//++import Foundation+import Testing+import WebKit+@testable import prism++@Suite("List-item note display (T-1745)")+@MainActor+struct WebListItemNoteDisplayTests {++ private static let notesScripts = ["prism-scroll", "prism-theme", "prism-media", "prism-notes"]++ /// The user's document: a two-item list.+ private func listBlock() -> MarkdownBlock {+ .list(ordered: false, start: 1, items: [+ ListItem(content: "First item", checkbox: nil),+ ListItem(content: "Second item", checkbox: nil),+ ])+ }++ private func manager() -> NotesManager {+ NotesManager.makeForTesting(store: MockNotesStore())+ }++ /// An active note keyed at `blockId`. Imported (author set) so `setImportedNotes`+ /// can inject it without touching iCloud.+ private func note(blockId: String, content: String) -> BlockNote {+ BlockNote(+ blockId: blockId,+ contextQuote: "",+ content: content,+ status: .active,+ createdAt: Date(timeIntervalSince1970: 1_700_000_000),+ modifiedAt: Date(timeIntervalSince1970: 1_700_000_000),+ author: "Tester",+ threadId: nil+ )+ }++ private func domID(_ block: MarkdownBlock, index: Int = 0) -> String {+ "b-\(block.id)-\(index)"+ }++ /// Builds the live page for `blocks` and pushes the feeder's real payloads for a+ /// notes manager holding `notes` keyed by anchor id.+ private func liveHarness(+ blocks: [MarkdownBlock], notes: [String: [BlockNote]]+ ) async throws -> WebDocumentLiveHarness {+ let manager = manager()+ manager.setImportedNotes(notes)+ let payloads = NoteStateFeeder.payloads(+ blocks: blocks, notesManager: manager,+ showInlineNotes: true, bannerPlacement: .top, exportUsername: ""+ )+ let harness = try await WebDocumentLiveHarness.make(+ blocks: blocks, featureScripts: Self.notesScripts+ )+ try await harness.send(.setNoteIndicators(json: payloads.indicatorsJSON))+ if let inline = payloads.inlineNotesJSON {+ try await harness.send(.setInlineNotes(json: inline))+ }+ return harness+ }++ // MARK: - Symptom 1: the bubble rendered at the bottom of the list++ @Test("A note on list item 2 renders its bubble inside item 2, not after the list")+ func inlineNoteRendersUnderTheNotedItem() async throws {+ let list = listBlock()+ let itemId = try #require(list.listItemId(at: 1))+ let harness = try await liveHarness(+ blocks: [list], notes: [itemId: [note(blockId: itemId, content: "Note on two")]]+ )++ let insideItem = try await harness.evalBool(+ "var li = document.querySelector('[data-prism-sub=\"item-1\"]');"+ + " return li ? !!li.querySelector('[data-prism-inline-note-host]') : false;"+ )+ #expect(insideItem == true, "the bubble must live inside the noted <li>")++ // And NOT as a trailing child of the list section (the reported "bottom of the list").+ let atBlockLevel = try await harness.evalBool(+ "var s = document.getElementById('\(domID(list))');"+ + " return s ? !!s.querySelector(':scope > [data-prism-inline-note-host]') : false;"+ )+ #expect(atBlockLevel == false, "no block-level bubble host for a list-item-only note")+ }++ @Test("The bubble carries the item's note content")+ func inlineNoteCarriesTheItemContent() async throws {+ let list = listBlock()+ let itemId = try #require(list.listItemId(at: 1))+ let harness = try await liveHarness(+ blocks: [list], notes: [itemId: [note(blockId: itemId, content: "Note on two")]]+ )+ let text = try await harness.evalString(+ "var li = document.querySelector('[data-prism-sub=\"item-1\"]');"+ + " var host = li ? li.querySelector('[data-prism-inline-note-host]') : null;"+ + " return host ? host.textContent : '';"+ )+ #expect(text?.contains("Note on two") == true)+ }++ // MARK: - Symptom 2: item 1's "+" turned into a dot++ @Test("The indicator dot lands on item 2, leaving item 1's add-note + intact")+ func indicatorLandsOnTheNotedItem() async throws {+ let list = listBlock()+ let itemId = try #require(list.listItemId(at: 1))+ let harness = try await liveHarness(+ blocks: [list], notes: [itemId: [note(blockId: itemId, content: "Note on two")]]+ )++ let dotOnItemTwo = try await harness.evalBool(+ "var li = document.querySelector('[data-prism-sub=\"item-1\"]');"+ + " return li ? !!li.querySelector('[data-prism-note-indicator]') : false;"+ )+ #expect(dotOnItemTwo == true, "item 2 carries the note, so item 2 carries the dot")++ let dotOnItemOne = try await harness.evalBool(+ "var li = document.querySelector('[data-prism-sub=\"item-0\"]');"+ + " return li ? !!li.querySelector('[data-prism-note-indicator]') : false;"+ )+ #expect(dotOnItemOne == false, "item 1 has no note and must carry no dot")++ // The reported symptom: item 1's "+" was suppressed (display:none) by the dot.+ let itemOnePlusVisible = try await harness.evalBool(+ "var li = document.querySelector('[data-prism-sub=\"item-0\"]');"+ + " var add = li ? li.querySelector('[data-prism-add-note]') : null;"+ + " return !!add && !add.hasAttribute('data-prism-suppressed');"+ )+ #expect(itemOnePlusVisible == true, "item 1 keeps its add-note + — the note is not its")++ // Item 2's own "+" IS redundant next to its dot, and stays suppressed.+ let itemTwoPlusSuppressed = try await harness.evalBool(+ "var li = document.querySelector('[data-prism-sub=\"item-1\"]');"+ + " var add = li ? li.querySelector('[data-prism-add-note]') : null;"+ + " return !!add && add.hasAttribute('data-prism-suppressed');"+ )+ #expect(itemTwoPlusSuppressed == true)+ }++ // MARK: - Tap routing++ @Test("Tapping the item's dot posts noteIndicatorTapped with the item sub id")+ func indicatorTapCarriesTheSubID() async throws {+ let list = listBlock()+ let itemId = try #require(list.listItemId(at: 1))+ let harness = try await liveHarness(+ blocks: [list], notes: [itemId: [note(blockId: itemId, content: "Note on two")]]+ )+ _ = try await harness.page.callJavaScript(+ "var i = document.querySelector('[data-prism-sub=\"item-1\"] [data-prism-note-indicator]');"+ + " if (i) { i.click(); } return null;",+ contentWorld: harness.bridgeWorld+ )+ let message = try await harness.waitForMessage(type: "noteIndicatorTapped")+ #expect(message?["blockID"] as? String == domID(list))+ #expect(message?["subID"] as? String == "item-1")+ }++ // MARK: - Nested items++ /// A parent whose second child carries the interesting id: `item-0-item-1` truncates to+ /// the PARENT's ordinal (0) under any `parseInt`-style reading of the sub id.+ private func nestedListBlock() -> MarkdownBlock {+ let nested = ListItem.NestedList(ordered: false, start: 1, items: [+ ListItem(content: "Child A", checkbox: nil),+ ListItem(content: "Child B", checkbox: nil),+ ])+ return .list(ordered: false, start: 1, items: [+ ListItem(content: "Parent", checkbox: nil, children: [.nestedList(nested)]),+ ListItem(content: "Sibling", checkbox: nil),+ ])+ }++ /// CREATION, not display: the display tests below inject an anchor that already names+ /// the nested item, so they cannot see a creation path that addresses the wrong one.+ /// A nested item's "+" must report the item it sits on — the whole dotted sub id, not a+ /// leading ordinal, which names the PARENT and would file the note one level up.+ @Test("A nested item's + posts the nested item's own sub id")+ func nestedItemAddNotePostsTheNestedSubID() async throws {+ let list = nestedListBlock()+ let harness = try await liveHarness(blocks: [list], notes: [:])++ _ = try await harness.page.callJavaScript(+ "var b = document.querySelector("+ + "'[data-prism-sub=\"item-0-item-1\"] > [data-prism-add-note]');"+ + " if (b) { b.click(); } return null;",+ contentWorld: harness.bridgeWorld+ )++ let message = try await harness.waitForMessage(type: "blockContextRequested")+ #expect(message?["blockID"] as? String == domID(list))+ let sub = message?["subTarget"] as? [String: Any]+ #expect(sub?["kind"] as? String == "listItem")+ #expect(+ sub?["subID"] as? String == "item-0-item-1",+ "the + must name the nested item; a bare ordinal resolves to the parent"+ )+ }++ /// The same loss of spatial association as symptom 1, one level down: a note on an item+ /// that OWNS a nested list must read under that item's own text, not below its whole+ /// subtree — which is where a plain `appendChild` puts it.+ @Test("A parent item's bubble renders above its nested list, not below it")+ func parentItemBubbleSitsAboveTheNestedList() async throws {+ let list = nestedListBlock()+ let parentId = "\(list.id)-item-0"+ let harness = try await liveHarness(+ blocks: [list], notes: [parentId: [note(blockId: parentId, content: "Parent note")]]+ )++ let hostBeforeNestedList = try await harness.evalBool(+ "var li = document.querySelector('[data-prism-sub=\"item-0\"]');"+ + " if (!li) { return false; }"+ + " var host = li.querySelector(':scope > [data-prism-inline-note-host]');"+ + " var nested = li.querySelector(':scope > ul, :scope > ol');"+ + " if (!host || !nested) { return false; }"+ + " return !!(host.compareDocumentPosition(nested)"+ + " & Node.DOCUMENT_POSITION_FOLLOWING);"+ )+ #expect(+ hostBeforeNestedList == true,+ "the bubble must sit under the item's own text, above its nested list"+ )+ }++ @Test("A note on a nested list item lands on that nested item")+ func nestedItemNoteLandsOnTheNestedItem() async throws {+ let list = nestedListBlock()+ // `allListItemIds()` spells the nested child as `{hash}-item-0-item-1`; the emitter+ // spells the same item's DOM attribute `data-prism-sub="item-0-item-1"`.+ let childId = "\(list.id)-item-0-item-1"+ let harness = try await liveHarness(+ blocks: [list], notes: [childId: [note(blockId: childId, content: "Nested note")]]+ )++ let onChild = try await harness.evalBool(+ "var li = document.querySelector('[data-prism-sub=\"item-0-item-1\"]');"+ + " return li ? (!!li.querySelector('[data-prism-note-indicator]')"+ + " && !!li.querySelector('[data-prism-inline-note-host]')) : false;"+ )+ #expect(onChild == true)+ }++ // MARK: - Block-level list notes are unaffected++ @Test("A note on the list block itself still renders at the block level")+ func blockLevelListNoteStaysAtBlockLevel() async throws {+ let list = listBlock()+ let harness = try await liveHarness(+ blocks: [list], notes: [list.id: [note(blockId: list.id, content: "Whole-list note")]]+ )+ let atBlockLevel = try await harness.evalBool(+ "var s = document.getElementById('\(domID(list))');"+ + " return s ? (!!s.querySelector(':scope > [data-prism-inline-note-host]')"+ + " && !!s.querySelector(':scope > [data-prism-note-indicator]')) : false;"+ )+ #expect(atBlockLevel == true)+ }++ /// Symptom 2 again, for the one anchor kind still addressed at the block: a whole-list+ /// note used to hide item 1's "+" (`suppressAddNote`'s `li > …` fallback) because the+ /// block dot landed in item 1's gutter. The note belongs to the LIST, so no item may+ /// lose its own add-note affordance.+ @Test("A whole-list note leaves every item's add-note + visible")+ func blockLevelListNoteKeepsEveryItemPlus() async throws {+ let list = listBlock()+ let harness = try await liveHarness(+ blocks: [list], notes: [list.id: [note(blockId: list.id, content: "Whole-list note")]]+ )++ let suppressedCount = try await harness.evalString(+ "return String(document.querySelectorAll("+ + "'li[data-prism-sub] > [data-prism-add-note][data-prism-suppressed]').length);"+ )+ #expect(suppressedCount == "0", "a note on the LIST suppresses no ITEM's +")++ // Not a geometry check: the harness applies no stylesheet, so the `display: none`+ // that suppression relies on can never take effect here and a measured width is+ // non-zero whatever the attributes say. Assert what the code does toggle instead —+ // each item still owns an add-note element, in the document, unsuppressed, and not+ // the dot itself (the dot must not have been retargeted onto an item).+ let intactPlusCount = try await harness.evalString(+ "var n = 0; var adds = document.querySelectorAll('li[data-prism-sub] > [data-prism-add-note]');"+ + " for (var i = 0; i < adds.length; i++) { var el = adds[i];"+ + " if (el.isConnected && !el.hasAttribute('data-prism-suppressed')"+ + " && !el.hasAttribute('data-prism-note-indicator')) { n++; } }"+ + " return String(n);"+ )+ #expect(intactPlusCount == "2", "both items keep an add-note + of their own")++ let dotsInsideItems = try await harness.evalString(+ "return String(document.querySelectorAll('li [data-prism-note-indicator]').length);"+ )+ #expect(dotsInsideItems == "0", "a note on the LIST puts no dot inside an item")+ }++ /// Why the suppression existed: at the shared gutter offset the block dot and item 1's+ /// "+" occupy overlapping boxes, so dropping the suppression alone would have them+ /// collide. The dot is marked instead, and document.css moves it one column further out.+ ///+ /// The live harness never applies the stylesheet (see `DocumentCSSAddNoteAffordanceTests`),+ /// so this pins the MARKING; the column arithmetic is pinned in+ /// `DocumentCSSNoteGutterRulesTests`.+ @Test("A whole-list dot is marked as sitting beside per-item gutter chrome")+ func blockLevelListDotTakesItsOwnColumn() async throws {+ let list = listBlock()+ let harness = try await liveHarness(+ blocks: [list], notes: [list.id: [note(blockId: list.id, content: "Whole-list note")]]+ )+ let marked = try await harness.evalBool(+ "var d = document.querySelector('#\(domID(list)) > [data-prism-note-indicator]');"+ + " return !!d && d.hasAttribute('data-prism-beside-items');"+ )+ #expect(marked == true, "the dot must take its own column beside the items' +")+ }++ /// The converse: a block whose items are NOT in the section gutter (a paragraph here,+ /// but the shape that matters is a list nested inside another block) keeps the plain+ /// block-dot column, so the override stays scoped to the case that collides.+ @Test("A non-list block's dot is not marked")+ func blockDotWithoutItemsIsNotMarked() async throws {+ let para = MarkdownBlock.paragraph(markdown: "Just a paragraph")+ let harness = try await liveHarness(+ blocks: [para], notes: [para.id: [note(blockId: para.id, content: "Block note")]]+ )+ let marked = try await harness.evalBool(+ "var d = document.querySelector('#\(domID(para)) > [data-prism-note-indicator]');"+ + " return !!d && d.hasAttribute('data-prism-beside-items');"+ )+ #expect(marked == false)+ }+}
diff --git a/prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift b/prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swiftnew file mode 100644index 0000000..0a071a4--- /dev/null+++ b/prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift@@ -0,0 +1,403 @@+//+// DocumentCSSNoteGutterRulesTests.swift+// prismTests+//+// Stylesheet guard for the note gutter columns (T-1745).+//+// Three offsets share one narrow strip to the left of the content, and the relationship+// between them is what the fix depends on — but nothing in the sheet states it, so a+// plausible-looking edit to any one of them silently reintroduces the bug:+//+// 1. An ITEM's "+" and an ITEM's note dot must sit in the SAME column. They mark the+// same anchor and are mutually exclusive (the dot replaces the "+"), so a note that+// appeared to jump sideways the moment it was added would read as a layout bug.+// They share it by both reading `--prism-item-gutter`, declared once per list level —+// plus one term the token cannot carry, the chip's own width, which the dot's rule+// restates as a literal to centre itself on the "+" (pinned to the chip rule below).+// 2. A LIST's block-level dot must sit in a DIFFERENT column from its items' "+"+// chips. It marks a different anchor (the whole list), and at the plain block-dot+// offset its box overlaps item 1's chip — which is why the dot used to hide that+// "+" rather than sit beside it (symptom 2 of T-1745).+// 3. Every one of those columns must still be inside the gutter `main` and `body`+// reserve, or the affordance is clipped by `body { overflow-x: hidden }`.+//+// Rule-pin, not a rendered proof: the live WebPage harness never applies the emitted+// stylesheet (specs/readable-tables-restoration/decision_log.md Decision 2), so computed+// geometry cannot be asserted here — the arithmetic is done against the sheet's own+// declared values instead. `WebListItemNoteDisplayTests` pins the JS half (which dot gets+// marked). On-device verification of the rendered result is still manual.+//++import Foundation+import Testing+@testable import prism++@Suite("document.css note gutter columns (T-1745)")+@MainActor+struct DocumentCSSNoteGutterRulesTests {++ /// The token both of an item's leading affordances read.+ private static let token = "--prism-item-gutter"++ // MARK: - Sheet access++ /// Loads the bundled `document.css` from the same `Bundle.main` location the scheme+ /// handler serves it from.+ private static func loadDocumentCSS() throws -> String {+ let url = try #require(+ Bundle.main.url(forResource: "document", withExtension: "css"),+ "bundled document.css must be present in the test host"+ )+ return try String(contentsOf: url, encoding: .utf8)+ }++ /// Strips `/* … */` comments, then collapses whitespace.+ ///+ /// Comments must go first: the prose in this stylesheet quotes both the token and the+ /// selectors it is describing, so a fragment matched inside a comment would otherwise+ /// be mistaken for a declaration. This suite's own comments do exactly that.+ private static func flattened(_ css: String) -> String {+ css+ .replacingOccurrences(of: "(?s)/\\*.*?\\*/", with: " ", options: .regularExpression)+ .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)+ }++ /// Every `(selector, body)` pair in the flattened sheet, in source order.+ ///+ /// A flat split on `{`/`}` is enough because this stylesheet nests only inside+ /// `@media`/`@supports` blocks, whose bodies are themselves rule lists — an at-rule+ /// prelude simply shows up as a selector with no declarations, which no query here+ /// matches.+ private static func rules(in css: String) -> [(selector: String, body: String)] {+ var result: [(selector: String, body: String)] = []+ var selector = ""+ var body = ""+ var inBody = false+ for character in css {+ switch character {+ case "{":+ inBody = true+ body = ""+ case "}":+ if inBody {+ result.append((+ selector: selector.trimmingCharacters(in: .whitespaces),+ body: body.trimmingCharacters(in: .whitespaces)+ ))+ }+ inBody = false+ selector = ""+ default:+ if inBody { body.append(character) } else { selector.append(character) }+ }+ }+ return result+ }++ /// The declared value of `property` in `body`, or nil.+ private static func value(of property: String, in body: String) -> String? {+ for declaration in body.split(separator: ";") {+ let parts = declaration.split(separator: ":", maxSplits: 1)+ guard parts.count == 2,+ parts[0].trimmingCharacters(in: .whitespaces) == property else { continue }+ return parts[1].trimmingCharacters(in: .whitespaces)+ }+ return nil+ }++ /// The leading `em` quantity in `value` (`-3.375em`, `calc(-1.5em + …)`), or nil.+ private static func em(_ value: String?) -> Double? {+ guard let value,+ let range = value.range(of: "-?[0-9]*\\.?[0-9]+em", options: .regularExpression)+ else { return nil }+ return Double(value[range].dropLast(2))+ }++ /// Every `em` quantity in `value`, in order — for `calc()` offsets that spell out more+ /// than one term (`calc(0.1em + (1.0625em + 2px - 0.5em) / 2)`).+ private static func ems(_ value: String?) -> [Double] {+ guard let value else { return [] }+ var quantities: [Double] = []+ var searchRange = value.startIndex..<value.endIndex+ while let range = value.range(+ of: "-?[0-9]*\\.?[0-9]+em", options: .regularExpression, range: searchRange+ ) {+ if let quantity = Double(value[range].dropLast(2)) { quantities.append(quantity) }+ searchRange = range.upperBound..<value.endIndex+ }+ return quantities+ }++ /// The left padding `ruleBody` ends up declaring, resolved across the `padding`+ /// shorthand and any `padding-left` longhand, last declaration winning.+ ///+ /// Needed because `body` spends its gutter contribution through the shorthand: reading+ /// `padding-left` alone would silently find nothing and, worse, invite the derivation+ /// below to assume a value the sheet never states.+ private static func leftPadding(in ruleBody: String) -> String? {+ var result: String?+ for declaration in ruleBody.split(separator: ";") {+ let parts = declaration.split(separator: ":", maxSplits: 1)+ guard parts.count == 2 else { continue }+ let property = parts[0].trimmingCharacters(in: .whitespaces)+ let declared = parts[1].trimmingCharacters(in: .whitespaces)+ switch property {+ case "padding-left":+ result = declared+ case "padding":+ let sides = declared.split(separator: " ").map(String.init)+ switch sides.count {+ case 1: result = sides[0]+ case 2, 3: result = sides[1] // top/right → right is also left+ case 4...: result = sides[3] // top/right/bottom/left+ default: break+ }+ default:+ continue+ }+ }+ return result+ }++ // MARK: - 1. The item column is shared++ @Test("An item's + and its note dot both read the shared gutter token")+ func itemAffordancesReadTheSharedToken() throws {+ let css = Self.flattened(try Self.loadDocumentCSS())+ let rules = Self.rules(in: css)++ for selectorFragment in ["li > .prism-add-note", "li > .prism-note-indicator"] {+ let rule = try #require(+ rules.first { $0.selector.hasPrefix(selectorFragment) },+ "document.css must keep a `\(selectorFragment)` positioning rule"+ )+ let left = try #require(+ Self.value(of: "left", in: rule.body),+ "`\(selectorFragment)` must declare a left offset"+ )+ #expect(+ left.contains("var(\(Self.token))"),+ """+ `\(selectorFragment)` must read \(Self.token) rather than restate a literal, \+ so an item's "+" and its note dot cannot drift out of one column (T-1745). \+ Declared: \(left)+ """+ )+ }+ }++ @Test("Each list level declares the gutter token exactly once")+ func eachLevelDeclaresTheTokenOnce() throws {+ let css = Self.flattened(try Self.loadDocumentCSS())+ let declaring = Self.rules(in: css).filter { Self.value(of: Self.token, in: $0.body) != nil }++ #expect(declaring.count >= 3, "expected a base plus per-level declarations")++ let selectors = declaring.map(\.selector)+ #expect(+ Set(selectors).count == selectors.count,+ """+ \(Self.token) is declared twice for the same selector, so which offset a level \+ resolves to depends on source order: \(selectors)+ """+ )+ #expect(+ selectors.contains("li"),+ "the bare `li` base declaration is the fallback for levels past the enumerated ones"+ )+ }++ // MARK: - 2. A list's block dot takes a different column++ /// The offsets, resolved from the sheet, that decide whether a whole-list note's dot+ /// lands on top of item 1's "+".+ private struct Columns {+ /// `ul, ol` padding-left — how far a top-level item is indented from the section.+ let listIndent: Double+ /// The gutter token for a top-level item, relative to that item.+ let topLevelItemGutter: Double+ /// The "+" chip's own width (its `::before` box, borders excluded).+ let chipWidth: Double+ /// The block-level dot's width.+ let dotWidth: Double+ /// The block-level dot's offset when it sits beside per-item gutter chrome.+ let besideItemsDotLeft: Double+ /// The gutter reserved to the left of the section: `main` padding plus `body`'s.+ let reservedGutter: Double++ /// The item chip's leading edge, relative to the SECTION.+ var chipLeft: Double { listIndent + topLevelItemGutter }+ /// The block dot's trailing edge, relative to the section.+ var dotRight: Double { besideItemsDotLeft + dotWidth }+ }++ private static func columns(in css: String) throws -> Columns {+ let rules = self.rules(in: css)++ func body(startingWith fragment: String, _ label: String) throws -> String {+ try #require(+ rules.first { $0.selector.hasPrefix(fragment) }?.body,+ "document.css must keep the \(label) rule (`\(fragment)`)"+ )+ }++ let listBody = try body(startingWith: "ul, ol", "list box")+ let itemLevelBody = try #require(+ rules.first { $0.selector.hasPrefix("section[data-prism-block-id] > ul > li,") }?.body,+ "document.css must keep the top-level item gutter declaration"+ )+ let chipBody = try body(startingWith: ".prism-add-note::before", "add-note chip")+ let dotBody = try body(+ startingWith: "section[data-prism-block-id] > .prism-note-indicator[data-prism-note-indicator]",+ "block-level note dot"+ )+ let besideBody = try body(+ startingWith: "section[data-prism-block-id] > .prism-note-indicator[data-prism-beside-items]",+ "beside-items block dot"+ )+ let mainBody = try body(startingWith: "main", "main")++ // The chip's `width: 1.25em` computes against its OWN `font-size: 0.85em`, not the+ // item's — the trap the T-1542 note in the sheet records. Borders are excluded: they+ // widen the chip to the right, away from the dot's column, so leaving them out keeps+ // the clearance check exact rather than optimistic.+ let declaredChipWidth = try #require(em(value(of: "width", in: chipBody)))+ let chipFontSize = try #require(em(value(of: "font-size", in: chipBody)))++ // The reserve is `main`'s left padding plus `body`'s. Both are read off the rules+ // that declare them — `body` spends its through the `padding` shorthand — so a+ // padding edit either changes the arithmetic below or fails here, rather than+ // leaving the clip budget quietly wrong.+ let rootBody = try #require(+ rules.first { $0.selector.hasSuffix(":root") }?.body, "document.css must have a :root block"+ )+ let bodyRuleBody = try #require(+ rules.first { $0.selector == "body" }?.body, "document.css must keep the `body` rule"+ )++ /// A padding declaration in em, resolving the one token the sheet spends there.+ func padding(_ declared: String?, _ label: String) throws -> Double {+ let declared = try #require(declared, "`\(label)` must declare a left padding")+ if declared == "var(--prism-spacing)" {+ return try #require(+ em(value(of: "--prism-spacing", in: rootBody)),+ "--prism-spacing must stay an em quantity for the gutter reserve to resolve"+ )+ }+ return try #require(+ em(declared),+ """+ `\(label)`'s left padding must be an em quantity (or --prism-spacing) — the \+ gutter reserve is derived from it and cannot be compared against the em \+ offsets otherwise. Declared: \(declared)+ """+ )+ }++ let mainPadding = try padding(leftPadding(in: mainBody), "main")+ let bodyPadding = try padding(leftPadding(in: bodyRuleBody), "body")++ return Columns(+ listIndent: try #require(em(value(of: "padding-left", in: listBody))),+ topLevelItemGutter: try #require(em(value(of: token, in: itemLevelBody))),+ chipWidth: declaredChipWidth * chipFontSize,+ dotWidth: try #require(em(value(of: "width", in: dotBody))),+ besideItemsDotLeft: try #require(em(value(of: "left", in: besideBody))),+ reservedGutter: mainPadding + bodyPadding+ )+ }++ @Test("The item dot's centring literal is the + chip's computed box")+ func itemDotCentringMatchesTheChipBox() throws {+ let css = Self.flattened(try Self.loadDocumentCSS())+ let columns = try Self.columns(in: css)+ let rules = Self.rules(in: css)++ let dotRule = try #require(+ rules.first { $0.selector.hasPrefix("li > .prism-note-indicator") },+ "document.css must keep a `li > .prism-note-indicator` positioning rule"+ )++ // Both offsets centre the 0.5em dot on the chip via `(chip - dot) / 2`, and both+ // spell the chip's width as a literal — the one term `--prism-item-gutter` cannot+ // carry, since it is the same for the "+" and the dot. Widen the chip or change its+ // font-size without touching these and the dot silently decentres, so the literal is+ // pinned to the chip rule it was derived from.+ for property in ["left", "top"] {+ let declared = try #require(+ Self.value(of: property, in: dotRule.body),+ "`li > .prism-note-indicator` must declare a \(property) offset"+ )+ #expect(+ Self.ems(declared).contains { abs($0 - columns.chipWidth) < 1e-9 },+ """+ `li > .prism-note-indicator`'s \(property) centres the dot on a chip of some \+ other width than the \(columns.chipWidth)em `.prism-add-note::before` \+ actually computes to (1.25em at font-size 0.85em), so an item's note dot no \+ longer lands on its "+" (T-1745). Declared: \(declared)+ """+ )+ }+ }++ @Test("A list's block-level dot column clears item 1's + chip")+ func blockDotColumnClearsTheItemChip() throws {+ let css = Self.flattened(try Self.loadDocumentCSS())+ let columns = try Self.columns(in: css)++ #expect(+ columns.dotRight <= columns.chipLeft,+ """+ a whole-list note's dot runs to \(columns.dotRight)em and item 1's "+" chip \+ starts at \(columns.chipLeft)em (both relative to the section), so they overlap \+ — the dot would sit on top of the "+" instead of beside it (T-1745)+ """+ )+ }++ @Test("Every gutter column stays inside the reserved gutter")+ func gutterColumnsAreNotClipped() throws {+ let css = Self.flattened(try Self.loadDocumentCSS())+ let columns = try Self.columns(in: css)++ // `body { overflow-x: hidden }` clips anything further left than this.+ for (label, left) in [+ ("the beside-items block dot", columns.besideItemsDotLeft),+ ("item 1's + chip", columns.chipLeft),+ ] {+ #expect(+ left >= -columns.reservedGutter,+ """+ \(label) starts at \(left)em from the section, outside the \+ \(columns.reservedGutter)em gutter main and body reserve — it would be \+ clipped by `body { overflow-x: hidden }`+ """+ )+ }+ }++ @Test("The beside-items override follows the base block-dot rule")+ func besideItemsOverrideFollowsTheBaseRule() throws {+ let css = Self.flattened(try Self.loadDocumentCSS())+ let rules = Self.rules(in: css)++ let base = try #require(rules.firstIndex {+ $0.selector.hasPrefix(+ "section[data-prism-block-id] > .prism-note-indicator[data-prism-note-indicator]"+ )+ })+ let override = try #require(rules.firstIndex {+ $0.selector.hasPrefix(+ "section[data-prism-block-id] > .prism-note-indicator[data-prism-beside-items]"+ )+ })+ #expect(+ override > base,+ """+ the two rules have equal specificity (0,3,1), so the beside-items override only \+ wins by coming later in source order — it is currently at \(override), before \+ the base rule at \(base)+ """+ )+ }+}
diff --git a/prismTests/WebRendering/WebDocumentMessageRouterTests.swift b/prismTests/WebRendering/WebDocumentMessageRouterTests.swiftindex 9a4443c..98f8297 100644--- a/prismTests/WebRendering/WebDocumentMessageRouterTests.swift+++ b/prismTests/WebRendering/WebDocumentMessageRouterTests.swift@@ -225,9 +225,12 @@ struct WebDocumentMessageRouterTests { let session = makeSession(blocks: [para]) let coordinator = DocumentLayoutCoordinator() let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)- router.handle(.noteIndicatorTapped(blockID: "b-\(para.id)-0", rowOrdinal: nil, rect: rect()))+ router.handle(.noteIndicatorTapped(+ blockID: "b-\(para.id)-0", rowOrdinal: nil, subID: nil, rect: rect()+ )) #expect(coordinator.notePopoverBlock?.id == para.id) #expect(coordinator.notePopoverTableRowId == nil)+ #expect(coordinator.notePopoverListItemId == nil) } @Test("noteIndicatorTapped with a row ordinal targets the table-row sub-id")@@ -236,19 +239,71 @@ struct WebDocumentMessageRouterTests { let session = makeSession(blocks: [table]) let coordinator = DocumentLayoutCoordinator() let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)- router.handle(.noteIndicatorTapped(blockID: "b-\(table.id)-0", rowOrdinal: 1, rect: rect()))+ router.handle(.noteIndicatorTapped(+ blockID: "b-\(table.id)-0", rowOrdinal: 1, subID: nil, rect: rect()+ )) #expect(coordinator.notePopoverBlock?.id == table.id) #expect(coordinator.notePopoverTableRowId == "\(table.id)-row-1") } + @Test("noteIndicatorTapped with a list-item subID targets the item sub-id (T-1745)")+ func listItemIndicatorTapOpensItemPopover() {+ let list = MarkdownBlock.list(ordered: false, start: 1, items: [+ ListItem(content: "First", checkbox: nil),+ ListItem(content: "Second", checkbox: nil),+ ])+ let session = makeSession(blocks: [list])+ let coordinator = DocumentLayoutCoordinator()+ let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+ router.handle(.noteIndicatorTapped(+ blockID: "b-\(list.id)-0", rowOrdinal: nil, subID: "item-1", rect: rect()+ ))+ #expect(coordinator.notePopoverBlock?.id == list.id)+ #expect(coordinator.notePopoverListItemId == "\(list.id)-item-1")+ #expect(coordinator.notePopoverTableRowId == nil)+ }++ @Test("A subID naming no real item targets nothing rather than a fabricated anchor")+ func unknownListItemSubIDIsIgnored() {+ let list = MarkdownBlock.list(ordered: false, start: 1, items: [+ ListItem(content: "First", checkbox: nil),+ ])+ let session = makeSession(blocks: [list])+ let coordinator = DocumentLayoutCoordinator()+ let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+ router.handle(.noteIndicatorTapped(+ blockID: "b-\(list.id)-0", rowOrdinal: nil, subID: "item-9", rect: rect()+ ))+ #expect(coordinator.notePopoverBlock?.id == list.id)+ #expect(coordinator.notePopoverListItemId == nil)+ }+ @Test("inlineNoteTapped opens the block popover/edit flow") func inlineNoteTapOpensPopover() { let para = MarkdownBlock.paragraph(markdown: "x") let session = makeSession(blocks: [para]) let coordinator = DocumentLayoutCoordinator() let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)- router.handle(.inlineNoteTapped(blockID: "b-\(para.id)-0", noteID: "n1", rect: rect()))+ router.handle(.inlineNoteTapped(+ blockID: "b-\(para.id)-0", noteID: "n1", subID: nil, rect: rect()+ )) #expect(coordinator.notePopoverBlock?.id == para.id)+ #expect(coordinator.notePopoverListItemId == nil)+ }++ @Test("inlineNoteTapped from an item-hosted bubble targets that item (T-1745)")+ func inlineNoteTapFromItemHostTargetsItem() {+ let list = MarkdownBlock.list(ordered: false, start: 1, items: [+ ListItem(content: "First", checkbox: nil),+ ListItem(content: "Second", checkbox: nil),+ ])+ let session = makeSession(blocks: [list])+ let coordinator = DocumentLayoutCoordinator()+ let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+ router.handle(.inlineNoteTapped(+ blockID: "b-\(list.id)-0", noteID: "n1", subID: "item-1", rect: rect()+ ))+ #expect(coordinator.notePopoverListItemId == "\(list.id)-item-1") } @Test("blockContextRequested (no sub-target) opens the block add-note sheet")@@ -272,7 +327,7 @@ struct WebDocumentMessageRouterTests { let coordinator = DocumentLayoutCoordinator() let router = WebDocumentMessageRouter(session: session, coordinator: coordinator) router.handle(.blockContextRequested(- blockID: "b-\(list.id)-0", subTarget: .listItem(ordinal: 1), rect: rect()+ blockID: "b-\(list.id)-0", subTarget: .listItem(subID: "item-1"), rect: rect() )) #expect(coordinator.addNoteBlock?.id == list.id) if case .listItem(let id, let text) = coordinator.addNoteAnchor {@@ -283,6 +338,113 @@ struct WebDocumentMessageRouterTests { } } + /// The wire shape feeding the handlers above, decoded by `BridgeMessageRouter`. Pinned+ /// here because a half-migration is silent: a `listItem` sub-target still carrying only+ /// the old `ordinal` field would decode to nil and quietly downgrade every list-item+ /// note to a block note (T-1745).+ @Test("A listItem sub-target decodes from subID, and the old ordinal-only shape does not")+ func listItemSubTargetDecodesFromSubID() {+ let generation = BridgeGeneration(sessionID: "s", parseRevision: 1, processGeneration: 0)+ let router = BridgeMessageRouter(expectedGeneration: generation)++ func decodeSubTarget(_ subTarget: [String: Any]) -> InboundBridgeMessage.SubTarget?? {+ let result = router.decode(body: [+ "type": "blockContextRequested",+ "generation": generation.argumentValue,+ "blockID": "b-x-0",+ "rect": ["x": 0, "y": 0, "width": 1, "height": 1],+ "subTarget": subTarget,+ ])+ guard case .accepted(.blockContextRequested(_, let sub, _)) = result else { return nil }+ return .some(sub)+ }++ #expect(+ decodeSubTarget(["kind": "listItem", "subID": "item-0-item-1"])+ == .some(.listItem(subID: "item-0-item-1"))+ )+ #expect(+ decodeSubTarget(["kind": "listItem", "ordinal": 1]) == .some(nil),+ "an ordinal-only listItem carries no address and must not resolve"+ )+ #expect(decodeSubTarget(["kind": "tableRow", "ordinal": 1]) == .some(.tableRow(ordinal: 1)))+ }++ /// A nested item's sub id is a PATH (`item-0-item-1`). Reading an ordinal out of it+ /// yields 0 — the parent — so a note created from a nested item's "+" used to be filed+ /// against the wrong item, and (since T-1745 renders item notes on their own row) shown+ /// against the wrong row (T-1745).+ @Test("blockContextRequested with a nested listItem sub-target anchors at the nested item")+ func nestedListItemContextOpensAddNoteOnTheNestedItem() {+ let nested = ListItem.NestedList(ordered: false, start: 1, items: [+ ListItem(content: "child a", checkbox: nil),+ ListItem(content: "child b", checkbox: nil),+ ])+ let list = MarkdownBlock.list(ordered: false, start: 1, items: [+ ListItem(content: "parent", checkbox: nil, children: [.nestedList(nested)]),+ ListItem(content: "sibling", checkbox: nil),+ ])+ let session = makeSession(blocks: [list])+ let coordinator = DocumentLayoutCoordinator()+ let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+ router.handle(.blockContextRequested(+ blockID: "b-\(list.id)-0", subTarget: .listItem(subID: "item-0-item-1"), rect: rect()+ ))+ #expect(coordinator.addNoteBlock?.id == list.id)+ if case .listItem(let id, let text) = coordinator.addNoteAnchor {+ #expect(id == "\(list.id)-item-0-item-1", "the nested child, not its parent")+ #expect(text == "child b")+ } else {+ Issue.record("expected a listItem anchor")+ }+ }++ /// A `subID` naming no item of the block (stale after a re-parse, or forged) must fall+ /// back to a block-level note rather than fabricate an anchor.+ @Test("blockContextRequested with an unknown listItem sub id falls back to the block")+ func unknownListItemSubIDFallsBackToTheBlock() {+ let list = MarkdownBlock.list(ordered: false, start: 1, items: [+ ListItem(content: "only", checkbox: nil),+ ])+ let session = makeSession(blocks: [list])+ let coordinator = DocumentLayoutCoordinator()+ let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+ router.handle(.blockContextRequested(+ blockID: "b-\(list.id)-0", subTarget: .listItem(subID: "item-9"), rect: rect()+ ))+ #expect(coordinator.addNoteBlock?.id == list.id)+ if case .block = coordinator.addNoteAnchor {} else {+ Issue.record("expected a block anchor for an unresolvable sub id")+ }+ }++ /// A `<details>` block emits `data-prism-sub="item-N"` on its list children, but+ /// `allListItemIds()` returns nothing for `.details` — so those sub ids are one-way, and+ /// two lists under one `<details>` even emit the same `item-0`. The tap path must stay+ /// fail-closed on that (a block-level note), never resolving an ambiguous address. See+ /// the note at `BlockHTMLEmitter.emitDetails`; full support is T-2032.+ @Test("A details child's list sub id does not resolve, and falls back to the block")+ func detailsChildListSubIDFallsBackToTheBlock() {+ let details = MarkdownBlock.details(+ summary: "More",+ children: [.list(ordered: false, start: 1, items: [+ ListItem(content: "inside", checkbox: nil),+ ])],+ isOpenByDefault: false,+ depth: 0+ )+ let session = makeSession(blocks: [details])+ let coordinator = DocumentLayoutCoordinator()+ let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+ router.handle(.blockContextRequested(+ blockID: "b-\(details.id)-0", subTarget: .listItem(subID: "item-0"), rect: rect()+ ))+ #expect(coordinator.addNoteBlock?.id == details.id)+ if case .block = coordinator.addNoteAnchor {} else {+ Issue.record("a details child's sub id must not resolve to a list-item anchor")+ }+ }+ @Test("blockContextRequested with a tableRow sub-target opens the table-row add-note sheet") func tableRowContextOpensAddNote() { let table = MarkdownBlock.table(headers: ["A"], rows: [["1"], ["2"]], alignments: [.leading])
diff --git a/prismTests/WebRendering/NoteStateFeederTests.swift b/prismTests/WebRendering/NoteStateFeederTests.swiftindex 573ae31..a6eaa8c 100644--- a/prismTests/WebRendering/NoteStateFeederTests.swift+++ b/prismTests/WebRendering/NoteStateFeederTests.swift@@ -8,6 +8,7 @@ // // Covers: // - Indicators for block / list-item / table-row notes with correct domID + rowOrdinal+// + subID (list items are addressed at the item, T-1745) // - Inline bubbles built only when showInlineNotes is on, carrying data-prism-note-id // - Banner built only for document-level notes, at the configured placement // - The domID matches BlockHTMLEmitter's for the same blocks (shared BlockDOMID logic)@@ -119,20 +120,94 @@ struct NoteStateFeederTests { #expect((indicators.first?["rowOrdinal"] as? NSNumber)?.intValue == 1) } - @Test("List-item note → a block-level indicator on the list (no per-item slot)")+ @Test("List-item note → a per-item indicator addressed by the item's data-prism-sub (T-1745)") func listItemIndicator() { let manager = manager() let list = list()- let itemId = list.listItemId(at: 0)!+ let itemId = list.listItemId(at: 1)! manager.setImportedNotes([itemId: [note(blockId: itemId)]]) let json = NoteStateFeeder.indicatorsJSON(blocks: [list], notesManager: manager) let indicators = decodeIndicators(json) #expect(indicators.count == 1) #expect(indicators.first?["domID"] as? String == "b-\(list.id)-0")+ // Addressed at the ITEM, not the list: a block-level indicator here landed in the+ // gutter beside item 1 and suppressed the FIRST item's "+" (T-1745).+ #expect(indicators.first?["subID"] as? String == "item-1") #expect(indicators.first?["rowOrdinal"] == nil) } + /// The payload that drives the whole-list placement case: a note on the LIST and a note+ /// on an ITEM are different anchors and must arrive as two entries, one block-addressed+ /// and one item-addressed. Collapsing them would put the list's dot on the item, or the+ /// item's on the list — the T-1745 defect in either direction.+ @Test("A list block note and a list-item note yield two separately addressed indicators")+ func listBlockAndItemNotesGetSeparateIndicators() {+ let manager = manager()+ let list = list()+ let itemId = list.listItemId(at: 1)!+ manager.setImportedNotes([+ list.id: [note(blockId: list.id, content: "whole list")],+ itemId: [note(blockId: itemId, content: "item two")],+ ])++ let indicators = decodeIndicators(+ NoteStateFeeder.indicatorsJSON(blocks: [list], notesManager: manager)+ )+ #expect(indicators.count == 2)+ #expect(indicators.allSatisfy { $0["domID"] as? String == "b-\(list.id)-0" })++ let subIDs = indicators.map { $0["subID"] as? String }+ #expect(subIDs.contains(where: { $0 == nil }), "the list's own note stays block-addressed")+ #expect(subIDs.contains(where: { $0 == "item-1" }), "the item's note is item-addressed")+ }++ /// An anchor naming an item the block no longer has (a note that outlived an edit, before+ /// relocation/orphaning catches up) must produce NO payload entry — never a fabricated+ /// subID, which would place the note on whatever item happened to match.+ @Test("An out-of-range list-item anchor produces no indicator")+ func outOfRangeListItemAnchorIsDropped() {+ let manager = manager()+ let list = list()+ manager.setImportedNotes(["\(list.id)-item-9": [note(blockId: "\(list.id)-item-9")]])++ let indicators = decodeIndicators(+ NoteStateFeeder.indicatorsJSON(blocks: [list], notesManager: manager)+ )+ #expect(indicators.isEmpty, "a stale item anchor is fail-closed, not guessed at")+ }++ @Test("The list-item subID matches the emitter's data-prism-sub for the same item")+ func listItemSubIDMatchesEmitter() {+ let manager = manager()+ let list = list()+ let itemId = list.listItemId(at: 1)!+ manager.setImportedNotes([itemId: [note(blockId: itemId)]])++ let subID = decodeIndicators(+ NoteStateFeeder.indicatorsJSON(blocks: [list], notesManager: manager)+ ).first?["subID"] as? String++ // Both spellings derive from allListItemIds(); the emitter writes the attribute the+ // feeder targets, so a drift on either side fails here rather than in the page.+ let emitted = BlockHTMLEmitter.emit(blocks: [list], footnotes: .empty, settings: RenderSettings())+ #expect(subID == "item-1")+ #expect(emitted.html.contains("data-prism-sub=\"\(subID ?? "")\""))+ }++ @Test("Only the noted item gets an indicator")+ func unnotedItemsGetNoIndicator() {+ let manager = manager()+ let list = list()+ let itemId = list.listItemId(at: 1)!+ manager.setImportedNotes([itemId: [note(blockId: itemId)]])++ let indicators = decodeIndicators(+ NoteStateFeeder.indicatorsJSON(blocks: [list], notesManager: manager)+ )+ #expect(indicators.compactMap { $0["subID"] as? String } == ["item-1"])+ }+ @Test("Resolved notes do not produce indicators") func resolvedNoIndicator() { let manager = manager()@@ -225,7 +300,7 @@ struct NoteStateFeederTests { #expect(model?["banner"] != nil) } - @Test("List-item note → an inline bubble on the list block when showInlineNotes is on")+ @Test("List-item note → an inline bubble hosted in that item, not on the list (T-1745)") func listItemBubble() { let manager = manager() let list = list()@@ -238,14 +313,39 @@ struct NoteStateFeederTests { showInlineNotes: true, bannerPlacement: .top, exportUsername: "", strings: .fallback ) let bubbles = decodeInline(json)?["bubbles"] as? [[String: Any]]- // The list-item note must surface in the list block's bubble host (Req 5.6).+ // One host, addressed at the item — a block-level host rendered the note after the+ // whole list (Req 5.6, T-1745). #expect(bubbles?.count == 1) #expect(bubbles?.first?["domID"] as? String == "b-\(list.id)-0")+ #expect(bubbles?.first?["subID"] as? String == "item-1") let html = bubbles?.first?["html"] as? String ?? "" #expect(html.contains("data-prism-note-id=\"\(aNote.id.uuidString)\"")) #expect(html.contains("List item bubble")) } + @Test("A list block note and a list-item note get separate hosts (T-1745)")+ func listBlockAndItemNotesGetSeparateHosts() {+ let manager = manager()+ let list = list()+ let itemId = list.listItemId(at: 1)!+ manager.setImportedNotes([+ list.id: [note(blockId: list.id, content: "Whole list")],+ itemId: [note(blockId: itemId, content: "Second item")],+ ])++ let json = NoteStateFeeder.inlineNotesJSON(+ blocks: [list], notesManager: manager,+ showInlineNotes: true, bannerPlacement: .top, exportUsername: "", strings: .fallback+ )+ let bubbles = decodeInline(json)?["bubbles"] as? [[String: Any]] ?? []+ #expect(bubbles.count == 2)+ let blockHost = bubbles.first { $0["subID"] == nil }+ let itemHost = bubbles.first { $0["subID"] as? String == "item-1" }+ #expect((blockHost?["html"] as? String ?? "").contains("Whole list"))+ #expect((blockHost?["html"] as? String ?? "").contains("Second item") == false)+ #expect((itemHost?["html"] as? String ?? "").contains("Second item"))+ }+ @Test("Body-row note → an inline bubble on the table block when showInlineNotes is on") func tableRowBubble() { let manager = manager()
diff --git a/prismTests/WebRendering/WebNotesBehaviourTests.swift b/prismTests/WebRendering/WebNotesBehaviourTests.swiftindex fc7e134..c3a9223 100644--- a/prismTests/WebRendering/WebNotesBehaviourTests.swift+++ b/prismTests/WebRendering/WebNotesBehaviourTests.swift@@ -187,7 +187,8 @@ struct WebNotesBehaviourTests { #expect(message?["blockID"] as? String == domID(list)) let sub = message?["subTarget"] as? [String: Any] #expect(sub?["kind"] as? String == "listItem")- #expect((sub?["ordinal"] as? NSNumber)?.intValue == 1 || (sub?["ordinal"] as? Int) == 1)+ // The whole `data-prism-sub` value, so nesting survives the round trip (T-1745).+ #expect(sub?["subID"] as? String == "item-1") } @Test("A context gesture on a table row posts blockContextRequested with a tableRow sub-target")
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex c2727a5..eeb1574 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -165,6 +165,9 @@ source rather than shapes seen today. - **`xcodebuild test` hangs** in post-test xcresult finalization (it builds prismUITests). Use `build-for-testing` then `test-without-building` with `NSUnbufferedIO=YES`; the process **exit code is authoritative**. Run targeted classes with `-only-testing:prismTests/<Class>` to avoid the hang. - **iOS WKWebView selection**: long-press triggers native text selection (not `contextmenu`), and the native selection callout renders above all web content → use native SwiftUI overlays / visible tap targets, not in-page pills or long-press gestures. - **Note flow is native-as-truth**: JS posts `selectionCandidate` / `noteIndicatorTapped` / `inlineNoteTapped` / `blockContextRequested` / `linkActivated` → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` state → SwiftUI sheets/popovers. `NotesManager` owns the notes; the web view only renders + reports. In-page affordances that open native UI route via `linkActivated` + a `prism://…` URL (footnote, `document-note/add`, `image-access/grant`); the Swift route literals live in `PrismLinkRoute`.+- **A sub-anchored note must be ADDRESSED at its sub-element, not at its block** (T-1745). `NoteStateFeeder` carries a `subID` on both the indicator and the bubble payload entries; it is the anchor id minus the block-hash prefix, which is byte-identical to the `data-prism-sub` the emitter wrote, because both come from `MarkdownBlock.allListItemIds()`. Collapsing a list-item note to the block was not a cosmetic shortcut: prism-notes.js drops a block dot at `section.firstChild` (the gutter beside the FIRST item) and used to suppress the nearest `li > [data-prism-add-note]`, so a note on item 2 both rendered after the whole list and turned item 1's `+` into a dot. `li` and `td` are NOT symmetric here — a bubble inside a `<td>` would deform the table, so table-row notes keep `rowOrdinal` addressing for the dot and stay in the block-level bubble host. The `+` and the dot read one `--prism-item-gutter` per list level (document.css) so they can never drift out of the same column.+- **The whole-list note is the case that still shares a column with item chrome** (T-1745). It is legitimately addressed at the block, so its dot goes in the section gutter — where a top-level item's `+` chip also lives (`-1.775em … -0.595em` from the section; the chip is `1.25em × font-size 0.85em` = **1.0625em** plus 2px of border, not 1.25em). `suppressAddNote` therefore takes the direct-child `+` **only**: a container that gets a dot may hide its own `+`, never a descendant's, because those are different note anchors. The dot moves out of the way instead — prism-notes.js tags it `data-prism-beside-items` when the section has direct `ul`/`ol` children with per-item subs, and document.css shifts it to `-2.5em`. The gutter is finite (`main` padding 1.7em + `body` padding 1em = 2.7em, then `body { overflow-x: hidden }` clips), so a further column is not free. `DocumentCSSNoteGutterRulesTests` does that arithmetic against the sheet — the live WebPage harness does **not** apply document.css, so a rect-based assertion there passes vacuously (I wrote one before noticing; every element came back at UA defaults).+- **Sub ids under `<details>` are ONE-WAY** (T-1745, T-2032). `BlockHTMLEmitter.emitDetails` writes `data-prism-sub="item-N"` on the list children of a `<details>`, but the enclosing section is the `.details` block and `allListItemIds()` returns nothing for `.details` — so those attributes have no native counterpart, and two lists under one `<details>` emit `item-0` twice into one section. Both directions fail closed today and must stay that way: the feeder enumerates sub ids only for a `.list` block (so nothing ever reaches `subElement`'s first-match `querySelector`), and the router resolves a tapped sub id by membership in `allListItemIds()` (so a `+` in there falls back to a block-level note). Do not "fix" either side by deriving an anchor from the attribute string alone; the address is genuinely ambiguous. - **`<button>` UA font-size trap (cost me 4 device rounds)**: a `<button>`'s default font-size is ~13.3px, NOT the content's 17px. Any `em` offset or `::before` chip size on a button-based affordance (`.prism-add-note`, `.prism-notes-toggle`) computes against 13.3px, so it silently mismatches sibling `<span>`s (e.g. the note dot at 17px). Fix: put `font-size: 1em` on the button so its em math matches the surrounding content. Symptom was the `+` never aligning with the note dot no matter the offset. - **Headless Chrome is a reliable CSS-geometry probe** when you can't see the device: `"/Applications/Google Chrome.app/.../Google Chrome" --headless=new --disable-gpu --dump-dom "file://probe.html"` runs the page's JS; have the JS write `getBoundingClientRect()` results into a `<pre id=out>` and read it from the dumped DOM. Gotchas: `print()`/console are swallowed (write to the DOM or `document.title`); `top` is `window.top` (read-only global) so don't `var top = …`; inline the real `document.css` into the probe. - **`make test-locales` runs the FULL unit suite ×4 locales** (en/en-AU/en-GB/en-US) — it is NOT a quick catalog check, and it wedges the test daemon on a contended machine (saw a 12-min hang). The catalog validation (`Tools/validate-localisation.py`) actually runs as a **build phase** during ANY build, so a clean build at zero warnings already validates the catalog — don't run test-locales just to check it.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c95e42b..242933a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A note on a list item now shows against that item (T-1745). Adding a note to the second item of a list put the note itself below the whole list, and turned the first item's **+** button into a filled dot — the mark that says "this item has a note" — so the list claimed the note belonged to an item it did not. The note was always attached correctly: copying or exporting notes named the right item, and reopening the document kept it there. Only the display was wrong. A list item's note now appears directly beneath the item it belongs to, the dot appears beside that item, and every other item keeps its **+**. Tapping the dot opens that item's notes rather than the list's, as does tapping the note. Items of a nested list behave the same way, at their own level: adding a note from a nested item's **+** used to file it against that item's parent, which — now that a note is shown against the item it names — would have put it visibly on the wrong line; it now stays on the nested item. A note attached to the list as a whole — one made by selecting text rather than by using an item's **+** — still shows at the list, and no longer takes the first item's **+** away: it sits in its own column beside the items, so every item remains available to note. Table rows are unaffected: their dot already appeared on the right row, and their notes continue to gather below the table, since a note placed inside a cell would distort the table. One place is not covered, and behaves as it did before: for a list inside a collapsible `<details>` section, an item's **+** adds the note to the section rather than to the item, because the app cannot yet tell those items apart — tracked separately (T-2032). - Opening a document that writes characters as HTML entities or backslash escapes inside emphasis, bold, or link text is no longer slow enough to matter (T-1966). Text written `*A*` shows an `A`, but the file spells it `A` — so while working out which part of the file each word on screen came from, which is what lets you select text and attach a note to it, the app searched the rest of the paragraph for an `A`, found none, and then searched the same stretch again for the next word, and again for the one after. A paragraph of 3,200 such words took 12.4 seconds to render; it now takes 57 milliseconds, and the cost grows in step with the length of the document rather than with its square. Nothing about the result changes — the same text, the same footnote badges, in the same order, with notes anchoring exactly where they did before, which was checked by rendering four thousand generated samples before and after and comparing every character and every anchor position. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. The three examples originally reported for this fault had already stopped being slow through earlier fixes, and are now pinned by growth guards so they cannot come back. One deliberately-constructed shape is not covered: where every repetition writes a *different* word the file spells some other way, the cost still grows with the square of the document's length, as this fault did. That is tracked separately (T-2034). - Adding a note to text in a paragraph that also mentions a footnote reference inside an image, a link address, an image tooltip, or raw HTML now works, as long as that reference is written out plainly (T-1992). In a paragraph like `![[^1]](cat.png) choose [^1] after` — or the same with the reference written inside a link address such as `[link](http://example.com/[^1])`, inside an image's tooltip text such as ``, or inside raw HTML — the badge still appeared in the right place, but the app matched it to the reference-shaped text inside the image, address or tooltip rather than the real one. Selecting the words in between and choosing **Add Note** was then declined, or saved a note quoting the wrong text and pointing at the image or link syntax, which the note carried into relocation and inline-note export. Stepping search onto such a footnote could also mark the wrong badge. Text that merely looks like a reference in those positions is now accounted for, so the words either side of a badge map to what you actually selected. Two spellings are not covered yet and still behave as they did before: an image whose alt text mixes the reference with formatting, as in `![*a*[^1]](cat.png) choose [^1] after`, and a link address that writes a character as an HTML entity, as in `[link](x&/[^1]) choose [^1] after`. In both the app cannot line the text up with your document and deliberately leaves it alone, so a selection over the words before the badge is still declined — tracked under T-2033. This is separate from the earlier fix for selecting after a badge (T-1876); footnotes inside list items and table cells are still tracked separately. - Adding a note to text selected inside a table cell or a list item now quotes the text you actually selected (T-1941). Selecting a word in the second cell of a row, in any row after the first, or in any list item after the first quoted text from the start of the table or list instead — and saving stored a wrong source range, which the note then carried into relocation and inline-note export. Only the very first cell and the very first list item behaved correctly. The rendered document's text-to-source map now records every cell and every item at its real position within the block's text, so a note anchors where you put it. A few places where the map used to record an anchor that could only ever be wrong now record none at all: a nested list's items, a list nested inside a quote or another list item, a list inside a collapsible `<details>` section, the summary of a `<details>` nested inside another, and the rare quote the parser cannot break into parts. Selecting text in one of those and reaching for **Add note** now declines quietly instead of quoting text from elsewhere in the block — the block's own **+** button still adds a note, as does the **+** beside each item of a nested list. Anchoring a selection in those places is tracked separately (T-2032). This was the same fault as the footnote-selection fix below (T-1876) on a different path; every place in the renderer that draws part of a block must now state where that part sits, so the next one cannot repeat it.
Confirmed real: the two PRs overlap on six files — document.css, prism-notes.js, NoteStateFeeder.swift, WebDocumentMessageRouter.swift, NoteStateFeederTests.swift, WebDocumentMessageRouterTests.swift. Whichever merges second implements the contract. Concrete surfaces this PR creates or moves:
subID. Indicators are no longer uniquely identified by (domID, rowOrdinal); a list can now carry several dots that differ only in subID.makeIndicator sets role="button" with no label, and this PR multiplies how many exist on a list.indicatorsJSON still folds a header-row note into a block-level indicator inside if case .table. #346 is expected to remove it; this PR leaves it exactly as it was.inlineNoteTapped handler derives subID from host.closest("[data-prism-sub]"). Any second resolver added by #346 must agree with it or one will win arbitrarily.document.css gutter geometry. Any offset #346 introduces in the same strip has to be re-checked against DocumentCSSNoteGutterRulesTests, which now asserts a 2.7em clip budget and 0.225em of clearance — very little slack remains.git merge-tree --write-tree against origin/main (which has advanced by PR #344) auto-merges every code file including prism-notes.js. Only CHANGELOG.md and docs/agent-notes/webview-rendering-status.md conflict, both from adjacent prose insertions. #344's prism-notes.js hunk is a setTimeout block after the selection handler (~line 480+) and it never opens WebDocumentController in this branch — the disjointness the task predicted holds.
Nothing in the automated suite renders the stylesheet: DocumentCSSNoteGutterRulesTests proves the numbers are consistent, WebListItemNoteDisplayTests proves the right elements get the right attributes, but no test observes a laid-out pixel. The agent note records four device rounds lost to the <button> UA font-size trap on exactly this chrome, so a look at a real list — top-level, nested, task-list, and a whole-list note — before shipping is worth the minute it costs.
li { --prism-item-gutter: -1.5em } catches levels past the five enumerated ones, putting the chip inside the marker gutter rather than the section gutter. Pre-existing behaviour, unchanged by this PR — but the dot now inherits it too, so at depth 6+ both affordances sit in the marker gutter together. Still a shared column, which is the invariant that matters; just a different one.