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, container: .board).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 { if ids.contains(lane.id) { return lane.id } if let card = lane.cards.first(where: { ids.contains($0.id) }) { return card.id } } return nil } }