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:
@@ -794,6 +794,70 @@ struct FrontmatterLenientFieldTests {
|
||||
#expect(try document("collapsed: true").unknownFields.isEmpty)
|
||||
}
|
||||
|
||||
/// **`hero` reads like `title` with a gate after it** (03-board-ui.md § Card face ▸ Hero image;
|
||||
/// 01-storage-format.md § Frontmatter's card table): the string family's scalar coercion, so a
|
||||
/// quoted name reads as its own text and an unquoted one as its source span — a filename that
|
||||
/// looks like a number included.
|
||||
@Test func heroReadsAnyScalarThatCouldNameAFile() throws {
|
||||
#expect(try document("hero: sketch.png").hero == .valid("sketch.png"))
|
||||
#expect(try document("hero: \"a picture.jpg\"").hero == .valid("a picture.jpg"))
|
||||
// A scalar YAML types as something else still reads as the text the author typed — the same
|
||||
// coercion `title: 2048` gets, and `2048.png` is a perfectly good filename.
|
||||
#expect(try document("hero: 2048").hero == .valid("2048"))
|
||||
// Nothing about the *name* is policed beyond the gate below: a leading dot, a tilde and a
|
||||
// space are all a filename's business, and one that names nothing simply resolves to nothing.
|
||||
#expect(try document("hero: \".hidden.png\"").hero == .valid(".hidden.png"))
|
||||
#expect(try document("hero: \"~shot.png\"").hero == .valid("~shot.png"))
|
||||
}
|
||||
|
||||
/// **A path is not a hero image spelled awkwardly — it is a value with no reading** (the bare
|
||||
/// filename grammar, ruled 2026-08-09). Every separator position refuses: a subfolder, a climb
|
||||
/// out, an absolute path, and a trailing slash. So do the two directory entries every folder
|
||||
/// carries, and the empty string.
|
||||
@Test func heroIsMalformedForEveryValueThatIsNotABareFilename() throws {
|
||||
#expect(try document("hero: art/sketch.png").hero == .malformed(raw: "art/sketch.png"))
|
||||
#expect(try document("hero: ../sketch.png").hero == .malformed(raw: "../sketch.png"))
|
||||
#expect(try document("hero: /etc/passwd").hero == .malformed(raw: "/etc/passwd"))
|
||||
// The mistake the key most invites: the folder is implied, so naming it is naming a path.
|
||||
#expect(try document("hero: attachments/sketch.png").hero == .malformed(raw: "attachments/sketch.png"))
|
||||
#expect(try document("hero: sketch.png/").hero == .malformed(raw: "sketch.png/"))
|
||||
// The two entries every folder carries, which name a directory rather than a file.
|
||||
#expect(try document("hero: .").hero == .malformed(raw: "."))
|
||||
#expect(try document("hero: ..").hero == .malformed(raw: ".."))
|
||||
// Empty can only be written quoted — unquoted is null, which is an absence (below). The raw
|
||||
// is the span as written, quotes included, like every other malformed value's.
|
||||
#expect(try document("hero: \"\"").hero == .malformed(raw: "\"\""))
|
||||
// And the string family's own floor: a sequence or mapping has no scalar reading at all.
|
||||
#expect(try document("hero: [a.png]").hero == .malformed(raw: "[a.png]"))
|
||||
#expect(try document("hero: {name: a.png}").hero == .malformed(raw: "{name: a.png}"))
|
||||
}
|
||||
|
||||
/// **No key is no banner, and that is an absence rather than a failure** — nothing to report and
|
||||
/// nothing to render, which is the card every board is made of.
|
||||
@Test func heroIsMissingWhenTheKeyIsAbsentOrNull() throws {
|
||||
#expect(try document("schema: 1").hero == .missing)
|
||||
#expect(try document("hero: null").hero == .missing)
|
||||
#expect(try document("schema: 1").coercedFields.isEmpty)
|
||||
}
|
||||
|
||||
/// A lenient field with no reading files a coerce-tier trace and leaves the bytes exactly as
|
||||
/// written — the family's posture, `hero` included.
|
||||
@Test func anUnreadableHeroFilesATraceAndRoundTrips() throws {
|
||||
let text = "---\nschema: 1\nkind: card\nhero: art/sketch.png\n---\nbody\n"
|
||||
let parsed = try FrontmatterDocument.parse(text)
|
||||
#expect(parsed.serialized() == text)
|
||||
#expect(parsed.coercedFields == [CoercedField(key: "hero", raw: "art/sketch.png")])
|
||||
// A readable name is an absence of trace, not a trace of a value.
|
||||
#expect(try document("hero: sketch.png").coercedFields.isEmpty)
|
||||
}
|
||||
|
||||
/// The key is the schema's, so the card window's Details section does not list it beside a user's
|
||||
/// own overlay keys — `iconColor`'s posture exactly: schema yes, control no.
|
||||
@Test func heroIsSchemaOwnedRatherThanAnUnknownKey() throws {
|
||||
#expect(FrontmatterKeys.schemaOwned.contains(FrontmatterKeys.hero))
|
||||
#expect(try document("hero: sketch.png").unknownFields.isEmpty)
|
||||
}
|
||||
|
||||
@Test func malformedLenientValuesStillRoundTrip() throws {
|
||||
let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n"
|
||||
let document = try FrontmatterDocument.parse(text)
|
||||
|
||||
Reference in New Issue
Block a user