prism branch T-1725/…-accessibility commits 4 (3 + review) files 15 touched lines +1314 / -106 tests run 319 / 319 green merge-base 7b41b78

Pre-push review: T-1725 — web note controls, keyboard and screen-reader accessible

PR #346. After the T-1542 WebKit cutover the notes UI inside the rendered document was div/span elements carrying role="button" and nothing else a control needs. This branch replaces them with real <button>/<a href> elements, gives them native-owned localised names, keeps keyboard focus alive across chrome rebuilds, and — the part that reaches beyond accessibility — aligns what a note dot says with what activating it actually reveals.

At a glance

  • Verified green: make lint 0/515, make lint-css clean, make build-macos and make build-ios succeed (one pre-existing ImageDimension warning, untouched by this branch), Tools/validate-localisation.py exit 0. Targeted suites: 96 + 88 + 135 = 319 passed, 0 failed, counts read from result bundles via Tools/check-test-results.sh.
  • Merge-base is clean of #344. git merge-tree auto-merges prism-notes.js against the advanced main; only CHANGELOG.md conflicts, and trivially (both add a bullet under ### Fixed). PR #344's regions are disjoint as expected.
  • Recommendation: merge #345 (T-1745) first, then rebase this one onto it and implement all nine contract points.
  • One real defect found in the new focus-restore mechanism: the bubble's focus key is not occurrence-qualified, so two identical blocks in one document share it.
  • One contract gap found: the handleInlineNoteTap collision with #345 is not among the eight points, and it is the same class of silent loss point 7 warns about.
  • Two comment-only corrections were applied and committed during this review (stale dedup comment, missing translator comment on the new plural key). No production behaviour was touched.

Verdict

Needs fixes — and merge #345 first

The code is correct, well-covered, and both platform builds, SwiftLint, stylelint and the localisation validator are clean. Nothing here blocks on its own merits.

Two things stop this being a plain green light. First, merge order: this PR removes the list-item→block-dot fold, and its replacement (per-item dots) lives in sibling PR #345. Merging #346 first opens a window where a note attached to a list item draws no indicator anywhere in the document. Merging #345 first closes that window to zero, because #345 removes the same fold and lands the replacement in the same change. Second, the eight-point merge-reconciliation contract in the PR body is missing a ninth pointhandleInlineNoteTap is rewritten by both PRs with different signatures and different coverage, and the conflict resolution that looks correct silently drops table-row bubble routing. That point should be written down before either PR merges, whichever goes first.

Review findings

8 raised · 2 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

Prism shows your notes right inside the document you are reading: a small coloured dot in the margin next to anything that has a note, and — if you leave the setting on — the note itself as a card underneath.

Those dots and cards looked like buttons but were not really buttons. They were plain pieces of page decoration that someone had labelled "this is a button", which is a bit like putting a Push sticker on a wall. If you used a mouse or a finger they worked, because the app was separately listening for taps anywhere. If you used a keyboard, pressing Tab walked straight past them and there was no way to open a note at all. If you used VoiceOver, it could reach the dot but had nothing to read out — the dot is drawn by the stylesheet rather than written as text — so it just said "button" and left you guessing.

The fix is to stop pretending and use the real thing. A real button, the kind the web browser itself provides, comes with all of that behaviour already built in: it can be reached with Tab, it sits in the right place in the reading order, and it opens when you press Enter or Space. Nothing had to be hand-written to make the keyboard work; it works because the element is now genuinely a button.

Why it matters

Two smaller repairs came along with it, and they turned out to matter more than they look.

Keeping your place. Every time a note is added, edited, resolved, or deleted anywhere in the document, the app redraws all of the note decoration from scratch. That threw a keyboard user back to the top of the page each time. Each control now carries a small hidden identifier, and the app remembers which one you were on and puts you back there afterwards. There is even a nice touch for the moment where adding a note replaces a block's + button with its new dot: focus hops onto the dot rather than vanishing. And deleting the last note does the reverse.

Saying the true number. Once the dot could speak, it had to say something, and "Show 2 notes" is a much stronger claim than a silent dot. Checking that claim turned up an existing bug: a dot on a bulleted list would count the notes belonging to the individual bullet points, but tapping it opened the notes for the list as a whole — a panel that could not possibly contain them. It would announce two notes and then show you nothing. That fold has been removed, so a dot now only ever counts what opening it shows you.

Key concepts

  • Accessible name — the words a screen reader speaks for a control. Text on a button supplies it for free; a control drawn as a shape has to be given one explicitly.
  • Focus — where the keyboard currently "is" on the page. Redrawing the thing you are standing on drops focus back to the start of the document.
  • Visually hidden text — text placed in the page for screen readers but clipped to a single pixel so it never shows. Used here so a note bubble can read out its author, its words, its time, and then what pressing it will do.

Architecture

Prism's rendered document is a WKWebView, but native Swift stays the source of truth: parsing, note state, search counting and persistence all run natively, and the page only renders HTML and reports interactions back over an isolated-world bridge. The notes chrome follows that rule strictly, which shapes the whole change.

Because the JavaScript bundle cannot reach the app's string catalog, every accessible name has to be native-owned and travel to the page. Three different channels already existed or were added for this:

  • The indicator dot's name rides the setNoteIndicators payload as a new per-entry label, resolved by NoteStateFeeder from a new pluralised closure on NoteRenderStrings (catalog key noteIndicator.count %lld).
  • The bubble's action label is baked into the HTML natively by NoteHTMLBuilder.
  • The per-block + keeps its existing <main data-prism-add-note-label> attribute channel.

The element swap itself is four lines of intent: indicator dot span[role=button]<button type=button>; inline bubble div[role=button]<button>; banner "add" a[href][role=button] → plain <a href> (the role was a lie — the UA never grants Space activation to a link just because a role was asserted); banner toggle unchanged plus aria-controls.

Patterns worth stealing

Hidden text, not aria-label, when the element already has content. An aria-label replaces an element's content as its accessible name. Putting one on a note bubble would have made VoiceOver announce "Show notes" and never read the note. Appending a .prism-visually-hidden span instead makes the name "author, content, timestamp, Show notes". The helper uses clip-path and a 1px box rather than display:none/visibility:hidden, both of which would remove the text from the accessibility tree along with the pixels.

Testing the property that was missing, not the one you wish you could test. A dispatchEvent-ed KeyboardEvent is untrusted and never fires a UA default action, so no test can prove Enter/Space activation directly. What the suite asserts instead is "the element that takes keyboard focus is the same element whose activation posts the bridge message" — focus it, then activate document.activeElement. Combined with the element genuinely being a <button>, Enter/Space parity follows by construction.

A test seam that mirrors the hazard. The focus-restore guard bails unless document.hasFocus(). The live harness runs an off-screen WebPage that is never key — so hasFocus() is false there for the same reason it is false behind a native sheet. That makes the harness a faithful reproduction of the hazard rather than an obstacle, and bridge.documentHasFocus (isolated world, unreachable from the page) is the seam that lets a test drive both sides.

Trade-offs

The load-bearing stylesheet work is worth naming, because it reads as cosmetic and is not. A <button>'s UA default font-size is roughly 13.3px against the document's 17px, and every dimension and offset of the indicator dot is expressed in em. Without font-size: 1em the dot shrinks and slips out of the gutter column it shares with the +. Similarly a button centres its text and shrink-wraps, so the bubble needs text-align: left; width: 100%, and a button's content model is phrasing content, so the bubble's parts became <span>s with display: block. All five of those resets are pinned by DocumentCSSNoteAccessibilityTests so a future tidy-up cannot quietly delete them.

The substantive change is not the accessibility work

The element swap is mechanical. The interesting change is that giving a control a spoken count forced an existing lie to become falsifiable, and the branch then went and fixed it.

Before: the block-level dot's presence was notesActive(block.id) || hasActiveListItemNote(block) , and the table branch additionally folded hasActiveHeaderRowNote into a block-level dot. But handleNoteIndicatorTap with no rowOrdinal sets notePopoverListItemId = nil and notePopoverTableRowId = nil, and WebNotePopoverView resolves noteKey to block.id. So a folded dot stood for notes its own activation provably could not reveal. As a silent dot that was a nuisance; as "Show 2 notes" followed by an empty popover it is a defect you cannot ship.

The resolution introduces NotesManager.notesShownOnActivation(anchorId:headingPath:) as the single definition of "what the popover shows for this anchor", read by WebNotePopoverView (which lists it) and by NoteStateFeeder.makeIndicator (which gates presence on it and counts it). The invariant is stated crisply in the code: one dot per anchor the tap can open. Presence and count come from one lookup, so they cannot drift on which anchor they describe.

Worth noting what the function is: a pure alias for allNotes(for:headingPath:) with no behaviour of its own. That is deliberate — it exists to name the concept and give both call sites one symbol to share — but a reader will chase it once before realising there is nothing underneath.

Where the invariant is not yet honoured

The branch is honest about this, which is the right call. makeIndicator shares the popover's function but not yet its arguments: the feeder passes no headingPath while WebNotePopoverView passes coordinator.notePopoverHeadingPath, and filterByHeadingPath returns the unfiltered superset for nil. For two identical blocks under different headings the count can exceed the list, and a dot can be drawn on an occurrence whose popover comes up empty — the same failure class, one axis over. Pre-existing for dot presence; newly amplified because the count is now spoken. Ticketed as T-2045, and correctly scoped out.

Bridge and security surface

handleInlineNoteTap now consumes the noteID it previously discarded, resolving which of the block's sub-anchors holds the tapped note. The containment is right: tappedNoteAnchor searches only the named block's own sub-anchor ids (allListItemIds() / allTableRowIds()), so a forged or stale UUID addresses nothing outside the block the message names, and an unknown id falls back to the block. Both are pinned by tests. bridge.documentHasFocus is added in the isolated world only, matching bridge.resolveSelectionRange; no page-world reachability, no CSP implications, no new subresource.

Edge cases

Focus-key uniqueness. The indicator key is note-indicator|{domID}|{row} and domID is occurrence-qualified (b-{hash}-{sourceIndex}), so dots are safe. The bubble key is note-{uuid} and is not — while buildBubbles deliberately maps one host to every DOM-id occurrence of an identical block. Two identical blocks therefore emit the same data-prism-focus-key twice, and restoreFocusKey's document.querySelector takes the first. Focus on the second occurrence's bubble is silently restored to the first occurrence's — and because the restore uses preventScroll: true, it moves off-screen without scrolling. Narrow, not a regression (focus previously fell to <body> unconditionally), but it is precisely the hazard the merge contract's own point 1 identifies for the subID case. The fix is to qualify the key: note-{domID}-{uuid}.

Dedup is now vestigial. With the header-row fold gone, mapped gives each block occurrence a unique domID and row ordinals within one are distinct, so no dedupKey can repeat. Keeping the guard is defensible given what the merge with #345 is about to reintroduce; the comment describing it was stale and has been corrected in this review.

A design question for manual verification. Wrapping the whole note in a <button> means VoiceOver reads it as a single unit — a long multi-paragraph note becomes one long announcement with no per-line rotor navigation inside it. The alternative shape is an inert card with a small "Show notes" button on it. The branch reasons the trade through and keeps the note's own words in the name, which is the important half, but this is the first thing device testing should look at.

Important changes — detailed

NoteStateFeeder: the block dot stops standing for anchors its tap cannot open

prism/Services/WebRendering/NoteStateFeeder.swift

Why it matters. This is the behavioural heart of the branch and the one part with a user-visible intermediate state. Removing hasActiveListItemNote and hasActiveHeaderRowNote means a note living only on a list item, or on a table's header row, draws no indicator at all until PR #345 (list items) and T-2044 (header rows) land. With inline notes on — the default — those notes still show as bubbles; with the setting off they are reachable only through the notes panel.

What to look at. NoteStateFeeder.swift:184-244 (indicatorsJSON, makeIndicator, dedupKey); the deleted hasActiveListItemNote / hasActiveHeaderRowNote helpers

Takeaway. When you make a control speak a number, you have converted a soft claim into a falsifiable one — and the first thing to check is whether the number was ever true. A silent dot that over-counts is a nuisance; a dot that says "Show 2 notes" and opens an empty panel is a bug report.
Rationale. Aligned on the pre-cutover model, which the specs still describe: pre-T-1542 ListBlockView gave each item its own indicator carrying only that item's notes, and table-row-notes Req 2.6 says a row indicator opens the popover "for that row" — its Decision 2 having explicitly rejected two indicator types coexisting on one block. So the invariant is a restoration, not an invention.

NotesManager.notesShownOnActivation as the single definition both sides read

prism/Services/NotesManager.swift

Why it matters. The mechanism that makes the alignment durable rather than a one-off correction. WebNotePopoverView lists it, NoteStateFeeder gates presence on it and counts it. Split across two call sites, the two had already drifted once.

What to look at. NotesManager.swift:352-366; WebNotePopoverView.swift:52-62; NoteStateFeeder.swift makeIndicator

Takeaway. A named alias with no behaviour of its own is a legitimate design move when two call sites must agree on a definition — the symbol is the contract. Just be aware a reader will chase it once to discover there is nothing underneath.
Rationale. Stated in the doc comment: "the ONE definition of what the popover shows for this anchor". An anchor is a block id or a single sub-anchor id — never a fold of several.

prism-notes.js: focus survives the chrome rebuild, in both directions

prism/Resources/WebRenderer/prism-notes.js

Why it matters. Both push handlers clear and rebuild all note chrome, so any note change anywhere in the document dropped keyboard focus to <body>. The new capture/restore covers three cases: the control reappears (restore by key), the '+' is suppressed by an arriving dot (hand focus to the dot), and the dot vanishes when the last note is deleted (hand focus back to the restored '+').

What to look at. prism-notes.js:55-133 (currentFocusKey, documentHasFocus, focusControl, restoreFocusKey, indicatorFocusKey, indicatorSuccessor) and the two registerCommand wrappers

Takeaway. focus({preventScroll: true}) is the right call when restoring — the user is being put back where they already were, so the default scroll-into-view can only move them off it. But preventScroll also means a wrong restore is invisible, which raises the cost of a non-unique focus key.
Rationale. The hasFocus() guard is load-bearing rather than defensive: document.activeElement does not reset when the WebView stops being first responder, so the natural sequence (tap a bubble, native sheet opens, save, setInlineNotes push) would call focus() on a background document, which WebKit can escalate into a window/first-responder request while the user is typing in the sheet.

The bubble's focus key is not occurrence-qualified

prism/Services/WebRendering/NoteHTMLBuilder.swift

Why it matters. buildBubbles deliberately maps one bubble host to EVERY DOM-id occurrence of an identical block, so two identical blocks in a document emit data-prism-focus-key="note-{uuid}" twice. restoreFocusKey's document.querySelector returns the first, so focus on the second occurrence's bubble is silently restored to the first occurrence's — off-screen, because the restore uses preventScroll.

What to look at. NoteHTMLBuilder.swift:103 (data-prism-focus-key="note-{idAttr}"); NoteStateFeeder.buildBubbles; prism-notes.js restoreFocusKey

Takeaway. Any identifier used for cross-rebuild identity has to be as unique as the thing it identifies. The indicator key got this right by carrying the occurrence-qualified domID; the bubble key carries only the note UUID, which is shared across occurrences by design.
Open question. Rationale not stated by the author and not inferable from the diff.

WebDocumentMessageRouter: the bubble tap resolves the tapped note's own anchor

prism/ViewModels/WebDocumentMessageRouter.swift

Why it matters. A block's bubble host gathers its sub-anchor notes too, so discarding the posted noteID opened the block's note list for a list-item note — a list showing everything except the note the user just touched. This is also the function that collides hardest with PR #345.

What to look at. WebDocumentMessageRouter.swift:164-209 (handleInlineNoteTap, tappedNoteAnchor)

Takeaway. Containment by construction: tappedNoteAnchor searches only the named block's own sub-anchor ids, so a forged or stale UUID can address nothing outside the block the message named, and an unknown id falls back to the block. Both branches are pinned by tests.
Rationale. Making the bubble's label honest ('Show notes') required activation to match it, since the bubble host carries sub-anchor notes; resolving the anchor from the note id was the smaller change compared with splitting hosts per sub-anchor (which is what #345 does structurally, for list items only).

document.css: the UA resets are load-bearing, and are pinned by tests

prism/Resources/WebRenderer/document.css

Why it matters. A <button>'s UA default font-size is ~13.3px against the document's 17px, and every dimension and offset of the indicator dot is in em — unreset, the dot shrinks and slips out of the gutter column it shares with the '+'. A button also centres and shrink-wraps, which the bubble has to undo.

What to look at. document.css:794-823 (.prism-inline-note resets, span stacking), 849-870 (.prism-note-indicator resets), 1198-1231 (.prism-visually-hidden, :focus-visible ring)

Takeaway. clip-path + a 1px box for visually-hidden text, never display:none or visibility:hidden — those remove the text from the accessibility tree along with the pixels, which defeats the entire purpose.
Rationale. Five resets that read as cosmetic tidy-up are pinned by DocumentCSSNoteAccessibilityTests precisely so a future cleanup pass cannot delete them without a failing test explaining why they exist.

Key decisions

Real controls rather than reimplemented ones.

role="button" is an assertion to the accessibility tree and carries none of the behaviour a control gets from the user agent — focusability, tab-order participation, Enter/Space activation. Using native <button>/<a href> means there is no scripted key handling to write or keep in sync, and the keyboard path is literally the same click path a pointer takes.

The banner's add affordance stays a link, with the role dropped.

An <a href> is focusable and Enter-activated natively, but the UA never adds Space activation just because a button role was asserted. The role announced a control that did not behave like one; removing it makes the announcement and the behaviour agree. Routing still goes through the existing linkActivated path.

Visually hidden text, not aria-label, for the bubble's action name.

An aria-label replaces an element's content as its accessible name, so a screen reader would announce "Show notes" and never read the note. As trailing hidden text the name becomes "author, content, timestamp, Show notes".

"Show notes", not "Edit note".

"Edit note" was accurate for no bubble in the app: activation opens the note list for the anchor, and Edit inside that list is gated on note.isEditable, which an imported note never has. The label now names what activation does.

Its own catalog key rather than reusing the sidebar's "Show notes".

RegularDocumentLayout already uses a "Show notes" key for the notes-sidebar toggle. One untagged entry doing two jobs means a translator can neither tell them apart nor diverge them where a locale needs it — a toggle and an activation are different acts. Same en/en-GB/en-US value; no en-AU divergence.

Banner note bubbles become inert cards, not tab stops.

The delegated tap handler requires a [data-prism-inline-note-host] ancestor, which only the in-flow bubbles have, so nothing routes a tap on a banner bubble. Making them <button>s would have added dead tab stops announcing an action the app cannot perform. Recorded as a cutover gap rather than intent: the pre-cutover GlobalNotesBanner did route onTapContent to coordinator.replyToNote, and restoring that is T-2047.

Element type asserted rather than synthetic key events dispatched.

A dispatchEvent-ed KeyboardEvent is untrusted and never triggers a UA default action, so it cannot prove Enter/Space activation. The tests assert the property that was actually missing — the element that takes keyboard focus is the same element whose activation posts the bridge message — and Enter/Space parity follows by construction from the element being a native button.

The headingPath asymmetry is inherited, not resolved.

The feeder passes no headingPath while WebNotePopoverView passes coordinator.notePopoverHeadingPath, and filterByHeadingPath returns the unfiltered superset for nil. So the count can exceed the list for two identical blocks under different headings. Pre-existing for presence, newly amplified for the count. Scoped out as T-2045 — a different axis from the accessibility work, and one that hits buildBubbles the same way.

The dedup guard is kept although no path can currently trip it.

With the header-row fold gone, each mapped entry has a unique occurrence-qualified domID and row ordinals within one are distinct. The guard is retained because the fold it used to absorb is exactly what the merge with #345 is liable to reintroduce (contract point 7). Comment corrected during this review to say so.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorPR body — merge-reconciliation contract with #345The eight-point contract omits handleInlineNoteTap, which both PRs rewrite with different signatures AND different coverage. This PR: handleInlineNoteTap(blockID:noteID:), resolving the anchor from the posted note id — covering list items, table body rows and the header row. PR #345: handleInlineNoteTap(blockID:subID:), resolving structurally from the bubble host's sub-element id — list items only, and it hard-sets coordinator.notePopoverTableRowId = nil. Keeping '#345's version' at conflict resolution silently drops table-row and header-row bubble routing, which is the same class of silent loss that point 7 exists to prevent, on the sibling function two screens away. The merged handler needs both channels: subID when the bubble is item-hosted, noteID resolution otherwise.Reported. A ninth contract point should be added to the PR body before either PR merges. Not fixed here — the PR body is not a file in the diff and this review is report-only for substantive changes.
majorMerge order vs PR #345This PR removes the list-item -> block-dot fold; the replacement (per-item dots) is in PR #345. Merging #346 first opens a window in which a note attached only to a list item draws no indicator anywhere in the document. With showInlineNotes on (the AppStorage default is true) it still appears as a bubble under the block; with the setting off it is reachable only via the notes panel. #345 removes the same fold and lands the replacement in the same change, so merging it first reduces that window to zero.Recommendation issued: merge #345 (T-1745) first, then rebase this PR onto it and implement all nine contract points. The intermediate state is tolerable but strictly worse than the alternative, and the alternative costs nothing.
minorNoteHTMLBuilder.swift:103 / prism-notes.js restoreFocusKeyThe inline bubble's focus key is 'note-{uuid}' — not occurrence-qualified — while buildBubbles deliberately emits one bubble host per DOM-id occurrence of an identical block. Two identical blocks therefore carry the same data-prism-focus-key, and restoreFocusKey's document.querySelector takes the first. Focus standing on the second occurrence's bubble is restored to the first occurrence's, and because the restore uses preventScroll: true it lands off-screen with no visible cue. This is the hazard the merge contract's own point 1 identifies for the subID case. Not a regression (focus previously fell to <body> unconditionally), and narrow, but it is a defect in the mechanism this branch introduces.Reported, not fixed — the fix (thread domID into NoteHTMLBuilder.inlineNotesHost/noteBubble to emit 'note-{domID}-{uuid}') is a substantive production change, out of scope for a report-only review. Recommend a ticket, or folding it into the #345 merge alongside contract point 1, which is the same problem.
nitNoteStateFeeder.swift:185 — dedup commentThe comment above `seen` still cited 'both a list-item note and a block note' as the case it collapses — the exact fold this PR removed, so it described a repeat that can no longer occur. Comment drift of exactly the kind the round-3 pass was hunting.Fixed. Restated what the guard is for (one dot slot per (domID, rowOrdinal)), recorded that no current path produces a repeat, and said why it is kept anyway: the merge with #345 is precisely where a re-emitted block dot can come back.
nitprism/Localizable.xcstrings — noteIndicator.count %lldThe new plural key shipped without a `comment`, while its sibling noteBubble.action.showNotes has a detailed one. A translator seeing only 'Show %lld notes' cannot tell that this string IS the dot's accessible name (the dot has no text of its own) or that the count is scoped to one anchor rather than the block.Fixed. Added a translator comment. Re-verified: JSON parses, Tools/validate-localisation.py exit 0, make build-macos clean.
nitNotesManager.notesShownOnActivationA pure alias for allNotes(for:headingPath:) with no behaviour of its own. Justified by its doc comment as the shared definition both call sites read, but it is an indirection a reader will chase once before finding nothing underneath.Skipped — the naming value is real (the symbol IS the contract between the feeder and the popover) and the doc comment says so explicitly.
nitprism-notes.js makeIndicator — dot.titleThe indicator dot now gets a title attribute, so macOS shows a hover tooltip ('Show 3 notes') that did not exist before. Deliberate and consistent with the existing add-note '+', but it is a user-visible change not mentioned in the CHANGELOG entry.Skipped — harmless, consistent with the sibling control, and not worth a CHANGELOG amendment on its own.
nitdocument.css :focus-visible ring`.prism-note-indicator:focus-visible { border-radius: 4px }` is at specificity (0,2,0), while the positioned rules that draw the dot carry `border-radius: 50%` at (0,3,1). The 4px is therefore inert for the dot and applies only to the bubble/toggle/add.Skipped — the outcome is the desired one (a round ring on a round dot), and the declaration is correct for the three controls it does reach.

Per-file diffs

Click to expand.

prism/Services/WebRendering/NoteStateFeeder.swift Modified +129 / -54
diff --git a/prism/Services/WebRendering/NoteStateFeeder.swift b/prism/Services/WebRendering/NoteStateFeeder.swiftindex 70a5a60..4074e03 100644--- a/prism/Services/WebRendering/NoteStateFeeder.swift+++ b/prism/Services/WebRendering/NoteStateFeeder.swift@@ -9,8 +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.+//    - indicatorsJSON: `[{domID, rowOrdinal?, label}]` — one entry per block / table row+//      that carries a note (Req 5.2). prism-notes.js draws the indicator dot; `label` is+//      the dot's catalog-resolved accessible name, native-owned because the JS bundle+//      cannot reach the string catalog (T-1725). //    - inlineNotesJSON: `{ bubbles: [{domID, 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@@ -38,13 +40,34 @@ struct NoteRenderStrings: Sendable {     var documentNotesCount: @Sendable (Int) -> String     /// Label for the banner's "add a document note" affordance (Req 5.6 entry point).     var addDocumentNote: String+    /// Pluralised accessible name for a note-indicator dot ("Show N notes", T-1725).+    ///+    /// The dot has no text — its glyph is a CSS background — so without this a screen+    /// reader announces an unnamed control. The JS bundle cannot reach the string+    /// catalog, so the resolved name RIDES THE `setNoteIndicators` PAYLOAD: native owns+    /// the string, the page only renders it.+    ///+    /// The count is the size of `NotesManager.notesShownOnActivation` for the dot's+    /// anchor — what tapping it actually reveals — not a sum over anchors the tap does+    /// not open.+    var noteIndicator: @Sendable (Int) -> String+    /// Action label appended (visually hidden) to an inline note bubble, so the bubble+    /// announces what activating it does on top of the note's own text (T-1725).+    ///+    /// "Show notes", not "Edit note": activating a bubble opens the note list for the+    /// anchor the tapped note belongs to (the block, or one of its list items / table+    /// rows), and Edit inside that list is gated on `note.isEditable` — an imported note+    /// offers no edit action at all.+    var showNotes: String      /// A neutral default for tests/previews; production resolves from the catalog.     /// `nonisolated` (the initialiser holds a `@Sendable` closure, no actor state) so it     /// is usable from default arguments and tests off the main actor.     nonisolated static let fallback = NoteRenderStrings(         documentNotesCount: { count in "\(count) document notes" },-        addDocumentNote: "Add a document note"+        addDocumentNote: "Add a document note",+        noteIndicator: { count in "Show \(count) notes" },+        showNotes: "Show notes"     ) } @@ -101,7 +124,9 @@ enum NoteStateFeeder {         strings: Strings = .fallback     ) -> Payloads {         Payloads(-            indicatorsJSON: indicatorsJSON(mapped: mapped, notesManager: notesManager),+            indicatorsJSON: indicatorsJSON(+                mapped: mapped, notesManager: notesManager, strings: strings+            ),             inlineNotesJSON: inlineNotesJSON(                 mapped: mapped,                 notesManager: notesManager,@@ -115,49 +140,73 @@ 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 within it, and+    /// the catalog-resolved accessible name the dot is rendered with (T-1725).     private struct Indicator: Encodable {         let domID: String         let rowOrdinal: Int?+        let label: 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?, label}]` JSON for every block / table row carrying+    /// an active note.+    ///+    /// ONE DOT PER ANCHOR THE TAP CAN OPEN. A block-level dot stands for the block's own+    /// notes only; a table body-row dot for that row's. Both its presence and its spoken+    /// count come from a single `NotesManager.notesShownOnActivation` lookup on the anchor+    /// the tap resolves to — the same call `WebNotePopoverView` lists from.+    ///+    /// Sub-anchors the block dot USED to fold in (list items, the table header row) no+    /// longer produce one: `handleNoteIndicatorTap` nils the sub-anchor ids for a+    /// block-level tap, so the folded dot opened a popover that could not contain the+    /// notes it was standing for — a lie the spoken count made much louder (T-1725). Their+    /// own per-anchor dots are separate work: T-1745 for list items (PR #345), T-2044 for+    /// the table header row. Sub-anchor notes still reach the reader through the inline+    /// bubbles and the notes panel meanwhile.     ///     /// Maps the blocks to their DOM ids and delegates; `payloads` uses the `mapped:`     /// overload to share one walk across both payloads.-    static func indicatorsJSON(blocks: [MarkdownBlock], notesManager: NotesManager) -> String {-        indicatorsJSON(mapped: BlockDOMID.map(blocks: blocks), notesManager: notesManager)+    static func indicatorsJSON(+        blocks: [MarkdownBlock], notesManager: NotesManager, strings: Strings = .fallback+    ) -> String {+        indicatorsJSON(+            mapped: BlockDOMID.map(blocks: blocks), notesManager: notesManager, strings: strings+        )     }      /// As `indicatorsJSON(blocks:notesManager:)` but takes the pre-computed     /// `(block, domID)` mapping so `payloads` walks the blocks only once.     static func indicatorsJSON(-        mapped: [(block: MarkdownBlock, domID: String)], notesManager: NotesManager+        mapped: [(block: MarkdownBlock, domID: String)],+        notesManager: NotesManager,+        strings: Strings = .fallback     ) -> 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): the page has exactly one dot slot for each, so a+        // second entry could only overwrite the first. No path below can currently produce+        // a repeat — `mapped` gives every block occurrence its own domID, and the row+        // ordinals within one are distinct — but the guard is kept because the fold this+        // used to absorb (a list-item / header-row note re-emitting the block's own dot) is+        // exactly the shape a merge is liable to reintroduce; see `makeIndicator`.         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 notes → one indicator on the block itself.+            if let indicator = makeIndicator(+                domID: domID, rowOrdinal: nil, anchorId: block.id,+                notesManager: notesManager, strings: strings+            ), seen.insert(dedupKey(domID: domID, rowOrdinal: nil)).inserted {+                indicators.append(indicator)             } -            // Table-row notes → a row indicator per row ordinal; header-row → block-level.-            if case .table = block {-                for ordinal in activeTableRowOrdinals(notesManager, block: block) {-                    appendIndicator(domID: domID, rowOrdinal: ordinal, into: &indicators, seen: &seen)-                }-                if hasActiveHeaderRowNote(notesManager, block: block) {-                    appendIndicator(domID: domID, rowOrdinal: nil, into: &indicators, seen: &seen)+            // Table body-row notes → a row indicator per row ordinal.+            guard case .table = block else { continue }+            for ordinal in activeTableRowOrdinals(notesManager, block: block) {+                if let indicator = makeIndicator(+                    domID: domID, rowOrdinal: ordinal, anchorId: "\(block.id)-row-\(ordinal)",+                    notesManager: notesManager, strings: strings+                ), seen.insert(dedupKey(domID: domID, rowOrdinal: ordinal)).inserted {+                    indicators.append(indicator)                 }             }         }@@ -165,13 +214,35 @@ enum NoteStateFeeder {         return encodeJSON(indicators) ?? "[]"     } -    private static func appendIndicator(-        domID: String, rowOrdinal: Int?,-        into indicators: inout [Indicator], seen: inout Set<String>-    ) {-        let key = "\(domID)#\(rowOrdinal.map(String.init) ?? "-")"-        guard seen.insert(key).inserted else { return }-        indicators.append(Indicator(domID: domID, rowOrdinal: rowOrdinal))+    /// The indicator for one anchor, or nil when that anchor has nothing live to show.+    ///+    /// Presence and spoken count come from the SAME `notesShownOnActivation` lookup the+    /// popover lists from, so the two cannot drift on WHICH ANCHOR they describe: a dot+    /// appears when the anchor has an active note, and names the whole list activation+    /// opens (resolved notes included — the popover shows those too, dimmed).+    ///+    /// The shared function is not yet shared ARGUMENTS, and the gap is real: this call+    /// passes no `headingPath` while `WebNotePopoverView` passes+    /// `coordinator.notePopoverHeadingPath`, and `filterByHeadingPath` returns the+    /// unfiltered superset for `nil`. For two identical blocks under different headings+    /// the count can therefore exceed the list, and a dot can be drawn on an occurrence+    /// whose popover comes up empty. Threading the heading path through the feeder is+    /// **T-2045** — a different axis from the accessibility work here, and one that hits+    /// `buildBubbles` the same way.+    private static func makeIndicator(+        domID: String, rowOrdinal: Int?, anchorId: String,+        notesManager: NotesManager, strings: Strings+    ) -> Indicator? {+        let revealed = notesManager.notesShownOnActivation(anchorId: anchorId)+        guard revealed.contains(where: { $0.status == .active }) else { return nil }+        return Indicator(+            domID: domID, rowOrdinal: rowOrdinal, label: strings.noteIndicator(revealed.count)+        )+    }++    /// One dot per (block occurrence, row) — the page has a single slot for each.+    private static func dedupKey(domID: String, rowOrdinal: Int?) -> String {+        "\(domID)#\(rowOrdinal.map(String.init) ?? "-")"     }      // MARK: - Inline notes (Req 5.6)@@ -226,7 +297,10 @@ enum NoteStateFeeder {         strings: Strings     ) -> String? {         let bubbles = showInlineNotes-            ? buildBubbles(mapped: mapped, notesManager: notesManager, exportUsername: exportUsername)+            ? buildBubbles(+                mapped: mapped, notesManager: notesManager,+                exportUsername: exportUsername, actionLabel: strings.showNotes+            )             : []          let banner = buildBanner(@@ -243,14 +317,22 @@ enum NoteStateFeeder {     /// 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).+    /// (`{hash}-row-{n}` / `{hash}-row-header`) — into the single per-block host.+    ///+    /// Bubbles and indicators deliberately DIVERGE here: a sub-anchor note gets a bubble+    /// but no dot of its own, because the indicator path now draws a dot only for the+    /// anchor its own tap opens and there is no per-item / per-header-row dot yet+    /// (T-1745 / #345 for list items, T-2044 for header rows). The bubble is what keeps+    /// those notes visible in the meantime, so it stays deliberately wider than the dots.+    ///+    /// The host HTML stacks each note (thread-aware order), and each note carries+    /// `data-prism-note-id` so a tap routes to `inlineNoteTapped` and resolves the note's+    /// own sub-anchor (Req 5.6).     private static func buildBubbles(         mapped: [(block: MarkdownBlock, domID: String)],         notesManager: NotesManager,-        exportUsername: String+        exportUsername: String,+        actionLabel: String     ) -> [Bubble] {         var bubbles: [Bubble] = []         for (block, domID) in mapped {@@ -260,7 +342,9 @@ enum NoteStateFeeder {                 .filter { $0.status == .active }             let notes = NoteGrouping.sortNotesThreadAware(active)             guard !notes.isEmpty else { continue }-            let html = NoteHTMLBuilder.inlineNotesHost(notes, exportUsername: exportUsername)+            let html = NoteHTMLBuilder.inlineNotesHost(+                notes, exportUsername: exportUsername, actionLabel: actionLabel+            )             bubbles.append(Bubble(domID: domID, html: html))         }         return bubbles@@ -268,8 +352,10 @@ enum NoteStateFeeder {      /// 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.+    ///+    /// Reuses the same id helpers the indicator path relies on, so the two agree on how an+    /// anchor is SPELLED. They do not agree on coverage, and are not meant to until the+    /// per-sub-anchor dots land (T-1745 / #345, T-2044): see `buildBubbles`.     private static func subAnchorIds(for block: MarkdownBlock) -> [String] {         switch block {         case .list:@@ -309,12 +395,6 @@ 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) }-    }-     /// Active body-row ordinals (`{hash}-row-{n}`) of a table block, sorted ascending.     private static func activeTableRowOrdinals(_ notesManager: NotesManager, block: MarkdownBlock) -> [Int] {         guard case .table = block else { return [] }@@ -329,11 +409,6 @@ enum NoteStateFeeder {         return ordinals.sorted()     } -    private static func hasActiveHeaderRowNote(_ notesManager: NotesManager, block: MarkdownBlock) -> Bool {-        guard case .table = block else { return false }-        return notesActive(notesManager, for: "\(block.id)-row-header")-    }-     // MARK: - JSON      private static func encodeJSON<T: Encodable>(_ value: T) -> String? {
prism/Resources/WebRenderer/prism-notes.js Modified +121 / -10
diff --git a/prism/Resources/WebRenderer/prism-notes.js b/prism/Resources/WebRenderer/prism-notes.jsindex d912029..54767d7 100644--- a/prism/Resources/WebRenderer/prism-notes.js+++ b/prism/Resources/WebRenderer/prism-notes.js@@ -21,6 +21,12 @@  * overlay, and the per-block "add note" affordance is icon-only with its accessible label  * read from <main data-prism-add-note-label> (catalog-resolved natively). This script only  * positions/toggles structural elements, injects that chrome, and reports selection state.+ *+ * Accessible names follow the same rule: this script cannot reach the app's string+ * catalog, so every name it applies is native-owned and arrives from outside. The note+ * indicator's name rides the setNoteIndicators payload (`label`, resolved by+ * NoteStateFeeder); the add-note "+" reads <main data-prism-add-note-label>; the bubble+ * and banner names are baked into the HTML NoteHTMLBuilder renders natively (T-1725).  */ (function () {     "use strict";@@ -46,8 +52,88 @@         }     } +    // ---- Focus stability across re-renders (T-1725) -----------------------+    // Every setNoteIndicators / setInlineNotes push rebuilds the whole note chrome, which+    // destroys the element a keyboard user is standing on — focus silently falls back to+    // <body> on a state change that had nothing to do with where they were. Each focusable+    // control therefore carries a data-prism-focus-key that identifies it across rebuilds+    // (the dot's block/row, the note's UUID, the banner's toggle/add). Capture the key+    // before clearing, re-focus the element that re-appears with it afterwards.++    function currentFocusKey() {+        var active = document.activeElement;+        var host = active && active.closest ? active.closest("[data-prism-focus-key]") : null;+        return host ? host.getAttribute("data-prism-focus-key") : null;+    }++    // Whether the rendered document is the thing the user is actually typing into.+    //+    // Load-bearing guard, not a nicety: document.activeElement does NOT reset when the+    // WebView stops being the first responder, so the natural note sequence — tap a bubble,+    // a native sheet opens over the document, save — ends in a setInlineNotes push whose+    // focus restore would call focus() on a BACKGROUND document, which WebKit can escalate+    // into a window / first-responder request while the user is in the sheet's text field.+    //+    // Exposed on the bridge (isolated world; unreachable from the page world) because the+    // live test harness runs an off-screen WebPage that is never key — document.hasFocus()+    // is false there for the same reason it is false behind a sheet, which is exactly what+    // makes the harness able to prove the guard, and why the restore path needs a seam to+    // be exercised at all. Same test-seam pattern as bridge.resolveSelectionRange below.+    bridge.documentHasFocus = function () {+        return typeof document.hasFocus === "function" ? document.hasFocus() : true;+    };++    function focusControl(element) {+        if (!element || typeof element.focus !== "function") { return false; }+        if (!bridge.documentHasFocus()) { return false; }+        // preventScroll: the control is being restored to where the user already was, so+        // the default "bring it into view" would only be able to move them off it.+        element.focus({ preventScroll: true });+        return true;+    }++    function restoreFocusKey(key) {+        if (!key) { return; }+        // Unquoted attribute value + CSS.escape: an unquoted value must be a CSS+        // identifier, which is exactly what CSS.escape produces (the focus keys carry+        // `|` and `-`). Hand-rolled quote/backslash escaping was the earlier spelling.+        var target = document.querySelector("[data-prism-focus-key=" + CSS.escape(key) + "]");+        // The key can also resolve to NOTHING — delete the last note on a block and the dot+        // the user was standing on is removed while the "+" it had suppressed comes back.+        // That is the suppression handoff in reverse, on the more common path, so hand+        // focus to the successor rather than letting it fall to <body> (T-1725).+        focusControl(target || indicatorSuccessor(key));+    }++    function indicatorFocusKey(blockID, rowOrdinal) {+        var row = (rowOrdinal === null || rowOrdinal === undefined) ? "" : rowOrdinal;+        return "note-indicator|" + blockID + "|" + row;+    }++    // The control that takes a vanished indicator's place: the add-note "+" that+    // clearIndicators has just un-suppressed in the same container the dot occupied.+    // Returns null for any other kind of focus key (a removed note bubble has no+    // successor — its block's remaining chrome is not "where the user was").+    function indicatorSuccessor(key) {+        var parts = key.split("|");+        if (parts.length !== 3 || parts[0] !== "note-indicator") { return null; }+        var section = document.getElementById(parts[1]);+        if (!section) { return null; }+        if (parts[2] === "") {+            return section.querySelector(":scope > [data-prism-add-note]")+                || section.querySelector("li > [data-prism-add-note]");+        }+        var row = section.querySelector(+            "[data-prism-sub=" + CSS.escape("row-" + parts[2]) + "]"+        );+        if (!row) { return null; }+        var cell = row.querySelector("td, th") || row;+        return cell.querySelector(":scope > [data-prism-add-note]");+    }+     // ---- setNoteIndicators (Req 5.2) --------------------------------------    // Payload: { indicators: "[{domID, rowOrdinal?}]" } (JSON string). Renders a+    // Payload: { indicators: "[{domID, rowOrdinal?, label}]" } (JSON string). `label` is the+    // dot's catalog-resolved accessible name, supplied by native (T-1725). Renders a     // leading indicator dot on each named block, or in the named table row. Replaces     // any previously rendered indicators so a re-push is idempotent. @@ -68,20 +154,39 @@     // 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 "+".-    function suppressAddNote(container) {+    function suppressAddNote(container, successor) {         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", ""); }+        if (!add) { return; }+        // Suppression is display:none, which drops focus to <body> if the user happened to+        // be standing on this "+". The dot replacing it is its successor, so hand focus on+        // rather than losing it (T-1725).+        var hadFocus = document.activeElement === add;+        add.setAttribute("data-prism-suppressed", "");+        if (hadFocus) { focusControl(successor); }     } -    function makeIndicator(blockID, rowOrdinal) {-        var dot = document.createElement("span");+    // A native <button>, not a <span role="button"> (T-1725): the user agent makes it+    // focusable, tab-ordered, and activated by Enter AND Space, so no tabindex and no+    // synthetic key handling are needed and the keyboard path is the same click path a+    // pointer takes. `label` is the catalog-resolved accessible name supplied by native in+    // the payload — the dot has no text of its own (its glyph is a CSS background), so+    // without it a screen reader announces an unnamed button. No string literal here+    // (Req 1.9). document.css resets the button's UA appearance and font-size so the dot+    // keeps its em-based gutter geometry.+    function makeIndicator(blockID, rowOrdinal, label) {+        var dot = document.createElement("button");+        dot.type = "button";         dot.className = "prism-note-indicator";         dot.setAttribute("data-prism-note-indicator", "");         dot.setAttribute("data-prism-chrome", "");-        dot.setAttribute("role", "button");+        dot.setAttribute("data-prism-focus-key", indicatorFocusKey(blockID, rowOrdinal));+        if (label) {+            dot.setAttribute("aria-label", label);+            dot.title = label;+        }         dot.addEventListener("click", function (event) {             event.preventDefault();             event.stopPropagation();@@ -106,17 +211,21 @@                 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);-                suppressAddNote(cell);+                var rowDot = makeIndicator(entry.domID, entry.rowOrdinal, entry.label);+                cell.insertBefore(rowDot, cell.firstChild);+                suppressAddNote(cell, rowDot);             } else {-                section.insertBefore(makeIndicator(entry.domID, null), section.firstChild);-                suppressAddNote(section);+                var blockDot = makeIndicator(entry.domID, null, entry.label);+                section.insertBefore(blockDot, section.firstChild);+                suppressAddNote(section, blockDot);             }         });     }      bridge.registerCommand("setNoteIndicators", function (payload) {+        var focusKey = currentFocusKey();         renderIndicators(parsePayloadJSON(payload && payload.indicators));+        restoreFocusKey(focusKey);         return true;     }); @@ -168,7 +277,9 @@     }      bridge.registerCommand("setInlineNotes", function (payload) {+        var focusKey = currentFocusKey();         renderInlineNotes(parsePayloadJSON(payload && payload.notes));+        restoreFocusKey(focusKey);         return true;     }); 
prism/Services/WebRendering/NoteHTMLBuilder.swift Modified +80 / -21
diff --git a/prism/Services/WebRendering/NoteHTMLBuilder.swift b/prism/Services/WebRendering/NoteHTMLBuilder.swiftindex f0ea1e9..d278143 100644--- a/prism/Services/WebRendering/NoteHTMLBuilder.swift+++ b/prism/Services/WebRendering/NoteHTMLBuilder.swift@@ -22,6 +22,11 @@ import Foundation  enum NoteHTMLBuilder { +    /// The id of the banner's collapsible bubbles group, referenced by the collapse+    /// toggle's `aria-controls` so a screen reader can tell what the toggle operates on+    /// (T-1725). Only one banner exists per document, so a constant id is unambiguous.+    static let bubblesContainerID = "prism-notes-bubbles"+     /// A stable absolute timestamp format. The retired SwiftUI views used a *relative*     /// style ("2 hours ago"); a static HTML render cannot keep a relative string fresh,     /// so an absolute date/time (`Date.FormatStyle`, per the project's date convention)@@ -33,10 +38,12 @@ enum NoteHTMLBuilder {     /// The host for all of a block's notes, stacked in the supplied (thread-aware) order.     /// Replies carry `data-prism-note-reply` so the stylesheet can indent them. Each note     /// carries `data-prism-note-id` so a tap routes to `inlineNoteTapped` (Req 5.6).-    static func inlineNotesHost(_ notes: [BlockNote], exportUsername: String) -> String {+    static func inlineNotesHost(+        _ notes: [BlockNote], exportUsername: String, actionLabel: String+    ) -> String {         var html = "<div class=\"prism-inline-notes\">"         for note in notes {-            html += noteBubble(note, exportUsername: exportUsername)+            html += noteBubble(note, exportUsername: exportUsername, actionLabel: actionLabel)         }         html += "</div>"         return html@@ -45,7 +52,30 @@ enum NoteHTMLBuilder {     /// A single note bubble. `data-prism-note-id` is the note's UUID string (the value     /// the tap handler posts back as `noteID`); the native router resolves the block from     /// the host's section id, so the UUID is purely for the round trip.-    static func noteBubble(_ note: BlockNote, exportUsername: String) -> String {+    ///+    /// `actionLabel` decides whether the bubble is a CONTROL at all (T-1725):+    ///+    ///  - Non-nil (the in-flow bubbles): a real `<button>`, not a `<div role="button">`.+    ///    A native button is focusable, tab-ordered, and activated by Enter AND Space by+    ///    the user agent, none of which the div had. Two knock-on shapes follow. The parts+    ///    are `<span>`s, not `<div>`s, because a button's content model is phrasing content+    ///    (`document.css` gives the spans `display: block` to keep the stacked layout); and+    ///    the label is appended as VISUALLY HIDDEN TEXT rather than set as an `aria-label`,+    ///    because an aria-label REPLACES the button's content as its accessible name — a+    ///    screen reader would announce the verb and never read the note. As trailing hidden+    ///    text the name becomes "author, content, timestamp, Show notes".+    ///  - Nil (the document-notes banner): a plain `<div>` with no label, no focus key and+    ///    no tab stop. The banner's bubbles sit outside `[data-prism-inline-note-host]`, so+    ///    `prism-notes.js` never routes a tap on them anywhere — making them focusable+    ///    controls announcing an action would put dead stops in the tab order.+    ///+    ///    Inert is the right shape for THIS state, not the end state: the pre-cutover+    ///    `GlobalNotesBanner` did route a tap (`onTapContent` → `coordinator.replyToNote`),+    ///    and that affordance was lost in the T-1542 cutover, not by this change. Restoring+    ///    it — a real activation route with an honest name — is **T-2047**.+    static func noteBubble(+        _ note: BlockNote, exportUsername: String, actionLabel: String?+    ) -> String {         let ownerClass = note.isCurrentUser(exportUsername: exportUsername)             ? "prism-note-user" : "prism-note-imported"         let replyClass = note.isReply ? " prism-note-reply" : ""@@ -53,17 +83,24 @@ enum NoteHTMLBuilder {          var inner = ""         if let author = note.author, !author.isEmpty {-            inner += "<div class=\"prism-note-author\">\(HTMLEscaping.escapeText(author))</div>"+            inner += "<span class=\"prism-note-author\">\(HTMLEscaping.escapeText(author))</span>"         }-        inner += "<div class=\"prism-note-content\">\(HTMLEscaping.escapeText(note.content))</div>"+        inner += "<span class=\"prism-note-content\">\(HTMLEscaping.escapeText(note.content))</span>"         if note.hasRealTimestamp {             let stamp = note.createdAt.formatted(timestampFormat)-            inner += "<div class=\"prism-note-timestamp\">\(HTMLEscaping.escapeText(stamp))</div>"+            inner += "<span class=\"prism-note-timestamp\">\(HTMLEscaping.escapeText(stamp))</span>"         } -        return "<div class=\"prism-inline-note \(ownerClass)\(replyClass)\""+        let classAttr = "class=\"prism-inline-note \(ownerClass)\(replyClass)\""+        guard let actionLabel else {+            return "<div \(classAttr) data-prism-chrome"+                + " data-prism-note-id=\"\(idAttr)\">\(inner)</div>"+        }+        inner += "<span class=\"prism-visually-hidden\">"+            + HTMLEscaping.escapeText(actionLabel) + "</span>"+        return "<button type=\"button\" \(classAttr)"             + " data-prism-chrome data-prism-note-id=\"\(idAttr)\""-            + " role=\"button\">\(inner)</div>"+            + " data-prism-focus-key=\"note-\(idAttr)\">\(inner)</button>"     }      // MARK: - Document-notes banner@@ -90,39 +127,61 @@ enum NoteHTMLBuilder {          var header = "<div class=\"prism-notes-banner-header\">"         // The toggle's text (the count) is its accessible name, so no extra label string is-        // needed (Req 1.9). The chevron is CSS-drawn chrome (aria-hidden).+        // needed (Req 1.9). The chevron is CSS-drawn chrome (aria-hidden). `aria-controls`+        // names the group it collapses, and the focus key lets prism-notes.js put focus+        // back here after the banner is re-injected (T-1725).         header += "<button type=\"button\" class=\"prism-notes-toggle\" data-prism-notes-toggle"-            + " data-prism-chrome aria-expanded=\"true\">"+            + " data-prism-chrome data-prism-focus-key=\"notes-toggle\""+            + " aria-expanded=\"true\" aria-controls=\"\(bubblesContainerID)\">"             + "<span class=\"prism-notes-chevron\" aria-hidden=\"true\"></span>"             + "<span class=\"prism-notes-banner-title\">\(HTMLEscaping.escapeText(countLabel))</span>"             + "</button>"         if let addLabel {-            let labelAttr = HTMLEscaping.escapeAttribute(addLabel)-            header += "<a class=\"prism-notes-add\" href=\"\(PrismLinkRoute.documentNoteAdd)\""-                + " data-prism-chrome role=\"button\" aria-label=\"\(labelAttr)\">\(plusCircleIconSVG)</a>"+            header += addLink(addLabel: addLabel, classNames: "prism-notes-add", body: plusCircleIconSVG)         }         header += "</div>" -        var bubbles = "<div class=\"prism-notes-bubbles\">"+        // `actionLabel: nil` — nothing routes a tap on a banner bubble (the tap handler+        // requires a `[data-prism-inline-note-host]` ancestor, which only the in-flow+        // bubbles have), so these render as inert cards rather than tab stops that announce+        // an action they cannot perform (T-1725). That missing route is a cutover gap, not+        // a decision: giving the banner's notes a reply affordance back is T-2047.+        var bubbles = "<div class=\"prism-notes-bubbles\" id=\"\(bubblesContainerID)\">"         for note in notes {-            bubbles += noteBubble(note, exportUsername: exportUsername)+            bubbles += noteBubble(note, exportUsername: exportUsername, actionLabel: nil)         }         bubbles += "</div>"          return "<div class=\"prism-notes-banner\">\(header)\(bubbles)</div>"     } +    /// The "add a document note" affordance: a `prism://` link routed through the existing+    /// `linkActivated` path.+    ///+    /// Deliberately NOT `role="button"` (T-1725). An `<a href>` is focusable and activated+    /// by Enter natively, but the user agent never adds Space activation just because a+    /// button role was asserted — so the role announced a control that did not behave like+    /// one. Left as a link, what a screen reader announces and what the keyboard does+    /// agree.+    private static func addLink(addLabel: String, classNames: String, body: String) -> String {+        let labelAttr = HTMLEscaping.escapeAttribute(addLabel)+        return "<a class=\"\(classNames)\" href=\"\(PrismLinkRoute.documentNoteAdd)\""+            + " data-prism-chrome data-prism-focus-key=\"notes-add\""+            + " aria-label=\"\(labelAttr)\">\(body)</a>"+    }+     /// The empty-state card: an "add a document note" row, or a bare card when adding isn't     /// available (no iCloud).     private static func emptyBanner(addLabel: String?) -> String {         guard let addLabel else { return "<div class=\"prism-notes-banner\"></div>" }-        let labelAttr = HTMLEscaping.escapeAttribute(addLabel)-        return "<div class=\"prism-notes-banner prism-notes-banner-empty\">"-            + "<a class=\"prism-notes-add prism-notes-add-empty\" href=\"\(PrismLinkRoute.documentNoteAdd)\""-            + " data-prism-chrome role=\"button\" aria-label=\"\(labelAttr)\">"-            + noteAddIconSVG+        let body = noteAddIconSVG             + "<span class=\"prism-notes-add-text\">\(HTMLEscaping.escapeText(addLabel))</span>"-            + "</a>"+        return "<div class=\"prism-notes-banner prism-notes-banner-empty\">"+            + addLink(+                addLabel: addLabel,+                classNames: "prism-notes-add prism-notes-add-empty",+                body: body+            )             + "</div>"     } 
prism/Resources/WebRenderer/document.css Modified +85 / -5
diff --git a/prism/Resources/WebRenderer/document.css b/prism/Resources/WebRenderer/document.cssindex 4960dc4..4b68162 100644--- a/prism/Resources/WebRenderer/document.css+++ b/prism/Resources/WebRenderer/document.css@@ -791,7 +791,27 @@ html[data-prism-comments="visible"] .prism-comment-inline { display: inline; } /* ---- Inline notes (Req 5.6) ---- */ .prism-inline-notes { margin: 0.4em 0; } +/* The bubble is a native <button> (T-1725), so its UA chrome has to be undone before the+ * card styling reads: a button centres its text, shrink-wraps its content, and imposes its+ * own ~13.3px font. `font-size` is restated after the inherited family/weight so the card+ * keeps its 0.95em ratio to the document text. `border: none` is overridden for imported+ * notes by the higher-specificity `.prism-inline-note.prism-note-imported` rule below. */ .prism-inline-note {+    -webkit-appearance: none;+    appearance: none;+    display: block;+    width: 100%;+    box-sizing: border-box;+    text-align: left;+    border: none;+    font-family: inherit;+    font-weight: inherit;+    font-style: inherit;+    color: inherit;++    /* No `cursor: pointer` here: `[data-prism-chrome] { cursor: default }` further down the+     * file has equal specificity and wins on order, so it would be a dead declaration. */+     background-color: var(--prism-inline-note-bg);     border-radius: 8px;     padding: 0.5em 0.8em;@@ -799,6 +819,12 @@ html[data-prism-comments="visible"] .prism-comment-inline { display: inline; }     font-size: 0.95em; } +/* A button may not contain block-level elements, so the bubble's parts are spans; they+ * stack the way the retired divs did (T-1725). */+.prism-note-author,+.prism-note-content,+.prism-note-timestamp { display: block; }+ /* Imported notes get a leading accent to distinguish them from the user's own,  * matching the retired in-flow note views. */ .prism-inline-note.prism-note-imported { border-left: 3px solid var(--prism-accent); }@@ -820,9 +846,26 @@ html[data-prism-comments="visible"] .prism-comment-inline { display: inline; } }  /* ---- Note indicators (Req 5.2) ---- */++/* The dot is a native <button> (T-1725) so it is focusable and Enter/Space-activated+ * without any scripted key handling. Undoing the UA chrome is load-bearing, not cosmetic:+ * a button's default font-size is ~13.3px against the content's 17px, and EVERY dimension+ * and offset below is in em — left unreset the dot would shrink and slip out of the gutter+ * column it shares with the add-note "+". `background-color` is transparent here and set+ * by the positioned rules that draw the actual dot. */ .prism-note-indicator {+    -webkit-appearance: none;+    appearance: none;+    border: none;+    padding: 0;+    background-color: transparent;+    font-size: 1em;+    line-height: 1;     color: var(--prism-accent);-    cursor: pointer;++    /* No `cursor: pointer`: `[data-prism-chrome] { cursor: default }` further down the file+     * has equal specificity and wins on order, so it would be a dead declaration. Same rule+     * as `.prism-inline-note` and `.prism-add-note` — all the note chrome is consistent. */ }  /* Block-level indicator dot: absolutely positioned in the leading gutter beside the@@ -871,12 +914,14 @@ th > .prism-note-indicator[data-prism-note-indicator] {     margin: 0;      /* Inherit the content font-size: a <button>'s UA default is ~13.3px, which would make-     * every em offset (and the chip) compute against a different unit than the note dot-     * (a <span> at the content's 17px), so the "+" and the dot never shared a column. With-     * 1em they use the same unit and scale together with the text-size setting (T-1542). */+     * every em offset (and the chip) compute against a different unit than the note dot,+     * so the "+" and the dot never shared a column. With 1em they use the same unit and+     * scale together with the text-size setting (T-1542). The dot resets its own font-size+     * for the same reason now that it is a <button> too (T-1725). */     font-size: 1em;     color: var(--prism-text-tertiary);-    cursor: pointer;++    /* No `cursor: pointer`: see `.prism-note-indicator` above — `[data-prism-chrome]` wins. */     line-height: 1;     opacity: var(--prism-add-note-opacity);     transition: opacity 0.15s ease, color 0.15s ease;@@ -1149,3 +1194,38 @@ main {     -webkit-user-select: text;     user-select: text; }++/*+ * ---- Accessible-only text (T-1725) ----+ * Text that must reach the accessibility tree but not the page: the note bubble's action+ * label ("Show notes"), which sharpens what activating the bubble does without an+ * aria-label — an aria-label would REPLACE the note's own text as the accessible name.+ * `clip-path` + a 1px box rather than display:none / visibility:hidden, both of which+ * remove the text from the accessibility tree along with the pixels.+ */+.prism-visually-hidden {+    position: absolute;+    width: 1px;+    height: 1px;+    margin: -1px;+    padding: 0;+    overflow: hidden;+    clip-path: inset(50%);+    white-space: nowrap;+    border: 0;+}++/*+ * ---- Keyboard focus ring for the note chrome (T-1725) ----+ * These controls all suppress their UA appearance (and the note dot is a 0.5em circle), so+ * without an explicit ring a keyboard user cannot see where they are. `:focus-visible`+ * keeps the ring to keyboard interaction — a pointer tap does not paint it.+ */+.prism-note-indicator:focus-visible,+.prism-inline-note:focus-visible,+.prism-notes-toggle:focus-visible,+.prism-notes-add:focus-visible {+    outline: 2px solid var(--prism-accent);+    outline-offset: 2px;+    border-radius: 4px;+}
prism/ViewModels/WebDocumentMessageRouter.swift Modified +44 / -8
diff --git a/prism/ViewModels/WebDocumentMessageRouter.swift b/prism/ViewModels/WebDocumentMessageRouter.swiftindex 201376c..f0e696a 100644--- a/prism/ViewModels/WebDocumentMessageRouter.swift+++ b/prism/ViewModels/WebDocumentMessageRouter.swift@@ -97,8 +97,8 @@ struct WebDocumentMessageRouter {         case .noteIndicatorTapped(let blockID, let rowOrdinal, _):             handleNoteIndicatorTap(blockID: blockID, rowOrdinal: rowOrdinal) -        case .inlineNoteTapped(let blockID, _, _):-            handleInlineNoteTap(blockID: blockID)+        case .inlineNoteTapped(let blockID, let noteID, _):+            handleInlineNoteTap(blockID: blockID, noteID: noteID)          case .blockContextRequested(let blockID, let subTarget, _):             handleBlockContext(blockID: blockID, subTarget: subTarget)@@ -161,16 +161,52 @@ struct WebDocumentMessageRouter {         }     } -    /// A tap on an inline note bubble opens the popover/edit flow for that block,-    /// matching the SwiftUI `onTapInlineNote` handler (Req 5.6).-    private func handleInlineNoteTap(blockID: String) {+    /// A tap on an inline note bubble opens the popover/edit flow for the note's own+    /// anchor, matching the SwiftUI `onTapInlineNote` handler (Req 5.6).+    ///+    /// A block's bubble host gathers its SUB-ANCHOR notes as well (list items, table+    /// rows), so the tapped note — not the host — decides which anchor the popover opens+    /// on. Discarding the posted note id opened the block's note list for a list-item+    /// note, i.e. a list showing everything except the note the user had just touched+    /// (T-1725).+    private func handleInlineNoteTap(blockID: String, noteID: String) {         guard let resolved = blockAndIndex(forDOMID: blockID) else { return }         let block = resolved.block+        let path = headingPath(for: block, sourceIndex: resolved.sourceIndex)         coordinator.notePopoverBlock = block         coordinator.notePopoverSourceIndex = resolved.sourceIndex-        coordinator.notePopoverHeadingPath = headingPath(for: block, sourceIndex: resolved.sourceIndex)-        coordinator.notePopoverListItemId = nil-        coordinator.notePopoverTableRowId = nil+        coordinator.notePopoverHeadingPath = path+        let anchor = tappedNoteAnchor(noteID: noteID, block: block, headingPath: path)+        coordinator.notePopoverListItemId = anchor.listItemId+        coordinator.notePopoverTableRowId = anchor.tableRowId+    }++    /// Which anchor of `block` holds the tapped note: one of its list items, one of its+    /// table rows, or the block itself (both nil, the fallback for an unknown id).+    ///+    /// Only the tapped block's OWN sub-anchor ids are searched, so a forged or stale note+    /// id can never address an anchor outside the block the message names.+    private func tappedNoteAnchor(+        noteID: String, block: MarkdownBlock, headingPath: [String]?+    ) -> (listItemId: String?, tableRowId: String?) {+        guard let notesManager, let uuid = UUID(uuidString: noteID) else { return (nil, nil) }+        func holdsNote(_ anchorId: String) -> Bool {+            notesManager.notesShownOnActivation(anchorId: anchorId, headingPath: headingPath)+                .contains { $0.id == uuid }+        }+        switch block {+        case .list:+            if let entry = block.allListItemIds().first(where: { holdsNote($0.id) }) {+                return (entry.id, nil)+            }+        case .table:+            if let entry = block.allTableRowIds().first(where: { holdsNote($0.id) }) {+                return (nil, entry.id)+            }+        default:+            break+        }+        return (nil, nil)     }      /// The block's heading ancestry, for note-lookup disambiguation (T-209). Uses the
prism/ViewModels/WebDocumentControllerFactory.swift Modified +15 / -1
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 752161d..4c1a35b 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -399,7 +399,21 @@ enum WebDocumentControllerFactory {                 String(localized: "globalNotes.count \(count)")             },             // Reuses the legacy GlobalNotesBanner key so existing translations carry over.-            addDocumentNote: String(localized: "Add a document note")+            addDocumentNote: String(localized: "Add a document note"),+            // The indicator dot has no text of its own — its glyph is a CSS background —+            // so this IS its accessible name (T-1725). Pluralised through the catalog+            // rather than concatenated, per the project's localisation rules.+            noteIndicator: { count in String(localized: "noteIndicator.count \(count)") },+            // Appended to a note bubble as visually-hidden text so the control announces+            // what activating it does on top of the note's own words. "Show notes", not+            // "Edit note": activation opens the note list for the bubble's anchor, and Edit+            // inside that list is gated on `note.isEditable`.+            //+            // Its OWN key rather than the "Show notes" the notes-sidebar toggle uses: the+            // two happen to share an English wording, but one is a toggle and the other an+            // activation, and a translator handed a single untagged entry can neither tell+            // them apart nor diverge them where a locale needs it.+            showNotes: String(localized: "noteBubble.action.showNotes", defaultValue: "Show notes")         )     } 
prism/Services/NotesManager.swift Modified +16 / -0
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swiftindex d0e476b..4fb45b8 100644--- a/prism/Services/NotesManager.swift+++ b/prism/Services/NotesManager.swift@@ -349,6 +349,22 @@ final class NotesManager {         return imported + user     } +    /// The notes that activating a note indicator (or an inline note bubble) puts on screen.+    ///+    /// The ONE definition of "what the popover shows for this anchor", read by both+    /// `WebNotePopoverView` (which lists them) and `NoteStateFeeder` (which counts them for+    /// the dot's spoken accessible name and decides whether to draw a dot at all). Split+    /// across two call sites the two drifted: the dot summed the block's own notes plus its+    /// list-item and header-row sub-anchors, while activation resolved to the block alone —+    /// so a list whose notes sat only on its items announced "Show 2 notes" and opened an+    /// empty popover (T-1725).+    ///+    /// An anchor is a block id or a single sub-anchor id (`{hash}-item-{n}`,+    /// `{hash}-row-{n}`, `{hash}-row-header`) — never a fold of several.+    func notesShownOnActivation(anchorId: String, headingPath: [String]? = nil) -> [BlockNote] {+        allNotes(for: anchorId, headingPath: headingPath)+    }+     /// Filters notes to those matching the heading path, keeping legacy notes (nil path).     private func filterByHeadingPath(_ notes: [BlockNote], headingPath: [String]?) -> [BlockNote] {         guard let headingPath else { return notes }
prism/Views/WebNotePopoverView.swift Modified +8 / -1
diff --git a/prism/Views/WebNotePopoverView.swift b/prism/Views/WebNotePopoverView.swiftindex 9ce320f..a23926d 100644--- a/prism/Views/WebNotePopoverView.swift+++ b/prism/Views/WebNotePopoverView.swift@@ -50,8 +50,15 @@ struct WebNotePopoverView: View {      /// The block's notes, imported then user, sorted chronologically and     /// disambiguated by heading path (T-209). Read live so edits reflect at once.+    ///+    /// Goes through `notesShownOnActivation` rather than calling `allNotes` directly:+    /// the note indicator's spoken accessible name ("Show N notes") counts the result of+    /// that same call, so the announced number cannot claim more than this list holds+    /// (T-1725).     private var notes: [BlockNote] {-        notesManager.allNotes(for: noteKey, headingPath: coordinator.notePopoverHeadingPath)+        notesManager.notesShownOnActivation(+            anchorId: noteKey, headingPath: coordinator.notePopoverHeadingPath+        )     }      /// Notes grouped into threads (root + replies), oldest thread first.
prism/Localizable.xcstrings Modified +84 / -0
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstringsindex bc61951..8a1c2e8 100644--- a/prism/Localizable.xcstrings+++ b/prism/Localizable.xcstrings@@ -6270,6 +6270,90 @@         }       }     },+    "noteBubble.action.showNotes": {+      "comment": "Accessible action label appended as visually hidden text to a note bubble in the rendered document (T-1725). It names what ACTIVATING the bubble does: it opens the note list for that bubble's anchor. Deliberately a separate key from the \"Show notes\" notes-sidebar label, which is a toggle rather than an activation — the two share an English wording but may need to diverge in other locales.",+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Show notes"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Show notes"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Show notes"+          }+        }+      }+    },+    "noteIndicator.count %lld": {+      "comment": "Accessible name of the note-indicator dot beside a block or table row in the rendered document (T-1725). The dot has no text of its own — its glyph is drawn in CSS — so this IS its name to VoiceOver. The count is how many notes activating the dot puts on screen for that one anchor, so it must stay a count and not become a generic label.",+      "extractionState": "manual",+      "localizations": {+        "en": {+          "variations": {+            "plural": {+              "one": {+                "stringUnit": {+                  "state": "translated",+                  "value": "Show 1 note"+                }+              },+              "other": {+                "stringUnit": {+                  "state": "translated",+                  "value": "Show %lld notes"+                }+              }+            }+          }+        },+        "en-GB": {+          "variations": {+            "plural": {+              "one": {+                "stringUnit": {+                  "state": "translated",+                  "value": "Show 1 note"+                }+              },+              "other": {+                "stringUnit": {+                  "state": "translated",+                  "value": "Show %lld notes"+                }+              }+            }+          }+        },+        "en-US": {+          "variations": {+            "plural": {+              "one": {+                "stringUnit": {+                  "state": "translated",+                  "value": "Show 1 note"+                }+              },+              "other": {+                "stringUnit": {+                  "state": "translated",+                  "value": "Show %lld notes"+                }+              }+            }+          }+        }+      }+    },     "paywall.exports.remaining %lld": {       "extractionState": "manual",       "localizations": {
prismTests/WebRendering/WebNoteAccessibilityTests.swift Added +453 / -0
diff --git a/prismTests/WebRendering/WebNoteAccessibilityTests.swift b/prismTests/WebRendering/WebNoteAccessibilityTests.swiftnew file mode 100644index 0000000..f7f044c--- /dev/null+++ b/prismTests/WebRendering/WebNoteAccessibilityTests.swift@@ -0,0 +1,453 @@+//+//  WebNoteAccessibilityTests.swift+//  prismTests+//+//  Keyboard + screen-reader semantics for the injected notes chrome (T-1725).+//+//  After the WebKit cutover the notes UI was built from custom `div`/`span` elements+//  carrying `role="button"` and nothing else a control needs: no `tabindex`, so they were+//  not reachable by keyboard at all; no accessible name on the indicator dot (it has no+//  text — the glyph is a CSS background), so VoiceOver announced a bare "button"; and+//  click-only activation, so even a focusable element would not have answered Enter/Space.+//  On top of that every `setNoteIndicators` / `setInlineNotes` push rebuilds the chrome+//  from scratch, so a keyboard user lost their place on any unrelated note-state change.+//+//  The fix uses REAL controls rather than re-implementing them: a native `<button>` is+//  focusable, tab-ordered, and activated by Enter AND Space by the user agent, and a+//  native `<a href>` is focusable and activated by Enter. That is why these tests assert+//  element type rather than dispatching synthetic key events: a `dispatchEvent`-ed+//  KeyboardEvent is untrusted and never triggers a UA default action, so it CANNOT prove+//  Enter/Space parity. What can, and what was actually missing, is "the element that takes+//  keyboard focus is the same element whose activation posts the bridge message" —+//  asserted below by focusing and then activating `document.activeElement`.+//+//  Live-harness caveat (specs/readable-tables-restoration/decision_log.md Decision 2): the+//  harness never applies document.css, so nothing here asserts computed style. The+//  stylesheet's side of the contract — the `<button>` UA reset that preserves the dot's+//  em-based gutter geometry, and the focus ring — is pinned in+//  DocumentCSSNoteAccessibilityTests.+//++import Foundation+import Testing+import WebKit+@testable import prism++@Suite("Web note chrome keyboard + screen-reader semantics (T-1725)")+@MainActor+struct WebNoteAccessibilityTests {++    private static let notesScripts = ["prism-scroll", "prism-theme", "prism-media", "prism-notes"]++    // MARK: - Fixtures++    private func paragraph(_ text: String = "Some paragraph text") -> MarkdownBlock {+        .paragraph(markdown: text)+    }++    private func tableBlock() -> MarkdownBlock {+        .table(headers: ["A", "B"], rows: [["1", "2"], ["3", "4"]], alignments: [.leading, .leading])+    }++    private func harness(_ blocks: [MarkdownBlock]) async throws -> WebDocumentLiveHarness {+        try await WebDocumentLiveHarness.make(blocks: blocks, featureScripts: Self.notesScripts)+    }++    private func domID(_ block: MarkdownBlock, index: Int = 0) -> String {+        "b-\(block.id)-\(index)"+    }++    private func note(blockId: String, content: String = "a note") -> 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+        )+    }++    /// Strings with recognisable sentinels, so a test can tell a payload-delivered,+    /// catalog-resolved label from a hardcoded one that crept into the JS.+    private static let strings = NoteRenderStrings(+        documentNotesCount: { "COUNT=\($0)" },+        addDocumentNote: "ADD-DOC-NOTE",+        noteIndicator: { "INDICATOR=\($0)" },+        showNotes: "SHOW-NOTES"+    )++    /// JSON-encodes a `setInlineNotes` payload around real `NoteHTMLBuilder` output, so the+    /// live tests exercise the markup that actually ships rather than an approximation.+    private func inlineNotesJSON(+        bubbles: [(domID: String, html: String)], banner: String? = nil+    ) throws -> String {+        var model: [String: Any] = [+            "bubbles": bubbles.map { ["domID": $0.domID, "html": $0.html] },+        ]+        if let banner { model["banner"] = ["html": banner, "placement": "top"] }+        let data = try JSONSerialization.data(withJSONObject: model)+        return try #require(String(data: data, encoding: .utf8))+    }++    private func bubbleHTML(_ notes: [BlockNote]) -> String {+        NoteHTMLBuilder.inlineNotesHost(+            notes, exportUsername: "", actionLabel: Self.strings.showNotes+        )+    }++    private func bannerHTML(_ notes: [BlockNote]) -> String {+        NoteHTMLBuilder.banner(+            notes, countLabel: "COUNT=\(notes.count)", addLabel: Self.strings.addDocumentNote,+            exportUsername: ""+        )+    }++    // MARK: - Native markup: element choice + accessible names++    @Test("An inline note bubble is a native button carrying a localised action label")+    func bubbleIsNativeButton() {+        let html = bubbleHTML([note(blockId: "b1", content: "Great point")])+        // A native <button> is focusable and Enter/Space-activated by the UA; the retired+        // `<div role="button">` was neither.+        #expect(html.contains("<button type=\"button\""))+        #expect(!html.contains("role=\"button\""))+        // The note's own text stays part of the accessible name (it is the button's+        // content); the action verb is appended as visually-hidden text rather than set as+        // an aria-label, which would have REPLACED the note text in the name.+        #expect(html.contains("Great point"))+        #expect(html.contains("SHOW-NOTES"))+        #expect(html.contains("prism-visually-hidden"))+        // A <button>'s content model is phrasing content, so the parts are spans.+        #expect(!html.contains("<div class=\"prism-note-content\""))+        #expect(html.contains("<span class=\"prism-note-content\""))+    }++    @Test("The bubble's action label names what activation does, not an action it lacks")+    func bubbleActionLabelIsTheRealAction() {+        // Activation opens the note's whole anchor list; the posted note id selects the+        // anchor, not an editor. Edit inside that list is gated on `note.isEditable`, so on+        // an imported note there is no Edit action at all — "Edit note" was accurate for no+        // bubble in the app (T-1725).+        let resolved = WebDocumentControllerFactory.noteStrings()+        #expect(resolved.showNotes == "Show notes")+        #expect(!resolved.showNotes.localizedCaseInsensitiveContains("edit"))+    }++    @Test("The banner's add affordance is announced as the link it is, not a fake button")+    func bannerAddIsNotAFakeButton() {+        let html = bannerHTML([])+        // `role="button"` on an <a href> is a claim the UA does not honour: Enter activates+        // it, Space does not. Dropping the role leaves an honest, natively-activated link.+        #expect(!html.contains("role=\"button\""))+        #expect(html.contains("aria-label=\"ADD-DOC-NOTE\""))+        #expect(html.contains("href=\"\(PrismLinkRoute.documentNoteAdd)\""))+    }++    @Test("Banner note bubbles are inert cards, not tab stops promising an action")+    func bannerBubblesAreNotControls() {+        let html = bannerHTML([note(blockId: BlockNote.documentSentinelId, content: "Doc note")])+        // Nothing routes a tap on a banner bubble: the delegated handler requires a+        // `[data-prism-inline-note-host]` ancestor, which only the in-flow bubbles have.+        // Making these <button>s would add focusable stops that do nothing, and the hidden+        // action label would name an action the app cannot perform (T-1725).+        #expect(html.contains("Doc note"))+        #expect(!html.contains("prism-visually-hidden"))+        #expect(!html.contains("SHOW-NOTES"))+        #expect(!html.contains("<button type=\"button\" class=\"prism-inline-note"))+        #expect(!html.contains("data-prism-focus-key=\"note-"))+        // The toggle and the add link remain real controls.+        #expect(html.contains("data-prism-focus-key=\"notes-toggle\""))+    }++    @Test("The banner's collapse toggle names the region it controls")+    func bannerToggleControlsBubbles() {+        let html = bannerHTML([note(blockId: BlockNote.documentSentinelId)])+        #expect(html.contains("aria-expanded=\"true\""))+        #expect(html.contains("aria-controls=\"\(NoteHTMLBuilder.bubblesContainerID)\""))+        #expect(html.contains("id=\"\(NoteHTMLBuilder.bubblesContainerID)\""))+    }++    @Test("Every focusable note control carries a stable focus key for re-render restore")+    func chromeCarriesFocusKeys() {+        #expect(bubbleHTML([note(blockId: "b1")]).contains("data-prism-focus-key=\"note-"))+        let banner = bannerHTML([note(blockId: BlockNote.documentSentinelId)])+        #expect(banner.contains("data-prism-focus-key=\"notes-toggle\""))+        #expect(banner.contains("data-prism-focus-key=\"notes-add\""))+    }++    // MARK: - The indicator's name reaches the page from the catalog++    @Test("The indicator payload carries a catalog-resolved, count-aware accessible name")+    func indicatorPayloadCarriesLabel() throws {+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let para = paragraph()+        manager.setImportedNotes([para.id: [note(blockId: para.id), note(blockId: para.id)]])+        let json = NoteStateFeeder.indicatorsJSON(+            blocks: [para], notesManager: manager, strings: Self.strings+        )+        let data = try #require(json.data(using: .utf8))+        let entries = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]])+        #expect(entries.count == 1)+        // The JS bundle cannot reach the string catalog, so the name RIDES THE PAYLOAD:+        // native-owned, catalog-resolved, and aware of how many notes the dot stands for.+        #expect(entries.first?["label"] as? String == "INDICATOR=2")+    }++    // MARK: - Live: the indicator is a real control++    @Test("The rendered note indicator is a focusable native button with a name")+    func indicatorIsFocusableNativeButton() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        let json = "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: json))+        let shape = try await harness.evalString(+            "var i = document.querySelector('[data-prism-note-indicator]');"+                + " if (!i) { return 'missing'; }"+                + " return i.tagName + '|' + i.type + '|' + i.tabIndex"+                + "   + '|' + (i.getAttribute('aria-label') || '');"+        )+        // `type` is pinned because the dot is built imperatively in JS: a <button> inside a+        // form defaults to type="submit", so leaving it unset is a live hazard, not a style+        // preference. The natively-built bubble asserts the same in bubbleIsNativeButton.+        #expect(shape == "BUTTON|button|0|Show 1 note")+    }++    @Test("The focused indicator is the control whose activation posts the tap")+    func focusedIndicatorActivatesTheSameControl() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        try await harness.send(+            .setNoteIndicators(json: "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]")+        )+        // Focus as a keyboard user would, then activate whatever holds focus. Enter/Space+        // on a native button synthesize exactly this click, so if the focused element is+        // the indicator then the keyboard path and the pointer path are one path. Before+        // the fix the indicator could not take focus, so activeElement stayed on <body> and+        // nothing was ever posted.+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-note-indicator]');"+                + " if (i) { i.focus(); }"+                + " if (document.activeElement) { document.activeElement.click(); }"+                + " return null;",+            contentWorld: harness.bridgeWorld+        )+        let message = try await harness.waitForMessage(type: "noteIndicatorTapped")+        #expect(message?["blockID"] as? String == domID(para))+    }++    @Test("An inline note bubble takes focus and activates from the keyboard")+    func inlineBubbleIsFocusable() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        let bubble = bubbleHTML([note(blockId: para.id, content: "Bubble text")])+        try await harness.send(+            .setInlineNotes(json: try inlineNotesJSON(bubbles: [(domID(para), bubble)]))+        )+        let shape = try await harness.evalString(+            "var n = document.querySelector('.prism-inline-note');"+                + " if (!n) { return 'missing'; }"+                + " n.focus();"+                + " return n.tagName + '|' + n.tabIndex + '|' + (document.activeElement === n);"+        )+        #expect(shape == "BUTTON|0|true")++        _ = try await harness.page.callJavaScript(+            "if (document.activeElement) { document.activeElement.click(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        let message = try await harness.waitForMessage(type: "inlineNoteTapped")+        #expect(message?["blockID"] as? String == domID(para))+    }++    @Test("The note bubble keeps its own text in the accessible name, plus the action")+    func bubbleAccessibleNameKeepsNoteText() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        let bubble = bubbleHTML([note(blockId: para.id, content: "Bubble text")])+        try await harness.send(+            .setInlineNotes(json: try inlineNotesJSON(bubbles: [(domID(para), bubble)]))+        )+        // No aria-label: the name is computed from content, so the note's words survive and+        // the action verb is appended rather than replacing them.+        let named = try await harness.evalBool(+            "var n = document.querySelector('.prism-inline-note');"+                + " if (!n) { return false; }"+                + " return !n.hasAttribute('aria-label')"+                + "   && n.textContent.indexOf('Bubble text') >= 0"+                + "   && n.textContent.indexOf('SHOW-NOTES') >= 0;"+        )+        #expect(named == true)+    }++    // MARK: - Live: tab order++    @Test("Note chrome is tab-ordered in reading order, banner first then the block's chrome")+    func chromeTabOrderFollowsReadingOrder() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        try await harness.send(+            .setNoteIndicators(json: "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]")+        )+        let bubble = bubbleHTML([note(blockId: para.id, content: "Bubble text")])+        let banner = bannerHTML([note(blockId: BlockNote.documentSentinelId, content: "Doc note")])+        try await harness.send(.setInlineNotes(+            json: try inlineNotesJSON(bubbles: [(domID(para), bubble)], banner: banner)+        ))+        // Tab visits tabindex="0" controls in DOM order, so asserting the document order of+        // the focusable chrome asserts the order a Tab press walks it. The banner is placed+        // at the top of <main>, so its toggle and add link come before the block's chrome —+        // and its note bubbles are inert, so they must NOT appear in this walk.+        let order = try await harness.evalString(+            "var nodes = document.querySelectorAll("+                + "'main [data-prism-note-indicator], main .prism-inline-note,"+                + " main .prism-notes-toggle, main .prism-notes-add,"+                + " main .prism-add-note:not([data-prism-suppressed])');"+                + " var out = [];"+                + " for (var i = 0; i < nodes.length; i++) {"+                + "   var n = nodes[i];"+                + "   if (n.tabIndex < 0) { continue; }"+                + "   out.push(n.hasAttribute('data-prism-note-indicator') ? 'indicator'"+                + "     : (n.classList.contains('prism-notes-toggle') ? 'banner-toggle'"+                + "     : (n.classList.contains('prism-notes-add') ? 'banner-add'"+                + "     : (n.classList.contains('prism-inline-note') ? 'bubble' : 'add'))));"+                + " }"+                + " return out.join(',');"+        )+        #expect(order == "banner-toggle,banner-add,indicator,bubble")+    }++    // MARK: - Live: focus retention across re-renders++    /// Lifts the "is this document the thing the user is typing into?" guard.+    ///+    /// The harness runs an OFF-SCREEN `WebPage` that is never key, so `document.hasFocus()`+    /// is false in it — the very condition the guard exists for (a native note sheet+    /// covering the document). That makes the harness a faithful reproduction of the+    /// hazard, and it means any test asserting the RESTORE path has to say so explicitly.+    /// The guard itself is pinned by `focusIsNotRestoredWhileTheDocumentIsUnfocused`.+    /// The seam lives on the bridge, in the isolated world, unreachable from the page.+    private func grantDocumentFocus(_ harness: WebDocumentLiveHarness) async throws {+        _ = try await harness.page.callJavaScript(+            "window.__prismBridge.documentHasFocus = function () { return true; };"+                + " return null;",+            contentWorld: harness.bridgeWorld+        )+    }++    @Test("Focus is not pulled into a document the user is not in")+    func focusIsNotRestoredWhileTheDocumentIsUnfocused() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        let json = "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: json))+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-note-indicator]'); if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        // No grantDocumentFocus: this is the save-from-a-native-sheet sequence. The user is+        // in the sheet's text field; document.activeElement still points at the bubble/dot+        // they came from, because WebKit does not reset it when the WebView stops being+        // first responder. Restoring here would call focus() on a background document and+        // can escalate into a window / first-responder request (T-1725).+        try await harness.send(.setNoteIndicators(json: json))+        let restored = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(restored == "BODY")+    }++    @Test("Deleting the last note hands focus from the vanishing dot back to the add control")+    func focusMovesFromRemovedIndicatorToAddControl() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        try await harness.send(+            .setNoteIndicators(json: "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]")+        )+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-note-indicator]'); if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        // Delete that note: the dot is removed and the "+" it had suppressed comes back.+        // The inverse of the suppression handoff, and the more common direction — without a+        // successor lookup the captured key resolves to nothing and focus falls to <body>.+        try await harness.send(.setNoteIndicators(json: "[]"))+        let focused = try await harness.evalBool(+            "var a = document.activeElement;"+                + " return !!(a && a.hasAttribute && a.hasAttribute('data-prism-add-note')"+                + "   && !a.hasAttribute('data-prism-suppressed'));"+        )+        #expect(focused == true)+    }++    @Test("Focus on an indicator survives an unrelated setNoteIndicators re-push")+    func focusSurvivesIndicatorRepush() async throws {+        let table = tableBlock()+        let harness = try await harness([table])+        let first = "[{\"domID\":\"\(domID(table))\",\"rowOrdinal\":1,\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: first))+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-note-indicator]'); if (i) { i.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        // A second push (a note added on a different row) rebuilds every indicator. The+        // keyboard user must still be standing where they were.+        let second = "[{\"domID\":\"\(domID(table))\",\"rowOrdinal\":1,\"label\":\"Show 1 note\"},"+            + "{\"domID\":\"\(domID(table))\",\"rowOrdinal\":0,\"label\":\"Show 1 note\"}]"+        try await harness.send(.setNoteIndicators(json: second))+        let restored = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-focus-key') || a.tagName) : 'none';"+        )+        #expect(restored == "note-indicator|\(domID(table))|1")+    }++    @Test("Focus on an inline note bubble survives a setInlineNotes re-push")+    func focusSurvivesInlineNotesRepush() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        let target = note(blockId: para.id, content: "Keep my place")+        let bubble = bubbleHTML([target])+        try await harness.send(+            .setInlineNotes(json: try inlineNotesJSON(bubbles: [(domID(para), bubble)]))+        )+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "var n = document.querySelector('.prism-inline-note'); if (n) { n.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        // Re-push (what a settings change or an edit to another note does).+        try await harness.send(+            .setInlineNotes(json: try inlineNotesJSON(bubbles: [(domID(para), bubble)]))+        )+        let restored = try await harness.evalString(+            "var a = document.activeElement;"+                + " return a && a.getAttribute ? (a.getAttribute('data-prism-note-id') || a.tagName) : 'none';"+        )+        #expect(restored == target.id.uuidString)+    }++    @Test("Focus moves to the note dot when a live note suppresses the focused add-note button")+    func focusMovesFromSuppressedAddControl() async throws {+        let para = paragraph()+        let harness = try await harness([para])+        // The "+" is injected at load; focus it, then let a note arrive on the same block.+        // The "+" is then hidden (display:none) in favour of the dot, which would silently+        // drop focus to <body> — the dot is its successor and inherits the focus instead.+        try await grantDocumentFocus(harness)+        _ = try await harness.page.callJavaScript(+            "var a = document.querySelector('[data-prism-add-note]'); if (a) { a.focus(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        try await harness.send(+            .setNoteIndicators(json: "[{\"domID\":\"\(domID(para))\",\"label\":\"Show 1 note\"}]")+        )+        let focused = try await harness.evalBool(+            "var a = document.activeElement;"+                + " return !!(a && a.hasAttribute && a.hasAttribute('data-prism-note-indicator'));"+        )+        #expect(focused == true)+    }+}
prismTests/WebRendering/DocumentCSSNoteAccessibilityTests.swift Added +132 / -0
diff --git a/prismTests/WebRendering/DocumentCSSNoteAccessibilityTests.swift b/prismTests/WebRendering/DocumentCSSNoteAccessibilityTests.swiftnew file mode 100644index 0000000..4a56a89--- /dev/null+++ b/prismTests/WebRendering/DocumentCSSNoteAccessibilityTests.swift@@ -0,0 +1,132 @@+//+//  DocumentCSSNoteAccessibilityTests.swift+//  prismTests+//+//  Stylesheet guard for T-1725: the note indicator dot and the inline note bubble became+//  native `<button>` elements so they are focusable and Enter/Space-activated by the user+//  agent. That is only safe if document.css undoes the button UA chrome, and two of those+//  resets are load-bearing rather than cosmetic:+//+//    1. `.prism-note-indicator { font-size: 1em }`. A `<button>`'s UA font-size is ~13.3px+//       against the document's 17px, and EVERY dimension and offset on the dot (width,+//       height, the `left: -1.4em` gutter position) is expressed in em. Without the reset+//       the dot silently shrinks and slides out of the gutter column it shares with the+//       add-note "+" — the exact failure the "+" already had to be fixed for (T-1542).+//    2. `.prism-inline-note { text-align: left; width: 100% }`. A button centres its text+//       and shrink-wraps its content, so a note card would render centred and ragged.+//+//  Also pinned: the visually-hidden helper carrying the bubble's action label must hide+//  the text WITHOUT removing it from the accessibility tree (so not `display: none` or+//  `visibility: hidden`), and the focus ring must exist at all — these controls suppress+//  their UA appearance, so a keyboard user has nothing else to see.+//+//  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+//  style cannot be asserted here. Visual verification is manual.+//++import Foundation+import Testing+@testable import prism++@Suite("document.css note control accessibility rules (T-1725)")+@MainActor+struct DocumentCSSNoteAccessibilityTests {++    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 first — the prose in this stylesheet names both the+    /// selectors and the properties, so a fragment matched inside a comment would be+    /// mistaken for a declaration — then collapses whitespace.+    private static func flattened(_ css: String) throws -> String {+        var stripped = ""+        var rest = Substring(css)+        while let open = rest.range(of: "/*") {+            stripped += rest[rest.startIndex..<open.lowerBound]+            guard let close = rest.range(of: "*/", range: open.upperBound..<rest.endIndex) else {+                rest = rest[rest.endIndex...]+                break+            }+            rest = rest[close.upperBound...]+        }+        stripped += rest+        return stripped.split(whereSeparator: \.isWhitespace).joined(separator: " ")+    }++    /// The declaration body of the first rule whose selector list is exactly `selector`.+    private static func ruleBody(_ selector: String, in css: String) throws -> String {+        let flat = try flattened(css)+        let needle = "\(selector) {"+        let start = try #require(flat.range(of: needle), "no rule for \(selector)")+        let end = try #require(+            flat.range(of: "}", range: start.upperBound..<flat.endIndex),+            "unterminated rule for \(selector)"+        )+        return String(flat[start.upperBound..<end.lowerBound])+    }++    @Test("The note indicator button keeps the content font-size, so its em geometry holds")+    func indicatorResetsButtonFontSize() throws {+        let body = try Self.ruleBody(".prism-note-indicator", in: try Self.loadDocumentCSS())+        #expect(body.contains("font-size: 1em"))+        #expect(body.contains("appearance: none"))+        #expect(body.contains("border: none"))+        #expect(body.contains("padding: 0"))+    }++    @Test("The inline note bubble undoes the button's centring and shrink-wrap")+    func bubbleResetsButtonLayout() throws {+        let body = try Self.ruleBody(".prism-inline-note", in: try Self.loadDocumentCSS())+        #expect(body.contains("text-align: left"))+        #expect(body.contains("width: 100%"))+        #expect(body.contains("display: block"))+        #expect(body.contains("font-family: inherit"))+        // The card ratio is restated after the inherited font properties, so it survives.+        #expect(body.contains("font-size: 0.95em"))+    }++    @Test("The bubble's spans stack the way the retired divs did")+    func bubblePartsAreBlocks() throws {+        let body = try Self.ruleBody(+            ".prism-note-author, .prism-note-content, .prism-note-timestamp",+            in: try Self.loadDocumentCSS()+        )+        #expect(body.contains("display: block"))+    }++    @Test("Visually-hidden text stays in the accessibility tree")+    func visuallyHiddenKeepsTextExposed() throws {+        let body = try Self.ruleBody(".prism-visually-hidden", in: try Self.loadDocumentCSS())+        // display:none / visibility:hidden would remove the label from the accessibility+        // tree along with the pixels, defeating the whole point of the helper.+        #expect(!body.contains("display: none"))+        #expect(!body.contains("visibility: hidden"))+        #expect(body.contains("clip-path"))+        #expect(body.contains("position: absolute"))+    }++    @Test("Every note control paints a keyboard focus ring")+    func noteControlsHaveFocusRings() throws {+        let flat = try Self.flattened(try Self.loadDocumentCSS())+        for selector in [+            ".prism-note-indicator:focus-visible",+            ".prism-inline-note:focus-visible",+            ".prism-notes-toggle:focus-visible",+            ".prism-notes-add:focus-visible",+        ] {+            #expect(flat.contains(selector), "no focus ring for \(selector)")+        }+        let body = try Self.ruleBody(+            ".prism-note-indicator:focus-visible, .prism-inline-note:focus-visible,"+                + " .prism-notes-toggle:focus-visible, .prism-notes-add:focus-visible",+            in: try Self.loadDocumentCSS()+        )+        #expect(body.contains("outline: 2px solid"))+    }+}
prismTests/WebRendering/NoteStateFeederTests.swift Modified +95 / -5
diff --git a/prismTests/WebRendering/NoteStateFeederTests.swift b/prismTests/WebRendering/NoteStateFeederTests.swiftindex 573ae31..bbcfe4d 100644--- a/prismTests/WebRendering/NoteStateFeederTests.swift+++ b/prismTests/WebRendering/NoteStateFeederTests.swift@@ -119,7 +119,7 @@ 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 → no block-level indicator (the block tap cannot reveal it)")     func listItemIndicator() {         let manager = manager()         let list = list()@@ -127,12 +127,97 @@ struct NoteStateFeederTests {         manager.setImportedNotes([itemId: [note(blockId: itemId)]])          let json = NoteStateFeeder.indicatorsJSON(blocks: [list], notesManager: manager)-        let indicators = decodeIndicators(json)+        // The block dot used to stand in for item notes, but activating it nils the+        // sub-anchor ids, so the popover it opened could not contain them — an empty+        // popover under a dot that (once named) announced "Show 1 note" (T-1725). One dot+        // per anchor the tap can open; the item's own dot is T-1745 (PR #345).+        #expect(decodeIndicators(json).isEmpty)+    }++    @Test("Header-row note → no block-level indicator on the table")+    func headerRowIndicator() {+        let manager = manager()+        let table = table()+        let headerId = "\(table.id)-row-header"+        manager.setImportedNotes([headerId: [note(blockId: headerId)]])++        // Same reason as the list item: `handleNoteIndicatorTap` with no rowOrdinal nils+        // notePopoverTableRowId, so a folded dot opened a popover without the header note.+        // A per-header-row dot is tracked separately (T-2044).+        #expect(decodeIndicators(+            NoteStateFeeder.indicatorsJSON(blocks: [table], notesManager: manager)+        ).isEmpty)+    }++    @Test("The indicator's spoken count is exactly what activating it reveals")+    func indicatorLabelCountsWhatThePopoverShows() {+        let manager = manager()+        let list = list()+        let itemId = list.listItemId(at: 0)!+        // One note on the list block itself, two on its first item. The dot stands for the+        // block anchor only, so it must announce 1 — the number the popover lists — not 3.+        manager.setImportedNotes([+            list.id: [note(blockId: list.id, content: "on the list")],+            itemId: [note(blockId: itemId, content: "one"), note(blockId: itemId, content: "two")],+        ])++        let indicators = decodeIndicators(NoteStateFeeder.indicatorsJSON(+            blocks: [list], notesManager: manager, strings: Self.countingStrings+        ))         #expect(indicators.count == 1)-        #expect(indicators.first?["domID"] as? String == "b-\(list.id)-0")-        #expect(indicators.first?["rowOrdinal"] == nil)+        // The single source of truth both sides read.+        let revealed = manager.notesShownOnActivation(anchorId: list.id)+        #expect(revealed.count == 1)+        #expect(indicators.first?["label"] as? String == "COUNT=\(revealed.count)")     } +    @Test("A resolved note counts towards the name, because the popover still lists it")+    func indicatorLabelIncludesResolvedNotes() {+        let manager = manager()+        let para = paragraph()+        manager.setImportedNotes([para.id: [+            note(blockId: para.id, content: "live"),+            note(blockId: para.id, content: "done", status: .resolved),+        ]])++        let indicators = decodeIndicators(NoteStateFeeder.indicatorsJSON(+            blocks: [para], notesManager: manager, strings: Self.countingStrings+        ))+        // Presence is gated on an ACTIVE note; the name describes the whole list activation+        // opens — WebNotePopoverView shows resolved notes too, dimmed.+        #expect(indicators.count == 1)+        #expect(indicators.first?["label"] as? String == "COUNT=2")+    }++    @Test("A table row's indicator counts that row's notes, not the whole table's")+    func rowIndicatorLabelCountsTheRow() {+        let manager = manager()+        let table = table()+        let rowId = "\(table.id)-row-1"+        manager.setImportedNotes([+            rowId: [note(blockId: rowId, content: "row note")],+            table.id: [note(blockId: table.id, content: "block note one"),+                       note(blockId: table.id, content: "block note two")],+        ])++        let indicators = decodeIndicators(NoteStateFeeder.indicatorsJSON(+            blocks: [table], notesManager: manager, strings: Self.countingStrings+        ))+        let row = indicators.first { $0["rowOrdinal"] != nil }+        let block = indicators.first { $0["rowOrdinal"] == nil }+        #expect(row?["label"] as? String == "COUNT=1")+        #expect(block?["label"] as? String == "COUNT=2")+    }++    /// Strings whose indicator name is just the number, so a test can assert the count the+    /// feeder resolved without depending on catalog wording.+    private static let countingStrings = NoteRenderStrings(+        documentNotesCount: { "DOC=\($0)" },+        addDocumentNote: "ADD",+        noteIndicator: { "COUNT=\($0)" },+        showNotes: "SHOW-NOTES"+    )+     @Test("Resolved notes do not produce indicators")     func resolvedNoIndicator() {         let manager = manager()@@ -398,7 +483,12 @@ struct NoteStateFeederTests {                 note(blockId: BlockNote.documentSentinelId, content: "two"),             ],         ])-        let strings = NoteStateFeeder.Strings(documentNotesCount: { "COUNT=\($0)" }, addDocumentNote: "Add")+        let strings = NoteStateFeeder.Strings(+            documentNotesCount: { "COUNT=\($0)" },+            addDocumentNote: "Add",+            noteIndicator: { "Show \($0)" },+            showNotes: "Show notes"+        )         let json = NoteStateFeeder.inlineNotesJSON(             blocks: [paragraph()], notesManager: manager,             showInlineNotes: true, bannerPlacement: .top, exportUsername: "", strings: strings
prismTests/WebRendering/WebDocumentMessageRouterTests.swift Modified +50 / -0
diff --git a/prismTests/WebRendering/WebDocumentMessageRouterTests.swift b/prismTests/WebRendering/WebDocumentMessageRouterTests.swiftindex 9a4443c..27d1dec 100644--- a/prismTests/WebRendering/WebDocumentMessageRouterTests.swift+++ b/prismTests/WebRendering/WebDocumentMessageRouterTests.swift@@ -249,6 +249,56 @@ struct WebDocumentMessageRouterTests {         let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)         router.handle(.inlineNoteTapped(blockID: "b-\(para.id)-0", noteID: "n1", rect: rect()))         #expect(coordinator.notePopoverBlock?.id == para.id)+        #expect(coordinator.notePopoverListItemId == nil)+        #expect(coordinator.notePopoverTableRowId == nil)+    }++    @Test("inlineNoteTapped on a list-item note opens that item's notes, not the list's")+    func inlineNoteTapResolvesTheSubAnchor() {+        let list = MarkdownBlock.list(ordered: false, start: 1, items: [+            ListItem(content: "First", checkbox: nil),+            ListItem(content: "Second", checkbox: nil),+        ])+        let itemId = list.listItemId(at: 1)!+        let itemNote = BlockNote(+            blockId: itemId, contextQuote: "", content: "on the item", status: .active,+            createdAt: Date(timeIntervalSince1970: 1_700_000_000),+            modifiedAt: Date(timeIntervalSince1970: 1_700_000_000),+            author: "Tester", threadId: nil+        )+        let notesManager = NotesManager.makeForTesting(store: MockNotesStore())+        notesManager.setImportedNotes([itemId: [itemNote]])++        let coordinator = DocumentLayoutCoordinator()+        let router = WebDocumentMessageRouter(+            session: makeSession(blocks: [list]), coordinator: coordinator,+            notesManager: notesManager+        )+        // A block's bubble host carries its item notes too, so discarding the posted note id+        // opened the LIST's note list — which by definition cannot contain an item note.+        router.handle(.inlineNoteTapped(+            blockID: "b-\(list.id)-0", noteID: itemNote.id.uuidString, rect: rect()+        ))+        #expect(coordinator.notePopoverBlock?.id == list.id)+        #expect(coordinator.notePopoverListItemId == itemId)+        #expect(coordinator.notePopoverTableRowId == nil)+    }++    @Test("An unknown note id falls back to the block rather than guessing an anchor")+    func inlineNoteTapUnknownIDFallsBackToBlock() {+        let list = MarkdownBlock.list(ordered: false, start: 1, items: [+            ListItem(content: "First", checkbox: nil),+        ])+        let coordinator = DocumentLayoutCoordinator()+        let router = WebDocumentMessageRouter(+            session: makeSession(blocks: [list]), coordinator: coordinator,+            notesManager: NotesManager.makeForTesting(store: MockNotesStore())+        )+        router.handle(.inlineNoteTapped(+            blockID: "b-\(list.id)-0", noteID: UUID().uuidString, rect: rect()+        ))+        #expect(coordinator.notePopoverBlock?.id == list.id)+        #expect(coordinator.notePopoverListItemId == nil)     }      @Test("blockContextRequested (no sub-target) opens the block add-note sheet")
CLAUDE.md Modified +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex 4fa8311..3bb9390 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -51,7 +51,7 @@ The document is rendered by WebKit-for-SwiftUI (`WebView`/`WebPage`). The SwiftU 2. **Emit**: `BlockHTMLEmitter` (`prism/Services/WebRendering/`) is a pure, deterministic function of `[MarkdownBlock]` + `FootnoteData` + `RenderSettings`. It emits one `<section>` per block carrying the content-hash block identity and an occurrence-qualified DOM id (`b-{hash}-{sourceIndex}`, allocated via the shared `BlockDOMID`), escapes by default, and is total (a block that fails to emit falls back to escaped-source `<pre>`, never dropped). `InlineHTMLRenderer` wraps mappable text runs in `<span data-prism-run>` and records a `DocumentSourceMap` (UTF-16 offsets, shipped as an inert `<div hidden>` data island) for selection-anchored notes. `emit` (and the model/service value types it reads) is `nonisolated`, so it runs off the MainActor: `WebDocumentControllerFactory.precomputeDocumentHTML` emits once per `parseRevision` on a `Task.detached` and caches the HTML on `DocumentSession`; the scheme handler serves that cache (synchronous on-main emit only on a miss). Its per-block/inline `HTMLSanitizer` (SwiftSoup) passes are serialized behind a shared `Mutex` because SwiftSoup keeps unsynchronized static pools (T-1681, `specs/offmain-html-emit/`). 3. **Serve**: `PrismDocSchemeHandler` (`prism-doc://` `URLSchemeHandler`) is the single audited I/O path — it serves the document HTML, `document.css` (the only asset fetched through the scheme), and mediates every image subresource through `/img/?src=` (rewritten absolute/relative URLs routed via `ImagePathResolver`/`ImageLoader`/`SVGSourceLoader`). It serves the verbatim CSP (`script-src 'none'`, `connect-src 'none'`, …) as a response header. The document is loaded via the scheme, never `loadHTMLString`. 4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot. `WebDocumentStateSynchronizer` (T-1719) is the single production owner that pushes native truth (sections, details open-state, table modes, notes, typography, comment visibility) to the controller and routes navigation targets (TOC/fragment via `session.pendingAnchorScroll`, notes via `coordinator.noteNavigationTarget`, search current match) through `controller.scrollTo` with `BlockDOMID.navigationDOMID` id translation — Observation-framework driven, so it works with no view mounted; `DocumentScrollContent` mounts the whole assembly via `WebDocumentStateSynchronizer.makeAssembly`. Two inputs are view-fed, because both are view-world environment values: the palette, pushed via `applyTheme(themeKey:contrast:)` — the colorScheme-resolved theme key plus `colorSchemeContrast`, grouped as a `WebPaletteFeed` so a single `.onChange` pushes them together and they can never be applied out of step (T-1829) — and `dynamicTypeSize`, fed in via `start(dynamicTypeSize:)` / `applyDynamicTypeSize(_:)`, which gets NO push of its own: the synchronizer folds it into the typography domain, because `applyTypography` carries one variables dict that wholly replaces the snapshot's typography, so a second pusher would drop the settings-derived half from the recovery replay (T-1828, font-settings Decision 18).-5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back.+5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back. Every interactive piece of that chrome is a NATIVE `<button>` or `<a href>` (T-1725) — never a `div`/`span` with `role="button"` — so the user agent supplies focusability, tab order, and Enter/Space activation, and there is no synthetic key handling to keep in sync. Those elements suppress their UA appearance, so `document.css` must reset it; the indicator dot's `font-size: 1em` is load-bearing rather than cosmetic, since the dot's whole gutter geometry is expressed in em. Accessible names are native-owned because the JS cannot reach the string catalog: the indicator's name rides the `setNoteIndicators` payload (`label`, pluralised via `NoteRenderStrings.noteIndicator`), the bubble's action label is baked in by `NoteHTMLBuilder` as visually-hidden text (an `aria-label` there would *replace* the note's own text in the accessible name), and the add-note "+" reads `<main data-prism-add-note-label>`. Both push handlers rebuild all chrome, so each control carries a `data-prism-focus-key` and `prism-notes.js` captures/restores focus around the rebuild. 6. **Search**: counts and navigation order stay in `SearchService`/`SearchCoordinator`. `SearchStateFeeder` translates that into a per-block `setSearchState` payload; `prism-search.js` re-finds the query in each block's rendered text and registers ranges on two named **CSS Custom Highlights** (`prism-search`, `prism-search-current`), windowed to the viewport. The web view's built-in find navigator stays disabled so Cmd+F routes to Prism's search. 7. **Security**: `HTMLSanitizer` (over SwiftSoup) reduces raw HTML embedded in markdown to an allowlist subset on load (Req 1.8/8.1); its `plainText` feeds searchable text. Combined with `allowsContentJavaScript = false` and the served CSP, active-content vectors are blocked by construction. 8. **In-page render libraries**: mermaid.js and highlight.js (+ their thin Prism drivers) run in the **page world** (they only render DOM, never post bridge messages). The bundled JS lives in `prism/Resources/WebRenderer/`.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c95e42b..df49f79 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 +- Notes in the document can now be reached with a keyboard, and VoiceOver announces them properly (T-1725). Since the WebKit rendering cutover the note dot beside a block, and the note bubbles shown under one, looked like buttons but were not: Tab walked straight past them, so there was no way to open a note without a pointer, and Enter or Space did nothing even if you got to one. VoiceOver could reach the dot only to announce an unnamed "button", because the dot is drawn rather than written and had no name of its own. The dot and each bubble are now real buttons — focusable, in reading order, and opened by Enter or Space just as by a tap — and the dot announces how many notes it stands for ("Show 3 notes"), while a bubble reads out the note's author, text, and time followed by what activating it does. The "add a document note" control at the top of the document announced itself as a button while answering only Enter; it now presents as the link it is, so what is announced and what the keyboard does agree, and the banner's collapse control now states which notes it hides. Adding, editing, resolving, or deleting a note anywhere in the document used to throw keyboard focus back to the start of the page, because every note control is redrawn each time; focus now stays on the control you were using, and where adding a note replaces a block's **+** button with its note dot, focus moves onto the dot instead of being dropped — as does the reverse, where deleting the last note on a block takes the dot away and brings the **+** back. Focus is only ever moved while you are actually in the document, so saving a note in a sheet no longer risks pulling you out of the text field you are typing in. Two things the announcements turned out to be describing wrongly are fixed with them. The number a dot announces is now the number of notes opening it shows you: a dot on a list said "Show 2 notes" when both notes belonged to items within the list, and then opened an empty panel, because opening a whole block's notes cannot show a note attached to one of its items. Notes attached to a list item or to a table's header row therefore no longer put a dot on the whole block — they are still shown as bubbles under the block and in the notes panel, and giving them dots of their own is being done separately. And tapping a note bubble now opens that note's own notes rather than the block's, so a note on a list item opens the item's; the hidden text on a bubble says "Show notes" instead of "Edit note", which was never what activation did and is not offered at all on an imported note. Confirming the announcements with VoiceOver and Voice Control on a device is still worth doing by hand. - 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 `*&#65;*` shows an `A`, but the file spells it `&#65;` — 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 `![cat](cat.png "see [^1]")`, 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&amp;/[^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.

Things to double-check

VoiceOver on a long note bubble.

Wrapping the entire note in a <button> means VoiceOver reads it as a single unit: a long multi-paragraph note becomes one continuous announcement with no per-line or per-word rotor navigation inside it. The alternative shape is an inert card carrying a small "Show notes" button. The branch reasons the trade through and keeps the note's own words in the accessible name, which is the important half — but this is the first thing device testing should look at, and the author has already flagged VoiceOver/Voice Control verification as manual work outstanding on both platforms.

The intermediate state, if #346 does merge first.

Confirm the acceptable-loss reasoning holds for your users: a note attached only to a list item will draw no dot until #345 lands. It still renders as a bubble under the block provided showInlineNotes is on, which is the default (AppSettings.swift:91). A user who has turned inline notes off sees nothing in the document and must use the notes panel. Compare against the status quo, which drew a dot that opened an empty popover — arguably a worse signal, but a signal.

The CHANGELOG conflict on rebase.

git merge-tree confirms prism-notes.js auto-merges against the advanced main (PR #344), but CHANGELOG.md conflicts because both add a bullet under ### Fixed. Trivial, but it does mean this branch cannot fast-path merge as-is — and neither can #345, which is in the same state.

Header-row notes remain dot-less past both merges.

T-2044 is the only follow-up whose gap is not closed by either PR: a table's header-row note has no indicator regardless of merge order, only a bubble. Worth confirming that is understood as a deliberate, ticketed hold rather than an oversight, since it outlives both changes.