Build the styling system and shared style editor

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

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 14:47:26 -04:00
parent bea6d02d1d
commit c6298c2e41
15 changed files with 1943 additions and 13 deletions
+13
View File
@@ -50,6 +50,13 @@ public enum AppPreferences {
public static func setLastCardWindowSize(_ size: CGSize) { public static func setLastCardWindowSize(_ size: CGSize) {
UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey) 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 // MARK: - Launch failures
@@ -147,6 +154,12 @@ public final class AppModel {
public let storeRegistry = BoardStoreRegistry() public let storeRegistry = BoardStoreRegistry()
public let boardRegistry: BoardRegistry 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 // MARK: Sessions
/// One open board window and everything hanging off it. /// One open board window and everything hanging off it.
+3 -2
View File
@@ -121,12 +121,13 @@ struct KanbanApp: App {
.keyboardShortcut("o", modifiers: .command) .keyboardShortcut("o", modifiers: .command)
} }
// The Board menu (11-command-nexus.md), in its inventoried order Rename precedes the // The Board menu (11-command-nexus.md), in its inventoried order Rename, then Style,
// width pair, with Open Card, Style and the Move items still owed. Its items act on the // 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 // frontmost board window, which they reach through the focus system rather than through the
// app model see `BoardCommands.swift`, which also owns their validation. // app model see `BoardCommands.swift`, which also owns their validation.
CommandMenu("Board") { CommandMenu("Board") {
BoardRenameCommand() BoardRenameCommand()
BoardStyleCommand()
Divider() Divider()
+147
View File
@@ -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 // MARK: - Creation
/// Creates a lane at the board's right end File New Lane N (11-command-nexus.md). /// Creates a lane at the board's right end File New Lane N (11-command-nexus.md).
+179
View File
@@ -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
}
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import Observation
import os
/// The quick-style row's memory: the backgrounds this user has applied lately, most-recent-first
/// (03-board-ui.md § Styling Controls "Quick-style row, recents only Recents are app-wide and
/// persist app-side (user preference, never board data)").
///
/// ### Why it lives here, and not in the registry
///
/// `BoardRegistry`'s own doc names this type's contents as the example of what does *not* belong in
/// it: "App-wide state that is not board-scoped quick-style recents, the SSH host-key table, the
/// last-used card-window size lives *beside* this file, not in it" (02-architecture.md §
/// Per-board app state). No board owns the list; styling one board's cards blue is what makes blue
/// offer itself on the next board, which is the whole point of the row.
///
/// ### Two halves, so the rules are testable without a defaults domain
///
/// The list *rule* is `updated(_:with:cap:)` a static pure function and this object is its
/// persistence and its observability. `@Observable` so every open context menu reorders the instant a
/// colour is applied from anywhere; `defaults` injectable so a test drives a suite of its own rather
/// than the user's.
///
/// ### What never enters the list
///
/// **The None well is not a colour.** It removes the `background` key, so there is nothing to
/// remember and nothing a future row could offer the recording call site simply never fires for it
/// (see `StyleCommand.apply`). Empty strings are refused here too, belt over braces.
@MainActor
@Observable
public final class StyleRecents {
/// How many the row keeps. Six is "capped ~6" from the milestone's shape and about what a
/// compact menu row carries without becoming a second palette which is the one thing the
/// design says this row is not ("the pathfinder's second full-palette tier is gone").
public static let cap = 6
/// The recents, most-recent-first. Palette names or `#RRGGBB[AA]` hex the same strings that go
/// to frontmatter, so a hand-written hex applied from a board's file could join the row without
/// any translation (nothing in-app can produce one today; § Controls' curated-in-app rule).
public private(set) var backgrounds: [String]
@ObservationIgnored
private let defaults: UserDefaults
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "style-recents")
/// - Parameter defaults: the domain to persist in. Injected for the reason `BoardRegistry` takes
/// its storage URL: a test must be able to hold its own without touching the user's.
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Anything but an array of strings is treated as an empty list rather than as an error: this
// is a convenience, and a hand-edited or truncated preference must never be the reason a
// context menu cannot open (`BoardRegistry`'s "it never takes the app down", one scale down).
backgrounds = (defaults.array(forKey: AppPreferences.quickStyleBackgroundsKey) as? [String]) ?? []
}
/// Records `value` as the most recently used background and persists the new list.
///
/// Move-to-front, deduped, capped see `updated(_:with:cap:)` for the rule itself. Called on
/// **every** background application from every anchor (the editor's wells, the quick-style row),
/// which is what "recently used" has to mean for the row to be worth having; the write is
/// unconditional even when the value was already at the front, since re-persisting an identical
/// list is cheaper than deciding not to.
public func record(_ value: String) {
backgrounds = Self.updated(backgrounds, with: value)
defaults.set(backgrounds, forKey: AppPreferences.quickStyleBackgroundsKey)
}
/// The list rule, as a pure function: `value` to the front, its earlier occurrence removed, the
/// tail truncated to `cap`.
///
/// An empty `value` returns the list unchanged. It is not a colour anyone applied the two ways
/// to arrive at one would be a removal (which is the None well, and never recorded) or a
/// hand-written blank and a blank swatch in the row would be an unclickable hole.
public static func updated(_ list: [String], with value: String, cap: Int = cap) -> [String] {
guard !value.isEmpty else { return list }
var updated = list.filter { $0 != value }
updated.insert(value, at: 0)
return Array(updated.prefix(cap))
}
}
@@ -323,6 +323,18 @@ public final class TransientBoardState {
/// out from under a commit that was about to read its draft. /// out from under a commit that was about to read its draft.
public private(set) var renameEditor: RenameEditor? 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 /// 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, /// 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 /// board-scoped menu commands (Delete, New Card, Paste, Move, Style, ) disable via menu
@@ -466,6 +478,26 @@ public final class TransientBoardState {
renameEditor = nil 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 // MARK: - Reload
/// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot. /// 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 /// 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. /// 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 /// `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 /// 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. /// snapshot, which is precisely why the results are not stored here.
@@ -516,6 +555,7 @@ public final class TransientBoardState {
dragMembers = dragMembers.resolved(against: snapshot) dragMembers = dragMembers.resolved(against: snapshot)
pendingCut = pendingCut.resolved(against: snapshot) pendingCut = pendingCut.resolved(against: snapshot)
newCardPlaceholder = resolvedPlaceholder(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 // 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. // last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
+42
View File
@@ -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 // MARK: - Lane width items
/// Increase / Decrease Lane Width **the width stepper's keyboard face** (03-board-ui.md § Lane, /// Increase / Decrease Lane Width **the width stepper's keyboard face** (03-board-ui.md § Lane,
+31
View File
@@ -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. /// needs the board's own window ref, which is the host's identity and not the board's.
let openCard: (ItemID) -> Void 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 /// 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. /// view does, which is the interaction's whole lifetime.
@State private var resize = LaneResizeSession() @State private var resize = LaneResizeSession()
@@ -89,6 +93,12 @@ struct BoardView: View {
.padding(spacing) .padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .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 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 // the strip is the window's content, not a control, and a rectangle around the whole board
// would read as an error state. // would read as an error state.
@@ -105,6 +115,27 @@ struct BoardView: View {
.onKeyPress(.escape) { handleEscape() } .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 // MARK: - Lanes
/// One lane's strip slot, plus its trailing grab strip. /// One lane's strip slot, plus its trailing grab strip.
+134 -4
View File
@@ -32,17 +32,28 @@ struct LaneHeaderDrag {
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in /// (`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. /// 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 /// ### What is still a later card's
/// ///
/// The lane context menu (Rename, Style, the quick-style recents row, the Width stepper, Delete), /// The search-aware filtering behind the count belongs to a later milestone. The card face is real
/// the lane's own top-edge accent band, and the search-aware filtering behind the count all belong /// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment
/// to later milestones. The card face is real (`CardFaceView`); what it still owes is the cut /// carousel.
/// treatment and the sole-selected card's attachment carousel.
struct LaneView: View { struct LaneView: View {
let store: BoardStore let store: BoardStore
let lane: Lane 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 /// 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 /// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
/// override it (see `BoardView.laneSlot`). /// override it (see `BoardView.laneSlot`).
@@ -61,12 +72,24 @@ struct LaneView: View {
/// Spacing between cards, and between the interior columns. /// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8 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 { var body: some View {
// `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) { VStack(alignment: .leading, spacing: 8) {
header header
cardStack cardStack
} }
.padding(6) .padding(6)
}
.background(selectionBackground) .background(selectionBackground)
.overlay(selectionStroke) .overlay(selectionStroke)
} }
@@ -80,6 +103,82 @@ struct LaneView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
.gesture(headerGesture) .gesture(headerGesture)
.overlay(alignment: .trailing) { newCardButton } .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 { private var headerContent: some View {
@@ -223,6 +322,9 @@ struct LaneView: View {
store.transient.beginPlaceholder(inLane: lane.id) store.transient.beginPlaceholder(inLane: lane.id)
} }
.onTapGesture { toggleLaneSelection() } .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 card: Card
let openCard: (ItemID) -> Void 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 /// 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. /// 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 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 // slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename. // Board Rename.
.onTapGesture { store.select([card.id], liveness: .live) } .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 // MARK: - Title row
+12
View File
@@ -32,6 +32,18 @@ enum ItemSymbol {
return name 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. /// Whether the running system can draw `name` as an SF Symbol.
/// ///
/// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself /// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself
+40 -3
View File
@@ -63,9 +63,46 @@ enum Palette {
} }
// The pathfinder's panel round-trip helpers (`NSColor.paletteHexString`, `Palette.name(forHex:)`) // 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 // stay unported: they exist to turn a colour the *system picker* returned back into a palette name,
// editor lands, and an unused writer is a claim about a surface that doesn't exist. The styling // and this app has no colour picker "custom hex is not pickable in-app" (03 § Styling Controls)
// card brings them back when the editor needs them. // 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 { extension Palette {
+561
View File
@@ -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()
}
)
}
+304
View File
@@ -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))
}
}
+349
View File
@@ -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)
}
}
+2
View File
@@ -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). - **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. - **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 ## Development
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: