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:
@@ -92,18 +92,33 @@ enum BoardBackdrop {
|
||||
/// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its
|
||||
/// byte count too. Missing values (a file that is not there) compare equal to each other, which
|
||||
/// is what stops a board naming a missing image from re-decoding on every reload.
|
||||
struct Stamp: Equatable, Sendable {
|
||||
/// `Hashable` because a stamp is half of a cache key as well as a comparison: the card face's
|
||||
/// hero cache files a decoded picture under "this path, as of these bytes" (`CardHeroCache`).
|
||||
struct Stamp: Hashable, Sendable {
|
||||
var modified: Date?
|
||||
var size: Int?
|
||||
}
|
||||
|
||||
static func stamp(of url: URL) -> Stamp {
|
||||
// **The cached resource values are dropped first, and that is load-bearing.** A `URL` value
|
||||
// memoizes what it was last told about the file behind it, so a stamp taken twice from *one*
|
||||
// URL value answers with the first read's date and size however many times the bytes were
|
||||
// replaced in between — and noticing exactly that is the only thing a stamp is for. A caller
|
||||
// that happens to rebuild its URL each time was never affected; one that holds a URL and
|
||||
// re-stats it (the card face's hero cache) would silently never see a change.
|
||||
var url = url
|
||||
url.removeAllCachedResourceValues()
|
||||
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
|
||||
}
|
||||
|
||||
/// Decodes the file at `url`, downsampled to `maximumPixelSize` on its longest edge — or `nil`
|
||||
/// for anything that is not a readable image.
|
||||
/// Decodes the file at `url`, downsampled to `limit` pixels on its longest edge — or `nil` for
|
||||
/// anything that is not a readable image.
|
||||
///
|
||||
/// **`limit` is the caller's, because "how big is big enough" is a question about the surface
|
||||
/// being drawn.** A window-filling backdrop wants the default; a card face's hero band is two
|
||||
/// orders smaller in area and passes its own (`CardHero.maximumPixelSize`), which is the whole
|
||||
/// reason the parameter exists rather than a second copy of these four options.
|
||||
///
|
||||
/// **ImageIO's thumbnail path, not a full decode plus a resize**: `CGImageSourceCreateThumbnail
|
||||
/// AtIndex` reads at a reduced scale, so the peak allocation is the *output* size rather than
|
||||
@@ -113,16 +128,16 @@ enum BoardBackdrop {
|
||||
/// laid on its side.
|
||||
///
|
||||
/// Never call this on the main actor; see `BoardBackdropImage`'s task.
|
||||
static func decode(_ url: URL) -> CGImage? {
|
||||
static func decode(_ url: URL, limit: Int = maximumPixelSize) -> CGImage? {
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize,
|
||||
kCGImageSourceThumbnailMaxPixelSize: limit,
|
||||
]
|
||||
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||
logger.debug("board backdrop image could not be decoded")
|
||||
logger.debug("image at \(url.lastPathComponent, privacy: .private) could not be decoded")
|
||||
return nil
|
||||
}
|
||||
return image
|
||||
|
||||
Reference in New Issue
Block a user