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:
@@ -116,6 +116,7 @@ extension FrontmatterDocument {
|
||||
record(FrontmatterKeys.background, backgroundImage)
|
||||
record(FrontmatterKeys.icon, icon)
|
||||
record(FrontmatterKeys.iconColor, iconColor)
|
||||
record(FrontmatterKeys.hero, hero)
|
||||
record(FrontmatterKeys.kind, kind)
|
||||
return found
|
||||
}
|
||||
@@ -241,6 +242,34 @@ extension FrontmatterDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A card's hero image — one of its own attachments, by bare filename**
|
||||
/// (03-board-ui.md § Card face ▸ Hero image; `FrontmatterKeys.hero`).
|
||||
///
|
||||
/// The string family's scalar coercion with one gate after it: the reading has to be a name a
|
||||
/// card's `attachments/` folder could hold. A value carrying a path separator, or spelling one of
|
||||
/// the two directory entries every folder has (`.`, `..`), or empty, is **malformed** — it renders
|
||||
/// as no banner and leaves the coerce tier's trace, exactly as `width: 1.5` does.
|
||||
///
|
||||
/// **Deliberately stricter than `backgroundImage`, and the difference is grammar rather than
|
||||
/// caution.** A board's `image` subkey names a file anywhere under the board root, so a path *is*
|
||||
/// its reading and where it leads is the renderer's question. `hero` names one of the card's own
|
||||
/// attachments — the flat, top-level files 01-storage-format.md § Attachments already defines and
|
||||
/// that travel with the card through every move and copy — so `hero: art/sketch.png` is not an
|
||||
/// awkward spelling of a hero image, it is a value naming something the key cannot mean. Refusing
|
||||
/// it here is what makes "no paths, no URLs" a fact of the schema rather than a convention the
|
||||
/// resolver happens to enforce; the resolver re-checks containment anyway (`CardHero`), because a
|
||||
/// lenient reading must never be the only thing standing between a value and the filesystem.
|
||||
///
|
||||
/// A file that is missing, unreadable, or not an image is **not** this layer's business and is
|
||||
/// perfectly `.valid` here: those are facts about the disk, and they degrade at the face — no
|
||||
/// banner, no defect, bytes untouched (`CardHeroImage`).
|
||||
public var hero: FieldValue<String> {
|
||||
read(FrontmatterKeys.hero) { value, raw in
|
||||
guard let text = Self.string(value, raw: raw), Self.isBareFilename(text) else { return nil }
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
||||
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
||||
@@ -326,6 +355,19 @@ extension FrontmatterDocument {
|
||||
return value >= 1 ? Int(value) : 1
|
||||
}
|
||||
|
||||
/// Whether `text` is a name one folder could hold — the gate `hero` puts after the string
|
||||
/// coercion (see it for why the strictness lives at this layer).
|
||||
///
|
||||
/// Three refusals, and nothing else: a **path** (any `/`, wherever it sits — leading, trailing or
|
||||
/// interior, which covers absolute paths, subfolder paths and climbs out with `..` in one rule),
|
||||
/// the two **directory entries** every folder carries, and the **empty** string. A name is not
|
||||
/// checked for anything else — leading dots, spaces, `~`, and characters the author's filesystem
|
||||
/// may or may not accept are all a filename's business, and a name that names nothing simply
|
||||
/// resolves to nothing later.
|
||||
private static func isBareFilename(_ text: String) -> Bool {
|
||||
!text.isEmpty && !text.contains("/") && text != "." && text != ".."
|
||||
}
|
||||
|
||||
/// YAML 1.1's boolean words, for a **quoted** scalar that spells one — the coercion `collapsed`
|
||||
/// needs and no other field wants. Case-insensitive, because the grammar itself is (`True`, `ON`,
|
||||
/// `Yes` are all booleans unquoted); anything else has no boolean reading.
|
||||
|
||||
Reference in New Issue
Block a user