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
+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),