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
+561
View File
@@ -0,0 +1,561 @@
import AppKit
import SwiftUI
/// **The one style editor** "a background palette grid and a curated symbol grid presented from
/// three anchors" (03-board-ui.md § Styling Controls), plus the two small surfaces that stand
/// beside it: the quick-style recents row the context menus carry, and the funnel every anchor's
/// writes pass through.
///
/// This file is deliberately anchor-agnostic. It knows a `BoardStore`, a `StyleTarget` and the app's
/// recents, and nothing at all about popovers, card-window sidebars or the board popover which is
/// what lets "one component, one behavior, three anchors" be a fact about the code rather than a
/// promise. The Style popover's *lifecycle* lives elsewhere for the same reason: it is a reload
/// rule, and it belongs with the other reload rules (`StyleEditorSession`, `TransientBoardState`).
// MARK: - The write funnel
/// Where every style application from every anchor goes: the store's write, and the recents list
/// that the write feeds.
///
/// **It exists so "updated on every background application from any anchor" is structural.** Two
/// surfaces apply backgrounds the editor's wells and the quick-style row and the recents list is
/// app-wide state a board store has no business knowing about (02-architecture.md § Per-board app
/// state), so neither of them may be trusted to remember it and neither may be given the job alone.
///
/// **The None well never records.** It is a *removal* `background` leaves the file so there is no
/// colour to remember; only `.set` reaches `StyleRecents.record`.
@MainActor
enum StyleCommand {
static func apply(
background: StyleChange = .keep,
icon: StyleChange = .keep,
to target: StyleTarget,
in store: BoardStore,
recents: StyleRecents
) {
store.applyStyle(to: target, background: background, icon: icon)
if case let .set(value) = background {
recents.record(value)
}
}
}
// MARK: - The curated symbol set
/// The symbol grid's contents "a hand-picked set (roughly five dozen kanban-relevant SF Symbols)"
/// (03-board-ui.md § Styling Controls).
///
/// The pathfinder's two quick-pick lists (card markers, container-like stages) and its browser
/// fallback set are the seed, widened to one grid's worth: the rewrite has no full-catalog browser
/// to fall back to "no full-browser escape hatch in-app; the raw file is the escape hatch" so
/// this set has to stand alone for the common case, and it is grouped by what a board item *is*
/// rather than alphabetically so scanning it works.
///
/// **Filtered through `ItemSymbol.exists` at read time**, for the same reason the renderer is
/// lenient: symbol inventories grow per macOS release, and a name this OS does not know would draw
/// an empty well. A curated list is a convenience, never a claim about the running system.
enum CuratedSymbols {
/// Every well in the grid, in order. Deliberately a stored constant rather than a computed
/// property: the list is the design decision, and `available` is the only thing the OS gets a
/// say in.
static let all: [String] = [
// Status and flow
"flag", "flag.checkered", "star", "bolt", "checkmark.circle", "checkmark.seal",
"xmark.circle", "exclamationmark.triangle", "questionmark.circle", "circle",
"pause.circle", "play.circle",
// Time
"hourglass", "clock", "alarm", "calendar", "timer",
// Work and craft
"hammer", "wrench.and.screwdriver", "gearshape", "ant", "lightbulb", "paintbrush", "pencil",
// Documents
"doc.text", "doc.on.doc", "note.text", "list.bullet", "list.bullet.rectangle",
"checklist", "book", "bookmark",
// Containers and stages
"tray", "tray.full", "folder", "archivebox", "shippingbox", "square.stack",
// People and communication
"person", "person.2", "bubble.left", "bubble.left.and.bubble.right", "envelope", "megaphone",
// Data and systems
"chart.bar", "chart.pie", "chart.line.uptrend.xyaxis", "terminal", "network",
// Markers
"tag", "paperclip", "link", "pin", "target", "flame", "leaf", "sparkles", "heart",
// Motion
"arrow.triangle.branch", "arrow.triangle.2.circlepath", "arrow.up.arrow.down",
// Other
"lock", "key", "trash",
]
/// The set this Mac can actually draw.
static var available: [String] { all.filter(ItemSymbol.exists) }
}
// MARK: - The editor
/// The style editor: a background section and a symbol section, each a leading "no value" well
/// followed by its grid, with the target set's current value stated beside the section title.
///
/// ### What it shows for a batch
///
/// Per dimension, `StyleFieldState`: every target agreeing shows that well selected, a disagreement
/// shows nothing selected and reads "" ("Mixed" to VoiceOver 10-accessibility.md's
/// never-colour-alone rule), and an off-palette value a hand-written hex, an uncurated symbol
/// states itself verbatim beside the title, outside the grids, where "choosing any well replaces
/// it".
///
/// ### Keyboard
///
/// "Inside the editor the grids are arrow-navigable and every well Tab-reachable" (§ Controls,
/// 10-accessibility.md): every well is a focusable button, and each grid moves focus by one on
/// / and by a row on /.
struct StyleEditorView: View {
let store: BoardStore
let recents: StyleRecents
let target: StyleTarget
/// Wells per row. Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the
/// popover narrow enough to sit beside a card without covering the lane it came from.
private let backgroundColumns = 7
private let symbolColumns = 8
var body: some View {
let subjects = store.styleSubjects(of: target)
let background = StyleFieldState.resolve(subjects.map(\.background))
let icon = StyleFieldState.resolve(subjects.map(\.icon))
VStack(alignment: .leading, spacing: 14) {
targetCaption(count: subjects.count)
backgroundSection(background)
Divider()
symbolSection(icon)
}
.padding(14)
.frame(width: 268)
// The read-only lock and the focused-editor rule disable every mutating surface, not only
// the menu items (02-architecture.md § The lock's scope) an editor whose wells would be
// refused should not look available. The popover stays *open*: the lock is a condition the
// banner is already explaining, not a reason to yank a surface out from under the pointer.
.disabled(!store.acceptsBoardMutations)
}
/// Who is being styled one quiet line, because a batch gesture with no statement of its scope
/// is the one place this editor could silently do more than the user meant.
private func targetCaption(count: Int) -> some View {
Text(caption(count: count))
.font(.caption)
.foregroundStyle(.secondary)
}
private func caption(count: Int) -> String {
switch store.styleLevel(of: target) {
case .board: "Board"
case .lane: count == 1 ? "Lane" : "\(count) lanes"
case .card: count == 1 ? "Card" : "\(count) cards"
}
}
// MARK: - Background
/// The twelve palette wells and their leading None (03-board-ui.md § Styling Controls:
/// "palette-only in-app plus a leading **None** well that removes the `background` key").
private func backgroundSection(_ state: StyleFieldState) -> some View {
VStack(alignment: .leading, spacing: 8) {
sectionHeader("Background", current: backgroundCurrent(state))
StyleWellGrid(
wells: backgroundWells(state),
columns: backgroundColumns,
apply: { change in
StyleCommand.apply(background: change, to: target, in: store, recents: recents)
}
)
}
}
private func backgroundWells(_ state: StyleFieldState) -> [StyleWell] {
var wells = [StyleWell(id: 0, face: .noValue, label: "None", change: .remove, isSelected: state == .unset)]
for (index, color) in Palette.backgrounds.enumerated() {
wells.append(StyleWell(
id: index + 1,
face: .color(color.name),
label: color.name,
change: .set(color.name),
isSelected: state == .uniform(color.name)
))
}
return wells
}
/// What the background dimension currently reads including the verbatim off-palette case, which
/// is exactly why this is a chip beside the title and not a highlighted well.
private func backgroundCurrent(_ state: StyleFieldState) -> CurrentValue {
switch state {
case .unset: CurrentValue(face: .noValue, text: "None")
case .mixed: CurrentValue(face: nil, text: "", spoken: "Mixed")
case let .uniform(value): CurrentValue(face: .color(value), text: value)
}
}
// MARK: - Symbol
/// The curated grid and its leading default well "its leading well is the level's default
/// symbol and removes the `icon` key" (§ Controls).
private func symbolSection(_ state: StyleFieldState) -> some View {
let level = store.styleLevel(of: target)
let fallback = ItemSymbol.default(for: level)
return VStack(alignment: .leading, spacing: 8) {
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback))
ScrollView(.vertical) {
StyleWellGrid(
wells: symbolWells(state, fallback: fallback),
columns: symbolColumns,
apply: { change in
StyleCommand.apply(icon: change, to: target, in: store, recents: recents)
}
)
}
// Eight rows or so before it scrolls: enough that the grid reads as a set rather than as
// a strip, short enough that the popover fits beside a card on a laptop screen.
.frame(maxHeight: 168)
}
}
private func symbolWells(_ state: StyleFieldState, fallback: String) -> [StyleWell] {
var wells = [StyleWell(
id: 0,
face: .defaultSymbol(fallback),
label: "Default (\(fallback))",
change: .remove,
isSelected: state == .unset
)]
for (index, name) in CuratedSymbols.available.enumerated() {
wells.append(StyleWell(
id: index + 1,
face: .symbol(name),
label: name,
change: .set(name),
isSelected: state == .uniform(name)
))
}
return wells
}
private func symbolCurrent(_ state: StyleFieldState, fallback: String) -> CurrentValue {
switch state {
case .unset: CurrentValue(face: .defaultSymbol(fallback), text: "Default")
case .mixed: CurrentValue(face: nil, text: "", spoken: "Mixed")
case let .uniform(value): CurrentValue(face: .symbol(value), text: value)
}
}
// MARK: - Section chrome
private func sectionHeader(_ title: String, current: CurrentValue) -> some View {
HStack(spacing: 6) {
Text(title)
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
if let face = current.face {
StyleWellFace(face: face, size: 14)
}
Text(current.text)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title), \(current.spoken ?? current.text)")
}
}
// MARK: - Current value
/// The current-value chip beside a section title: the one place an off-palette value is stated
/// ("labeled verbatim, outside the grids"), and the one place a mixed batch reads "".
private struct CurrentValue {
let face: StyleWellFace.Face?
let text: String
/// What VoiceOver says when the written text would not do "Mixed" for the em dash, which is a
/// glyph rather than a word (10-accessibility.md: a batch's mixed state "reads as 'mixed', never
/// conveyed by highlight alone").
var spoken: String?
init(face: StyleWellFace.Face?, text: String, spoken: String? = nil) {
self.face = face
self.text = text
self.spoken = spoken
}
}
// MARK: - Wells
/// One well: what it draws, what it is called, and what clicking it asks of the frontmatter key.
private struct StyleWell: Identifiable {
let id: Int
let face: StyleWellFace.Face
let label: String
let change: StyleChange
let isSelected: Bool
}
/// A well's face a colour, a symbol, or one of the two "no value" leading wells.
private struct StyleWellFace: View {
enum Face: Equatable {
/// The background grid's None well: a slashed empty swatch, Finder's own vocabulary for
/// "there isn't one".
case noValue
/// A palette name or a hand-written hex. An unresolvable value draws like `noValue` the
/// renderer's lenient rule, which is what makes an off-palette chip honest about a value the
/// app cannot read.
case color(String)
case symbol(String)
/// The symbol grid's leading well: the level's default, drawn quieter than a chosen one so
/// "no symbol set" and "this symbol set" do not look alike.
case defaultSymbol(String)
}
let face: Face
var size: CGFloat = 20
var body: some View {
switch face {
case .noValue:
swatch(nil)
case let .color(value):
swatch(Palette.color(named: value))
case let .symbol(name):
glyph(name, tint: AnyShapeStyle(.primary))
case let .defaultSymbol(name):
glyph(name, tint: AnyShapeStyle(.secondary))
}
}
/// A colour well. **Always stroked**: `chalk` is `#FFFFFF` and an unbordered white swatch is an
/// invisible control on a light popover (10-accessibility.md's contrast stance turned on the
/// app's own chrome). A `nil` colour adds the diagonal strike that means "none".
private func swatch(_ color: Color?) -> some View {
RoundedRectangle(cornerRadius: 4)
.fill(color ?? Color(nsColor: .textBackgroundColor))
.overlay { if color == nil { NoValueStrike().stroke(.secondary, lineWidth: 1) } }
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(.separator, lineWidth: 1))
.frame(width: size, height: size)
}
private func glyph(_ name: String, tint: AnyShapeStyle) -> some View {
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
.imageScale(.medium)
.foregroundStyle(tint)
.frame(width: size, height: size)
}
}
/// The corner-to-corner slash on the None well the pathfinder's swatch vocabulary, kept because it
/// is also the system's (an empty colour well slashes in Finder's own tag editor).
private struct NoValueStrike: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + 3, y: rect.maxY - 3))
path.addLine(to: CGPoint(x: rect.maxX - 3, y: rect.minY + 3))
return path
}
}
/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid (10-accessibility.md Style
/// editor).
///
/// Focus is the grid's own state rather than the editor's, because the two grids are independently
/// navigable and Tab is what crosses between them which is exactly what the accessibility doc asks
/// for ("the grids are arrow-navigable and every well Tab-reachable"). The arrow handler sits on the
/// container: a focused `Button` does not consume arrow keys, so the press bubbles here, and moving
/// focus is all it does **selection is never implied by focus**, since a well's job is to write to
/// disk and a stray arrow key must not restyle a board.
private struct StyleWellGrid: View {
let wells: [StyleWell]
let columns: Int
let apply: (StyleChange) -> Void
@FocusState private var focused: Int?
var body: some View {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(minimum: 20), spacing: 6), count: columns),
spacing: 6
) {
ForEach(wells) { well in
Button {
apply(well.change)
} label: {
StyleWellFace(face: well.face)
.overlay(selectionRing(well.isSelected))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable()
.focused($focused, equals: well.id)
.help(well.label)
.accessibilityLabel(well.label)
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
}
}
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
private func selectionRing(_ isSelected: Bool) -> some View {
RoundedRectangle(cornerRadius: 5)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 2)
.padding(-2)
}
/// One step per press, clamped at the ends rather than wrapped: a grid whose last row is short
/// would wrap into a hole, and Finder's own icon grids clamp too.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -columns
case .downArrow: delta = columns
default: return .ignored
}
let current = focused ?? 0
let next = min(max(0, current + delta), wells.count - 1)
focused = next
return .handled
}
}
// MARK: - Context-menu surfaces
/// The two style entries every context menu carries Style and the quick-style recents row
/// (11-command-nexus.md Context menus; 03-board-ui.md § Styling Controls).
///
/// One view for both menus because the entries are identical on a card and on a lane: only the
/// *target* differs, and that is the caller's to compute (the clicked item, or the selection it
/// belongs to).
struct StyleMenuItems: View {
let store: BoardStore
let recents: StyleRecents
let target: StyleTarget
var body: some View {
Button("Style…") {
store.transient.beginStyleEditor(for: target)
}
.disabled(!store.acceptsBoardMutations)
QuickStyleRow(store: store, recents: recents, target: target)
}
}
/// The quick-style row: "one compact row of recently used backgrounds one-click recolor for the
/// common case; the pathfinder's second full-palette tier is gone" (03-board-ui.md § Styling
/// Controls).
///
/// A `.palette`-styled `Picker` is what macOS renders as a horizontal swatch strip inside a menu
/// the pathfinder's finding, and the only shape that puts colours in a menu row at all. AppKit draws
/// a menu item from an image and a title, so the dots are `NSImage`s (`PaletteSwatch`) rather than
/// SwiftUI shapes.
///
/// **Absent until it has something to offer.** A brand-new install has no recents, and an empty
/// picker in a context menu is a row that looks broken.
struct QuickStyleRow: View {
let store: BoardStore
let recents: StyleRecents
let target: StyleTarget
/// A sentinel for "the current value is not one of these", so a mixed batch or a background
/// that has aged out of the recents leaves the row unchecked rather than checking the wrong
/// dot. It is never a rendered option, so it can never be picked.
private enum Choice: Hashable {
case value(String)
case other
}
var body: some View {
if !recents.backgrounds.isEmpty {
Picker("Recent Colors", selection: selection) {
ForEach(recents.backgrounds, id: \.self) { name in
Label {
Text(name)
} icon: {
Image(nsImage: PaletteSwatch.circleImage(for: name))
}
.tag(Choice.value(name))
}
}
.pickerStyle(.palette)
.disabled(!store.acceptsBoardMutations)
}
}
private var selection: Binding<Choice> {
Binding(
get: {
let state = StyleFieldState.resolve(store.styleSubjects(of: target).map(\.background))
guard case let .uniform(value) = state, recents.backgrounds.contains(value) else { return .other }
return .value(value)
},
set: { picked in
guard case let .value(name) = picked else { return }
StyleCommand.apply(background: .set(name), to: target, in: store, recents: recents)
}
)
}
}
// MARK: - Presentation
/// The Style popover's content: the editor, aimed at **the session's own target set**.
///
/// Reading the target from the session rather than re-deriving it from the selection is what makes
/// the settled lifecycle visible: the popover was aimed once, at what the gesture named, and from
/// then on it follows *that* set as members vanish a selection change behind an open popover must
/// not silently re-aim it, and a right-click on an unselected card must keep styling that card.
struct StyleEditorPopover: View {
let store: BoardStore
let recents: StyleRecents
var body: some View {
// Empty for the frame between a session ending and the popover's own dismissal landing
// the binding is already `false`, so this is a formality rather than a state.
if let session = store.transient.styleEditor {
StyleEditorView(store: store, recents: recents, target: session.target)
}
}
}
/// Whether *this* anchor is the one showing the open Style popover.
///
/// Every candidate surface a card face, a lane header, the strip itself binds its popover
/// through this, and `StyleEditorSession.presentationAnchor(in:)` answers for exactly one of them.
/// So the popover follows its target set across reloads (an anchor that vanishes hands it to the next
/// live target) and only an *emptied* set takes it down, which is the settled lifecycle.
///
/// The setter is narrowed to this anchor's own dismissal: a session that has moved to another anchor
/// must not be discarded by the surface it just left.
@MainActor
func styleEditorPresentation(_ store: BoardStore, anchor: ItemID?) -> Binding<Bool> {
Binding(
// Spelled with an explicit `guard let` rather than optional chaining: `nil == nil` is
// `true`, so a chained comparison would tell the board strip (whose anchor *is* `nil`) to
// present a popover nobody opened.
get: {
guard let session = store.transient.styleEditor else { return false }
return session.presentationAnchor(in: store.snapshot) == anchor
},
set: { presented in
guard !presented,
let session = store.transient.styleEditor,
session.presentationAnchor(in: store.snapshot) == anchor
else { return }
store.transient.discardStyleEditor()
}
)
}