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
+73 -6
View File
@@ -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
}