The Paste row says what it is about to do — one card, one lane, or a count of either

Both context menus (`CardFaceView`'s pointer menu and VoiceOver twin, `LaneView`'s own pair)
twin a plain "Paste" row over `ClipboardStore.paste(into:)`. It now reads the clipboard's own
manifest and titles itself "Paste Card" / "Paste Lane" for a single copied item, "Paste N Cards" /
"Paste N Lanes" for several, and stays plain "Paste" for no app payload — a Finder file copy, a
screenshot, anything else the paste command still accepts but never decodes to a
`ClipboardManifest`.

`ClipboardManifest.pasteMenuTitle(for:)` is the one pure function both menus call — `kind` and
`entries.count` are the whole of it, since `SelectionKind` is singular by construction
(`SelectionGrammar.mixesKinds` refuses a mixed copy), so there is no mixed shape to compose a
plural for. `CardFaceView.pasteTitle` rides the exact Observable read `pasteEnabled` already makes
(`appModel.clipboard.payload`) — no new subscription on a builder that is not lazy.
`LaneView.pasteTitle` reads the same field directly rather than through `canPaste(into:)`, since
this view is already unconditionally subscribed to selection/snapshot and there is no reduction to
preserve.

Edit ▸ Paste on the menu bar stays plain — it is a responder attached to the platform's own Edit
menu row (`ClipboardCommands.boardClipboardCommands`), not a `Button` this app titles, and the
card's scope is context menus only.

Flagged for a follow-up: DESIGN/11-command-nexus.md's Card and Lane rows (lines 110-111) describe
Paste generically and could note the dynamic title.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 12:24:54 -04:00
parent 3180e565e8
commit 1817d6f0b9
4 changed files with 111 additions and 4 deletions
+37
View File
@@ -161,6 +161,43 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
entries.map { $0.title ?? "Untitled" }.joined(separator: "\n") entries.map { $0.title ?? "Untitled" }.joined(separator: "\n")
} }
// MARK: - Paste menu title
/// **The context-menu Paste row's title** (Pipeline card 36cce96a "the paste option should show
/// 'Paste Card' or 'Paste Lane' or 'Paste N Cards' depending on what's in pasteboard"): "Paste
/// Card"/"Paste Lane" for one entry, "Paste N Cards"/"Paste N Lanes" for several, plain "Paste"
/// for no app payload at all. One pure function of the manifest, called verbatim by both context
/// menus (`CardFaceView.pasteTitle`, `LaneView.pasteTitle`) a title composed twice is a title
/// that drifts twice.
///
/// **`kind` and `entries.count` are the whole of it** exactly the two fields the type comment
/// already promises are the selection's own vocabulary and its recorded order, so this reads no
/// more of the manifest than that. There is no *mixed* shape to compose a plural for: `kind` is
/// singular by construction `SelectionGrammar.mixesKinds` refuses a selection that would produce
/// one, so `ClipboardStore.capture` never writes a manifest naming both cards and lanes. Every
/// manifest this app ever produces is cards-only or lanes-only, which is what makes the
/// card-family/lane-family split exhaustive rather than a case among others.
///
/// **`nil` is the caller's to pass, not this function's to look up.** Both menus already hold
/// `payload` for their own `.disabled` reads (`CardFaceView.pasteEnabled`, `LaneView.pasteEnabled`
/// via `canPaste(into:)`), so a caller with no app payload passes `nil` rather than this function
/// reaching for a store it has no seam to. Answering plain "Paste" here is also the whole of
/// **foreign pasteboard content's** title: a Finder file copy or a screenshot that the paste
/// command still accepts (`ClipboardStore.canPasteFiles`/`canPasteImage`) never decodes to a
/// `ClipboardManifest` in the first place, so it never reaches any branch but this one.
///
/// **An empty `entries` answers plain "Paste" too**, though nothing reachable ever produces one:
/// `init?(data:)` refuses a decoded manifest with no entries, and `ClipboardStore.capture` refuses
/// an empty selection before a manifest is ever built. The clause exists so this function has no
/// partial case, not because the branch is live.
public static func pasteMenuTitle(for payload: ClipboardManifest?) -> String {
guard let payload, !payload.entries.isEmpty else { return "Paste" }
let count = payload.entries.count
let noun = payload.kind == .card ? "Card" : "Lane"
guard count > 1 else { return "Paste \(noun)" }
return "Paste \(count) \(noun)s"
}
// MARK: Coding // MARK: Coding
public func encoded() -> Data? { public func encoded() -> Data? {
+18 -2
View File
@@ -897,6 +897,12 @@ struct CardFaceView: View, Equatable {
/// which, again, this very face rendering already rules out. Reduces to `!isReadOnly && /// which, again, this very face rendering already rules out. Reduces to `!isReadOnly &&
/// imagePayload != nil` (`pasteImageEnabled`). /// imagePayload != nil` (`pasteImageEnabled`).
/// ///
/// **The Paste row's dynamic title (`pasteTitle`, 2026-08-09 card 36cce96a) rides the same
/// channel `pasteEnabled` already opened** rather than adding one: it reads
/// `appModel.clipboard.payload` again, the identical Observable property, and hands it to
/// `ClipboardManifest.pasteMenuTitle(for:)` a pure function of a value already in hand, not a
/// second store read. No new subscription, no selection or snapshot touched.
///
/// Copy and Cut lean on the same style of proof: `targetIDs` is always non-empty (at minimum this /// Copy and Cut lean on the same style of proof: `targetIDs` is always non-empty (at minimum this
/// card alone) and always homogeneous cards on the board side (`SelectionGrammar.mixesKinds` /// card alone) and always homogeneous cards on the board side (`SelectionGrammar.mixesKinds`
/// answers `true` only for the trash see its own doc comment), so `canCopy`/`canCut`'s /// answers `true` only for the trash see its own doc comment), so `canCopy`/`canCut`'s
@@ -940,7 +946,7 @@ struct CardFaceView: View, Equatable {
.disabled(!copyEnabled) .disabled(!copyEnabled)
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
Button("Paste") { appModel.clipboard.paste(into: store) } Button(pasteTitle) { appModel.clipboard.paste(into: store) }
.disabled(!pasteEnabled) .disabled(!pasteEnabled)
Menu("Copy Special") { Menu("Copy Special") {
Button("Copy Link") { copyLink() } Button("Copy Link") { copyLink() }
@@ -1094,7 +1100,7 @@ struct CardFaceView: View, Equatable {
.disabled(!copyEnabled) .disabled(!copyEnabled)
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
Button("Paste") { appModel.clipboard.paste(into: store) } Button(pasteTitle) { appModel.clipboard.paste(into: store) }
.disabled(!pasteEnabled) .disabled(!pasteEnabled)
Button("Send to Trash") { deleteTargets() } Button("Send to Trash") { deleteTargets() }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
@@ -1249,6 +1255,16 @@ struct CardFaceView: View, Equatable {
store.acceptsBoardMutations && appModel.clipboard.payload != nil store.acceptsBoardMutations && appModel.clipboard.payload != nil
} }
/// **The Paste row's title** "Paste Card"/"Paste N Cards" and their lane twins, plain "Paste"
/// for anything else (`ClipboardManifest.pasteMenuTitle(for:)`'s own doc comment carries the full
/// rule). Reads exactly the same `appModel.clipboard.payload` `pasteEnabled` already reads above
/// the render-safety proof this file's type comment states for `pasteEnabled` covers this property
/// too, since it is the identical Observable read, not a second one: no new subscription, no
/// `store.selection`/`store.snapshot` touched.
private var pasteTitle: String {
ClipboardManifest.pasteMenuTitle(for: appModel.clipboard.payload)
}
/// Paste Image into Card's enablement, `pasteEnabled`'s own reduction: `canPasteImage(intoCard:in:)` /// Paste Image into Card's enablement, `pasteEnabled`'s own reduction: `canPasteImage(intoCard:in:)`
/// only ever answers `false` on its snapshot lookup for a card that is not a live board item, which /// only ever answers `false` on its snapshot lookup for a card that is not a live board item, which
/// this face rendering at all already rules out see the type comment's "Render-safety" section. /// this face rendering at all already rules out see the type comment's "Render-safety" section.
+14 -2
View File
@@ -664,7 +664,7 @@ struct LaneView: View, Equatable {
.disabled(!copyEnabled) .disabled(!copyEnabled)
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
.disabled(!cutEnabled) .disabled(!cutEnabled)
Button("Paste") { appModel.clipboard.paste(into: store) } Button(pasteTitle) { appModel.clipboard.paste(into: store) }
.disabled(!pasteEnabled) .disabled(!pasteEnabled)
Divider() Divider()
@@ -706,7 +706,7 @@ struct LaneView: View, Equatable {
.disabled(!copyEnabled) .disabled(!copyEnabled)
Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) } Button("Cut") { appModel.clipboard.cut(from: store, targeting: clipboardTarget) }
.disabled(!cutEnabled) .disabled(!cutEnabled)
Button("Paste") { appModel.clipboard.paste(into: store) } Button(pasteTitle) { appModel.clipboard.paste(into: store) }
.disabled(!pasteEnabled) .disabled(!pasteEnabled)
Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) } Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) }
.disabled(!store.acceptsBoardMutations) .disabled(!store.acceptsBoardMutations)
@@ -838,6 +838,18 @@ struct LaneView: View, Equatable {
appModel.clipboard.canPaste(into: store) appModel.clipboard.canPaste(into: store)
} }
/// **The Paste row's title** `ClipboardManifest.pasteMenuTitle(for:)`'s own doc comment carries
/// the full rule ("Paste Card"/"Paste N Cards" and their lane twins, plain "Paste" otherwise),
/// shared verbatim with `CardFaceView.pasteTitle`. Reads `appModel.clipboard.payload` directly
/// rather than routing through `canPaste(into:)` as `pasteEnabled` does above: this body is already
/// unconditionally subscribed to `store.selection`/`store.snapshot` (`laneMenu`'s own
/// "Render-safety" section), so there is no render-safety reduction to preserve here and no reason
/// to read the payload a second time through a predicate that also checks board mutation and a
/// target this only ever needs the payload itself.
private var pasteTitle: String {
ClipboardManifest.pasteMenuTitle(for: appModel.clipboard.payload)
}
private var headerContent: some View { private var headerContent: some View {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) { HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane)) Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
+42
View File
@@ -287,6 +287,48 @@ struct ClipboardManifestTests {
) )
#expect(manifest.plainText == "First\nUntitled") #expect(manifest.plainText == "First\nUntitled")
} }
// MARK: - pasteMenuTitle
private func manifest(kind: SelectionKind, entryCount: Int) -> ClipboardManifest {
ClipboardManifest(
copyID: "abc",
boardRoot: URL(fileURLWithPath: "/tmp/B", isDirectory: true),
kind: kind,
container: .board,
entries: (0..<entryCount).map { entry("item-\($0)") }
)
}
@Test("No app payload titles the row plain 'Paste'")
func pasteMenuTitleNoPayload() {
#expect(ClipboardManifest.pasteMenuTitle(for: nil) == "Paste")
}
@Test("An entryless manifest — unreachable in practice, but defensive — titles the row plain 'Paste'")
func pasteMenuTitleEmptyEntries() {
#expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .card, entryCount: 0)) == "Paste")
}
@Test("One copied card titles the row 'Paste Card'")
func pasteMenuTitleSingleCard() {
#expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .card, entryCount: 1)) == "Paste Card")
}
@Test("Several copied cards title the row 'Paste N Cards'")
func pasteMenuTitleMultipleCards() {
#expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .card, entryCount: 3)) == "Paste 3 Cards")
}
@Test("One copied lane titles the row 'Paste Lane'")
func pasteMenuTitleSingleLane() {
#expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .lane, entryCount: 1)) == "Paste Lane")
}
@Test("Several copied lanes title the row 'Paste N Lanes'")
func pasteMenuTitleMultipleLanes() {
#expect(ClipboardManifest.pasteMenuTitle(for: manifest(kind: .lane, entryCount: 2)) == "Paste 2 Lanes")
}
} }
// MARK: - Copy and staging // MARK: - Copy and staging