prism branch T-1577/notes-action-placement commits 18 files 27 touched lines +3110 / -376

Pre-push review: T-1577 notes-action-placement

Copy becomes the primary notes action across four surfaces via one shared CopyNotesButton and one behavioural copy-availability predicate; the Share-with-Notes flow hoists to a screen-level ExportNotesFlow. 18 commits: full spec, three implementation phases (TDD, parallel work streams), per-phase design reviews, and this pre-push review’s fixes.

At a glance

  • One implementation per behaviour: one payload call site, one availability predicate, one share flow, one screen banner — five drifted per-surface statics deleted, grep-clean.
  • Two latent bugs fixed: imported/orphaned-only documents copied an empty string (Decision 12); post-purchase export retries died with the dismissed pane (Decision 13/M1).
  • Review fix: O(1) all-empty guards on both visibility predicates — the no-notes case previously paid a full content-hash block-ID scan (quadratic on tables) up to 5× per Observation invalidation.
  • 20/20 acceptance criteria code-complete; 18 verified automated, 2 recorded as device-only manual checks in design.md.
  • Follow-ups tracked, not blocking: macOS File > Export unification onto the flow, perform context-struct refactor, self-hiding ExportWithNotesButton, T-1652 test-race.

Verdict

Ready to push

All four review agents came back with zero blocking findings; the one major (an O(doc) predicate scan paid by documents with no notes on every body pass) was fixed in-review along with seven smaller items, and the touched suites pass serially (110 tests, only the known pre-existing T-1457 failure). Two device-only checks (Req 2.5 username prompt above the pane sheet; Req 3.3 Liquid Glass grouping) remain recorded in the spec for a later manual pass — acknowledged as deferred by the author.

Review findings

12 raised · 5 fixed · 7 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism can get your notes out of a document two ways: copy (all notes onto the clipboard as Markdown) and export (a share sheet with the whole document, notes woven in). The export button used to sit in the always-visible toolbar while the more-used copy button hid inside the notes panel. This branch swaps their prominence: on iPhone the document screen now shows copy (export moved into the notes panel, which offers both); on iPad and Mac the top toolbar shows both, copy first. Every copy button is the same component, so payload, free-tier rules, and confirmation text are identical everywhere.

Why It Matters

The most frequent action is one tap away on every device, buttons appear exactly when there is something to copy, and two quiet bugs are gone: documents whose notes were all imported or orphaned used to copy an empty string, and an export interrupted by the paywall could silently fail to resume after purchase.

Key Concepts

  • Predicate — a yes/no rule: “would copying produce at least one note?” The button shows only when yes.
  • Paywall / free tier — 20 free copy/export uses; after a purchase the interrupted action re-runs automatically.
  • Orphaned / imported notes — notes from a sidecar file or whose anchor text was edited away; they still belong in “all notes”.

Changes Overview

  • NotesExporter.export no longer returns "" on a nil container; imported/orphaned notes emit with a fallbackDisplayName header (Decision 12). New hasIncludableNotes shares the exporter’s own collect/filter helpers so predicate and payload agree by construction (Decision 11), behind an O(1) all-empty guard.
  • NotesManager.hasCopyableNotes feeds the predicate the same merged anchored+imported map exportMarkdown uses.
  • StoreManager.runGatedExport is @discardableResult returning ExportGateResult — callers branch on .blocked instead of re-reading showPaywall.
  • New CopyNotesButton serves all four surfaces; static testable perform runs gate → payload → clipboard → increment-once → banner; a @ToolbarContentBuilder helper wraps the visibility conditional once for both toolbar hosts.
  • New @Observable ExportNotesFlow on DocumentLayoutCoordinator owns username-prompt/validation/error state; DocumentReaderView hosts the alerts; every share trigger is a thin ExportWithNotesButton. resetSessionState() resets flow + banner on document switch (Decision 15).

Implementation Approach

One implementation per behaviour: the drifted per-surface statics (exportMarkdownPayload, shouldShowActions×2, showsShareAction, copiedBannerMessage) were deleted and their tests retargeted onto the new contracts. Each implementation task had a failing-test task first; a 40-seed property-based test asserts predicate/payload agreement over arbitrary note populations.

Trade-offs

  • Static perform with explicit params (vs an instance flow): surfaces need different banner sinks per call; costs a 7-param signature and a re-listed retry closure (follow-up: context struct).
  • Two sibling ToolbarItems with explicit ids (vs one ToolbarItemGroup): per-item ids need separate items; adjacent same-placement items share one Liquid Glass bubble anyway — visual grouping is a recorded manual check.
  • Mutable test seams (copyToClipboard, share) instead of protocol injection: the parallel locale-matrix test processes share one pasteboard/UserDefaults; the static seam is let in release.

Technical Deep Dive

Predicate/payload coupling. hasIncludableNotes shares the exporter’s private collectors, making filter-semantics drift structurally impossible; the unused documentNotes param is kept for signature parity (post-Decision-12 it feeds only the header). The all-empty guard matters: the per-block scan resolves content-hash block IDs — tables materialise allTableRowIds() (quadratic in rows via repeated id access) — so a no-notes 500KB document would pay ~1MB of string churn per body pass, up to 5× per Observation invalidation. hasActiveAnchoredNotes (export rule, pre-existing) got the same guard as the pane/sidebar now evaluate it per pass.

Retry lifecycle. pendingExportAction stores perform/run self-references, never view state. Decision 15 closes the two gaps the hoist created: resetSessionState() resets flow state (an open username prompt across a document switch would otherwise share the previous session’s notes via the retained pendingShareContext), and retryOnBanner routes pane-originated retry confirmations to the coordinator banner since the pane dismissed on .blocked. Residual: pendingExportAction itself is not cleared on document switch — bounded by paywall lifetime, pre-existing class of hazard.

Nudge bimodality. Synchronous clipboard paths use gate-captured nudgeMessage(for:); async completion paths (share sheet, NSSavePanel) use postIncrementNudgeMessage() because the gate result is gone by completion time. Consistent per call-site kind.

Architecture Impact

DocumentLayoutCoordinator is now the owner of cross-surface UX state (banner + export flow) — the landing place for future gated actions. The T-138 two-insertion-site parity contract is formally superseded (Decision 8); the placement contract is shouldShowShareButton at three insertion sites, pinned by ShareWithNotesPlacementTests.

Potential Issues

  • Req 2.5 (manual): flow alerts host under the pane sheet; SwiftUI may defer covered-context alert presentation. Remedy recorded: also attach exportNotesFlowAlerts to the sheet content.
  • Req 3.3 (manual): Liquid Glass grouping/flicker unverifiable in unit tests; explicit NotesToolbarItemIDs in place.
  • Export button visibility still hand-wired per host (unlike copy) — the T-1154 failure mode; follow-up: self-hiding ExportWithNotesButton.
  • Parallel locale-matrix test races on shared globals tracked as T-1652; re-run suspects serially before calling regression.

Important changes — detailed

NotesExporter: nil-container fix + shared-helper predicate

prism/Services/NotesExporter.swift

Why it matters. Correctness (imported/orphaned-only documents copied an empty string) and the foundation of Req 5: the copy button shows iff the copy output would contain a note.

What to look at. NotesExporter.swift: export(...) header/Source handling, hasIncludableNotes, collectOrphanedNotes

Takeaway. When a predicate must agree with a payload, share the payload’s own collect/filter helpers instead of reimplementing the semantics — drift becomes structurally impossible, and a property-based test keeps shortcuts honest.
Rationale. Decision 11 (predicate beside the exporter) and Decision 12 (emit imported/orphaned notes with a fallback header when no container exists).

CopyNotesButton: one component for four surfaces

prism/Views/CopyNotesButton.swift

Why it matters. Deletes four drifted per-surface implementations; the gate → payload → clipboard → increment-once → banner sequence now exists exactly once and is unit-tested.

What to look at. CopyNotesButton.swift: perform(...), toolbarContent(...), retryOnBanner, NotesToolbarItemID

Takeaway. SwiftUI bodies aren’t unit-inspectable — give shared buttons a static, fully-parameterised entry point and test that; the view is a thin shell over it.
Rationale. Decision 10; retryOnBanner added by Decision 15 so a pane-originated retry-after-purchase banner lands on a surface that still exists.

ExportNotesFlow: Share-with-Notes state hoisted to screen level

prism/Views/DocumentLayoutCoordinator.swift

Why it matters. Fixes the M1 bug: a post-purchase retry previously called into a dismissed view’s @State — the username prompt silently never appeared. Flow state now survives any surface dismissal.

What to look at. DocumentLayoutCoordinator.swift: ExportNotesFlow, exportNotesFlow lazy wiring, resetSessionState()

Takeaway. State that must outlive the view that triggers it belongs on a coordinator object; store retries as self-references on that object, never as view-local closures.
Rationale. Decision 13; reset hygiene (an open prompt across a document switch would share the previous session’s notes) added as Decision 15 from the phase 1 design review.

StoreManager.runGatedExport returns its gate result

prism/Services/StoreManager.swift

Why it matters. Callers branched on .blocked by re-reading showPaywall after the fact — coupling to a side effect. The returned ExportGateResult makes blocked-path handling deterministic.

What to look at. StoreManager.swift: runGatedExport @discardableResult, #if DEBUG setEntitlementStateForTesting

Takeaway. When callers need to react to a decision a helper already made, return the decision — don’t make them reverse-engineer it from mutated state.
Rationale. Decision 13 (design NEW-2); @discardableResult keeps the pre-existing call sites source-compatible.

Surface wiring: compact copy, regular copy-leads-export, pane/sidebar both

prism/Views/RegularDocumentLayout.swift

Why it matters. The user-visible feature. Compact screen swaps export for copy; regular toolbar hosts copy leading export as sibling ToolbarItems with stable explicit ids; pane and sidebar adopt the shared components; all nudges route to one coordinator banner.

What to look at. RegularDocumentLayout.swift:510-536, DocumentReaderView.swift:143-156/365-374, NotesPanel.swift:127-168, SidebarNotesView.swift:143-167

Takeaway. Per-item explicit ToolbarItem ids are the tool for keeping Liquid Glass toolbar identity stable when adjacent items appear/disappear independently.
Rationale. Decisions 6 (order), 8 (T-138 parity superseded), 14 (sidebar share follows the export rule).

O(1) all-empty guards on both visibility predicates (review fix)

prism/Services/NotesManager.swift

Why it matters. The no-notes state — the most common — paid a full content-hash block-ID scan (quadratic on tables) on every body pass, up to 5× per Observation invalidation across surfaces. Now O(1).

What to look at. NotesExporter.hasIncludableNotes guard; NotesManager.hasActiveAnchoredNotes guard

Takeaway. A predicate that is cheap per call but evaluated per body pass on several surfaces multiplies; make the common case O(1) before it reaches a render path.
Rationale. Pre-push efficiency review finding; the pre-change surfaces gated on O(1) checks (hasDisplayableNotes/hasNotes), so parity for the common case was the bar.

Test retargeting: parity contract → placement contract

prismTests/ShareWithNotesPlacementTests.swift

Why it matters. The T-138 compact/regular parity tests asserted a contract this feature deliberately ends; they were rewritten as the Decision 8 placement contract rather than deleted, keeping the T-1154 sub-block and orphan-exclusion regression coverage alive.

What to look at. InlineNotesExportToolbarTests.swift → ShareWithNotesPlacementTests.swift; SidebarNotesViewTests predicate/share-rule tests; CopyNotesButtonTests payload-parity

Takeaway. When a feature supersedes a tested contract, retarget the tests to the new contract in the same branch — deleting them silently drops adjacent regression coverage that still applies.
Rationale. Spec phase 3 (tasks 12–13), Decision 8.

Key decisions

Decision 8 — End the T-138 compact/regular Share-button parity.

Removing export from the compact document screen is the point of the feature; the parity contract and its tests were formally superseded, with the insertion-site contract (shouldShowShareButton at regular toolbar, pane, sidebar) pinned by the renamed ShareWithNotesPlacementTests.

Decision 9 — A paywall-gated action becomes the primary visible affordance.

Copy stays gated (each copy consumes an export credit); making it primary raises its visibility, not its cost. Free-tier mechanics unchanged: gate → paywall on blocked → pending-action retry after purchase.

Decision 10/11 — One button component; predicate beside the exporter.

CopyNotesButton owns visibility, gating, and banner text for all four surfaces; hosts own only toast presentation. The availability predicate lives in NotesExporter sharing export(...)’s helpers so predicate and payload cannot drift.

Decision 12 — Fix the exporter’s nil-container bug rather than encode it in the predicate.

export(...) returning "" for imported-only documents was a latent bug, not a contract; a predicate agreeing with it would have made empty copies permanent. Fixed with a fallbackDisplayName header and omitted Source line.

Decision 13 — Hoist flow state to the coordinator; return the gate result.

Screen-level flow state is the only way Req 2.4’s retry and 2.5’s prompt can both hold once the pane dismisses. Returning ExportGateResult replaces the fragile showPaywall read-back.

Decision 14 — Sidebar share follows the export visibility rule.

The sidebar share button is the same full-document flow, so it hides for copyable-but-not-exportable populations (e.g. orphaned-only) — a small visible change from the old pair-gating with copy, covered by a dedicated test.

Decision 15 — Session-reset hygiene and a retry banner sink.

Both halves came out of the phase 1 design review: resetSessionState() resets the flow (an open username prompt across a document switch would share the previous session’s notes), and retryOnBanner gives pane hosts a surviving surface for the retry confirmation. Regression tests added at this review.

Review findings

SeverityAreaFindingResolution
majorNotesExporter/NotesManager predicateshasIncludableNotes paid a full content-hash block-ID scan (tables quadratic via allTableRowIds) for documents with no notes — the common state — up to 5× per Observation invalidation across the copy surfaces; hasActiveAnchoredNotes likewise builds a full ID set and is newly on pane/sidebar body paths.O(1) all-empty guards added to both predicates. Touched suites re-run serially: 110 tests, only the known pre-existing failure.
minorCopyNotesButton test seamstatic var copyToClipboard was a process-global mutable function pointer shipped in release builds; sibling seams were better guarded (instance-scoped share, #if DEBUG entitlement hook).#if DEBUG var / #else let — immutable in release.
minorDecision 15 test coverageThe reset hygiene Decision 15 exists for (a stale prompt sharing the previous document’s notes) had zero automated coverage; resetSessionState/cancelUsernamePrompt were untested.Two regression tests added: resetSessionState defuses an open prompt (banner cleared, late submit is a no-op); cancel clears the pending share.
minorSpec/doc stalenessDecision 15 still “proposed” though shipped; requirements 3.4 cited “(Decision 14, proposed)”; design.md said “same ToolbarItemGroup” while the implementation uses sibling ToolbarItems; CLAUDE.md source structure missing CopyNotesButton.swift and ExportNotesFlow.All four references updated to match reality.
nitToolbar item ids"copyNotes"/"exportNotes" raw literals in different files sharing one identity namespace — the ids exist precisely to protect identity against each other.Shared NotesToolbarItemID namespace.
minormacOS File > Export pathtriggerExport() in DocumentReaderView hand-rolls the gate → username-check → prompt sequence ExportNotesFlow now encapsulates, with a character-for-character duplicate alert stack attached to the same view.Skipped: predates the branch and unification is a behaviour-touching refactor; recorded as the top follow-up (ExportNotesFlow was built with a pluggable share seam for exactly this).
minorCopyNotesButton.perform shape7 parameters, a retry closure re-listing six of them, and the static seam — an instance context struct (mirroring ShareContext) would collapse all three.Skipped: would change the tested API surface mid-review; recorded as follow-up.
minorExportWithNotesButton visibilityThe export gate (shouldShowShareButton) is hand-wired at each of four insertion sites while its sibling self-hides — the T-1154 failure mode (a host forgetting the gate).Skipped: tests pin the current gates; self-hiding shape recorded as follow-up.
minorpendingExportAction retentionThe stored retry strongly captures the session/notesManager and is not cleared by resetSessionState — a retry fired after a document-switch-while-paywalled re-runs against the old session.Skipped: bounded by paywall lifetime, pre-existing hazard class, modal paywall masks it in practice; noted in implementation.md.
nithasIncludableNotes signaturedocumentNotes parameter is accepted but unused (post-Decision-12 it feeds only the header); kept for signature parity and documented.Skipped: dropping it forces test edits for a cosmetic gain; recorded as follow-up.
nitNotesPanel toolbar conditionalPane hosts a self-hiding button inside an unconditional ToolbarItem while toolbarContent documents hoisting the conditional for Liquid Glass slots.Skipped: the pane’s own pre-existing convention is conditional-inside-item (add-note and export items likewise); the self-hide is the operative predicate for pane/sidebar surfaces, not dead weight.
nitmacOS File > Export nudge coverageThe “must still fire post-migration” nudge writer shipped without automated coverage (view-layer code).Skipped: testable only after the File > Export flow unification follow-up; flagged there.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +10 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5279b27..685a183 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ## [Unreleased] +### Added++- Notes action placement spec: requirements, design, tasks, and decision log for making copy the primary notes action — copy on the compact document screen and regular toolbar, export added to the notes pane, one shared copy-availability predicate across all copy surfaces (T-1577)++### Changed++- Copy notes is now the primary notes action (T-1577). On iPhone the document screen's toolbar shows Copy notes instead of Share with Notes, which moved into the notes pane alongside copy; on iPad and Mac the top toolbar shows copy leading the export button. Every copy button appears exactly when the copy output would contain at least one note under the current export settings, each action carries an accessibility label and help text, and an export blocked by the paywall from inside the pane now retries fully — including the author-name prompt and its confirmation toast — after a purchase completes.+- The test suite covering notes-action placement was retargeted to the new contracts (T-1577): the T-138 share-button parity tests became the Share-with-Notes placement contract, and visibility/payload tests now assert the shared copy-availability predicate and the single export payload call site. Two device-only checks are recorded in the spec for manual verification.+ ### Fixed +- Copying notes from a document whose notes are all imported or orphaned (no saved notes container) now produces those notes with a proper header, instead of silently copying an empty string. Part of the notes-action-placement groundwork (T-1577 phase 1), which also introduced the shared copy button, the screen-level Share-with-Notes flow, and the single copy-availability rule that the toolbar and pane surfaces adopt in the next phase. - macOS: tapping a diagram or image again after its zoom window is already open now brings that window to the front instead of opening a duplicate window. - Markdown links whose target mixes percent-encoded and literal characters (for example `[doc](my%20file and more.md)`) now open the correct file or page. The web renderer no longer double-escapes the link before resolving it, so such links route to `my%20file%20and%20more.md` instead of the broken `my%2520file%20and%20more.md`. - iOS image zoom: starting a drag right after tapping a toolbar zoom button (Zoom In/Out/Fit/Reset) no longer jumps the image back toward its previous pan position. The drag now continues smoothly from where the image currently sits.
CLAUDE.md Modified +2 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex 9d7a9fa..1344c6c 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -187,7 +187,8 @@ prism/ │   ├── DocumentReaderView.swift       # Platform-adaptive layout selector │   ├── CompactDocumentLayout.swift    # iPhone layout (platform-specific shell) │   ├── RegularDocumentLayout.swift    # iPad/macOS layout (platform-specific shell)-│   ├── DocumentLayoutCoordinator.swift # Shared state for both layouts+│   ├── DocumentLayoutCoordinator.swift # Shared state for both layouts + ExportNotesFlow (screen-level Share-with-Notes flow)+│   ├── CopyNotesButton.swift          # Shared copy-notes button for all four copy surfaces + toolbar helper │   ├── DocumentScrollContent.swift    # Shared scroll content body for both layouts │   ├── SharedBlockViews.swift         # Shared block rendering, scroll helpers, navigation title │   ├── SharedCollapsibleSections.swift # Shared collapsible section logic
prism/Localizable.xcstrings Modified +46 / -0
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstringsindex c7c94c7..bc61951 100644--- a/prism/Localizable.xcstrings+++ b/prism/Localizable.xcstrings@@ -3037,6 +3037,52 @@         }       }     },+    "Notes copied": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Notes copied"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Notes copied"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Notes copied"+          }+        }+      }+    },+    "Notes copied — %@": {+      "extractionState": "manual",+      "localizations": {+        "en": {+          "stringUnit": {+            "state": "translated",+            "value": "Notes copied — %@"+          }+        },+        "en-GB": {+          "stringUnit": {+            "state": "translated",+            "value": "Notes copied — %@"+          }+        },+        "en-US": {+          "stringUnit": {+            "state": "translated",+            "value": "Notes copied — %@"+          }+        }+      }+    },     "Notes list": {       "extractionState": "manual",       "localizations": {
prism/Services/InlineNotesShareHelper.swift Modified +64 / -86
diff --git a/prism/Services/InlineNotesShareHelper.swift b/prism/Services/InlineNotesShareHelper.swiftindex 757bb18..2f82203 100644--- a/prism/Services/InlineNotesShareHelper.swift+++ b/prism/Services/InlineNotesShareHelper.swift@@ -18,16 +18,19 @@ import AppKit private let logger = Logger.prism(category: "InlineNotesShareHelper")  enum InlineNotesShareHelper {-    /// Whether the Share-with-Notes toolbar action should be visible for the+    /// Whether the Share-with-Notes action should be visible for the     /// given session.     ///     /// Requirements 8.3, 8.5, 10.1 (`specs/export-with-inline-notes/requirements.md`)     /// and Decision 19 of the same spec require the action be hidden when there     /// are no active anchored notes (including orphaned-note exclusion).     ///-    /// Both `InlineNotesExportToolbar` (compact layout) and `RegularLayoutToolbar`-    /// (regular layout) gate `ExportWithNotesButton` on this predicate so the two-    /// insertion sites stay in lockstep and tests have one observable contract.+    /// The T-138 compact/regular insertion-site parity contract is superseded:+    /// the compact document screen no longer hosts the export button+    /// (notes-action-placement Req 1.2, Decision 8). Every remaining+    /// Share-with-Notes insertion site — the regular toolbar, the notes pane,+    /// and the notes sidebar — gates `ExportWithNotesButton` on this predicate+    /// so tests keep one observable contract.     @MainActor     static func shouldShowShareButton(         notesManager: NotesManager,@@ -106,115 +109,90 @@ enum InlineNotesShareHelper {  // MARK: - Export Button View -/// Reusable export button for embedding in toolbar blocks.+/// Reusable Share-with-Notes trigger for embedding in toolbar blocks.+///+/// A thin trigger — label, help text, and disabled-while-loading only. All+/// flow state (username prompt, validation/error alerts, nudge banner) lives+/// in `ExportNotesFlow`, owned by `DocumentLayoutCoordinator` with alerts+/// hosted by `DocumentReaderView`, so the flow survives dismissal of the+/// surface the button sits on (notes-action-placement Decision 13,+/// Req 2.4/2.5). struct ExportWithNotesButton: View {     let notesManager: NotesManager     let session: DocumentSession+    let flow: ExportNotesFlow++    /// Invoked when the export gate blocks (the paywall is presenting); the+    /// notes pane passes pane-dismiss on iOS (Req 2.4). Driven by the+    /// returned gate result, never by re-reading `showPaywall`.+    var onBlocked: (() -> Void)? -    @State private var showUsernamePrompt = false-    @State private var showExportError = false-    @State private var showValidationError = false-    @State private var usernameInput = ""-    @State private var nudgeMessage: String?     @Environment(AppSettings.self) private var settings     @Environment(StoreManager.self) private var storeManager      var body: some View {         Button {-            triggerShare()+            let gate = flow.run(+                notesManager: notesManager,+                session: session,+                settings: settings,+                storeManager: storeManager+            )+            if gate == .blocked {+                onBlocked?()+            }         } label: {             Image(systemName: "square.and.arrow.up")         }         .accessibilityLabel(LocalizedStringKey("Share with Notes"))         .help("Share with Notes")         .disabled(storeManager.entitlementState == .loading)-        .toast(message: $nudgeMessage)-        .alert("Enter Your Name", isPresented: $showUsernamePrompt) {-            TextField("Name", text: $usernameInput)-            Button("Export") {-                if case .success(let name) = AppSettings.validateUsername(usernameInput) {-                    settings.exportUsername = name-                    performShare()-                } else {-                    showValidationError = true-                }-            }-            Button("Cancel", role: .cancel) {}-        } message: {-            Text("Your name will appear as the author on exported notes.")-        }-        .alert("Invalid Name", isPresented: $showValidationError) {-            Button("Try Again") {-                showUsernamePrompt = true-            }-            Button("Cancel", role: .cancel) {}-        } message: {-            Text("Name cannot be empty or contain ] characters.")-        }-        .alert("Export Failed", isPresented: $showExportError) {-            Button("OK", role: .cancel) {}-        } message: {-            Text("Could not create the export file. Please try again.")-        }-    }--    /// Checks the export gate, then either shows the paywall (req 4.4) or-    /// proceeds with the username prompt + share flow (req 4.2, 4.3).-    private func triggerShare() {-        storeManager.runGatedExport(-            retry: { triggerShare() },-            perform: { _ in-                if settings.exportUsername.isEmpty {-                    usernameInput = ""-                    showUsernamePrompt = true-                } else {-                    performShare()-                }-            }-        )-    }--    private func performShare() {-        let succeeded = InlineNotesShareHelper.share(-            notesManager: notesManager,-            session: session,-            settings: settings,-            onComplete: {-                storeManager.incrementExportCount()-                nudgeMessage = storeManager.postIncrementNudgeMessage()-            }-        )-        if !succeeded {-            showExportError = true-        }     } } -// MARK: - Toolbar Modifier (for CompactDocumentLayout)+// MARK: - Flow Alert Host (for DocumentReaderView) -/// View modifier that adds export button via toolbar modifier.-/// Used by CompactDocumentLayout which doesn't define its own toolbar block.-private struct InlineNotesExportToolbar: ViewModifier {-    let notesManager: NotesManager-    let session: DocumentSession+/// Hosts `ExportNotesFlow`'s alerts at screen level, above any pane sheet, so+/// the username prompt and error alerts present regardless of which surface+/// triggered the export — including after that surface has been dismissed+/// (notes-action-placement Decision 13, Req 2.4/2.5).+private struct ExportNotesFlowAlerts: ViewModifier {+    @Bindable var flow: ExportNotesFlow      func body(content: Content) -> some View {         content-            .toolbar {-                if InlineNotesShareHelper.shouldShowShareButton(-                    notesManager: notesManager,-                    session: session-                ) {-                    ToolbarItem(placement: .primaryAction) {-                        ExportWithNotesButton(notesManager: notesManager, session: session)-                    }+            .alert("Enter Your Name", isPresented: $flow.showUsernamePrompt) {+                TextField("Name", text: $flow.usernameInput)+                Button("Export") {+                    flow.submitUsername()+                }+                Button("Cancel", role: .cancel) {+                    flow.cancelUsernamePrompt()+                }+            } message: {+                Text("Your name will appear as the author on exported notes.")+            }+            .alert("Invalid Name", isPresented: $flow.showValidationError) {+                Button("Try Again") {+                    flow.reopenUsernamePrompt()+                }+                Button("Cancel", role: .cancel) {+                    flow.cancelUsernamePrompt()                 }+            } message: {+                Text("Name cannot be empty or contain ] characters.")+            }+            .alert("Export Failed", isPresented: $flow.showExportError) {+                Button("OK", role: .cancel) {}+            } message: {+                Text("Could not create the export file. Please try again.")             }     } }  extension View {-    func inlineNotesExportToolbar(notesManager: NotesManager, session: DocumentSession) -> some View {-        modifier(InlineNotesExportToolbar(notesManager: notesManager, session: session))+    /// Attaches the screen-level alert host for the Share-with-Notes flow.+    func exportNotesFlowAlerts(_ flow: ExportNotesFlow) -> some View {+        modifier(ExportNotesFlowAlerts(flow: flow))     } }
prism/Services/NotesExporter.swift Modified +67 / -8
diff --git a/prism/Services/NotesExporter.swift b/prism/Services/NotesExporter.swiftindex 5a92321..352e052 100644--- a/prism/Services/NotesExporter.swift+++ b/prism/Services/NotesExporter.swift@@ -16,12 +16,18 @@ import Foundation struct NotesExporter {     /// Export notes as Markdown.     ///+    /// Imported and orphaned notes can exist without a persisted container+    /// (`documentNotes == nil`); they are still emitted, with the header title+    /// derived from `fallbackDisplayName` and the Source line omitted+    /// (notes-action-placement Decision 12).+    ///     /// - Parameters:-    ///   - documentNotes: The document notes container.+    ///   - documentNotes: The document notes container, if one has been persisted.     ///   - anchoredNotes: Notes grouped by block ID.     ///   - orphanedNotes: Notes that couldn't be matched to current blocks.     ///   - blocks: Current document blocks for ordering.     ///   - username: The configured export username, used as fallback author for user-created notes.+    ///   - fallbackDisplayName: Header title when no container exists (the manager's display name).     /// - Returns: Markdown string ready for sharing.     static func export(         documentNotes: DocumentNotes?,@@ -30,17 +36,20 @@ struct NotesExporter {         blocks: [MarkdownBlock],         username: String = "",         includeResolved: Bool = false,-        showHTMLComments: Bool = false+        showHTMLComments: Bool = false,+        fallbackDisplayName: String = "document"     ) -> String {-        guard let notes = documentNotes else { return "" }-         var output = ""          // Requirement 11.2: Title with document filename-        output += "# Notes: \(notes.displayName)\n\n"+        output += "# Notes: \(documentNotes?.displayName ?? fallbackDisplayName)\n\n" -        // Requirement 11.3: Metadata with full identifier and export date-        output += "**Source:** \(notes.identifier.path)  \n"+        // Requirement 11.3: Metadata with full identifier and export date.+        // The identifier lives on the container, so the Source line is omitted+        // when no container exists (Decision 12).+        if let notes = documentNotes {+            output += "**Source:** \(notes.identifier.path)  \n"+        }         output += "**Exported:** \(formatDate(Date()))\n\n"         output += "---\n\n" @@ -74,7 +83,7 @@ struct NotesExporter {         }          // Requirement 11.9: Orphaned notes in separate section, ordered by creation date-        let activeOrphans = includeResolved ? orphanedNotes : orphanedNotes.filter { $0.status == .active }+        let activeOrphans = collectOrphanedNotes(orphanedNotes, includeResolved: includeResolved)         if !activeOrphans.isEmpty {             if exportedCount > 0 {                 output += "---\n\n"@@ -88,6 +97,56 @@ struct NotesExporter {         return output     } +    /// Whether `export(...)` with the same inputs would emit at least one note.+    ///+    /// This is the copy-availability predicate (notes-action-placement Req 5.1,+    /// Decision 11): it reuses the exact collect/filter helpers `export(...)`+    /// uses, so predicate and payload cannot drift. Header metadata alone does+    /// not count, and a nil container no longer forces false (Decision 12) —+    /// `documentNotes` is accepted only for signature parity with `export(...)`,+    /// since post-fix it feeds the header, never the emitted notes.+    ///+    /// Cheap paths (document-level sentinel, orphaned list) are checked before+    /// the per-block scan, and the no-notes case — the common state, evaluated+    /// on every body pass of the copy surfaces — is O(1): the block scan+    /// rebuilds content-hash block IDs (including list-item and table-row+    /// sub-IDs), which is far too expensive to pay for documents without notes.+    static func hasIncludableNotes(+        documentNotes: DocumentNotes?,+        anchoredNotes: [String: [BlockNote]],+        orphanedNotes: [BlockNote],+        blocks: [MarkdownBlock],+        includeResolved: Bool+    ) -> Bool {+        if anchoredNotes.isEmpty && orphanedNotes.isEmpty {+            return false+        }++        let docLevelNotes = collectNotesById(+            BlockNote.documentSentinelId, from: anchoredNotes, includeResolved: includeResolved+        )+        if !docLevelNotes.isEmpty {+            return true+        }++        if !collectOrphanedNotes(orphanedNotes, includeResolved: includeResolved).isEmpty {+            return true+        }++        return blocks.contains { block in+            !collectNotes(for: block, from: anchoredNotes, includeResolved: includeResolved).isEmpty+        }+    }++    /// Filter orphaned notes by the include-resolved setting.+    /// Shared by `export(...)` and `hasIncludableNotes(...)` (Decision 11).+    private static func collectOrphanedNotes(+        _ orphanedNotes: [BlockNote],+        includeResolved: Bool+    ) -> [BlockNote] {+        includeResolved ? orphanedNotes : orphanedNotes.filter { $0.status == .active }+    }+     /// Collect notes by block ID (for document-level notes).     /// When `includeResolved` is false, only active notes are returned.     private static func collectNotesById(
prism/Services/NotesManager.swift Modified +39 / -9
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swiftindex c312960..d0e476b 100644--- a/prism/Services/NotesManager.swift+++ b/prism/Services/NotesManager.swift@@ -437,6 +437,13 @@ final class NotesManager {     /// - Parameter blocks: The current document blocks.     /// - Returns: `true` if at least one active note is anchored to a current block or is document-level.     func hasActiveAnchoredNotes(in blocks: [MarkdownBlock]) -> Bool {+        // O(1) guard for the common no-notes state: the ID-set build below+        // rebuilds content-hash block IDs (plus list-item/table-row sub-IDs)+        // and this predicate now runs in body passes on several surfaces.+        if anchoredNotes.isEmpty && importedNotes.isEmpty {+            return false+        }+         var blockIds = Set(blocks.map(\.id))         for block in blocks {             switch block {@@ -1013,23 +1020,46 @@ extension NotesManager {         includeResolved: Bool = false,         showHTMLComments: Bool = false     ) -> String {-        // Merge user and imported notes for export-        var allAnchored = anchoredNotes-        for (blockId, notes) in importedNotes {-            allAnchored[blockId] = notes + (allAnchored[blockId] ?? [])-        }--        return NotesExporter.export(+        NotesExporter.export(             documentNotes: documentNotes,-            anchoredNotes: allAnchored,+            anchoredNotes: mergedAnchoredNotes,             orphanedNotes: orphanedNotes,             blocks: blocks,             username: username,             includeResolved: includeResolved,-            showHTMLComments: showHTMLComments+            showHTMLComments: showHTMLComments,+            fallbackDisplayName: documentDisplayName+        )+    }++    /// Whether copying notes would produce at least one note under the current+    /// export settings — the copy-availability predicate shared by every copy+    /// surface (notes-action-placement Req 5.1/5.2).+    ///+    /// Delegates to `NotesExporter.hasIncludableNotes` with the same merged+    /// anchored+imported map, orphaned list, and container that `exportMarkdown`+    /// passes, so the predicate and the payload agree by construction (Decision 11).+    func hasCopyableNotes(blocks: [MarkdownBlock], includeResolved: Bool) -> Bool {+        NotesExporter.hasIncludableNotes(+            documentNotes: documentNotes,+            anchoredNotes: mergedAnchoredNotes,+            orphanedNotes: orphanedNotes,+            blocks: blocks,+            includeResolved: includeResolved         )     } +    /// User and imported notes merged into one anchored map, as consumed by+    /// `NotesExporter`. Shared by `exportMarkdown` and `hasCopyableNotes` so+    /// the predicate sees exactly the payload's inputs (Decision 11).+    private var mergedAnchoredNotes: [String: [BlockNote]] {+        var allAnchored = anchoredNotes+        for (blockId, notes) in importedNotes {+            allAnchored[blockId] = notes + (allAnchored[blockId] ?? [])+        }+        return allAnchored+    }+     /// Export the document with notes embedded as `[!COMMENT]` blockquotes.     ///     /// Produces a copy of the raw source with notes injected inline
prism/Services/StoreManager.swift Modified +21 / -2
diff --git a/prism/Services/StoreManager.swift b/prism/Services/StoreManager.swiftindex defe063..24fdd61 100644--- a/prism/Services/StoreManager.swift+++ b/prism/Services/StoreManager.swift@@ -285,24 +285,43 @@ extension StoreManager {     /// branches uniformly. Used by every gated call site to avoid duplicating     /// the switch and the paywall-presentation wiring (req 4.1–4.4).     ///+    /// Returns the gate result so callers can branch on `.blocked`+    /// deterministically (e.g. dismiss the presenting pane so the paywall is+    /// visible) instead of re-reading `showPaywall` after the fact+    /// (notes-action-placement Decision 13).+    ///     /// - Parameters:     ///   - retry: invoked on `.blocked` as `pendingExportAction` so the     ///     paywall's `onDismiss` can re-run the export after a successful     ///     purchase (req 6.7).     ///   - perform: receives the gate result for the allowed branches.+    @discardableResult     func runGatedExport(         retry: @escaping () -> Void,         perform: (ExportGateResult) -> Void-    ) {+    ) -> ExportGateResult {         let gate = checkExport()         switch gate {         case .loading:-            return+            break         case .blocked:             pendingExportAction = retry             showPaywall = true         case .allowed, .allowedWithNudge:             perform(gate)         }+        return gate+    }+}++#if DEBUG+extension StoreManager {+    /// Test-only: simulates an entitlement transition (e.g. a completed+    /// purchase) without contacting StoreKit. Pairs with the test-only+    /// `init(exportCounter:entitlementState:)` so retry-after-purchase flows+    /// can be exercised in unit tests.+    func setEntitlementStateForTesting(_ state: EntitlementState) {+        entitlementState = state     } }+#endif
prism/Views/CompactDocumentLayout.swift Modified +3 / -0
diff --git a/prism/Views/CompactDocumentLayout.swift b/prism/Views/CompactDocumentLayout.swiftindex 6c1abcf..d505b66 100644--- a/prism/Views/CompactDocumentLayout.swift+++ b/prism/Views/CompactDocumentLayout.swift@@ -171,6 +171,9 @@ struct CompactDocumentLayout: View {                 notesManager: notesManager,                 blocks: session.parsedBlocks,                 structure: session.documentStructure,+                session: session,+                exportFlow: coordinator.exportNotesFlow,+                onRetryBanner: { coordinator.bannerMessage = $0 },                 onNavigate: { blockId in                     notesScrollTarget = blockId                 },
prism/Views/CopyNotesButton.swift Added +205 / -0
diff --git a/prism/Views/CopyNotesButton.swift b/prism/Views/CopyNotesButton.swiftnew file mode 100644index 0000000..4747657--- /dev/null+++ b/prism/Views/CopyNotesButton.swift@@ -0,0 +1,205 @@+//+//  CopyNotesButton.swift+//  prism+//+//  Shared copy-all-notes button for every copy surface: compact document+//  screen, regular top toolbar, notes pane, and notes sidebar+//  (notes-action-placement Decision 10).+//++import SwiftUI++/// One button view for all four copy surfaces.+///+/// Encapsulates the availability predicate (`NotesManager.hasCopyableNotes`,+/// Req 5.1/5.2), the paywall gate, the payload, the count increment, and the+/// banner message text. Hosts route the banner to their existing toast+/// surface through `onBanner` (coordinator banner for the toolbar hosts,+/// pane-local state for the pane surfaces).+///+/// No keyboard shortcut parameter by design: Cmd+C stays text-selection copy+/// (Req 3.5, Decision 7).+struct CopyNotesButton: View {+    /// Visual context the button is embedded in.+    enum Style {+        /// Toolbar item — default system button styling.+        case toolbar+        /// Pane/sidebar header — borderless, matching the sibling header buttons.+        case header+    }++    /// The notes manager providing note data and the copy payload.+    let notesManager: NotesManager++    /// Current document blocks, passed through to the payload and predicate.+    let blocks: [MarkdownBlock]++    /// Visual context; defaults to the toolbar styling.+    var buttonStyle: Style = .toolbar++    /// Invoked when the export gate blocks (the paywall is presenting); the+    /// notes pane passes pane-dismiss on iOS (Req 2.4). Driven by the+    /// returned gate result, never by re-reading `showPaywall`.+    var onBlocked: (() -> Void)?++    /// Receives the confirmation / free-tier nudge banner text (Req 1.3).+    var onBanner: (String) -> Void++    /// Separate sink for the retry-after-purchase banner (Decision 15). Pane+    /// hosts pass the coordinator banner so the retry's confirmation lands on+    /// a surface that still exists after the pane dismissed; nil routes the+    /// retry banner through `onBanner`.+    var retryOnBanner: ((String) -> Void)?++    @Environment(AppSettings.self) private var settings+    @Environment(StoreManager.self) private var storeManager++    var body: some View {+        // Self-hides when nothing would be copied — defence in depth behind+        // the toolbar helper's conditional (Req 1.4: hidden wins over disabled).+        if notesManager.hasCopyableNotes(+            blocks: blocks,+            includeResolved: settings.exportIncludeResolved+        ) {+            switch buttonStyle {+            case .toolbar:+                button+            case .header:+                button.buttonStyle(.borderless)+            }+        }+    }++    private var button: some View {+        Button {+            Self.perform(+                notesManager: notesManager,+                blocks: blocks,+                settings: settings,+                storeManager: storeManager,+                onBanner: onBanner,+                retryOnBanner: retryOnBanner,+                onBlocked: onBlocked+            )+        } label: {+            Image(systemName: "doc.on.doc")+        }+        .accessibilityLabel(LocalizedStringKey("Copy notes"))+        .help("Copy all notes as Markdown")+        .disabled(storeManager.entitlementState == .loading)+    }++    // MARK: - Perform (testable entry point)++    /// Clipboard write hook. Mutable in DEBUG so unit tests can substitute a+    /// recorder instead of asserting on the shared system pasteboard, which+    /// races across the parallel locale-matrix test processes (see the+    /// `ExportNotesFlow.share` seam precedent); immutable in release builds.+    #if DEBUG+    @MainActor static var copyToClipboard: (String) -> Void = ClipboardHelper.copy+    #else+    @MainActor static let copyToClipboard: (String) -> Void = ClipboardHelper.copy+    #endif++    /// Runs the gate → payload → clipboard → increment-once → banner+    /// sequence (Req 1.3). The unit-testable entry point shared by every+    /// copy surface; SwiftUI bodies are not unit-inspectable.+    ///+    /// On `.blocked` the retry stored in `pendingExportAction` is `perform`+    /// itself — never view-local state — so a purchase completed after the+    /// originating surface dismissed still re-runs the copy (Req 2.4).+    /// `onBlocked` fires iff the returned gate is `.blocked`; `.loading` is+    /// a silent no-op.+    ///+    /// The stored retry banners through `retryOnBanner` (defaulting to+    /// `onBanner`): a pane-originated copy captures pane-local banner state+    /// that is dead by the time a purchase completes, so pane hosts pass the+    /// coordinator banner as the retry sink while keeping the pane-local+    /// banner for the immediate path (Decision 15).+    @MainActor+    @discardableResult+    static func perform(+        notesManager: NotesManager,+        blocks: [MarkdownBlock],+        settings: AppSettings,+        storeManager: StoreManager,+        onBanner: @escaping (String) -> Void,+        retryOnBanner: ((String) -> Void)? = nil,+        onBlocked: (() -> Void)? = nil+    ) -> ExportGateResult {+        let retryBanner = retryOnBanner ?? onBanner+        let gate = storeManager.runGatedExport(+            retry: {+                perform(+                    notesManager: notesManager,+                    blocks: blocks,+                    settings: settings,+                    storeManager: storeManager,+                    onBanner: retryBanner,+                    onBlocked: onBlocked+                )+            },+            perform: { gate in+                let markdown = notesManager.exportMarkdown(+                    blocks: blocks,+                    username: settings.exportUsername,+                    includeResolved: settings.exportIncludeResolved,+                    showHTMLComments: settings.showHTMLComments+                )+                copyToClipboard(markdown)+                storeManager.incrementExportCount()+                if let nudge = StoreManager.nudgeMessage(for: gate) {+                    // Composed via the catalog format key, not concatenation.+                    onBanner(String(localized: "Notes copied — \(nudge)"))+                } else {+                    onBanner(String(localized: "Notes copied"))+                }+            }+        )+        if gate == .blocked {+            onBlocked?()+        }+        return gate+    }++    // MARK: - Shared toolbar insertion (Req 1.1, 3.1)++    /// Toolbar content for the two toolbar hosts (compact document screen,+    /// regular layout). Wraps the `hasCopyableNotes` conditional around the+    /// `ToolbarItem` once, so the hosts cannot hand-wire divergent+    /// conditionals (Decision 10). The conditional sits at the item level+    /// because an empty `ToolbarItem` may still claim a Liquid Glass slot;+    /// the explicit item id keeps toolbar identity stable when copy/export+    /// visibility flips independently.+    @MainActor+    @ToolbarContentBuilder+    static func toolbarContent(+        notesManager: NotesManager,+        blocks: [MarkdownBlock],+        settings: AppSettings,+        onBanner: @escaping (String) -> Void+    ) -> some ToolbarContent {+        if notesManager.hasCopyableNotes(+            blocks: blocks,+            includeResolved: settings.exportIncludeResolved+        ) {+            ToolbarItem(id: NotesToolbarItemID.copy, placement: .primaryAction) {+                CopyNotesButton(+                    notesManager: notesManager,+                    blocks: blocks,+                    onBanner: onBanner+                )+            }+        }+    }+}++/// Shared identity namespace for the notes-action toolbar items. The regular+/// toolbar hosts both items behind independent visibility conditionals; the+/// explicit ids exist precisely to keep their identity stable against each+/// other (Req 3.3), so they live in one place rather than as scattered+/// string literals.+enum NotesToolbarItemID {+    static let copy = "copyNotes"+    static let export = "exportNotes"+}
prism/Views/DocumentLayoutCoordinator.swift Modified +188 / -0
diff --git a/prism/Views/DocumentLayoutCoordinator.swift b/prism/Views/DocumentLayoutCoordinator.swiftindex ccfcfa5..95decf1 100644--- a/prism/Views/DocumentLayoutCoordinator.swift+++ b/prism/Views/DocumentLayoutCoordinator.swift@@ -188,6 +188,25 @@ final class DocumentLayoutCoordinator {     /// Error message to display when document reload fails.     var reloadError: String? +    // MARK: - Screen Banner + Export Flow (notes-action-placement)++    /// Screen-level toast banner rendered by `DocumentReaderView` (export+    /// nudges; copy confirmations join it in the surface-wiring tasks).+    var bannerMessage: String?++    /// Share-with-Notes flow state, owned here so it survives dismissal of+    /// the surface that triggered the export (notes-action-placement+    /// Decision 13). Wired lazily so nudge messages route to `bannerMessage`.+    /// `@ObservationIgnored` because the reference itself never changes;+    /// views observe the flow object's own `@Observable` state.+    @ObservationIgnored private(set) lazy var exportNotesFlow: ExportNotesFlow = {+        let flow = ExportNotesFlow()+        flow.onBanner = { [weak self] message in+            self?.bannerMessage = message+        }+        return flow+    }()+     // MARK: - Session Reset      /// Resets all session-scoped state. Call from `.onChange(of: session.id)`.@@ -211,6 +230,12 @@ final class DocumentLayoutCoordinator {         zoomImage = nil         imageAccessNeededDirectory = nil         presentImageAccessPicker = false+        // Clear the screen banner so a document switch inside the toast's+        // 3-second window can't show the previous session's message, and+        // reset the export flow so a username prompt left open can't share+        // the previous session's notes (notes-action-placement Decision 15).+        bannerMessage = nil+        exportNotesFlow.reset()     }      // MARK: - Note Helpers@@ -459,6 +484,169 @@ final class DocumentLayoutCoordinator {     } } +// MARK: - Export Notes Flow (notes-action-placement Decision 13)++/// Screen-level owner of the Share-with-Notes flow.+///+/// Holds the username-prompt / validation / export-error presentation state+/// plus the `run(...)` entry point used by every Share-with-Notes trigger+/// (regular toolbar, notes pane, sidebar). Owned by+/// `DocumentLayoutCoordinator` with alerts hosted by `DocumentReaderView`,+/// so the flow survives dismissal of the pane that triggered the export:+/// the post-purchase retry stored in `StoreManager.pendingExportAction`+/// references `run`, never view-local `@State` (Req 2.4/2.5, design M1).+@Observable+@MainActor+final class ExportNotesFlow {+    /// Signature of the share-sheet presenter (see+    /// `InlineNotesShareHelper.share`). `onComplete` fires when the share+    /// completes so the export counter increments exactly once. Optional to+    /// match the helper's signature (optional function types are escaping).+    typealias ShareAction = @MainActor (+        _ notesManager: NotesManager,+        _ session: DocumentSession,+        _ settings: AppSettings,+        _ onComplete: (() -> Void)?+    ) -> Bool++    // MARK: Presentation state (alerts hosted by DocumentReaderView)++    /// Whether the username prompt alert is shown (Req 2.5).+    var showUsernamePrompt = false++    /// Whether the username validation error alert is shown.+    var showValidationError = false++    /// Whether the export failure alert is shown.+    var showExportError = false++    /// Text field binding for the username prompt.+    var usernameInput = ""++    // MARK: Wiring++    /// Routes nudge messages to the owning coordinator's screen-level banner.+    /// Assigned by `DocumentLayoutCoordinator` when it creates the flow.+    @ObservationIgnored var onBanner: (String) -> Void = { _ in }++    /// The share-sheet presenter. Mutable so unit tests can substitute a+    /// recorder without presenting UI; production code never reassigns it.+    @ObservationIgnored var share: ShareAction = { notesManager, session, settings, onComplete in+        InlineNotesShareHelper.share(+            notesManager: notesManager,+            session: session,+            settings: settings,+            onComplete: onComplete+        )+    }++    /// Everything `run` captured when the username prompt was shown, consumed+    /// by `submitUsername()`. Kept across the validation-error "Try Again"+    /// loop; cleared on cancel.+    @ObservationIgnored private var pendingShareContext: ShareContext?++    private struct ShareContext {+        let notesManager: NotesManager+        let session: DocumentSession+        let settings: AppSettings+        let storeManager: StoreManager+    }++    /// Entry point for every Share-with-Notes trigger. Runs the export gate,+    /// then either shows the username prompt (no username set) or performs+    /// the share directly (req 4.2, 4.3).+    ///+    /// On `.blocked` the retry stored in `pendingExportAction` is `run`+    /// itself, so a purchase completed after the originating surface+    /// dismissed still re-runs the full flow — including the username+    /// prompt — from the document screen (Req 2.4/2.5).+    @discardableResult+    func run(+        notesManager: NotesManager,+        session: DocumentSession,+        settings: AppSettings,+        storeManager: StoreManager+    ) -> ExportGateResult {+        let context = ShareContext(+            notesManager: notesManager,+            session: session,+            settings: settings,+            storeManager: storeManager+        )+        return storeManager.runGatedExport(+            retry: { [weak self] in+                self?.run(+                    notesManager: notesManager,+                    session: session,+                    settings: settings,+                    storeManager: storeManager+                )+            },+            perform: { _ in+                if settings.exportUsername.isEmpty {+                    pendingShareContext = context+                    usernameInput = ""+                    showUsernamePrompt = true+                } else {+                    performShare(context)+                }+            }+        )+    }++    /// Confirm action for the username prompt. Validates the input; on+    /// success persists the username and performs the pending share, on+    /// failure shows the validation error (whose "Try Again" reopens the+    /// prompt via `reopenUsernamePrompt()`).+    func submitUsername() {+        guard let context = pendingShareContext else { return }+        if case .success(let name) = AppSettings.validateUsername(usernameInput) {+            context.settings.exportUsername = name+            pendingShareContext = nil+            performShare(context)+        } else {+            showValidationError = true+        }+    }++    /// "Try Again" action for the validation error alert.+    func reopenUsernamePrompt() {+        showUsernamePrompt = true+    }++    /// Cancel action for the username prompt and validation alerts.+    func cancelUsernamePrompt() {+        pendingShareContext = nil+    }++    /// Resets all flow state. Called from+    /// `DocumentLayoutCoordinator.resetSessionState()` on document switch:+    /// `pendingShareContext` retains the triggering session and notes+    /// manager, so a username prompt left open across a switch would+    /// otherwise share the previous document's notes (notes-action-placement+    /// Decision 15).+    func reset() {+        showUsernamePrompt = false+        showValidationError = false+        showExportError = false+        usernameInput = ""+        pendingShareContext = nil+    }++    private func performShare(_ context: ShareContext) {+        let storeManager = context.storeManager+        let succeeded = share(context.notesManager, context.session, context.settings) { [weak self] in+            storeManager.incrementExportCount()+            if let nudge = storeManager.postIncrementNudgeMessage() {+                self?.onBanner(nudge)+            }+        }+        if !succeeded {+            showExportError = true+        }+    }+}+ // MARK: - Media Zoom Requests (T-1542)  /// A mermaid diagram targeted for the fullscreen/zoom view. Built by the web message
prism/Views/DocumentReaderView.swift Modified +26 / -8
diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swiftindex a29c161..43db527 100644--- a/prism/Views/DocumentReaderView.swift+++ b/prism/Views/DocumentReaderView.swift@@ -118,9 +118,6 @@ struct DocumentReaderView: View {     @Environment(RecentFilesManager.self) private var recentFilesManager     @Environment(StoreManager.self) private var storeManager -    /// Toast for nudge messages after gated exports complete (req 5.1).-    @State private var exportNudgeMessage: String?-     private var minimumRequiredWidth: CGFloat {         let dividerWidth = DraggableDivider.hitTargetWidth         return Self.minimumContentWidth@@ -146,8 +143,17 @@ struct DocumentReaderView: View {                         showNotesSheet: $showNotesSheet,                         coordinator: coordinator                     )-                    // Export toolbar for compact layout (T-138)-                    .inlineNotesExportToolbar(notesManager: notesManager, session: session)+                    // Copy notes is the compact document screen's primary+                    // notes action; export moved into the notes pane+                    // (notes-action-placement Req 1.1/1.2, Decision 8).+                    .toolbar {+                        CopyNotesButton.toolbarContent(+                            notesManager: notesManager,+                            blocks: session.parsedBlocks,+                            settings: settings,+                            onBanner: { coordinator.bannerMessage = $0 }+                        )+                    }                 } else {                     RegularDocumentLayout(                         session: session,@@ -157,7 +163,8 @@ struct DocumentReaderView: View {                         showRightSidebar: $rightSidebarVisible,                         coordinator: coordinator                     )-                    // Export button added directly in RegularDocumentLayout toolbar (T-138)+                    // Copy + export buttons added directly in the+                    // RegularDocumentLayout toolbar (notes-action-placement Req 3.1)                 }             }             .environment(notesManager)@@ -358,7 +365,13 @@ struct DocumentReaderView: View {                 isAvailable: storeManager.isExportMenuAvailable             ))             #endif-            .toast(message: $exportNudgeMessage)+            // Screen-level host for the Share-with-Notes flow (notes-action-+            // placement Decision 13): the alerts present above any pane sheet+            // and survive dismissal of the surface that triggered the export+            // (Req 2.4/2.5). Copy confirmations, export nudges, and the macOS+            // File > Export nudge all route to the coordinator banner.+            .exportNotesFlowAlerts(coordinator.exportNotesFlow)+            .toast(message: Bindable(coordinator).bannerMessage)             .themedBackground()         }     }@@ -525,7 +538,12 @@ struct DocumentReaderView: View {                 do {                     try content.write(to: url, atomically: true, encoding: .utf8)                     storeManager.incrementExportCount()-                    exportNudgeMessage = storeManager.postIncrementNudgeMessage()+                    // Nudge routes to the shared coordinator banner (req 5.1;+                    // notes-action-placement task 8). Only assign when a nudge+                    // fired so a nil result can't clear an in-flight banner.+                    if let nudge = storeManager.postIncrementNudgeMessage() {+                        coordinator.bannerMessage = nudge+                    }                 } catch {                     exportError = error.localizedDescription                 }
prism/Views/NotesPanel.swift Modified +68 / -70
diff --git a/prism/Views/NotesPanel.swift b/prism/Views/NotesPanel.swiftindex 768e3ef..eab11f3 100644--- a/prism/Views/NotesPanel.swift+++ b/prism/Views/NotesPanel.swift@@ -30,27 +30,6 @@ import SwiftUI struct NotesPanel: View {     /// Shared empty state description used by both NotesPanel and SidebarNotesView.     static let emptyStateDescription = "Tap the + icon next to any block to add a note."-    static let copiedBannerMessage = "Notes copied"-    static let showsShareAction = false--    static func shouldShowActions(hasDisplayableNotes: Bool) -> Bool {-        hasDisplayableNotes-    }--    static func exportMarkdownPayload(-        notesManager: NotesManager,-        blocks: [MarkdownBlock],-        username: String,-        includeResolved: Bool = false,-        showHTMLComments: Bool = false-    ) -> String {-        notesManager.exportMarkdown(-            blocks: blocks,-            username: username,-            includeResolved: includeResolved,-            showHTMLComments: showHTMLComments-        )-    }      /// The notes manager providing note data.     let notesManager: NotesManager@@ -61,6 +40,18 @@ struct NotesPanel: View {     /// Document structure for hierarchical note grouping.     let structure: DocumentStructure +    /// Document session for the Share-with-Notes export button (Req 2.1/2.2).+    let session: DocumentSession++    /// Screen-level Share-with-Notes flow, owned by the coordinator so the+    /// flow survives pane dismissal (notes-action-placement Decision 13).+    let exportFlow: ExportNotesFlow++    /// Coordinator banner sink for the retry-after-purchase copy banner: the+    /// pane dismisses when the paywall presents, so the retry's confirmation+    /// must land on a surface that still exists (Decision 15).+    let onRetryBanner: (String) -> Void+     /// Called when the user taps a note to navigate to its block.     let onNavigate: (String) -> Void @@ -78,13 +69,8 @@ struct NotesPanel: View {     @State private var showDocumentNoteSheet = false      @Environment(AppSettings.self) private var settings-    @Environment(StoreManager.self) private var storeManager     @Environment(\.themeColors) private var colors -    private var isExportLoading: Bool {-        storeManager.entitlementState == .loading-    }-     /// Persistent remaining-exports label shown when the user is locked and     /// has used the nudge threshold (req 5.2).     private var remainingExportsLabel: some View {@@ -138,6 +124,8 @@ struct NotesPanel: View {                         }                         .accessibilityLabel(LocalizedStringKey("Close"))                     }+                    // Order: add-note, copy, export — copy leads export+                    // (Decision 6, Req 2.1).                     ToolbarItem(placement: .primaryAction) {                         if notesManager.iCloudAvailable {                             Button {@@ -149,14 +137,33 @@ struct NotesPanel: View {                         }                     }                     ToolbarItem(placement: .primaryAction) {-                        if Self.shouldShowActions(hasDisplayableNotes: notesManager.hasDisplayableNotes) {-                            Button {-                                copyNotes()-                            } label: {-                                Image(systemName: "doc.on.doc")-                            }-                            .accessibilityLabel(LocalizedStringKey("Copy notes"))-                            .disabled(isExportLoading)+                        // Pane-local banner for the immediate path; the+                        // coordinator banner receives the retry-after-purchase+                        // banner because the pane dismisses on blocked+                        // (Req 2.4, Decision 15).+                        CopyNotesButton(+                            notesManager: notesManager,+                            blocks: blocks,+                            onBlocked: onDismiss,+                            onBanner: { copyBannerMessage = $0 },+                            retryOnBanner: onRetryBanner+                        )+                    }+                    ToolbarItem(placement: .primaryAction) {+                        // Export follows the export action's visibility rule+                        // (Req 2.3); on blocked the pane dismisses so the+                        // paywall presented at screen level is visible+                        // (Req 2.4).+                        if InlineNotesShareHelper.shouldShowShareButton(+                            notesManager: notesManager,+                            session: session+                        ) {+                            ExportWithNotesButton(+                                notesManager: notesManager,+                                session: session,+                                flow: exportFlow,+                                onBlocked: onDismiss+                            )                         }                     }                 }@@ -235,16 +242,28 @@ struct NotesPanel: View {                 .help("Add document note")             } -            if Self.shouldShowActions(hasDisplayableNotes: notesManager.hasDisplayableNotes) {-                Button {-                    copyNotes()-                } label: {-                    Image(systemName: "doc.on.doc")-                }+            // Unreachable in practice (DocumentReaderView hardcodes+            // useCompact = false on macOS); updated for code-path consistency+            // only. The old Cmd+C binding disappears with the shared button —+            // Cmd+C stays text-selection copy (Req 3.5, Decision 7).+            CopyNotesButton(+                notesManager: notesManager,+                blocks: blocks,+                buttonStyle: .header,+                onBanner: { copyBannerMessage = $0 },+                retryOnBanner: onRetryBanner+            )++            if InlineNotesShareHelper.shouldShowShareButton(+                notesManager: notesManager,+                session: session+            ) {+                ExportWithNotesButton(+                    notesManager: notesManager,+                    session: session,+                    flow: exportFlow+                )                 .buttonStyle(.borderless)-                .help("Copy all notes as Markdown")-                .keyboardShortcut("c", modifiers: .command)-                .disabled(isExportLoading)             }         }         .padding()@@ -428,33 +447,6 @@ struct NotesPanel: View {      // MARK: - Empty State -    private func copyNotes() {-        storeManager.runGatedExport(-            retry: { copyNotes() },-            perform: { gate in-                let markdown = Self.exportMarkdownPayload(-                    notesManager: notesManager,-                    blocks: blocks,-                    username: settings.exportUsername,-                    includeResolved: settings.exportIncludeResolved,-                    showHTMLComments: settings.showHTMLComments-                )-                ClipboardHelper.copy(markdown)-                storeManager.incrementExportCount()-                if let nudge = StoreManager.nudgeMessage(for: gate) {-                    copyBannerMessage = "\(Self.copiedBannerMessage) — \(nudge)"-                } else {-                    copyBannerMessage = Self.copiedBannerMessage-                }-            }-        )-        #if os(iOS)-        if storeManager.showPaywall {-            onDismiss()-        }-        #endif-    }-     private var emptyState: some View {         ContentUnavailableView(             "No Notes Yet",@@ -474,6 +466,9 @@ struct NotesPanel: View {         notesManager: manager,         blocks: [],         structure: DocumentStructure(preamble: [], sections: [], headingPathByBlockId: [:]),+        session: DocumentSession(clipboardContent: "# Preview"),+        exportFlow: ExportNotesFlow(),+        onRetryBanner: { _ in },         onNavigate: { _ in },         onDismiss: {}     )@@ -487,6 +482,9 @@ struct NotesPanel: View {         notesManager: manager,         blocks: [],         structure: DocumentStructure(preamble: [], sections: [], headingPathByBlockId: [:]),+        session: DocumentSession(clipboardContent: "# Preview"),+        exportFlow: ExportNotesFlow(),+        onRetryBanner: { _ in },         onNavigate: { _ in },         onDismiss: {}     )
prism/Views/NotesSidebar.swift Modified +9 / -0
diff --git a/prism/Views/NotesSidebar.swift b/prism/Views/NotesSidebar.swiftindex 1cf7442..daa8d78 100644--- a/prism/Views/NotesSidebar.swift+++ b/prism/Views/NotesSidebar.swift@@ -12,6 +12,12 @@ import SwiftUI /// This replaces DocumentSidebar's notes functionality. struct NotesSidebar: View {     let session: DocumentSession++    /// Screen-level Share-with-Notes flow (owned by the layout coordinator),+    /// passed through to the sidebar share button (notes-action-placement+    /// Decision 13).+    let exportFlow: ExportNotesFlow+     let onNavigateToNote: (String) -> Void      @Environment(NotesManager.self) private var notesManager@@ -23,6 +29,7 @@ struct NotesSidebar: View {             blocks: session.parsedBlocks,             structure: session.documentStructure,             session: session,+            exportFlow: exportFlow,             onNavigate: onNavigateToNote         )         .background(colors.background)@@ -33,6 +40,7 @@ struct NotesSidebar: View {     @Previewable @State var manager = NotesManager()     NotesSidebar(         session: PreviewHelpers.savedSession(),+        exportFlow: ExportNotesFlow(),         onNavigateToNote: { _ in }     )     .environment(manager)@@ -43,6 +51,7 @@ struct NotesSidebar: View {     @Previewable @State var manager = NotesManager()     NotesSidebar(         session: DocumentSession(clipboardContent: "# Unsaved"),+        exportFlow: ExportNotesFlow(),         onNavigateToNote: { _ in }     )     .environment(manager)
prism/Views/RegularDocumentLayout.swift Modified +31 / -5
diff --git a/prism/Views/RegularDocumentLayout.swift b/prism/Views/RegularDocumentLayout.swiftindex 687dd87..dcd8ef6 100644--- a/prism/Views/RegularDocumentLayout.swift+++ b/prism/Views/RegularDocumentLayout.swift@@ -129,6 +129,7 @@ struct RegularDocumentLayout: View {                 session: session,                 notesManager: notesManager,                 settings: settings,+                coordinator: coordinator,                 reduceMotion: reduceMotion,                 showLeftSidebar: $showLeftSidebar,                 showRightSidebar: $showRightSidebar,@@ -263,6 +264,7 @@ struct RegularDocumentLayout: View {                  NotesSidebar(                     session: session,+                    exportFlow: coordinator.exportNotesFlow,                     onNavigateToNote: { blockId in                         scrollTarget = blockId                     }@@ -432,6 +434,10 @@ struct RegularLayoutToolbar: ViewModifier {     let session: DocumentSession     let notesManager: NotesManager     let settings: AppSettings+    /// Coordinator access for the screen-level banner (`bannerMessage`) and+    /// the Share-with-Notes flow the export button triggers+    /// (notes-action-placement Decision 13).+    let coordinator: DocumentLayoutCoordinator     let reduceMotion: Bool     @Binding var showLeftSidebar: Bool     @Binding var showRightSidebar: Bool@@ -499,12 +505,32 @@ struct RegularLayoutToolbar: ViewModifier {                     .accessibilityLabel(LocalizedStringKey("Save document"))                     .help("Save document")                 }+            } -                if InlineNotesShareHelper.shouldShowShareButton(-                    notesManager: notesManager,-                    session: session-                ) {-                    ExportWithNotesButton(notesManager: notesManager, session: session)+            // Copy leads export inside the same primary-action glass bubble+            // (Req 3.1, Decision 6): same-placement items with no spacer+            // between them share one group. Each action is its own toolbar+            // item with an explicit id so the two independent visibility+            // conditionals (Req 3.3) don't churn toolbar identity when one+            // flips without the other. No keyboard shortcut on either —+            // Cmd+C stays selection copy (Req 3.5, Decision 7).+            CopyNotesButton.toolbarContent(+                notesManager: notesManager,+                blocks: session.parsedBlocks,+                settings: settings,+                onBanner: { coordinator.bannerMessage = $0 }+            )++            if InlineNotesShareHelper.shouldShowShareButton(+                notesManager: notesManager,+                session: session+            ) {+                ToolbarItem(id: NotesToolbarItemID.export, placement: .primaryAction) {+                    ExportWithNotesButton(+                        notesManager: notesManager,+                        session: session,+                        flow: coordinator.exportNotesFlow+                    )                 }             } 
prism/Views/SidebarNotesView.swift Modified +34 / -115
diff --git a/prism/Views/SidebarNotesView.swift b/prism/Views/SidebarNotesView.swiftindex 38531e7..265dacb 100644--- a/prism/Views/SidebarNotesView.swift+++ b/prism/Views/SidebarNotesView.swift@@ -16,37 +16,14 @@ import SwiftUI /// Requirements covered: /// - 6.5: Notes segment displays annotations with count badge struct SidebarNotesView: View {-    static let copiedBannerMessage = "Notes copied"-+    /// The sidebar share button stays iOS-only (Req 3.4); its visibility on+    /// iOS follows the export action's rule (Decision 14).     #if os(macOS)     static let showsShareAction = false     #else     static let showsShareAction = true     #endif -    static func shouldShowActions(hasNotes: Bool, hasDisplayableNotes: Bool) -> Bool {-        #if os(macOS)-        hasDisplayableNotes-        #else-        hasNotes-        #endif-    }--    static func exportMarkdownPayload(-        notesManager: NotesManager,-        blocks: [MarkdownBlock],-        username: String,-        includeResolved: Bool = false,-        showHTMLComments: Bool = false-    ) -> String {-        notesManager.exportMarkdown(-            blocks: blocks,-            username: username,-            includeResolved: includeResolved,-            showHTMLComments: showHTMLComments-        )-    }-     /// The notes manager providing note data.     let notesManager: NotesManager @@ -56,25 +33,25 @@ struct SidebarNotesView: View {     /// Document structure for hierarchical note grouping.     let structure: DocumentStructure -    /// Document session for share action (iOS only). When nil, share button is disabled.+    /// Document session for share action (iOS only). When nil, the share+    /// button is hidden.     let session: DocumentSession? +    /// Screen-level Share-with-Notes flow, owned by the coordinator so the+    /// flow's alerts survive any surface change (notes-action-placement+    /// Decision 13).+    let exportFlow: ExportNotesFlow+     /// Called when user selects a note to navigate to its block.     let onNavigate: (String) -> Void      @Environment(AppSettings.self) private var settings-    @Environment(StoreManager.self) private var storeManager     @Environment(\.themeColors) private var colors     @State private var copyBannerMessage: String?-    @State private var shareError: String?     @State private var replyToNote: BlockNote?     @State private var editingNote: BlockNote?     @State private var showDocumentNoteSheet = false -    private var isExportLoading: Bool {-        storeManager.entitlementState == .loading-    }-     /// Persistent remaining-exports label shown when the user is locked and     /// has used the nudge threshold (req 5.2).     private var remainingExportsLabel: some View {@@ -102,16 +79,6 @@ struct SidebarNotesView: View {             }         }         .toast(message: $copyBannerMessage)-        .alert("Share Failed", isPresented: Binding(-            get: { shareError != nil },-            set: { if !$0 { shareError = nil } }-        )) {-            Button("OK") { shareError = nil }-        } message: {-            if let shareError {-                Text(shareError)-            }-        }         .sheet(item: $replyToNote) { parentNote in             AddNoteSheet(                 mode: .reply(parentNote: parentNote),@@ -173,84 +140,34 @@ struct SidebarNotesView: View {                 .help("Add document note")             } -            // Copy/share actions only when notes exist-            if Self.shouldShowActions(-                hasNotes: notesManager.hasNotes,-                hasDisplayableNotes: notesManager.hasDisplayableNotes-            ) {-                Button {-                    copyNotes()-                } label: {-                    Image(systemName: "doc.on.doc")-                }-                .buttonStyle(.borderless)-                .help("Copy all notes as Markdown")-                .disabled(isExportLoading)--                if Self.showsShareAction {-                    Button {-                        shareNotes()-                    } label: {-                        Image(systemName: "square.and.arrow.up")-                    }-                    .buttonStyle(.borderless)-                    .help("Share notes")-                    .disabled(isExportLoading)-                }-            }-        }-        .padding(.horizontal, 12)-        .padding(.vertical, 8)-    }+            // Copy self-hides on the shared availability predicate+            // (Req 5.1/5.2). The sidebar stays mounted while the paywall+            // presents, so its local banner state serves the retry path too —+            // no separate retry sink needed (Decision 15).+            CopyNotesButton(+                notesManager: notesManager,+                blocks: blocks,+                buttonStyle: .header,+                onBanner: { copyBannerMessage = $0 }+            ) -    private func copyNotes() {-        storeManager.runGatedExport(-            retry: { copyNotes() },-            perform: { gate in-                let markdown = Self.exportMarkdownPayload(+            // Share follows the export action's visibility rule instead of+            // pair-gating with copy (Req 3.4, Decision 14).+            if Self.showsShareAction, let session,+               InlineNotesShareHelper.shouldShowShareButton(+                   notesManager: notesManager,+                   session: session+               ) {+                ExportWithNotesButton(                     notesManager: notesManager,-                    blocks: blocks,-                    username: settings.exportUsername,-                    includeResolved: settings.exportIncludeResolved,-                    showHTMLComments: settings.showHTMLComments+                    session: session,+                    flow: exportFlow                 )-                ClipboardHelper.copy(markdown)-                storeManager.incrementExportCount()-                if let nudge = StoreManager.nudgeMessage(for: gate) {-                    copyBannerMessage = "\(Self.copiedBannerMessage) — \(nudge)"-                } else {-                    copyBannerMessage = Self.copiedBannerMessage-                }-            }-        )-    }--    private func shareNotes() {-        storeManager.runGatedExport(-            retry: { shareNotes() },-            perform: { _ in performShare() }-        )-    }--    private func performShare() {-        guard let session else {-            shareError = "Could not share notes."-            return-        }-        let succeeded = InlineNotesShareHelper.share(-            notesManager: notesManager,-            session: session,-            settings: settings,-            onComplete: {-                storeManager.incrementExportCount()-                if let nudge = storeManager.postIncrementNudgeMessage() {-                    copyBannerMessage = nudge-                }+                .buttonStyle(.borderless)             }-        )-        if !succeeded {-            shareError = "Could not share notes."         }+        .padding(.horizontal, 12)+        .padding(.vertical, 8)     }      // MARK: - Notes List@@ -394,6 +311,7 @@ struct SidebarNotesView: View {         blocks: [],         structure: DocumentStructure(preamble: [], sections: [], headingPathByBlockId: [:]),         session: nil,+        exportFlow: ExportNotesFlow(),         onNavigate: { id in print("Navigate to: \(id)") }     )     .environment(storeManager)@@ -409,6 +327,7 @@ struct SidebarNotesView: View {         blocks: [],         structure: DocumentStructure(preamble: [], sections: [], headingPathByBlockId: [:]),         session: nil,+        exportFlow: ExportNotesFlow(),         onNavigate: { _ in }     )     .environment(storeManager)
prismTests/CopyNotesButtonTests.swift Added +468 / -0
diff --git a/prismTests/CopyNotesButtonTests.swift b/prismTests/CopyNotesButtonTests.swiftnew file mode 100644index 0000000..196bb77--- /dev/null+++ b/prismTests/CopyNotesButtonTests.swift@@ -0,0 +1,468 @@+//+//  CopyNotesButtonTests.swift+//  prismTests+//+//  Tests for CopyNotesButton.perform — the testable entry point for the+//  gate → payload → clipboard → increment-once → banner sequence shared by+//  every copy surface (notes-action-placement Decision 10).+//+//  The clipboard write is recorded through the `copyToClipboard` seam rather+//  than asserted on the system pasteboard: the locale-matrix test plan runs+//  configurations in parallel processes that share one pasteboard, so real+//  round-trips race (same reason ExportNotesFlow stubs its share presenter).+//+//  - Spec: specs/notes-action-placement/ (Requirements 1.3, 1.4, 2.4)+//++import Foundation+import Testing+@testable import prism++/// Serialized because the tests read and write `UserDefaults.standard` (the+/// `@AppStorage` backing store for the export settings) and swap the shared+/// `copyToClipboard` seam, matching the ExportNotesFlowTests precedent.+@Suite("CopyNotesButton.perform", .serialized)+struct CopyNotesButtonTests {++    // MARK: - Helpers++    @MainActor+    private func makeCounter(startingAt count: Int = 0) -> ExportCounter {+        let store = ExportCounterTests.MockKeyValueStore()+        store.storage[ExportCounter.storageKey] = Int64(count)+        // Fresh suite name guarantees an empty UserDefaults — no clean-up needed.+        let defaults = UserDefaults(suiteName: UUID().uuidString)!+        return ExportCounter(kvs: store, defaults: defaults)+    }++    @MainActor+    private func makeStore(+        entitlementState: EntitlementState = .locked,+        count: Int = 0+    ) -> StoreManager {+        StoreManager(exportCounter: makeCounter(startingAt: count), entitlementState: entitlementState)+    }++    private func makeNote(+        blockId: String,+        content: String,+        status: NoteStatus = .active,+        contextQuote: String = "Context quote"+    ) -> BlockNote {+        BlockNote(+            id: UUID(),+            blockId: blockId,+            contextQuote: contextQuote,+            sectionHeading: nil,+            content: content,+            status: status,+            createdAt: Date(),+            modifiedAt: Date()+        )+    }++    /// A notes manager with one active and one resolved note anchored to the+    /// contained block, so the includeResolved settings dimension is+    /// observable in the copied payload.+    private struct PopulatedNotes {+        let manager: NotesManager+        let blocks: [MarkdownBlock]+        let activeMarker: String+        let resolvedMarker: String+    }++    @MainActor+    private func makePopulatedNotesManager() -> PopulatedNotes {+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let block = MarkdownBlock.paragraph(markdown: "Paragraph with notes attached.")+        let activeMarker = "active-note-marker-\(UUID().uuidString.prefix(8))"+        let resolvedMarker = "resolved-note-marker-\(UUID().uuidString.prefix(8))"+        manager.setImportedNotes([block.id: [+            makeNote(blockId: block.id, content: activeMarker),+            makeNote(blockId: block.id, content: resolvedMarker, status: .resolved)+        ]])+        return PopulatedNotes(+            manager: manager,+            blocks: [block],+            activeMarker: activeMarker,+            resolvedMarker: resolvedMarker+        )+    }++    /// Seeds the persisted export settings, then returns a fresh AppSettings.+    /// Callers must pair with `defer { clearSettings() }`.+    @MainActor+    private func makeSettings(+        username: String?,+        includeResolved: Bool = false,+        showHTMLComments: Bool = false+    ) -> AppSettings {+        if let username {+            UserDefaults.standard.set(username, forKey: "exportUsername")+        } else {+            UserDefaults.standard.removeObject(forKey: "exportUsername")+        }+        UserDefaults.standard.set(includeResolved, forKey: "exportIncludeResolved")+        UserDefaults.standard.set(showHTMLComments, forKey: "showHTMLComments")+        return AppSettings()+    }++    @MainActor+    private func clearSettings() {+        UserDefaults.standard.removeObject(forKey: "exportUsername")+        UserDefaults.standard.removeObject(forKey: "exportIncludeResolved")+        UserDefaults.standard.removeObject(forKey: "showHTMLComments")+    }++    /// Records clipboard writes without touching the system pasteboard.+    /// Callers must pair `install()` with `defer { ClipboardRecorder.restore() }`.+    @MainActor+    private final class ClipboardRecorder {+        private(set) var copied: [String] = []++        func install() {+            CopyNotesButton.copyToClipboard = { [weak self] text in+                self?.copied.append(text)+            }+        }++        static func restore() {+            CopyNotesButton.copyToClipboard = ClipboardHelper.copy+        }+    }++    // MARK: - Allowed path (Req 1.3)++    @Test("allowed gate copies the exportMarkdown payload with the AppSettings values and increments exactly once")+    @MainActor+    func allowedGateCopiesPayloadAndIncrementsOnce() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer", includeResolved: true)+        let store = makeStore()+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()+        var banners: [String] = []++        let gate = CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { banners.append($0) }+        )++        #expect(gate == .allowed)+        let expected = notes.manager.exportMarkdown(+            blocks: notes.blocks,+            username: settings.exportUsername,+            includeResolved: settings.exportIncludeResolved,+            showHTMLComments: settings.showHTMLComments+        )+        #expect(clipboard.copied == [expected], "Payload copied exactly once, with the AppSettings values")+        #expect(clipboard.copied.first?.contains(notes.activeMarker) == true)+        #expect(+            clipboard.copied.first?.contains(notes.resolvedMarker) == true,+            "exportIncludeResolved from AppSettings must reach the payload"+        )+        #expect(store.exportCount == 1, "Export count increments exactly once")+        #expect(banners == ["Notes copied"])+    }++    @Test("includeResolved off keeps resolved notes out of the copied payload")+    @MainActor+    func includeResolvedOffExcludesResolvedNotes() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer", includeResolved: false)+        let store = makeStore()+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()++        CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { _ in }+        )++        #expect(clipboard.copied.count == 1)+        #expect(clipboard.copied.first?.contains(notes.activeMarker) == true)+        #expect(clipboard.copied.first?.contains(notes.resolvedMarker) == false)+    }++    /// Payload parity against the single `exportMarkdown` call site: every+    /// copy surface routes through `perform`, so per-surface payload wrapper+    /// statics no longer exist to compare — parity is structural. This pins+    /// the copied payload to the manager's full population: anchored user,+    /// imported, document-level, and orphaned notes all reach the clipboard+    /// (Req 1.3; supersedes the per-surface payload parity test deleted with+    /// the statics in the notes-action-placement wiring).+    @Test("copied payload matches exportMarkdown across all note kinds")+    @MainActor+    func copiedPayloadMatchesExportMarkdownAcrossNoteKinds() async {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer", includeResolved: true)+        let store = makeStore()++        let block = MarkdownBlock.paragraph(markdown: "Anchored paragraph content.")+        let anchoredMarker = "anchored-user-note-marker"+        let documentMarker = "document-level-note-marker"+        let orphanMarker = "orphaned-note-marker"+        let importedMarker = "imported-note-marker"++        let notesStore = MockNotesStore()+        let manager = NotesManager.makeForTesting(store: notesStore)+        await notesStore.preload(DocumentNotes(+            identifier: DocumentIdentifier(path: "project/specs/doc.md"),+            displayName: "doc.md",+            notes: [+                makeNote(+                    blockId: block.id,+                    content: anchoredMarker,+                    contextQuote: "Anchored paragraph content."+                ),+                makeNote(blockId: BlockNote.documentSentinelId, content: documentMarker, contextQuote: ""),+                makeNote(+                    blockId: "block-that-no-longer-exists",+                    content: orphanMarker,+                    contextQuote: "Content that no longer appears in the document"+                )+            ]+        ))+        await manager.loadNotes(+            source: .file(url: URL(fileURLWithPath: "/Users/test/project/specs/doc.md")),+            sessionID: UUID(),+            blocks: [block]+        )+        manager.setImportedNotes([block.id: [makeNote(blockId: block.id, content: importedMarker)]])+        #expect(!manager.orphanedNotes.isEmpty, "Precondition: one preloaded note must be orphaned")++        let clipboard = ClipboardRecorder()+        clipboard.install()++        let gate = CopyNotesButton.perform(+            notesManager: manager,+            blocks: [block],+            settings: settings,+            storeManager: store,+            onBanner: { _ in }+        )++        #expect(gate == .allowed)+        let expected = manager.exportMarkdown(+            blocks: [block],+            username: settings.exportUsername,+            includeResolved: settings.exportIncludeResolved,+            showHTMLComments: settings.showHTMLComments+        )+        #expect(clipboard.copied == [expected], "Copied payload must be exactly the exportMarkdown output")+        for marker in [anchoredMarker, importedMarker, documentMarker, orphanMarker] {+            #expect(+                clipboard.copied.first?.contains(marker) == true,+                "Every note kind must reach the clipboard (missing \(marker))"+            )+        }+    }++    // MARK: - Banner composition (Req 1.3)++    @Test("nudge-window copy composes the banner via the catalog format key")+    @MainActor+    func nudgeWindowCopyComposesBanner() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(count: StoreManager.nudgeThreshold)+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()+        var banners: [String] = []++        let gate = CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { banners.append($0) }+        )++        #expect(gate == .allowedWithNudge(remaining: 9))+        // Count incremented exactly once: 10 → 11, so 9 of 20 remain.+        #expect(store.exportCount == StoreManager.nudgeThreshold + 1)+        #expect(banners == ["Notes copied — 9 of 20 free exports remaining"])+    }++    // MARK: - Blocked gate (Req 2.4)++    @Test("blocked gate fires onBlocked, stores the retry, and does not copy")+    @MainActor+    func blockedGateFiresOnBlockedAndStoresRetry() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(count: StoreManager.freeExportLimit)+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()+        var banners: [String] = []+        var onBlockedCount = 0++        let gate = CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { banners.append($0) },+            onBlocked: { onBlockedCount += 1 }+        )++        #expect(gate == .blocked)+        #expect(onBlockedCount == 1)+        #expect(store.pendingExportAction != nil)+        #expect(store.showPaywall)+        #expect(clipboard.copied.isEmpty)+        #expect(store.exportCount == StoreManager.freeExportLimit)+        #expect(banners.isEmpty)+    }++    /// The retry stored on a blocked gate references `perform` itself, never+    /// view-local state, so a purchase completed after the originating+    /// surface dismissed still re-runs the copy (Req 2.4).+    @Test("retry after purchase re-runs the copy and banners")+    @MainActor+    func retryAfterPurchasePerformsCopy() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(count: StoreManager.freeExportLimit)+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()+        var banners: [String] = []++        let gate = CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { banners.append($0) }+        )+        #expect(gate == .blocked)+        #expect(clipboard.copied.isEmpty)++        // The purchase completes and the paywall's onDismiss fires the retry.+        store.setEntitlementStateForTesting(.unlocked)+        store.pendingExportAction?()++        #expect(clipboard.copied.count == 1)+        #expect(clipboard.copied.first?.contains(notes.activeMarker) == true)+        #expect(banners == ["Notes copied"])+        // incrementExportCount is a no-op once unlocked.+        #expect(store.exportCount == StoreManager.freeExportLimit)+    }++    // MARK: - Retry banner sink (Decision 15)++    @Test("allowed gate banners through onBanner even when retryOnBanner is provided")+    @MainActor+    func allowedGateBannersThroughImmediateSink() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore()+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()+        var paneBanners: [String] = []+        var coordinatorBanners: [String] = []++        let gate = CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { paneBanners.append($0) },+            retryOnBanner: { coordinatorBanners.append($0) }+        )++        #expect(gate == .allowed)+        #expect(paneBanners == ["Notes copied"], "Immediate path keeps the pane-local banner")+        #expect(coordinatorBanners.isEmpty)+    }++    /// The retry stored on `.blocked` banners through the retry sink: a+    /// pane-originated copy captures pane-local banner state that is dead by+    /// the time the purchase completes, so the confirmation must land on the+    /// coordinator toast instead (Decision 15).+    @Test("retry stored on .blocked banners through the retry sink, not onBanner")+    @MainActor+    func retryStoredOnBlockedUsesRetryBannerSink() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(count: StoreManager.freeExportLimit)+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()+        var paneBanners: [String] = []+        var coordinatorBanners: [String] = []++        let gate = CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { paneBanners.append($0) },+            retryOnBanner: { coordinatorBanners.append($0) }+        )+        #expect(gate == .blocked)+        #expect(paneBanners.isEmpty)+        #expect(coordinatorBanners.isEmpty)++        // The purchase completes and the paywall's onDismiss fires the retry.+        store.setEntitlementStateForTesting(.unlocked)+        store.pendingExportAction?()++        #expect(clipboard.copied.count == 1)+        #expect(paneBanners.isEmpty, "The dismissed pane's banner must not receive the retry banner")+        #expect(coordinatorBanners == ["Notes copied"])+    }++    // MARK: - Loading gate (Req 1.4)++    @Test("loading gate is a silent no-op")+    @MainActor+    func loadingGateIsSilentNoOp() {+        defer { clearSettings() }+        defer { ClipboardRecorder.restore() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(entitlementState: .loading)+        let notes = makePopulatedNotesManager()+        let clipboard = ClipboardRecorder()+        clipboard.install()+        var banners: [String] = []+        var onBlockedCount = 0++        let gate = CopyNotesButton.perform(+            notesManager: notes.manager,+            blocks: notes.blocks,+            settings: settings,+            storeManager: store,+            onBanner: { banners.append($0) },+            onBlocked: { onBlockedCount += 1 }+        )++        #expect(gate == .loading)+        #expect(banners.isEmpty)+        #expect(onBlockedCount == 0)+        #expect(clipboard.copied.isEmpty)+        #expect(store.exportCount == 0)+        #expect(store.pendingExportAction == nil)+        #expect(!store.showPaywall)+    }+}
prismTests/ExportNotesFlowTests.swift Added +440 / -0
diff --git a/prismTests/ExportNotesFlowTests.swift b/prismTests/ExportNotesFlowTests.swiftnew file mode 100644index 0000000..c740bc6--- /dev/null+++ b/prismTests/ExportNotesFlowTests.swift@@ -0,0 +1,440 @@+//+//  ExportNotesFlowTests.swift+//  prismTests+//+//  Tests for ExportNotesFlow.run — the screen-level owner of the+//  Share-with-Notes flow (notes-action-placement Decision 13).+//+//  The flow is owned by DocumentLayoutCoordinator and its alerts are hosted+//  by DocumentReaderView, so it survives dismissal of the pane that+//  triggered the export: the post-purchase retry stored in+//  StoreManager.pendingExportAction references flow.run, never view-local+//  @State (Req 2.4/2.5).+//++import Foundation+import Testing+@testable import prism++/// Serialized because the username-prompt cases read and write the+/// `exportUsername` key in `UserDefaults.standard` (the `@AppStorage` backing+/// store), matching the AppSettingsTests precedent.+@Suite("ExportNotesFlow", .serialized)+struct ExportNotesFlowTests {++    // MARK: - Helpers++    @MainActor+    private func makeCounter(startingAt count: Int = 0) -> ExportCounter {+        let store = ExportCounterTests.MockKeyValueStore()+        store.storage[ExportCounter.storageKey] = Int64(count)+        // Fresh suite name guarantees an empty UserDefaults — no clean-up needed.+        let defaults = UserDefaults(suiteName: UUID().uuidString)!+        return ExportCounter(kvs: store, defaults: defaults)+    }++    @MainActor+    private func makeStore(+        entitlementState: EntitlementState = .locked,+        count: Int = 0+    ) -> StoreManager {+        StoreManager(exportCounter: makeCounter(startingAt: count), entitlementState: entitlementState)+    }++    @MainActor+    private func makeSession() -> DocumentSession {+        DocumentSession(+            url: URL(fileURLWithPath: "/tmp/export-flow-test.md"),+            content: "# Title\n\nBody."+        )+    }++    @MainActor+    private func makeNotesManager() -> NotesManager {+        NotesManager.makeForTesting(store: MockNotesStore())+    }++    /// Seeds (or clears) the persisted export username, then returns settings.+    /// Callers must pair with `defer { clearUsername() }`.+    @MainActor+    private func makeSettings(username: String?) -> AppSettings {+        if let username {+            UserDefaults.standard.set(username, forKey: "exportUsername")+        } else {+            UserDefaults.standard.removeObject(forKey: "exportUsername")+        }+        return AppSettings()+    }++    @MainActor+    private func clearUsername() {+        UserDefaults.standard.removeObject(forKey: "exportUsername")+    }++    /// Records share invocations without presenting any UI. `result` mimics+    /// the share sheet failing to present (file write error); when+    /// `invokesCompletion` is true the stub reports a completed share+    /// synchronously, like the macOS presentation path.+    @MainActor+    private final class ShareRecorder {+        private(set) var callCount = 0+        var result = true+        var invokesCompletion = true++        func install(on flow: ExportNotesFlow) {+            flow.share = { _, _, _, onComplete in+                self.callCount += 1+                if self.invokesCompletion {+                    onComplete?()+                }+                return self.result+            }+        }+    }++    // MARK: - Username prompt (Req 2.5)++    @Test("run with no username set reaches the prompt state without sharing")+    @MainActor+    func runWithoutUsernameShowsPrompt() {+        defer { clearUsername() }+        let settings = makeSettings(username: nil)+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        let gate = flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )++        #expect(gate == .allowed)+        #expect(flow.showUsernamePrompt)+        #expect(share.callCount == 0)+        #expect(store.exportCount == 0)+    }++    @Test("submitUsername with a valid name persists it and performs the pending share")+    @MainActor+    func submitUsernamePerformsPendingShare() {+        defer { clearUsername() }+        let settings = makeSettings(username: nil)+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )+        flow.usernameInput = "Reviewer"+        flow.submitUsername()++        #expect(settings.exportUsername == "Reviewer")+        #expect(share.callCount == 1)+        #expect(!flow.showValidationError)+        #expect(store.exportCount == 1)+    }++    @Test("submitUsername with an invalid name shows the validation error and keeps the pending share")+    @MainActor+    func submitInvalidUsernameShowsValidationError() {+        defer { clearUsername() }+        let settings = makeSettings(username: nil)+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )+        flow.usernameInput = "Bad]Name"+        flow.submitUsername()++        #expect(flow.showValidationError)+        #expect(share.callCount == 0)++        // "Try Again" reopens the prompt; a valid name still shares.+        flow.reopenUsernamePrompt()+        #expect(flow.showUsernamePrompt)+        flow.usernameInput = "Reviewer"+        flow.submitUsername()++        #expect(share.callCount == 1)+        #expect(settings.exportUsername == "Reviewer")+    }++    @Test("run with a username set shares immediately without prompting")+    @MainActor+    func runWithUsernameSharesImmediately() {+        defer { clearUsername() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        let gate = flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )++        #expect(gate == .allowed)+        #expect(!flow.showUsernamePrompt)+        #expect(share.callCount == 1)+        #expect(store.exportCount == 1)+    }++    // MARK: - Blocked gate (Req 2.4)++    @Test("blocked gate stores the retry in pendingExportAction and returns .blocked")+    @MainActor+    func blockedGateStoresRetry() {+        defer { clearUsername() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(count: StoreManager.freeExportLimit)+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        let gate = flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )++        #expect(gate == .blocked)+        #expect(store.pendingExportAction != nil)+        #expect(store.showPaywall)+        #expect(share.callCount == 0)+        #expect(!flow.showUsernamePrompt)+    }++    /// Design M1 regression: the retry stored on a blocked gate references+    /// `flow.run`, never view-local state, so a purchase completed after the+    /// originating pane dismissed still re-runs the full flow — including the+    /// username prompt — from the document screen (Req 2.4/2.5).+    @Test("retry after purchase with no username set reaches the prompt state")+    @MainActor+    func retryAfterPurchaseReachesPromptState() {+        defer { clearUsername() }+        let settings = makeSettings(username: nil)+        let store = makeStore(count: StoreManager.freeExportLimit)+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        let gate = flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )+        #expect(gate == .blocked)+        #expect(store.pendingExportAction != nil)+        #expect(!flow.showUsernamePrompt)++        // The originating pane is gone; the purchase completes and the+        // paywall's onDismiss fires the stored retry.+        store.setEntitlementStateForTesting(.unlocked)+        store.pendingExportAction?()++        #expect(flow.showUsernamePrompt)+        #expect(share.callCount == 0)++        // Completing the prompt performs the share.+        flow.usernameInput = "Reviewer"+        flow.submitUsername()+        #expect(share.callCount == 1)+        #expect(settings.exportUsername == "Reviewer")+    }++    // MARK: - Share failure++    @Test("share failure sets the export-error state")+    @MainActor+    func shareFailureSetsErrorState() {+        defer { clearUsername() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.result = false+        share.invokesCompletion = false+        share.install(on: flow)++        flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )++        #expect(flow.showExportError)+        #expect(store.exportCount == 0)+        #expect(coordinator.bannerMessage == nil)+    }++    // MARK: - Nudge routing (coordinator banner)++    @Test("nudge after a completed share routes to the coordinator banner")+    @MainActor+    func nudgeRoutesToCoordinatorBanner() {+        defer { clearUsername() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(count: StoreManager.nudgeThreshold)+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )++        // Count incremented exactly once: 10 → 11, so 9 of 20 remain.+        #expect(store.exportCount == StoreManager.nudgeThreshold + 1)+        #expect(coordinator.bannerMessage == "9 of 20 free exports remaining")+    }++    @Test("completed share below the nudge window leaves the banner empty")+    @MainActor+    func noBannerBelowNudgeWindow() {+        defer { clearUsername() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )++        #expect(store.exportCount == 1)+        #expect(coordinator.bannerMessage == nil)+    }++    // MARK: - Session reset (Decision 15)++    /// Decision 15 regression: a username prompt left open across a document+    /// switch must not share the previous session's notes. `resetSessionState`+    /// clears the screen banner and resets the flow, and the cleared pending+    /// context makes a late `submitUsername()` a no-op.+    @Test("resetSessionState clears the banner and defuses an open username prompt")+    @MainActor+    func resetSessionStateDefusesOpenPrompt() {+        defer { clearUsername() }+        let settings = makeSettings(username: nil)+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )+        coordinator.bannerMessage = "Notes copied"+        #expect(flow.showUsernamePrompt)++        coordinator.resetSessionState()++        #expect(coordinator.bannerMessage == nil)+        #expect(!flow.showUsernamePrompt)+        #expect(!flow.showValidationError)+        #expect(!flow.showExportError)+        #expect(flow.usernameInput.isEmpty)++        // The pending context is gone: submitting the old prompt's input+        // must not share the previous session's notes.+        flow.usernameInput = "Reviewer"+        flow.submitUsername()+        #expect(share.callCount == 0)+    }++    @Test("cancelUsernamePrompt clears the pending share so a later submit is a no-op")+    @MainActor+    func cancelUsernamePromptClearsPendingShare() {+        defer { clearUsername() }+        let settings = makeSettings(username: nil)+        let store = makeStore()+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )+        flow.cancelUsernamePrompt()++        flow.usernameInput = "Reviewer"+        flow.submitUsername()++        #expect(share.callCount == 0)+        #expect(!flow.showValidationError)+        #expect(store.exportCount == 0)+    }++    // MARK: - Loading gate++    @Test("loading gate is a silent no-op")+    @MainActor+    func loadingGateIsSilentNoOp() {+        defer { clearUsername() }+        let settings = makeSettings(username: "Reviewer")+        let store = makeStore(entitlementState: .loading)+        let coordinator = DocumentLayoutCoordinator()+        let flow = coordinator.exportNotesFlow+        let share = ShareRecorder()+        share.install(on: flow)++        let gate = flow.run(+            notesManager: makeNotesManager(),+            session: makeSession(),+            settings: settings,+            storeManager: store+        )++        #expect(gate == .loading)+        #expect(share.callCount == 0)+        #expect(!flow.showUsernamePrompt)+        #expect(!flow.showExportError)+        #expect(store.pendingExportAction == nil)+        #expect(!store.showPaywall)+    }+}
prismTests/NotesExporterPredicateTests.swift Added +389 / -0
diff --git a/prismTests/NotesExporterPredicateTests.swift b/prismTests/NotesExporterPredicateTests.swiftnew file mode 100644index 0000000..fbf63fa--- /dev/null+++ b/prismTests/NotesExporterPredicateTests.swift@@ -0,0 +1,389 @@+//+//  NotesExporterPredicateTests.swift+//  prismTests+//+//  Tests for the copy-availability predicate (NotesExporter.hasIncludableNotes /+//  NotesManager.hasCopyableNotes) and the Decision 12 exporter nil-container fix.+//+//  The property-based test asserts predicate/payload agreement over arbitrary+//  note populations, per the ExportImportRoundTripPropertyTests precedent.+//+//  - Spec: specs/notes-action-placement/ (Requirements 5.1, 5.3; Decisions 11, 12)+//++import Foundation+import Testing+@testable import prism++@Suite("NotesExporter predicate and nil-container fix")+struct NotesExporterPredicateTests {++    // MARK: - Test Helpers++    /// Create a test block note with a distinctive content marker.+    func makeNote(+        blockId: String,+        content: String,+        status: NoteStatus = .active,+        createdAt: Date = Date()+    ) -> BlockNote {+        BlockNote(+            id: UUID(),+            blockId: blockId,+            contextQuote: "Context quote",+            sectionHeading: nil,+            content: content,+            status: status,+            createdAt: createdAt,+            modifiedAt: createdAt+        )+    }++    /// Create test DocumentNotes (the persisted container).+    func makeDocumentNotes(+        displayName: String = "test.md",+        identifier: DocumentIdentifier = DocumentIdentifier(path: "project/specs/test.md"),+        notes: [BlockNote] = []+    ) -> DocumentNotes {+        DocumentNotes(+            schemaVersion: 1,+            identifier: identifier,+            displayName: displayName,+            notes: notes+        )+    }++    // MARK: - Property-Based Test: Predicate/Payload Agreement (Req 5.1, Decision 11)++    /// The kind of population slot a generated note occupies.+    private enum NoteKind: CaseIterable {+        case anchored+        case documentLevel+        case orphaned+    }++    /// For arbitrary note populations (anchored/document-level/orphaned x+    /// active/resolved, container present/nil) and both includeResolved values:+    /// `hasIncludableNotes` is true iff `export` emits at least one generated+    /// note. Note bodies are unique non-empty markers asserted via containment,+    /// so the oracle cannot false-pass on empty or duplicated output.+    /// (Imported notes reach the exporter pre-merged into the anchored map;+    /// the merge itself is covered by the NotesManager tests below.)+    @Test("hasIncludableNotes agrees with export payload over arbitrary populations", arguments: 1...40)+    func predicatePayloadAgreement(seed: Int) {+        var rng: RandomNumberGenerator = SeededRandomNumberGenerator(seed: UInt64(seed))++        let blocks: [MarkdownBlock] = (0..<3).map {+            .paragraph(markdown: "Generated paragraph \(seed)-\($0) body text.")+        }++        var anchoredNotes: [String: [BlockNote]] = [:]+        var orphanedNotes: [BlockNote] = []+        var generated: [(marker: String, status: NoteStatus)] = []++        let noteCount = Int.random(in: 0...6, using: &rng)+        for index in 0..<noteCount {+            let marker = "unique-marker-\(seed)-\(index)"+            let status: NoteStatus = Bool.random(using: &rng) ? .active : .resolved+            let kind = NoteKind.allCases.randomElement(using: &rng) ?? .anchored++            switch kind {+            case .anchored:+                let blockId = blocks.randomElement(using: &rng)?.id ?? blocks[0].id+                anchoredNotes[blockId, default: []].append(+                    makeNote(blockId: blockId, content: marker, status: status)+                )+            case .documentLevel:+                let blockId = BlockNote.documentSentinelId+                anchoredNotes[blockId, default: []].append(+                    makeNote(blockId: blockId, content: marker, status: status)+                )+            case .orphaned:+                orphanedNotes.append(+                    makeNote(blockId: "orphan-block-\(seed)-\(index)", content: marker, status: status)+                )+            }+            generated.append((marker, status))+        }++        let containerPresent = Bool.random(using: &rng)+        let documentNotes = containerPresent ? makeDocumentNotes() : nil++        for includeResolved in [false, true] {+            let output = NotesExporter.export(+                documentNotes: documentNotes,+                anchoredNotes: anchoredNotes,+                orphanedNotes: orphanedNotes,+                blocks: blocks,+                includeResolved: includeResolved+            )++            let includedMarkers = generated+                .filter { includeResolved || $0.status == .active }+                .map(\.marker)+            let excludedMarkers = generated+                .filter { !includeResolved && $0.status == .resolved }+                .map(\.marker)++            for marker in includedMarkers {+                #expect(+                    output.contains(marker),+                    "Expected marker \(marker) in export (seed \(seed), container \(containerPresent), includeResolved \(includeResolved))"+                )+            }+            for marker in excludedMarkers {+                #expect(+                    !output.contains(marker),+                    "Marker \(marker) must be excluded (seed \(seed), container \(containerPresent), includeResolved \(includeResolved))"+                )+            }++            let predicate = NotesExporter.hasIncludableNotes(+                documentNotes: documentNotes,+                anchoredNotes: anchoredNotes,+                orphanedNotes: orphanedNotes,+                blocks: blocks,+                includeResolved: includeResolved+            )+            #expect(+                predicate == !includedMarkers.isEmpty,+                "Predicate must agree with payload (seed \(seed), container \(containerPresent), includeResolved \(includeResolved))"+            )+        }+    }++    // MARK: - Exporter Nil-Container Fix (Decision 12)++    @Test("Nil container: anchored (imported) notes are emitted with the fallback display-name header")+    func nilContainerEmitsAnchoredNotesWithFallbackHeader() {+        let block = MarkdownBlock.paragraph(markdown: "Paragraph with an imported comment.")+        let note = makeNote(blockId: block.id, content: "Imported-only note body")++        let output = NotesExporter.export(+            documentNotes: nil,+            anchoredNotes: [block.id: [note]],+            orphanedNotes: [],+            blocks: [block],+            fallbackDisplayName: "imported.md"+        )++        #expect(output.contains("# Notes: imported.md"))+        #expect(output.contains("Imported-only note body"))+    }++    @Test("Nil container: orphaned-only notes are emitted and the predicate is true")+    func nilContainerEmitsOrphanedNotes() {+        let block = MarkdownBlock.paragraph(markdown: "Current content.")+        let orphan = makeNote(blockId: "gone-block", content: "Orphaned-only note body")++        let output = NotesExporter.export(+            documentNotes: nil,+            anchoredNotes: [:],+            orphanedNotes: [orphan],+            blocks: [block]+        )++        #expect(output.contains("## Orphaned Notes"))+        #expect(output.contains("Orphaned-only note body"))++        let predicate = NotesExporter.hasIncludableNotes(+            documentNotes: nil,+            anchoredNotes: [:],+            orphanedNotes: [orphan],+            blocks: [block],+            includeResolved: false+        )+        #expect(predicate == true)+    }++    // MARK: - Predicate Edges (Req 5.1)++    @Test("Empty population is not includable, container present or nil")+    func emptyPopulationIsNotIncludable() {+        let blocks = [MarkdownBlock.paragraph(markdown: "No notes here.")]++        for documentNotes in [makeDocumentNotes(), nil] {+            for includeResolved in [false, true] {+                let predicate = NotesExporter.hasIncludableNotes(+                    documentNotes: documentNotes,+                    anchoredNotes: [:],+                    orphanedNotes: [],+                    blocks: blocks,+                    includeResolved: includeResolved+                )+                #expect(+                    predicate == false,+                    "Header metadata alone must not count (container \(documentNotes != nil), includeResolved \(includeResolved))"+                )+            }+        }+    }++    @Test("Resolved-only population flips with includeResolved")+    func resolvedOnlyPopulationFlipsWithIncludeResolved() {+        let block = MarkdownBlock.paragraph(markdown: "Resolved note target.")+        let resolved = makeNote(blockId: block.id, content: "Resolved note body", status: .resolved)+        let anchoredNotes = [block.id: [resolved]]++        let excluded = NotesExporter.hasIncludableNotes(+            documentNotes: makeDocumentNotes(notes: [resolved]),+            anchoredNotes: anchoredNotes,+            orphanedNotes: [],+            blocks: [block],+            includeResolved: false+        )+        #expect(excluded == false)++        let included = NotesExporter.hasIncludableNotes(+            documentNotes: makeDocumentNotes(notes: [resolved]),+            anchoredNotes: anchoredNotes,+            orphanedNotes: [],+            blocks: [block],+            includeResolved: true+        )+        #expect(included == true)+    }++    @Test("Document-level note under the sentinel key is includable")+    func documentLevelNoteIsIncludable() {+        let block = MarkdownBlock.paragraph(markdown: "Body content.")+        let docNote = makeNote(blockId: BlockNote.documentSentinelId, content: "Document-level note body")++        let predicate = NotesExporter.hasIncludableNotes(+            documentNotes: makeDocumentNotes(notes: [docNote]),+            anchoredNotes: [BlockNote.documentSentinelId: [docNote]],+            orphanedNotes: [],+            blocks: [block],+            includeResolved: false+        )+        #expect(predicate == true)+    }++    @Test("List-item sub-block notes are includable via the block scan")+    func listItemNoteIsIncludable() {+        let listBlock = MarkdownBlock.list(ordered: false, start: 1, items: [+            ListItem(content: "First item", checkbox: nil),+            ListItem(content: "Second item", checkbox: nil)+        ])+        guard let itemId = listBlock.listItemId(at: 1) else {+            Issue.record("Expected list item ID")+            return+        }+        let note = makeNote(blockId: itemId, content: "List-item note body")++        let predicate = NotesExporter.hasIncludableNotes(+            documentNotes: makeDocumentNotes(notes: [note]),+            anchoredNotes: [itemId: [note]],+            orphanedNotes: [],+            blocks: [listBlock],+            includeResolved: false+        )+        #expect(predicate == true)+    }+}++// MARK: - NotesManager.hasCopyableNotes (Req 5.1, 5.2, 5.3)++@Suite("NotesManager copy-availability predicate")+struct NotesManagerHasCopyableNotesTests {++    func makeNote(+        blockId: String,+        contextQuote: String,+        content: String,+        status: NoteStatus = .active+    ) -> BlockNote {+        BlockNote(+            id: UUID(),+            blockId: blockId,+            contextQuote: contextQuote,+            sectionHeading: nil,+            content: content,+            status: status,+            createdAt: Date(),+            modifiedAt: Date()+        )+    }++    func makeDocumentNotes(notes: [BlockNote]) -> DocumentNotes {+        DocumentNotes(+            schemaVersion: 1,+            identifier: DocumentIdentifier(path: "project/specs/doc.md"),+            displayName: "doc.md",+            notes: notes+        )+    }++    func makeURL() -> URL {+        URL(fileURLWithPath: "/Users/test/project/specs/doc.md")+    }++    @Test("Imported-only document is copyable and exports the imported notes with display-name header")+    @MainActor+    func importedOnlyDocumentIsCopyable() async {+        let store = MockNotesStore()+        let manager = NotesManager.makeForTesting(store: store)+        let block = MarkdownBlock.paragraph(markdown: "Paragraph with imported comment.")++        await manager.loadNotes(source: .file(url: makeURL()), sessionID: UUID(), blocks: [block])+        #expect(manager.documentNotes == nil, "Precondition: imported-only document has no persisted container")++        let importedData = CommentBlockExtractor.ImportedNoteData(+            author: "Alice",+            content: "Imported-only manager note body",+            isResolved: false,+            anchorBlockId: block.id+        )+        manager.loadImportedNotes(+            [importedData],+            blocks: [block],+            structure: MarkdownSectionBuilder.build(from: [block])+        )++        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: false) == true)++        let output = manager.exportMarkdown(blocks: [block])+        #expect(output.contains("Imported-only manager note body"))+        #expect(output.contains("# Notes: doc.md"), "Header must derive from the manager display name (Decision 12)")+    }++    @Test("Resolved-only population flips hasCopyableNotes with includeResolved (Req 5.3)")+    @MainActor+    func includeResolvedFlipsHasCopyableNotes() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Resolved-only paragraph content.")+        let resolved = makeNote(+            blockId: block.id,+            contextQuote: "Resolved-only paragraph content.",+            content: "Resolved manager note body",+            status: .resolved+        )+        await store.preload(makeDocumentNotes(notes: [resolved]))++        let manager = NotesManager.makeForTesting(store: store)+        await manager.loadNotes(source: .file(url: makeURL()), sessionID: UUID(), blocks: [block])+        #expect(manager.documentNotes != nil, "Precondition: persisted container loaded")++        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: false) == false)+        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: true) == true)++        // Predicate/payload agreement through the manager as well+        let excludedOutput = manager.exportMarkdown(blocks: [block], includeResolved: false)+        #expect(!excludedOutput.contains("Resolved manager note body"))+        let includedOutput = manager.exportMarkdown(blocks: [block], includeResolved: true)+        #expect(includedOutput.contains("Resolved manager note body"))+    }++    @Test("Empty document has no copyable notes")+    @MainActor+    func emptyDocumentHasNoCopyableNotes() async {+        let store = MockNotesStore()+        let manager = NotesManager.makeForTesting(store: store)+        let block = MarkdownBlock.paragraph(markdown: "Nothing annotated here.")++        await manager.loadNotes(source: .file(url: makeURL()), sessionID: UUID(), blocks: [block])++        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: false) == false)+        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: true) == false)+    }+}
prismTests/NotesExporterTests.swift Modified +8 / -3
diff --git a/prismTests/NotesExporterTests.swift b/prismTests/NotesExporterTests.swiftindex 8a1c7a5..d462e15 100644--- a/prismTests/NotesExporterTests.swift+++ b/prismTests/NotesExporterTests.swift@@ -82,8 +82,11 @@ struct NotesExporterTests {      // MARK: - Empty State Tests -    @Test("Returns empty string when documentNotes is nil")-    func exportReturnsEmptyForNilNotes() {+    @Test("Emits fallback display-name header when documentNotes is nil (Decision 12)")+    func exportEmitsFallbackHeaderForNilNotes() {+        // Pre-Decision-12 this returned "" and silently dropped imported/orphaned+        // notes (specs/notes-action-placement/). The header now derives from the+        // manager's display name; the Source line requires the container.         let result = NotesExporter.export(             documentNotes: nil,             anchoredNotes: [:],@@ -91,7 +94,9 @@ struct NotesExporterTests {             blocks: []         ) -        #expect(result == "")+        #expect(result.contains("# Notes: document"))+        #expect(!result.contains("**Source:**"))+        #expect(result.contains("**Exported:**"))     }      @Test("Returns empty string when no notes exist")
prismTests/NotesPanelDocumentNotesTests.swift Modified +19 / -8
diff --git a/prismTests/NotesPanelDocumentNotesTests.swift b/prismTests/NotesPanelDocumentNotesTests.swiftindex ddcae2a..2efd1c4 100644--- a/prismTests/NotesPanelDocumentNotesTests.swift+++ b/prismTests/NotesPanelDocumentNotesTests.swift@@ -78,17 +78,28 @@ struct NotesPanelDocumentNotesTests {         #expect(anchoredNotes[0].content == "Block note")     } -    // MARK: - Toolbar Plus Button+    // MARK: - Toolbar Copy Availability++    /// The pane's toolbar copy action is the shared `CopyNotesButton`, whose+    /// visibility follows `NotesManager.hasCopyableNotes` (notes-action-+    /// placement Req 5.1/5.2). Document-level notes count toward the+    /// predicate, so creating one via the pane's plus button makes copy+    /// available even with no block-anchored notes.+    @Test("Document-level note makes the pane copy action available")+    func testPanel_copyAvailableWithDocumentNote() async throws {+        let store = MockNotesStore()+        let manager = NotesManager.makeForTesting(store: store)++        let url = URL(fileURLWithPath: "/test.md")+        await manager.loadNotes(source: .file(url: url), sessionID: UUID(), blocks: []) -    @Test("Toolbar plus button always visible when notes available")-    func testPanel_createViaToolbarPlus() {-        let manager = NotesManager()+        // With no notes, no copy surface shows.+        #expect(!manager.hasCopyableNotes(blocks: [], includeResolved: false)) -        // With no notes, actions should not be shown-        #expect(!NotesPanel.shouldShowActions(hasDisplayableNotes: false))+        await manager.createDocumentNote(content: "New document note", source: .file(url: url), sessionID: UUID()) -        // With notes, actions should be shown-        #expect(NotesPanel.shouldShowActions(hasDisplayableNotes: true))+        // With a document-level note, copy is available.+        #expect(manager.hasCopyableNotes(blocks: [], includeResolved: false))     }      // MARK: - Section Plus Button
prismTests/ShareWithNotesPlacementTests.swift Renamed +21 / -18
diff --git a/prismTests/ShareWithNotesPlacementTests.swift b/prismTests/ShareWithNotesPlacementTests.swiftnew file mode 100644index 0000000..bc52def--- /dev/null+++ b/prismTests/ShareWithNotesPlacementTests.swift@@ -0,0 +1,323 @@+//+//  ShareWithNotesPlacementTests.swift+//  prismTests+//+//  Placement contract for the Share-with-Notes action: every insertion+//  site — the regular top toolbar, the compact notes pane, and the notes+//  sidebar — gates `ExportWithNotesButton` on the single predicate+//  `InlineNotesShareHelper.shouldShowShareButton`, which delegates to+//  `NotesManager.hasActiveAnchoredNotes(in:)`. The compact document+//  screen hosts no export button (notes-action-placement Req 1.2).+//+//  History: this suite began as the T-138 regression tests asserting the+//  compact and regular layouts insert the export button in lockstep. That+//  parity contract is superseded by notes-action-placement Decision 8 —+//  the compact insertion site was removed — but the gate semantics carry+//  over unchanged: export stays hidden without active anchored notes+//  (export-with-inline-notes Req 8.3/8.5/10.1), including the T-1154+//  sub-block anchor coverage and Decision 19 orphan exclusion below.++import Foundation+import Testing+@testable import prism++struct ShareWithNotesPlacementTests {++    // MARK: - Helpers++    private func makeURL(path: String = "/Users/test/project/specs/doc.md") -> URL {+        URL(fileURLWithPath: path)+    }++    // MARK: - Export Button Visibility Preconditions++    /// Verifies that `hasActiveAnchoredNotes` returns true when user notes+    /// exist for blocks in the document. This is the condition that gates+    /// whether the export toolbar button is visible.+    @Test("Export button visible when user notes exist on document blocks")+    @MainActor+    func exportButtonVisibleWithUserNotes() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Test content")+        let manager = NotesManager.makeForTesting(store: store)++        await manager.createNote(+            content: "Review comment",+            for: block,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [block]),+            source: .file(url: makeURL()), sessionID: UUID()+        )++        #expect(+            manager.hasActiveAnchoredNotes(in: [block]),+            "Export button should be visible when active user notes exist"+        )+    }++    /// Verifies that `hasActiveAnchoredNotes` returns true when imported+    /// notes exist for blocks in the document.+    @Test("Export button visible when imported notes exist on document blocks")+    @MainActor+    func exportButtonVisibleWithImportedNotes() {+        let block = MarkdownBlock.paragraph(markdown: "Test content")+        let manager = NotesManager.makeForTesting(store: MockNotesStore())++        let importedData = CommentBlockExtractor.ImportedNoteData(+            author: "Alice",+            content: "Imported note",+            isResolved: false,+            anchorBlockId: block.id+        )+        manager.loadImportedNotes([importedData], blocks: [block], structure: MarkdownSectionBuilder.build(from: [block]))++        #expect(+            manager.hasActiveAnchoredNotes(in: [block]),+            "Export button should be visible when active imported notes exist"+        )+    }++    /// Verifies that `hasActiveAnchoredNotes` returns false when no notes+    /// exist, so the export button remains hidden.+    @Test("Export button hidden when no notes exist")+    @MainActor+    func exportButtonHiddenWithoutNotes() {+        let block = MarkdownBlock.paragraph(markdown: "Test content")+        let manager = NotesManager.makeForTesting(store: MockNotesStore())++        #expect(+            !manager.hasActiveAnchoredNotes(in: [block]),+            "Export button should be hidden when no notes exist"+        )+    }++    // MARK: - Export File Generation++    /// Verifies that `exportWithInlineNotes` generates valid output when+    /// called with user notes, matching the flow every Share-with-Notes+    /// button triggers through `ExportNotesFlow.run`.+    @Test("Export generates valid markdown with inline notes")+    @MainActor+    func exportGeneratesValidOutput() async {+        let source = "# Title\n\nSome content here."+        let blocks = MarkdownBlockParser.parse(source)+        let store = MockNotesStore()+        let manager = NotesManager.makeForTesting(store: store)++        // Create a note on the paragraph block+        guard blocks.count >= 2 else {+            Issue.record("Expected at least 2 blocks from parsed source")+            return+        }+        let paragraphBlock = blocks[1]+        await manager.createNote(+            content: "Review this section",+            for: paragraphBlock,+            sourceIndex: 1,+            in: MarkdownSectionBuilder.build(from: blocks),+            source: .file(url: makeURL()), sessionID: UUID()+        )++        let exported = manager.exportWithInlineNotes(+            rawSource: source,+            blocks: blocks,+            username: "TestUser"+        )++        #expect(exported.contains("[!COMMENT TestUser id="), "Export should contain comment marker with username")+        #expect(exported.contains("Review this section"), "Export should contain the note content")+        #expect(exported.contains("# Title"), "Export should preserve original document content")+    }++    // MARK: - T-1154: hasActiveAnchoredNotes covers sub-block anchors++    /// Notes anchored to list items use IDs of the form "{listBlockId}-item-{index}".+    /// Before T-1154, `hasActiveAnchoredNotes(in:)` only built its key set from+    /// `blocks.map(\.id)`, so list-item notes were treated as orphaned and the+    /// Share-with-Notes action would be incorrectly hidden once the UI gate is+    /// restored.+    @Test("Share gate sees active list-item notes anchored under current blocks")+    @MainActor+    func gateRecognisesActiveListItemNotes() async {+        let item1 = ListItem(content: "First item", checkbox: nil, children: [])+        let item2 = ListItem(content: "Second item", checkbox: nil, children: [])+        let list = MarkdownBlock.list(ordered: false, start: 1, items: [item1, item2])+        let manager = NotesManager.makeForTesting(store: MockNotesStore())++        await manager.createNoteForListItem(+            content: "Review the second item",+            block: list,+            itemIndex: 1,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [list]),+            source: .file(url: makeURL()),+            sessionID: UUID()+        )++        #expect(+            manager.hasActiveAnchoredNotes(in: [list]),+            "Share-with-Notes gate must consider list-item notes anchored under current blocks"+        )+    }++    /// Notes anchored to table rows use IDs of the form "{tableBlockId}-row-{index}".+    /// Same regression as the list-item case: sub-block notes were excluded by+    /// `hasActiveAnchoredNotes(in:)` before T-1154.+    @Test("Share gate sees active table-row notes anchored under current blocks")+    @MainActor+    func gateRecognisesActiveTableRowNotes() async {+        let table = MarkdownBlock.table(+            headers: ["Name", "Age"],+            rows: [["Alice", "30"], ["Bob", "25"]],+            alignments: [.leading, .trailing]+        )+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let rowSubId = "\(table.id)-row-0"++        await manager.handleNoteCreation(+            content: "Note on Alice row",+            for: table,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [table]),+            source: .file(url: makeURL()),+            sessionID: UUID(),+            anchor: .tableRow(subId: rowSubId, contextQuote: "Name: Alice | 30")+        )++        #expect(+            manager.hasActiveAnchoredNotes(in: [table]),+            "Share-with-Notes gate must consider table-row notes anchored under current blocks"+        )+    }++    /// Decision 19 keeps orphan exclusion intact: a list-item or table-row note+    /// whose parent block is no longer in the current parsed blocks must NOT+    /// satisfy the Share-with-Notes gate.+    @Test("Share gate ignores sub-block notes anchored under missing blocks")+    @MainActor+    func gateExcludesOrphanedSubBlockNotes() async {+        let table = MarkdownBlock.table(+            headers: ["Name"],+            rows: [["Alice"]],+            alignments: [.leading]+        )+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let rowSubId = "\(table.id)-row-0"++        await manager.handleNoteCreation(+            content: "Note on Alice row",+            for: table,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [table]),+            source: .file(url: makeURL()),+            sessionID: UUID(),+            anchor: .tableRow(subId: rowSubId, contextQuote: "Name: Alice")+        )++        let unrelatedParagraph = MarkdownBlock.paragraph(markdown: "Different document content")++        #expect(+            !manager.hasActiveAnchoredNotes(in: [unrelatedParagraph]),+            "Sub-block notes anchored under blocks that no longer exist must be treated as orphaned (Decision 19)"+        )+    }++    // MARK: - T-1154: UI gate predicate++    /// The Share-with-Notes button must be gated on the manager predicate+    /// at every insertion site — the regular top toolbar, the notes pane,+    /// and the notes sidebar (Decision 8 placement contract). The helper+    /// exposes `shouldShowShareButton(notesManager:session:)` so the+    /// SwiftUI views and tests share a single source of truth. This test+    /// verifies that predicate honours the same rules as+    /// `hasActiveAnchoredNotes(in:)`, since SwiftUI body content is not+    /// directly inspectable in unit tests.+    @Test("Toolbar gate predicate hides Share with Notes when no active notes exist")+    @MainActor+    func toolbarGatePredicateHiddenWhenNoNotes() {+        let block = MarkdownBlock.paragraph(markdown: "Test content")+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/tmp/test.md"),+            content: "Test content"+        )+        session.parsedBlocks = [block]++        #expect(+            !InlineNotesShareHelper.shouldShowShareButton(+                notesManager: manager,+                session: session+            ),+            "Predicate must return false when there are no active anchored notes"+        )+    }++    @Test("Toolbar gate predicate shows Share with Notes for active list-item notes")+    @MainActor+    func toolbarGatePredicateVisibleWithListItemNote() async {+        let item = ListItem(content: "An item", checkbox: nil, children: [])+        let list = MarkdownBlock.list(ordered: false, start: 1, items: [item])+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/tmp/test.md"),+            content: "- An item"+        )+        session.parsedBlocks = [list]++        await manager.createNoteForListItem(+            content: "Item note",+            block: list,+            itemIndex: 0,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [list]),+            source: .file(url: makeURL()),+            sessionID: UUID()+        )++        #expect(+            InlineNotesShareHelper.shouldShowShareButton(+                notesManager: manager,+                session: session+            ),+            "Predicate must return true when a list-item note is anchored to a current block"+        )+    }++    /// Symmetric counterpart to ``toolbarGatePredicateVisibleWithListItemNote``.+    /// Table-row notes were part of the same T-1154 regression, so the gate+    /// predicate must also see table-row anchors under the current blocks.+    @Test("Toolbar gate predicate shows Share with Notes for active table-row notes")+    @MainActor+    func toolbarGatePredicateVisibleWithTableRowNote() async {+        let table = MarkdownBlock.table(+            headers: ["Name", "Age"],+            rows: [["Alice", "30"]],+            alignments: [.leading, .trailing]+        )+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/tmp/test.md"),+            content: "| Name | Age |\n| --- | --- |\n| Alice | 30 |"+        )+        session.parsedBlocks = [table]+        let rowSubId = "\(table.id)-row-0"++        await manager.handleNoteCreation(+            content: "Row note",+            for: table,+            sourceIndex: 0,+            in: MarkdownSectionBuilder.build(from: [table]),+            source: .file(url: makeURL()),+            sessionID: UUID(),+            anchor: .tableRow(subId: rowSubId, contextQuote: "Name: Alice | 30")+        )++        #expect(+            InlineNotesShareHelper.shouldShowShareButton(+                notesManager: manager,+                session: session+            ),+            "Predicate must return true when a table-row note is anchored to a current block"+        )+    }+}
prismTests/SidebarNotesViewTests.swift Modified +110 / -43
diff --git a/prismTests/SidebarNotesViewTests.swift b/prismTests/SidebarNotesViewTests.swiftindex a9f7be7..ecd3bb0 100644--- a/prismTests/SidebarNotesViewTests.swift+++ b/prismTests/SidebarNotesViewTests.swift@@ -464,61 +464,128 @@ struct SidebarNotesViewTests {         #expect(result[1].notes.count == 1)     } -    // MARK: - Notes actions (T-228)+    // MARK: - Notes actions (T-1577 notes-action-placement)++    // Every copy surface (pane, sidebar, both toolbars) is one shared+    // CopyNotesButton whose visibility follows NotesManager.hasCopyableNotes+    // (Req 5.1/5.2), so per-surface visibility statics no longer exist to+    // assert. Payload parity against the single exportMarkdown call site+    // inside CopyNotesButton.perform — and the "Notes copied" banner text it+    // composes — is covered by CopyNotesButtonTests. The tests below pin the+    // visibility semantics that changed when the per-surface rules were+    // unified onto the shared predicate.++    /// The pane's previous rule (`hasDisplayableNotes`) showed a copy button+    /// for a resolved-only population even with include-resolved off — a+    /// visible button that copies nothing. The shared predicate hides copy+    /// there and follows the setting (Req 5.1, Decision 3).+    @Test("Resolved-only notes are displayable but only copyable when includeResolved is on")+    func copyAvailabilityFollowsIncludeResolvedNotDisplayability() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Resolved-only paragraph content")+        let resolved = BlockNote(+            id: UUID(),+            blockId: block.id,+            contextQuote: "Resolved-only paragraph content",+            content: "resolved note",+            status: .resolved,+            createdAt: Date(),+            modifiedAt: Date()+        )+        await store.preload(DocumentNotes(+            identifier: DocumentIdentifier(path: "project/specs/doc.md"),+            displayName: "doc.md",+            notes: [resolved]+        )) -    @Test("Notes copy actions use exportMarkdown payload")-    func notesCopyActionsUseExportMarkdownPayload() {-        let manager = NotesManager()-        let blocks: [MarkdownBlock] = [-            .heading(level: 1, text: "Title"),-            .paragraph(markdown: "Body")-        ]-        let username = "Reviewer"+        let manager = NotesManager.makeForTesting(store: store)+        await manager.loadNotes(+            source: .file(url: URL(fileURLWithPath: "/Users/test/project/specs/doc.md")),+            sessionID: UUID(),+            blocks: [block]+        ) -        let expected = manager.exportMarkdown(blocks: blocks, username: username)+        #expect(manager.hasDisplayableNotes, "The resolved section still renders in the list UI")+        #expect(!manager.hasCopyableNotes(blocks: [block], includeResolved: false))+        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: true))+    } -        let panelPayload = NotesPanel.exportMarkdownPayload(-            notesManager: manager,-            blocks: blocks,-            username: username-        )-        let sidebarPayload = SidebarNotesView.exportMarkdownPayload(-            notesManager: manager,-            blocks: blocks,-            username: username+    /// The sidebar's previous iOS rule (`hasNotes` — persisted user notes+    /// only) hid copy for imported-only documents; the shared predicate+    /// counts imported notes on every surface (Req 5.2, Decision 3).+    @Test("Imported-only notes are copyable despite no persisted user notes")+    func copyAvailabilityIncludesImportedOnlyNotes() {+        let manager = NotesManager.makeForTesting(store: MockNotesStore())+        let block = MarkdownBlock.paragraph(markdown: "Paragraph with an imported comment")++        let importedData = CommentBlockExtractor.ImportedNoteData(+            author: "reviewer",+            content: "imported comment",+            isResolved: false,+            anchorBlockId: block.id         )+        manager.loadImportedNotes([importedData], blocks: [block], structure: buildStructure(from: [block])) -        #expect(panelPayload == expected)-        #expect(sidebarPayload == expected)+        #expect(!manager.hasNotes, "The old iOS sidebar gate would have hidden copy here")+        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: false))     } -    @Test("Copy feedback uses Notes copied banner text")-    func notesCopyFeedbackBannerText() {-        #expect(NotesPanel.copiedBannerMessage == "Notes copied")-        #expect(SidebarNotesView.copiedBannerMessage == "Notes copied")-    }+    /// The sidebar share button follows the export action's visibility rule+    /// instead of pair-gating with copy (Req 3.4, Decision 14): a population+    /// that is copyable but has no active anchored notes shows copy without+    /// share; adding an anchored note makes share visible.+    @Test("Sidebar share follows the export rule, not the copy predicate")+    func sidebarShareFollowsExportRule() async {+        let store = MockNotesStore()+        let block = MarkdownBlock.paragraph(markdown: "Paragraph without anchored notes")+        let orphanedNote = BlockNote(+            id: UUID(),+            blockId: "block-that-no-longer-exists",+            contextQuote: "Content that no longer appears in the document",+            content: "orphaned note",+            status: .active,+            createdAt: Date(),+            modifiedAt: Date()+        )+        await store.preload(DocumentNotes(+            identifier: DocumentIdentifier(path: "project/specs/doc.md"),+            displayName: "doc.md",+            notes: [orphanedNote]+        )) -    @Test("NotesPanel actions follow hasDisplayableNotes guard")-    func notesPanelActionVisibilityGuard() {-        #expect(NotesPanel.shouldShowActions(hasDisplayableNotes: true))-        #expect(!NotesPanel.shouldShowActions(hasDisplayableNotes: false))-    }+        let manager = NotesManager.makeForTesting(store: store)+        let source = DocumentSource.file(url: URL(fileURLWithPath: "/Users/test/project/specs/doc.md"))+        await manager.loadNotes(source: source, sessionID: UUID(), blocks: [block]) -    @Test("Sidebar action visibility keeps macOS copy available")-    func sidebarActionVisibilityGuard() {-        #if os(macOS)-        #expect(SidebarNotesView.shouldShowActions(hasNotes: false, hasDisplayableNotes: true))-        #expect(!SidebarNotesView.shouldShowActions(hasNotes: false, hasDisplayableNotes: false))-        #else-        #expect(SidebarNotesView.shouldShowActions(hasNotes: true, hasDisplayableNotes: true))-        #expect(!SidebarNotesView.shouldShowActions(hasNotes: false, hasDisplayableNotes: true))-        #endif+        let session = DocumentSession(+            url: URL(fileURLWithPath: "/Users/test/project/specs/doc.md"),+            content: "Paragraph without anchored notes"+        )+        session.parsedBlocks = [block]++        // Orphaned-only population: copyable, but not exportable+        // (the export flow excludes orphaned notes, Decision 19).+        #expect(!manager.orphanedNotes.isEmpty, "Precondition: the preloaded note must be orphaned")+        #expect(manager.hasCopyableNotes(blocks: [block], includeResolved: false))+        #expect(!InlineNotesShareHelper.shouldShowShareButton(notesManager: manager, session: session))++        // An active anchored note satisfies the export rule.+        await manager.createNote(+            content: "anchored note",+            for: block,+            sourceIndex: 0,+            in: buildStructure(from: [block]),+            source: source,+            sessionID: UUID()+        )+        #expect(InlineNotesShareHelper.shouldShowShareButton(notesManager: manager, session: session))     } -    @Test("Share visibility rules remove notes export on macOS")+    /// The sidebar share affordance stays iOS-only (Req 3.4). The notes pane+    /// deliberately has no such gate any more: it offers the export button on+    /// every platform by design (Req 2.1).+    @Test("Share visibility rules keep the sidebar share iOS-only")     func notesShareVisibilityRules() {-        #expect(!NotesPanel.showsShareAction)-         #if os(macOS)         #expect(!SidebarNotesView.showsShareAction)         #else
specs/OVERVIEW.md Modified +10 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 2beaee6..505dbac 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -73,6 +73,7 @@ | [iOS Zoom Controls](#ios-zoom-controls) | 2026-05-31 | Done | Zoom buttons, % overlay, double-tap, pan clamping, and a mermaid gesture-responsiveness fix for the iOS image and mermaid fullscreen viewers, matching macOS (T-1419) | | [WebView Rendering](#webview-rendering) | 2026-06-12 | Done | Replace the SwiftUI/Textual document renderer with a WebKit-for-SwiftUI pipeline (HTML emitter over the existing block model, JS bridge, sanitize/CSP/scheme-handler security, single-cutover beta retiring the Textual fork) (T-1542) | | [Web Markdown Fidelity](#web-markdown-fidelity) | 2026-06-15 | Done | Make the web renderer preserve rich blocks inside list items, ordered-list start numbers, and multi-paragraph/nested blockquotes; samples-driven compliance suite (T-1558) |+| [Notes Action Placement](#notes-action-placement) | 2026-07-02 | Done | Make copy the primary notes action: copy on the compact document screen and regular toolbar, export added to the notes pane, one shared availability predicate across all copy surfaces (T-1577) |  --- @@ -741,3 +742,12 @@ Make the web renderer (MarkdownBlock model + BlockHTMLEmitter) preserve three Co - [design.md](web-markdown-fidelity/design.md) - [tasks.md](web-markdown-fidelity/tasks.md) - [decision_log.md](web-markdown-fidelity/decision_log.md)++## Notes Action Placement++Make copy the primary notes action (T-1577): the compact document screen swaps its export button for copy, the notes pane gains both copy and export, and the regular toolbar shows copy leading export. All copy surfaces unify on one shared `CopyNotesButton` and one behavioural availability predicate (visible iff the copy output would contain a note), fixing two latent bugs along the way (imported-only documents copy an empty string; pane-originated export retries die after paywall dismissal).++- [requirements.md](notes-action-placement/requirements.md)+- [design.md](notes-action-placement/design.md)+- [tasks.md](notes-action-placement/tasks.md)+- [decision_log.md](notes-action-placement/decision_log.md)
specs/notes-action-placement/decision_log.md Added +486 / -0
diff --git a/specs/notes-action-placement/decision_log.md b/specs/notes-action-placement/decision_log.mdnew file mode 100644index 0000000..b1e7db1--- /dev/null+++ b/specs/notes-action-placement/decision_log.md@@ -0,0 +1,486 @@+# Decision Log: Notes Action Placement++## Decision 1: Fixed placements instead of a configurable primary action++**Date**: 2026-07-02+**Status**: accepted++### Context++T-1577 was previously interpreted (June 2026) as a Settings toggle letting the user choose which action — export or copy — occupies the always-visible toolbar. That spec work (an earlier `specs/notes-action-placement/` draft in a since-deleted worktree) was discarded, and the ticket description was rewritten to prescribe fixed placements.++### Decision++Placements are fixed, not configurable: compact layout shows Copy on the document screen and both actions in the notes pane; regular layout shows both actions in the top toolbar.++### Rationale++The user explicitly rewrote the ticket to this design and asked for the old interpretation's work to be discarded. Copy is the more frequent action and earns the always-visible spot; a toggle adds Settings surface area for little benefit.++### Alternatives Considered++- **Settings toggle choosing the primary action**: The earlier interpretation - Rejected by the user; adds configuration complexity for a decision the app can make outright.+- **Keep export primary, add copy beside it in compact**: Less disruptive - Rejected; the compact toolbar has limited space and the ticket explicitly makes copy the main action.++### Consequences++**Positive:**+- Simpler implementation and UI; no new Settings entry.+- The common action (copy) is one tap away on iPhone.++**Negative:**+- Export on iPhone now requires opening the notes pane (two taps instead of one).++---++## Decision 2: Spec folder name `notes-action-placement`++**Date**: 2026-07-02+**Status**: accepted++### Context++The spec needs a folder under `specs/`. The discarded earlier draft used `notes-action-placement`; that folder was deleted with its worktree, so the name is free. `specs/copy-notes-action/` already exists (T-228) and must not be reused.++### Decision++Name the spec folder `specs/notes-action-placement/`.++### Rationale++The name describes the change (where the notes actions live) and avoids colliding with the existing `copy-notes-action` spec. Reusing the old name keeps ticket history traceable to one spec folder.++### Alternatives Considered++- **`copy-main-action`**: Mirrors the ticket title - Vaguer about scope and easily confused with the existing `copy-notes-action` spec.+- **`notes-toolbar-actions`**: Emphasises toolbars - Slightly misleading since the compact notes pane is also in scope.++### Consequences++**Positive:**+- Clear, collision-free name matching ticket history.++**Negative:**+- A reader finding references to the discarded draft may need this log entry to know the content is new.++---++## Decision 3: One behavioural copy-visibility predicate across all surfaces++**Date**: 2026-07-02+**Status**: accepted++### Context++Copy-visibility predicates currently differ per surface: the compact notes pane uses `hasDisplayableNotes` (both platforms), while the regular sidebar uses `hasNotes` on iOS and `hasDisplayableNotes` on macOS. Export uses a third rule (`hasActiveAnchoredNotes`). These diverge on real documents — one with only imported notes has `hasNotes == false` but `hasDisplayableNotes == true` — and `hasDisplayableNotes` can be true while the copy output is empty (resolved-only notes with include-resolved off). The new always-visible copy button needs one unambiguous rule, and "same rule as the pane" would inherit this inconsistency.++### Decision++Copy is available if and only if at least one note would be included in the copy output under the current export settings. This single behavioural predicate governs every copy surface: compact document screen, regular top toolbar, notes pane, and notes sidebar (Requirement 5).++### Rationale++Defining availability by the observable outcome ("would copying produce a note?") removes the dependence on three divergent internal flags and closes the edges where a visible button copies an empty document (resolved-only with include-resolved off; imported-only, which today produces an empty payload because no persisted container exists — see Decision 12, which fixes that exporter bug so the predicate and a working payload agree). Export keeps its own established rule because its payload (inline-annotated document) has different emptiness semantics. *(Corrected during design review: the original rationale claimed imported-only documents "have a copy payload but no copy button" — code verification showed the payload is empty today; the bug is inverted.)*++### Alternatives Considered++- **Reuse the export rule (`hasActiveAnchoredNotes`)**: Copy and export toggle together - Rejected; a document with only a document-level or imported note would show no copy button despite having copyable notes.+- **"Same rule as the notes-pane copy button"**: Minimal change - Rejected; the phrase is ambiguous (three predicates exist) and perpetuates the sidebar-iOS divergence.+- **Always visible, disabled when empty**: More discoverable - Rejected; permanent toolbar noise for documents without notes, contrary to how export behaves today.++### Consequences++**Positive:**+- Copy appears exactly when copying does something, uniformly on every surface.+- Together with Decision 12, imported-only documents get a copy button that actually produces output (today the pane shows a button that copies an empty string).++**Negative:**+- Copy and export can appear/disappear independently in the regular toolbar.+- The predicate must respect the include-resolved setting, so visibility can change when that setting changes.++---++## Decision 4: Regular-layout notes sidebar keeps its header actions++**Date**: 2026-07-02+**Status**: accepted++### Context++`SidebarNotesView` (regular layout) already has copy and share buttons in its header. With both actions added to the top toolbar, the sidebar buttons become redundant while the sidebar is open. Separately, Decision 3 normalises the sidebar copy button's visibility predicate.++### Decision++Keep the sidebar header actions (add-note, copy, iOS-only notes-only share); the top toolbar additions are additive. The only sidebar change is the copy button's visibility predicate moving to the shared rule (Decision 3).++### Rationale++The ticket only asks for the toolbar to gain both buttons. Removing sidebar actions would change muscle memory and drop the iPad-only notes-only share affordance without being requested.++### Alternatives Considered++- **Remove sidebar copy/share**: Single home for the actions - Rejected; not requested, and it would remove the iPad sidebar share affordance that has no toolbar equivalent.++### Consequences++**Positive:**+- Additive change; no relearning for existing users.++**Negative:**+- Duplicate copy affordances visible when the sidebar is open.++---++## Decision 5: Notes-pane export is the full-document Share-with-Notes flow++**Date**: 2026-07-02 (corrected during design review)+**Status**: accepted++### Context++The ticket's "export button in the notes pane" needed pinning to a concrete flow. An earlier review claimed the app has two share flavours (full-document `ExportWithNotesButton` vs a "notes-only" share in the regular sidebar); code verification during design review showed that is false — `SidebarNotesView.performShare` calls the same `InlineNotesShareHelper.share` full-document flow. The app has exactly one share flow.++### Decision++The notes-pane export button triggers the app's single full-document Share-with-Notes flow — the same action the compact document screen loses in Requirement 1 (hoisted to screen level per Decision 13).++### Rationale++It preserves exactly the behaviour that leaves the compact document screen, and no payload ambiguity exists in code.++### Alternatives Considered++- **A distinct notes-only share flavour**: Assumed to exist by the earlier review - Not real; no such flavour exists in the codebase, so there is nothing to choose between.++### Consequences++**Positive:**+- One share flow everywhere; the sidebar share button unifies onto the same hoisted flow (Decisions 13/14).++**Negative:**+- Requirements 2.2/3.4 needed correcting where the earlier mischaracterisation had leaked in.++---++## Decision 6: Regular toolbar order — copy leads export in one group++**Date**: 2026-07-02+**Status**: accepted++### Context++The feature's premise is that copy is the main action, but the requirements originally said only "alongside the existing export button", leaving prominence to the implementer.++### Decision++In the regular layout's top toolbar the Copy notes button is placed before (leading) the export button, inside the same toolbar group.++### Rationale++Leading position expresses "copy is the main action" on the one surface where both buttons coexist; a single group keeps the related actions in one glass bubble per the platform's toolbar grouping.++### Alternatives Considered++- **Export leads**: Preserves the existing button's position - Rejected; contradicts the ticket's premise.+- **Separate groups (ToolbarSpacer)**: Visually separates the actions - Rejected; they are sibling actions on the same data and belong together.++### Consequences++**Positive:**+- Prominence is specified and testable.++**Negative:**+- The export button shifts position, a minor muscle-memory change for existing users.++---++## Decision 7: No keyboard shortcut for the toolbar copy button++**Date**: 2026-07-02+**Status**: accepted++### Context++The notes pane's macOS header copy button binds Cmd+C while the pane is open. An always-visible toolbar copy button that reused that binding would hijack text-selection copy in the document web view.++### Decision++The new toolbar Copy notes button registers no keyboard shortcut; Cmd+C remains selection copy. Existing shortcuts inside the notes pane are untouched.++### Rationale++Selection copy is the universally expected Cmd+C behaviour in a document viewer; a global override would be a regression. No alternative chord is needed for a discoverable toolbar button.++### Alternatives Considered++- **Cmd+C on the toolbar button**: Parity with the pane header - Rejected; conflicts with text-selection copy at all times, not just while a pane is open.+- **Alternative chord (e.g. Cmd+Shift+C)**: Conflict-free - Rejected; unrequested, and Cmd+Shift+C is commonly web-inspector muscle memory.++### Consequences++**Positive:**+- No regression to selection copy.++**Negative:**+- Keyboard-only users reach toolbar copy via accessibility navigation rather than a chord.++---++## Decision 8: Supersession of the T-138 compact/regular Share-button parity++**Date**: 2026-07-02+**Status**: accepted++### Context++T-138 established that the compact and regular layouts both insert `ExportWithNotesButton` gated on one predicate, with `InlineNotesShareHelper`'s documentation and tests describing the two insertion sites as staying "in lockstep". Requirement [1.2](requirements.md#1.2) removes the compact insertion site.++### Decision++This spec supersedes the compact-toolbar placement aspect of that contract: the compact document screen no longer hosts the export button. The export gate itself (`specs/export-with-inline-notes/` Req 8.3/8.5/10.1 — hidden without active anchored notes) is preserved unchanged wherever the button appears.++### Rationale++Recording the supersession here keeps the spec corpus consistent and explains why the lockstep-parity comments and tests change, rather than leaving future readers to reconcile contradicting documents.++### Alternatives Considered++- **Leave undocumented**: Less writing - Rejected; the spec corpus would contradict itself and parity-contract test failures would look like regressions.++### Consequences++**Positive:**+- Placement history is traceable; gate requirements remain authoritative.++**Negative:**+- `InlineNotesShareHelper` documentation and the parity-oriented tests need updating alongside the implementation.++---++## Decision 9: A paywall-gated action becomes the primary visible affordance++**Date**: 2026-07-02+**Status**: accepted++### Context++Copy consumes one export credit from the shared 20-free-export quota. Promoting copy to the always-visible primary action on every surface makes the gated action more prominent and will accelerate free-quota consumption relative to today, where copy is buried in the pane.++### Decision++Accept this consequence: gating, quota, and nudge mechanics stay exactly as they are; no free allowance is added for copy.++### Rationale++The gating design (20 free exports across copy/export/share) is an existing, deliberate product decision from the in-app purchase spec. Making copy prominent changes exposure, not mechanics; changing quota rules is a product decision outside this ticket.++### Alternatives Considered++- **Make copy free**: Avoids surprising paywalls on the primary button - Rejected; undermines the unlock's value proposition, which explicitly gates copy notes.+- **Separate quota for copy**: Softens the effect - Rejected; complicates the counter model (iCloud-synced single counter) for speculative benefit.++### Consequences++**Positive:**+- No change to purchase logic or counter model.++**Negative:**+- Free-tier users will hit the paywall sooner; the nudge toast on the primary button is more visible (arguably also a positive for conversion).++---++## Decision 10: One `CopyNotesButton` component owns visibility, gating, and banner text; hosts own toast presentation++**Date**: 2026-07-02+**Status**: accepted (design phase)++### Context++Copy logic is duplicated in `NotesPanel` and `SidebarNotesView`, and Requirement 5.2 demands lockstep visibility across four surfaces. Separately, the document screen needs a toast (Req 1.3) — `ExportWithNotesButton` self-hosts its toast inside a toolbar item, while `DocumentReaderView` already renders a screen-level toast for export nudges.++### Decision++A single `CopyNotesButton` view encapsulates the availability predicate, paywall gate, payload, count increment, and banner message text, and emits the banner through an `onBanner` callback. Hosts route it to their existing toast surface: a `DocumentLayoutCoordinator`-published banner rendered by `DocumentReaderView`'s screen-level toast for the two toolbar surfaces; pane/sidebar-local banner state for the pane surfaces.++*Amended during design review:* the same host-routed banner treatment applies to export nudges (`ExportNotesFlow`, Decision 13) — the self-hosted toast pattern is not carried into the new pane/toolbar contexts; and the per-host `ToolbarItem` visibility conditional is centralised in one shared `@ToolbarContentBuilder` helper, since host-side hand-wiring is exactly the failure mode that produced today's predicate divergence.++### Rationale++Putting visibility inside the component makes Req 5.2's lockstep structural rather than conventional. Routing toasts through hosts avoids betting on toast rendering inside toolbar items (`.toast` wraps its target in a `ZStack`, which is untested in the compact toolbar context) and reuses the two toast surfaces that already exist; the coordinator is the only state shared by both layouts' toolbars.++### Alternatives Considered++- **Self-hosted toast like `ExportWithNotesButton`**: Most consistent with the existing export button - Rejected; toast-in-toolbar-item rendering is unverified for the new placements, and Req 1.3 explicitly wants the toast on the document screen.+- **Free functions + per-surface buttons**: Smaller diff - Rejected; keeps four hand-wired visibility checks, which is what caused today's three-predicate divergence.++### Consequences++**Positive:**+- Duplicated `copyNotes()`/`shouldShowActions`/`exportMarkdownPayload` code is deleted; one place to test.++**Negative:**+- `CopyNotesButton` needs style/shortcut/callback parameters to serve toolbar and header contexts.++---++## Decision 11: Availability predicate implemented beside the exporter++**Date**: 2026-07-02+**Status**: accepted (design phase)++### Context++Requirement 5.1 defines copy availability as "≥ 1 note would be included in the copy output under current export settings". `NotesExporter.export` always emits header metadata, so output-string emptiness is the wrong test, and a predicate implemented independently of the exporter's three inclusion paths (document-level, anchored+imported, orphaned — each `includeResolved`-filtered) would drift.++### Decision++`NotesExporter.hasIncludableNotes(...)` reuses the same collect/filter helpers as `export(...)`; `NotesManager.hasCopyableNotes(blocks:includeResolved:)` feeds it the same inputs `exportMarkdown` uses. A property-based test asserts predicate/payload agreement.++### Rationale++Sharing the collect helpers makes the invariant hold by construction; the PBT catches regressions if either side later changes independently.++### Alternatives Considered++- **Predicate on `NotesManager` from its own flags**: No exporter change - Rejected; re-derives inclusion rules and will drift (this is exactly how `hasNotes`/`hasDisplayableNotes` diverged).+- **Call `export()` and count notes in the string**: Trivially in lockstep - Rejected; builds the full payload on every `body` evaluation of four surfaces.++### Consequences++**Positive:**+- Predicate cannot silently disagree with the payload.++**Negative:**+- `NotesExporter`'s private helpers need internal visibility for the shared path.++---++## Decision 12: Fix the exporter's nil-container bug so imported-only documents are copyable++**Date**: 2026-07-02+**Status**: accepted++### Context++`NotesExporter.export` returns `""` whenever `documentNotes` is nil. `loadImportedNotes` never creates a container, so a document whose only notes are imported comments produces an empty copy payload today — while the pane still shows a copy button. Requirement 5.1 counts imported notes, so the predicate and the payload cannot both be truthful without resolving this. The design review surfaced the contradiction; the peer validator and both external systems recommended fixing the exporter, the design critic recommended hiding the button instead.++### Decision++Fix `NotesExporter.export` to emit imported and orphaned notes when no persisted container exists, deriving the header from the manager's document display name. The availability predicate then follows the fixed payload: imported-only → visible and working.++### Rationale++Requirement 5.1 as approved counts imported notes; hiding the button would require amending it and would leave imported comments un-copyable from any surface. The exporter change is small, is covered directly by new example tests, and turns a silent empty-output bug into correct behaviour.++### Alternatives Considered++- **Mirror the nil guard (hide copy for imported-only)**: Strictly placement-only scope - Rejected; leaves imported comments un-copyable everywhere and requires weakening Requirement 5.1.+- **Create a container when importing notes**: Also unblocks the exporter - Rejected; persisting a container as a side effect of merely opening a document changes storage semantics far beyond this ticket.++### Consequences++**Positive:**+- Copy works for imported-only documents; predicate and payload agree by construction.++**Negative:**+- A pre-existing `NotesExporter` bug fix is pulled into this feature's scope.++---++## Decision 13: Share-with-Notes flow state hoisted to screen level; gate API returns its result++**Date**: 2026-07-02+**Status**: accepted (design phase)++### Context++`ExportWithNotesButton` keeps its username prompt, error alerts, and nudge toast in view-local `@State`. Requirement 2.4 dismisses the notes pane when the paywall blocks an export from inside it; the post-purchase retry stored in `pendingExportAction` would then call `triggerShare` on a deinstalled view — the username prompt silently never appears (reachable: a free user can exhaust the quota via copy with no username set). Separately, callers detect a blocked gate by re-reading `storeManager.showPaywall` after `runGatedExport`, coupling to a side effect.++### Decision++An `ExportNotesFlow` object owned by `DocumentLayoutCoordinator` holds the prompt/error state and the `run(...)` entry point; `DocumentReaderView` hosts its alerts and banner. Buttons become thin triggers; retry closures reference `flow.run`, never view state. `runGatedExport` becomes `@discardableResult` returning the `ExportGateResult` so blocked-path handling branches on `.blocked` deterministically.++### Rationale++Screen-level flow state survives pane dismissal, which is the only way Requirement 2.4's retry and 2.5's prompt can both hold. Returning the gate result replaces the fragile read-back pattern (and improves the existing `NotesPanel` incumbent in passing).++### Alternatives Considered++- **Preflight the username before the gate**: Avoids the hoist - Rejected; still leaves nudge/error state on a dead view and reorders the flow (prompt before paywall) observable to users.+- **Re-present the pane after purchase and let the user tap again**: Minimal code - Rejected; Requirement 2.4 says the blocked action retries, not that the user gets a second chance to trigger it.++### Consequences++**Positive:**+- Retry works regardless of which surface initiated the export; one alert/banner host; sidebar share unifies onto the same flow.++**Negative:**+- `ExportWithNotesButton` is refactored rather than reused as-is; the regular toolbar path changes too and needs regression coverage.++---++## Decision 14: Sidebar share button follows the export visibility rule++**Date**: 2026-07-02+**Status**: accepted++### Context++The sidebar's iOS share button is the full-document Share-with-Notes flow (Decision 5 correction) but is currently pair-gated with copy via `shouldShowActions`. With copy moving to its own predicate (Decision 3), keeping the pair-gating would create a third visibility rule for the share action — the divergence pattern this spec exists to remove.++### Decision++The sidebar share button follows the export action's visibility rule (`shouldShowShareButton` / active anchored notes), like every other Share-with-Notes button.++### Rationale++One rule per action kind. The button is an export; gating an export on the copy predicate (which now includes e.g. orphaned-only populations the share flow treats differently) would be arbitrary.++### Alternatives Considered++- **Keep pair-gated with copy**: No visible change for sidebar users - Rejected; institutionalises a third rule for the same action and contradicts the spec's premise.++### Consequences++**Positive:**+- Every Share-with-Notes insertion site shares one predicate; every copy site shares another.++**Negative:**+- Small visible change: the sidebar share button hides for populations that are copyable but not exportable (e.g. orphaned-only), where it previously showed.++---++## Decision 15: Session-reset hygiene and retry banner sink for the hoisted flow state++**Date**: 2026-07-02+**Status**: accepted++### Context++The phase 1 design review found two consequences of hoisting action state to screen level (Decision 13) that the design text did not fully resolve. First, `ExportNotesFlow` now outlives the document: its `pendingShareContext` retains the triggering `DocumentSession` and `NotesManager`, and `resetSessionState` does not touch it — a username prompt left open across a document switch would share the previous document's notes. The design flagged the analogous 3-second stale-banner window but was silent on the much longer-lived prompt context. Second, the design states both that pane-originated copy banners present pane-locally (Overview, toast routing) and that retry-after-purchase banners land on the coordinator toast — but `CopyNotesButton.perform` builds the stored retry from the same captured `onBanner`, which for a pane origin references the dismissed pane's dead state, so the retry banner would be silently lost.++### Decision++Task 8 resets `ExportNotesFlow` (pending context, prompt/validation/error flags, username input) in `DocumentLayoutCoordinator.resetSessionState`, alongside the banner. Task 10 gives `CopyNotesButton.perform` an explicit retry banner sink (`retryOnBanner`, defaulting to `onBanner`); pane hosts pass the coordinator banner as the retry sink while keeping the pane-local banner for the immediate path.++### Rationale++Both fixes close gaps at the seams Decision 13 created rather than reverting it. Resetting the flow in `resetSessionState` is the established pattern for session-scoped coordinator state. A separate retry sink is the only way to satisfy both banner-routing statements in the design with closures captured at tap time: the immediate banner needs the pane, the retry banner needs a surface that still exists.++### Alternatives Considered++- **Route all pane banners to the coordinator toast**: One sink, no API change - Rejected; the screen-level toast renders behind the pane sheet, so the user gets no visible confirmation for the common in-pane copy.+- **Accept losing the retry banner (pre-change behaviour)**: No API change - Rejected; the design explicitly promises the retry banner on the coordinator toast, and the copy did succeed — silent success after a purchase is confusing.+- **Have the pane's onBanner check its own visibility at call time**: No API change - Rejected; a dismissed sheet's view struct cannot reliably report visibility from a closure captured before dismissal.++### Consequences++**Positive:**+- A document switch can never share or copy the previous session's notes through a stale prompt.+- Retry-after-purchase always produces visible confirmation, regardless of origin surface.++**Negative:**+- `CopyNotesButton.perform` gains one parameter used only by pane/sidebar hosts.+- Flow reset on session change discards an in-flight username prompt; the user must re-trigger the action in the new document (correct, but a visible cancellation).++---
specs/notes-action-placement/design.md Added +139 / -0
diff --git a/specs/notes-action-placement/design.md b/specs/notes-action-placement/design.mdnew file mode 100644index 0000000..db88cc7--- /dev/null+++ b/specs/notes-action-placement/design.md@@ -0,0 +1,139 @@+# Design: Notes Action Placement++## Overview++Move copy-notes to the always-visible toolbar (compact: replacing export; regular: leading export), add export to the compact notes pane, and unify the four copy surfaces on one shared button component and one behavioural availability predicate. Requirements: `requirements.md` (T-1577).++## Architecture++### What changes++| Area | Change |+|---|---|+| `NotesExporter` | Fix the latent nil-container bug: `export(...)` currently returns `""` whenever `documentNotes == nil`, discarding imported/orphaned notes (imported-only documents copy an empty string today). It emits those notes with a header derived from the manager's display name when no container exists (Decision 12). New `hasIncludableNotes(...) -> Bool` shares the exact collect/filter helpers `export(...)` uses, so predicate and payload cannot drift (Req [5.1](requirements.md#5.1)). |+| `NotesManager` | New `hasCopyableNotes(blocks:includeResolved:) -> Bool` delegating to `NotesExporter.hasIncludableNotes` with the same merged anchored+imported map, orphaned list, and `documentNotes` that `exportMarkdown` passes. May short-circuit cheap paths (document-level sentinel, orphaned list) before the block scan; the PBT keeps any shortcut honest. |+| `StoreManager` | `runGatedExport` becomes `@discardableResult` returning the `ExportGateResult`, so callers branch on `.blocked` deterministically instead of re-reading `showPaywall` after the fact (existing `NotesPanel` incumbent pattern improved in passing). |+| `ExportNotesFlow` (new, on `DocumentLayoutCoordinator`) | Screen-level owner of the Share-with-Notes flow: username-prompt / validation / export-error presentation state plus `run(notesManager:session:settings:storeManager:)`. `DocumentReaderView` attaches the alerts and renders the banner, so the flow survives pane dismissal — the post-purchase retry stored in `pendingExportAction` is `flow.run`, not view-local state (Req [2.4](requirements.md#2.4)/[2.5](requirements.md#2.5)). Nudge messages route to the coordinator banner (no self-hosted toast). |+| `ExportWithNotesButton` | Becomes a thin trigger (label + help + disabled-while-loading) calling `ExportNotesFlow.run`; its `@State` alerts and self-hosted `.toast` are removed. Gains `onBlocked: (() -> Void)?` driven by the returned gate result — the pane passes pane-dismiss on iOS. |+| `CopyNotesButton` (new, `prism/Views/CopyNotesButton.swift`) | One button view for all four copy surfaces. The action delegates to a static `perform(notesManager:blocks:settings:storeManager:onBanner:onBlocked:)` — the unit-testable entry point for the gate → payload → clipboard → increment-once → banner sequence (SwiftUI bodies are not unit-inspectable; this mirrors the statics being deleted). |+| `DocumentReaderView` | :150: replace `.inlineNotesExportToolbar(...)` with the shared copy toolbar content (compact only). `exportNudgeMessage` (@State, :122/:361/:528) migrates to the coordinator banner — one screen-level `.toast` serves copy banners, export nudges, and the macOS File > Export nudge writer at :528 (that path must still fire post-migration). Hosts `ExportNotesFlow`'s alerts. |+| `RegularDocumentLayout` | Toolbar group at :503–508: copy before export as sibling `ToolbarItem`s with the same `.primaryAction` placement and no spacer between them, sharing one Liquid Glass group (Req [3.1](requirements.md#3.1), Decision 6; per-item `.id` requires separate items rather than one `ToolbarItemGroup`). Both items get explicit `.id(...)` so the two independent visibility conditionals don't churn identity in the shared Liquid Glass bubble. The toolbar struct gains a coordinator parameter (banner + flow access). |+| `NotesPanel` | Gains non-optional `session: DocumentSession` (single call site, `CompactDocumentLayout:170`, has it). iOS toolbar order: `+`, copy, export (copy leads, Decision 6). `copyNotes()`/`shouldShowActions`/`exportMarkdownPayload` statics replaced by `CopyNotesButton`. The macOS header branch is unreachable in practice (`DocumentReaderView:133` hardcodes `useCompact = false` on macOS) and is updated only for code-path consistency; its dead Cmd+C binding disappears with the replacement — `CopyNotesButton` has no shortcut parameter. |+| `SidebarNotesView` | Header copy button becomes `CopyNotesButton`; share button becomes the thin `ExportWithNotesButton` trigger of the same `ExportNotesFlow`, gated on the export rule (Decision 14); local `copyNotes()`/`shareNotes()`/`performShare()`/`shouldShowActions`/`exportMarkdownPayload` deleted. |+| `InlineNotesShareHelper` | `InlineNotesExportToolbar` modifier + `inlineNotesExportToolbar` extension deleted. `shouldShowShareButton` doc comment updated: the T-138 two-insertion-site parity contract ends (Decision 8); insertion sites are now the regular toolbar, the notes pane, and the sidebar. `share(...)` itself is unchanged. |++### Copy-availability predicate (Req 5)++`hasCopyableNotes` is true iff `export(...)` would emit ≥ 1 note under the current settings. With the Decision 12 exporter fix, that means: document-level notes (sentinel key), anchored+imported notes for current blocks (including list-item/table-row sub-block IDs), and orphaned notes — each with the same `includeResolved` status filter. Imported-only documents → true (and copy now actually produces those notes). Header metadata alone does not count (the exporter always emits it, so string-emptiness is the wrong test).++Reactivity (Req [5.3](requirements.md#5.3)) is free: Observation tracks stored-property reads made anywhere in the synchronous `body` pass, so surfaces evaluate the predicate (and read `AppSettings.exportIncludeResolved`) in `body`.++### Toolbar insertion (Req 1.1, 3.1)++A shared `@ToolbarContentBuilder` helper (in `CopyNotesButton.swift`) wraps the `if hasCopyableNotes(...)` conditional around the `ToolbarItem` once, so the compact and regular hosts cannot hand-wire divergent conditionals — the residual risk Decision 10 accepts. The conditional stays at the item level because an empty `ToolbarItem` may still claim a glass slot; `CopyNotesButton` also self-hides as defence in depth. Manual check on iPad/iOS 26: no flicker or slot-shift when copy/export visibility flips (unit tests cannot cover Liquid Glass grouping).++### Toast routing (Req 1.3 "toast presented on the document screen")++`CopyNotesButton.perform` emits the banner text through `onBanner`. Hosts wire it to their existing toast surface:++- Compact document screen + regular toolbar → the coordinator banner rendered by `DocumentReaderView`'s screen-level `.toast`. The banner property is cleared in `DocumentLayoutCoordinator.resetSessionState` so a document switch inside the 3-second window can't show a stale banner.+- Notes pane / sidebar → their existing local `copyBannerMessage` state (unchanged presentation inside the pane).++The composed message ("Notes copied — {nudge}") is produced via a catalog format key, not string concatenation (localisation rules); plain "Notes copied" — today a hardcoded constant — gets a catalog entry. Export nudges route through the same coordinator banner via `ExportNotesFlow` (the self-hosted toast pattern is not carried into the new pane/toolbar contexts).++### Pattern extension audit (predicate + payload call sites)++| Call site | Today | Needs change | Decision |+|---|---|---|---|+| `RegularDocumentLayout:503` `shouldShowShareButton` | export gate | button becomes flow trigger | Export rule unchanged (Req 2.3/3.3). |+| `InlineNotesShareHelper:204` (compact modifier) | export gate | **delete** | Compact loses export (Req 1.2, Decision 8). |+| `NotesPanel:152/:238` `shouldShowActions` | `hasDisplayableNotes` | **replace** | `hasCopyableNotes` via shared toolbar helper / `CopyNotesButton`. |+| `SidebarNotesView:177` `shouldShowActions` | `hasNotes` (iOS) / `hasDisplayableNotes` (macOS) | **replace** | Copy → `hasCopyableNotes`; share → export rule (Decision 14). |+| `NotesPanel:109/:124`, `SidebarNotesView:98` `hasDisplayableNotes` | list-vs-empty-state switch | no | Not a copy surface; empty state must still render when only resolved notes exist. |+| `DocumentReaderView:301` `hasNotes` | clipboard-note migration | no | Not a copy surface. |+| `NotesPanel.exportMarkdownPayload` / `SidebarNotesView.exportMarkdownPayload` | duplicate payload wrappers | **delete** | `CopyNotesButton.perform` calls `notesManager.exportMarkdown`; parity test retargets it. |++## Components and Interfaces++```swift+// NotesExporter.swift+static func hasIncludableNotes(+    documentNotes: DocumentNotes?,+    anchoredNotes: [String: [BlockNote]],   // caller passes the merged user+imported map+    orphanedNotes: [BlockNote],+    blocks: [MarkdownBlock],+    includeResolved: Bool+) -> Bool   // true iff export(...) with the same inputs emits ≥1 note+            // (post-Decision-12: nil documentNotes no longer forces false)++// NotesManager.swift+func hasCopyableNotes(blocks: [MarkdownBlock], includeResolved: Bool) -> Bool++// StoreManager.swift+@discardableResult+func runGatedExport(retry: @escaping () -> Void,+                    perform: (ExportGateResult) -> Void) -> ExportGateResult++// CopyNotesButton.swift+struct CopyNotesButton: View {+    let notesManager: NotesManager+    let blocks: [MarkdownBlock]+    var buttonStyle: Style = .toolbar        // .toolbar | .header (borderless)+    var onBlocked: (() -> Void)?             // pane passes onDismiss on iOS (Req 2.4)+    var onBanner: (String) -> Void++    @MainActor+    static func perform(notesManager:blocks:settings:storeManager:onBanner:onBlocked:)+    // gate → exportMarkdown → ClipboardHelper.copy → incrementExportCount (once)+    // → onBanner(composed text); onBlocked fired iff the returned gate == .blocked+}++// DocumentLayoutCoordinator.swift+@Observable final class ExportNotesFlow {   // owned by the coordinator+    var showUsernamePrompt/showValidationError/showExportError: Bool+    var usernameInput: String+    func run(notesManager:session:settings:storeManager:) -> ExportGateResult+    // gate (retry = self.run…) → username check → InlineNotesShareHelper.share+    // → increment + nudge → coordinator banner+}+```++Contracts:++- `CopyNotesButton` label `doc.on.doc`, accessibility label "Copy notes", help "Copy all notes as Markdown" (Req 4.1, existing catalog keys); disabled while `entitlementState == .loading` (Req 1.4; hidden wins because the toolbar helper's conditional runs first).+- Blocked-path behaviour is driven by the gate result (`.blocked` → `onBlocked`), never by re-reading `showPaywall`. `.loading` is a silent no-op, matching today.+- Retry closures (`pendingExportAction`) reference `CopyNotesButton.perform` / `ExportNotesFlow.run` — no view-local `@State` — so a purchase completed after the pane dismissed still re-runs the action, including the username prompt, from the document screen (Req 2.4/2.5). Pane-originated *banners* on retry land on the coordinator toast (the pane is gone); acceptable, previously the toast was silently lost.+- `NotesPanel` export gated on `InlineNotesShareHelper.shouldShowShareButton` (Req 2.3); username-prompt and error alerts present from `DocumentReaderView` above the pane sheet.+- Regular toolbar: no `keyboardShortcut` anywhere (Req 3.5); Cmd+C stays selection copy.++## Testing Strategy++- **Predicate/payload agreement (PBT).** Property: for arbitrary note populations (anchored/imported/orphaned/document-level × active/resolved, container present/nil) and both `includeResolved` values, `hasCopyableNotes == (export output contains ≥1 generated note)`. Generated note bodies are unique non-empty markers asserted via containment, so the oracle can't false-pass on empty or duplicated content. Swift Testing with seeded randomised generation, per the `ExportImportRoundTripPropertyTests` precedent.+- **Exporter nil-container fix (example-based):** imported-only → output contains the imported notes and a display-name header; orphaned-only likewise; empty → no notes emitted. Guards Decision 12 directly (the PBT alone would have "passed" on the old agree-on-empty behaviour).+- **Predicate edges:** resolved-only with `includeResolved` off → false, on → true; imported-only → true; orphaned-only → true; empty → false.+- **`CopyNotesButton.perform`:** gate-allowed path copies payload, increments exactly once, composes banner (with and without nudge); `.blocked` fires `onBlocked` and stores `pendingExportAction`; `.loading` no-ops. Stubbed store via `KeyValueStoreProtocol`.+- **`ExportNotesFlow.run`:** username-unset → prompt state; blocked → retry stored; **retry-after-purchase with no username set reaches the prompt state** (the M1 regression case); share failure → error state.+- **Reactivity:** toggling `exportIncludeResolved` flips `hasCopyableNotes` for a resolved-only population (Req 5.3's settings dimension).+- **Retarget/delete existing tests:** `InlineNotesExportToolbarTests` (T-138 parity → new placement contract, Decision 8); `SidebarNotesViewTests` `shouldShowActions` (:501–514) → `hasCopyableNotes`; payload-parity (:480–486) → single `exportMarkdown` call site; `SidebarNotesViewTests:518–527` and `NotesPanel.showsShareAction` (pane-has-no-share assertions — now false by design, Req 2.1); `NotesPanelDocumentNotesTests:88–91` (`shouldShowActions` deleted).+- **Localisation:** `make test-locales` after adding "Notes copied" and the composed-nudge format key.+- **Manual:** iPad/iOS 26 toolbar animation when copy/export visibility flips independently (Liquid Glass grouping is not unit-testable).++## Outstanding manual verification++Recorded at the phase 3 validation pass (task 13, 2026-07-03). Both checks+need a device/simulator session and cannot be automated in the unit suite:++1. **iPad/iOS 26 toolbar animation (Req 3.3, Decision 10).** With a document+   whose copy and export visibility flip independently (e.g. resolve the last+   active anchored note while an orphaned note keeps copy available), confirm+   the regular toolbar's Liquid Glass bubble shows no flicker or slot-shift+   when either button appears/disappears. The explicit `ToolbarItem` ids are+   in place; only the visual grouping behaviour is unverified.+2. **Username prompt above the pane sheet (Req 2.5).** With no export+   username set, trigger Share with Notes from inside the compact notes pane+   on iOS and confirm the username prompt presents above the pane sheet. The+   flow alerts are hosted by `DocumentReaderView` *under* the sheet, and+   SwiftUI may defer alert presentation from a covered context (phase 2+   review finding). If the prompt does not appear, host the flow alerts on+   the pane sheet content as well (attach `exportNotesFlowAlerts(_:)` to the+   `NotesPanel` sheet content in `CompactDocumentLayout`).
specs/notes-action-placement/requirements.md Added +84 / -0
diff --git a/specs/notes-action-placement/requirements.md b/specs/notes-action-placement/requirements.mdnew file mode 100644index 0000000..331f9b6--- /dev/null+++ b/specs/notes-action-placement/requirements.md@@ -0,0 +1,84 @@+# Requirements: Notes Action Placement++**Transit ticket:** T-1577 — Make copy the main action++## Introduction++Prism's export action ("Share with Notes") sits in the always-visible document toolbar, while the more frequently used copy-notes action is only reachable inside the notes pane. This feature swaps their prominence in the compact layout: copy moves to the document screen's toolbar and the notes pane offers both actions. In the regular layout the top toolbar gains the copy action alongside the existing export button, with copy leading. Every affected button carries help text and an accessibility label so the two actions are distinguishable.++**Terminology:** "compact layout" is the small view selected for compact horizontal size class (iPhone, compact-width iPad windows); "regular layout" is the big view (full-width iPad, macOS). "Copy" is the copy-all-notes-as-Markdown action; "export" is the full-document Share-with-Notes flow.++**Supersession note:** Removing the export button from the compact document screen ([1.2](#1.2)) ends the compact/regular Share-button placement parity introduced by T-138 (the shared-insertion-site contract described in `InlineNotesShareHelper` and its tests). The export *gate* requirements of `specs/export-with-inline-notes/` (8.3, 8.5, 10.1 — hidden without active anchored notes) are preserved unchanged; only the compact placement moves. See Decision 8.++## Out of Scope++- No user-configurable choice of which action is primary (the earlier Settings-toggle interpretation of T-1577 was discarded).+- No changes to what the actions produce or cost: the copied Markdown payload, the share-sheet flow, and the paywall / free-tier gating rules stay as they are — each copy or export still consumes one export credit (see Decision 9).+- No changes to the macOS File > Export… menu command.+- No changes to other copy affordances (code blocks, mermaid diagrams) — those are free actions and unrelated.+- No redesign of the notes pane or sidebar beyond the action buttons and visibility rules described below.+- No keyboard shortcuts for the new buttons (see [3.5](#3.5)); existing shortcuts inside the notes pane are untouched.++## Requirements++### 1. Copy as the primary action in the compact layout++**User Story:** As an iPhone user, I want to copy my notes directly from the document screen, so that the most frequent notes action no longer requires opening the notes pane.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHEN the copy action is available (per [Requirement 5](#5.1)), THEN the compact layout's document screen SHALL display a Copy notes button in the toolbar's primary-action area.+2. <a name="1.2"></a>The compact layout's document screen SHALL NOT display the export (Share with Notes) button.+3. <a name="1.3"></a>WHEN the document-screen Copy notes button is activated, THEN the system SHALL produce the same clipboard Markdown as the notes-pane copy action (same payload computation and export settings), run the same paywall gate, increment the export count exactly once, and show the same confirmation / free-tier nudge message text in a toast presented on the document screen.+4. <a name="1.4"></a>IF the copy action is unavailable (per [Requirement 5](#5.1)), THEN the Copy notes button SHALL be hidden; WHILE the button is visible and the purchase entitlement state is loading, it SHALL be disabled. Hidden takes precedence over disabled.++### 2. Both actions in the compact layout's notes pane++**User Story:** As an iPhone user, I want copy and export available inside the notes pane, so that export remains reachable after it leaves the document screen.++**Acceptance Criteria:**++1. <a name="2.1"></a>The notes pane SHALL offer both a Copy notes button and an export (Share with Notes) button, subject to the visibility rules in [2.3](#2.3).+2. <a name="2.2"></a>WHEN the notes-pane export button is activated, THEN the system SHALL run the full-document Share-with-Notes flow (username prompt when no username is set, share sheet, paywall gating, export counting, nudge toasts) — the same single share flow used by every other export button. *(Corrected 2026-07-02: an earlier draft contrasted this with a "notes-only share" in the regular sidebar; code verification showed no such flavour exists.)*+3. <a name="2.3"></a>The export button SHALL follow the export action's existing visibility rule (hidden when the document has no active anchored notes); the copy button SHALL follow [Requirement 5](#5.1).+4. <a name="2.4"></a>WHEN the paywall gate blocks a copy or export triggered from inside the notes pane, THEN on iOS the pane SHALL dismiss and the paywall SHALL present (matching the pane copy button's current behaviour), and after a completed purchase the blocked action SHALL retry via the existing pending-export mechanism.+5. <a name="2.5"></a>WHEN the notes-pane export requires the username prompt, THEN the prompt SHALL appear and function from within the pane context.++### 3. Both actions in the regular layout's top toolbar++**User Story:** As an iPad or Mac user, I want copy and export in the top toolbar, so that I can use either action without opening the notes sidebar.++**Acceptance Criteria:**++1. <a name="3.1"></a>The regular layout's top toolbar SHALL display a Copy notes button placed before (leading) the export button, within the same toolbar group.+2. <a name="3.2"></a>WHEN the toolbar Copy notes button is activated, THEN the system SHALL behave per the semantics of [1.3](#1.3), with the toast presented on the document screen.+3. <a name="3.3"></a>The toolbar Copy notes button SHALL follow [Requirement 5](#5.1) and the export button SHALL keep its existing visibility rule, so each appears independently of the other.+4. <a name="3.4"></a>The notes sidebar SHALL keep its add-note, copy, and (iOS-only) share buttons; the sidebar copy button's visibility SHALL follow [Requirement 5](#5.1) (normalising the current iOS-only `hasNotes` rule to the shared predicate), and the sidebar share button — being the same full-document Share-with-Notes flow — SHALL follow the export action's visibility rule (Decision 14).+5. <a name="3.5"></a>The toolbar Copy notes button SHALL NOT register a keyboard shortcut; Cmd+C SHALL remain text-selection copy.++### 4. Discoverability++**User Story:** As a user, I want each notes action labelled, so that I can tell copy and export apart before activating them.++**Acceptance Criteria:**++1. <a name="4.1"></a>Each button added or moved by this feature SHALL carry exactly these strings:++   | Button | Accessibility label | Help text |+   |---|---|---|+   | Compact document-screen copy | Copy notes | Copy all notes as Markdown |+   | Regular toolbar copy | Copy notes | Copy all notes as Markdown |+   | Notes-pane export | Share with Notes | Share with Notes |++2. <a name="4.2"></a>Help text SHALL be attached via the platform tooltip mechanism and accessibility labels via the platform accessibility API; whether a hover tooltip is rendered on iPad is OS behaviour and is not asserted.+3. <a name="4.3"></a>All user-visible strings for these buttons SHALL resolve through the localisation catalog, reusing existing keys where the wording is unchanged.++### 5. One copy-availability rule++**User Story:** As a user, I want the copy button to appear exactly when there is something to copy, so that its presence is predictable across screens.++**Acceptance Criteria:**++1. <a name="5.1"></a>The copy action SHALL be available if and only if at least one note would be included in the copy output under the current export settings (anchored, orphaned, imported, and document-level notes all count; the include-resolved setting is respected).+2. <a name="5.2"></a>This rule SHALL apply uniformly to every copy button surface: compact document screen, regular top toolbar, notes pane, and notes sidebar.+3. <a name="5.3"></a>WHEN the set of qualifying notes changes while a surface is visible (e.g. the last note is deleted or resolved), THEN the copy button's visibility SHALL update without requiring the surface to be reopened.
specs/notes-action-placement/tasks.md Added +123 / -0
diff --git a/specs/notes-action-placement/tasks.md b/specs/notes-action-placement/tasks.mdnew file mode 100644index 0000000..c90b111--- /dev/null+++ b/specs/notes-action-placement/tasks.md@@ -0,0 +1,123 @@+---+references:+    - requirements.md+    - design.md+    - decision_log.md+---+# Notes Action Placement++## Services & Flows++- [x] 1. Write failing tests for exporter nil-container fix and copy-availability predicate <!-- id:oz5y469 -->+  - New prismTests/NotesExporterPredicateTests.swift (or extend existing exporter tests); red phase for task 2+  - PBT: predicate/payload agreement over arbitrary populations (anchored/imported/orphaned/document-level x active/resolved, container present/nil) x both includeResolved values, per the ExportImportRoundTripPropertyTests precedent+  - Generated note bodies are unique non-empty markers asserted via containment, so the oracle cannot false-pass on empty or duplicated output+  - Example edges: imported-only emits notes plus display-name header; orphaned-only true; resolved-only flips with includeResolved; empty false+  - Reactivity edge: toggling includeResolved flips hasCopyableNotes for a resolved-only population (Req 5.3 settings dimension)+  - Stream: 1+  - Requirements: [5.1](requirements.md#5.1), [5.3](requirements.md#5.3)++- [x] 2. Implement NotesExporter fix, hasIncludableNotes, and NotesManager.hasCopyableNotes <!-- id:oz5y46a -->+  - NotesExporter.export: emit imported/orphaned notes when documentNotes is nil, header derived from the manager display name (Decision 12)+  - NotesExporter.hasIncludableNotes shares the exact collect/filter helpers export() uses; nil container no longer forces false+  - NotesManager.hasCopyableNotes(blocks:includeResolved:) passes the same merged anchored+imported map, orphaned list, and documentNotes as exportMarkdown+  - Cheap-path short-circuit (document sentinel, orphaned list before block scan) allowed; the PBT from task 1 keeps it honest+  - Blocked-by: oz5y469 (Write failing tests for exporter nil-container fix and copy-availability predicate)+  - Stream: 1+  - Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2)++- [x] 3. Make StoreManager.runGatedExport return the gate result <!-- id:oz5y46b -->+  - @discardableResult, returns ExportGateResult so callers branch on .blocked instead of re-reading showPaywall afterwards (design NEW-2)+  - Migrate the existing NotesPanel.copyNotes showPaywall read-back in passing; behaviour unchanged+  - Interface change exempt from test-first; existing StoreManager tests cover gate semantics+  - Stream: 2+  - Requirements: [2.4](requirements.md#2.4)++- [x] 4. Write failing tests for CopyNotesButton.perform <!-- id:oz5y46c -->+  - Red phase for task 5; stub store via KeyValueStoreProtocol+  - Allowed path: copies exportMarkdown payload with AppSettings values, increments export count exactly once, composes banner with and without nudge via the catalog format key+  - .blocked fires onBlocked and stores pendingExportAction; .loading no-ops silently+  - Blocked-by: oz5y46a (Implement NotesExporter fix, hasIncludableNotes, and NotesManager.hasCopyableNotes), oz5y46b (Make StoreManager.runGatedExport return the gate result)+  - Stream: 1+  - Requirements: [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [2.4](requirements.md#2.4)++- [x] 5. Implement CopyNotesButton, shared toolbar content helper, and catalog keys <!-- id:oz5y46d -->+  - prism/Views/CopyNotesButton.swift: view plus static perform(notesManager:blocks:settings:storeManager:onBanner:onBlocked:) as the testable entry point+  - Label doc.on.doc; accessibilityLabel Copy notes; help Copy all notes as Markdown; disabled while entitlementState == .loading; styles .toolbar/.header; no keyboardShortcut parameter (Req 3.5, dead macOS pane header)+  - @ToolbarContentBuilder helper wraps the if hasCopyableNotes conditional around the ToolbarItem with explicit .id, written once for both toolbar hosts+  - Localizable.xcstrings: add Notes copied and the composed-nudge format key (no string concatenation); run make test-locales+  - Blocked-by: oz5y46c (Write failing tests for CopyNotesButton.perform)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [5.2](requirements.md#5.2)++- [x] 6. Write failing tests for ExportNotesFlow.run <!-- id:oz5y46e -->+  - Red phase for task 7+  - Username unset reaches prompt state; blocked gate stores retry in pendingExportAction; share failure sets error state; nudge routes to coordinator banner+  - Regression case: retry after purchase with no username set reaches the prompt state even though the originating pane is gone (design M1)+  - Blocked-by: oz5y46b (Make StoreManager.runGatedExport return the gate result)+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5)++- [x] 7. Implement ExportNotesFlow and thin ExportWithNotesButton trigger <!-- id:oz5y46f -->+  - @Observable ExportNotesFlow owned by DocumentLayoutCoordinator: username prompt/validation/error state plus run(notesManager:session:settings:storeManager:)+  - ExportWithNotesButton becomes label+help+disabled trigger; onBlocked driven by the returned gate result; its @State alerts and self-hosted toast removed+  - DocumentReaderView hosts the flow alerts; retry closures reference flow.run, never view-local state+  - Blocked-by: oz5y46e (Write failing tests for ExportNotesFlow.run)+  - Stream: 2+  - Requirements: [2.2](requirements.md#2.2), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5)++## Surface Wiring++- [x] 8. Wire compact document screen: copy replaces export, banner migrates to coordinator <!-- id:oz5y46g -->+  - DocumentReaderView:150: replace .inlineNotesExportToolbar with the shared copy toolbar content (compact only)+  - Migrate exportNudgeMessage @State (:122/:361/:528) to the coordinator banner; the macOS File > Export nudge writer at :528 must still fire+  - Clear the banner in DocumentLayoutCoordinator.resetSessionState (stale-banner-on-document-switch window)+  - Also reset ExportNotesFlow in resetSessionState (pendingShareContext, prompt/validation/error flags, usernameInput): a username prompt left open across a document switch would otherwise share the previous session's notes (phase 1 review finding, Decision 15)+  - Delete InlineNotesExportToolbar modifier and inlineNotesExportToolbar extension; update the InlineNotesShareHelper doc comment (T-138 parity superseded, Decision 8)+  - Blocked-by: oz5y46d (Implement CopyNotesButton, shared toolbar content helper, and catalog keys), oz5y46f (Implement ExportNotesFlow and thin ExportWithNotesButton trigger)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [4.1](requirements.md#4.1)++- [x] 9. Wire regular toolbar: copy leading export in one group <!-- id:oz5y46h -->+  - RegularDocumentLayout ToolbarItemGroup at :503-508: copy leading export in the same group (Decision 6), explicit .id on both items against Liquid Glass identity churn+  - Toolbar struct gains a coordinator parameter for banner and flow access+  - No keyboard shortcut; Cmd+C stays selection copy (Req 3.5)+  - Blocked-by: oz5y46d (Implement CopyNotesButton, shared toolbar content helper, and catalog keys), oz5y46f (Implement ExportNotesFlow and thin ExportWithNotesButton trigger)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [4.1](requirements.md#4.1)++- [x] 10. Wire NotesPanel: session param, copy and export buttons, delete statics <!-- id:oz5y46i -->+  - Non-optional session: DocumentSession parameter; single call site CompactDocumentLayout:170 plus previews+  - iOS toolbar order: add-note, copy, export (copy leads, Decision 6); CopyNotesButton uses pane-local banner and onBlocked pane-dismiss on iOS (Req 2.4)+  - Export button gated on InlineNotesShareHelper.shouldShowShareButton (Req 2.3)+  - Retry-after-purchase banner routing: perform's stored retry reuses the captured onBanner, so a pane-originated retry banner would write to the dismissed pane's dead state; add a retry banner sink to CopyNotesButton.perform (retryOnBanner defaulting to onBanner) so the retry lands on the coordinator toast (Decision 15)+  - Delete copyNotes()/shouldShowActions/exportMarkdownPayload/showsShareAction statics; macOS header updated for consistency only (unreachable, its Cmd+C binding disappears)+  - Blocked-by: oz5y46d (Implement CopyNotesButton, shared toolbar content helper, and catalog keys), oz5y46f (Implement ExportNotesFlow and thin ExportWithNotesButton trigger)+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [4.1](requirements.md#4.1)++- [x] 11. Wire SidebarNotesView: shared copy button, share follows export rule <!-- id:oz5y46j -->+  - Copy button becomes CopyNotesButton (.header style) with sidebar-local banner+  - Share button becomes the thin ExportWithNotesButton trigger gated on the export rule (Decision 14) instead of pair-gating with copy+  - Delete copyNotes()/shareNotes()/performShare()/shouldShowActions/exportMarkdownPayload+  - Blocked-by: oz5y46d (Implement CopyNotesButton, shared toolbar content helper, and catalog keys), oz5y46f (Implement ExportNotesFlow and thin ExportWithNotesButton trigger)+  - Stream: 2+  - Requirements: [3.4](requirements.md#3.4), [5.2](requirements.md#5.2)++## Test Retargeting & Validation++- [x] 12. Retarget or delete superseded tests <!-- id:oz5y46k -->+  - InlineNotesExportToolbarTests: T-138 parity contract becomes the new placement contract (Decision 8)+  - SidebarNotesViewTests :480-527: shouldShowActions cases move to hasCopyableNotes; payload-parity test retargets the single exportMarkdown call site; share-visibility cases move to the export rule (phase 2 already deleted the tests that exclusively asserted removed statics - see the stream reports; write the replacement coverage here)+  - NotesPanelDocumentNotesTests :88-91 and any NotesPanel.showsShareAction assertions (pane-has-no-share is now false by design, Req 2.1)+  - Retire the now-unused copiedBannerMessage statics on NotesPanel and SidebarNotesView and the test asserting them (banner text now composes inside CopyNotesButton.perform)+  - Blocked-by: oz5y46g (Wire compact document screen: copy replaces export, banner migrates to coordinator), oz5y46h (Wire regular toolbar: copy leading export in one group), oz5y46i (Wire NotesPanel: session param, copy and export buttons, delete statics), oz5y46j (Wire SidebarNotesView: shared copy button, share follows export rule)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [3.4](requirements.md#3.4), [5.2](requirements.md#5.2)++- [x] 13. Full validation pass <!-- id:oz5y46l -->+  - make lint and make test-quick green; fix regressions surfaced by the suite (known pre-existing failures: SidebarNotesViewTests/groupByStructureImportedFirst and InlineNotesExportPerformanceTests, T-1457 family; confirm against main before chasing)+  - Grep confirms no references remain to InlineNotesExportToolbar, shouldShowActions, exportMarkdownPayload, or NotesPanel.showsShareAction. Exception: SidebarNotesView.showsShareAction is KEPT deliberately as the iOS-only share gate (Req 3.4, phase 2 review)+  - Record outstanding manual checks in the spec (not automatable): (1) iPad/iOS 26 toolbar animation when copy/export visibility flips independently (design testing strategy); (2) Req 2.5 - with no username set, trigger export from inside the notes pane on iOS and confirm the username prompt presents above the pane sheet; the flow alerts are hosted by DocumentReaderView UNDER the sheet, and SwiftUI may defer alert presentation from a covered context (phase 2 review finding). If the prompt does not appear, host the flow alerts on the pane sheet content as well+  - Blocked-by: oz5y46k (Retarget or delete superseded tests)+  - Stream: 1

Things to double-check

Req 2.5 — username prompt above the pane sheet (device-only).

The flow’s alerts are hosted by DocumentReaderView under the notes-pane sheet; SwiftUI may defer alert presentation from a covered context. With no export username set, trigger Share with Notes from inside the compact pane on iOS: the prompt must present above the sheet. Remedy if it doesn’t: attach exportNotesFlowAlerts(_:) to the pane sheet content as well. Recorded in design.md § Outstanding manual verification.

Req 3.3 — Liquid Glass toolbar grouping (device-only).

On iPad/iOS 26, flip copy and export visibility independently (resolve the last active anchored note while an orphaned note keeps copy alive) and confirm no flicker or slot-shift in the shared glass bubble. Explicit NotesToolbarItemIDs are in place; only the visual behaviour is unverified.

Parallel test runs are flaky by infrastructure, not by this branch.

The four locale-matrix test processes share UserDefaults.standard and the system pasteboard (tracked as T-1652); settings/clipboard-touching tests must be judged on serial runs (-parallel-testing-worker-count 1 -only-test-configuration "en (base)"). Serial result at this review: 110 tests, 1 known pre-existing failure (T-1457 family).