Files
lanework/Kanban/LiveStore/StyleModel.swift
T
rzen ece33bbf78 The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover
The colour combo was a wide two-zone field with a second door onto the Colors
panel; the symbol picker was a small square button with one. Both now subclass
one `ComboFieldControl`, so they are the same width, height, radius and trigger
by construction: click the face for the standalone picker, click the chevron for
the quick list. The symbol face opens a new floating browser over the OS's own
category, ordering and keyword plists out of CoreGlyphs.bundle — searchable,
categorised, trademark-restricted glyphs withheld.

The palette grows twelve to sixteen per table, filling the hue ring's four
widest gaps with lime, jade, indigo and magenta at each table's own saturation
and brightness. That gives the Style… popover's background grid a third row and
the tint grid its third row of four, and both grids gain an Other… row onto the
system colour picker — which the card sidebar's combo has had all along and the
primary styling surface never did. An arbitrary hex already round-tripped; it is
asserted now, including that an unquoted one is a YAML comment and no value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 09:44:30 -04:00

187 lines
10 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, an SF Symbol name, or a `#RRGGBB[AA]` hex.
///
/// **Hex does arrive here from the app now**, which this comment used to deny — 03-board-ui.md's
/// "custom hex is not pickable in-app but stays fully honored from disk" stopped being true the day
/// `ColorComboView` shipped an **Other…** row onto `NSColorPanel.shared`, and the 2026-08-09 rework
/// put that door on the Style… popover and the symbol popover's tint row as well. The write path
/// always handled it (`FrontmatterValue.emitScalar` quotes a `#` because YAML would otherwise read it
/// as a comment; `BackgroundField.flowText` quotes unconditionally) and now says so out loud:
/// `CustomColorRoundTripTests` asserts the whole chain.
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, 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
}
}