A picture off the pasteboard becomes a card's file, its hero, or the board's backdrop

⌘V grows an image-data branch, below the app's own clipboard format and refused
outright while a file URL is on the pasteboard: a screenshot or a browser's Copy
Image lands as "Pasted Image.png" in the anchor card's attachments/, through the
very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style
collision ladder, one set of banners, and the same silence a drop's arrival has.
A card window's ⌘V pastes onto its own card; a focused text field still wins the
selector natively. A file-shaped flavor travels byte for byte, PNG preferred when
several are offered; TIFF and BMP are re-encoded to PNG, being interchange
encodings rather than files anyone wants in a folder.

The hero key gets the setter it was born owing: "Set as Hero" on any image row of
the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins
so the context entry is nobody's only home. It writes as a restyle — one key, one
bracket, one invertible step on the window's own stack — and replaces rather than
refusing, because a card has one hero and the row that has it says Remove instead.

Edit ▸ Paste as Board Background is the same payload's other destination, taking
the existing background.image convention at its word: the picture into the board
folder as "Pasted Background.png", the colour subkey untouched, the generator's
overwrite-our-own-name rule inherited and its echo memo taught to tell the two
producers apart.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 02:43:53 -04:00
parent b18f7ca609
commit ca5d45156b
21 changed files with 1792 additions and 42 deletions
+40 -4
View File
@@ -159,6 +159,7 @@ struct CardAttachmentsSection: View {
name: name,
url: url,
isSelected: isSelected,
isHero: attachments.hero == name,
isSectionFocused: isFocused,
thumbnails: thumbnails,
pointSize: pointSize,
@@ -194,17 +195,48 @@ struct CardAttachmentsSection: View {
.contextMenu { menu(for: name) }
}
/// The attachment row's context menu **Open, Reveal in Finder, Remove** (11-command-nexus.md
/// Context menus), twins of the focused section's grammar keys (Return / ) and of File Reveal
/// in Finder in its attachments-focused context. No new store method, no parallel
/// implementation: every row here calls exactly what the keyboard calls.
/// The attachment row's context menu **Open, Reveal in Finder, the hero row, Remove**
/// (11-command-nexus.md Context menus), twins of the focused section's grammar keys (Return /
/// ) and of File Reveal in Finder in its attachments-focused context. No new store method, no
/// parallel implementation: every row here calls exactly what the keyboard calls.
///
/// It acts on **its own row**, not on the selection, which is what makes a right-click on an
/// unselected row unambiguous without a select-first dance.
///
/// ### The hero row is one slot with two words
///
/// **"Set as Hero"** on any image row that is not already the hero, **"Remove Hero"** on the one
/// that is, and nothing at all on a row that can be neither a non-image file, or any row while
/// the board is locked (03-board-ui.md § Card face Hero image; the rules themselves are
/// `CardAttachments.canSetHero`/`canRemoveHero`, so the menu and the tests read one answer).
///
/// **Present-or-absent rather than enabled-or-disabled**, which is the opposite of Remove just
/// below it and the difference is what the row would *mean* greyed out. A disabled Remove says
/// "this file cannot be removed right now", which is true and useful under the lock. A disabled
/// "Set as Hero" on a `.zip` would say "this file could be the hero, but not now", which is not
/// true and never will be. The lock case follows the row rather than splitting it: a menu whose
/// hero slot appears and disappears by file type and *also* greys by lock would be two rules
/// where the section has one.
///
/// It sits between the read-only pair and Remove, on the divider grammar the Board menu uses
/// (`KanbanApp`): reads first, then the edit-shaped rows, with the destructive one last.
@ViewBuilder
private func menu(for name: String) -> some View {
Button("Open") { attachments.open(name) }
Button("Reveal in Finder") { attachments.reveal(name) }
if CardAttachments.canSetHero(
name, hero: attachments.hero, names: names, isEditable: attachments.isEditable
) {
Divider()
Button("Set as Hero") { attachments.setAsHero(name) }
} else if CardAttachments.canRemoveHero(
name, hero: attachments.hero, isEditable: attachments.isEditable
) {
Divider()
Button("Remove Hero") { attachments.removeHero() }
}
Divider()
Button("Remove") { attachments.remove(name) }
.disabled(!attachments.isEditable)
@@ -249,6 +281,9 @@ private struct AttachmentRow: View {
let name: String
let url: URL?
let isSelected: Bool
/// Whether this row's file is the card's hero spoken, and nothing more: the row draws no badge
/// of its own, because the banner *is* the sighted signal and it is right there on the card face.
let isHero: Bool
let isSectionFocused: Bool
let thumbnails: AttachmentThumbnailCache
let pointSize: CGFloat
@@ -279,6 +314,7 @@ private struct AttachmentRow: View {
.help(name)
.accessibilityElement(children: .combine)
.accessibilityLabel(name)
.accessibilityValue(isHero ? AccessibilityPhrases.heroAttachment : "")
.accessibilityAddTraits(isSelected ? .isSelected : [])
.task(id: url?.path) {
guard let slot, let url else { return }