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
+21 -6
View File
@@ -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
+18
View File
@@ -162,6 +162,24 @@ enum BoardMetrics {
em(0.3, bodyPointSize: bodyPointSize)
}
/// **The hero banner's height** (03-board-ui.md § Card face Hero image) the band a card
/// draws across the full width of its plate, above the icon-and-title row, when its `hero` key
/// names a readable attachment.
///
/// 2.75 em 36pt at the standard 13pt body, inside the ruling's "roughly 2.53× the body size".
/// The band is a *sample* of the picture rather than the picture, so the figure is chosen against
/// the row it sits over: a shade under the 44pt a plain one-line card is tall
/// (`nominalCardHeight`), which keeps a hero card recognisably a card the title still dominates
/// its own face, which is 03's standing rule for everything the face draws.
///
/// **Fixed rather than derived from the image**, so every hero card in a lane bands to the same
/// depth and the masonry stays a masonry; the picture is aspect-fill cropped into it
/// (`CardHeroImage`). Em-scaled like every other figure here, so the band grows with the system
/// text size and with the board's zoom instead of shrinking against a title twice its usual size.
static func cardHeroHeight(bodyPointSize: CGFloat) -> CGFloat {
em(2.75, bodyPointSize: bodyPointSize)
}
/// The card plate's inset around its content.
static func cardContentPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.75, bodyPointSize: bodyPointSize)
+96 -23
View File
@@ -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
+264
View File
@@ -0,0 +1,264 @@
import CoreGraphics
import SwiftUI
import os
// MARK: - CardHero
/// **The card face's hero image** (03-board-ui.md § Card face Hero image; the card's `hero` key,
/// 01-storage-format.md § Frontmatter) `BoardBackdrop`'s opposite number one level down, and
/// deliberately its opposite in the one respect that matters.
///
/// ### The board names a path; a card names one of its own files
///
/// A board's backdrop takes a path relative to the board root, because a board is a folder and the
/// picture may sensibly live anywhere in it. A card's hero takes a **bare filename** and resolves it
/// inside that card's own `attachments/` folder, because that folder is already the answer to "where
/// do this card's files live": the app lists it, relocates loose files into it, and the point a
/// move, a copy, a trash and a restore all carry it with the card. A hero named that way survives
/// every one of those gestures with nothing to rewrite.
///
/// The reading refuses a path outright (`FrontmatterDocument.hero`), so by the time a name reaches
/// this file it is already separator-free. The containment check below is still made, and is not
/// redundant: a lenient reading must never be the only thing between a value and the filesystem, and
/// this is the layer that actually builds the URL.
///
/// ### Nothing here decides whether the file is any good
///
/// A name that resolves nowhere, a file that is missing, and a file that is not an image all end the
/// same way **no banner, exactly as with no key** (the ruling's own words): no defect, no badge, no
/// write. The face's band exists only where a picture was actually decoded (`CardHeroImage`), which
/// is what makes that promise structural rather than a branch somebody has to remember.
enum CardHero {
/// The longest edge, in pixels, a hero is ever decoded at.
///
/// A third of the backdrop's, and for a band a third of a window's height that is generous: the
/// widest a card face gets is one lane at full window width, and this covers that at 2× Retina
/// backing with room to spare. Smaller matters here in a way it does not for a backdrop a board
/// has one backdrop and may have hundreds of hero cards, so the figure is a per-card memory cost
/// as much as a decode cost.
static let maximumPixelSize = 1024
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "card-hero")
/// Where `name` lands inside `cardFolder`'s `attachments/`, or `nil` when it lands nowhere this
/// card may read.
///
/// The gate is the schema's own grammar restated against the filesystem: a name has to be
/// non-empty, carry no separator, and after standardizing, which is what catches a `.` or `..`
/// that slipped through resolve to a direct child of the attachments folder. `BoardBackdrop`'s
/// containment check is the model; this one is stricter by exactly one clause, because "inside
/// this folder" and "directly inside this folder" are different promises and only the second one
/// is what an attachment is.
static func imageURL(named name: String, inCardFolder cardFolder: URL) -> URL? {
guard !name.isEmpty, !name.contains("/") else { return nil }
let attachments = cardFolder
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
.standardizedFileURL
let candidate = attachments.appendingPathComponent(name).standardizedFileURL
guard candidate.deletingLastPathComponent().path == attachments.path else { return nil }
return candidate
}
/// This card's hero image, where it names one `container` is the folder the card's own folder
/// sits in: its lane on the board side, `<root>/.trash/` on the trash side.
///
/// **The container comes from the caller because the face must not go looking for it.** A card
/// face knows its card and its container, not its path; resolving that path from the snapshot
/// would be a walk of the board per face, which is the O(board)-per-face shape
/// RENDER-INSTRUMENTATION.md exists to keep out of this view. The two callers are the lane and
/// the trash column, each of which already knows its own folder and computes it once for the
/// whole strip.
///
/// The key's absence is the first thing checked, so the overwhelmingly common card no `hero`
/// at all costs one optional read and builds no URLs.
static func imageURL(for card: Card, inContainer container: URL) -> URL? {
guard let name = card.hero.value else { return nil }
let folder = container.appendingPathComponent(card.id.rawValue, isDirectory: true)
guard let url = imageURL(named: name, inCardFolder: folder) else {
logger.debug("card hero names nothing readable")
return nil
}
return url
}
}
// MARK: - CardHeroCache
/// Decoded hero images, app-wide **one decode per picture per version of it**, whoever draws it.
///
/// ### Why a shared cache rather than each face's own `@State`
///
/// `BoardBackdropImage` keeps its bitmap in view state, which is right for a view there is one of.
/// A hero face is drawn many times over: every card in every lane of every open board, plus the drag
/// replica, which is a *second*, separately-built rendition of the same face. View state cannot span
/// those, so each would decode the same file again, and a face rebuilt at a new identity a filter
/// change, a lane move would decode it once more.
///
/// ### Deliberately not `@Observable`
///
/// A tracked write here would invalidate every view that had read the dictionary, which is every hero
/// face on the board the O(board) invalidation this codebase has already paid for once
/// (RENDER-INSTRUMENTATION.md Selection is O(board) in card bodies). Instead the cache is inert
/// storage: a face reads it during a body pass, and the *face's own* `@State` is what redraws when
/// its picture lands. A face that misses simply draws no band until its task fills one in.
///
/// ### Freshness, and its one honest limit
///
/// The key carries the file's stamp, so a replaced file keys differently and the stale entry ages out
/// under the cap `AttachmentThumbnailCache`'s posture, and there is deliberately no invalidation
/// path to keep honest. What the stamp cannot do on its own is *notice*: the stamp is read in a face's
/// task, and that task re-runs when the face's URL changes rather than when the board reloads, so a
/// hero file replaced in place under an open board refreshes on the next thing that rebuilds the face
/// rather than immediately. The alternative keying the task on the board's reload pulse, as the
/// backdrop does would invalidate every hero face on every filesystem event anywhere in the board,
/// which is the wrong trade at this multiplicity.
@MainActor
enum CardHeroCache {
/// **Which file, as of which bytes.** Not the drawn size: the decode is downsampled to one
/// figure (`CardHero.maximumPixelSize`) rather than to the band's own dimensions, so a zoom
/// change re-lays out and re-crops without costing a single decode.
struct Key: Hashable {
let path: String
let stamp: BoardBackdrop.Stamp
}
/// How many decoded heroes the app keeps. Sized for "the hero cards on screen across the open
/// boards" with room around it; a plain insertion-ordered drop rather than a recency policy,
/// `AttachmentThumbnailCache`'s choice for its reason a board's access pattern is the cards it
/// is showing.
static let limit = 64
private static var images: [Key: CGImage] = [:]
/// Insertion order over `images`, for the cap.
private static var order: [Key] = []
/// This key's picture, or `nil` when it has not been decoded the one dictionary read a render
/// is allowed to do.
static func image(for key: Key) -> CGImage? {
images[key]
}
/// The picture for this file as of the last time anyone stamped it, without touching the disk
/// what a **synchronously drawn** rendition has to make do with (the drag replica: an
/// `.onDrag(_:preview:)` builder runs while the body does and cannot await a decode).
///
/// A miss draws no band, which is the same thing a face that has not loaded yet draws; it is not
/// a failure and there is nothing to report.
static func image(forFileAt url: URL) -> CGImage? {
guard let stamp = stamps[url.path] else { return nil }
return images[Key(path: url.path, stamp: stamp)]
}
/// The last stamp seen for each path the bridge between a render, which may not `stat`, and the
/// task that did.
private static var stamps: [String: BoardBackdrop.Stamp] = [:]
/// Resolves this file's stamp and decodes it if that stamp has no picture the whole of the
/// cache's write side, called from a face's `.task` and never from a body. Answers the picture
/// so the caller can hold it in its own state.
///
/// Both halves run off the main actor: the `stat` because a render is waiting on this task, and
/// the decode because it is ImageIO reading a file. Only `CGImage` which is `Sendable` comes
/// back.
static func load(_ url: URL) async -> CGImage? {
let stamp = await Task.detached(priority: .utility) { BoardBackdrop.stamp(of: url) }.value
stamps[url.path] = stamp
let key = Key(path: url.path, stamp: stamp)
if let cached = images[key] { return cached }
guard !Task.isCancelled else { return nil }
let limit = CardHero.maximumPixelSize
let decoded = await Task.detached(priority: .userInitiated) {
BoardBackdrop.decode(url, limit: limit)
}.value
guard let decoded else { return nil }
remember(decoded, for: key)
return decoded
}
private static func remember(_ image: CGImage, for key: Key) {
if images.updateValue(image, forKey: key) == nil {
order.append(key)
}
while order.count > limit {
images.removeValue(forKey: order.removeFirst())
}
}
/// Forgets everything tests only, so one suite's fixtures cannot decide another's hits.
static func removeAll() {
images.removeAll()
order.removeAll()
stamps.removeAll()
}
}
// MARK: - CardHeroImage
/// The hero banner: the decoded picture, drawn to fill a band across the top of a card's plate
/// (03-board-ui.md § Card face Hero image).
///
/// **Fill, cropped never letterboxed and never stretched**, `BoardBackdropImage`'s rule for its
/// reason: a band of the plate's colour down two edges would make a styled card look like a broken
/// one. The crop is centred, which is what an aspect-fill is; there is no focal point to choose from
/// and no key to write one in.
///
/// ### The band exists only when there is a picture
///
/// A card whose `hero` names a file that is missing, unreadable, or not an image "renders exactly as
/// with no key" (the ruling). That is a promise about *height*, not just about ink, and the only way
/// to keep it without a disk touch during layout is to give the band no height until a decode has
/// actually landed. So this view is zero-tall until then and grows in one step when the picture
/// arrives one settle per hero as a board opens, and none afterwards, because the cache answers the
/// second and every later draw synchronously.
struct CardHeroImage: View {
let url: URL
/// The band's height (`BoardMetrics.cardHeroHeight`) the face's figure rather than this view's,
/// because it is the face's rhythm the band belongs to.
let height: CGFloat
/// The plate's corner radius, which the band's **top** corners round to exactly, so the picture
/// reads as the card's own edge rather than a photograph laid over it.
let cornerRadius: CGFloat
/// This face's copy of the decoded picture. Seeded from the shared cache in the task's first,
/// synchronous step, so a face rebuilt for any reason at all a filter change, a lane move, a
/// re-open gets its banner back in one pass rather than flashing through no band.
@State private var image: CGImage?
var body: some View {
// `Color.clear` establishes the band and is what `clipShape` trims against; the overlay is
// what overflows it. Decorative in the precise sense 10-accessibility.md means: the face is
// one flattened element carrying its title and attachment count, and a picture adds nothing
// VoiceOver could usefully say (`CardFaceView`'s `.accessibilityElement(children: .ignore)`
// would drop a label here anyway saying it is what keeps the band inert in the replica too).
Color.clear
.frame(height: image == nil ? 0 : height)
.overlay {
if let image {
Image(decorative: image, scale: 1)
.resizable()
.aspectRatio(contentMode: .fill)
}
}
.clipShape(UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius))
.allowsHitTesting(false)
.task(id: url) { await load() }
}
/// Fills the band from the cache, and from the disk when the cache has nothing for these bytes.
///
/// A failure clears what was there: the file the card names is the file it shows, and holding the
/// previous picture would make a hero that has been deleted look like one that still works.
private func load() async {
image = CardHeroCache.image(forFileAt: url)
let decoded = await CardHeroCache.load(url)
guard !Task.isCancelled else { return }
image = decoded
}
}
+7
View File
@@ -1042,6 +1042,12 @@ struct LaneView: View, Equatable {
let selection = store.selection
let selectedIDs = selection.container == .board ? selection.ids : []
let selectedCount = max(1, selectedIDs.count)
// **This lane's folder the container every one of its cards resolves its hero against**
// (`CardHero.imageURL(for:inContainer:)`), hoisted for `slots`' reason: it is one value for
// the whole lane, and building it inside the `ForEach` closure would mint the same URL once
// per card. A face cannot resolve its own path it knows its card and its container, not
// where the card sits and finding it from the snapshot would be a board walk per face.
let cardsFolder = store.rootURL.appendingPathComponent(lane.id.rawValue, isDirectory: true)
return ScrollView(.vertical) {
// Cards stay standard width whatever the lane spans: at a slot width of
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
@@ -1057,6 +1063,7 @@ struct LaneView: View, Equatable {
role: .board(openCard: openCard),
marquee: marquee,
drops: drops,
hero: CardHero.imageURL(for: card, inContainer: cardsFolder),
isSelected: selectedIDs.contains(card.id),
// 1 for an unselected face: the replica's fan and count badge want
// "how many ride along", and a card outside the selection drags
+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