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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user