diff --git a/DESIGN/01-storage-format.md b/DESIGN/01-storage-format.md index 47f1635..4596915 100644 --- a/DESIGN/01-storage-format.md +++ b/DESIGN/01-storage-format.md @@ -83,6 +83,7 @@ Body: lane description / WIP policy / notes. | Field | Type | Required | Notes | |---|---|---|---| | `order` | number | no | Rank within its lane, ascending = top-to-bottom. Optional (re-ruled 2026-07-31) — missing or unusable reads as append-at-end (Ordering below) | +| `hero` | string | no | The **bare filename** of one of this card's own attachments, drawn as a banner across the top of its card face (03-board-ui.md § Card face ▸ Hero image, ruled 2026-08-09). `attachments/` is implied and paths are not the grammar: a value carrying a path separator (or `.`/`..`/empty) has no reading and is malformed — it renders as absent and files a coerce-tier trace, like any other no-sensible-reading fallback. A name that resolves to a missing, unreadable or non-image file renders exactly as no key at all — no banner, no defect, bytes untouched. **No in-app setter this version** (files-first opt-in — `iconColor`'s schema-yes-control-no posture); the agent guide teaches the key | Body: the card's content — the whole point. diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index 15bc90e..6d703e7 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -27,6 +27,7 @@ Toolbars are **pure enhancement**: every function they host already has a menu i - Leading icon + title. The only face chip in scope is **attachments** (a quiet indicator when the card has files — the title dominates). Metadata chips (labels/assignees/due) went to the enhanced schema with their fields — out of scope. - **No body excerpt** (settled): the face stays title-only — the old "iterate on the card face later" item is closed with no growth. +- **Hero image** (ruled 2026-08-09): a card whose `hero` key names one of its own attachments (a bare filename — 01-storage-format.md § Frontmatter's card table) draws it as a **banner across the full plate width above the icon-and-title row**, aspect-fill cropped to a fixed em-scaled band (2.75 em — 36pt at the standard body, so it scales with system text size and board zoom like every other face figure). Opt-in and hand-written: **no in-app setter this version** — "Set as Hero" from the attachment row is future work. A key that is absent, malformed, or names a file that is missing, unreadable or not an image renders **exactly as no key at all** — the band has no height until a picture actually decodes, so the degrade is structural rather than a branch. Everything else the face draws is unchanged and sits outside the band: the accent stripe runs the plate's full leading edge over it, the selection and file-hover strokes ring the whole plate, the cut/drag dims cover it, and the drop model registers the plate's real height — a hero card is simply a taller card. The trash draws it too (the one-face rule). The drag replica carries the band when the picture is already decoded, which it is for any card the user can see to drag. - **Titles are optional at every level.** On cards and lanes, a missing `title` renders as a quiet placeholder ("Untitled", secondary styling) wherever the title would appear. On boards, the fallback is the folder name (sans extension), never "Untitled" — see 01-storage-format.md's board-naming rule; window title and welcome recents show `title` when present, folder name otherwise. - Attachments on the face: **the chip only — there is no face carousel** (resettled 2026-07-28, reversing the carry-over): the pathfinder's selection-keyed in-place expansion — compact unselected, media carousel when sole-selected — **proved undesirable and does not carry over**. A card has **one presentation**: selection changes styling (the selection treatment), never geometry, so the masonry never reflows on click and a card face is the same object whatever the selection state. The attachment chip above is the face's whole attachment story; viewing media is the card window's job (⌘↩ / double-click — the attachments section and QuickLook, 05-card-window.md). The earlier 2026-07-28 carousel settlements (metrics, tick paging, clamp, dots, marquee suppression, trash exclusion) are superseded with it — recorded on their Resolved cards. diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index 8376d05..e64704a 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -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. diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 515c4e2..6561b7b 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -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 diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index ed4b1f0..d57bc8f 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -254,6 +254,20 @@ public struct Card: Identifiable, Sendable, Equatable { public let icon: FieldValue public let iconColor: FieldValue + /// **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 + /// 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 diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift index c6c9df0..24a54b5 100644 --- a/Kanban/Storage/FrontmatterDocument.swift +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -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 = [ - 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, ] } diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift index 1b757e5..a1d17dc 100644 --- a/Kanban/Storage/FrontmatterFields.swift +++ b/Kanban/Storage/FrontmatterFields.swift @@ -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 { + 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 { read(FrontmatterKeys.created) { value, _ in Self.date(value) } } public var modified: FieldValue { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } } public var deleted: FieldValue { 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. diff --git a/Kanban/UI/Board/BoardBackdropImage.swift b/Kanban/UI/Board/BoardBackdropImage.swift index 2d9b43a..122f784 100644 --- a/Kanban/UI/Board/BoardBackdropImage.swift +++ b/Kanban/UI/Board/BoardBackdropImage.swift @@ -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 diff --git a/Kanban/UI/Board/BoardMetrics.swift b/Kanban/UI/Board/BoardMetrics.swift index 6e340d4..f0eb73e 100644 --- a/Kanban/UI/Board/BoardMetrics.swift +++ b/Kanban/UI/Board/BoardMetrics.swift @@ -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.5–3× 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) diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 7c31962..387bd2b 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -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 diff --git a/Kanban/UI/Board/CardHeroImage.swift b/Kanban/UI/Board/CardHeroImage.swift new file mode 100644 index 0000000..c772d41 --- /dev/null +++ b/Kanban/UI/Board/CardHeroImage.swift @@ -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, `/.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 + } +} diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 53f0b3f..b79a4b6 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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 diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index a795ced..7c115cf 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -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, + 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 diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 3b2a1d9..0930c35 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -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 diff --git a/KanbanTests/BoardZoomTests.swift b/KanbanTests/BoardZoomTests.swift index dae0449..3e9aa67 100644 --- a/KanbanTests/BoardZoomTests.swift +++ b/KanbanTests/BoardZoomTests.swift @@ -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) }), diff --git a/KanbanTests/CardHeroTests.swift b/KanbanTests/CardHeroTests.swift new file mode 100644 index 0000000..cc065ff --- /dev/null +++ b/KanbanTests/CardHeroTests.swift @@ -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) + } +} diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index db5ac9d..8b285f4 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -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) diff --git a/KanbanTests/ViewEquatableTests.swift b/KanbanTests/ViewEquatableTests.swift index dcc4677..49b0188 100644 --- a/KanbanTests/ViewEquatableTests.swift +++ b/KanbanTests/ViewEquatableTests.swift @@ -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 )) } } diff --git a/KanbanTests/VisualAccommodationsTests.swift b/KanbanTests/VisualAccommodationsTests.swift index ee5978e..4d248d4 100644 --- a/KanbanTests/VisualAccommodationsTests.swift +++ b/KanbanTests/VisualAccommodationsTests.swift @@ -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) }),