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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user