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
+80 -13
View File
@@ -275,24 +275,63 @@ struct StyleRecentsTests {
// MARK: - The curated grid
@Suite("Styling ▸ the curated symbol grid")
/// **2026-08-09: one flat ~65-entry list split into three level-specific sets** (`boards`, `lanes`,
/// `cards`) plus `combined`, their stable-order union this suite pins the new shape per set rather
/// than the old single-list bounds.
@Suite("Styling ▸ the curated symbol sets")
struct CuratedSymbolsTests {
@Test("Roughly five dozen symbols, no duplicates")
func shape() {
#expect(CuratedSymbols.all.count >= 55)
#expect(CuratedSymbols.all.count <= 72)
#expect(Set(CuratedSymbols.all).count == CuratedSymbols.all.count)
@Test("Each level's set is in the ~3040 range, no duplicates", arguments: [
CuratedSymbols.boards, CuratedSymbols.lanes, CuratedSymbols.cards,
])
func shape(_ set: [String]) {
#expect(set.count >= 30)
#expect(set.count <= 40)
#expect(Set(set).count == set.count)
}
@Test("Every curated name is one this system can actually draw")
func everyNameResolves() {
// A curated list is a convenience, never a claim about the running OS `available` filters
// it but a name that fails here on the *deployment target* is a typo, not an inventory
// difference, and the grid would show an empty well.
let missing = CuratedSymbols.all.filter { !ItemSymbol.exists($0) }
@Test("Every curated name, at every level, is one this system can actually draw", arguments: [
CuratedSymbols.boards, CuratedSymbols.lanes, CuratedSymbols.cards,
])
func everyNameResolves(_ set: [String]) {
// A curated list is a convenience, never a claim about the running OS `available(for:)`
// filters it but a name that fails here on the *deployment target* is a typo, not an
// inventory difference, and the grid would show an empty well.
let missing = set.filter { !ItemSymbol.exists($0) }
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
#expect(CuratedSymbols.available.count == CuratedSymbols.all.count)
}
@Test("available(for:) reads the matching level's set, filtered")
func availableForLevelMatchesTheSet() {
#expect(CuratedSymbols.available(for: .board) == CuratedSymbols.availableBoards)
#expect(CuratedSymbols.available(for: .lane) == CuratedSymbols.availableLanes)
#expect(CuratedSymbols.available(for: .card) == CuratedSymbols.availableCards)
// Nothing here fails to resolve, so filtering is a no-op on the deployment target the same
// claim `everyNameResolves` makes, restated for the filtered accessors the app actually calls.
#expect(CuratedSymbols.availableBoards.count == CuratedSymbols.boards.count)
#expect(CuratedSymbols.availableLanes.count == CuratedSymbols.lanes.count)
#expect(CuratedSymbols.availableCards.count == CuratedSymbols.cards.count)
}
@Test("Overlap between sets is allowed — a status marker fits at every level")
func genuineOverlapIsAllowed() {
for name in ["flag", "star"] {
#expect(CuratedSymbols.boards.contains(name))
#expect(CuratedSymbols.lanes.contains(name))
#expect(CuratedSymbols.cards.contains(name))
}
}
@Test("combined is the three sets' union, stable order, deduplicated")
func combinedIsTheStableUnion() {
let combined = CuratedSymbols.combined
#expect(Set(combined).count == combined.count, "no duplicate across the three sets")
#expect(Set(combined) == Set(CuratedSymbols.boards + CuratedSymbols.lanes + CuratedSymbols.cards))
// Stable order: boards' own members appear in `combined` in the same relative order boards
// itself lists them, and the first new (non-boards) name is the first lanes-only entry.
let boardsOnly = combined.filter(CuratedSymbols.boards.contains)
#expect(boardsOnly == CuratedSymbols.boards)
#expect(CuratedSymbols.availableCombined.count == combined.filter(ItemSymbol.exists).count)
}
@Test("The level defaults are drawable too — they are the grid's leading well")
@@ -301,4 +340,32 @@ struct CuratedSymbolsTests {
#expect(ItemSymbol.exists(ItemSymbol.lane))
#expect(ItemSymbol.exists(ItemSymbol.card))
}
// MARK: - Context wiring
/// **The style editor's grid content**: one level's set for a homogeneous target, the combined set
/// for one that spans levels `StyleEditorView.curatedSymbols`' whole decision, pulled out as
/// `CuratedSymbols.availableForStyleEditor` so it is testable without a view
/// (`BoardStore.styleTargetSpansLevels` supplies the second argument from a real target in
/// `StyleWriteTests.spansLevelsDetectsAGenuineMix`).
@Test("The style editor's grid: a homogeneous target reads its own level's set, a spanning target reads the combined set")
func styleEditorGridChoosesByLevelOrSpan() {
#expect(CuratedSymbols.availableForStyleEditor(level: .board, spansLevels: false) == CuratedSymbols.availableBoards)
#expect(CuratedSymbols.availableForStyleEditor(level: .lane, spansLevels: false) == CuratedSymbols.availableLanes)
#expect(CuratedSymbols.availableForStyleEditor(level: .card, spansLevels: false) == CuratedSymbols.availableCards)
// `spansLevels` wins regardless of which level `styleLevel(of:)` collapsed to a spanning
// target must never read as a single level's narrower set.
#expect(CuratedSymbols.availableForStyleEditor(level: .card, spansLevels: true) == CuratedSymbols.availableCombined)
#expect(CuratedSymbols.availableForStyleEditor(level: .lane, spansLevels: true) == CuratedSymbols.availableCombined)
}
/// **`BoardInfoPopover`'s board glyph** passes a fixed board-level set rather than asking
/// `styleLevel` at all there is no target to ask, since this anchor only ever styles the board.
/// The tripwire is the same one `CardSidebarTests.theCuratedSetPassedToTheSidebarPickerDoesNotShrinkIt`
/// pins for the card anchor: the set stays wider than the picker's own domain-agnostic default, so
/// the anchor's explicit `symbols:` argument can never quietly become a no-op.
@Test("The board glyph's set stays wider than the picker's own default")
func boardAnchorSetStaysWiderThanThePickerDefault() {
#expect(CuratedSymbols.availableBoards.count > SymbolPickerCatalog.defaultSet.count)
}
}