prism branch worktree-T-1034+tables-in-list-items commits 2 (+working tree) files 9 touched lines +348 / -0 (committed) + small post-review edits

Pre-push review: T-1034 tables in list items

Two commits implementing T-1034 (render markdown tables nested inside list items). Parser branch + state-owning view wrapper + four parser tests. Pre-push review fixed four findings (one major doc drift, two minor, one nit) and applied them in the working tree.

At a glance

  • Parser fix: MarkdownBlockParser.convertListItem gains an else if let table = child as? Table branch (3 lines) that wraps the table as a .block(.table) child instead of stringifying it.
  • Renderer fix: ListBlockView.nestedBlockView gains a .table case that delegates to a new private struct NestedTableBlockView wrapper. The wrapper owns @State for TableDisplayMode (which a @ViewBuilder func can't hold) and hides the row-indicator column by relying on AccessibleTableView.hasIndicators's default-gating.
  • Tests: 4 new tests in a new top-level TableInListItemTests struct (extracted from the parent to stay under SwiftLint's 700-line struct-body limit). Includes a structural hash-pin guard against silent ID drift.
  • Known migration consequence: Any existing note attached to a list item containing a table will orphan after this change — the item's contentForHashing necessarily shifts. Accepted in decision_log.md Decision 1; no clean recovery path.

Verdict

Ready to push — pending a clean test-suite run

SwiftLint clean, both make build-ios and make build-macos pass with zero warnings, and xcodebuild build-for-testing compiles every file (including the 4 new tests). However, xcodebuild test execution couldn't be confirmed on this machine — it hangs at Timed out after 120.0s while initiating control session with daemon in the Xcode test runner, which is an environmental issue not a code issue. Recommend running make test-quick once on a machine with healthy Xcode daemons before pushing. All four review findings were addressed in-tree.

Review findings

5 raised · 4 fixed · 1 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism is a markdown viewer. When users wrote a table inside a bullet point — common in meeting notes or documentation — Prism showed it as raw text with literal pipe characters instead of as a formatted table. This change makes those nested tables render the same way they do at the top level of a document.

Why It Matters

Documents naturally mix structures (lists with tables inside). The previous behaviour made common real-world documents look visually broken.

Key Concepts

  • AST: The parser converts markdown into a tree of typed objects (paragraph, table, code block, list item). The renderer walks that tree.
  • List item children: A list item can contain other block-level things — code blocks, blockquotes, sublists, and (now) tables.
  • Per-row notes: A Prism feature for attaching notes to specific table rows. Deliberately not enabled for nested tables in this change — that's a future enhancement.

Changes Overview

Two source files plus a test file. MarkdownBlockParser.convertListItem gets one new as? Table branch that calls the existing convertTable(_:) helper. ListBlockView.nestedBlockView gets a .table case that delegates to a new private struct NestedTableBlockView defined in the same file. ListItemNestedBlocksTests.swift gains a new top-level TableInListItemTests struct with four tests.

Implementation Approach

The wrapper view is the only non-obvious piece. AccessibleTableView requires a Binding<TableDisplayMode> so users can toggle fitted/readable/wide. The top-level call site (TextualBlockView) owns the state and passes the binding down. nestedBlockView is a @ViewBuilder func — not a View — so it can't hold @State itself. NestedTableBlockView's entire job is to own that state. Its custom init initialises _displayMode via State(initialValue: TableDisplayMode.initialMode(headers:rows:)) so each nested table starts in the right mode for its size. It then delegates to AccessibleTableView without blockId / onRowIndicatorTap; the existing hasIndicators predicate (!blockId.isEmpty && onRowIndicatorTap != nil) gates the indicator column off automatically.

Trade-offs

The notes-orphaning consequence is the only meaningful one: moving the table from the item's content string into children changes contentForHashing, which changes the list block's id, which changes every item ID derived from it. Notes attached to such items are orphaned. Decision log accepts this, citing the same trade-off accepted when code blocks moved out of content in Issue #25. No clean migration exists because the old hash isn't reconstructible from the new AST.

Technical Deep Dive

Parser branch placement: The new branch sits between the BlockQuote branch and the trailing stringification fallback. Placement order is correctness-neutral today (Table doesn't subclass earlier match types), but a future swift-markdown Table subtype with custom behaviour would want to live ahead of Table. The branch reuses convertTable(_:), the same helper the top-level path uses.

State identity on the wrapper: State(initialValue:) inside a custom init is the canonical SwiftUI idiom for deferring state construction to view-identity. init runs on every re-render, but State only honours initialValue: on the first call for a given identity slot. TableDisplayMode.initialMode re-runs each time (an O(headers + rows*cols) .count scan) and the result is discarded — trivial for realistic nested-table sizes and matches the existing top-level pattern.

Indicator-column gating: AccessibleTableView's default values (blockId: "", rowNotes: [:], onRowIndicatorTap: nil) plus the hasIndicators computed predicate form a load-bearing contract. Six existing previews and now NestedTableBlockView rely on this. A future change flipping either default would silently turn on the indicator column in nested contexts; the smolspec's Out-of-Scope section is the documented guard.

Hash-stability regression guard: The test pins the substring [0:B:table:col a,col b:x,y:leading|leading] plus a list:false: prefix. This catches changes to MarkdownBlock.contentForHashing's notation, .table's id format, ColumnAlignment.hashToken's tokens, AND a regression of the fix itself (stringification fallback returning would remove the substring). An inline pointer comment names the three canonical format sources for debuggability.

Architecture Impact

Localised additive change. No new public surface, no new abstractions. Does not extend to: <details> blocks (already handled by DetailsBlockParser), tables-in-blockquotes-in-list-items (out of scope), or footnote popovers (intentionally restricted renderer). The notes-orphaning consequence re-confirms a pattern set by Issue #25 — content-based identity for list items is convenient but every parser change re-routing content between content and children is an implicit note-ID migration.

Potential Issues

  • Runtime test gap: Builds and lint clean; test execution unconfirmed on this machine (Xcode daemon hang). Needs make test-quick on a clean environment before merge.
  • Wide nested tables: Rendered at the item's indent column, which is narrower than page width. .readable mode falls back to a horizontal ScrollView (horizontal-inside-vertical), which works but can feel cramped — UX pass worth considering.
  • No notes-orphaning telemetry: Users will discover the orphaning ad hoc. If it bites more users than expected, Decision 1's "Alternatives Considered" still lists the migration shim option.
  • No view-layer smoke test: A compile failure in AccessibleTableView would catch some regressions; a behavioural regression (indicator column re-appearing) would only show in manual visual check.

Important changes — detailed

Parser: route Table through .block(.table) instead of stringifying

prism/Services/MarkdownBlockParser.swift

Why it matters. The original bug. Without this branch, every Markdown.Table inside a list item falls into the trailing else { contentParts.append(child.format()) } and shows as raw pipe text. Fix is mechanical but load-bearing.

What to look at. MarkdownBlockParser.swift:669-671

Takeaway. swift-markdown attaches Table as a direct child of ListItem (also confirmed at MarkdownBlockParser.swift:453 top-level and DetailsBlockParser.swift:160/274). The if-chain pattern lets new block types plug in incrementally.
Rationale. Reusing convertTable(_:) avoids a parallel table-conversion path. Placement before the stringification fallback short-circuits the bug.

Renderer: NestedTableBlockView wrapper owns @State so a @ViewBuilder func doesn't have to

prism/Views/ListBlockView.swift

Why it matters. AccessibleTableView needs Binding<TableDisplayMode>. nestedBlockView is a @ViewBuilder function and can't hold @State. The wrapper is the minimum that makes the state ownership legal.

What to look at. ListBlockView.swift:233-242 (case) + ListBlockView.swift:471-496 (struct)

Takeaway. When you need per-cell state inside a switch in a @ViewBuilder func, lift each case into a tiny private struct. Compare with NestedBlockquoteView for the visual-styling-only variant of the same pattern.
Rationale. Hoisting the state to the parent ListBlockView would require a per-item dictionary keyed by table identity — more state and more identity bookkeeping than one wrapper per use site.

Indicator column hidden via default-parameter gating, not a new view variant

prism/Views/ListBlockView.swift

Why it matters. Per-row notes on nested tables are explicitly out of scope for T-1034. The wrapper omits blockId and onRowIndicatorTap; AccessibleTableView.hasIndicators is already gated on those.

What to look at. ListBlockView.swift:489-494

Takeaway. When an existing view already has a predicate-gated optional feature, omitting parameters at the call site is cleaner than carving a new no-feature variant. The contract becomes part of AccessibleTableView's stable public API.
Rationale. Existing previews already rely on the same defaulting pattern, so this isn't a new contract — it's reuse of an existing one.

Hash-pin regression test guards against silent list-item ID drift

prismTests/ListItemNestedBlocksTests.swift

Why it matters. Future refactors of MarkdownBlock.contentForHashing, .table's id format, or ColumnAlignment.hashToken would silently shift every nested-table list-item note ID. This test fails loudly instead.

What to look at. ListItemNestedBlocksTests.swift:916-958

Takeaway. When a hash format is load-bearing for persistence but not part of any explicit contract, pin a structural fingerprint (substrings of expected output) rather than the exact hash. Add a pointer comment naming the format sources for debuggability.
Rationale. Substring assertion sidesteps paragraph.format() whitespace quirks while still catching the regressions that actually matter (format changes and the fix regressing).

Decision 1: Accept orphaned notes; do not implement migration shim

specs/tables-in-list-items/decision_log.md

Why it matters. Moving the table from content (stringified) into children changes the item's contentForHashing → list block id → derived note IDs. Existing notes on such items orphan. No clean recovery without keeping a shadow parser around.

What to look at. decision_log.md Decision 1

Takeaway. Content-based identity for persisted user data + an evolving parser = an implicit migration every time content re-routes between fields. Capture the trade-off explicitly so the next similar fix has precedent.
Rationale. Blast radius is narrow (affected items were already rendering as broken raw markdown). Same trade-off was accepted for code blocks in Issue #25. Surfacing the consequence in release notes is cheaper than a permanent shadow-parser code path.

Key decisions

Accept orphaned notes on list items containing tables; do not migrate.

Captured as Decision 1 in specs/tables-in-list-items/decision_log.md. Three alternatives evaluated and rejected: migration shim (adds permanent shadow-parser code path), position-based item IDs (much bigger architectural change), defer the fix entirely (defeats the ticket). Mitigation is a release-notes mention.

Place new tests in a separate top-level struct in the same file.

Captured as Decision 2 in specs/tables-in-list-items/decision_log.md. Smolspec said "extend [this file]"; literal reading would push the parent struct past SwiftLint's 700-line type_body_length. Swift Testing discovers @Test on any top-level struct, so splitting is invisible to test execution. Alternatives rejected: lint exception (weakens project-wide rule signal); separate file (scatters related coverage); pre-emptive refactor of the existing struct (out of scope and risky).

Initial display mode chosen via existing TableDisplayMode.initialMode helper.

Reuses the same fitted-vs-readable selection logic that top-level tables use, so behaviour is consistent across nesting depths. Wider tables fall back to readable mode automatically.

Wrapper uses substring assertion rather than exact hash pin.

Inline comment explains: paragraph.format() has whitespace quirks not worth codifying. Pinning the structural fingerprint catches the regressions that matter (format drift, fix regression) without codifying incidental output.

Hide indicator column via default-parameter gating, not a new view variant.

Six existing previews already rely on the same defaults. Adding a no-indicator AccessibleTableView variant would duplicate the view body just to suppress one column. Trade-off accepted: the default-gating contract is now load-bearing for nested usage and should be preserved.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majordocs/agent-notes/markdown-parser.mdAgent notes "Remaining Constraints" section (lines 46-50) said ListBlockView.nestedBlockView "only renders nested .codeBlock, .mermaid, and .blockquote". Stale after this change — .table is now also rendered.Updated to list .table alongside the others, plus a follow-up bullet explaining the NestedTableBlockView wrapper and the deliberate row-indicator-column omission.
minorprismTests/ListItemNestedBlocksTests.swiftThe new TableInListItemTests struct included a verbatim copy of the parent struct's private extractBlocks(from:) helper because cross-struct private access isn't possible. Author-acknowledged in an inline comment but still a real duplication.Lifted extractBlocks(from:) to a file-private free function above both structs. Both suites now share the single helper. SwiftLint preferred private over fileprivate at file scope, so the declaration uses private.
minorspecs/tables-in-list-items/decision_log.mdSmolspec's Implementation Approach said "extend prismTests/ListItemNestedBlocksTests.swift" — a literal reading suggests adding to the existing struct. Implementation extracted the new tests into a new top-level TableInListItemTests struct to stay under SwiftLint's type_body_length, but this divergence wasn't captured anywhere.Added Decision 2 to the decision log capturing the rationale, three alternatives considered (lint exception, separate file, refactor existing struct), and consequences.
nitprismTests/ListItemNestedBlocksTests.swift (hash-pin test)Hash-pin substring assertion has no inline pointer naming the format sources. A future maintainer fixing a failing pin would have to grep for the format definitions.Added a comment naming MarkdownBlock.contentForHashing, MarkdownBlock.id for .table, and ColumnAlignment.hashToken as the canonical format sources.
nitCHANGELOG.mdTwo ### Added bullets for T-1034 (one for spec planning commit, one for implementation commit). Mildly redundant but each accurately describes its commit.Left as-is. The two commits describe two phases of work; keeping them separate is intentional and matches how other multi-commit features have been logged.

Per-file diffs

Click to expand.

prism/Services/MarkdownBlockParser.swift Modified +3 / -0
diff --git a/prism/Services/MarkdownBlockParser.swift b/prism/Services/MarkdownBlockParser.swift
index daabc96..416b026 100644
--- a/prism/Services/MarkdownBlockParser.swift
+++ b/prism/Services/MarkdownBlockParser.swift
@@ -666,6 +666,9 @@ enum MarkdownBlockParser: Sendable {
             } else if let blockquote = child as? BlockQuote {
                 // Handle blockquotes inside list items
                 children.append(.block(convertBlockquote(blockquote)))
+            } else if let table = child as? Table {
+                // Handle tables inside list items (T-1034)
+                children.append(.block(convertTable(table)))
             } else {
                 // Other inline content
                 contentParts.append(child.format())
prism/Views/ListBlockView.swift Modified +46 / -0
diff --git a/prism/Views/ListBlockView.swift b/prism/Views/ListBlockView.swift
index 34ecc60..02bf75d 100644
--- a/prism/Views/ListBlockView.swift
+++ b/prism/Views/ListBlockView.swift
@@ -230,6 +230,17 @@ struct ListBlockView: View {
             )
         case .blockquote(let content):
             NestedBlockquoteView(content: content)
+        case .table(let headers, let rows, let alignments):
+            // T-1034: Render tables nested inside list items via the same
+            // AccessibleTableView used at the top level. The wrapper exists
+            // to own @State for the display mode, which a @ViewBuilder
+            // function cannot hold.
+            NestedTableBlockView(
+                headers: headers,
+                rows: rows,
+                alignments: alignments,
+                searchQuery: searchQuery
+            )
         default:
             // Fallback for any other block types
             EmptyView()
@@ -448,3 +459,38 @@ private struct NestedBlockquoteView: View {
         .padding(.vertical, 4)
     }
 }
+
+// MARK: - Nested Table View
+
+/// Wrapper view for tables nested inside list items (T-1034).
+///
+/// Exists to own `@State` for the table display mode. `ListBlockView.nestedBlockView`
+/// is a `@ViewBuilder` function and so cannot hold state itself. The wrapper delegates
+/// to `AccessibleTableView` with the row-indicator column hidden — per-row notes on
+/// nested tables are out of scope for T-1034.
+private struct NestedTableBlockView: View {
+    let headers: [String]
+    let rows: [[String]]
+    let alignments: [ColumnAlignment]
+    let searchQuery: String
+
+    @State private var displayMode: TableDisplayMode
+
+    init(headers: [String], rows: [[String]], alignments: [ColumnAlignment], searchQuery: String) {
+        self.headers = headers
+        self.rows = rows
+        self.alignments = alignments
+        self.searchQuery = searchQuery
+        self._displayMode = State(initialValue: TableDisplayMode.initialMode(headers: headers, rows: rows))
+    }
+
+    var body: some View {
+        AccessibleTableView(
+            headers: headers,
+            rows: rows,
+            alignments: alignments,
+            searchQuery: searchQuery,
+            displayMode: $displayMode
+        )
+    }
+}
prismTests/ListItemNestedBlocksTests.swift Modified +148 / -10
diff --git a/prismTests/ListItemNestedBlocksTests.swift b/prismTests/ListItemNestedBlocksTests.swift
index 3d5d29a..8bd7c1e 100644
--- a/prismTests/ListItemNestedBlocksTests.swift
+++ b/prismTests/ListItemNestedBlocksTests.swift
@@ -9,22 +9,24 @@
 import Testing
 @testable import prism

+/// Extracts all block children from a list item's `children` array.
+///
+/// Shared across `ListItemNestedBlocksTests` and `TableInListItemTests`
+/// (T-1034) so the two suites don't carry duplicate copies.
+private func extractBlocks(from item: ListItem) -> [MarkdownBlock] {
+    item.children.compactMap { child in
+        if case .block(let block) = child {
+            return block
+        }
+        return nil
+    }
+}
+
 /// Tests for nested block-level content inside list items.
 ///
 /// Per CommonMark spec §5.3 and §5.5, list items can contain block-level
 /// elements like fenced code blocks and nested lists when properly indented.
 struct ListItemNestedBlocksTests {
-    // MARK: - Helper Functions
-
-    /// Extracts all block children from a list item's children array.
-    private func extractBlocks(from item: ListItem) -> [MarkdownBlock] {
-        item.children.compactMap { child in
-            if case .block(let block) = child {
-                return block
-            }
-            return nil
-        }
-    }

     /// Extracts the first nested list from a list item's children array.
     private func extractNestedList(from item: ListItem) -> ListItem.NestedList? {
@@ -836,4 +838,142 @@ struct ListItemNestedBlocksTests {
         let child2: ListItemChild = .block(codeBlock)
         #expect(child.id == child2.id, "Same content should produce same ID")
     }
+
+}
+
+// MARK: - Tables Inside List Items (T-1034)
+
+/// Tests covering markdown tables nested inside list items.
+///
+/// Lives in its own struct so the parent suite stays under the
+/// `type_body_length` lint limit. Uses the file-private `extractBlocks(from:)`
+/// helper shared with `ListItemNestedBlocksTests`.
+struct TableInListItemTests {
+    @Test("Parses pipe table inside unordered list item as .block(.table)")
+    func testTableInsideUnorderedListItem() {
+        // Tables nested inside list items must surface as a .table block
+        // child, not as stringified text concatenated into `content`.
+        let content = """
+        - Lead paragraph for the item.
+
+          | col a | col b |
+          |-------|-------|
+          | x     | y     |
+        """
+        let blocks = MarkdownBlockParser.parse(content)
+
+        guard case .list(_, let items) = blocks.first else {
+            Issue.record("Expected list block, got \(String(describing: blocks.first))")
+            return
+        }
+        #expect(items.count == 1)
+
+        let item = items[0]
+        let nestedBlocks = extractBlocks(from: item)
+        #expect(nestedBlocks.count == 1, "Item should have exactly one nested block child")
+
+        guard case .table(let headers, let rows, _) = nestedBlocks.first else {
+            Issue.record("Expected .table child, got \(String(describing: nestedBlocks.first))")
+            return
+        }
+        #expect(headers == ["col a", "col b"])
+        #expect(rows == [["x", "y"]])
+    }
+
+    @Test("List item `content` does not contain raw table pipe characters after fix")
+    func testTableNotStringifiedIntoListItemContent() {
+        // Regression guard: the pre-fix code path appended the table's
+        // formatted markdown (with `|` separators) into the item's content
+        // via the trailing `else { contentParts.append(child.format()) }`
+        // branch. After the fix, the table moves into `children` and the
+        // item's `content` must NOT contain those pipe characters.
+        let content = """
+        - Lead paragraph for the item.
+
+          | col a | col b |
+          |-------|-------|
+          | x     | y     |
+        """
+        let blocks = MarkdownBlockParser.parse(content)
+
+        guard case .list(_, let items) = blocks.first else {
+            Issue.record("Expected list block")
+            return
+        }
+        #expect(
+            !items[0].content.contains("|"),
+            "Item content must not contain pipe characters from a stringified table; got: \(items[0].content)"
+        )
+    }
+
+    @Test("List block id stays stable for the canonical nested-table fixture")
+    func testNestedTableListBlockIdIsStable() {
+        // Regression guard against silent ID drift.
+        //
+        // The list-item note ID is derived (via nestedListItemId) from the
+        // containing list block's `id`, which folds in each item's
+        // `contentForHashing` — which in turn folds in each child block's
+        // `id`. A future refactor that changes any of those formats would
+        // silently shift every nested-table list item's note ID without
+        // failing any other test.
+        //
+        // The structural fingerprint (rather than the exact id) is pinned
+        // because `paragraph.format()` output has whitespace quirks not
+        // worth codifying. See specs/tables-in-list-items/decision_log.md
+        // Decision 1.
+        //
+        // If this test fails after a parser/model refactor, the relevant
+        // formats live in `MarkdownBlock.contentForHashing`
+        // (`prism/Models/MarkdownBlock.swift`, the `[index:B:...]`
+        // notation), `MarkdownBlock.id` for `.table` (the
+        // `table:<headers>:<rows>:<alignments>` notation), and
+        // `ColumnAlignment.hashToken` (the `leading|center|trailing`
+        // tokens).
+        let content = """
+        - Lead paragraph for the item.
+
+          | col a | col b |
+          |-------|-------|
+          | x     | y     |
+        """
+        let blocks = MarkdownBlockParser.parse(content)
+
+        guard let listBlock = blocks.first, case .list = listBlock else {
+            Issue.record("Expected list block")
+            return
+        }
+
+        let id = listBlock.id
+        #expect(id.hasPrefix("list:false:"), "List id should start with `list:false:`; got: \(id)")
+        #expect(
+            id.contains("[0:B:table:col a,col b:x,y:leading|leading]"),
+            "List id should embed the canonical nested-table fingerprint; got: \(id)"
+        )
+    }
+
+    @Test("Column alignments inside list-item tables round-trip through the parser")
+    func testTableAlignmentsPreservedInListItem() {
+        // Alignment markers (`:---`, `:---:`, `---:`) on a table nested
+        // inside a list item must survive into the .table block's
+        // alignments array, matching how top-level tables behave.
+        let content = """
+        - Item with an aligned table:
+
+          | left | centre | right |
+          |:-----|:------:|------:|
+          | a    | b      | c     |
+        """
+        let blocks = MarkdownBlockParser.parse(content)
+
+        guard case .list(_, let items) = blocks.first else {
+            Issue.record("Expected list block")
+            return
+        }
+        let nestedBlocks = extractBlocks(from: items[0])
+        guard case .table(_, _, let alignments) = nestedBlocks.first else {
+            Issue.record("Expected .table child")
+            return
+        }
+        #expect(alignments == [.leading, .center, .trailing])
+    }
 }
docs/agent-notes/markdown-parser.md Modified +3 / -2
diff --git a/docs/agent-notes/markdown-parser.md b/docs/agent-notes/markdown-parser.md
index 804b88e..007fd31 100644
--- a/docs/agent-notes/markdown-parser.md
+++ b/docs/agent-notes/markdown-parser.md
@@ -46,7 +46,8 @@ Both operate on raw string content before AST parsing.
 ### Remaining Constraints

 - `DetailsBlockParser.convertMarkup` does not yet detect image-only paragraphs — images inside `<details>` blocks still render as paragraphs.
-- List item rendering (`ListBlockView.nestedBlockView`) only renders nested `.codeBlock`, `.mermaid`, and `.blockquote`; other nested block types fall back to `EmptyView`.
+- List item rendering (`ListBlockView.nestedBlockView`) renders nested `.codeBlock`, `.mermaid`, `.blockquote`, and `.table` (T-1034 added `.table`); other nested block types fall back to `EmptyView`.
+- Nested `.table` inside a list item uses `NestedTableBlockView` — a wrapper that owns `@State` for the display mode and delegates to `AccessibleTableView` with the row-indicator column hidden (per-row notes on nested tables are out of scope as of T-1034).
 - If image extraction is added to details/list contexts, the corresponding renderers must be updated in parallel.

 ## Test Files
CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9486c19..1277443 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,9 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

 ### Added

+- Render markdown tables that appear inside list items as tables instead of raw markdown text (T-1034). `MarkdownBlockParser.convertListItem` now emits a `.block(.table(...))` child for swift-markdown `Table` nodes nested under a list item, and `ListBlockView` renders them via a new private `NestedTableBlockView` wrapper that delegates to the existing `AccessibleTableView`. The wrapper hides the row-indicator column (per-row notes on nested tables deferred per ticket) and initialises its display mode via `TableDisplayMode.initialMode(headers:rows:)` so wider tables fall back to readable mode automatically. Test coverage extends `ListItemNestedBlocksTests` with four cases (parser emits the table child, item content no longer contains pipe text, alignment markers round-trip, list-block id structural fingerprint pinned against silent ID drift). Knowingly orphans any existing note attached to a list item that contains a table — the item's `contentForHashing` necessarily changes — accepted per `specs/tables-in-list-items/decision_log.md`.
 - 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.
+- `tables-in-list-items` smolspec, decision log, and 4-task implementation plan for T-1034. Plans the missing parser branch in `MarkdownBlockParser.convertListItem` and a state-owning `NestedTableBlockView` wrapper in `ListBlockView` so markdown tables inside list items render as tables instead of raw markdown; per-row notes deferred. Decision log accepts that any pre-existing note on a list item containing a table will orphan after the fix (item content hash necessarily changes), since no clean migration path exists. Lives under `specs/tables-in-list-items/`.

 ### Fixed

specs/OVERVIEW.md Modified +9 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.md
index 0a13a0a..3459054 100644
--- a/specs/OVERVIEW.md
+++ b/specs/OVERVIEW.md
@@ -67,6 +67,7 @@
 | [Localisation](#localisation) | 2026-05-09 | Done | String Catalog with en (neutral base), en-GB, en-US, and a build-time-merged en-AU column from a small overrides file; covers UI, accessibility, and service-layer error strings |
 | [Imported Notes Processor](#imported-notes-processor) | 2026-05-16 | Done | Extract the imported-notes load path from `NotesManager` into a dedicated `ImportedNotesProcessor` (T-1217) |
 | [prism-notes.js](#prism-notes-js) | 2026-05-16 | Done | Self-contained single-file JavaScript library for adding basic note-taking and copy-as-markdown to any HTML page; personal use, no sync/share/import |
+| [Tables in List Items](#tables-in-list-items) | 2026-05-17 | Done | Render markdown tables that appear inside list items (currently shown as raw markdown); per-row notes deferred (T-1034) |

 ---

@@ -682,3 +683,11 @@ Self-contained single-file JavaScript library that adds basic note-taking and co
 - [smolspec.md](prism-notes-js/smolspec.md)
 - [tasks.md](prism-notes-js/tasks.md)
 - [decision_log.md](prism-notes-js/decision_log.md)
+
+## Tables in List Items
+
+Markdown tables that appear inside a list item currently render as raw markdown text instead of as tables — the parser stringifies them into the item's `content` and the list renderer has no `.table` case. This change adds the missing parser branch (in `MarkdownBlockParser.convertListItem`) and a state-owning wrapper view (`NestedTableBlockView` in `ListBlockView.swift`) that delegates to the existing `AccessibleTableView` with the row-indicator column hidden. Per-row notes for nested tables are explicitly deferred (T-1034). The change is small (~40-55 LOC) but knowingly orphans any pre-existing note that was attached to a list item containing a table, since the item's content hash necessarily changes (see decision log).
+
+- [smolspec.md](tables-in-list-items/smolspec.md)
+- [tasks.md](tables-in-list-items/tasks.md)
+- [decision_log.md](tables-in-list-items/decision_log.md)
specs/tables-in-list-items/smolspec.md Added +71 / -0
diff --git a/specs/tables-in-list-items/smolspec.md b/specs/tables-in-list-items/smolspec.md
new file mode 100644
index 0000000..0e547d1
--- /dev/null
+++ b/specs/tables-in-list-items/smolspec.md
@@ -0,0 +1,71 @@
+# Tables in List Items
+
+## Overview
+
+Markdown tables that appear inside a list item currently render as raw markdown text instead of as tables, because the parser stringifies them into the list item's `content` and the list renderer has no `.table` case in its nested-block switch. This change makes tables inside list items render the same way they do at the top level. Per-row notes for these nested tables are explicitly out of scope (per ticket T-1034); the indicator column will not be shown.
+
+## Requirements
+
+- The parser MUST emit a `.block(.table(headers:rows:alignments:))` child on a `ListItem` when the source markdown contains a table inside that list item, instead of merging the table's source text into the item's `content`.
+- The list renderer MUST render `.table` children of a list item with column alignments, fitted/readable display-mode toggle, and search-match highlighting equivalent to a top-level table.
+- A nested table MUST initially render in `.fitted` mode for small tables and `.readable` mode for complex ones (more than three columns or any cell wider than 40 characters), matching how top-level tables choose their initial mode.
+- The nested table MUST NOT show a row-indicator column or expose per-row note affordances.
+- The change MUST NOT alter how tables render at the top level, and MUST NOT change how other block types (code blocks, blockquotes, nested lists, mermaid) render inside list items.
+
+## Implementation Approach
+
+**Parser** — `prism/Services/MarkdownBlockParser.swift`, `convertListItem` (the child-type chain at lines 651-672):
+
+Add a branch alongside the existing `Paragraph` / `CodeBlock` / `UnorderedList` / `OrderedList` / `BlockQuote` cases:
+
+```swift
+} else if let table = child as? Table {
+    children.append(.block(convertTable(table)))
+}
+```
+
+This mirrors the `BlockQuote` branch (line 666) and reuses the existing `nonisolated static convertTable(_:)` at line 686. The unqualified `Table` name matches the surrounding branches.
+
+`Table` is confirmed to appear as a direct child of `ListItem` in swift-markdown's AST — the same pattern is already used at `MarkdownBlockParser.swift:453` (top-level conversion) and `DetailsBlockParser.swift:160` and `:274` (inside `<details>` blocks). No wrapping container is involved.
+
+**Renderer** — `prism/Views/ListBlockView.swift`, `nestedBlockView` (lines 220-237):
+
+Add a `.table` case that delegates to a small wrapper view defined in the same file:
+
+```swift
+case .table(let headers, let rows, let alignments):
+    NestedTableBlockView(headers: headers, rows: rows, alignments: alignments, searchQuery: searchQuery)
+```
+
+The wrapper exists for **state ownership**, not styling: `AccessibleTableView` requires a `Binding<TableDisplayMode>` (see its current call site at `TextualBlockView.swift:127-137`, where `TextualBlockView` owns `@State` and passes the binding down). `nestedBlockView` is a `@ViewBuilder func`, not a `View`, so it cannot hold `@State` itself. `NestedTableBlockView` is a `private struct` whose only job is to own `@State private var displayMode: TableDisplayMode` (initialised from `TableDisplayMode.initialMode(headers:rows:)`) and forward `headers`, `rows`, `alignments`, `searchQuery`, and `$displayMode` to `AccessibleTableView`. It passes `blockId: ""` and omits `onRowIndicatorTap`; `AccessibleTableView.hasIndicators` already gates the indicator column on `!blockId.isEmpty && onRowIndicatorTap != nil`, so this hides it. (Note: the sibling `NestedBlockquoteView` exists for a different reason — visual variation — so this wrapper is a similar shape but not the same motivation.)
+
+**Tests** — extend `prismTests/ListItemNestedBlocksTests.swift`:
+
+Reuse the existing `extractBlocks(from:)` helper at line 20. Add tests that cover:
+- a simple table inside an unordered list item produces a single `.block(.table(headers:rows:alignments:))` child with the expected headers, rows, and `.leading` alignments;
+- the list item's `content` no longer contains the table's pipe-delimited source text after the fix (regression guard against the stringify-into-content fallback returning);
+- a table with explicit `:---:` / `---:` column alignments produces matching `[.center, .trailing]` (or similar) alignment values in the `.table` block;
+- the parent list block's `id` differs from a fixture computed before the change — i.e. pin the post-fix `id` value so a future refactor can't silently shift list-item note IDs again.
+
+**Out of Scope:**
+
+- Per-row notes on tables inside list items (deferred per T-1034).
+- Changes to top-level table rendering or to `AccessibleTableView` itself.
+- Tables nested two levels deep (e.g. table inside blockquote inside list item) — `convertBlockquote` does not currently descend into tables, so these still render via the existing blockquote fallback.
+- Tables inside `<details>` blocks — already handled by `DetailsBlockParser` and unaffected by this change.
+- Tables inside footnote popovers — `FootnotePopoverView` uses a deliberately restricted renderer (paragraphs, lists, blockquotes only); not touched.
+- A one-time data migration for orphaned notes (see Risks below).
+- UI tests — `AccessibleTableView` is already exercised at the top level; nested-table-specific UI testing adds no coverage.
+
+## Risks and Assumptions
+
+- **Risk (notes orphaning, accepted)**: Any list item that contains a table will get a new `contentForHashing` after this change. The hash includes both `content` (which loses the stringified table text) and each child's id (which gains a `[N:B:table:...]` segment — see `MarkdownBlock.contentForHashing` at line 159 and `MarkdownBlock.id` at line 440). This changes the parent list block's `id` and therefore the item IDs derived from it via `nestedListItemId`. Notes attached to such items will be orphaned (the note record persists, but the new item ID won't match).
+  - **Mitigation**: Accepted, not migrated. There is no clean recovery path because the *old* hash isn't reconstructible from the new AST without keeping a parallel "before-fix" parser around. The same trade-off was accepted when code blocks moved out of `content` in Issue #25.
+  - **Blast radius**: Bounded — only list items that contain a table are affected. Tables inside list items were until now rendering as raw markdown gibberish, so any user who built notes on those items was working with already-broken rendering and is unlikely to have built durable annotation on them.
+  - **Action**: Mention in the release notes for the next version that notes on list items containing tables may need to be re-attached. Decision captured in `decision_log.md`.
+
+- **Risk (silent ID drift)**: Because the hash format is implicit, future refactors could shift item IDs again without anyone noticing. **Mitigation**: pin the post-fix list block `id` in the new test (last test bullet above).
+
+- **Assumption**: Nested tables tend to be small (≤3 columns, short cells), so `.fitted` mode will be chosen most of the time. Wider tables fall back to `.readable` automatically.
+
+- **Prerequisite**: `convertTable` and `AccessibleTableView` continue to be module-internal entry points (currently `nonisolated static` / `internal`); no access-level change required.
specs/tables-in-list-items/decision_log.md Added +91 / -0
diff --git a/specs/tables-in-list-items/decision_log.md b/specs/tables-in-list-items/decision_log.md
new file mode 100644
index 0000000..2690061
--- /dev/null
+++ b/specs/tables-in-list-items/decision_log.md
@@ -0,0 +1,85 @@
+# Decision Log: Tables in List Items
+
+## Decision 1: Accept orphaned notes on list items containing tables; do not migrate
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+Today, a markdown table inside a list item is stringified by `MarkdownBlockParser.convertListItem` and appended to the item's text `content` (see `MarkdownBlockParser.swift:651-672`, the trailing `else { contentParts.append(child.format()) }` branch). This produces broken raw-markdown rendering, which is what T-1034 fixes.
+
+The fix moves the table from `content` into a `.block(.table(...))` entry in the item's `children`. `ListItem.contentForHashing` (`MarkdownBlock.swift:159-179`) folds both the literal `content` string and each child's `block.id` into the hash that drives the list block's `id` (`MarkdownBlock.swift:440`). The list block's `id` is in turn fed into `nestedListItemId` to derive the per-item ID used as the note key.
+
+Net effect: every list item that contains a table will get a new ID after the fix. Existing notes attached to such items will be orphaned — they remain in storage, but their `itemId` no longer matches.
+
+### Decision
+
+Accept the orphaning. Do not implement a migration shim. Surface the consequence in the next release's notes.
+
+### Rationale
+
+There is no clean recovery path: the *old* item ID was derived from a hash that included the stringified table source as part of `content`. Reconstructing that hash from the new AST would require either (a) keeping the previous parser branch alive as a "shadow" parser to compute legacy IDs alongside new ones, or (b) shipping a one-time fingerprint table that maps known-bad IDs to new IDs.
+
+Both options carry more long-term maintenance cost than the orphaning itself, and the blast radius is narrow:
+
+- Affected items are exactly those whose only-broken rendering was a table — users were looking at raw markdown like `| a | b |\n|---|---|` inside a list item, which is unpleasant enough that few people would have built durable annotation on top of it.
+- The same trade-off was accepted when fenced code blocks moved out of `content` and into `children` for Issue #25; no migration shim was added then, and no user complaints followed.
+
+### Alternatives Considered
+
+- **Migration shim that recomputes legacy IDs**: Keep the old stringify-into-content branch behind a flag and run it once to enumerate legacy IDs for any list item that contains a table, then update the persisted note records to the new IDs. Rejected because it adds a parallel parser code path that must stay correct forever, and because the affected note population is expected to be very small.
+- **Stable item IDs based on source position rather than content hash**: Switch list items to byte-offset-based IDs that survive content rewrites. Rejected as out of scope — this is a much bigger architectural change affecting every list-item note in the corpus, not just those with nested tables, and would itself require a migration.
+- **Defer the fix entirely**: Leave tables inside list items rendering as raw markdown to preserve note IDs. Rejected because it defeats the ticket.
+
+### Consequences
+
+**Positive:**
+- Fix can ship as a small additive change (~40-55 LOC) without dragging in a migration system.
+- Avoids a permanent shadow-parser code path that would couple future parser changes to legacy ID compatibility.
+
+**Negative:**
+- A small number of existing notes will silently stop displaying against their original items after the upgrade. Users will need to re-attach them.
+- Sets a (mild) precedent that parser changes are allowed to orphan notes when no clean migration exists.
+
+### Impact
+
+- Affects user-visible content for any list item that contains a markdown table.
+- Affected files: `prism/Services/MarkdownBlockParser.swift`, `prism/Views/ListBlockView.swift`, `prismTests/ListItemNestedBlocksTests.swift`.
+- Requires a release-notes entry.
+
+---
+
+## Decision 2: Place new tests in a separate top-level struct in the same file
+
+**Date**: 2026-05-17
+**Status**: accepted
+
+### Context
+
+Smolspec's Implementation Approach (`smolspec.md` line 49) directed: "extend `prismTests/ListItemNestedBlocksTests.swift`". A literal reading suggests adding tests as methods on the existing `ListItemNestedBlocksTests` struct. The four new T-1034 tests plus their fixtures total ~140 lines, which would push the struct's body past SwiftLint's `type_body_length` warning threshold (700 lines, see `.swiftlint.yml`).
+
+### Decision
+
+Add the new tests as a separate top-level `struct TableInListItemTests` in the same file. Lift the shared `extractBlocks(from:)` helper to a file-private free function so both structs can use it without duplication.
+
+### Rationale
+
+Swift Testing discovers `@Test` methods on any top-level struct, so suite splitting is invisible to test execution. The split keeps `ListItemNestedBlocksTests` under the lint limit without disabling the rule or carving out an exception. Co-locating both structs in one file preserves the smolspec's "extend [this file]" intent — readers still find all list-item-block tests in one place.
+
+### Alternatives Considered
+
+- **Disable `type_body_length` for this file**: Rejected because the lint rule is project-wide guidance against god-structs; an exception for one test file would weaken its signal.
+- **Move T-1034 tests to a new file `TableInListItemTests.swift`**: Rejected because nested-block parser tests already cluster in this one file, and splitting them across files would scatter related coverage.
+- **Refactor the existing struct to be smaller first**: Rejected as out of scope for T-1034 and risky — the existing tests are stable regression coverage.
+
+### Consequences
+
+**Positive:**
+- Lint passes without rule exceptions.
+- Shared helper (`extractBlocks`) lives in one place.
+
+**Negative:**
+- Two struct names in one file is mildly unusual for newcomers — mitigated by a header comment on `TableInListItemTests` explaining the split.
+
+---
specs/tables-in-list-items/tasks.md Added +26 / -0
diff --git a/specs/tables-in-list-items/tasks.md b/specs/tables-in-list-items/tasks.md
new file mode 100644
index 0000000..206a057
--- /dev/null
+++ b/specs/tables-in-list-items/tasks.md
@@ -0,0 +1,26 @@
+# Tables in List Items
+
+- [x] 1. Parser emits a `.block(.table(...))` child when a list item contains a table, instead of stringifying it into the item's `content`
+  - Update `MarkdownBlockParser.convertListItem` in `prism/Services/MarkdownBlockParser.swift` (the child-type chain around lines 651-672): add an `else if let table = child as? Table` branch that appends `.block(convertTable(table))` to the item's `children`, matching the surrounding `BlockQuote` branch.
+  - Extend `prismTests/ListItemNestedBlocksTests.swift`. Reuse the existing `extractBlocks(from:)` helper. Cover three cases (use `#expect` and Swift Testing): (a) a simple unordered-list item with a 2-column / 1-row pipe table produces exactly one `.block(.table(headers:rows:alignments:))` child with matching headers and rows; (b) the same list item's `content` does NOT contain a literal `|` character (regression guard against the stringify-into-content fallback returning); (c) a table with `:---:` and `---:` alignment markers produces matching `.center` / `.trailing` values in the `.table`'s alignments array.
+  - Verify: `make test-quick` runs the new tests and they pass. The renderer is still missing the `.table` case at this point — that is task 2.
+  - Spec reference: see `Implementation Approach` → Parser section and the first three test bullets in `specs/tables-in-list-items/smolspec.md`.
+
+- [x] 2. Renderer displays tables nested inside list items using `AccessibleTableView`, with no row-indicator column
+  - In `prism/Views/ListBlockView.swift`, add a `case .table(let headers, let rows, let alignments)` branch to `nestedBlockView` (around lines 220-237) that returns a new wrapper view: `NestedTableBlockView(headers: headers, rows: rows, alignments: alignments, searchQuery: searchQuery)`.
+  - Define `private struct NestedTableBlockView: View` in the same file, alongside the existing `NestedBlockquoteView` (line 425). It owns `@State private var displayMode: TableDisplayMode` initialised from `TableDisplayMode.initialMode(headers:rows:)`, and renders `AccessibleTableView` with `headers`, `rows`, `alignments`, `searchQuery`, `displayMode: $displayMode`, `blockId: ""`, and no `onRowIndicatorTap`. The empty `blockId` + missing callback hides the indicator column (gated by `AccessibleTableView.hasIndicators`).
+  - Verify: run the app with a markdown fixture that contains a list item whose body is `1. item text\n   | col a | col b |\n   |---|---|\n   | x | y |`. Confirm the table renders as a real table (not raw text), responds to the fitted/readable toggle when applicable, and shows no indicator column.
+  - Verify: `make build-ios` and `make build-macos` both succeed with zero warnings; `make test-quick` still passes.
+  - Spec reference: `Implementation Approach` → Renderer section in `specs/tables-in-list-items/smolspec.md`.
+
+- [x] 3. Hash-stability regression test pins the post-fix list block `id` for an item containing a table
+  - Add one further test to `prismTests/ListItemNestedBlocksTests.swift` that parses a fixed markdown input (a single-item unordered list whose item contains the same 2x1 table used in task 1), grabs the resulting `.list(...)` block, and asserts its `id` equals a hard-coded string captured from the first successful run.
+  - Purpose: future refactors that change `MarkdownBlock.contentForHashing`, `ListItem.contentForHashing`, or the `.table` `id` format will fail this test loudly instead of silently shifting every nested-table list-item note ID. The decision_log.md `Decision 1` explains why this guard matters.
+  - Verify: `make test-quick` passes including the new pin.
+  - Spec reference: last bullet of the `Tests` block in `specs/tables-in-list-items/smolspec.md`; rationale in `specs/tables-in-list-items/decision_log.md` Decision 1.
+
+- [x] 4. Final verification: lint, full test suite, and both platform builds pass with zero warnings
+  - Run `make lint` and address any new warnings (none expected — changes are small and follow existing patterns).
+  - Run `make test-quick` and confirm all unit tests pass, including the three new tests added in tasks 1 and 3.
+  - Run `make build-ios` and `make build-macos` and confirm both complete with zero warnings and zero errors.
+  - Spot-check: render a real document that contains a table inside a list item (the user can supply one, or hand-craft a small fixture). Confirm visual rendering matches a top-level table at the same indent level.
specs/tables-in-list-items/implementation.md Added +~300 / -0
diff --git a/specs/tables-in-list-items/implementation.md b/specs/tables-in-list-items/implementation.md
new file mode 100644
index 0000000..4af78d1
--- /dev/null
+++ b/specs/tables-in-list-items/implementation.md
@@ -0,0 +1,296 @@
+# Implementation: Tables in List Items (T-1034)
+
+Three-level walkthrough of the T-1034 change set on branch
+`worktree-T-1034+tables-in-list-items`. Two commits, ~190 lines net,
+touching the parser, the list renderer, and the test suite.
+
+---
+
+## Beginner Level
+
+### What Changed / What This Does
+
+Prism displays markdown documents. Markdown supports two structures relevant
+here: **lists** (bullet points or numbered items) and **tables** (rows and
+columns of data). Until now, if you wrote a table inside a list item — for
+example, a bulleted list explaining a topic where one bullet has a table
+showing data — Prism would show the table as raw text with the pipe
+characters (`| col a | col b |`) instead of as a formatted table.
+
+This change makes those nested tables render properly, the same way they
+look at the top level of a document.
+
+### Why It Matters
+
+Documents you write naturally often mix structures. Putting a table inside
+a list item is common — think of meeting notes ("1. Action item X: see
+table below for owners") or technical documentation ("- Option A: tested
+against the following matrix..."). Before this change, those documents
+were readable but visually broken in Prism. Now they look right.
+
+### Key Concepts
+
+- **AST (Abstract Syntax Tree)**: When Prism reads markdown, it first
+  converts it into a tree of objects (paragraph, code block, table, etc.)
+  using Apple's `swift-markdown` library. The display layer then walks
+  this tree to draw the screen.
+- **List item children**: A list item isn't just text — it can contain
+  other block-level things underneath: code blocks, blockquotes, sublists,
+  and (now) tables.
+- **Renderer**: The SwiftUI code that decides how each AST node draws.
+- **Per-row notes**: A Prism feature that lets users attach a note to a
+  specific row of a table. This change deliberately doesn't enable
+  per-row notes on tables that live inside list items — the indicator
+  column for note buttons is hidden. Adding row notes there is a future
+  consideration.
+
+---
+
+## Intermediate Level
+
+### Changes Overview
+
+Two source files plus a test file:
+
+1. **`prism/Services/MarkdownBlockParser.swift`** — One new branch in the
+   `convertListItem` switch chain (line ~669). When a `ListItem`'s direct
+   AST child is a `Markdown.Table`, the parser now wraps it as
+   `.block(convertTable(table))` and appends it to the item's `children`
+   array. Previously, `Table` fell into the trailing `else`
+   stringification fallback (`contentParts.append(child.format())`),
+   which is why tables appeared as raw pipe text in the rendered output.
+
+2. **`prism/Views/ListBlockView.swift`** — `nestedBlockView` (the
+   `@ViewBuilder` switch that paints nested block children of a list
+   item) gains a `.table` case alongside the existing `.codeBlock`,
+   `.mermaid`, and `.blockquote` cases. The case delegates to a new
+   `private struct NestedTableBlockView` defined in the same file.
+
+3. **`prismTests/ListItemNestedBlocksTests.swift`** — Four new tests in
+   a new top-level `TableInListItemTests` struct. The shared
+   `extractBlocks(from:)` helper was lifted to a file-private free
+   function so both test structs can use it.
+
+### Implementation Approach
+
+The wrapper view is the only non-obvious piece. `AccessibleTableView`
+(the existing top-level table renderer) requires a
+`Binding<TableDisplayMode>` so the user can toggle between fitted /
+readable / wide. The top-level call site (`TextualBlockView`) owns the
+state and passes the binding down. But `nestedBlockView` is a
+`@ViewBuilder` function — not itself a `View` — and can't hold `@State`.
+
+`NestedTableBlockView` is a thin wrapper whose entire job is to own
+that state. Its custom `init` initialises `_displayMode` via
+`State(initialValue: TableDisplayMode.initialMode(headers:rows:))` so
+each nested table starts in a sensible mode for its size (small tables
+fitted, wider ones readable). The wrapper then delegates to
+`AccessibleTableView` with the data, the binding, and *without*
+`blockId` / `onRowIndicatorTap`. `AccessibleTableView.hasIndicators`
+is gated on `!blockId.isEmpty && onRowIndicatorTap != nil`, so omitting
+both hides the indicator column. The defaults on those parameters are
+public-by-design — six existing previews already rely on the same
+pattern.
+
+The test struct was split because adding ~140 lines to
+`ListItemNestedBlocksTests` would have pushed its body past SwiftLint's
+`type_body_length` warning (700 lines). Splitting is invisible to Swift
+Testing's `@Test` discovery, so the two structs are functionally one
+suite.
+
+### Trade-offs
+
+- **Notes orphaning, accepted**: Once a table moves from the item's
+  `content` string into its `children` array, the item's
+  `contentForHashing` changes (it loses the stringified table text and
+  gains a `[N:B:table-id]` entry for the child). That cascades through
+  `MarkdownBlock.id` for the list, then through `nestedListItemId`,
+  changing the persisted note key for every list item that contains a
+  table. Existing notes on such items are orphaned. There is no clean
+  migration: the *old* hash isn't reconstructible without keeping the
+  pre-fix parser around as a shadow. Decision log Decision 1 accepts
+  this, citing the same trade-off that was accepted when code blocks
+  moved out of `content` in Issue #25.
+- **No per-row notes**: The ticket reporter explicitly scoped them out
+  ("focus on just rendering the table instead"). Adding them would
+  require a stable sub-block identity scheme for nested tables that
+  doesn't yet exist.
+- **Wrapper instead of inlining**: Inlining the table view directly in
+  `nestedBlockView` is impossible because `@ViewBuilder func`s can't
+  own state. The wrapper is the minimum that makes the state ownership
+  legal.
+- **Substring-based hash pin instead of exact id**: The hash-pin test
+  asserts that the list block's `id` contains
+  `"[0:B:table:col a,col b:x,y:leading|leading]"`. It deliberately
+  doesn't pin the *full* id because `paragraph.format()` whitespace
+  isn't worth codifying. Catching format drift is what matters.
+
+---
+
+## Expert Level
+
+### Technical Deep Dive
+
+**Parser branch placement matters.** The new branch sits between the
+`BlockQuote` branch and the trailing stringification fallback in
+`convertListItem`'s if-chain (line ~669). Because Swift's `as?` casts
+are short-circuit-evaluated and `Table` doesn't subclass any of the
+preceding match types, placement order is correctness-neutral *for now*
+— but if a future swift-markdown adds a `Table` subtype with custom
+behaviour we'd handle, we'd want it ahead of `Table`. The branch reuses
+`MarkdownBlockParser.convertTable(_:)` (line ~689), which is also the
+top-level conversion path and already covers headers, rows, and
+`ColumnAlignment` mapping.
+
+**State identity on the wrapper.** `State(initialValue:)` inside a
+custom `init` is the canonical SwiftUI idiom for deferring state
+construction to view-identity creation. SwiftUI calls `init` on every
+re-render, but its `State` storage only honours `initialValue:` on the
+first call for a given identity slot in the view tree. Repeated calls
+re-execute `TableDisplayMode.initialMode(headers:rows:)` (an
+`O(headers + rows*cols)` `.count` scan over the cell strings) and
+discard the result — measurable but trivial for the table sizes that
+realistically appear inline. This matches `MarkdownBlock.tableDefaultMode`
+at the top level; no new performance footprint.
+
+**Indicator-column gating via defaults.** `AccessibleTableView` exposes
+`blockId: String = ""`, `rowNotes: [String: [BlockNote]] = [:]`, and
+`onRowIndicatorTap: ((String) -> Void)?` (defaulting to `nil`). The
+computed `hasIndicators` predicate is `!blockId.isEmpty &&
+onRowIndicatorTap != nil`, so omitting either parameter from the
+memberwise init suppresses the indicator column. Six previews and now
+`NestedTableBlockView` rely on this — it's a load-bearing contract, not
+incidental. A future change that flips one of those parameters to
+non-default would silently turn on the indicator column in nested
+contexts; the smolspec's Out-of-Scope section is the documented guard.
+
+**Hash-stability regression guard.** The `id` for the canonical fixture
+is, deterministically:
+
+- Item `contentForHashing`:
+  `"<formatted lead paragraph>[0:B:table:col a,col b:x,y:leading|leading]"`
+- List `id`:
+  `"list:false:" + the above`
+
+The test pins the substring `"[0:B:table:col a,col b:x,y:leading|leading]"`
+plus the `"list:false:"` prefix. This catches:
+
+- `MarkdownBlock.contentForHashing`'s `[index:B:...]` notation changing.
+- `.table`'s id format (`table:<headers>:<rows>:<alignments>`) changing.
+- `ColumnAlignment.hashToken`'s leading/center/trailing tokens
+  changing.
+- The fix regressing — if `Table` falls back into the stringification
+  branch again, the `.block(.table)` child disappears and the substring
+  is no longer present.
+
+A reviewer reading the test sees a pointer comment naming the three
+canonical format sources so future drift is debuggable.
+
+### Architecture Impact
+
+This is a localised additive change: two new switch cases, one wrapper
+view, no new public surface, no new abstractions. It does **not**
+extend to:
+
+- `<details>` blocks containing tables — already handled by
+  `DetailsBlockParser`, unaffected.
+- Tables nested two levels deep (e.g. table inside blockquote inside
+  list item) — `convertBlockquote` doesn't descend; this remains broken
+  but is out of scope.
+- Tables in footnote popovers — `FootnotePopoverView` uses a
+  deliberately restricted renderer, intentionally untouched.
+
+The notes-orphaning consequence is the only architecture-relevant
+ripple. It re-confirms the pattern set by Issue #25: content-based
+identity for list items is convenient for de-duplication and stable
+across cosmetic edits, but every parser change that re-routes content
+between `content` and `children` is an implicit note-ID migration.
+Decision log Decision 1 captures this contract explicitly so the next
+similar fix has a precedent.
+
+### Potential Issues
+
+- **Runtime test verification gap**: `swiftlint` is clean, `make
+  build-ios` and `make build-macos` both pass with zero warnings, and
+  `xcodebuild build-for-testing` succeeds (i.e. every test compiles).
+  `xcodebuild test` itself couldn't be confirmed on this machine — it
+  hangs at "Timed out after 120.0s while initiating control session
+  with daemon" inside the Xcode test runner, an environmental issue
+  unrelated to the code. Needs a clean `make test-quick` on a working
+  machine before merge.
+- **Wide nested tables**: A table inside a list item is rendered at
+  the item's indent column, which is narrower than the page width.
+  `TableDisplayMode.initialMode` picks `.readable` for tables with >3
+  columns or any cell >40 chars, which falls back to a horizontal
+  ScrollView. Horizontal-inside-vertical scrolling is a SwiftUI
+  pattern that works but can feel cramped — worth a UX pass at some
+  point.
+- **Notes telemetry**: There's no logging today that would surface
+  *how many* existing notes orphan when this ships. Users will discover
+  it ad hoc. If the orphaning turns out to bite more users than
+  expected, the migration option in Decision 1's "Alternatives
+  Considered" is still on the table.
+- **No view-layer smoke test**: A compile failure in `AccessibleTableView`'s
+  init signature would break this, but a behavioural regression (e.g.
+  the indicator column re-appearing if defaults change) would only be
+  caught by manual visual check. A future refactor could add a
+  lightweight render test, but it's not warranted for this scope.
+
+---
+
+## Completeness Assessment
+
+### Fully implemented and verified
+
+- Parser branch emits `.block(.table)` for nested tables
+  (`MarkdownBlockParser.swift:669-671`).
+- Renderer case + `NestedTableBlockView` wrapper with hidden indicator
+  column (`ListBlockView.swift:233-242`, `:471-496`).
+- `TableDisplayMode.initialMode` drives the initial display mode per
+  nested table.
+- Four new parser tests covering structure, content-string regression,
+  alignment round-trip, and hash-pin regression
+  (`prismTests/ListItemNestedBlocksTests.swift`, new
+  `TableInListItemTests` struct).
+- Shared `extractBlocks(from:)` helper lifted to file-private (no
+  duplication).
+- Decision log: Decision 1 (notes orphaning, accepted), Decision 2
+  (test-struct extraction).
+- Agent notes updated to reflect `.table` is now supported in
+  `nestedBlockView`.
+- CHANGELOG entry under `### Added`.
+- `specs/OVERVIEW.md` entry with status `Done`.
+- SwiftLint clean. `make build-ios` and `make build-macos` both pass
+  with zero warnings.
+
+### Implemented but pending runtime verification
+
+- `xcodebuild test` execution of the four new tests — the build
+  succeeds (all code compiles) but the test runner hangs on Xcode
+  daemon initialisation in this environment. The tests follow the same
+  patterns as the existing tests in the same file and are mechanically
+  verifiable by code inspection (an independent design-critic review
+  confirmed the hash-pin substring is exactly what the source-of-truth
+  format produces). Needs `make test-quick` on a clean environment
+  before merge.
+
+### Deliberately not implemented (out of scope per ticket)
+
+- Per-row notes on tables nested inside list items.
+- Tables nested two levels deep (table inside blockquote inside list
+  item).
+- Tables in `<details>` blocks — already handled by
+  `DetailsBlockParser`.
+- A one-time data migration for notes that orphan due to the
+  contentForHashing change (decision log Decision 1).
+- UI tests for the nested-table rendering path.
+
+### Validation findings
+
+- **Logic**: No inconsistencies between the spec, decision log, and
+  implementation. The orphaning consequence is correctly described and
+  matches the actual code behaviour.
+- **Gaps identified**: None blocking. The only gap is the runtime test
+  execution, captured above as the single pending item.
+- **Recommendation**: Run `make test-quick` once on a machine where
+  Xcode daemons are healthy. If all four new tests pass, ship.

Things to double-check

Runtime test execution

Critical pending item. xcodebuild test couldn't be confirmed in this environment due to a persistent Xcode daemon-init hang. Run make test-quick on a clean machine and verify the four new TableInListItemTests all pass before pushing. The build compiles, lint is clean, both platform builds pass — but only test execution actually validates behaviour.

Notes orphaning on real-world documents

The change knowingly orphans any pre-existing note on a list item that contains a table (see Decision 1). Spot-check the user's actual document corpus before announcing the release: if many notes were attached to such items, the orphaning consequence will surprise users. If the blast radius turns out to be large, Decision 1's "Alternatives Considered" still lists the migration shim as an option.

Visual rendering of wide nested tables

Nested tables render at the item's indent column, which is narrower than the page width. TableDisplayMode.initialMode picks .readable for tables with >3 columns or any cell >40 chars, which falls back to a horizontal ScrollView. Worth opening a real markdown document with a wide nested table to confirm the result is usable, not just technically correct.