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
|
||||
|
||||
@@ -32,6 +32,18 @@ enum ItemSymbol {
|
||||
return name
|
||||
}
|
||||
|
||||
/// The default for a level — the style editor's symbol grid needs the three defaults as a
|
||||
/// function rather than as three constants, because its leading well is "the level's default
|
||||
/// symbol" and the editor is one component serving all three (03-board-ui.md § Styling ▸
|
||||
/// Controls).
|
||||
static func `default`(for level: StyleLevel) -> String {
|
||||
switch level {
|
||||
case .board: board
|
||||
case .lane: lane
|
||||
case .card: card
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the running system can draw `name` as an SF Symbol.
|
||||
///
|
||||
/// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself
|
||||
|
||||
+40
-3
@@ -63,9 +63,46 @@ enum Palette {
|
||||
}
|
||||
|
||||
// The pathfinder's panel round-trip helpers (`NSColor.paletteHexString`, `Palette.name(forHex:)`)
|
||||
// and its swatch drawing are deliberately not ported yet: nothing writes a colour until the style
|
||||
// editor lands, and an unused writer is a claim about a surface that doesn't exist. The styling
|
||||
// card brings them back when the editor needs them.
|
||||
// stay unported: they exist to turn a colour the *system picker* returned back into a palette name,
|
||||
// and this app has no colour picker — "custom hex is not pickable in-app" (03 § Styling ▸ Controls)
|
||||
// makes the whole round trip a surface that doesn't exist. Its swatch drawing, on the other hand, is
|
||||
// below: a menu can only render `Image`/`Text`, so the quick-style row's dots have to be pictures.
|
||||
|
||||
// MARK: - Menu swatches
|
||||
|
||||
/// A colour value drawn as a picture, for the one surface that cannot take a SwiftUI shape: **menu
|
||||
/// items**. AppKit renders a menu row from its label's image and text, so the quick-style recents row
|
||||
/// (03-board-ui.md § Styling ▸ Controls) needs an `NSImage` per dot where the editor's own wells are
|
||||
/// ordinary views.
|
||||
enum PaletteSwatch {
|
||||
|
||||
/// A filled dot for `value` (a palette name or `#RRGGBB[AA]` hex), hairline-bordered.
|
||||
///
|
||||
/// The border is not decoration: the background palette contains `chalk` (`#FFFFFF`), and an
|
||||
/// unbordered white dot on a light menu is an invisible menu item — the same reason the editor's
|
||||
/// wells are stroked (10-accessibility.md's contrast stance applied to the app's own chrome).
|
||||
///
|
||||
/// A value that resolves to nothing draws the border alone rather than a guessed colour, matching
|
||||
/// every other lenient rendering here: there is no colour, so show none.
|
||||
static func circleImage(for value: String, diameter: CGFloat = 14) -> NSImage {
|
||||
let color = Palette.nsColor(for: value)
|
||||
return NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in
|
||||
let inset = rect.insetBy(dx: 0.5, dy: 0.5)
|
||||
let path = NSBezierPath(ovalIn: inset)
|
||||
// Under a translucent colour the menu's own backdrop would show through unevenly across
|
||||
// appearances; filling the standard control backdrop first makes the dot composite the
|
||||
// same way in light and dark. An opaque colour covers it completely.
|
||||
NSColor.textBackgroundColor.setFill()
|
||||
path.fill()
|
||||
color?.setFill()
|
||||
path.fill()
|
||||
NSColor.separatorColor.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Palette {
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user