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
+2
View File
@@ -1,5 +1,7 @@
**August 2026** **August 2026**
The app's appearance can now be set to Light, Dark, or Auto from the View menu or the toolbar's new Appearance item.
A board can now wear a background image, painted across the whole window with a frosted strip keeping the title bar legible. A board can now wear a background image, painted across the whole window with a frosted strip keeping the title bar legible.
The background field is now written as a mapping — *{color: green}* instead of a bare *green* — and a board's may name an image beside the color. The background field is now written as a mapping — *{color: green}* instead of a bare *green* — and a board's may name an image beside the color.
+1 -1
View File
@@ -13,7 +13,7 @@ The board window: layout, lanes, cards, and styling. Interaction mechanics (sele
Toolbars are **pure enhancement**: every function they host already has a menu item + shortcut (04-interactions.md's contract), so nothing below is anyone's only path. Both windows' toolbars are **user-customizable, macOS-native** (right-click ▸ Customize Toolbar…, drag to rearrange, system overflow and icon/text display options) — the sets below are shipped defaults, not verdicts. Toolbar item labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary everywhere, and the customize palette self-documents against the menus. One exception: the Undo/Redo toolbar items keep static labels — NSUndoManager rewrites their menu titles dynamically ("Undo Move Card…", 04-interactions.md ▸ Configurable bindings), which a toolbar label doesn't track. Toolbars are **pure enhancement**: every function they host already has a menu item + shortcut (04-interactions.md's contract), so nothing below is anyone's only path. Both windows' toolbars are **user-customizable, macOS-native** (right-click ▸ Customize Toolbar…, drag to rearrange, system overflow and icon/text display options) — the sets below are shipped defaults, not verdicts. Toolbar item labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary everywhere, and the customize palette self-documents against the menus. One exception: the Undo/Redo toolbar items keep static labels — NSUndoManager rewrites their menu titles dynamically ("Undo Move Card…", 04-interactions.md ▸ Configurable bindings), which a toolbar label doesn't track.
- **Board window default: the search field, nothing else** — **centered** (ratified 2026-08-06 off the 2026-08-01 live try-out, reversing the earlier trailing ruling): the titlebar reads as a placement grammar — leading is board identity (the title widget), center is view controls (search today, the filter family if one ever grows), trailing remains the user's catalog space. The mechanism is `NSToolbar.centeredItemIdentifiers`, deliberately not a flexible-space sandwich: it centers against the window rather than leftover space, holds as catalog items install, and sits outside the autosaved configuration, so it takes effect on machines with a saved arrangement — and `defaultItems` is unchanged, keeping the pinned default-set tests standing. The known tensions were weighed and accepted at ratification: the 400pt leading widget and a centered field share the titlebar's budget in narrow windows (the squeezed field's expand-in-place answer below is the relief), and HIG's trailing-edge convention yields to the grammar. The titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **A squeezed field expands in place; the strip answers only genuine unreachability** (blessed 2026-08-06): a space-constrained `NSSearchToolbarItem` collapses to a magnifying-glass button still in the window, and ⌘F expands and focuses it — the toolbar's own field, a better surface than the fallback — so the transient strip fires only for a truly windowless field (true overflow, or the item removed from the toolbar). **The item's overflow row is the platform's own second answer, and the duality is blessed** (2026-08-06): AppKit's overflow menu row carries a live action that widens the window until the field is usable, and suppressing it would destroy the honest overflow presentation — so a squeezed toolbar reaches search two ways with two honest resolutions: ⌘F expands the item or surfaces the strip (ours), the overflow row grows the window (the platform's) — different gestures, reasonable respective outcomes, no contradiction to resolve. **The field is the platform's own search toolbar item and grows on focus** (2026-08-01): the em-derived width is the *focused* width — applied when the field takes the keyboard, expanding via the item's own animation — and the resting width is AppKit's natural one, not the app's to set. **The two-homes width rule is focused-width parity** (ruled 2026-08-01): the width the user *types in* is the same em-derived figure whichever home the field is in — the toolbar item focused, or the transient strip; the toolbar field's resting width is outside the invariant (the strip never rests — it exists only while a search is live or focused, so it has no collapsed state to mirror). **Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out (the zoom pair is catalog-only by the same logic as everything else here — the titlebar's default stays the search field alone; each disables at its end of the ladder, and both disable mid-drag like their menu rows — Layout ▸ zoom above), Undo, Redo (the pair disabled only under locks and on empty stacks — re-ruled 2026-07-31, twice: the provider follows the board, so boards without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every** tier and Pro git boards bind the git provider — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it. - **Board window default: the search field and Appearance** — both **centered** (ratified 2026-08-06 off the 2026-08-01 live try-out, reversing the earlier trailing ruling; Appearance joined the default set 2026-08-07 — 11-command-nexus.md ▸ View ▸ Appearance): the titlebar reads as a placement grammar — leading is board identity (the title widget), center is view controls (search and the app's Auto/Light/Dark override today, the filter family if one ever grows), trailing remains the user's catalog space. The mechanism is `NSToolbar.centeredItemIdentifiers`, deliberately not a flexible-space sandwich: it centers against the window rather than leftover space, holds as catalog items install, and sits outside the autosaved configuration, so it takes effect on machines with a saved arrangement — and `defaultItems` is unchanged, keeping the pinned default-set tests standing. The known tensions were weighed and accepted at ratification: the 400pt leading widget and a centered field share the titlebar's budget in narrow windows (the squeezed field's expand-in-place answer below is the relief), and HIG's trailing-edge convention yields to the grammar. The titlebar stays clean. ⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the field transiently until the search clears. **A squeezed field expands in place; the strip answers only genuine unreachability** (blessed 2026-08-06): a space-constrained `NSSearchToolbarItem` collapses to a magnifying-glass button still in the window, and ⌘F expands and focuses it — the toolbar's own field, a better surface than the fallback — so the transient strip fires only for a truly windowless field (true overflow, or the item removed from the toolbar). **The item's overflow row is the platform's own second answer, and the duality is blessed** (2026-08-06): AppKit's overflow menu row carries a live action that widens the window until the field is usable, and suppressing it would destroy the honest overflow presentation — so a squeezed toolbar reaches search two ways with two honest resolutions: ⌘F expands the item or surfaces the strip (ours), the overflow row grows the window (the platform's) — different gestures, reasonable respective outcomes, no contradiction to resolve. **The field is the platform's own search toolbar item and grows on focus** (2026-08-01): the em-derived width is the *focused* width — applied when the field takes the keyboard, expanding via the item's own animation — and the resting width is AppKit's natural one, not the app's to set. **The two-homes width rule is focused-width parity** (ruled 2026-08-01): the width the user *types in* is the same em-derived figure whichever home the field is in — the toolbar item focused, or the transient strip; the toolbar field's resting width is outside the invariant (the strip never rests — it exists only while a search is live or focused, so it has no collapsed state to mirror). **Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out (the zoom pair is catalog-only, unlike Appearance below — the titlebar's default is the search field plus Appearance, nothing more; each disables at its end of the ladder, and both disable mid-drag like their menu rows — Layout ▸ zoom above), Undo, Redo (the pair disabled only under locks and on empty stacks — re-ruled 2026-07-31, twice: the provider follows the board, so boards without app-managed git — repo-nested included — bind 13-native-undo.md's native stack in **every** tier and Pro git boards bind the git provider — 06-history-undo.md), Show Trash (toggle state matching the View menu checkmark), Appearance (a pull-down of Auto/Light/Dark — 11-command-nexus.md ▸ View ▸ Appearance; the one catalog command that also ships as a default item, centered beside search rather than reached through Customize). The board popover deliberately has **no toolbar item** — the window-title widget is its committed home (below), and a second entry would muddy it.
- **Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed functions, all discoverable from its toolbar; the catalog is the same trio. Edit Body is a **single toggle button** (on-state in Edit — mirroring the View ▸ Edit Body checkmark and the ⌘E/Return/Escape grammar; the pathfinder's segmented Preview|Edit is retired). Raw Source is likewise a toggle showing on-state; while source mode is active, Edit Body disables (Cancel/Apply own the exits — 05-card-window.md). Add Attachment stays enabled in every mode — attachment operations never touch `index.md`, so they're safe alongside a raw edit (the sidebar's feedback returns on exit). - **Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed functions, all discoverable from its toolbar; the catalog is the same trio. Edit Body is a **single toggle button** (on-state in Edit — mirroring the View ▸ Edit Body checkmark and the ⌘E/Return/Escape grammar; the pathfinder's segmented Preview|Edit is retired). Raw Source is likewise a toggle showing on-state; while source mode is active, Edit Body disables (Cancel/Apply own the exits — 05-card-window.md). Add Attachment stays enabled in every mode — attachment operations never touch `index.md`, so they're safe alongside a raw edit (the sidebar's feedback returns on exit).
## Lane ## Lane
+3
View File
@@ -55,6 +55,9 @@ The single source of truth for **every command and action the app can perform**
| View | Comments Beside Body (checkmark toggle) | — (no default) | Card window; checked = side-by-side (default), unchecked = body over comments; app-wide, persisted (05 ▸ Composition) | | View | Comments Beside Body (checkmark toggle) | — (no default) | Card window; checked = side-by-side (default), unchecked = body over comments; app-wide, persisted (05 ▸ Composition) |
| View | Raw Source (checkmark toggle; toggling off = Apply) | ⌥⌘E | Card window | | View | Raw Source (checkmark toggle; toggling off = Apply) | ⌥⌘E | Card window |
| View | History | — (no default) | Card window; focuses the sidebar History section (05); git boards only — section absent, item disabled on mode none / repo-nested | | View | History | — (no default) | Card window; focuses the sidebar History section (05); git boards only — section absent, item disabled on mode none / repo-nested |
| View | Appearance ▸ Auto | — (no default) | Everywhere (no board or card window needed); app-wide, persisted across restarts — follows the system appearance; radio-exclusive with Light/Dark, checkmark on the active one (03-board-ui.md ▸ Toolbar) |
| View | Appearance ▸ Light | — (no default) | Everywhere; app-wide, persisted across restarts; radio-exclusive with Auto/Dark |
| View | Appearance ▸ Dark | — (no default) | Everywhere; app-wide, persisted across restarts; radio-exclusive with Auto/Light |
| Window | Welcome to Lanework | — (no default) | Everywhere; shows (or focuses) the welcome window (02 ▸ Launch and window lifecycle) | | Window | Welcome to Lanework | — (no default) | Everywhere; shows (or focuses) the welcome window (02 ▸ Launch and window lifecycle) |
## Fixed grammar keys (G) ## Fixed grammar keys (G)
+6
View File
@@ -27,8 +27,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// It starts nothing the board-open path waits on: the listener writes cached facts that a /// It starts nothing the board-open path waits on: the listener writes cached facts that a
/// *later* composition may read, and never reaches into a session that is already open /// *later* composition may read, and never reaches into a session that is already open
/// (`ProEntitlement`). /// (`ProEntitlement`).
///
/// **The appearance override applies here too, for the same reason.** `AppearanceStore.init`
/// only reads; this is the one call that hands its answer to `NSApp` the global side effect
/// `KanbanApp.init` must not carry, since a unit-test host runs that `init` on every launch
/// (`AppearanceStore.applyCurrent`).
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
appModel?.entitlement.start() appModel?.entitlement.start()
appModel?.appearance.applyCurrent()
} }
/// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar /// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar
+29
View File
@@ -127,6 +127,26 @@ public enum AppPreferences {
/// stale-from-a-future-build value indistinguishable from a legal one downstream. /// stale-from-a-future-build value indistinguishable from a legal one downstream.
public static let boardZoomLevelKey = "boardZoomLevel" public static let boardZoomLevelKey = "boardZoomLevel"
// MARK: The appearance override
/// **View Appearance** (11-command-nexus.md) Auto / Light / Dark, app-wide and persisted
/// across restarts (03-board-ui.md Toolbar). Read and written by `AppearanceStore`, which owns
/// the override's rules; the key is declared here with its neighbours for `WindowID`'s reason.
///
/// **Absent key = Auto.** Setting Auto removes the key rather than writing a third spelling of it
/// (the remove-at-default family a default lane width and an empty rename both do the same), and
/// a stored string that is neither "light" nor "dark" a hand edit, a future build's value read by
/// an older one degrades to Auto rather than refusing to resolve.
public static let appearanceKey = "appearance"
/// The stored override, read the same lenient way `AppearanceStore.init` does. Not itself on that
/// type's read path it takes its own injectable `defaults` rather than always reading
/// `.standard` but declared here with a reader for the shape every other preference in this enum
/// keeps (`showComments`'s).
public static var appearance: AppAppearance? {
UserDefaults.standard.string(forKey: appearanceKey).flatMap(AppAppearance.init(rawValue:))
}
/// The cached subscription facts behind the tier decision (12-editions.md The entitlement) /// The cached subscription facts behind the tier decision (12-editions.md The entitlement)
/// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`. /// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`.
/// ///
@@ -271,6 +291,14 @@ public final class AppModel {
/// outside every scene's environment, reach it through this object. /// outside every scene's environment, reach it through this object.
public let zoom: BoardZoomStore public let zoom: BoardZoomStore
/// The app-wide appearance override (11-command-nexus.md View Appearance; 03-board-ui.md
/// Toolbar). Owned here for `zoom`'s reason exactly: app-scoped, persisted beside it, and reached
/// by the View-menu picker and the board-toolbar item alike both live outside a board's own
/// environment (the menu bar entirely, the toolbar through `WindowToolbarController`), so an
/// `@Observable` object both can hold is the only thing keeping them from becoming two answers to
/// one question.
public let appearance: AppearanceStore
/// The app's one drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop). /// The app's one drag session (DRAG-REORDER.md; 04-interactions.md Drag and drop).
/// ///
/// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the /// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the
@@ -688,6 +716,7 @@ public final class AppModel {
boardRegistry = BoardRegistry(storageURL: registryStorageURL) boardRegistry = BoardRegistry(storageURL: registryStorageURL)
styleRecents = StyleRecents(defaults: preferences) styleRecents = StyleRecents(defaults: preferences)
zoom = BoardZoomStore(defaults: preferences) zoom = BoardZoomStore(defaults: preferences)
appearance = AppearanceStore(defaults: preferences)
clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot) clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot)
// Reads the cached facts and nothing else no StoreKit API is touched until // Reads the cached facts and nothing else no StoreKit API is touched until
// `ProEntitlement.start()`, which the app's launch calls and a test host never does. // `ProEntitlement.start()`, which the app's launch calls and a test host never does.
+36
View File
@@ -0,0 +1,36 @@
import SwiftUI
// MARK: - View Appearance
/// View Appearance Auto / Light / Dark (11-command-nexus.md View; 03-board-ui.md Toolbar).
///
/// **App-wide and always enabled**, unlike `ZoomCommands` beside it in the View menu: appearance is a
/// preference about how *every* window in the app draws, welcome included, so this row needs no
/// `@FocusedValue` scoping and no board window in front `NewBoardCommand`'s posture (everywhere, no
/// focus required) rather than `ZoomCommands`' (board windows only).
///
/// A `Picker` rather than three independent toggles: SwiftUI renders one placed directly in a
/// menu-bar command group as a submenu "Appearance" as its title, "Auto" / "Light" / "Dark" as its
/// rows, a checkmark on whichever is selected which is the three-way exclusive choice a trio of
/// `Toggle`s cannot express (nothing stops more than one, or none, from reading as checked). `nil` is
/// the Auto tag; `AppearanceStore.setOverride` is the single write path the board-toolbar item shares
/// (`BoardZoomStore.step`'s rule a toolbar item is a menu command with a different face, never a
/// second implementation of it).
struct AppearanceCommands: View {
/// A plain `let` rather than an `@Environment` read, `ZoomCommands`' reason: menu commands live in
/// the menu bar, outside every scene's environment; the row re-renders on a change because
/// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern).
let appModel: AppModel
var body: some View {
Picker("Appearance", selection: Binding(
get: { appModel.appearance.override },
set: { appModel.appearance.setOverride($0) }
)) {
Text("Auto").tag(nil as AppAppearance?)
Text("Light").tag(AppAppearance.light as AppAppearance?)
Text("Dark").tag(AppAppearance.dark as AppAppearance?)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
import AppKit
import Observation
// MARK: - AppAppearance
/// The app-wide appearance override Auto (follow system) / Light / Dark (11-command-nexus.md
/// View Appearance; 03-board-ui.md Toolbar).
///
/// There is no `.auto` case: following the system is the *absence* of an override, which is what makes
/// "no stored key" the one honest spelling of it (`AppearanceStore`, `AppPreferences.appearanceKey`)
/// a third case would need its own reading of what it means to override with "don't override".
public enum AppAppearance: String, CaseIterable, Sendable {
case light
case dark
}
// MARK: - AppearanceStore
/// The app's one appearance override, app-wide and persisted (11-command-nexus.md View Appearance;
/// 03-board-ui.md Toolbar).
///
/// `BoardZoomStore`'s shape exactly, and for its reasons. Two consumers need change notification a
/// property wrapper in a view cannot give them: the board toolbar's picker item, whose checkmarks are
/// read fresh whenever AppKit opens its menu rather than polled (`WindowToolbarController`), and the
/// View-menu picker, which lives outside every scene's environment and reaches `AppModel` as a plain
/// `let` the same way `ZoomCommands` reaches `zoom`. An `@Observable` object over an injectable
/// `UserDefaults` is what serves both without either one mirroring the other's state.
///
/// ### Why not `@AppStorage`, like Show Comments
///
/// Show Comments has exactly one write path and exactly one thing reading it back the checkbox
/// itself. This preference has two independent controls that must never drift the way the toolbar's
/// zoom buttons and the View-menu zoom rows must not (`BoardZoomStore.setLevel`'s rule), and the
/// toolbar's is AppKit underneath an `NSMenuToolbarItem` cannot bind to `@AppStorage` at all.
///
/// ### Why the write path applies live, unlike zoom's
///
/// A zoom level only ever feeds a board's own drawing, so persisting it is enough the board reads it
/// back through the environment. An appearance override is a statement about the whole app's chrome,
/// every open window included, so the setter both persists *and* calls the apply seam in the same
/// beat: there is no reload and no window that has to be told twice.
@MainActor
@Observable
public final class AppearanceStore {
/// The current override. `nil` is Auto the app follows the system appearance.
public private(set) var override: AppAppearance?
@ObservationIgnored
private let defaults: UserDefaults
/// The one seam that touches `NSApp` injected so a test can prove the setter's whole contract
/// (persist, then apply) without a live application object, `BoardZoomStore.defaults`'s reason
/// turned toward AppKit rather than `UserDefaults`.
@ObservationIgnored
private let apply: (NSAppearance.Name?) -> Void
/// - Parameters:
/// - defaults: the domain to persist in injected for `BoardZoomStore`'s reason: a test holds
/// its own rather than touching the developer's real appearance.
/// - apply: what "make it so" means. Defaulted to the real thing; a test hands in a recording
/// closure instead so it never touches `NSApp`.
public init(
defaults: UserDefaults = .standard,
apply: @escaping (NSAppearance.Name?) -> Void = { name in
NSApp.appearance = name.map { NSAppearance(named: $0) } ?? nil
}
) {
self.defaults = defaults
self.apply = apply
// A string, not an enum-backed scalar: the key is absent for Auto (the remove-at-default
// idiom a default lane width and an empty rename already use), and any value that survives to
// here but is neither "light" nor "dark" a hand edit, a future build's spelling read by an
// older one degrades to Auto rather than refusing to resolve. `AppAppearance.init(rawValue:)`
// already answers `nil` for anything it does not recognise, so the lenient read costs nothing
// beyond the `flatMap`.
override = defaults.string(forKey: AppPreferences.appearanceKey).flatMap(AppAppearance.init(rawValue:))
}
// MARK: - The pure resolver
/// What an override means to AppKit no `NSApp`, no live application, provable with nothing but
/// the enum (`BoardZoom.normalize`'s reason: the rule is a function, and the object around it is
/// only that function's persistence and observability).
///
/// `nonisolated`, unlike everything else here: it touches no actor-isolated state, and marking it
/// so is what lets a plain (non-`@MainActor`) test call it directly, the same freedom
/// `BoardZoom.normalize` has by living outside `BoardZoomStore` entirely.
public nonisolated static func appearanceName(for override: AppAppearance?) -> NSAppearance.Name? {
switch override {
case .light: .aqua
case .dark: .darkAqua
case nil: nil
}
}
// MARK: - Writing
/// Sets the override, persists it, and applies it the single write path the View-menu picker and
/// the board-toolbar item share (`BoardZoomStore.setLevel`'s rule: the two faces of one command
/// must never become two implementations of it).
///
/// **Auto removes the key** rather than writing a third spelling of it: the preference is meant to
/// read as "no override on file" to anyone who inspects it, the same bargain a default lane width
/// and an empty rename already keep.
///
/// **An unchanged value writes and applies nothing**, `BoardZoomStore.setLevel`'s own guard and for
/// the same load-bearing reason: `@Observable` notifies on every assignment, equal or not, so an
/// ungated write would invalidate every observer of `override` the picker's checkmarks, the
/// toolbar controller's tracked validation on a no-op, and hand the apply seam a repeat call for
/// nothing every one of its callers would have to tolerate.
public func setOverride(_ newValue: AppAppearance?) {
guard newValue != override else { return }
override = newValue
if let newValue {
defaults.set(newValue.rawValue, forKey: AppPreferences.appearanceKey)
} else {
defaults.removeObject(forKey: AppPreferences.appearanceKey)
}
apply(Self.appearanceName(for: newValue))
}
/// Re-applies the stored override launch's whole job
/// (`AppDelegate.applicationDidFinishLaunching`). `init` above already read the value; this is the
/// method that hands it to AppKit, kept separate from `init` so building a store including in a
/// test, including as `AppModel`'s own construction is never itself a global side effect.
public func applyCurrent() {
apply(Self.appearanceName(for: override))
}
}
+1
View File
@@ -775,6 +775,7 @@ struct BoardWindowHost: View {
store: store, store: store,
search: boardSearch, search: boardSearch,
zoom: appModel.zoom, zoom: appModel.zoom,
appearance: appModel.appearance,
session: appModel.dragSession session: appModel.dragSession
)) ))
} }
+73 -6
View File
@@ -83,6 +83,18 @@ struct ToolbarItemSpec {
make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField, make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField,
install: (NSSearchToolbarItem) -> Void install: (NSSearchToolbarItem) -> Void
) )
/// A pull-down of mutually exclusive choices **Appearance** (03-board-ui.md Toolbar): an
/// `NSMenuToolbarItem`, item image plus indicator, whose menu lists `options` in order.
/// `selected()` names the option index carrying the checkmark, read fresh whenever AppKit
/// opens the menu rather than polled the same freshness every other menu row in the app
/// gets (`validateMenuItem(_:)`) and `select(_:)` is a chosen row's whole action. The one
/// behavior with no `activate()` of its own: firing lives in the dropdown's rows, not in the
/// item itself, the way `responderAction`'s lives in the responder chain rather than here.
case picker(
options: [(title: String, symbol: String?)],
selected: () -> Int?,
select: (Int) -> Void
)
} }
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a /// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
@@ -121,7 +133,7 @@ struct ToolbarItemSpec {
switch behavior { switch behavior {
case let .button(isEnabled, _): isEnabled() case let .button(isEnabled, _): isEnabled()
case let .toggle(isEnabled, _, _): isEnabled() case let .toggle(isEnabled, _, _): isEnabled()
case .responderAction, .searchField: true case .responderAction, .searchField, .picker: true
} }
} }
@@ -129,17 +141,18 @@ struct ToolbarItemSpec {
var isOn: Bool? { var isOn: Bool? {
switch behavior { switch behavior {
case let .toggle(_, isOn, _): isOn() case let .toggle(_, isOn, _): isOn()
case .button, .responderAction, .searchField: nil case .button, .responderAction, .searchField, .picker: nil
} }
} }
/// Firing the item: a button performs, a toggle flips. A no-op for the two kinds AppKit drives /// Firing the item: a button performs, a toggle flips. A no-op for the kinds AppKit drives itself
/// itself. /// or that fire from somewhere other than the item's own primary action (`.picker`'s dropdown
/// rows).
func activate() { func activate() {
switch behavior { switch behavior {
case let .button(_, perform): perform() case let .button(_, perform): perform()
case let .toggle(_, isOn, setOn): setOn(!isOn()) case let .toggle(_, isOn, setOn): setOn(!isOn())
case .responderAction, .searchField: break case .responderAction, .searchField, .picker: break
} }
} }
} }
@@ -302,6 +315,8 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
// window-scoped wiring, so it is handed none. // window-scoped wiring, so it is handed none.
install: willBeInsertedIntoToolbar ? install : nil install: willBeInsertedIntoToolbar ? install : nil
) )
case let .picker(options, _, _):
return makePickerItem(spec, options: options)
} }
} }
@@ -406,6 +421,41 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return item return item
} }
/// A pull-down of mutually exclusive options **Appearance**, so far the one item of this shape.
///
/// `selected`/`select` are deliberately not captured here: every row's action and every row's
/// validation re-fetch the spec fresh from `specs[identifier]` (`pickerItemFired(_:)`,
/// `validateMenuItem(_:)`), the same indirection `itemFired(_:)` and `toggleFired(_:)` already use
/// for their own specs so a spec rebuilt between two menu presentations is never read stale.
private func makePickerItem(_ spec: ToolbarItemSpec, options: [(title: String, symbol: String?)]) -> NSToolbarItem {
let item = NSMenuToolbarItem(itemIdentifier: spec.identifier)
decorate(item, with: spec)
// "Pull-down: item image + indicator" the item's own glyph draws at rest, the indicator
// chevron shows there is a menu, and the rows are what actually name Auto/Light/Dark.
item.showsIndicator = true
let menu = NSMenu()
for (index, option) in options.enumerated() {
let menuItem = NSMenuItem(
title: option.title,
action: #selector(pickerItemFired(_:)),
keyEquivalent: ""
)
menuItem.target = self
// The row's position in `options`, not an identifier of its own `select(_:)` and
// `selected()` both speak in this same index, which is what lets one closure pair stand
// for every row rather than one closure per option.
menuItem.tag = index
menuItem.representedObject = spec.identifier.rawValue
if let symbol = option.symbol {
menuItem.image = NSImage(systemSymbolName: symbol, accessibilityDescription: option.title)
}
menu.addItem(menuItem)
}
item.menu = menu
return item
}
/// The three strings every item carries: the toolbar label, the palette label (the same string /// The three strings every item carries: the toolbar label, the palette label (the same string
/// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads. /// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads.
private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) { private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) {
@@ -444,13 +494,30 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
revalidate() revalidate()
} }
/// A row in a `.picker` item's own dropdown **Appearance**'s Auto/Light/Dark, fired straight
/// from the menu rather than through `itemFired(_:)`, since the item has no primary action of its
/// own (`ToolbarItemSpec.activate()` is a no-op for `.picker`).
@objc private func pickerItemFired(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String,
let spec = specs[NSToolbarItem.Identifier(raw)],
case let .picker(_, _, select) = spec.behavior
else { return }
select(sender.tag)
revalidate()
}
/// The overflow menu's copy of an item validates like the item itself including the checkmark, /// The overflow menu's copy of an item validates like the item itself including the checkmark,
/// which is where a toggle's on-state goes when the menu is its face (03 Toolbar: "the system /// which is where a toggle's on-state goes when the menu is its face (03 Toolbar: "the system
/// overflow"). /// overflow") and where a `.picker` row's checkmark goes too, against its own index rather than
/// against `isOn` (which answers `nil` for the whole item, having no single on-state to give).
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
guard let raw = menuItem.representedObject as? String, guard let raw = menuItem.representedObject as? String,
let spec = specs[NSToolbarItem.Identifier(raw)] let spec = specs[NSToolbarItem.Identifier(raw)]
else { return true } else { return true }
if case let .picker(_, selected, _) = spec.behavior {
menuItem.state = selected() == menuItem.tag ? .on : .off
return true
}
if let isOn = spec.isOn { if let isOn = spec.isOn {
menuItem.state = isOn ? .on : .off menuItem.state = isOn ? .on : .off
} }
+7 -2
View File
@@ -240,9 +240,10 @@ struct KanbanApp: App {
ToolbarCommands() ToolbarCommands()
// The View menu. `CommandGroupPlacement.toolbar` *is* View the menu the toolbar's own // The View menu. `CommandGroupPlacement.toolbar` *is* View the menu the toolbar's own
// items live in which is where 11-command-nexus.md files Show Trash. Two dividers split it // items live in which is where 11-command-nexus.md files Show Trash. Three dividers split it
// by scope, which is the only grouping the inventory implies: the board's toggle, then the // by scope, which is the only grouping the inventory implies: the board's toggle, then the
// board's zoom ladder, then the card window's view-state rows. // board's zoom ladder, then the card window's view-state rows, then the app-wide appearance
// override last, because unlike everything above it, it needs no window in front at all.
// //
// "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window // "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window
// menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a // menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a
@@ -257,6 +258,10 @@ struct KanbanApp: App {
Divider() Divider()
CardViewCommands() CardViewCommands()
Divider()
AppearanceCommands(appModel: appModel)
} }
// The Board menu (11-command-nexus.md), complete and in its inventoried row order Open // The Board menu (11-command-nexus.md), complete and in its inventoried row order Open
+47 -8
View File
@@ -11,16 +11,18 @@ extension NSToolbarItem.Identifier {
static let boardShowTrash = Self("board.showTrash") static let boardShowTrash = Self("board.showTrash")
static let boardZoomIn = Self("board.zoomIn") static let boardZoomIn = Self("board.zoomIn")
static let boardZoomOut = Self("board.zoomOut") static let boardZoomOut = Self("board.zoomOut")
static let boardAppearance = Self("board.appearance")
} }
// MARK: - The board window's toolbar // MARK: - The board window's toolbar
/// The board window's toolbar (03-board-ui.md Toolbar). /// The board window's toolbar (03-board-ui.md Toolbar).
/// ///
/// ### The default is one item, and the catalog is five more /// ### The default is the search field and Appearance, and the catalog is the rest
/// ///
/// "**Board window default: the search field, nothing else** trailing, the one default item; the /// "**Board window default: the search field and Appearance** both **centered**, the titlebar's
/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`. /// view-controls cluster." The flexible space ahead of the pair is what keeps them off the leading
/// edge before `centeredItemIdentifiers` takes over their placement.
/// ///
/// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out , Undo, Redo , /// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out , Undo, Redo ,
/// Show Trash (toggle state matching the View menu checkmark)." Every one of them is the *same command* as its menu row /// Show Trash (toggle state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
@@ -54,8 +56,14 @@ enum BoardToolbar {
/// than one window's Finder's behaviour, and the reason the identifier is a constant. /// than one window's Finder's behaviour, and the reason the identifier is a constant.
static let identifier = "dev.rzen.indie.Kanban.board" static let identifier = "dev.rzen.indie.Kanban.board"
/// "The search field, nothing else trailing, the one default item." /// "The search field and Appearance both centered, immediately after the field
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch] /// (03-board-ui.md Toolbar, the view-controls cluster)."
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch, .boardAppearance]
/// The Appearance picker's rows, in menu order the one place index and meaning are joined, so
/// `specs(...)`'s `selected`/`select` closures and this array can never name two different
/// orderings of the same three choices.
private static let appearanceOptions: [AppAppearance?] = [nil, .light, .dark]
/// - Parameters: /// - Parameters:
/// - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the /// - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the
@@ -64,12 +72,15 @@ enum BoardToolbar {
/// `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms /// `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms
/// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live /// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live
/// at the top rung. /// at the top rung.
/// - appearance: the app-wide appearance override, `zoom`'s reason exactly not the store's,
/// and `@Observable` so the picker's checkmark, read when its menu opens, is never stale.
/// - session: the app's drag session, for the same guard the menu rows carry /// - session: the app's drag session, for the same guard the menu rows carry
/// (`ZoomCommands.isEnabled`). /// (`ZoomCommands.isEnabled`).
static func specs( static func specs(
store: BoardStore, store: BoardStore,
search: BoardSearchPresentation, search: BoardSearchPresentation,
zoom: BoardZoomStore, zoom: BoardZoomStore,
appearance: AppearanceStore,
session: DragSession session: DragSession
) -> [ToolbarItemSpec] { ) -> [ToolbarItemSpec] {
[ [
@@ -141,6 +152,31 @@ enum BoardToolbar {
setOn: { [weak store] shown in store?.setTrashVisible(shown) } setOn: { [weak store] shown in store?.setTrashVisible(shown) }
) )
), ),
// A pull-down rather than a toggle: Auto/Light/Dark is a three-way exclusive choice, not
// an on/off bit. The one default (and centered) catalog item beside the field
// (`defaultItems`, `controller(...)`'s `centeredItemIdentifiers`), always enabled an
// appearance override needs no board state, exactly as the View-menu row needs no board
// window (`AppearanceCommands`).
.mirroring(
menuTitle: "Appearance",
identifier: .boardAppearance,
symbol: "circle.lefthalf.filled",
behavior: .picker(
options: [
(title: "Auto", symbol: nil),
(title: "Light", symbol: nil),
(title: "Dark", symbol: nil),
],
selected: { [weak appearance] in
guard let appearance else { return nil }
return appearanceOptions.firstIndex(of: appearance.override)
},
select: { [weak appearance] index in
guard appearanceOptions.indices.contains(index) else { return }
appearance?.setOverride(appearanceOptions[index])
}
)
),
.staticLabel( .staticLabel(
"Search", "Search",
identifier: .boardSearch, identifier: .boardSearch,
@@ -180,18 +216,21 @@ enum BoardToolbar {
store: BoardStore, store: BoardStore,
search: BoardSearchPresentation, search: BoardSearchPresentation,
zoom: BoardZoomStore, zoom: BoardZoomStore,
appearance: AppearanceStore,
session: DragSession session: DragSession
) -> WindowToolbarController { ) -> WindowToolbarController {
let controller = WindowToolbarController( let controller = WindowToolbarController(
identifier: identifier, identifier: identifier,
specs: specs(store: store, search: search, zoom: zoom, session: session), specs: specs(store: store, search: search, zoom: zoom, appearance: appearance, session: session),
defaults: defaultItems defaults: defaultItems
) )
// Centered against the window, not a flexible-space sandwich (03 Toolbar's placement // Centered against the window, not a flexible-space sandwich (03 Toolbar's placement
// grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install // grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install
// and sits outside the autosaved configuration, so it reaches machines that saved an // and sits outside the autosaved configuration, so it reaches machines that saved an
// arrangement under the old trailing default. `defaultItems` is untouched. // arrangement under the old trailing default. `defaultItems` is untouched. Appearance joined
controller.toolbar.centeredItemIdentifiers = [.boardSearch] // the cluster after search (`defaultItems`'s own order), so the two center together as one
// group search first, Appearance beside it rather than as two independently-placed items.
controller.toolbar.centeredItemIdentifiers = [.boardSearch, .boardAppearance]
controller.onInstalledItemsChanged = { [weak search] identifiers in controller.onInstalledItemsChanged = { [weak search] identifiers in
search?.isInstalledInToolbar = identifiers.contains(.boardSearch) search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
} }
+166
View File
@@ -0,0 +1,166 @@
import AppKit
import Foundation
import Testing
@testable import Kanban
/// An appearance store on a scratch defaults domain the override is app-wide and persisted, so a
/// suite that used `.standard` would touch the developer's own appearance
/// (`BoardZoomTests.makeStore`'s reason). The apply seam defaults to a no-op so a test that is not
/// exercising it never touches `NSApp`.
@MainActor
private func makeStore(
seeding stored: String? = nil,
apply: @escaping (NSAppearance.Name?) -> Void = { _ in }
) -> (AppearanceStore, UserDefaults, () -> Void) {
let name = "dev.rzen.indie.Kanban.appearance-tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
if let stored { defaults.set(stored, forKey: AppPreferences.appearanceKey) }
return (AppearanceStore(defaults: defaults, apply: apply), defaults,
{ UserDefaults.standard.removePersistentDomain(forName: name) })
}
// MARK: - The pure resolver
/// `AppearanceStore.appearanceName(for:)` no `NSApp`, no live application, provable with nothing but
/// the enum (`BoardZoomLadderTests`'s reason for testing `BoardZoom`'s pure functions on their own).
@Suite("Appearance ▸ the pure resolver")
struct AppearanceResolverTests {
@Test("Light resolves to aqua, dark to darkAqua, and Auto to nothing at all")
func resolvesToTheRightName() {
#expect(AppearanceStore.appearanceName(for: .light) == .aqua)
#expect(AppearanceStore.appearanceName(for: .dark) == .darkAqua)
#expect(AppearanceStore.appearanceName(for: nil) == nil)
}
}
// MARK: - The persisted override
@Suite("Appearance ▸ the persisted override")
@MainActor
struct AppearanceStorePersistenceTests {
@Test("A fresh domain opens on Auto")
func freshDomainIsAuto() {
let (store, _, tearDown) = makeStore()
defer { tearDown() }
#expect(store.override == nil)
}
@Test("Light and Dark survive the trip through defaults")
func overrideRoundTrips() {
let (store, defaults, tearDown) = makeStore()
defer { tearDown() }
store.setOverride(.light)
#expect(AppearanceStore(defaults: defaults).override == .light)
store.setOverride(.dark)
#expect(AppearanceStore(defaults: defaults).override == .dark)
}
/// The remove-at-default idiom, `BoardZoomStore`'s neighbours already keep it: Auto is meant to
/// read as "no override on file" to anyone who inspects the domain, not as a third stored spelling
/// of the same thing.
@Test("Setting Auto removes the key rather than writing a third spelling of it")
func autoRemovesTheKey() {
let (store, defaults, tearDown) = makeStore(seeding: "light")
defer { tearDown() }
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")
store.setOverride(nil)
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == nil)
#expect(store.override == nil)
}
/// The trap this exists for: a hand edit, or a future build's spelling read by an older one, must
/// degrade rather than crash or silently misapply an override nobody asked for.
@Test("A stored string that is neither light nor dark degrades to Auto")
func unknownStringIsAuto() {
for stored in ["sepia", "", "Light", "LIGHT", "light "] {
let (store, _, tearDown) = makeStore(seeding: stored)
defer { tearDown() }
#expect(store.override == nil, "\"\(stored)\" must not resolve to an override")
}
}
@Test("Every case survives its own round trip through the raw value")
func everyCaseRoundTrips() {
for override in AppAppearance.allCases {
let (store, defaults, tearDown) = makeStore()
defer { tearDown() }
store.setOverride(override)
#expect(AppearanceStore(defaults: defaults).override == override)
}
}
}
// MARK: - The apply seam
/// The one write path `setOverride` and `applyCurrent` and the seam it hands its answer to,
/// proven with a recording closure rather than `NSApp` (`AppearanceStore.init`'s own reason for taking
/// one).
@Suite("Appearance ▸ the apply seam")
@MainActor
struct AppearanceApplySeamTests {
@Test("Setting an override invokes the apply seam with the resolved name, in order")
func setInvokesApply() {
var applied: [NSAppearance.Name?] = []
let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
defer { tearDown() }
store.setOverride(.light)
store.setOverride(.dark)
store.setOverride(nil)
#expect(applied == [.aqua, .darkAqua, nil])
}
/// `BoardZoomStore.setLevel`'s own guard, load-bearing for the same reason: `@Observable` notifies
/// on every assignment, equal or not, so an ungated write would hand the apply seam a repeat call
/// for a selection that never moved.
@Test("An unchanged value writes and applies nothing")
func unchangedValueIsANoOp() {
var applyCount = 0
let (store, defaults, tearDown) = makeStore(apply: { _ in applyCount += 1 })
defer { tearDown() }
store.setOverride(.light)
#expect(applyCount == 1)
store.setOverride(.light)
#expect(applyCount == 1, "the same value again must not re-apply")
#expect(defaults.string(forKey: AppPreferences.appearanceKey) == "light")
store.setOverride(nil)
#expect(applyCount == 2)
store.setOverride(nil)
#expect(applyCount == 2, "Auto set twice must not re-apply either")
}
/// Launch's whole job: `init` only reads, so nothing has applied yet until this is called.
@Test("applyCurrent re-applies the stored value, and reads nothing new")
func applyCurrentReappliesTheStoredValue() {
var applied: [NSAppearance.Name?] = []
let (store, _, tearDown) = makeStore(seeding: "dark", apply: { applied.append($0) })
defer { tearDown() }
#expect(applied.isEmpty, "construction alone must not touch the apply seam")
store.applyCurrent()
#expect(applied == [.darkAqua])
#expect(store.override == .dark, "applyCurrent hands out what init already resolved")
}
@Test("applyCurrent on a fresh Auto domain applies nil")
func applyCurrentOnAutoAppliesNil() {
var applied: [NSAppearance.Name?] = []
let (store, _, tearDown) = makeStore(apply: { applied.append($0) })
defer { tearDown() }
store.applyCurrent()
#expect(applied == [nil])
}
}
+3
View File
@@ -841,6 +841,7 @@ struct UndoCommandSurfaceTests {
store: store, store: store,
search: BoardSearchPresentation(), search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!), zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }),
session: DragSession() session: DragSession()
) )
let provider = FakeHistoryProvider() let provider = FakeHistoryProvider()
@@ -899,6 +900,7 @@ struct UndoCommandSurfaceTests {
store: store, store: store,
search: BoardSearchPresentation(), search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!), zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!),
appearance: AppearanceStore(defaults: UserDefaults(suiteName: zoomDomain + ".appearance")!, apply: { _ in }),
session: DragSession() session: DragSession()
) )
let provider = FakeHistoryProvider() let provider = FakeHistoryProvider()
@@ -944,6 +946,7 @@ struct UndoCommandSurfaceTests {
store: store, store: store,
search: BoardSearchPresentation(), search: BoardSearchPresentation(),
zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!), zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!),
appearance: AppearanceStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!, apply: { _ in }),
session: DragSession() session: DragSession()
) )
+75 -19
View File
@@ -41,16 +41,32 @@ private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore {
return store return store
} }
/// The board catalog, with the two collaborators every test here supplies the same way: a fresh zoom /// An appearance store on a scratch defaults domain, apply seam stubbed out this suite's subject is
/// store and a drag session with nothing in flight. /// 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 @MainActor
private func boardSpecs( private func boardSpecs(
store: BoardStore, store: BoardStore,
search: BoardSearchPresentation = BoardSearchPresentation(), search: BoardSearchPresentation = BoardSearchPresentation(),
zoom: BoardZoomStore? = nil, zoom: BoardZoomStore? = nil,
appearance: AppearanceStore? = nil,
session: DragSession = DragSession() session: DragSession = DragSession()
) -> [ToolbarItemSpec] { ) -> [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 // MARK: - The vocabulary
@@ -99,16 +115,16 @@ struct ToolbarVocabularyTests {
@Suite("Toolbar ▸ the board window") @Suite("Toolbar ▸ the board window")
struct BoardToolbarTests { struct BoardToolbarTests {
@Test("The default set is the search field, trailing, and nothing else") @Test("The default set is the search field and Appearance, trailing, and nothing else")
func defaultsAreTheSearchFieldAlone() { func defaultsAreTheSearchFieldAndAppearance() {
// "Board window default: the search field, nothing else trailing, the one default item; // "Board window default: the search field and Appearance both centered, immediately after
// the titlebar stays clean." The flexible space ahead of it is what "trailing" means to // the field." The flexible space ahead of them is what keeps the pair off the leading edge, so
// NSToolbar, so the *items* in the default set are exactly one. // the *items* in the default set are exactly two.
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch]) #expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch, .boardAppearance])
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch]) #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 { func catalogIsTheDesignsInventory() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
@@ -116,8 +132,8 @@ struct BoardToolbarTests {
let specs = boardSpecs(store: store) let specs = boardSpecs(store: store)
// "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out , Undo, Redo , // "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 // Show Trash, Appearance" plus the search field, which is a catalog item too (a user who
// put it back). // removes it can put it back).
#expect(specs.map(\.identifier) == [ #expect(specs.map(\.identifier) == [
.boardNewCard, .boardNewCard,
.boardNewLane, .boardNewLane,
@@ -126,12 +142,13 @@ struct BoardToolbarTests {
.boardUndo, .boardUndo,
.boardRedo, .boardRedo,
.boardShowTrash, .boardShowTrash,
.boardAppearance,
.boardSearch, .boardSearch,
]) ])
// "The board popover deliberately has no toolbar item the window-title widget is its // "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 // 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. // 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" /// 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) let specs = boardSpecs(store: store)
#expect(specs.map(\.label) == [ #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 // 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 // NSUndoManager rewrites their menu titles dynamically ('Undo Move Card'), which a toolbar
@@ -275,7 +292,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation() 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, // 03's three customization sentences: "right-click Customize Toolbar, drag to rearrange,
// system overflow and icon/text display options". // system overflow and icon/text display options".
@@ -304,7 +321,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation() 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") #expect(search.focusField == nil, "nothing to focus until the item exists")
@@ -339,7 +356,7 @@ struct BoardToolbarTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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( let item = try #require(controller.toolbar(
controller.toolbar, controller.toolbar,
@@ -373,7 +390,7 @@ struct BoardToolbarTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let search = BoardSearchPresentation() 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(
controller.toolbar, controller.toolbar,
@@ -409,6 +426,45 @@ struct BoardToolbarTests {
#expect(!store.transient.isTrashVisible) #expect(!store.transient.isTrashVisible)
#expect(showTrash.isOn == false) #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 // MARK: - The card window's toolbar