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
+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