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
+138 -8
View File
@@ -32,17 +32,28 @@ struct LaneHeaderDrag {
/// (`LaneReorderSession`). The one thing carved out of the drag region is the button, which sits in
/// an overlay outside the gesture so a click on it can never be read as the beginning of a drag.
///
/// ### The lane's one context menu
///
/// "The lane has one context menu (settled), invoked on the header or on lane empty space alike"
/// (03-board-ui.md § Lane), so both surfaces attach the *same* `laneMenu`. It carries Style, the
/// quick-style recents row and the Width stepper today; Rename and Delete are m5's context-menus
/// card, and their rows go into that same builder rather than into a second menu.
///
/// ### What is still a later card's
///
/// The lane context menu (Rename, Style, the quick-style recents row, the Width stepper, Delete),
/// the lane's own top-edge accent band, and the search-aware filtering behind the count all belong
/// to later milestones. The card face is real (`CardFaceView`); what it still owes is the cut
/// treatment and the sole-selected card's attachment carousel.
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
/// (`CardFaceView`); what it still owes is the cut treatment and the sole-selected card's attachment
/// carousel.
struct LaneView: View {
let store: BoardStore
let lane: Lane
/// The app-wide quick-style recents (03-board-ui.md § Styling Controls "never board data"),
/// read from the environment rather than threaded down the strip: the list belongs to the app,
/// not to this board, and every context menu in the window wants it.
@Environment(AppModel.self) private var appModel
/// Interior masonry columns the lane's width units, or the resize session's snapped count
/// while this lane is being dragged. Passed in rather than read off `lane` so the live drag can
/// override it (see `BoardView.laneSlot`).
@@ -61,12 +72,24 @@ struct LaneView: View {
/// Spacing between cards, and between the interior columns.
private let cardSpacing: CGFloat = 8
/// The lane plate's corner radius shared by the selection treatment and the accent band, whose
/// top corners round to exactly this so the band reads as the lane's own edge.
private let cornerRadius: CGFloat = 10
/// C7 · full-column top edge (03-board-ui.md § Styling Capabilities).
private let bandHeight: CGFloat = 5
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
cardStack
// `spacing: 0` and the padding moved inside: the accent band is **full-width** along the
// lane's top edge, so it must sit outside the content inset rather than in it.
VStack(alignment: .leading, spacing: 0) {
accentBand
VStack(alignment: .leading, spacing: 8) {
header
cardStack
}
.padding(6)
}
.padding(6)
.background(selectionBackground)
.overlay(selectionStroke)
}
@@ -80,6 +103,82 @@ struct LaneView: View {
.contentShape(Rectangle())
.gesture(headerGesture)
.overlay(alignment: .trailing) { newCardButton }
.contextMenu { laneMenu }
// The lane's half of the Style popover. Anchored on the header because that is the
// lane's own furniture `styleEditorPresentation` decides whether this lane is the
// session's presenting anchor at all.
.popover(isPresented: styleEditorPresentation(store, anchor: lane.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
}
/// The lane's colour as C7 "a lane's color paints a full-width band along its top edge; the
/// surfaces themselves keep the standard chrome, so colored title text never sits on a colored
/// fill" (03-board-ui.md § Styling Capabilities, settled in the pathfinder's treatment
/// shootout).
///
/// A value that resolves to nothing paints **no band**, and the bytes stay on disk exactly as
/// written the card stripe's rule, for its reason: there is no sensible default colour for
/// "the author meant something we can't read", and a wrong colour is worse than none.
@ViewBuilder
private var accentBand: some View {
if let color = Palette.color(for: lane.background) {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
.fill(color)
.frame(height: bandHeight)
.frame(maxWidth: .infinity)
// Decoration only: the header below it owns the lane's click and drag.
.allowsHitTesting(false)
}
}
// MARK: - The lane's one context menu
/// Rename, Style, the quick-style recents row, the Width control, Delete (11-command-nexus.md
/// Context menus) the style trio and the width stepper today.
@ViewBuilder
private var laneMenu: some View {
// m5-context-menus: Rename (a twin of Board Rename) and Delete (a twin of File Delete)
// belong to the card that brings the selection model and the delete command; both are rows
// of *this* menu when they land, not of a second one.
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
Divider()
widthControl
}
/// The width stepper "the header context menu's Width control (stepper, uncapped) is the
/// precise control it never touches the window, it **re-divides** the existing width across the
/// new unit total" (03-board-ui.md § Lane). A +/ pair rather than a slider or a fixed 1×/2×/3×
/// list, because the control is uncapped in one direction and floored at one unit in the other.
///
/// **Single-lane by nature**, unlike the style entries above it: the design gives the batch to
/// the / menu items and keeps the stepper on the lane whose menu is open.
private var widthControl: some View {
let units = LaneLayoutMath.displayUnits(of: lane)
return Section("Width — \(units)×") {
Button("Increase Width") {
store.setLaneWidth(lane.id, units: units + 1)
}
Button("Decrease Width") {
store.setLaneWidth(lane.id, units: units - 1)
}
// A one-unit lane cannot shrink (`width` is 1), and an item whose only outcome is a
// no-op reads better disabled than dead `LaneWidthCommands`' rule, same floor.
.disabled(units <= 1)
}
.disabled(!store.acceptsBoardMutations)
}
/// What this lane's menu styles: the whole selection when this lane is part of it, else this lane
/// alone standard macOS context-menu targeting (the pathfinder's `styleSelection`, kept).
/// Right-clicking something outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(lane.id) else {
return .items([lane.id])
}
return .items(store.selection.ids)
}
private var headerContent: some View {
@@ -223,6 +322,9 @@ struct LaneView: View {
store.transient.beginPlaceholder(inLane: lane.id)
}
.onTapGesture { toggleLaneSelection() }
// The same menu the header carries "one menu, invoked on the header or lane empty
// space alike" (03-board-ui.md § Lane, settled).
.contextMenu { laneMenu }
}
}
@@ -363,6 +465,9 @@ private struct CardFaceView: View {
let card: Card
let openCard: (ItemID) -> Void
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// The plate's corner radius shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
private let cornerRadius: CGFloat = 8
@@ -396,6 +501,31 @@ private struct CardFaceView: View {
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename.
.onTapGesture { store.select([card.id], liveness: .live) }
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
}
// MARK: - Context menu
/// Open, Rename, Style, the quick-style recents row, Delete (11-command-nexus.md Context
/// menus) the style pair today.
@ViewBuilder
private var cardMenu: some View {
// m5-context-menus: Open (a twin of Board Open Card, always the clicked card alone
// a card window is tied to one card), Rename, and Delete land with the selection-model and
// delete cards, as rows of this same menu.
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
}
/// What this card's menu styles: the whole selection when this card is part of it, else this card
/// alone. Standard macOS right-clicking outside the selection acts on what was clicked.
private var styleTarget: StyleTarget {
guard store.selection.liveness == .live, store.selection.ids.contains(card.id) else {
return .items([card.id])
}
return .items(store.selection.ids)
}
// MARK: - Title row