The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs

Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app
escape hatch is ratified in full, reversing 2026-07-29's palette-only rule —
the combo's Other… opens the system Colors panel, a pick landing on a palette
color stores the name, anything else the hex. Free-picked colors change no
contrast story: they land on the same runtime ink computation hand-written hex
always got (10 amended to say so; no warning surface is owed). Anchor
ownership: the card sidebar's background story is the combo alone — the well
grid's background half stays with the other anchors (StyleEditorView gains
showsBackground beside showsSymbols; the popover's symbol half already went to
its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a
panel pick never enters them.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-06 22:05:48 -04:00
parent 73698cd77b
commit 9766e1f61c
9 changed files with 981 additions and 18 deletions
+197
View File
@@ -0,0 +1,197 @@
import AppKit
import Testing
@testable import Kanban
/// `ColorComboView`'s pure model (`ColorCombo.swift`): item-list composition, selection matching,
/// hex normalization and display-name casing every rule stated where a test can hold it without
/// an `NSView`, matching `PaletteTests.swift`'s own split between the vocabulary and the view.
///
/// One outer suite, nested by concern swift-testing discovers nested types as sub-suites, which
/// is what lets `-only-testing:KanbanTests/ColorComboTests` run the whole file while each concern
/// still reads as its own group, `PaletteTests.swift`'s top-level-structs style scoped one level in.
struct ColorComboTests {
// MARK: - Display names
struct DisplayName {
@Test func kebabCaseBecomesTitleCaseWithHyphensAsSpaces() {
#expect(ColorComboModel.displayName("light-cayenne") == "Light Cayenne")
#expect(ColorComboModel.displayName("smokey-rich-eggplant") == "Smokey Rich Eggplant")
#expect(ColorComboModel.displayName("obsidian") == "Obsidian")
#expect(ColorComboModel.displayName("deep-sky-blue") == "Deep Sky Blue")
}
}
// MARK: - Hex normalization
struct HexNormalization {
@Test func sixDigitHexUppercasesUnchanged() {
#expect(ColorComboModel.normalizedHex("#b6071e") == "#B6071E")
#expect(ColorComboModel.normalizedHex("#B6071E") == "#B6071E")
}
/// Alpha below full opacity is meaningful and stays only a fully-opaque suffix collapses.
@Test func eightDigitHexWithPartialAlphaStaysEightDigits() {
#expect(ColorComboModel.normalizedHex("#b6071e80") == "#B6071E80")
}
/// `#RRGGBBFF` fully opaque, spelled with an explicit alpha byte collapses to the
/// six-digit form, so it compares equal to a bare `#RRGGBB` written for the same colour.
@Test func fullyOpaqueEightDigitHexCollapsesToSixDigits() {
#expect(ColorComboModel.normalizedHex("#b6071eFF") == "#B6071E")
#expect(ColorComboModel.normalizedHex("#B6071EFF") == ColorComboModel.normalizedHex("#B6071E"))
}
@Test func malformedOrUnprefixedValuesNormalizeToNil() {
for value in ["", "#", "#12", "#12345", "#1234567", "#GGGGGG", "B6071E", "light-cayenne"] {
#expect(ColorComboModel.normalizedHex(value) == nil, "'\(value)' should not normalize")
}
}
}
// MARK: - Selection matching
struct Matching {
@Test func nilValueMatchesNone() {
#expect(ColorComboModel.match(role: .background, value: nil) == .none)
#expect(ColorComboModel.match(role: .foreground, value: nil) == .none)
}
@Test func aNameInTheRolesOwnPaletteMatchesThatRowExactly() {
#expect(ColorComboModel.match(role: .background, value: "light-cayenne") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .foreground, value: "fern") == .palette("fern"))
}
/// Names are matched exactly, like `Palette.nsColor(for:)` a near-miss is not "the same
/// row", it is an off-palette value with its own dynamic row.
@Test func aNearMissNameIsNotAPaletteMatch() {
let match = ColorComboModel.match(role: .background, value: "Light-Cayenne")
guard case let .current(_, title) = match else {
Issue.record("expected .current, got \(match)")
return
}
#expect(title == "Light-Cayenne")
}
/// A hex that normalizes to one of the role's own palette hexes selects the **name**, not
/// the hex case-insensitively, and with a fully-opaque `#RRGGBBFF` collapsing exactly like
/// a bare `#RRGGBB` would.
@Test func aHexEqualToAPaletteColorsHexMatchesItsNamedRow() {
#expect(ColorComboModel.match(role: .background, value: "#B6071E") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#b6071e") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#B6071EFF") == .palette("light-cayenne"))
#expect(ColorComboModel.match(role: .background, value: "#b6071eff") == .palette("light-cayenne"))
}
/// Partial alpha keeps a value off the palette rows even when its RGB matches one exactly
/// the stored colour is genuinely translucent, which no palette entry is.
@Test func aTranslucentHexNeverMatchesAnOpaquePaletteColor() {
let match = ColorComboModel.match(role: .background, value: "#B6071E80")
guard case let .current(swatchValue, _) = match else {
Issue.record("expected .current, got \(match)")
return
}
#expect(swatchValue == "#B6071E80")
}
/// 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
/// since the other table does know it.
@Test func aForeignPaletteNameFallsToTheDynamicRowNamedFromTheOtherTable() {
let match = ColorComboModel.match(role: .background, value: "carnation")
#expect(match == .current(swatchValue: "carnation", title: "Carnation"))
}
/// A custom hex nowhere in either table: the dynamic row states it verbatim, uppercased.
@Test func aCustomHexFallsToTheDynamicRowUppercased() {
let match = ColorComboModel.match(role: .background, value: "#123456")
#expect(match == .current(swatchValue: "#123456", title: "#123456"))
let lowercase = ColorComboModel.match(role: .background, value: "#abcdef")
#expect(lowercase == .current(swatchValue: "#abcdef", title: "#ABCDEF"))
}
/// Unresolvable garbage neither a name either table knows nor a parseable hex falls to
/// the dynamic row exactly as written, no casing applied.
@Test func garbageFallsToTheDynamicRowVerbatim() {
let match = ColorComboModel.match(role: .background, value: "chartreuse")
#expect(match == .current(swatchValue: "chartreuse", title: "chartreuse"))
}
/// The three names shared by both tables (`obsidian`, `aluminum`, `chalk`) are in *both*
/// roles' own palettes, so they match directly and never reach the "foreign name" branch.
@Test func namesSharedByBothTablesMatchDirectlyInEitherRole() {
#expect(ColorComboModel.match(role: .background, value: "obsidian") == .palette("obsidian"))
#expect(ColorComboModel.match(role: .foreground, value: "obsidian") == .palette("obsidian"))
}
}
// MARK: - Item list composition
struct Menu {
/// None first, a separator, then exactly the role's twelve, in the palette's own order.
@Test func baseOrderIsNoneSeparatorThenTheRolesTwelve() {
let menu = ColorComboModel.menu(role: .background, value: nil)
var expected: [ColorComboItem] = [.none, .separator]
expected.append(contentsOf: Palette.backgrounds.map { .palette($0.name) })
expected.append(contentsOf: [.separator, .other])
#expect(menu.items == expected)
}
@Test func foregroundRoleListsTheForegroundTwelveNotTheBackgroundTwelve() {
let menu = ColorComboModel.menu(role: .foreground, value: nil)
let paletteNames = menu.items.compactMap { item -> String? in
if case let .palette(name) = item { return name }
return nil
}
#expect(paletteNames == Palette.foregrounds.map(\.name))
}
/// `Other` is always last, and there is never more than one dynamic row.
@Test func otherIsAlwaysLast() {
for value in [nil, "obsidian", "carnation", "#123456", "chartreuse"] {
let menu = ColorComboModel.menu(role: .background, value: value)
#expect(menu.items.last == .other)
}
}
/// No dynamic row, and no selected item beyond the palette rows, when the value is `nil` or
/// one of the role's own twelve.
@Test func noDynamicRowWhenTheValueIsNoneOrAPaletteName() {
let none = ColorComboModel.menu(role: .background, value: nil)
#expect(!none.items.contains { if case .current = $0 { return true }; return false })
#expect(none.selectedIndex == 0)
#expect(none.items[none.selectedIndex] == .none)
let named = ColorComboModel.menu(role: .background, value: "dark-teal")
#expect(!named.items.contains { if case .current = $0 { return true }; return false })
#expect(named.items[named.selectedIndex] == .palette("dark-teal"))
}
/// A foreign or unresolvable value inserts exactly one dynamic row, immediately before the
/// trailing separator and `Other`, and it is the checked row.
@Test func dynamicRowAppearsOnlyForAForeignValueAndIsSelected() {
let menu = ColorComboModel.menu(role: .background, value: "#123456")
let dynamicRows = menu.items.filter { if case .current = $0 { return true }; return false }
#expect(dynamicRows.count == 1)
#expect(menu.items[menu.selectedIndex] == .current(swatchValue: "#123456", title: "#123456"))
// Immediately before the trailing separator + Other
#expect(
Array(menu.items.suffix(3)) ==
[.current(swatchValue: "#123456", title: "#123456"), .separator, .other]
)
}
/// A hex landing exactly on a palette colour selects that named row and adds no dynamic row
/// at all the same shape as picking the name directly.
@Test func anExactHexMatchProducesNoDynamicRow() {
let byName = ColorComboModel.menu(role: .background, value: "light-cayenne")
let byHex = ColorComboModel.menu(role: .background, value: "#B6071E")
#expect(byName.items == byHex.items)
#expect(byName.selectedIndex == byHex.selectedIndex)
}
}
}