One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
57 lines
2.9 KiB
Swift
57 lines
2.9 KiB
Swift
import AppKit
|
|
|
|
/// The `icon` field's rendering rule: **a name that resolves is drawn, anything else falls back to
|
|
/// the level's default** (03-board-ui.md § Styling ▸ Capabilities, "`icon`: SF Symbol per item with
|
|
/// per-level defaults").
|
|
///
|
|
/// ### Lenient, never an error
|
|
///
|
|
/// `icon` is a lenient field (01-storage-format.md § Frontmatter): a typo in a hand-written name is
|
|
/// not a load failure, not a warning, and not an empty box — it renders as the default and the
|
|
/// author's bytes are left exactly as written until they change them. That mirrors the read side's
|
|
/// treatment of `width`, and it is what makes "any other SF Symbol name works written by hand"
|
|
/// (03 § Controls) safe to promise: the app's curated grid is a convenience, the file is the escape
|
|
/// hatch, and a name the running OS does not happen to have simply degrades.
|
|
///
|
|
/// `NSImage(systemSymbolName:)` is the only honest test — the symbol set is the *OS's*, and it grows
|
|
/// between releases, so a hard-coded allow-list would be wrong the day after it was written.
|
|
enum ItemSymbol {
|
|
|
|
/// The per-level defaults, verbatim from 03-board-ui.md § Styling ▸ Capabilities.
|
|
static let board = "rectangle.split.3x1"
|
|
static let lane = "square.stack"
|
|
static let card = "doc.text"
|
|
|
|
/// `field`'s symbol name if it names a symbol this system can draw, `fallback` otherwise.
|
|
///
|
|
/// Handles all three `FieldValue` shapes the same way, which is the point: a missing key, a
|
|
/// malformed one (a sequence where a scalar belongs), and a valid-but-unknown name are one
|
|
/// case to the renderer — *there is no symbol to draw, so draw the default*.
|
|
static func name(_ field: FieldValue<String>, fallback: String) -> String {
|
|
guard let name = field.value, exists(name) else { return fallback }
|
|
return name
|
|
}
|
|
|
|
/// The default for a level — the style editor's symbol grid needs the three defaults as a
|
|
/// function rather than as three constants, because its leading well is "the level's default
|
|
/// symbol" and the editor is one component serving all three (03-board-ui.md § Styling ▸
|
|
/// Controls).
|
|
static func `default`(for level: StyleLevel) -> String {
|
|
switch level {
|
|
case .board: board
|
|
case .lane: lane
|
|
case .card: card
|
|
}
|
|
}
|
|
|
|
/// Whether the running system can draw `name` as an SF Symbol.
|
|
///
|
|
/// Uncached deliberately. The lookup is a bundle-backed symbol resolution that AppKit itself
|
|
/// caches, it runs once per lane per render pass, and a cache here would be one more piece of
|
|
/// main-actor state to keep honest across the OS's own symbol availability. If a profile ever
|
|
/// says otherwise, this one function is where the memo goes.
|
|
static func exists(_ name: String) -> Bool {
|
|
!name.isEmpty && NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil
|
|
}
|
|
}
|