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)
}
}