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:
@@ -12,8 +12,9 @@ import Testing
|
||||
/// can notice when a key goes missing, arrives out of order, or renders as a YAML indicator. The
|
||||
/// only check on it is a test that reads a file and says what the rows must be.
|
||||
/// - **Style** pins its own target — this window's card, always, never the selection-aware target the
|
||||
/// Style… popover carries — and (2026-08-08) hands the compact `SymbolPicker` the kanban-relevant
|
||||
/// `CuratedSymbols` set rather than the picker's own smaller general-purpose default. A target wired
|
||||
/// Style… popover carries — and (2026-08-08) hands the compact `SymbolPicker` the card-flavored
|
||||
/// `CuratedSymbols.availableCards` set (one of the three level-specific sets `CuratedSymbols` split
|
||||
/// into on 2026-08-09) rather than the picker's own smaller general-purpose default. A target wired
|
||||
/// to the wrong card, or a curated list quietly narrowed back to the picker's default, would look
|
||||
/// completely normal until it restyled the wrong card or hid a symbol the vocabulary used to offer.
|
||||
/// - **The sidebar's geometry** is a grid of fixed-size wells in a column sized from font metrics:
|
||||
@@ -334,19 +335,20 @@ struct CardStyleAnchorTests {
|
||||
#expect(CardStyleSection.target(forCard: lower) == CardStyleSection.target(forCard: upper))
|
||||
}
|
||||
|
||||
/// **The sidebar's `SymbolPicker` is fed `CuratedSymbols.available`, not the picker's own
|
||||
/// **The sidebar's `SymbolPicker` is fed `CuratedSymbols.availableCards`, not the picker's own
|
||||
/// smaller default** — the design ruling behind the 2026-08-08 grid-to-picker swap ("so a card's
|
||||
/// curated vocabulary doesn't shrink"). Not a test *of* the view (this file's own stance), but of
|
||||
/// the two catalogs the ruling is a claim about: if `CuratedSymbols` ever shrank to
|
||||
/// `SymbolPickerCatalog.defaultSet`'s size or below, the sidebar's explicit `symbols:` argument
|
||||
/// would have quietly become a no-op, and this is the tripwire for that.
|
||||
/// curated vocabulary doesn't shrink"), restated for the 2026-08-09 three-set split. Not a test
|
||||
/// *of* the view (this file's own stance), but of the two catalogs the ruling is a claim about: if
|
||||
/// `CuratedSymbols.cards` ever shrank to `SymbolPickerCatalog.defaultSet`'s size or below, the
|
||||
/// sidebar's explicit `symbols:` argument would have quietly become a no-op, and this is the
|
||||
/// tripwire for that.
|
||||
@Test("The card sidebar's curated symbol vocabulary is at least as wide as the picker's own default")
|
||||
func theCuratedSetPassedToTheSidebarPickerDoesNotShrinkIt() {
|
||||
#expect(CuratedSymbols.available.count > SymbolPickerCatalog.defaultSet.count)
|
||||
#expect(CuratedSymbols.availableCards.count > SymbolPickerCatalog.defaultSet.count)
|
||||
// And it is still the same *available* set every other curated surface reads — filtered
|
||||
// through `ItemSymbol.exists` (`CuratedSymbols.available`'s own doc comment), never the
|
||||
// unfiltered `all`, so the sidebar never offers a well this Mac cannot draw.
|
||||
#expect(CuratedSymbols.available.allSatisfy(ItemSymbol.exists))
|
||||
// through `ItemSymbol.exists` (`CuratedSymbols.availableCards`'s own doc comment), never the
|
||||
// unfiltered `cards`, so the sidebar never offers a well this Mac cannot draw.
|
||||
#expect(CuratedSymbols.availableCards.allSatisfy(ItemSymbol.exists))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ~30–40 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,4 +407,21 @@ struct StyleWriteTests {
|
||||
#expect(ItemSymbol.default(for: store.styleLevel(of: .items([lane1]))) == ItemSymbol.lane)
|
||||
#expect(ItemSymbol.default(for: .board) == ItemSymbol.board)
|
||||
}
|
||||
|
||||
/// **The curated symbol grid's finer question** (`CuratedSymbols.availableForStyleEditor`'s own
|
||||
/// doc comment): unlike `styleLevel(of:)`, which collapses a target naming both a lane and a card
|
||||
/// to `.card`, `styleTargetSpansLevels` says so plainly — a target the live UI can never build
|
||||
/// (04-interactions.md's cards-XOR-lanes rule) but that `styleSubjects`' own fixture above
|
||||
/// (`.items([card2, card1, lane1])`) shows is perfectly constructible by hand.
|
||||
@Test("A target naming both a lane and a card is flagged spanning; a homogeneous or board target is not")
|
||||
func spansLevelsDetectsAGenuineMix() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
|
||||
#expect(store.styleTargetSpansLevels(.board) == false)
|
||||
#expect(store.styleTargetSpansLevels(.items([lane1, lane2])) == false)
|
||||
#expect(store.styleTargetSpansLevels(.items([card1, card2])) == false)
|
||||
#expect(store.styleTargetSpansLevels(.items([lane1, card1])) == true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ struct SymbolPickerFullCatalogTests {
|
||||
|
||||
@Test("A nonexistent bundle path falls back to the merged curated set, sorted and unique")
|
||||
func nonexistentPathFallsBack() {
|
||||
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.all).sorted()
|
||||
// `CuratedSymbols.all` was one flat list; 2026-08-09 split it into three level-specific sets
|
||||
// plus `combined`, the union this fallback still reads (`SymbolPicker.swift`'s `load`).
|
||||
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
|
||||
#expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user