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
+10
View File
@@ -128,6 +128,16 @@ enum AccessibilityPhrases {
/// the dim is the sighted signal, this is the other one.
static let cutPending = "cut, pending paste"
/// **The attachment row that is the card's hero** (05-card-window.md Attachments; ruled
/// 2026-08-09) carried as the row's *value*, beside its filename label.
///
/// The same reasoning `cutPending` states one field over: a sighted user sees the banner on the
/// card face and knows which file made it, and without this the sidebar gives a VoiceOver user no
/// way to tell one of five images from the other four. It is also what makes the row's own
/// context menu legible "Remove Hero" appearing on exactly one row is otherwise a menu that
/// changes for no announced reason.
static let heroAttachment = "hero image"
/// A card element's value the attachment count and the comment count when the card has either
/// (design ruling 2026-08-09, card e729e30a the comments chip's face value gains "N comments"
/// beside the existing attachment wording), the cut-pending phrase when it is staged for paste,
+88 -3
View File
@@ -45,9 +45,94 @@ extension View {
.onCommand(#selector(NSText.copy(_:)), perform: clipboard.canCopy(from: store) ? {
clipboard.copy(from: store)
} : nil)
.onCommand(#selector(NSText.paste(_:)), perform: clipboard.canPaste(into: store) ? {
clipboard.paste(into: store)
} : nil)
.onCommand(#selector(NSText.paste(_:)), perform: Self.pasteAction(store: store, clipboard: clipboard))
}
/// **V's two branches as one optional handler** (04-interactions.md Clipboard, the image-data
/// branch ruled 2026-08-09).
///
/// The precedence is expressed as the order of these two `if`s and nowhere else, which is the
/// same discipline the rest of this file states: availability *is* the handler's presence, so a
/// board payload winning over a picture is one expression rather than a condition on one item and
/// a matching negation on another. `ClipboardStore.refresh` has already made the two readings
/// mutually exclusive at the source (`imagePayload` is `nil` whenever a board payload is
/// readable), so this ordering is belt over braces but it is the ordering a reader will look
/// for, and stating it here costs one line.
///
/// `nil` neither branch applies greys the standard Paste row out exactly as before.
private static func pasteAction(store: BoardStore, clipboard: ClipboardStore) -> (() -> Void)? {
if clipboard.canPaste(into: store) {
return { clipboard.paste(into: store) }
}
if clipboard.canPasteImage(into: store) {
return { clipboard.pasteImage(into: store) }
}
return nil
}
}
// MARK: - The card window's V
extension View {
/// **V in a card window pastes a picture into that card** (04-interactions.md Clipboard, the
/// image-data branch; 05-card-window.md Attachments).
///
/// The board's own responder shape, one window over and with one branch instead of two: 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 image branch, and only while there is a picture 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
/// comment edit stays a text paste and this responder never sees the selector which is the
/// board's own "focused-editor rule, twice over" arriving in the window where it matters most.
/// It is also why this hangs on the window's whole content rather than on the attachments
/// section: 05 makes the *window* the drop surface for files, and the paste is that sentence's
/// keyboard twin.
func cardWindowImagePaste(store: BoardStore, cardID: ItemID, clipboard: ClipboardStore) -> some View {
onCommand(
#selector(NSText.paste(_:)),
perform: clipboard.canPasteImage(intoCard: cardID, in: store) ? {
clipboard.pasteImage(intoCard: cardID, in: store)
} : nil
)
}
}
// MARK: - Edit Paste as Board Background
/// **Edit Paste as Board Background** the pasteboard's picture into the board folder, with
/// `background.image` pointed at it (03-board-ui.md § Styling Capabilities; ruled 2026-08-09).
///
/// ### Why this is a row of its own rather than another V branch
///
/// V has a target: the anchor card, or the card window's card. A board's backdrop is not on that
/// path at all it is one value per board, reachable with nothing selected so folding it into the
/// paste selector would mean either a modifier nobody could discover or V meaning two different
/// things depending on the selection. A named row says what it does, and the Nexus's "no default
/// chord" posture covers the rest: it remaps like any other item.
///
/// **Board window only**, which needs no clause: the row reads `\.boardStore`, and a card window or
/// the welcome window in front means there is no focused board store and the item is disabled.
///
/// The title is API (04-interactions.md Configurable bindings) and is unique across the menu bar.
struct PasteBoardBackgroundCommand: View {
let clipboard: ClipboardStore
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Paste as Board Background") {
guard let store else { return }
clipboard.pasteBoardBackground(into: store)
}
.disabled(!isEnabled)
}
private var isEnabled: Bool {
guard let store else { return false }
return clipboard.canPasteBoardBackground(into: store)
}
}
+19
View File
@@ -60,6 +60,25 @@ enum PasteTarget {
return Cards(laneID: lane.id, index: index)
}
/// **Which card an image paste lands on** the anchor card, and only a card (04-interactions.md
/// Clipboard, the image-data branch ruled 2026-08-09: "paste targets a card per the existing
/// paste-target grammar").
///
/// `flattenAnchor` again rather than a rule of its own, because the ruling says "the existing
/// paste-target grammar" and this is it: the last selected card in flatten order, which is
/// already what a card payload anchors after and what N creates after.
///
/// **`nil` wherever the anchor is not a card**, which is the whole of the difference from
/// `cards(selection:lastActiveLaneID:snapshot:)` and the reason there is no last-active-lane
/// fallback here. A board payload can *append to a lane*, so "nothing selected" still has an
/// answer; a picture has to land in some card's `attachments/`, and there is no card the app
/// could pick without inventing one. So an empty selection, a lane selection and a trash
/// selection all answer `nil`, the menu greys out, and the user selects a card rather than a
/// screenshot silently arriving on whichever card the app guessed at.
static func card(selection: ItemReferenceSet, snapshot: BoardModel) -> ItemID? {
NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot)?.anchorCardID
}
/// The lane payload's slot among the board's live lanes **always an answer**, zero-lane board
/// included, because lane paste "stays enabled and lands at the board's right end" whatever the
/// board holds. That is what makes it the other way out of a board with no lanes.
+138
View File
@@ -48,6 +48,15 @@ public final class CardAttachments {
/// stabler identity than an index across a reload that inserted a file above it.
public var selected: String?
/// **The file this card's `hero` key names**, republished from the snapshot exactly as `names`
/// is (`Card.hero`) `nil` for a card with no hero, and for one whose key is malformed, which is
/// the same "no band" the face renders (03-board-ui.md § Card face Hero image).
///
/// Here rather than derived in the section for `names`' reason: the sidebar must not be a second
/// reading of the card able to disagree with the board face's. It drives one thing only which
/// of the two hero rows a row's context menu offers.
public var hero: String?
/// Whether the section currently holds keyboard focus. Read by File Reveal in Finder, whose
/// card-window scope is "the card's folder the selected attachment's file instead when the
/// attachments section is focused" (11-command-nexus.md).
@@ -62,6 +71,11 @@ public final class CardAttachments {
/// .removeAttachment(named:fromCard:)`.
public var removeFile: ((String) -> Void)?
/// Points the card's `hero` key at a file, or removes it filled by the host with `BoardStore
/// .setHero(_:onCard:on:)`, this window's undo stack attached, so the step lands on the stack the
/// gesture was issued on (13-native-undo.md Rules two levels).
public var setHeroFile: ((String?) -> Void)?
public init() {}
// MARK: - Derived
@@ -105,6 +119,58 @@ public final class CardAttachments {
removeFile?(name)
}
/// **Set as Hero** the row's file becomes the card's banner picture (05 Attachments, ruled
/// 2026-08-09; 03-board-ui.md § Card face Hero image).
public func setAsHero(_ name: String) {
guard Self.canSetHero(name, hero: hero, names: names, isEditable: isEditable) else { return }
setHeroFile?(name)
}
/// **Remove Hero** the key goes, the file stays. Removing the *hero* is not removing the
/// attachment: the picture is still one of the card's files and is still in the list, which is
/// what keeps this row distinct from the Remove one sitting below it.
public func removeHero() {
guard isEditable, hero != nil else { return }
setHeroFile?(nil)
}
// MARK: - The hero rows' rules
/// Whether a row offers **Set as Hero** the row is an image, the section can write, the file is
/// actually in the listing, and the card's hero is not already this very file.
///
/// **Image-type only** (`PastedImage.isImageName`), because the key means a picture: offering the
/// row on a `.zip` would let a user set a hero that can never draw, and 03's structural degrade
/// would leave them with a key and no band and nothing to explain it.
///
/// **Absent, not disabled, on the current hero's row**: that row shows Remove Hero instead, which
/// is the same slot saying the true thing. Everywhere else "Set as Hero" *replaces* whatever hero
/// the card had one hero per card, and a Remove-then-Set dance would be ceremony (see
/// `BoardStore.setHero(_:onCard:on:)`).
///
/// A pure static for `moved`/`settle`'s reason: the menu's two branches become lines of test
/// rather than a context menu somebody has to open.
public nonisolated static func canSetHero(
_ name: String,
hero: String?,
names: [String],
isEditable: Bool
) -> Bool {
guard isEditable, names.contains(name), hero != name else { return false }
return PastedImage.isImageName(name)
}
/// Whether a row offers **Remove Hero** it is the card's current hero, and the section can
/// write. The image test is deliberately *not* repeated: a hero somebody hand-wrote to a
/// non-image file is exactly the state this row exists to get out of.
public nonisolated static func canRemoveHero(
_ name: String,
hero: String?,
isEditable: Bool
) -> Bool {
isEditable && hero == name
}
// MARK: - Row actions that are not writes
/// Double-click, Return, and the context menu's Open: the file's default app (05 Attachments).
@@ -238,6 +304,78 @@ struct AddAttachmentCommand: View {
}
}
// MARK: - File Set as Hero / Remove Hero
/// **The attachment row's hero pair, as menu rows** card window only, acting on the attachments
/// section's *selected* row (11-command-nexus.md; 05-card-window.md Attachments; 03-board-ui.md
/// § Card face Hero image).
///
/// ### Why the menu rows exist at all
///
/// 11's context-menu contract: "Every entry is a twin of a menu command, a fixed grammar key, or a
/// configuration control **no function's only home**". The row's pointer path is the context menu;
/// these are its required twins, and they are also what makes the gesture keyboard-reachable in a
/// section 05 went out of its way to make keyboard-native.
///
/// ### Two rows, not one row with two titles
///
/// **Titles are API** (04-interactions.md Configurable bindings) a user's custom binding is
/// stored against the title so a single row that renamed itself would silently drop that binding
/// every time the selection moved. Two rows is `Collapse Lane`/`Expand Lane`'s answer to the same
/// shape, and for the same reason. In the *context* menu the two share one slot, because a context
/// menu is built fresh per row and carries no bindings.
///
/// The subject is the **selected** row rather than a row under a pointer, which is what a menu-bar
/// item can address at all File Reveal in Finder's card-window scope reads the same selection.
struct SetAsHeroCommand: View {
@FocusedValue(\.cardAttachments) private var attachments
/// The row's validation as a value a test can hold `AddAttachmentCommand.isEnabled`'s shape,
/// for its reason. It is `CardAttachments.canSetHero` applied to the selected row, so the menu
/// row and the context row can never disagree about what "an image that is not already the hero"
/// means.
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
guard let attachments, let selected = attachments.selected else { return false }
return CardAttachments.canSetHero(
selected,
hero: attachments.hero,
names: attachments.names,
isEditable: attachments.isEditable
)
}
var body: some View {
Button("Set as Hero") {
guard let attachments, let selected = attachments.selected else { return }
attachments.setAsHero(selected)
}
.disabled(!Self.isEnabled(attachments))
}
}
/// Set as Hero's other direction see it for why the pair is two rows.
struct RemoveHeroCommand: View {
@FocusedValue(\.cardAttachments) private var attachments
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
guard let attachments, let selected = attachments.selected else { return false }
return CardAttachments.canRemoveHero(
selected,
hero: attachments.hero,
isEditable: attachments.isEditable
)
}
var body: some View {
Button("Remove Hero") {
attachments?.removeHero()
}
.disabled(!Self.isEnabled(attachments))
}
}
// MARK: - The focused value
/// The focused card window's attachments section, beside `FocusedValues.cardBody` see
+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 }