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:
@@ -1065,6 +1065,43 @@ struct BoardLoaderOptionalOrderTests {
|
||||
#expect(byID[ranked] == 1024)
|
||||
#expect(byID[orderless] == 2048)
|
||||
}
|
||||
|
||||
/// **`hero` rides onto the card model, on both sides of the container boundary** (03-board-ui.md
|
||||
/// § Card face ▸ Hero image). The trash half is the one worth writing down: a trashed card is an
|
||||
/// ordinary card in a special place and draws the same face, so a snapshot that dropped the key
|
||||
/// on the way through `.trash/` would silently un-band every deleted card.
|
||||
///
|
||||
/// The malformed case rides too, as a *shape* rather than a value: the coerce tier reports on it
|
||||
/// (`coercedFrontmatter`), and the face reads it as no hero at all.
|
||||
@Test func heroCarriesOntoCardsInBothContainers() throws {
|
||||
let fixture = try BoardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let lane = uuidFolderName()
|
||||
let card = uuidFolderName()
|
||||
let pathy = uuidFolderName()
|
||||
let bare = uuidFolderName()
|
||||
let trashed = uuidFolderName()
|
||||
|
||||
try fixture.index("", "schema: 1\n")
|
||||
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||
try fixture.index("\(lane)/\(card)", "schema: 1\nkind: card\norder: 1024\nhero: sketch.png\n")
|
||||
try fixture.index("\(lane)/\(pathy)", "schema: 1\nkind: card\norder: 2048\nhero: art/sketch.png\n")
|
||||
try fixture.index("\(lane)/\(bare)", "schema: 1\nkind: card\norder: 3072\n")
|
||||
try fixture.index(".trash/\(trashed)", "schema: 1\nkind: card\nhero: cover.jpg\n")
|
||||
|
||||
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||
let cards = Dictionary(uniqueKeysWithValues: result.model.lanes[0].cards.map { ($0.id.rawValue, $0.hero) })
|
||||
#expect(cards[card] == .valid("sketch.png"))
|
||||
#expect(cards[pathy] == .malformed(raw: "art/sketch.png"))
|
||||
#expect(cards[bare] == .missing)
|
||||
#expect(result.model.trash.map(\.hero) == [.valid("cover.jpg")])
|
||||
|
||||
let byPath = Dictionary(uniqueKeysWithValues:
|
||||
result.coercedFrontmatter.map { ($0.path, $0.fields.map(\.key)) })
|
||||
#expect(byPath["\(lane)/\(pathy)/index.md"] == ["hero"])
|
||||
#expect(byPath["\(lane)/\(card)/index.md"] == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Encoding strictness
|
||||
|
||||
@@ -208,6 +208,7 @@ struct BoardZoomMetricsTests {
|
||||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||||
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
||||
("cardHeroHeight", { BoardMetrics.cardHeroHeight(bodyPointSize: $0) }),
|
||||
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
||||
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
||||
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The card face's hero image** (03-board-ui.md § Card face ▸ Hero image; the card's `hero` key,
|
||||
/// 01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// The *reading* lives with its siblings in `FrontmatterTests` and the *metric* with the rest of the
|
||||
/// board's geometry in `VisualAccommodationsTests`/`BoardZoomTests`; what is here is everything new:
|
||||
/// where a name resolves (`CardHero`), what the band can actually draw (the decode), and the shared
|
||||
/// cache the drag replica depends on being able to read synchronously (`CardHeroCache`).
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A card folder with an `attachments/` inside it, under a temp root.
|
||||
private struct CardFixture {
|
||||
let root: URL
|
||||
let lane = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
let card = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
|
||||
var cardFolder: URL {
|
||||
root
|
||||
.appendingPathComponent(lane, isDirectory: true)
|
||||
.appendingPathComponent(card, isDirectory: true)
|
||||
}
|
||||
|
||||
var attachments: URL {
|
||||
cardFolder.appendingPathComponent("attachments", isDirectory: true)
|
||||
}
|
||||
|
||||
init() throws {
|
||||
root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("CardHeroTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
}
|
||||
|
||||
/// A real PNG of the given pixel size — the only way to test a decode honestly, since the whole
|
||||
/// question is what ImageIO makes of actual bytes.
|
||||
@discardableResult
|
||||
private func writePNG(at url: URL, width: Int, height: Int) throws -> URL {
|
||||
let context = try #require(CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
))
|
||||
context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
let image = try #require(context.makeImage())
|
||||
let destination = try #require(
|
||||
CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil))
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
#expect(CGImageDestinationFinalize(destination))
|
||||
return url
|
||||
}
|
||||
|
||||
/// The card as the loader reads it — the model value the face is handed, rather than one assembled
|
||||
/// by hand, so the carry-through and the resolution are exercised against the same thing.
|
||||
private func loadedCard(_ fixture: CardFixture, frontmatter: String) throws -> Card {
|
||||
func index(_ folder: URL, _ text: String) throws {
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
try "---\n\(text)---\n"
|
||||
.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
|
||||
}
|
||||
try index(fixture.root, "schema: 1\n")
|
||||
try index(fixture.root.appendingPathComponent(fixture.lane, isDirectory: true), "schema: 1\norder: 1024\n")
|
||||
try index(fixture.cardFolder, frontmatter)
|
||||
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||
return try #require(model.lanes.first?.cards.first)
|
||||
}
|
||||
|
||||
// MARK: - Where a name resolves
|
||||
|
||||
@Suite("Card hero ▸ the name resolves inside the card's own attachments")
|
||||
struct CardHeroResolutionTests {
|
||||
|
||||
/// The whole grammar: a bare filename lands directly in this card's `attachments/`, which is what
|
||||
/// makes a hero survive every move, copy, trash and restore the card takes — the folder travels
|
||||
/// with it and nothing has to be rewritten.
|
||||
@Test("A bare filename lands in this card's attachments folder")
|
||||
func aBareNameResolves() throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
#expect(CardHero.imageURL(named: "sketch.png", inCardFolder: fixture.cardFolder)?.path
|
||||
== fixture.attachments.appendingPathComponent("sketch.png").path)
|
||||
// Nothing about the name is policed here beyond the shape: a dot file, a tilde and spaces are
|
||||
// all filenames, and one naming nothing simply decodes to nothing later.
|
||||
#expect(CardHero.imageURL(named: ".hidden.png", inCardFolder: fixture.cardFolder) != nil)
|
||||
#expect(CardHero.imageURL(named: "~a shot 2.png", inCardFolder: fixture.cardFolder) != nil)
|
||||
}
|
||||
|
||||
/// **Belt over the reading's braces.** `FrontmatterDocument.hero` already refuses a path, so
|
||||
/// nothing pathy should ever reach here — but a lenient reading must never be the only thing
|
||||
/// standing between a value and the filesystem, and this is the layer that builds the URL.
|
||||
///
|
||||
/// The last two are what makes this stricter than `BoardBackdrop`'s check: "inside the folder"
|
||||
/// and "directly inside the folder" are different promises, and an attachment is the second.
|
||||
@Test("Anything that is not a name resolves nowhere")
|
||||
func pathsResolveNowhere() throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
#expect(CardHero.imageURL(named: "", inCardFolder: fixture.cardFolder) == nil)
|
||||
#expect(CardHero.imageURL(named: "art/sketch.png", inCardFolder: fixture.cardFolder) == nil)
|
||||
#expect(CardHero.imageURL(named: "../sketch.png", inCardFolder: fixture.cardFolder) == nil)
|
||||
#expect(CardHero.imageURL(named: "/etc/passwd", inCardFolder: fixture.cardFolder) == nil)
|
||||
#expect(CardHero.imageURL(named: "attachments/sketch.png", inCardFolder: fixture.cardFolder) == nil)
|
||||
#expect(CardHero.imageURL(named: ".", inCardFolder: fixture.cardFolder) == nil)
|
||||
#expect(CardHero.imageURL(named: "..", inCardFolder: fixture.cardFolder) == nil)
|
||||
}
|
||||
|
||||
/// The card-level entry point, on the board side: the container is the lane folder, and the card
|
||||
/// folder is appended here so the face never has to know its own path.
|
||||
@Test("A card's hero resolves under its container")
|
||||
func aCardResolvesUnderItsContainer() throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let card = try loadedCard(fixture, frontmatter: "schema: 1\nkind: card\norder: 1024\nhero: sketch.png\n")
|
||||
let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true)
|
||||
#expect(CardHero.imageURL(for: card, inContainer: lane)?.path
|
||||
== fixture.attachments.appendingPathComponent("sketch.png").path)
|
||||
}
|
||||
|
||||
/// **The no-key-no-change identity** — the card every board is made of. No key means no reading,
|
||||
/// no URL, no band, no trace, and bytes that come back exactly as they went in.
|
||||
@Test("A card with no hero key resolves to nothing and changes nothing")
|
||||
func noKeyIsNoChange() throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let text = "schema: 1\nkind: card\norder: 1024\ntitle: Plain\n"
|
||||
let card = try loadedCard(fixture, frontmatter: text)
|
||||
let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true)
|
||||
|
||||
#expect(card.hero == .missing)
|
||||
#expect(CardHero.imageURL(for: card, inContainer: lane) == nil)
|
||||
#expect(card.document.coercedFields.isEmpty)
|
||||
#expect(card.document.serialized() == "---\n\(text)---\n")
|
||||
}
|
||||
|
||||
/// A malformed key is the same absence with a trace behind it: the face draws no band, and the
|
||||
/// value the author wrote is still on disk for them to fix.
|
||||
@Test("A pathy hero resolves to nothing, and the bytes stay as written")
|
||||
func aMalformedKeyResolvesToNothing() throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let text = "schema: 1\nkind: card\norder: 1024\nhero: art/sketch.png\n"
|
||||
let card = try loadedCard(fixture, frontmatter: text)
|
||||
let lane = fixture.root.appendingPathComponent(fixture.lane, isDirectory: true)
|
||||
|
||||
#expect(card.hero == .malformed(raw: "art/sketch.png"))
|
||||
#expect(CardHero.imageURL(for: card, inContainer: lane) == nil)
|
||||
#expect(card.document.serialized() == "---\n\(text)---\n")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - What the band can draw
|
||||
|
||||
@Suite("Card hero ▸ what the band can draw")
|
||||
struct CardHeroDecodeTests {
|
||||
|
||||
/// A hero is decoded **downsampled**, at the card's own limit rather than the backdrop's: 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.
|
||||
@Test("An image decodes downsampled to the hero's own limit")
|
||||
func anImageDecodesDownsampled() throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let url = try writePNG(
|
||||
at: fixture.attachments.appendingPathComponent("sketch.png"), width: 2400, height: 1200)
|
||||
let decoded = try #require(BoardBackdrop.decode(url, limit: CardHero.maximumPixelSize))
|
||||
#expect(decoded.width == CardHero.maximumPixelSize)
|
||||
#expect(decoded.height == CardHero.maximumPixelSize / 2)
|
||||
// The parameter is the whole reason the backdrop's decode was widened rather than copied.
|
||||
#expect(CardHero.maximumPixelSize < BoardBackdrop.maximumPixelSize)
|
||||
}
|
||||
|
||||
/// **Missing, unreadable, and not-an-image all end the same way** — no picture, so no band, so a
|
||||
/// face that renders exactly as one with no key (the ruling). No defect, no badge, and nothing
|
||||
/// written.
|
||||
@Test("A missing or non-image file decodes to nothing")
|
||||
func nonImagesDecodeToNothing() throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
|
||||
let missing = fixture.attachments.appendingPathComponent("gone.png")
|
||||
#expect(BoardBackdrop.decode(missing, limit: CardHero.maximumPixelSize) == nil)
|
||||
|
||||
let notes = fixture.attachments.appendingPathComponent("notes.txt")
|
||||
try "not a picture".write(to: notes, atomically: true, encoding: .utf8)
|
||||
#expect(BoardBackdrop.decode(notes, limit: CardHero.maximumPixelSize) == nil)
|
||||
|
||||
// The liar: an image extension over text. The extension is not what is read.
|
||||
let liar = fixture.attachments.appendingPathComponent("fake.png")
|
||||
try "not a picture either".write(to: liar, atomically: true, encoding: .utf8)
|
||||
#expect(BoardBackdrop.decode(liar, limit: CardHero.maximumPixelSize) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The shared cache
|
||||
|
||||
/// Serialized because the cache is app-wide by design (see `CardHeroCache`) — one suite's fixtures
|
||||
/// must not decide another's hits.
|
||||
@MainActor
|
||||
@Suite("Card hero ▸ the shared cache", .serialized)
|
||||
struct CardHeroCacheTests {
|
||||
|
||||
/// **The claim the drag replica rests on**: a picture already on screen can be read back
|
||||
/// *synchronously*, with no `stat` and no decode. A preview builder is non-escaping — it runs
|
||||
/// while the body does — so a replica that had to await anything would lift a card with a hole
|
||||
/// where its banner is.
|
||||
@Test("A loaded hero reads back synchronously")
|
||||
func aLoadedHeroReadsBackSynchronously() async throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
CardHeroCache.removeAll()
|
||||
|
||||
let url = try writePNG(
|
||||
at: fixture.attachments.appendingPathComponent("sketch.png"), width: 800, height: 400)
|
||||
#expect(CardHeroCache.image(forFileAt: url) == nil)
|
||||
|
||||
let loaded = await CardHeroCache.load(url)
|
||||
#expect(loaded != nil)
|
||||
#expect(CardHeroCache.image(forFileAt: url) != nil)
|
||||
}
|
||||
|
||||
/// A file that is not an image caches nothing — and, crucially, is not *remembered* as an image
|
||||
/// either: the synchronous read stays empty, so the replica draws the same hero-less face the
|
||||
/// board does.
|
||||
@Test("A non-image caches nothing")
|
||||
func aNonImageCachesNothing() async throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
CardHeroCache.removeAll()
|
||||
|
||||
let notes = fixture.attachments.appendingPathComponent("notes.txt")
|
||||
try "not a picture".write(to: notes, atomically: true, encoding: .utf8)
|
||||
|
||||
#expect(await CardHeroCache.load(notes) == nil)
|
||||
#expect(CardHeroCache.image(forFileAt: notes) == nil)
|
||||
}
|
||||
|
||||
/// **Replaced bytes key differently** — the freshness posture `AttachmentThumbnailCache` already
|
||||
/// takes, and the reason there is no invalidation path to keep honest: the stamp is part of the
|
||||
/// key, so a rewritten file cannot be answered with the old picture.
|
||||
@Test("A replaced file keys differently")
|
||||
func replacedBytesKeyDifferently() async throws {
|
||||
let fixture = try CardFixture()
|
||||
defer { fixture.tearDown() }
|
||||
CardHeroCache.removeAll()
|
||||
|
||||
// Both are wider than the decode limit, so both come back at it — the *shape* is what tells
|
||||
// them apart, which is exactly what a stale entry could not fake.
|
||||
let url = fixture.attachments.appendingPathComponent("sketch.png")
|
||||
try writePNG(at: url, width: 2048, height: 1024)
|
||||
let first = await CardHeroCache.load(url)
|
||||
#expect(first?.height == CardHero.maximumPixelSize / 2)
|
||||
|
||||
try FileManager.default.removeItem(at: url)
|
||||
try writePNG(at: url, width: 2048, height: 512)
|
||||
let second = await CardHeroCache.load(url)
|
||||
#expect(second?.height == CardHero.maximumPixelSize / 4)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -262,7 +262,7 @@ struct CardFaceViewEquatableTests {
|
||||
role: .board(openCard: { _ in }),
|
||||
marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
isSelected: false,
|
||||
hero: nil, isSelected: false,
|
||||
selectedCount: 1
|
||||
)
|
||||
let after = CardFaceView(
|
||||
@@ -271,7 +271,7 @@ struct CardFaceViewEquatableTests {
|
||||
role: .board(openCard: { _ in Issue.record("the gate must not care which opener it holds") }),
|
||||
marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry),
|
||||
isSelected: false,
|
||||
hero: nil, isSelected: false,
|
||||
selectedCount: 1
|
||||
)
|
||||
#expect(before == after)
|
||||
@@ -290,7 +290,7 @@ struct CardFaceViewEquatableTests {
|
||||
|
||||
let unselected = CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1
|
||||
)
|
||||
|
||||
// The gate is now the *only* thing standing between a click and this face's repaint: nothing
|
||||
@@ -298,21 +298,50 @@ struct CardFaceViewEquatableTests {
|
||||
// leave a selected card wearing no accent ring at all.
|
||||
#expect(unselected != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 1
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
|
||||
))
|
||||
|
||||
// And the count, which the drag replica's fan and count badge are drawn from: a card that is
|
||||
// still selected but now travels with four others has a different image under the cursor.
|
||||
let alone = CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 1
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 1
|
||||
)
|
||||
#expect(alone != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: true, selectedCount: 5
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: true, selectedCount: 5
|
||||
))
|
||||
}
|
||||
|
||||
/// **The hero is a compared input too** (03-board-ui.md § Card face ▸ Hero image) — a resolution
|
||||
/// the parent does, like selected-ness, and the one input that changes the face's *height*. A gate
|
||||
/// that swallowed it would leave a card banding a picture it no longer names, or naming one it
|
||||
/// never draws.
|
||||
@Test("The resolved hero file is a difference")
|
||||
func theHeroIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let card = try firstCard(fixture.snapshot())
|
||||
let marquee = MarqueeControl(
|
||||
session: MarqueeSession(), registry: MarqueeTargetRegistry(), store: store
|
||||
)
|
||||
let drops = makeDrops(store: store, session: DragSession(), registry: LaneDropRegistry())
|
||||
let sketch = fixture.root.appendingPathComponent("sketch.png")
|
||||
|
||||
func face(_ hero: URL?) -> CardFaceView {
|
||||
CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, hero: hero, isSelected: false, selectedCount: 1
|
||||
)
|
||||
}
|
||||
|
||||
#expect(face(nil) == face(nil))
|
||||
#expect(face(sketch) == face(sketch))
|
||||
#expect(face(nil) != face(sketch))
|
||||
#expect(face(sketch) != face(fixture.root.appendingPathComponent("cover.png")))
|
||||
}
|
||||
|
||||
@Test("An edited card is unequal — the gate never withholds a repaint")
|
||||
func anEditedCardIsADifference() throws {
|
||||
let fixture = try makeFixture()
|
||||
@@ -331,18 +360,18 @@ struct CardFaceViewEquatableTests {
|
||||
#expect(before != after)
|
||||
|
||||
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
|
||||
|
||||
// And a different card, which is the ordinary within-lane case.
|
||||
let sibling = try #require(try firstLane(fixture.snapshot()).cards.first {
|
||||
$0.id == ItemID(rawValue: Ident.card2)
|
||||
})
|
||||
#expect(CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
!= CardFaceView(store: store, card: sibling, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1))
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The two homes are never equal, and the trash's confirmation host is compared by identity")
|
||||
@@ -360,21 +389,21 @@ struct CardFaceViewEquatableTests {
|
||||
let confirmations = TrashConfirmations()
|
||||
|
||||
let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
let trash = CardFaceView(store: store, card: card, role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
// The role decides which container a click selects in, whether the face has an Open gesture
|
||||
// at all, and whether Delete is the permanent one — never a difference to swallow.
|
||||
#expect(board != trash)
|
||||
#expect(trash == CardFaceView(store: store, card: card,
|
||||
role: .trash(confirmations: confirmations),
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
hero: nil, isSelected: false, selectedCount: 1))
|
||||
// Window-lived state, so identity is meaningful as well as cheap.
|
||||
#expect(trash != CardFaceView(store: store, card: card,
|
||||
role: .trash(confirmations: TrashConfirmations()),
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
hero: nil, isSelected: false, selectedCount: 1))
|
||||
}
|
||||
|
||||
@Test("The window-lived collaborators are compared by identity, the strip's gap by value")
|
||||
@@ -391,35 +420,35 @@ struct CardFaceViewEquatableTests {
|
||||
let marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
|
||||
let drops = makeDrops(store: store, session: session, registry: registry)
|
||||
let base = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops, isSelected: false, selectedCount: 1)
|
||||
marquee: marquee, drops: drops, hero: nil, isSelected: false, selectedCount: 1)
|
||||
|
||||
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: marquee, drops: drops,
|
||||
isSelected: false, selectedCount: 1))
|
||||
hero: nil, isSelected: false, selectedCount: 1))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: DragSession(), registry: registry),
|
||||
isSelected: false, selectedCount: 1
|
||||
hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
|
||||
isSelected: false, selectedCount: 1
|
||||
hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
|
||||
isSelected: false, selectedCount: 1
|
||||
hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store),
|
||||
drops: drops, isSelected: false, selectedCount: 1
|
||||
drops: drops, hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
#expect(base != CardFaceView(
|
||||
store: store, card: card, role: .board(openCard: { _ in }),
|
||||
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store),
|
||||
drops: drops, isSelected: false, selectedCount: 1
|
||||
drops: drops, hero: nil, isSelected: false, selectedCount: 1
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ struct BoardMetricsSettledFiguresTests {
|
||||
#expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) == 18)
|
||||
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
||||
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
|
||||
// The hero band (03-board-ui.md § Card face ▸ Hero image) — 36pt at the standard body, inside
|
||||
// the ruling's 2.5–3× and deliberately under the 44pt a plain one-line card is tall, so the
|
||||
// title still dominates the face it bands.
|
||||
#expect(BoardMetrics.cardHeroHeight(bodyPointSize: size) == 36)
|
||||
#expect(BoardMetrics.cardHeroHeight(bodyPointSize: size)
|
||||
< BoardMetrics.nominalCardHeight(bodyPointSize: size))
|
||||
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
|
||||
#expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
|
||||
#expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
|
||||
@@ -102,6 +108,7 @@ struct BoardMetricsScalingTests {
|
||||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||||
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
||||
("cardHeroHeight", { BoardMetrics.cardHeroHeight(bodyPointSize: $0) }),
|
||||
("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
|
||||
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
||||
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
||||
|
||||
Reference in New Issue
Block a user