Build the styling system and shared style editor
One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -645,6 +645,153 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
|
||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the two styled
|
||||
/// keys currently say there.
|
||||
///
|
||||
/// The editor reads these for its per-dimension current-value display (`StyleFieldState.resolve`)
|
||||
/// and `applyStyle` reads the *same* values to decide what is a no-op, so the display and the
|
||||
/// write can never disagree about what is already on disk. `id` is `nil` for the board root,
|
||||
/// which has no `ItemID` by design (see `ItemID`'s doc comment).
|
||||
public struct StyleSubject: Sendable, Equatable {
|
||||
public let id: ItemID?
|
||||
public let folder: URL
|
||||
public let background: FieldValue<String>
|
||||
public let icon: FieldValue<String>
|
||||
}
|
||||
|
||||
/// The live items `target` names, in display order — lanes left to right, each lane's cards top
|
||||
/// to bottom.
|
||||
///
|
||||
/// **Vanished targets are simply absent**, ancestor walk included: a tombstoned card, a card
|
||||
/// under a tombstoned lane, and an id that names nothing all contribute no subject, which is the
|
||||
/// same silent skip `commitRename` gives a vanished rename target — "nothing is ever written into
|
||||
/// a vanished folder". A style editor whose set has emptied dismisses (`StyleEditorSession`), so
|
||||
/// an empty result is a frame's worth of nothing to show rather than a state to handle.
|
||||
public func styleSubjects(of target: StyleTarget) -> [StyleSubject] {
|
||||
switch target {
|
||||
case .board:
|
||||
return [StyleSubject(
|
||||
id: nil,
|
||||
folder: rootURL,
|
||||
background: snapshot.background,
|
||||
icon: snapshot.icon
|
||||
)]
|
||||
|
||||
case let .items(ids):
|
||||
var subjects: [StyleSubject] = []
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
let laneFolder = rootURL.appendingPathComponent(lane.id.rawValue)
|
||||
if ids.contains(lane.id) {
|
||||
subjects.append(StyleSubject(
|
||||
id: lane.id,
|
||||
folder: laneFolder,
|
||||
background: lane.background,
|
||||
icon: lane.icon
|
||||
))
|
||||
}
|
||||
for card in lane.cards where !card.isDeleted && ids.contains(card.id) {
|
||||
subjects.append(StyleSubject(
|
||||
id: card.id,
|
||||
folder: laneFolder.appendingPathComponent(card.id.rawValue),
|
||||
background: card.background,
|
||||
icon: card.icon
|
||||
))
|
||||
}
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
}
|
||||
|
||||
/// Which level `target` sits at — the editor's symbol grid needs it for its leading well, "the
|
||||
/// level's default symbol" (03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// A set naming any card is a card set: 04-interactions.md's cards-XOR-lanes rule means a live
|
||||
/// selection never mixes the two, so the branch below is a total answer rather than a policy —
|
||||
/// and if a mixed set ever reached here, `doc.text` is the level whose default would actually be
|
||||
/// removed by the leading well.
|
||||
public func styleLevel(of target: StyleTarget) -> StyleLevel {
|
||||
switch target {
|
||||
case .board:
|
||||
return .board
|
||||
case let .items(ids):
|
||||
let namesACard = snapshot.lanes.contains { lane in
|
||||
!lane.isDeleted && lane.cards.contains { !$0.isDeleted && ids.contains($0.id) }
|
||||
}
|
||||
return namesACard ? .card : .lane
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a style gesture — the **one** commit point every anchor shares (03-board-ui.md §
|
||||
/// Styling ▸ Controls: "One component, one behavior, three anchors"), and the quick-style recents
|
||||
/// row with them.
|
||||
///
|
||||
/// **One bracket, whatever the target set's size.** "Choosing a well applies to the whole
|
||||
/// selection — one gesture, one commit on git boards" (§ Controls), so every target's `index.md`
|
||||
/// is rewritten inside a single `performWrite`: the churn rounds back as one app-mediated reload,
|
||||
/// and the auto-committer (m7) sees one operation rather than N.
|
||||
///
|
||||
/// **No-ops are skipped per dimension and per target** — `setLaneWidth`'s rule, for its reason: a
|
||||
/// well clicked twice, or a batch where half the cards are already that colour, must not stamp
|
||||
/// `modified` or mint a commit on the items that were already right. A dimension whose value is
|
||||
/// already what the gesture asks contributes nothing; a target both of whose dimensions are
|
||||
/// no-ops is dropped entirely; and a gesture that changes nothing anywhere never opens the
|
||||
/// bracket at all.
|
||||
///
|
||||
/// **`iconColor` is not a parameter, and that is the design**: it is "resolved — schema yes,
|
||||
/// control no" (§ Capabilities). The field renders when hand-written and the app offers no
|
||||
/// control for it, so there is nothing here to pass.
|
||||
///
|
||||
/// Failure is `performWrite`'s: the banner is posted before the rethrow, which is swallowed here
|
||||
/// like every other gesture with no second thing to do. A batch that fails partway leaves the
|
||||
/// targets written before it written — the Writer is "atomic per filesystem operation, not per
|
||||
/// gesture" — and the reload shows the true state, which is the honest one.
|
||||
public func applyStyle(to target: StyleTarget, background: StyleChange = .keep, icon: StyleChange = .keep) {
|
||||
let edits: [(folder: URL, background: StyleChange, icon: StyleChange)] = styleSubjects(of: target)
|
||||
.compactMap { subject in
|
||||
let background = Self.effective(background, against: subject.background)
|
||||
let icon = Self.effective(icon, against: subject.icon)
|
||||
guard background != .keep || icon != .keep else { return nil }
|
||||
return (folder: subject.folder, background: background, icon: icon)
|
||||
}
|
||||
guard !edits.isEmpty else { return }
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for edit in edits {
|
||||
// `.style(title: nil)`: `updateIndex` enriches it off the document it reads, so a
|
||||
// failure names the item by the title it still has (see `WriteOperation.style`).
|
||||
try BoardWriter.updateIndex(inItemFolder: edit.folder, operation: .style(title: nil)) { document in
|
||||
Self.apply(edit.background, to: FrontmatterKeys.background, in: &document)
|
||||
Self.apply(edit.icon, to: FrontmatterKeys.icon, in: &document)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `change` narrowed against what is already on disk: `.keep` when it would write what is
|
||||
/// already there.
|
||||
///
|
||||
/// The comparison is against the **valid** reading, not the written text: a malformed value —
|
||||
/// `background: [a, b]`, a sequence where a scalar belongs — is never equal to a palette name, so
|
||||
/// choosing a well always replaces it, which is what "choosing any well replaces it" (§ Controls)
|
||||
/// promises about a value the app could not read.
|
||||
nonisolated static func effective(_ change: StyleChange, against field: FieldValue<String>) -> StyleChange {
|
||||
switch change {
|
||||
case .keep: .keep
|
||||
case let .set(value): field.value == value ? .keep : .set(value)
|
||||
case .remove: field.isMissing ? .keep : .remove
|
||||
}
|
||||
}
|
||||
|
||||
private static func apply(_ change: StyleChange, to key: String, in document: inout FrontmatterDocument) {
|
||||
switch change {
|
||||
case .keep: break
|
||||
case let .set(value): document.set(key, to: .string(value))
|
||||
case .remove: document.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Creation
|
||||
|
||||
/// Creates a lane at the board's right end — File ▸ New Lane ⇧⌘N (11-command-nexus.md).
|
||||
|
||||
Reference in New Issue
Block a user