From 200fbce2762f49815a727395ef93195d29b64d82 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 10:22:41 -0400 Subject: [PATCH] =?UTF-8?q?The=20card=20window's=20=E2=8C=98V=20drops=20it?= =?UTF-8?q?s=20picture=20branch=20=E2=80=94=20the=20attachments=20header?= =?UTF-8?q?=20now=20offers=20one=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/CardWindowHost.swift | 54 +++++-- Kanban/App/ClipboardStore.swift | 31 +++- Kanban/UI/Board/CardFaceView.swift | 5 +- Kanban/UI/Board/ClipboardCommands.swift | 68 ++++++--- Kanban/UI/Card/CardAttachments.swift | 12 ++ Kanban/UI/Card/CardAttachmentsSection.swift | 33 +++- KanbanTests/CardAttachmentsTests.swift | 31 +++- KanbanTests/PasteImageTests.swift | 158 +++++++++++++++++++- 8 files changed, 343 insertions(+), 49 deletions(-) diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index b0c3de5..d3ed8c6 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -495,11 +495,12 @@ struct CardWindowHost: View { .onChange(of: placement.card.hero.value, initial: true) { _, hero in attachments.hero = hero } - // **⌘V in this window pastes onto this card** (04-interactions.md ▸ Clipboard's - // image-data and file-URL branches). Here rather than inside `CardWindowView` because the - // availability is the clipboard's observable reading and this is where a store, a card id - // and the app-wide clipboard are all in scope at once — the same join `configureAttachments` - // makes for the other two writes. + // **⌘V in this window pastes a Finder-copied file onto this card** (04-interactions.md ▸ + // Clipboard's file-URL branch; the image-data branch retired 2026-08-09 for the + // attachments header's paste-image affordance, wired below through `configureAttachments`). + // Here rather than inside `CardWindowView` because the availability is the clipboard's + // observable reading and this is where a store, a card id and the app-wide clipboard are + // all in scope at once — the same join `configureAttachments` makes for the other writes. .cardWindowPaste( store: store, cardID: placement.card.id, @@ -724,7 +725,9 @@ struct CardWindowHost: View { session.rawSourceIsActive = { [rawSource] in rawSource.isActive } session.rawSourceApply = { [rawSource] in rawSource.applyAndLeave() } session.rawSourceCancel = { [rawSource] in rawSource.cancel() } - Self.configureAttachments(attachments, store: store, cardID: cardID, undo: session.undo) + Self.configureAttachments( + attachments, store: store, cardID: cardID, undo: session.undo, clipboard: appModel.clipboard + ) Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) } @@ -827,17 +830,23 @@ struct CardWindowHost: View { } } - /// Points the attachments section at its card — **the one place Add Attachment… and Remove - /// learn which card they act on** (05-card-window.md ▸ Attachments). + /// Points the attachments section at its card — **the one place Add Attachment…, Remove and the + /// paste-image affordance learn which card they act on** (05-card-window.md ▸ Attachments). /// - /// Both seams are the store's own bracketed methods, unchanged: `importAttachments(_:toCard:)` - /// is the *same* call the board window's Finder drop makes, so a file added through ⇧⌘A, through - /// the header's plus, through a drop anywhere in this window, and through a drop on the card's - /// face on the board all take one path — one collision rename, one set of banners, one commit - /// shape. There is deliberately no card-window import of its own to keep in step with it. + /// Every seam is the store's or the clipboard's own bracketed method, unchanged: + /// `importAttachments(_:toCard:)` is the *same* call the board window's Finder drop makes, so a + /// file added through ⇧⌘A, through the header's plus, through a drop anywhere in this window, and + /// through a drop on the card's face on the board all take one path — one collision rename, one + /// set of banners, one commit shape. `pasteImage`/`canPasteImage` are `ClipboardStore + /// .pasteImage(intoCard:in:)`/`.canPasteImage(intoCard:in:)` the same way — the exact seam the + /// retired ⌘V image branch used, and the board's "Paste Image into Card" context-menu row still + /// uses (`CardFaceView`). There is deliberately no card-window import or paste of its own to keep + /// in step with either. /// - /// The store is captured **weakly**, `configureSession`'s rule: a panel still running after the - /// board window has gone should write nothing rather than resurrect a released store. + /// The store and the clipboard are captured **weakly**, `configureSession`'s rule: a panel or a + /// paste still running after the board window has gone should write nothing rather than + /// resurrect a released store — and the clipboard is app-wide and always outlives any one window + /// in practice, but the same discipline costs nothing to apply uniformly. /// /// `static`, and taking every collaborator as a parameter, for `configureRawSource`'s reason: /// the target resolution is invisible in a running window until it is wrong, and this shape is @@ -846,7 +855,8 @@ struct CardWindowHost: View { _ attachments: CardAttachments, store: BoardStore, cardID: ItemID, - undo: CardWindowUndo + undo: CardWindowUndo, + clipboard: ClipboardStore ) { attachments.importFiles = { [weak store] urls in store?.importAttachments(urls, toCard: cardID) @@ -860,6 +870,18 @@ struct CardWindowHost: View { attachments.setHeroFile = { [weak store] name in store?.setHero(name, onCard: cardID, on: undo) } + // **The retired ⌘V image branch's seam, now the header's control's** (04-interactions.md ▸ + // Clipboard, re-ruled 2026-08-09). No undo step here either, `setHeroFile`'s neighbor and an + // import's own reason: an attachment arrival is not on the stack (13-native-undo.md ▸ Out of + // scope). + attachments.pasteImage = { [weak store, weak clipboard] in + guard let store, let clipboard else { return } + clipboard.pasteImage(intoCard: cardID, in: store) + } + attachments.canPasteImage = { [weak store, weak clipboard] in + guard let store, let clipboard else { return false } + return CardPasteImageAffordance.isVisible(clipboard: clipboard, cardID: cardID, in: store) + } } /// Points the raw-source outlet at its card — the outlet's three seams (05-card-window.md ▸ Raw diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index 43917ec..85fa8de 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -48,9 +48,9 @@ import os /// /// `NSPasteboard.changeCount` is a machine-wide counter, so a value that moved without this store /// moving it means another app owns the pasteboard now. It is checked exactly where 04 says — menu -/// validation, app activation, and before every paste — and nowhere else. `payload` is observable -/// state rather than a computed pasteboard read precisely so the menu items' enablement re-evaluates -/// when it changes rather than whenever SwiftUI happens to rebuild them. +/// validation, app activation, before every paste, and a window becoming key — and nowhere else. +/// `payload` is observable state rather than a computed pasteboard read precisely so the menu items' +/// enablement re-evaluates when it changes rather than whenever SwiftUI happens to rebuild them. /// /// **"Menu validation" is two checkpoints here, not a hook**: the commands validate by conditional /// responder attachment (`ClipboardCommands` — availability *is* the handler's presence), which @@ -62,6 +62,15 @@ import os /// screenshot hotkey, ⌃⇧⌘4 — would leave ⌘V dead until the next app switch, which is the image /// branch's headline gesture failing in the exact case it was built for. Both checkpoints are one /// `changeCount` read in the common case, which is why they can afford to fire on every ⌘-chord. +/// +/// **A window becoming key is the fourth checkpoint** (widened 2026-08-09 for the card window's +/// paste-image affordance, `CardPasteImageAffordance`): a visible control, unlike a menu item, has to +/// read true continuously while its window is frontmost rather than only at the instant something +/// probes it. Switching between two already-open windows of this app — no menu opened, no ⌘ pressed +/// — crosses neither of the first two checkpoints and stays inside one app activation, so the third +/// checkpoint misses it too; `NSWindow.didBecomeKeyNotification` is the moment that is left. Fired +/// for every window rather than filtered to card windows, for the same cost reason: one more +/// `changeCount` read on an already-cheap path. @MainActor @Observable public final class ClipboardStore { @@ -196,6 +205,22 @@ public final class ClipboardStore { ) { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } }) + // **The fourth checkpoint** (type comment ▸ takeover, widened 2026-08-09 for the card + // window's paste-image affordance): any window becoming key. The first three checkpoints + // exist for *menu* validation, which only ever runs when something is about to fire — but a + // visible control has to read true continuously while its window is frontmost, not only at + // the moment a chord or a menu happens to probe it. Switching between two already-open + // windows of this app crosses neither of those moments and stays inside one activation, so it + // needed a trigger of its own. Every window, not just card windows: cheap (one `changeCount` + // read in the common case, `refresh()`'s own guarantee) and simpler than teaching this store + // which window kind is asking. + stalenessObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + }) commandKeyToken = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in // Local monitors run on the main thread, before the event reaches its window // (`LocalModifierFlipWatch`'s note) — and the event is returned unchanged, always, for diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 69edb2e..d0c5724 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -766,8 +766,9 @@ struct CardFaceView: View, Equatable { /// card I right-clicked, regardless of the live selection") turns out to be what was wanted. /// /// **Paste Image into Card is genuinely per-card** — "into card" is the row's own wording, and it - /// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own ⌘V - /// already calls for its attachment branch. It always targets **this** card, never the widened + /// is `ClipboardStore.pasteImage(intoCard:in:)`, the *same* method the card window's own + /// attachments-header affordance calls for its picture (the ⌘V image branch it once rode retired + /// 2026-08-09 for that visible control). It always targets **this** card, never the widened /// selection: like Open, Rename and Style's anchor, "single-card by nature" — the existing /// precedent this SCOPE asks new rows to follow for such rows. "… more tbd" is left as the SCOPE /// asks: one row today, the submenu built to grow. diff --git a/Kanban/UI/Board/ClipboardCommands.swift b/Kanban/UI/Board/ClipboardCommands.swift index e5aeb45..fa82780 100644 --- a/Kanban/UI/Board/ClipboardCommands.swift +++ b/Kanban/UI/Board/ClipboardCommands.swift @@ -79,17 +79,24 @@ extension View { extension View { - /// **⌘V in a card window pastes onto that card** (04-interactions.md ▸ Clipboard, the image-data - /// and file-URL branches; 05-card-window.md ▸ Attachments) — the file branch first, the picture - /// branch behind it, `pasteAction`'s own precedence one window over. + /// **⌘V in a card window pastes a Finder-copied file onto that card** (04-interactions.md ▸ + /// Clipboard, the file-URL branch; 05-card-window.md ▸ Attachments) — the board's own responder + /// shape, one window over. /// - /// The board's own responder shape, one window over and with two branches instead of three: there - /// is no board payload a card window could paste — cards and lanes land on a *board* — so the card - /// window answers `paste:` only for the two attachment branches, and only while one of them has - /// something to take. **One combined handler, not two `.onCommand`s for the same selector**: the - /// precedence has to be one expression for the same reason `pasteAction` is, and layering a second - /// responder over the same selector would leave the ordering to however SwiftUI happened to chain - /// them rather than to this file. + /// **The image-data branch retired from here** (re-ruled 2026-08-09 — "instead of a special ⌘V + /// handler at card window level, add a control that shows up when an image is detected in + /// pasteboard"): raw image data on this window's ⌘V is no longer implicitly an attachment. It + /// falls through to whatever the focused surface does natively — the body editor's own paste when + /// it can read the pasteboard, nothing at all when it cannot (`CardBodyTextView`'s capability + /// yield, unchanged; a screenshot with the editor focused is now a genuine no-op there). The + /// header's paste-image affordance is the one path left for the picture (`CardAttachments + /// .pasteImage`, wired in `CardWindowHost.configureAttachments`), and the board's own ⌘V and its + /// "Paste Image into Card" context-menu row are untouched — this file changes for the *card + /// window* only. + /// + /// There is no board payload a card window could paste — cards and lanes land on a *board* — so + /// the card window answers `paste:` only for the file branch, and only while it has something to + /// take. /// /// **A focused text field still wins, with nothing here doing the arithmetic.** `NSTextView` /// consumes `paste:` natively, so ⌘V in the body editor, the comment composer or an inline @@ -101,13 +108,21 @@ extension View { func cardWindowPaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View { onCommand( #selector(NSText.paste(_:)), - perform: Self.cardWindowPasteAction(store: store, cardID: cardID, clipboard: clipboard) + perform: CardWindowPasteRouting.action(store: store, cardID: cardID, clipboard: clipboard) ) } +} - /// The card window's own `pasteAction` — the file branch outranking the picture branch, exactly - /// as `refresh()` orders them. - private static func cardWindowPasteAction( +/// The card window's own `pasteAction` — one clause, now that the picture branch answers to a +/// control instead of to this selector (`CardPasteImageAffordance`, below). +/// +/// A free type rather than a `View` extension member, unlike `pasteAction` (above) staying where it +/// is: this composition is *the* regression surface for "does ⌘V still auto-attach a picture in a +/// card window", 04-interactions.md ▸ Clipboard's re-ruling, and a test needs to call it without a +/// throwaway `View` conformer standing in for one. +@MainActor +enum CardWindowPasteRouting { + static func action( store: BoardStore, cardID: ItemID, clipboard: ClipboardStore @@ -115,13 +130,32 @@ extension View { if clipboard.canPasteFiles(intoCard: cardID, in: store) { return { clipboard.pasteFiles(intoCard: cardID, in: store) } } - if clipboard.canPasteImage(intoCard: cardID, in: store) { - return { clipboard.pasteImage(intoCard: cardID, in: store) } - } return nil } } +// MARK: - The card window's paste-image affordance + +/// **The image-data branch's replacement**: a visible control rather than a silent keyboard shortcut +/// (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). The card window no longer answers `paste:` +/// for raw image data at all (`CardWindowPasteRouting`, above); the attachments section header's +/// button is the one path left for it (`CardAttachmentsSection`), and it takes exactly the seam the +/// retired branch took — `ClipboardStore.pasteImage(intoCard:in:)`, through `CardAttachments +/// .pasteImage`. +/// +/// **The pure seam the control's visibility and its paste share**: `ClipboardStore +/// .canPasteImage(intoCard:in:)` is already the whole predicate — the lock, the payload, and the +/// card being on the board side — so this is a named alias for it rather than a second reading that +/// could drift from what pressing the button actually does. Named on its own so a test can say what +/// it is asserting ("the affordance shows") without borrowing a name that means "the paste would +/// succeed", even though today the two answers are one call. +@MainActor +enum CardPasteImageAffordance { + static func isVisible(clipboard: ClipboardStore, cardID: ItemID, in store: BoardStore) -> Bool { + clipboard.canPasteImage(intoCard: cardID, in: store) + } +} + // MARK: - Edit ▸ Paste as Board Background /// **Edit ▸ Paste as Board Background** — the pasteboard's picture into the board folder, with diff --git a/Kanban/UI/Card/CardAttachments.swift b/Kanban/UI/Card/CardAttachments.swift index 00b8c72..829e343 100644 --- a/Kanban/UI/Card/CardAttachments.swift +++ b/Kanban/UI/Card/CardAttachments.swift @@ -76,6 +76,18 @@ public final class CardAttachments { /// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels). public var setHeroFile: ((String?) -> Void)? + /// **The header's paste-image affordance's write** — filled by the host with `ClipboardStore + /// .pasteImage(intoCard:in:)`, the same call the card window's own ⌘V used before the control + /// replaced it, and the board's "Paste Image into Card" context-menu row still uses + /// (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). One import path behind three pointers + /// at it now, none of them a second implementation. + public var pasteImage: (() -> Void)? + + /// **Whether the affordance shows at all** — filled by the host with `CardPasteImageAffordance + /// .isVisible(clipboard:cardID:in:)`, read fresh on every view evaluation rather than cached, so + /// the control's presence and `pasteImage()`'s success can never disagree. + public var canPasteImage: (() -> Bool)? + public init() {} // MARK: - Derived diff --git a/Kanban/UI/Card/CardAttachmentsSection.swift b/Kanban/UI/Card/CardAttachmentsSection.swift index 5993879..a50c1e2 100644 --- a/Kanban/UI/Card/CardAttachmentsSection.swift +++ b/Kanban/UI/Card/CardAttachmentsSection.swift @@ -80,7 +80,14 @@ struct CardAttachmentsSection: View { var body: some View { VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) { - CardSidebarSectionHeader(title: "Attachments") { addAffordance } + CardSidebarSectionHeader(title: "Attachments") { + HStack(spacing: 8) { + if attachments.canPasteImage?() == true { + pasteImageAffordance + } + addAffordance + } + } if names.isEmpty { emptyHint @@ -96,6 +103,30 @@ struct CardAttachmentsSection: View { // MARK: - Header + /// **The paste-image affordance** — the ⌘V image-data branch's replacement (04-interactions.md ▸ + /// Clipboard, re-ruled 2026-08-09): present only while the pasteboard holds a picture this card + /// could take, rather than a permanent row that would spend most of its life disabled. Its + /// visibility and its tap read and call the exact same seam, `CardAttachments.canPasteImage`/ + /// `.pasteImage` — the header's own `CardPasteImageAffordance.isVisible` — so the button can never + /// promise a paste it does not deliver. + /// + /// **Present-or-absent, the hero rows' own posture** (below): a disabled picture-paste icon + /// sitting in this header the other 99% of the time a card window is open would be furniture + /// nobody reads, where "it showed up" is the whole point of the control replacing a keyboard + /// shortcut nobody could see. + private var pasteImageAffordance: some View { + Button { + attachments.pasteImage?() + } label: { + Image(systemName: "photo.badge.plus") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Paste the copied picture as an attachment") + .accessibilityLabel("Paste Image as Attachment") + } + /// The header's **quiet add affordance** — "a pointer twin of File ▸ Add Attachment…, no /// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row /// does rather than opening a panel of its own. diff --git a/KanbanTests/CardAttachmentsTests.swift b/KanbanTests/CardAttachmentsTests.swift index 9a006c7..b3e0116 100644 --- a/KanbanTests/CardAttachmentsTests.swift +++ b/KanbanTests/CardAttachmentsTests.swift @@ -66,6 +66,19 @@ private func purgeFromTrash(_ url: URL?) -> Bool { return true } +/// A `ClipboardStore` for the tests below that only need to satisfy `configureAttachments`'s +/// paste-image seam — the affordance itself is `PasteImageTests`' subject, not this file's. A +/// `FakePasteboard` (`ClipboardTests.swift`'s double, never the machine's) and a throwaway staging +/// directory the caller tears down alongside its board fixture. +@MainActor +private func makeScratchClipboard() throws -> (clipboard: ClipboardStore, staging: URL) { + let staging = FileManager.default.temporaryDirectory + .appendingPathComponent("CardAttachmentsTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + let clipboard = ClipboardStore(pasteboard: FakePasteboard(), stagingRoot: staging, observesActivation: false) + return (clipboard, staging) +} + /// The same, by name, for the paths that do not hand the resulting URL back (the store's). @discardableResult private func purgeFromTrash(named name: String) -> Bool { @@ -246,9 +259,13 @@ struct AddAttachmentTargetTests { defer { sources.tearDown() } let shot = try sources.file("shot.png", Data([0x89, 0x50])) let store = try BoardStore(rootURL: fixture.root) + let (clipboard, staging) = try makeScratchClipboard() + defer { try? FileManager.default.removeItem(at: staging) } let attachments = CardAttachments() - CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo()) + CardWindowHost.configureAttachments( + attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard + ) attachments.importFiles?([shot]) #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50])) @@ -264,9 +281,13 @@ struct AddAttachmentTargetTests { try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01])) try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/\(doomed)", Data([0x02])) let store = try BoardStore(rootURL: fixture.root) + let (clipboard, staging) = try makeScratchClipboard() + defer { try? FileManager.default.removeItem(at: staging) } let attachments = CardAttachments() - CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo()) + CardWindowHost.configureAttachments( + attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard + ) attachments.removeFile?(doomed) #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty) @@ -283,9 +304,13 @@ struct AddAttachmentTargetTests { defer { fixture.tearDown() } try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01])) let store = try BoardStore(rootURL: fixture.root) + let (clipboard, staging) = try makeScratchClipboard() + defer { try? FileManager.default.removeItem(at: staging) } let attachments = CardAttachments() - CardWindowHost.configureAttachments(attachments, store: store, cardID: card1, undo: CardWindowUndo()) + CardWindowHost.configureAttachments( + attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard + ) attachments.cardFolder = cardFolder(fixture) attachments.names = ["shot.png"] diff --git a/KanbanTests/PasteImageTests.swift b/KanbanTests/PasteImageTests.swift index f082073..c381c46 100644 --- a/KanbanTests/PasteImageTests.swift +++ b/KanbanTests/PasteImageTests.swift @@ -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