From b0ffff1aa14e198c08359f5d9ce43f2c54ef7984 Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 7 Aug 2026 12:55:37 -0400 Subject: [PATCH] =?UTF-8?q?The=20app=20learns=20Appearance=20=E2=80=94=20A?= =?UTF-8?q?uto,=20Light,=20Dark=20from=20the=20View=20menu=20and=20a=20too?= =?UTF-8?q?lbar=20pull-down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 2 + DESIGN/03-board-ui.md | 2 +- DESIGN/11-command-nexus.md | 3 + Kanban/App/AppDelegate.swift | 6 + Kanban/App/AppModel.swift | 29 +++++ Kanban/App/AppearanceCommands.swift | 36 ++++++ Kanban/App/AppearanceStore.swift | 130 +++++++++++++++++++ Kanban/App/BoardWindowHost.swift | 1 + Kanban/App/WindowToolbar.swift | 79 +++++++++++- Kanban/KanbanApp.swift | 9 +- Kanban/UI/Board/BoardToolbar.swift | 55 ++++++-- KanbanTests/AppearanceTests.swift | 166 +++++++++++++++++++++++++ KanbanTests/HistoryProviderTests.swift | 3 + KanbanTests/ToolbarTests.swift | 94 +++++++++++--- 14 files changed, 579 insertions(+), 36 deletions(-) create mode 100644 Kanban/App/AppearanceCommands.swift create mode 100644 Kanban/App/AppearanceStore.swift create mode 100644 KanbanTests/AppearanceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dccab..5cc79e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ **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. 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. diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index e039d83..2a183e0 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -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. -- **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). ## Lane diff --git a/DESIGN/11-command-nexus.md b/DESIGN/11-command-nexus.md index 58582e5..f87d55b 100644 --- a/DESIGN/11-command-nexus.md +++ b/DESIGN/11-command-nexus.md @@ -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 | 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 | 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) | ## Fixed grammar keys (G) diff --git a/Kanban/App/AppDelegate.swift b/Kanban/App/AppDelegate.swift index 6a65296..96dfdef 100644 --- a/Kanban/App/AppDelegate.swift +++ b/Kanban/App/AppDelegate.swift @@ -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 /// *later* composition may read, and never reaches into a session that is already open /// (`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) { appModel?.entitlement.start() + appModel?.appearance.applyCurrent() } /// **The close is respected.** "Closing the last board window leaves the app windowless (menu bar diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 854a304..9da9330 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -127,6 +127,26 @@ public enum AppPreferences { /// stale-from-a-future-build value indistinguishable from a legal one downstream. 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) — /// 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. 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). /// /// 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) styleRecents = StyleRecents(defaults: preferences) zoom = BoardZoomStore(defaults: preferences) + appearance = AppearanceStore(defaults: preferences) clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot) // 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. diff --git a/Kanban/App/AppearanceCommands.swift b/Kanban/App/AppearanceCommands.swift new file mode 100644 index 0000000..2b79fb0 --- /dev/null +++ b/Kanban/App/AppearanceCommands.swift @@ -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?) + } + } +} diff --git a/Kanban/App/AppearanceStore.swift b/Kanban/App/AppearanceStore.swift new file mode 100644 index 0000000..6f82e14 --- /dev/null +++ b/Kanban/App/AppearanceStore.swift @@ -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)) + } +} diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index c84d9cb..960e807 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -775,6 +775,7 @@ struct BoardWindowHost: View { store: store, search: boardSearch, zoom: appModel.zoom, + appearance: appModel.appearance, session: appModel.dragSession )) } diff --git a/Kanban/App/WindowToolbar.swift b/Kanban/App/WindowToolbar.swift index 83b2930..9cf2001 100644 --- a/Kanban/App/WindowToolbar.swift +++ b/Kanban/App/WindowToolbar.swift @@ -83,6 +83,18 @@ struct ToolbarItemSpec { make: (_ willBeInsertedIntoToolbar: Bool) -> NSSearchField, 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 @@ -121,7 +133,7 @@ struct ToolbarItemSpec { switch behavior { case let .button(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? { switch behavior { 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 - /// itself. + /// Firing the item: a button performs, a toggle flips. A no-op for the kinds AppKit drives itself + /// or that fire from somewhere other than the item's own primary action (`.picker`'s dropdown + /// rows). func activate() { switch behavior { case let .button(_, perform): perform() 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. install: willBeInsertedIntoToolbar ? install : nil ) + case let .picker(options, _, _): + return makePickerItem(spec, options: options) } } @@ -406,6 +421,41 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { 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 — /// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads. private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) { @@ -444,13 +494,30 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { 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, /// 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 { guard let raw = menuItem.representedObject as? String, let spec = specs[NSToolbarItem.Identifier(raw)] else { return true } + if case let .picker(_, selected, _) = spec.behavior { + menuItem.state = selected() == menuItem.tag ? .on : .off + return true + } if let isOn = spec.isOn { menuItem.state = isOn ? .on : .off } diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 4b3b833..b46e631 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -240,9 +240,10 @@ struct KanbanApp: App { ToolbarCommands() // 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 - // 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 // 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() CardViewCommands() + + Divider() + + AppearanceCommands(appModel: appModel) } // The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open diff --git a/Kanban/UI/Board/BoardToolbar.swift b/Kanban/UI/Board/BoardToolbar.swift index 6126824..2d31ded 100644 --- a/Kanban/UI/Board/BoardToolbar.swift +++ b/Kanban/UI/Board/BoardToolbar.swift @@ -11,16 +11,18 @@ extension NSToolbarItem.Identifier { static let boardShowTrash = Self("board.showTrash") static let boardZoomIn = Self("board.zoomIn") static let boardZoomOut = Self("board.zoomOut") + static let boardAppearance = Self("board.appearance") } // MARK: - The board window's 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 -/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`. +/// "**Board window default: the search field and Appearance** — both **centered**, the titlebar's +/// 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 …, /// 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. static let identifier = "dev.rzen.indie.Kanban.board" - /// "The search field, nothing else — trailing, the one default item." - static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch] + /// "The search field and Appearance — both centered, immediately after the field + /// (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: /// - 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 /// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live /// 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 /// (`ZoomCommands.isEnabled`). static func specs( store: BoardStore, search: BoardSearchPresentation, zoom: BoardZoomStore, + appearance: AppearanceStore, session: DragSession ) -> [ToolbarItemSpec] { [ @@ -141,6 +152,31 @@ enum BoardToolbar { 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( "Search", identifier: .boardSearch, @@ -180,18 +216,21 @@ enum BoardToolbar { store: BoardStore, search: BoardSearchPresentation, zoom: BoardZoomStore, + appearance: AppearanceStore, session: DragSession ) -> WindowToolbarController { let controller = WindowToolbarController( 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 ) // Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement // grammar, ratified 2026-08-06): `centeredItemIdentifiers` holds as catalog items install // and sits outside the autosaved configuration, so it reaches machines that saved an - // arrangement under the old trailing default. `defaultItems` is untouched. - controller.toolbar.centeredItemIdentifiers = [.boardSearch] + // arrangement under the old trailing default. `defaultItems` is untouched. Appearance joined + // 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 search?.isInstalledInToolbar = identifiers.contains(.boardSearch) } diff --git a/KanbanTests/AppearanceTests.swift b/KanbanTests/AppearanceTests.swift new file mode 100644 index 0000000..58dcbc5 --- /dev/null +++ b/KanbanTests/AppearanceTests.swift @@ -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]) + } +} diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index d1a6cb6..1e3c271 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -841,6 +841,7 @@ struct UndoCommandSurfaceTests { store: store, search: BoardSearchPresentation(), 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() ) let provider = FakeHistoryProvider() @@ -899,6 +900,7 @@ struct UndoCommandSurfaceTests { store: store, search: BoardSearchPresentation(), zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!), + appearance: AppearanceStore(defaults: UserDefaults(suiteName: zoomDomain + ".appearance")!, apply: { _ in }), session: DragSession() ) let provider = FakeHistoryProvider() @@ -944,6 +946,7 @@ struct UndoCommandSurfaceTests { store: store, search: BoardSearchPresentation(), 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() ) diff --git a/KanbanTests/ToolbarTests.swift b/KanbanTests/ToolbarTests.swift index 550b8e6..6b02e17 100644 --- a/KanbanTests/ToolbarTests.swift +++ b/KanbanTests/ToolbarTests.swift @@ -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