The app learns Appearance — Auto, Light, Dark from the View menu and a toolbar pull-down

View ▸ Appearance (11-command-nexus.md): three radio-exclusive rows,
app-wide, persisted, needing no window in front — the View menu's new
last group. AppearanceStore owns the override's rules (absent key =
Auto, lenient reads degrade to Auto, remove-at-default) with an
injectable apply seam so test hosts never touch NSApp; the one real
apply hands NSApp.appearance its answer in applicationDidFinishLaunching,
the global side effect KanbanApp.init must not carry. The board toolbar
gains its first .picker item — an NSMenuToolbarItem whose rows re-fetch
their spec fresh, checkmark read at menu-open like every other menu row —
and Appearance joins the search field as the second default item,
centered beside it (03-board-ui.md ▸ Toolbar, ratified 2026-08-07).

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-07 12:55:45 -04:00
parent c686242a14
commit b0ffff1aa1
14 changed files with 579 additions and 36 deletions
+75 -19
View File
@@ -41,16 +41,32 @@ private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore {
return store
}
/// The board catalog, with the two collaborators every test here supplies the same way: a fresh zoom
/// store and a drag session with nothing in flight.
/// An appearance store on a scratch defaults domain, apply seam stubbed out this suite's subject is
/// the toolbar item, not the store itself (`AppearanceTests.swift` owns that), so nothing here should
/// touch `NSApp` (`makeZoom`'s reason, turned toward AppKit).
@MainActor
private func makeAppearance() -> AppearanceStore {
let name = "dev.rzen.indie.Kanban.toolbar-tests.appearance.\(UUID().uuidString)"
return AppearanceStore(defaults: UserDefaults(suiteName: name)!, apply: { _ in })
}
/// The board catalog, with the three collaborators every test here supplies the same way: a fresh
/// zoom store, a fresh appearance store, and a drag session with nothing in flight.
@MainActor
private func boardSpecs(
store: BoardStore,
search: BoardSearchPresentation = BoardSearchPresentation(),
zoom: BoardZoomStore? = nil,
appearance: AppearanceStore? = nil,
session: DragSession = DragSession()
) -> [ToolbarItemSpec] {
BoardToolbar.specs(store: store, search: search, zoom: zoom ?? makeZoom(), session: session)
BoardToolbar.specs(
store: store,
search: search,
zoom: zoom ?? makeZoom(),
appearance: appearance ?? makeAppearance(),
session: session
)
}
// MARK: - The vocabulary
@@ -99,16 +115,16 @@ struct ToolbarVocabularyTests {
@Suite("Toolbar ▸ the board window")
struct BoardToolbarTests {
@Test("The default set is the search field, trailing, and nothing else")
func defaultsAreTheSearchFieldAlone() {
// "Board window default: the search field, nothing else trailing, the one default item;
// the titlebar stays clean." The flexible space ahead of it is what "trailing" means to
// NSToolbar, so the *items* in the default set are exactly one.
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch])
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch])
@Test("The default set is the search field and Appearance, trailing, and nothing else")
func defaultsAreTheSearchFieldAndAppearance() {
// "Board window default: the search field and Appearance both centered, immediately after
// the field." The flexible space ahead of them is what keeps the pair off the leading edge, so
// the *items* in the default set are exactly two.
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch, .boardAppearance])
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch, .boardAppearance])
}
@Test("The catalog is 03's seven commands plus the field — and the board popover is not in it")
@Test("The catalog is 03's eight commands plus the field — and the board popover is not in it")
func catalogIsTheDesignsInventory() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
@@ -116,8 +132,8 @@ struct BoardToolbarTests {
let specs = boardSpecs(store: store)
// "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out , Undo, Redo ,
// Show Trash" plus the search field, which is a catalog item too (a user who removes it can
// put it back).
// Show Trash, Appearance" plus the search field, which is a catalog item too (a user who
// removes it can put it back).
#expect(specs.map(\.identifier) == [
.boardNewCard,
.boardNewLane,
@@ -126,12 +142,13 @@ struct BoardToolbarTests {
.boardUndo,
.boardRedo,
.boardShowTrash,
.boardAppearance,
.boardSearch,
])
// "The board popover deliberately has no toolbar item the window-title widget is its
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
// pinned by the exact-inventory assertion above and stated again here.
#expect(specs.count == 8)
#expect(specs.count == 9)
}
/// The zoom pair is catalog-only "the titlebar's default stays the search field alone"
@@ -221,7 +238,7 @@ struct BoardToolbarTests {
let specs = boardSpecs(store: store)
#expect(specs.map(\.label) == [
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Search",
"New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Appearance", "Search",
])
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card'), which a toolbar
@@ -275,7 +292,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
// 03's three customization sentences: "right-click Customize Toolbar, drag to rearrange,
// system overflow and icon/text display options".
@@ -304,7 +321,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
#expect(search.focusField == nil, "nothing to focus until the item exists")
@@ -339,7 +356,7 @@ struct BoardToolbarTests {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
let item = try #require(controller.toolbar(
controller.toolbar,
@@ -373,7 +390,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation()
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession())
let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), appearance: makeAppearance(), session: DragSession())
_ = controller.toolbar(
controller.toolbar,
@@ -409,6 +426,45 @@ struct BoardToolbarTests {
#expect(!store.transient.isTrashVisible)
#expect(showTrash.isOn == false)
}
/// The picker's whole state contract, `showTrashTogglesTheQuasiLane`'s shape turned toward
/// `.picker`: no single `isOn`, so the checkmark and the write path are the item's own
/// `selected`/`select` closures rather than `.isOn`/`.activate()`.
@Test("Appearance is a picker whose selected row tracks the store and whose rows write it")
func appearanceTracksTheStore() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let appearance = makeAppearance()
let specs = boardSpecs(store: store, appearance: appearance)
let item = try #require(specs.spec(.boardAppearance))
#expect(item.isOn == nil, "a picker has no single on-state")
#expect(item.isEnabled, "always enabled — no board state gates an appearance override")
guard case let .picker(options, selected, select) = item.behavior else {
Issue.record("Appearance is not a picker")
return
}
#expect(options.map(\.title) == ["Auto", "Light", "Dark"])
#expect(selected() == 0, "Auto by default, like the store's own nil override")
select(1)
#expect(appearance.override == .light, "the row drives the store's own setter")
#expect(selected() == 1)
select(2)
#expect(appearance.override == .dark)
#expect(selected() == 2)
select(0)
#expect(appearance.override == nil)
#expect(selected() == 0)
// `.activate()` is a no-op for a picker firing lives in the dropdown's own rows.
item.activate()
#expect(appearance.override == nil, "activate() does not move the selection")
}
}
// MARK: - The card window's toolbar