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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user