Build card faces with edge-accent styling

The card face becomes real: leading SF Symbol (card default doc.text,
tinted by a valid hand-written iconColor — schema yes, control no),
title or the quiet untitled placeholder, and a quiet paperclip when
the card has attachments — title-only by design, no body excerpt.
Color is the settled K1 edge accent, not a fill: background paints a
4pt stripe down the left edge, resolved through the ported pathfinder
palette (12 icon tints + 12 backgrounds carried over verbatim, plus
raw #RRGGBB[AA]); anything unresolvable paints nothing and stays on
disk exactly as written. The snapshot now carries each card's flat
attachment names — the loader's one read inside a card folder, shared
with the Writer's listing so the m5 carousel and m6 sidebar can never
disagree on order (Finder order, the Writer's existing comparator).
The face keeps its top-aligned structure so the sole-selection
carousel can expand inside the card without moving masonry neighbors.
18 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 13:48:50 -04:00
parent b35566e0fe
commit b4c90838b4
13 changed files with 692 additions and 69 deletions
+59 -5
View File
@@ -19,11 +19,18 @@ import os
/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast.
///
/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card*
/// (01-storage-format.md § Fractal layout Rules); since cards are leaves here this loader
/// never scans a card folder's contents beyond checking for `index.md` that reservation is
/// satisfied by construction and needs no explicit filtering. Doubly so under the shape rule:
/// were a card folder ever scanned, `attachments` and `comments` are non-UUID-shaped and would
/// read as strays, not levels so they never need special-casing against the stray warning.
/// (01-storage-format.md § Fractal layout Rules), and cards are leaves *structurally*: the
/// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the
/// shape rule `attachments` and `comments` are non-UUID-shaped and would read as strays, not
/// levels, so they never need special-casing against the stray warning.
///
/// **The one read inside a card folder** is `attachmentNames(in:)`: a single flat listing of
/// `attachments/`, feeding `Card.attachments`. It is a *names* read and nothing more it never
/// opens a file, never descends, never warns, and degrades to `[]` on any failure. Two board-
/// window surfaces need it before a card window exists (the face's paperclip indicator and the
/// sole-selected card's carousel 03-board-ui.md § Card face), and the snapshot is where they
/// read from. Everything else about a card folder's contents remains outside this loader's
/// business.
///
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory this loader does not resolve
@@ -119,6 +126,7 @@ public enum BoardLoader: Sendable {
icon: cardDocument.icon,
iconColor: cardDocument.iconColor,
order: cardOrder,
attachments: attachmentNames(in: cardURL),
document: cardDocument
))
}
@@ -176,6 +184,52 @@ public enum BoardLoader: Sendable {
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
}
/// The names of `<card>/attachments/`'s **top-level regular files** the flat view
/// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are
/// tolerated, preserved verbatim and not surfaced"). `[]` when there is no `attachments/`.
///
/// Three exclusions, the same three `directoryCandidates` makes and for the same reasons:
/// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a
/// subfolder stays reachable through Reveal in Finder and through body-relative paths, but
/// never appears as an attachment), and symlinks (this loader resolves nothing the same
/// stance the level walk takes).
///
/// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
/// `"shot 10.png"`: the order has to be stable across loads for the face carousel's pages and
/// its dots, and where it is already the sidebar's order it may as well be the same one.
///
/// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
/// structure (01-storage-format.md § Malformed input), and this field decorates a card a
/// permissions race here must never be the reason a whole board refuses to open.
///
/// Internal rather than `private`: `BoardWriter.listAttachments` the card window sidebar's
/// authoritative listing answers through this same function behind its own card-folder
/// guard, so the face and the sidebar can never disagree about what a card's attachments are.
/// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder
/// the app ever creates under a card.
static func attachmentNames(in cardFolder: URL) -> [String] {
let folder = cardFolder.appendingPathComponent(
BoardWriter.attachmentsFolderName, isDirectory: true
)
guard let entries = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
.map(\.lastPathComponent)
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group **both cases**,
/// per the shape-only identity predicate below.
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")