The colour combo was a wide two-zone field with a second door onto the Colors panel; the symbol picker was a small square button with one. Both now subclass one `ComboFieldControl`, so they are the same width, height, radius and trigger by construction: click the face for the standalone picker, click the chevron for the quick list. The symbol face opens a new floating browser over the OS's own category, ordering and keyword plists out of CoreGlyphs.bundle — searchable, categorised, trademark-restricted glyphs withheld. The palette grows twelve to sixteen per table, filling the hue ring's four widest gaps with lime, jade, indigo and magenta at each table's own saturation and brightness. That gives the Style… popover's background grid a third row and the tint grid its third row of four, and both grids gain an Other… row onto the system colour picker — which the card sidebar's combo has had all along and the primary styling surface never did. An arbitrary hex already round-tripped; it is asserted now, including that an unquoted one is a YAML comment and no value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
215 lines
11 KiB
Swift
215 lines
11 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 (`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(
|
|
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")
|
|
}
|
|
}
|
|
}
|