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:
+125
-33
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user