prism PR #253 author @ArjenSchwarz head → base T-1237/bugfix-toc-sidebar-stuck-in-raw-source → main commits 3 files 4 touched lines +166 / -2 state merged

PR review: #253 — Fix T-1237: TOC sidebar can get stuck open in raw source mode

PR #253 by @ArjenSchwarz · merging T-1237/bugfix-toc-sidebar-stuck-in-raw-sourcemain · MERGED

At a glance

  • Fixes a UX trap on iPad/macOS where the TOC sidebar got stuck visible after switching into raw source mode — the only toolbar control to close it was disabled.
  • Replaces blanket .inactive(showRawSource) on the TOC toolbar button with a state-aware predicate showRawSource && !showLeftSidebar, so the close action stays live while the open action is still blocked in raw source.
  • Extracts the predicate as a static helper RegularLayoutToolbar.isTOCToggleDisabled(showRawSource:showLeftSidebar:) for unit-test coverage, widens the toolbar struct from private to internal to expose it.
  • Adds prismTests/RegularLayoutToolbarTests.swift covering the full 2×2 matrix of (showRawSource, showLeftSidebar); documents the deliberate toolbar-vs-menu-command asymmetry in agent notes.

Verdict

Ready to merge (already merged)

Tight, well-scoped regression fix for T-1237. The implementation matches the bugfix report, regression tests cover the full 2×2 truth table, the deliberate divergence with the menu-command path is documented in docs/agent-notes/toc-left-sidebar.md, and CI was green. One minor structural call worth a follow-up — widening RegularLayoutToolbar from private to internal just to test a pure 1-line predicate is heavier than necessary; a file-scope internal func or namespaced enum would have preserved private while still satisfying @testable import. Nothing rises to blocker/critical/major.

Review findings

4 raised · 0 fixed · 4 skipped

Jump to findings →

Author's PR description

Shown verbatim — the markdown the author wrote, unmodified.

@ArjenSchwarz · 2026-05-16
## Summary
- The TOC toolbar button in the iPad/macOS layout was unconditionally disabled while raw source was active. If the sidebar was already open when the user entered raw source, it stayed visible with no toolbar control to close it.
- Replaced `.inactive(showRawSource)` on the TOC button with a state-aware predicate (`showRawSource && !showLeftSidebar`) so an open sidebar can always be closed, while opening from a closed state is still blocked in raw source.
- Extracted the predicate as `RegularLayoutToolbar.isTOCToggleDisabled(showRawSource:showLeftSidebar:)` for unit-test coverage.

## Root cause
`.inactive(showRawSource)` (introduced in f8ed94b) conflated the button's two semantic actions (open vs. close) into one disabled state. Because the sidebar's visibility persists across mode switches, the blanket disable creates an unreachable state when the sidebar is open in raw source. Compact mode is unaffected because its TOC is a transient sheet.

Full investigation in `specs/bugfixes/toc-sidebar-stuck-in-raw-source/report.md`.

## Test plan
- [x] Added regression tests in `prismTests/RegularLayoutToolbarTests.swift` covering the 2×2 matrix of (`showRawSource` × `showLeftSidebar`).
- [x] `make build-macos` passes with no warnings.
- [x] `make lint` passes (0 violations).
- [ ] CI: `make test-quick` runs the regression tests on macOS.
- [ ] Manual: on macOS, open a document with the TOC sidebar visible, toggle raw source on, click the `sidebar.left` toolbar button — sidebar closes. Sidebar can no longer be re-opened from the toolbar while in raw source.

Closes T-1237.

Commits

Three-level explanation

What changed

Prism has an iPad/macOS view with a Table of Contents (TOC) sidebar on the left. There's also a 'raw source' mode that swaps the rendered markdown for the unformatted text. Before this fix, switching into raw source mode while the TOC sidebar was open left the user stuck — the button that would normally close the sidebar was grey'd out, so the sidebar stayed visible until the user backed out of raw source first.

Why it matters

It's a small thing, but it's exactly the kind of dead end that erodes trust in an app: the user pressed a button they expect to work, and it didn't. The fix lets users close an already-open sidebar from raw source mode, while still preventing them from opening it from a closed state (because TOC navigation links wouldn't make sense in raw source anyway).

Key concepts

  • Toggle button: one UI control that performs different actions depending on state — "open" when closed, "close" when open.
  • Disabling logic: turning a button off should consider which action it would perform. Disabling both actions equally is what created the trap here.

Architecture

The affected code lives in prism/Views/RegularDocumentLayout.swift — specifically the RegularLayoutToolbar ViewModifier that attaches the iPad/macOS toolbar to the document reader. The previous code applied .inactive(showRawSource) to the TOC toggle button as a single blanket disable.

Patterns

The fix introduces a small static predicate:

static func isTOCToggleDisabled(showRawSource: Bool, showLeftSidebar: Bool) -> Bool {
    showRawSource && !showLeftSidebar
}

This is a deliberate move from state-blind disable to state-aware disable — the button is only disabled when both conditions are true (raw source AND sidebar closed). The predicate is pulled out as a static function so the test target can exercise the full 2×2 truth table without instantiating any SwiftUI views. The struct's visibility was widened from private to internal to support @testable import prism.

Trade-offs

The author considered auto-hiding the sidebar on entry to raw source (with optional restore) but rejected it as too surprising — it discards explicit user intent. The choice here preserves whatever the user set up, with a minimal predicate change. The asymmetry with the menu command path (Cmd+Option+T, which is intentionally not gated) is documented in agent notes rather than enforced uniformly — a conscious scope decision.

Deep dive

The root cause is a classic state-coupling defect: a single boolean (showRawSource) was used to gate two independent semantic actions on a toggle control. Because the sidebar's visibility (showLeftSidebar) is orthogonal to showRawSource and persists across mode switches, blanket-disabling on the latter creates a state graph where the (open, raw) node has no incoming edges to (closed, raw) via the toolbar — only via the menu command, which serves as the de-facto workaround. The fix breaks the coupling by adding the second dimension to the disable predicate.

Architecture impact

The change is local: one predicate, one widened visibility modifier on a single ViewModifier. The widening from private to internal is the most arguable structural choice — it exposes 7 stored properties and the body(content:) method to the rest of the module just so a 1-line pure function can be referenced from the test target. A tighter alternative would be a file-scope internal func (or namespaced enum TOCToggleRule) that keeps the ViewModifier private, since @testable import already grants access to internal symbols. Functionally identical, smaller blast radius.

Edge cases & alignment

  • Toolbar/menu divergenceDocumentActions.toggleTableOfContents is not gated on showRawSource, so the menu shortcut can both open and close the sidebar in raw source. Documented in docs/agent-notes/toc-left-sidebar.md. A future maintainer touching either path is reliant on agent notes (no inline cross-reference) — a one-line code comment cross-referencing both sites would close the loop.
  • Compact layout — keeps a bare .inactive(isRawSourceActive); defensible because the compact TOC is a transient sheet (no persistent stuck-open state).
  • SwiftUI diffing — the helper returns Bool, identical shape to the previous expression. No identity, allocation, or observation cost change.

Test coverage

The new RegularLayoutToolbarTests covers the full 2×2 truth table: (open, raw) → enabled, (closed, raw) → disabled, (open, rendered) → enabled, (closed, rendered) → enabled. Uses @Test + #expect(... == bool) consistent with the suite's prevailing style.

Important changes — detailed

RegularLayoutToolbar: state-aware TOC disable predicate

prism/Views/RegularDocumentLayout.swift

Why it matters. This is the core correctness fix — replaces a blanket disable with a two-condition predicate so an open sidebar stays closable in raw source mode.

What to look at. prism/Views/RegularDocumentLayout.swift:442-444 (helper), :463-466 (use site)

Takeaway. When disabling a toggle control that flips between two semantic actions, evaluate each action independently — a single boolean disable predicate is a code smell.
Rationale. The author rejected auto-hiding the sidebar on entry to raw source because it surprises users who explicitly chose to open it; the predicate-only change preserves user intent and avoids new state machinery.

RegularLayoutToolbar visibility: private → internal

prism/Views/RegularDocumentLayout.swift

Why it matters. Widens the toolbar struct so the test target can name the static predicate without instantiating SwiftUI views.

What to look at. prism/Views/RegularDocumentLayout.swift:428

Takeaway. Pure predicates extracted for testing are often better placed at file scope or in a small namespacing enum than by widening the view's own visibility.
Rationale. Author chose this path for minimal change footprint; a file-scope helper would have kept the ViewModifier private at the cost of slightly more refactoring. (inferred — not stated by the author)

RegularLayoutToolbarTests: 2×2 truth-table coverage

prismTests/RegularLayoutToolbarTests.swift

Why it matters. Pins the new predicate's behaviour so a future regression of the same shape can't reintroduce the trap.

What to look at. prismTests/RegularLayoutToolbarTests.swift:1-60

Takeaway. For a pure predicate gating a discrete state matrix, the appropriate test shape is the full truth table — three @Test cases here cover four input combinations cleanly.
Rationale. Author opted for unit-level coverage of the predicate rather than a UI test, citing testability as the reason the predicate was extracted in the first place.

agent-notes: document toolbar/menu-command asymmetry

docs/agent-notes/toc-left-sidebar.md

Why it matters. Captures the deliberate scope decision that the menu command (Cmd+Option+T) is intentionally not gated on raw source — otherwise a future maintainer would 'fix' the divergence as a bug.

What to look at. docs/agent-notes/toc-left-sidebar.md:48-49

Takeaway. When you deliberately leave related code paths inconsistent, the divergence belongs somewhere a maintainer will find it before changing either site.
Rationale. The menu shortcut served as the user's workaround for the bug — touching it here would expand surface area beyond the reported defect.

Key decisions

Use state-aware predicate instead of auto-hiding the sidebar Preserves explicit user intent across mode switches. Auto-hide-with-restore would require new state that only exists to paper over the original defect; the predicate change is the minimal correction. Source: bugfix report "Alternatives Considered".
Leave the menu-command path ungated on raw source The toolbar bug was the reported defect; the menu shortcut was the user's documented workaround. Aligning the menu path would expand scope without addressing the reported issue. Documented in agent notes so the divergence is intentional, not accidental. Source: bugfix report "Alternatives Considered" + new agent-note entry.
Widen <code>RegularLayoutToolbar</code> from <code>private</code> to <code>internal</code> (vs. file-scope helper) Chose minimum-change footprint — drop the private keyword on the existing struct so @testable import prism can name isTOCToggleDisabled. A file-scope internal func or namespaced enum would have kept the view truly private while still being testable; the trade-off is one extra symbol vs. wider module visibility on a SwiftUI view that isn't meant to be constructed elsewhere. (inferred — not stated by the author.)
Leave compact-layout TOC button on bare <code>.inactive(isRawSourceActive)</code> Compact mode shows the TOC as a transient sheet, not a persistent sidebar — there is no "stuck open" failure mode there. The asymmetric pattern is genuinely the right shape for each layout. Documented in agent notes. Source: bugfix report + agent-note entry.

Review findings

SeverityAreaFindingResolution
minorprism/Views/RegularDocumentLayout.swift:428 — RegularLayoutToolbar visibilityDropping `private` on the whole `RegularLayoutToolbar` struct exposes 7 stored properties (`session`, `notesManager`, `settings`, `reduceMotion`, three `@Binding`s, `onSave`) and the `body(content:)` method to the rest of the module just to make one pure static predicate testable. `@testable import` already grants access to `internal` symbols, so the minimum-viable widening is a file-scope `internal func` (or a tiny `enum TOCToggleRule { static func isDisabled(...) }`), leaving the `ViewModifier` `private`.Extract the predicate to a file-scope helper or namespaced enum and revert `RegularLayoutToolbar` to `private struct`. Functionally identical, smaller blast radius. Not a blocker.
minorprism/Views/RegularDocumentLayout.swift / prism/Views/DocumentReaderView.swift — toolbar/menu asymmetry not cross-referenced in codeThe deliberate divergence between the toolbar (gated by `isTOCToggleDisabled`) and the menu command (`DocumentActions.toggleTableOfContents`, ungated) is documented in `docs/agent-notes/toc-left-sidebar.md`, but neither code site has an inline pointer to the other. A future maintainer touching one path won't necessarily check agent notes.Optional: add a one-line cross-reference comment at one or both call sites pointing readers to the agent note. Defensible to skip if you treat agent notes as the source of truth.
nitprism/Views/RegularDocumentLayout.swift:463-466 — call-site verbosity`.inactive(Self.isTOCToggleDisabled(showRawSource: showRawSource, showLeftSidebar: showLeftSidebar))` is 4 lines replacing one. Both arguments are already in scope as instance properties on the same struct, so a `private var isTOCToggleDisabled: Bool` instance computed property would let the call site collapse to `.inactive(isTOCToggleDisabled)`. The static form was chosen for testability — see finding 1.Couple with the finding-1 resolution: a file-scope free function lets the call site read `.inactive(isTOCToggleDisabled(showRawSource, showLeftSidebar))` without `Self.` or labelled args.
nitspecs/bugfixes/toc-sidebar-stuck-in-raw-source/report.md:41 — wording precisionThe report says 'Changed `RegularLayoutToolbar` from `private` to `internal`'. Technically accurate — the diff drops the `private` keyword, falling back to default `internal` — but a reader scanning the diff for an explicit `internal` keyword may briefly stumble.Optional clarifying tweak: 'removed `private` from `RegularLayoutToolbar` (defaults to `internal`)'.

Per-file diffs

Click to expand.

prism/Views/RegularDocumentLayout.swift Modified +13 / -2
diff --git a/prism/Views/RegularDocumentLayout.swift b/prism/Views/RegularDocumentLayout.swift
index bb7e07f1..3a989397 100644
--- a/prism/Views/RegularDocumentLayout.swift
+++ b/prism/Views/RegularDocumentLayout.swift
@@ -425,7 +425,7 @@ struct RegularDocumentLayout: View {
 }

 /// Toolbar for the regular document layout.
-private struct RegularLayoutToolbar: ViewModifier {
+struct RegularLayoutToolbar: ViewModifier {
     let session: DocumentSession
     let notesManager: NotesManager
     let settings: AppSettings
@@ -435,6 +435,14 @@ private struct RegularLayoutToolbar: ViewModifier {
     @Binding var showRawSource: Bool
     let onSave: () -> Void

+    /// Returns `true` only when raw source is active and the sidebar is
+    /// already closed. An open sidebar must always be closable from the
+    /// toolbar — blanket-disabling on `showRawSource` alone traps users
+    /// with no toolbar control to dismiss the sidebar.
+    static func isTOCToggleDisabled(showRawSource: Bool, showLeftSidebar: Bool) -> Bool {
+        showRawSource && !showLeftSidebar
+    }
+
     func body(content: Content) -> some View {
         content.toolbar {
             ToolbarItemGroup(placement: .navigation) {
@@ -452,7 +460,10 @@ private struct RegularLayoutToolbar: ViewModifier {
                         : LocalizedStringKey("Show table of contents")
                 )
                 .help(showLeftSidebar ? "Hide table of contents" : "Show table of contents")
-                .inactive(showRawSource)
+                .inactive(Self.isTOCToggleDisabled(
+                    showRawSource: showRawSource,
+                    showLeftSidebar: showLeftSidebar
+                ))
             }

             ToolbarItemGroup(placement: .primaryAction) {
prismTests/RegularLayoutToolbarTests.swift Added +60 / -0
diff --git a/prismTests/RegularLayoutToolbarTests.swift b/prismTests/RegularLayoutToolbarTests.swift
new file mode 100644
index 00000000..c8b8d6b8
--- /dev/null
+++ b/prismTests/RegularLayoutToolbarTests.swift
@@ -0,0 +1,60 @@
+//
+//  RegularLayoutToolbarTests.swift
+//  prismTests
+//
+//  Regression tests for T-1237: TOC sidebar can get stuck open in raw source
+//  mode. Before the fix, the toolbar's TOC toggle button was unconditionally
+//  disabled while raw source was active, so an already-open sidebar could
+//  not be closed from the toolbar.
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("RegularLayoutToolbar TOC button enablement")
+struct RegularLayoutToolbarTests {
+
+    // MARK: - T-1237 regression
+
+    @Test("TOC toggle is enabled when sidebar is open, even in raw source mode")
+    func tocToggleEnabledWhenSidebarOpenInRawSource() {
+        // Before T-1237 fix this returned `true` (disabled), leaving an
+        // open sidebar stuck visible.
+        #expect(
+            RegularLayoutToolbar.isTOCToggleDisabled(
+                showRawSource: true,
+                showLeftSidebar: true
+            ) == false
+        )
+    }
+
+    @Test("TOC toggle is disabled when sidebar is closed and raw source is on")
+    func tocToggleDisabledWhenSidebarClosedInRawSource() {
+        // Prevents opening the sidebar from a closed state while raw
+        // source is active — TOC navigation targets only exist in the
+        // rendered view.
+        #expect(
+            RegularLayoutToolbar.isTOCToggleDisabled(
+                showRawSource: true,
+                showLeftSidebar: false
+            ) == true
+        )
+    }
+
+    @Test("TOC toggle is enabled in rendered mode regardless of sidebar state")
+    func tocToggleEnabledInRenderedMode() {
+        #expect(
+            RegularLayoutToolbar.isTOCToggleDisabled(
+                showRawSource: false,
+                showLeftSidebar: true
+            ) == false
+        )
+        #expect(
+            RegularLayoutToolbar.isTOCToggleDisabled(
+                showRawSource: false,
+                showLeftSidebar: false
+            ) == false
+        )
+    }
+}
docs/agent-notes/toc-left-sidebar.md Modified +2 / -0
diff --git a/docs/agent-notes/toc-left-sidebar.md b/docs/agent-notes/toc-left-sidebar.md
index 7e84674a..3b87a916 100644
--- a/docs/agent-notes/toc-left-sidebar.md
+++ b/docs/agent-notes/toc-left-sidebar.md
@@ -45,3 +45,5 @@ Only visible sidebars contribute to the threshold calculation.
 - AppSettings sidebar properties use `-1` as sentinel for "use platform default"
 - `SidebarContentsView` toggle binding intentionally checks current value before firing callback to avoid duplicate toggles during `DisclosureGroup` reconciliation.
 - **Sidebar List background**: Any sidebar view using `List` must apply `.scrollContentBackground(.hidden)` on the List and `.listRowBackground(Color.clear)` on rows. Without these, the system List background (white/black) covers the theme background set by the caller. `.scrollContentBackground(.hidden)` does NOT propagate through wrapper views — it must be applied directly to the `List`. See `SidebarNotesView` as the reference pattern. (T-77 regression fix)
+- **TOC toolbar button disable rule (T-1237)**: The regular-layout TOC toolbar button must use `RegularLayoutToolbar.isTOCToggleDisabled(showRawSource:showLeftSidebar:)`, not a bare `.inactive(showRawSource)`. The button toggles between "open" and "close" actions; both are blanket-disabled only when raw source is active AND the sidebar is closed. An already-open sidebar must always be closable, otherwise it stays visible with no toolbar control to dismiss it. Compact mode is unaffected because the TOC there is a sheet, not persistent state.
+- **Toolbar vs. menu command asymmetry (T-1237)**: The `isTOCToggleDisabled` rule applies only to the toolbar button. The menu command (`Cmd+Option+T`, routed through `DocumentActions.toggleTableOfContents`) is not gated on raw source mode and can both open and close the sidebar while raw source is active. This divergence was an intentional scope decision when fixing T-1237 — the menu shortcut was the user's workaround for the toolbar trap. If the two are ever aligned, remove this note.
specs/bugfixes/toc-sidebar-stuck-in-raw-source/report.md Added +91 / -0
diff --git a/specs/bugfixes/toc-sidebar-stuck-in-raw-source/report.md b/specs/bugfixes/toc-sidebar-stuck-in-raw-source/report.md
new file mode 100644
index 00000000..5cbf8a8c
--- /dev/null
+++ b/specs/bugfixes/toc-sidebar-stuck-in-raw-source/report.md
@@ -0,0 +1,91 @@
+# Bugfix Report: TOC Sidebar Stuck Open In Raw Source Mode
+
+**Date:** 2026-05-17
+**Status:** Fixed
+**Transit ticket:** T-1237
+
+## Description of the Issue
+
+On iPad/macOS, the TOC left sidebar in `RegularDocumentLayout` could become stuck in the open state when the user switched into raw source view. The toolbar button that toggles the sidebar was unconditionally disabled while raw source was active, so an already-open sidebar could not be closed from the toolbar — the user had to leave raw source mode first.
+
+**Reproduction steps:**
+1. On iPad or macOS, open any markdown document with the TOC sidebar visible.
+2. Toggle raw source mode on.
+3. Try to hide the TOC sidebar via the toolbar `sidebar.left` button.
+4. Observe the button is dimmed/disabled — the sidebar remains visible.
+
+**Impact:** UX trap on iPad/macOS users who use both raw source and the TOC sidebar. Workaround was to exit raw source first, then close the sidebar. Discovered by automated code issue check after commit f8ed94b (PR #245).
+
+## Investigation Summary
+
+Used systematic Fagan inspection.
+
+- **Symptoms examined:** Disabled `sidebar.left` toolbar button while in raw source mode with sidebar open.
+- **Code inspected:** `RegularDocumentLayout.swift` (toolbar + main HStack), `CompactBottomToolbar.swift`, `DocumentReaderView.swift` (action dispatch), `prismApp.swift` (`ToggleTOCButton`), commit f8ed94b.
+- **Hypotheses tested:** Considered auto-hiding the sidebar on entering raw source vs. keeping the toggle live when the sidebar is open. Option B was selected as the least surprising fix that preserves user intent across mode switches.
+
+## Discovered Root Cause
+
+The `.inactive(showRawSource)` modifier on the TOC toolbar button in `RegularLayoutToolbar` conflates two distinct user intents (open vs. close) into one disabled state. Because the sidebar's visibility is orthogonal to raw source and persists across mode switches, disabling the toggle without first ensuring the sidebar is closed creates an unreachable state where the sidebar is visible but its only toolbar control is dimmed.
+
+**Defect type:** Logic error — state coupling.
+
+**Why it occurred:** The intent of the original change (f8ed94b) was correct — TOC navigation targets are not meaningful in raw source view, so the user should not be able to *open* the sidebar from a closed state. The author modelled this as a single "TOC interaction" being inactive, rather than separately considering the "close an already-open sidebar" path.
+
+**Contributing factors:** Compact mode uses a transient sheet, which has no persistent state that can be stuck open, so the same `.inactive(isRawSourceActive)` pattern works fine there. The asymmetric behaviour between compact and regular layouts is what made the regular-layout case a trap.
+
+## Resolution for the Issue
+
+**Changes made:**
+- `prism/Views/RegularDocumentLayout.swift` — replaced `.inactive(showRawSource)` on the TOC toolbar button with `.inactive(RegularLayoutToolbar.isTOCToggleDisabled(showRawSource:showLeftSidebar:))`. The helper returns `true` (disabled) only when raw source is active AND the sidebar is currently closed, allowing the user to close an already-open sidebar.
+- `prism/Views/RegularDocumentLayout.swift` — extracted the disable predicate as a small `static` helper on `RegularLayoutToolbar` so the rule is testable without instantiating SwiftUI views. Changed `RegularLayoutToolbar` from `private` to `internal` so the test target can reference it.
+
+**Approach rationale:** Preserves user intent — if the user explicitly opened the sidebar, that state survives toggling raw source. The button's accessibility label and help text already derive from `showLeftSidebar`, so they continue to read correctly ("Hide table of contents" while the sidebar is open in raw source). Minimal change, no auto-state machinery, no surprise.
+
+**Alternatives considered:**
+- **Auto-hide sidebar on entering raw source (with optional restore):** Simpler state model but surprises users who explicitly chose to open the sidebar. Adding "remember and restore" adds state that's only needed because of this fix.
+- **Apply the same logic to the menu command (`Cmd+Option+T`):** Out of scope — the bug report specifically calls out the toolbar behaviour. The menu command path currently works in raw source (it was the user's workaround for the bug). Changing it here would expand surface area beyond the reported defect.
+
+## Regression Test
+
+**Test file:** `prismTests/RegularLayoutToolbarTests.swift`
+
+**Test names:**
+- `tocToggleEnabledWhenSidebarOpenInRawSource` — the primary T-1237 regression.
+- `tocToggleDisabledWhenSidebarClosedInRawSource` — verifies opening is still blocked.
+- `tocToggleEnabledInRenderedMode` — verifies normal rendered-mode behaviour is unaffected.
+
+**What it verifies:** The 2×2 matrix of (`showRawSource` × `showLeftSidebar`) for the TOC button disable predicate. Only the closed-and-raw-source case is disabled.
+
+**Run command:**
+```
+xcodebuild test -project prism.xcodeproj -scheme prism \
+  -destination 'platform=macOS' \
+  -only-testing:prismTests/RegularLayoutToolbarTests
+```
+
+## Affected Files
+
+| File | Change |
+|------|--------|
+| `prism/Views/RegularDocumentLayout.swift` | Replaced `.inactive(showRawSource)` on TOC toolbar button with a state-aware helper; extracted the helper for testability; changed toolbar struct from `private` to `internal`. |
+| `prismTests/RegularLayoutToolbarTests.swift` | New regression tests for the TOC button disable predicate. |
+
+## Verification
+
+**Automated:**
+- [x] Regression tests pass
+- [x] Full unit test suite passes (`make test-quick`)
+- [x] Linter passes (`make lint`)
+
+**Manual verification (matrix):**
+- Sidebar open + rendered: button enabled (close action)
+- Sidebar closed + rendered: button enabled (open action)
+- Sidebar open + raw source: button enabled (close action) — **previously stuck**
+- Sidebar closed + raw source: button disabled (cannot open)
+
+## Prevention
+
+**Recommendations to avoid similar bugs:**
+- When applying `.inactive(...)`/`.disabled(...)` to a toggle control that switches between two semantic actions, consider whether each action remains valid independently. A blanket disable assumes both actions are equally invalid in the disabled state, which is often false for persistent-state toggles.
+- Compact (sheet-based) and regular (sidebar-based) layouts diverge on state persistence. A pattern that works in compact may trap users in regular — review both layouts when adding cross-mode interaction rules.

Things to double-check

Test framework <code>#expect(... == true/false)</code> style The new tests use explicit-equality #expect(value == true). Confirmed prevailing style — both that form and bare #expect(value) appear hundreds of times across prismTests/. No action needed; the bare form would be slightly tighter but is purely taste.
Visibility widening blast radius Verified: only one production reference to RegularLayoutToolbar exists outside the test file (the use site at line 129). The widening exposes nothing that the test target couldn't already reach via @testable, but it does loosen access for the rest of the module.
Truth-table coverage Confirmed: tocToggleEnabledInRenderedMode bundles both rendered-mode cases (open and closed) into two #expect calls in one @Test, and the two raw-source tests cover the remaining cases — all four (showRawSource × showLeftSidebar) combinations are exercised.