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 `` 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:
@@ -262,7 +262,7 @@ struct CardFaceViewEquatableTests {
|
||||
role: .board(openCard: { _ in }),
|
||||
marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
isSelected: false,
|
||||
hero: nil, isSelected: false,
|
||||
selectedCount: 1
|
||||
)
|
||||
let after = CardFaceView(
|
||||
@@ -271,7 +271,7 @@ struct CardFaceViewEquatableTests {
|
||||
role: .board(openCard: { _ in Issue.record("the gate must not care which opener it holds") }),
|
||||
marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
isSelected: false,
|
||||
hero: nil, isSelected: false,
|
||||
selectedCount: 1
|
||||
)
|
||||
#expect(before == after)
|
||||
@@ -290,7 +290,7 @@ struct CardFaceViewEquatableTests {
|
||||
|
||||
let unselected = CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1
|
||||
)
|
||||
|
||||
// The gate is now the *only* thing standing between a click and this face's repaint: nothing
|
||||
@@ -298,21 +298,50 @@ struct CardFaceViewEquatableTests {
|
||||
// leave a selected card wearing no accent ring at all.
|
||||
#expect(unselected != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 1
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
|
||||
))
|
||||
|
||||
// And the count, which the drag replica's fan and count badge are drawn from: a card that is
|
||||
// still selected but now travels with four others has a different image under the cursor.
|
||||
let alone = CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 1
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
|
||||
)
|
||||
#expect(alone != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 5
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 5
|
||||
))
|
||||
}
|
||||
|
||||
/// **The hero is a compared input too** (03-board-ui.md § Card face ▸ Hero image) — a resolution
|
||||
/// the parent does, like selected-ness, and the one input that changes the face's *height*. A gate
|
||||
/// that swallowed it would leave a card banding a picture it no longer names, or naming one it
|
||||
/// never draws.
|
||||
@Test("The resolved hero file is a difference")
|
||||
func theHeroIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let card = try firstCard(fixture.snapshot())
|
||||
let marquee = MarqueeControl(
|
||||
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||
)
|
||||
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
|
||||
let sketch = fixture.root.appendingPathComponent("sketch.png")
|
||||
|
||||
func face(_ hero: URL?) -> CardFaceView {
|
||||
CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, hero: hero, isSelected: false, selectedCount: 1
|
||||
)
|
||||
}
|
||||
|
||||
#expect(face(nil) == face(nil))
|
||||
#expect(face(sketch) == face(sketch))
|
||||
#expect(face(nil) != face(sketch))
|
||||
#expect(face(sketch) != face(fixture.root.appendingPathComponent("cover.png")))
|
||||
}
|
||||
|
||||
@Test("An edited card is unequal — the gate never withholds a repaint")
|
||||
func anEditedCardIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
@@ -331,18 +360,18 @@ struct CardFaceViewEquatableTests {
|
||||
#expect(before != after)
|
||||
|
||||
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
|
||||
|
||||
// And a different card, which is the ordinary within-lane case.
|
||||
let sibling = try #require(try firstLane(fixture.snapshot()).cards.first {
|
||||
$0.id == ItemID(rawValue: Ident.card2)
|
||||
})
|
||||
#expect(CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The two homes are never equal, and the trash's confirmation host is compared by identity")
|
||||
@@ -360,21 +389,21 @@ struct CardFaceViewEquatableTests {
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
// The role decides which container a click selects in, whether the face has an Open gesture
|
||||
// at all, and whether Delete is the permanent one — never a difference to swallow.
|
||||
#expect(board != trash)
|
||||
#expect(trash == CardFaceView(store: store, card: card,
|
||||
role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
hero: nil, isSelected: false, selectedCount: 1))
|
||||
// Window-lived state, so identity is meaningful as well as cheap.
|
||||
#expect(trash != CardFaceView(store: store, card: card,
|
||||
role: .trash(confirmations: TrashConfirmations()),
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
hero: nil, isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
|
||||
@@ -391,35 +420,35 @@ struct CardFaceViewEquatableTests {
|
||||
let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
|
||||
let drops = makeDrops(store: store, session: session, registry: registry)
|
||||
let base = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
|
||||
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
hero: nil, isSelected: false, selectedCount: 1))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: DragSession(), registry: registry),
|
||||
isSelected: false, selectedCount: 1
|
||||
hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
|
||||
isSelected: false, selectedCount: 1
|
||||
hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
|
||||
isSelected: false, selectedCount: 1
|
||||
hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store),
|
||||
drops: drops, isSelected: false, selectedCount: 1
|
||||
drops: drops, hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store),
|
||||
drops: drops, isSelected: false, selectedCount: 1
|
||||
drops: drops, hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user