prism branch worktree-T-1036-footnote-badge-a11y commits 3 (spec + impl + refactor) files 9 changed lines +558 / -0

Pre-push review: T-1036 footnote badge accessibility

Audit finding C2: VoiceOver discoverability and operability of inline footnote badges. View-layer-only fix; no Textual fork change.

At a glance

  • Single-file view-layer fix in Prism (HighlightedInlineText.swift) plus a free helper in prism/Services/FootnoteAccessibility.swift. No Textual fork change, no Package.resolved bump.

  • VoiceOver now narrates each inline [^id] as “footnote N” instead of the bare digit, and per-paragraph accessibility actions (“Show footnote N”) open the existing prism://footnote/{id} popover flow.

  • Helpers folded into a single analyze(markdown:data:) pass after the first review round — one regex walk per render instead of two.

Verdict

Ready to push (with deferred manual VoiceOver verification)

Code review surfaced one efficiency improvement (now applied) and no must-fix findings. SwiftLint passed clean. Test-quick, test-locales, and manual VoiceOver verification did not complete in this session because the local Xcode pipeline was under heavy load; those steps remain pending on Task 5 of tasks.md. Ship if you accept that verification follows.

Review findings

4 raised · 1 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism renders footnotes inline. A reference like [^1] displays as a tiny blue “1” pill that, when tapped, opens a popover with the footnote’s content. Before this change, if you read a document with VoiceOver, the pill announced as just “one” — with no hint that it was a footnote and no way to open the popover without using a mouse or finger.

This change teaches VoiceOver to say “footnote one” instead of “one”, and adds a swipe-up “Show footnote 1” action so screen-reader users can open the popover the same way sighted users tap it.

Why It Matters

Footnotes are part of the reading experience. If VoiceOver can’t reach them, a chunk of every document is invisible to a subset of users. The accessibility audit (docs/accessibility-review.md, finding C2) flagged this as a ‘critical’ issue.

Key Concepts

  • VoiceOver: Apple’s built-in screen reader. Reads UI aloud and lets users navigate by swiping rather than touching exact targets.
  • Accessibility action: A named operation a user can pick from a list, similar to a right-click menu but invoked with a swipe gesture.
  • Accessibility label: The text VoiceOver speaks for a UI element. By default SwiftUI figures it out from the visible text; here we override it so digits read as “footnote N”.
  • Localisation catalog: Apple’s .xcstrings file where every user-facing string lives so it can be translated.

Changes Overview

  • prism/Services/FootnoteAccessibility.swift — new helper. Single analyze(markdown:data:) pass returns both (a) unique footnote references in first-occurrence order and (b) a rebuilt accessibility label with each resolvable [^id] replaced by the localised “footnote N” phrase. Two thin wrappers expose the outputs individually for unit testing.
  • prism/Views/HighlightedInlineText.swift — adds a private FootnoteAccessibilityModifier: ViewModifier. When analyze reports any entries, the modifier applies .accessibilityLabel(Text(rebuiltLabel)) and .accessibilityActions { … } with one Button per unique entry. Each action opens prism://footnote/{id} via @Environment(\.openURL) — the same URL the existing tap handler in DocumentReaderView.swift:150 routes into the popover/sheet flow.
  • prism/Localizable.xcstrings — adds a11y.footnote.inline %lld (“footnote N”) and a11y.footnote.action %lld (“Show footnote N”) for en, en-GB, en-US.
  • prismTests/FootnoteAccessibilityTests.swift — eight tests cover ordered unique extraction, duplicate-identifier collapse, unresolved filtering, empty-data behaviour, and the four label-rebuild cases from the smolspec test plan.

Implementation Approach

The smolspec originally proposed setting an accessibilityLabel attribute on each badge’s AttributedString sub-range — pushed down into the Textual fork. Peer review rejected that: Text(AttributedString) typically renders the whole string as one accessibility element, so per-run labels probably never fire, and adding a LocalizedStringResource parameter to a single-consumer fork is poor API hygiene. The shipped approach overrides the entire paragraph’s accessibility label at the SwiftUI view layer instead.

Trade-offs

  • Single combined pass vs two helpers. First impl ran the regex twice; the refactor commit folds the work into one call. Tests stay unchanged.
  • Modifier no-ops when no references are present, so plain paragraphs keep SwiftUI’s auto-derived narration.
  • Switch Control / Full Keyboard Access deliberately out of scope — .accessibilityActions is exposed to VoiceOver’s Actions rotor; SC/FKA users still operate at paragraph granularity.

Technical Deep Dive

FootnoteAccessibility.analyze(markdown:data:) does a single String.matches(of: FootnoteData.referencePattern) walk and accumulates two outputs in lockstep. An Entry is appended on first occurrence of each resolvable identifier (deduped via a Set<String>); the label is built from surrounding text plus a localised phrase for each resolvable substitution. Unresolved identifiers are left intact in the label and excluded from the entry list. Empty FootnoteData short-circuits to (entries: [], rebuiltLabel: markdown).

Reusing FootnoteData.referencePattern keeps regex semantics aligned with FootnoteStripping, MarkdownBlockParser, and applyFootnoteSearchMatches. The pattern accepts [a-zA-Z0-9_-]+ so identifiers are URL-path-safe by construction; the modifier still uses if let url = URL(string: ...) rather than a force-unwrap — defensive failure plus log is cheaper than betting on the pattern never widening.

The localised phrase is built via String(localized: "a11y.footnote.inline \(displayNumber)") and LocalizedStringKey("a11y.footnote.action \(entry.displayNumber)"). Swift’s LocalizedStringKey interpolation generates %lld placeholders for Int arguments at compile time. Precedent: SharedCollapsibleSections.swift:170-171 uses the same pattern.

The modifier is annotated @ViewBuilder on body(content:) so the if/else branches return SwiftUI’s _ConditionalContent. View identity is preserved per HighlightedInlineText instance because markdown is immutable for a given parsed block.

Architecture Impact

  • No Textual fork change. Avoids a lockstep fork commit + Package.resolved bump; all a11y wording stays in Localizable.xcstrings.
  • Helpers in a sibling service module (FootnoteAccessibility alongside FootnoteStripping). Tests exercise the helpers directly; the view’s role shrinks to wiring.
  • One regex pass per render, matching the existing applyFootnoteSearchMatches cost.
  • Routing reuses an existing surface. Action Buttons call openURL with prism://footnote/{id} — the same contract the tap path uses. If the tap path ever changes, accessibility actions move with it.

Potential Issues

  • VoiceOver narration is manually verified only. SwiftUI’s accessibility surface isn’t fully introspectable from XCTest. Tests cover the deterministic helpers; the modifier’s VoiceOver effect needs a real device or simulator.
  • Overriding .accessibilityLabel replaces auto-derived narration. Once footnotes appear in a paragraph, VoiceOver no longer infers anything from inline markup. The rebuilder copies non-footnote text verbatim from markdown, so **bold**, *italic*, and backtick code spans survive unstyled. If audibly awful, the rebuilder will need a small inline-markdown stripper.
  • SC/FKA gap is known and unaddressed. .accessibilityActions is a VoiceOver rotor surface only; SC/FKA users need an inline focus target. Tracked as a separate ticket.
  • Duplicate references: [^a] appearing twice yields one action (correct) but the rebuilder substitutes both occurrences with the same phrase (matches smolspec; flag if writers ever want distinct voicing).

Important changes — detailed

FootnoteAccessibility.analyze — single regex pass that emits both entries and rebuilt label

prism/Services/FootnoteAccessibility.swift

Why it matters. This is the load-bearing helper. One regex walk produces both the action list and the rebuilt accessibility label, so every render of HighlightedInlineText does at most one extra regex pass relative to the existing applyFootnoteSearchMatches call.

What to look at. FootnoteAccessibility.swift:34-65 (analyze body); wrappers at 69-88

Takeaway. When two helpers share a scan, expose the combined result as a struct and keep the split helpers as thin wrappers — preserves unit-test surface without doubling work.
Rationale. Pre-push review flagged a duplicate regex scan when the modifier called resolvedReferences and rebuiltAccessibilityLabel back-to-back. The combined analyze() keeps the modifier path single-pass while leaving the test-facing API alone.

FootnoteAccessibilityModifier — view-layer override that's a no-op when no footnotes

prism/Views/HighlightedInlineText.swift

Why it matters. The modifier governs the entire VoiceOver behaviour change. The branch on entries.isEmpty preserves SwiftUI's auto-derived narration for paragraphs without footnotes so we don't regress general accessibility.

What to look at. HighlightedInlineText.swift:233-265 (private FootnoteAccessibilityModifier struct)

Takeaway. Always-applied accessibility overrides clobber auto-derived narration. Guard the override on a real signal so paragraphs without your special-case content keep their default behaviour.
Rationale. Decision log Decision 1 captures the pivot away from sub-range AttributedString attributes (unproven through Text(AttributedString)) to view-layer paragraph-level override. The shipped modifier is the literal embodiment of that decision.

Accessibility action routing reuses the existing prism://footnote/{id} OpenURLAction

prism/Views/HighlightedInlineText.swift

Why it matters. There is no new code path for action invocation. Each ForEach Button hits @Environment(\.openURL) with the same URL the tap handler already routes — DocumentReaderView.swift:150-160 handles both.

What to look at. HighlightedInlineText.swift:255-263 (Button closure inside .accessibilityActions)

Takeaway. When a tap surface already routes through OpenURLAction, mirror it for accessibility actions instead of forking. Future routing changes carry both behaviours forward together.
Rationale. Smolspec Requirement 3 explicitly bound the action invocation to the existing OpenURLAction so the popover/sheet flow stays single-sourced.

Two parameterised localised keys added to Localizable.xcstrings

prism/Localizable.xcstrings

Why it matters. Both spoken label and action name must localise; using %lld matches Prism's existing parameterised-key convention (e.g. paywall.exports.remaining %lld).

What to look at. Localizable.xcstrings (a11y.footnote.action %lld, a11y.footnote.inline %lld) with en, en-GB, en-US variants

Takeaway. When introducing a new a11y namespace in xcstrings, mirror the platform's argument convention (%lld for Int) and seed all existing locale variants up front so test-locales stays green.
Rationale. Smolspec Requirement 4 and the project's localisation rules in CLAUDE.md require LocalizedStringKey-extracted parameterised keys. The %lld form is the existing convention; %d would diverge.

Key decisions

View-layer .accessibilityLabel override instead of Textual-fork sub-range a11y attributes.

The smolspec initially proposed pushing an accessibilityLabel attribute onto each badge's AttributedString sub-range inside the Textual fork's footnoteReferences(provider:) extension. Peer review (Codex + Kiro) rejected that for two reasons: Text(AttributedString) typically renders the whole string as one accessibility element so sub-range labels probably never fire, and adding a LocalizedStringResource parameter to a single-consumer fork is poor API hygiene. Shipped approach overrides the entire paragraph's accessibility label at the SwiftUI view layer instead. See specs/footnote-badge-a11y/decision_log.md Decision 1.

Keep the split helpers as thin wrappers around analyze() instead of removing them.

Unit tests cover resolvedReferences and rebuiltAccessibilityLabel independently. Folding into a single helper would change the test surface and require either rewriting tests or testing through the combined struct. Keeping the wrappers preserves the test-facing API; the modifier path uses analyze() directly for single-pass work.

No Textual fork change — keeps Package.resolved untouched.

The fork mechanism would have required a lockstep fork commit, a Prism Package.resolved bump, and a new LocalizedStringResource parameter on the syntax-extension factory. Single-file view-layer change avoids all of that. The fork already provides everything Prism needs via FootnoteReferenceAttribute.

Switch Control and Full Keyboard Access remain out of scope.

Per-paragraph .accessibilityActions is a VoiceOver rotor surface; SC/FKA users still operate at paragraph granularity. The audit (finding C2) was scoped to VoiceOver, and inline focus for SC/FKA is tracked as a separate concern. The current change does not regress SC/FKA but does not improve it either.

Review findings

SeverityAreaFindingResolution
minorFootnoteAccessibility helpersModifier called resolvedReferences() and rebuiltAccessibilityLabel() back-to-back, scanning the same markdown regex twice per render.Added analyze(markdown:data:) that returns both outputs in one walk. Modifier now calls analyze() once; split helpers stay as wrappers so tests don't change. Commit e292b53.
minorFootnoteAccessibilityTests fixtureTests build FootnoteData from (id, number) tuples via a private helper; FootnoteSearchTests has a very similar makeFootnoteData helper that could be extracted into a shared fixture.Skipped. The two helpers differ slightly (this one omits content), the duplication is small, and a shared fixture is its own refactor outside this chore's scope.
minorLocalizedStringKey extractionReviewer asked whether LocalizedStringKey("a11y.footnote.action \(displayNumber)") actually maps to the catalog key a11y.footnote.action %lld.Verified: same form is already used in SharedCollapsibleSections.swift:170-171 with %@, heading level %lld, collapsed. Swift's LocalizedStringKey interpolation emits %lld for Int. No code change.
minorURL construction in accessibility action buttonReviewer flagged a force-unwrap on URL(string: "prism://footnote/\(identifier)")!.Already used guard let url = URL(string: ...) — the force-unwrap claim was a false read. No code change.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9486c19..37c6366 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Research report on adding HTML viewing with annotations to Prism, covering WKWebView, native parse, AttributedString, HTML→Markdown, and hybrid rendering options plus a Hypothesis-style note-anchoring cascade and a layered security model (sanitisation, CSP, scheme handler). Stored under `docs/agent-notes/` and not bundled in the app.
 - `prism-notes-js` spec for a self-contained single-file JavaScript library that adds basic note-taking and copy-as-markdown to any HTML page (personal-use companion artifact; not part of the Prism Swift app). Smolspec, decision log, and 8 implementation tasks live under `specs/prism-notes-js/`.
 - `prism-notes-js/prism-notes.js` implementation (~1500 lines, single ES2022 IIFE, no dependencies). Selecting text shows a "+ Add note" pill; saving creates a note anchored via a text-quote selector (prefix/exact/suffix + position hint) and a SHA-256 document-text hash. Notes persist in `localStorage` keyed by `pathname+search`, re-highlight via the CSS Custom Highlight API on reload, and surface in a side panel with a "Copy as Markdown" footer button (clipboard write with a textarea-fallback modal). Includes `prism-notes-js/README.md` documenting the three install paths (script tag, bookmarklet for permissive CSP, DevTools paste for strict CSP) and `prism-notes-js/test-page.html` as a manual-test fixture. Personal-use scope: no sync, no sharing, no import, no Shadow DOM, no accessibility commitments.
+- `footnote-badge-a11y` smolspec (T-1036) for VoiceOver discoverability and operability of inline footnote badges, covering paragraph-level accessibility-label rebuild and per-paragraph accessibility actions in `HighlightedInlineText`. Decision log captures the pivot away from a Textual fork change to a single-file view-layer approach. Five linearly-dependent tasks live under `specs/footnote-badge-a11y/`.
+- Footnote badges are now discoverable and operable for VoiceOver (T-1036). New `FootnoteAccessibility` helper (`prism/Services/FootnoteAccessibility.swift`) extracts unique footnote references in first-occurrence order and rebuilds a paragraph-level accessibility label that substitutes each resolvable `[^id]` with the localised inline phrase "footnote N". `HighlightedInlineText` applies a private `FootnoteAccessibilityModifier` that overrides the paragraph's accessibility label and exposes one "Show footnote N" action per unique reference via `.accessibilityActions { … }`. Each action opens the existing `prism://footnote/{id}` URL through `@Environment(\.openURL)`, reusing the tap-routing flow already wired in `DocumentReaderView`. The modifier is a no-op when no resolvable references are present, so paragraphs without footnotes keep their auto-derived narration. New localised keys `a11y.footnote.inline %lld` and `a11y.footnote.action %lld` added to `prism/Localizable.xcstrings`. Helpers covered by new unit tests in `prismTests/FootnoteAccessibilityTests.swift`.

 ### Fixed

docs/agent-notes/footnotes.md Modified +8 / -0
diff --git a/docs/agent-notes/footnotes.md b/docs/agent-notes/footnotes.md
index 323f10e..a40e6ef 100644
--- a/docs/agent-notes/footnotes.md
+++ b/docs/agent-notes/footnotes.md
@@ -27,3 +27,11 @@ Files added/modified in the Textual fork at `/Users/arjen/projects/personal/text
 Key architectural note: The Textual codebase was restructured during an upstream merge. `PatternProcessor.Rule` was replaced by `AttributedStringMarkdownParser.SyntaxExtension`. The factory is now `.footnoteReferences(provider:)` on `AttributedStringMarkdownParser.SyntaxExtension`. `InlineText` accepts `syntaxExtensions` — footnote extensions are passed directly. Prism passes `[.footnoteReferences(provider: footnoteData)]` as syntax extensions to `InlineText` and `AttributedStringMarkdownParser` in `HighlightedInlineText`.

 The Textual fork pin was updated from `6b0d7ce` (pre-footnote) to `69009d4` (includes footnote attribute, styling, environment, and PatternProcessor fix). The pin tracks `main` on `arjenschwarz/textual`.
+
+## VoiceOver Accessibility (T-1036)
+
+- VoiceOver narration of a footnote badge inside `Text(AttributedString)` defaults to reading the bare display digit (e.g. "1") because the badge run is part of a single accessibility element. Setting `accessibilityLabel` on an AttributedString sub-range does not override that narration in `Text(AttributedString)` — the whole string remains one accessibility element.
+- Workaround used in Prism: override the view's accessibility label at the SwiftUI layer. `HighlightedInlineText` applies a private `FootnoteAccessibilityModifier` that, when the rendered markdown contains any resolvable `[^id]` reference, replaces the auto-derived label with a string rebuilt from the source markdown by substituting each resolvable `[^id]` with the localised inline phrase ("footnote N"). The modifier is a no-op when there are no resolvable references, so paragraphs without footnotes keep VoiceOver's default narration.
+- The same modifier exposes per-paragraph accessibility actions ("Show footnote N") via `.accessibilityActions { … }`. One Button is emitted per unique footnote identifier in first-occurrence order. Each Button opens `prism://footnote/{identifier}` via `@Environment(\.openURL)`, reusing the `OpenURLAction` already wired in `DocumentReaderView`. There is no separate code path for action invocation versus tap.
+- The helpers (`FootnoteAccessibility.resolvedReferences(in:data:)` and `FootnoteAccessibility.rebuiltAccessibilityLabel(for:data:)`) live in `prism/Services/FootnoteAccessibility.swift` and are unit-tested in `prismTests/FootnoteAccessibilityTests.swift`. Both reuse `FootnoteData.referencePattern` to keep regex behaviour aligned with the rest of the footnote pipeline.
+- Scope limit: per-paragraph `.accessibilityActions` is exposed to VoiceOver's Actions rotor. Switch Control and Full Keyboard Access still operate at paragraph granularity; an inline-focus surface for those input methods is out of scope for T-1036.
prism/Localizable.xcstrings Modified +46 / -0
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstrings
index 4032875..280e774 100644
--- a/prism/Localizable.xcstrings
+++ b/prism/Localizable.xcstrings
@@ -6510,6 +6510,52 @@
         }
       }
     },
+    "a11y.footnote.action %lld": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Show footnote %lld"
+          }
+        },
+        "en-GB": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Show footnote %lld"
+          }
+        },
+        "en-US": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "Show footnote %lld"
+          }
+        }
+      }
+    },
+    "a11y.footnote.inline %lld": {
+      "extractionState": "manual",
+      "localizations": {
+        "en": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "footnote %lld"
+          }
+        },
+        "en-GB": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "footnote %lld"
+          }
+        },
+        "en-US": {
+          "stringUnit": {
+            "state": "translated",
+            "value": "footnote %lld"
+          }
+        }
+      }
+    },
     "error.reload": {
       "extractionState": "manual",
       "localizations": {
prism/Services/FootnoteAccessibility.swift Added +90 / -0
diff --git a/prism/Services/FootnoteAccessibility.swift b/prism/Services/FootnoteAccessibility.swift
new file mode 100644
index 0000000..0031681
--- /dev/null
+++ b/prism/Services/FootnoteAccessibility.swift
@@ -0,0 +1,90 @@
+//
+//  FootnoteAccessibility.swift
+//  prism
+//
+//  Created by Claude on 17/5/2026.
+//
+
+import Foundation
+
+/// Accessibility-layer helpers for inline footnote references.
+///
+/// Used by `HighlightedInlineText` to override the paragraph's VoiceOver
+/// label so each `[^id]` reads as "footnote N", and to expose one
+/// accessibility action per unique footnote referenced in the paragraph.
+/// See `specs/footnote-badge-a11y/smolspec.md` for the design.
+enum FootnoteAccessibility {
+    /// A single resolved footnote reference with its visible display number.
+    struct Entry: Equatable, Sendable {
+        let identifier: String
+        let displayNumber: Int
+    }
+
+    /// The data the view layer needs to override the paragraph's spoken
+    /// label and expose per-paragraph actions: the resolved references in
+    /// first-occurrence order plus the rebuilt accessibility label.
+    struct Analysis: Equatable, Sendable {
+        let entries: [Entry]
+        let rebuiltLabel: String
+    }
+
+    /// Single regex pass over `markdown` that produces both the
+    /// first-occurrence-ordered entry list and the rebuilt accessibility
+    /// label. Unresolved `[^id]` runs are left intact in the rebuilt label
+    /// and excluded from the entry list.
+    static func analyze(
+        markdown: String,
+        data: FootnoteData
+    ) -> Analysis {
+        guard !data.isEmpty else {
+            return Analysis(entries: [], rebuiltLabel: markdown)
+        }
+        var seen: Set<String> = []
+        var entries: [Entry] = []
+        var label = ""
+        var cursor = markdown.startIndex
+        for match in markdown.matches(of: FootnoteData.referencePattern) {
+            label.append(contentsOf: markdown[cursor..<match.range.lowerBound])
+            let identifier = String(match.1)
+            if let definition = data.definition(for: identifier) {
+                if seen.insert(identifier).inserted {
+                    entries.append(Entry(
+                        identifier: identifier,
+                        displayNumber: definition.displayNumber
+                    ))
+                }
+                label.append(inlinePhrase(for: definition.displayNumber))
+            } else {
+                label.append(contentsOf: markdown[match.range])
+            }
+            cursor = match.range.upperBound
+        }
+        label.append(contentsOf: markdown[cursor...])
+        return Analysis(entries: entries, rebuiltLabel: label)
+    }
+
+    /// Returns one `Entry` per unique footnote identifier referenced in
+    /// `markdown`, in first-occurrence order. Identifiers without a matching
+    /// definition in `data` are excluded.
+    static func resolvedReferences(
+        in markdown: String,
+        data: FootnoteData
+    ) -> [Entry] {
+        analyze(markdown: markdown, data: data).entries
+    }
+
+    /// Returns `markdown` with each resolvable `[^id]` substring replaced by
+    /// the localised inline-footnote phrase. Unresolved references are left
+    /// intact so they continue to render as plain text.
+    static func rebuiltAccessibilityLabel(
+        for markdown: String,
+        data: FootnoteData
+    ) -> String {
+        analyze(markdown: markdown, data: data).rebuiltLabel
+    }
+
+    /// Localised inline phrase for a footnote with the given display number.
+    static func inlinePhrase(for displayNumber: Int) -> String {
+        String(localized: "a11y.footnote.inline \(displayNumber)")
+    }
+}
prism/Views/HighlightedInlineText.swift Modified +37 / -0
diff --git a/prism/Views/HighlightedInlineText.swift b/prism/Views/HighlightedInlineText.swift
index c78963d..84706e5 100644
--- a/prism/Views/HighlightedInlineText.swift
+++ b/prism/Views/HighlightedInlineText.swift
@@ -80,6 +80,7 @@ struct HighlightedInlineText: View {
         .onChange(of: matchCount) { _, newCount in
             onMatchCountChanged?(newCount)
         }
+        .modifier(FootnoteAccessibilityModifier(markdown: markdown, footnoteData: footnoteData))
     }

     /// Scaled body font for the search-highlighted Text path.
@@ -228,6 +229,42 @@ extension HighlightedInlineText {
     }
 }

+// MARK: - Footnote Accessibility Modifier
+
+/// Overrides the paragraph's accessibility label and exposes one
+/// accessibility action per resolvable footnote referenced in `markdown`.
+/// When there are no resolvable references the modifier is a no-op, leaving
+/// VoiceOver's auto-derived narration unchanged. Resolves T-1036 / audit C2.
+private struct FootnoteAccessibilityModifier: ViewModifier {
+    let markdown: String
+    let footnoteData: FootnoteData
+
+    @Environment(\.openURL) private var openURL
+
+    @ViewBuilder
+    func body(content: Content) -> some View {
+        let analysis = FootnoteAccessibility.analyze(
+            markdown: markdown,
+            data: footnoteData
+        )
+        if analysis.entries.isEmpty {
+            content
+        } else {
+            content
+                .accessibilityLabel(Text(analysis.rebuiltLabel))
+                .accessibilityActions {
+                    ForEach(analysis.entries, id: \.identifier) { entry in
+                        Button(LocalizedStringKey("a11y.footnote.action \(entry.displayNumber)")) {
+                            if let url = URL(string: "prism://footnote/\(entry.identifier)") {
+                                openURL(url)
+                            }
+                        }
+                    }
+                }
+        }
+    }
+}
+

 // MARK: - Preview

prismTests/FootnoteAccessibilityTests.swift Added +150 / -0
diff --git a/prismTests/FootnoteAccessibilityTests.swift b/prismTests/FootnoteAccessibilityTests.swift
new file mode 100644
index 0000000..2b7a507
--- /dev/null
+++ b/prismTests/FootnoteAccessibilityTests.swift
@@ -0,0 +1,150 @@
+//
+//  FootnoteAccessibilityTests.swift
+//  prismTests
+//
+//  Created by Claude on 17/5/2026.
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("Footnote Accessibility Helpers")
+struct FootnoteAccessibilityTests {
+
+    // MARK: - Fixtures
+
+    /// Builds a `FootnoteData` containing the given identifier→displayNumber
+    /// pairs, with each identifier also recorded in `referenceOrder`.
+    private static func footnoteData(_ entries: [(String, Int)]) -> FootnoteData {
+        var definitions: [String: FootnoteDefinition] = [:]
+        var order: [String] = []
+        for (identifier, number) in entries {
+            definitions[identifier] = FootnoteDefinition(
+                identifier: identifier,
+                displayNumber: number,
+                content: "definition for \(identifier)"
+            )
+            order.append(identifier)
+        }
+        return FootnoteData(definitions: definitions, referenceOrder: order)
+    }
+
+    // MARK: - resolvedReferences()
+
+    @Test("Resolved references returned in first-occurrence order")
+    func resolvedReferencesOrdered() {
+        let markdown = "See [^b] before [^a] but also [^c]."
+        let data = Self.footnoteData([("a", 1), ("b", 2), ("c", 3)])
+        let result = FootnoteAccessibility.resolvedReferences(in: markdown, data: data)
+        #expect(result == [
+            .init(identifier: "b", displayNumber: 2),
+            .init(identifier: "a", displayNumber: 1),
+            .init(identifier: "c", displayNumber: 3),
+        ])
+    }
+
+    @Test("Duplicate identifier collapses to one entry at first occurrence")
+    func duplicateIdentifierCollapses() {
+        let markdown = "Both [^a] and [^a] cite the same note; [^b] does not."
+        let data = Self.footnoteData([("a", 1), ("b", 2)])
+        let result = FootnoteAccessibility.resolvedReferences(in: markdown, data: data)
+        #expect(result == [
+            .init(identifier: "a", displayNumber: 1),
+            .init(identifier: "b", displayNumber: 2),
+        ])
+    }
+
+    @Test("Unresolved identifier is dropped from the result")
+    func unresolvedIdentifierDropped() {
+        let markdown = "Known [^a] and unknown [^missing] tokens."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.resolvedReferences(in: markdown, data: data)
+        #expect(result == [.init(identifier: "a", displayNumber: 1)])
+    }
+
+    @Test("Empty FootnoteData returns no entries")
+    func emptyFootnoteDataReturnsNothing() {
+        let markdown = "Paragraph with [^a] and [^b] references."
+        let result = FootnoteAccessibility.resolvedReferences(in: markdown, data: .empty)
+        #expect(result.isEmpty)
+    }
+
+    @Test("Markdown without footnote references returns no entries")
+    func noFootnoteReferencesReturnsNothing() {
+        let markdown = "Just prose, no references here."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.resolvedReferences(in: markdown, data: data)
+        #expect(result.isEmpty)
+    }
+
+    // MARK: - rebuiltAccessibilityLabel()
+
+    @Test("Single resolvable reference is replaced inline")
+    func singleResolvableReferenceReplaced() {
+        let markdown = "See [^a] for details."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("[^a]"))
+        #expect(result.hasPrefix("See "))
+        #expect(result.hasSuffix(" for details."))
+        // Display number must appear where the badge stood.
+        #expect(result.contains("1"))
+    }
+
+    @Test("Multiple resolvable references each replaced, surrounding text preserved")
+    func multipleResolvableReferencesReplaced() {
+        let markdown = "First [^a], then [^b], finally [^c]."
+        let data = Self.footnoteData([("a", 1), ("b", 2), ("c", 3)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("[^a]"))
+        #expect(!result.contains("[^b]"))
+        #expect(!result.contains("[^c]"))
+        #expect(result.contains("First "))
+        #expect(result.contains(", then "))
+        #expect(result.contains(", finally "))
+        #expect(result.hasSuffix("."))
+        for number in ["1", "2", "3"] {
+            #expect(result.contains(number))
+        }
+    }
+
+    @Test("Unresolved reference is left untouched")
+    func unresolvedReferenceLeftIntact() {
+        let markdown = "Known [^a], unknown [^missing]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("[^a]"))
+        #expect(result.contains("[^missing]"))
+    }
+
+    @Test("Markdown without footnote references is returned unchanged")
+    func noFootnoteReferencesReturnsInputUnchanged() {
+        let markdown = "Plain paragraph with no footnotes."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(result == markdown)
+    }
+
+    @Test("Empty FootnoteData returns the input unchanged")
+    func emptyFootnoteDataReturnsInputUnchanged() {
+        let markdown = "Paragraph with [^a] reference."
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: .empty
+        )
+        #expect(result == markdown)
+    }
+}
specs/footnote-badge-a11y/decision_log.md Added +74 / -0
diff --git a/specs/footnote-badge-a11y/decision_log.md b/specs/footnote-badge-a11y/decision_log.md
new file mode 100644
index 0000000..215d153
--- /dev/null
+++ b/specs/footnote-badge-a11y/decision_log.md
@@ -0,0 +1,74 @@
+# Decision Log: Footnote Badge Accessibility
+
+## Decision 1: Rebuild paragraph accessibility label in Prism, not in the Textual fork
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+The accessibility audit (C2 / T-1036) asks for two things:
+(1) badges that announce as "footnote N" rather than the bare display
+digit, and (2) a discoverable, operable accessibility action per badge.
+
+Initial design proposed adding an `accessibilityLabel` attribute on each
+badge sub-range inside the Textual fork's `footnoteReferences(provider:)`
+syntax extension, with the caller passing a `LocalizedStringResource` so
+Prism would own the wording. Peer review (Codex and Kiro, via the
+peer-review-validator agent) flagged this as risky: `Text(AttributedString)`
+typically treats the entire string as a single accessibility element, so a
+sub-range `accessibilityLabel` may not affect VoiceOver narration at all,
+and the behaviour through Textual's `WithInlineStyle` / `FootnoteProperty`
+rebuild pipeline is unverified. Additionally, putting a localised-string
+parameter into a syntax-extension factory's API was rejected by both peers
+as the wrong shape for a fork with a single consumer.
+
+### Decision
+
+Do not touch the Textual fork. Override `HighlightedInlineText`'s
+`.accessibilityLabel(_:)` at the view layer with a string rebuilt from the
+source `markdown` and `footnoteData`, substituting each resolvable `[^id]`
+with a localised "footnote N" phrase. Attach
+`.accessibilityActions { … }` at the same view level for per-paragraph
+actions.
+
+### Rationale
+
+- View-level `.accessibilityLabel` is a documented, predictable mechanism
+  that always produces the announced string for the surrounding
+  accessibility element.
+- All semantic data needed (identifier, display number) is already in
+  `FootnoteData`; the fork's `FootnoteReferenceAttribute` does not need to
+  carry presentation strings.
+- Eliminates the `Package.resolved` pin bump and lockstep fork commit,
+  keeping the change to a single Prism file plus the string catalog.
+- Localisation stays where every other Prism a11y string lives.
+
+### Alternatives Considered
+
+- **Fork-side `accessibilityLabel` attribute on the badge AttributedString
+  sub-range**: Rejected because the mechanism is unproven in
+  `Text(AttributedString)`, the fork is a single-consumer dependency where
+  adding a string parameter has no benefit, and it forces a lockstep fork
+  commit + pin bump for a one-file Prism change.
+- **Paragraph-level actions only, no relabelling**: Rejected because it
+  leaves the badge announcement at "1" — only half of audit finding C2.
+
+### Consequences
+
+**Positive:**
+- Single-file Prism change; no fork or pin bump.
+- Mechanism (view-level `.accessibilityLabel`) is well-known and easy to
+  verify.
+- All a11y wording stays in `Localizable.xcstrings`.
+
+**Negative:**
+- Overriding the paragraph's accessibility label replaces VoiceOver's
+  auto-derived narration, so any future inline cue VoiceOver would have
+  added (e.g. emphasis stress) is also suppressed for the paragraph. The
+  audit accepts paragraph-level narration as adequate.
+- Per-paragraph `.accessibilityActions` is a VoiceOver rotor surface;
+  Switch Control and Full Keyboard Access users still operate at
+  paragraph granularity. Out of scope for this chore.
+
+---
specs/footnote-badge-a11y/smolspec.md Added +113 / -0
diff --git a/specs/footnote-badge-a11y/smolspec.md b/specs/footnote-badge-a11y/smolspec.md
new file mode 100644
index 0000000..2b47096
--- /dev/null
+++ b/specs/footnote-badge-a11y/smolspec.md
@@ -0,0 +1,113 @@
+# Footnote Badge Accessibility (T-1036)
+
+## Overview
+
+Footnote `[^id]` pills render as styled inline text runs. VoiceOver speaks
+only the bare display number (e.g. "1") with no indication that the run is a
+footnote, and there is no exposed accessibility action that opens the
+footnote popover. This change rebuilds the paragraph's spoken label at the
+view layer so each badge is announced as "footnote N" inline, and exposes
+one accessibility action per unique footnote reference that opens the same
+`prism://footnote/{id}` flow used by tap. Resolves accessibility audit
+finding C2 in `docs/accessibility-review.md`. No changes to the Textual fork.
+
+## Requirements
+
+- When VoiceOver reads a paragraph containing one or more `[^id]` footnote
+  references, each reference MUST be announced with a label that identifies
+  it as a footnote and includes the visible display number (e.g.
+  "footnote 1"), in place of the bare digit.
+- A paragraph that contains one or more resolvable footnote references
+  MUST expose one VoiceOver action per unique footnote identifier, named so
+  the target footnote is unambiguous (e.g. "Show footnote 1"). Actions MUST
+  be ordered by first occurrence in the source markdown.
+- Invoking a footnote accessibility action MUST trigger the same popover or
+  sheet that a touch tap triggers — namely, the existing
+  `prism://footnote/{id}` route handled by the `OpenURLAction` at
+  `prism/Views/DocumentReaderView.swift:150`.
+- Spoken labels and action names MUST be localised through
+  `prism/Localizable.xcstrings`, parameterised on the display number using
+  the project's existing `%lld` argument convention (see
+  `docs/agent-notes/localisation.md`).
+- The behaviour MUST apply identically when search is active and when
+  search is inactive (both render paths of `HighlightedInlineText`).
+- Unresolved footnote references (no matching definition in `footnoteData`)
+  MUST NOT contribute an action or a relabelled segment — they remain plain
+  text per existing rendering behaviour.
+
+## Implementation Approach
+
+**Single file changed**: `prism/Views/HighlightedInlineText.swift`.
+
+**Spoken paragraph label** — override the view's accessibility label with a
+rebuilt string. Walk the source `markdown` and substitute each resolvable
+`[^id]` token with a localised "footnote N" phrase, leaving everything else
+untouched. The substitution uses the same regex and resolver already in
+this file:
+
+- `FootnoteData.referencePattern` (regex over `[^id]`) — already used by
+  `applyFootnoteSearchMatches` at lines 165–178.
+- `FootnoteData.definition(for:)` — resolves identifier to display number.
+- `@Environment(\.footnoteData)` is already wired at line 55.
+
+Apply with `.accessibilityLabel(Text(rebuiltString))` on the view body.
+This intentionally replaces VoiceOver's auto-derived label for the
+paragraph. Tap routing, text selection, and visual rendering are
+unaffected.
+
+**Accessibility actions** — compute a list of `(identifier, displayNumber)`
+tuples for each *unique* identifier in `markdown` (first-occurrence order),
+filtered to identifiers that resolve via `footnoteData`. Attach an
+`.accessibilityActions { … }` block to the view body that emits one
+`Button(Text(LocalizedStringKey))` per tuple. The closure opens
+`URL(string: "prism://footnote/\(identifier)")` via
+`@Environment(\.openURL)`, which routes through the existing
+`OpenURLAction` at `DocumentReaderView.swift:150`. Pattern reference:
+`prism/Views/ImageBlockView.swift:202–203`.
+
+**Localisation** — add two keys to `prism/Localizable.xcstrings`:
+
+- `a11y.footnote.inline %lld` — spoken inline form for the rebuilt label
+  (e.g. "footnote 1").
+- `a11y.footnote.action %lld` — action name (e.g. "Show footnote 1").
+
+Both parameters are `Int` display numbers, formatted via `%lld` to match
+existing parameterised keys.
+
+**Out of scope:**
+
+- Touching the Textual fork. The fork already provides everything Prism
+  needs via `FootnoteReferenceAttribute`; no `Package.resolved` bump.
+- Badge contrast (audit C3 / T-1037).
+- Heading-level VoiceOver semantics (C1 / T-1035) and other audit items.
+- Switch Control / Full Keyboard Access focus improvements. Per-paragraph
+  `.accessibilityActions` is exposed via the VoiceOver rotor; SC/FKA users
+  still operate the paragraph at paragraph granularity, which matches the
+  C2 audit scope. A separate ticket can pick up SC/FKA inline-focus if
+  needed.
+- Tuning VoiceOver reading order for footnotes that interrupt a sentence —
+  the rebuilt label preserves inline source order.
+
+## Risks and Assumptions
+
+- **Risk**: Overriding the paragraph's `.accessibilityLabel` could
+  unintentionally hide other inline content (e.g. emphasis cues) from
+  VoiceOver. **Mitigation**: rebuild the label by string substitution on
+  the raw markdown, so non-footnote prose is preserved verbatim; verify
+  manually that paragraph narration sounds correct in both render paths.
+- **Risk**: A paragraph with many footnotes adds many actions to the
+  rotor. **Mitigation**: dedupe by identifier; typical paragraphs have
+  ≤ 3 footnotes; existing `ImageBlockView` pattern handles 2-action sets
+  without UX issue.
+- **Assumption**: The action list can be computed cheaply on each view
+  body evaluation (regex + dictionary lookup over inline-paragraph-sized
+  strings). The same regex is already evaluated per-render in
+  `applyFootnoteSearchMatches`.
+- **Assumption**: `.accessibilityActions` on the view body is exposed to
+  VoiceOver's Actions rotor. Validated by the shipped `ImageBlockView`
+  pattern.
+- **Test plan**: unit-test the rebuilt-label string and the
+  `(identifier, displayNumber)` extraction (deterministic outputs from
+  a sample markdown + `FootnoteData`). VoiceOver narration itself is
+  manual verification on a device or simulator in both search-active and
+  search-inactive states.
specs/footnote-badge-a11y/tasks.md Added +38 / -0
diff --git a/specs/footnote-badge-a11y/tasks.md b/specs/footnote-badge-a11y/tasks.md
new file mode 100644
index 0000000..fb7cd52
--- /dev/null
+++ b/specs/footnote-badge-a11y/tasks.md
@@ -0,0 +1,38 @@
+---
+references:
+    - specs/footnote-badge-a11y/smolspec.md
+    - specs/footnote-badge-a11y/decision_log.md
+---
+# Footnote Badge Accessibility (T-1036)
+
+- [x] 1. Footnote action-list extraction helper with unit tests <!-- id:h2oolxu -->
+  - Add a static helper (e.g. on HighlightedInlineText or a sibling utility) that takes a markdown String and FootnoteData and returns [(identifier: String, displayNumber: Int)] for each footnote reference in the source.
+  - Output deduped by identifier in first-occurrence order; unresolved identifiers (no FootnoteData.definition(for:)) excluded.
+  - Reuse FootnoteData.referencePattern (already used by applyFootnoteSearchMatches at HighlightedInlineText.swift:165-178).
+  - Verification: new unit tests in prismTests cover (a) ordered unique extraction, (b) duplicate identifier collapses to one entry at first occurrence, (c) unresolved identifier dropped, (d) empty FootnoteData returns []. make test-quick stays green.
+
+- [x] 2. Paragraph accessibility-label rebuilder with unit tests <!-- id:h2oolxv -->
+  - Add a helper that takes a markdown String and FootnoteData and returns an accessibility-label String with each resolvable [^id] occurrence replaced by a localised phrase produced from String(localized: "a11y.footnote.inline %lld", displayNumber).
+  - Unresolved [^id] runs left as-is. Helper produces deterministic strings for unit testing.
+  - Verification: unit tests cover (a) single resolvable reference replaced, (b) multiple references each replaced inline preserving surrounding text, (c) unresolved reference left untouched, (d) no footnote references returns input unchanged. make test-quick stays green.
+  - Blocked-by: h2oolxu (Footnote action-list extraction helper with unit tests)
+
+- [x] 3. Localised a11y string keys in Localizable.xcstrings <!-- id:h2oolxw -->
+  - Add two new keys to prism/Localizable.xcstrings with %lld interpolation: a11y.footnote.inline (en value: "footnote %lld") and a11y.footnote.action (en value: "Show footnote %lld").
+  - Follow the existing parameterised-key convention documented in docs/agent-notes/localisation.md.
+  - Verification: make test-locales passes; merged catalog at $DERIVED_FILE_DIR/Localizable.merged.xcstrings contains both keys.
+  - Blocked-by: h2oolxv (Paragraph accessibility-label rebuilder with unit tests)
+
+- [x] 4. Wire .accessibilityLabel and .accessibilityActions into HighlightedInlineText <!-- id:h2oolxx -->
+  - In prism/Views/HighlightedInlineText.swift attach .accessibilityLabel(Text(rebuiltLabel)) to the view body using the helper from task 2 with @Environment(\.footnoteData) and the current markdown.
+  - Attach .accessibilityActions { … } emitting one Button(Text(LocalizedStringKey("a11y.footnote.action \(n)"))) per (identifier, displayNumber) tuple from the helper in task 1.
+  - Each button calls @Environment(\.openURL) with URL(string: "prism://footnote/\(identifier)")! so routing reuses the existing OpenURLAction at DocumentReaderView.swift:150.
+  - Pattern reference: prism/Views/ImageBlockView.swift:202-203.
+  - Verification: project builds via make build-ios and make build-macos with zero warnings; existing prismTests stay green via make test-quick.
+  - Blocked-by: h2oolxw (Localised a11y string keys in Localizable.xcstrings)
+
+- [ ] 5. VoiceOver verification on iOS and macOS plus lint and dual-platform build <!-- id:h2oolxy -->
+  - iOS Simulator (iPhone 17, iOS 26): open a document with footnotes, enable VoiceOver, confirm each [^id] reads as "footnote N" inline, swipe Actions rotor and confirm "Show footnote N" actions are listed and activating one opens the popover. Repeat with search active.
+  - macOS: same checks with VoiceOver; popover is the macOS variant.
+  - Run make lint, make build-ios, and make build-macos — all must complete clean with zero warnings/errors.
+  - Blocked-by: h2oolxx (Wire .accessibilityLabel and .accessibilityActions into HighlightedInlineText)

Things to double-check

Run make test-quick, make test-locales, make build-ios, make build-macos when the local Xcode pipeline frees up.

The local environment was under heavy load during this session — make test-quick and make test-locales were started but interrupted because concurrent xcodebuild processes were deadlocking on ./DerivedData. make lint passed clean. Task 5 in tasks.md stays Pending until full verification runs.

Manual VoiceOver verification on iOS Simulator and macOS.

Open a document with footnotes, enable VoiceOver, confirm each [^id] reads as “footnote N” inline. Swipe the Actions rotor and confirm “Show footnote N” actions are listed and activating one opens the popover. Repeat with search active to cover the Text(AttributedString) render path.

Audibility of inline markdown markers in the rebuilt accessibility label.

The rebuilder copies non-footnote text verbatim from markdown, so **bold**, *italic*, and backtick code spans survive unstyled into VoiceOver narration. If asterisks read as “asterisk asterisk” in practice, the rebuilder needs a small inline-markdown stripper before emitting the label.