One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
180 lines
9.7 KiB
Swift
180 lines
9.7 KiB
Swift
import Foundation
|
|
|
|
/// The styling system's vocabulary — what a style gesture *is*, before any view or any write exists
|
|
/// (03-board-ui.md § Styling). Three small value types, deliberately kept out of both the view layer
|
|
/// and the Writer: the editor builds gestures out of them, `BoardStore.applyStyle` turns them into
|
|
/// files, and every rule that can be stated without a screen or a disk is stated here where a test
|
|
/// can reach it.
|
|
|
|
// MARK: - What a gesture asks of one key
|
|
|
|
/// What one style gesture asks of one frontmatter key.
|
|
///
|
|
/// **Three cases, because "leave it alone" and "there is no value" are different instructions.** A
|
|
/// style editor always speaks about both dimensions at once — a click on a background well says
|
|
/// nothing about the symbol — so `keep` is what the untouched dimension carries, and `remove` is
|
|
/// what the two leading wells carry: "the None well … removes the `background` key" and the symbol
|
|
/// grid's "leading well is the level's default symbol and removes the `icon` key" (03 § Styling ▸
|
|
/// Controls). Writing `background: ""` instead would be a real (if blank) value and the exact
|
|
/// mistake the remove-at-default family — the empty rename, the width landing on 1 — exists to
|
|
/// avoid.
|
|
///
|
|
/// `set` carries the string that goes to frontmatter verbatim: a kebab-case palette name from the
|
|
/// grid, or an SF Symbol name. Hex never arrives here from the app ("custom hex is not pickable
|
|
/// in-app but stays fully honored from disk"), though nothing in the type forbids it.
|
|
public enum StyleChange: Sendable, Equatable {
|
|
case keep
|
|
case set(String)
|
|
case remove
|
|
}
|
|
|
|
// MARK: - What a gesture is aimed at
|
|
|
|
/// What a style edit acts on — "one component, one behavior, three anchors" needs exactly one
|
|
/// vocabulary word for "who is being styled" (03-board-ui.md § Styling ▸ Controls).
|
|
///
|
|
/// **A set of items, or the board — never both, and never a mix of levels in spirit.** The editor is
|
|
/// "selection-aware: it styles the selected cards or lane, and with nothing selected, the board", and
|
|
/// 04-interactions.md's cards-XOR-lanes rule means a live selection is homogeneous by kind. Nothing
|
|
/// here enforces that (a `Set<ItemID>` cannot), because the enforcement belongs to the selection
|
|
/// model; what this type *does* guarantee is that a board target and an item target are structurally
|
|
/// different things, which is what makes "it never silently retargets to the board" (§ Controls) a
|
|
/// property of the code rather than a discipline.
|
|
public enum StyleTarget: Sendable, Equatable {
|
|
case board
|
|
case items(Set<ItemID>)
|
|
}
|
|
|
|
/// Which level a target sits at — the one thing the editor needs beyond the values themselves,
|
|
/// because the symbol grid's leading well is "the level's default symbol" (03 § Styling ▸ Controls)
|
|
/// and the three defaults differ (§ Capabilities).
|
|
public enum StyleLevel: Sendable, Equatable {
|
|
case board
|
|
case lane
|
|
case card
|
|
}
|
|
|
|
// MARK: - The current value across a target set
|
|
|
|
/// What one style dimension currently reads across a whole target set — the editor's per-dimension
|
|
/// display, and the whole of 03-board-ui.md § Styling ▸ Controls' batch rule: "a multi-selection
|
|
/// shows per-dimension mixed state (no well selected, '—' where a value would read)".
|
|
///
|
|
/// Three states rather than an optional plus a flag, because the three mean genuinely different
|
|
/// things to the grid: `unset` selects the leading well (None / the level default), `uniform`
|
|
/// selects the matching well — or, when the value is off-palette, labels itself verbatim outside the
|
|
/// grid — and `mixed` selects nothing at all.
|
|
///
|
|
/// **Deliberately a pure function of the fields.** It takes `FieldValue`s rather than a store so the
|
|
/// interesting rules — what counts as agreement, what a malformed value reads as — are testable
|
|
/// without a board on disk.
|
|
public enum StyleFieldState: Sendable, Equatable {
|
|
/// No target carries the key. The leading well is the current value.
|
|
case unset
|
|
/// Every target carries the same value, written exactly this way.
|
|
case uniform(String)
|
|
/// The targets disagree. Nothing is selected; the display reads "—".
|
|
case mixed
|
|
|
|
/// The state of one dimension over `fields`, one per target, in any order.
|
|
///
|
|
/// An **empty** list is `unset` rather than a fourth case: a target set with no live members is
|
|
/// about to dismiss the popover anyway (`StyleEditorSession.resolved(against:)`), and a frame
|
|
/// rendered with nothing selected is the honest thing to show in the meantime.
|
|
public static func resolve(_ fields: [FieldValue<String>]) -> StyleFieldState {
|
|
var iterator = fields.makeIterator()
|
|
guard let first = iterator.next() else { return .unset }
|
|
let firstText = written(first)
|
|
while let next = iterator.next() {
|
|
guard written(next) == firstText else { return .mixed }
|
|
}
|
|
guard let firstText else { return .unset }
|
|
return .uniform(firstText)
|
|
}
|
|
|
|
/// The value **as written on disk**, or `nil` when the key is absent.
|
|
///
|
|
/// A malformed field — a sequence where a scalar belongs — reads as its raw text rather than as
|
|
/// nothing, which is what makes the off-palette display honest: "off-palette values display
|
|
/// leniently … labeled verbatim, outside the grids" (03 § Styling ▸ Controls) is about *what the
|
|
/// author wrote*, and a value the parser could not make a string of is exactly the case where
|
|
/// showing the bytes matters most. Agreement is judged on this same text, so two targets whose
|
|
/// `background` is malformed in the same way read as uniform — they do carry the same value.
|
|
public static func written(_ field: FieldValue<String>) -> String? {
|
|
switch field {
|
|
case .missing: nil
|
|
case let .valid(value): value
|
|
case let .malformed(raw): raw
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The open Style… popover
|
|
|
|
/// The Style… popover's live target — 03-board-ui.md § Styling ▸ Controls' settled lifecycle
|
|
/// sentence, as a value: "The Style… popover tracks its target set live and dismisses when it
|
|
/// empties … its target is the selection, re-resolved across reloads by 02-architecture.md's UUID
|
|
/// rule — a member that vanishes or flips liveness leaves the set and the mixed-state display
|
|
/// recomputes; a set emptied by a foreign reload dismisses the popover … it never silently retargets
|
|
/// to the board".
|
|
///
|
|
/// **Held in `TransientBoardState`, not in a view**, and that is the whole design of it: the rule is
|
|
/// a *reload* rule, and the container that re-grounds the selection, the drag, the pending cut and
|
|
/// the rename editor on every applied snapshot is the one place a reload rule can be written once.
|
|
/// The sibling it most resembles is `RenameEditor` — an item-referencing editor that a vanish
|
|
/// discards — which is exactly the analogy the design draws ("the inline-rename discard applied
|
|
/// here").
|
|
///
|
|
/// The two embedded anchors (the card window's Style section, the board popover's styling area) need
|
|
/// none of this and get none: "the card sidebar dismisses with its card's window, and the board
|
|
/// popover's target is the board itself".
|
|
public struct StyleEditorSession: Sendable, Equatable {
|
|
|
|
/// Who is being styled. `let`, because a session never changes target — a session whose targets
|
|
/// all vanished is *gone*, not re-aimed.
|
|
public let target: StyleTarget
|
|
|
|
public init(target: StyleTarget) {
|
|
self.target = target
|
|
}
|
|
|
|
/// This session re-grounded on a freshly applied snapshot, or `nil` when it has nothing left to
|
|
/// style — which the presenting anchor reads as "dismiss".
|
|
///
|
|
/// - **Items** are constrained through `ItemReferenceSet`, so liveness is **effective —
|
|
/// ancestor-walked** for free: a card under a lane an agent just tombstoned renders nowhere and
|
|
/// therefore leaves the set, exactly as it leaves the selection.
|
|
/// - **The board** never dismisses. It cannot vanish from its own snapshot, and a board target
|
|
/// has no membership to lose ("a board-targeted editor has no vanish case").
|
|
///
|
|
/// Nothing here can turn an item target into a board target: emptiness returns `nil`, and the
|
|
/// only way a board session exists is for one to have been opened that way.
|
|
public func resolved(against snapshot: BoardModel) -> StyleEditorSession? {
|
|
switch target {
|
|
case .board:
|
|
return self
|
|
case let .items(ids):
|
|
let live = ItemReferenceSet(ids: ids, liveness: .live).resolved(against: snapshot).ids
|
|
guard !live.isEmpty else { return nil }
|
|
return live == ids ? self : StyleEditorSession(target: .items(live))
|
|
}
|
|
}
|
|
|
|
/// The item the popover hangs off, or `nil` for the board window itself.
|
|
///
|
|
/// **Derived, never stored.** Every candidate anchor — a card face, a lane header, the strip —
|
|
/// asks this one question of the current snapshot, so exactly one surface presents the popover
|
|
/// and the answer follows the target set across reloads: an anchor that vanishes hands the
|
|
/// popover to the next live target rather than taking it down with it (only an *emptied* set
|
|
/// dismisses). Display order — lanes left to right, cards top to bottom — is the tie-break, so
|
|
/// the popover lands on the first thing the user's eye would find.
|
|
public func presentationAnchor(in snapshot: BoardModel) -> ItemID? {
|
|
guard case let .items(ids) = target else { return nil }
|
|
for lane in snapshot.lanes where !lane.isDeleted {
|
|
if ids.contains(lane.id) { return lane.id }
|
|
if let card = lane.cards.first(where: { !$0.isDeleted && ids.contains($0.id) }) { return card.id }
|
|
}
|
|
return nil
|
|
}
|
|
}
|