Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate. The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone. ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it. In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
216 lines
12 KiB
Swift
216 lines
12 KiB
Swift
import AppKit
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// The colour vocabulary `background` and `iconColor` are written in (03-board-ui.md § Styling ▸
|
|
/// Capabilities): a kebab-case palette name, or a `#RRGGBB[AA]` hex — and **nil for everything
|
|
/// else**, which is a rendering instruction, never an error.
|
|
///
|
|
/// The palette tables themselves are pinned by name *and* hex rather than merely counted: they
|
|
/// are a carried-over design artefact ("the pathfinder's palettes … carry over as the starting
|
|
/// point"), and their names are what users hand-write into files — a silent rename or a shifted
|
|
/// hex would change what an existing board renders as.
|
|
|
|
// MARK: - The tables
|
|
|
|
struct PaletteTableTests {
|
|
|
|
/// 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", "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-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 (`Palette.name(forHex:in:)`, `SystemColorPanel.
|
|
/// changeColor(_:)`'s own case-insensitive compare) 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(
|
|
Palette.nsColor(for: entry.name),
|
|
"palette name '\(entry.name)' did not resolve"
|
|
)
|
|
let byHex = try #require(
|
|
NSColor(paletteHex: entry.hex),
|
|
"palette hex '\(entry.hex)' for '\(entry.name)' is not parseable"
|
|
)
|
|
#expect(byName == byHex, "'\(entry.name)' resolved to something other than \(entry.hex)")
|
|
}
|
|
}
|
|
|
|
/// The three names appearing in both tables are the same colour in each — `obsidian`,
|
|
/// `aluminum` and `chalk` are one colour with one hex, listed twice because both *pickers*
|
|
/// offer them. Resolution searches foregrounds first, so a drift would make the same written
|
|
/// name mean two different things depending on which field it landed in.
|
|
@Test func namesSharedByBothTablesCarryOneHex() {
|
|
for name in ["obsidian", "aluminum", "chalk"] {
|
|
let foreground = Palette.foregrounds.first { $0.name == name }?.hex
|
|
let background = Palette.backgrounds.first { $0.name == name }?.hex
|
|
#expect(foreground != nil && foreground == background, "'\(name)' differs between the two tables")
|
|
}
|
|
}
|
|
|
|
/// Both tables answer either field: the foreground/background split is what each picker
|
|
/// offers, not a namespace (`Palette.nsColor(for:)`). A hand-written `background: carnation`
|
|
/// — a name only the icon-tint table lists — must resolve, not read as garbage.
|
|
@Test func aNameFromEitherTableResolvesRegardlessOfWhichFieldItCameFrom() {
|
|
#expect(Palette.nsColor(for: "carnation") != nil) // foregrounds only
|
|
#expect(Palette.nsColor(for: "intense-cool-shale") != nil) // backgrounds only
|
|
}
|
|
}
|
|
|
|
// MARK: - Name matching
|
|
|
|
struct PaletteNameMatchingTests {
|
|
|
|
/// Names are matched **exactly**, kebab-case as the tables spell them. A near-miss degrades
|
|
/// like any other unknown value rather than being guessed at — the same posture `ItemSymbol`
|
|
/// takes towards a typo'd symbol name.
|
|
@Test func nameMatchingIsCaseSensitiveAndExact() {
|
|
#expect(Palette.nsColor(for: "deep-sky-blue") != nil)
|
|
#expect(Palette.nsColor(for: "Deep-Sky-Blue") == nil)
|
|
#expect(Palette.nsColor(for: "DEEP-SKY-BLUE") == nil)
|
|
#expect(Palette.nsColor(for: "deep sky blue") == nil)
|
|
#expect(Palette.nsColor(for: "deepskyblue") == nil)
|
|
#expect(Palette.nsColor(for: " fern") == nil)
|
|
#expect(Palette.nsColor(for: "fern ") == nil)
|
|
}
|
|
|
|
/// `color(for:)` folds all three `FieldValue` shapes into one answer, exactly as
|
|
/// `ItemSymbol.name(_:fallback:)` does: to a renderer, a missing key, a malformed one, and a
|
|
/// valid-but-unknown name are one case — *there is no colour, so use your default*.
|
|
@Test func everyUnusableFieldShapeReadsAsNoColour() {
|
|
#expect(Palette.color(for: .valid("fern")) != nil)
|
|
#expect(Palette.color(for: .valid("#FF0000")) != nil)
|
|
#expect(Palette.color(for: FieldValue<String>.missing) == nil)
|
|
#expect(Palette.color(for: .malformed(raw: "[a, b]")) == nil)
|
|
#expect(Palette.color(for: .valid("chartreuse")) == nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Hex parsing
|
|
|
|
/// `#RRGGBB[AA]` in **sRGB** — the colour space the digits name, so a value sampled from a
|
|
/// screenshot renders as the colour the author sampled.
|
|
struct PaletteHexTests {
|
|
|
|
private func components(_ hex: String) throws -> (CGFloat, CGFloat, CGFloat, CGFloat) {
|
|
let color = try #require(NSColor(paletteHex: hex), "'\(hex)' did not parse")
|
|
let srgb = try #require(color.usingColorSpace(.sRGB), "'\(hex)' is not sRGB-convertible")
|
|
return (srgb.redComponent, srgb.greenComponent, srgb.blueComponent, srgb.alphaComponent)
|
|
}
|
|
|
|
private func expectComponents(
|
|
_ hex: String,
|
|
_ expected: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)
|
|
) throws {
|
|
let (red, green, blue, alpha) = try components(hex)
|
|
let tolerance: CGFloat = 0.001
|
|
#expect(abs(red - expected.red) < tolerance, "\(hex): red \(red) != \(expected.red)")
|
|
#expect(abs(green - expected.green) < tolerance, "\(hex): green \(green) != \(expected.green)")
|
|
#expect(abs(blue - expected.blue) < tolerance, "\(hex): blue \(blue) != \(expected.blue)")
|
|
#expect(abs(alpha - expected.alpha) < tolerance, "\(hex): alpha \(alpha) != \(expected.alpha)")
|
|
}
|
|
|
|
@Test func sixDigitHexParsesToItsSRGBComponentsAtFullOpacity() throws {
|
|
try expectComponents("#FF0000", (red: 1, green: 0, blue: 0, alpha: 1))
|
|
try expectComponents("#00FF00", (red: 0, green: 1, blue: 0, alpha: 1))
|
|
try expectComponents("#0000FF", (red: 0, green: 0, blue: 1, alpha: 1))
|
|
try expectComponents("#000000", (red: 0, green: 0, blue: 0, alpha: 1))
|
|
try expectComponents("#FFFFFF", (red: 1, green: 1, blue: 1, alpha: 1))
|
|
// The rich-board fixture's board background — an ordinary hand-written value.
|
|
try expectComponents("#1E1E1E", (red: 30 / 255, green: 30 / 255, blue: 30 / 255, alpha: 1))
|
|
}
|
|
|
|
@Test func eightDigitHexParsesItsTrailingPairAsAlpha() throws {
|
|
try expectComponents("#FF000080", (red: 1, green: 0, blue: 0, alpha: 128 / 255))
|
|
try expectComponents("#00FF00FF", (red: 0, green: 1, blue: 0, alpha: 1))
|
|
try expectComponents("#0000FF00", (red: 0, green: 0, blue: 1, alpha: 0))
|
|
}
|
|
|
|
/// Hex digits are case-insensitive — unlike palette *names*. Two different vocabularies with
|
|
/// two different rules, deliberately: `#ff0000` is the same number as `#FF0000`, where
|
|
/// `Fern` is simply not a name the palette has.
|
|
@Test func hexDigitsAreCaseInsensitive() throws {
|
|
let lower = try #require(NSColor(paletteHex: "#a1b2c3"))
|
|
let upper = try #require(NSColor(paletteHex: "#A1B2C3"))
|
|
#expect(lower == upper)
|
|
}
|
|
|
|
/// Garbage resolves to nothing — no throw, no default colour, no partial read of a truncated
|
|
/// value. Every one of these leaves the bytes on disk untouched and the surface undecorated.
|
|
@Test func malformedAndUnknownValuesResolveToNil() {
|
|
for value in [
|
|
"", // the empty string
|
|
"#", // a lone marker
|
|
"#12", // too short
|
|
"#12345", // five digits
|
|
"#1234567", // seven — neither RGB nor RGBA
|
|
"#123456789", // nine
|
|
"#GGGGGG", // right length, not hex
|
|
"#12345G", // one bad digit
|
|
"not-a-color", // kebab-case, but not a name the palette has
|
|
"FF0000", // hex digits without the '#' read as a name, and no name matches
|
|
"rgb(255,0,0)", // a different colour vocabulary entirely
|
|
] {
|
|
#expect(Palette.nsColor(for: value) == nil, "'\(value)' should not resolve")
|
|
#expect(Palette.color(named: value) == nil, "'\(value)' should not resolve to a SwiftUI Color")
|
|
}
|
|
}
|
|
}
|