Build the styling system and shared style editor

One style-editor component, anchor-agnostic: a background grid (None
well plus the 12 palette colors) and a curated symbol grid (the
pathfinder's five-dozen set, leading well removing the icon key for
the level default), selection-aware across cards, lanes, and the
board itself. Batch edits compute per-dimension state — uniform,
mixed (no well selected), or an off-palette value labeled verbatim
outside the grids — and choosing a well applies to the whole target
set as one write bracket, skipping no-ops per field. The popover
tracks its target set live per the freshly ratified rule: targets
re-resolve by UUID on every reload, a vanished target leaves the set,
an emptied set dismisses the editor, and nothing ever silently
retargets to the board. Anchors landing now: Board > Style
(Opt-Cmd-S) and the card/lane context menus, which also carry the
quick-style recents row (app-wide, persisted, capped at six, None
never recorded) and the lane's width control twinning the menu
chords. The styling system's other two renders arrive with it: a
lane's background paints the C7 top-edge band, the board's paints
the window content background — malformed values paint nothing and
stay byte-identical on disk. 31 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 14:47:26 -04:00
parent bea6d02d1d
commit c6298c2e41
15 changed files with 1943 additions and 13 deletions
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import Observation
import os
/// The quick-style row's memory: the backgrounds this user has applied lately, most-recent-first
/// (03-board-ui.md § Styling Controls "Quick-style row, recents only Recents are app-wide and
/// persist app-side (user preference, never board data)").
///
/// ### Why it lives here, and not in the registry
///
/// `BoardRegistry`'s own doc names this type's contents as the example of what does *not* belong in
/// it: "App-wide state that is not board-scoped quick-style recents, the SSH host-key table, the
/// last-used card-window size lives *beside* this file, not in it" (02-architecture.md §
/// Per-board app state). No board owns the list; styling one board's cards blue is what makes blue
/// offer itself on the next board, which is the whole point of the row.
///
/// ### Two halves, so the rules are testable without a defaults domain
///
/// The list *rule* is `updated(_:with:cap:)` a static pure function and this object is its
/// persistence and its observability. `@Observable` so every open context menu reorders the instant a
/// colour is applied from anywhere; `defaults` injectable so a test drives a suite of its own rather
/// than the user's.
///
/// ### What never enters the list
///
/// **The None well is not a colour.** It removes the `background` key, so there is nothing to
/// remember and nothing a future row could offer the recording call site simply never fires for it
/// (see `StyleCommand.apply`). Empty strings are refused here too, belt over braces.
@MainActor
@Observable
public final class StyleRecents {
/// How many the row keeps. Six is "capped ~6" from the milestone's shape and about what a
/// compact menu row carries without becoming a second palette which is the one thing the
/// design says this row is not ("the pathfinder's second full-palette tier is gone").
public static let cap = 6
/// The recents, most-recent-first. Palette names or `#RRGGBB[AA]` hex the same strings that go
/// to frontmatter, so a hand-written hex applied from a board's file could join the row without
/// any translation (nothing in-app can produce one today; § Controls' curated-in-app rule).
public private(set) var backgrounds: [String]
@ObservationIgnored
private let defaults: UserDefaults
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "style-recents")
/// - Parameter defaults: the domain to persist in. Injected for the reason `BoardRegistry` takes
/// its storage URL: a test must be able to hold its own without touching the user's.
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Anything but an array of strings is treated as an empty list rather than as an error: this
// is a convenience, and a hand-edited or truncated preference must never be the reason a
// context menu cannot open (`BoardRegistry`'s "it never takes the app down", one scale down).
backgrounds = (defaults.array(forKey: AppPreferences.quickStyleBackgroundsKey) as? [String]) ?? []
}
/// Records `value` as the most recently used background and persists the new list.
///
/// Move-to-front, deduped, capped see `updated(_:with:cap:)` for the rule itself. Called on
/// **every** background application from every anchor (the editor's wells, the quick-style row),
/// which is what "recently used" has to mean for the row to be worth having; the write is
/// unconditional even when the value was already at the front, since re-persisting an identical
/// list is cheaper than deciding not to.
public func record(_ value: String) {
backgrounds = Self.updated(backgrounds, with: value)
defaults.set(backgrounds, forKey: AppPreferences.quickStyleBackgroundsKey)
}
/// The list rule, as a pure function: `value` to the front, its earlier occurrence removed, the
/// tail truncated to `cap`.
///
/// An empty `value` returns the list unchanged. It is not a colour anyone applied the two ways
/// to arrive at one would be a removal (which is the None well, and never recorded) or a
/// hand-written blank and a blank swatch in the row would be an unclickable hole.
public static func updated(_ list: [String], with value: String, cap: Int = cap) -> [String] {
guard !value.isEmpty else { return list }
var updated = list.filter { $0 != value }
updated.insert(value, at: 0)
return Array(updated.prefix(cap))
}
}