prism branch worktree-t-1259-strip-md-markers-a11y commits 2 files 7 touched lines +601 / -15

Pre-push review: T-1259 strip-md-markers-a11y

Strips inline markdown markers from the footnote VoiceOver label rebuilt by FootnoteAccessibility.analyze(markdown:data:). Follow-up to T-1036 / PR #256 closing accessibility audit finding C2.

At a glance

  • FootnoteAccessibility.analyze now routes each surrounding-prose segment through renderedPlainText(_:) before appending to the rebuilt label.
  • Implementation is a private MarkupWalker (InlinePlainTextCollector); the load-bearing override is visitInlineCode because swift-markdown's own InlineCode.plainText re-wraps the literal text in backticks.
  • Leading/trailing whitespace is trimmed before parse and re-attached after, so Document(parsing:) doesn't treat 4+ leading spaces as an indented code block.
  • Test plan: emphasis, strong, code (with literal asterisks preserved), link text-only, image alt-only, strikethrough, autolink, backslash escape, unbalanced markers, nested emphasis, link containing strong, whitespace preservation, and the indented-segment guard. Three cases assert exact rebuilt-label strings.
  • Resolves the "Potential Issues" bullet in specs/footnote-badge-a11y/implementation.md.

Verdict

Ready to push

Lint clean. make build-macos and make build-ios both succeed with zero warnings touching FootnoteAccessibility.swift. Isolated FootnoteAccessibilityTests all pass (25 cases). Scope is contained: one service file, its test file, three spec docs, one parent-spec correction, and one agent-note line. No view-layer, fork, public-API, or localisation changes. Pre-push review surfaced six fixable findings (mostly spec / test-completeness); all addressed in commit ceb926e.

Review findings

8 raised · 7 fixed · 1 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

When a paragraph contains a footnote, Prism's VoiceOver narration used to read the raw markdown markers — "asterisk asterisk bold asterisk asterisk" for **bold**, "backtick code backtick" for inline code. This change makes VoiceOver read the same text a sighted reader sees: **bold** becomes bold, [docs](url) becomes docs, and so on. Inside an inline code span (`literal`) the markers stay — that's intentional, because code is supposed to read verbatim.

Why It Matters

The parent ticket T-1036 made footnote badges discoverable to VoiceOver but flagged this limitation as a known issue. T-1259 closes it. The audit finding (C2 in docs/accessibility-review.md) is now fully resolved.

Key Concepts

  • VoiceOver: Apple's built-in screen reader.
  • Accessibility label: the spoken text Prism gives VoiceOver for a paragraph. The existing T-1036 code overrides it to inject "footnote N" where each [^id] sits.
  • swift-markdown: the existing parser library that already powers Prism's rendering.
  • MarkupWalker: a built-in pattern for walking the parsed AST. The new helper is a 20-line walker that emits visible text for each node.

Changes Overview

  • prism/Services/FootnoteAccessibility.swift: adds import Markdown, the private renderedPlainText(_ segment: Substring) -> String helper, and the private InlinePlainTextCollector: MarkupWalker. analyze(markdown:data:) now routes its two raw label.append sites through the helper.
  • prismTests/FootnoteAccessibilityTests.swift: thirteen new cases covering the supported inline constructs, unbalanced markers, nested emphasis, autolink, backslash escape, and the indented-segment edge case. Three cases assert exact rebuilt labels.
  • specs/strip-md-markers-a11y/{smolspec,decision_log,tasks,implementation}.md: full smolspec plus the implementation explanation you're reading.
  • docs/agent-notes/footnotes.md: one-paragraph addition pointing to the new helper.
  • specs/footnote-badge-a11y/implementation.md: replaces the "known limitation" paragraph with a pointer to T-1259.

Implementation Approach

The smolspec originally proposed using Document(parsing: …).plainText directly. Peer review (recorded in the decision log) caught that swift-markdown's InlineCode.plainText keeps the backticks (swift-markdown/.../Inline Leaves/InlineCode.swift:51-53) and SymbolLink.plainText keeps the double backticks. So the shipped helper is a MarkupWalker whose default visit(_:) recurses into children (which is exactly what we want for wrappers like Emphasis/Strong/Link/Image/Strikethrough — they fall away for free) and whose leaf overrides emit visible text without delimiters.

Whitespace handling is the second subtlety. Slices like " inside a list item" have four leading spaces; Document(parsing:) would parse that as an indented code block. The helper trims outer whitespace before parse and re-attaches it after, so the spoken label keeps the visible spacing.

Trade-offs

  • Per-segment Document parses (N+1 per paragraph where N is the number of footnotes) instead of one parse for the whole paragraph. Documented in the decision log; out-of-scope alternative would require re-testing the resolved-references contract.
  • No Textual fork change. No HighlightedInlineText change. No localisation-catalog change. No public-API change in FootnoteAccessibility.
  • The existing regex walk over [^id] references in analyze() is untouched.

Technical Deep Dive

InlinePlainTextCollector overrides six visit methods:

  • visitText → appends text.string (matches built-in).
  • visitInlineCode → appends inlineCode.code without backticks. Load-bearing: the built-in returns "`\(code)`".
  • visitSymbolLink → appends destination ?? "" without double backticks. Load-bearing: the built-in returns "``\(destination ?? "")``".
  • visitInlineHTML → appends rawHTML. Matches built-in. HTML inline tags are explicitly out of scope per the smolspec.
  • visitSoftBreak → appends " "; visitLineBreak → appends "\n". Both match the built-in plainText contract.

No override is needed for Emphasis, Strong, Strikethrough, Link, or Image because the default MarkupWalker.visit(_:) descends into their children. Link's URL never enters the label because the visitor never sees it — only the children (the link text) get visited. Image's alt-text falls out the same way; the source property is never accessed.

The whitespace trim/re-attach: segment.prefix(while: \.isWhitespace) for the leading prefix; segment.reversed().prefix(while: \.isWhitespace).count for the trailing length. Both linear over the slice. The guard leadingCount + trailingCount < segment.count falls through to return String(segment) for all-whitespace slices, preserving them verbatim.

Architecture Impact

  • Zero view-layer change. The FootnoteAccessibilityModifier in HighlightedInlineText.swift:238 still no-ops on empty entries; only the rebuilt-label content changes when there is a footnote.
  • Zero Localizable.xcstrings change. The localised inline phrase (a11y.footnote.inline %lld) is substituted in analyze directly, never passed through the helper, so translations stay intact.
  • Zero Textual fork change. Package.resolved untouched.
  • Zero public-API change. analyze, rebuiltAccessibilityLabel, and resolvedReferences signatures are identical.

Potential Issues

  • Reference-style links ([text][ref] with no inline definition in the slice). swift-markdown preserves them as bracketed text; VoiceOver narrates the brackets. Documented as out-of-scope behaviour in the smolspec.
  • Raw inline HTML (<em>foo</em>). The walker emits rawHTML verbatim. Rare in user markdown; out of scope per the smolspec.
  • Multi-paragraph slices. If a future caller passes a slice that spans a blank line, the walker visits multiple Paragraphs but doesn't insert separators. Current callers (segments between inline [^id] matches in one paragraph) never trigger this.

Important changes — detailed

renderedPlainText walks the inline AST with a MarkupWalker

prism/Services/FootnoteAccessibility.swift

Why it matters. Load-bearing: this is the entire behaviour change. The walker is what drops the markers VoiceOver was reading aloud.

What to look at. FootnoteAccessibility.swift:70-130

Takeaway. When swift-markdown's built-in `Markup.plainText` is almost right but leaves a few delimiters in place (InlineCode, SymbolLink), wrapping a `MarkupWalker` and overriding only the leaves is cheaper than re-deriving the AST traversal.
Rationale. Decision log Decision 1 documents the tradeoff vs ordered regex passes and vs reusing `Markup.plainText` directly. Both alternatives have known correctness gaps; the walker captures the minimum delta to handle InlineCode/SymbolLink without abandoning the built-in recursion for the wrappers.

analyze routes prose segments through the helper

prism/Services/FootnoteAccessibility.swift

Why it matters. Wire-up site. The two `label.append(...)` calls in `analyze` are the only behaviour-change call sites; the surrounding code (regex walk over `[^id]`, dedupe, localised phrase injection) is unchanged from T-1036.

What to look at. FootnoteAccessibility.swift:51-67

Takeaway. Constrain change scope by changing how an existing accumulator is fed, not by rewriting the walk that builds it. Tests for the existing contract (resolvedReferences, deduplication, ordering) continue to pass unmodified.
Rationale. Decision log explicitly rejects refactoring `analyze` into an AST-driven walk — would have forced re-testing the resolved-references contract.

Leading/trailing whitespace trim guards against indented-code-block parsing

prism/Services/FootnoteAccessibility.swift

Why it matters. Without this, a slice like `" leading-spaces **bold** [^a]"` would be parsed by `Document(parsing:)` as an indented code block, swallowing the prose into an opaque CodeBlock and producing the wrong label.

What to look at. FootnoteAccessibility.swift:84-96

Takeaway. When parsing arbitrary slices of a markdown string with a block-aware parser, trim outer whitespace first or you'll trip CommonMark block-construct heuristics (indented code blocks, ATX headings, etc.).
Rationale. Decision log Decision 2 documents this. The smolspec also notes it. A dedicated test (`indentedSegmentNotInterpretedAsCodeBlock`) locks the behaviour.

Test plan covers MUST-level constructs end-to-end with exact-match assertions

prismTests/FootnoteAccessibilityTests.swift

Why it matters. The pre-push review caught two MUST-level gaps in the original test suite (autolink and backslash escape). Three of the cases now assert exact rebuilt-label strings, locking the shape rather than just the absence of markers.

What to look at. FootnoteAccessibilityTests.swift:153-329

Takeaway. When reviewing tests for an accessibility-label rebuilder, `contains` / `!contains` assertions are weak — they leave room for off-by-one spacing, duplicated phrases, or substring leakage. Pin at least a handful of cases to exact strings.
Rationale. Spec-review feedback. Locks the visible shape of the spoken label.

Marks the parent ticket's known limitation as resolved

specs/footnote-badge-a11y/implementation.md

Why it matters. Replaces the T-1036 'Potential Issues' bullet that proposed a future stripper with a pointer to this ticket. Future readers searching for the asterisk-asterisk issue will find the resolution without rediscovering the gap.

What to look at. specs/footnote-badge-a11y/implementation.md:189-195

Takeaway. When closing a known-limitation note from an earlier ticket, replace the bullet rather than adding a new note — searches for the old text find the new spec, and the resolved limitation doesn't keep getting re-surfaced in future audits.
Rationale. Spec-review feedback. Keeps documentation truth aligned with code state.

Key decisions

MarkupWalker over `Markup.plainText`

The smolspec originally proposed Document(parsing:).plainText. Peer review found that swift-markdown's InlineCode.plainText returns "`\(code)`" and SymbolLink.plainText returns "``\(destination)``" — both surface raw markers to VoiceOver. A custom MarkupWalker with leaf overrides for those two nodes is the minimum delta. Emphasis, Strong, Strikethrough, Link, and Image need no override because the default MarkupWalker.visit(_:) recurses into children, which is exactly the unwrap behaviour the spoken label wants.

Trim outer whitespace before parsing each segment

Slices like " inside a list item" would otherwise be parsed by Document(parsing:) as indented code blocks (4+ leading spaces ⇒ CommonMark indented code block heuristic). The helper trims leading/trailing whitespace, parses the trimmed core, and re-attaches the whitespace around the collector's result so spacing between footnote substitutions survives intact.

Reject regex-based stripping (and reject reusing <code>MarkdownBlock.stripMarkdownFormatting</code>)

Decision log Alternatives Considered: an ordered sequence of regex substitutions has correctness gaps in (a) code spans containing markers (`**not bold**`), (b) non-greedy **…** miscount in nested emphasis, (c) reference-style links, (d) autolinks, and (e) backslash escapes. The repo already ships MarkdownBlock.stripMarkdownFormatting(_:) in prism/Models/MarkdownBlock.swift:767 (used by searchableText and TableRowContextQuote); running its regexes against the new test cases reproduces each gap, so it wasn't reusable.

Review findings

SeverityAreaFindingResolution
majorspecs/strip-md-markers-a11y/{smolspec,decision_log,tasks}.mdSpec/decision-log/tasks all said the helper uses `Document(parsing:).plainText` but the shipped code uses a custom MarkupWalker.Rewrote the Implementation Approach in smolspec.md; added the load-bearing InlineCode/SymbolLink rationale to decision_log.md Decision 1; updated tasks.md item 1 to describe the walker.
majorprismTests/FootnoteAccessibilityTests.swiftMissing tests for the MUST-level autolink and backslash-escape constructs.Added `autolinkBracketsStripped` and `backslashEscapeStripped`.
majorspecs/footnote-badge-a11y/implementation.mdParent T-1036 implementation note still listed the marker-survival as a known limitation, even though T-1259 resolves it.Replaced the bullet with a pointer to specs/strip-md-markers-a11y/.
minorprism/Services/FootnoteAccessibility.swiftHelper name `strippedInlineMarkdown` was misleading — it doesn't strip markers from code-span content; it renders inline markdown to plain text.Renamed to `renderedPlainText`.
minorprismTests/FootnoteAccessibilityTests.swift`footnotePhraseNotStripped` test was redundant once the other tests asserted on exact-match labels.Dropped. The remaining cases cover both the phrase substitution and the absence of stray `[^a]` brackets.
minorprismTests/FootnoteAccessibilityTests.swiftSeveral tests asserted only `contains` / `!contains`, leaving the label shape under-pinned.Tightened `strongMarkersStripped`, `whitespaceBetweenReferencesPreserved`, and the new `indentedSegmentNotInterpretedAsCodeBlock` to assert exact rebuilt-label strings via `String(localized: "a11y.footnote.inline \(n)")`.
minorspecs/strip-md-markers-a11y/decision_log.mdDecision log didn't mention the in-repo `MarkdownBlock.stripMarkdownFormatting` regex helper as a concrete example of the rejected alternative.Added the citation to Alternatives Considered.
minorMicrooptimisations in `renderedPlainText`Efficiency review suggested an `inout` accumulator and a marker-free fast path. Each is a small win; helper already runs on inline-paragraph-sized slices behind the modifier's no-op-on-empty-entries guard.Not applied. Decision logged as 'acceptable cost per render' in the smolspec's Risks and Assumptions; would inflate the diff without measurable benefit.

Per-file diffs

Click to expand.

prism/Services/FootnoteAccessibility.swift Modified +76 / -8
diff --git a/prism/Services/FootnoteAccessibility.swift b/prism/Services/FootnoteAccessibility.swift
index 683d459..0d2c649 100644
--- a/prism/Services/FootnoteAccessibility.swift
+++ b/prism/Services/FootnoteAccessibility.swift
@@ -6,18 +6,17 @@
 //

 import Foundation
+import Markdown

 /// 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.
-///
-/// Known limitation (tracked as T-1259): markdown markers (`**bold**`,
-/// `*italic*`, `` `code` ``) in surrounding prose are copied verbatim into
-/// the rebuilt label, so VoiceOver narrates the raw markers. A follow-up
-/// will strip common inline markdown markers before emitting the label.
+/// See `specs/footnote-badge-a11y/smolspec.md` for the design. Surrounding
+/// prose is run through `swift-markdown`'s `Markup.plainText` so VoiceOver
+/// narrates the visible text without inline markdown markers — see
+/// `specs/strip-md-markers-a11y/smolspec.md` (T-1259).
 enum FootnoteAccessibility {
     /// A single resolved footnote reference with its visible display number.
     struct Entry: Equatable, Sendable {
@@ -49,7 +48,7 @@ enum FootnoteAccessibility {
         var label = ""
         var cursor = markdown.startIndex
         for match in markdown.matches(of: FootnoteData.referencePattern) {
-            label.append(contentsOf: markdown[cursor..<match.range.lowerBound])
+            label.append(renderedPlainText(markdown[cursor..<match.range.lowerBound]))
             let identifier = String(match.1)
             if let definition = data.definition(for: identifier) {
                 if seen.insert(identifier).inserted {
@@ -64,10 +63,70 @@ enum FootnoteAccessibility {
             }
             cursor = match.range.upperBound
         }
-        label.append(contentsOf: markdown[cursor...])
+        label.append(renderedPlainText(markdown[cursor...]))
         return Analysis(entries: entries, rebuiltLabel: label)
     }

+    /// Renders `segment` to its visible text by parsing the inline markdown
+    /// and walking the AST. Wrapper elements (emphasis, strong, strikethrough,
+    /// link, image) contribute only their children's text; inline code spans
+    /// emit their literal `code` without the surrounding backticks; unbalanced
+    /// markers survive as literal text, matching the visual rendering. The
+    /// `InlineCode` override is the load-bearing one — swift-markdown's own
+    /// `InlineCode.plainText` re-wraps the code in backticks, which would
+    /// defeat the point.
+    ///
+    /// Leading and trailing whitespace are stripped from the slice before
+    /// parsing so `Document(parsing:)` doesn't treat 4+ leading spaces as an
+    /// indented code block, then re-attached around the result so spacing
+    /// between footnote substitutions survives intact.
+    private static func renderedPlainText(_ segment: Substring) -> String {
+        guard !segment.isEmpty else { return "" }
+        let leadingCount = segment.prefix(while: \.isWhitespace).count
+        let trailingCount = segment.reversed().prefix(while: \.isWhitespace).count
+        guard leadingCount + trailingCount < segment.count else {
+            return String(segment)
+        }
+        let core = segment.dropFirst(leadingCount).dropLast(trailingCount)
+        let leading = segment.prefix(leadingCount)
+        let trailing = segment.suffix(trailingCount)
+        var collector = InlinePlainTextCollector()
+        collector.visit(Document(parsing: String(core)))
+        return String(leading) + collector.result + String(trailing)
+    }
+
+    /// `MarkupWalker` that emits the visible text of an inline AST. Default
+    /// `visit(_:)` recurses into children, so wrapper elements (Emphasis,
+    /// Strong, Strikethrough, Link, Image) contribute only their inner text.
+    /// Leaf overrides emit code content without the `` ` `` delimiters.
+    private struct InlinePlainTextCollector: MarkupWalker {
+        var result = ""
+
+        mutating func visitText(_ text: Text) {
+            result.append(text.string)
+        }
+
+        mutating func visitInlineCode(_ inlineCode: InlineCode) {
+            result.append(inlineCode.code)
+        }
+
+        mutating func visitSymbolLink(_ symbolLink: SymbolLink) {
+            result.append(symbolLink.destination ?? "")
+        }
+
+        mutating func visitInlineHTML(_ inlineHTML: InlineHTML) {
+            result.append(inlineHTML.rawHTML)
+        }
+
+        mutating func visitSoftBreak(_ softBreak: SoftBreak) {
+            result.append(" ")
+        }
+
+        mutating func visitLineBreak(_ lineBreak: LineBreak) {
+            result.append("\n")
+        }
+    }
+
     /// Returns one `Entry` per unique footnote identifier referenced in
     /// `markdown`, in first-occurrence order. Identifiers without a matching
     /// definition in `data` are excluded.
prismTests/FootnoteAccessibilityTests.swift Modified +177 / -0
diff --git a/prismTests/FootnoteAccessibilityTests.swift b/prismTests/FootnoteAccessibilityTests.swift
index 2b7a507..24039c7 100644
--- a/prismTests/FootnoteAccessibilityTests.swift
+++ b/prismTests/FootnoteAccessibilityTests.swift
@@ -147,4 +147,183 @@ struct FootnoteAccessibilityTests {
         )
         #expect(result == markdown)
     }
+
+    // MARK: - Inline markdown marker stripping (T-1259)
+
+    @Test("Strong emphasis markers are stripped from surrounding prose")
+    func strongMarkersStripped() {
+        // Exact-match assertion to pin the rebuilt-label shape (prose
+        // preserved, footnote phrase appended in place of `[^a]`).
+        let markdown = "Read **bold** [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let phrase = String(localized: "a11y.footnote.inline \(1)")
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(result == "Read bold \(phrase).")
+    }
+
+    @Test("Emphasis markers are stripped from surrounding prose")
+    func emphasisMarkersStripped() {
+        let markdown = "Read *italic* [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        // Asterisks around italic are gone; no leftover stray *
+        #expect(!result.contains("*"))
+        #expect(result.contains("italic"))
+    }
+
+    @Test("Inline code delimiters stripped, code body emitted verbatim")
+    func inlineCodeStripped() {
+        // Inside backticks the asterisks are literal user content, so the
+        // spoken label keeps them — only the surrounding ` delimiters are
+        // removed.
+        let markdown = "Run `**not bold**` [^a] now."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("`"))
+        #expect(result.contains("**not bold**"))
+        #expect(result.hasPrefix("Run "))
+    }
+
+    @Test("Link reduces to its visible text, URL is dropped")
+    func linkUrlDropped() {
+        let markdown = "See [the docs](https://example.com) [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("https://example.com"))
+        #expect(!result.contains("["))
+        #expect(!result.contains("]"))
+        #expect(!result.contains("("))
+        #expect(!result.contains(")"))
+        #expect(result.contains("the docs"))
+    }
+
+    @Test("Image reduces to its alt text, source is dropped")
+    func imageReducesToAltText() {
+        let markdown = "Spans ![diagram](img.png) [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("img.png"))
+        #expect(!result.contains("!["))
+        #expect(result.contains("diagram"))
+    }
+
+    @Test("Strikethrough markers are stripped")
+    func strikethroughStripped() {
+        let markdown = "Note ~~old~~ [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("~~"))
+        #expect(result.contains("old"))
+    }
+
+    @Test("Unbalanced markers are preserved as literal text")
+    func unbalancedMarkersPreserved() {
+        // `*broken` has no closing asterisk; swift-markdown renders the
+        // asterisk literally, so the spoken label should too.
+        let markdown = "One *broken [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(result.contains("*broken"))
+    }
+
+    @Test("Nested emphasis inside strong both stripped")
+    func nestedEmphasisStripped() {
+        let markdown = "Mixed **bold *italic* end** [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("*"))
+        #expect(result.contains("bold "))
+        #expect(result.contains("italic"))
+        #expect(result.contains(" end"))
+    }
+
+    @Test("Link text containing strong is fully unwrapped")
+    func linkContainingStrongStripped() {
+        let markdown = "Link [**bold link**](https://x) [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("*"))
+        #expect(!result.contains("https://x"))
+        #expect(result.contains("bold link"))
+    }
+
+    @Test("Whitespace between adjacent footnote references is preserved")
+    func whitespaceBetweenReferencesPreserved() {
+        let markdown = "First [^a], then [^b]."
+        let data = Self.footnoteData([("a", 1), ("b", 2)])
+        let phrase1 = String(localized: "a11y.footnote.inline \(1)")
+        let phrase2 = String(localized: "a11y.footnote.inline \(2)")
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(result == "First \(phrase1), then \(phrase2).")
+    }
+
+    @Test("Autolink renders without the angle brackets")
+    func autolinkBracketsStripped() {
+        let markdown = "Visit <https://example.com> [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("<https://"))
+        #expect(!result.contains(">"))
+        #expect(result.contains("https://example.com"))
+    }
+
+    @Test("Backslash escape emits the literal character without the backslash")
+    func backslashEscapeStripped() {
+        let markdown = "A \\*literal\\* [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(!result.contains("\\"))
+        #expect(result.contains("*literal*"))
+    }
+
+    @Test("Indented segment doesn't parse as a code block")
+    func indentedSegmentNotInterpretedAsCodeBlock() {
+        // The stripper trims leading/trailing whitespace before parsing,
+        // so a segment with 4+ leading spaces is rendered as inline text
+        // rather than swallowed by an indented code block.
+        let markdown = "    leading-spaces **bold** [^a]."
+        let data = Self.footnoteData([("a", 1)])
+        let phrase = String(localized: "a11y.footnote.inline \(1)")
+        let result = FootnoteAccessibility.rebuiltAccessibilityLabel(
+            for: markdown,
+            data: data
+        )
+        #expect(result == "    leading-spaces bold \(phrase).")
+    }
 }
docs/agent-notes/footnotes.md Modified +1 / -0
diff --git a/docs/agent-notes/footnotes.md b/docs/agent-notes/footnotes.md
index a40e6ef..af41d78 100644
--- a/docs/agent-notes/footnotes.md
+++ b/docs/agent-notes/footnotes.md
@@ -34,4 +34,5 @@ The Textual fork pin was updated from `6b0d7ce` (pre-footnote) to `69009d4` (inc
 - 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.
+- Surrounding-prose segments between resolved `[^id]` references are run through a private `swift-markdown`-driven `InlinePlainTextCollector` (T-1259) before being appended to the rebuilt label. The collector is a `MarkupWalker` that emits the visible text of each inline construct: emphasis/strong/strikethrough/link/image wrappers fall away because the walker recurses into their children, and inline code spans emit their literal `code` content without the surrounding backticks (swift-markdown's own `InlineCode.plainText` keeps the backticks, which would defeat the point). Unbalanced markers survive as literal text, matching the visual rendering. Leading/trailing whitespace is preserved around the parsed segment so footnote substitutions don't lose their surrounding spaces. See `specs/strip-md-markers-a11y/`.
 - 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.
specs/footnote-badge-a11y/implementation.md Modified +5 / -7
diff --git a/specs/footnote-badge-a11y/implementation.md b/specs/footnote-badge-a11y/implementation.md
index 1dc990c..143a4e6 100644
--- a/specs/footnote-badge-a11y/implementation.md
+++ b/specs/footnote-badge-a11y/implementation.md
@@ -188,13 +188,11 @@ for a given parsed block, so view identity is preserved across renders.
   VoiceOver in this session.
 - **Overriding `.accessibilityLabel` replaces SwiftUI's auto-derived
   narration.** Once footnotes appear in a paragraph, VoiceOver no
-  longer infers anything from inline markup. The label-rebuilder
-  copies non-footnote text verbatim from the markdown source, which
-  means markdown markers (`**bold**`, `*italic*`, ``code``) survive
-  unstyled into the spoken label. If this turns out to be audibly
-  awful (asterisks being read as "asterisk asterisk"), the
-  rebuilder will need a small inline-markdown stripper before it
-  emits the label.
+  longer infers anything from inline markup. Resolved by T-1259
+  (`specs/strip-md-markers-a11y/`): the label-rebuilder now runs each
+  surrounding-prose segment through a `swift-markdown` `MarkupWalker`
+  so emphasis/strong/code/link/image/strikethrough markers no longer
+  reach VoiceOver's spoken label.
 - **Switch Control / FKA gap is known and unaddressed.** Per-paragraph
   `.accessibilityActions` is a VoiceOver rotor surface; users of those
   input methods still need an inline focus target. The audit owners
specs/strip-md-markers-a11y/smolspec.md Added +159 / -0
diff --git a/specs/strip-md-markers-a11y/smolspec.md b/specs/strip-md-markers-a11y/smolspec.md
new file mode 100644
index 0000000..b0303cd
--- /dev/null
+++ b/specs/strip-md-markers-a11y/smolspec.md
@@ -0,0 +1,162 @@
+# Strip Inline Markdown Markers from Footnote Accessibility Label (T-1259)
+
+## Overview
+
+`FootnoteAccessibility.analyze(markdown:data:)` copies non-footnote prose
+verbatim from the source markdown into the paragraph's rebuilt VoiceOver
+label. When a paragraph contains inline markdown formatting (e.g.
+`**bold**`, `*italic*`, `` `code` ``, `[text](url)`) and at least one
+resolvable footnote reference, the modifier overrides SwiftUI's
+auto-derived narration with a string still carrying the raw markers, so
+VoiceOver reads `"asterisk asterisk bold asterisk asterisk"`. This change
+parses each surrounding-prose segment with `swift-markdown` and emits its
+`plainText` into the rebuilt label, so the spoken paragraph matches what
+the user sees on screen. Follow-up to T-1036 / PR #256, audit finding C2
+in `docs/accessibility-review.md`.
+
+## Requirements
+
+- The rebuilt label produced by
+  `FootnoteAccessibility.analyze(markdown:data:)` MUST narrate the visible
+  text content of each supported inline markdown construct without the
+  raw delimiter characters. Supported constructs: emphasis (`*…*`,
+  `_…_`), strong (`**…**`, `__…__`), inline code (`` `…` ``),
+  strikethrough (`~~…~~`), inline link (`[text](url)` → `text`), image
+  (`![alt](src)` → `alt`), and autolink (`<https://x>` → `https://x`).
+- Source text that is not part of a recognised inline construct (stray
+  asterisks, literal brackets, prose, punctuation) MUST be emitted into
+  the rebuilt label as written. Backslash escapes such as `\*` MUST emit
+  the literal escaped character without the backslash, matching how
+  `swift-markdown` renders the visible text.
+- Inline code content MUST be emitted verbatim (e.g. `` `**not bold**` ``
+  narrates as `"asterisk asterisk not bold asterisk asterisk"`), because
+  inside code spans the markers are literal user content, not styling.
+- The localised inline footnote phrase (e.g. `"footnote 1"`) substituted
+  in place of each resolvable `[^id]` MUST NOT be processed by the
+  stripper, so localisations containing marker-like characters are
+  preserved.
+- Unresolved `[^id]` runs MUST continue to be copied through unchanged,
+  as they are today (no styling, no substitution).
+- Behaviour when the paragraph has no resolvable references MUST be
+  unchanged — the call-site modifier
+  (`FootnoteAccessibilityModifier` in
+  `prism/Views/HighlightedInlineText.swift:238`) still no-ops on empty
+  entries, so VoiceOver's auto-derived narration is preserved for plain
+  paragraphs.
+
+## Implementation Approach
+
+**Single source file changed**: `prism/Services/FootnoteAccessibility.swift`.
+
+Add `import Markdown` and a private helper
+`renderedPlainText(_ segment: Substring) -> String` that parses the
+segment with `Document(parsing: String(segment))` and walks the inline
+AST with a private `InlinePlainTextCollector: MarkupWalker`. Wrapper
+elements (emphasis, strong, strikethrough, link, image) fall away
+naturally because the default `MarkupWalker.visit(_:)` recurses into
+their children. Leaf overrides emit the visible text:
+
+- `visitText`, `visitSoftBreak` (→ `" "`), `visitLineBreak` (→ `"\n"`),
+  `visitInlineHTML` — emit the textual content.
+- `visitInlineCode` emits the literal `code` content **without** the
+  surrounding backticks. swift-markdown's own `InlineCode.plainText`
+  re-wraps in backticks (see
+  `swift-markdown/.../Inline Leaves/InlineCode.swift`), which would
+  defeat the point of the helper.
+- `visitSymbolLink` emits the destination without the double backticks
+  that `SymbolLink.plainText` would inject.
+
+`swift-markdown` is already a project dependency, imported by
+`prism/Services/MarkdownBlockParser.swift:9`,
+`prism/Services/DetailsBlockParser.swift:9`, and
+`prism/Services/ExportSourceMapper.swift:12`.
+
+Update `analyze(markdown:data:)` so the two `label.append(...)` calls
+that currently splice raw `markdown[cursor..<match.range.lowerBound]` and
+`markdown[cursor...]` route through `renderedPlainText`. The localised
+phrase substituted in place of each resolvable `[^id]` is appended
+directly, not via the helper, so translations are untouched.
+
+Leading and trailing whitespace are trimmed from the slice before
+parsing so `Document(parsing:)` doesn't treat 4+ leading spaces as an
+indented code block, then re-attached around the collector's result so
+spacing between footnote substitutions survives intact.
+
+The empty-`FootnoteData` early return continues to bypass the helper and
+return the unchanged `markdown`, matching today's behaviour for the
+plain-paragraph path.
+
+Unit tests extend `prismTests/FootnoteAccessibilityTests.swift` with new
+cases listed in the test plan below.
+
+**Out of scope:**
+
+- Changing the regex-driven walk in `analyze()` to an AST-driven walk
+  over the whole paragraph. Per-segment parsing keeps the existing
+  entry-extraction logic and tests intact while still using the AST for
+  the surrounding prose.
+- Touching the Textual fork, `HighlightedInlineText`, or
+  `Localizable.xcstrings`. The behaviour change is contained to the
+  helper and the two `label.append` call sites.
+- Reference-style links `[text][ref]` with no inline definition in the
+  segment. `swift-markdown` preserves the bracketed text when the
+  reference is undefined; the rebuilt label keeps the same bracketed
+  shape and VoiceOver narrates the brackets. Documented here so the
+  behaviour is intentional, not accidental.
+- Other audit items (C1 / T-1035 heading semantics; C3 / T-1037 badge
+  contrast; Switch Control / FKA focus).
+
+## Risks and Assumptions
+
+- **Risk**: A prose segment slices a markdown construct in half (e.g.
+  the closing `**` of a strong span lives after the next `[^id]`). The
+  helper parses the partial segment and emits the unbalanced markers as
+  literal text. **Mitigation**: This matches the rendered behaviour —
+  `swift-markdown` already treats unbalanced markers as literal text in
+  the rendered paragraph, so the spoken label tracks the visual output.
+  Documented in the test plan with an explicit unbalanced-marker case.
+- **Risk**: Per-segment parsing creates many short-lived `Document`
+  values per render. **Mitigation**: Acceptable — typical paragraphs
+  have ≤ 3 footnotes, so ≤ 4 segments per paragraph. The cost is
+  dominated by SwiftUI's per-render work, same hot-path assumption as
+  the original smolspec for T-1036 in
+  `specs/footnote-badge-a11y/smolspec.md`. The modifier still short-
+  circuits on `entries.isEmpty`, so the helper never runs for plain
+  paragraphs.
+- **Risk**: `Markup.plainText` could change semantics in a future
+  `swift-markdown` upgrade and quietly alter spoken labels. **Mitigation**:
+  Pinned `swift-markdown 0.7.3` in `Package.resolved`; the unit tests
+  encode the expected substitutions so a behaviour shift surfaces as a
+  test failure, not a silent regression.
+- **Assumption**: `Document(parsing:).plainText` preserves leading and
+  trailing whitespace within a single-paragraph fragment. The test
+  plan includes a case ("`First [^a], then [^b].`") that asserts the
+  surrounding spaces are intact.
+- **Prerequisite**: `FootnoteAccessibility.analyze` from T-1036 is on
+  `main` (commit `974b6a6` and prior).
+
+### Test plan
+
+Extend `prismTests/FootnoteAccessibilityTests.swift`. Each case asserts
+on the exact `rebuiltLabel` produced by `analyze`:
+
+| Input (markdown)                                         | Expected rebuilt label                                |
+| -------------------------------------------------------- | ----------------------------------------------------- |
+| `Read **bold** [^a].`                                    | `Read bold ${phrase(1)}.`                             |
+| `Run `` `code` `` [^a] now.`                             | ``Run code ${phrase(1)} now.``                        |
+| `See [docs](http://x) [^a].`                             | `See docs ${phrase(1)}.`                              |
+| `Spans ![alt](img.png) [^a].`                            | `Spans alt ${phrase(1)}.`                             |
+| `Note ~~old~~ [^a].`                                     | `Note old ${phrase(1)}.`                              |
+| `One *broken [^a].`                                      | `One *broken ${phrase(1)}.` (unbalanced kept)         |
+| `` Quote `**inside**` [^a]. ``                           | `Quote **inside** ${phrase(1)}.` (code is literal)    |
+| `First [^a], then [^b].`                                 | `First ${phrase(1)}, then ${phrase(2)}.` (whitespace) |
+| `Mixed **bold *italic* end** [^a].`                      | `Mixed bold italic end ${phrase(1)}.`                 |
+| `Link [**bold link**](u) [^a].`                          | `Link bold link ${phrase(1)}.`                        |
+
+`${phrase(n)}` is the value of
+`String(localized: "a11y.footnote.inline %lld", defaultValue: "footnote %d")`
+for `n`, matching the existing helper at
+`FootnoteAccessibility.swift:97-100`. Tests SHOULD assert on prefix /
+suffix / `contains(phrase(n))` (consistent with the existing
+`rebuiltAccessibilityLabel` tests) so they remain stable across
+localisation tweaks.
specs/strip-md-markers-a11y/decision_log.md Added +138 / -0
diff --git a/specs/strip-md-markers-a11y/decision_log.md b/specs/strip-md-markers-a11y/decision_log.md
new file mode 100644
index 0000000..a446185
--- /dev/null
+++ b/specs/strip-md-markers-a11y/decision_log.md
@@ -0,0 +1,158 @@
+# Decision Log: Strip Inline Markdown Markers from Footnote Accessibility Label
+
+## Decision 1: Use `swift-markdown` AST via `Markup.plainText` rather than regex passes
+
+**Date**: 2026-05-18
+**Status**: accepted
+
+### Context
+
+`FootnoteAccessibility.analyze(markdown:data:)` (added in T-1036) appends
+non-footnote prose verbatim into the paragraph's overridden VoiceOver
+label. When the prose contains inline markdown formatting, the raw
+delimiter characters survive into the spoken label and VoiceOver narrates
+them ("asterisk asterisk bold asterisk asterisk"). The T-1259 chore
+brief proposed two implementation styles: a small inline-markdown
+stripper that runs an ordered sequence of regex substitutions over the
+common marker characters, or a parser-based extraction.
+
+### Decision
+
+Add `import Markdown` to `FootnoteAccessibility.swift` and parse each
+surrounding-prose segment with `Document(parsing: String(segment))`. A
+private `MarkupWalker` (`InlinePlainTextCollector`) walks the inline AST
+and emits visible text — wrapper elements (emphasis, strong,
+strikethrough, link, image) fall away because the default
+`MarkupWalker.visit(_:)` recurses into children; leaf overrides handle
+text/softbreak/linebreak/HTML and strip the delimiters around `InlineCode`
+and `SymbolLink` so the spoken label drops backticks. Leave the existing
+regex-driven walk over footnote references intact.
+
+### Rationale
+
+`swift-markdown` is already a project dependency, used by the same
+`prism/Services/` directory (`MarkdownBlockParser.swift:9`,
+`DetailsBlockParser.swift:9`, `ExportSourceMapper.swift:12`).
+`Markup.plainText` is the standard library API the project uses at
+`MarkdownBlockParser.swift:493,504,515` to extract visible text from
+inline AST. The walker is a thin extension of that idiom because
+swift-markdown's `InlineCode.plainText` re-wraps the literal text in
+backticks (see
+`swift-markdown/.../Inline Leaves/InlineCode.swift:51-53`) and
+`SymbolLink.plainText` re-wraps the destination in double backticks —
+both surface raw markers to VoiceOver. Otherwise the parser handles
+every case the regex approach would need to re-invent: nested emphasis,
+code-span literals (where `**not bold**` inside backticks must stay
+literal), link text vs. URL, image alt text, autolinks, backslash
+escapes, intra-word underscores, and unbalanced markers. The lever
+already exists; there is no value in reimplementing it less correctly.
+
+### Alternatives Considered
+
+- **Ordered regex substitutions over common markers** (the original
+  brief's first suggestion): Rejected after peer review surfaced several
+  correctness gaps. The code-span-first ordering still exposes
+  `` `**not bold**` `` to the later strong pass; non-greedy `\*\*…\*\*`
+  miscounts in `**outer *inner* outer**`; reference-style links
+  (`[t][ref]`), autolinks (`<https://x>`), and backslash escapes
+  (`\*literal\*`) would each need their own pass. Each new pass is more
+  code, more tests, and a wider risk of mangling unrelated punctuation.
+  Concrete in-repo example of that approach:
+  `MarkdownBlock.stripMarkdownFormatting(_:)` at
+  `prism/Models/MarkdownBlock.swift:767` (used by
+  `MarkdownBlock.searchableText` and `TableRowContextQuote.stripMarkdown`)
+  — running its regexes against the new test cases reproduces each gap
+  above, so reusing it would regress the spoken-label contract.
+- **Reuse `Markup.plainText` directly** (no custom walker): Rejected for
+  the `InlineCode`/`SymbolLink` backtick wrapping above. A minimal
+  walker keeps the AST recursion that the protocol already provides
+  while letting the two leaves drop their delimiters.
+- **Refactor `analyze()` to walk a single `Document` parse of the whole
+  paragraph**: Rejected for scope. The current entry-extraction tests
+  exercise the regex walk; replacing that walk would force a re-test of
+  the resolved-references contract and risk regressing the dedupe/order
+  guarantees. Per-segment parsing keeps the contract narrow.
+
+### Consequences
+
+**Positive:**
+- Correctness across all the inline constructs `swift-markdown` already
+  renders. No bespoke handling for nested emphasis or code-span
+  literals.
+- Spoken label tracks the visual rendering by construction — both
+  surfaces share the same parser.
+- Implementation reduces to one helper plus two call sites; the existing
+  entry-extraction walk and tests are unchanged.
+
+**Negative:**
+- Each render path that contains footnotes parses N + 1 short
+  `Document`s (one per prose segment). Acceptable given typical
+  paragraphs have ≤ 3 footnotes and the modifier still no-ops on
+  plain paragraphs.
+- Couples the accessibility label tightly to `Markup.plainText` semantics.
+  Mitigated by pinned `swift-markdown 0.7.3` and the test suite asserting
+  expected substitutions, so a future upgrade surfaces as a test
+  failure rather than a silent narration shift.
+
+### Impact
+
+Scope is contained to `prism/Services/FootnoteAccessibility.swift` and
+new cases in `prismTests/FootnoteAccessibilityTests.swift`. No view-layer
+changes, no localisation changes, no fork changes, no public-API change
+(the existing `analyze` / `rebuiltAccessibilityLabel` /
+`resolvedReferences` signatures are preserved).
+
+---
+
+## Decision 2: Trim and re-attach outer whitespace around the parsed segment
+
+**Date**: 2026-05-18
+**Status**: accepted
+
+### Context
+
+The surrounding-prose segments passed to `renderedPlainText(_:)` are
+slices of the original markdown taken between `[^id]` matches. Many of
+these slices start or end with whitespace (e.g. `", then "`). Feeding
+`Document(parsing:)` raw slices that start with four or more spaces
+would cause CommonMark to treat the segment as an indented code block,
+swallowing the prose into a `CodeBlock` whose plain-text shape is
+unsuitable for the spoken label.
+
+### Decision
+
+`renderedPlainText` trims leading and trailing whitespace from the slice
+before parsing, runs the walker over the trimmed core, and re-attaches
+the original whitespace prefix and suffix around the collector's result.
+
+### Rationale
+
+Trimming before parse keeps `Document(parsing:)` on the inline path
+regardless of the slice's offset in the source paragraph. Re-attaching
+whitespace preserves the spacing the user sees on screen (and matches
+the rendered paragraph's surface form), so the spoken label tracks the
+visible label one-for-one. The Substring `prefix(while:)` /
+`reversed().prefix(while:).count` walks are linear over already-hot
+characters and run on inline-paragraph-sized slices.
+
+### Alternatives Considered
+
+- **Parse the whole paragraph once and walk it**: discussed in
+  Decision 1 — out of scope for this chore.
+- **Skip the trim and accept the indented-code-block edge case**:
+  Rejected because slices with 4+ leading spaces appear in real
+  paragraphs (poetry, formatted lists embedded in prose). Hiding the
+  content from VoiceOver would regress accessibility silently.
+
+### Consequences
+
+**Positive:**
+- Visual spacing in the source paragraph survives into the spoken label.
+- The indented-code-block trap is closed; the test
+  `indentedSegmentNotInterpretedAsCodeBlock` locks the behaviour.
+
+**Negative:**
+- Two extra `prefix(while:)` walks per segment. Negligible at typical
+  paragraph sizes.
+
+---
specs/strip-md-markers-a11y/tasks.md Added +30 / -0
diff --git a/specs/strip-md-markers-a11y/tasks.md b/specs/strip-md-markers-a11y/tasks.md
new file mode 100644
index 0000000..709ef55
--- /dev/null
+++ b/specs/strip-md-markers-a11y/tasks.md
@@ -0,0 +1,29 @@
+---
+references:
+    - specs/strip-md-markers-a11y/smolspec.md
+    - specs/strip-md-markers-a11y/decision_log.md
+---
+# Strip Inline Markdown Markers from Footnote Accessibility Label (T-1259)
+
+- [ ] 1. Inline-markdown stripper helper with unit tests
+  - Add `import Markdown` to `prism/Services/FootnoteAccessibility.swift` and a private static helper `renderedPlainText(_:)` that parses a `Substring` with `Document(parsing:)` and walks the inline AST with a private `InlinePlainTextCollector: MarkupWalker`. Leaf overrides emit text/softbreak/linebreak/HTML; explicit overrides on `visitInlineCode` and `visitSymbolLink` drop the delimiters that swift-markdown's built-in `plainText` would re-wrap. Pattern reference: `prism/Services/MarkdownBlockParser.swift:493,504,515`.
+  - Wire the helper into `analyze(markdown:data:)` so both `label.append(...)` sites that splice raw `markdown[…]` route through the helper. The substituted localised footnote phrase must NOT pass through the helper.
+  - Trim leading/trailing whitespace from the slice before parsing so `Document(parsing:)` doesn't treat 4+ leading spaces as an indented code block; re-attach the same whitespace around the collector's result.
+  - Add fixtures in `prismTests/FootnoteAccessibilityTests.swift` covering: emphasis/strong (exact-match), inline code with literal asterisks preserved, link text vs. URL, image alt vs. src, strikethrough, autolink, backslash escape, unbalanced markers, nested emphasis, link text containing strong, whitespace preservation between two footnote refs (exact-match), and an indented segment to guard the code-block edge case (exact-match). Assertions use a mix of prefix/suffix/`contains` and explicit `==` so they remain stable across localisation tweaks while still pinning the rebuilt-label shape.
+  - Verification: `make test-quick` stays green (or, given the project's known parallel-runner flakiness, isolated `xcodebuild test -only-testing:prismTests/FootnoteAccessibilityTests` plus `make build-macos` / `make build-ios` clean).
+
+- [ ] 2. Sync the agent-notes footnotes entry
+  - Update `docs/agent-notes/footnotes.md` to note that the rebuilt VoiceOver label now strips inline markdown markers via `swift-markdown`'s `Markup.plainText`. Reference: `specs/strip-md-markers-a11y/smolspec.md` and `specs/strip-md-markers-a11y/decision_log.md`.
+  - Replace the doc-comment caveat at the top of `prism/Services/FootnoteAccessibility.swift:17-20` (the 'Known limitation' block referring to T-1259) with a one-line note describing the current behaviour and the helper that handles it.
+  - Verification: file diffs show only the doc-comment / agent-note edits; no behaviour change.
+
+- [ ] 3. Dual-platform build and lint clean
+  - Run `make lint`, `make build-ios`, and `make build-macos` with zero warnings and zero errors.
+  - Spot-check that no unrelated warnings have appeared in `FootnoteAccessibility.swift` or `HighlightedInlineText.swift`.
+  - Verification: each command exits 0 with clean output.
+
+- [ ] 4. VoiceOver verification on iOS and macOS
+  - iOS Simulator (iPhone 17, iOS 26): open a document with a paragraph mixing `**bold**`, `*italic*`, `` `code` ``, `[text](url)`, and at least one `[^id]` reference. Enable VoiceOver and confirm the paragraph reads without 'asterisk' / 'backtick' narration; footnote phrases still read as 'footnote N'.
+  - macOS: repeat the same check with VoiceOver.
+  - Repeat both checks with search active so the second `HighlightedInlineText` render path is covered.
+  - Verification: spoken labels match the visual paragraph for all four supported constructs plus the unbalanced-marker fixture.
specs/strip-md-markers-a11y/implementation.md Added +220 / -0

Things to double-check

VoiceOver manual verification (task 4)

Tasks file still has [ ] 4. VoiceOver verification on iOS and macOS unchecked. The unit tests pin the deterministic helper output, but only an iOS Simulator / macOS run with VoiceOver enabled can confirm what the screen reader actually narrates. Recommended before merge if a device is available; otherwise after merge, on the integration milestone covering both T-1036 and T-1259.

<code>make test-quick</code> parallel-runner flakiness

The Prism project's make test-quick target reports every FootnoteAccessibilityTests case as failed in 0.000 seconds when run via xcbeautify's parallel runner — even though every case passes when the same suite is run via xcodebuild test -only-testing:prismTests/FootnoteAccessibilityTests. Documented in memory/blitz_test_timeout.md. Verification here relied on the isolated run plus make build-macos/make build-ios as the project's known-reliable signal.