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:
@@ -146,6 +146,20 @@ struct CardFaceView: View, Equatable {
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// **This card's hero image, resolved** — the file its `hero` key names inside its own
|
||||
/// `attachments/`, or `nil` for the overwhelmingly common card that names none
|
||||
/// (03-board-ui.md § Card face ▸ Hero image; `CardHero.imageURL(for:inContainer:)`).
|
||||
///
|
||||
/// A parameter rather than a resolution done here, and for `isSelected`'s kind of reason one axis
|
||||
/// over: resolving it needs the card's *folder*, which this view does not know — it knows its card
|
||||
/// and its container, and finding the path from the snapshot would be a board walk per face. The
|
||||
/// two parents each know their own container folder and compute it once for the whole strip.
|
||||
///
|
||||
/// Whether the file exists, decodes, or is an image at all is deliberately not asked here: this is
|
||||
/// a URL, and a name that leads nowhere draws no band (`CardHeroImage`), which is the ruling's
|
||||
/// "renders exactly as with no key" kept structurally.
|
||||
let hero: URL?
|
||||
|
||||
/// Whether this card is in the selection **of its own container** — a parameter rather than a
|
||||
/// read off the store, and that is the whole of the fix RENDER-INSTRUMENTATION.md ▸ "Selection is
|
||||
/// O(board) in card bodies" asked for.
|
||||
@@ -203,10 +217,15 @@ struct CardFaceView: View, Equatable {
|
||||
|
||||
/// The whole of what this face is a function of **as far as its parent is concerned**: the card
|
||||
/// value (`Card` is `Equatable` down to its attachment names and its parsed document), which home
|
||||
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the two selection figures the parent
|
||||
/// resolves for it, and the three window-lived collaborators — the store by identity, the band
|
||||
/// and the drop machinery by their own equivalence tests, which exist because the strip rebuilds
|
||||
/// both structs, closures and all, on every body pass.
|
||||
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the hero file the parent resolved for it,
|
||||
/// the two selection figures the parent resolves for it, and the three window-lived collaborators
|
||||
/// — the store by identity, the band and the drop machinery by their own equivalence tests, which
|
||||
/// exist because the strip rebuilds both structs, closures and all, on every body pass.
|
||||
///
|
||||
/// **The hero is compared as a URL, not as a picture.** It moves only when the card's key or its
|
||||
/// container does, both of which are already `card`-and-role facts; comparing it costs a path
|
||||
/// comparison on a value that is `nil` for almost every card, and the picture behind it is the
|
||||
/// banner view's own state (`CardHeroImage`), which no gate here could see anyway.
|
||||
///
|
||||
/// **Selection is a compared input now, and that is what makes the gate reach it.** It used to be
|
||||
/// an Observation read — `isSelected` off `store.selection` — which meant a click anywhere on the
|
||||
@@ -236,6 +255,7 @@ struct CardFaceView: View, Equatable {
|
||||
nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool {
|
||||
lhs.card == rhs.card
|
||||
&& lhs.role.isEquivalent(to: rhs.role)
|
||||
&& lhs.hero == rhs.hero
|
||||
&& lhs.isSelected == rhs.isSelected
|
||||
&& lhs.selectedCount == rhs.selectedCount
|
||||
&& lhs.store === rhs.store
|
||||
@@ -294,12 +314,7 @@ struct CardFaceView: View, Equatable {
|
||||
|
||||
/// Everything the two containers share — which, after the pivot, is the face itself.
|
||||
private var face: some View {
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||
.padding(.leading, stripeWidth)
|
||||
plateContent
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
||||
@@ -391,6 +406,49 @@ struct CardFaceView: View, Equatable {
|
||||
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
|
||||
}
|
||||
|
||||
/// What sits on the plate: the hero band, then the padded title row (03-board-ui.md § Card face).
|
||||
///
|
||||
/// **The band is full-bleed and the title row is not**, which is the whole layout call. A hero is
|
||||
/// a picture of what the card is about, so it takes the plate's own width and rounds its top
|
||||
/// corners to the plate's radius (`CardHeroImage`); the row below keeps every inset it has always
|
||||
/// had, including the stripe's reserved leading padding, so a card's title sits on exactly the
|
||||
/// grid it sat on before whether or not the card has a hero.
|
||||
///
|
||||
/// **Zero spacing, and a band with no height when there is no picture** — so a card with no
|
||||
/// `hero`, or one whose hero names a file that is missing or unreadable, lays out identically to
|
||||
/// the face as it was: the stack's first element contributes nothing at all.
|
||||
///
|
||||
/// Everything else the face draws is attached *outside* this stack and is therefore untouched by
|
||||
/// the band: the accent stripe still runs the plate's full leading edge (over the band's leading
|
||||
/// corner — the stripe is the card's edge, and a picture does not interrupt it), the selection and
|
||||
/// file-hover strokes still ring the whole plate, the cut and drag dims still cover it, and the
|
||||
/// geometry the drop model registers is still the plate's — a hero card is simply a taller card,
|
||||
/// which the masonry already understands.
|
||||
private var plateContent: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
heroBanner
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid,
|
||||
// so colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||
.padding(.leading, stripeWidth)
|
||||
}
|
||||
}
|
||||
|
||||
/// The hero band, for a card that names a readable one — and nothing whatever for a card that
|
||||
/// does not, which is the ruling's degrade stated as a branch that is simply not taken.
|
||||
@ViewBuilder
|
||||
private var heroBanner: some View {
|
||||
if let hero {
|
||||
CardHeroImage(
|
||||
url: hero,
|
||||
height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize),
|
||||
cornerRadius: cornerRadius
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card drag
|
||||
|
||||
/// Begins this card's system drag session — in **its own container**, which is the whole of what
|
||||
@@ -571,25 +629,40 @@ struct CardFaceView: View, Equatable {
|
||||
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
|
||||
/// arrow keys).
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.boardFont(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// **The band is drawn from the cache alone, synchronously.** A preview builder is
|
||||
// non-escaping — it runs while the body does, and the system snapshots the result
|
||||
// immediately — so there is no task to await a decode in. A hero the face is already
|
||||
// showing is in the cache by definition, which is the only case that matters: you cannot
|
||||
// drag a card whose banner has not drawn yet without having looked at it first. A miss
|
||||
// draws no band, and the replica is then exactly the face a hero-less card lifts.
|
||||
if let hero, let image = CardHeroCache.image(forFileAt: hero) {
|
||||
Color.clear
|
||||
.frame(height: BoardMetrics.cardHeroHeight(bodyPointSize: pointSize))
|
||||
.overlay { Image(decorative: image, scale: 1).resizable().aspectRatio(contentMode: .fill) }
|
||||
.clipShape(UnevenRoundedRectangle(
|
||||
topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius))
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.boardFont(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.padding(.leading, stripeWidth)
|
||||
}
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.padding(.leading, stripeWidth)
|
||||
// **The size of the face it was lifted from**, taken from that face's own measurement rather
|
||||
// than from a representative figure: a card is as wide as its lane's interior column, so a
|
||||
// replica drawn at a nominal width is visibly a different card from the one under the cursor,
|
||||
// and — since the system centres a preview on the view the drag started from — leaves the
|
||||
// pointer sitting beside the image instead of on it. The width is the only frame this needs:
|
||||
// the replica lays the same row out with the same paddings and the same `lineLimit`, so at
|
||||
// the face's width it comes out at the face's height (`BoardMetrics`).
|
||||
// the replica lays the same band and the same row out with the same paddings and the same
|
||||
// `lineLimit`, so at the face's width it comes out at the face's height (`BoardMetrics`).
|
||||
.frame(
|
||||
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
|
||||
alignment: .leading
|
||||
|
||||
Reference in New Issue
Block a user