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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user