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:
@@ -83,6 +83,7 @@ Body: lane description / WIP policy / notes.
|
|||||||
| Field | Type | Required | 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) |
|
| `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.
|
Body: the card's content — the whole point.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- **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.
|
- **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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,13 @@ enum AgentGuide {
|
|||||||
/// lanes by editing frontmatter — and, more to the point, has to know that `collapsed: true` is why
|
/// 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
|
/// 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.
|
/// 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 `` into a
|
||||||
|
/// body will reach for.
|
||||||
|
static let version = 13
|
||||||
|
|
||||||
// MARK: - The version marker
|
// 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`
|
absent key is the default, and the app removes it too. A lane's `width`
|
||||||
rides along untouched while it is folded.
|
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
|
**Quote any `title` containing a colon** — `title: Fix: the thing` is
|
||||||
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
invalid YAML; write `title: "Fix: the thing"`. The same goes for any value
|
||||||
containing `: ` or starting with `#`, `[`, `{`, or a quote — when in doubt,
|
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` →
|
If the name is taken, pick a free one Finder-style (`shot.png` →
|
||||||
`shot 2.png`) — never overwrite.
|
`shot 2.png`) — never overwrite.
|
||||||
- Reference attachments from the card body by relative path:
|
- Reference attachments from the card body by relative path:
|
||||||
``.
|
``. 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
|
- Subfolders under `attachments/` are tolerated but the app never creates
|
||||||
or lists them — keep attachments top-level.
|
or lists them — keep attachments top-level.
|
||||||
|
|
||||||
|
|||||||
@@ -828,6 +828,7 @@ public enum BoardLoader: Sendable {
|
|||||||
background: document.background,
|
background: document.background,
|
||||||
icon: document.icon,
|
icon: document.icon,
|
||||||
iconColor: document.iconColor,
|
iconColor: document.iconColor,
|
||||||
|
hero: document.hero,
|
||||||
order: order,
|
order: order,
|
||||||
attachments: entry.attachments,
|
attachments: entry.attachments,
|
||||||
document: document
|
document: document
|
||||||
@@ -1137,6 +1138,7 @@ public enum BoardLoader: Sendable {
|
|||||||
background: document.background,
|
background: document.background,
|
||||||
icon: document.icon,
|
icon: document.icon,
|
||||||
iconColor: document.iconColor,
|
iconColor: document.iconColor,
|
||||||
|
hero: document.hero,
|
||||||
order: order,
|
order: order,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
document: document
|
document: document
|
||||||
|
|||||||
@@ -254,6 +254,20 @@ public struct Card: Identifiable, Sendable, Equatable {
|
|||||||
public let icon: FieldValue<String>
|
public let icon: FieldValue<String>
|
||||||
public let iconColor: FieldValue<String>
|
public let iconColor: FieldValue<String>
|
||||||
|
|
||||||
|
/// **The card's hero image** (03-board-ui.md § Card face ▸ Hero image) — the bare filename of one
|
||||||
|
/// of this card's own attachments, drawn as a banner across the top of its face.
|
||||||
|
///
|
||||||
|
/// **Card-level only**, which is why `Lane` and `BoardModel` carry no twin: a lane has no face to
|
||||||
|
/// band and a board already has a backdrop. The reading is `FrontmatterDocument.hero`'s — a bare
|
||||||
|
/// filename or nothing — and this carries the *shape* rather than the answer so the coerce tier
|
||||||
|
/// can report on a value that had no reading.
|
||||||
|
///
|
||||||
|
/// A **name, not a location**, and not a promise: where it resolves is the face's question
|
||||||
|
/// (`CardHero.imageURL(for:inContainer:)`), and a name that leads to a missing file, an
|
||||||
|
/// unreadable one or a non-image draws no banner at all — the card renders exactly as one with no
|
||||||
|
/// key, and the bytes stay as written.
|
||||||
|
public let hero: FieldValue<String>
|
||||||
|
|
||||||
/// Rank within its lane, ascending = top-to-bottom — the reading, not necessarily the key. See
|
/// 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
|
/// `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
|
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
|
||||||
|
|||||||
@@ -574,6 +574,22 @@ public enum FrontmatterKeys {
|
|||||||
/// next edits the key.
|
/// next edits the key.
|
||||||
public static let collapsed = "collapsed"
|
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 created = "created"
|
||||||
public static let modified = "modified"
|
public static let modified = "modified"
|
||||||
public static let modifiedBy = "modified-by"
|
public static let modifiedBy = "modified-by"
|
||||||
@@ -633,7 +649,7 @@ public enum FrontmatterKeys {
|
|||||||
public static let author = "author"
|
public static let author = "author"
|
||||||
|
|
||||||
public static let schemaOwned: Set<String> = [
|
public static let schemaOwned: Set<String> = [
|
||||||
schema, title, order, width, collapsed, created, modified, modifiedBy, deleted, background,
|
schema, title, order, width, collapsed, hero, created, modified, modifiedBy, deleted,
|
||||||
icon, iconColor, kind,
|
background, icon, iconColor, kind,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ extension FrontmatterDocument {
|
|||||||
record(FrontmatterKeys.background, backgroundImage)
|
record(FrontmatterKeys.background, backgroundImage)
|
||||||
record(FrontmatterKeys.icon, icon)
|
record(FrontmatterKeys.icon, icon)
|
||||||
record(FrontmatterKeys.iconColor, iconColor)
|
record(FrontmatterKeys.iconColor, iconColor)
|
||||||
|
record(FrontmatterKeys.hero, hero)
|
||||||
record(FrontmatterKeys.kind, kind)
|
record(FrontmatterKeys.kind, kind)
|
||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
@@ -241,6 +242,34 @@ extension FrontmatterDocument {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **A card's hero image — one of its own attachments, by bare filename**
|
||||||
|
/// (03-board-ui.md § Card face ▸ Hero image; `FrontmatterKeys.hero`).
|
||||||
|
///
|
||||||
|
/// The string family's scalar coercion with one gate after it: the reading has to be a name a
|
||||||
|
/// card's `attachments/` folder could hold. A value carrying a path separator, or spelling one of
|
||||||
|
/// the two directory entries every folder has (`.`, `..`), or empty, is **malformed** — it renders
|
||||||
|
/// as no banner and leaves the coerce tier's trace, exactly as `width: 1.5` does.
|
||||||
|
///
|
||||||
|
/// **Deliberately stricter than `backgroundImage`, and the difference is grammar rather than
|
||||||
|
/// caution.** A board's `image` subkey names a file anywhere under the board root, so a path *is*
|
||||||
|
/// its reading and where it leads is the renderer's question. `hero` names one of the card's own
|
||||||
|
/// attachments — the flat, top-level files 01-storage-format.md § Attachments already defines and
|
||||||
|
/// that travel with the card through every move and copy — so `hero: art/sketch.png` is not an
|
||||||
|
/// awkward spelling of a hero image, it is a value naming something the key cannot mean. Refusing
|
||||||
|
/// it here is what makes "no paths, no URLs" a fact of the schema rather than a convention the
|
||||||
|
/// resolver happens to enforce; the resolver re-checks containment anyway (`CardHero`), because a
|
||||||
|
/// lenient reading must never be the only thing standing between a value and the filesystem.
|
||||||
|
///
|
||||||
|
/// A file that is missing, unreadable, or not an image is **not** this layer's business and is
|
||||||
|
/// perfectly `.valid` here: those are facts about the disk, and they degrade at the face — no
|
||||||
|
/// banner, no defect, bytes untouched (`CardHeroImage`).
|
||||||
|
public var hero: FieldValue<String> {
|
||||||
|
read(FrontmatterKeys.hero) { value, raw in
|
||||||
|
guard let text = Self.string(value, raw: raw), Self.isBareFilename(text) else { return nil }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
||||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
||||||
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
||||||
@@ -326,6 +355,19 @@ extension FrontmatterDocument {
|
|||||||
return value >= 1 ? Int(value) : 1
|
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`
|
/// 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`,
|
/// 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.
|
/// `Yes` are all booleans unquoted); anything else has no boolean reading.
|
||||||
|
|||||||
@@ -92,18 +92,33 @@ enum BoardBackdrop {
|
|||||||
/// timestamp's resolution keeps its date, and a re-export at the same instant rarely keeps its
|
/// 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
|
/// 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.
|
/// 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 modified: Date?
|
||||||
var size: Int?
|
var size: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
static func stamp(of url: URL) -> Stamp {
|
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])
|
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||||
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
|
return Stamp(modified: values?.contentModificationDate, size: values?.fileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes the file at `url`, downsampled to `maximumPixelSize` on its longest edge — or `nil`
|
/// Decodes the file at `url`, downsampled to `limit` pixels on its longest edge — or `nil` for
|
||||||
/// for anything that is not a readable image.
|
/// 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
|
/// **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
|
/// 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.
|
/// laid on its side.
|
||||||
///
|
///
|
||||||
/// Never call this on the main actor; see `BoardBackdropImage`'s task.
|
/// 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 }
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||||
let options: [CFString: Any] = [
|
let options: [CFString: Any] = [
|
||||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
kCGImageSourceShouldCacheImmediately: true,
|
kCGImageSourceShouldCacheImmediately: true,
|
||||||
kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize,
|
kCGImageSourceThumbnailMaxPixelSize: limit,
|
||||||
]
|
]
|
||||||
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
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 nil
|
||||||
}
|
}
|
||||||
return image
|
return image
|
||||||
|
|||||||
@@ -162,6 +162,24 @@ enum BoardMetrics {
|
|||||||
em(0.3, bodyPointSize: bodyPointSize)
|
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.
|
/// The card plate's inset around its content.
|
||||||
static func cardContentPadding(bodyPointSize: CGFloat) -> CGFloat {
|
static func cardContentPadding(bodyPointSize: CGFloat) -> CGFloat {
|
||||||
em(0.75, bodyPointSize: bodyPointSize)
|
em(0.75, bodyPointSize: bodyPointSize)
|
||||||
|
|||||||
@@ -146,6 +146,20 @@ struct CardFaceView: View, Equatable {
|
|||||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||||
let drops: BoardDropContext
|
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
|
/// 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
|
/// read off the store, and that is the whole of the fix RENDER-INSTRUMENTATION.md ▸ "Selection is
|
||||||
/// O(board) in card bodies" asked for.
|
/// 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
|
/// 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
|
/// 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
|
/// it is drawn in (`CardFaceRole.isEquivalent(to:)`), the hero file the parent resolved for it,
|
||||||
/// resolves for it, and the three window-lived collaborators — the store by identity, the band
|
/// the two selection figures the parent resolves for it, and the three window-lived collaborators
|
||||||
/// and the drop machinery by their own equivalence tests, which exist because the strip rebuilds
|
/// — the store by identity, the band and the drop machinery by their own equivalence tests, which
|
||||||
/// both structs, closures and all, on every body pass.
|
/// 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
|
/// **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
|
/// 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 {
|
nonisolated static func == (lhs: CardFaceView, rhs: CardFaceView) -> Bool {
|
||||||
lhs.card == rhs.card
|
lhs.card == rhs.card
|
||||||
&& lhs.role.isEquivalent(to: rhs.role)
|
&& lhs.role.isEquivalent(to: rhs.role)
|
||||||
|
&& lhs.hero == rhs.hero
|
||||||
&& lhs.isSelected == rhs.isSelected
|
&& lhs.isSelected == rhs.isSelected
|
||||||
&& lhs.selectedCount == rhs.selectedCount
|
&& lhs.selectedCount == rhs.selectedCount
|
||||||
&& lhs.store === rhs.store
|
&& 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.
|
/// Everything the two containers share — which, after the pivot, is the face itself.
|
||||||
private var face: some View {
|
private var face: some View {
|
||||||
titleRow
|
plateContent
|
||||||
.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)
|
|
||||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate))
|
||||||
.overlay(alignment: .leading) { accentStripe }
|
.overlay(alignment: .leading) { accentStripe }
|
||||||
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
// 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)
|
.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
|
// MARK: - The card drag
|
||||||
|
|
||||||
/// Begins this card's system drag session — in **its own container**, which is the whole of what
|
/// 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
|
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
|
||||||
/// arrow keys).
|
/// arrow keys).
|
||||||
private var replicaFace: some View {
|
private var replicaFace: some View {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
// **The band is drawn from the cache alone, synchronously.** A preview builder is
|
||||||
.foregroundStyle(iconTint)
|
// non-escaping — it runs while the body does, and the system snapshots the result
|
||||||
.imageScale(.medium)
|
// immediately — so there is no task to await a decode in. A hero the face is already
|
||||||
Text(card.title.value ?? "Untitled")
|
// showing is in the cache by definition, which is the only case that matters: you cannot
|
||||||
.boardFont(.body)
|
// drag a card whose banner has not drawn yet without having looked at it first. A miss
|
||||||
.lineLimit(4)
|
// draws no band, and the replica is then exactly the face a hero-less card lifts.
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
if let hero, let image = CardHeroCache.image(forFileAt: hero) {
|
||||||
attachmentsIndicator
|
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
|
// **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
|
// 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,
|
// 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
|
// 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:
|
// 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 replica lays the same band and the same row out with the same paddings and the same
|
||||||
// the face's width it comes out at the face's height (`BoardMetrics`).
|
// `lineLimit`, so at the face's width it comes out at the face's height (`BoardMetrics`).
|
||||||
.frame(
|
.frame(
|
||||||
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
|
width: BoardMetrics.cardReplicaWidth(measured: measuredWidth, bodyPointSize: pointSize),
|
||||||
alignment: .leading
|
alignment: .leading
|
||||||
|
|||||||
@@ -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, `<root>/.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1042,6 +1042,12 @@ struct LaneView: View, Equatable {
|
|||||||
let selection = store.selection
|
let selection = store.selection
|
||||||
let selectedIDs = selection.container == .board ? selection.ids : []
|
let selectedIDs = selection.container == .board ? selection.ids : []
|
||||||
let selectedCount = max(1, selectedIDs.count)
|
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) {
|
return ScrollView(.vertical) {
|
||||||
// Cards stay standard width whatever the lane spans: at a slot width of
|
// Cards stay standard width whatever the lane spans: at a slot width of
|
||||||
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
|
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
|
||||||
@@ -1057,6 +1063,7 @@ struct LaneView: View, Equatable {
|
|||||||
role: .board(openCard: openCard),
|
role: .board(openCard: openCard),
|
||||||
marquee: marquee,
|
marquee: marquee,
|
||||||
drops: drops,
|
drops: drops,
|
||||||
|
hero: CardHero.imageURL(for: card, inContainer: cardsFolder),
|
||||||
isSelected: selectedIDs.contains(card.id),
|
isSelected: selectedIDs.contains(card.id),
|
||||||
// 1 for an unselected face: the replica's fan and count badge want
|
// 1 for an unselected face: the replica's fan and count badge want
|
||||||
// "how many ride along", and a card outside the selection drags
|
// "how many ride along", and a card outside the selection drags
|
||||||
|
|||||||
@@ -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<ItemID>,
|
||||||
|
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 {
|
private var scrollableCards: some View {
|
||||||
// **The trash-side selection, read once for the whole column — and this is a new subscription,
|
// **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
|
// 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 selection = store.selection
|
||||||
let selectedIDs = selection.container == .trash ? selection.ids : []
|
let selectedIDs = selection.container == .trash ? selection.ids : []
|
||||||
let selectedCount = max(1, selectedIDs.count)
|
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) {
|
return ScrollView(.vertical) {
|
||||||
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
|
// **`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
|
// 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 {
|
Group {
|
||||||
switch slot {
|
switch slot {
|
||||||
case let .entry(.card(card)):
|
case let .entry(.card(card)):
|
||||||
CardFaceView(
|
cardRow(
|
||||||
store: store,
|
card,
|
||||||
card: card,
|
cardsFolder: cardsFolder,
|
||||||
role: .trash(confirmations: confirmations),
|
selectedIDs: selectedIDs,
|
||||||
marquee: marquee,
|
selectedCount: selectedCount
|
||||||
drops: drops,
|
|
||||||
isSelected: selectedIDs.contains(card.id),
|
|
||||||
selectedCount: selectedIDs.contains(card.id) ? selectedCount : 1
|
|
||||||
)
|
)
|
||||||
// The value gate, `LaneView`'s rule on the trash side (`CardFaceView.==`).
|
|
||||||
.equatable()
|
|
||||||
case let .entry(.lane(lane)):
|
case let .entry(.lane(lane)):
|
||||||
// The opaque unit's row — its own view, because "no styling accents" and
|
// The opaque unit's row — its own view, because "no styling accents" and
|
||||||
// "never expandable" are exactly what a card face is not
|
// "never expandable" are exactly what a card face is not
|
||||||
|
|||||||
@@ -1065,6 +1065,43 @@ struct BoardLoaderOptionalOrderTests {
|
|||||||
#expect(byID[ranked] == 1024)
|
#expect(byID[ranked] == 1024)
|
||||||
#expect(byID[orderless] == 2048)
|
#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
|
// MARK: - Encoding strictness
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ struct BoardZoomMetricsTests {
|
|||||||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||||
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||||||
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
||||||
|
("cardHeroHeight", { BoardMetrics.cardHeroHeight(bodyPointSize: $0) }),
|
||||||
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
||||||
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
||||||
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(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)
|
#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 {
|
@Test func malformedLenientValuesStillRoundTrip() throws {
|
||||||
let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n"
|
let text = "---\nschema: 1\nbackground: [red, blue]\nwidth: 1.5\nicon: {a: 1}\n---\nbody\n"
|
||||||
let document = try FrontmatterDocument.parse(text)
|
let document = try FrontmatterDocument.parse(text)
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ struct CardFaceViewEquatableTests {
|
|||||||
role: .board(openCard: { _ in }),
|
role: .board(openCard: { _ in }),
|
||||||
marquee: marquee,
|
marquee: marquee,
|
||||||
drops: makeDrops(store: store, session: session, registry: registry),
|
drops: makeDrops(store: store, session: session, registry: registry),
|
||||||
isSelected: false,
|
hero: nil, isSelected: false,
|
||||||
selectedCount: 1
|
selectedCount: 1
|
||||||
)
|
)
|
||||||
let after = CardFaceView(
|
let after = CardFaceView(
|
||||||
@@ -271,7 +271,7 @@ struct CardFaceViewEquatableTests {
|
|||||||
role: .board(openCard: { _ in Issue.record("the gate must not care which opener it holds") }),
|
role: .board(openCard: { _ in Issue.record("the gate must not care which opener it holds") }),
|
||||||
marquee: marquee,
|
marquee: marquee,
|
||||||
drops: makeDrops(store: store, session: session, registry: registry),
|
drops: makeDrops(store: store, session: session, registry: registry),
|
||||||
isSelected: false,
|
hero: nil, isSelected: false,
|
||||||
selectedCount: 1
|
selectedCount: 1
|
||||||
)
|
)
|
||||||
#expect(before == after)
|
#expect(before == after)
|
||||||
@@ -290,7 +290,7 @@ struct CardFaceViewEquatableTests {
|
|||||||
|
|
||||||
let unselected = CardFaceView(
|
let unselected = CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }),
|
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
|
// 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.
|
// leave a selected card wearing no accent ring at all.
|
||||||
#expect(unselected != CardFaceView(
|
#expect(unselected != CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }),
|
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
|
// 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.
|
// still selected but now travels with four others has a different image under the cursor.
|
||||||
let alone = CardFaceView(
|
let alone = CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }),
|
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(
|
#expect(alone != CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }),
|
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")
|
@Test("An edited card is unequal — the gate never withholds a repaint")
|
||||||
func anEditedCardIsADifference() throws {
|
func anEditedCardIsADifference() throws {
|
||||||
let fixture = try makeFixture()
|
let fixture = try makeFixture()
|
||||||
@@ -331,18 +360,18 @@ struct CardFaceViewEquatableTests {
|
|||||||
#expect(before != after)
|
#expect(before != after)
|
||||||
|
|
||||||
#expect(CardFaceView(store: store, card: before, role: .board(openCard: { _ in }),
|
#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 }),
|
!= 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.
|
// And a different card, which is the ordinary within-lane case.
|
||||||
let sibling = try #require(try firstLane(fixture.snapshot()).cards.first {
|
let sibling = try #require(try firstLane(fixture.snapshot()).cards.first {
|
||||||
$0.id == ItemID(rawValue: Ident.card2)
|
$0.id == ItemID(rawValue: Ident.card2)
|
||||||
})
|
})
|
||||||
#expect(CardFaceView(store: store, card: after, role: .board(openCard: { _ in }),
|
#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 }),
|
!= 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")
|
@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 confirmations = TrashConfirmations()
|
||||||
|
|
||||||
let board = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
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),
|
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
|
// 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.
|
// at all, and whether Delete is the permanent one — never a difference to swallow.
|
||||||
#expect(board != trash)
|
#expect(board != trash)
|
||||||
#expect(trash == CardFaceView(store: store, card: card,
|
#expect(trash == CardFaceView(store: store, card: card,
|
||||||
role: .trash(confirmations: confirmations),
|
role: .trash(confirmations: confirmations),
|
||||||
marquee: marquee, drops: drops,
|
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.
|
// Window-lived state, so identity is meaningful as well as cheap.
|
||||||
#expect(trash != CardFaceView(store: store, card: card,
|
#expect(trash != CardFaceView(store: store, card: card,
|
||||||
role: .trash(confirmations: TrashConfirmations()),
|
role: .trash(confirmations: TrashConfirmations()),
|
||||||
marquee: marquee, drops: drops,
|
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")
|
@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 marquee = MarqueeControl(session: bandSession, registry: bandRegistry, store: store)
|
||||||
let drops = makeDrops(store: store, session: session, registry: registry)
|
let drops = makeDrops(store: store, session: session, registry: registry)
|
||||||
let base = CardFaceView(store: store, card: card, role: .board(openCard: { _ in }),
|
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 }),
|
#expect(base != CardFaceView(store: other, card: card, role: .board(openCard: { _ in }),
|
||||||
marquee: marquee, drops: drops,
|
marquee: marquee, drops: drops,
|
||||||
isSelected: false, selectedCount: 1))
|
hero: nil, isSelected: false, selectedCount: 1))
|
||||||
#expect(base != CardFaceView(
|
#expect(base != CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||||
drops: makeDrops(store: store, session: DragSession(), registry: registry),
|
drops: makeDrops(store: store, session: DragSession(), registry: registry),
|
||||||
isSelected: false, selectedCount: 1
|
hero: nil, isSelected: false, selectedCount: 1
|
||||||
))
|
))
|
||||||
#expect(base != CardFaceView(
|
#expect(base != CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||||
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
|
drops: makeDrops(store: store, session: session, registry: LaneDropRegistry()),
|
||||||
isSelected: false, selectedCount: 1
|
hero: nil, isSelected: false, selectedCount: 1
|
||||||
))
|
))
|
||||||
#expect(base != CardFaceView(
|
#expect(base != CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
store: store, card: card, role: .board(openCard: { _ in }), marquee: marquee,
|
||||||
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
|
drops: makeDrops(store: store, session: session, registry: registry, gap: 20),
|
||||||
isSelected: false, selectedCount: 1
|
hero: nil, isSelected: false, selectedCount: 1
|
||||||
))
|
))
|
||||||
#expect(base != CardFaceView(
|
#expect(base != CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }),
|
store: store, card: card, role: .board(openCard: { _ in }),
|
||||||
marquee: MarqueeControl(session: MarqueeSession(), registry: bandRegistry, store: store),
|
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(
|
#expect(base != CardFaceView(
|
||||||
store: store, card: card, role: .board(openCard: { _ in }),
|
store: store, card: card, role: .board(openCard: { _ in }),
|
||||||
marquee: MarqueeControl(session: bandSession, registry: MarqueeTargetRegistry(), store: store),
|
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.laneHeaderTrailingReserve(bodyPointSize: size) == 18)
|
||||||
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
||||||
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
|
#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.cardContentPadding(bodyPointSize: size) == 10)
|
||||||
#expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
|
#expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
|
||||||
#expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
|
#expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
|
||||||
@@ -102,6 +108,7 @@ struct BoardMetricsScalingTests {
|
|||||||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||||||
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||||||
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
||||||
|
("cardHeroHeight", { BoardMetrics.cardHeroHeight(bodyPointSize: $0) }),
|
||||||
("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
|
("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
|
||||||
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
||||||
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user