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:
@@ -50,6 +50,13 @@ public enum AppPreferences {
|
||||
public static func setLastCardWindowSize(_ size: CGSize) {
|
||||
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey)
|
||||
}
|
||||
|
||||
/// The quick-style row's recently-used backgrounds — an array of palette names / hex strings,
|
||||
/// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist
|
||||
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
|
||||
/// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule;
|
||||
/// the key is declared here with its neighbours for `WindowID`'s reason.
|
||||
public static let quickStyleBackgroundsKey = "quickStyleBackgrounds"
|
||||
}
|
||||
|
||||
// MARK: - Launch failures
|
||||
@@ -147,6 +154,12 @@ public final class AppModel {
|
||||
public let storeRegistry = BoardStoreRegistry()
|
||||
public let boardRegistry: BoardRegistry
|
||||
|
||||
/// The quick-style row's app-wide recents (03-board-ui.md § Styling ▸ Controls). Owned here for
|
||||
/// the registries' reason — app-scoped, and a test holds its own rather than colliding with the
|
||||
/// app's — and reached by the context menus through the environment, since a `BoardStore` is
|
||||
/// board-scoped and this list deliberately is not.
|
||||
public let styleRecents = StyleRecents()
|
||||
|
||||
// MARK: Sessions
|
||||
|
||||
/// One open board window and everything hanging off it.
|
||||
|
||||
@@ -121,12 +121,13 @@ struct KanbanApp: App {
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
}
|
||||
|
||||
// The Board menu (11-command-nexus.md), in its inventoried order — Rename precedes the
|
||||
// width pair, with Open Card, Style… and the Move items still owed. Its items act on the
|
||||
// The Board menu (11-command-nexus.md), in its inventoried order — Rename, then Style…,
|
||||
// then the width pair, with Open Card and the Move items still owed. Its items act on the
|
||||
// frontmost board window, which they reach through the focus system rather than through the
|
||||
// app model — see `BoardCommands.swift`, which also owns their validation.
|
||||
CommandMenu("Board") {
|
||||
BoardRenameCommand()
|
||||
BoardStyleCommand()
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -645,6 +645,153 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
|
||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
|
||||
/// keys currently say there.
|
||||
///
|
||||
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
|
||||
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
|
||||
/// write can never disagree about what is already on disk. `id` is `nil` for the board root,
|
||||
/// which has no `ItemID` by design (see `ItemID`'s doc comment).
|
||||
public struct StyleSubject: Sendable, Equatable {
|
||||
public let id: ItemID?
|
||||
public let folder: URL
|
||||
public let background: FieldValue<String>
|
||||
public let icon: FieldValue<String>
|
||||
}
|
||||
|
||||
/// The live items `target` names, in display order — lanes left to right, each lane's cards top
|
||||
/// to bottom.
|
||||
///
|
||||
/// **Vanished targets are simply absent**, ancestor walk included: a tombstoned card, a card
|
||||
/// under a tombstoned lane, and an id that names nothing all contribute no subject, which is the
|
||||
/// same silent skip `commitRename` gives a vanished rename target — "nothing is ever written into
|
||||
/// a vanished folder". A style editor whose set has emptied dismisses (`StyleEditorSession`), so
|
||||
/// an empty result is a frame's worth of nothing to show rather than a state to handle.
|
||||
public func styleSubjects(of target: StyleTarget) -> [StyleSubject] {
|
||||
switch target {
|
||||
case .board:
|
||||
return [StyleSubject(
|
||||
id: nil,
|
||||
folder: rootURL,
|
||||
background: snapshot.background,
|
||||
icon: snapshot.icon
|
||||
)]
|
||||
|
||||
case let .items(ids):
|
||||
var subjects: [StyleSubject] = []
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
let laneFolder = rootURL.appendingPathComponent(lane.id.rawValue)
|
||||
if ids.contains(lane.id) {
|
||||
subjects.append(StyleSubject(
|
||||
id: lane.id,
|
||||
folder: laneFolder,
|
||||
background: lane.background,
|
||||
icon: lane.icon
|
||||
))
|
||||
}
|
||||
for card in lane.cards where !card.isDeleted && ids.contains(card.id) {
|
||||
subjects.append(StyleSubject(
|
||||
id: card.id,
|
||||
folder: laneFolder.appendingPathComponent(card.id.rawValue),
|
||||
background: card.background,
|
||||
icon: card.icon
|
||||
))
|
||||
}
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
}
|
||||
|
||||
/// Which level `target` sits at — the editor's symbol grid needs it for its leading well, "the
|
||||
/// level's default symbol" (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// A set naming any card is a card set: 04-interactions.md's cards-XOR-lanes rule means a live
|
||||
/// selection never mixes the two, so the branch below is a total answer rather than a policy —
|
||||
/// and if a mixed set ever reached here, `doc.text` is the level whose default would actually be
|
||||
/// removed by the leading well.
|
||||
public func styleLevel(of target: StyleTarget) -> StyleLevel {
|
||||
switch target {
|
||||
case .board:
|
||||
return .board
|
||||
case let .items(ids):
|
||||
let namesACard = snapshot.lanes.contains { lane in
|
||||
!lane.isDeleted && lane.cards.contains { !$0.isDeleted && ids.contains($0.id) }
|
||||
}
|
||||
return namesACard ? .card : .lane
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a style gesture — the **one** commit point every anchor shares (03-board-ui.md §
|
||||
/// Styling ▸ Controls: "One component, one behavior, three anchors"), and the quick-style recents
|
||||
/// row with them.
|
||||
///
|
||||
/// **One bracket, whatever the target set's size.** "Choosing a well applies to the whole
|
||||
/// selection — one gesture, one commit on git boards" (§ Controls), so every target's `index.md`
|
||||
/// is rewritten inside a single `performWrite`: the churn rounds back as one app-mediated reload,
|
||||
/// and the auto-committer (m7) sees one operation rather than N.
|
||||
///
|
||||
/// **No-ops are skipped per dimension and per target** — `setLaneWidth`'s rule, for its reason: a
|
||||
/// well clicked twice, or a batch where half the cards are already that colour, must not stamp
|
||||
/// `modified` or mint a commit on the items that were already right. A dimension whose value is
|
||||
/// already what the gesture asks contributes nothing; a target both of whose dimensions are
|
||||
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
|
||||
/// bracket at all.
|
||||
///
|
||||
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes,
|
||||
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
|
||||
/// control for it, so there is nothing here to pass.
|
||||
///
|
||||
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
|
||||
/// like every other gesture with no second thing to do. A batch that fails partway leaves the
|
||||
/// targets written before it written — the Writer is "atomic per filesystem operation, not per
|
||||
/// gesture" — and the reload shows the true state, which is the honest one.
|
||||
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
|
||||
let edits: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target)
|
||||
.compactMap { subject in
|
||||
let background = Self.effective(background, against: subject.background)
|
||||
let icon = Self.effective(icon, against: subject.icon)
|
||||
guard background != .keep || icon != .keep else { return nil }
|
||||
return (folder: subject.folder, background: background, icon: icon)
|
||||
}
|
||||
guard !edits.isEmpty else { return }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for edit in edits {
|
||||
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||
// failure names the item by the title it still has (see `WriteOperation.style`).
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `change` narrowed against what is already on disk: `.keep` when it would write what is
|
||||
/// already there.
|
||||
///
|
||||
/// The comparison is against the **valid** reading, not the written text: a malformed value —
|
||||
/// `background: [a, b]`, a sequence where a scalar belongs — is never equal to a palette name, so
|
||||
/// choosing a well always replaces it, which is what "choosing any well replaces it" (§ Controls)
|
||||
/// promises about a value the app could not read.
|
||||
nonisolated static func effective(_ change: StyleChange, against field: FieldValue<String>) -> StyleChange {
|
||||
switch change {
|
||||
case .keep: .keep
|
||||
case let .set(value): field.value == value ? .keep : .set(value)
|
||||
case .remove: field.isMissing ? .keep : .remove
|
||||
}
|
||||
}
|
||||
|
||||
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
|
||||
switch change {
|
||||
case .keep: break
|
||||
case let .set(value): document.set(key, to: .string(value))
|
||||
case .remove: document.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Creation
|
||||
|
||||
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
/// The styling system's vocabulary — what a style gesture *is*, before any view or any write exists
|
||||
/// (03-board-ui.md § Styling). Three small value types, deliberately kept out of both the view layer
|
||||
/// and the Writer: the editor builds gestures out of them, `BoardStore.applyStyle` turns them into
|
||||
/// files, and every rule that can be stated without a screen or a disk is stated here where a test
|
||||
/// can reach it.
|
||||
|
||||
// MARK: - What a gesture asks of one key
|
||||
|
||||
/// What one style gesture asks of one frontmatter key.
|
||||
///
|
||||
/// **Three cases, because "leave it alone" and "there is no value" are different instructions.** A
|
||||
/// style editor always speaks about both dimensions at once — a click on a background well says
|
||||
/// nothing about the symbol — so `keep` is what the untouched dimension carries, and `remove` is
|
||||
/// what the two leading wells carry: "the None well … removes the `background` key" and the symbol
|
||||
/// grid's "leading well is the level's default symbol and removes the `icon` key" (03 § Styling ▸
|
||||
/// Controls). Writing `background: ""` instead would be a real (if blank) value and the exact
|
||||
/// mistake the remove-at-default family — the empty rename, the width landing on 1 — exists to
|
||||
/// avoid.
|
||||
///
|
||||
/// `set` carries the string that goes to frontmatter verbatim: a kebab-case palette name from the
|
||||
/// grid, or an SF Symbol name. Hex never arrives here from the app ("custom hex is not pickable
|
||||
/// in-app but stays fully honored from disk"), though nothing in the type forbids it.
|
||||
public enum StyleChange: Sendable, Equatable {
|
||||
case keep
|
||||
case set(String)
|
||||
case remove
|
||||
}
|
||||
|
||||
// MARK: - What a gesture is aimed at
|
||||
|
||||
/// What a style edit acts on — "one component, one behavior, three anchors" needs exactly one
|
||||
/// vocabulary word for "who is being styled" (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// **A set of items, or the board — never both, and never a mix of levels in spirit.** The editor is
|
||||
/// "selection-aware: it styles the selected cards or lane, and with nothing selected, the board", and
|
||||
/// 04-interactions.md's cards-XOR-lanes rule means a live selection is homogeneous by kind. Nothing
|
||||
/// here enforces that (a `Set<ItemID>` cannot), because the enforcement belongs to the selection
|
||||
/// model; what this type *does* guarantee is that a board target and an item target are structurally
|
||||
/// different things, which is what makes "it never silently retargets to the board" (§ Controls) a
|
||||
/// property of the code rather than a discipline.
|
||||
public enum StyleTarget: Sendable, Equatable {
|
||||
case board
|
||||
case items(Set<ItemID>)
|
||||
}
|
||||
|
||||
/// Which level a target sits at — the one thing the editor needs beyond the values themselves,
|
||||
/// because the symbol grid's leading well is "the level's default symbol" (03 § Styling ▸ Controls)
|
||||
/// and the three defaults differ (§ Capabilities).
|
||||
public enum StyleLevel: Sendable, Equatable {
|
||||
case board
|
||||
case lane
|
||||
case card
|
||||
}
|
||||
|
||||
// MARK: - The current value across a target set
|
||||
|
||||
/// What one style dimension currently reads across a whole target set — the editor's per-dimension
|
||||
/// display, and the whole of 03-board-ui.md § Styling ▸ Controls' batch rule: "a multi-selection
|
||||
/// shows per-dimension mixed state (no well selected, '—' where a value would read)".
|
||||
///
|
||||
/// Three states rather than an optional plus a flag, because the three mean genuinely different
|
||||
/// things to the grid: `unset` selects the leading well (None / the level default), `uniform`
|
||||
/// selects the matching well — or, when the value is off-palette, labels itself verbatim outside the
|
||||
/// grid — and `mixed` selects nothing at all.
|
||||
///
|
||||
/// **Deliberately a pure function of the fields.** It takes `FieldValue`s rather than a store so the
|
||||
/// interesting rules — what counts as agreement, what a malformed value reads as — are testable
|
||||
/// without a board on disk.
|
||||
public enum StyleFieldState: Sendable, Equatable {
|
||||
/// No target carries the key. The leading well is the current value.
|
||||
case unset
|
||||
/// Every target carries the same value, written exactly this way.
|
||||
case uniform(String)
|
||||
/// The targets disagree. Nothing is selected; the display reads "—".
|
||||
case mixed
|
||||
|
||||
/// The state of one dimension over `fields`, one per target, in any order.
|
||||
///
|
||||
/// An **empty** list is `unset` rather than a fourth case: a target set with no live members is
|
||||
/// about to dismiss the popover anyway (`StyleEditorSession.resolved(against:)`), and a frame
|
||||
/// rendered with nothing selected is the honest thing to show in the meantime.
|
||||
public static func resolve(_ fields: [FieldValue<String>]) -> StyleFieldState {
|
||||
var iterator = fields.makeIterator()
|
||||
guard let first = iterator.next() else { return .unset }
|
||||
let firstText = written(first)
|
||||
while let next = iterator.next() {
|
||||
guard written(next) == firstText else { return .mixed }
|
||||
}
|
||||
guard let firstText else { return .unset }
|
||||
return .uniform(firstText)
|
||||
}
|
||||
|
||||
/// The value **as written on disk**, or `nil` when the key is absent.
|
||||
///
|
||||
/// A malformed field — a sequence where a scalar belongs — reads as its raw text rather than as
|
||||
/// nothing, which is what makes the off-palette display honest: "off-palette values display
|
||||
/// leniently … labeled verbatim, outside the grids" (03 § Styling ▸ Controls) is about *what the
|
||||
/// author wrote*, and a value the parser could not make a string of is exactly the case where
|
||||
/// showing the bytes matters most. Agreement is judged on this same text, so two targets whose
|
||||
/// `background` is malformed in the same way read as uniform — they do carry the same value.
|
||||
public static func written(_ field: FieldValue<String>) -> String? {
|
||||
switch field {
|
||||
case .missing: nil
|
||||
case let .valid(value): value
|
||||
case let .malformed(raw): raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The open Style… popover
|
||||
|
||||
/// The Style… popover's live target — 03-board-ui.md § Styling ▸ Controls' settled lifecycle
|
||||
/// sentence, as a value: "The Style… popover tracks its target set live and dismisses when it
|
||||
/// empties … its target is the selection, re-resolved across reloads by 02-architecture.md's UUID
|
||||
/// rule — a member that vanishes or flips liveness leaves the set and the mixed-state display
|
||||
/// recomputes; a set emptied by a foreign reload dismisses the popover … it never silently retargets
|
||||
/// to the board".
|
||||
///
|
||||
/// **Held in `TransientBoardState`, not in a view**, and that is the whole design of it: the rule is
|
||||
/// a *reload* rule, and the container that re-grounds the selection, the drag, the pending cut and
|
||||
/// the rename editor on every applied snapshot is the one place a reload rule can be written once.
|
||||
/// The sibling it most resembles is `RenameEditor` — an item-referencing editor that a vanish
|
||||
/// discards — which is exactly the analogy the design draws ("the inline-rename discard applied
|
||||
/// here").
|
||||
///
|
||||
/// The two embedded anchors (the card window's Style section, the board popover's styling area) need
|
||||
/// none of this and get none: "the card sidebar dismisses with its card's window, and the board
|
||||
/// popover's target is the board itself".
|
||||
public struct StyleEditorSession: Sendable, Equatable {
|
||||
|
||||
/// Who is being styled. `let`, because a session never changes target — a session whose targets
|
||||
/// all vanished is *gone*, not re-aimed.
|
||||
public let target: StyleTarget
|
||||
|
||||
public init(target: StyleTarget) {
|
||||
self.target = target
|
||||
}
|
||||
|
||||
/// This session re-grounded on a freshly applied snapshot, or `nil` when it has nothing left to
|
||||
/// style — which the presenting anchor reads as "dismiss".
|
||||
///
|
||||
/// - **Items** are constrained through `ItemReferenceSet`, so liveness is **effective —
|
||||
/// ancestor-walked** for free: a card under a lane an agent just tombstoned renders nowhere and
|
||||
/// therefore leaves the set, exactly as it leaves the selection.
|
||||
/// - **The board** never dismisses. It cannot vanish from its own snapshot, and a board target
|
||||
/// has no membership to lose ("a board-targeted editor has no vanish case").
|
||||
///
|
||||
/// Nothing here can turn an item target into a board target: emptiness returns `nil`, and the
|
||||
/// only way a board session exists is for one to have been opened that way.
|
||||
public func resolved(against snapshot: BoardModel) -> StyleEditorSession? {
|
||||
switch target {
|
||||
case .board:
|
||||
return self
|
||||
case let .items(ids):
|
||||
let live = ItemReferenceSet(ids: ids, liveness: .live).resolved(against: snapshot).ids
|
||||
guard !live.isEmpty else { return nil }
|
||||
return live == ids ? self : StyleEditorSession(target: .items(live))
|
||||
}
|
||||
}
|
||||
|
||||
/// The item the popover hangs off, or `nil` for the board window itself.
|
||||
///
|
||||
/// **Derived, never stored.** Every candidate anchor — a card face, a lane header, the strip —
|
||||
/// asks this one question of the current snapshot, so exactly one surface presents the popover
|
||||
/// and the answer follows the target set across reloads: an anchor that vanishes hands the
|
||||
/// popover to the next live target rather than taking it down with it (only an *emptied* set
|
||||
/// dismisses). Display order — lanes left to right, cards top to bottom — is the tie-break, so
|
||||
/// the popover lands on the first thing the user's eye would find.
|
||||
public func presentationAnchor(in snapshot: BoardModel) -> ItemID? {
|
||||
guard case let .items(ids) = target else { return nil }
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
if ids.contains(lane.id) { return lane.id }
|
||||
if let card = lane.cards.first(where: { !$0.isDeleted && ids.contains($0.id) }) { return card.id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -323,6 +323,18 @@ public final class TransientBoardState {
|
||||
/// out from under a commit that was about to read its draft.
|
||||
public private(set) var renameEditor: RenameEditor?
|
||||
|
||||
/// The open Style… popover's target, or `nil` when no popover is open (03-board-ui.md § Styling
|
||||
/// ▸ Controls). See `StyleEditorSession` for the lifecycle it implements and why it lives here
|
||||
/// rather than in a view.
|
||||
///
|
||||
/// **Not an inline editor**, deliberately: it is not a title field, it does not hold the text
|
||||
/// domain's keyboard, and `isEditingInline` must stay the answer to "may board commands run" —
|
||||
/// Style… itself is *disabled* while an inline editor is open, so the two never coexist anyway.
|
||||
///
|
||||
/// `private(set)` for `renameEditor`'s reason: its two transitions are the methods below, and a
|
||||
/// session assignable from anywhere could be re-aimed behind the reload rule's back.
|
||||
public private(set) var styleEditor: StyleEditorSession?
|
||||
|
||||
/// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one
|
||||
/// boolean: "while an inline title editor — rename or the new-card placeholder — is focused,
|
||||
/// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu
|
||||
@@ -466,6 +478,26 @@ public final class TransientBoardState {
|
||||
renameEditor = nil
|
||||
}
|
||||
|
||||
// MARK: - The style editor's lifecycle
|
||||
|
||||
/// Opens the Style… popover on `target` — the menu item's call and both context menus'
|
||||
/// (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// Replacing any session already open, for the inline editors' reason turned inside out: there is
|
||||
/// one popover, so a second Style… is the first one being re-aimed by a fresh gesture. Unlike
|
||||
/// `beginRename`/`beginPlaceholder` it does **not** clear the inline editors — it cannot coexist
|
||||
/// with one (Style… disables while an editor is focused), so clearing them here would be a rule
|
||||
/// about a state that menu validation already rules out.
|
||||
public func beginStyleEditor(for target: StyleTarget) {
|
||||
styleEditor = StyleEditorSession(target: target)
|
||||
}
|
||||
|
||||
/// Closes the popover — the user dismissing it, and the anchor's response to a session the
|
||||
/// reload rule emptied.
|
||||
public func discardStyleEditor() {
|
||||
styleEditor = nil
|
||||
}
|
||||
|
||||
// MARK: - Reload
|
||||
|
||||
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
|
||||
@@ -508,6 +540,13 @@ public final class TransientBoardState {
|
||||
/// current universe does not have. It is not an `ItemReferenceSet` only because it is one
|
||||
/// optional rather than a set on a side — the rule it obeys is the same one.
|
||||
///
|
||||
/// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
|
||||
/// settled): a member that vanishes or flips liveness leaves the set — so the editor's
|
||||
/// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
|
||||
/// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
|
||||
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
|
||||
/// both halves.
|
||||
///
|
||||
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references
|
||||
/// an item, so no snapshot can invalidate either — the query's *results* change with every
|
||||
/// snapshot, which is precisely why the results are not stored here.
|
||||
@@ -516,6 +555,7 @@ public final class TransientBoardState {
|
||||
dragMembers = dragMembers.resolved(against: snapshot)
|
||||
pendingCut = pendingCut.resolved(against: snapshot)
|
||||
newCardPlaceholder = resolvedPlaceholder(against: snapshot)
|
||||
styleEditor = styleEditor?.resolved(against: snapshot)
|
||||
|
||||
// One universe computed once and asked three questions — the rename target's liveness, the
|
||||
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
|
||||
|
||||
@@ -121,6 +121,48 @@ struct BoardRenameCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Style
|
||||
|
||||
/// Board ▸ Style… (⌥⌘S) — the style editor's menu-bar anchor (11-command-nexus.md;
|
||||
/// 03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// **Selection-aware, with the board as the empty-selection case**: "Board window: selected cards or
|
||||
/// lane; nothing selected = the board". The item does not present anything itself — it opens the
|
||||
/// session (`TransientBoardState.beginStyleEditor`) and the board window's anchors decide which
|
||||
/// surface hosts the popover, which is what keeps the presentation attached to what is being styled
|
||||
/// rather than to the menu bar.
|
||||
///
|
||||
/// Validation is `acceptsBoardMutations` — the lock and the focused-editor rule, the latter naming
|
||||
/// Style in its own list of board-scoped commands (04-interactions.md ▸ Grammar) — plus one rule of
|
||||
/// its own: **a tombstoned selection disables it rather than falling through to the board.**
|
||||
/// Everything edit-shaped is disabled on tombstoned selections (04 ▸ The trash), and quietly
|
||||
/// restyling the board because the user had a trashed card selected would be the silent retarget
|
||||
/// 03 forbids.
|
||||
struct BoardStyleCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Style…") {
|
||||
guard let store, let target = styleTarget else { return }
|
||||
store.transient.beginStyleEditor(for: target)
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.option, .command])
|
||||
.disabled(styleTarget == nil)
|
||||
}
|
||||
|
||||
private var styleTarget: StyleTarget? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return .board }
|
||||
guard selection.liveness == .live else { return nil }
|
||||
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
||||
// items that render — the same universe its own reload rule will hold it to.
|
||||
let live = selection.resolved(against: store.snapshot).ids
|
||||
return live.isEmpty ? nil : .items(live)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane width items
|
||||
|
||||
/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane,
|
||||
|
||||
@@ -45,6 +45,10 @@ struct BoardView: View {
|
||||
/// needs the board's own window ref, which is the host's identity and not the board's.
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
|
||||
/// Styling ▸ Controls).
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
|
||||
/// view does, which is the interaction's whole lifetime.
|
||||
@State private var resize = LaneResizeSession()
|
||||
@@ -89,6 +93,12 @@ struct BoardView: View {
|
||||
.padding(spacing)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background(boardBackground)
|
||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||||
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
|
||||
// the strip is the window's content, not a control, and a rectangle around the whole board
|
||||
// would read as an error state.
|
||||
@@ -105,6 +115,27 @@ struct BoardView: View {
|
||||
.onKeyPress(.escape) { handleEscape() }
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
|
||||
/// The board's `background`, painting "the board window's content background (the surface behind
|
||||
/// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
|
||||
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
||||
/// its ≥ 4.5:1 rule to: text does sit on it. That runtime contrast computation (a hex background's
|
||||
/// text colour, recomputed against the composited backdrop on appearance change) is not this
|
||||
/// card's — what ships here is the palette path, whose twelve pairs are AA-verified at design
|
||||
/// time.
|
||||
///
|
||||
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
||||
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
||||
@ViewBuilder
|
||||
private var boardBackground: some View {
|
||||
if let color = Palette.color(for: store.snapshot.background) {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lanes
|
||||
|
||||
/// One lane's strip slot, plus its trailing grab strip.
|
||||
|
||||
@@ -32,17 +32,28 @@ struct LaneHeaderDrag {
|
||||
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
|
||||
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
|
||||
///
|
||||
/// ### The lane's one context menu
|
||||
///
|
||||
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
|
||||
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Style…, the
|
||||
/// quick-style recents row and the Width stepper today; Rename and Delete are m5's context-menus
|
||||
/// card, and their rows go into that same builder rather than into a second menu.
|
||||
///
|
||||
/// ### What is still a later card's
|
||||
///
|
||||
/// The lane context menu (Rename, Style…, the quick-style recents row, the Width stepper, Delete),
|
||||
/// the lane's own top-edge accent band, and the search-aware filtering behind the count all belong
|
||||
/// to later milestones. The card face is real (`CardFaceView`); what it still owes is the cut
|
||||
/// treatment and the sole-selected card's attachment carousel.
|
||||
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
|
||||
/// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment
|
||||
/// carousel.
|
||||
struct LaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let lane: Lane
|
||||
|
||||
/// The app-wide quick-style recents (03-board-ui.md § Styling ▸ Controls — "never board data"),
|
||||
/// read from the environment rather than threaded down the strip: the list belongs to the app,
|
||||
/// not to this board, and every context menu in the window wants it.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// Interior masonry columns — the lane's width units, or the resize session's snapped count
|
||||
/// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
|
||||
/// override it (see `BoardView.laneSlot`).
|
||||
@@ -61,12 +72,24 @@ struct LaneView: View {
|
||||
/// Spacing between cards, and between the interior columns.
|
||||
private let cardSpacing: CGFloat = 8
|
||||
|
||||
/// The lane plate's corner radius — shared by the selection treatment and the accent band, whose
|
||||
/// top corners round to exactly this so the band reads as the lane's own edge.
|
||||
private let cornerRadius: CGFloat = 10
|
||||
|
||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||||
private let bandHeight: CGFloat = 5
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
cardStack
|
||||
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header
|
||||
cardStack
|
||||
}
|
||||
.padding(6)
|
||||
}
|
||||
.padding(6)
|
||||
.background(selectionBackground)
|
||||
.overlay(selectionStroke)
|
||||
}
|
||||
@@ -80,6 +103,82 @@ struct LaneView: View {
|
||||
.contentShape(Rectangle())
|
||||
.gesture(headerGesture)
|
||||
.overlay(alignment: .trailing) { newCardButton }
|
||||
.contextMenu { laneMenu }
|
||||
// The lane's half of the Style… popover. Anchored on the header because that is the
|
||||
// lane's own furniture — `styleEditorPresentation` decides whether this lane is the
|
||||
// session's presenting anchor at all.
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
}
|
||||
|
||||
/// The lane's colour as C7 — "a lane's color paints a full-width band along its top edge; the
|
||||
/// surfaces themselves keep the standard chrome, so colored title text never sits on a colored
|
||||
/// fill" (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's treatment
|
||||
/// shootout).
|
||||
///
|
||||
/// A value that resolves to nothing paints **no band**, and the bytes stay on disk exactly as
|
||||
/// written — the card stripe's rule, for its reason: there is no sensible default colour for
|
||||
/// "the author meant something we can't read", and a wrong colour is worse than none.
|
||||
@ViewBuilder
|
||||
private var accentBand: some View {
|
||||
if let color = Palette.color(for: lane.background) {
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
|
||||
.fill(color)
|
||||
.frame(height: bandHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
// Decoration only: the header below it owns the lane's click and drag.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lane's one context menu
|
||||
|
||||
/// Rename, Style…, the quick-style recents row, the Width control, Delete (11-command-nexus.md ▸
|
||||
/// Context menus) — the style trio and the width stepper today.
|
||||
@ViewBuilder
|
||||
private var laneMenu: some View {
|
||||
// m5-context-menus: Rename (a twin of Board ▸ Rename) and Delete (a twin of File ▸ Delete)
|
||||
// belong to the card that brings the selection model and the delete command; both are rows
|
||||
// of *this* menu when they land, not of a second one.
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||
|
||||
Divider()
|
||||
|
||||
widthControl
|
||||
}
|
||||
|
||||
/// The width stepper — "the header context menu's Width control (stepper, uncapped) is the
|
||||
/// precise control … it never touches the window, it **re-divides** the existing width across the
|
||||
/// new unit total" (03-board-ui.md § Lane). A +/− pair rather than a slider or a fixed 1×/2×/3×
|
||||
/// list, because the control is uncapped in one direction and floored at one unit in the other.
|
||||
///
|
||||
/// **Single-lane by nature**, unlike the style entries above it: the design gives the batch to
|
||||
/// the ⌥⌘→/⌥⌘← menu items and keeps the stepper on the lane whose menu is open.
|
||||
private var widthControl: some View {
|
||||
let units = LaneLayoutMath.displayUnits(of: lane)
|
||||
return Section("Width — \(units)×") {
|
||||
Button("Increase Width") {
|
||||
store.setLaneWidth(lane.id, units: units + 1)
|
||||
}
|
||||
Button("Decrease Width") {
|
||||
store.setLaneWidth(lane.id, units: units - 1)
|
||||
}
|
||||
// A one-unit lane cannot shrink (`width` is ≥ 1), and an item whose only outcome is a
|
||||
// no-op reads better disabled than dead — `LaneWidthCommands`' rule, same floor.
|
||||
.disabled(units <= 1)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// What this lane's menu styles: the whole selection when this lane is part of it, else this lane
|
||||
/// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
|
||||
/// Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
|
||||
return .items([lane.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
private var headerContent: some View {
|
||||
@@ -223,6 +322,9 @@ struct LaneView: View {
|
||||
store.transient.beginPlaceholder(inLane: lane.id)
|
||||
}
|
||||
.onTapGesture { toggleLaneSelection() }
|
||||
// The same menu the header carries — "one menu, invoked on the header or lane empty
|
||||
// space alike" (03-board-ui.md § Lane, settled).
|
||||
.contextMenu { laneMenu }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +465,9 @@ private struct CardFaceView: View {
|
||||
let card: Card
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
private let cornerRadius: CGFloat = 8
|
||||
@@ -396,6 +501,31 @@ private struct CardFaceView: View {
|
||||
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
|
||||
// Board ▸ Rename.
|
||||
.onTapGesture { store.select([card.id], liveness: .live) }
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context menu
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete (11-command-nexus.md ▸ Context
|
||||
/// menus) — the style pair today.
|
||||
@ViewBuilder
|
||||
private var cardMenu: some View {
|
||||
// m5-context-menus: Open (a twin of Board ▸ Open Card, always the clicked card alone —
|
||||
// a card window is tied to one card), Rename, and Delete land with the selection-model and
|
||||
// delete cards, as rows of this same menu.
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||
}
|
||||
|
||||
/// What this card's menu styles: the whole selection when this card is part of it, else this card
|
||||
/// alone. Standard macOS — right-clicking outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
|
||||
return .items([card.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
// MARK: - Title row
|
||||
|
||||
@@ -32,6 +32,18 @@ enum ItemSymbol {
|
||||
return name
|
||||
}
|
||||
|
||||
/// The default for a level — the style editor's symbol grid needs the three defaults as a
|
||||
/// function rather than as three constants, because its leading well is "the level's default
|
||||
/// symbol" and the editor is one component serving all three (03-board-ui.md § Styling ▸
|
||||
/// Controls).
|
||||
static func `default`(for level: StyleLevel) -> String {
|
||||
switch level {
|
||||
case .board: board
|
||||
case .lane: lane
|
||||
case .card: card
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the running system can draw `name` as an SF Symbol.
|
||||
///
|
||||
/// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself
|
||||
|
||||
+40
-3
@@ -63,9 +63,46 @@ enum Palette {
|
||||
}
|
||||
|
||||
// The pathfinder's panel round-trip helpers (`NSColor.paletteHexString`, `Palette.name(forHex:)`)
|
||||
// and its swatch drawing are deliberately not ported yet: nothing writes a colour until the style
|
||||
// editor lands, and an unused writer is a claim about a surface that doesn't exist. The styling
|
||||
// card brings them back when the editor needs them.
|
||||
// stay unported: they exist to turn a colour the *system picker* returned back into a palette name,
|
||||
// and this app has no colour picker — "custom hex is not pickable in-app" (03 § Styling ▸ Controls)
|
||||
// makes the whole round trip a surface that doesn't exist. Its swatch drawing, on the other hand, is
|
||||
// below: a menu can only render `Image`/`Text`, so the quick-style row's dots have to be pictures.
|
||||
|
||||
// MARK: - Menu swatches
|
||||
|
||||
/// A colour value drawn as a picture, for the one surface that cannot take a SwiftUI shape: **menu
|
||||
/// items**. AppKit renders a menu row from its label's image and text, so the quick-style recents row
|
||||
/// (03-board-ui.md § Styling ▸ Controls) needs an `NSImage` per dot where the editor's own wells are
|
||||
/// ordinary views.
|
||||
enum PaletteSwatch {
|
||||
|
||||
/// A filled dot for `value` (a palette name or `#RRGGBB[AA]` hex), hairline-bordered.
|
||||
///
|
||||
/// The border is not decoration: the background palette contains `chalk` (`#FFFFFF`), and an
|
||||
/// unbordered white dot on a light menu is an invisible menu item — the same reason the editor's
|
||||
/// wells are stroked (10-accessibility.md's contrast stance applied to the app's own chrome).
|
||||
///
|
||||
/// A value that resolves to nothing draws the border alone rather than a guessed colour, matching
|
||||
/// every other lenient rendering here: there is no colour, so show none.
|
||||
static func circleImage(for value: String, diameter: CGFloat = 14) -> NSImage {
|
||||
let color = Palette.nsColor(for: value)
|
||||
return NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in
|
||||
let inset = rect.insetBy(dx: 0.5, dy: 0.5)
|
||||
let path = NSBezierPath(ovalIn: inset)
|
||||
// Under a translucent colour the menu's own backdrop would show through unevenly across
|
||||
// appearances; filling the standard control backdrop first makes the dot composite the
|
||||
// same way in light and dark. An opaque colour covers it completely.
|
||||
NSColor.textBackgroundColor.setFill()
|
||||
path.fill()
|
||||
color?.setFill()
|
||||
path.fill()
|
||||
NSColor.separatorColor.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Palette {
|
||||
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// **The one style editor** — "a background palette grid and a curated symbol grid — presented from
|
||||
/// three anchors" (03-board-ui.md § Styling ▸ Controls), plus the two small surfaces that stand
|
||||
/// beside it: the quick-style recents row the context menus carry, and the funnel every anchor's
|
||||
/// writes pass through.
|
||||
///
|
||||
/// This file is deliberately anchor-agnostic. It knows a `BoardStore`, a `StyleTarget` and the app's
|
||||
/// recents, and nothing at all about popovers, card-window sidebars or the board popover — which is
|
||||
/// what lets "one component, one behavior, three anchors" be a fact about the code rather than a
|
||||
/// promise. The Style… popover's *lifecycle* lives elsewhere for the same reason: it is a reload
|
||||
/// rule, and it belongs with the other reload rules (`StyleEditorSession`, `TransientBoardState`).
|
||||
|
||||
// MARK: - The write funnel
|
||||
|
||||
/// Where every style application from every anchor goes: the store's write, and the recents list
|
||||
/// that the write feeds.
|
||||
///
|
||||
/// **It exists so "updated on every background application from any anchor" is structural.** Two
|
||||
/// surfaces apply backgrounds — the editor's wells and the quick-style row — and the recents list is
|
||||
/// app-wide state a board store has no business knowing about (02-architecture.md § Per-board app
|
||||
/// state), so neither of them may be trusted to remember it and neither may be given the job alone.
|
||||
///
|
||||
/// **The None well never records.** It is a *removal* — `background` leaves the file — so there is no
|
||||
/// colour to remember; only `.set` reaches `StyleRecents.record`.
|
||||
@MainActor
|
||||
enum StyleCommand {
|
||||
static func apply(
|
||||
background: StyleChange = .keep,
|
||||
icon: StyleChange = .keep,
|
||||
to target: StyleTarget,
|
||||
in store: BoardStore,
|
||||
recents: StyleRecents
|
||||
) {
|
||||
store.applyStyle(to: target, background: background, icon: icon)
|
||||
if case let .set(value) = background {
|
||||
recents.record(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The curated symbol set
|
||||
|
||||
/// The symbol grid's contents — "a hand-picked set (roughly five dozen kanban-relevant SF Symbols)"
|
||||
/// (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// The pathfinder's two quick-pick lists (card markers, container-like stages) and its browser
|
||||
/// fallback set are the seed, widened to one grid's worth: the rewrite has no full-catalog browser
|
||||
/// to fall back to — "no full-browser escape hatch in-app; the raw file is the escape hatch" — so
|
||||
/// this set has to stand alone for the common case, and it is grouped by what a board item *is*
|
||||
/// rather than alphabetically so scanning it works.
|
||||
///
|
||||
/// **Filtered through `ItemSymbol.exists` at read time**, for the same reason the renderer is
|
||||
/// lenient: symbol inventories grow per macOS release, and a name this OS does not know would draw
|
||||
/// an empty well. A curated list is a convenience, never a claim about the running system.
|
||||
enum CuratedSymbols {
|
||||
|
||||
/// Every well in the grid, in order. Deliberately a stored constant rather than a computed
|
||||
/// property: the list is the design decision, and `available` is the only thing the OS gets a
|
||||
/// say in.
|
||||
static let all: [String] = [
|
||||
// Status and flow
|
||||
"flag", "flag.checkered", "star", "bolt", "checkmark.circle", "checkmark.seal",
|
||||
"xmark.circle", "exclamationmark.triangle", "questionmark.circle", "circle",
|
||||
"pause.circle", "play.circle",
|
||||
// Time
|
||||
"hourglass", "clock", "alarm", "calendar", "timer",
|
||||
// Work and craft
|
||||
"hammer", "wrench.and.screwdriver", "gearshape", "ant", "lightbulb", "paintbrush", "pencil",
|
||||
// Documents
|
||||
"doc.text", "doc.on.doc", "note.text", "list.bullet", "list.bullet.rectangle",
|
||||
"checklist", "book", "bookmark",
|
||||
// Containers and stages
|
||||
"tray", "tray.full", "folder", "archivebox", "shippingbox", "square.stack",
|
||||
// People and communication
|
||||
"person", "person.2", "bubble.left", "bubble.left.and.bubble.right", "envelope", "megaphone",
|
||||
// Data and systems
|
||||
"chart.bar", "chart.pie", "chart.line.uptrend.xyaxis", "terminal", "network",
|
||||
// Markers
|
||||
"tag", "paperclip", "link", "pin", "target", "flame", "leaf", "sparkles", "heart",
|
||||
// Motion
|
||||
"arrow.triangle.branch", "arrow.triangle.2.circlepath", "arrow.up.arrow.down",
|
||||
// Other
|
||||
"lock", "key", "trash",
|
||||
]
|
||||
|
||||
/// The set this Mac can actually draw.
|
||||
static var available: [String] { all.filter(ItemSymbol.exists) }
|
||||
}
|
||||
|
||||
// MARK: - The editor
|
||||
|
||||
/// The style editor: a background section and a symbol section, each a leading "no value" well
|
||||
/// followed by its grid, with the target set's current value stated beside the section title.
|
||||
///
|
||||
/// ### What it shows for a batch
|
||||
///
|
||||
/// Per dimension, `StyleFieldState`: every target agreeing shows that well selected, a disagreement
|
||||
/// shows nothing selected and reads "—" ("Mixed" to VoiceOver — 10-accessibility.md's
|
||||
/// never-colour-alone rule), and an off-palette value — a hand-written hex, an uncurated symbol —
|
||||
/// states itself verbatim beside the title, outside the grids, where "choosing any well replaces
|
||||
/// it".
|
||||
///
|
||||
/// ### Keyboard
|
||||
///
|
||||
/// "Inside the editor the grids are arrow-navigable and every well Tab-reachable" (§ Controls,
|
||||
/// 10-accessibility.md): every well is a focusable button, and each grid moves focus by one on
|
||||
/// ←/→ and by a row on ↑/↓.
|
||||
struct StyleEditorView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let target: StyleTarget
|
||||
|
||||
/// Wells per row. Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the
|
||||
/// popover narrow enough to sit beside a card without covering the lane it came from.
|
||||
private let backgroundColumns = 7
|
||||
private let symbolColumns = 8
|
||||
|
||||
var body: some View {
|
||||
let subjects = store.styleSubjects(of: target)
|
||||
let background = StyleFieldState.resolve(subjects.map(\.background))
|
||||
let icon = StyleFieldState.resolve(subjects.map(\.icon))
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
targetCaption(count: subjects.count)
|
||||
backgroundSection(background)
|
||||
Divider()
|
||||
symbolSection(icon)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 268)
|
||||
// The read-only lock and the focused-editor rule disable every mutating surface, not only
|
||||
// the menu items (02-architecture.md § The lock's scope) — an editor whose wells would be
|
||||
// refused should not look available. The popover stays *open*: the lock is a condition the
|
||||
// banner is already explaining, not a reason to yank a surface out from under the pointer.
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// Who is being styled — one quiet line, because a batch gesture with no statement of its scope
|
||||
/// is the one place this editor could silently do more than the user meant.
|
||||
private func targetCaption(count: Int) -> some View {
|
||||
Text(caption(count: count))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func caption(count: Int) -> String {
|
||||
switch store.styleLevel(of: target) {
|
||||
case .board: "Board"
|
||||
case .lane: count == 1 ? "Lane" : "\(count) lanes"
|
||||
case .card: count == 1 ? "Card" : "\(count) cards"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
/// The twelve palette wells and their leading None (03-board-ui.md § Styling ▸ Controls:
|
||||
/// "palette-only in-app … plus a leading **None** well that removes the `background` key").
|
||||
private func backgroundSection(_ state: StyleFieldState) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
sectionHeader("Background", current: backgroundCurrent(state))
|
||||
StyleWellGrid(
|
||||
wells: backgroundWells(state),
|
||||
columns: backgroundColumns,
|
||||
apply: { change in
|
||||
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func backgroundWells(_ state: StyleFieldState) -> [StyleWell] {
|
||||
var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)]
|
||||
for (index, color) in Palette.backgrounds.enumerated() {
|
||||
wells.append(StyleWell(
|
||||
id: index + 1,
|
||||
face: .color(color.name),
|
||||
label: color.name,
|
||||
change: .set(color.name),
|
||||
isSelected: state == .uniform(color.name)
|
||||
))
|
||||
}
|
||||
return wells
|
||||
}
|
||||
|
||||
/// What the background dimension currently reads — including the verbatim off-palette case, which
|
||||
/// is exactly why this is a chip beside the title and not a highlighted well.
|
||||
private func backgroundCurrent(_ state: StyleFieldState) -> CurrentValue {
|
||||
switch state {
|
||||
case .unset: CurrentValue(face: .noValue, text: "None")
|
||||
case .mixed: CurrentValue(face: nil, text: "—", spoken: "Mixed")
|
||||
case let .uniform(value): CurrentValue(face: .color(value), text: value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Symbol
|
||||
|
||||
/// The curated grid and its leading default well — "its leading well is the level's default
|
||||
/// symbol and removes the `icon` key" (§ Controls).
|
||||
private func symbolSection(_ state: StyleFieldState) -> some View {
|
||||
let level = store.styleLevel(of: target)
|
||||
let fallback = ItemSymbol.default(for: level)
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback))
|
||||
ScrollView(.vertical) {
|
||||
StyleWellGrid(
|
||||
wells: symbolWells(state, fallback: fallback),
|
||||
columns: symbolColumns,
|
||||
apply: { change in
|
||||
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
|
||||
}
|
||||
)
|
||||
}
|
||||
// Eight rows or so before it scrolls: enough that the grid reads as a set rather than as
|
||||
// a strip, short enough that the popover fits beside a card on a laptop screen.
|
||||
.frame(maxHeight: 168)
|
||||
}
|
||||
}
|
||||
|
||||
private func symbolWells(_ state: StyleFieldState, fallback: String) -> [StyleWell] {
|
||||
var wells = [StyleWell(
|
||||
id: 0,
|
||||
face: .defaultSymbol(fallback),
|
||||
label: "Default (\(fallback))",
|
||||
change: .remove,
|
||||
isSelected: state == .unset
|
||||
)]
|
||||
for (index, name) in CuratedSymbols.available.enumerated() {
|
||||
wells.append(StyleWell(
|
||||
id: index + 1,
|
||||
face: .symbol(name),
|
||||
label: name,
|
||||
change: .set(name),
|
||||
isSelected: state == .uniform(name)
|
||||
))
|
||||
}
|
||||
return wells
|
||||
}
|
||||
|
||||
private func symbolCurrent(_ state: StyleFieldState, fallback: String) -> CurrentValue {
|
||||
switch state {
|
||||
case .unset: CurrentValue(face: .defaultSymbol(fallback), text: "Default")
|
||||
case .mixed: CurrentValue(face: nil, text: "—", spoken: "Mixed")
|
||||
case let .uniform(value): CurrentValue(face: .symbol(value), text: value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section chrome
|
||||
|
||||
private func sectionHeader(_ title: String, current: CurrentValue) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
if let face = current.face {
|
||||
StyleWellFace(face: face, size: 14)
|
||||
}
|
||||
Text(current.text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(title), \(current.spoken ?? current.text)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Current value
|
||||
|
||||
/// The current-value chip beside a section title: the one place an off-palette value is stated
|
||||
/// ("labeled verbatim, outside the grids"), and the one place a mixed batch reads "—".
|
||||
private struct CurrentValue {
|
||||
let face: StyleWellFace.Face?
|
||||
let text: String
|
||||
/// What VoiceOver says when the written text would not do — "Mixed" for the em dash, which is a
|
||||
/// glyph rather than a word (10-accessibility.md: a batch's mixed state "reads as 'mixed', never
|
||||
/// conveyed by highlight alone").
|
||||
var spoken: String?
|
||||
|
||||
init(face: StyleWellFace.Face?, text: String, spoken: String? = nil) {
|
||||
self.face = face
|
||||
self.text = text
|
||||
self.spoken = spoken
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wells
|
||||
|
||||
/// One well: what it draws, what it is called, and what clicking it asks of the frontmatter key.
|
||||
private struct StyleWell: Identifiable {
|
||||
let id: Int
|
||||
let face: StyleWellFace.Face
|
||||
let label: String
|
||||
let change: StyleChange
|
||||
let isSelected: Bool
|
||||
}
|
||||
|
||||
/// A well's face — a colour, a symbol, or one of the two "no value" leading wells.
|
||||
private struct StyleWellFace: View {
|
||||
|
||||
enum Face: Equatable {
|
||||
/// The background grid's None well: a slashed empty swatch, Finder's own vocabulary for
|
||||
/// "there isn't one".
|
||||
case noValue
|
||||
/// A palette name or a hand-written hex. An unresolvable value draws like `noValue` — the
|
||||
/// renderer's lenient rule, which is what makes an off-palette chip honest about a value the
|
||||
/// app cannot read.
|
||||
case color(String)
|
||||
case symbol(String)
|
||||
/// The symbol grid's leading well: the level's default, drawn quieter than a chosen one so
|
||||
/// "no symbol set" and "this symbol set" do not look alike.
|
||||
case defaultSymbol(String)
|
||||
}
|
||||
|
||||
let face: Face
|
||||
var size: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
switch face {
|
||||
case .noValue:
|
||||
swatch(nil)
|
||||
case let .color(value):
|
||||
swatch(Palette.color(named: value))
|
||||
case let .symbol(name):
|
||||
glyph(name, tint: AnyShapeStyle(.primary))
|
||||
case let .defaultSymbol(name):
|
||||
glyph(name, tint: AnyShapeStyle(.secondary))
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour well. **Always stroked**: `chalk` is `#FFFFFF` and an unbordered white swatch is an
|
||||
/// invisible control on a light popover (10-accessibility.md's contrast stance turned on the
|
||||
/// app's own chrome). A `nil` colour adds the diagonal strike that means "none".
|
||||
private func swatch(_ color: Color?) -> some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(color ?? Color(nsColor: .textBackgroundColor))
|
||||
.overlay { if color == nil { NoValueStrike().stroke(.secondary, lineWidth: 1) } }
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(.separator, lineWidth: 1))
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
private func glyph(_ name: String, tint: AnyShapeStyle) -> some View {
|
||||
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
|
||||
.imageScale(.medium)
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner-to-corner slash on the None well — the pathfinder's swatch vocabulary, kept because it
|
||||
/// is also the system's (an empty colour well slashes in Finder's own tag editor).
|
||||
private struct NoValueStrike: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX + 3, y: rect.maxY - 3))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - 3, y: rect.minY + 3))
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid (10-accessibility.md ▸ Style
|
||||
/// editor).
|
||||
///
|
||||
/// Focus is the grid's own state rather than the editor's, because the two grids are independently
|
||||
/// navigable and Tab is what crosses between them — which is exactly what the accessibility doc asks
|
||||
/// for ("the grids are arrow-navigable and every well Tab-reachable"). The arrow handler sits on the
|
||||
/// container: a focused `Button` does not consume arrow keys, so the press bubbles here, and moving
|
||||
/// focus is all it does — **selection is never implied by focus**, since a well's job is to write to
|
||||
/// disk and a stray arrow key must not restyle a board.
|
||||
private struct StyleWellGrid: View {
|
||||
|
||||
let wells: [StyleWell]
|
||||
let columns: Int
|
||||
let apply: (StyleChange) -> Void
|
||||
|
||||
@FocusState private var focused: Int?
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible(minimum: 20), spacing: 6), count: columns),
|
||||
spacing: 6
|
||||
) {
|
||||
ForEach(wells) { well in
|
||||
Button {
|
||||
apply(well.change)
|
||||
} label: {
|
||||
StyleWellFace(face: well.face)
|
||||
.overlay(selectionRing(well.isSelected))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.focusable()
|
||||
.focused($focused, equals: well.id)
|
||||
.help(well.label)
|
||||
.accessibilityLabel(well.label)
|
||||
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
|
||||
move(press.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectionRing(_ isSelected: Bool) -> some View {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 2)
|
||||
.padding(-2)
|
||||
}
|
||||
|
||||
/// One step per press, clamped at the ends rather than wrapped: a grid whose last row is short
|
||||
/// would wrap into a hole, and Finder's own icon grids clamp too.
|
||||
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
|
||||
let delta: Int
|
||||
switch key {
|
||||
case .leftArrow: delta = -1
|
||||
case .rightArrow: delta = 1
|
||||
case .upArrow: delta = -columns
|
||||
case .downArrow: delta = columns
|
||||
default: return .ignored
|
||||
}
|
||||
let current = focused ?? 0
|
||||
let next = min(max(0, current + delta), wells.count - 1)
|
||||
focused = next
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context-menu surfaces
|
||||
|
||||
/// The two style entries every context menu carries — Style… and the quick-style recents row
|
||||
/// (11-command-nexus.md ▸ Context menus; 03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// One view for both menus because the entries are identical on a card and on a lane: only the
|
||||
/// *target* differs, and that is the caller's to compute (the clicked item, or the selection it
|
||||
/// belongs to).
|
||||
struct StyleMenuItems: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let target: StyleTarget
|
||||
|
||||
var body: some View {
|
||||
Button("Style…") {
|
||||
store.transient.beginStyleEditor(for: target)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
QuickStyleRow(store: store, recents: recents, target: target)
|
||||
}
|
||||
}
|
||||
|
||||
/// The quick-style row: "one compact row of recently used backgrounds … one-click recolor for the
|
||||
/// common case; the pathfinder's second full-palette tier is gone" (03-board-ui.md § Styling ▸
|
||||
/// Controls).
|
||||
///
|
||||
/// A `.palette`-styled `Picker` is what macOS renders as a horizontal swatch strip inside a menu —
|
||||
/// the pathfinder's finding, and the only shape that puts colours in a menu row at all. AppKit draws
|
||||
/// a menu item from an image and a title, so the dots are `NSImage`s (`PaletteSwatch`) rather than
|
||||
/// SwiftUI shapes.
|
||||
///
|
||||
/// **Absent until it has something to offer.** A brand-new install has no recents, and an empty
|
||||
/// picker in a context menu is a row that looks broken.
|
||||
struct QuickStyleRow: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
let target: StyleTarget
|
||||
|
||||
/// A sentinel for "the current value is not one of these", so a mixed batch — or a background
|
||||
/// that has aged out of the recents — leaves the row unchecked rather than checking the wrong
|
||||
/// dot. It is never a rendered option, so it can never be picked.
|
||||
private enum Choice: Hashable {
|
||||
case value(String)
|
||||
case other
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !recents.backgrounds.isEmpty {
|
||||
Picker("Recent Colors", selection: selection) {
|
||||
ForEach(recents.backgrounds, id: \.self) { name in
|
||||
Label {
|
||||
Text(name)
|
||||
} icon: {
|
||||
Image(nsImage: PaletteSwatch.circleImage(for: name))
|
||||
}
|
||||
.tag(Choice.value(name))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.palette)
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
}
|
||||
|
||||
private var selection: Binding<Choice> {
|
||||
Binding(
|
||||
get: {
|
||||
let state = StyleFieldState.resolve(store.styleSubjects(of: target).map(\.background))
|
||||
guard case let .uniform(value) = state, recents.backgrounds.contains(value) else { return .other }
|
||||
return .value(value)
|
||||
},
|
||||
set: { picked in
|
||||
guard case let .value(name) = picked else { return }
|
||||
StyleCommand.apply(background: .set(name), to: target, in: store, recents: recents)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Presentation
|
||||
|
||||
/// The Style… popover's content: the editor, aimed at **the session's own target set**.
|
||||
///
|
||||
/// Reading the target from the session rather than re-deriving it from the selection is what makes
|
||||
/// the settled lifecycle visible: the popover was aimed once, at what the gesture named, and from
|
||||
/// then on it follows *that* set as members vanish — a selection change behind an open popover must
|
||||
/// not silently re-aim it, and a right-click on an unselected card must keep styling that card.
|
||||
struct StyleEditorPopover: View {
|
||||
|
||||
let store: BoardStore
|
||||
let recents: StyleRecents
|
||||
|
||||
var body: some View {
|
||||
// Empty for the frame between a session ending and the popover's own dismissal landing —
|
||||
// the binding is already `false`, so this is a formality rather than a state.
|
||||
if let session = store.transient.styleEditor {
|
||||
StyleEditorView(store: store, recents: recents, target: session.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether *this* anchor is the one showing the open Style… popover.
|
||||
///
|
||||
/// Every candidate surface — a card face, a lane header, the strip itself — binds its popover
|
||||
/// through this, and `StyleEditorSession.presentationAnchor(in:)` answers for exactly one of them.
|
||||
/// So the popover follows its target set across reloads (an anchor that vanishes hands it to the next
|
||||
/// live target) and only an *emptied* set takes it down, which is the settled lifecycle.
|
||||
///
|
||||
/// The setter is narrowed to this anchor's own dismissal: a session that has moved to another anchor
|
||||
/// must not be discarded by the surface it just left.
|
||||
@MainActor
|
||||
func styleEditorPresentation(_ store: BoardStore, anchor: ItemID?) -> Binding<Bool> {
|
||||
Binding(
|
||||
// Spelled with an explicit `guard let` rather than optional chaining: `nil == nil` is
|
||||
// `true`, so a chained comparison would tell the board strip (whose anchor *is* `nil`) to
|
||||
// present a popover nobody opened.
|
||||
get: {
|
||||
guard let session = store.transient.styleEditor else { return false }
|
||||
return session.presentationAnchor(in: store.snapshot) == anchor
|
||||
},
|
||||
set: { presented in
|
||||
guard !presented,
|
||||
let session = store.transient.styleEditor,
|
||||
session.presentationAnchor(in: store.snapshot) == anchor
|
||||
else { return }
|
||||
store.transient.discardStyleEditor()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// The styling system's rules that need neither a screen nor (mostly) a disk: the per-dimension
|
||||
/// mixed state the editor displays, the Style… popover's live target set, the quick-style recents
|
||||
/// list, and the curated symbol grid (03-board-ui.md § Styling).
|
||||
///
|
||||
/// The popover half drives a **real store over a real temp board** for `TransientBoardStateTests`'
|
||||
/// reason — the lifecycle's contract includes being re-resolved by the store's reload path, and a
|
||||
/// suite that only called `resolved(against:)` by hand could pass with that wire cut.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func tombstoned(order: String, title: String) -> String {
|
||||
"""
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
deleted: 2026-03-03T09:00:00Z
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", Item.board)
|
||||
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
|
||||
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||||
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||||
private let card1 = ItemID(rawValue: Ident.card1)
|
||||
private let card2 = ItemID(rawValue: Ident.card2)
|
||||
private let card3 = ItemID(rawValue: Ident.card3)
|
||||
private let card4 = ItemID(rawValue: Ident.card4)
|
||||
|
||||
@MainActor
|
||||
private func reload(_ store: BoardStore) async {
|
||||
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||
await store.awaitQuiescence()
|
||||
}
|
||||
|
||||
// MARK: - Mixed state
|
||||
|
||||
@Suite("Styling ▸ mixed state")
|
||||
struct StyleFieldStateTests {
|
||||
|
||||
@Test("Agreement reads uniform, disagreement reads mixed, absence reads unset")
|
||||
func theThreeStates() {
|
||||
#expect(StyleFieldState.resolve([]) == .unset)
|
||||
#expect(StyleFieldState.resolve([.missing, .missing]) == .unset)
|
||||
#expect(StyleFieldState.resolve([.valid("fern"), .valid("fern")]) == .uniform("fern"))
|
||||
#expect(StyleFieldState.resolve([.valid("fern"), .valid("chalk")]) == .mixed)
|
||||
// A set value and an absent one disagree: half the batch is coloured, which is exactly the
|
||||
// case "—" exists for.
|
||||
#expect(StyleFieldState.resolve([.valid("fern"), .missing]) == .mixed)
|
||||
}
|
||||
|
||||
@Test("An off-palette value is uniform like any other — the display, not this rule, is what differs")
|
||||
func offPaletteValuesAreOrdinary() {
|
||||
let state = StyleFieldState.resolve([.valid("#112233AA"), .valid("#112233AA")])
|
||||
#expect(state == .uniform("#112233AA"))
|
||||
#expect(!Palette.backgrounds.contains { $0.name == "#112233AA" },
|
||||
"it is the editor's verbatim chip that treats this specially, outside the grids")
|
||||
}
|
||||
|
||||
@Test("A malformed value reads as the bytes on disk, and two of a kind agree")
|
||||
func malformedValuesReadVerbatim() {
|
||||
#expect(StyleFieldState.written(.malformed(raw: "[a, b]")) == "[a, b]")
|
||||
#expect(StyleFieldState.written(.missing) == nil)
|
||||
#expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .malformed(raw: "[a, b]")]) == .uniform("[a, b]"))
|
||||
#expect(StyleFieldState.resolve([.malformed(raw: "[a, b]"), .missing]) == .mixed)
|
||||
}
|
||||
|
||||
@Test("Only a change that would rewrite the same bytes is skipped")
|
||||
func noOpNarrowing() {
|
||||
#expect(BoardStore.effective(.set("fern"), against: .valid("fern")) == .keep)
|
||||
#expect(BoardStore.effective(.set("fern"), against: .valid("chalk")) == .set("fern"))
|
||||
#expect(BoardStore.effective(.set("fern"), against: .missing) == .set("fern"))
|
||||
// A malformed value is never equal to a palette name, so a well always replaces it.
|
||||
#expect(BoardStore.effective(.set("fern"), against: .malformed(raw: "[a, b]")) == .set("fern"))
|
||||
#expect(BoardStore.effective(.remove, against: .missing) == .keep)
|
||||
#expect(BoardStore.effective(.remove, against: .valid("fern")) == .remove)
|
||||
#expect(BoardStore.effective(.remove, against: .malformed(raw: "[a, b]")) == .remove)
|
||||
#expect(BoardStore.effective(.keep, against: .valid("fern")) == .keep)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The popover's target
|
||||
|
||||
@MainActor
|
||||
@Suite("Styling ▸ the Style… popover's target")
|
||||
struct StyleEditorSessionTests {
|
||||
|
||||
@Test("A vanished member leaves the set; the survivors keep the popover open")
|
||||
func vanishedMemberLeavesTheSet() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.beginStyleEditor(for: .items([card1, card2]))
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Second"))
|
||||
await reload(store)
|
||||
|
||||
let session = try #require(store.transient.styleEditor)
|
||||
#expect(session.target == .items([card1]))
|
||||
// And the display recomputes off the survivors, which is the point of narrowing rather than
|
||||
// dismissing.
|
||||
#expect(store.styleSubjects(of: session.target).map(\.id) == [card1])
|
||||
}
|
||||
|
||||
@Test("A set emptied by a foreign reload dismisses the popover")
|
||||
func emptiedSetDismisses() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.beginStyleEditor(for: .items([card3]))
|
||||
|
||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
||||
await reload(store)
|
||||
|
||||
// The card's own flag never changed — its lane's did. Effective liveness is ancestor-walked,
|
||||
// so the card renders nowhere and the session has nothing left to style.
|
||||
#expect(store.transient.styleEditor == nil, "the editor is closed")
|
||||
}
|
||||
|
||||
@Test("It never silently retargets to the board")
|
||||
func neverRetargetsToTheBoard() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.beginStyleEditor(for: .items([card1]))
|
||||
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "1024", title: "First"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.styleEditor?.target != .board)
|
||||
#expect(store.transient.styleEditor == nil)
|
||||
}
|
||||
|
||||
@Test("A board-targeted editor has no vanish case")
|
||||
func boardSessionsSurvive() async throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.transient.beginStyleEditor(for: .board)
|
||||
|
||||
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Todo"))
|
||||
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing"))
|
||||
await reload(store)
|
||||
|
||||
#expect(store.transient.styleEditor?.target == .board)
|
||||
}
|
||||
|
||||
@Test("An unknown id is gone from the start — a session of nothing but strangers closes")
|
||||
func unknownIdsResolveAway() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
let stranger = StyleEditorSession(target: .items([card4]))
|
||||
#expect(stranger.resolved(against: store.snapshot) == nil)
|
||||
|
||||
let mixed = StyleEditorSession(target: .items([card4, card1]))
|
||||
#expect(mixed.resolved(against: store.snapshot)?.target == .items([card1]))
|
||||
}
|
||||
|
||||
@Test("The presenting anchor is the first live target in display order, board sessions none")
|
||||
func anchorFollowsDisplayOrder() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(StyleEditorSession(target: .board).presentationAnchor(in: store.snapshot) == nil)
|
||||
#expect(StyleEditorSession(target: .items([card3, card2])).presentationAnchor(in: store.snapshot) == card2)
|
||||
#expect(StyleEditorSession(target: .items([lane2, lane1])).presentationAnchor(in: store.snapshot) == lane1)
|
||||
// A lane outranks a card in its own lane — a cards-XOR-lanes selection never mixes the two,
|
||||
// but the walk has to be total.
|
||||
#expect(StyleEditorSession(target: .items([card1, lane1])).presentationAnchor(in: store.snapshot) == lane1)
|
||||
}
|
||||
|
||||
@Test("The style editor is not an inline editor")
|
||||
func notAnInlineEditor() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.transient.beginStyleEditor(for: .board)
|
||||
// `isEditingInline` gates every board command; a popover that claimed the text domain would
|
||||
// disable the very menu items that opened it.
|
||||
#expect(!store.isEditingInline)
|
||||
#expect(store.acceptsBoardMutations)
|
||||
|
||||
store.transient.discardStyleEditor()
|
||||
#expect(store.transient.styleEditor == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recents
|
||||
|
||||
@MainActor
|
||||
@Suite("Styling ▸ quick-style recents")
|
||||
struct StyleRecentsTests {
|
||||
|
||||
/// A defaults domain of this test's own — the list is a real user preference, and a suite that
|
||||
/// wrote into `UserDefaults.standard` would be editing the developer's own quick-style row.
|
||||
private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
|
||||
let name = "StyleRecentsTests-\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: name) else {
|
||||
Issue.record("could not create a defaults suite")
|
||||
return (StyleRecents(defaults: .standard), {})
|
||||
}
|
||||
return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
|
||||
}
|
||||
|
||||
@Test("Most-recent-first, deduped by move-to-front, capped")
|
||||
func listRule() {
|
||||
#expect(StyleRecents.updated([], with: "fern") == ["fern"])
|
||||
#expect(StyleRecents.updated(["fern"], with: "chalk") == ["chalk", "fern"])
|
||||
// A repeat is a move, never a second entry.
|
||||
#expect(StyleRecents.updated(["chalk", "fern"], with: "fern") == ["fern", "chalk"])
|
||||
#expect(StyleRecents.updated(["fern"], with: "fern") == ["fern"])
|
||||
|
||||
let full = ["a", "b", "c", "d", "e", "f"]
|
||||
#expect(StyleRecents.updated(full, with: "g", cap: 6) == ["g", "a", "b", "c", "d", "e"])
|
||||
#expect(StyleRecents.updated(full, with: "g", cap: 6).count == 6)
|
||||
}
|
||||
|
||||
@Test("An empty value is not a colour anyone applied")
|
||||
func emptyValuesAreIgnored() {
|
||||
// The None well is a *removal* — nothing to remember — and the call site never records for
|
||||
// it; this is the belt behind that brace.
|
||||
#expect(StyleRecents.updated(["fern"], with: "") == ["fern"])
|
||||
}
|
||||
|
||||
@Test("Recording persists, and a fresh instance reads the same list back")
|
||||
func recordingRoundTrips() throws {
|
||||
let name = "StyleRecentsTests-\(UUID().uuidString)"
|
||||
let store = try #require(UserDefaults(suiteName: name))
|
||||
defer { store.removePersistentDomain(forName: name) }
|
||||
|
||||
let recents = StyleRecents(defaults: store)
|
||||
recents.record("fern")
|
||||
recents.record("chalk")
|
||||
recents.record("fern")
|
||||
|
||||
#expect(recents.backgrounds == ["fern", "chalk"])
|
||||
#expect(StyleRecents(defaults: store).backgrounds == ["fern", "chalk"])
|
||||
#expect(store.array(forKey: AppPreferences.quickStyleBackgroundsKey) as? [String] == ["fern", "chalk"])
|
||||
}
|
||||
|
||||
@Test("A garbage preference reads as an empty list rather than taking the row down")
|
||||
func toleratesGarbage() throws {
|
||||
let (recents, teardown) = makeRecents()
|
||||
defer { teardown() }
|
||||
#expect(recents.backgrounds.isEmpty, "a first launch has no recents and no row")
|
||||
|
||||
let name = "StyleRecentsTests-garbage-\(UUID().uuidString)"
|
||||
let store = try #require(UserDefaults(suiteName: name))
|
||||
defer { store.removePersistentDomain(forName: name) }
|
||||
store.set(42, forKey: AppPreferences.quickStyleBackgroundsKey)
|
||||
#expect(StyleRecents(defaults: store).backgrounds.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The curated grid
|
||||
|
||||
@Suite("Styling ▸ the curated symbol grid")
|
||||
struct CuratedSymbolsTests {
|
||||
|
||||
@Test("Roughly five dozen symbols, no duplicates")
|
||||
func shape() {
|
||||
#expect(CuratedSymbols.all.count >= 55)
|
||||
#expect(CuratedSymbols.all.count <= 72)
|
||||
#expect(Set(CuratedSymbols.all).count == CuratedSymbols.all.count)
|
||||
}
|
||||
|
||||
@Test("Every curated name is one this system can actually draw")
|
||||
func everyNameResolves() {
|
||||
// A curated list is a convenience, never a claim about the running OS — `available` filters
|
||||
// it — but a name that fails here on the *deployment target* is a typo, not an inventory
|
||||
// difference, and the grid would show an empty well.
|
||||
let missing = CuratedSymbols.all.filter { !ItemSymbol.exists($0) }
|
||||
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
|
||||
#expect(CuratedSymbols.available.count == CuratedSymbols.all.count)
|
||||
}
|
||||
|
||||
@Test("The level defaults are drawable too — they are the grid's leading well")
|
||||
func levelDefaultsResolve() {
|
||||
#expect(ItemSymbol.exists(ItemSymbol.board))
|
||||
#expect(ItemSymbol.exists(ItemSymbol.lane))
|
||||
#expect(ItemSymbol.exists(ItemSymbol.card))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// `BoardStore.applyStyle` — the one commit point every style anchor shares (03-board-ui.md §
|
||||
/// Styling ▸ Controls).
|
||||
///
|
||||
/// Like `LaneWidthWriteTests` and `InlineEditWriteTests`, these drive a real store over a real temp
|
||||
/// board and read the **raw bytes** back rather than the app's own read path: the claims are about
|
||||
/// the file — which key lands or leaves, what the stamps do, and everything else surviving
|
||||
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// An item index carrying whatever style keys a test needs, plus the usual unowned baggage: an
|
||||
/// unknown key with an inline comment, a `created` from before today, a foreign `modified-by`, and a
|
||||
/// body — all of which a style write has to leave exactly as it found them.
|
||||
private func styled(order: String, title: String, keys: [String] = []) -> String {
|
||||
let extra = keys.map { "\($0)\n" }.joined()
|
||||
return """
|
||||
---
|
||||
schema: 1
|
||||
title: \(title)
|
||||
order: \(order)
|
||||
\(extra)project: lanework # agent overlay
|
||||
created: 2026-01-01T09:00:00Z
|
||||
modified: 2026-02-02T09:00:00Z
|
||||
modified-by: claude
|
||||
---
|
||||
\(title) body.
|
||||
|
||||
"""
|
||||
}
|
||||
|
||||
/// The board root's own `index.md` — styled like any other item (`StyleTarget.board`), and carrying
|
||||
/// an `iconColor` the app must never touch (schema yes, control no).
|
||||
private let boardIndex = """
|
||||
---
|
||||
schema: 1
|
||||
title: Board
|
||||
iconColor: carnation
|
||||
---
|
||||
Board description.
|
||||
|
||||
"""
|
||||
|
||||
/// Two live lanes with cards, an uneditable lane, and a tombstoned lane with a live card inside it —
|
||||
/// the ancestor-walk case.
|
||||
@MainActor
|
||||
private func makeBoard() throws -> WriterFixture {
|
||||
let fixture = try WriterFixture()
|
||||
try fixture.item("", boardIndex)
|
||||
try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: fern", "iconColor: chalk"]))
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
|
||||
try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: chalk", "icon: tray"]))
|
||||
try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third", keys: ["deleted: 2026-03-03T09:00:00Z"]))
|
||||
try fixture.item(Ident.lane3, Item.uneditable)
|
||||
try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone", keys: ["deleted: 2026-03-03T09:00:00Z"]))
|
||||
try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
|
||||
return fixture
|
||||
}
|
||||
|
||||
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||||
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||||
private let lane3 = ItemID(rawValue: Ident.lane3)
|
||||
private let lane4 = ItemID(rawValue: Ident.lane4)
|
||||
private let card1 = ItemID(rawValue: Ident.card1)
|
||||
private let card2 = ItemID(rawValue: Ident.card2)
|
||||
private let card3 = ItemID(rawValue: Ident.card3)
|
||||
private let card4 = ItemID(rawValue: Ident.card4)
|
||||
|
||||
/// The file's lines minus the ones a style write is *supposed* to change. `iconColor:` deliberately
|
||||
/// survives the filter — it is not `icon:`, and the app offers no control for it.
|
||||
private func untouchedLines(_ text: String) -> [Substring] {
|
||||
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||
!$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:")
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up — the
|
||||
/// only way to assert "one gesture, one commit" from outside.
|
||||
@MainActor
|
||||
private final class BracketLog {
|
||||
private(set) var begins = 0
|
||||
private(set) var ends = 0
|
||||
|
||||
func attach(to store: BoardStore) {
|
||||
store.watcherBrackets = (begin: { self.begins += 1 }, end: { self.ends += 1 })
|
||||
}
|
||||
}
|
||||
|
||||
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||||
try BoardLoader.load(boardRoot: fixture.root).model
|
||||
}
|
||||
|
||||
private func card(_ id: ItemID, in model: BoardModel) -> Card? {
|
||||
model.lanes.flatMap(\.cards).first { $0.id == id }
|
||||
}
|
||||
|
||||
private func lane(_ id: ItemID, in model: BoardModel) -> Lane? {
|
||||
model.lanes.first { $0.id == id }
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@MainActor
|
||||
@Suite("BoardStore ▸ applyStyle")
|
||||
struct StyleWriteTests {
|
||||
|
||||
@Test("Setting a background writes exactly that key, stamps, and touches nothing else")
|
||||
func setsTheBackgroundKey() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
|
||||
store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
#expect(after.contains("background: smokey-ocean"))
|
||||
#expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
|
||||
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||||
#expect(untouchedLines(after) == untouchedLines(before))
|
||||
|
||||
let written = try #require(card(card2, in: load(fixture)))
|
||||
#expect(written.background == .valid("smokey-ocean"))
|
||||
let modified = try #require(written.modified.value)
|
||||
#expect(abs(modified.timeIntervalSinceNow) < 60)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Both dimensions land in one rewrite, and iconColor is never touched")
|
||||
func setsBothDimensionsAtOnce() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||
#expect(after.contains("background: dark-teal"))
|
||||
#expect(after.contains("icon: flag"))
|
||||
// "iconColor: resolved — schema yes, control no" (03 § Styling ▸ Capabilities): the field
|
||||
// renders when hand-written and the app offers no control for it, so a style write must
|
||||
// carry it through untouched like any unknown key.
|
||||
#expect(after.contains("iconColor: chalk"))
|
||||
#expect(!after.contains("background: fern"), "the old value is replaced, not duplicated")
|
||||
}
|
||||
|
||||
@Test("The None and default wells remove their key rather than writing a blank value")
|
||||
func removesTheKey() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.applyStyle(to: .items([lane2]), background: .remove, icon: .remove)
|
||||
|
||||
let after = try fixture.indexText(Ident.lane2)
|
||||
#expect(!after.contains("background"))
|
||||
#expect(!after.contains("icon"))
|
||||
#expect(!after.contains("\"\""), "a removal is a missing key, never an empty string")
|
||||
|
||||
let written = try #require(lane(lane2, in: load(fixture)))
|
||||
#expect(written.background.isMissing)
|
||||
#expect(written.icon.isMissing)
|
||||
}
|
||||
|
||||
@Test("A batch rewrites every target inside a single bracket")
|
||||
func batchesInOneBracket() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = BracketLog()
|
||||
log.attach(to: store)
|
||||
|
||||
// "Choosing a well applies to the whole selection — one gesture, one commit on git boards"
|
||||
// (03 § Styling ▸ Controls): the churn has to round back as ONE app-mediated reload.
|
||||
store.applyStyle(to: .items([card1, card2]), background: .set("light-cayenne"))
|
||||
|
||||
#expect(log.begins == 1)
|
||||
#expect(log.ends == 1)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: light-cayenne"))
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: light-cayenne"))
|
||||
}
|
||||
|
||||
@Test("A value a target already carries writes nothing — per target and per dimension")
|
||||
func skipsNoOps() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = BracketLog()
|
||||
log.attach(to: store)
|
||||
let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// `card1` is already `fern` and `card2` has no background at all: only the second file may
|
||||
// move. A well clicked twice must not stamp `modified` or mint a commit on what was already
|
||||
// right (`setLaneWidth`'s rule).
|
||||
store.applyStyle(to: .items([card1, card2]), background: .set("fern"))
|
||||
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
|
||||
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: fern"))
|
||||
#expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
|
||||
}
|
||||
|
||||
@Test("A gesture that changes nothing anywhere opens no bracket at all")
|
||||
func wholeGestureNoOpWritesNothing() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = BracketLog()
|
||||
log.attach(to: store)
|
||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
// Both dimensions already read this way: `background: fern` is set and `icon` is absent, so
|
||||
// the removal is a no-op too.
|
||||
store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)
|
||||
|
||||
#expect(log.begins == 0)
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
||||
#expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)") == ["index.md"], "no temp-file residue either")
|
||||
}
|
||||
|
||||
@Test("A malformed value is replaced — choosing a well always wins")
|
||||
func replacesAMalformedValue() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second", keys: ["background: [a, b]"]))
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
#expect(card(card2, in: store.snapshot)?.background == .malformed(raw: "[a, b]"))
|
||||
|
||||
store.applyStyle(to: .items([card2]), background: .set("shale"))
|
||||
|
||||
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||
#expect(after.contains("background: shale"))
|
||||
#expect(!after.contains("[a, b]"))
|
||||
}
|
||||
|
||||
@Test("Board styling writes the board root's own index.md")
|
||||
func stylesTheBoardRoot() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))
|
||||
|
||||
let after = try fixture.indexText("")
|
||||
#expect(after.contains("background: intense-cool-shale"))
|
||||
#expect(after.contains("icon: square.stack"))
|
||||
#expect(after.contains("iconColor: carnation"))
|
||||
#expect(after.contains("Board description."))
|
||||
|
||||
let model = try load(fixture)
|
||||
#expect(model.background == .valid("intense-cool-shale"))
|
||||
// The lanes are none of a board-level gesture's business.
|
||||
#expect(lane(lane1, in: model)?.background.isMissing == true)
|
||||
}
|
||||
|
||||
@Test("Vanished, tombstoned and hidden targets are skipped silently")
|
||||
func skipsTargetsThatRenderNowhere() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let log = BracketLog()
|
||||
log.attach(to: store)
|
||||
let tombstoned = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)")
|
||||
let hidden = try fixture.indexData("\(Ident.lane4)/\(Ident.card4)")
|
||||
|
||||
// An id that names nothing, a tombstoned card, and a live card under a tombstoned lane —
|
||||
// "nothing is ever written into a vanished folder", ancestor walk included.
|
||||
store.applyStyle(
|
||||
to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
|
||||
background: .set("obsidian")
|
||||
)
|
||||
|
||||
#expect(log.begins == 0)
|
||||
#expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == tombstoned)
|
||||
#expect(try fixture.indexData("\(Ident.lane4)/\(Ident.card4)") == hidden)
|
||||
#expect(store.banners.oneShots.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes")
|
||||
func uneditableTargetBanners() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let before = try fixture.indexData(Ident.lane3)
|
||||
|
||||
store.applyStyle(to: .items([lane3]), background: .set("fern"))
|
||||
|
||||
#expect(try fixture.indexData(Ident.lane3) == before)
|
||||
#expect(store.banners.oneShots.count == 1)
|
||||
let posted = try #require(store.banners.oneShots.first)
|
||||
#expect(posted.error.operation == .style(title: "Odd"),
|
||||
"the title is enriched off the document the write refused")
|
||||
#expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't restyle 'Odd' — "))
|
||||
}
|
||||
|
||||
@Test("A read-only board refuses the write without a second banner")
|
||||
func readOnlyBoardRefusesQuietly() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
store.enterVanishedRootLock()
|
||||
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||
|
||||
store.applyStyle(to: .items([card1]), background: .set("obsidian"))
|
||||
|
||||
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
||||
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
|
||||
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
||||
}
|
||||
|
||||
// MARK: Subjects and levels
|
||||
|
||||
@Test("Subjects are the live targets in display order, with their current values")
|
||||
func subjectsAreLiveTargetsInDisplayOrder() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
let subjects = store.styleSubjects(of: .items([card2, card1, card3, lane1]))
|
||||
#expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
|
||||
#expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
|
||||
#expect(subjects.last?.folder.lastPathComponent == Ident.card2)
|
||||
|
||||
// The board is always exactly one subject, at the root.
|
||||
let board = store.styleSubjects(of: .board)
|
||||
#expect(board.count == 1)
|
||||
let boardSubject = try #require(board.first)
|
||||
#expect(boardSubject.id == nil, "a board root has no ItemID by design")
|
||||
#expect(boardSubject.folder == fixture.root)
|
||||
}
|
||||
|
||||
@Test("The level a target sits at decides the symbol grid's leading well")
|
||||
func levelFollowsTheTarget() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.styleLevel(of: .board) == .board)
|
||||
#expect(store.styleLevel(of: .items([lane1, lane2])) == .lane)
|
||||
#expect(store.styleLevel(of: .items([card1])) == .card)
|
||||
#expect(ItemSymbol.default(for: store.styleLevel(of: .items([card1]))) == ItemSymbol.card)
|
||||
#expect(ItemSymbol.default(for: store.styleLevel(of: .items([lane1]))) == ItemSymbol.lane)
|
||||
#expect(ItemSymbol.default(for: .board) == ItemSymbol.board)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
||||
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced).
|
||||
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
|
||||
|
||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
||||
|
||||
## Development
|
||||
|
||||
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
|
||||
|
||||
Reference in New Issue
Block a user