Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.
BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.
The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.
1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
83 lines
4.5 KiB
Swift
83 lines
4.5 KiB
Swift
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))
|
|
}
|
|
}
|