Build the styling system and shared style editor

One style-editor component, anchor-agnostic: a background grid (None
well plus the 12 palette colors) and a curated symbol grid (the
pathfinder's five-dozen set, leading well removing the icon key for
the level default), selection-aware across cards, lanes, and the
board itself. Batch edits compute per-dimension state — uniform,
mixed (no well selected), or an off-palette value labeled verbatim
outside the grids — and choosing a well applies to the whole target
set as one write bracket, skipping no-ops per field. The popover
tracks its target set live per the freshly ratified rule: targets
re-resolve by UUID on every reload, a vanished target leaves the set,
an emptied set dismisses the editor, and nothing ever silently
retargets to the board. Anchors landing now: Board > Style
(Opt-Cmd-S) and the card/lane context menus, which also carry the
quick-style recents row (app-wide, persisted, capped at six, None
never recorded) and the lane's width control twinning the menu
chords. The styling system's other two renders arrive with it: a
lane's background paints the C7 top-edge band, the board's paints
the window content background — malformed values paint nothing and
stay byte-identical on disk. 31 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 14:47:26 -04:00
parent bea6d02d1d
commit c6298c2e41
15 changed files with 1943 additions and 13 deletions
+304
View File
@@ -0,0 +1,304 @@
import Foundation
import Testing
@testable import Kanban
/// The styling system's rules that need neither a screen nor (mostly) a disk: the per-dimension
/// mixed state the editor displays, the Style popover's live target set, the quick-style recents
/// list, and the curated symbol grid (03-board-ui.md § Styling).
///
/// The popover half drives a **real store over a real temp board** for `TransientBoardStateTests`'
/// reason the lifecycle's contract includes being re-resolved by the store's reload path, and a
/// suite that only called `resolved(against:)` by hand could pass with that wire cut.
// MARK: - Fixtures
private func tombstoned(order: String, title: String) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: 2026-03-03T09:00:00Z
---
\(title) body.
"""
}
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
@MainActor
private func reload(_ store: BoardStore) async {
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
}
// MARK: - Mixed state
@Suite("Styling ▸ mixed state")
struct StyleFieldStateTests {
@Test("Agreement reads uniform, disagreement reads mixed, absence reads unset")
func theThreeStates() {
#expect(StyleFieldState.resolve([]) == .unset)
#expect(StyleFieldState.resolve([.missing, .missing]) == .unset)
#expect(StyleFieldState.resolve([.valid("fern"), .valid("fern")]) == .uniform("fern"))
#expect(StyleFieldState.resolve([.valid("fern"), .valid("chalk")]) == .mixed)
// A set value and an absent one disagree: half the batch is coloured, which is exactly the
// case "" exists for.
#expect(StyleFieldState.resolve([.valid("fern"), .missing]) == .mixed)
}
@Test("An off-palette value is uniform like any other — the display, not this rule, is what differs")
func offPaletteValuesAreOrdinary() {
let state = StyleFieldState.resolve([.valid("#112233AA"), .valid("#112233AA")])
#expect(state == .uniform("#112233AA"))
#expect(!Palette.backgrounds.contains { $0.name == "#112233AA" },
"it is the editor's verbatim chip that treats this specially, outside the grids")
}
@Test("A malformed value reads as the bytes on disk, and two of a kind agree")
func malformedValuesReadVerbatim() {
#expect(StyleFieldState.written(.malformed(raw: "[a, b]")) == "[a, b]")
#expect(StyleFieldState.written(.missing) == nil)
#expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .malformed(raw: "[a, b]")]) == .uniform("[a, b]"))
#expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .missing]) == .mixed)
}
@Test("Only a change that would rewrite the same bytes is skipped")
func noOpNarrowing() {
#expect(BoardStore.effective(.set("fern"), against: .valid("fern")) == .keep)
#expect(BoardStore.effective(.set("fern"), against: .valid("chalk")) == .set("fern"))
#expect(BoardStore.effective(.set("fern"), against: .missing) == .set("fern"))
// A malformed value is never equal to a palette name, so a well always replaces it.
#expect(BoardStore.effective(.set("fern"), against: .malformed(raw: "[a, b]")) == .set("fern"))
#expect(BoardStore.effective(.remove, against: .missing) == .keep)
#expect(BoardStore.effective(.remove, against: .valid("fern")) == .remove)
#expect(BoardStore.effective(.remove, against: .malformed(raw: "[a, b]")) == .remove)
#expect(BoardStore.effective(.keep, against: .valid("fern")) == .keep)
}
}
// MARK: - The popover's target
@MainActor
@Suite("Styling ▸ the Style… popover's target")
struct StyleEditorSessionTests {
@Test("A vanished member leaves the set; the survivors keep the popover open")
func vanishedMemberLeavesTheSet() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card1, card2]))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
await reload(store)
let session = try #require(store.transient.styleEditor)
#expect(session.target == .items([card1]))
// And the display recomputes off the survivors, which is the point of narrowing rather than
// dismissing.
#expect(store.styleSubjects(of: session.target).map(\.id) == [card1])
}
@Test("A set emptied by a foreign reload dismisses the popover")
func emptiedSetDismisses() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card3]))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store)
// The card's own flag never changed its lane's did. Effective liveness is ancestor-walked,
// so the card renders nowhere and the session has nothing left to style.
#expect(store.transient.styleEditor == nil, "the editor is closed")
}
@Test("It never silently retargets to the board")
func neverRetargetsToTheBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .items([card1]))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
await reload(store)
#expect(store.transient.styleEditor?.target != .board)
#expect(store.transient.styleEditor == nil)
}
@Test("A board-targeted editor has no vanish case")
func boardSessionsSurvive() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .board)
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
await reload(store)
#expect(store.transient.styleEditor?.target == .board)
}
@Test("An unknown id is gone from the start — a session of nothing but strangers closes")
func unknownIdsResolveAway() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let stranger = StyleEditorSession(target: .items([card4]))
#expect(stranger.resolved(against: store.snapshot) == nil)
let mixed = StyleEditorSession(target: .items([card4, card1]))
#expect(mixed.resolved(against: store.snapshot)?.target == .items([card1]))
}
@Test("The presenting anchor is the first live target in display order, board sessions none")
func anchorFollowsDisplayOrder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(StyleEditorSession(target: .board).presentationAnchor(in: store.snapshot) == nil)
#expect(StyleEditorSession(target: .items([card3, card2])).presentationAnchor(in: store.snapshot) == card2)
#expect(StyleEditorSession(target: .items([lane2, lane1])).presentationAnchor(in: store.snapshot) == lane1)
// A lane outranks a card in its own lane a cards-XOR-lanes selection never mixes the two,
// but the walk has to be total.
#expect(StyleEditorSession(target: .items([card1, lane1])).presentationAnchor(in: store.snapshot) == lane1)
}
@Test("The style editor is not an inline editor")
func notAnInlineEditor() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.beginStyleEditor(for: .board)
// `isEditingInline` gates every board command; a popover that claimed the text domain would
// disable the very menu items that opened it.
#expect(!store.isEditingInline)
#expect(store.acceptsBoardMutations)
store.transient.discardStyleEditor()
#expect(store.transient.styleEditor == nil)
}
}
// MARK: - Recents
@MainActor
@Suite("Styling ▸ quick-style recents")
struct StyleRecentsTests {
/// A defaults domain of this test's own the list is a real user preference, and a suite that
/// wrote into `UserDefaults.standard` would be editing the developer's own quick-style row.
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
let name = "StyleRecentsTests-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: name) else {
Issue.record("could not create a defaults suite")
return (StyleRecents(defaults: .standard), {})
}
return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
}
@Test("Most-recent-first, deduped by move-to-front, capped")
func listRule() {
#expect(StyleRecents.updated([], with: "fern") == ["fern"])
#expect(StyleRecents.updated(["fern"], with: "chalk") == ["chalk", "fern"])
// A repeat is a move, never a second entry.
#expect(StyleRecents.updated(["chalk", "fern"], with: "fern") == ["fern", "chalk"])
#expect(StyleRecents.updated(["fern"], with: "fern") == ["fern"])
let full = ["a", "b", "c", "d", "e", "f"]
#expect(StyleRecents.updated(full, with: "g", cap: 6) == ["g", "a", "b", "c", "d", "e"])
#expect(StyleRecents.updated(full, with: "g", cap: 6).count == 6)
}
@Test("An empty value is not a colour anyone applied")
func emptyValuesAreIgnored() {
// The None well is a *removal* nothing to remember and the call site never records for
// it; this is the belt behind that brace.
#expect(StyleRecents.updated(["fern"], with: "") == ["fern"])
}
@Test("Recording persists, and a fresh instance reads the same list back")
func recordingRoundTrips() throws {
let name = "StyleRecentsTests-\(UUID().uuidString)"
let store = try #require(UserDefaults(suiteName: name))
defer { store.removePersistentDomain(forName: name) }
let recents = StyleRecents(defaults: store)
recents.record("fern")
recents.record("chalk")
recents.record("fern")
#expect(recents.backgrounds == ["fern", "chalk"])
#expect(StyleRecents(defaults: store).backgrounds == ["fern", "chalk"])
#expect(store.array(forKey: AppPreferences.quickStyleBackgroundsKey) as? [String] == ["fern", "chalk"])
}
@Test("A garbage preference reads as an empty list rather than taking the row down")
func toleratesGarbage() throws {
let (recents, teardown) = makeRecents()
defer { teardown() }
#expect(recents.backgrounds.isEmpty, "a first launch has no recents and no row")
let name = "StyleRecentsTests-garbage-\(UUID().uuidString)"
let store = try #require(UserDefaults(suiteName: name))
defer { store.removePersistentDomain(forName: name) }
store.set(42, forKey: AppPreferences.quickStyleBackgroundsKey)
#expect(StyleRecents(defaults: store).backgrounds.isEmpty)
}
}
// MARK: - The curated grid
@Suite("Styling ▸ the curated symbol grid")
struct CuratedSymbolsTests {
@Test("Roughly five dozen symbols, no duplicates")
func shape() {
#expect(CuratedSymbols.all.count >= 55)
#expect(CuratedSymbols.all.count <= 72)
#expect(Set(CuratedSymbols.all).count == CuratedSymbols.all.count)
}
@Test("Every curated name is one this system can actually draw")
func everyNameResolves() {
// A curated list is a convenience, never a claim about the running OS `available` filters
// it but a name that fails here on the *deployment target* is a typo, not an inventory
// difference, and the grid would show an empty well.
let missing = CuratedSymbols.all.filter { !ItemSymbol.exists($0) }
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
#expect(CuratedSymbols.available.count == CuratedSymbols.all.count)
}
@Test("The level defaults are drawable too — they are the grid's leading well")
func levelDefaultsResolve() {
#expect(ItemSymbol.exists(ItemSymbol.board))
#expect(ItemSymbol.exists(ItemSymbol.lane))
#expect(ItemSymbol.exists(ItemSymbol.card))
}
}
+349
View File
@@ -0,0 +1,349 @@
import Foundation
import Testing
@testable import Kanban
/// `BoardStore.applyStyle` the one commit point every style anchor shares (03-board-ui.md §
/// Styling Controls).
///
/// Like `LaneWidthWriteTests` and `InlineEditWriteTests`, these drive a real store over a real temp
/// board and read the **raw bytes** back rather than the app's own read path: the claims are about
/// the file which key lands or leaves, what the stamps do, and everything else surviving
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixtures
/// An item index carrying whatever style keys a test needs, plus the usual unowned baggage: an
/// unknown key with an inline comment, a `created` from before today, a foreign `modified-by`, and a
/// body all of which a style write has to leave exactly as it found them.
private func styled(order: String, title: String, keys: [String] = []) -> String {
let extra = keys.map { "\($0)\n" }.joined()
return """
---
schema: 1
title: \(title)
order: \(order)
\(extra)project: lanework # agent overlay
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
\(title) body.
"""
}
/// The board root's own `index.md` styled like any other item (`StyleTarget.board`), and carrying
/// an `iconColor` the app must never touch (schema yes, control no).
private let boardIndex = """
---
schema: 1
title: Board
iconColor: carnation
---
Board description.
"""
/// Two live lanes with cards, an uneditable lane, and a tombstoned lane with a live card inside it
/// the ancestor-walk case.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", boardIndex)
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third", keys: ["deleted: 2026-03-03T09:00:00Z"]))
try fixture.item(Ident.lane3, Item.uneditable)
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone", keys: ["deleted: 2026-03-03T09:00:00Z"]))
try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let lane4 = ItemID(rawValue: Ident.lane4)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
/// The file's lines minus the ones a style write is *supposed* to change. `iconColor:` deliberately
/// survives the filter it is not `icon:`, and the app offers no control for it.
private func untouchedLines(_ text: String) -> [Substring] {
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
!$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:")
}
}
/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up the
/// only way to assert "one gesture, one commit" from outside.
@MainActor
private final class BracketLog {
private(set) var begins = 0
private(set) var ends = 0
func attach(to store: BoardStore) {
store.watcherBrackets = (begin: { self.begins += 1 }, end: { self.ends += 1 })
}
}
private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func card(_ id: ItemID, in model: BoardModel) -> Card? {
model.lanes.flatMap(\.cards).first { $0.id == id }
}
private func lane(_ id: ItemID, in model: BoardModel) -> Lane? {
model.lanes.first { $0.id == id }
}
// MARK: - Tests
@MainActor
@Suite("BoardStore ▸ applyStyle")
struct StyleWriteTests {
@Test("Setting a background writes exactly that key, stamps, and touches nothing else")
func setsTheBackgroundKey() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
#expect(after.contains("background: smokey-ocean"))
#expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
#expect(untouchedLines(after) == untouchedLines(before))
let written = try #require(card(card2, in: load(fixture)))
#expect(written.background == .valid("smokey-ocean"))
let modified = try #require(written.modified.value)
#expect(abs(modified.timeIntervalSinceNow) < 60)
#expect(store.banners.oneShots.isEmpty)
}
@Test("Both dimensions land in one rewrite, and iconColor is never touched")
func setsBothDimensionsAtOnce() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
#expect(after.contains("background: dark-teal"))
#expect(after.contains("icon: flag"))
// "iconColor: resolved schema yes, control no" (03 § Styling Capabilities): the field
// renders when hand-written and the app offers no control for it, so a style write must
// carry it through untouched like any unknown key.
#expect(after.contains("iconColor: chalk"))
#expect(!after.contains("background: fern"), "the old value is replaced, not duplicated")
}
@Test("The None and default wells remove their key rather than writing a blank value")
func removesTheKey() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.applyStyle(to: .items([lane2]), background: .remove, icon: .remove)
let after = try fixture.indexText(Ident.lane2)
#expect(!after.contains("background"))
#expect(!after.contains("icon"))
#expect(!after.contains("\"\""), "a removal is a missing key, never an empty string")
let written = try #require(lane(lane2, in: load(fixture)))
#expect(written.background.isMissing)
#expect(written.icon.isMissing)
}
@Test("A batch rewrites every target inside a single bracket")
func batchesInOneBracket() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = BracketLog()
log.attach(to: store)
// "Choosing a well applies to the whole selection one gesture, one commit on git boards"
// (03 § Styling Controls): the churn has to round back as ONE app-mediated reload.
store.applyStyle(to: .items([card1, card2]), background: .set("light-cayenne"))
#expect(log.begins == 1)
#expect(log.ends == 1)
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: light-cayenne"))
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: light-cayenne"))
}
@Test("A value a target already carries writes nothing — per target and per dimension")
func skipsNoOps() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = BracketLog()
log.attach(to: store)
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
// `card1` is already `fern` and `card2` has no background at all: only the second file may
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
// right (`setLaneWidth`'s rule).
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: fern"))
#expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
}
@Test("A gesture that changes nothing anywhere opens no bracket at all")
func wholeGestureNoOpWritesNothing() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = BracketLog()
log.attach(to: store)
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
// Both dimensions already read this way: `background: fern` is set and `icon` is absent, so
// the removal is a no-op too.
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
#expect(log.begins == 0)
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)") == ["index.md"], "no temp-file residue either")
}
@Test("A malformed value is replaced — choosing a well always wins")
func replacesAMalformedValue() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second", keys: ["background: [a, b]"]))
let store = try BoardStore(rootURL: fixture.root)
#expect(card(card2, in: store.snapshot)?.background == .malformed(raw: "[a, b]"))
store.applyStyle(to: .items([card2]), background: .set("shale"))
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
#expect(after.contains("background: shale"))
#expect(!after.contains("[a, b]"))
}
@Test("Board styling writes the board root's own index.md")
func stylesTheBoardRoot() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
let after = try fixture.indexText("")
#expect(after.contains("background: intense-cool-shale"))
#expect(after.contains("icon: square.stack"))
#expect(after.contains("iconColor: carnation"))
#expect(after.contains("Board description."))
let model = try load(fixture)
#expect(model.background == .valid("intense-cool-shale"))
// The lanes are none of a board-level gesture's business.
#expect(lane(lane1, in: model)?.background.isMissing == true)
}
@Test("Vanished, tombstoned and hidden targets are skipped silently")
func skipsTargetsThatRenderNowhere() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let log = BracketLog()
log.attach(to: store)
let tombstoned = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)")
let hidden = try fixture.indexData("\(Ident.lane4)/\(Ident.card4)")
// An id that names nothing, a tombstoned card, and a live card under a tombstoned lane
// "nothing is ever written into a vanished folder", ancestor walk included.
store.applyStyle(
to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
background: .set("obsidian")
)
#expect(log.begins == 0)
#expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == tombstoned)
#expect(try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") == hidden)
#expect(store.banners.oneShots.isEmpty)
}
@Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes")
func uneditableTargetBanners() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let before = try fixture.indexData(Ident.lane3)
store.applyStyle(to: .items([lane3]), background: .set("fern"))
#expect(try fixture.indexData(Ident.lane3) == before)
#expect(store.banners.oneShots.count == 1)
let posted = try #require(store.banners.oneShots.first)
#expect(posted.error.operation == .style(title: "Odd"),
"the title is enriched off the document the write refused")
#expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't restyle 'Odd' — "))
}
@Test("A read-only board refuses the write without a second banner")
func readOnlyBoardRefusesQuietly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
store.applyStyle(to: .items([card1]), background: .set("obsidian"))
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
}
// MARK: Subjects and levels
@Test("Subjects are the live targets in display order, with their current values")
func subjectsAreLiveTargetsInDisplayOrder() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let subjects = store.styleSubjects(of: .items([card2, card1, card3, lane1]))
#expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
#expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
#expect(subjects.last?.folder.lastPathComponent == Ident.card2)
// The board is always exactly one subject, at the root.
let board = store.styleSubjects(of: .board)
#expect(board.count == 1)
let boardSubject = try #require(board.first)
#expect(boardSubject.id == nil, "a board root has no ItemID by design")
#expect(boardSubject.folder == fixture.root)
}
@Test("The level a target sits at decides the symbol grid's leading well")
func levelFollowsTheTarget() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.styleLevel(of: .board) == .board)
#expect(store.styleLevel(of: .items([lane1, lane2])) == .lane)
#expect(store.styleLevel(of: .items([card1])) == .card)
#expect(ItemSymbol.default(for: store.styleLevel(of: .items([card1]))) == ItemSymbol.card)
#expect(ItemSymbol.default(for: store.styleLevel(of: .items([lane1]))) == ItemSymbol.lane)
#expect(ItemSymbol.default(for: .board) == ItemSymbol.board)
}
}