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
+38 -16
View File
@@ -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
+28 -3
View File
@@ -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
+3 -2
View File
@@ -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.
+51 -17
View File
@@ -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
+12
View File
@@ -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
+32 -1
View File
@@ -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.