Hero image for cards — one of the card's own attachments, banded across its face

A card whose `hero:` names one of its own attachments draws that picture as a
banner across the full width of its plate, above the icon-and-title row,
aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and
em-scaled like every other figure the board draws, so it grows with the system
text size and with the board's zoom rather than shrinking against a title twice
its usual size. The figure sits deliberately under the 44pt a plain one-line
card is tall: a hero card should read as a card with a picture on it rather than
a picture with a caption, which is 03's standing rule that the title dominates.

The key's grammar is a **bare filename**, and that is what separates it from the
board background's `image` subkey rather than a nervousness about paths. A board
names a file anywhere under its root, so a path is that key's reading and where
it leads is the renderer's question. A card names one of the files it already
owns — the flat `attachments/` folder the app lists, relocates into, and carries
through every move, copy, trash and restore — so `hero: art/sketch.png` is not an
awkward spelling of a hero image, it is a value the key cannot mean. It therefore
has no reading at all: a value carrying a separator, or spelling `.`/`..`, or
empty, is malformed at the document layer, which renders it as absent and leaves
the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written,
the resolver re-checks containment anyway, and the whole degrade family below
that — a name pointing at a missing file, an unreadable one, or one that is not
an image — ends the same way: no banner, no defect, nothing written.

That last promise is about *height* as much as about ink, so the band is given no
height at all until a picture has actually decoded. A card whose hero cannot be
drawn lays out identically to a card with no key, structurally rather than by a
branch somebody has to remember; the price is one settle per hero as a board
opens, and none after that. Everything else the face draws is attached outside
the new stack and is untouched by it — the accent stripe still runs the plate's
full leading edge across the band's corner, the selection and file-hover strokes
still ring the whole plate, the cut and drag dims still cover it, and the drop
model still registers the plate's real height, so a hero card is simply a taller
card the masonry already understands. The trash draws it too, by the one-face
rule.

Decoding is ImageIO's downsampling path off the main actor at a quarter of the
backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter
rather than being copied), and the results live in one app-wide, deliberately
non-observable cache keyed on path plus the file's date and size. Non-observable
because a tracked write there would invalidate every hero face on the board,
which is the O(board) invalidation this view was rebuilt once already to shed;
each face holds its own picture in view state and seeds it from the cache, which
is also what lets the drag replica — whose preview builder is non-escaping and
cannot await anything — carry the band at the face's real height. Taking a stamp
twice from one URL value turned out to answer with the first read's date and size
however many times the bytes had changed, so `stamp(of:)` now drops its cached
resource values first; noticing a replacement is the only thing a stamp is for.

The face takes the resolved URL as a compared input rather than resolving it, for
selected-ness's reason one axis over: resolving needs the card's folder, which a
face does not know, and finding it from the snapshot would be a board walk per
face. The lane and the trash column each know their own container and compute it
once for the whole strip.

There is no in-app setter this version — the key is written by hand or by an
agent, which is why the guide bumps to v13 with a clause spelling the grammar out
beside the other card keys, and why `attachments/` gets the one-line pointer an
agent that has just written `![](attachments/x.png)` will need. "Set as Hero"
from the attachment row is future work, as is the card window and print, which
draw the same model and show no banner today.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-08 23:41:15 -04:00
parent f9f284cac9
commit ce92c24190
19 changed files with 975 additions and 64 deletions
+34 -10
View File
@@ -326,6 +326,31 @@ struct TrashLaneView: View {
}
}
/// One trashed card's face **extracted purely to keep the slot switch type-checkable**. The
/// column's `ForEach` closure is one expression covering three slot kinds, and the face's own
/// argument list is long enough that inlining it here pushed the whole thing past the solver's
/// budget. Every value it needs is hoisted once per body and handed in, so nothing about the
/// lifetime or the subscriptions changes by moving these lines.
private func cardRow(
_ card: Card,
cardsFolder: URL,
selectedIDs: Set<ItemID>,
selectedCount: Int
) -> some View {
CardFaceView(
store: store,
card: card,
role: .trash(confirmations: confirmations),
marquee: marquee,
drops: drops,
hero: CardHero.imageURL(for: card, inContainer: cardsFolder),
isSelected: selectedIDs.contains(card.id),
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
)
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
.equatable()
}
private var scrollableCards: some View {
// **The trash-side selection, read once for the whole column and this is a new subscription,
// deliberately.** `LaneView` was already reading the selection for its header, so hoisting it
@@ -338,6 +363,10 @@ struct TrashLaneView: View {
let selection = store.selection
let selectedIDs = selection.container == .trash ? selection.ids : []
let selectedCount = max(1, selectedIDs.count)
// The trash's own folder the container its cards resolve their heroes against, `LaneView`'s
// hoist one container over. A trashed card is an ordinary card in a special place, so it wears
// its hero exactly as it did in its lane; only the folder its attachments now sit under moved.
let cardsFolder = BoardWriter.trashFolder(inBoard: store.rootURL)
return ScrollView(.vertical) {
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
// one width unit, so its masonry is a single column but it is the *same* layout the
@@ -354,17 +383,12 @@ struct TrashLaneView: View {
Group {
switch slot {
case let .entry(.card(card)):
CardFaceView(
store: store,
card: card,
role: .trash(confirmations: confirmations),
marquee: marquee,
drops: drops,
isSelected: selectedIDs.contains(card.id),
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
cardRow(
card,
cardsFolder: cardsFolder,
selectedIDs: selectedIDs,
selectedCount: selectedCount
)
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
.equatable()
case let .entry(.lane(lane)):
// The opaque unit's row its own view, because "no styling accents" and
// "never expandable" are exactly what a card face is not