diff --git a/Kanban/LiveStore/StyleModel.swift b/Kanban/LiveStore/StyleModel.swift index b9487bb..c18b35f 100644 --- a/Kanban/LiveStore/StyleModel.swift +++ b/Kanban/LiveStore/StyleModel.swift @@ -20,8 +20,15 @@ import Foundation /// avoid. /// /// `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 -/// in-app but stays fully honored from disk"), though nothing in the type forbids it. +/// grid, an SF Symbol name, or a `#RRGGBB[AA]` hex. +/// +/// **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 { case keep case set(String) diff --git a/Kanban/Storage/AgentGuide.swift b/Kanban/Storage/AgentGuide.swift index b331789..3b4b9ed 100644 --- a/Kanban/Storage/AgentGuide.swift +++ b/Kanban/Storage/AgentGuide.swift @@ -701,11 +701,13 @@ enum AgentGuide { to tint it. - Icon tint palette: `obsidian`, `aluminum`, `soapstone`, `chalk`, - `carnation`, `rich-grapefruit`, `smokey-tangerine`, `fern`, - `light-teal`, `deep-sky-blue`, `pale-violet`, `deep-cool-granite`. + `carnation`, `rich-grapefruit`, `smokey-tangerine`, `rich-lime`, + `fern`, `light-jade`, `light-teal`, `deep-sky-blue`, `rich-indigo`, + `pale-violet`, `rich-magenta`, `deep-cool-granite`. - Background palette: `obsidian`, `shale`, `aluminum`, `chalk`, - `light-cayenne`, `light-mocha`, `smokey-mocha`, `smokey-fern`, - `dark-teal`, `smokey-ocean`, `smokey-rich-eggplant`, + `light-cayenne`, `light-mocha`, `smokey-mocha`, `smokey-lime`, + `smokey-fern`, `dark-jade`, `dark-teal`, `smokey-ocean`, + `smokey-indigo`, `smokey-rich-eggplant`, `smokey-magenta`, `intense-cool-shale`. ## Git diff --git a/Kanban/UI/Card/CardSidebarSections.swift b/Kanban/UI/Card/CardSidebarSections.swift index 4254adc..85f1900 100644 --- a/Kanban/UI/Card/CardSidebarSections.swift +++ b/Kanban/UI/Card/CardSidebarSections.swift @@ -175,10 +175,14 @@ struct CardStyleSection: View { // MARK: - Symbol /// The labeled **Symbol** row, below the background combo — `backgroundComboRow`'s own - /// inspector-row shape, restated: caption leading, the compact control trailing. Unlike the - /// 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 - /// stretching it would just be empty frame around a fixed-size button. + /// inspector-row shape, restated: caption leading, the combo trailing, **at the same width**. + /// + /// That width used to be the difference between the two rows. The picker was a 20pt bordered + /// 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 { HStack(spacing: 0) { Text("Symbol") @@ -193,6 +197,7 @@ struct CardStyleSection: View { currentColor: currentIconColor, onSelectColor: { applyIconColor($0) } ) + .frame(width: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize) * 0.55) // The same lock `StyleEditorView`'s whole body disabled under // (`.disabled(!store.acceptsBoardMutations)`, `StyleEditor.swift`) — the read-only lock // and the board's inline-editing rule alike, preserved exactly across the control swap diff --git a/Kanban/UI/ColorCombo.swift b/Kanban/UI/ColorCombo.swift index c354ac0..e617a3e 100644 --- a/Kanban/UI/ColorCombo.swift +++ b/Kanban/UI/ColorCombo.swift @@ -4,8 +4,8 @@ import SwiftUI /// 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, /// 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 -/// twelve palette colours, an off-palette current value stated verbatim when there is one, and +/// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's own +/// 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 /// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`). /// @@ -27,13 +27,14 @@ import SwiftUI /// 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.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. enum ColorComboRole: Sendable, Equatable { case background 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] { switch self { 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, /// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)` /// 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. case none 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. case palette(String) /// 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 /// 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`, - /// 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. static func match(role: ColorComboRole, value: String?) -> ColorComboMatch { 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 - /// 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). private static func currentTitle(role: ColorComboRole, value: String) -> String { if let foreign = role.otherPalette.first(where: { $0.name == value }) { @@ -143,7 +144,7 @@ enum ColorComboModel { // MARK: Item list /// 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…**. static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu { 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. let value: String? 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. var onChange: @MainActor (String?) -> Void /// 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 { let control = ColorComboControl(frame: .zero) - // The swatch zone's one job: open the same panel takeover **Other…** does. A closure, not a - // target/action pair — there is exactly one caller and no `NSMenuItem`-style Objective-C - // boundary to cross for it. - control.onSwatchClick = { [weak coordinator = context.coordinator] in + // The two zones' jobs, wired once (`ComboFieldControl`'s grammar): the face opens the same + // panel takeover **Other…** does, the trigger pops the dropdown. Closures, not target/action + // pairs — there is exactly one caller each and no `NSMenuItem`-style Objective-C boundary to + // cross for them. + control.onFaceClick = { [weak coordinator = context.coordinator] in coordinator?.openColorPanel() } + control.onTriggerClick = { [weak control] in + control?.popUpMenu() + } return control } @@ -230,6 +235,7 @@ struct ColorComboView: NSViewRepresentable { context.coordinator.role = role context.coordinator.onChange = onChange context.coordinator.onPanelChange = onPanelChange + control.metrics = .current control.isEnabled = isEnabled context.coordinator.rebuild(control, value: value) } @@ -276,11 +282,10 @@ struct ColorComboView: NSViewRepresentable { /// rather than a bullet. private static let menuSwatchSize = NSSize(width: 44, height: 14) - /// Whichever coordinator most recently took the shared panel over — `NSColorPanel` exposes - /// `setTarget(_:)`/`setAction(_:)` but no matching getter, so "is it still mine to detach" - /// has nowhere to live but here. `weak`, so a coordinator that never got around to detaching - /// (a window closed from under it) does not keep the next owner from being collected either. - private static weak var currentPanelOwner: Coordinator? + /// This coordinator's handle on `NSColorPanel.shared` — the takeover, its continuous action + /// and its last-writer-wins detach all live in `SystemColorPanel` now, shared with the two + /// surfaces that gained an **Other…** on 2026-08-09. + private let colorPanel = SystemColorPanel() init( role: ColorComboRole, @@ -315,16 +320,13 @@ struct ColorComboView: NSViewRepresentable { } control.comboMenu = menu control.checkedItem = checkedItem + control.accessibilityValueText = checkedItem?.title control.swatchValue = value } /// See `ColorComboView.dismantleNSView(_:coordinator:)`. func detachColorPanel() { - guard Coordinator.currentPanelOwner === self else { return } - let panel = NSColorPanel.shared - panel.setTarget(nil) - panel.setAction(nil) - Coordinator.currentPanelOwner = nil + colorPanel.detach() } private func menuItem(for item: ColorComboItem) -> NSMenuItem { @@ -373,51 +375,40 @@ struct ColorComboView: NSViewRepresentable { onChange(sender.representedObject as? String) } - /// Seeds the shared panel with the current resolved colour (black when there isn't one), - /// takes it over — "don't fight over the panel if something else takes it later" (this - /// view's own doc comment) — and asks for continuous updates, which is what makes a drag on - /// the panel's own sliders call `changeColor(_:)` on every tick rather than only on release. + /// Opens the shared Colors panel seeded with the current resolved colour, streaming what it + /// picks into `onPanelChange` — the palette name when the colour lands exactly on one of + /// `role`'s own entries, the hex otherwise. Every rule of that takeover is + /// `SystemColorPanel`'s; this is the seed and the destination. /// /// 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 /// place that happens. @objc func openColorPanel() { - let panel = NSColorPanel.shared - panel.showsAlpha = true - panel.color = currentValue.flatMap(Palette.nsColor(for:)) ?? .black - panel.setTarget(self) - panel.setAction(#selector(changeColor(_:))) - 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) + colorPanel.present( + seed: currentValue.flatMap(Palette.nsColor(for:)), + matching: role.palette + ) { [weak self] value in + self?.onPanelChange(value) + } } } } -// MARK: - Two-zone NSControl +// MARK: - The control -/// The collapsed face: a custom control in Xcode's inspector colour combo's own shape — a flat -/// 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()`); -/// the trigger zone pops the dropdown `ColorComboView.Coordinator.rebuild(_:value:)` builds. Neither -/// zone owns a bezel or a cell of its own — everything both draw and hit-test is computed straight -/// from `bounds` on every pass, so there is nothing cached here the way the face image the -/// `NSPopUpButton` this replaces used to keep (`swatchValue`'s `didSet` just marks a redraw). +/// The collapsed face: `ComboFieldControl` with a **swatch** in its face zone. +/// +/// Everything that makes it a two-zone combo — the field, the hairline, the trailing chevron square, +/// the hit split, the disabled compositing, the geometry — is the base class's now (ComboField.swift), +/// shared with the symbol combo so the two cannot drift apart. What is left here is the one thing that +/// is actually about *colour*: the swatch, and the menu the trigger pops. /// /// 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 /// 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. -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 /// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update. @@ -435,100 +426,14 @@ final class ColorComboControl: NSControl { var comboMenu: NSMenu? 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` /// 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 → /// underlay + stroke only, the same "there is no colour, so show none" rule. - private func drawSwatch() { - let inset = swatchZone.insetBy(dx: Self.swatchPaddingH, dy: Self.swatchPaddingV) - let path = NSBezierPath(roundedRect: inset, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius) + override func drawFace(in rect: NSRect) { + let inset = rect.insetBy(dx: metrics.facePaddingH, dy: metrics.facePaddingV) + guard inset.width > 0, inset.height > 0 else { return } + let path = NSBezierPath(roundedRect: inset, xRadius: metrics.cornerRadius, yRadius: metrics.cornerRadius) NSColor.textBackgroundColor.setFill() path.fill() if let swatchValue, let color = Palette.nsColor(for: swatchValue) { @@ -540,81 +445,11 @@ final class ColorComboControl: NSControl { 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 /// 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. - private func popUpMenu() { + func popUpMenu() { guard let comboMenu else { return } 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 } } diff --git a/Kanban/UI/ComboField.swift b/Kanban/UI/ComboField.swift new file mode 100644 index 0000000..7cf77c3 --- /dev/null +++ b/Kanban/UI/ComboField.swift @@ -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 + } +} diff --git a/Kanban/UI/Palette.swift b/Kanban/UI/Palette.swift index ade3556..aaacfce 100644 --- a/Kanban/UI/Palette.swift +++ b/Kanban/UI/Palette.swift @@ -5,7 +5,31 @@ import SwiftUI /// (03-board-ui.md § Styling ▸ Capabilities): **a kebab-case palette name, or a `#RRGGBB[AA]` /// hex**. This file is the single source for the name→hex mapping — the pathfinder's twelve icon /// 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 38→110, 268→352, 110→205 and 205→268. 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.18–4.67 contrast on white against the old set's 2.18–4.94, and the backgrounds at 6.6–14.7 +/// against the old 5.7–16.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 /// @@ -28,7 +52,9 @@ struct PaletteColor: Identifiable, Sendable { 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] = [ PaletteColor(name: "obsidian", hex: "#000000"), PaletteColor(name: "aluminum", hex: "#9B9B9B"), @@ -37,15 +63,23 @@ enum Palette { PaletteColor(name: "carnation", hex: "#FF576C"), PaletteColor(name: "rich-grapefruit", hex: "#FF864C"), PaletteColor(name: "smokey-tangerine", hex: "#E5A334"), + PaletteColor(name: "rich-lime", hex: "#ADB812"), PaletteColor(name: "fern", hex: "#50B23D"), + PaletteColor(name: "light-jade", hex: "#21B880"), PaletteColor(name: "light-teal", hex: "#00B7B7"), PaletteColor(name: "deep-sky-blue", hex: "#0084E5"), + PaletteColor(name: "rich-indigo", hex: "#6065E6"), PaletteColor(name: "pale-violet", hex: "#8C59C5"), + PaletteColor(name: "rich-magenta", hex: "#D952BE"), PaletteColor(name: "deep-cool-granite", hex: "#597199"), ] - /// Background palette (`background`) — the twelve wells the style editor will offer, "every - /// pair AA-verified at design time" (03-board-ui.md § Styling ▸ Controls). + /// Background palette (`background`) — the wells the style editor offers, "every pair + /// 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] = [ PaletteColor(name: "obsidian", hex: "#000000"), PaletteColor(name: "shale", hex: "#5B5B5B"), @@ -54,12 +88,33 @@ enum Palette { PaletteColor(name: "light-cayenne", hex: "#B6071E"), PaletteColor(name: "light-mocha", hex: "#B73C14"), PaletteColor(name: "smokey-mocha", hex: "#674611"), + PaletteColor(name: "smokey-lime", hex: "#5B610E"), PaletteColor(name: "smokey-fern", hex: "#145312"), + PaletteColor(name: "dark-jade", hex: "#035437"), PaletteColor(name: "dark-teal", hex: "#005152"), PaletteColor(name: "smokey-ocean", hex: "#003168"), + PaletteColor(name: "smokey-indigo", hex: "#14177A"), PaletteColor(name: "smokey-rich-eggplant", hex: "#290659"), + PaletteColor(name: "smokey-magenta", hex: "#5C074B"), 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 = ["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`, diff --git a/Kanban/UI/StyleEditor.swift b/Kanban/UI/StyleEditor.swift index 52e0cda..ccf586f 100644 --- a/Kanban/UI/StyleEditor.swift +++ b/Kanban/UI/StyleEditor.swift @@ -240,7 +240,8 @@ struct StyleEditorLayout: Equatable { /// The Style… popover and the board popover's styling area: a fixed frame, its own padding, and /// 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 /// 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. @@ -395,8 +396,15 @@ struct StyleEditorView: View { // MARK: - Background - /// The twelve palette wells and their leading None (03-board-ui.md § Styling ▸ Controls: - /// "palette-only in-app … plus a leading **None** well that removes the `background` key"). + /// The palette wells and their leading None (03-board-ui.md § Styling ▸ Controls: "palette-only + /// 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 { VStack(alignment: .leading, spacing: layout.wellSpacing) { 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) } ) + 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) } } diff --git a/Kanban/UI/SymbolBrowser.swift b/Kanban/UI/SymbolBrowser.swift new file mode 100644 index 0000000..55a68e6 --- /dev/null +++ b/Kanban/UI/SymbolBrowser.swift @@ -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) + } + } +} diff --git a/Kanban/UI/SymbolCatalog.swift b/Kanban/UI/SymbolCatalog.swift new file mode 100644 index 0000000..b466b60 --- /dev/null +++ b/Kanban/UI/SymbolCatalog.swift @@ -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 = ["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() + 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] ?? []) } + } +} diff --git a/Kanban/UI/SymbolPicker.swift b/Kanban/UI/SymbolPicker.swift index e2eaffc..a5d60e2 100644 --- a/Kanban/UI/SymbolPicker.swift +++ b/Kanban/UI/SymbolPicker.swift @@ -1,10 +1,10 @@ -import Foundation +import AppKit import SwiftUI -/// **A reusable SF Symbol picker** — a single well showing the resolved symbol, opening a curated -/// grid with a search escape hatch (03-board-ui.md § Styling ▸ Controls: "its leading well is the -/// level's default symbol and removes the `icon` key … Any other SF Symbol name works written by -/// hand … No full-browser escape hatch in-app; the raw file is the escape hatch"). `StyleEditor.swift` +/// **A reusable SF Symbol picker** — a two-zone combo showing the resolved symbol, opening a curated +/// grid from its trigger and the standalone browser from its face (03-board-ui.md § Styling ▸ +/// Controls: "its leading well is the level's default symbol and removes the `icon` key … Any other +/// SF Symbol name works written by hand"). `StyleEditor.swift` /// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three /// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a /// control that only ever needs one glyph — a saved search, a smart filter, a future per-item @@ -28,6 +28,24 @@ import SwiftUI /// general-purpose control cannot assume its 36 will always contain what the caller is after — a /// search with nothing to search would just move the dead end from "no matching well" to "no way to /// look further". +/// +/// ### 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 @@ -52,15 +70,19 @@ enum SymbolPickerCatalog { /// curated list is a convenience, never a claim about the running system. static var available: [String] { defaultSet.filter(ItemSymbol.exists) } - /// The colour row's seven tints — `Palette.foregrounds`' hues, minus the four grayscale steps - /// (a symbol's *tint* wants colour; "no tint" is the None well's job, not a gray's) and minus - /// `deep-cool-granite`, the mutedest of the eight, dropped so the row plus its leading None - /// fills the 4×2 grid exactly. Palette names, not hexes, exactly as the style editor's wells - /// write them. - static let colorSet: [String] = [ - "carnation", "rich-grapefruit", "smokey-tangerine", "fern", - "light-teal", "deep-sky-blue", "pale-violet", - ] + /// The colour row's tints — `Palette.tints`' names, which is the icon-tint palette minus the four + /// grayscale steps (a symbol's *tint* wants colour; "no tint" is the None well's job, not a + /// gray's) and minus `deep-cool-granite`, the mutedest of the ring. Palette names, not hexes, + /// exactly as the style editor's wells write them. + /// + /// **Eleven now, seven before** — the palette grew to sixteen on 2026-08-09 and the exclusions + /// did not, so the row gained its third 4-wide row (`SymbolPickerLayout.colorRows`) and its + /// 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 /// 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 /// wells and glyphs scale up by `gridScale` — a deliberate, user-tuned enlargement (the picker's grid /// is this popover's whole subject, where the style editor's is one section among several), still -/// anchored to the shared base so the two components move together at every text size. The at-rest -/// button keeps the unscaled side (`restSide`) — it sits inline with a text field and matches that -/// field's height, not the grid's. Only the shape wraps a picker's own frame around them — six +/// anchored to the shared base so the two components move together at every text size. +/// +/// **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 /// 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 @@ -144,17 +168,14 @@ struct SymbolPickerLayout: Equatable { static let columns = 6 static let rows = 6 - /// The colour row's own shape — 4×2, the leading None plus `SymbolPickerCatalog.colorSet`'s - /// seven tints. + /// The colour row's own shape — 4×3, the leading None plus `SymbolPickerCatalog.colorSet`'s + /// eleven tints. Was 4×2 and seven until the palette grew (2026-08-09). 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 /// than in miniature. static let gridScale: CGFloat = 1.3 - /// The at-rest button's side — the unscaled base, matched to the style editor's wells and to - /// the text-field height the button sits beside. - var restSide: CGFloat /// The glyph's own point size inside a grid well — the body size under `gridScale`, since a /// symbol renders at the font size, not the frame; a bigger well alone would just add margin. var glyphPointSize: CGFloat @@ -178,14 +199,12 @@ struct SymbolPickerLayout: Equatable { var popoverWidth: CGFloat static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout { - let baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) - let side = (baseSide * gridScale).rounded() + let side = (StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) * gridScale).rounded() let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize) let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize) let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded() let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded() return SymbolPickerLayout( - restSide: baseSide, glyphPointSize: (bodyPointSize * gridScale).rounded(), wellSide: side, wellSpacing: spacing, @@ -201,16 +220,14 @@ struct SymbolPickerLayout: Equatable { // MARK: - The control -/// A single symbol well that opens a curated grid — the reusable primitive `03-board-ui.md`'s -/// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to -/// hand-edit `icon`, but a control any caller can aim at one symbol field without wiring up a -/// `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView` carries for -/// the board's own background+icon editor. +/// A two-zone symbol combo — the reusable primitive a caller can aim at one symbol field without +/// wiring up a `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView` +/// carries for the board's own background+icon editor. /// -/// **View-local state only** — the popover's presented flag lives here, its search text lives with -/// the popover content. Nothing about a store, an undo stack, or a target set is known to this type; -/// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding -/// would be. +/// **No store, no undo stack, no target set** is known to this type; `onSelect`/`onSelectColor` are +/// the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding would be. +/// That is unchanged by the 2026-08-09 rework — the API is byte for byte what it was, so every +/// existing caller kept working while the control underneath became a `ComboFieldControl`. struct SymbolPicker: View { /// 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. var onSelectColor: ((String?) -> Void)? = nil - @State private var isPresented = false + @Environment(\.isEnabled) private var isEnabled - private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } - - /// What the well actually draws — `current` if this system can resolve it, `fallback` otherwise. - /// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here - /// because this control's `current` is already a plain optional string by the time it arrives. - private var resolvedName: String { - if let current, ItemSymbol.exists(current) { return current } - return fallback - } - - /// 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) - } + /// The curated popover's presented flag — **view-local, and a SwiftUI `.popover` deliberately**. + /// + /// The rework's first cut popped an `NSPopover` straight from the control, which is the natural + /// thing for an `NSView` to do and is wrong here: this picker is mounted *inside* another popover + /// at one of its anchors (`BoardInfoPopover`, beside the rename field), and a second popover that + /// takes key status from the first would dismiss the surface it was opened from. SwiftUI already + /// handles that nesting, and did before this card; keeping the presentation where it was is the + /// change that is not being made. + @State private var isPopoverPresented = false var body: some View { - let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize) - Button { - isPresented = true - } label: { - Image(systemName: resolvedName) - .imageScale(.medium) - // The tint the board actually renders with, on the well that states the board's - // 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) + SymbolComboView( + current: current, + fallback: fallback, + isEnabled: isEnabled, + currentColor: onSelectColor == nil ? nil : currentColor, + onFace: { openBrowser() }, + onTrigger: { isPopoverPresented = true } + ) .help("Symbol") .accessibilityLabel("Symbol") - .accessibilityValue(resolvedName) - .popover(isPresented: $isPresented, arrowEdge: .bottom) { + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { SymbolPickerPopoverContent( current: current, fallback: fallback, symbols: symbols, searchable: searchable, - layout: layout, + layout: SymbolPickerLayout.metrics(bodyPointSize: CardWindowMetrics.bodyPointSize), onSelect: { name in + isPopoverPresented = false 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, onSelectColor: onSelectColor.map { select in { name in + isPopoverPresented = false 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 @@ -312,9 +466,14 @@ private struct SymbolPickerPopoverContent: View { let searchable: Bool let layout: SymbolPickerLayout 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 /// `nil` is "no colour row" — `SymbolPicker.onSelectColor`'s opt-in, passed through. 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 = "" @@ -324,17 +483,36 @@ private struct SymbolPickerPopoverContent: View { searchField } 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 tints, so the row keeps standing where the eye left it. if let onSelectColor { Divider() SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor) + if let onPickCustomColor { + deeperRow("Other…", action: onPickCustomColor) + } } } .padding(layout.contentPadding) .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 { TextField("Search Symbols", text: $query) .textFieldStyle(.roundedBorder) @@ -534,7 +712,8 @@ private struct SymbolColorWell: Identifiable { } /// 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 /// 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. diff --git a/Kanban/UI/SystemColorPanel.swift b/Kanban/UI/SystemColorPanel.swift new file mode 100644 index 0000000..5a7c435 --- /dev/null +++ b/Kanban/UI/SystemColorPanel.swift @@ -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? + + 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) + } + } + } +} diff --git a/KanbanTests/ColorComboTests.swift b/KanbanTests/ColorComboTests.swift index 791fd71..6fe2694 100644 --- a/KanbanTests/ColorComboTests.swift +++ b/KanbanTests/ColorComboTests.swift @@ -98,7 +98,7 @@ struct ColorComboTests { } /// 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. @Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() { let match = ColorComboModel.match(role: .background, value: "carnation") @@ -132,7 +132,7 @@ struct ColorComboTests { 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() { let menu = ColorComboModel.menu(role: .background, value: nil) 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 - /// one of the role's own twelve. + /// one of the role's own entries. @Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() { let none = ColorComboModel.menu(role: .background, value: nil) #expect(!none.items.contains { if case .current = $0 { return true }; return false }) diff --git a/KanbanTests/ContrastMathTests.swift b/KanbanTests/ContrastMathTests.swift index ee4a692..f32a61d 100644 --- a/KanbanTests/ContrastMathTests.swift +++ b/KanbanTests/ContrastMathTests.swift @@ -21,8 +21,8 @@ import Testing /// 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. /// -/// The palette's own suite closes the loop. 03-board-ui.md ▸ Styling ▸ Controls promises the twelve -/// wells are "AA-verified at design time … pinned by a computed-contrast unit test over all 12 +/// 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 /// 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 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("smokey-ocean")) != 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. #expect(BoardTextInk.paintedColor(.valid("carnation")) != nil) #expect(BoardTextInk.paintedColor(.valid("#12345")) == nil) @@ -479,21 +479,25 @@ struct BoardTextInkTests { struct PaletteContrastTests { /// **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 - /// (10-accessibility.md), the claim pinned by a computed-contrast unit test over all 12 pairs so + /// "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 pairs so /// 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 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` /// *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 /// 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 /// 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. @Test("The ink the board picks clears 4.5:1 on every palette background, in both appearances") func everyPaletteBackgroundHasAReadableInk() throws { @@ -528,8 +532,8 @@ struct PaletteContrastTests { /// 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. /// - /// Every one of the twelve wells 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 + /// Every well is a colour the system's own label fails on in one of the two + /// 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 /// 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. @@ -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 /// "verified at design time" would stop being a statement anyone could check. @Test("No palette background carries alpha") diff --git a/KanbanTests/CustomColorRoundTripTests.swift b/KanbanTests/CustomColorRoundTripTests.swift new file mode 100644 index 0000000..17fe39f --- /dev/null +++ b/KanbanTests/CustomColorRoundTripTests.swift @@ -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") + } + } +} diff --git a/KanbanTests/PaletteTests.swift b/KanbanTests/PaletteTests.swift index 973239b..59bcbd6 100644 --- a/KanbanTests/PaletteTests.swift +++ b/KanbanTests/PaletteTests.swift @@ -15,19 +15,69 @@ import Testing 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) == [ "obsidian", "aluminum", "soapstone", "chalk", - "carnation", "rich-grapefruit", "smokey-tangerine", "fern", - "light-teal", "deep-sky-blue", "pale-violet", "deep-cool-granite", + "carnation", "rich-grapefruit", "smokey-tangerine", "rich-lime", + "fern", "light-jade", "light-teal", "deep-sky-blue", + "rich-indigo", "pale-violet", "rich-magenta", "deep-cool-granite", ]) #expect(Palette.backgrounds.map(\.name) == [ "obsidian", "shale", "aluminum", "chalk", - "light-cayenne", "light-mocha", "smokey-mocha", "smokey-fern", - "dark-teal", "smokey-ocean", "smokey-rich-eggplant", "intense-cool-shale", + "light-cayenne", "light-mocha", "smokey-mocha", "smokey-lime", + "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 { for entry in Palette.foregrounds + Palette.backgrounds { let byName = try #require( diff --git a/KanbanTests/SymbolCatalogTests.swift b/KanbanTests/SymbolCatalogTests.swift new file mode 100644 index 0000000..bd9b409 --- /dev/null +++ b/KanbanTests/SymbolCatalogTests.swift @@ -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) + } +} diff --git a/KanbanTests/SymbolPickerTests.swift b/KanbanTests/SymbolPickerTests.swift index ec96568..5de923d 100644 --- a/KanbanTests/SymbolPickerTests.swift +++ b/KanbanTests/SymbolPickerTests.swift @@ -26,10 +26,14 @@ struct SymbolPickerCatalogDefaultSetTests { @Suite("SymbolPicker ▸ the colour row") 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() { #expect(SymbolPickerCatalog.colorSet.count == SymbolPickerLayout.colorColumns * SymbolPickerLayout.colorRows - 1) + #expect(SymbolPickerCatalog.colorSet.count == 11) #expect(Set(SymbolPickerCatalog.colorSet).count == SymbolPickerCatalog.colorSet.count) }