diff --git a/DESIGN/03-board-ui.md b/DESIGN/03-board-ui.md index dc452a4..76408c1 100644 --- a/DESIGN/03-board-ui.md +++ b/DESIGN/03-board-ui.md @@ -43,10 +43,10 @@ Toolbars are **pure enhancement**: every function they host already has a menu i ### Controls (settled) -One **style editor** component — a background palette grid and a curated symbol grid — presented as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. **The card window sidebar now styles through the compact `SymbolPicker` instead of this component** (2026-08-06 anchor-ownership rule for the background half; the symbol half followed the same way on 2026-08-08): the sidebar pairs the **color combo** (below) — standing in for the background half, the narrow context the combo was built for — with the picker's own well and popover-hosted `CuratedSymbols` grid, so this component now has exactly one anchor left, the Style… popover, which alone carries both grids in full. (The board popover was briefly a third anchor — the background half embedded in its Theme tab — until the 2026-08-07 Theme rework made that tab preset-only; Style… with nothing selected is the board's manual grid now, and the popover's symbol picker beside the rename field still owns the board glyph — the same `SymbolPicker` the card sidebar now uses.) +One **style editor** component — a background palette grid and a curated symbol grid — presented as a **popover** opened by Style… from a card/lane context menu or the menu bar (Board ▸ Style…, ⌥⌘S — 11-command-nexus.md; selection-aware: it styles the selected cards or lane, and with nothing selected, the board). One component, one behavior — replacing the pathfinder's swatch-row-plus-Style-popover split, whose functions were right and whose form wasn't. **The card window sidebar now styles through the compact `SymbolPicker` instead of this component** (2026-08-06 anchor-ownership rule for the background half; the symbol half followed the same way on 2026-08-08): the sidebar pairs the **color combo** (below) — standing in for the background half, the narrow context the combo was built for — with the picker's own well and popover-hosted grid, fed `CuratedSymbols`' card-level set, so this component now has exactly one anchor left, the Style… popover, which alone carries both grids in full. (The board popover was briefly a third anchor — the background half embedded in its Theme tab — until the 2026-08-07 Theme rework made that tab preset-only; Style… with nothing selected is the board's manual grid now, and the popover's symbol picker beside the rename field still owns the board glyph — the same `SymbolPicker` the card sidebar now uses.) - **Curated-first, panel-backed** (re-ratified 2026-08-06, reversing 2026-07-29's palette-only ruling): the background grid offers the 12 palette colors — every one AA-verified through one code path (ratified 2026-07-29: palette names route through the same runtime ink-selection seam as hand-written hex — the appearance flip picks the readable label vocabulary — and PaletteContrastTests pins that the chosen ink meets AA in both appearances for all 12 backgrounds, so palette drift can never silently break it) — plus a leading **None** well that removes the `background` key. Beside the grid the vocabulary now has a second, compact form: the **color combo** — a swatch-faced popup listing None, the role's twelve, the current off-palette value verbatim when there is one, and **Other…**, which opens the system Colors panel. The panel is the in-app escape hatch the 2026-07-29 ruling withheld: a pick landing exactly on a palette color stores the *name* (so a re-pick never drifts to a hex spelling), anything else stores the hex — the same unlimited vocabulary hand-editing always had, now pickable. An arbitrary pick changes no contrast story (it lands on the identical runtime ink computation hand-written hex already gets — 10-accessibility.md), and the quick-style recents stay palette-vocabulary: a panel pick never enters them. -- **Curated symbol grid**: a hand-picked set (roughly five dozen kanban-relevant SF Symbols); its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. (Symbols keep this stance deliberately — the 2026-08-06 color-panel reversal above is colors only: the system offers a Colors panel worth deferring to, and no symbol browser of equal standing.) +- **Curated symbol grid**: three hand-picked, level-specific sets (~30–40 SF Symbols each, one for boards, one for lanes, one for cards — split 2026-08-09 from an original single ~65-entry list); the grid draws from whichever set matches the target's level, or the three combined for a target spanning more than one (unreachable today under 04-interactions.md's cards-XOR-lanes rule, kept as a real fallback rather than an assumption). Its leading well is the level's default symbol and removes the `icon` key. Any other SF Symbol name works written by hand — named symbols the running OS knows, that is: inventories grow per macOS release, so a newer-OS name renders the level default on an older Mac, value preserved on disk — the palette stance again. No full-browser escape hatch in-app; the raw file is the escape hatch. (Symbols keep this stance deliberately — the 2026-08-06 color-panel reversal above is colors only: the system offers a Colors panel worth deferring to, and no symbol browser of equal standing.) - **Off-palette values display leniently**: a hand-written hex background or uncurated symbol shows as the current value in the editor (labeled verbatim, outside the grids); choosing any well replaces it. - **Batch edits**: a multi-selection shows per-dimension mixed state (no well selected, "—" where a value would read); choosing a well applies to the whole selection — one gesture, one commit on git boards. - **The Style… popover tracks its target set live and dismisses when it empties** (settled): its target is the selection, re-resolved across reloads by 02-architecture.md's UUID rule — a member that vanishes or flips liveness leaves the set and the mixed-state display recomputes; a set emptied by a foreign reload dismisses the popover (the inline-rename discard applied here) — it never silently retargets to the board, and nothing writes into a vanished folder (a member moved to the trash leaves the set like any other departure). **The read-only lock instead disables its wells in place** (settled): a popover open when the lock lands stays open, content disabled — the banner names why, and the lock never yanks a surface (the Edit buffer's keeps-its-place posture). The embedded anchors need no rule of their own: the card sidebar dismisses with its card's window, and the board popover's target is the board itself. diff --git a/DESIGN/05-card-window.md b/DESIGN/05-card-window.md index b709fe7..cf601a5 100644 --- a/DESIGN/05-card-window.md +++ b/DESIGN/05-card-window.md @@ -63,7 +63,7 @@ Stacked sections under small-caps headers, in this order; quiet rows, read-optim ### Style -The card-level styling home: the **Background color combo** over the compact **`SymbolPicker`** (03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership rule for the background half, extended to the symbol half on 2026-08-08) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the style editor's background half here, panel escape hatch included, while the picker's own well and popover-hosted grid — fed `CuratedSymbols`, not the picker's smaller general-purpose default, so a card's curated vocabulary doesn't shrink — stands in for the symbol half the same way; the full style editor's grid remains the surface only at Style…. Card styling is discoverable here without a context menu; the picker is the same reusable control the board popover's glyph picker beside the rename field uses. +The card-level styling home: the **Background color combo** over the compact **`SymbolPicker`** (03-board-ui.md ▸ Styling ▸ Controls, its 2026-08-06 anchor-ownership rule for the background half, extended to the symbol half on 2026-08-08) — the sidebar is exactly the narrow context the combo was built for, so it stands in for the style editor's background half here, panel escape hatch included, while the picker's own well and popover-hosted grid — fed `CuratedSymbols`' card-level set (one of three level-specific sets, 2026-08-09), not the picker's smaller general-purpose default, so a card's curated vocabulary doesn't shrink — stands in for the symbol half the same way; the full style editor's grid remains the surface only at Style…. Card styling is discoverable here without a context menu; the picker is the same reusable control the board popover's glyph picker beside the rename field uses. ### Details — unknown frontmatter keys diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 57fc45a..d1f0dd3 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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. diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index b3d5fc4..bbc4b46 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -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, diff --git a/Kanban/UI/Card/CardSidebarSections.swift b/Kanban/UI/Card/CardSidebarSections.swift index 50196d5..4254adc 100644 --- a/Kanban/UI/Card/CardSidebarSections.swift +++ b/Kanban/UI/Card/CardSidebarSections.swift @@ -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) } diff --git a/Kanban/UI/StyleEditor.swift b/Kanban/UI/StyleEditor.swift index 711ecdf..52e0cda 100644 --- a/Kanban/UI/StyleEditor.swift +++ b/Kanban/UI/StyleEditor.swift @@ -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() + 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), diff --git a/Kanban/UI/SymbolPicker.swift b/Kanban/UI/SymbolPicker.swift index 38ac419..e2eaffc 100644 --- a/Kanban/UI/SymbolPicker.swift +++ b/Kanban/UI/SymbolPicker.swift @@ -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() } diff --git a/KanbanTests/CardSidebarTests.swift b/KanbanTests/CardSidebarTests.swift index 84f47fd..8041cd6 100644 --- a/KanbanTests/CardSidebarTests.swift +++ b/KanbanTests/CardSidebarTests.swift @@ -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)) } } diff --git a/KanbanTests/StyleModelTests.swift b/KanbanTests/StyleModelTests.swift index 11f09d9..17e47c3 100644 --- a/KanbanTests/StyleModelTests.swift +++ b/KanbanTests/StyleModelTests.swift @@ -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) + } } diff --git a/KanbanTests/StyleWriteTests.swift b/KanbanTests/StyleWriteTests.swift index 7b771e8..9dc5cc5 100644 --- a/KanbanTests/StyleWriteTests.swift +++ b/KanbanTests/StyleWriteTests.swift @@ -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) + } } diff --git a/KanbanTests/SymbolPickerTests.swift b/KanbanTests/SymbolPickerTests.swift index 328f9fa..ec96568 100644 --- a/KanbanTests/SymbolPickerTests.swift +++ b/KanbanTests/SymbolPickerTests.swift @@ -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) } }