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:
@@ -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")
|
||||
|
||||
@@ -151,9 +151,11 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
||||
public var isDeleted: Bool { !deleted.isMissing }
|
||||
}
|
||||
|
||||
/// A card: `<root>/<guid>/<guid>/index.md`. Leaf of the fractal tree — `attachments/` and the
|
||||
/// (future, out-of-scope) `comments/` live alongside `index.md` on disk but are not modeled
|
||||
/// here.
|
||||
/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments. Structurally
|
||||
/// still a leaf — `comments/` (future, out-of-scope) and the attachment files' contents live
|
||||
/// alongside `index.md` on disk and are not modeled here; `attachments` is the one thing the
|
||||
/// snapshot reaches inside a card folder for, because two board-window surfaces need it before
|
||||
/// any card window exists (see its own doc comment).
|
||||
public struct Card: Identifiable, Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
|
||||
@@ -171,6 +173,25 @@ public struct Card: Identifiable, Sendable, Equatable {
|
||||
/// comment; the same reasoning applies here.
|
||||
public let order: Double
|
||||
|
||||
/// The card's attachment file names — **flat: top-level regular files only, in Finder
|
||||
/// order** (01-storage-format.md § Attachments: "the app's attachment surfaces … are flat:
|
||||
/// top-level files only", and "subfolders are tolerated, preserved verbatim, never created
|
||||
/// by the app, and not surfaced"). Empty when the card has no `attachments/` folder, and
|
||||
/// empty when it has one that couldn't be listed — the field is cosmetic, so a
|
||||
/// directory-listing race degrades to "nothing to show" rather than failing a load.
|
||||
///
|
||||
/// Names, not URLs: the two board-window consumers only need to know *whether*, *how many*,
|
||||
/// and in *what order* — the face's quiet paperclip indicator (03-board-ui.md § Card face)
|
||||
/// and the sole-selected card's attachment carousel. The card window's sidebar does its own
|
||||
/// listing through `BoardWriter.listAttachments`, since it acts on the files rather than
|
||||
/// showing them; that call answers through the very same enumeration
|
||||
/// (`BoardLoader.attachmentNames(in:)`), so the two surfaces agree by construction.
|
||||
///
|
||||
/// Freshness is the watcher's, by construction: it reloads on any change anywhere under the
|
||||
/// board root, so an attachment added in Finder rebuilds the snapshot exactly like an edited
|
||||
/// `index.md` does — no separate invalidation path to keep honest.
|
||||
public let attachments: [String]
|
||||
|
||||
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
|
||||
public let document: FrontmatterDocument
|
||||
|
||||
|
||||
@@ -1002,26 +1002,17 @@ public enum BoardWriter: Sendable {
|
||||
/// directory, *is* `.unreadable` — that is a caller asking about a card that isn't there,
|
||||
/// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
|
||||
/// disturbs anything inside it, subfolders included.
|
||||
///
|
||||
/// The listing itself is `BoardLoader.attachmentNames(in:)`, which is also what fills
|
||||
/// `Card.attachments` for the board window's face indicator and carousel. **One function, so
|
||||
/// the sidebar and the face can never disagree** about a card's attachments or their order.
|
||||
/// What this call adds over that one is the card-folder guard and this API's error
|
||||
/// vocabulary — the difference between a surface that *acts* on the files and two that
|
||||
/// merely show them.
|
||||
public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
|
||||
let operation = WriteOperation.listAttachments
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
|
||||
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: attachmentsFolder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let files = entries.filter { url in
|
||||
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
|
||||
return false
|
||||
}
|
||||
return values.isRegularFile == true && values.isSymbolicLink != true
|
||||
}
|
||||
return files.map(\.lastPathComponent).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
return BoardLoader.attachmentNames(in: cardFolder)
|
||||
}
|
||||
|
||||
// MARK: - Move/copy pre-flight
|
||||
|
||||
Reference in New Issue
Block a user