prism branch T-2044/bugfix-table-header-row-note-indicator commits 3 files 10 touched lines +685 / -43 production +82 / -32 across 3 files tests 109/109 passed (macOS, clean export)

Pre-push review: T-2044 header-row note indicator

Second-round review of PR #419. The one Requires discussion item from round one — the header-row dot living inside a <th scope="col"> — was re-argued from WebKit's source rather than the accname spec, with no markup change. That reasoning holds, and I verified it independently against WebKit main. All four minors were addressed. Verdict: ready to push.

At a glance

  • The discussion item is settled in the author's favour, verified at source. Both WebKit claims reproduce exactly — same function names, same line numbers, same code — on WebKit main @ fea77037 (2026-09-06). Either finding alone defeats the mechanism I raised; both hold.
  • Two corrections to the report's precision, none to its conclusion. includeFocusableContent is raised at four sites in WebCore, not the two the report names. The extra pair are alternativeText()'s nameFrom-heading path and — the one that matters — accessibleNameForNode() at AccessibilityNodeObject.cpp:4512, which sets it positionally as the second aggregate member and so is invisible to a grep for the field name. That site passes true, meaning a <th>'s subtree is read with focusable content included when the <th> labels something else. Unreachable here (Prism emits no aria-labelledby or <label> pointing at a header cell, and titleUIElement() explicitly refuses <th>), but the asymmetry is real and the report's "only for headings and base-appearance select options" is incomplete.
  • All four round-one minors verified fixed. renderIndicators now goes through the shared subElement()/CSS.escape helper; the router's new tableRow(forRowOrdinal:subID:block:) carries the case .table guard and resolves by membership in allTableRowIds(); the stale agent-note line about rowOrdinal addressing is corrected; the report now frames only the reading half as closed, with T-2318 named for the authoring half.
  • No double-counting, and I checked the shape that would cause it. notesShownOnActivation is a single anchor-exact dictionary lookup that never folds sub-anchors, so the table's block dot still excludes header-row notes now that the header has its own. The header row's bubble correctly stays in the block-level host (subID: nil) rather than being inserted into the <tr>, which would have been invalid DOM.
  • I reproduced the verification from a clean export. git archive into an isolated directory, fresh derived data: SwiftLint clean, check-webkit-test-isolation.py passes, and the four touched suites run 109/109 green on macOS. No new compiler warnings attributable to the diff.
  • The iOS build is owed by convention, not by risk. The author ran make build-macos and targeted suites but not make build-ios this round. Nothing in the diff is platform-conditional (the sole #if in the touched Swift is a pre-existing #if DEBUG test seam), and my macOS run compiled every changed Swift file. Worth one run before merge purely because no CI job runs any test on this repo — this is the whole verification the PR has.

Verdict

Ready to push

The author's WebKit argument is correct. I fetched WebKit main @ fea77037 (2026-09-06) and read both functions: AccessibilityObject::dependsOnTextUnderElement() at AccessibilityObject.cpp:2382 enumerates exactly PopUpButton (non-<select>), Summary, Button, ToggleButton, Checkbox, ListBoxOption, ListItem (non-Cocoa only), the four menu/radio roles, Switch, Tab, plus the tail isHeading() || isLink() || isOutput(). No cell, columnheader, rowheader, gridcell or table-header-container role appears anywhere in it, and AccessibilityNodeObject::visibleText() is the sole producer of AccessibilityTextSource::Children and is gated on that predicate — so no name-from-content is computed for a <th> at all. And shouldUseAccessibilityObjectInnerText at AccessibilityNodeObject.cpp:3814 carries the quoted comment and the quoted line verbatim, with includeFocusableContent { false } at AXCoreObject.h:350. The ordering is what settles it: the skip return at :4001 sits before alternativeText() at :4017, which is the only place a child's aria-label is ever harvested — so a focusable child is dropped before its label is read.

The cross-check the report did not do also lands the same way: for a bare <th>, AXTitle, AXDescription and AXValue are all empty on macOS (title() returns only on Visible/Children/LabelByElement, all gated by the same predicate; descriptionAttributeValue() reads aria-label on the <th> itself, never a child's; stringValue() has no cell branch), and a cell is a non-barren container whose children VoiceOver descends into. So my round-one spec reading was right about the spec and wrong about the engine Prism ships on, exactly as claimed.

The rejection of the aria-label-on-the-<th> remedy also stands on its own: aria-label replaces name-from-content, so a header cell holding an image would lose that image's alt text. That is a real regression traded for a hazard the engine does not have. The regression test pins the right three properties, and pinning native focusability is the load-bearing one, since that is precisely what WebKit's skip keys on.

All four minors from round one are addressed and verified. What remains is documentation precision and nits — nothing that should hold the branch.

Review findings

15 raised · 5 fixed · 10 skipped

Jump to findings →

Tests

Pass rate: 100% (109 of 109)

New tests: 8

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What Changed

Prism lets you attach notes to parts of a markdown document — a paragraph, a list item, a row of a table. Wherever a note exists, a small dot appears in the margin; tap it and the note opens.

One place the dot was missing: a table's header row (the top row with the column titles). A note could be attached there, and you could still see it in the notes panel and as an inline bubble under the table, but the row itself showed nothing. This branch gives it a dot.

Why It Matters

The dot is how you discover a note. Without it, a header-row note existed but nothing on the row said so. Worse, it was never a simple oversight — it was removed on purpose in an earlier ticket, because at the time the only dot available was the table's own dot, and tapping that opened a list of notes that could not contain the header row's note. Once the dot started reading its count out loud to screen-reader users ("Show 1 note"), a dot that could not show that note became an outright false statement. Removing it was the honest choice until the header row could be addressed properly.

Key Concepts

Addressing. Body rows are numbered — row 0, row 1, row 2 — so a dot can say "I belong to row 1". The header row has no number. This change gives it a name instead, "row-header", sent through a channel the code already had for list items. Nothing new was added to the wire format.

Where the dot goes. A table row (<tr>) may legally contain only cells. So the dot is put inside the row's first cell, not into the row itself — the same placement body-row dots already use.

Architecture

Prism renders documents in WebKit but keeps native Swift as the source of truth. Note indicators travel one way — NoteStateFeeder turns NotesManager state into a JSON payload, prism-notes.js renders it into the DOM — and taps travel back the other way over a generation-tagged bridge into WebDocumentMessageRouter, which sets popover state on DocumentLayoutCoordinator.

The header row was the one anchor kind with no address. Three coordinated edits close it:

  • Feeder (NoteStateFeeder.swift): after the existing block and list-item passes, a .table block now also offers an indicator at {blockId}-row-header carrying subID: "row-header". It is offered unconditionally and makeIndicator decides — returning nil unless that exact anchor has an active note — so a table with no header note emits nothing.
  • Page (prism-notes.js): renderIndicators treats subID === "row-header" as a row case alongside rowOrdinal, sharing the row-then-first-cell walk. Routing it through the generic subID branch would have inserted the dot at the container's own firstChild — a direct child of <tr>, which is invalid.
  • Router (WebDocumentMessageRouter.swift): a new tableRow(forRowOrdinal:subID:block:) resolves both channels and sets notePopoverTableRowId.

Patterns

Reuse the general slot rather than add a specific one. The bridge message already carried an untyped subID: String? for list items, decoded with no value allowlist. Threading the header row through it needed no wire-contract change. The two rejected alternatives — a headerRow: Bool field, or a sentinel rowOrdinal: -1 — would have added surface area or an undocumented magic number.

Resolve inbound sub-addresses by membership, never by string-building. The round-one review flagged that the router built "{blockId}-row-{ordinal}" and trusted it. It now looks the candidate up in the block's own allTableRowIds(), behind a case .table guard — the same rule the neighbouring listItem(forSubID:block:) and tappedTableRowId already follow. Unreachable from the shipping page, so it is hardening rather than a fix.

Trade-offs

The dot goes on the row; the bubble stays in a single block-level host below the table. That asymmetry is deliberate and unchanged — a bubble inside a <td>/<th> would deform the table — so the dot and the bubble address the same anchor from two different DOM positions.

The accessibility argument, and why it survives scrutiny

The header row's first cell is a <th scope="col">: the element every body cell in column 1 resolves its column header against. Round one raised that under accname as specified, columnheader has nameFrom: contents, step 2F recurses into the cell, and step 2C would pick up the dot's aria-label — folding "Show 1 note" into a name read once per row rather than once per table. That reading of the spec is correct, and Chrome and Firefox would behave that way.

WebKit does not, for two independent reasons, and I verified both against main @ fea77037 (2026-09-06) rather than accepting them:

  1. AccessibilityObject::dependsOnTextUnderElement() (AccessibilityObject.cpp:2382) switches over PopUpButton (non-<select> only), Summary, Button, ToggleButton, Checkbox, ListBoxOption, ListItem (guarded #if !PLATFORM(COCOA), so excluded on Apple platforms), MenuItem, MenuItemCheckbox, MenuItemRadio, RadioButton, Switch, Tab, then falls through to isHeading() || isLink() || isOutput(). No cell-family role is present. AccessibilityNodeObject::visibleText() (:3548) is the only producer of AccessibilityTextSource::Children and is wrapped in that predicate, so a <th> gets no name-from-content computed at all.
  2. Where one is computed, shouldUseAccessibilityObjectInnerText (AccessibilityNodeObject.cpp:3814) carries, at :3854, if (object.canSetFocusAttribute() && !mode.includeFocusableContent) return false; under the comment // Skip focusable children, so we don't include the text of links and controls. — with includeFocusableContent { false } at AXCoreObject.h:350. Statement order in textUnderElement's processChild lambda is what makes this decisive: the skip returns at :4001, and alternativeText() — the sole harvester of a child's aria-label — is called at :4017, after it. A skipped child contributes neither text nor label.

The Cocoa cross-check the report did not perform agrees. AXCoreObject::title() returns only on Visible/Children/LabelByElement, all gated by the same predicate; descriptionAttributeValue() reads Alternative, sourced from aria-label/aria-labelledby/alt on the <th> itself; stringValue() has no cell branch. AXTitle, AXDescription and AXValue are all empty, the cell is absent from canHaveChildren()'s exclusion switch, and titleUIElement() explicitly refuses a <th> ("by definition they are title ui elements"). VoiceOver descends into the cell's children.

Where the report is imprecise

includeFocusableContent is raised at four sites, not two. Beyond headings (:3553) and base-appearance select options (:3558) there is alternativeText()'s nameFrom-heading path (:3512) and, more interestingly, accessibleNameForNode() (:4512), which sets it positionally as the second member of an aggregate initialiser and is therefore invisible to anyone grepping the field name. That site passes true. It runs when computing a name for a labelling element<label>, aria-labelledby, slot assignment — not for an element's own name-from-content, and Prism emits nothing that points at a header cell that way. The conclusion is unaffected; the enumeration in the report is not exhaustive.

Residual, and why it is not blocking

The finding is a source read, not a VoiceOver measurement, and the report says so. Because the <th>'s computed name is empty rather than merely unpolluted, an assistive client that synthesises a description from descendants when the name is empty could still surface the dot. That is client behaviour outside WebKit, identical in shape to what body-row dots already do, and it argues for measurement rather than for markup change. Two of the three pinned properties — no text node contributed, no naming override on the cell — hold in any engine.

Verification

Reproduced from a clean git archive export with fresh derived data: SwiftLint clean, check-webkit-test-isolation.py passes, four suites 109/109 on macOS with -enableCodeCoverage NO. The author additionally mutation-verified every new test (string-building the row anchor fails unknownTableRowAddressIsIgnored; giving the dot a text node fails headerRowIndicatorDoesNotAlterTheColumnHeaderName; forcing the JS row lookup to row-header reports header=2 row1=0 instead of header=1 row1=1) — which is the right standard for a change whose failure mode is silence.

Important changes — detailed

prism-notes.js: the header row is a ROW case, not a subID case

prism-notes.js

Why it matters. This is the one place the reuse could have gone wrong. The header row rides the <code>subID</code> channel, so the obvious implementation routes it through the generic subID branch — which inserts the dot at the container's own <code>firstChild</code>. The container here is a <code>&lt;tr&gt;</code>, which may legally contain only <code>&lt;td&gt;</code>/<code>&lt;th&gt;</code>, so that would emit invalid DOM whose rendering is up to the parser's error recovery. <code>renderIndicators</code> instead treats <code>subID === "row-header"</code> as a row alongside <code>rowOrdinal</code> and shares the row-then-first-cell walk.

What to look at. prism-notes.js renderIndicators, the hasRow || isHeaderRow branch

Takeaway. When a new case reuses an existing transport, check whether it also shares the existing <em>placement</em>. Here the address is shared with list items but the DOM placement is shared with body rows, and following the transport would have been the wrong half to follow.
Rationale. A <code>&lt;tr&gt;</code>'s content model admits only cells, so the dot must go inside the first cell — exactly where a body-row dot already goes. The live test asserts the dot is found by <code>cell.querySelector(':scope &gt; [data-prism-note-indicator]')</code>, which fails if it lands on the row instead.

WebDocumentMessageRouter: row anchors resolved by membership, not string-built

WebDocumentMessageRouter.swift

Why it matters. Round one flagged that the router built <code>"{blockId}-row-{ordinal}"</code> and handed it straight to the coordinator, with no <code>case .table</code> guard and no membership check — the exact rule its two neighbours document. The extracted <code>tableRow(forRowOrdinal:subID:block:)</code> now guards on <code>.table</code> and resolves both channels through <code>block.allTableRowIds()</code>, so a stale ordinal, a forged one, or a row address on a non-table block targets nothing rather than a fabricated anchor.

What to look at. WebDocumentMessageRouter.swift:178-202

Takeaway. Hardening an unreachable path is worth doing when the surrounding code already <em>documents</em> the rule: the cost is one <code>first { }</code>, and the alternative is a lone exception that a future reader will copy.
Rationale. Stated in the code: unreachable from the shipping page because <code>NoteStateFeeder</code> only ever emits ordinals it enumerated from the same block, but present because the two neighbours document membership as the rule for every inbound sub-address.

The header-row indicator is offered unconditionally and refused by count

NoteStateFeeder.swift

Why it matters. <code>appendIndicator(subID: "row-header", …)</code> runs for <em>every</em> table, with no "does the header have a note" precondition at the call site. That reads alarming and is correct: <code>makeIndicator</code> returns <code>nil</code> unless <code>notesShownOnActivation</code> finds an active note at that exact anchor, so a table with no header note emits nothing. Same shape as the list-item pass above it — offer every anchor, let one function decide.

What to look at. NoteStateFeeder.swift:228-236, with makeIndicator at :284

Takeaway. Centralising the "is there anything to show" test in one function is what lets every caller be unconditional. The alternative — a presence check per anchor kind — is where the block dot and the popover drifted apart in T-1725.
Rationale. Presence and spoken count come from the same <code>notesShownOnActivation</code> lookup the popover lists from, so the dot and what its tap opens cannot disagree about which anchor they describe.

No double-counting: the block dot still excludes header-row notes

NoteStateFeeder.swift

Why it matters. The failure this change could plausibly have introduced is two dots announcing the same note — the new header dot plus the table's own block dot. It does not, and the reason is one line elsewhere: <code>notesShownOnActivation</code> is <code>allNotes(for:)</code>, an anchor-exact dictionary lookup that never folds sub-anchors. The <code>seen</code> dedup keys are also disjoint (<code>domID#-#row-header</code> vs <code>domID#-#-</code> vs <code>domID#N#-</code>), and <code>activeTableRowOrdinals</code> skips the header because <code>Int("header")</code> is nil.

What to look at. NoteStateFeeder.swift dedupKey at :299; NotesManager.notesShownOnActivation at :698

Takeaway. Before adding an anchor to a fan-out, check what the <em>parent</em> anchor's lookup includes. Here it includes nothing but itself, which is what makes adding a sibling free.
Rationale. Asserted directly by <code>headerAndBodyRowIndicatorsCoexist</code>, which requires exactly two indicators with distinct counts and no block-level entry.

The regression test pins native focusability, which is the load-bearing property

WebNoteAccessibilityTests.swift

Why it matters. Of the three properties <code>headerRowIndicatorDoesNotAlterTheColumnHeaderName</code> asserts, focusability is the one whose connection to the conclusion is non-obvious. WebKit's skip keys on <code>canSetFocusAttribute()</code> — so a non-focusable <code>&lt;span role="button"&gt;</code> carrying the identical <code>aria-label</code> <em>would</em> fold into the header's name. T-1725's decision to use real controls is therefore load-bearing for an accessibility property nobody had connected it to, and the test is what stops a future "simplification" back to a styled span from silently reopening it.

What to look at. WebNoteAccessibilityTests.swift, headerRowIndicatorDoesNotAlterTheColumnHeaderName

Takeaway. When a conclusion rests on engine behaviour a test cannot reach, pin the <em>page-side preconditions</em> that behaviour keys on. You cannot assert what VoiceOver says; you can assert the three facts that make the answer what it is.
Rationale. Written out in the test's own doc comment, including why <code>aria-label</code> on the <code>&lt;th&gt;</code> was rejected (it replaces name-from-content, losing a header image's alt text) and why <code>aria-hidden</code> on the dot is worse (it hides a live control).

Key decisions

Change nothing in the emitted markup for the <code>&lt;th scope="col"&gt;</code> concern.

Settled against WebKit's source rather than the accname spec, and I confirmed both findings on main @ fea77037 with matching function names and line numbers. Neither dependsOnTextUnderElement() nor the focusable-child skip admits the dot's label into a <th>'s accessible name on the only engine Prism ships on. The suggested remedy was rejected on its own merits too: aria-label replaces name-from-content, so a header cell containing an image loses that image's alt text.

Honestly caveated in the report as a source read rather than a VoiceOver measurement, and the WebKit behaviour is explicitly a deviation from the spec that other engines do not share.

Address the header row through the existing <code>subID</code> channel.

Rejected: a dedicated isHeaderRow: Bool on the wire (new surface area for a slot that already exists), and a sentinel rowOrdinal: -1 (an undocumented magic number, precisely the anchor ambiguity MarkdownBlock.allTableRowIds()'s explicit -row-header spelling exists to avoid). noteIndicatorTapped already carries an untyped subID: String? that BridgeMessageRouter decodes with no value allowlist, so the JS→native leg needed no contract change.

Close only the reading half; the authoring half is T-2318.

subTargetOf in prism-notes.js still refuses "row-header", so the header row gets no add-note + and a context gesture on it posts blockContextRequested with no sub-target, falling back to a table-block note that specs/table-row-notes Req 1.3 forbids outright. The report states this rather than claiming Req 2.1/2.6 closed, and names the follow-up ticket. Correct scoping for a bugfix branch.

Give <code>indicatorSuccessor</code> the row-vs-item distinction even though it is a no-op today.

The header cell contains no add-note + to hand focus to, so the new branch always returns null. The report is explicit that this is a landmine-avoidance measure for when T-2318 adds one, not a live path. Acceptable as written; see the nit below on testing it.

Review findings

SeverityAreaFindingResolution
majorRound-one discussion item: the dot inside <code>&lt;th scope="col"&gt;</code>Round one held that under accname, name-from-content on the <code>&lt;th&gt;</code> would fold the dot's aria-label into the column header every body cell in that column resolves against — polluting it once per row. Raised as Requires discussion, reasoned from the spec, not measured.The author's counter-argument is correct and I verified it independently against WebKit main @ fea77037 (2026-09-06). dependsOnTextUnderElement() at AccessibilityObject.cpp:2382 lists no cell-family role, and visibleText() is the sole producer of AccessibilityTextSource::Children gated on it — so no name-from-content is computed for a <th> at all. Separately, shouldUseAccessibilityObjectInnerText at AccessibilityNodeObject.cpp:3814 skips canSetFocusAttribute() children with includeFocusableContent defaulting false (AXCoreObject.h:350), and the skip returns at :4001 before alternativeText() at :4017 — the only place a child's aria-label is harvested. The Cocoa cross-check agrees: AXTitle, AXDescription and AXValue are all empty for a bare <th>. My spec reading was right about the spec and wrong about the engine. No markup change is owed.
minorRound-one minor 1: shared selector helperrenderIndicators hand-built [data-prism-sub='row-N'] while its sibling in the same file used the shared subElement()/CSS.escape helper.Verified fixed. Both the render path (prism-notes.js:350) and the successor path (:220) now call subElement(), which applies CSS.escape. No remaining hand-built sub selectors in the indicator paths.
minorRound-one minor 2: case .table guard + allTableRowIds() membershiphandleNoteIndicatorTap string-built the row anchor with no block-kind guard and no membership check, unlike its two neighbours.Verified fixed. Extracted to tableRow(forRowOrdinal:subID:block:) at WebDocumentMessageRouter.swift:189, which guards on case .table and resolves both channels through block.allTableRowIds(). Pinned by the new unknownTableRowAddressIsIgnored, which the author mutation-verified.
minorRound-one minor 3: stale agent-note linedocs/agent-notes/webview-rendering-status.md line 251 still said table-row notes keep rowOrdinal addressing for the dot, contradicting the T-2044 bullet below it.Verified fixed. That bullet now distinguishes the dot (per-row, body by rowOrdinal and header by subID) from the bubble (one block-level host for the whole table), and the neighbouring bullet is rewritten to describe the new routing rather than the removed fold.
minorRound-one minor 4: report's Req 2.1 framingThe report read as if Req 2.1/2.6 were closed for the header row, when only the reading half is.Verified fixed. The report now carries a 'Requirements closed, and the half that is not' section stating that subTargetOf still refuses "row-header", that a context gesture falls back to a block-level note which Req 1.3 forbids, and naming T-2318. The CHANGELOG entry carries the same qualification.
minorreport.md — includeFocusableContent enumeration incompleteThe report says includeFocusableContent is raised 'only for headings and base-appearance select options'. There are four sites in WebCore, not two: alternativeText()'s nameFrom-heading path (AccessibilityNodeObject.cpp:3512) and accessibleNameForNode() (:4512) also raise it. The second sets it positionally as the second member of an aggregate initialiser, so it is invisible to a grep for the field name — and it passes true, meaning a <th>'s subtree IS read with focusable content included when the <th> labels something else.Not fixed — reported. Unreachable in Prism: nothing emits aria-labelledby or a <label> pointing at a header cell, and titleUIElement() explicitly refuses to give a <th> a title UI element. The conclusion is unaffected. Worth a one-line correction to the report so a future reader auditing this does not conclude the enumeration was exhaustive and miss the asymmetry.
minordocs/agent-notes/webview-rendering-status.md:254 — newly stale invariantThe <details> bullet still reads 'the feeder enumerates sub ids only for a .list block (so nothing ever reaches subElement's first-match querySelector)'. As of this PR the feeder also enumerates "row-header" for a .table block, so that sentence is now false. This is the same class of stale line as the round-one minor, one bullet further down, and this branch updated the two neighbouring bullets without it.Not fixed — reported. The invariant still HOLDS (the emitter writes row-header only inside a .table section, exactly once, and the feeder emits that subID only for a .table block, so the <details> ambiguity stays unreachable and fails closed), but the note is the load-bearing statement of WHY it holds. Suggested amendment: '…only for a .list block's items and a .table block's header row — both unique within their own section'.
minorreport.md — 'written by gesture' is impreciseThe report states a header-row note 'can be read but still not written by gesture'. Verified in source that this is not quite right: WebNotePopoverView.addNote() (prism/Views/WebNotePopoverView.swift:205-213) routes through coordinator.notePopoverTableRowId, which this change now sets to {blockId}-row-header. So once one header-row note exists, the new dot → popover → Add Note creates a further note anchored at the header row, entirely by gesture.Not fixed — reported. This is a benign side effect (arguably a small win), not a defect. The CHANGELOG's narrower claim — that long-press/right-click still falls back to a table-block note — remains true. The accurate word in the report is 'originated' rather than 'written'.
minorreport.md — 'no + anywhere on the table' does not hold for a header-only tableThe report argues no add-note + exists on such a table because per-row affordances suppress the block-level one. renderAddNoteControls (prism-notes.js:600-626) sets hasChildAffordance only from rows subTargetOf accepts, and it refuses the header row — so a table with zero body rows (valid GFM: '| A |' / '|---|', which MarkdownBlockParser.swift:881 emits with rows: []) gets a block-level +, whose tap creates the table-block note Req 1.3 forbids.Not fixed — reported. Entirely pre-existing and not introduced by this branch; the report's sentence is conditionally worded ('a table whose body rows carry per-row affordances') so it is arguably already qualified. Worth adding the header-only case to T-2318's scope so it is not lost.
minorVerification: make build-ios not run this roundThe author ran make build-macos, make lint and the targeted suites, but not make build-ios, make test, or make test-ui. CLAUDE.md sets those as the pre-push bar, and since the per-locale sweep went workflow_dispatch-only, no CI job on this repo runs any test — the local run is the entire verification the PR has.Not blocking. I independently reproduced the macOS build and 109/109 targeted tests from a clean git archive export with fresh derived data, which compiles every changed Swift file. Nothing in the diff is platform-conditional — the sole #if in the touched Swift is a pre-existing #if DEBUG test seam, and the JS is platform-neutral. One make build-ios before merge is cheap insurance given CI provides none, but the risk of an iOS-only break is negligible.
nitWebDocumentMessageRouter.swift:201 — membership question answered with a tuple buildblock.allTableRowIds().first { $0.id == candidate }?.id returns a string already equal to candidate, after allTableRowIds() has computed a TableRowContextQuote for every row in the table.Not fixed — reported. `return block.allTableRowIds().contains { $0.id == candidate } ? candidate : nil` is the same semantics, states the intent, and discards no work that is used. On a tap path the cost is irrelevant; the readability point is the real one.
nitprism-notes.js:218 — nested ternaryvar rowSubValue = parts[2] !== "" ? "row-" + parts[2] : (parts[3] === "row-header" ? "row-header" : null); reads densely next to the file's otherwise plain ES5 style, and next to renderIndicators' own hasRow / isHeaderRow spelling twenty lines below.Not fixed — reported. A three-line if / else if matching the sibling's spelling would make the two branches visibly the same shape.
nitprism-notes.js:218-224 — the new indicatorSuccessor header branch is dead todayThe header cell never contains an add-note +, so the branch always returns null and no test covers it. Production consequence: deleting the last header-row note while focused on its dot drops focus to <body>, where a body row would hand off to its +.Not fixed — acceptable as written. The report is explicit that this is landmine-avoidance for T-2318 rather than a live path, and it is not a regression (before this change there was no header dot to stand on at all). If it is to be kept, a live test that injects a [data-prism-add-note] into the <th> and asserts the handoff would stop it being merely aspirational.
nitTest style: JS-in-Swift escaping differs between the two new live suitesWebNotesBehaviourTests uses '[data-prism-sub=\\\"row-header\\\"]' (backslash-escaped quotes inside a single-quoted JS string) while WebNoteAccessibilityTests uses the cleaner 'tr[data-prism-sub=\"row-header\"] th'. Both valid; the second is markedly easier to read.Not fixed — reported. Cosmetic.
nitResidual: every dot in a table announces the same generic nameThe header dot and every body-row dot announce 'Show N notes' (NoteRenderStrings.noteIndicator), so a VoiceOver user cannot tell them apart except positionally. specs/table-row-notes Req 6.1 asks for row identification ('Row 3, Name: Alice, 2 notes').Not fixed — out of scope. Pre-existing for body rows and unchanged here; the header row now simply joins the existing set. Belongs against T-2045 / T-2318, recorded so it is not mistaken for something this branch introduced.

Tests

Source: local run at 2026-09-07T02:05:00+10:00 · snapshot 5b6c324fc2166d625fa22e9c52be86ba65c32869

Baseline: none

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

Coverage scope: as the project configures it

Totals: 109 passed · 0 failed · 0 skipped · 0 errored · 0 flaky

New and removed tests

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

Blast radius

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

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

Skipped files

Per-file diffs

Click to expand.

prism/Resources/WebRenderer/prism-notes.js Modified +25 / -14
diff --git a/prism/Resources/WebRenderer/prism-notes.js b/prism/Resources/WebRenderer/prism-notes.jsindex bcee5f33..ef8aa98a 100644--- a/prism/Resources/WebRenderer/prism-notes.js+++ b/prism/Resources/WebRenderer/prism-notes.js@@ -205,26 +205,28 @@     // successor — its block's remaining chrome is not "where the user was").     //     // The container is whichever one the dot was drawn in, which is what the key's own-    // components already say: a list item for a `subID` key, a row cell for a row key, the-    // section itself otherwise. A block key resolves ONLY to the section's direct-child-    // "+": a block dot suppresses nothing on a list (its items keep theirs — T-1745), so-    // there is no item "+" to hand focus back to.+    // components already say: a row cell for a row key (a body ordinal OR the header row's+    // "row-header" subID, T-2044), a list item for any other `subID` key, the section+    // itself otherwise. A block key resolves ONLY to the section's direct-child "+": a+    // block dot suppresses nothing on a list (its items keep theirs — T-1745), so there is+    // no item "+" to hand focus back to.     function indicatorSuccessor(key) {         var parts = key.split("|");         if (parts.length !== 4 || parts[0] !== "note-indicator") { return null; }         var section = document.getElementById(parts[1]);         if (!section) { return null; }+        var rowSubValue = parts[2] !== "" ? "row-" + parts[2] : (parts[3] === "row-header" ? "row-header" : null);+        if (rowSubValue) {+            var row = subElement(section, rowSubValue);+            if (!row) { return null; }+            var cell = row.querySelector("td, th") || row;+            return cell.querySelector(":scope > [data-prism-add-note]");+        }         if (parts[3] !== "") {             var item = subElement(section, parts[3]);             return item ? item.querySelector(":scope > [data-prism-add-note]") : null;         }-        if (parts[2] === "") {-            return section.querySelector(":scope > [data-prism-add-note]");-        }-        var row = subElement(section, "row-" + parts[2]);-        if (!row) { return null; }-        var cell = row.querySelector("td, th") || row;-        return cell.querySelector(":scope > [data-prism-add-note]");+        return section.querySelector(":scope > [data-prism-add-note]");     }      // ---- setNoteIndicators (Req 5.2) -------------------------------------@@ -337,11 +339,20 @@             var section = document.getElementById(entry.domID);             if (!section) { return; }             var hasRow = entry.rowOrdinal !== null && entry.rowOrdinal !== undefined;-            if (hasRow) {-                var row = section.querySelector("[data-prism-sub='row-" + entry.rowOrdinal + "']");+            // A table-row indicator: a body row (rowOrdinal) or the header row (subID+            // "row-header" — the header has no ordinal, so it rides the same subID+            // channel T-1745 opened for list items instead of a second one, T-2044).+            // Both land in the row's first cell, never as a direct child of <tr>, which+            // only <td>/<th> may legally contain.+            var isHeaderRow = !hasRow && entry.subID === "row-header";+            if (hasRow || isHeaderRow) {+                var rowSubValue = hasRow ? "row-" + entry.rowOrdinal : "row-header";+                var row = subElement(section, rowSubValue);                 if (!row) { return; }                 var cell = row.querySelector("td, th") || row;-                var rowDot = makeIndicator(entry.domID, entry.rowOrdinal, null, entry.label);+                var rowDot = makeIndicator(+                    entry.domID, hasRow ? entry.rowOrdinal : null, isHeaderRow ? entry.subID : null, entry.label+                );                 cell.insertBefore(rowDot, cell.firstChild);                 suppressAddNote(cell, rowDot);             } else if (entry.subID) {
prism/Services/WebRendering/NoteStateFeeder.swift Modified +25 / -13
diff --git a/prism/Services/WebRendering/NoteStateFeeder.swift b/prism/Services/WebRendering/NoteStateFeeder.swiftindex 4f08cecd..73864cc2 100644--- a/prism/Services/WebRendering/NoteStateFeeder.swift+++ b/prism/Services/WebRendering/NoteStateFeeder.swift@@ -28,9 +28,11 @@ //  Sub-block addressing (T-1745): a LIST-ITEM note is addressed at the item, not at the //  list. `subID` is the anchor id minus its block-hash prefix — exactly the value the //  emitter writes into that item's `data-prism-sub` (`item-1`, nested `item-0-item-1`),-//  because both spellings come from `MarkdownBlock.allListItemIds()`. Table rows keep-//  addressing by `rowOrdinal`: a `<td>` is not a flow container, so a row bubble stays in-//  the block-level host below the table.+//  because both spellings come from `MarkdownBlock.allListItemIds()`. A table BODY row's+//  indicator addresses by `rowOrdinal`; the HEADER row has no ordinal, so its indicator+//  reuses the same `subID` channel instead (`"row-header"`, T-2044). Either way, a row's+//  BUBBLE still collects in the block-level host below the table: a `<td>`/`<th>` is not+//  a flow container, so a bubble inside one would deform the table. // //  Localisation (Req 1.9): the banner count label is catalog-resolved natively and //  passed in via `Strings`; note text/author/timestamp are user/document data (escaped),@@ -174,11 +176,12 @@ enum NoteStateFeeder {     ///     /// Nothing folds a sub-anchor into the block dot, from either direction. A list-item     /// note does not (T-1745 gave it its own dot), and neither does a TABLE HEADER-ROW-    /// note: `handleNoteIndicatorTap` with no `rowOrdinal` nils `notePopoverTableRowId`,-    /// so a block dot drawn for header-row notes opens a popover that cannot contain them-    /// — and, once the dot names its count out loud, says so. Header-row notes therefore-    /// draw NO dot until they get their own through the `subID` channel (**T-2044**);-    /// they still reach the reader as inline bubbles and in the notes panel.+    /// note: the header row has no ordinal, so its dot rides the same `subID` channel+    /// T-1745 opened for list items (`"row-header"`, **T-2044**) rather than a second+    /// one, and `handleNoteIndicatorTap` resolves that spelling to the header row's own+    /// anchor. Before this a block dot drawn for header-row notes would have opened a+    /// popover that could not contain them — and, once the dot names its count out loud,+    /// said so.     ///     /// Maps the blocks to their DOM ids and delegates; `payloads` uses the `mapped:`     /// overload to share one walk across both payloads.@@ -223,9 +226,17 @@ enum NoteStateFeeder {                 )             } -            // Table body-row notes → a row indicator per row ordinal. No header-row branch:-            // see the doc comment above.             guard case .table = block else { continue }++            // The header row has no ordinal, so it is addressed by `subID: "row-header"` —+            // the same channel T-1745 opened for list items — rather than `rowOrdinal`+            // (T-2044).+            appendIndicator(+                domID: domID, subID: "row-header", anchorId: "\(block.id)-row-header",+                notesManager: notesManager, strings: strings, into: &indicators, seen: &seen+            )++            // Table body-row notes → a row indicator per row ordinal.             for ordinal in activeTableRowOrdinals(notesManager, block: block) {                 appendIndicator(                     domID: domID, rowOrdinal: ordinal, anchorId: "\(block.id)-row-\(ordinal)",@@ -377,9 +388,10 @@ enum NoteStateFeeder {     /// block-level host, so only the tapped note's own id identifies its row. See     /// `WebDocumentMessageRouter.handleInlineNoteTap`.     ///-    /// A block-level bubble host is still WIDER than the block's dot: the header row's-    /// notes render here while drawing no dot of their own (T-2044). The bubble is what-    /// keeps them visible in the meantime.+    /// A block-level bubble host is still WIDER than the block's own dot: both the header+    /// row and every body row draw their OWN indicator dot (on the row, T-2044/T-1745),+    /// but all of a table's bubbles still collect in this one host below the table — the+    /// dot and the bubble address the same anchor from two different DOM positions.     private static func buildBubbles(         mapped: [(block: MarkdownBlock, domID: String)],         notesManager: NotesManager,
prism/ViewModels/WebDocumentMessageRouter.swift Modified +32 / -5
diff --git a/prism/ViewModels/WebDocumentMessageRouter.swift b/prism/ViewModels/WebDocumentMessageRouter.swiftindex 7dc38762..21cc0eb7 100644--- a/prism/ViewModels/WebDocumentMessageRouter.swift+++ b/prism/ViewModels/WebDocumentMessageRouter.swift@@ -157,9 +157,13 @@ struct WebDocumentMessageRouter {     // MARK: - Notes (Req 5.2/5.3/5.6)      /// A tap on a note indicator opens the existing note popover, mirroring the SwiftUI-    /// path: for a block-level indicator the popover targets the block; for a table-row-    /// indicator it targets the row sub-id (`{blockId}-row-{ordinal}`); for a list-item-    /// indicator it targets the item sub-id (`{blockId}-item-{n}`, T-1745) (Req 5.2).+    /// path: for a block-level indicator the popover targets the block; for a table+    /// body-row indicator it targets the row sub-id via `rowOrdinal`+    /// (`{blockId}-row-{ordinal}`); for a table HEADER-row indicator it targets+    /// `{blockId}-row-header` via `subID: "row-header"` — the header has no ordinal, so+    /// its dot reuses the same `subID` channel T-1745 opened for list items rather than a+    /// second one (T-2044); for a list-item indicator `subID` targets the item sub-id+    /// (`{blockId}-item-{n}`, T-1745) (Req 5.2).     private func handleNoteIndicatorTap(blockID: String, rowOrdinal: Int?, subID: String?) {         guard let resolved = blockAndIndex(forDOMID: blockID) else { return }         let block = resolved.block@@ -167,11 +171,34 @@ struct WebDocumentMessageRouter {         coordinator.notePopoverSourceIndex = resolved.sourceIndex         coordinator.notePopoverHeadingPath = headingPath(for: block, sourceIndex: resolved.sourceIndex)         coordinator.notePopoverListItemId = Self.listItem(forSubID: subID, block: block)?.id+        coordinator.notePopoverTableRowId = Self.tableRow(+            forRowOrdinal: rowOrdinal, subID: subID, block: block+        )+    }++    /// The table row a note-indicator tap names — a body row by `rowOrdinal`, or the header+    /// row by the fixed `subID` `"row-header"` (T-2044) — or nil for a block-level tap.+    ///+    /// Resolved by MEMBERSHIP in the block's own `allTableRowIds()`, never string-built, the+    /// same rule `listItem(forSubID:block:)` and `tappedTableRowId(noteID:block:headingPath:)`+    /// already follow: a stale or forged ordinal, a row sub-id on a non-table block, or an+    /// ordinal past the end of the table targets nothing rather than a fabricated anchor.+    /// Unreachable from the shipping page — `NoteStateFeeder` only ever emits ordinals it+    /// enumerated from this same block — so this is hardening, and it is here because the+    /// two neighbours above document that as the rule for every inbound sub-address.+    private static func tableRow(+        forRowOrdinal rowOrdinal: Int?, subID: String?, block: MarkdownBlock+    ) -> String? {+        guard case .table = block else { return nil }+        let candidate: String         if let rowOrdinal {-            coordinator.notePopoverTableRowId = "\(block.id)-row-\(rowOrdinal)"+            candidate = "\(block.id)-row-\(rowOrdinal)"+        } else if subID == "row-header" {+            candidate = "\(block.id)-row-header"         } else {-            coordinator.notePopoverTableRowId = nil+            return nil         }+        return block.allTableRowIds().first { $0.id == candidate }?.id     }      /// A tap on an inline note bubble opens the popover/edit flow for the note's OWN
prismTests/WebRendering/NoteStateFeederTests.swift Modified +50 / -7
diff --git a/prismTests/WebRendering/NoteStateFeederTests.swift b/prismTests/WebRendering/NoteStateFeederTests.swiftindex 8ae663bd..e16fed36 100644--- a/prismTests/WebRendering/NoteStateFeederTests.swift+++ b/prismTests/WebRendering/NoteStateFeederTests.swift@@ -143,19 +143,62 @@ struct NoteStateFeederTests {         #expect(indicators.first?["label"] is String)     } -    @Test("Header-row note → no block-level indicator on the table")+    @Test("Header-row note → a header-row indicator addressed by subID \"row-header\" (T-2044)")     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)+        let json = NoteStateFeeder.indicatorsJSON(blocks: [table], notesManager: manager)+        let indicators = decodeIndicators(json)+        #expect(indicators.count == 1)+        #expect(indicators.first?["domID"] as? String == "b-\(table.id)-0")+        // The header row has no ordinal, so its dot rides the same `subID` channel T-1745+        // opened for list items rather than a second one — `handleNoteIndicatorTap`+        // resolves "row-header" to the header row's own anchor (T-2044).+        #expect(indicators.first?["subID"] as? String == "row-header")+        #expect(indicators.first?["rowOrdinal"] == nil)+        #expect(indicators.first?["label"] is String)+    }++    /// Coexistence on ONE table — the case the two row channels are most liable to regress+    /// on, because everything after the branch is shared: the same `seen` dedup key, the+    /// same `makeIndicator` count lookup, and (in the page) the same row-and-first-cell DOM+    /// walk. The header row is addressed by `subID: "row-header"` and a body row by+    /// `rowOrdinal`, so both must survive the same pass with their own slot and their own+    /// count — a header entry that leaked an ordinal, or a body entry that leaked the+    /// header's subID, would collide on one dot slot and one of the two notes would lose+    /// its indicator silently (T-2044).+    @Test("A header-row note and a body-row note on one table emit two independent indicators (T-2044)")+    func headerAndBodyRowIndicatorsCoexist() {+        let manager = manager()+        let table = table()+        let headerId = "\(table.id)-row-header"+        let rowId = "\(table.id)-row-1"+        // Different counts, so neither dot can pass by borrowing the other's number.+        manager.setImportedNotes([+            headerId: [note(blockId: headerId, content: "on the header")],+            rowId: [note(blockId: rowId, content: "on row 1"), note(blockId: rowId, content: "also row 1")],+        ])++        let indicators = decodeIndicators(NoteStateFeeder.indicatorsJSON(+            blocks: [table], notesManager: manager, strings: Self.countingStrings+        ))+        #expect(indicators.count == 2)++        let header = indicators.first { $0["subID"] as? String == "row-header" }+        let body = indicators.first { $0["rowOrdinal"] != nil }+        #expect(header?["domID"] as? String == "b-\(table.id)-0")+        #expect(header?["rowOrdinal"] == nil)+        #expect(body?["domID"] as? String == "b-\(table.id)-0")+        #expect(body?["subID"] == nil)+        #expect((body?["rowOrdinal"] as? NSNumber)?.intValue == 1)+        #expect(header?["label"] as? String == "COUNT=1")+        #expect(body?["label"] as? String == "COUNT=2")+        // No block-level dot: neither row note belongs to the table's own anchor, and folding+        // either into a block dot is exactly what T-1725 removed.+        #expect(indicators.contains { $0["subID"] == nil && $0["rowOrdinal"] == nil } == false)     }      @Test("The indicator's spoken count is exactly what activating it reveals")
prismTests/WebRendering/WebDocumentMessageRouterTests.swift Modified +92 / -2
diff --git a/prismTests/WebRendering/WebDocumentMessageRouterTests.swift b/prismTests/WebRendering/WebDocumentMessageRouterTests.swiftindex d34eef5c..2ef6b9dd 100644--- a/prismTests/WebRendering/WebDocumentMessageRouterTests.swift+++ b/prismTests/WebRendering/WebDocumentMessageRouterTests.swift@@ -246,6 +246,60 @@ struct WebDocumentMessageRouterTests {         #expect(coordinator.notePopoverTableRowId == "\(table.id)-row-1")     } +    @Test("noteIndicatorTapped with subID \"row-header\" targets the header row (T-2044)")+    func headerRowIndicatorTapOpensHeaderRowPopover() {+        let table = MarkdownBlock.table(headers: ["A"], rows: [["1"], ["2"]], alignments: [.leading])+        let session = makeSession(blocks: [table])+        let coordinator = DocumentLayoutCoordinator()+        let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+        router.handle(.noteIndicatorTapped(+            blockID: "b-\(table.id)-0", rowOrdinal: nil, subID: "row-header", rect: rect()+        ))+        #expect(coordinator.notePopoverBlock?.id == table.id)+        #expect(coordinator.notePopoverTableRowId == "\(table.id)-row-header")+        #expect(coordinator.notePopoverListItemId == nil)+    }++    /// Coexistence: one table carrying a note on its header row AND on a body row. The two+    /// dots arrive on different channels (`subID: "row-header"` vs `rowOrdinal`) but land in+    /// the SAME single-valued coordinator slot, so each tap must both set its own anchor and+    /// displace the other's — a `default:` that failed to clear, or a `case` that matched too+    /// eagerly, would leave the second tap showing the first row's notes.+    ///+    /// Asserted through `notePopoverNoteKey` — the key `WebNotePopoverView` actually looks its+    /// notes up by — and through the notes that key reveals, so this pins what the reader sees+    /// rather than the field it is spelled in (T-2044).+    @Test("Header-row and body-row taps on one table each open their own note (T-2044)")+    func headerAndBodyRowTapsResolveIndependently() {+        let table = MarkdownBlock.table(headers: ["A"], rows: [["1"], ["2"]], alignments: [.leading])+        let session = makeSession(blocks: [table])+        let coordinator = DocumentLayoutCoordinator()+        let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)+        let notes = NotesManager.makeForTesting(store: MockNotesStore())+        notes.setImportedNotes([+            "\(table.id)-row-header": [makeNote(blockId: "\(table.id)-row-header", content: "on the header")],+            "\(table.id)-row-1": [makeNote(blockId: "\(table.id)-row-1", content: "on row 1")],+        ])++        router.handle(.noteIndicatorTapped(+            blockID: "b-\(table.id)-0", rowOrdinal: nil, subID: "row-header", rect: rect()+        ))+        #expect(coordinator.notePopoverBlock?.id == table.id)+        #expect(coordinator.notePopoverNoteKey == "\(table.id)-row-header")+        #expect(notes.notesShownOnActivation(+            anchorId: coordinator.notePopoverNoteKey ?? ""+        ).map(\.content) == ["on the header"])++        router.handle(.noteIndicatorTapped(+            blockID: "b-\(table.id)-0", rowOrdinal: 1, subID: nil, rect: rect()+        ))+        #expect(coordinator.notePopoverBlock?.id == table.id)+        #expect(coordinator.notePopoverNoteKey == "\(table.id)-row-1")+        #expect(notes.notesShownOnActivation(+            anchorId: coordinator.notePopoverNoteKey ?? ""+        ).map(\.content) == ["on row 1"])+    }+     @Test("noteIndicatorTapped with a list-item subID targets the item sub-id (T-1745)")     func listItemIndicatorTapOpensItemPopover() {         let list = MarkdownBlock.list(ordered: false, start: 1, items: [@@ -278,6 +332,40 @@ struct WebDocumentMessageRouterTests {         #expect(coordinator.notePopoverListItemId == nil)     } +    /// The row channel resolves by MEMBERSHIP in the block's own `allTableRowIds()` — the+    /// same rule `listItem(forSubID:block:)` above and `tappedTableRowId` (the inline-note+    /// path) already follow — so an ordinal past the end of the table, or a row address on a+    /// block that is not a table, targets nothing rather than a fabricated anchor.+    ///+    /// Not reachable from the shipping page: `NoteStateFeeder` only ever emits ordinals it+    /// enumerated from this same block. It is pinned because the two neighbouring resolvers+    /// document string-building as the thing NOT to do for an inbound sub-address, and the+    /// row branch was the one that still did it (T-2044 review).+    @Test("A row address naming no real row targets nothing rather than a fabricated anchor")+    func unknownTableRowAddressIsIgnored() {+        let table = MarkdownBlock.table(headers: ["A"], rows: [["1"]], alignments: [.leading])+        let para = MarkdownBlock.paragraph(markdown: "x")+        let session = makeSession(blocks: [table, para])+        let coordinator = DocumentLayoutCoordinator()+        let router = WebDocumentMessageRouter(session: session, coordinator: coordinator)++        // An ordinal past the end of a real table: the membership walk refuses it.+        router.handle(.noteIndicatorTapped(+            blockID: "b-\(table.id)-0", rowOrdinal: 7, subID: nil, rect: rect()+        ))+        #expect(coordinator.notePopoverBlock?.id == table.id)+        #expect(coordinator.notePopoverTableRowId == nil)++        // A row address on a block that has no rows at all: the `case .table` guard. The+        // trailing component is the per-hash OCCURRENCE index, not the source index, so the+        // paragraph's only occurrence is `-0` even though it is the second block.+        router.handle(.noteIndicatorTapped(+            blockID: "b-\(para.id)-0", rowOrdinal: 0, subID: "row-header", rect: rect()+        ))+        #expect(coordinator.notePopoverBlock?.id == para.id)+        #expect(coordinator.notePopoverTableRowId == nil)+    }+     @Test("inlineNoteTapped opens the block popover/edit flow")     func inlineNoteTapOpensPopover() {         let para = MarkdownBlock.paragraph(markdown: "x")@@ -337,8 +425,10 @@ struct WebDocumentMessageRouterTests {         #expect(coordinator.notePopoverListItemId == nil)     } -    /// The header row rides the same channel: it has no JS row ordinal and no dot of its-    /// own (T-2044), so its bubble's only address is the note id it posts.+    /// The header row's BUBBLE still rides this channel even though it now has its own+    /// indicator dot (T-2044): a table's bubbles all live in one block-level host below+    /// the table regardless of which row they belong to, so the bubble's only address is+    /// the note id it posts.     @Test("inlineNoteTapped on a header-row note resolves the header row")     func inlineNoteTapResolvesTheHeaderRow() {         let table = MarkdownBlock.table(
prismTests/WebRendering/WebNoteAccessibilityTests.swift Modified +78 / -0
diff --git a/prismTests/WebRendering/WebNoteAccessibilityTests.swift b/prismTests/WebRendering/WebNoteAccessibilityTests.swiftindex 29087544..b79a8fbb 100644--- a/prismTests/WebRendering/WebNoteAccessibilityTests.swift+++ b/prismTests/WebRendering/WebNoteAccessibilityTests.swift@@ -668,6 +668,84 @@ struct WebNoteAccessibilityTests {         #expect(restored == "note-indicator|\(domID(para))||")     } +    // MARK: - Live: the header-row dot shares a cell with a COLUMN HEADER (T-2044)++    /// Contract point 1: the header row's dot is the same real control every other dot is.+    /// It is placed inside the header row's first `<th>` rather than as a child of the+    /// `<tr>` (a `<tr>` may only contain `<td>`/`<th>`), so it is worth pinning where it+    /// lands as well as what it is.+    @Test("The header-row dot is a named native button inside the header cell")+    func headerRowIndicatorIsNamedNativeButtonInTheHeaderCell() async throws {+        let table = tableBlock()+        let harness = try await harness([table])+        try await harness.send(.setNoteIndicators(+            json: "[{\"domID\":\"\(domID(table))\",\"subID\":\"row-header\",\"label\":\"INDICATOR=1\"}]"+        ))+        let shape = try await harness.evalString(+            "var th = document.querySelector('tr[data-prism-sub=\"row-header\"] th');"+                + " if (!th) { return 'missing-th'; }"+                + " var i = th.querySelector(':scope > [data-prism-note-indicator]');"+                + " if (!i) { return 'missing-dot'; }"+                + " return i.tagName + '|' + i.type + '|' + i.tabIndex"+                + "   + '|' + (i.getAttribute('aria-label') || '');"+        )+        #expect(shape == "BUTTON|button|0|INDICATOR=1")+    }++    /// Contract point 2, and the reason this pair exists: the header cell is a+    /// `<th scope="col">`, i.e. the element every body cell in that column resolves its+    /// column header against. A dot placed in a BODY row's `<td>` can only affect that one+    /// cell's own announcement (no `scope="row"` is ever emitted); a dot in the header cell+    /// is read once per row of the table if it reaches the header's accessible name.+    ///+    /// Under the accname algorithm as SPECIFIED it would: `<th>` names from content, and+    /// step 2C would fold the dot's `aria-label` ("Show 1 note") into that name. That is not+    /// what the engine Prism ships on does, for two independent reasons, both read out of+    /// WebCore rather than measured with VoiceOver (recorded in+    /// `specs/bugfixes/table-header-row-note-indicator/report.md`):+    /// `AccessibilityObject::dependsOnTextUnderElement()` does not list cell/columnheader,+    /// so no name-from-content is computed for a `<th>` at all; and where one IS computed,+    /// `shouldUseAccessibilityObjectInnerText` skips any child that+    /// `canSetFocusAttribute()` unless `TextUnderElementMode::includeFocusableContent`+    /// (default `false`, set only for headings and base-appearance select options).+    ///+    /// Both of those are UA behaviour this test cannot reach. What it CAN pin is the three+    /// page-side properties the conclusion rests on, each of which a plausible future edit+    /// would break:+    ///+    ///  - the dot contributes **no text node** to the cell, so any consumer that reads the+    ///    header by its text — rather than by running accname over its element children —+    ///    sees the author's header and nothing else. This is the engine-independent half.+    ///  - the dot stays **natively focusable** (a `<button>`, not the `<span role="button">`+    ///    T-1725 retired). Focusability is exactly the property WebKit's skip keys on: a+    ///    non-focusable `role="button"` span carrying the same `aria-label` WOULD fold in.+    ///  - the header cell carries **no naming override** of its own. `aria-label` on the+    ///    `<th>` was the reviewer's suggested pre-emptive fix and is deliberately not taken:+    ///    it would replace name-from-content, and a header cell holding an image would lose+    ///    that image's alt text — a real regression traded for a hazard the engine does not+    ///    have. `aria-hidden` on the dot is worse still: it would hide a live control.+    @Test("The header-row dot leaves its column header's own announcement intact")+    func headerRowIndicatorDoesNotAlterTheColumnHeaderName() async throws {+        let table = tableBlock()+        let harness = try await harness([table])+        try await harness.send(.setNoteIndicators(+            json: "[{\"domID\":\"\(domID(table))\",\"subID\":\"row-header\",\"label\":\"INDICATOR=1\"}]"+        ))+        let shape = try await harness.evalString(+            "var th = document.querySelector('tr[data-prism-sub=\"row-header\"] th');"+                + " if (!th) { return 'missing-th'; }"+                + " var i = th.querySelector('[data-prism-note-indicator]');"+                + " if (!i) { return 'missing-dot'; }"+                + " return 'text=' + th.textContent"+                + "   + '|override=' + (th.hasAttribute('aria-label')"+                + "       || th.hasAttribute('aria-labelledby'))"+                + "   + '|dotText=' + JSON.stringify(i.textContent)"+                + "   + '|focusable=' + (i.tabIndex >= 0 && !i.disabled)"+                + "   + '|ariaHidden=' + i.hasAttribute('aria-hidden');"+        )+        #expect(shape == "text=A|override=false|dotText=\"\"|focusable=true|ariaHidden=false")+    }+     // MARK: - Live: the per-item dots #345 added (merge reconciliation, T-1725 + T-1745)      /// Contract point 1. Every item dot on a list used to derive the SAME focus key
prismTests/WebRendering/WebNotesBehaviourTests.swift Modified +96 / -0
diff --git a/prismTests/WebRendering/WebNotesBehaviourTests.swift b/prismTests/WebRendering/WebNotesBehaviourTests.swiftindex a0c5f88b..80831271 100644--- a/prismTests/WebRendering/WebNotesBehaviourTests.swift+++ b/prismTests/WebRendering/WebNotesBehaviourTests.swift@@ -111,6 +111,102 @@ struct WebNotesBehaviourTests {         #expect((ordinal as? NSNumber)?.intValue == 0 || (ordinal as? Int) == 0)     } +    /// The header row has no ordinal, so its indicator rides `subID: "row-header"` — the+    /// same channel T-1745 opened for list items — rather than `rowOrdinal` (T-2044).+    /// `renderIndicators` must place the dot in the header row's own cell: a `<tr>` may+    /// only contain `<td>`/`<th>`, so routing this through the generic subID branch+    /// (which inserts at the container's own `firstChild`) would drop an invalid direct+    /// child into the `<tr>`.+    @Test("setNoteIndicators renders a header-row indicator in the header row (T-2044)")+    func headerRowIndicatorRenders() async throws {+        let table = tableBlock()+        let harness = try await harness([table])+        let json = "[{\"domID\":\"\(domID(table))\",\"subID\":\"row-header\"}]"+        try await harness.send(.setNoteIndicators(json: json))+        let rendered = try await harness.evalBool(+            "var s = document.getElementById('\(domID(table))');"+                + " var row = s ? s.querySelector('[data-prism-sub=\\\"row-header\\\"]') : null;"+                + " var cell = row ? row.querySelector('td, th') : null;"+                + " return cell ? !!cell.querySelector(':scope > [data-prism-note-indicator]') : false;"+        )+        #expect(rendered == true)+    }++    @Test("Tapping the header-row indicator posts noteIndicatorTapped with subID \"row-header\" (T-2044)")+    func headerRowIndicatorTapPosts() async throws {+        let table = tableBlock()+        let harness = try await harness([table])+        try await harness.send(+            .setNoteIndicators(json: "[{\"domID\":\"\(domID(table))\",\"subID\":\"row-header\"}]")+        )+        _ = try await harness.page.callJavaScript(+            "var i = document.querySelector('[data-prism-sub=\\\"row-header\\\"] [data-prism-note-indicator]');"+                + " if (i) { i.click(); } return null;",+            contentWorld: harness.bridgeWorld+        )+        let message = try await harness.waitForMessage(type: "noteIndicatorTapped")+        #expect(message?["blockID"] as? String == domID(table))+        #expect(message?["subID"] as? String == "row-header")+        #expect(message?["rowOrdinal"] == nil)+    }++    /// Coexistence in the page: one `setNoteIndicators` payload carrying BOTH a header-row+    /// entry (`subID: "row-header"`) and a body-row entry (`rowOrdinal`). `renderIndicators`+    /// takes a different branch for each but then shares the row-and-first-cell walk, so a+    /// lookup that fell through to the wrong row would put both dots in one cell and leave+    /// the other row bare — and the dots must carry their own identity back, or the second+    /// tap would open the first row's popover (T-2044).+    ///+    /// The counts are read back as one string rather than a bool so a failure says WHICH row+    /// went wrong instead of just "false".+    @Test("A header-row and a body-row indicator on one table render and post independently (T-2044)")+    func headerAndBodyRowIndicatorsCoexist() async throws {+        let table = tableBlock()+        let harness = try await harness([table])+        let json = "[{\"domID\":\"\(domID(table))\",\"subID\":\"row-header\"},"+            + "{\"domID\":\"\(domID(table))\",\"rowOrdinal\":1}]"+        try await harness.send(.setNoteIndicators(json: json))++        // One dot in the header row's own cell, one in row 1's, none in row 0's, none anywhere+        // else in the block — a dot inserted as a direct <tr> child would miss the cell counts+        // while still showing up in the block total.+        let placement = try await harness.evalString(+            "var s = document.getElementById('\(domID(table))');"+                + " if (!s) { return 'no section'; }"+                + " function dots(sub) {"+                + "   var r = s.querySelector('[data-prism-sub=\\\"' + sub + '\\\"]');"+                + "   if (!r) { return 'no row'; }"+                + "   var c = r.querySelector('td, th');"+                + "   if (!c) { return 'no cell'; }"+                + "   return String(c.querySelectorAll(':scope > [data-prism-note-indicator]').length);"+                + " }"+                + " return 'header=' + dots('row-header') + ' row1=' + dots('row-1')"+                + "   + ' row0=' + dots('row-0')"+                + "   + ' total=' + s.querySelectorAll('[data-prism-note-indicator]').length;"+        )+        #expect(placement == "header=1 row1=1 row0=0 total=2")++        _ = try await harness.page.callJavaScript(+            "var h = document.querySelector('[data-prism-sub=\\\"row-header\\\"] [data-prism-note-indicator]');"+                + " if (h) { h.click(); }"+                + " var b = document.querySelector('[data-prism-sub=\\\"row-1\\\"] [data-prism-note-indicator]');"+                + " if (b) { b.click(); } return null;",+            contentWorld: harness.bridgeWorld+        )++        let header = try await harness.waitForMessage(type: "noteIndicatorTapped") {+            ($0["subID"] as? String) == "row-header"+        }+        #expect(header?["blockID"] as? String == domID(table))+        #expect(header?["rowOrdinal"] == nil)++        let body = try await harness.waitForMessage(type: "noteIndicatorTapped") {+            ($0["rowOrdinal"] as? NSNumber)?.intValue == 1 || ($0["rowOrdinal"] as? Int) == 1+        }+        #expect(body?["blockID"] as? String == domID(table))+        #expect(body?["subID"] == nil)+    }+     // MARK: - Inline notes (Req 5.6)      @Test("setInlineNotes renders the supplied bubble HTML in the named block")
specs/bugfixes/table-header-row-note-indicator/report.md Added +283 / -0
diff --git a/specs/bugfixes/table-header-row-note-indicator/report.md b/specs/bugfixes/table-header-row-note-indicator/report.mdnew file mode 100644index 00000000..3701a80b--- /dev/null+++ b/specs/bugfixes/table-header-row-note-indicator/report.md@@ -0,0 +1,283 @@+# Bugfix Report: Table Header-Row Notes Have No Indicator++**Date:** 2026-09-07+**Status:** Fixed++## Description of the Issue++A note anchored to a markdown table's HEADER row rendered no indicator dot in the+rendered (WebKit) document path, even though the same note on a body row or a list+item drew one. The header row's note was still reachable through the inline note+bubble and the notes panel, but there was no way to discover or open it from the dot+gutter the way every other anchor works.++**Reproduction steps:**+1. Open a markdown document containing a table, with an existing note anchored to the+   table's header row (`{blockId}-row-header`).+2. Render the document.+3. Observe: no indicator dot appears on the header row. Body rows with notes, and list+   items with notes, do show their dot.++**Impact:** Low-medium severity, narrow scope — table header-row notes only. The note+itself was never lost (it round-trips and exports correctly, and is visible via the+inline bubble and the Notes panel); only the row-level discovery affordance was+missing, which is an accessibility/discoverability gap.++**Requirements closed, and the half that is not.** Against+`specs/table-row-notes/requirements.md`, this fix closes the READING half: Req 2.1+("every table row — header and body" shows an indicator when it has notes) and Req 2.6+(tapping it opens that row's own popover). The AUTHORING half stays open and is+deliberately out of scope, so "Req 2.1/2.6" should not be read as closing the feature+for the header row. `subTargetOf` in `prism-notes.js` still refuses `"row-header"`, so+the header row gets no add-note "+" — and because a table whose body rows carry per-row+affordances gets no block-level "+" either, there is no "+" anywhere on the table for it+— while a right-click / long-press on the header row posts `blockContextRequested` with+no sub-target and falls back to a BLOCK-level note, which Req 1.3 forbids outright+("SHALL NOT allow notes to be created on the table block itself — only on individual+rows"). After this change a header-row note can therefore be read but still not written+by gesture; it arrives only by import or through the selection path. That gap is filed+as **T-2318**, raised while fixing this one.++## Investigation Summary++Transit ticket T-2044 already contained a precise root-cause writeup (referencing+T-1725 / PR #346) and a regression test that PINNED the gap:+`NoteStateFeederTests.headerRowIndicator()` asserted the indicators JSON was EMPTY for+a header-row note, with a comment explaining that the dot had been removed rather than+left lying about what it opened.++- **Symptoms examined:** `NoteStateFeeder.indicatorsJSON` walks a table's body rows+  (`activeTableRowOrdinals`) but had no branch for the header row at all.+- **Code inspected:** `NoteStateFeeder.swift` (indicator emission),+  `WebDocumentMessageRouter.handleNoteIndicatorTap` (tap routing),+  `prism-notes.js` (`renderIndicators`, `indicatorSuccessor`), `WebBridgeContract.swift`+  (message shape), `BridgeMessageRouter.swift` (decode), `BlockHTMLEmitter.swift`+  (confirms `<tr data-prism-sub="row-header">` with `<th>` cells).+- **Hypotheses tested:** whether the header row could reuse `rowOrdinal` (rejected —+  the header has no ordinal, and forcing one, e.g. `-1`, would be an implicit,+  undocumented sentinel rather than an explicit address) versus reusing the `subID`+  channel T-1745 already opened for list items (chosen — see Resolution).++## Discovered Root Cause++**Defect type:** Missing feature path (an anchor kind with no addressing scheme),+introduced as a deliberate simplification when T-1725 gave note indicators a spoken+accessible-name count.++**Why it occurred:** Before T-1725, a header-row note quietly folded into the table's+block-level indicator. `WebDocumentMessageRouter.handleNoteIndicatorTap` only sets+`notePopoverTableRowId` when a `rowOrdinal` is present — a header row, having none,+always nils it — so the folded dot opened a popover scoped to the block, one that could+never contain the header row's own note. That was a silent misdirection; once T-1725+gave the dot a spoken "Show N notes" label, the same fold became a stated falsehood to+a screen-reader user (announcing a count the tap could not reveal). T-1725 removed the+fold rather than ship the false announcement, leaving header-row notes with no dot at+all until this ticket gave them their own addressing.++**Contributing factors:** Table rows are two different DOM shapes from the addressing+system's point of view — an ordinal-addressable body row, and an ordinal-less header+row — and `NoteStateFeeder`/`WebDocumentMessageRouter`/`prism-notes.js` only had a path+for the first shape.++## Resolution for the Issue++**Changes made:**+- `prism/Services/WebRendering/NoteStateFeeder.swift` — `indicatorsJSON` now emits an+  indicator for the header row anchor (`{blockId}-row-header`) addressed by+  `subID: "row-header"` (reusing the T-1745 `subID` channel rather than inventing a+  second qualifier), alongside the existing per-ordinal body-row indicators. Doc+  comments updated to describe the new addressing instead of the removed fold.+- `prism/ViewModels/WebDocumentMessageRouter.swift` — `handleNoteIndicatorTap` now+  special-cases `(rowOrdinal: nil, subID: "row-header")` to set+  `notePopoverTableRowId = "{blockId}-row-header"`, alongside the existing+  ordinal-driven and list-item branches.+- `prism/Resources/WebRenderer/prism-notes.js` — `renderIndicators` treats a+  `subID === "row-header"` entry as a row case (like `rowOrdinal`), placing the dot+  inside the row's first `<td>`/`<th>` cell rather than routing it through the generic+  subID branch, which inserts at the container's own `firstChild` — invalid for a+  `<tr>`, which may only contain `<td>`/`<th>`. `indicatorSuccessor` was given the same+  row-vs-item distinction for consistency (currently a no-op in practice, since header+  rows have no add-note "+" affordance to hand focus to, but avoids a landmine should+  one be added later).+- `docs/agent-notes/webview-rendering-status.md` — updated the one note describing the+  header row's missing dot as a still-open gap.++**Approach rationale:** Reusing the existing `subID` channel (rather than adding a+`headerRow: Bool` field or a sentinel ordinal) keeps the wire contract's shape stable —+`noteIndicatorTapped` already carries an untyped `subID: String?` that+`BridgeMessageRouter` decodes with no allowlist of values, so no contract change was+needed on the JS→native leg. The ticket's own "Shape of the work" section pointed at+exactly this reuse.++**Alternatives considered:**+- **A dedicated `isHeaderRow: Bool` field on the indicator payload / message** — Explicit,+  but adds a new field to a wire contract that already has a general-purpose `subID`+  slot doing the same job for list items; rejected as unnecessary surface area.+- **A sentinel `rowOrdinal` (e.g. `-1`) for the header row** — Rejected: an implicit,+  undocumented magic number is exactly the kind of anchor ambiguity the codebase's+  notes pipeline goes out of its way to avoid elsewhere (see `MarkdownBlock.allTableRowIds()`'s+  explicit `-row-header` spelling).++## Accessibility Side Effect: The Dot Lives Inside a `<th scope="col">`++Raised in pre-push review as "requires discussion", and settled here.++**The concern.** The header row's first cell is a `<th scope="col">` — the element every+body cell in column 1 resolves its column header against. The dot is inserted as that+cell's first child. Under the accname algorithm as SPECIFIED, a `<th>` names from+content (step 2F), the recursion reaches the dot, and step 2C picks up its `aria-label`+("Show 1 note"). If that holds, the whole column's header announcement is polluted for+as long as a header-row note exists — read once per row, not once per table. Body rows+do not have this property: `scope="row"` is never emitted, so a dot in a `<td>` can only+affect that one cell's own announcement, which is where the control actually is.++**What was established, and how.** Not measured with VoiceOver. Read out of the WebKit+source — `Source/WebCore/accessibility/AccessibilityNodeObject.cpp` and+`AccessibilityObject.cpp` on `main` (verified present unchanged in a mid-2024 revision,+so it is long-standing behaviour rather than something recent). Two independent findings,+either of which alone defeats the mechanism:++1. `AccessibilityObject::dependsOnTextUnderElement()` enumerates the roles that take a+   name from their content — button, checkbox, menu item, tab, heading, link, and so on.+   Cell / columnheader is not among them, so `visibleText()` computes no+   name-from-content for a `<th>` at all.+2. Where a name-from-content IS computed, `shouldUseAccessibilityObjectInnerText`+   contains, verbatim: `// Skip focusable children, so we don't include the text of links+   and controls.` / `if (object.canSetFocusAttribute() && !mode.includeFocusableContent)+   return false;`. `TextUnderElementMode::includeFocusableContent` defaults to `false`+   and `visibleText()` raises it only for headings and base-appearance select options.+   A `<button>` child is therefore skipped before its `aria-label` is ever consulted.++Point 2 is a deliberate UA heuristic that DEVIATES from the accname spec — Chrome and+Firefox would fold the label in — so the reviewer's reading of the spec was correct; it+simply is not what the only engine Prism ships on does. Honest limits: this is a source+read of the current WebKit tree, not an observation of VoiceOver on iOS 26 / macOS 26,+and it says nothing about a future WebKit that aligns with the spec.++**Decision: change nothing in the emitted markup.** The suggested cheap fix — give the+`<th>` an explicit `aria-label` carrying its own cell text — is rejected, and not only+as unnecessary. `aria-label` REPLACES name-from-content, so a header cell containing an+image would lose that image's alt text, and any inline structure would be flattened to+whatever plain-text projection produced the label. That trades a real regression for a+hazard the engine does not have. (`aria-hidden` on the dot was never a candidate: it+would hide a live control.) The two shapes that would have avoided the loss — an+`aria-labelledby` wrapper `<span>` around every header cell's content, or moving the dot+out of the cell — both change emitted HTML that the source map and the inline-run+structure are built over, which is not a trade worth making on a bugfix branch for a+non-defect.++**What is pinned instead.** `WebNoteAccessibilityTests` gains+`headerRowIndicatorDoesNotAlterTheColumnHeaderName`, asserting the three page-side+properties the conclusion rests on, each of which a plausible future edit would break:++- the dot contributes **no text node** to the cell (`th.textContent` is still exactly the+  author's header), so any consumer reading the header as text — rather than running+  accname over its element children — is unaffected. This half holds in any engine.+- the dot stays **natively focusable** (a `<button>`, not the `<span role="button">`+  T-1725 retired). Focusability is precisely what finding 2 keys on: a non-focusable+  `role="button"` span carrying the same `aria-label` WOULD fold in. The T-1725 decision+  to use real controls is load-bearing for this, which was not previously written down.+- the header cell carries **no naming override**, so a later well-meaning `aria-label`+  has to argue with this test rather than land quietly.++## Regression Test++**Test files / names:**+- `prismTests/WebRendering/NoteStateFeederTests.swift` — `headerRowIndicator()`+  (re-purposed from asserting emptiness to asserting the new indicator + `subID`).+- `prismTests/WebRendering/WebDocumentMessageRouterTests.swift` —+  `headerRowIndicatorTapOpensHeaderRowPopover()` (new).+- `prismTests/WebRendering/WebNotesBehaviourTests.swift` — `headerRowIndicatorRenders()`+  and `headerRowIndicatorTapPosts()` (new, live-WebKit, exercise the real+  `prism-notes.js` DOM insertion and click routing end to end).++Coexistence of the two row channels on one table (commit `86ec8a84`), one per layer:+- `NoteStateFeederTests.headerAndBodyRowIndicatorsCoexist()`+- `WebDocumentMessageRouterTests.headerAndBodyRowTapsResolveIndependently()`+- `WebNotesBehaviourTests.headerAndBodyRowIndicatorsCoexist()`++Added in review follow-up:+- `WebDocumentMessageRouterTests.unknownTableRowAddressIsIgnored()` — a row address+  naming no real row, and a row address on a non-table block, target nothing.+- `WebNoteAccessibilityTests.headerRowIndicatorIsNamedNativeButtonInTheHeaderCell()` and+  `headerRowIndicatorDoesNotAlterTheColumnHeaderName()` — see the accessibility section+  above.++**What they verify:** that a header-row note produces an indicator entry addressed by+`subID: "row-header"`; that native resolves that entry back to the header row's own+note-popover anchor, and only ever to a row the block really has; that the live page+renders the dot inside the header row's own cell and posts the correct message when+tapped; that a header-row and a body-row note on one table stay independent through+every layer; and that the dot adds no text and no naming override to the column header+cell it shares.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+  -destination 'platform=macOS' -configuration Debug -derivedDataPath ./DerivedData \+  -resultBundlePath ./DerivedData/t.xcresult -testPlan prism -only-test-configuration "en (base)" \+  -parallel-testing-worker-count 1 -enableCodeCoverage NO \+  -only-testing:prismTests/NoteStateFeederTests \+  -only-testing:prismTests/WebDocumentMessageRouterTests \+  -only-testing:prismTests/WebNotesBehaviourTests+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/WebRendering/NoteStateFeeder.swift` | Emit a header-row indicator addressed by `subID: "row-header"`; updated doc comments |+| `prism/ViewModels/WebDocumentMessageRouter.swift` | Resolve a row address (ordinal or `"row-header"`) to the header/body row's note-popover anchor, by membership in the block's own `allTableRowIds()` |+| `prism/Resources/WebRenderer/prism-notes.js` | Place the header-row dot in the row's own cell via the shared `subElement()` helper; keep `indicatorSuccessor` consistent |+| `prismTests/WebRendering/NoteStateFeederTests.swift` | Re-purposed `headerRowIndicator()` to assert the new behaviour; coexistence test |+| `prismTests/WebRendering/WebDocumentMessageRouterTests.swift` | New tap-routing, coexistence and unknown-row-address tests; updated a stale doc comment |+| `prismTests/WebRendering/WebNotesBehaviourTests.swift` | New live-WebKit render + tap tests; coexistence test |+| `prismTests/WebRendering/WebNoteAccessibilityTests.swift` | New live-WebKit tests pinning the header-cell dot's control shape and the column header's unchanged text |+| `docs/agent-notes/webview-rendering-status.md` | Updated note describing the (now closed) gap |+| `CHANGELOG.md` | `[Unreleased] / Fixed` entry |++## Verification++**Automated:**+- [x] Regression tests pass (`NoteStateFeederTests`, `WebDocumentMessageRouterTests`,+      `WebNotesBehaviourTests` — 75 tests total across the two foreground runs)+- [ ] Full test suite (`make test-quick` / `make test-locales`) — intentionally NOT run+      per this task's validation rules (other sibling agents/sweeps were running on+      the same machine); the targeted suites above cover every file this change+      touches.+- [x] `make lint` passes (0 violations)+- [x] `make build-macos` passes+- [x] `make verify-test-isolation` passes (new live-WebKit tests are `async` in the+      existing `@Suite(.liveWebKit) @MainActor` suite)++**Manual verification:** Not performed (no simulator/device session run as part of+this fix); relied on the live-WebKit tests, which drive the real `prism-notes.js`+against a real `WebPage`.++## Prevention++**Recommendations to avoid similar bugs:**+- When an anchor kind gains a UI-facing feature (a spoken count, a new gesture),+  audit every OTHER anchor kind that shares the same code path for the same+  requirement before shipping — T-1725's own list-item work found this gap in the+  first place; the header row was the one shape it could not close in the same pass.+- Prefer reusing an existing general-purpose addressing channel (`subID`) over adding+  a parallel boolean/sentinel for a new anchor shape; it keeps the wire contract's+  surface area from growing linearly with anchor kinds.++## Related++- Transit T-2044 (this ticket); references T-1725 (PR #346, removed the incorrect+  fold) and T-1745 (PR #345, opened the `subID` channel this fix reuses).+- Related, explicitly NOT addressed here: T-2045 (indicators/bubbles ignore+  `headingPath`, over-counting duplicate blocks) — lives in the same file+  (`NoteStateFeeder.makeIndicator`) but is a different axis of the same pre-existing+  gap, called out in the ticket as out of scope.+- **T-2318** — a header row can now SHOW a note indicator but still cannot be GIVEN a+  note by gesture (`subTargetOf` refuses `"row-header"`, so there is no "+" and a context+  gesture falls back to a block-level note, which Req 1.3 forbids). Filed while fixing+  this ticket; see "Requirements closed, and the half that is not" above.+- `specs/table-row-notes/requirements.md` Req 2.1 ("every table row — header and+  body") and Req 2.6 ("tapping the row indicator opens the existing note popover for+  that row") — the reading half, closed here. Req 1.3 (no notes on the table block+  itself) — still violated for the header row's authoring path, via T-2318.
CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8b947fb7..9aa2e726 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- A note anchored to a table's header row now draws its own indicator dot on the header row, instead of none at all (T-2044). T-1725 had removed the header row's indicator deliberately rather than leave it wrong: with no ordinal to address it by, a header-row note used to fold into the table's block-level dot, which opens a popover scoped to the block and so could never contain the header row's own note — and once that dot started announcing its count out loud, it was announcing a note the tap could not show. The header row now gets an indicator of its own, addressed by `subID: "row-header"` — the same channel T-1745 opened for list items — rather than a second one; `WebDocumentMessageRouter` resolves that exact string back to the header row's anchor, and `prism-notes.js` places the dot inside the header row's own cell, since a `<tr>` may only contain `<td>`/`<th>` and the list-item placement path it reuses cannot be used verbatim. Header-row notes were always visible through the inline note bubble and the notes panel; only the row's own indicator dot was missing. This is the READING half only: a header row still cannot be given a note by long-press or right-click (that gesture falls back to a note on the whole table), so a header-row note continues to arrive by import or through the text-selection path. That is T-2318.+ - Tapping a note in the compact Notes panel or the regular Notes sidebar now scrolls to the block that note is actually anchored to, instead of always the first occurrence of identical content elsewhere in the document (T-1929). Both note UIs navigated by passing the note's bare content-hash block id, which `BlockDOMID` resolves to its first (or first-visible) occurrence by design — the note's own stored heading path, already recorded for note storage/display disambiguation (T-209), was never consulted. Navigation now resolves the note's specific occurrence against that heading path and builds the same verified composite target TOC/search/scroll-restore already use, falling back to the previous first-occurrence behaviour only when a note carries no heading path (legacy notes) or its heading path no longer matches any occurrence (the block was relocated). A table-row or list-item note anchor resolves to its parent block's correct occurrence, since rows and items are not independently scrollable. - An open document now follows its file when you rename or move it in Finder, the Files app, or another file provider (T-1881). The app watches the open file through a file presenter, and that presenter never implemented the half of its contract that deals with the file moving: it kept pointing at where the file used to be, and so did the document. Reload then failed on a file that is perfectly readable, the title kept the old name, images beside the document were looked for in the old folder, and the notes were left filed under a path nothing would ever look up again. The presenter now retargets itself the moment it is told the file moved, and the document follows it — which is what carries the title, the Save destination, Reload, the remembered reading position and the page's image resolution across the move, since all of them are derived from the one property. The notes move with the document, and the record left at the old path is retired rather than stranded there. Moving them is its own operation rather than a reuse of the one Save As runs, because three things a Save As can take for granted are not true of a rename, and each of them lost notes: a renamed file's notes may not be loaded yet (a rename arriving before you have opened the notes pane, or before the document has finished loading, used to be reported as migrated while nothing moved); an old and a new name can be the same file, so renaming `readme.md` to `README.md` used to write the notes and then delete the file it had just written, taking every note with it; and the document is already at its new name by the time the app is told, so a note written in the instant the rename lands used to clear every note already on the document. Trashing an open document is no longer mistaken for a rename either — it arrives as one, measurably, so the notes would have been rewritten under a path inside the Trash and the ones at the real path deleted; the document now stays where it was, which is where Put Back returns it and where its notes are waiting. Two things deliberately do not move: the Recent Files entry still names the path you opened from (that is T-1842 / T-2172 / T-2173), and the security-scoped access the session holds is left alone — it was granted for the file itself and survives the rename, whereas releasing it is the one way to actually lose access to the file. Two files moved in quick succession — a rename followed by a drag into another folder, or an iCloud reorganisation — are followed all the way, with the notes taken from where they actually are rather than from the intermediate location the document only passed through; before this they were left behind at the original path while everything reported success. And if the notes cannot be written to their new location, you are now told so, with the same alert Save As raises, instead of the notes quietly ceasing to be the document's. - `HTMLImageSourceRewriter` no longer re-emits a mediated `src`/`srcset` value with an embedded quote character left unescaped (T-1942). The rewriter always re-emits these attributes double-quoted, but a value could still contain a raw `"`: the `rewrite` closure's `data:` passthrough hands unencoded `data:` URIs back unchanged, and `rewriteSrcset` re-joins a candidate's descriptor half — never passed through `rewrite` — verbatim. Either one could terminate the re-emitted attribute early, splicing the remainder into attribute position (e.g. `srcset='a.png 1x" onerror=… z='`). The value is now escaped for its double-quoted context before being written, so any quote characters it carries round-trip intact instead of breaking out. The escape leaves a character reference the value already carries alone (the `data:` passthrough re-emits the attribute's text as written, references undecoded, and the browser decodes it once), so an SVG `data:` URI whose author correctly wrote `&amp;amp;` for a literal ampersand still renders that ampersand rather than the reference. This is defence in depth rather than a live exploit: the subsequent `HTMLSanitizer` (SwiftSoup) pass is the actual security boundary and already reduced the broken-out remainder to non-allowlisted junk.
docs/agent-notes/webview-rendering-status.md Modified +2 / -2
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex a03d566a..d3d25a5e 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -248,9 +248,9 @@ take down unrelated applications' web content. Always PID-verify, never pattern- - **`xcodebuild test` hangs** in post-test xcresult finalization (it builds prismUITests). Use `build-for-testing` then `test-without-building` with `NSUnbufferedIO=YES`; the process **exit code is authoritative**. Run targeted classes with `-only-testing:prismTests/<Class>` to avoid the hang. - **iOS WKWebView selection**: long-press triggers native text selection (not `contextmenu`), and the native selection callout renders above all web content → use native SwiftUI overlays / visible tap targets, not in-page pills or long-press gestures. - **Note flow is native-as-truth**: JS posts `selectionCandidate` / `noteIndicatorTapped` / `inlineNoteTapped` / `blockContextRequested` / `linkActivated` → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` state → SwiftUI sheets/popovers. `NotesManager` owns the notes; the web view only renders + reports. In-page affordances that open native UI route via `linkActivated` + a `prism://…` URL (footnote, `document-note/add`, `image-access/grant`); the Swift route literals live in `PrismLinkRoute`.-- **A sub-anchored note must be ADDRESSED at its sub-element, not at its block** (T-1745). `NoteStateFeeder` carries a `subID` on both the indicator and the bubble payload entries; it is the anchor id minus the block-hash prefix, which is byte-identical to the `data-prism-sub` the emitter wrote, because both come from `MarkdownBlock.allListItemIds()`. Collapsing a list-item note to the block was not a cosmetic shortcut: prism-notes.js drops a block dot at `section.firstChild` (the gutter beside the FIRST item) and used to suppress the nearest `li > [data-prism-add-note]`, so a note on item 2 both rendered after the whole list and turned item 1's `+` into a dot. `li` and `td` are NOT symmetric here — a bubble inside a `<td>` would deform the table, so table-row notes keep `rowOrdinal` addressing for the dot and stay in the block-level bubble host. The `+` and the dot read one `--prism-item-gutter` per list level (document.css) so they can never drift out of the same column.+- **A sub-anchored note must be ADDRESSED at its sub-element, not at its block** (T-1745). `NoteStateFeeder` carries a `subID` on both the indicator and the bubble payload entries; it is the anchor id minus the block-hash prefix, which is byte-identical to the `data-prism-sub` the emitter wrote, because both come from `MarkdownBlock.allListItemIds()`. Collapsing a list-item note to the block was not a cosmetic shortcut: prism-notes.js drops a block dot at `section.firstChild` (the gutter beside the FIRST item) and used to suppress the nearest `li > [data-prism-add-note]`, so a note on item 2 both rendered after the whole list and turned item 1's `+` into a dot. `li` and `td` are NOT symmetric here — a bubble inside a `<td>` would deform the table, so every table-row note (body AND header) keeps its BUBBLE in the block-level host, even though each row draws its own dot: the body row's dot is addressed by `rowOrdinal`, the header row's by `subID: "row-header"` (T-2044, see the bullet below). The `+` and the dot read one `--prism-item-gutter` per list level (document.css) so they can never drift out of the same column. - **The whole-list note is the case that still shares a column with item chrome** (T-1745). It is legitimately addressed at the block, so its dot goes in the section gutter — where a top-level item's `+` chip also lives (`-1.775em … -0.595em` from the section; the chip is `1.25em × font-size 0.85em` = **1.0625em** plus 2px of border, not 1.25em). `suppressAddNote` therefore takes the direct-child `+` **only**: a container that gets a dot may hide its own `+`, never a descendant's, because those are different note anchors. The dot moves out of the way instead — prism-notes.js tags it `data-prism-beside-items` when the section has direct `ul`/`ol` children with per-item subs, and document.css shifts it to `-2.5em`. The gutter is finite (`main` padding 1.7em + `body` padding 1em = 2.7em, then `body { overflow-x: hidden }` clips), so a further column is not free. `DocumentCSSNoteGutterRulesTests` does that arithmetic against the sheet — the live WebPage harness does **not** apply document.css, so a rect-based assertion there passes vacuously (I wrote one before noticing; every element came back at UA defaults).-- **An inline-note tap needs BOTH routing channels, and neither is redundant** (T-1725 + T-1745 merge). `WebDocumentMessageRouter.handleInlineNoteTap` takes a `subID` *and* a `noteID`, and it looks like one of them could go. It cannot. A list-item note is rendered in a host **inside** its `<li>`, so the DOM itself says which anchor the bubble belongs to and `prism-notes.js` posts that item's `data-prism-sub` — structural, exact, and independent of note state. A table's notes have no such host: a bubble inside a `<td>` would deform the table, so every body-row AND header-row bubble lives in the one block-level host below the table, and the DOM can only name the block. There the tapped note's own id is the sole address, resolved by searching the block's own `allTableRowIds()` (never string-built, so a forged id targets nothing). Keeping only the structural channel silently opens the table's own note list for a row note; keeping only the id channel silently opens the list's for an item note. Same rule one level up: **every dot names exactly the anchor its own tap opens** — which is why a table header-row note draws no block dot at all (a block-level tap nils `notePopoverTableRowId`, so the dot would open a popover that cannot contain it). Its own dot is T-2044. `notesShownOnActivation` is the one lookup both the dot's count and the popover's list read, but not yet with the same arguments — the feeder passes no `headingPath` while `WebNotePopoverView` does (T-2045); do not add new anchors assuming that gap is closed.+- **An inline-note tap needs BOTH routing channels, and neither is redundant** (T-1725 + T-1745 merge). `WebDocumentMessageRouter.handleInlineNoteTap` takes a `subID` *and* a `noteID`, and it looks like one of them could go. It cannot. A list-item note is rendered in a host **inside** its `<li>`, so the DOM itself says which anchor the bubble belongs to and `prism-notes.js` posts that item's `data-prism-sub` — structural, exact, and independent of note state. A table's notes have no such host: a bubble inside a `<td>` would deform the table, so every body-row AND header-row bubble lives in the one block-level host below the table, and the DOM can only name the block. There the tapped note's own id is the sole address, resolved by searching the block's own `allTableRowIds()` (never string-built, so a forged id targets nothing). Keeping only the structural channel silently opens the table's own note list for a row note; keeping only the id channel silently opens the list's for an item note. Same rule one level up: **every dot names exactly the anchor its own tap opens** — a table header-row note used to draw no dot at all, because a block-level tap nils `notePopoverTableRowId` and would have opened a popover that could not contain it. T-2044 gave it one: the header row has no `rowOrdinal`, so `NoteStateFeeder` addresses it with `subID: "row-header"` — the same channel T-1745 opened for list items — and `WebDocumentMessageRouter.handleNoteIndicatorTap` special-cases that exact string back to `{blockId}-row-header`. `prism-notes.js` cannot route it through the generic subID branch (`subElement` + `insertBefore` at the container's first child) the way a list item is: a `<tr>` may only contain `<td>`/`<th>`, so `renderIndicators` treats `subID === "row-header"` as a row case alongside `rowOrdinal`, inserting into the row's first cell exactly like a body-row dot. `notesShownOnActivation` is the one lookup both the dot's count and the popover's list read, but not yet with the same arguments — the feeder passes no `headingPath` while `WebNotePopoverView` does (T-2045); do not add new anchors assuming that gap is closed. - **Sub ids under `<details>` are ONE-WAY** (T-1745, T-2032). `BlockHTMLEmitter.emitDetails` writes `data-prism-sub="item-N"` on the list children of a `<details>`, but the enclosing section is the `.details` block and `allListItemIds()` returns nothing for `.details` — so those attributes have no native counterpart, and two lists under one `<details>` emit `item-0` twice into one section. Both directions fail closed today and must stay that way: the feeder enumerates sub ids only for a `.list` block (so nothing ever reaches `subElement`'s first-match `querySelector`), and the router resolves a tapped sub id by membership in `allListItemIds()` (so a `+` in there falls back to a block-level note). Do not "fix" either side by deriving an anchor from the attribute string alone; the address is genuinely ambiguous. - **The native selection affordance is PAGE-scoped state living natively, so it must be cleared on the navigation path, not by a message** (T-1852). `WebSelectionAffordanceState` is written only from inbound `selectionCandidate`, so before the fix a reload left the "Add Note" button floating at the outgoing page's rect with its block id + range. The obvious fix — have the outgoing page post `cleared` — CANNOT work and looks like it should: `load` assigns the new `parseRevision` *before* navigating, so `BridgeMessageRouter`'s exact-generation match drops anything the old page says afterwards. The clear lives in `WebDocumentController.resetForNavigation()`, the one choke point `load` and `handleProcessTermination` share (so it covers re-parse reloads, the same-revision iOS folder-access retry, and WebContent recovery). It is deliberately NOT in `WebDocumentStateSnapshot`: that snapshot is *replayable* native truth, and a selection is the user pointing at one rendered page — replaying it after a crash recovery is the same bug wearing a different hat. A clear alone is not enough, because clearing does not stop the OUTGOING page from re-arming: on a same-revision reload (the iOS folder-access retry) the old page stays interactive and generation-matched for the whole load, so a selection made *during* the load was accepted and the stale overlay came straight back until the new page's scripts ran. `WebDocumentController.handle` therefore also drops `selectionCandidate` while `!isReady` — `resetForNavigation` lowers `isReady` synchronously and only the INCOMING page's `ready` raises it, so clear + gate close the window rather than narrowing it. Nothing legitimate is lost: `ready` is posted from a timer prism-bridge.js registers before prism-notes.js runs (script order in `WebDocumentControllerFactory.userScripts()`, all `.atDocumentEnd`, equal-delay timers fire in registration order), so it always precedes the fresh page's own `cleared`. Defence in depth: each fresh page posts that initial `cleared` from `prism-notes.js`, deferred one timer turn (same reason as bridge.js's `ready` — the handler channel is not live at injection) and guarded on `lastCandidateKey === null`, i.e. "seed the dedup key if nothing has reported yet" — unconditional, it wipes a candidate that resolved first. It passes the allowlist because it is the INCOMING page's post, stamped with the per-serve generation island `WebDocumentControllerFactory.emitHTML` injects from the controller's live values. Consequence for live tests: "the first `selectionCandidate`" is no longer "the candidate under test" — use `WebDocumentLiveHarness.waitForSelectionCandidate(state:)`; and `make(injectedScriptSource:)` injects a script between bridge.js and the feature scripts, the only deterministic way to reach the window before notes.js's deferred timer. - **The selection affordance rect is viewport-relative, so it is a function of the selection AND the scroll offset** (T-1878). `prism-notes.js` used to compute it only inside the `selectionchange` listener, and a scroll fires no `selectionchange` — so the rect went stale and, because `WebSelectionOverlayGeometry.point` clamps into the view, the button stuck to a window edge instead of leaving with the text. The fix is CONTINUOUS REPOSITION: a throttled scroll listener in prism-notes.js re-runs `handleSelectionChange`, and the native overlay hides while the refreshed rect misses the viewport (`WebSelectionOverlayGeometry.isVisible`, extracted out of `WebDocumentView` to be testable). Clear-on-scroll was rejected and is the tempting wrong answer: nothing would ever re-arm the affordance, because a scroll fires no `selectionchange` — the user would have to destroy and remake a selection that never stopped being live. The listener is REFRESH-ONLY (it returns unless the page has already reported `available`, re-checked inside the timer), which is what keeps it compatible with T-1852 above: a scroll cannot resurrect what `resetForNavigation` cleared, and the `!isReady` gate still owns the navigation window. `available` ONLY, never `declined`: native draws nothing for a cross-block selection (`canAddNote` is false), so arming the refresh for it costs ~60 no-op bridge messages/sec during a fling — and nothing is lost, because eligibility is a function of the selection's DOM endpoints and never of the scroll offset, so declined→available always goes through a genuine `selectionchange`. It uses a timer, not rAF (rAF may never fire for the inert offscreen harness page), and the capture phase, so a selection inside a self-scrolling wide table or code block refreshes too. The throttle is a LEADING GUARD WITH A TRAILING FIRE — deliberately the opposite of the debounces in prism-scroll.js/prism-search.js: a debounce would strand the button mid-fling, a leading-edge throttle would strand it wherever the window's first event landed; reading the position at fire time is what makes it converge. It reads scroll events and owns no scroll (T-1918), and is deliberately NOT suppressed during a programmatic scroll — a TOC/search jump moves the selected text as well. `resize` shares the listener (same guard, same throttle) because its failure mode is WORSE than the scroll one: after a scroll the next scroll event repairs the rect, whereas after a rotation nothing necessarily follows. The same argument pulls in the two NATIVE-PUSH reflows that fire no DOM event at all and are reachable from the toolbar with no page interaction (so the selection survives them): `setInlineNotes` refreshes from the end of `renderInlineNotes` (the top banner is inserted before the first block and shifts the whole document), and `setSectionState` refreshes off `bridge.onSectionVisibilityChanged` — the hook T-1944 already added for prism-search.js, reused rather than duplicated. Collapsing the selection's OWN section needs no special case: the hidden subtree reports a zero rect, which `isVisible` already rejects. Still not covered, and fair: a typography / Dynamic Type reflow moves the text with no event of its own AND no hook to hang off — `applyTypography` writes CSS variables whose reflow lands asynchronously, so the command handler returning is not the moment the text has moved; repairing it needs prism-theme.js to notify after the variables have taken effect. Two halves make the reposition honest rather than merely mobile. (1) The posted rect is INTERSECTED with every clipping ancestor (`clipRectToAncestors`): native only knows the viewport, so without it a cell scrolled sideways out of a `.prism-table-wrap` still looked visible; an empty intersection arrives as a zero-sized rect, which `isVisible` rejects — that also kills the `display:none` 0,0,0,0 rect that used to park the button at the top-left corner. (2) `WebSelectionOverlayGeometry.point` FLIPS the capsule below the selection when there is no room above, because the clamp otherwise left a ~64pt band at the top of the view where every selection drew the button at the same pinned y — the reported defect in miniature — and BOTH decisions are hysteretic: `isVisible` (appearing needs ~6pt of vertical overlap, staying needs any) so a scroll jittering by a pixel across the edge cannot blink a stationary button, and `flipsBelow` (flipping down needs the room above to run out at rect.y 44, flipping back up needs it back with 6pt to spare) because the flip moves the capsule ~72pt and a hard threshold there would hop it further than the blink the first band prevents. Both states are the view's (`@State overlayIsVisible` / `overlayFlippedBelow`, fed back as the `wasVisible` / `wasFlippedBelow` arguments); both rules are monotone in that argument and therefore idempotent, so the feedback settles in one step — swept at all three boundaries, predicates and composed pipeline, in WebSelectionOverlayGeometryTests. The clamp survives and IS reachable in the drawn path, in two cases only: a viewport too small to hold the capsule, and a selection taller than the viewport (one drag down a tall table's cells), which pins to the bottom edge over text filling the screen — not the detached pin T-1878 removes. Finally, `WebDocumentController.clearSelection` calls `bridge.refreshSelectionCandidate()` in the same JS call as `removeAllRanges()`: `selectionchange` is async, so until it landed the page still believed an affordance was on screen and a scroll in that window re-armed the button the user had just dismissed by tapping it. That call is a Swift string literal reaching for a JS property behind an existence guard, so a rename on either side degrades SILENTLY — the literal is therefore hoisted to `WebDocumentController.clearSelectionScript` and pinned by a test that swaps the real function for a spy, runs the exact literal against a live page, and requires the spy to fire.

Things to double-check

The residual the WebKit argument leaves open.

Both findings establish that WebKit computes no accessible name for a <th> — not that it computes an unpolluted one. AXTitle, AXDescription and AXValue all come back empty, and VoiceOver descends into the cell's children instead. An assistive client that synthesises a description from descendants when the name is empty could therefore still reach the dot's button. That is client behaviour outside WebKit, it is identical in shape to what every body-row dot already does, and it argues for one VoiceOver pass on a table with a header note rather than for any markup change. Worth ten minutes on a device before 1.0, not worth holding this branch.

One <code>make build-ios</code> before merge.

Not because a break is likely — nothing in the diff is platform-conditional, and my macOS run compiled every changed Swift file — but because no CI job on this repo runs any test or build of the app since the per-locale sweep went workflow_dispatch-only. A green PR says nothing. The local run is the whole verification, so completing it to the CLAUDE.md bar costs one command.

A header-only table (<code>| A |</code> with no body rows).

Pre-existing and not introduced here, but surfaced while checking the report's claim that no + exists on such a table: with zero body rows nothing sets hasChildAffordance, so a block-level + is injected and its tap creates the table-block note Req 1.3 forbids. Belongs in T-2318's scope; noting it here so it does not evaporate with this review.

The <code>accessibleNameForNode()</code> asymmetry, if the write path ever changes.

That site passes includeFocusableContent = true, so a <th>'s subtree — dot included — is read when the <th> is used to label something else. Nothing in Prism's emitted HTML does that today. If a future change ever adds an aria-labelledby or a <label> pointing at a header cell, the round-one concern comes back and this analysis will need redoing. Because the flag is set positionally in an aggregate initialiser there, a grep for includeFocusableContent will not find it.