A reusable symbol picker — the board's glyph joins its name in the info popover

SymbolPicker: one well at rest, a 6x6 curated grid in a popover (leading
well = the level default, clearing the key), and an optional search over
the OS's full symbol inventory read from CoreGlyphs metadata. Geometry
font-derived off StyleEditorLayout's base, grid enlarged by a deliberate
1.3x. Wired inline with the rename field in the board info popover
through the StyleCommand funnel; the Title header retires. Curated set
and the search's standing vs 03's full-browser refusal await ratification.
This commit is contained in:
2026-08-06 21:18:35 -04:00
parent b1ea97c03e
commit 73698cd77b
3 changed files with 575 additions and 2 deletions
+20 -1
View File
@@ -309,9 +309,28 @@ struct BoardInfoView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Title")
HStack(spacing: 6) {
// The board's own icon, inline with its name the same field the embedded style
// editor's symbol section below writes, offered here too since a board's identity
// is its name *and* its glyph together (03-board-ui.md § Styling Controls). No
// `undo:` the board popover has none of its own, so this reaches the board's
// stack exactly as the embedded editor's writes do.
SymbolPicker(
current: store.snapshot.icon.value,
fallback: ItemSymbol.board,
onSelect: { name in
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
}
)
.disabled(!store.acceptsBoardMutations)
BoardRenameField(store: store)
}
}
.padding(inset)
Divider()
+467
View File
@@ -0,0 +1,467 @@
import Foundation
import SwiftUI
/// **A reusable SF Symbol picker** a single well showing the resolved symbol, opening a curated
/// grid with a search escape hatch (03-board-ui.md § Styling Controls: "its leading well is the
/// level's default symbol and removes the `icon` key Any other SF Symbol name works written by
/// hand No full-browser escape hatch in-app; the raw file is the escape hatch"). `StyleEditor.swift`
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three
/// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a
/// control that only ever needs one glyph a saved search, a smart filter, a future per-item
/// affordance is not forced to carry the style editor's background section or its `BoardStore`
/// coupling to get one.
///
/// ### 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.
///
/// ### The one thing `CuratedSymbols` never needed
///
/// The style editor's curated grid has no search and no full-catalog fallback ("no full-browser
/// escape hatch in-app" is a statement about *that* surface). This picker adds one anyway, because a
/// general-purpose control cannot assume its 36 will always contain what the caller is after a
/// search with nothing to search would just move the dead end from "no matching well" to "no way to
/// look further".
// MARK: - The symbol catalogs
/// The picker's two symbol lists: the curated 36-glyph grid it opens with, and the OS's full
/// 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.
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",
"list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
"paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
"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.
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
/// Where the OS keeps the full SF Symbols inventory read-only system metadata, present on
/// every Mac that ships SF Symbols at all.
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
/// The default path's catalog, loaded once. A `static let` rather than a `lazy var`: the load is
/// synchronous and the result is a plain `[String]` Sendable, immutable once computed so
/// Swift's usual thread-safe one-time global initialization is the whole of the "cache" this
/// needs, with no actor to hang it off.
private static let cachedFullCatalog: [String] = load(bundlePath: defaultBundlePath)
/// Every SF Symbol name the running OS knows, sorted and deduplicated the search grid's source.
///
/// **Not filtered through `ItemSymbol.exists`.** The plist this reads already reflects the
/// running OS's own inventory (it *is* the OS's inventory), and running a few thousand
/// `NSImage(systemSymbolName:)` lookups against it on every search keystroke would be pure cost
/// for an answer the file has already given for free. A curated list is different: it is a
/// hand-written guess that might be stale, and only guesses need checking.
///
/// `bundlePath` defaults to the real system location and is cached there; any other path the
/// test suite's nonexistent one, chiefly reloads (and re-falls-back) on every call, which is
/// the honest cost of asking a question the cache was never built to answer.
static func fullCatalog(bundlePath: String = defaultBundlePath) -> [String] {
bundlePath == defaultBundlePath ? cachedFullCatalog : load(bundlePath: bundlePath)
}
/// The plist read, and its one fallback: a bundle that won't open, a resource that isn't there,
/// or a `"symbols"` key that isn't the dictionary this format has always used all read the same
/// way as "no inventory to read" rather than as three different failure modes to chase. The
/// merged curated set is never empty, so the picker always has *something* to search, even on a
/// system whose metadata this reader cannot make sense of.
private static func load(bundlePath: String) -> [String] {
guard
let bundle = Bundle(path: bundlePath),
let plistPath = bundle.path(forResource: "name_availability", ofType: "plist"),
let data = FileManager.default.contents(atPath: plistPath),
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
let root = plist as? [String: Any],
let symbols = root["symbols"] as? [String: Any]
else {
return Set(defaultSet + CuratedSymbols.all).sorted()
}
return symbols.keys.sorted()
}
/// `symbols` narrowed to the names matching `query` pure, so the AND semantics and the
/// order-preservation are assertable without a picker on screen.
///
/// Whitespace-trimmed first, and an empty result of that is "no query", not "match nothing" a
/// freshly opened search field must show the full catalog, not a blank grid. A non-empty query
/// splits into whitespace-separated tokens, every one of which must appear, case-insensitively,
/// somewhere in the name: `"wrench screw"` finds `wrench.and.screwdriver` the way a Spotlight-style
/// search would, rather than requiring the words adjacent or in order.
static func filter(_ query: String, in symbols: [String]) -> [String] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return symbols }
let tokens = trimmed.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }
return symbols.filter { name in
let lowered = name.lowercased()
return tokens.allSatisfy { lowered.contains($0) }
}
}
}
// MARK: - Geometry
/// The picker's font-derived geometry well side, well spacing, the fixed 6×6 grid, and the
/// popover's own padding following `StyleEditorLayout`'s derivation rather than restating it: the
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's
/// wells and glyphs scale up by `gridScale` a deliberate, user-tuned enlargement (the picker's grid
/// is this popover's whole subject, where the style editor's is one section among several), still
/// anchored to the shared base so the two components move together at every text size. The at-rest
/// button keeps the unscaled side (`restSide`) it sits inline with a text field and matches that
/// field's height, not the grid's. Only the shape wraps a picker's own frame around them six
/// columns fixed (not a
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s
/// sidebar/popover split does), and a total padded width that is fixed for the same reason the
/// style editor's popover frame is: a popover is a window this app sizes, and a resizing one across
/// keystrokes would be distracting rather than helpful.
struct SymbolPickerLayout: Equatable {
static let columns = 6
static let rows = 6
/// The grid's enlargement over the style editor's well size glyphs read at a glance rather
/// than in miniature.
static let gridScale: CGFloat = 1.3
/// The at-rest button's side the unscaled base, matched to the style editor's wells and to
/// the text-field height the button sits beside.
var restSide: CGFloat
/// The glyph's own point size inside a grid well the body size under `gridScale`, since a
/// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
var glyphPointSize: CGFloat
var wellSide: CGFloat
var wellSpacing: CGFloat
/// The gap between the search field and the grid below it one figure rather than a pixel
/// literal, so Dynamic Type moves it with everything else (10-accessibility.md's full-relative-
/// scaling rule).
var searchSpacing: CGFloat
/// The popover's own inset, on all four sides.
var contentPadding: CGFloat
var gridWidth: CGFloat
/// The search grid's scroll cap six rows tall, so a long result list scrolls inside the popover
/// rather than growing it.
var gridHeight: CGFloat
/// The grid's width plus its padding on both sides the popover's fixed width.
var popoverWidth: CGFloat
static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
let baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
let side = (baseSide * gridScale).rounded()
let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
return SymbolPickerLayout(
restSide: baseSide,
glyphPointSize: (bodyPointSize * gridScale).rounded(),
wellSide: side,
wellSpacing: spacing,
searchSpacing: spacing,
contentPadding: padding,
gridWidth: gridWidth,
gridHeight: gridHeight,
popoverWidth: (gridWidth + padding * 2).rounded()
)
}
}
// MARK: - The control
/// A single symbol well that opens a curated grid the reusable primitive `03-board-ui.md`'s
/// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to
/// hand-edit `icon`, but a control any caller can aim at one symbol field without wiring up a
/// `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView` carries for
/// the board's own background+icon editor.
///
/// **View-local state only** the popover's presented flag lives here, its search text lives with
/// the popover content. Nothing about a store, an undo stack, or a target set is known to this type;
/// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding
/// would be.
struct SymbolPicker: View {
/// The committed symbol name, or `nil` for "no override" read alongside `fallback` rather than
/// pre-resolved by the caller, so this view (and only this view) has to know the lenient-render
/// rule (`ItemSymbol.name(_:fallback:)`'s rule, restated for a plain `String?` since a caller here
/// may have no `FieldValue` at all).
let current: String?
/// The level default shown when `current` is absent or unresolvable, and the grid's leading well.
let fallback: String
/// The curated grid's contents. Defaults to `SymbolPickerCatalog.available` so a caller with no
/// opinion gets the general-purpose set; a caller styling a specific domain (a template chooser,
/// say) can supply its own.
var symbols: [String] = SymbolPickerCatalog.available
/// Whether the popover offers the search field and full-catalog fallback at all. `false` collapses
/// the picker to the curated grid alone a caller with no use for the OS's whole inventory
/// (a fixed small vocabulary) is not forced to carry the search chrome anyway.
var searchable: Bool = true
/// The name to set, or `nil` to clear back to the default mirrors `StyleChange`'s `set`/`remove`
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
/// to hand back.
let onSelect: (String?) -> Void
@State private var isPresented = false
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// What the well actually draws `current` if this system can resolve it, `fallback` otherwise.
/// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here
/// because this control's `current` is already a plain optional string by the time it arrives.
private var resolvedName: String {
if let current, ItemSymbol.exists(current) { return current }
return fallback
}
var body: some View {
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
Button {
isPresented = true
} label: {
Image(systemName: resolvedName)
.imageScale(.medium)
.frame(width: layout.restSide, height: layout.restSide)
}
.buttonStyle(.bordered)
.help("Symbol")
.accessibilityLabel("Symbol")
.accessibilityValue(resolvedName)
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
SymbolPickerPopoverContent(
current: current,
fallback: fallback,
symbols: symbols,
searchable: searchable,
layout: layout,
onSelect: { name in
onSelect(name)
isPresented = false
}
)
}
}
}
// MARK: - The popover's content
/// The popover's body: the search field (when `searchable`), and either the curated grid or a
/// live search result never both, since a query and the at-rest curated set answer the same
/// question two different ways.
private struct SymbolPickerPopoverContent: View {
let current: String?
let fallback: String
let symbols: [String]
let searchable: Bool
let layout: SymbolPickerLayout
let onSelect: (String?) -> Void
@State private var query = ""
var body: some View {
VStack(alignment: .leading, spacing: layout.searchSpacing) {
if searchable {
searchField
}
resultBody
}
.padding(layout.contentPadding)
.frame(width: layout.popoverWidth)
}
private var searchField: some View {
TextField("Search Symbols", text: $query)
.textFieldStyle(.roundedBorder)
// **Escape steps outward one layer per press** (`BoardRenameField`'s idiom, the app's
// standing Escape grammar): a non-empty query clears itself and keeps the popover open,
// an empty one lets the press through to the popover's own dismissal.
.onKeyPress(.escape) {
guard !query.isEmpty else { return .ignored }
query = ""
return .handled
}
}
@ViewBuilder
private var resultBody: some View {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
SymbolWellGrid(wells: curatedWells, layout: layout) { well in
onSelect(well.isDefault ? nil : well.name)
}
} else {
let matches = SymbolPickerCatalog.filter(query, in: SymbolPickerCatalog.fullCatalog())
if matches.isEmpty {
Text("No matches")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, layout.wellSpacing)
} else {
ScrollView(.vertical) {
SymbolWellGrid(wells: matchWells(matches), layout: layout) { well in
onSelect(well.name)
}
}
.frame(height: layout.gridHeight)
}
}
}
/// The at-rest grid: the leading default well, then up to 35 more from `symbols` 03-board-ui.md
/// § Styling Controls' "leading well is the level's default symbol" rule, restated for this
/// control's plain-optional `current`/`fallback` pair.
///
/// `fallback` is dropped from the trailing set if present, so the default is never drawn twice
/// which is also why the trailing set is 35 rather than 36: the two together fill the 6×6 grid
/// exactly when `fallback` was one of `symbols` to begin with (as it is for the card level, whose
/// default `doc.text` sits inside `SymbolPickerCatalog.defaultSet`), and fall one well short of
/// full when it wasn't (board and lane) a quieter outcome than a grid that overflows its own
/// 6×6 cap.
private var curatedWells: [SymbolPickerWell] {
let isDefaultSelected = current.map { !ItemSymbol.exists($0) } ?? true
var wells = [SymbolPickerWell(
id: 0,
name: fallback,
label: "Default (\(fallback))",
isSelected: isDefaultSelected,
isDefault: true
)]
let trailing = symbols.filter { $0 != fallback }
for (index, name) in trailing.prefix(SymbolPickerLayout.columns * SymbolPickerLayout.rows - 1).enumerated() {
wells.append(SymbolPickerWell(
id: index + 1,
name: name,
label: name,
isSelected: current == name,
isDefault: false
))
}
return wells
}
private func matchWells(_ matches: [String]) -> [SymbolPickerWell] {
matches.enumerated().map { index, name in
SymbolPickerWell(id: index, name: name, label: name, isSelected: current == name, isDefault: false)
}
}
}
// MARK: - Wells
/// One well in either grid: what it draws, what it is called, and whether it is the leading default.
private struct SymbolPickerWell: Identifiable {
let id: Int
let name: String
let label: String
let isSelected: Bool
/// Whether this is the leading "no override" well drawn quieter (`StyleWellFace`'s
/// `.defaultSymbol` treatment) so "no symbol set" and "this symbol set" read differently at a
/// glance, and selected by `onSelect(nil)` rather than `onSelect(well.name)`.
let isDefault: Bool
}
/// One well's face: the glyph, tinted by whether it is the default. `StyleEditor.swift`'s
/// `StyleWellFace` already draws this exact shape, but as a `private` type it is not this file's to
/// reach a small sibling here, rather than widening that file's access for one caller outside it.
private struct SymbolWellFace: View {
let name: String
let isDefault: Bool
let size: CGFloat
/// The glyph's font size set explicitly (`SymbolPickerLayout.glyphPointSize`) rather than
/// inherited, since the grid's enlargement lives in the font, not the frame.
let glyphPointSize: CGFloat
var body: some View {
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
.font(.system(size: glyphPointSize))
.foregroundStyle(isDefault ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary))
.frame(width: size, height: size)
}
}
/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid `StyleWellGrid`'s pattern,
/// mirrored rather than shared for the same reason `SymbolWellFace` is its own type. The duplication
/// is small (one `move(_:)` handler) and the alternative exporting `StyleWellGrid` generically out
/// of the style editor would widen a file whose whole point is staying anchor-agnostic to a second,
/// unrelated caller.
private struct SymbolWellGrid: View {
let wells: [SymbolPickerWell]
let layout: SymbolPickerLayout
let onSelect: (SymbolPickerWell) -> Void
@FocusState private var focused: Int?
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
count: SymbolPickerLayout.columns
),
spacing: layout.wellSpacing
) {
ForEach(wells) { well in
Button {
onSelect(well)
} label: {
SymbolWellFace(
name: well.name,
isDefault: well.isDefault,
size: layout.wellSide,
glyphPointSize: layout.glyphPointSize
)
.overlay(selectionRing(well.isSelected))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.focusable()
.focused($focused, equals: well.id)
.help(well.label)
.accessibilityLabel(well.label)
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
}
}
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
move(press.key)
}
}
private func selectionRing(_ isSelected: Bool) -> some View {
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
.strokeBorder(
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
)
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
}
/// One step per press, clamped at the ends `StyleWellGrid.move(_:)`'s rule, restated for this
/// grid's own fixed column count.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -SymbolPickerLayout.columns
case .downArrow: delta = SymbolPickerLayout.columns
default: return .ignored
}
let current = focused ?? 0
let next = min(max(0, current + delta), wells.count - 1)
focused = next
return .handled
}
}
+87
View File
@@ -0,0 +1,87 @@
import Testing
@testable import Kanban
/// **The symbol picker's pure seams** (03-board-ui.md § Styling Controls, the general-purpose
/// picker `SymbolPicker.swift` builds beside the style editor's own curated grid): the curated
/// default set, the full-catalog loader and its cache, and the search filter's AND semantics.
/// SwiftUI rendering the well grid, the popover's arrow-key navigation is deliberately untested,
/// exactly as `StyleEditor.swift`'s own wells are.
@Suite("SymbolPicker ▸ the curated default set")
struct SymbolPickerCatalogDefaultSetTests {
@Test("Exactly 36 entries, all unique")
func shape() {
#expect(SymbolPickerCatalog.defaultSet.count == 36)
#expect(Set(SymbolPickerCatalog.defaultSet).count == SymbolPickerCatalog.defaultSet.count)
}
@Test("Every entry is one this system can actually draw — pins the list against typos")
func everyNameResolves() {
let missing = SymbolPickerCatalog.defaultSet.filter { !ItemSymbol.exists($0) }
#expect(missing.isEmpty, "unknown SF Symbol names: \(missing)")
#expect(SymbolPickerCatalog.available.count == SymbolPickerCatalog.defaultSet.count)
}
}
@Suite("SymbolPicker ▸ filter")
struct SymbolPickerFilterTests {
@Test("An empty or whitespace-only query returns the input unchanged")
func emptyQueryIsANoOp() {
let symbols = ["star", "flag", "heart"]
#expect(SymbolPickerCatalog.filter("", in: symbols) == symbols)
#expect(SymbolPickerCatalog.filter(" ", in: symbols) == symbols)
#expect(SymbolPickerCatalog.filter("\t\n", in: symbols) == symbols)
}
@Test("A single token matches case-insensitively, as a substring")
func singleTokenSubstring() {
let symbols = ["star", "star.fill", "flag", "flag.checkered"]
#expect(SymbolPickerCatalog.filter("star", in: symbols) == ["star", "star.fill"])
#expect(SymbolPickerCatalog.filter("STAR", in: symbols) == ["star", "star.fill"])
#expect(SymbolPickerCatalog.filter("Fla", in: symbols) == ["flag", "flag.checkered"])
}
@Test("Multiple tokens are an AND — every token must appear somewhere in the name")
func multiTokenIsAnAnd() {
let symbols = ["wrench.and.screwdriver", "screwdriver", "wrench"]
#expect(SymbolPickerCatalog.filter("wrench screw", in: symbols) == ["wrench.and.screwdriver"])
#expect(SymbolPickerCatalog.filter("screw wrench", in: symbols) == ["wrench.and.screwdriver"])
}
@Test("Input order is preserved")
func orderPreserved() {
let symbols = ["zebra.star", "apple.star", "mango.star"]
#expect(SymbolPickerCatalog.filter("star", in: symbols) == symbols)
}
@Test("No match returns an empty array")
func noMatchIsEmpty() {
#expect(SymbolPickerCatalog.filter("xyzzy-nonexistent", in: ["star", "flag"]).isEmpty)
}
}
@Suite("SymbolPicker ▸ the full catalog")
struct SymbolPickerFullCatalogTests {
@Test("The default path returns a sorted, unique, non-empty list containing well-known names")
func defaultPathLoads() {
let catalog = SymbolPickerCatalog.fullCatalog()
#expect(!catalog.isEmpty)
#expect(catalog == catalog.sorted())
#expect(Set(catalog).count == catalog.count)
#expect(catalog.contains("star"))
#expect(catalog.contains("folder"))
}
@Test("Two calls against the default path return identical results — the cache is coherent")
func cacheIsCoherent() {
#expect(SymbolPickerCatalog.fullCatalog() == SymbolPickerCatalog.fullCatalog())
}
@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()
#expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected)
}
}