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. The app's /// own is the **group's shared suite** (02-architecture.md § Per-board app state, ruled /// 2026-07-29), so the row an upgrader built up in base is the row Pro offers. public init(defaults: UserDefaults = AppGroup.defaults) { 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)) } }