Files
lanework/Kanban/UI/StyleEditor.swift
T
rzen ece33bbf78 The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover
The colour combo was a wide two-zone field with a second door onto the Colors
panel; the symbol picker was a small square button with one. Both now subclass
one `ComboFieldControl`, so they are the same width, height, radius and trigger
by construction: click the face for the standalone picker, click the chevron for
the quick list. The symbol face opens a new floating browser over the OS's own
category, ordering and keyword plists out of CoreGlyphs.bundle — searchable,
categorised, trademark-restricted glyphs withheld.

The palette grows twelve to sixteen per table, filling the hue ring's four
widest gaps with lime, jade, indigo and magenta at each table's own saturation
and brightness. That gives the Style… popover's background grid a third row and
the tint grid its third row of four, and both grids gain an Other… row onto the
system colour picker — which the card sidebar's combo has had all along and the
primary styling surface never did. An arbitrary hex already round-tripped; it is
asserted now, including that an unquoted one is a YAML comment and no value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 09:44:30 -04:00

919 lines
45 KiB
Swift

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`).
///
/// The one thing here that *names* an anchor is `StyleEditorLayout`, and it names only geometry: a
/// popover is a window this app sizes and a sidebar section is a column the window sizes, so the two
/// cannot share a frame. Nothing behavioral hangs off it — see its own doc comment.
// 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 {
/// - Parameter undo: the issuing window's own stack, for the one anchor that has one — the card
/// window's sidebar (13-native-undo.md ▸ Rules ▸ two levels). `nil`, which every board-side
/// anchor passes, is the board's stack.
static func apply(
background: StyleChange = .keep,
icon: StyleChange = .keep,
iconColor: StyleChange = .keep,
to target: StyleTarget,
in store: BoardStore,
recents: StyleRecents,
on undo: CardWindowUndo? = nil
) {
store.applyStyle(to: target, background: background, icon: icon, iconColor: iconColor, on: undo)
if case let .set(value) = background {
recents.record(value)
}
}
}
// MARK: - The curated symbol sets
/// The symbol grid's contents — **three hand-picked, level-specific sets** (03-board-ui.md § Styling
/// ▸ Controls: "three hand-picked, level-specific sets … for boards/lanes/cards"), because a board's
/// identity, a lane's stage and a card's content are different things to reach for a glyph about, and
/// one flat list made every context scroll past the other two's symbols to find its own
/// (2026-08-09, splitting the original single ~65-entry list this card's `combined` still records).
///
/// Each set is seeded from that original list and `SymbolPickerCatalog.defaultSet`, reorganized by
/// which level the glyph actually reads as being *about*:
///
/// - **`boards`**: project/container/identity-flavored — a board is a whole workspace, so this leans
/// on places, organizations and domains (a briefcase, a house, a globe) over single-item content.
/// - **`lanes`**: workflow/stage/status-flavored — a lane is a stage a card passes through, so this
/// leans on flow and gating glyphs (arrows, an hourglass, a checkmark, a stop sign).
/// - **`cards`**: work-item/content-flavored — a card is one piece of work, so this keeps the
/// documents-and-craft vocabulary the original single list was built around (a doc, a hammer, a
/// paperclip).
///
/// **Overlap is allowed where a glyph is genuinely apt at every level** (`flag`, `star`, `bolt`: a
/// status marker means the same thing on a board, a lane or a card) — the three lists are not a
/// partition, because forcing one would mean dropping a symbol from two contexts it actually fits.
///
/// **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 {
/// Board-level: project/container/identity-flavored. A stored constant rather than a computed
/// property: the list is the design decision, and `availableBoards` is the only thing the OS
/// gets a say in.
static let boards: [String] = [
// Identity and structure
"rectangle.split.3x1", "square.grid.2x2", "square.grid.3x3", "rectangle.3.group", "cube",
"shippingbox",
// Places and organizations
"building.2", "building.columns", "house", "map", "globe", "location",
// Containers
"folder", "archivebox", "tray.full", "server.rack",
// Domains
"briefcase", "graduationcap", "cart", "airplane", "car", "book", "newspaper",
"gamecontroller", "paintpalette",
// Status and markers
"flag", "flag.checkered", "star", "target", "bolt", "sparkles",
// Data
"chart.bar", "chart.pie", "chart.line.uptrend.xyaxis", "network",
// People
"person.2", "person.3",
// Work
"hammer", "wrench.and.screwdriver", "lightbulb",
]
/// Lane-level: workflow/stage/status-flavored.
static let lanes: [String] = [
// Status and flow
"square.stack", "tray", "tray.full", "arrow.right.circle", "arrow.triangle.branch",
"arrow.triangle.2.circlepath", "arrow.up.arrow.down", "checkmark.circle", "checkmark.seal",
"xmark.circle", "exclamationmark.triangle", "questionmark.circle", "circle", "pause.circle",
"play.circle", "stop.circle",
// Time and pacing
"hourglass", "clock", "alarm", "timer", "calendar",
// Review and gates
"eye", "flag", "flag.checkered", "target", "bolt",
// Containers
"folder", "archivebox", "shippingbox",
// People
"person", "person.2", "bubble.left", "bubble.left.and.bubble.right",
// Priority and risk
"exclamationmark.circle", "flame", "star",
]
/// Card-level: work-item/content-flavored — the vocabulary the original single list was built
/// around.
static let cards: [String] = [
// Documents and content
"doc.text", "doc.on.doc", "doc.richtext", "note.text", "list.bullet",
"list.bullet.rectangle", "checklist", "book", "bookmark", "paperclip",
// Work and craft
"hammer", "wrench.and.screwdriver", "gearshape", "ant", "lightbulb", "paintbrush", "pencil",
"terminal",
// Markers
"tag", "link", "pin", "flag", "star", "flame", "leaf", "sparkles", "heart",
// People and communication
"person", "person.2", "bubble.left", "envelope", "megaphone",
// Status
"checkmark.circle", "xmark.circle", "exclamationmark.triangle", "clock",
// Security and other
"lock", "key", "trash",
]
/// The three sets combined, in stable order (boards, then lanes, then cards) and deduplicated —
/// close kin to the original single list this card split apart, kept alive as: (1) the style
/// editor's fallback for a target that spans more than one level, and (2) the seed
/// `SymbolPickerCatalog`'s own full-catalog fallback merges in.
///
/// A spanning target is unreachable today — 04-interactions.md's cards-XOR-lanes rule keeps a
/// live selection homogeneous, and `BoardStore.styleLevel(of:)` collapses even a hypothetical mix
/// to `.card` for the leading well's default — but the symbol grid asks the finer question
/// (`BoardStore.styleTargetSpansLevels`) rather than trust that collapse, so a loosened invariant
/// would degrade to "shows everything" instead of silently narrowing to one level's vocabulary.
static var combined: [String] {
var seen = Set<String>()
return (boards + lanes + cards).filter { seen.insert($0).inserted }
}
/// The level's set, filtered to what this Mac can actually draw — the style editor's grid reads
/// this once it knows which level a target sits at (`BoardStore.styleLevel(of:)`).
static func available(for level: StyleLevel) -> [String] {
switch level {
case .board: boards.filter(ItemSymbol.exists)
case .lane: lanes.filter(ItemSymbol.exists)
case .card: cards.filter(ItemSymbol.exists)
}
}
static var availableBoards: [String] { boards.filter(ItemSymbol.exists) }
static var availableLanes: [String] { lanes.filter(ItemSymbol.exists) }
static var availableCards: [String] { cards.filter(ItemSymbol.exists) }
/// The mixed-target fallback, filtered — `combined`'s own doc comment.
static var availableCombined: [String] { combined.filter(ItemSymbol.exists) }
/// The style editor's grid-content decision, pulled out as a pure function so it is testable
/// without a view on screen: `available(for:)` when `target` sits at one level, `availableCombined`
/// when it spans more than one (`BoardStore.styleTargetSpansLevels`'s own doc comment on why that
/// question gets asked at all). `StyleEditorView.curatedSymbols` is a one-line call to this.
static func availableForStyleEditor(level: StyleLevel, spansLevels: Bool) -> [String] {
spansLevels ? availableCombined : available(for: level)
}
}
// MARK: - The anchor's chrome
/// Everything about the editor that is the **anchor's** business rather than the editor's: how wide
/// it is, what padding it brings, how many wells fall in a row, and whether its symbol grid scrolls.
///
/// **It exists so "one component, one behavior, another anchor" survives an anchor that is not a
/// popover** (05-card-window.md ▸ Style: the card sidebar embeds this same editor). A popover is a
/// window the app sizes; a sidebar section is a column the window sizes — and the 268-point frame
/// that makes the first one narrow enough to sit beside a card would overflow the second by 70
/// points. Nothing about *behavior* is in here: every well, every write, the batch display and the
/// keyboard grammar are the editor's, identical at every anchor. Only the geometry moves.
///
/// ### Everything here scales with the body font
///
/// A well is a **container for a glyph**, and the glyph inside it is drawn at `.imageScale(.medium)`
/// — a relative size. So a well fixed at 20 points would be overrun by its own symbol at a large
/// system text size, and 10-accessibility.md's "every well Tab-reachable and labeled by name" would
/// be true of controls the user could no longer read. Every figure below is therefore a multiple of
/// the body point size, chosen to reproduce today's numbers at the standard 13pt body — the same
/// derivation, and the same rationale, as `BoardMetrics` and `CardWindowMetrics`.
struct StyleEditorLayout: Equatable {
/// One well's side, and the gap between two — the numbers the grids are laid out on, derived
/// once so the fit rule below and the wells themselves cannot drift apart.
///
/// 1.55 em and 0.45 em: 20pt and 6pt at the standard 13pt body, which is what the grids have
/// always drawn.
static func wellSide(bodyPointSize: CGFloat) -> CGFloat {
max(1, (bodyPointSize * 1.55).rounded())
}
static func wellSpacing(bodyPointSize: CGFloat) -> CGFloat {
max(1, (bodyPointSize * 0.45).rounded())
}
/// The gap between the editor's two sections — and, at the popover anchor, its inset too, which
/// is why it is one figure rather than two that happen to agree. The *sidebar* anchor brings no
/// inset of its own (its column is already gutted) but still wants the sections apart, so the
/// spacing has to survive `padding` going to zero.
static func sectionSpacing(bodyPointSize: CGFloat) -> CGFloat {
max(1, (bodyPointSize * 1.08).rounded())
}
/// A fixed width, or `nil` to take whatever the anchor proposes.
var width: CGFloat?
/// The editor's own inset. Zero where the anchor already insets its column.
var padding: CGFloat
var backgroundColumns: Int
var symbolColumns: Int
/// How tall the symbol grid may grow before it scrolls inside itself, or `nil` for "never" —
/// the grid then draws whole and the anchor scrolls it.
var symbolGridMaximumHeight: CGFloat?
/// The well geometry this layout's grids draw on — carried on the value rather than read from
/// the statics above, so a view has one thing to consult and the two can never disagree about
/// which text size they were computed for.
var wellSide: CGFloat
var wellSpacing: CGFloat
/// The Style… popover and the board popover's styling area: a fixed frame, its own padding, and
/// a symbol grid that scrolls within it.
///
/// Seventeen background wells (None + the sixteen) fall as 7 + 7 + 3 — two rows until the
/// palette grew on 2026-08-09, three since — which keeps the popover narrow
/// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is
/// eight rows or so — enough that it reads as a set rather than as a strip, short enough that the
/// popover fits beside a card on a laptop screen.
///
/// The column counts are the design's own and stay fixed at every text size — 03-board-ui.md
/// names the 7 + 6 fall — while the *frame* around them grows, which is what keeps the 7 wells
/// inside it (20.6 em is 268pt at the standard body size, the number 03 settled on).
static func popover(bodyPointSize: CGFloat) -> StyleEditorLayout {
StyleEditorLayout(
width: (bodyPointSize * 20.6).rounded(),
padding: sectionSpacing(bodyPointSize: bodyPointSize),
backgroundColumns: 7,
symbolColumns: 8,
symbolGridMaximumHeight: (bodyPointSize * 12.9).rounded(),
wellSide: wellSide(bodyPointSize: bodyPointSize),
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
)
}
/// The card window's sidebar section (05-card-window.md ▸ Style).
///
/// - **No width and no padding of its own**: the sidebar's width is `CardWindowMetrics`' one
/// decision and its gutter is already applied to the whole section stack, so an editor with an
/// opinion here would either overflow the column or inset twice.
/// - **As many wells per row as the column holds**, rather than the popover's 7 and 8 — the
/// sidebar is narrower than the popover at every text size, and a grid wider than its column is
/// a grid with wells the pointer cannot reach.
/// - **The symbol grid does not scroll.** The sidebar is already a scroll view, and a scroll view
/// inside a scroll view is a scroll view that fights (`CardWindowView`'s rule, for its reason).
static func sidebar(contentWidth: CGFloat, bodyPointSize: CGFloat) -> StyleEditorLayout {
let columns = columns(fitting: contentWidth, bodyPointSize: bodyPointSize)
return StyleEditorLayout(
width: nil,
padding: 0,
backgroundColumns: columns,
symbolColumns: columns,
symbolGridMaximumHeight: nil,
wellSide: wellSide(bodyPointSize: bodyPointSize),
wellSpacing: wellSpacing(bodyPointSize: bodyPointSize)
)
}
/// How many wells fit across `width` — `n` wells and `n - 1` gaps, floored, and never less than
/// one. Pure, and the whole of "the grid never overflows the column it was given".
///
/// Both the column and the wells grow with the text size, so the count stays roughly stable
/// across text sizes rather than collapsing to one: `CardWindowMetrics`' sidebar is 26 body
/// *characters* wide and a well is 1.55 body *ems*, and the ratio between those does not move.
static func columns(fitting width: CGFloat, bodyPointSize: CGFloat) -> Int {
let side = wellSide(bodyPointSize: bodyPointSize)
let spacing = wellSpacing(bodyPointSize: bodyPointSize)
return max(1, Int((width + spacing) / (side + spacing)))
}
}
// MARK: - The editor
/// The style editor: a background section and a symbol section (each omissible — `showsBackground`/
/// `showsSymbols`, since 03's anchors compose the halves they need), 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
/// The anchor's geometry, and nothing else (`StyleEditorLayout`). `nil` takes the popover's, so
/// the two anchors that were here first say nothing about it — resolved in `body` rather than
/// defaulted in the declaration, because the popover's geometry now depends on the live text
/// size and a default argument cannot read one.
var layout: StyleEditorLayout?
/// **Which stack this anchor's writes register on** — the card window's own when the editor is
/// mounted in one (`CardStyleSection`), and `nil`, the board's, everywhere else
/// (13-native-undo.md ▸ Rules ▸ two levels). It sits beside `layout` and arrives the same way —
/// the anchor telling the shared component about itself — but unlike `layout` it is not geometry:
/// a colour chosen in a card window is one of that window's session gestures.
var undo: CardWindowUndo?
/// Whether the symbol section appears at all. On everywhere but the board info popover, whose
/// inline `SymbolPicker` beside the rename field owns the board's glyph now — two surfaces
/// writing the same key in one popover would make the second read as a different setting.
var showsSymbols: Bool = true
/// Whether the background section appears at all. On everywhere but the card window sidebar,
/// where the labeled `ColorComboView` row is the background story (03 ▸ Styling ▸ Controls,
/// the 2026-08-06 anchor-ownership rule): the sidebar is the narrow context the combo was
/// built for, and grid-plus-combo over one value read as two settings — `showsSymbols`'
/// reasoning, pointed the other way.
var showsBackground: Bool = true
/// The live body metric, read here rather than passed in — `CardStyleSection`'s pattern, so
/// every anchor derives its geometry the same way (10-accessibility.md's full-relative-scaling
/// rule).
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
var body: some View {
let layout = self.layout ?? .popover(bodyPointSize: pointSize)
let subjects = store.styleSubjects(of: target)
let background = StyleFieldState.resolve(subjects.map(\.background))
let icon = StyleFieldState.resolve(subjects.map(\.icon))
VStack(alignment: .leading, spacing: StyleEditorLayout.sectionSpacing(bodyPointSize: pointSize)) {
targetCaption(count: subjects.count)
if showsBackground {
backgroundSection(background, layout: layout)
}
if showsBackground && showsSymbols {
Divider()
}
if showsSymbols {
symbolSection(icon, layout: layout)
}
}
.padding(layout.padding)
.frame(width: layout.width)
// 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 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"), and — since
/// 2026-08-09 — an **Other…** row onto the system Colors panel.
///
/// **"Palette-only in-app" is the sentence that changed**, and it changed before this card:
/// `ColorComboView` shipped an **Other…** row of its own, so the card window's sidebar could
/// already write an arbitrary hex while the Style… popover — the *primary* styling surface —
/// could not. This closes that gap rather than opening a new one. Seventeen wells at seven
/// columns is three rows where the old twelve made two, which is the other half of the same change.
private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
VStack(alignment: .leading, spacing: layout.wellSpacing) {
sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
StyleWellGrid(
wells: backgroundWells(state),
columns: layout.backgroundColumns,
layout: layout,
apply: { change in
StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
}
)
HStack(spacing: 0) {
Spacer(minLength: 0)
Button("Other…") { openBackgroundPanel(state) }
.buttonStyle(.link)
.font(.caption)
}
}
}
/// Opens the Colors panel on the background dimension, seeded with what the target set currently
/// reads (nothing, for a mixed set — there is no one colour to start from).
///
/// **The panel fires continuously**, so this debounces exactly as `CardStyleSection` does for the
/// colour combo's own drag: ~400ms trailing, cancelled and replaced on every tick, so a drag
/// writes once it settles instead of once per pixel it passes through.
///
/// It goes to `store.applyStyle` rather than through `StyleCommand.apply`, which is the same call
/// the sidebar's debounce makes and for the same reason: `StyleRecents` remembers *deliberate
/// palette picks*, and a drag that sweeps through six palette-exact colours on its way somewhere
/// else would otherwise flush the row it is supposed to be helping.
private func openBackgroundPanel(_ state: StyleFieldState) {
let seed: NSColor? = if case let .uniform(value) = state { Palette.nsColor(for: value) } else { nil }
let target = self.target
SharedColorPanelSession.present(seed: seed, matching: Palette.backgrounds) { [weak store, weak undo] value in
guard let store else { return }
store.applyStyle(to: target, background: .set(value), icon: .keep, on: undo)
}
}
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, layout: StyleEditorLayout) -> some View {
let level = store.styleLevel(of: target)
let fallback = ItemSymbol.default(for: level)
return VStack(alignment: .leading, spacing: layout.wellSpacing) {
sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback), layout: layout)
symbolGrid(state, fallback: fallback, layout: layout)
}
}
/// The curated grid, scrolling within its own cap or drawn whole — the anchor's call
/// (`StyleEditorLayout.symbolGridMaximumHeight`), and the one shape difference between the
/// popover and the card sidebar.
@ViewBuilder
private func symbolGrid(_ state: StyleFieldState, fallback: String, layout: StyleEditorLayout) -> some View {
let grid = StyleWellGrid(
wells: symbolWells(state, fallback: fallback),
columns: layout.symbolColumns,
layout: layout,
apply: { change in
StyleCommand.apply(icon: change, to: target, in: store, recents: recents, on: undo)
}
)
if let maximumHeight = layout.symbolGridMaximumHeight {
ScrollView(.vertical) { grid }
.frame(maxHeight: maximumHeight)
} else {
grid
}
}
/// Which set the curated grid draws from — `CuratedSymbols.availableForStyleEditor`'s decision,
/// fed `target`'s own level (`BoardStore.styleLevel(of:)`) and whether it spans more than one
/// (`BoardStore.styleTargetSpansLevels`). The decision itself lives on `CuratedSymbols` so it is
/// testable without a view on screen; this is the one-line wiring.
private var curatedSymbols: [String] {
CuratedSymbols.availableForStyleEditor(
level: store.styleLevel(of: target),
spansLevels: store.styleTargetSpansLevels(target)
)
}
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.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, layout: StyleEditorLayout) -> some View {
HStack(spacing: layout.wellSpacing) {
Text(title)
.font(.subheadline.weight(.semibold))
Spacer(minLength: layout.wellSpacing)
if let face = current.face {
// A touch smaller than a well: this is a *statement* of the current value, not a
// control, and it must not read as a fourteenth swatch that can be clicked.
StyleWellFace(face: face, size: (layout.wellSide * 0.7).rounded())
}
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
/// The well's side, supplied by the caller because it is font-derived and the caller is the one
/// holding the layout it came from (`StyleEditorLayout.wellSide`).
let size: CGFloat
/// Increase Contrast, for the swatch's border below (10-accessibility.md; `Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
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: cornerRadius)
.fill(color ?? Color(nsColor: .textBackgroundColor))
.overlay {
if color == nil {
NoValueStrike(inset: strikeInset)
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
}
}
// A point heavier under Increase Contrast — this hairline is the *only* thing separating
// a `chalk` well from the popover it sits on, which is the same reason it is drawn at all
// (10-accessibility.md's contrast stance turned on the app's own chrome).
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
)
.frame(width: size, height: size)
}
/// The swatch's radius and its "none" strike's inset, as fractions of the well — so both follow
/// the well when the text size grows it (10-accessibility.md's full-relative-scaling rule).
private var cornerRadius: CGFloat { max(1, (size * 0.2).rounded()) }
private var strikeInset: CGFloat { max(1, (size * 0.15).rounded()) }
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 {
/// How far in from each corner the stroke starts — a fraction of the well, supplied by the
/// caller, so it follows the well when the text size grows it.
let inset: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
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
/// The anchor's geometry — the well side and spacing this grid lays out on
/// (`StyleEditorLayout`, all font-derived).
let layout: StyleEditorLayout
let apply: (StyleChange) -> Void
@FocusState private var focused: Int?
/// Increase Contrast, for the selection ring below — 10-accessibility.md names the selection
/// indicator specifically, and this is the style editor's ("the current value is stated by
/// trait", whose visible half is this ring).
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
count: columns
),
spacing: layout.wellSpacing
) {
ForEach(wells) { well in
Button {
apply(well.change)
} label: {
StyleWellFace(face: well.face, size: layout.wellSide)
.overlay(selectionRing(well.isSelected))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// **Every well is Tab-reachable and labeled by name** (10-accessibility.md ▸ Style
// editor). The focus ring is deliberately *not* disabled here, unlike the board
// strip's: a well is a control, and Full Keyboard Access has to be able to show
// which one Tab landed on.
.focusable()
.focused($focused, equals: well.id)
.help(well.label)
.accessibilityLabel(well.label)
// "The current value is stated by trait" — never by the highlight alone.
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
}
}
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
/// The selected well's ring — a point heavier under Increase Contrast, which is 10's
/// "strengthens … the selection indicator" landing on the one selection indicator this component
/// has (`Accommodations`). The trait beside it is what makes the state readable without it.
private func selectionRing(_ isSelected: Bool) -> some View {
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
.strokeBorder(
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
)
// Drawn *outside* the well, by the ring's own half-width, so a heavier ring under
// Increase Contrast grows outward instead of eating into the swatch it is marking.
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 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).
///
/// **The target arrives as a closure, deliberately.** `.contextMenu`'s builder is non-escaping, so a
/// caller's menu is assembled while its own body runs — and a target computed eagerly is a
/// `store.selection` read at body time, which under Observation subscribes that body to every
/// selection change on the board. Deferring it means a card face's context menu costs its face
/// nothing until a row actually acts (`CardFaceView.isSelected` has the measurement).
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.
///
/// The target is deferred for `StyleMenuItems`' reason, and this row is where the deferral has to
/// hold: the `Binding` below is read when the menu shows a checkmark and written when a dot is
/// picked, both of them the picker's own doing rather than the enclosing card face's body.
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()
}
)
}