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:
@@ -1784,6 +1784,26 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `target` names **both** a lane and a card — the finer question `styleLevel(of:)`
|
||||
/// deliberately does not ask, because that function's job is a single leading-well default and a
|
||||
/// mixed set collapsing to `.card` is fine for that ("if a mixed set ever reached here, `doc.text`
|
||||
/// is the level whose default would actually be removed"). The curated symbol grid's job is
|
||||
/// different — showing the *wrong* level's whole vocabulary would be a worse failure than showing
|
||||
/// too much — so it asks this instead (`CuratedSymbols.combined`'s own doc comment).
|
||||
///
|
||||
/// Unreachable through any live selection today: 04-interactions.md's cards-XOR-lanes rule keeps
|
||||
/// a `store.selection` homogeneous, so every real `StyleTarget.items` this app constructs already
|
||||
/// names only lanes or only cards. Kept as a real check rather than assumed, so the grid degrades
|
||||
/// gracefully instead of silently mis-rendering if that invariant is ever loosened.
|
||||
public func styleTargetSpansLevels(_ target: StyleTarget) -> Bool {
|
||||
guard case let .items(ids) = target else { return false }
|
||||
let namesALane = snapshot.lanes.contains { ids.contains($0.id) }
|
||||
let namesACard = snapshot.lanes.contains { lane in
|
||||
lane.cards.contains { ids.contains($0.id) }
|
||||
}
|
||||
return namesALane && namesACard
|
||||
}
|
||||
|
||||
/// Writes a style gesture — the **one** commit point every anchor shares (03-board-ui.md §
|
||||
/// Styling ▸ Controls: "One component, one behavior, three anchors"), and the quick-style recents
|
||||
/// row with them.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user