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
+29
View File
@@ -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.