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:
2026-07-27 14:47:26 -04:00
parent bea6d02d1d
commit c6298c2e41
15 changed files with 1943 additions and 13 deletions
@@ -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.