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
+30 -2
View File
@@ -474,6 +474,23 @@ struct CardWindowHost: View {
.onChange(of: placement.card.attachments, initial: true) { _, names in
attachments.names = names
}
// **The hero is the snapshot's too**, and republished the same way, so the row that
// offers "Remove Hero" is the row the card face is actually banding from (05
// Attachments; 03-board-ui.md § Card face Hero image). A malformed key reads as no
// hero, which is exactly what the face draws.
.onChange(of: placement.card.hero.value, initial: true) { _, hero in
attachments.hero = hero
}
// **V in this window pastes a picture onto this card** (04-interactions.md Clipboard's
// image-data branch). 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.
.cardWindowImagePaste(
store: store,
cardID: placement.card.id,
clipboard: appModel.clipboard
)
.onChange(of: Self.cardFolder(root: store.rootURL, placement: placement), initial: true) { _, folder in
// Re-derived from the store's *current* root, `cardFolder`'s rule: a mid-session
// folder rename moves the board, and rows resolving against where it used to be
@@ -693,7 +710,7 @@ 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)
Self.configureAttachments(attachments, store: store, cardID: cardID, undo: session.undo)
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
}
@@ -811,13 +828,24 @@ struct CardWindowHost: View {
/// `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
/// what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureAttachments(_ attachments: CardAttachments, store: BoardStore, cardID: ItemID) {
static func configureAttachments(
_ attachments: CardAttachments,
store: BoardStore,
cardID: ItemID,
undo: CardWindowUndo
) {
attachments.importFiles = { [weak store] urls in
store?.importAttachments(urls, toCard: cardID)
}
attachments.removeFile = { [weak store] name in
store?.removeAttachment(named: name, fromCard: cardID)
}
// **The window's own stack**, like every other gesture issued in this window
// (13-native-undo.md Rules two levels): Set as Hero is a restyle, and a restyle made here
// folds into this session's close step rather than landing on the board's.
attachments.setHeroFile = { [weak store] name in
store?.setHero(name, onCard: cardID, on: undo)
}
}
/// Points the raw-source outlet at its card the outlet's three seams (05-card-window.md Raw
+27
View File
@@ -201,6 +201,20 @@ public protocol ClipboardPasteboard: AnyObject {
/// resulting `changeCount`.
@discardableResult
func write(manifest: Data, text: String) -> Int
/// **Every type identifier the pasteboard currently carries** what the image-data branch
/// classifies (04-interactions.md Clipboard, ruled 2026-08-09; `PastedImage.flavor`).
///
/// A list rather than a set of yes/no questions, because the *rule* is a decision over a list and
/// belongs in one place: adding a flavor to `PastedImage.verbatimTypes` must not also mean adding
/// a method here. It subsumes `manifestData()`'s question too, and does not replace it the
/// manifest is read as bytes and decoded, which a type list cannot answer.
func availableTypes() -> [String]
/// The bytes under one type, whatever it is the general read behind `manifestData()`'s
/// specific one, added for the image branch (which knows its type only at runtime, off the
/// classification above).
func data(forType type: String) -> Data?
}
/// The real pasteboard.
@@ -226,6 +240,19 @@ public final class SystemPasteboard: ClipboardPasteboard {
pasteboard.data(forType: Self.type)
}
/// `NSPasteboard.types` the **first item's** types, which is what "the clipboard's payload"
/// means for every producer this branch cares about: a screenshot, a browser's Copy Image, a
/// Finder copy, and this app's own write are all single-item writes. A multi-item pasteboard's
/// later items are deliberately not consulted; pasting the second image of a five-image copy is a
/// gesture nobody has asked for and would need a target grammar of its own.
public func availableTypes() -> [String] {
(pasteboard.types ?? []).map(\.rawValue)
}
public func data(forType type: String) -> Data? {
pasteboard.data(forType: NSPasteboard.PasteboardType(type))
}
@discardableResult
public func write(manifest: Data, text: String) -> Int {
pasteboard.clearContents()
+168
View File
@@ -66,6 +66,21 @@ public final class ClipboardStore {
/// happened to invalidate the menu.
public private(set) var payload: ClipboardManifest?
/// **The image-data branch's reading of the same pasteboard**, as of the same `refresh()`
/// `nil` when there is no picture to paste, when a board payload outranks one, or when the
/// pasteboard carries file URLs (04-interactions.md Clipboard, ruled 2026-08-09;
/// `PastedImage.flavor(hasBoardItems:types:)` holds the precedence and this holds its answer).
///
/// Observed beside `payload` and refreshed in the same breath, for `payload`'s exact reason: the
/// three surfaces that turn on it the board's V fallback, the card window's V, and Edit
/// Paste as Board Background are menu-validated, and a computed pasteboard read would leave
/// every one of them stale until something else happened to rebuild the menu.
///
/// **Two readings, never two reads**: one `refresh()` reads the pasteboard once and fills both,
/// which is what makes "a board payload wins" a property of the code rather than an ordering two
/// call sites have to remember.
public private(set) var imagePayload: PastedImage.Flavor?
/// The staging directory public because the tests assert on what it holds after a copy, a
/// paste and a sweep, exactly as `BoardRegistry.storageURL` is public for its tests.
@ObservationIgnored public let stagingRoot: URL
@@ -271,6 +286,153 @@ public final class ClipboardStore {
}
}
// MARK: - Paste the image-data branch
// **V's fallback, not a second command** (04-interactions.md Clipboard, ruled 2026-08-09).
// A pasteboard carrying raw image data and no file URL pastes the picture into a card's
// `attachments/` the screenshot, the browser's Copy Image, Preview's C. Everything about it
// is deliberately the *existing* machinery seen from one branch over:
//
// - **The precedence is `refresh()`'s**, which fills `payload` and `imagePayload` from one read
// and can therefore never let a picture divert a board paste.
// - **The write is `BoardStore.importAttachments(_:toCard:)`** the same call a Finder file drop
// and the card window's A make. That is what buys the Finder-style collision rename, the
// `performWrite` bracket (one app-mediated reload, the read-only lock, the banner on failure),
// the echo ledger's receipt, and the staging rules, without a second import path in the app to
// keep in step with the first.
// - **The name is minted by writing a temp file** rather than by asking the Writer for a free
// name and then writing under it: `importFiles` takes source URLs and climbs its own ladder, so
// handing it a file already called "Pasted Image.png" is how a paste gets Finder's answer
// rather than a second implementation of it.
// - **It registers no undo step**, exactly like every other attachment arrival (13-native-undo.md
// Out of scope: "attachment add/remove registers no undo step in v1"). A paste that landed a
// file is an import, and imports are not on the stack half a pair would be worse than none.
// - **It announces exactly as a file drop does**, which is to say the arrival is silent: the
// write is app-mediated, so the reload it produces carries receipts and the announcer's ladder
// is quiet by construction (10-accessibility.md "app-mediated echoes never do"). What the
// user gets is what a drop gives them: the row appearing in the attachments section and the
// card face's chip counting one higher.
/// Whether V would paste a picture into `store`'s **anchor card** the board window's branch.
///
/// Three clauses. The board accepts board mutations (the lock and the focused-editor rule, exactly
/// as `canPaste(into:)` reads them); there is a picture on the pasteboard; and the selection
/// anchors a *card*, because an attachment belongs to one. A lane selection, an empty selection
/// and a trash selection all anchor no card and therefore offer nothing here which is
/// `PasteTarget.card`'s answer, so the item's availability and the paste's own refusal are the
/// same expression.
public func canPasteImage(into store: BoardStore) -> Bool {
guard store.acceptsBoardMutations, imagePayload != nil else { return false }
return PasteTarget.card(selection: store.selection, snapshot: store.snapshot) != nil
}
/// V's image branch on the board resolves the anchor card and pastes into it.
@discardableResult
public func pasteImage(into store: BoardStore) -> Bool {
refresh()
guard canPasteImage(into: store),
let cardID = PasteTarget.card(selection: store.selection, snapshot: store.snapshot)
else { return false }
return pasteImage(intoCard: cardID, in: store)
}
/// Whether V would paste a picture into this **named** card the card window's branch, where
/// the target is the window's own card rather than a selection's anchor.
///
/// The lock clause is `!store.isReadOnly` rather than `acceptsBoardMutations`, which is
/// `CardAttachments.isEditable`'s reading and the right one here: the focused-editor half of
/// `acceptsBoardMutations` is about *this board window's* inline title editor, and a card window
/// has no business going dead because a board window behind it is mid-rename. A focused text
/// field in the card window still wins V natively, which is the rule that actually matters here
/// and needs no arithmetic (`ClipboardCommands`' focused-editor note).
///
/// The card must be on the **board side**: `BoardStore.importAttachments` refuses a trashed card
/// outright, and offering a row that would no-op is exactly what the codebase's named predicates
/// exist to prevent.
public func canPasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
guard !store.isReadOnly, imagePayload != nil else { return false }
return BoardStore.boardItem(cardID, in: store.snapshot)?.cardID != nil
}
/// Pastes the pasteboard's picture into `cardID`'s `attachments/`.
///
/// - Returns: whether a file was handed to the import path. `false` is every way this can decline
/// no picture, a card that is not there, a pasteboard that declared a type it could not back
/// up, or a temp file that would not write and every one of them writes nothing at all.
@discardableResult
public func pasteImage(intoCard cardID: ItemID, in store: BoardStore) -> Bool {
refresh()
guard canPasteImage(intoCard: cardID, in: store), let flavor = imagePayload else { return false }
guard let raw = pasteboard.data(forType: flavor.type),
let bytes = PastedImage.encode(raw, as: flavor)
else {
// The pasteboard named a flavor it cannot produce, or produced bytes that are not an
// image. Nothing is written and nothing is said: the honest outcome of a pasteboard that
// lied is the one where the card is untouched.
Self.logger.debug("image paste declined — the declared flavor produced no usable bytes")
return false
}
guard let staged = Self.stageForImport(bytes, named: flavor.fileName) else { return false }
defer { try? FileManager.default.removeItem(at: staged.deletingLastPathComponent()) }
store.importAttachments([staged], toCard: cardID)
return true
}
/// Writes `bytes` to a private temp folder under `name`, and answers the file's URL.
///
/// **A folder per paste, not a shared scratch directory**: the file has to carry the exact name
/// the import ladder will start from ("Pasted Image.png"), so two pastes in flight would collide
/// on it and the folder is what the caller removes afterwards, which is one `removeItem`
/// instead of a file plus whatever else ended up beside it.
///
/// The app's own container temp directory, so this needs no sandbox grant and no bookmark: the
/// bytes came off the pasteboard, they are going into the board the app already holds, and this
/// is the few milliseconds in between.
private static func stageForImport(_ bytes: Data, named name: String) -> URL? {
let folder = FileManager.default.temporaryDirectory
.appendingPathComponent("PastedImage-\(UUID().uuidString)", isDirectory: true)
guard (try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)) != nil
else { return nil }
let url = folder.appendingPathComponent(name)
guard (try? bytes.write(to: url)) != nil else {
try? FileManager.default.removeItem(at: folder)
return nil
}
return url
}
// MARK: - Paste the board backdrop
/// Whether Edit Paste as Board Background applies to `store` (03-board-ui.md § Styling
/// Capabilities; the `background` mapping's `image` subkey).
///
/// Two clauses and no third: a board that accepts mutations, and a picture on the pasteboard.
/// There is no target to resolve a board has exactly one backdrop which is what makes this
/// the one image-paste surface that stays live on a zero-lane board.
public func canPasteBoardBackground(into store: BoardStore) -> Bool {
store.acceptsBoardMutations && imagePayload != nil
}
/// Edit Paste as Board Background the picture into the board folder, `background.image`
/// pointed at it.
///
/// The bytes are prepared exactly as the attachment branch's are (same classification, same
/// format rule) and then handed to `BoardStore.applyPastedBackground(data:fileExtension:)`, which
/// owns the naming, the one bracket and the undo step. Nothing about the *file* is decided here.
@discardableResult
public func pasteBoardBackground(into store: BoardStore) -> Bool {
refresh()
guard canPasteBoardBackground(into: store), let flavor = imagePayload else { return false }
guard let raw = pasteboard.data(forType: flavor.type),
let bytes = PastedImage.encode(raw, as: flavor)
else {
Self.logger.debug("background paste declined — the declared flavor produced no usable bytes")
return false
}
return store.applyPastedBackground(data: bytes, fileExtension: flavor.fileExtension)
}
// MARK: - Paste
/// Where this paste is going, resolved **now** from the selection as it stands when V is
@@ -467,6 +629,12 @@ public final class ClipboardStore {
guard count != lastChangeCount else { return }
lastChangeCount = count
payload = pasteboard.manifestData().flatMap(ClipboardManifest.init(data:))
// The image branch's whole precedence, applied here so it is applied once: a board payload
// outranks a picture, and a file URL means this is not the image branch's pasteboard at all.
imagePayload = PastedImage.flavor(
hasBoardItems: payload != nil,
types: pasteboard.availableTypes()
)
if let cut = armedCut, payload?.copyID != cut.copyID {
voidCut()
}
+210
View File
@@ -0,0 +1,210 @@
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
import os
// MARK: - PastedImage
/// **A picture on the pasteboard, as a file this app could write** (04-interactions.md Clipboard,
/// the image-data branch ruled 2026-08-09) which flavor to take, what the landed file is called,
/// and whether the bytes travel verbatim or are re-encoded on the way.
///
/// ### Why this is a pure rule with no pasteboard in it
///
/// `NewCardTarget` and `PasteTarget`'s reason, one layer over: every clause below is a *decision*
/// about a list of type identifiers, and a decision that can be a value function should be one the
/// menu item's `disabled`, the paste's own refusal, and the tests all read the same answer instead of
/// three hand-kept-in-sync conditions. The bytes are fetched by whoever owns the pasteboard seam
/// (`ClipboardStore`); nothing here touches `NSPasteboard`.
///
/// ### The precedence, which is the whole of the classification
///
/// 1. **The app's own clipboard type wins outright.** A Lanework copy on the pasteboard is a board
/// payload and pastes as cards or lanes exactly as it always did an image flavor riding beside
/// it (there is none today, but a future manifest could carry a preview) must never divert V.
/// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes
/// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is
/// the ruling's own parenthesis. A file URL is a *reference* to something the user already has
/// filed; taking a second copy of it into `attachments/` behind their back is a different gesture
/// with a different answer, and this branch declines rather than guessing at it.
/// 3. **Raw image data is the fallback**, which is the screenshot (4), the browser's Copy Image,
/// and Preview's C.
///
/// ### The format rule
///
/// **A file-shaped flavor is kept byte for byte** PNG, JPEG, GIF, HEIC, WebP. These are the
/// encodings a file on disk is already written in, so re-encoding one would cost either fidelity (a
/// JPEG round-tripped through PNG is bigger *and* still carries the original's artefacts) or the
/// picture itself (a GIF's animation does not survive a single-frame decode). The extension is that
/// type's own preferred one, so the landed file opens in Preview and QuickLooks in the sidebar with
/// no ceremony.
///
/// **Everything else is re-encoded to PNG** in practice `public.tiff`, AppKit's lossless
/// interchange flavor, which is what a screenshot and a Preview copy put down beside their PNG and
/// what a great many apps offer *instead* of one. TIFF is an interchange encoding rather than a file
/// people want sitting in a card's folder: a 5K screenshot is tens of megabytes as TIFF and about a
/// tenth of that as PNG, both lossless. `.bmp` rides the same branch for the same reason.
///
/// The preference order is PNG first and TIFF last, so the overwhelmingly common paste a screenshot
/// offering `public.png` and `public.tiff` together lands as the PNG it already is, with no decode
/// and no re-encode at all.
public enum PastedImage {
// MARK: The name
/// **The name a pasted image lands under** Finder's own shape for a file that arrives with no
/// name of its own ("Pasted Image.png", then "Pasted Image 2.png", ).
///
/// Only the stem is here: the extension is the flavor's (`Flavor.fileExtension`), and the
/// collision ladder is `BoardWriter.freshName`'s, reached by handing the import path a temporary
/// file with this name on it. Nothing in this app climbs a second ladder "Finder-style rename
/// on collision is one rule wherever the app has to find a free name".
public static let baseName = "Pasted Image"
/// **The board backdrop's own stem** the same rule one level up (03-board-ui.md § Styling
/// Capabilities), so a pasted background is as recognizable in a board folder as a pasted
/// attachment is in a card's.
///
/// Its own constant rather than `baseName` reused: the two files land in different folders for
/// different reasons, and a board folder holding something called "Pasted Image.png" would say
/// nothing about what it is for. `FacetsGenerator.fileName` is the sibling this is modeled on.
public static let backgroundBaseName = "Pasted Background"
// MARK: The flavor
/// One readable image payload on the pasteboard: where to read it from, what to write, and what
/// to call it.
public struct Flavor: Equatable, Sendable {
/// The pasteboard type identifier the bytes come from.
public let type: String
/// The landed file's extension the flavor's own for a verbatim write, `png` for a
/// converted one.
public let fileExtension: String
/// Whether the bytes are re-encoded on the way (see the type comment's format rule).
public let convertsToPNG: Bool
/// The file name a paste of this flavor mints, before the Finder ladder ever sees it.
public var fileName: String { "\(PastedImage.baseName).\(fileExtension)" }
/// The board-backdrop file name for the same flavor.
public var backgroundFileName: String { "\(PastedImage.backgroundBaseName).\(fileExtension)" }
}
// MARK: Classification
/// The flavors kept verbatim, **in this app's preference order** not the pasteboard's, which
/// is the *owner's* ranking of what it thinks a taker wants and has no idea a file is about to
/// be written.
///
/// PNG leads because it is lossless, universally readable, and the flavor a screenshot already
/// carries. JPEG follows so a photograph copied out of a browser lands as the JPEG it is rather
/// than as a PNG several times its size. GIF, HEIC and WebP are here so that a source offering
/// only one of them is still a paste rather than a refusal.
public static let verbatimTypes: [UTType] = [.png, .jpeg, .gif, .heic, .webP]
/// The flavors re-encoded to PNG the interchange bitmaps (see the type comment).
public static let convertedTypes: [UTType] = [.tiff, .bmp]
/// What this pasteboard offers the image branch, or `nil` when the branch does not apply.
///
/// - Parameter hasBoardItems: whether the app's own clipboard type is present and readable
/// clause 1 of the precedence. Passed in rather than read here because deciding *that* is
/// `ClipboardManifest`'s job and this type has no pasteboard.
/// - Parameter types: every type identifier the pasteboard currently carries.
public static func flavor(hasBoardItems: Bool, types: [String]) -> Flavor? {
guard !hasBoardItems, !carriesFileURL(types) else { return nil }
let offered = Set(types)
for type in verbatimTypes where offered.contains(type.identifier) {
// A registered type with no preferred extension is not something a file can be named
// after; skipping it lets the ladder fall through to the converted branch rather than
// minting "Pasted Image." with nothing after the dot.
guard let ext = type.preferredFilenameExtension else { continue }
return Flavor(type: type.identifier, fileExtension: ext, convertsToPNG: false)
}
for type in convertedTypes where offered.contains(type.identifier) {
return Flavor(type: type.identifier, fileExtension: "png", convertsToPNG: true)
}
return nil
}
/// Whether the pasteboard is carrying a file reference clause 2 of the precedence.
///
/// Conformance rather than equality with `public.file-url`, for `FinderDrop.isDirectory
/// (typeIdentifiers:)`'s reason: a source is free to declare a subtype of it, and the rule is
/// about what the value *is*. A type the system does not know is not a file URL, which is the
/// same optimistic reading a drag's unknown types get.
public static func carriesFileURL(_ types: [String]) -> Bool {
types.contains { UTType($0)?.conforms(to: .fileURL) ?? false }
}
// MARK: The bytes
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "paste-image")
/// The bytes this flavor writes, given what the pasteboard handed over `raw` itself for a
/// verbatim flavor, a PNG re-encoding for a converted one, and `nil` when the payload turns out
/// not to be a decodable image at all.
///
/// A `nil` here is the honest refusal: the pasteboard *declared* a type it cannot back up, and
/// writing an unreadable file into somebody's card folder is worse than doing nothing. The
/// caller treats it exactly as it treats an empty pasteboard.
public static func encode(_ raw: Data, as flavor: Flavor) -> Data? {
guard flavor.convertsToPNG else { return raw.isEmpty ? nil : raw }
return pngData(from: raw)
}
/// A bitmap payload re-encoded as PNG, through ImageIO.
///
/// **ImageIO rather than `NSBitmapImageRep`**, which is the same call `BoardBackdrop.decode`
/// makes and for the same reasons: it is the framework that actually owns the codecs, it is
/// `Sendable`-clean and main-actor-free, and it needs no AppKit image cache in the middle. The
/// full image is decoded rather than a thumbnail this is a *conversion*, and downsampling a
/// picture the user pasted would silently cost them resolution they never agreed to lose.
///
/// Alpha survives, because a PNG destination writing a CGImage with an alpha channel keeps it
/// which matters for exactly the payload this branch sees most, a screenshot of a rounded window.
public static func pngData(from data: Data) -> Data? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let image = CGImageSourceCreateImageAtIndex(
source, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary
)
else {
logger.debug("pasteboard bitmap could not be decoded")
return nil
}
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
logger.debug("pasteboard bitmap could not be re-encoded as PNG")
return nil
}
return output as Data
}
// MARK: Which attachments can be a hero
/// Whether an attachment named `name` is one **"Set as Hero" may point at** an image, by its
/// name's own extension (05-card-window.md Attachments; 03-board-ui.md § Card face Hero
/// image).
///
/// **By extension rather than by opening the file**, deliberately. The context menu is built
/// while the pointer is going down on a row, and the sidebar may be showing a hundred of them;
/// a per-row `CGImageSourceCreateWithURL` to decide whether a menu row is offered is exactly the
/// kind of disk touch the card window keeps out of a body evaluation. The cost of being wrong is
/// nothing either way: a name whose extension lies renders as no band at all (`CardHeroImage`'s
/// structural degrade), and an image the system does not recognize by extension is simply not
/// offered the row the `hero` key is still hand-writable, which is what it was born as.
public static func isImageName(_ name: String) -> Bool {
let ext = (name as NSString).pathExtension
guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return false }
return type.conforms(to: .image)
}
}
+1 -1
View File
@@ -273,7 +273,7 @@ public final class CardWindowUndo {
}
private static let fieldOrder: [ExpectedField.Kind] = [
.title, .order, .width, .background, .backgroundImage, .icon, .iconColor, .body,
.title, .order, .width, .background, .backgroundImage, .hero, .icon, .iconColor, .body,
]
}
}
+12
View File
@@ -46,6 +46,15 @@ public enum ExpectedField: Sendable, Equatable {
/// afterwards. `nil` is the absent subkey, exactly as everywhere else here.
case backgroundImage(String?)
/// `hero` the card's banner picture, named by the attachment row's "Set as Hero" / "Remove
/// Hero" (`BoardStore.setHero(_:onCard:on:)`, ruled 2026-08-09).
///
/// Its own case beside `.icon` rather than a reading of it, for `.backgroundImage`'s reason: they
/// are independent values on one card, and a step that named the hero must not stale because
/// somebody picked a symbol afterwards. `nil` is the removed key Remove Hero exactly as
/// everywhere else here.
case hero(String?)
/// `icon` the styling gesture's symbol dimension.
case icon(String?)
@@ -69,6 +78,7 @@ public enum ExpectedField: Sendable, Equatable {
case .collapsed: .collapsed
case .background: .background
case .backgroundImage: .backgroundImage
case .hero: .hero
case .icon: .icon
case .iconColor: .iconColor
case .body: .body
@@ -84,6 +94,7 @@ public enum ExpectedField: Sendable, Equatable {
case collapsed
case background
case backgroundImage
case hero
case icon
case iconColor
case body
@@ -348,6 +359,7 @@ public enum HistoryStaleness {
case let .collapsed(expected): equal(document.collapsed, expected)
case let .background(expected): equal(document.background, expected)
case let .backgroundImage(expected): equal(document.backgroundImage, expected)
case let .hero(expected): equal(document.hero, expected)
case let .icon(expected): equal(document.icon, expected)
case let .iconColor(expected): equal(document.iconColor, expected)
case let .body(expected): document.body == expected
+20 -5
View File
@@ -219,6 +219,11 @@ struct KanbanApp: App {
ShareBoardCommand(appModel: appModel)
RevealInFinderCommand()
AddAttachmentCommand()
// The attachment row's hero pair, as the menu-bar twins 11-command-nexus.md's
// context-menu contract requires ("no function's only home"). Two rows rather than one
// that renames itself, for titles-are-API's reason (`SetAsHeroCommand`).
SetAsHeroCommand()
RemoveHeroCommand()
AddCommentCommand()
Divider()
@@ -233,12 +238,22 @@ struct KanbanApp: App {
// (`UndoCommands.swift`).
UndoRedoCommands()
// The Edit menu: Find (F), then its card-window stepping twins, placed after the standard
// Cut/Copy/Paste/Select All group, which is where macOS puts Find. The clipboard items above
// them stay the system's, answered by the board as a responder (`ClipboardCommands.swift`)
// a second item sharing one of those titles is what titles-are-API forbids. Undo and Redo
// were the system's too until the latch (the group just above).
// The Edit menu: Paste as Board Background, then Find (F) and its card-window stepping
// twins, placed after the standard Cut/Copy/Paste/Select All group, which is where macOS puts
// Find. The clipboard items above them stay the system's, answered by the board as a
// responder (`ClipboardCommands.swift`) a second item sharing one of those titles is what
// titles-are-API forbids. Undo and Redo were the system's too until the latch (the group just
// above).
//
// **Paste as Board Background joined 2026-08-09** with the image-data paste branch: it sits
// directly under the system's Paste because it is the one paste this app has that V could
// not carry a backdrop has no selection to target, so it needs a name rather than a
// modifier (`PasteBoardBackgroundCommand`). No default chord, like Welcome's row.
CommandGroup(after: .pasteboard) {
PasteBoardBackgroundCommand(clipboard: appModel.clipboard)
Divider()
FindCommand()
FindSteppingCommands()
}
+212 -16
View File
@@ -296,15 +296,21 @@ public final class BoardStore: HealHost {
/// refreshes nothing the pre-skip behaviour of `snapshotGeneration` exactly, kept exactly.
public private(set) var landedReloads: Int = 0
/// **The generated background this store wrote, and the reload count it was written at** the
/// reroll's echo (`generatedBackgroundName(replacing:inRoot:)`, which is the only reader and
/// carries the whole reasoning).
/// **The board image this store wrote, and the reload count it was written at** the reroll's
/// echo (`boardImageName(base:replacing:inRoot:)`, which is the only reader and carries the whole
/// reasoning).
///
/// **`base` is carried beside the name** because there are two producers now the generator's
/// `facets.png` and a pasted `Pasted Background.png` (ruled 2026-08-09) and the echo's claim is
/// "this store already owns *that family's* name since the last reload". Without it, a paste
/// following a reroll inside one reload window would read `facets.png` as its own and overwrite a
/// file it never wrote.
///
/// `@ObservationIgnored` because nothing renders it: it is bookkeeping about a file name, and a
/// view that redrew when it changed would be redrawing for the write it is already going to be
/// told about by the reload.
@ObservationIgnored
var generatedBackgroundEcho: (name: String, reloads: Int)?
var generatedBackgroundEcho: (name: String, base: String, reloads: Int)?
/// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
/// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
@@ -2055,7 +2061,9 @@ public final class BoardStore: HealHost {
let root = rootURL
let priorImage = snapshot.backgroundImage
let priorColor = snapshot.background
let name = generatedBackgroundName(replacing: priorImage.value, inRoot: root)
let name = boardImageName(
base: FacetsGenerator.fileName, replacing: priorImage.value, inRoot: root
)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.writeBoardImage(
@@ -2070,7 +2078,7 @@ public final class BoardStore: HealHost {
}
}
guard landed != nil else { return false }
generatedBackgroundEcho = (name: name, reloads: landedReloads)
generatedBackgroundEcho = (name: name, base: FacetsGenerator.fileName, reloads: landedReloads)
// restyle prior style (13-native-undo.md Rules). The board's own stack, never a window's:
// there is no card here to have a session.
@@ -2172,13 +2180,14 @@ public final class BoardStore: HealHost {
document.setBackgroundImage(nil)
}
/// The name a generated background is written under: **ours to overwrite**, or the next free one.
/// The name a board image is written under: **ours to overwrite**, or the next free one.
///
/// `current` is what `background.image` says now. When that is already the generated name the
/// file is this board's own output and is replaced in place including when it has been deleted
/// from the folder by hand, which is a board whose backdrop is broken and is exactly what
/// regenerating fixes. Otherwise the Finder ladder decides, which yields the plain name when
/// nothing holds it and `facets 2.png` when something does.
/// `base` is the producer's own file name `facets.png` for the generator, `Pasted
/// Background.<ext>` for a paste and `current` is what `background.image` says now. When that
/// is already `base` the file is this board's own output and is replaced in place including
/// when it has been deleted from the folder by hand, which is a board whose backdrop is broken
/// and is exactly what regenerating (or re-pasting) fixes. Otherwise the Finder ladder decides,
/// which yields the plain name when nothing holds it and `facets 2.png` when something does.
///
/// ### The reroll's echo
///
@@ -2194,10 +2203,91 @@ public final class BoardStore: HealHost {
/// is the better authority. Once a reload lands, the snapshot's `background.image` takes over and
/// this memory stops being consulted including when a hand edit pointed the board somewhere
/// else in the meantime.
private func generatedBackgroundName(replacing current: String?, inRoot root: URL) -> String {
if current == FacetsGenerator.fileName { return FacetsGenerator.fileName }
if let echo = generatedBackgroundEcho, echo.reloads == landedReloads { return echo.name }
return BoardWriter.freshName(for: FacetsGenerator.fileName, in: root)
///
/// **The echo is only consulted for its own family** (`base`), which is what keeps two producers
/// off each other: a paste landing inside the reroll's echo window must not read `facets.png` as
/// a name it owns.
private func boardImageName(base: String, replacing current: String?, inRoot root: URL) -> String {
if current == base { return base }
if let echo = generatedBackgroundEcho, echo.base == base, echo.reloads == landedReloads {
return echo.name
}
return BoardWriter.freshName(for: base, in: root)
}
// MARK: - Pasted background
/// **Applies a pasted picture as this board's backdrop** Edit Paste as Board Background
/// (03-board-ui.md § Styling Capabilities; 04-interactions.md Clipboard's image-data branch,
/// ruled 2026-08-09).
///
/// `applyGeneratedBackground` line for line the same two-writes-one-bracket ordering (picture
/// first, so a failure never leaves the board naming a file that is not there), the same
/// `.setBoardBackground` operation, the same swallowed failure, the same restyle step with
/// exactly two differences, both of them deliberate:
///
/// - **The colour is not touched.** The generator writes `background.color` because it *knows*
/// its render's ground colour and wants the board to degrade to it; a picture off the
/// pasteboard has no such figure, and inventing one (an average, a corner sample) would be this
/// gesture quietly restyling a board the user only asked to give a backdrop. So the mapping's
/// other subkey survives untouched, which is `setBackgroundImage`'s whole per-subkey contract.
/// - **The name carries the payload's own extension** (`Pasted Background.png`, `.jpeg`, ),
/// because the format rule keeps a file-shaped flavor verbatim (`PastedImage`). A board pasted
/// twice in two formats therefore leaves the first file behind the same quiet leftover
/// choosing a solid colour over a generated background already leaves, and for the same reason:
/// undo restores the *field*, and a field cannot point an undo back at bytes this gesture
/// deleted.
///
/// **The undo restores the field, not the bytes** `applyGeneratedBackground`'s own note,
/// unchanged and for its reason: a re-paste over this board's own `Pasted Background.png`
/// overwrites pixels nothing kept a copy of.
///
/// - Returns: whether bytes reached disk, which is the same question as "is an echo reload
/// coming".
@discardableResult
public func applyPastedBackground(data: Data, fileExtension: String) -> Bool {
let root = rootURL
let priorImage = snapshot.backgroundImage
let base = "\(PastedImage.backgroundBaseName).\(fileExtension)"
let name = boardImageName(base: base, replacing: priorImage.value, inRoot: root)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.writeBoardImage(
data: data, named: name, inRoot: root, operation: .setBoardBackground
)
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
document.setBackgroundImage(name)
}
}
guard landed != nil else { return false }
generatedBackgroundEcho = (name: name, base: base, reloads: landedReloads)
// restyle prior image (13-native-undo.md Rules). The board's own stack, never a window's:
// there is no card here to have a session. **Only the image subkey is declared**, which is
// the field-level predicate at its narrowest: a colour chosen after this paste must not stale
// the step, because this gesture never wrote a colour.
registerStep(
HistoryPhrase.name(.restyle, kind: .board),
undoExpects: [.present(root, .backgroundImage(name))],
redoExpects: [.present(root, .backgroundImage(priorImage.value))]
) { _ in
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
// A malformed prior reads as a removal, `restore(_:to:in:)`'s own rule and the one
// the redo expectation above is written against.
document.setBackgroundImage(priorImage.value)
}
} redo: { _ in
try BoardWriter.updateIndex(
inItemFolder: root, kind: .board, operation: .setBoardBackground
) { document in
document.setBackgroundImage(name)
}
}
return true
}
/// Both subkeys, written into the mapping rather than over it (BackgroundField.swift) spelled
@@ -3667,6 +3757,112 @@ public final class BoardStore: HealHost {
}
}
// MARK: - The hero image
/// **Points a card's `hero` key at one of its attachments, or removes it** the card window
/// attachment row's "Set as Hero" and "Remove Hero" (03-board-ui.md § Card face Hero image,
/// whose "no in-app setter this version" line this ruling retires, 2026-08-09; 05-card-window.md
/// Attachments).
///
/// ### It is a style write, and is shaped like one
///
/// One key on one card's `index.md`, through `updateIndex` inside one `performWrite` bracket,
/// registering one **restyle** step `applyStyle`'s shape with the batch collapsed to a single
/// subject, because there is one: a hero belongs to a card, and the row that sets it names
/// exactly one file. It takes `.style` as its operation for the same reason it takes the restyle
/// phrase: nothing here writes a file, which is the whole of what made
/// `WriteOperation.setBoardBackground` split off from `.style` one level up.
///
/// **It does register an undo step**, unlike its neighbours in this section. That is not an
/// inconsistency with "attachment add/remove registers no undo step" (13-native-undo.md Out of
/// scope): the reason that rule exists is that a removed *file* has nowhere to come back from, and
/// this gesture moves no file at all it edits a key whose before-value the step carries, which
/// is the ordinary invertible frontmatter write every other style dimension already is.
///
/// ### Replacement, not refusal
///
/// "Set as Hero" on a card that already has one **replaces** it, and the row is simply absent on
/// the row already holding the key (`CardAttachments.canSetHero(_:)`) a card has one hero, the
/// user picked a different picture, and making them Remove first would be ceremony. A call that
/// would write what is already there is a no-op: `effective(_:against:)` is the same predicate
/// `applyStyle` uses to decide a dimension changed nothing, so a redundant set costs no write, no
/// reload and no undo step.
///
/// ### The guards are `importAttachments`'
///
/// **The board container and only it** a trashed card's hero is not editable from a window that
/// is dismissing itself and a lane id is refused because a lane has no hero. `name` is not
/// checked against the card's listing here: the surface only ever offers a row it is showing, and
/// the schema's own reading refuses anything that is not a bare filename anyway
/// (`FrontmatterDocument.hero`), so a name that has since gone lands a key that renders as no band
/// which is exactly what 03 says a hero naming a missing file does.
///
/// - Parameter name: the attachment's file name, or `nil` to remove the key.
/// - Parameter window: the card window whose stack the step belongs on, when the gesture came
/// from one (13-native-undo.md Rules two levels). A window-issued gesture also anchors by
/// **card identity** rather than by path, `applyStyle`'s rule verbatim, so a lane move under an
/// open window never stales it.
/// - Returns: whether bytes reached disk.
@discardableResult
public func setHero(_ name: String?, onCard cardID: ItemID, on window: CardWindowUndo? = nil) -> Bool {
guard let item = Self.boardItem(cardID, in: snapshot),
let card = item.cardID,
let subject = Self.card(cardID, in: snapshot)
else { return false }
let prior = subject.hero
let change: StyleChange = name.map { .set($0) } ?? .remove
guard Self.effective(change, against: prior) != .keep else { return false }
let folder = rootURL
.appendingPathComponent(item.laneID.rawValue, isDirectory: true)
.appendingPathComponent(card.rawValue, isDirectory: true)
let anchor: HistoryAnchor = window != nil ? .card(cardID) : .path(folder)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a failure
// names the card by the title it still has.
try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in
document.setStyleValue(name, for: FrontmatterKeys.hero)
}
}
guard landed != nil else { return false }
registerStep(
HistoryPhrase.name(.restyle, kind: .card),
subject: item.title,
on: window,
undoExpects: [.present(anchor, .hero(name))],
redoExpects: [.present(anchor, .hero(prior.value))]
) { store in
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: anchor, .style(title: nil)),
operation: .style(title: nil)
) { document in
// A malformed prior reads as a removal, `restore(_:to:in:)`'s own rule and the one
// the redo expectation above is written against.
Self.restore(prior, to: FrontmatterKeys.hero, in: &document)
}
} redo: { store in
try BoardWriter.updateIndex(
inItemFolder: try store.requiredFolder(for: anchor, .style(title: nil)),
operation: .style(title: nil)
) { document in
document.setStyleValue(name, for: FrontmatterKeys.hero)
}
}
return true
}
/// One live board-side card off a snapshot, by identity `boardItem`'s sibling for a caller that
/// needs the card's *fields* rather than its position.
nonisolated static func card(_ id: ItemID, in snapshot: BoardModel) -> Card? {
for lane in snapshot.lanes {
if let card = lane.cards.first(where: { $0.id == id }) { return card }
}
return nil
}
// MARK: - The loose-file carve-out
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
+6 -3
View File
@@ -585,9 +585,12 @@ public enum FrontmatterKeys {
/// carrying a separator is not a hero image spelled awkwardly, it is a value with no reading at
/// all (`FrontmatterDocument.hero`).
///
/// **Opt-in, and hand-written**: there is no in-app setter this version (ruled 2026-08-09), so
/// the key is written by an author or an agent and the app only ever reads it. It joins
/// `schemaOwned` all the same it is Lanework's to interpret, not an unknown key riding along.
/// **Opt-in, and written from exactly one control**: the card window attachment row's "Set as
/// Hero" / "Remove Hero" (re-ruled 2026-08-09, promoting the key's own "future work" note the day
/// it landed; `BoardStore.setHero(_:onCard:on:)`). The row can only name a file the card already
/// has, which is the key's grammar enforced by the surface rather than validated after the fact;
/// hand-writing and agent-writing stay exactly as legal as they were. It joins `schemaOwned` for
/// the reason it always did it is Lanework's to interpret, not an unknown key riding along.
public static let hero = "hero"
public static let created = "created"
+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 }