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:
@@ -121,6 +121,48 @@ struct BoardRenameCommand: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Style
|
||||
|
||||
/// Board ▸ Style… (⌥⌘S) — the style editor's menu-bar anchor (11-command-nexus.md;
|
||||
/// 03-board-ui.md § Styling ▸ Controls).
|
||||
///
|
||||
/// **Selection-aware, with the board as the empty-selection case**: "Board window: selected cards or
|
||||
/// lane; nothing selected = the board". The item does not present anything itself — it opens the
|
||||
/// session (`TransientBoardState.beginStyleEditor`) and the board window's anchors decide which
|
||||
/// surface hosts the popover, which is what keeps the presentation attached to what is being styled
|
||||
/// rather than to the menu bar.
|
||||
///
|
||||
/// Validation is `acceptsBoardMutations` — the lock and the focused-editor rule, the latter naming
|
||||
/// Style in its own list of board-scoped commands (04-interactions.md ▸ Grammar) — plus one rule of
|
||||
/// its own: **a tombstoned selection disables it rather than falling through to the board.**
|
||||
/// Everything edit-shaped is disabled on tombstoned selections (04 ▸ The trash), and quietly
|
||||
/// restyling the board because the user had a trashed card selected would be the silent retarget
|
||||
/// 03 forbids.
|
||||
struct BoardStyleCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Style…") {
|
||||
guard let store, let target = styleTarget else { return }
|
||||
store.transient.beginStyleEditor(for: target)
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.option, .command])
|
||||
.disabled(styleTarget == nil)
|
||||
}
|
||||
|
||||
private var styleTarget: StyleTarget? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return .board }
|
||||
guard selection.liveness == .live else { return nil }
|
||||
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
||||
// items that render — the same universe its own reload rule will hold it to.
|
||||
let live = selection.resolved(against: store.snapshot).ids
|
||||
return live.isEmpty ? nil : .items(live)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane width items
|
||||
|
||||
/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane,
|
||||
|
||||
@@ -45,6 +45,10 @@ struct BoardView: View {
|
||||
/// needs the board's own window ref, which is the host's identity and not the board's.
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
|
||||
/// Styling ▸ Controls).
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// One resize at a time, per window. `@State` so it lives exactly as long as this board window's
|
||||
/// view does, which is the interaction's whole lifetime.
|
||||
@State private var resize = LaneResizeSession()
|
||||
@@ -89,6 +93,12 @@ struct BoardView: View {
|
||||
.padding(spacing)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background(boardBackground)
|
||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
||||
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
|
||||
// the strip is the window's content, not a control, and a rectangle around the whole board
|
||||
// would read as an error state.
|
||||
@@ -105,6 +115,27 @@ struct BoardView: View {
|
||||
.onKeyPress(.escape) { handleEscape() }
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
|
||||
/// The board's `background`, painting "the board window's content background (the surface behind
|
||||
/// and between lanes)" (03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
|
||||
/// is what the design asks for — and it is why the board is the level 10-accessibility.md binds
|
||||
/// its ≥ 4.5:1 rule to: text does sit on it. That runtime contrast computation (a hex background's
|
||||
/// text colour, recomputed against the composited backdrop on appearance change) is not this
|
||||
/// card's — what ships here is the palette path, whose twelve pairs are AA-verified at design
|
||||
/// time.
|
||||
///
|
||||
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
|
||||
/// the same lenient degrade as the other two levels, and the bytes stay as written.
|
||||
@ViewBuilder
|
||||
private var boardBackground: some View {
|
||||
if let color = Palette.color(for: store.snapshot.background) {
|
||||
color
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lanes
|
||||
|
||||
/// One lane's strip slot, plus its trailing grab strip.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user