The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover

The colour combo was a wide two-zone field with a second door onto the Colors
panel; the symbol picker was a small square button with one. Both now subclass
one `ComboFieldControl`, so they are the same width, height, radius and trigger
by construction: click the face for the standalone picker, click the chevron for
the quick list. The symbol face opens a new floating browser over the OS's own
category, ordering and keyword plists out of CoreGlyphs.bundle — searchable,
categorised, trademark-restricted glyphs withheld.

The palette grows twelve to sixteen per table, filling the hue ring's four
widest gaps with lime, jade, indigo and magenta at each table's own saturation
and brightness. That gives the Style… popover's background grid a third row and
the tint grid its third row of four, and both grids gain an Other… row onto the
system colour picker — which the card sidebar's combo has had all along and the
primary styling surface never did. An arbitrary hex already round-tripped; it is
asserted now, including that an unquoted one is a YAML comment and no value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent ca0328be2e
commit ece33bbf78
17 changed files with 2030 additions and 322 deletions
+9 -2
View File
@@ -20,8 +20,15 @@ import Foundation
/// avoid. /// avoid.
/// ///
/// `set` carries the string that goes to frontmatter verbatim: a kebab-case palette name from the /// `set` carries the string that goes to frontmatter verbatim: a kebab-case palette name from the
/// grid, or an SF Symbol name. Hex never arrives here from the app ("custom hex is not pickable /// grid, an SF Symbol name, or a `#RRGGBB[AA]` hex.
/// in-app but stays fully honored from disk"), though nothing in the type forbids it. ///
/// **Hex does arrive here from the app now**, which this comment used to deny 03-board-ui.md's
/// "custom hex is not pickable in-app but stays fully honored from disk" stopped being true the day
/// `ColorComboView` shipped an **Other** row onto `NSColorPanel.shared`, and the 2026-08-09 rework
/// put that door on the Style popover and the symbol popover's tint row as well. The write path
/// always handled it (`FrontmatterValue.emitScalar` quotes a `#` because YAML would otherwise read it
/// as a comment; `BackgroundField.flowText` quotes unconditionally) and now says so out loud:
/// `CustomColorRoundTripTests` asserts the whole chain.
public enum StyleChange: Sendable, Equatable { public enum StyleChange: Sendable, Equatable {
case keep case keep
case set(String) case set(String)
+6 -4
View File
@@ -701,11 +701,13 @@ enum AgentGuide {
to tint it. to tint it.
- Icon tint palette: `obsidian`, `aluminum`, `soapstone`, `chalk`, - Icon tint palette: `obsidian`, `aluminum`, `soapstone`, `chalk`,
`carnation`, `rich-grapefruit`, `smokey-tangerine`, `fern`, `carnation`, `rich-grapefruit`, `smokey-tangerine`, `rich-lime`,
`light-teal`, `deep-sky-blue`, `pale-violet`, `deep-cool-granite`. `fern`, `light-jade`, `light-teal`, `deep-sky-blue`, `rich-indigo`,
`pale-violet`, `rich-magenta`, `deep-cool-granite`.
- Background palette: `obsidian`, `shale`, `aluminum`, `chalk`, - Background palette: `obsidian`, `shale`, `aluminum`, `chalk`,
`light-cayenne`, `light-mocha`, `smokey-mocha`, `smokey-fern`, `light-cayenne`, `light-mocha`, `smokey-mocha`, `smokey-lime`,
`dark-teal`, `smokey-ocean`, `smokey-rich-eggplant`, `smokey-fern`, `dark-jade`, `dark-teal`, `smokey-ocean`,
`smokey-indigo`, `smokey-rich-eggplant`, `smokey-magenta`,
`intense-cool-shale`. `intense-cool-shale`.
## Git ## Git
+9 -4
View File
@@ -175,10 +175,14 @@ struct CardStyleSection: View {
// MARK: - Symbol // MARK: - Symbol
/// The labeled **Symbol** row, below the background combo `backgroundComboRow`'s own /// The labeled **Symbol** row, below the background combo `backgroundComboRow`'s own
/// inspector-row shape, restated: caption leading, the compact control trailing. Unlike the /// inspector-row shape, restated: caption leading, the combo trailing, **at the same width**.
/// combo, the picker gets no width of its own `SymbolPicker`'s at-rest well is already ///
/// font-derived and small (`SymbolPickerLayout.restSide`), matched to a text field's height, and /// That width used to be the difference between the two rows. The picker was a 20pt bordered
/// stretching it would just be empty frame around a fixed-size button. /// square with one hit zone sitting under a wide two-zone colour combo, and this is the sidebar
/// where the mismatch was most visible two adjacent rows setting two adjacent keys, looking
/// like different kinds of control. Since the 2026-08-09 rework `SymbolPicker` *is* a combo
/// (`ComboField.swift`), so the row hands it the identical `* 0.55` frame and the pair reads as
/// one inspector.
private var symbolRow: some View { private var symbolRow: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("Symbol") Text("Symbol")
@@ -193,6 +197,7 @@ struct CardStyleSection: View {
currentColor: currentIconColor, currentColor: currentIconColor,
onSelectColor: { applyIconColor($0) } onSelectColor: { applyIconColor($0) }
) )
.frame(width: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize) * 0.55)
// The same lock `StyleEditorView`'s whole body disabled under // The same lock `StyleEditorView`'s whole body disabled under
// (`.disabled(!store.acceptsBoardMutations)`, `StyleEditor.swift`) the read-only lock // (`.disabled(!store.acceptsBoardMutations)`, `StyleEditor.swift`) the read-only lock
// and the board's inline-editing rule alike, preserved exactly across the control swap // and the board's inline-editing rule alike, preserved exactly across the control swap
+50 -215
View File
@@ -4,8 +4,8 @@ import SwiftUI
/// A reusable colour-picker combo: a collapsed face split into **two zones**, Xcode's inspector /// A reusable colour-picker combo: a collapsed face split into **two zones**, Xcode's inspector
/// colour combo's own shape a flat swatch of the current value filling almost the whole control, /// colour combo's own shape a flat swatch of the current value filling almost the whole control,
/// and a narrow chevron trigger at the trailing edge. Clicking the swatch opens /// and a narrow chevron trigger at the trailing edge. Clicking the swatch opens
/// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's /// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's own
/// twelve palette colours, an off-palette current value stated verbatim when there is one, and /// palette colours, an off-palette current value stated verbatim when there is one, and
/// **Other**, which hands off to that same panel the swatch and **Other** are two doors onto /// **Other**, which hands off to that same panel the swatch and **Other** are two doors onto
/// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`). /// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`).
/// ///
@@ -27,13 +27,14 @@ import SwiftUI
/// Which of the two palettes a combo offers `Palette.backgrounds` for `background`, /// Which of the two palettes a combo offers `Palette.backgrounds` for `background`,
/// `Palette.foregrounds` for `iconColor`/icon tints. Both tables already answer either field /// `Palette.foregrounds` for `iconColor`/icon tints. Both tables already answer either field
/// (`Palette.nsColor(for:)`), so a combo's *role* is only about which twelve it lists, never about /// (`Palette.nsColor(for:)`), so a combo's *role* is only about which table it lists, never about
/// which values it can resolve. /// which values it can resolve.
enum ColorComboRole: Sendable, Equatable { enum ColorComboRole: Sendable, Equatable {
case background case background
case foreground case foreground
/// The twelve rows this picker offers. /// The rows this picker offers one per entry in the role's own table (sixteen since
/// 2026-08-09; the count is the palette's business, not this type's).
var palette: [PaletteColor] { var palette: [PaletteColor] {
switch self { switch self {
case .background: Palette.backgrounds case .background: Palette.backgrounds
@@ -41,7 +42,7 @@ enum ColorComboRole: Sendable, Equatable {
} }
} }
/// The *other* picker's twelve consulted only to name a foreign palette value in the dynamic /// The *other* picker's table consulted only to name a foreign palette value in the dynamic
/// current-value row (`ColorComboModel.match`). Never offered as a row of this picker's own, /// current-value row (`ColorComboModel.match`). Never offered as a row of this picker's own,
/// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)` /// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)`
/// itself would happily resolve a foreground name. /// itself would happily resolve a foreground name.
@@ -60,7 +61,7 @@ enum ColorComboItem: Equatable {
/// Clears the field the well grid's own leading None well, same removal. /// Clears the field the well grid's own leading None well, same removal.
case none case none
case separator case separator
/// One of `role`'s own twelve, by name. `ColorComboModel.displayName(_:)` is its title; the row /// One of `role`'s own entries, by name. `ColorComboModel.displayName(_:)` is its title; the row
/// is never built with anything the role's own palette doesn't list. /// is never built with anything the role's own palette doesn't list.
case palette(String) case palette(String)
/// The live value's own row present only when the stored value matches neither `.none` nor a /// The live value's own row present only when the stored value matches neither `.none` nor a
@@ -116,7 +117,7 @@ enum ColorComboModel {
/// match, **by name** a panel pick landing exactly on a palette colour selects the name, so /// match, **by name** a panel pick landing exactly on a palette colour selects the name, so
/// picking it again from the panel later re-emits the name rather than drifting to a hex. /// picking it again from the panel later re-emits the name rather than drifting to a hex.
/// - anything else (a foreign palette name, a custom hex, or unresolvable garbage) `.current`, /// - anything else (a foreign palette name, a custom hex, or unresolvable garbage) `.current`,
/// titled with the other picker's display name when `value` is one of *its* twelve, else /// titled with the other picker's display name when `value` is one of *its* own, else
/// `value` itself, uppercased when it looks like hex and left verbatim otherwise. /// `value` itself, uppercased when it looks like hex and left verbatim otherwise.
static func match(role: ColorComboRole, value: String?) -> ColorComboMatch { static func match(role: ColorComboRole, value: String?) -> ColorComboMatch {
guard let value else { return .none } guard let value else { return .none }
@@ -131,7 +132,7 @@ enum ColorComboModel {
} }
/// The dynamic current-value row's title the other table's display name when `value` is one /// The dynamic current-value row's title the other table's display name when `value` is one
/// of its twelve, the raw string otherwise (hex shown uppercase, matching `NSColor. /// of its own, the raw string otherwise (hex shown uppercase, matching `NSColor.
/// paletteHexString`'s own casing so a stored value and a freshly panel-picked one read alike). /// paletteHexString`'s own casing so a stored value and a freshly panel-picked one read alike).
private static func currentTitle(role: ColorComboRole, value: String) -> String { private static func currentTitle(role: ColorComboRole, value: String) -> String {
if let foreign = role.otherPalette.first(where: { $0.name == value }) { if let foreign = role.otherPalette.first(where: { $0.name == value }) {
@@ -143,7 +144,7 @@ enum ColorComboModel {
// MARK: Item list // MARK: Item list
/// The dropdown's full row list and which row is checked, for `role` at `value`: **None**, /// The dropdown's full row list and which row is checked, for `role` at `value`: **None**,
/// separator, the twelve, then only when `match` lands on `.current` that dynamic row, /// separator, the role's own entries, then only when `match` lands on `.current` that dynamic row,
/// separator, **Other**. /// separator, **Other**.
static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu { static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu {
var items: [ColorComboItem] = [.none, .separator] var items: [ColorComboItem] = [.none, .separator]
@@ -199,7 +200,7 @@ struct ColorComboView: NSViewRepresentable {
/// carries it. Never a resolved `Color`: matching needs the string, not what it renders as. /// carries it. Never a resolved `Color`: matching needs the string, not what it renders as.
let value: String? let value: String?
let isEnabled: Bool let isEnabled: Bool
/// One discrete row picked **None**, one of the twelve, or the dynamic current-value row. /// One discrete row picked **None**, one of the palette rows, or the dynamic current-value row.
/// Fired once, synchronously; the call site commits it immediately. /// Fired once, synchronously; the call site commits it immediately.
var onChange: @MainActor (String?) -> Void var onChange: @MainActor (String?) -> Void
/// One tick of a live `NSColorPanel` drag opened from **Other** or the swatch zone fires /// One tick of a live `NSColorPanel` drag opened from **Other** or the swatch zone fires
@@ -217,12 +218,16 @@ struct ColorComboView: NSViewRepresentable {
func makeNSView(context: Context) -> ColorComboControl { func makeNSView(context: Context) -> ColorComboControl {
let control = ColorComboControl(frame: .zero) let control = ColorComboControl(frame: .zero)
// The swatch zone's one job: open the same panel takeover **Other** does. A closure, not a // The two zones' jobs, wired once (`ComboFieldControl`'s grammar): the face opens the same
// target/action pair there is exactly one caller and no `NSMenuItem`-style Objective-C // panel takeover **Other** does, the trigger pops the dropdown. Closures, not target/action
// boundary to cross for it. // pairs there is exactly one caller each and no `NSMenuItem`-style Objective-C boundary to
control.onSwatchClick = { [weak coordinator = context.coordinator] in // cross for them.
control.onFaceClick = { [weak coordinator = context.coordinator] in
coordinator?.openColorPanel() coordinator?.openColorPanel()
} }
control.onTriggerClick = { [weak control] in
control?.popUpMenu()
}
return control return control
} }
@@ -230,6 +235,7 @@ struct ColorComboView: NSViewRepresentable {
context.coordinator.role = role context.coordinator.role = role
context.coordinator.onChange = onChange context.coordinator.onChange = onChange
context.coordinator.onPanelChange = onPanelChange context.coordinator.onPanelChange = onPanelChange
control.metrics = .current
control.isEnabled = isEnabled control.isEnabled = isEnabled
context.coordinator.rebuild(control, value: value) context.coordinator.rebuild(control, value: value)
} }
@@ -276,11 +282,10 @@ struct ColorComboView: NSViewRepresentable {
/// rather than a bullet. /// rather than a bullet.
private static let menuSwatchSize = NSSize(width: 44, height: 14) private static let menuSwatchSize = NSSize(width: 44, height: 14)
/// Whichever coordinator most recently took the shared panel over `NSColorPanel` exposes /// This coordinator's handle on `NSColorPanel.shared` the takeover, its continuous action
/// `setTarget(_:)`/`setAction(_:)` but no matching getter, so "is it still mine to detach" /// and its last-writer-wins detach all live in `SystemColorPanel` now, shared with the two
/// has nowhere to live but here. `weak`, so a coordinator that never got around to detaching /// surfaces that gained an **Other** on 2026-08-09.
/// (a window closed from under it) does not keep the next owner from being collected either. private let colorPanel = SystemColorPanel()
private static weak var currentPanelOwner: Coordinator?
init( init(
role: ColorComboRole, role: ColorComboRole,
@@ -315,16 +320,13 @@ struct ColorComboView: NSViewRepresentable {
} }
control.comboMenu = menu control.comboMenu = menu
control.checkedItem = checkedItem control.checkedItem = checkedItem
control.accessibilityValueText = checkedItem?.title
control.swatchValue = value control.swatchValue = value
} }
/// See `ColorComboView.dismantleNSView(_:coordinator:)`. /// See `ColorComboView.dismantleNSView(_:coordinator:)`.
func detachColorPanel() { func detachColorPanel() {
guard Coordinator.currentPanelOwner === self else { return } colorPanel.detach()
let panel = NSColorPanel.shared
panel.setTarget(nil)
panel.setAction(nil)
Coordinator.currentPanelOwner = nil
} }
private func menuItem(for item: ColorComboItem) -> NSMenuItem { private func menuItem(for item: ColorComboItem) -> NSMenuItem {
@@ -373,51 +375,40 @@ struct ColorComboView: NSViewRepresentable {
onChange(sender.representedObject as? String) onChange(sender.representedObject as? String)
} }
/// Seeds the shared panel with the current resolved colour (black when there isn't one), /// Opens the shared Colors panel seeded with the current resolved colour, streaming what it
/// takes it over "don't fight over the panel if something else takes it later" (this /// picks into `onPanelChange` the palette name when the colour lands exactly on one of
/// view's own doc comment) and asks for continuous updates, which is what makes a drag on /// `role`'s own entries, the hex otherwise. Every rule of that takeover is
/// the panel's own sliders call `changeColor(_:)` on every tick rather than only on release. /// `SystemColorPanel`'s; this is the seed and the destination.
/// ///
/// Two callers, one takeover: the dropdown's own **Other** row (`#selector` target above) /// Two callers, one takeover: the dropdown's own **Other** row (`#selector` target above)
/// and `ColorComboControl`'s swatch-zone click (wired in `ColorComboView.makeNSView`) /// and `ColorComboControl`'s face-zone click (wired in `ColorComboView.makeNSView`)
/// Xcode's own two-zone combo opens the same panel from either half, and this is the one /// Xcode's own two-zone combo opens the same panel from either half, and this is the one
/// place that happens. /// place that happens.
@objc func openColorPanel() { @objc func openColorPanel() {
let panel = NSColorPanel.shared colorPanel.present(
panel.showsAlpha = true seed: currentValue.flatMap(Palette.nsColor(for:)),
panel.color = currentValue.flatMap(Palette.nsColor(for:)) ?? .black matching: role.palette
panel.setTarget(self) ) { [weak self] value in
panel.setAction(#selector(changeColor(_:))) self?.onPanelChange(value)
Coordinator.currentPanelOwner = self }
panel.makeKeyAndOrderFront(nil)
}
/// The panel's own action, continuous while the user drags: normalizes what it picked to
/// this app's stored-value vocabulary and hands it to `onPanelChange` the palette name
/// when the colour lands exactly on one of `role`'s twelve, the hex otherwise. The name-wins
/// rule is the same one `ColorComboModel.match` applies to a value already on disk.
@objc private func changeColor(_ sender: NSColorPanel) {
guard let hex = sender.color.paletteHexString else { return }
onPanelChange(Palette.name(forHex: hex, in: role.palette) ?? hex)
} }
} }
} }
// MARK: - Two-zone NSControl // MARK: - The control
/// The collapsed face: a custom control in Xcode's inspector colour combo's own shape a flat /// The collapsed face: `ComboFieldControl` with a **swatch** in its face zone.
/// swatch filling almost the whole control, and a fixed-width chevron trigger at the trailing edge. ///
/// The swatch zone opens the Colors panel directly (`ColorComboView.Coordinator.openColorPanel()`); /// Everything that makes it a two-zone combo the field, the hairline, the trailing chevron square,
/// the trigger zone pops the dropdown `ColorComboView.Coordinator.rebuild(_:value:)` builds. Neither /// the hit split, the disabled compositing, the geometry is the base class's now (ComboField.swift),
/// zone owns a bezel or a cell of its own everything both draw and hit-test is computed straight /// shared with the symbol combo so the two cannot drift apart. What is left here is the one thing that
/// from `bounds` on every pass, so there is nothing cached here the way the face image the /// is actually about *colour*: the swatch, and the menu the trigger pops.
/// `NSPopUpButton` this replaces used to keep (`swatchValue`'s `didSet` just marks a redraw).
/// ///
/// Plain internal, not `private`/`fileprivate`, even though nothing outside this file constructs one /// Plain internal, not `private`/`fileprivate`, even though nothing outside this file constructs one
/// directly: it is `ColorComboView`'s `NSViewType`, an associated-type witness the compiler requires /// directly: it is `ColorComboView`'s `NSViewType`, an associated-type witness the compiler requires
/// to be at least as visible as `ColorComboView` itself (internal, usable module-wide) same /// to be at least as visible as `ColorComboView` itself (internal, usable module-wide) same
/// reasoning as the un-modified-access `Coordinator` a few lines up. /// reasoning as the un-modified-access `Coordinator` a few lines up.
final class ColorComboControl: NSControl { final class ColorComboControl: ComboFieldControl {
/// The value the swatch zone currently draws a palette name or a hand-written hex, exactly as /// The value the swatch zone currently draws a palette name or a hand-written hex, exactly as
/// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update. /// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update.
@@ -435,100 +426,14 @@ final class ColorComboControl: NSControl {
var comboMenu: NSMenu? var comboMenu: NSMenu?
var checkedItem: NSMenuItem? var checkedItem: NSMenuItem?
/// Fired by a click anywhere in the swatch zone wired once, in `ColorComboView.makeNSView`, to
/// the coordinator's `openColorPanel()`. `@MainActor`, this file's own established convention for
/// a stored closure an AppKit callback fires (`onChange`/`onPanelChange` above), even though this
/// control's own methods are already implicitly MainActor-isolated as an `NSResponder` subclass.
var onSwatchClick: (@MainActor () -> Void)?
override var isEnabled: Bool {
get { super.isEnabled }
set {
super.isEnabled = newValue
needsDisplay = true
}
}
/// About half the old regular `NSPopUpButton`'s height the whole point of this rework. Width
/// is `NSView.noIntrinsicMetric`: this control obeys whatever SwiftUI proposes, exactly as the
/// button it replaces did.
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: 14)
}
/// The fixed-width trigger strip at the trailing edge, full height the geometry this whole
/// control exists to draw: "a flat swatch occupying the control, a chevron trigger at the
/// trailing edge."
private static let triggerWidth: CGFloat = 16
/// The swatch's padding inside its zone, asymmetric and user-tuned: a wider berth at the sides
/// than above and below, so the colour reads as a bar sitting in the field rather than filling
/// it wall to wall. The space comes out of the swatch the control's overall size is untouched.
private static let swatchPaddingH: CGFloat = 7
private static let swatchPaddingV: CGFloat = 4
/// The trigger square's own inset from the zone's height kept at the old ring width rather
/// than the swatch's larger padding, so the indicator stays a legible ~10pt square instead of
/// shrinking with every padding tweak the swatch takes.
private static let triggerInset: CGFloat = 2
private static let cornerRadius: CGFloat = 3
/// The field's own radius a point more than the swatch's, so the two rounded rects run
/// concentric instead of pinching at the corners.
private static let fieldRadius: CGFloat = 4
private var triggerRect: NSRect {
NSRect(x: bounds.maxX - Self.triggerWidth, y: bounds.minY, width: Self.triggerWidth, height: bounds.height)
}
private var swatchZone: NSRect {
NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width - Self.triggerWidth, height: bounds.height)
}
// MARK: Drawing
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
defer { context.restoreGState() }
// A transparency layer, not a flat `setAlpha` around each shape: the swatch's underlay,
// fill and stroke overlap, and drawing each at reduced alpha independently would let the
// stroke double up over the fill beneath it. Compositing the whole disabled face as one
// layer avoids that.
if !isEnabled {
context.setAlpha(0.35)
context.beginTransparencyLayer(auxiliaryInfo: nil)
}
drawField()
drawSwatch()
drawTrigger()
if !isEnabled {
context.endTransparencyLayer()
}
}
/// The control's own field: a bordered, filled rounded rect over the whole bounds, under both
/// zones what makes the swatch and the trigger read as one control rather than two shapes
/// floating beside each other. `controlColor` fill the push-button neutral grey, not
/// `controlBackgroundColor`, whose near-black dark-mode reading drowned the padding ring
/// `separatorColor` hairline, the half-point inset keeping the stroke on whole pixels.
private func drawField() {
let path = NSBezierPath(
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
xRadius: Self.fieldRadius,
yRadius: Self.fieldRadius
)
NSColor.controlColor.setFill()
path.fill()
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
}
/// The colour rect, drawn exactly like `PaletteSwatch.rectImage`: a `textBackgroundColor` /// The colour rect, drawn exactly like `PaletteSwatch.rectImage`: a `textBackgroundColor`
/// underlay so a translucent stored colour composites the same way in light and dark, the /// underlay so a translucent stored colour composites the same way in light and dark, the
/// resolved colour on top, a `separatorColor` hairline stroke last. `nil`/unresolvable value /// resolved colour on top, a `separatorColor` hairline stroke last. `nil`/unresolvable value
/// underlay + stroke only, the same "there is no colour, so show none" rule. /// underlay + stroke only, the same "there is no colour, so show none" rule.
private func drawSwatch() { override func drawFace(in rect: NSRect) {
let inset = swatchZone.insetBy(dx: Self.swatchPaddingH, dy: Self.swatchPaddingV) let inset = rect.insetBy(dx: metrics.facePaddingH, dy: metrics.facePaddingV)
let path = NSBezierPath(roundedRect: inset, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius) guard inset.width > 0, inset.height > 0 else { return }
let path = NSBezierPath(roundedRect: inset, xRadius: metrics.cornerRadius, yRadius: metrics.cornerRadius)
NSColor.textBackgroundColor.setFill() NSColor.textBackgroundColor.setFill()
path.fill() path.fill()
if let swatchValue, let color = Palette.nsColor(for: swatchValue) { if let swatchValue, let color = Palette.nsColor(for: swatchValue) {
@@ -540,81 +445,11 @@ final class ColorComboControl: NSControl {
path.stroke() path.stroke()
} }
/// The trigger: a small vertically-centred rounded **square**, `controlAccentColor`-filled, with
/// a white `chevron.up.chevron.down` centred inside the standard `NSPopUpButton` indicator's
/// own look, redrawn here since this control has no bezel of its own to borrow one from.
private func drawTrigger() {
let side = triggerRect.height - 2 * Self.triggerInset
let square = NSRect(
x: triggerRect.midX - side / 2,
y: triggerRect.midY - side / 2,
width: side,
height: side
)
let path = NSBezierPath(roundedRect: square, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
NSColor.controlAccentColor.setFill()
path.fill()
let config = NSImage.SymbolConfiguration(pointSize: 7, weight: .bold)
.applying(.init(paletteColors: [.white]))
guard let chevron = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
.withSymbolConfiguration(config)
else { return }
let size = chevron.size
chevron.draw(in: NSRect(
x: square.midX - size.width / 2,
y: square.midY - size.height / 2,
width: size.width,
height: size.height
))
}
// MARK: Events
/// Point-in-trigger-zone pops the dropdown; anywhere else in the control fires the swatch click
/// the two-zone split this whole rework exists for. A disabled control answers neither.
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
let point = convert(event.locationInWindow, from: nil)
if triggerRect.contains(point) {
popUpMenu()
} else {
onSwatchClick?()
}
}
override var acceptsFirstResponder: Bool { isEnabled }
/// Space and Return pop the dropdown the one keyboard path into this control. There is
/// currently no keyboard equivalent for the swatch zone's direct panel launch; see this class's
/// own doc comment and the file's top-level report for what a full accessibility pass would add.
override func keyDown(with event: NSEvent) {
guard isEnabled else {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 49, 36, 76: // Space, Return, keypad Enter
popUpMenu()
default:
super.keyDown(with: event)
}
}
/// Standard popup placement: `comboMenu` is asked to land `checkedItem` at the control's own top /// Standard popup placement: `comboMenu` is asked to land `checkedItem` at the control's own top
/// edge, the same non-pulldown anchor `NSPopUpButton` itself uses so the checked row appears /// edge, the same non-pulldown anchor `NSPopUpButton` itself uses so the checked row appears
/// where the control's own face is rather than wherever the pointer happened to be. /// where the control's own face is rather than wherever the pointer happened to be.
private func popUpMenu() { func popUpMenu() {
guard let comboMenu else { return } guard let comboMenu else { return }
comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self) comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self)
} }
// MARK: Accessibility
override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }
/// The checked row's own title "Light Cayenne", "None", a bare hex exactly what the dropdown
/// itself would show ticked, since `Coordinator.rebuild(_:value:)` hands this control the very
/// item it built the menu from rather than a copy.
override func accessibilityValue() -> Any? { checkedItem?.title }
} }
+279
View File
@@ -0,0 +1,279 @@
import AppKit
/// **The two-zone combo, as chrome both pickers share** a bordered field with a large *face* and a
/// narrow accent-coloured *trigger* at the trailing edge, Xcode's inspector colour combo's own shape.
///
/// It exists because "the two pickers should rhyme in terms of UX" (2026-08-09) is only true as long
/// as nobody edits one of them. `ColorComboControl` drew this shape first and drew it alone; a second
/// control drawing "the same" shape from its own constants would be one careless padding tweak away
/// from two controls that merely used to match. So the shape moved here and both subclass it: the
/// colour combo's face is a swatch, the symbol combo's is a glyph, and **every other pixel is this
/// file's** width, height, radius, hairline, the trigger square, the disabled treatment, and the
/// hit split between the two zones.
///
/// ### The grammar the two zones carry
///
/// Identical on both controls, which is the whole point:
///
/// - **The face** opens the *standalone* picker `NSColorPanel.shared` for a colour
/// (`SystemColorPanel`), the symbol browser for a glyph (`SymbolBrowserPanel`). A window that
/// stays up while the user keeps choosing.
/// - **The trigger** pops the *quick* list the colour dropdown's palette rows, the symbol
/// popover's curated grid. A transient surface that closes on a pick.
///
/// Each quick list also carries a row onto the standalone picker (**Other**, **More Symbols**),
/// which is deliberate rather than redundant: the face zone is pointer-only `keyDown` below reaches
/// the trigger, and there is no second key that would obviously mean "the other zone" so the row
/// inside the popover is how the keyboard gets to the deep door at all.
///
/// ### Everything scales with the body font
///
/// 10-accessibility.md's full-relative-scaling rule, which the hard-coded constants this file
/// replaces did not follow. Every figure is a multiple of the body point size, chosen to reproduce
/// the numbers `ColorComboControl` shipped with at the standard 13pt body with one deliberate
/// exception, `height`.
// MARK: - Metrics
/// The combo's geometry, as a pure value no `NSView`, so the parity claim ("the two controls are
/// the same size") is a thing a test can assert rather than a thing a screenshot suggests.
struct ComboFieldMetrics: Equatable {
/// The control's height.
///
/// **1.4 em is 18pt at the standard body, where this shape shipped at a flat 14** the one
/// figure here that is not a restatement of an old constant. 14 was tuned for a colour bar and
/// is a fine height for one; a *glyph* in a 14pt field, once the face's own padding is taken
/// out, is six points tall and unreadable. 18 still reads as the compact control the original
/// rework was after (a regular `NSPopUpButton` is about 25) and leaves the glyph room to be a
/// glyph.
var height: CGFloat
/// The trigger strip at the trailing edge, full height.
var triggerWidth: CGFloat
/// The trigger square's inset from the strip's height kept off the *ring* width rather than
/// the face's larger padding, so the indicator stays a legible square instead of shrinking with
/// every padding tweak the face takes.
var triggerInset: CGFloat
/// The face's inset inside its zone, asymmetric and user-tuned: a wider berth at the sides than
/// above and below, so a swatch reads as a bar sitting in the field rather than filling it wall
/// to wall. The space comes out of the face the control's overall size is untouched.
var facePaddingH: CGFloat
var facePaddingV: CGFloat
/// A glyph face's own inset, much tighter than a swatch's: a symbol *is* the face, where a
/// swatch is a sample sitting in one.
var glyphPadding: CGFloat
/// The face's and the trigger square's radius.
var cornerRadius: CGFloat
/// The field's own radius a point more than the face's, so the two rounded rects run
/// concentric instead of pinching at the corners.
var fieldRadius: CGFloat
static func metrics(bodyPointSize: CGFloat) -> ComboFieldMetrics {
func em(_ multiple: CGFloat) -> CGFloat { max(1, (bodyPointSize * multiple).rounded()) }
return ComboFieldMetrics(
height: em(1.4),
triggerWidth: em(1.25),
triggerInset: em(0.15),
facePaddingH: em(0.54),
facePaddingV: em(0.38),
glyphPadding: em(0.15),
cornerRadius: em(0.23),
fieldRadius: em(0.31)
)
}
/// The live metrics read from the same place every other font-derived geometry in the app
/// reads it (`CardWindowMetrics.bodyPointSize`), so a text-size change moves the combos with
/// everything else.
@MainActor
static var current: ComboFieldMetrics { metrics(bodyPointSize: CardWindowMetrics.bodyPointSize) }
/// The glyph's point size inside a face the face zone's height less its padding, which is
/// what makes a symbol fill the control instead of floating in it.
var glyphPointSize: CGFloat { max(1, height - 2 * glyphPadding) }
}
// MARK: - The control
/// The shared field. Subclasses draw the face and answer the two zones; nothing else here is theirs
/// to change.
///
/// Plain internal rather than `private`: both subclasses live in other files, and each is an
/// `NSViewRepresentable`'s `NSViewType`, an associated-type witness the compiler requires to be at
/// least as visible as the representable itself.
class ComboFieldControl: NSControl {
/// The geometry every pass draws from, refreshed by the representable on each SwiftUI update so
/// a text-size change lands without recreating the view.
var metrics: ComboFieldMetrics = .metrics(bodyPointSize: 13) {
didSet {
guard metrics != oldValue else { return }
invalidateIntrinsicContentSize()
needsDisplay = true
}
}
/// A click anywhere in the face zone the standalone picker's door.
///
/// Unannotated rather than `@MainActor`: this class is an `NSResponder` subclass and therefore
/// already main-actor isolated, so every call site below is on the main actor by construction
/// and the annotation would only force every caller to hand over a `@Sendable` closure it has no
/// reason to be.
var onFaceClick: (() -> Void)?
/// A click in the trigger zone, or Space/Return the quick list's door.
var onTriggerClick: (() -> Void)?
/// What VoiceOver reads as this control's value: the checked dropdown row's title, the resolved
/// symbol's name. Set by whichever coordinator owns the control.
var accessibilityValueText: String?
override var isEnabled: Bool {
get { super.isEnabled }
set {
super.isEnabled = newValue
needsDisplay = true
}
}
/// Width is `noIntrinsicMetric`: the control obeys whatever SwiftUI proposes, so a sidebar row
/// sizes it and an unconstrained pass falls back to the representable's own default.
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: metrics.height)
}
// MARK: Zones
var triggerRect: NSRect {
NSRect(
x: bounds.maxX - metrics.triggerWidth,
y: bounds.minY,
width: metrics.triggerWidth,
height: bounds.height
)
}
var faceZone: NSRect {
NSRect(
x: bounds.minX,
y: bounds.minY,
width: max(0, bounds.width - metrics.triggerWidth),
height: bounds.height
)
}
// MARK: Drawing
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
defer { context.restoreGState() }
// A transparency layer, not a flat `setAlpha` around each shape: the face's underlay, fill
// and stroke overlap, and drawing each at reduced alpha independently would let the stroke
// double up over the fill beneath it. Compositing the whole disabled face as one layer
// avoids that.
if !isEnabled {
context.setAlpha(0.35)
context.beginTransparencyLayer(auxiliaryInfo: nil)
}
drawField()
drawFace(in: faceZone)
drawTrigger()
if !isEnabled {
context.endTransparencyLayer()
}
}
/// The subclass's half: whatever belongs in the face zone. The base draws nothing.
func drawFace(in rect: NSRect) {}
/// The control's own field: a bordered, filled rounded rect over the whole bounds, under both
/// zones what makes the face and the trigger read as one control rather than two shapes
/// floating beside each other. `controlColor` fill the push-button neutral grey, not
/// `controlBackgroundColor`, whose near-black dark-mode reading drowned the padding ring
/// `separatorColor` hairline, the half-point inset keeping the stroke on whole pixels.
private func drawField() {
let path = NSBezierPath(
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
xRadius: metrics.fieldRadius,
yRadius: metrics.fieldRadius
)
NSColor.controlColor.setFill()
path.fill()
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
}
/// The trigger: a small vertically-centred rounded square, `controlAccentColor`-filled, with a
/// white `chevron.up.chevron.down` centred inside the standard `NSPopUpButton` indicator's own
/// look, redrawn here since this control has no bezel of its own to borrow one from.
private func drawTrigger() {
let side = triggerRect.height - 2 * metrics.triggerInset
let square = NSRect(
x: triggerRect.midX - side / 2,
y: triggerRect.midY - side / 2,
width: side,
height: side
)
let path = NSBezierPath(roundedRect: square, xRadius: metrics.cornerRadius, yRadius: metrics.cornerRadius)
NSColor.controlAccentColor.setFill()
path.fill()
let config = NSImage.SymbolConfiguration(pointSize: (side * 0.6).rounded(), weight: .bold)
.applying(.init(paletteColors: [.white]))
guard let chevron = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
.withSymbolConfiguration(config)
else { return }
let size = chevron.size
chevron.draw(in: NSRect(
x: square.midX - size.width / 2,
y: square.midY - size.height / 2,
width: size.width,
height: size.height
))
}
// MARK: Events
/// Point-in-trigger-zone pops the quick list; anywhere else fires the face. A disabled control
/// answers neither.
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
let point = convert(event.locationInWindow, from: nil)
if triggerRect.contains(point) {
onTriggerClick?()
} else {
onFaceClick?()
}
}
override var acceptsFirstResponder: Bool { isEnabled }
/// Space and Return pop the quick list the one keyboard path into this control. The face zone
/// has no key of its own on purpose; the standalone picker is reached from *inside* the quick
/// list instead (this file's header).
override func keyDown(with event: NSEvent) {
guard isEnabled else {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 49, 36, 76: // Space, Return, keypad Enter
onTriggerClick?()
default:
super.keyDown(with: event)
}
}
// MARK: Accessibility
override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }
override func accessibilityValue() -> Any? { accessibilityValueText }
override func accessibilityPerformPress() -> Bool {
guard isEnabled else { return false }
onTriggerClick?()
return true
}
}
+59 -4
View File
@@ -5,7 +5,31 @@ import SwiftUI
/// (03-board-ui.md § Styling Capabilities): **a kebab-case palette name, or a `#RRGGBB[AA]` /// (03-board-ui.md § Styling Capabilities): **a kebab-case palette name, or a `#RRGGBB[AA]`
/// hex**. This file is the single source for the namehex mapping the pathfinder's twelve icon /// hex**. This file is the single source for the namehex mapping the pathfinder's twelve icon
/// tints and twelve backgrounds, carried over verbatim as the starting point ("The pathfinder's /// tints and twelve backgrounds, carried over verbatim as the starting point ("The pathfinder's
/// palettes (12 icon tints, 12 backgrounds) carry over"). /// palettes (12 icon tints, 12 backgrounds) carry over"), **grown to sixteen each on 2026-08-09**.
///
/// ### The two tables are one structure
///
/// Each is **four neutrals plus a twelve-stop hue ring**, and the two rings are paired stop for
/// stop `carnation``light-cayenne`, `fern``smokey-fern`, `light-teal``dark-teal`,
/// `pale-violet``smokey-rich-eggplant`. The tint ring is light and the background ring is deep;
/// otherwise they name the same hues in the same order. Anything added to one belongs in the other
/// at the same position, which is why the 2026-08-09 additions come in pairs.
///
/// ### How the four new stops were chosen
///
/// Not by taste by the holes. The original ring sat at 352°, 19°, 38°, 110°, 180°, 205° and 268°
/// (plus one desaturated slate at 218° that is a neutral in all but name), and its four widest gaps
/// were 38110, 268352, 110205 and 205268. So: **yellow-green at 64°, jade at 158°, indigo at
/// 238°, magenta at 312°**, each inserted at its hue position rather than appended, so the ring
/// stays a ring. Saturation and brightness were set to each table's own existing statistics rather
/// than picked by eye, which is what keeps the new swatches *inside* the family: the tints land at
/// 2.184.67 contrast on white against the old set's 2.184.94, and the backgrounds at 6.614.7
/// against the old 5.716.6. `rich-lime` is deliberately darkened to `smokey-tangerine`'s exact
/// 2.18 an untamed yellow-green would have been the brightest thing in the table by a wide
/// margin.
///
/// The design's AA promise is not a claim this file makes; it is `ContrastMathTests`' two computed
/// assertions over `backgrounds`, which is precisely what made growing the table a safe thing to do.
/// ///
/// ### Lenient, never an error /// ### Lenient, never an error
/// ///
@@ -28,7 +52,9 @@ struct PaletteColor: Identifiable, Sendable {
enum Palette { enum Palette {
/// Icon-tint palette (`iconColor`). /// Icon-tint palette (`iconColor`) **four neutrals, then the hue ring, then the one muted
/// slate**, in that order. See `Palette`'s own doc comment for the ring and how the four
/// 2026-08-09 additions were placed in it.
static let foregrounds: [PaletteColor] = [ static let foregrounds: [PaletteColor] = [
PaletteColor(name: "obsidian", hex: "#000000"), PaletteColor(name: "obsidian", hex: "#000000"),
PaletteColor(name: "aluminum", hex: "#9B9B9B"), PaletteColor(name: "aluminum", hex: "#9B9B9B"),
@@ -37,15 +63,23 @@ enum Palette {
PaletteColor(name: "carnation", hex: "#FF576C"), PaletteColor(name: "carnation", hex: "#FF576C"),
PaletteColor(name: "rich-grapefruit", hex: "#FF864C"), PaletteColor(name: "rich-grapefruit", hex: "#FF864C"),
PaletteColor(name: "smokey-tangerine", hex: "#E5A334"), PaletteColor(name: "smokey-tangerine", hex: "#E5A334"),
PaletteColor(name: "rich-lime", hex: "#ADB812"),
PaletteColor(name: "fern", hex: "#50B23D"), PaletteColor(name: "fern", hex: "#50B23D"),
PaletteColor(name: "light-jade", hex: "#21B880"),
PaletteColor(name: "light-teal", hex: "#00B7B7"), PaletteColor(name: "light-teal", hex: "#00B7B7"),
PaletteColor(name: "deep-sky-blue", hex: "#0084E5"), PaletteColor(name: "deep-sky-blue", hex: "#0084E5"),
PaletteColor(name: "rich-indigo", hex: "#6065E6"),
PaletteColor(name: "pale-violet", hex: "#8C59C5"), PaletteColor(name: "pale-violet", hex: "#8C59C5"),
PaletteColor(name: "rich-magenta", hex: "#D952BE"),
PaletteColor(name: "deep-cool-granite", hex: "#597199"), PaletteColor(name: "deep-cool-granite", hex: "#597199"),
] ]
/// Background palette (`background`) the twelve wells the style editor will offer, "every /// Background palette (`background`) the wells the style editor offers, "every pair
/// pair AA-verified at design time" (03-board-ui.md § Styling Controls). /// AA-verified at design time" (03-board-ui.md § Styling Controls). The verification is
/// `ContrastMathTests`' two computed assertions over this table, which is what makes growing it
/// safe: a new well with no readable ink fails there rather than shipping.
///
/// Same shape as `foregrounds` and paired to it hue for hue see `Palette`'s doc comment.
static let backgrounds: [PaletteColor] = [ static let backgrounds: [PaletteColor] = [
PaletteColor(name: "obsidian", hex: "#000000"), PaletteColor(name: "obsidian", hex: "#000000"),
PaletteColor(name: "shale", hex: "#5B5B5B"), PaletteColor(name: "shale", hex: "#5B5B5B"),
@@ -54,12 +88,33 @@ enum Palette {
PaletteColor(name: "light-cayenne", hex: "#B6071E"), PaletteColor(name: "light-cayenne", hex: "#B6071E"),
PaletteColor(name: "light-mocha", hex: "#B73C14"), PaletteColor(name: "light-mocha", hex: "#B73C14"),
PaletteColor(name: "smokey-mocha", hex: "#674611"), PaletteColor(name: "smokey-mocha", hex: "#674611"),
PaletteColor(name: "smokey-lime", hex: "#5B610E"),
PaletteColor(name: "smokey-fern", hex: "#145312"), PaletteColor(name: "smokey-fern", hex: "#145312"),
PaletteColor(name: "dark-jade", hex: "#035437"),
PaletteColor(name: "dark-teal", hex: "#005152"), PaletteColor(name: "dark-teal", hex: "#005152"),
PaletteColor(name: "smokey-ocean", hex: "#003168"), PaletteColor(name: "smokey-ocean", hex: "#003168"),
PaletteColor(name: "smokey-indigo", hex: "#14177A"),
PaletteColor(name: "smokey-rich-eggplant", hex: "#290659"), PaletteColor(name: "smokey-rich-eggplant", hex: "#290659"),
PaletteColor(name: "smokey-magenta", hex: "#5C074B"),
PaletteColor(name: "intense-cool-shale", hex: "#1F2E45"), PaletteColor(name: "intense-cool-shale", hex: "#1F2E45"),
] ]
/// The tint names the icon-colour pickers offer: `foregrounds` **minus the four neutrals and
/// minus `deep-cool-granite`**.
///
/// A symbol's *tint* wants colour "no tint" is the None well's job, not a grey's and the
/// mutedest entry of the ring is the one that reads least like a deliberate choice at glyph
/// size. Eleven remain, which is exactly what a leading None needs to fill a 4×3 grid
/// (`SymbolPickerLayout`), and that is not a coincidence: the ring was grown to sixteen partly
/// so this row could gain its third row (2026-08-09).
///
/// Derived rather than hand-listed, so a palette addition joins the tint row automatically and
/// a rename can never leave a dead name behind the failure mode the old hand-written constant
/// had.
static let tints: [PaletteColor] = {
let neutrals: Set<String> = ["obsidian", "aluminum", "soapstone", "chalk", "deep-cool-granite"]
return foregrounds.filter { !neutrals.contains($0.name) }
}()
} }
// The pathfinder's panel round-trip helpers, ported below (`NSColor.paletteHexString`, // The pathfinder's panel round-trip helpers, ported below (`NSColor.paletteHexString`,
+37 -3
View File
@@ -240,7 +240,8 @@ struct StyleEditorLayout: Equatable {
/// The Style popover and the board popover's styling area: a fixed frame, its own padding, and /// The Style popover and the board popover's styling area: a fixed frame, its own padding, and
/// a symbol grid that scrolls within it. /// a symbol grid that scrolls within it.
/// ///
/// Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the popover narrow /// Seventeen background wells (None + the sixteen) fall as 7 + 7 + 3 two rows until the
/// palette grew on 2026-08-09, three since which keeps the popover narrow
/// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is /// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is
/// eight rows or so enough that it reads as a set rather than as a strip, short enough that the /// eight rows or so enough that it reads as a set rather than as a strip, short enough that the
/// popover fits beside a card on a laptop screen. /// popover fits beside a card on a laptop screen.
@@ -395,8 +396,15 @@ struct StyleEditorView: View {
// MARK: - Background // MARK: - Background
/// The twelve palette wells and their leading None (03-board-ui.md § Styling Controls: /// The palette wells and their leading None (03-board-ui.md § Styling Controls: "palette-only
/// "palette-only in-app plus a leading **None** well that removes the `background` key"). /// in-app plus a leading **None** well that removes the `background` key"), and since
/// 2026-08-09 an **Other** row onto the system Colors panel.
///
/// **"Palette-only in-app" is the sentence that changed**, and it changed before this card:
/// `ColorComboView` shipped an **Other** row of its own, so the card window's sidebar could
/// already write an arbitrary hex while the Style popover the *primary* styling surface
/// could not. This closes that gap rather than opening a new one. Seventeen wells at seven
/// columns is three rows where the old twelve made two, which is the other half of the same change.
private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View { private func backgroundSection(_ state: StyleFieldState, layout: StyleEditorLayout) -> some View {
VStack(alignment: .leading, spacing: layout.wellSpacing) { VStack(alignment: .leading, spacing: layout.wellSpacing) {
sectionHeader("Background", current: backgroundCurrent(state), layout: layout) sectionHeader("Background", current: backgroundCurrent(state), layout: layout)
@@ -408,6 +416,32 @@ struct StyleEditorView: View {
StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo) StyleCommand.apply(background: change, to: target, in: store, recents: recents, on: undo)
} }
) )
HStack(spacing: 0) {
Spacer(minLength: 0)
Button("Other…") { openBackgroundPanel(state) }
.buttonStyle(.link)
.font(.caption)
}
}
}
/// Opens the Colors panel on the background dimension, seeded with what the target set currently
/// reads (nothing, for a mixed set there is no one colour to start from).
///
/// **The panel fires continuously**, so this debounces exactly as `CardStyleSection` does for the
/// colour combo's own drag: ~400ms trailing, cancelled and replaced on every tick, so a drag
/// writes once it settles instead of once per pixel it passes through.
///
/// It goes to `store.applyStyle` rather than through `StyleCommand.apply`, which is the same call
/// the sidebar's debounce makes and for the same reason: `StyleRecents` remembers *deliberate
/// palette picks*, and a drag that sweeps through six palette-exact colours on its way somewhere
/// else would otherwise flush the row it is supposed to be helping.
private func openBackgroundPanel(_ state: StyleFieldState) {
let seed: NSColor? = if case let .uniform(value) = state { Palette.nsColor(for: value) } else { nil }
let target = self.target
SharedColorPanelSession.present(seed: seed, matching: Palette.backgrounds) { [weak store, weak undo] value in
guard let store else { return }
store.applyStyle(to: target, background: .set(value), icon: .keep, on: undo)
} }
} }
+318
View File
@@ -0,0 +1,318 @@
import AppKit
import SwiftUI
/// **The standalone symbol picker** the symbol combo's face-zone door, and the glyph half of the
/// rhyme the colour combo's face already had: click the swatch, the Colors panel opens; click the
/// glyph, this opens (2026-08-09).
///
/// ### Why a panel and not a popover
///
/// Because the thing it is rhyming with is a panel. `NSColorPanel.shared` is a floating window that
/// **stays up while you keep choosing**, streaming each pick to whatever surface opened it, and every
/// property that makes it feel like a tool rather than a menu follows from that: it can be moved out
/// of the way, it can stay open across several cards, and it does not steal the board's key window
/// permanently. A popover would have been the third popover in a stack (the board popover already
/// hosts a symbol combo whose trigger opens one) and would have closed the moment the user looked
/// away. So: an `NSPanel`, `.utilityWindow`, non-activating, hosting SwiftUI.
///
/// ### Shared, exactly like the colour panel
///
/// One panel, borrowed in turn. `present()` hands it a new owner's closure and the previously
/// presenting surface simply stops receiving picks `SystemColorPanel`'s last-writer-wins, restated
/// for a window this app owns rather than one AppKit owns. That is what lets the card window's
/// sidebar, the board popover and a future caller all use it without any of them coordinating.
///
/// ### What a pick does
///
/// Fires `onSelect` **immediately**, and the panel stays open the colour panel's continuous
/// behaviour, one dimension over. The receiving surface routes it through `StyleCommand.apply` exactly
/// as a well click does, so a pick here is one undoable step on the same stack, feeding the same
/// batch bracket. Unlike a colour drag there is nothing to debounce: a click is already discrete.
@MainActor
final class SymbolBrowserPanel: NSObject {
static let shared = SymbolBrowserPanel()
private var panel: NSPanel?
private let model = SymbolBrowserModel()
private override init() { super.init() }
/// Opens the browser (or re-aims an open one) at one symbol field.
///
/// - Parameters:
/// - current: the value as written, so the browser can show which glyph is live.
/// - fallback: the level's default what **Use Default** goes back to, and what the browser
/// marks as selected when `current` is absent or unresolvable (`ItemSymbol.name(_:fallback:)`'s
/// lenient rule, which this control obeys like every other renderer).
/// - onSelect: a name to set, or `nil` to clear the key. `SymbolPicker.onSelect`'s contract
/// verbatim, so a caller wires the same closure to both doors.
func present(current: String?, fallback: String, onSelect: @escaping (String?) -> Void) {
model.current = current
model.fallback = fallback
model.onSelect = onSelect
let panel = self.panel ?? makePanel()
self.panel = panel
panel.makeKeyAndOrderFront(nil)
}
/// **The closure is dropped when the window closes, and only then.**
///
/// Not when the surface that opened it goes away, which is the tempting rule and the broken one:
/// opening this browser dismisses whichever popover the picker was mounted in (the board popover,
/// the Style popover), so a claim tied to the opener's lifetime would be released a frame after
/// it was made and every pick would go nowhere. The panel therefore holds the last closure it was
/// given outliving its opener on purpose, exactly as `SharedColorPanelSession` does until
/// another surface claims it or the user closes the window.
///
/// Closing it matters because that closure retains its opener's `BoardStore`. One board's worth,
/// bounded, and released here.
private func panelWillClose() {
model.onSelect = nil
}
private func makePanel() -> NSPanel {
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 560, height: 420),
styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.title = "Symbols"
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.minSize = NSSize(width: 460, height: 320)
panel.contentView = NSHostingView(rootView: SymbolBrowserView(model: model))
panel.center()
// A utility panel that never becomes key could not host a search field; this one must.
panel.becomesKeyOnlyIfNeeded = false
NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: panel,
queue: .main
) { _ in
MainActor.assumeIsolated { SymbolBrowserPanel.shared.panelWillClose() }
}
return panel
}
}
// MARK: - The model
/// The browser's live state, shared between the panel (which owns it across presentations) and the
/// SwiftUI view (which observes it).
///
/// A reference type rather than the view's own `@State` because the panel outlives any one
/// presentation: re-aiming the browser at a different card must update the open window, not build a
/// second one. `category` and `query` deliberately **persist** across re-aims a user who was
/// browsing Nature for one card is very likely still browsing Nature for the next, and resetting
/// their place would be the browser forgetting what it was doing.
@MainActor
@Observable
final class SymbolBrowserModel {
var current: String?
var fallback: String = ItemSymbol.card
/// The selected category's key, or `nil` for **All Symbols**.
var category: String?
var query: String = ""
/// The presenting surface's write. `@ObservationIgnored` because nothing observes it, and
/// unannotated for `ComboFieldControl.onFaceClick`'s reason this class is already main-actor
/// isolated, so the closure is called there by construction.
@ObservationIgnored
var onSelect: ((String?) -> Void)?
/// The grid's contents: the selected category (or everything), narrowed by the query.
///
/// The query searches **within the selected category**, not across the whole catalog. Searching
/// globally from inside a category would make the sidebar selection silently irrelevant the
/// moment a character was typed; this way the two controls compose, and All Symbols is right
/// there for a global search.
var visibleSymbols: [String] {
let contents = SymbolCatalog.contents()
let base: [String]
if let category, let hit = contents.categories.first(where: { $0.key == category }) {
base = hit.symbols
} else {
base = contents.allSymbols
}
return SymbolCatalog.search(query, in: base, keywords: contents.keywords)
}
/// What the board would actually draw for the current value the leading-well rule, so the
/// browser marks the glyph that is on screen rather than the string on disk.
var resolvedName: String {
if let current, ItemSymbol.exists(current) { return current }
return fallback
}
func select(_ name: String?) {
current = name
onSelect?(name)
}
}
// MARK: - The view
/// The panel's content: a category sidebar, a search field, and a scrolling grid.
///
/// The shape is the SF Symbols app's, deliberately it is the arrangement every Mac user who has
/// ever looked for a glyph already knows, and inventing a different one would be novelty for its own
/// sake.
private struct SymbolBrowserView: View {
@Bindable var model: SymbolBrowserModel
/// Focus starts in the search field: someone who opened a symbol browser is looking for a
/// symbol, and the overwhelmingly common next act is to type its name.
@FocusState private var searchFocused: Bool
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// The grid's well side the symbol popover's own enlarged well (`SymbolPickerLayout`), reused
/// so a glyph is the same size in both surfaces and the eye does not have to re-scale between
/// them.
private var wellSide: CGFloat {
(StyleEditorLayout.wellSide(bodyPointSize: pointSize) * SymbolPickerLayout.gridScale).rounded()
}
private var spacing: CGFloat { StyleEditorLayout.wellSpacing(bodyPointSize: pointSize) }
var body: some View {
NavigationSplitView {
sidebar
.navigationSplitViewColumnWidth(min: 150, ideal: 170, max: 240)
} detail: {
detail
}
.frame(minWidth: 460, minHeight: 320)
}
// MARK: Sidebar
private var sidebar: some View {
List(selection: $model.category) {
Label("All Symbols", systemImage: "square.grid.2x2")
.tag(String?.none)
Section("Categories") {
ForEach(SymbolCatalog.categories) { category in
Label(category.title, systemImage: category.icon)
.tag(String?.some(category.key))
}
}
}
.listStyle(.sidebar)
}
// MARK: Detail
private var detail: some View {
let symbols = model.visibleSymbols
return VStack(alignment: .leading, spacing: spacing) {
searchField
if symbols.isEmpty {
ContentUnavailableView.search(text: model.query)
} else {
grid(symbols)
}
Divider()
footer
}
.padding(spacing)
}
private var searchField: some View {
HStack(spacing: spacing) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search Symbols", text: $model.query)
.textFieldStyle(.plain)
.focused($searchFocused)
// **Escape steps outward one layer per press** the app's standing Escape grammar
// (`BoardRenameField`, the symbol popover's own field): a non-empty query clears
// itself, an empty one lets the press through to the panel's own close.
.onKeyPress(.escape) {
guard !model.query.isEmpty else { return .ignored }
model.query = ""
return .handled
}
if !model.query.isEmpty {
Button {
model.query = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear Search")
}
}
.padding(.horizontal, spacing)
.padding(.vertical, spacing / 2)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
.onAppear { searchFocused = true }
}
private func grid(_ symbols: [String]) -> some View {
ScrollView(.vertical) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: wellSide + spacing), spacing: spacing)],
spacing: spacing
) {
// `id: \.self` is safe and cheap here: the OS's catalog is deduplicated by
// construction (it is a dictionary's keys), so the names are unique.
ForEach(symbols, id: \.self) { name in
well(name)
}
}
.padding(.vertical, spacing / 2)
}
}
private func well(_ name: String) -> some View {
let isSelected = model.resolvedName == name
return Button {
model.select(name)
} label: {
Image(systemName: name)
.font(.system(size: (pointSize * SymbolPickerLayout.gridScale).rounded()))
.frame(width: wellSide, height: wellSide)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.25)) : AnyShapeStyle(.clear))
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(name)
.accessibilityLabel(name)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
/// The live value, stated, and the one control that is not a glyph: **Use Default**, which is the
/// popover's leading default well restated as a button the browser has thousands of wells and
/// no sensible place to hide a special one among them.
private var footer: some View {
HStack(spacing: spacing) {
Image(systemName: model.resolvedName)
.foregroundStyle(.secondary)
Text(model.current ?? "Default (\(model.fallback))")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: spacing)
Button("Use Default") {
model.select(nil)
}
.disabled(model.current == nil)
}
}
}
+274
View File
@@ -0,0 +1,274 @@
import Foundation
/// **The OS's own SF Symbols index, read off disk** the categories, their canonical order, and the
/// search keywords behind them. What `SymbolBrowserPanel` browses.
///
/// ### Why this is a read and not a hand-written list
///
/// There is no public API that enumerates SF Symbols, let alone their categories, so the obvious plan
/// is a curated constant. But `SymbolPickerCatalog.fullCatalog()` already establishes the better one:
/// the data ships with the OS as plain property lists inside `CoreGlyphs.bundle`, and that file
/// already reads one of them (`name_availability.plist`) for exactly this reason a hand-written
/// inventory is "a hand-written guess that might be stale, and only guesses need checking", where the
/// bundle *is* the running system's answer. This extends that read to the four siblings that make a
/// real browser possible:
///
/// - `categories.plist` the categories, in **Apple's own display order**, each with a
/// representative glyph. That ordering is a design decision Apple already made and this app has no
/// better one, so it is taken verbatim.
/// - `symbol_categories.plist` every symbol's category memberships. A symbol may sit in several,
/// and does; the browser lists it under each.
/// - `symbol_order.plist` the canonical ordering the SF Symbols app itself displays in, which is
/// grouped by shape and family. Sorting a category alphabetically instead would scatter
/// `arrow.up`, `arrow.down` and `arrow.left` across the grid; this keeps families together.
/// - `symbol_search.plist` per-symbol search keywords. This is what makes "delete" find `trash`
/// and "password" find `key.slash`, which a name-substring search cannot.
///
/// ### The three rulings taken on that data (2026-08-09)
///
/// **Five categories are dropped** `all`, `whatsnew`, `variable`, `multicolor`, `draw`. They
/// classify *rendering behaviour* or *release vintage*, not subject matter. "Multicolor" as a sibling
/// of "Nature" answers a question nobody browsing for a lane glyph is asking, and `whatsnew` is a
/// category that means something different every autumn.
///
/// **Trademark-restricted symbols are excluded.** `symbol_restrictions.strings` names about six
/// hundred glyphs the Apple logo, iCloud, FaceTime, Apple Intelligence each carrying "may only be
/// used to refer to" its product. Offering them in a general glyph browser invites a misuse the app
/// can prevent for the price of one set lookup. A board that already has one on disk still renders it:
/// this narrows what the *picker offers*, never what the renderer honours, which is the same split
/// `03-board-ui.md` draws with "curated in-app, unlimited on disk".
///
/// **`indices` stays**, numerals in every script and all. Letters and digits in circles are genuinely
/// useful for numbered lanes, `symbol_order` puts the Latin ones first, and dropping the Arabic-Indic
/// and Devanagari variants to tidy the grid would be the worse call.
///
/// ### What is *not* filtered
///
/// Names are **not** run through `ItemSymbol.exists`. `fullCatalog()`'s reasoning applies unchanged
/// the plists are the running OS's inventory, so checking them against the running OS is cost for an
/// answer already given, and thousands of `NSImage` lookups per keystroke is a real cost. (Verified
/// once by hand at the time of writing: every name in `symbol_categories.plist` also appears in
/// `name_availability.plist`, zero misses.) The *curated* lists remain filtered, because those are
/// the guesses.
enum SymbolCatalog {
// MARK: - Types
/// One browsable category: what it is called, what it looks like in a sidebar, and what is in it.
struct Category: Identifiable, Sendable, Equatable {
/// The OS's own key `objectsandtools`. Stable across releases and the browser's selection
/// token, which is why selection survives a reload where a localized title would not.
let key: String
/// The sidebar's label.
let title: String
/// The representative glyph `categories.plist` names for it.
let icon: String
/// Its members, in `symbol_order.plist`'s canonical order.
let symbols: [String]
var id: String { key }
}
/// Everything one load produces kept as one value so the loader is a pure function of a bundle
/// path and the test suite can drive it against a path that isn't there.
struct Contents: Sendable, Equatable {
let categories: [Category]
/// Every offered symbol, canonically ordered the browser's "All Symbols".
let allSymbols: [String]
/// Symbol its search keywords. Absent for most symbols; the search falls back to the name.
let keywords: [String: [String]]
}
// MARK: - Loading
/// Where the OS keeps its SF Symbols metadata read-only system data, present on every Mac that
/// ships SF Symbols at all. The same bundle `SymbolPickerCatalog` reads.
static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
/// Categories that classify rendering behaviour or release vintage rather than subject matter
/// see this type's own doc comment.
static let excludedCategoryKeys: Set<String> = ["all", "whatsnew", "variable", "multicolor", "draw"]
/// The category titles, which are the one thing here that **is** hand-written: the bundle ships
/// no localized names for its keys, only the keys. Twenty-seven pairs, and `title(forKey:)` falls
/// back to a title-cased key for one a future OS adds a new category shows up in the browser
/// reading a little awkwardly rather than not showing up at all.
static let categoryTitles: [String: String] = [
"accessibility": "Accessibility",
"arrows": "Arrows",
"automotive": "Automotive",
"cameraandphotos": "Camera & Photos",
"commerce": "Commerce",
"communication": "Communication",
"connectivity": "Connectivity",
"devices": "Devices",
"editing": "Editing",
"fitness": "Fitness",
"gaming": "Gaming",
"health": "Health",
"home": "Home",
"human": "Human",
"indices": "Indices",
"keyboard": "Keyboard",
"maps": "Maps",
"math": "Math",
"media": "Media",
"nature": "Nature",
"objectsandtools": "Objects & Tools",
"privacyandsecurity": "Privacy & Security",
"shapes": "Shapes",
"textformatting": "Text Formatting",
"time": "Time",
"transportation": "Transportation",
"weather": "Weather",
]
/// The default path's contents, loaded once. A `static let` rather than a `lazy var`, for
/// `SymbolPickerCatalog.cachedFullCatalog`'s reason: the load is synchronous and the result is
/// immutable and `Sendable`, so Swift's own thread-safe one-time global initialization is the
/// whole of the cache, with no actor to hang it off.
private static let cached: Contents = load(bundlePath: defaultBundlePath)
/// The contents for `bundlePath` cached at the real system location, reloaded anywhere else,
/// which is `fullCatalog(bundlePath:)`'s bargain and the honest cost of asking a question the
/// cache was never built to answer.
static func contents(bundlePath: String = defaultBundlePath) -> Contents {
bundlePath == defaultBundlePath ? cached : load(bundlePath: bundlePath)
}
static var categories: [Category] { cached.categories }
static var allSymbols: [String] { cached.allSymbols }
/// The title for `key` the table above, else the key title-cased on its word boundaries as best
/// they can be guessed from a lowercase run (there are none, so `objectsandtools` would come back
/// as "Objectsandtools"; the table exists precisely so that never happens for a key we know).
static func title(forKey key: String) -> String {
categoryTitles[key] ?? key.prefix(1).uppercased() + key.dropFirst()
}
/// The load, and its one fallback.
///
/// A bundle that won't open, a resource that isn't there, or a plist whose shape this reader
/// cannot make sense of all read the same way as "no inventory" rather than as several
/// failure modes to chase, which is `SymbolPickerCatalog.load`'s posture and its reason. The
/// fallback is a single synthetic category over the app's own curated sets, so the browser always
/// has something to show and something to search even on a system whose metadata this cannot
/// read.
static func load(bundlePath: String) -> Contents {
guard let bundle = Bundle(path: bundlePath) else { return fallback() }
// A `.strings` file, but a binary plist inside a dictionary of name the sentence
// explaining what the trademark permits. Only the keys matter here.
let restricted = Set((plist(bundle, "symbol_restrictions", "strings") as? [String: Any] ?? [:]).keys)
let order = plist(bundle, "symbol_order", "plist") as? [String] ?? []
let membership = plist(bundle, "symbol_categories", "plist") as? [String: [String]] ?? [:]
let keywords = plist(bundle, "symbol_search", "plist") as? [String: [String]] ?? [:]
let ordered = plist(bundle, "categories", "plist") as? [[String: String]] ?? []
guard !membership.isEmpty, !ordered.isEmpty else { return fallback() }
// `symbol_order` is the display order; a symbol missing from it (there are a handful) sorts
// after everything that is in it, alphabetically among its peers, rather than to the front.
var rank: [String: Int] = [:]
rank.reserveCapacity(order.count)
for (index, name) in order.enumerated() where rank[name] == nil { rank[name] = index }
func canonical(_ names: [String]) -> [String] {
names.sorted { lhs, rhs in
switch (rank[lhs], rank[rhs]) {
case let (left?, right?): left < right
case (_?, nil): true
case (nil, _?): false
case (nil, nil): lhs < rhs
}
}
}
var buckets: [String: [String]] = [:]
var offered = Set<String>()
for (name, keys) in membership where !restricted.contains(name) {
var isOffered = false
for key in keys where !excludedCategoryKeys.contains(key) {
buckets[key, default: []].append(name)
isOffered = true
}
// A symbol whose every category was dropped is not offered at all it would be
// unreachable in the sidebar and would only ever surface from a search, which is a
// confusing half-presence.
if isOffered { offered.insert(name) }
}
let categories: [Category] = ordered.compactMap { entry in
guard let key = entry["key"], !excludedCategoryKeys.contains(key),
let symbols = buckets[key], !symbols.isEmpty
else { return nil }
return Category(
key: key,
title: title(forKey: key),
icon: entry["icon"] ?? "square.grid.2x2",
symbols: canonical(symbols)
)
}
guard !categories.isEmpty else { return fallback() }
return Contents(
categories: categories,
allSymbols: canonical(Array(offered)),
keywords: keywords
)
}
private static func plist(_ bundle: Bundle, _ name: String, _ type: String) -> Any? {
guard let path = bundle.path(forResource: name, ofType: type),
let data = FileManager.default.contents(atPath: path)
else { return nil }
return try? PropertyListSerialization.propertyList(from: data, format: nil)
}
/// One synthetic category over the app's own curated vocabulary see `load(bundlePath:)`.
private static func fallback() -> Contents {
let symbols = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
return Contents(
categories: [Category(key: "all", title: "All Symbols", icon: "square.grid.2x2", symbols: symbols)],
allSymbols: symbols,
keywords: [:]
)
}
// MARK: - Search
/// Whether `name` (with `keywords`) matches `query` **pure**, so the AND semantics and the
/// keyword reach are assertable without a panel on screen.
///
/// A query splits into whitespace-separated tokens and **every** token must appear, as a
/// case-insensitive substring, either in the name or in one of the keywords. `SymbolPickerCatalog.
/// filter`'s rule, one dimension wider: that one searches names alone, which is right for a
/// thirty-six-glyph curated grid and useless against nine thousand, where the word a user reaches
/// for ("delete", "password", "wifi") is frequently not in the name at all.
///
/// Tokens are matched independently, so `"arrow down"` finds `arrow.down` *and* anything keyworded
/// both the Spotlight-ish behaviour a search field is expected to have.
static func matches(query tokens: [String], name: String, keywords: [String]) -> Bool {
guard !tokens.isEmpty else { return true }
let lowered = name.lowercased()
let loweredKeywords = keywords.map { $0.lowercased() }
return tokens.allSatisfy { token in
lowered.contains(token) || loweredKeywords.contains { $0.contains(token) }
}
}
/// `query` split into the lowercased tokens `matches(query:name:keywords:)` wants trimmed
/// first, and an empty result of that is "no query" rather than "match nothing", so a freshly
/// opened search field shows everything.
static func tokens(_ query: String) -> [String] {
query.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: { $0.isWhitespace })
.map { $0.lowercased() }
}
/// `symbols` narrowed to those matching `query`, order preserved.
static func search(_ query: String, in symbols: [String], keywords: [String: [String]]) -> [String] {
let tokens = tokens(query)
guard !tokens.isEmpty else { return symbols }
return symbols.filter { matches(query: tokens, name: $0, keywords: keywords[$0] ?? []) }
}
}
+250 -71
View File
@@ -1,10 +1,10 @@
import Foundation import AppKit
import SwiftUI import SwiftUI
/// **A reusable SF Symbol picker** a single well showing the resolved symbol, opening a curated /// **A reusable SF Symbol picker** a two-zone combo showing the resolved symbol, opening a curated
/// grid with a search escape hatch (03-board-ui.md § Styling Controls: "its leading well is the /// grid from its trigger and the standalone browser from its face (03-board-ui.md § Styling
/// level's default symbol and removes the `icon` key Any other SF Symbol name works written by /// Controls: "its leading well is the level's default symbol and removes the `icon` key Any other
/// hand No full-browser escape hatch in-app; the raw file is the escape hatch"). `StyleEditor.swift` /// SF Symbol name works written by hand"). `StyleEditor.swift`
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three /// 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 /// 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 /// control that only ever needs one glyph a saved search, a smart filter, a future per-item
@@ -28,6 +28,24 @@ import SwiftUI
/// general-purpose control cannot assume its 36 will always contain what the caller is after 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 /// search with nothing to search would just move the dead end from "no matching well" to "no way to
/// look further". /// look further".
///
/// ### Shape and grammar (2026-08-09): the two pickers rhyme
///
/// This control used to be a single 20pt bordered square with one hit zone, sitting in a sidebar
/// beside a wide two-zone colour combo that had a *second* door onto `NSColorPanel.shared`. Two
/// controls setting adjacent frontmatter keys, looking and behaving nothing alike. Now it is a
/// `ComboFieldControl` like the colour combo same field, same height, same trailing chevron, by
/// construction rather than by agreement with the same two-zone grammar:
///
/// - **the face** opens `SymbolBrowserPanel`, the standalone searchable/categorised browser, exactly
/// as the colour combo's swatch opens the Colors panel;
/// - **the trigger** pops the curated popover this file always had.
///
/// And that popover gained the two rows the grammar implies: **More Symbols** onto the browser (the
/// dropdown's **Other**, one dimension over and the only *keyboard* route to the face zone's
/// door), and an **Other** under the tint grid onto the Colors panel. The tint grid itself grew from
/// 4×2 to 4×3, which is what the palette went from twelve entries to sixteen to make room for
/// (`Palette.tints`).
// MARK: - The symbol catalogs // MARK: - The symbol catalogs
@@ -52,15 +70,19 @@ enum SymbolPickerCatalog {
/// curated list is a convenience, never a claim about the running system. /// curated list is a convenience, never a claim about the running system.
static var available: [String] { defaultSet.filter(ItemSymbol.exists) } static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
/// The colour row's seven tints `Palette.foregrounds`' hues, minus the four grayscale steps /// The colour row's tints `Palette.tints`' names, which is the icon-tint palette minus the four
/// (a symbol's *tint* wants colour; "no tint" is the None well's job, not a gray's) and minus /// grayscale steps (a symbol's *tint* wants colour; "no tint" is the None well's job, not a
/// `deep-cool-granite`, the mutedest of the eight, dropped so the row plus its leading None /// gray's) and minus `deep-cool-granite`, the mutedest of the ring. Palette names, not hexes,
/// fills the 4×2 grid exactly. Palette names, not hexes, exactly as the style editor's wells /// exactly as the style editor's wells write them.
/// write them. ///
static let colorSet: [String] = [ /// **Eleven now, seven before** the palette grew to sixteen on 2026-08-09 and the exclusions
"carnation", "rich-grapefruit", "smokey-tangerine", "fern", /// did not, so the row gained its third 4-wide row (`SymbolPickerLayout.colorRows`) and its
"light-teal", "deep-sky-blue", "pale-violet", /// leading None still fills the grid exactly. That fit is asserted, not hoped for
] /// (`SymbolPickerColorSetTests`).
///
/// Derived from `Palette` rather than hand-listed: the old constant restated seven names the
/// palette already knew, which is one rename away from a dead well.
static var colorSet: [String] { Palette.tints.map(\.name) }
/// Where the OS keeps the full SF Symbols inventory read-only system metadata, present on /// Where the OS keeps the full SF Symbols inventory read-only system metadata, present on
/// every Mac that ships SF Symbols at all. /// every Mac that ships SF Symbols at all.
@@ -132,9 +154,11 @@ enum SymbolPickerCatalog {
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's /// 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 /// 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 /// 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 /// anchored to the shared base so the two components move together at every text size.
/// 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 /// **The at-rest control's geometry is not here** it is `ComboFieldMetrics`', shared with the
/// colour combo, which is what makes the two the same size (2026-08-09). This type now describes the
/// popover alone. Only the shape wraps a picker's own frame around them six
/// columns fixed (not a /// columns fixed (not a
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s /// 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 /// sidebar/popover split does), and a total padded width that is fixed for the same reason the
@@ -144,17 +168,14 @@ struct SymbolPickerLayout: Equatable {
static let columns = 6 static let columns = 6
static let rows = 6 static let rows = 6
/// The colour row's own shape 4×2, the leading None plus `SymbolPickerCatalog.colorSet`'s /// The colour row's own shape 4×3, the leading None plus `SymbolPickerCatalog.colorSet`'s
/// seven tints. /// eleven tints. Was 4×2 and seven until the palette grew (2026-08-09).
static let colorColumns = 4 static let colorColumns = 4
static let colorRows = 2 static let colorRows = 3
/// The grid's enlargement over the style editor's well size glyphs read at a glance rather /// The grid's enlargement over the style editor's well size glyphs read at a glance rather
/// than in miniature. /// than in miniature.
static let gridScale: CGFloat = 1.3 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 /// 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. /// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
var glyphPointSize: CGFloat var glyphPointSize: CGFloat
@@ -178,14 +199,12 @@ struct SymbolPickerLayout: Equatable {
var popoverWidth: CGFloat var popoverWidth: CGFloat
static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout { static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
let baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) let side = (StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) * gridScale).rounded()
let side = (baseSide * gridScale).rounded()
let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize) let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize) let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded() let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded() let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
return SymbolPickerLayout( return SymbolPickerLayout(
restSide: baseSide,
glyphPointSize: (bodyPointSize * gridScale).rounded(), glyphPointSize: (bodyPointSize * gridScale).rounded(),
wellSide: side, wellSide: side,
wellSpacing: spacing, wellSpacing: spacing,
@@ -201,16 +220,14 @@ struct SymbolPickerLayout: Equatable {
// MARK: - The control // MARK: - The control
/// A single symbol well that opens a curated grid the reusable primitive `03-board-ui.md`'s /// A two-zone symbol combo the reusable primitive a caller can aim at one symbol field without
/// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to /// wiring up a `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView`
/// hand-edit `icon`, but a control any caller can aim at one symbol field without wiring up a /// carries for the board's own background+icon editor.
/// `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 /// **No store, no undo stack, no target set** is known to this type; `onSelect`/`onSelectColor` are
/// the popover content. Nothing about a store, an undo stack, or a target set is known to this type; /// the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding would be.
/// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding /// That is unchanged by the 2026-08-09 rework the API is byte for byte what it was, so every
/// would be. /// existing caller kept working while the control underneath became a `ComboFieldControl`.
struct SymbolPicker: View { struct SymbolPicker: View {
/// The committed symbol name, or `nil` for "no override" read alongside `fallback` rather than /// The committed symbol name, or `nil` for "no override" read alongside `fallback` rather than
@@ -240,63 +257,200 @@ struct SymbolPicker: View {
/// the callers that wanted a symbol picker keep getting exactly one. /// the callers that wanted a symbol picker keep getting exactly one.
var onSelectColor: ((String?) -> Void)? = nil var onSelectColor: ((String?) -> Void)? = nil
@State private var isPresented = false @Environment(\.isEnabled) private var isEnabled
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } /// The curated popover's presented flag **view-local, and a SwiftUI `.popover` deliberately**.
///
/// What the well actually draws `current` if this system can resolve it, `fallback` otherwise. /// The rework's first cut popped an `NSPopover` straight from the control, which is the natural
/// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here /// thing for an `NSView` to do and is wrong here: this picker is mounted *inside* another popover
/// because this control's `current` is already a plain optional string by the time it arrives. /// at one of its anchors (`BoardInfoPopover`, beside the rename field), and a second popover that
private var resolvedName: String { /// takes key status from the first would dismiss the surface it was opened from. SwiftUI already
if let current, ItemSymbol.exists(current) { return current } /// handles that nesting, and did before this card; keeping the presentation where it was is the
return fallback /// change that is not being made.
} @State private var isPopoverPresented = false
/// The at-rest well's tint, or `nil` for the standard one `Palette`'s lenient rule, gated on
/// the colour row being offered at all.
private var resolvedTint: AnyShapeStyle? {
guard onSelectColor != nil, let currentColor, let color = Palette.color(named: currentColor) else { return nil }
return AnyShapeStyle(color)
}
var body: some View { var body: some View {
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize) SymbolComboView(
Button { current: current,
isPresented = true fallback: fallback,
} label: { isEnabled: isEnabled,
Image(systemName: resolvedName) currentColor: onSelectColor == nil ? nil : currentColor,
.imageScale(.medium) onFace: { openBrowser() },
// The tint the board actually renders with, on the well that states the board's onTrigger: { isPopoverPresented = true }
// glyph shown only where the picker offers the colour row, and lenient exactly )
// like the glyph itself: an unresolvable value tints nothing.
.foregroundStyle(resolvedTint ?? AnyShapeStyle(.primary))
.frame(width: layout.restSide, height: layout.restSide)
}
.buttonStyle(.bordered)
.help("Symbol") .help("Symbol")
.accessibilityLabel("Symbol") .accessibilityLabel("Symbol")
.accessibilityValue(resolvedName) .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
.popover(isPresented: $isPresented, arrowEdge: .bottom) {
SymbolPickerPopoverContent( SymbolPickerPopoverContent(
current: current, current: current,
fallback: fallback, fallback: fallback,
symbols: symbols, symbols: symbols,
searchable: searchable, searchable: searchable,
layout: layout, layout: SymbolPickerLayout.metrics(bodyPointSize: CardWindowMetrics.bodyPointSize),
onSelect: { name in onSelect: { name in
isPopoverPresented = false
onSelect(name) onSelect(name)
isPresented = false },
// **More Symbols** the popover's route to the face zone's door, and the only one
// the keyboard has (`ComboField.swift`'s note on why the face has no key of its own).
onBrowse: {
isPopoverPresented = false
openBrowser()
}, },
currentColor: currentColor, currentColor: currentColor,
onSelectColor: onSelectColor.map { select in onSelectColor: onSelectColor.map { select in
{ name in { name in
isPopoverPresented = false
select(name) select(name)
isPresented = false }
},
// **Other** under the tint grid the colour combo's own row, one field over.
onPickCustomColor: onSelectColor.map { select in
{
isPopoverPresented = false
openTintPanel(select)
} }
} }
) )
} }
} }
/// The face zone's door, and **More Symbols**' one call, because they are one gesture spelled
/// two ways (`ComboField.swift`'s grammar).
///
/// The closure handed over **outlives this view on purpose**. Opening the browser dismisses
/// whichever popover this picker was mounted in, which would take a view-owned closure with it and
/// leave the browser picking into nothing; the shared panel keeps the last closure it was given
/// until another surface claims it or its own window closes (`SymbolBrowserPanel`).
private func openBrowser() {
SymbolBrowserPanel.shared.present(current: current, fallback: fallback, onSelect: onSelect)
}
/// The tint's **Other** the shared, debounced Colors panel, for the same
/// outlives-the-popover reason (`SharedColorPanelSession`). Seeded with the live tint, matched
/// against the icon palette so a pick landing on one of its colours stores the *name*.
private func openTintPanel(_ select: @escaping (String?) -> Void) {
SharedColorPanelSession.present(
seed: currentColor.flatMap(Palette.nsColor(for:)),
matching: Palette.foregrounds
) { value in
select(value)
}
}
}
// MARK: - The AppKit bridge
/// `SymbolPicker`'s face: a `SymbolComboControl` with its two zones wired to the caller's closures.
///
/// Deliberately **dumb** it draws and it reports clicks. Every decision about what a click *means*
/// (which popover, which panel, what closure survives what) is `SymbolPicker`'s, in SwiftUI, where
/// the presentation modifiers and the state that drives them already live. `ColorComboView` carries a
/// coordinator because its dropdown is an `NSMenu` that has to be built in AppKit; this one has no
/// such need and gets no such thing.
///
/// `.disabled(_:)` reaches it through `@Environment(\.isEnabled)` on `SymbolPicker` rather than a
/// parameter, since callers already spell the lock that way (`CardStyleSection`, `BoardInfoPopover`).
private struct SymbolComboView: NSViewRepresentable {
let current: String?
let fallback: String
let isEnabled: Bool
/// The live tint, or `nil` for the standard label colour already gated by the caller on whether
/// a colour row is offered at all, so this view has no opinion about it.
let currentColor: String?
let onFace: () -> Void
let onTrigger: () -> Void
/// What the face draws `current` if this system can resolve it, `fallback` otherwise. The
/// lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated because this
/// control's `current` arrives as a plain optional string.
private var resolvedName: String {
if let current, ItemSymbol.exists(current) { return current }
return fallback
}
/// The width an unconstrained measuring pass falls back to a face wide enough for the glyph to
/// sit in with room to read as a *field* rather than a button, plus the trigger. The normal case
/// is a finite proposal (a sidebar row's `.frame(width:)`), which this never has to stand in for.
@MainActor
private var defaultFaceWidth: CGFloat {
let metrics = ComboFieldMetrics.current
return (metrics.height * 2 + metrics.triggerWidth).rounded()
}
func makeNSView(context: Context) -> SymbolComboControl {
SymbolComboControl(frame: .zero)
}
func updateNSView(_ control: SymbolComboControl, context: Context) {
control.metrics = .current
control.isEnabled = isEnabled
control.glyphName = resolvedName
control.glyphTint = currentColor.flatMap(Palette.nsColor(for:))
control.accessibilityValueText = resolvedName
// Rewired on every update rather than once in `makeNSView`: the closures close over this
// struct's current values, and a stale one would aim the browser at the field this picker
// used to be pointed at.
control.onFaceClick = onFace
control.onTriggerClick = onTrigger
}
/// Obeys whatever width SwiftUI proposes, like any other control `ColorComboView.sizeThatFits`'
/// rule, restated so the two combos answer a proposal identically and a caller that frames them
/// alike gets two controls the same size.
func sizeThatFits(_ proposal: ProposedViewSize, nsView: SymbolComboControl, context: Context) -> CGSize? {
let width: CGFloat
if let proposed = proposal.width, proposed.isFinite {
width = proposed
} else {
width = defaultFaceWidth
}
return CGSize(width: width, height: nsView.intrinsicContentSize.height)
}
}
// MARK: - The face
/// `ComboFieldControl` with a **glyph** in its face zone the symbol half of the shared chrome, and
/// the whole of what is specific to symbols about it.
final class SymbolComboControl: ComboFieldControl {
/// The resolved name the face draws the coordinator's `resolvedName`, handed over on every
/// SwiftUI update.
var glyphName: String = ItemSymbol.card {
didSet {
guard glyphName != oldValue else { return }
needsDisplay = true
}
}
/// The live `iconColor`, or `nil` for the standard label colour. Lenient exactly like the glyph:
/// an unresolvable value tints nothing.
var glyphTint: NSColor? {
didSet {
guard glyphTint != oldValue else { return }
needsDisplay = true
}
}
/// The glyph, centred and tinted. A name this system cannot draw falls back to the dashed
/// question mark the wells use the same "show that there is nothing here" the grids draw.
override func drawFace(in rect: NSRect) {
let name = ItemSymbol.exists(glyphName) ? glyphName : "questionmark.square.dashed"
let config = NSImage.SymbolConfiguration(pointSize: metrics.glyphPointSize, weight: .regular)
.applying(.init(paletteColors: [glyphTint ?? .labelColor]))
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
.withSymbolConfiguration(config)
else { return }
let size = image.size
image.draw(in: NSRect(
x: rect.minX + metrics.facePaddingH,
y: rect.midY - size.height / 2,
width: size.width,
height: size.height
))
}
} }
// MARK: - The popover's content // MARK: - The popover's content
@@ -312,9 +466,14 @@ private struct SymbolPickerPopoverContent: View {
let searchable: Bool let searchable: Bool
let layout: SymbolPickerLayout let layout: SymbolPickerLayout
let onSelect: (String?) -> Void let onSelect: (String?) -> Void
/// Opens the standalone browser the popover's route to the face zone's door, present whenever
/// the caller wired one.
var onBrowse: (() -> Void)? = nil
var currentColor: String? = nil var currentColor: String? = nil
/// `nil` is "no colour row" `SymbolPicker.onSelectColor`'s opt-in, passed through. /// `nil` is "no colour row" `SymbolPicker.onSelectColor`'s opt-in, passed through.
var onSelectColor: ((String?) -> Void)? = nil var onSelectColor: ((String?) -> Void)? = nil
/// Opens the Colors panel for the tint. Wired only where the colour row is.
var onPickCustomColor: (() -> Void)? = nil
@State private var query = "" @State private var query = ""
@@ -324,17 +483,36 @@ private struct SymbolPickerPopoverContent: View {
searchField searchField
} }
resultBody resultBody
if let onBrowse {
deeperRow("More Symbols…", action: onBrowse)
}
// The colour row rides below whichever grid is up a search narrows the symbols, not // The colour row rides below whichever grid is up a search narrows the symbols, not
// the tints, so the row keeps standing where the eye left it. // the tints, so the row keeps standing where the eye left it.
if let onSelectColor { if let onSelectColor {
Divider() Divider()
SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor) SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor)
if let onPickCustomColor {
deeperRow("Other…", action: onPickCustomColor)
}
} }
} }
.padding(layout.contentPadding) .padding(layout.contentPadding)
.frame(width: layout.popoverWidth) .frame(width: layout.popoverWidth)
} }
/// A row onto a standalone picker **More Symbols** under the glyph grid, **Other** under the
/// tint grid. Trailing-aligned link buttons rather than extra wells in the grids above them: a
/// well that is not a value is a well that has to be *learned*, and the colour combo's dropdown
/// already spells this door as a titled row.
private func deeperRow(_ title: String, action: @escaping () -> Void) -> some View {
HStack(spacing: 0) {
Spacer(minLength: 0)
Button(title, action: action)
.buttonStyle(.link)
.font(.caption)
}
}
private var searchField: some View { private var searchField: some View {
TextField("Search Symbols", text: $query) TextField("Search Symbols", text: $query)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
@@ -534,7 +712,8 @@ private struct SymbolColorWell: Identifiable {
} }
/// The tint grid under the symbol grid the leading **None** well and /// The tint grid under the symbol grid the leading **None** well and
/// `SymbolPickerCatalog.colorSet`'s seven tints, 4×2 (the board popover's colour row, 2026-08-07). /// `SymbolPickerCatalog.colorSet`'s eleven tints, 4×3 (the board popover's colour row, 2026-08-07;
/// grown by a row with the palette on 2026-08-09).
/// `StyleWellGrid`'s pattern one more time, and `SymbolWellGrid`'s reason for restating it: the /// `StyleWellGrid`'s pattern one more time, and `SymbolWellGrid`'s reason for restating it: the
/// style editor's grids are that file's own, and this row's shape (wide swatches on a fixed /// style editor's grids are that file's own, and this row's shape (wide swatches on a fixed
/// four-column re-division of the symbol grid's width) fits neither. /// four-column re-division of the symbol grid's width) fits neither.
+124
View File
@@ -0,0 +1,124 @@
import AppKit
/// **The shared Colors panel, as one takeover every surface can borrow** `NSColorPanel.shared`
/// seeded, targeted, and translated back into the app's stored-value vocabulary.
///
/// This is `ColorComboView.Coordinator`'s panel handling, lifted out of it. That coordinator was the
/// only thing in the app that opened the system picker, so the logic could live inside it; now three
/// surfaces do the colour combo's face and its **Other** row, the Style popover's Background
/// section, and the symbol popover's tint section (2026-08-09) and the parts that must not be
/// re-derived are the awkward ones: `NSColorPanel` is a **singleton with a settable target and no
/// getter for it**, so "is the panel still mine to detach from" has nowhere to live but in a static,
/// and a second surface taking the panel over has to leave the first one's teardown harmless.
///
/// ### What it hands back
///
/// Not an `NSColor` the **stored string**: the palette name when the picked colour lands exactly
/// on one of the caller's own palette entries, `#RRGGBB[AA]` otherwise. That rule (name wins over
/// hex) is the same one `ColorComboModel.match` applies to a value already on disk, which is what
/// makes a colour survive a round trip through the panel without drifting from `light-cayenne` into
/// an anonymous `#B6071E`.
///
/// ### It fires continuously
///
/// `NSColorPanel`'s action runs on every tick of a drag, not on release, and that is deliberate: a
/// live preview is the point of opening the panel at all. **Every caller therefore owes a debounce**
/// before the value reaches disk `CardStyleSection.debounceBackground` is the pattern, ~400ms
/// trailing, and a drag that passes through a palette-exact colour mid-gesture must not be recorded
/// in `StyleRecents` the way a deliberate pick is.
@MainActor
final class SystemColorPanel: NSObject {
/// Whichever instance most recently took the shared panel over. `weak`, so an owner that never
/// got around to detaching a window closed from under it does not keep the next one from
/// being collected either.
private static weak var currentOwner: SystemColorPanel?
private var onChange: ((String) -> Void)?
/// The palette a picked colour is matched against before it is written the caller's own role
/// table, so a background pick checks the backgrounds and a tint pick checks the tints.
private var palette: [PaletteColor] = []
/// Seeds the panel with `seed` (black when the field carries no resolvable colour), takes it
/// over, and asks for continuous updates.
///
/// Opening it again from the same owner re-seeds rather than stacking the panel is one window
/// and this is how a second click on the same face behaves.
func present(
seed: NSColor?,
matching palette: [PaletteColor],
showsAlpha: Bool = true,
onChange: @escaping (String) -> Void
) {
self.onChange = onChange
self.palette = palette
let panel = NSColorPanel.shared
panel.showsAlpha = showsAlpha
panel.color = seed ?? .black
panel.setTarget(self)
panel.setAction(#selector(changeColor(_:)))
Self.currentOwner = self
panel.makeKeyAndOrderFront(nil)
}
/// Releases the panel **only if this instance still holds it** last-writer-wins, so a view
/// torn down after another surface took the panel over does not silently unhook that surface.
func detach() {
guard Self.currentOwner === self else { return }
let panel = NSColorPanel.shared
panel.setTarget(nil)
panel.setAction(nil)
Self.currentOwner = nil
onChange = nil
}
/// The panel's own action. A colour no sRGB conversion can express is dropped rather than
/// guessed at `paletteHexString`'s `nil`, which no picker swatch actually produces.
@objc private func changeColor(_ sender: NSColorPanel) {
guard let hex = sender.color.paletteHexString else { return }
onChange?(Palette.name(forHex: hex, in: palette) ?? hex)
}
}
// MARK: - The session a transient surface borrows
/// **The Colors panel for surfaces that do not outlive it, debounce included.**
///
/// The obvious shape is a `SystemColorPanel` in the opening view's own state, and that is exactly
/// what `CardStyleSection` does correctly, because a card window's sidebar outlives any panel
/// opened from it. It does **not** work for the two surfaces that gained an **Other** on
/// 2026-08-09: the Style popover's Background section and the symbol popover's tint section are
/// both inside *transient popovers*. Opening the Colors panel takes key status away, the popover
/// dismisses, the view's state goes with it and the panel the user is now dragging in has nothing
/// left to talk to. The gesture would break itself.
///
/// So the session lives above every view: one panel, one pending write, replaced by whichever
/// surface opened it last. Callers capture their store **weakly**, so a board closed while its
/// Colors panel is still up drops the write instead of resurrecting a dead store.
///
/// The debounce is `CardStyleSection.debounceBackground`'s, restated because the state it needs now
/// has to live somewhere a dismissed popover cannot take with it: the panel's action is continuous,
/// and only the value the user is still on ~400ms after the last tick reaches disk. A drag is
/// therefore one write, and never feeds `StyleRecents` that row remembers deliberate palette
/// picks, not colours a drag swept through.
@MainActor
enum SharedColorPanelSession {
private static let panel = SystemColorPanel()
private static var commit: Task<Void, Never>?
static func present(
seed: NSColor?,
matching palette: [PaletteColor],
write: @escaping (String) -> Void
) {
panel.present(seed: seed, matching: palette) { value in
commit?.cancel()
commit = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
write(value)
}
}
}
}
+3 -3
View File
@@ -98,7 +98,7 @@ struct ColorComboTests {
} }
/// A name from the *other* picker's table `carnation` is foreground-only is not one of /// A name from the *other* picker's table `carnation` is foreground-only is not one of
/// `.background`'s twelve, so it falls to the dynamic row, titled with its own display name /// `.background`'s own table, so it falls to the dynamic row, titled with its own display name
/// since the other table does know it. /// since the other table does know it.
@Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() { @Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
let match = ColorComboModel.match(role: .background, value: "carnation") let match = ColorComboModel.match(role: .background, value: "carnation")
@@ -132,7 +132,7 @@ struct ColorComboTests {
struct Menu { struct Menu {
/// None first, a separator, then exactly the role's twelve, in the palette's own order. /// None first, a separator, then exactly the role's own entries, in the palette's own order.
@Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() { @Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
let menu = ColorComboModel.menu(role: .background, value: nil) let menu = ColorComboModel.menu(role: .background, value: nil)
var expected: [ColorComboItem] = [.none, .separator] var expected: [ColorComboItem] = [.none, .separator]
@@ -159,7 +159,7 @@ struct ColorComboTests {
} }
/// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or /// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
/// one of the role's own twelve. /// one of the role's own entries.
@Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() { @Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
let none = ColorComboModel.menu(role: .background, value: nil) let none = ColorComboModel.menu(role: .background, value: nil)
#expect(!none.items.contains { if case .current = $0 { return true }; return false }) #expect(!none.items.contains { if case .current = $0 { return true }; return false })
+14 -10
View File
@@ -21,8 +21,8 @@ import Testing
/// paints (palette name and hand-written hex alike, one path), that a translucent one resolves /// paints (palette name and hand-written hex alike, one path), that a translucent one resolves
/// differently in the two appearances, and that a board painting nothing is left alone. /// differently in the two appearances, and that a board painting nothing is left alone.
/// ///
/// The palette's own suite closes the loop. 03-board-ui.md Styling Controls promises the twelve /// The palette's own suite closes the loop. 03-board-ui.md Styling Controls promises the palette
/// wells are "AA-verified at design time pinned by a computed-contrast unit test over all 12 /// wells are "AA-verified at design time pinned by a computed-contrast unit test over all
/// pairs"; a pair is a background *and its ink*, so the promise is only keepable by checking the ink /// pairs"; a pair is a background *and its ink*, so the promise is only keepable by checking the ink
/// the seam chooses which `PaletteContrastTests` does, in both appearances. That is the whole of /// the seam chooses which `PaletteContrastTests` does, in both appearances. That is the whole of
/// the difference between the two paths: the palette is a fixed set and can be checked in advance, /// the difference between the two paths: the palette is a fixed set and can be checked in advance,
@@ -384,7 +384,7 @@ struct BoardTextInkTests {
#expect(BoardTextInk.paintedColor(.valid("#1e1e1e")) != nil) #expect(BoardTextInk.paintedColor(.valid("#1e1e1e")) != nil)
#expect(BoardTextInk.paintedColor(.valid("smokey-ocean")) != nil) #expect(BoardTextInk.paintedColor(.valid("smokey-ocean")) != nil)
#expect(BoardTextInk.paintedColor(.valid("chalk")) != nil) #expect(BoardTextInk.paintedColor(.valid("chalk")) != nil)
// A foreground-table name in the `background` field resolves, because the 12+12 split is a // A foreground-table name in the `background` field resolves, because the two-table split is a
// picker split and not a namespace (`Palette`) so it paints, so it decides an ink. // picker split and not a namespace (`Palette`) so it paints, so it decides an ink.
#expect(BoardTextInk.paintedColor(.valid("carnation")) != nil) #expect(BoardTextInk.paintedColor(.valid("carnation")) != nil)
#expect(BoardTextInk.paintedColor(.valid("#12345")) == nil) #expect(BoardTextInk.paintedColor(.valid("#12345")) == nil)
@@ -479,21 +479,25 @@ struct BoardTextInkTests {
struct PaletteContrastTests { struct PaletteContrastTests {
/// **03-board-ui.md Styling Controls' promise, computed and this test *is* the promise**: /// **03-board-ui.md Styling Controls' promise, computed and this test *is* the promise**:
/// "the background grid offers the 12 palette colors every pair AA-verified at design time /// "the background grid offers the palette colors every pair AA-verified at design time
/// (10-accessibility.md), the claim pinned by a computed-contrast unit test over all 12 pairs so /// (10-accessibility.md), the claim pinned by a computed-contrast unit test over all pairs so
/// palette drift can never silently break it." /// palette drift can never silently break it."
/// ///
/// **This is also the gate the palette grew through.** Four backgrounds were added on 2026-08-09;
/// the reason that was a safe thing to do is that a new well with no readable ink fails here
/// rather than shipping, which is exactly the first failure mode named below.
///
/// A **pair** is a background and the ink its text is drawn in, so the claim cannot be settled by /// A **pair** is a background and the ink its text is drawn in, so the claim cannot be settled by
/// a table of colours alone only by the code that chooses the ink. What is asserted here is /// a table of colours alone only by the code that chooses the ink. What is asserted here is
/// therefore end to end and in both appearances: for every well, the scheme `BoardTextInk` /// therefore end to end and in both appearances: for every well, the scheme `BoardTextInk`
/// *selects* clears 4.5:1 on the colour that well paints. Nothing weaker would be the design's /// *selects* clears 4.5:1 on the colour that well paints. Nothing weaker would be the design's
/// claim; nothing stronger is true, since no single ink reads on all twelve. /// claim; nothing stronger is true, since no single ink reads on all of them.
/// ///
/// Two ways to fail, both of them the point. A **new well** whose colour has no readable ink at /// Two ways to fail, both of them the point. A **new well** whose colour has no readable ink at
/// all a mid-grey, the dead zone `InkChoiceTests.neitherPassingTakesTheHigherRatio` documents /// all a mid-grey, the dead zone `InkChoiceTests.neitherPassingTakesTheHigherRatio` documents
/// fails here instead of shipping. And a regression in the *selection* fails here too: with the /// fails here instead of shipping. And a regression in the *selection* fails here too: with the
/// appearance-native label, which is what the board drew before this card, every one of the /// appearance-native label, which is what the board drew before this card, every one of the
/// twelve failed in one appearance (ten dark wells under Aqua, `chalk` and `aluminum` under Dark /// wells failed in one appearance (every dark well under Aqua, `chalk` and `aluminum` under Dark
/// Aqua), so this test would have caught the m4 bug it now guards against returning. /// Aqua), so this test would have caught the m4 bug it now guards against returning.
@Test("The ink the board picks clears 4.5:1 on every palette background, in both appearances") @Test("The ink the board picks clears 4.5:1 on every palette background, in both appearances")
func everyPaletteBackgroundHasAReadableInk() throws { func everyPaletteBackgroundHasAReadableInk() throws {
@@ -528,8 +532,8 @@ struct PaletteContrastTests {
/// The other half of "verified at design time": **the appearance-native label is not enough**, /// The other half of "verified at design time": **the appearance-native label is not enough**,
/// which is why the selection has to happen at all. /// which is why the selection has to happen at all.
/// ///
/// Every one of the twelve wells is a colour the system's own label fails on in one of the two /// Every well is a colour the system's own label fails on in one of the two
/// appearances the ten dark ones under Aqua, `chalk` and `aluminum` under Dark Aqua. Stating /// appearances the dark ones under Aqua, `chalk` and `aluminum` under Dark Aqua. Stating
/// it as a test keeps the reasoning from decaying into folklore: if a future palette were tame /// it as a test keeps the reasoning from decaying into folklore: if a future palette were tame
/// enough that the native label always worked, this would fail and the seam's board-side wiring /// enough that the native label always worked, this would fail and the seam's board-side wiring
/// could be reconsidered rather than carried on faith. /// could be reconsidered rather than carried on faith.
@@ -548,7 +552,7 @@ struct PaletteContrastTests {
} }
} }
/// The twelve are opaque, which is why the design can verify them at all: a palette well with /// Every well is opaque, which is why the design can verify them at all: a palette well with
/// alpha would make its own contrast a function of the appearance's window background, and /// alpha would make its own contrast a function of the appearance's window background, and
/// "verified at design time" would stop being a statement anyone could check. /// "verified at design time" would stop being a statement anyone could check.
@Test("No palette background carries alpha") @Test("No palette background carries alpha")
+226
View File
@@ -0,0 +1,226 @@
import AppKit
import Foundation
import Testing
@testable import Kanban
/// **An arbitrary colour picked from the system panel, all the way to disk and back** (2026-08-09,
/// the card that put an **Other** on the Style popover and the symbol popover's tint row).
///
/// The guarantee already held `FrontmatterValue.emitScalar` quotes a `#` because YAML says it must,
/// `BackgroundField.flowText` quotes unconditionally, `Palette.nsColor(for:)` takes the hex branch
/// but it held as an *emergent property of four files that do not mention each other*. Nothing
/// asserted it end to end, and the one link that would break silently is the sharpest: `#` opens a
/// YAML comment, so an unquoted `background: #2E1A51` parses as a key with **no value at all**. A
/// colour would vanish on save and the file would still be valid YAML.
///
/// These are the assertions that make the chain a contract rather than a coincidence.
// MARK: - The codec
@Suite("Custom colour ▸ the hex codec")
struct CustomColorCodecTests {
/// `NSColor paletteHexString NSColor` is the identity on any sRGB colour the panel can
/// return, to within the 8-bit quantisation the format has.
@Test("A panel colour survives the trip to hex and back")
func opaqueColoursRoundTrip() throws {
for (red, green, blue) in [
(0.0, 0.0, 0.0), (1.0, 1.0, 1.0), (0.18, 0.10, 0.32), (0.42, 0.71, 0.60), (1.0, 0.0, 0.5),
] {
let picked = NSColor(srgbRed: red, green: green, blue: blue, alpha: 1)
let hex = try #require(picked.paletteHexString, "no hex for \(red),\(green),\(blue)")
let parsed = try #require(NSColor(paletteHex: hex))
let tolerance = 1.0 / 255 + 0.0001
#expect(abs(parsed.redComponent - red) < tolerance)
#expect(abs(parsed.greenComponent - green) < tolerance)
#expect(abs(parsed.blueComponent - blue) < tolerance)
#expect(parsed.alphaComponent == 1)
}
}
/// **Full opacity collapses to six digits.** A colour the user never touched the opacity slider
/// on has to be written exactly as a curated palette entry would be, or it would never match one
/// (`ColorComboModel.match` compares normalized strings) and a panel pick that landed dead on
/// `fern` would store an anonymous hex instead of the name.
@Test("Opacity is written only when there is some")
func alphaCollapsesAtFullOpacity() throws {
let opaque = NSColor(srgbRed: 0.5, green: 0.25, blue: 0.75, alpha: 1)
#expect(opaque.paletteHexString == "#8040BF")
let translucent = NSColor(srgbRed: 0.5, green: 0.25, blue: 0.75, alpha: 0.5)
#expect(translucent.paletteHexString == "#8040BF80")
#expect(try #require(NSColor(paletteHex: "#8040BF80")).alphaComponent == 128.0 / 255)
}
/// A panel colour landing exactly on a palette entry comes back as the **name**, in both
/// tables the rule `SystemColorPanel` applies before handing a value to a caller, and the
/// reason picking `Light Cayenne` from the panel twice does not drift into `#B6071E`.
@Test("A palette-exact pick resolves to the palette name, never the hex")
func paletteExactPicksResolveToNames() throws {
for entry in Palette.backgrounds {
let picked = try #require(NSColor(paletteHex: entry.hex))
let hex = try #require(picked.paletteHexString)
#expect(Palette.name(forHex: hex, in: Palette.backgrounds) == entry.name)
}
for entry in Palette.foregrounds {
let picked = try #require(NSColor(paletteHex: entry.hex))
let hex = try #require(picked.paletteHexString)
#expect(Palette.name(forHex: hex, in: Palette.foregrounds) == entry.name)
}
}
/// A colour that is *not* a palette entry stays a hex the other half of the same rule, which
/// would otherwise be satisfied by a function that always returned a name.
@Test("An off-palette pick stays a hex")
func offPaletteStaysHex() throws {
let picked = NSColor(srgbRed: 0.181, green: 0.102, blue: 0.318, alpha: 1)
let hex = try #require(picked.paletteHexString)
#expect(Palette.name(forHex: hex, in: Palette.backgrounds) == nil)
#expect(Palette.name(forHex: hex, in: Palette.foregrounds) == nil)
#expect(Palette.nsColor(for: hex) != nil, "a hex the pickers store must still resolve")
}
}
// MARK: - The YAML boundary
/// **The one place the chain could break silently**: `#` is YAML's comment introducer, so a hex
/// emitted as a plain scalar is a key with no value.
@Suite("Custom colour ▸ the YAML boundary")
struct CustomColorEmissionTests {
@Test("A hex is emitted quoted, because unquoted it would be a comment")
func hexIsQuoted() {
#expect(FrontmatterValue.string("#2E1A51").yamlText == "\"#2E1A51\"")
#expect(FrontmatterValue.string("#2E1A5180").yamlText == "\"#2E1A5180\"")
// The control: a palette name needs no quoting and does not get any.
#expect(FrontmatterValue.string("smokey-ocean").yamlText == "smokey-ocean")
}
/// Proof that the quoting is *necessary*, not merely present the emitter's round-trip probe is
/// only as good as the claim it is testing, so state the claim.
@Test("Unquoted, a hex really does parse as nothing")
func unquotedHexParsesAsNothing() throws {
let document = try FrontmatterDocument.parse("---\niconColor: #2E1A51\n---\nBody.\n")
#expect(document.iconColor.value == nil, "an unquoted hex must not be readable as a colour")
let quoted = try FrontmatterDocument.parse("---\niconColor: \"#2E1A51\"\n---\nBody.\n")
#expect(quoted.iconColor.value == "#2E1A51")
}
}
// MARK: - End to end, on disk
@MainActor
@Suite("Custom colour ▸ through the store to disk")
struct CustomColorWriteTests {
private func fixture() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", """
---
schema: 1
title: Board
---
Board description.
""")
try fixture.item("11111111-1111-4111-8111-111111111111", """
---
schema: 1
title: Todo
order: 1024
---
Lane body.
""")
return fixture
}
private let lane = ItemID(rawValue: "11111111-1111-4111-8111-111111111111")
/// A hex chosen from the panel lands on disk **quoted**, and the app's own reader gets the same
/// colour back out. The `background` half a flow mapping, always double-quoted.
@Test("An arbitrary background hex round-trips through a real write")
func backgroundHexRoundTrips() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let picked = NSColor(srgbRed: 0.181, green: 0.102, blue: 0.318, alpha: 1)
let value = try #require(picked.paletteHexString)
store.applyStyle(to: .items([lane]), background: .set(value), icon: .keep)
let text = try fixture.indexText("11111111-1111-4111-8111-111111111111")
#expect(text.contains("background: {color: \"\(value)\"}"), "written as: \(text)")
let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
let written = try #require(reloaded.lanes.first { $0.id == lane })
#expect(written.background == .valid(value))
// To within the 8-bit quantisation `#RRGGBB` has the format's own precision, not a
// slackness in the round trip.
let resolved = try #require(Palette.nsColor(for: value))
let tolerance = 1.0 / 255 + 0.0001
#expect(abs(resolved.redComponent - picked.redComponent) < tolerance)
#expect(abs(resolved.greenComponent - picked.greenComponent) < tolerance)
#expect(abs(resolved.blueComponent - picked.blueComponent) < tolerance)
#expect(resolved.alphaComponent == 1)
}
/// The `iconColor` half a plain scalar, so the quoting is `emitScalar`'s round-trip probe
/// rather than an unconditional rule. Different code path, same guarantee.
@Test("An arbitrary icon-tint hex round-trips through a real write")
func iconColorHexRoundTrips() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let picked = NSColor(srgbRed: 0.85, green: 0.32, blue: 0.74, alpha: 1)
let value = try #require(picked.paletteHexString)
store.applyStyle(to: .items([lane]), background: .keep, icon: .keep, iconColor: .set(value))
let text = try fixture.indexText("11111111-1111-4111-8111-111111111111")
#expect(text.contains("iconColor: \"\(value)\""), "written as: \(text)")
let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
let written = try #require(reloaded.lanes.first { $0.id == lane })
#expect(written.iconColor == .valid(value))
#expect(Palette.color(for: written.iconColor) != nil)
}
/// A translucent pick the panel's opacity slider keeps its alpha across the trip. Eight
/// digits is a shape the schema names (`#RRGGBB[AA]`) and the one the emitter has to quote just
/// as carefully.
@Test("A translucent pick keeps its alpha on disk")
func translucentHexRoundTrips() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let picked = NSColor(srgbRed: 0.2, green: 0.4, blue: 0.6, alpha: 0.5)
let value = try #require(picked.paletteHexString)
#expect(value.count == 9, "a translucent colour must carry its alpha pair")
store.applyStyle(to: .items([lane]), background: .set(value), icon: .keep)
let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
let written = try #require(reloaded.lanes.first { $0.id == lane })
let stored = try #require(written.background.value)
let colour = try #require(Palette.nsColor(for: stored))
#expect(abs(colour.alphaComponent - 0.5) < 1.0 / 255 + 0.0001)
}
/// Every one of the four new palette colours is a value the write path handles as a **name**,
/// not as the hex it stands for the additions joined the vocabulary, they did not become a
/// special case.
@Test("The 2026-08-09 palette additions write as names")
func newPaletteNamesWriteAsNames() throws {
let fixture = try fixture()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
for name in ["smokey-lime", "dark-jade", "smokey-indigo", "smokey-magenta"] {
store.applyStyle(to: .items([lane]), background: .set(name), icon: .keep)
let text = try fixture.indexText("11111111-1111-4111-8111-111111111111")
#expect(text.contains("background: {color: \"\(name)\"}"), "written as: \(text)")
let reloaded = try BoardLoader.load(boardRoot: fixture.root).model
let written = try #require(reloaded.lanes.first { $0.id == lane })
#expect(Palette.color(for: written.background) != nil, "'\(name)' did not resolve after a round trip")
}
}
}
+55 -5
View File
@@ -15,19 +15,69 @@ import Testing
struct PaletteTableTests { struct PaletteTableTests {
@Test func bothTablesHoldTheTwelveNamedColoursTheDesignCarriesOver() { /// The pathfinder's twelve, plus the four 2026-08-09 hue-gap fills, each **at its hue position**
/// rather than appended the order is the ring, and this list is what pins it.
@Test func bothTablesHoldTheSixteenNamedColoursInHueOrder() {
#expect(Palette.foregrounds.map(\.name) == [ #expect(Palette.foregrounds.map(\.name) == [
"obsidian", "aluminum", "soapstone", "chalk", "obsidian", "aluminum", "soapstone", "chalk",
"carnation", "rich-grapefruit", "smokey-tangerine", "fern", "carnation", "rich-grapefruit", "smokey-tangerine", "rich-lime",
"light-teal", "deep-sky-blue", "pale-violet", "deep-cool-granite", "fern", "light-jade", "light-teal", "deep-sky-blue",
"rich-indigo", "pale-violet", "rich-magenta", "deep-cool-granite",
]) ])
#expect(Palette.backgrounds.map(\.name) == [ #expect(Palette.backgrounds.map(\.name) == [
"obsidian", "shale", "aluminum", "chalk", "obsidian", "shale", "aluminum", "chalk",
"light-cayenne", "light-mocha", "smokey-mocha", "smokey-fern", "light-cayenne", "light-mocha", "smokey-mocha", "smokey-lime",
"dark-teal", "smokey-ocean", "smokey-rich-eggplant", "intense-cool-shale", "smokey-fern", "dark-jade", "dark-teal", "smokey-ocean",
"smokey-indigo", "smokey-rich-eggplant", "smokey-magenta", "intense-cool-shale",
]) ])
} }
/// The two tables are **one structure** (`Palette`'s own doc comment): four neutrals plus a
/// twelve-stop ring each, paired stop for stop. A table grown on one side alone would break the
/// pairing silently every picker would still work, and the design would quietly stop being a
/// design.
@Test func theTwoTablesAreTheSameShape() {
#expect(Palette.foregrounds.count == 16)
#expect(Palette.backgrounds.count == 16)
#expect(Set(Palette.foregrounds.map(\.name)).count == 16, "a name is listed twice")
#expect(Set(Palette.backgrounds.map(\.name)).count == 16, "a name is listed twice")
// Four neutrals lead each table; the twelve after them are the ring.
#expect(Palette.foregrounds.dropFirst(4).count == 12)
#expect(Palette.backgrounds.dropFirst(4).count == 12)
}
/// Every hex is spelled the one way the app emits them uppercase `#RRGGBB` so a panel pick
/// that lands on a palette colour matches by string (`ColorComboModel.match`'s hex branch) and
/// comes back as the *name*. A lowercase entry would still resolve and would still round-trip;
/// it would just quietly stop being recognised as the palette colour it is.
@Test func everyHexIsSixUppercaseDigits() {
for entry in Palette.foregrounds + Palette.backgrounds {
#expect(entry.hex == entry.hex.uppercased(), "'\(entry.name)' is not uppercase")
#expect(entry.hex.count == 7 && entry.hex.hasPrefix("#"), "'\(entry.name)' is not #RRGGBB")
let digitsAreHex = entry.hex.dropFirst().allSatisfy { $0.isHexDigit }
#expect(digitsAreHex, "'\(entry.name)' has a non-hex digit")
}
}
/// The tint row's source: the icon palette minus the four greys and minus `deep-cool-granite`.
///
/// **Eleven is load-bearing**, not incidental `SymbolPickerLayout`'s colour grid is four wide
/// and its leading None takes the first cell, so eleven is exactly what fills three rows. The
/// palette grew to sixteen partly to make that true; this is the assertion that keeps the two
/// facts tied together.
@Test func theTintRingIsTheIconPaletteWithoutItsNeutrals() {
#expect(Palette.tints.map(\.name) == [
"carnation", "rich-grapefruit", "smokey-tangerine", "rich-lime",
"fern", "light-jade", "light-teal", "deep-sky-blue",
"rich-indigo", "pale-violet", "rich-magenta",
])
#expect(Palette.tints.count == SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
// Every tint is a foreground, and no grey slipped in.
for tint in Palette.tints {
#expect(Palette.foregrounds.contains { $0.name == tint.name && $0.hex == tint.hex })
}
}
@Test func everyPaletteNameResolvesToItsOwnHex() throws { @Test func everyPaletteNameResolvesToItsOwnHex() throws {
for entry in Palette.foregrounds + Palette.backgrounds { for entry in Palette.foregrounds + Palette.backgrounds {
let byName = try #require( let byName = try #require(
+312
View File
@@ -0,0 +1,312 @@
import AppKit
import Testing
@testable import Kanban
/// **`SymbolCatalog`'s pure seams** the OS category read behind `SymbolBrowserPanel` (2026-08-09).
/// The panel itself, its sidebar and its grid are deliberately untested, exactly as every other
/// SwiftUI surface in this app is; what is asserted here is the shape of the data it browses and the
/// search that narrows it.
///
/// The load reads real system plists, so a few of these are assertions about **this machine's**
/// SF Symbols inventory. That is the point rather than a compromise: the whole reason the categories
/// are read instead of hand-written is that the OS's answer is the true one, and a test that stubbed
/// it would only be checking the stub.
@Suite("SymbolCatalog ▸ the categories")
struct SymbolCatalogCategoryTests {
@Test("The system load yields many categories, each non-empty and uniquely keyed")
func categoriesLoad() {
let categories = SymbolCatalog.categories
#expect(categories.count > 10, "only \(categories.count) categories — the read has degraded to its fallback")
#expect(Set(categories.map(\.key)).count == categories.count, "a category key is listed twice")
for category in categories {
#expect(!category.symbols.isEmpty, "'\(category.key)' is empty")
#expect(!category.title.isEmpty, "'\(category.key)' has no title")
}
}
/// The five non-semantic categories are dropped see `SymbolCatalog`'s doc comment. `multicolor`
/// is the one that would hurt most if it came back: it is the largest category in the file and
/// classifies rendering, not subject.
@Test("Rendering-mode and vintage categories are not offered")
func nonSemanticCategoriesAreExcluded() {
let keys = Set(SymbolCatalog.categories.map(\.key))
for excluded in SymbolCatalog.excludedCategoryKeys {
#expect(!keys.contains(excluded), "'\(excluded)' should not be browsable")
}
}
/// Every offered category has a hand-written title the one curated constant in this file, and
/// the one that decays silently: a key gaining no entry falls back to a title-cased key, which
/// reads as "Objectsandtools" rather than failing.
@Test("Every offered category has a real title, not the title-cased fallback")
func everyCategoryHasATitle() {
for category in SymbolCatalog.categories {
#expect(
SymbolCatalog.categoryTitles[category.key] != nil,
"no title for '\(category.key)' — add one to SymbolCatalog.categoryTitles"
)
}
}
/// Each category's representative glyph is one this system can draw. It is a name out of the OS's
/// own plist, so a failure here means the read is misaligned with the running inventory rather
/// than that somebody made a typo.
@Test("Every category icon renders on this OS")
func categoryIconsRender() {
let missing = SymbolCatalog.categories.filter { !ItemSymbol.exists($0.icon) }
#expect(missing.isEmpty, "unrenderable category icons: \(missing.map(\.key))")
}
/// A spot check that the categories mean what they say `leaf` under Nature, `trash` under
/// Objects & Tools, `arrow.up` under Arrows. Cheap, and it would catch a key/value transposition
/// that every structural assertion above would sail through.
@Test("Well-known glyphs sit in the categories a user would look in")
func membershipIsPlausible() throws {
func symbols(_ key: String) throws -> [String] {
try #require(SymbolCatalog.categories.first { $0.key == key }, "no '\(key)' category").symbols
}
#expect(try symbols("nature").contains("leaf"))
#expect(try symbols("objectsandtools").contains("trash"))
#expect(try symbols("arrows").contains("arrow.up"))
#expect(try symbols("time").contains("timer"))
}
/// Trademark-restricted glyphs are not offered `SymbolCatalog`'s second ruling. `applelogo` is
/// the clearest case; `icloud` is the one a picker would plausibly have surfaced by accident.
@Test("Trademark-restricted glyphs are not offered")
func restrictedSymbolsAreExcluded() {
let offered = Set(SymbolCatalog.allSymbols)
for restricted in ["applelogo", "icloud", "faceid"] where ItemSymbol.exists(restricted) {
#expect(!offered.contains(restricted), "'\(restricted)' is trademark-restricted and should not be offered")
}
}
/// **Everything offered is drawable.** The catalog is not filtered through `ItemSymbol.exists` at
/// read time (that would be thousands of lookups for an answer the plist already gave), so this
/// is the test that earns that shortcut sampled rather than exhaustive, because exhaustive is
/// exactly the cost the shortcut exists to avoid.
@Test("A wide sample of the offered catalog renders on this OS")
func offeredSymbolsRender() {
let all = SymbolCatalog.allSymbols
#expect(all.count > 1000, "only \(all.count) symbols — the read has degraded to its fallback")
let step = max(1, all.count / 400)
let sample = stride(from: 0, to: all.count, by: step).map { all[$0] }
let missing = sample.filter { !ItemSymbol.exists($0) }
#expect(missing.isEmpty, "offered but unrenderable: \(missing)")
}
/// Every category's members are a subset of the "All Symbols" list the sidebar and the
/// all-symbols view cannot disagree about what exists.
@Test("No category offers a symbol the all-symbols list does not")
func categoriesAreSubsetsOfAll() {
let all = Set(SymbolCatalog.allSymbols)
for category in SymbolCatalog.categories {
let strays = category.symbols.filter { !all.contains($0) }
#expect(strays.isEmpty, "'\(category.key)' offers \(strays.prefix(5)) which All Symbols does not")
}
}
/// A bundle that is not there degrades to the app's own curated vocabulary rather than to an
/// empty browser `SymbolPickerCatalog.fullCatalog`'s own fallback posture, restated.
@Test("A nonexistent bundle falls back to the curated sets, never to nothing")
func nonexistentBundleFallsBack() {
let contents = SymbolCatalog.load(bundlePath: "/nonexistent")
let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted()
#expect(contents.allSymbols == expected)
#expect(contents.categories.count == 1)
#expect(contents.categories.first?.symbols == expected)
#expect(contents.keywords.isEmpty)
}
@Test("Two reads of the system path agree — the cache is coherent")
func cacheIsCoherent() {
#expect(SymbolCatalog.contents().allSymbols == SymbolCatalog.contents().allSymbols)
#expect(SymbolCatalog.categories.map(\.key) == SymbolCatalog.contents().categories.map(\.key))
}
@Test("An unknown key title-cases rather than coming back empty")
func unknownTitleFallsBack() {
#expect(SymbolCatalog.title(forKey: "nature") == "Nature")
#expect(SymbolCatalog.title(forKey: "somethingnew") == "Somethingnew")
}
}
// MARK: - Search
@Suite("SymbolCatalog ▸ search")
struct SymbolCatalogSearchTests {
private let keywords = [
"trash": ["delete", "remove", "garbage"],
"key.slash": ["password", "security"],
"star": ["favorite"],
]
@Test("An empty or whitespace-only query returns the input unchanged")
func emptyQueryIsANoOp() {
let symbols = ["star", "flag", "heart"]
#expect(SymbolCatalog.search("", in: symbols, keywords: [:]) == symbols)
#expect(SymbolCatalog.search(" \t\n", in: symbols, keywords: [:]) == symbols)
}
/// **The reason this exists beside `SymbolPickerCatalog.filter`**: the word a user reaches for is
/// frequently not in the name. A name-only search finds nothing for "delete".
@Test("A keyword matches a symbol whose name does not contain the query at all")
func keywordsAreSearched() {
let symbols = ["trash", "star", "key.slash"]
#expect(SymbolCatalog.search("delete", in: symbols, keywords: keywords) == ["trash"])
#expect(SymbolCatalog.search("password", in: symbols, keywords: keywords) == ["key.slash"])
// The name-only filter genuinely cannot do this the contrast is the justification.
#expect(SymbolPickerCatalog.filter("delete", in: symbols).isEmpty)
}
@Test("Names still match as case-insensitive substrings")
func namesAreSearched() {
let symbols = ["star", "star.fill", "flag"]
#expect(SymbolCatalog.search("STAR", in: symbols, keywords: [:]) == ["star", "star.fill"])
}
@Test("Multiple tokens are an AND across names and keywords together")
func multiTokenIsAnAnd() {
let symbols = ["trash", "trash.slash", "star"]
let keywords = ["trash.slash": ["delete", "disabled"], "trash": ["delete"]]
#expect(SymbolCatalog.search("delete slash", in: symbols, keywords: keywords) == ["trash.slash"])
#expect(SymbolCatalog.search("delete trash", in: symbols, keywords: keywords) == ["trash", "trash.slash"])
}
@Test("Input order is preserved — the canonical ordering survives a search")
func orderPreserved() {
let symbols = ["zebra.star", "apple.star", "mango.star"]
#expect(SymbolCatalog.search("star", in: symbols, keywords: [:]) == symbols)
}
@Test("No match returns an empty list")
func noMatchIsEmpty() {
#expect(SymbolCatalog.search("xyzzy-nonexistent", in: ["star"], keywords: [:]).isEmpty)
}
@Test("Tokenizing trims and lowercases, and an empty query yields no tokens")
func tokenizing() {
#expect(SymbolCatalog.tokens(" Arrow UP ") == ["arrow", "up"])
#expect(SymbolCatalog.tokens(" ").isEmpty)
}
@Test("An empty token list matches everything — 'no query' is not 'match nothing'")
func emptyTokensMatchEverything() {
#expect(SymbolCatalog.matches(query: [], name: "anything", keywords: []))
}
/// Against the real catalog: the words a user would actually type find the glyphs they mean.
@Test("Real searches find real glyphs")
func realSearchesWork() {
let contents = SymbolCatalog.contents()
func find(_ query: String) -> [String] {
SymbolCatalog.search(query, in: contents.allSymbols, keywords: contents.keywords)
}
#expect(find("trash").contains("trash"))
#expect(find("wrench screw").contains("wrench.and.screwdriver"))
#expect(find("calendar").contains("calendar"))
#expect(find("qwertyuiop-nope").isEmpty)
}
}
// MARK: - The shared combo chrome
/// **The rhyme, asserted.** The card's first ask was that the two pickers be "roughly same
/// shape/size", and the way that was made true is structural one metrics value, one base control
/// so the test is about the structure rather than about two numbers that happen to agree today.
@Suite("ComboField ▸ the shared chrome")
struct ComboFieldMetricsTests {
@Test("Every figure scales with the body font, and reproduces the shipped numbers at 13pt")
func metricsAtTheStandardBody() {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: 13)
#expect(metrics.height == 18)
#expect(metrics.triggerWidth == 16)
#expect(metrics.facePaddingH == 7)
#expect(metrics.facePaddingV == 5)
#expect(metrics.cornerRadius == 3)
#expect(metrics.fieldRadius == 4)
#expect(metrics.triggerInset == 2)
}
@Test("A larger text size grows every figure, and none collapses to zero")
func metricsScale() {
let small = ComboFieldMetrics.metrics(bodyPointSize: 11)
let large = ComboFieldMetrics.metrics(bodyPointSize: 24)
#expect(large.height > small.height)
#expect(large.triggerWidth > small.triggerWidth)
#expect(large.glyphPointSize > small.glyphPointSize)
for metrics in [ComboFieldMetrics.metrics(bodyPointSize: 8), small, large] {
#expect(metrics.height >= 1)
#expect(metrics.triggerWidth >= 1)
#expect(metrics.cornerRadius >= 1)
#expect(metrics.glyphPointSize >= 1)
}
}
/// The field radius runs a point outside the face's so the two rounded rects stay concentric
/// a small thing, and exactly the kind of thing that drifts when two files own it.
@Test("The field's radius stays outside the face's")
func radiiAreConcentric() {
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
#expect(metrics.fieldRadius >= metrics.cornerRadius)
}
}
/// A glyph must actually fit: the face's height less its own padding is what the symbol is drawn
/// at, and a negative or vanishing figure is the bug the 14pt height had.
@Test("A glyph fills most of the field's height")
func glyphFillsTheField() {
for size in [11.0, 13.0, 17.0, 24.0] as [CGFloat] {
let metrics = ComboFieldMetrics.metrics(bodyPointSize: size)
#expect(metrics.glyphPointSize > metrics.height * 0.6, "the glyph is lost in its own field at \(size)pt")
#expect(metrics.glyphPointSize <= metrics.height)
}
}
/// **The two controls are the same control.** Both are `ComboFieldControl`s and both take their
/// geometry from the same value, so a change to one lands on the other which is the whole of
/// the parity claim, and cheaper to assert than any pair of measurements.
@MainActor
@Test("Both combos are the same chrome, at the same size")
func bothCombosShareTheChrome() {
let colour = ColorComboControl(frame: .zero)
let symbol = SymbolComboControl(frame: .zero)
for control in [colour as ComboFieldControl, symbol] {
control.metrics = .metrics(bodyPointSize: 13)
}
#expect(colour.intrinsicContentSize.height == symbol.intrinsicContentSize.height)
#expect(colour.intrinsicContentSize.width == symbol.intrinsicContentSize.width)
colour.setFrameSize(NSSize(width: 120, height: colour.intrinsicContentSize.height))
symbol.setFrameSize(NSSize(width: 120, height: symbol.intrinsicContentSize.height))
#expect(colour.triggerRect == symbol.triggerRect, "the trigger zones must line up")
#expect(colour.faceZone == symbol.faceZone, "the face zones must line up")
}
/// The two zones tile the control exactly no dead strip between them, no overlap that would
/// make one door swallow the other's clicks.
@MainActor
@Test("The face and the trigger tile the control with no gap and no overlap")
func zonesTileTheControl() {
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 140, height: 18))
control.metrics = .metrics(bodyPointSize: 13)
#expect(control.faceZone.maxX == control.triggerRect.minX)
#expect(control.faceZone.minX == control.bounds.minX)
#expect(control.triggerRect.maxX == control.bounds.maxX)
#expect(control.faceZone.width + control.triggerRect.width == control.bounds.width)
}
/// A control too narrow for its own trigger must not hand the face a negative width a sidebar
/// squeezed to nothing is a layout bug, not a crash.
@MainActor
@Test("A control narrower than its trigger degrades to an empty face")
func degenerateWidthIsSafe() {
let control = SymbolComboControl(frame: NSRect(x: 0, y: 0, width: 4, height: 18))
control.metrics = .metrics(bodyPointSize: 13)
#expect(control.faceZone.width >= 0)
}
}
+5 -1
View File
@@ -26,10 +26,14 @@ struct SymbolPickerCatalogDefaultSetTests {
@Suite("SymbolPicker ▸ the colour row") @Suite("SymbolPicker ▸ the colour row")
struct SymbolPickerColorSetTests { struct SymbolPickerColorSetTests {
@Test("Seven unique tints — the leading None plus these fills the 4×2 grid exactly") /// Eleven since the palette grew (2026-08-09), seven before it stated as the grid arithmetic
/// rather than as a literal, because the claim is *the leading None plus these fills the grid
/// exactly*, and that is what breaks when either the palette or the column count moves.
@Test("The leading None plus the tints fills the colour grid exactly")
func shape() { func shape() {
#expect(SymbolPickerCatalog.colorSet.count #expect(SymbolPickerCatalog.colorSet.count
== SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1) == SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1)
#expect(SymbolPickerCatalog.colorSet.count == 11)
#expect(Set(SymbolPickerCatalog.colorSet).count == SymbolPickerCatalog.colorSet.count) #expect(Set(SymbolPickerCatalog.colorSet).count == SymbolPickerCatalog.colorSet.count)
} }