Three curated symbol vocabularies — boards, lanes, and cards each pick from their own shelf

`CuratedSymbols` was one flat ~65-glyph list serving every style-editor target alike. It is
now three level-specific sets — `boards` (project/container/identity), `lanes`
(workflow/stage/status), `cards` (work-item/content) — each ~30-40 entries, seeded from the
original list and `SymbolPickerCatalog.defaultSet`, reorganized by which level a glyph actually
reads as being about. Overlap is kept where a glyph genuinely fits everywhere (`flag`, `star`).

Wiring:
- `BoardInfoPopover`'s board-glyph `SymbolPicker` now passes `CuratedSymbols.availableBoards`
  instead of the picker's domain-agnostic default.
- The card sidebar's `SymbolPicker` (`CardSidebarSections`) now passes
  `CuratedSymbols.availableCards` instead of the old flat `available`.
- The style editor's own curated grid (`StyleEditorView`, the Style… popover's only remaining
  anchor) reads `CuratedSymbols.availableForStyleEditor(level:spansLevels:)`: a homogeneous
  target reads its own level's set, and a target that somehow spans more than one level (today
  unreachable — 04-interactions.md's cards-XOR-lanes rule keeps a live selection homogeneous)
  reads the three combined, via a new `BoardStore.styleTargetSpansLevels` seam that asks the
  question `styleLevel(of:)` deliberately collapses.
- `CuratedSymbols.combined` (the three sets' stable-order union) also replaces the old `.all`
  in `SymbolPickerCatalog`'s full-catalog fallback.
- `SymbolPickerCatalog.defaultSet` is kept as the fallback for a caller naming no level (a
  future saved-search picker, say) rather than retired.

DESIGN/03-board-ui.md and DESIGN/05-card-window.md's Styling/sidebar prose amended minimally
where they named "the curated set" as a single list.

Tests: three new/rewritten suites in KanbanTests/StyleModelTests.swift (set shape, availability,
overlap, `combined`, the style-editor level/span decision, the board-anchor width tripwire), one
new test in KanbanTests/StyleWriteTests.swift (`styleTargetSpansLevels`), and the old
single-list-pinning tests in KanbanTests/SymbolPickerTests.swift and KanbanTests/CardSidebarTests.swift
updated to the new set names. 2834 tests, 487 suites green (KanbanTests, arm64); one unrelated
flaky failure (RootRecoveryTests.vanishAndReturn under full-suite load) passed clean in isolation.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 01:26:36 -04:00
parent c87616f3fb
commit db863ba011
11 changed files with 288 additions and 79 deletions
+5
View File
@@ -318,6 +318,11 @@ struct BoardInfoView: View {
SymbolPicker(
current: store.snapshot.icon.value,
fallback: ItemSymbol.board,
// Board-level: project/container/identity-flavored, not the picker's
// domain-agnostic default (`CuratedSymbols`' three-set split, 2026-08-09)
// this well is the board's own glyph, so it reaches for the same vocabulary
// the style editor's grid would offer a board target.
symbols: CuratedSymbols.availableBoards,
onSelect: { name in
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
+7 -6
View File
@@ -33,11 +33,12 @@ import SwiftUI
/// ### The curated set does not shrink
///
/// `SymbolPicker`'s own default (`SymbolPickerCatalog.defaultSet`, 36 general "boards and projects"
/// glyphs) is deliberately smaller than `CuratedSymbols.all` (~65, grouped by what a board item *is*
/// status/flow, containers, people, ) `SymbolPicker.swift`'s own doc comment states why the two
/// lists are free to diverge. A card is exactly the domain `CuratedSymbols` was grouped for, so this
/// section passes it explicitly (`symbols: CuratedSymbols.available`) rather than taking the picker's
/// smaller default: the Style popover and this section now offer the identical curated vocabulary,
/// glyphs) is deliberately smaller than `CuratedSymbols.cards` (~40, work-item/content-flavored one
/// of the three level-specific sets `CuratedSymbols` split into, 2026-08-09) `SymbolPicker.swift`'s
/// own doc comment states why the lists are free to diverge. A card is exactly the level
/// `CuratedSymbols.cards` is grouped for, so this section passes it explicitly (`symbols:
/// CuratedSymbols.availableCards`) rather than taking the picker's domain-agnostic default: the
/// Style popover's card-level grid and this section now offer the identical curated vocabulary,
/// through two different controls.
///
/// ### Writes still ride the one funnel, on this window's own stack
@@ -187,7 +188,7 @@ struct CardStyleSection: View {
SymbolPicker(
current: currentSymbol,
fallback: ItemSymbol.card,
symbols: CuratedSymbols.available,
symbols: CuratedSymbols.availableCards,
onSelect: { applySymbol($0) },
currentColor: currentIconColor,
onSelectColor: { applyIconColor($0) }
+125 -33
View File
@@ -49,53 +49,134 @@ enum StyleCommand {
}
}
// MARK: - The curated symbol set
// MARK: - The curated symbol sets
/// The symbol grid's contents "a hand-picked set (roughly five dozen kanban-relevant SF Symbols)"
/// (03-board-ui.md § Styling Controls).
/// 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).
///
/// 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.
/// 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 {
/// 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] = [
/// 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
"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",
"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",
// 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",
"terminal",
// Markers
"tag", "paperclip", "link", "pin", "target", "flame", "leaf", "sparkles", "heart",
// Motion
"arrow.triangle.branch", "arrow.triangle.2.circlepath", "arrow.up.arrow.down",
// Other
"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 set this Mac can actually draw.
static var available: [String] { all.filter(ItemSymbol.exists) }
/// 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
@@ -388,6 +469,17 @@ struct StyleEditorView: View {
}
}
/// 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,
@@ -396,7 +488,7 @@ struct StyleEditorView: View {
change: .remove,
isSelected: state == .unset
)]
for (index, name) in CuratedSymbols.available.enumerated() {
for (index, name) in curatedSymbols.enumerated() {
wells.append(StyleWell(
id: index + 1,
face: .symbol(name),
+15 -12
View File
@@ -13,10 +13,13 @@ import SwiftUI
///
/// ### Why the curated set differs from `CuratedSymbols`
///
/// `CuratedSymbols.all` is grouped by what a *board item* is (status/flow, containers, people)
/// this control has no board item in mind, so `SymbolPickerCatalog.defaultSet` is a smaller,
/// ungrouped 36 chosen for the general "boards and projects" case instead. The two lists are free to
/// diverge; nothing here reads the other.
/// `CuratedSymbols` is three sets grouped by *level* boards, lanes, cards (2026-08-09) because a
/// board's identity, a lane's stage and a card's content want different glyphs. This control has no
/// level built in; a caller aimed at one names it explicitly (`BoardInfoPopover` passes
/// `CuratedSymbols.availableBoards`, the card sidebar `CuratedSymbols.availableCards`), and a caller
/// with no level in mind a saved search, a smart filter falls back to `SymbolPickerCatalog.defaultSet`,
/// a smaller, ungrouped 36 chosen for the general "boards and projects" case instead. The lists are
/// free to diverge; nothing here reads the others beyond the merge below.
///
/// ### The one thing `CuratedSymbols` never needed
///
@@ -32,11 +35,11 @@ import SwiftUI
/// inventory it searches into once the grid alone isn't enough.
enum SymbolPickerCatalog {
/// The picker's curated grid, in order a general "boards and projects" set rather than the
/// style editor's kanban-item groupings, chosen so a first-run picker with no caller-supplied
/// `symbols` still shows something broadly useful. A stored constant, not a computed property,
/// for `CuratedSymbols.all`'s own reason: the list is the design decision, and `available` is the
/// only thing the OS gets a say in.
/// The picker's curated grid, in order a general "boards and projects" set rather than one of
/// the style editor's level-specific groupings, chosen so a first-run picker with no
/// caller-supplied `symbols` still shows something broadly useful. A stored constant, not a
/// computed property, for `CuratedSymbols`' own reason: the list is the design decision, and
/// `available` is the only thing the OS gets a say in.
static let defaultSet: [String] = [
"star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
"tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
@@ -45,8 +48,8 @@ enum SymbolPickerCatalog {
"airplane", "gamecontroller", "globe",
]
/// The set this Mac can actually draw `CuratedSymbols.available`'s rule, mirrored: a curated
/// list is a convenience, never a claim about the running system.
/// The set this Mac can actually draw `CuratedSymbols.available(for:)`'s rule, mirrored: a
/// curated list is a convenience, never a claim about the running system.
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
/// The colour row's seven tints `Palette.foregrounds`' hues, minus the four grayscale steps
@@ -98,7 +101,7 @@ enum SymbolPickerCatalog {
let root = plist as? [String: Any],
let symbols = root["symbols"] as? [String: Any]
else {
return Set(defaultSet + CuratedSymbols.all).sorted()
return Set(defaultSet + CuratedSymbols.combined).sorted()
}
return symbols.keys.sorted()
}