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

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

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

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent ca0328be2e
commit ece33bbf78
17 changed files with 2030 additions and 322 deletions
+55 -5
View File
@@ -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(