The card window's ⌘V drops its picture branch — the attachments header now offers one instead

Raw image data on a card window's ⌘V was a keyboard shortcut with no visible
trigger; the sidebar's Attachments header now grows a quiet control — beside
the existing add affordance, present only while the pasteboard holds a
picture this card could take — that pastes it through the exact seam the
retired branch used (ClipboardStore.pasteImage(intoCard:in:), the board's
"Paste Image into Card" row's own call). The file-URL branch stays on ⌘V; a
Finder copy is still unambiguous. CardBodyTextView's paste-yield mechanism
needed no change at all — it forwards by capability, not by picture-specific
logic, so a screenshot ⌘V with the body editor focused is now a genuine
no-op there, served by the new control instead.

The pasteboard's re-read gains a fourth checkpoint — a window becoming key —
alongside menu-tracking, ⌘-down and app activation: a persistent visible
control has to read true continuously while its window is frontmost, not
only at the instant a menu or chord probes it.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 10:37:55 -04:00
parent 546fd2840f
commit 200fbce276
8 changed files with 343 additions and 49 deletions
+151 -7
View File
@@ -547,17 +547,154 @@ struct SetAsHeroTests {
/// ever crossed or the id captured from the wrong place.
@Test("The attachment row's seam writes the window's own card")
func theRowsSeam() throws {
let fixture = try makeClipboardBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: store, cardID: clipboardCard1, undo: CardWindowUndo()
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
attachments.setHeroFile?("photo.png")
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: fixture).value == "photo.png")
#expect(try heroKey(of: Ident.card1, lane: Ident.lane1, in: harness.fixture).value == "photo.png")
}
}
// MARK: - The card window's paste-image affordance
/// **The header's control, wired through `configureAttachments`** replaces the retired V
/// image-data branch (04-interactions.md Clipboard, re-ruled 2026-08-09). Driven through the real
/// wiring, `theRowsSeam`'s own reason: the test breaks if the seam is ever crossed or the id captured
/// from the wrong place.
@MainActor
@Suite("Paste ▸ the card window's paste-image affordance")
struct PasteImageAffordanceTests {
private func attachments(_ card: String, in fixture: WriterFixture) throws -> [String] {
let model = try BoardLoader.load(boardRoot: fixture.root).model
for lane in model.lanes {
if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments }
}
return []
}
@Test("The button's write lands the pasteboard's picture on the window's own card")
func theButtonsWrite() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: harness.store, cardID: clipboardCard4, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
#expect(attachments.canPasteImage?() == true)
attachments.pasteImage?()
#expect(try self.attachments(Ident.card4, in: harness.fixture) == ["Pasted Image.png"])
}
@Test("The button's visibility clears once the pasteboard no longer offers a picture")
func visibilityFollowsThePasteboard() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
#expect(attachments.canPasteImage?() == false, "nothing on the pasteboard yet")
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
#expect(attachments.canPasteImage?() == true)
// A Finder-copied file outranks the picture riding beside it the same precedence V's own
// file branch reads (`PastedImage.flavor`'s clause order) so the button goes quiet exactly
// where the file branch lights up instead.
harness.pasteboard.seedFileURLs(
[URL(fileURLWithPath: "/tmp/shot.png")], also: [(UTType.png.identifier, encodedImage(.png))]
)
harness.clipboard.refresh()
#expect(attachments.canPasteImage?() == false, "a file URL wins the precedence")
}
@Test("The button is absent under the read-only lock")
func lockedBoardsOfferNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
harness.store.enterVanishedRootLock()
let attachments = CardAttachments()
CardWindowHost.configureAttachments(
attachments, store: harness.store, cardID: clipboardCard1, undo: CardWindowUndo(),
clipboard: harness.clipboard
)
#expect(attachments.canPasteImage?() == false)
}
}
// MARK: - The card window's V routing, image data retired
/// **`CardWindowPasteRouting.action` keeps one clause** (04-interactions.md Clipboard, re-ruled
/// 2026-08-09: "instead of a special V handler at card window level add a control"). Driven
/// directly rather than through `canPasteFiles`/`canPasteImage` on their own, because those two
/// predicates staying correct says nothing about whether the *composition* still offers the retired
/// branch which is exactly the regression this suite exists to catch.
@MainActor
@Suite("Paste ▸ the card window's ⌘V, image data retired")
struct CardWindowPasteRoutingTests {
private func attachmentNames(_ card: String, in fixture: WriterFixture) throws -> [String] {
let model = try BoardLoader.load(boardRoot: fixture.root).model
for lane in model.lanes {
if let match = lane.cards.first(where: { $0.id.rawValue == card }) { return match.attachments }
}
return []
}
@Test("Raw image data alone offers the card window's ⌘V nothing")
func imageDataAloneOffersNothing() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
harness.pasteboard.seed([(UTType.png.identifier, encodedImage(.png))])
harness.clipboard.refresh()
#expect(
harness.clipboard.canPasteImage(intoCard: clipboardCard4, in: harness.store),
"the affordance would still show"
)
let action = CardWindowPasteRouting.action(
store: harness.store, cardID: clipboardCard4, clipboard: harness.clipboard
)
#expect(action == nil)
#expect(try attachmentNames(Ident.card4, in: harness.fixture).isEmpty)
}
@Test("A Finder-copied file still routes through the card window's ⌘V")
func fileURLsStillRoute() throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let source = try WriterFixture()
defer { source.tearDown() }
let shot = try source.file("shot.png", Data([0x89, 0x50]))
harness.pasteboard.seedFileURLs([shot])
harness.clipboard.refresh()
let action = CardWindowPasteRouting.action(
store: harness.store, cardID: clipboardCard4, clipboard: harness.clipboard
)
#expect(action != nil)
action?()
#expect(try attachmentNames(Ident.card4, in: harness.fixture) == ["shot.png"])
}
}
@@ -803,8 +940,15 @@ private final class PasteCatcher: NSView {
}
/// **`CardBodyTextView` yields a paste it cannot read** (05-card-window.md Attachments, ruled
/// 2026-08-09) the screenshot pasteboard reaches the window's attachment branch even while the
/// editor holds the keyboard, and a text paste never leaves the editor.
/// 2026-08-09) a screenshot pasteboard reaches *whatever responds behind the editor* even while
/// the editor holds the keyboard, and a text paste never leaves the editor. The mechanism is
/// capability-based (`readablePasteboardTypes`), not a picture-specific rule, which is exactly what
/// lets `CardWindowPasteRouting.action`'s image branch retire without touching this file at all
/// (re-ruled 2026-08-09 04-interactions.md Clipboard): in the real app today nothing answers
/// `paste:` behind the editor for an image-only pasteboard any more, so a screenshot V with the
/// body editor focused is a no-op, served instead by the attachments header's control
/// (`CardPasteImageAffordance`). `PasteCatcher` here stands for "something behind the editor still
/// takes it" in the general case, which is the claim these tests actually pin.
///
/// The pasteboard is a private named one through the view's `yieldPasteboard` seam, so the suite
/// never reads the machine's except through `super.paste`, which is AppKit's own and is exactly