diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 66377d7..aae5ded 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -50,6 +50,13 @@ public enum AppPreferences { public static func setLastCardWindowSize(_ size: CGSize) { UserDefaults.standard.set(NSStringFromSize(size), forKey: lastCardWindowSizeKey) } + + /// The quick-style row's recently-used backgrounds — an array of palette names / hex strings, + /// most-recent-first (03-board-ui.md § Styling ▸ Controls: "Recents are app-wide and persist + /// app-side (user preference, never board data)"; 11-command-nexus.md files it under the + /// preferences that "need no UI"). Read and written by `StyleRecents`, which owns the list rule; + /// the key is declared here with its neighbours for `WindowID`'s reason. + public static let quickStyleBackgroundsKey = "quickStyleBackgrounds" } // MARK: - Launch failures @@ -147,6 +154,12 @@ public final class AppModel { public let storeRegistry = BoardStoreRegistry() public let boardRegistry: BoardRegistry + /// The quick-style row's app-wide recents (03-board-ui.md § Styling ▸ Controls). Owned here for + /// the registries' reason — app-scoped, and a test holds its own rather than colliding with the + /// app's — and reached by the context menus through the environment, since a `BoardStore` is + /// board-scoped and this list deliberately is not. + public let styleRecents = StyleRecents() + // MARK: Sessions /// One open board window and everything hanging off it. diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 86ebbf9..52c2785 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -121,12 +121,13 @@ struct KanbanApp: App { .keyboardShortcut("o", modifiers: .command) } - // The Board menu (11-command-nexus.md), in its inventoried order — Rename precedes the - // width pair, with Open Card, Style… and the Move items still owed. Its items act on the + // The Board menu (11-command-nexus.md), in its inventoried order — Rename, then Style…, + // then the width pair, with Open Card and the Move items still owed. Its items act on the // frontmost board window, which they reach through the focus system rather than through the // app model — see `BoardCommands.swift`, which also owns their validation. CommandMenu("Board") { BoardRenameCommand() + BoardStyleCommand() Divider() diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 2f93885..da2e555 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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 + public let icon: FieldValue + } + + /// 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) -> StyleChange { + switch change { + case .keep: .keep + case let .set(value): field.value == value ? .keep : .set(value) + case .remove: field.isMissing ? .keep : .remove + } + } + + private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) { + switch change { + case .keep: break + case let .set(value): document.set(key, to: .string(value)) + case .remove: document.remove(key) + } + } + // MARK: - Creation /// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md). diff --git a/Kanban/LiveStore/StyleModel.swift b/Kanban/LiveStore/StyleModel.swift new file mode 100644 index 0000000..1e513eb --- /dev/null +++ b/Kanban/LiveStore/StyleModel.swift @@ -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` 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) +} + +/// 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]) -> 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? { + 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 + } +} diff --git a/Kanban/LiveStore/StyleRecents.swift b/Kanban/LiveStore/StyleRecents.swift new file mode 100644 index 0000000..0ad52d2 --- /dev/null +++ b/Kanban/LiveStore/StyleRecents.swift @@ -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)) + } +} diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index 3b7a1d3..e07eb6d 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -323,6 +323,18 @@ public final class TransientBoardState { /// out from under a commit that was about to read its draft. public private(set) var renameEditor: RenameEditor? + /// The open Style… popover's target, or `nil` when no popover is open (03-board-ui.md § Styling + /// ▸ Controls). See `StyleEditorSession` for the lifecycle it implements and why it lives here + /// rather than in a view. + /// + /// **Not an inline editor**, deliberately: it is not a title field, it does not hold the text + /// domain's keyboard, and `isEditingInline` must stay the answer to "may board commands run" — + /// Style… itself is *disabled* while an inline editor is open, so the two never coexist anyway. + /// + /// `private(set)` for `renameEditor`'s reason: its two transitions are the methods below, and a + /// session assignable from anywhere could be re-aimed behind the reload rule's back. + public private(set) var styleEditor: StyleEditorSession? + /// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one /// boolean: "while an inline title editor — rename or the new-card placeholder — is focused, /// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu @@ -466,6 +478,26 @@ public final class TransientBoardState { renameEditor = nil } + // MARK: - The style editor's lifecycle + + /// Opens the Style… popover on `target` — the menu item's call and both context menus' + /// (03-board-ui.md § Styling ▸ Controls). + /// + /// Replacing any session already open, for the inline editors' reason turned inside out: there is + /// one popover, so a second Style… is the first one being re-aimed by a fresh gesture. Unlike + /// `beginRename`/`beginPlaceholder` it does **not** clear the inline editors — it cannot coexist + /// with one (Style… disables while an editor is focused), so clearing them here would be a rule + /// about a state that menu validation already rules out. + public func beginStyleEditor(for target: StyleTarget) { + styleEditor = StyleEditorSession(target: target) + } + + /// Closes the popover — the user dismissing it, and the anchor's response to a session the + /// reload rule emptied. + public func discardStyleEditor() { + styleEditor = nil + } + // MARK: - Reload /// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot. @@ -508,6 +540,13 @@ public final class TransientBoardState { /// current universe does not have. It is not an `ItemReferenceSet` only because it is one /// optional rather than a set on a side — the rule it obeys is the same one. /// + /// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls, + /// settled): a member that vanishes or flips liveness leaves the set — so the editor's + /// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload + /// clears the session, which is how "the popover dismisses when it empties" reaches the screen. + /// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns + /// both halves. + /// /// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references /// an item, so no snapshot can invalidate either — the query's *results* change with every /// snapshot, which is precisely why the results are not stored here. @@ -516,6 +555,7 @@ public final class TransientBoardState { dragMembers = dragMembers.resolved(against: snapshot) pendingCut = pendingCut.resolved(against: snapshot) newCardPlaceholder = resolvedPlaceholder(against: snapshot) + styleEditor = styleEditor?.resolved(against: snapshot) // One universe computed once and asked three questions — the rename target's liveness, the // last-active lane's, and (via the placeholder above, which asks its own way) the anchor's. diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index b7ccc6d..f73c488 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -121,6 +121,48 @@ struct BoardRenameCommand: View { } } +// MARK: - Style + +/// Board ▸ Style… (⌥⌘S) — the style editor's menu-bar anchor (11-command-nexus.md; +/// 03-board-ui.md § Styling ▸ Controls). +/// +/// **Selection-aware, with the board as the empty-selection case**: "Board window: selected cards or +/// lane; nothing selected = the board". The item does not present anything itself — it opens the +/// session (`TransientBoardState.beginStyleEditor`) and the board window's anchors decide which +/// surface hosts the popover, which is what keeps the presentation attached to what is being styled +/// rather than to the menu bar. +/// +/// Validation is `acceptsBoardMutations` — the lock and the focused-editor rule, the latter naming +/// Style in its own list of board-scoped commands (04-interactions.md ▸ Grammar) — plus one rule of +/// its own: **a tombstoned selection disables it rather than falling through to the board.** +/// Everything edit-shaped is disabled on tombstoned selections (04 ▸ The trash), and quietly +/// restyling the board because the user had a trashed card selected would be the silent retarget +/// 03 forbids. +struct BoardStyleCommand: View { + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("Style…") { + guard let store, let target = styleTarget else { return } + store.transient.beginStyleEditor(for: target) + } + .keyboardShortcut("s", modifiers: [.option, .command]) + .disabled(styleTarget == nil) + } + + private var styleTarget: StyleTarget? { + guard let store, store.acceptsBoardMutations else { return nil } + let selection = store.selection + guard !selection.isEmpty else { return .board } + guard selection.liveness == .live else { return nil } + // Re-resolved against the snapshot on the way in, so the session starts out holding only + // items that render — the same universe its own reload rule will hold it to. + let live = selection.resolved(against: store.snapshot).ids + return live.isEmpty ? nil : .items(live) + } +} + // MARK: - Lane width items /// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane, diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 59b4cf4..8fad2bd 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -45,6 +45,10 @@ struct BoardView: View { /// needs the board's own window ref, which is the host's identity and not the board's. let openCard: (ItemID) -> Void + /// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md § + /// Styling ▸ Controls). + @Environment(AppModel.self) private var appModel + /// One resize at a time, per window. `@State` so it lives exactly as long as this board window's /// view does, which is the interaction's whole lifetime. @State private var resize = LaneResizeSession() @@ -89,6 +93,12 @@ struct BoardView: View { .padding(spacing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + .background(boardBackground) + // The board's own anchor for the Style… popover — the surface a board-targeted session hangs + // off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor). + .popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) { + StyleEditorPopover(store: store, recents: appModel.styleRecents) + } // The board is a focus target so the grammar keys reach it at all. The focus *ring* is off: // the strip is the window's content, not a control, and a rectangle around the whole board // would read as an error state. @@ -105,6 +115,27 @@ struct BoardView: View { .onKeyPress(.escape) { handleEscape() } } + // MARK: - Styling + + /// The board's `background`, painting "the board window's content background (the surface behind + /// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities). + /// + /// Unlike the lane band and the card stripe this one is a **fill**, because at board level that + /// is what the design asks for — and it is why the board is the level 10-accessibility.md binds + /// its ≥ 4.5:1 rule to: text does sit on it. That runtime contrast computation (a hex background's + /// text colour, recomputed against the composited backdrop on appearance change) is not this + /// card's — what ships here is the palette path, whose twelve pairs are AA-verified at design + /// time. + /// + /// A value that resolves to nothing paints nothing, so the window keeps the standard background: + /// the same lenient degrade as the other two levels, and the bytes stay as written. + @ViewBuilder + private var boardBackground: some View { + if let color = Palette.color(for: store.snapshot.background) { + color + } + } + // MARK: - Lanes /// One lane's strip slot, plus its trailing grab strip. diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index b7b65ee..0f51da7 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -32,17 +32,28 @@ struct LaneHeaderDrag { /// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in /// an overlay outside the gesture so a click on it can never be read as the beginning of a drag. /// +/// ### The lane's one context menu +/// +/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike" +/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Style…, the +/// quick-style recents row and the Width stepper today; Rename and Delete are m5's context-menus +/// card, and their rows go into that same builder rather than into a second menu. +/// /// ### What is still a later card's /// -/// The lane context menu (Rename, Style…, the quick-style recents row, the Width stepper, Delete), -/// the lane's own top-edge accent band, and the search-aware filtering behind the count all belong -/// to later milestones. The card face is real (`CardFaceView`); what it still owes is the cut -/// treatment and the sole-selected card's attachment carousel. +/// The search-aware filtering behind the count belongs to a later milestone. The card face is real +/// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment +/// carousel. struct LaneView: View { let store: BoardStore let lane: Lane + /// The app-wide quick-style recents (03-board-ui.md § Styling ▸ Controls — "never board data"), + /// read from the environment rather than threaded down the strip: the list belongs to the app, + /// not to this board, and every context menu in the window wants it. + @Environment(AppModel.self) private var appModel + /// Interior masonry columns — the lane's width units, or the resize session's snapped count /// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can /// override it (see `BoardView.laneSlot`). @@ -61,12 +72,24 @@ struct LaneView: View { /// Spacing between cards, and between the interior columns. private let cardSpacing: CGFloat = 8 + /// The lane plate's corner radius — shared by the selection treatment and the accent band, whose + /// top corners round to exactly this so the band reads as the lane's own edge. + private let cornerRadius: CGFloat = 10 + + /// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities). + private let bandHeight: CGFloat = 5 + var body: some View { - VStack(alignment: .leading, spacing: 8) { - header - cardStack + // `spacing: 0` and the padding moved inside: the accent band is **full-width** along the + // lane's top edge, so it must sit outside the content inset rather than in it. + VStack(alignment: .leading, spacing: 0) { + accentBand + VStack(alignment: .leading, spacing: 8) { + header + cardStack + } + .padding(6) } - .padding(6) .background(selectionBackground) .overlay(selectionStroke) } @@ -80,6 +103,82 @@ struct LaneView: View { .contentShape(Rectangle()) .gesture(headerGesture) .overlay(alignment: .trailing) { newCardButton } + .contextMenu { laneMenu } + // The lane's half of the Style… popover. Anchored on the header because that is the + // lane's own furniture — `styleEditorPresentation` decides whether this lane is the + // session's presenting anchor at all. + .popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .bottom) { + StyleEditorPopover(store: store, recents: appModel.styleRecents) + } + } + + /// The lane's colour as C7 — "a lane's color paints a full-width band along its top edge; the + /// surfaces themselves keep the standard chrome, so colored title text never sits on a colored + /// fill" (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's treatment + /// shootout). + /// + /// A value that resolves to nothing paints **no band**, and the bytes stay on disk exactly as + /// written — the card stripe's rule, for its reason: there is no sensible default colour for + /// "the author meant something we can't read", and a wrong colour is worse than none. + @ViewBuilder + private var accentBand: some View { + if let color = Palette.color(for: lane.background) { + UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius) + .fill(color) + .frame(height: bandHeight) + .frame(maxWidth: .infinity) + // Decoration only: the header below it owns the lane's click and drag. + .allowsHitTesting(false) + } + } + + // MARK: - The lane's one context menu + + /// Rename, Style…, the quick-style recents row, the Width control, Delete (11-command-nexus.md ▸ + /// Context menus) — the style trio and the width stepper today. + @ViewBuilder + private var laneMenu: some View { + // m5-context-menus: Rename (a twin of Board ▸ Rename) and Delete (a twin of File ▸ Delete) + // belong to the card that brings the selection model and the delete command; both are rows + // of *this* menu when they land, not of a second one. + StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget) + + Divider() + + widthControl + } + + /// The width stepper — "the header context menu's Width control (stepper, uncapped) is the + /// precise control … it never touches the window, it **re-divides** the existing width across the + /// new unit total" (03-board-ui.md § Lane). A +/− pair rather than a slider or a fixed 1×/2×/3× + /// list, because the control is uncapped in one direction and floored at one unit in the other. + /// + /// **Single-lane by nature**, unlike the style entries above it: the design gives the batch to + /// the ⌥⌘→/⌥⌘← menu items and keeps the stepper on the lane whose menu is open. + private var widthControl: some View { + let units = LaneLayoutMath.displayUnits(of: lane) + return Section("Width — \(units)×") { + Button("Increase Width") { + store.setLaneWidth(lane.id, units: units + 1) + } + Button("Decrease Width") { + store.setLaneWidth(lane.id, units: units - 1) + } + // A one-unit lane cannot shrink (`width` is ≥ 1), and an item whose only outcome is a + // no-op reads better disabled than dead — `LaneWidthCommands`' rule, same floor. + .disabled(units <= 1) + } + .disabled(!store.acceptsBoardMutations) + } + + /// What this lane's menu styles: the whole selection when this lane is part of it, else this lane + /// alone — standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept). + /// Right-clicking something outside the selection acts on what was clicked. + private var styleTarget: StyleTarget { + guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else { + return .items([lane.id]) + } + return .items(store.selection.ids) } private var headerContent: some View { @@ -223,6 +322,9 @@ struct LaneView: View { store.transient.beginPlaceholder(inLane: lane.id) } .onTapGesture { toggleLaneSelection() } + // The same menu the header carries — "one menu, invoked on the header or lane empty + // space alike" (03-board-ui.md § Lane, settled). + .contextMenu { laneMenu } } } @@ -363,6 +465,9 @@ private struct CardFaceView: View { let card: Card let openCard: (ItemID) -> Void + /// The app-wide quick-style recents — see `LaneView`'s own note. + @Environment(AppModel.self) private var appModel + /// The plate's corner radius — shared with the accent stripe, which rounds its left corners to /// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it. private let cornerRadius: CGFloat = 8 @@ -396,6 +501,31 @@ private struct CardFaceView: View { // slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or // Board ▸ Rename. .onTapGesture { store.select([card.id], liveness: .live) } + .contextMenu { cardMenu } + .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) { + StyleEditorPopover(store: store, recents: appModel.styleRecents) + } + } + + // MARK: - Context menu + + /// Open, Rename, Style…, the quick-style recents row, Delete (11-command-nexus.md ▸ Context + /// menus) — the style pair today. + @ViewBuilder + private var cardMenu: some View { + // m5-context-menus: Open (a twin of Board ▸ Open Card, always the clicked card alone — + // a card window is tied to one card), Rename, and Delete land with the selection-model and + // delete cards, as rows of this same menu. + StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget) + } + + /// What this card's menu styles: the whole selection when this card is part of it, else this card + /// alone. Standard macOS — right-clicking outside the selection acts on what was clicked. + private var styleTarget: StyleTarget { + guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else { + return .items([card.id]) + } + return .items(store.selection.ids) } // MARK: - Title row diff --git a/Kanban/UI/ItemSymbol.swift b/Kanban/UI/ItemSymbol.swift index 9b73a5a..8b44274 100644 --- a/Kanban/UI/ItemSymbol.swift +++ b/Kanban/UI/ItemSymbol.swift @@ -32,6 +32,18 @@ enum ItemSymbol { return name } + /// The default for a level — the style editor's symbol grid needs the three defaults as a + /// function rather than as three constants, because its leading well is "the level's default + /// symbol" and the editor is one component serving all three (03-board-ui.md § Styling ▸ + /// Controls). + static func `default`(for level: StyleLevel) -> String { + switch level { + case .board: board + case .lane: lane + case .card: card + } + } + /// Whether the running system can draw `name` as an SF Symbol. /// /// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself diff --git a/Kanban/UI/Palette.swift b/Kanban/UI/Palette.swift index e0f6aa1..5217a7d 100644 --- a/Kanban/UI/Palette.swift +++ b/Kanban/UI/Palette.swift @@ -63,9 +63,46 @@ enum Palette { } // The pathfinder's panel round-trip helpers (`NSColor.paletteHexString`, `Palette.name(forHex:)`) -// and its swatch drawing are deliberately not ported yet: nothing writes a colour until the style -// editor lands, and an unused writer is a claim about a surface that doesn't exist. The styling -// card brings them back when the editor needs them. +// stay unported: they exist to turn a colour the *system picker* returned back into a palette name, +// and this app has no colour picker — "custom hex is not pickable in-app" (03 § Styling ▸ Controls) +// makes the whole round trip a surface that doesn't exist. Its swatch drawing, on the other hand, is +// below: a menu can only render `Image`/`Text`, so the quick-style row's dots have to be pictures. + +// MARK: - Menu swatches + +/// A colour value drawn as a picture, for the one surface that cannot take a SwiftUI shape: **menu +/// items**. AppKit renders a menu row from its label's image and text, so the quick-style recents row +/// (03-board-ui.md § Styling ▸ Controls) needs an `NSImage` per dot where the editor's own wells are +/// ordinary views. +enum PaletteSwatch { + + /// A filled dot for `value` (a palette name or `#RRGGBB[AA]` hex), hairline-bordered. + /// + /// The border is not decoration: the background palette contains `chalk` (`#FFFFFF`), and an + /// unbordered white dot on a light menu is an invisible menu item — the same reason the editor's + /// wells are stroked (10-accessibility.md's contrast stance applied to the app's own chrome). + /// + /// A value that resolves to nothing draws the border alone rather than a guessed colour, matching + /// every other lenient rendering here: there is no colour, so show none. + static func circleImage(for value: String, diameter: CGFloat = 14) -> NSImage { + let color = Palette.nsColor(for: value) + return NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in + let inset = rect.insetBy(dx: 0.5, dy: 0.5) + let path = NSBezierPath(ovalIn: inset) + // Under a translucent colour the menu's own backdrop would show through unevenly across + // appearances; filling the standard control backdrop first makes the dot composite the + // same way in light and dark. An opaque colour covers it completely. + NSColor.textBackgroundColor.setFill() + path.fill() + color?.setFill() + path.fill() + NSColor.separatorColor.setStroke() + path.lineWidth = 1 + path.stroke() + return true + } + } +} extension Palette { diff --git a/Kanban/UI/StyleEditor.swift b/Kanban/UI/StyleEditor.swift new file mode 100644 index 0000000..3523467 --- /dev/null +++ b/Kanban/UI/StyleEditor.swift @@ -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 { + 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 { + 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() + } + ) +} diff --git a/KanbanTests/StyleModelTests.swift b/KanbanTests/StyleModelTests.swift new file mode 100644 index 0000000..5b69276 --- /dev/null +++ b/KanbanTests/StyleModelTests.swift @@ -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)) + } +} diff --git a/KanbanTests/StyleWriteTests.swift b/KanbanTests/StyleWriteTests.swift new file mode 100644 index 0000000..a7b1dcd --- /dev/null +++ b/KanbanTests/StyleWriteTests.swift @@ -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) + } +} diff --git a/README.md b/README.md index 07e1506..8bfe225 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Lanework is in early development. This list tracks what has actually shipped and - **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). - **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock. +- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. + ## Development The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`: