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
+129 -4
View File
@@ -390,9 +390,10 @@ struct BoardLoaderNonUUIDStrayTests {
}
}
/// Reserved card children are covered "by construction" now: `attachments/` and
/// `comments/` are non-UUID-shaped, and this loader never scans a card folder's contents
/// anyway (cards are leaves) either way, they must never surface a warning.
/// Reserved card children are covered "by construction": `attachments/` and `comments/` are
/// non-UUID-shaped, and the *level walk* stops at depth 2 so neither can ever be mistaken
/// for an item, and neither may surface a warning. (`attachments/` is read for its file
/// names, which is a listing, not a descent see `CardAttachmentListingTests` below.)
@Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
@@ -401,7 +402,7 @@ struct BoardLoaderNonUUIDStrayTests {
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)", "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png")
try fixture.strayFile("\(lane)/\(card)/comments/whatever.md")
@@ -412,6 +413,130 @@ struct BoardLoaderNonUUIDStrayTests {
}
}
// MARK: - Card attachments (01-storage-format.md § Attachments)
/// `Card.attachments` the loader's one read *inside* a card folder. The golden-fixture suite
/// pins the everyday shapes on real committed trees (`FixtureBoardTests`); these cover what a git
/// fixture can't carry (a symlink) and what only a synthetic tree can arrange (a card folder whose
/// `attachments` is a *file*, an empty folder, Finder's numeric ordering).
struct CardAttachmentListingTests {
/// Builds a one-card board and returns that card, so each test below is one arrangement plus
/// one assertion.
private func card(in fixture: BoardFixture) throws -> Card {
let result = try BoardLoader.load(boardRoot: fixture.root)
return try #require(result.model.lanes.first?.cards.first)
}
private func boardWithOneCard(_ fixture: BoardFixture) throws -> String {
let lane = "10000000-0000-4000-8000-000000000001"
let cardID = "20000000-0000-4000-8000-000000000002"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(cardID)", "schema: 1\norder: 1024\n")
return "\(lane)/\(cardID)"
}
/// **Symlinks are not surfaced** the same never-resolve stance the level walk takes
/// (`directoryCandidates`), so a link into another volume or a cycle can't turn a listing
/// into a traversal. The link itself stays on disk untouched; it just isn't an attachment.
@Test func symlinksInAttachmentsAreNotSurfaced() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments/real.png")
try fixture.strayFile("outside.png")
let attachments = fixture.root.appendingPathComponent("\(cardPath)/attachments", isDirectory: true)
try FileManager.default.createSymbolicLink(
at: attachments.appendingPathComponent("link-to-file.png"),
withDestinationURL: fixture.root.appendingPathComponent("outside.png")
)
try FileManager.default.createSymbolicLink(
at: attachments.appendingPathComponent("link-to-folder"),
withDestinationURL: fixture.root
)
#expect(try card(in: fixture).attachments == ["real.png"])
}
/// The four shapes in one folder the fixture board's assertion restated synthetically, so
/// the rule is pinned even if the bundled fixture tree ever loses a file to a copy phase.
@Test func onlyTopLevelNonHiddenRegularFilesAreListed() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments/sketch.png")
try fixture.strayFile("\(cardPath)/attachments/notes.txt")
try fixture.strayFile("\(cardPath)/attachments/.DS_Store")
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
#expect(try card(in: fixture).attachments == ["notes.txt", "sketch.png"])
}
/// **Finder order** (`localizedStandardCompare`), not plain lexicographic digits inside a
/// name *count* rather than collate, so the run `importAttachments` itself produces on a
/// collision (`shot.png` `shot 2.png` `shot 10.png`) pages 2-before-10, and the face's
/// carousel matches the card window sidebar's listing, which answers through this same
/// enumeration. (`shot.png` trailing its own numbered copies is Finder's own ordering of a
/// space against a dot, not a quirk of ours.)
@Test func namesSortInFinderOrderSoNumbersCountRatherThanCollate() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
for name in ["shot 10.png", "shot 2.png", "shot.png", "page-10.txt", "page-2.txt"] {
try fixture.strayFile("\(cardPath)/attachments/\(name)")
}
#expect(try card(in: fixture).attachments == [
"page-2.txt", "page-10.txt", "shot 2.png", "shot 10.png", "shot.png",
])
}
@Test func anEmptyAttachmentsFolderListsNothing() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.emptyFolder("\(cardPath)/attachments")
#expect(try card(in: fixture).attachments.isEmpty)
}
/// A listing that *cannot* be made degrades to `[]` here because a hand-editor left a
/// `attachments` **file** where the folder would be. Fail-fast is reserved for structure
/// (01-storage-format.md § Malformed input); a cosmetic field must never be why a board
/// refuses to open, and the file itself is preserved verbatim like any other stray.
@Test func anAttachmentsThatIsNotADirectoryDegradesToAnEmptyListing() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments", contents: "not a folder")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.first?.cards.first?.attachments.isEmpty == true)
#expect(result.warnings.isEmpty)
}
/// `BoardWriter.listAttachments` the card window sidebar's authoritative listing and
/// `Card.attachments` are **one enumeration**, so a sidebar and a face looking at the same
/// card can never disagree about its files or their order.
@Test func theSnapshotsListingAndTheWritersAreTheSameAnswer() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
for name in ["shot 10.png", "shot 2.png", "shot.png", ".hidden"] {
try fixture.strayFile("\(cardPath)/attachments/\(name)")
}
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
#expect(try card(in: fixture).attachments == BoardWriter.listAttachments(ofCard: cardFolder))
}
}
// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout Rules, "Identity
// comparison is UUID-value equality, never string equality")