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
+20 -2
View File
@@ -96,7 +96,13 @@ enum AgentGuide {
/// lanes by editing frontmatter and, more to the point, has to know that `collapsed: true` is why
/// a lane it wrote a card into is not showing it. One clause beside `width` in Frontmatter, with the
/// remove-to-expand rule stated because writing `false` is the mistake the key invites.
static let version = 12
/// **v13 names the card's `hero` key** (03-board-ui.md § Card face Hero image): a card face draws
/// one of the card's own attachments as a banner, and since there is no in-app setter this version,
/// an agent writing the key *is* how a hero image gets set. One clause beside the other card keys
/// in Frontmatter, spelling the grammar the reading enforces a bare filename, never a path
/// because a path is exactly what an agent that has just written `![](attachments/x.png)` into a
/// body will reach for.
static let version = 13
// MARK: - The version marker
@@ -409,6 +415,16 @@ enum AgentGuide {
absent key is the default, and the app removes it too. A lane's `width`
rides along untouched while it is folded.
Cards may set `hero` — **the bare filename of one of that card's own
attachments** (`hero: sketch.png`), which the app draws as a banner across
the top of the card's face. **A filename, never a path**: `attachments/` is
implied, so `hero: attachments/sketch.png` and any other value containing a
`/` name nothing and draw nothing. The file has to sit directly in that
card's `attachments/` folder (Attachments below); a name that is missing,
unreadable or not an image draws no banner and is otherwise harmless, so a
hero set before the file arrives simply starts working when it does. There
is no control for this in the app — writing the key is how a hero gets set.
**Quote any `title` containing a colon** — `title: Fix: the thing` is
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
containing `: ` or starting with `#`, `[`, `{`, or a quote — when in doubt,
@@ -548,7 +564,9 @@ enum AgentGuide {
If the name is taken, pick a free one Finder-style (`shot.png` →
`shot 2.png`) — never overwrite.
- Reference attachments from the card body by relative path:
`![](attachments/sketch.png)`.
`![](attachments/sketch.png)`. To put that same picture on the card's
face, add `hero: sketch.png` to the frontmatter — the bare name, without
the folder (Frontmatter above).
- Subfolders under `attachments/` are tolerated but the app never creates
or lists them — keep attachments top-level.
+2
View File
@@ -828,6 +828,7 @@ public enum BoardLoader: Sendable {
background: document.background,
icon: document.icon,
iconColor: document.iconColor,
hero: document.hero,
order: order,
attachments: entry.attachments,
document: document
@@ -1137,6 +1138,7 @@ public enum BoardLoader: Sendable {
background: document.background,
icon: document.icon,
iconColor: document.iconColor,
hero: document.hero,
order: order,
attachments: attachments,
document: document
+14
View File
@@ -254,6 +254,20 @@ public struct Card: Identifiable, Sendable, Equatable {
public let icon: FieldValue<String>
public let iconColor: FieldValue<String>
/// **The card's hero image** (03-board-ui.md § Card face Hero image) the bare filename of one
/// of this card's own attachments, drawn as a banner across the top of its face.
///
/// **Card-level only**, which is why `Lane` and `BoardModel` carry no twin: a lane has no face to
/// band and a board already has a backdrop. The reading is `FrontmatterDocument.hero`'s a bare
/// filename or nothing and this carries the *shape* rather than the answer so the coerce tier
/// can report on a value that had no reading.
///
/// A **name, not a location**, and not a promise: where it resolves is the face's question
/// (`CardHero.imageURL(for:inContainer:)`), and a name that leads to a missing file, an
/// unreadable one or a non-image draws no banner at all the card renders exactly as one with no
/// key, and the bytes stay as written.
public let hero: FieldValue<String>
/// Rank within its lane, ascending = top-to-bottom the reading, not necessarily the key. See
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
+18 -2
View File
@@ -574,6 +574,22 @@ public enum FrontmatterKeys {
/// next edits the key.
public static let collapsed = "collapsed"
/// **A card's hero image** (03-board-ui.md § Card face Hero image; 01-storage-format.md
/// § Frontmatter's card table) `hero: sketch.png`, a **bare filename** resolving inside that
/// card's own `attachments/` folder.
///
/// No paths and no URLs, which is the whole of the key's grammar and the reason its reading is
/// stricter than `background`'s `image` subkey: that one names a file anywhere under the board
/// root and reads as the path it was written as, while this one names one of the card's own
/// attachments the files the app already lists, relocates and moves with the card so a value
/// carrying a separator is not a hero image spelled awkwardly, it is a value with no reading at
/// all (`FrontmatterDocument.hero`).
///
/// **Opt-in, and hand-written**: there is no in-app setter this version (ruled 2026-08-09), so
/// the key is written by an author or an agent and the app only ever reads it. It joins
/// `schemaOwned` all the same it is Lanework's to interpret, not an unknown key riding along.
public static let hero = "hero"
public static let created = "created"
public static let modified = "modified"
public static let modifiedBy = "modified-by"
@@ -633,7 +649,7 @@ public enum FrontmatterKeys {
public static let author = "author"
public static let schemaOwned: Set<String> = [
schema, title, order, width, collapsed, created, modified, modifiedBy, deleted, background,
icon, iconColor, kind,
schema, title, order, width, collapsed, hero, created, modified, modifiedBy, deleted,
background, icon, iconColor, kind,
]
}
+42
View File
@@ -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.