Files
lanework/KanbanTests/ContrastMathTests.swift
T
rzen ece33bbf78 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
2026-08-09 09:44:30 -04:00

566 lines
30 KiB
Swift

import AppKit
import SwiftUI
import Testing
@testable import Kanban
/// **10-accessibility.md's one hard number**, as arithmetic a suite can hold still: "The ≥ 4.5:1
/// automatic-contrast rule binds where text does sit on a user-chosen color: the **board**
/// background (palette pairs verified at design time; arbitrary hex computes its text color at
/// runtime against that threshold). An `#RRGGBBAA` background with alpha computes against the color
/// **composited over its effective backdrop** in the active appearance."
///
/// Three layers, tested at three altitudes:
///
/// 1. **`ContrastMath`** — WCAG relative luminance, contrast ratio and source-over compositing, pure
/// functions of four numbers. Pinned against the standard's own published values, because the
/// whole point of citing WCAG is that the numbers are not ours to choose.
/// 2. **The decision** — which of the two label vocabularies the text takes, including the case the
/// design's threshold cannot be met by either, which is a real outcome for a mid-grey hex and not
/// a defensive branch.
/// 3. **`BoardTextInk`** — the board's application: that the rule binds to *every* colour the board
/// paints (palette name and hand-written hex alike, one path), that a translucent one resolves
/// differently in the two appearances, and that a board painting nothing is left alone.
///
/// The palette's own suite closes the loop. 03-board-ui.md ▸ Styling ▸ Controls promises the palette
/// wells are "AA-verified at design time … pinned by a computed-contrast unit test over all
/// pairs"; a pair is a background *and its ink*, so the promise is only keepable by checking the ink
/// the seam chooses — which `PaletteContrastTests` does, in both appearances. That is the whole of
/// the difference between the two paths: the palette is a fixed set and can be checked in advance,
/// a hand-written hex arrives from a file and can only be computed as it renders.
///
/// The candidate inks are **injected** into the pure layer rather than read from AppKit, so every
/// branch is reachable and no assertion depends on the exact alpha Apple ships `labelColor` at this
/// year. The live colours are exercised separately, in claims that stay true whatever those values
/// are.
// MARK: - Relative luminance
@Suite("Contrast ▸ WCAG relative luminance")
struct RelativeLuminanceTests {
private func luminance(_ hex: String) throws -> Double {
let color = try #require(NSColor(paletteHex: hex), "'\(hex)' did not parse")
let srgb = try #require(SRGBColor(color), "'\(hex)' is not sRGB-convertible")
return ContrastMath.relativeLuminance(of: srgb)
}
/// The three values every implementation of this formula is checked against. `#808080` is the
/// interesting one: the channel is 0.502, and the answer is 0.216 rather than 0.5 — the gap
/// between "half the bits" and "half the light" is exactly what makes eyeballing contrast
/// unreliable and this function necessary.
@Test("White is 1, black is 0, mid-grey is 0.2159")
func theStandardsPublishedValues() throws {
#expect(abs(try luminance("#FFFFFF") - 1) < 0.0001)
#expect(abs(try luminance("#000000") - 0) < 0.0001)
#expect(abs(try luminance("#808080") - 0.2159) < 0.0005)
}
/// The channel weights, isolated: a pure primary's luminance *is* its coefficient, since the
/// other two channels linearise to zero and the primary linearises to one. A transposed pair
/// (green and red are the easy ones to swap) would sail past every grey test above.
@Test("Each primary weighs its WCAG coefficient")
func theChannelWeights() throws {
#expect(abs(try luminance("#FF0000") - 0.2126) < 0.0001)
#expect(abs(try luminance("#00FF00") - 0.7152) < 0.0001)
#expect(abs(try luminance("#0000FF") - 0.0722) < 0.0001)
}
/// **The linear leg below the knee** — WCAG's transfer function is a piecewise curve, and a
/// `pow(c, 2.2)` shortcut gets the near-blacks wrong. `#050505` is 0.0196, under the 0.03928
/// threshold, so the answer is a plain division by 12.92; the shortcut would say ~0.00019, an
/// eightfold error on exactly the values a dark hand-written board background lands on.
@Test("Near-black uses the linear leg of the transfer function")
func theTransferFunctionsLinearLeg() throws {
#expect(abs(try luminance("#050505") - (5.0 / 255.0) / 12.92) < 0.000001)
}
/// Monotone: a lighter colour has a higher luminance. Cheap, and it catches a sign or an inverted
/// branch that the three fixed points above could conceivably straddle.
@Test("Luminance rises with lightness")
func luminanceIsMonotone() throws {
let ramp = ["#000000", "#202020", "#404040", "#808080", "#C0C0C0", "#FFFFFF"]
let values = try ramp.map { try luminance($0) }
#expect(values == values.sorted())
#expect(Set(values).count == ramp.count)
}
}
// MARK: - Contrast ratio
@Suite("Contrast ▸ ratio")
struct ContrastRatioTests {
private func color(_ hex: String) throws -> SRGBColor {
try #require(NSColor(paletteHex: hex).flatMap(SRGBColor.init), "'\(hex)' did not parse")
}
/// The range's two ends: 21:1 is the maximum the formula can produce, 1:1 is a colour against
/// itself.
@Test("Black on white is 21:1 and a colour on itself is 1:1")
func theRangesEnds() throws {
let white = try color("#FFFFFF")
let black = try color("#000000")
#expect(abs(ContrastMath.contrastRatio(white, black) - 21) < 0.001)
#expect(abs(ContrastMath.contrastRatio(white, white) - 1) < 0.0001)
#expect(abs(ContrastMath.contrastRatio(black, black) - 1) < 0.0001)
}
/// Symmetric — the lighter colour always takes the numerator, so no caller has to know which
/// argument is the text and which is the surface.
@Test("The ratio is symmetric in its arguments")
func theRatioIsSymmetric() throws {
let a = try color("#1F2E45")
let b = try color("#D5D5D5")
#expect(ContrastMath.contrastRatio(a, b) == ContrastMath.contrastRatio(b, a))
}
/// The threshold is the design's, spelled once (10-accessibility.md).
@Test("The AA threshold is 4.5")
func theThresholdIsTheDesigns() {
#expect(ContrastMath.aaThreshold == 4.5)
}
}
// MARK: - Alpha compositing
@Suite("Contrast ▸ source-over compositing")
struct CompositeTests {
private let white = SRGBColor(red: 1, green: 1, blue: 1)
private let black = SRGBColor(red: 0, green: 0, blue: 0)
@Test("A fully opaque source replaces the backdrop, a fully transparent one vanishes")
func theTwoDegenerateAlphas() {
let red = SRGBColor(red: 1, green: 0, blue: 0)
#expect(ContrastMath.composite(red, over: white) == red)
#expect(ContrastMath.composite(SRGBColor(red: 1, green: 0, blue: 0, alpha: 0), over: white) == white)
}
/// Half of a colour and half of what is behind it — and the arithmetic that says the *same*
/// translucent value is two different colours over two different backdrops, which is the whole
/// reason 10-accessibility.md asks for the composite rather than the written value.
@Test("A half-alpha source lands halfway to its backdrop")
func aHalfAlphaSourceMeetsItsBackdropHalfway() {
let halfBlack = SRGBColor(red: 0, green: 0, blue: 0, alpha: 0.5)
let overWhite = ContrastMath.composite(halfBlack, over: white)
let overBlack = ContrastMath.composite(halfBlack, over: black)
#expect(abs(overWhite.red - 0.5) < 0.0001)
#expect(abs(overBlack.red - 0) < 0.0001)
#expect(overWhite.alpha == 1)
}
/// Straight (non-premultiplied) alpha, with a **translucent backdrop** — the general form. The
/// board never composites two washes today, but a seam that only worked against an opaque
/// backdrop would be one special case pretending to be a rule.
@Test("Two translucent layers combine their alphas")
func aTranslucentBackdropCombines() {
let source = SRGBColor(red: 1, green: 0, blue: 0, alpha: 0.5)
let backdrop = SRGBColor(red: 0, green: 0, blue: 1, alpha: 0.5)
let result = ContrastMath.composite(source, over: backdrop)
#expect(abs(result.alpha - 0.75) < 0.0001)
// 0.5 of the source over 0.25 of the backdrop, renormalised by the 0.75 output alpha.
#expect(abs(result.red - (0.5 / 0.75)) < 0.0001)
#expect(abs(result.blue - (0.25 / 0.75)) < 0.0001)
}
/// Total rather than crashing: two invisible layers have no colour, and the seam has to say so
/// without dividing by zero.
@Test("Compositing nothing over nothing stays transparent")
func theFullyTransparentCase() {
let nothing = SRGBColor(red: 1, green: 1, blue: 1, alpha: 0)
#expect(ContrastMath.composite(nothing, over: nothing).alpha == 0)
}
}
// MARK: - The decision
@Suite("Contrast ▸ which ink the text takes")
struct InkChoiceTests {
/// The system's inks as of writing — 85% black and 85% white (`NSColor.labelColor`). Written
/// down here rather than resolved so the branch each case exercises is the branch it claims to;
/// the live values are checked separately in `BoardTextInkTests`.
private let labels = ContrastMath.Ink(
light: SRGBColor(red: 0, green: 0, blue: 0, alpha: 0.85),
dark: SRGBColor(red: 1, green: 1, blue: 1, alpha: 0.85)
)
/// Full-strength ink, which is what Increase Contrast moves the labels towards — and the only
/// way to reach the both-candidates-pass branch, since two 85% labels never both clear 4.5:1.
private let opaqueLabels = ContrastMath.Ink(
light: SRGBColor(red: 0, green: 0, blue: 0),
dark: SRGBColor(red: 1, green: 1, blue: 1)
)
private let opaque = SRGBColor(red: 0.5, green: 0.5, blue: 0.5)
private func hex(_ value: String) throws -> SRGBColor {
try #require(NSColor(paletteHex: value).flatMap(SRGBColor.init), "'\(value)' did not parse")
}
/// **The card's case, from the light side.** A near-black hand-written background in a light
/// window: the native ink is dark glyphs on a dark surface, which is unreadable, so the decision
/// crosses to the dark appearance's ink and the header renders in light glyphs.
@Test("A dark hex in the light appearance takes the dark appearance's ink")
func aDarkBackgroundFlipsTheLightAppearance() throws {
let choice = ContrastMath.inkChoice(
background: try hex("#101010"),
backdrop: try hex("#ECECEC"),
ink: labels,
native: .light
)
#expect(choice.scheme == .dark)
#expect(choice.meetsAA)
}
/// The same case from the other side — a pale background in a dark window.
@Test("A light hex in the dark appearance takes the light appearance's ink")
func aLightBackgroundFlipsTheDarkAppearance() throws {
let choice = ContrastMath.inkChoice(
background: try hex("#F5F5DC"),
backdrop: try hex("#1E1E1E"),
ink: labels,
native: .dark
)
#expect(choice.scheme == .light)
#expect(choice.meetsAA)
}
/// The rule's *quiet* half: when the native ink already clears the threshold, nothing moves. A
/// dark board in a dark window is the common case, and a decision that flipped it anyway would
/// be a redesign rather than an accommodation.
@Test("A background the native ink already reads on is left alone")
func theNativeInkIsKeptWhenItPasses() throws {
let dark = ContrastMath.inkChoice(
background: try hex("#101010"),
backdrop: try hex("#1E1E1E"),
ink: labels,
native: .dark
)
#expect(dark.scheme == .dark)
#expect(dark.meetsAA)
let light = ContrastMath.inkChoice(
background: try hex("#F5F5DC"),
backdrop: try hex("#ECECEC"),
ink: labels,
native: .light
)
#expect(light.scheme == .light)
#expect(light.meetsAA)
}
/// **Both candidates pass → the appearance-native one wins**, in each direction.
///
/// Reachable only with full-strength ink (see `opaqueLabels`), and only in a sliver: pure black
/// clears 4.5:1 above L = 0.175 and pure white clears it below L = 0.1833, so the window where
/// both pass is eight thousandths of a luminance wide. `#767676` (channel 0.4603) sits in the
/// middle of it at L ≈ 0.179, where both score ≈ 4.58. Two 85%-alpha labels never both clear the
/// threshold at all, which is why this case needs the opaque pair to exist — and why the rule is
/// stated anyway: Increase Contrast is exactly what moves the system's labels here.
@Test("When both inks clear the threshold the native one is preferred")
func bothPassingPrefersTheNativeAppearance() {
let crossover = SRGBColor(red: 0.4603, green: 0.4603, blue: 0.4603)
let asLight = ContrastMath.inkChoice(
background: crossover, backdrop: opaque, ink: opaqueLabels, native: .light
)
let asDark = ContrastMath.inkChoice(
background: crossover, backdrop: opaque, ink: opaqueLabels, native: .dark
)
#expect(asLight.meetsAA && asDark.meetsAA, "the crossover surface should clear 4.5:1 both ways")
#expect(asLight.scheme == .light)
#expect(asDark.scheme == .dark)
}
/// **The documented fallback.** A hand-written mid-grey has no readable ink in the system's
/// vocabulary: 85%-alpha labels top out in the low fours against `#6E6E6E` in *both*
/// appearances. The rule is to paint the better of the two anyway and report the miss — the
/// alternatives being to override the user's colour (which "the bytes stay as written" forbids)
/// or to invent an ink no other window in the app uses.
@Test("When neither ink clears the threshold the higher-ratio one is used")
func neitherPassingTakesTheHigherRatio() throws {
let grey = try hex("#6E6E6E")
for native in [ColorScheme.light, .dark] {
let choice = ContrastMath.inkChoice(
background: grey, backdrop: opaque, ink: labels, native: native
)
#expect(!choice.meetsAA, "\(native) unexpectedly cleared AA on #6E6E6E")
// The answer is the same whichever appearance asked: with neither passing, the decision
// is the arithmetic's alone.
#expect(choice.scheme == .dark)
let rejected = ContrastMath.ratio(of: labels.light, on: grey)
#expect(choice.ratio > rejected)
}
}
/// **The threshold is a boundary, not a region.** Walking a grey ramp, the decision's `meetsAA`
/// flag must agree with the ratio it reports on every step — a decision that claimed a pass at
/// 4.49 or a miss at 4.51 would make the design's number decorative.
@Test("meetsAA agrees with the reported ratio at the boundary")
func theThresholdIsExact() {
for step in 0...255 {
let level = Double(step) / 255
let surface = SRGBColor(red: level, green: level, blue: level)
let choice = ContrastMath.inkChoice(
background: surface, backdrop: opaque, ink: labels, native: .light
)
#expect(choice.meetsAA == (choice.ratio >= ContrastMath.aaThreshold))
// And the chosen ink is never worse than the one passed over.
let other: ColorScheme = choice.scheme == .dark ? .light : .dark
let rejected = ContrastMath.ratio(of: labels[other], on: surface)
#expect(choice.ratio >= rejected || choice.meetsAA)
}
}
/// **Alpha is what makes the appearance matter** — 10-accessibility.md's "light and dark resolve
/// differently", as two decisions that disagree about the same frontmatter value.
///
/// `background: #00000080` is a mid-grey over the light window background and a near-black over
/// the dark one, and the two surfaces are not merely different shades: the light one lands in the
/// dead zone where *no* label ink clears AA (≈ 4.1:1 at best), while the dark one clears it four
/// times over. Composite against the wrong appearance's backdrop and this board is reported as
/// fine when it is not, or as unreadable when it is.
@Test("A translucent hex resolves against the appearance's own backdrop")
func alphaMakesTheAppearanceDecisive() throws {
let translucent = try hex("#00000080")
let inLight = ContrastMath.inkChoice(
background: translucent, backdrop: try hex("#ECECEC"), ink: labels, native: .light
)
let inDark = ContrastMath.inkChoice(
background: translucent, backdrop: try hex("#1E1E1E"), ink: labels, native: .dark
)
#expect(inLight.scheme == .light)
#expect(!inLight.meetsAA, "half-black over the light window background is the mid-grey dead zone")
#expect(inDark.scheme == .dark)
#expect(inDark.meetsAA)
#expect(inDark.ratio > inLight.ratio * 2)
}
/// The backdrop **flipping a decision on its own**: `#FFFFFF80` is a pale surface in either
/// appearance, because half of white is still lighter than the dark window background. So the
/// dark appearance's native ink fails on it and the header renders in dark glyphs inside a dark
/// window — a board the composite gets right and the written value alone gets backwards (`#FFFFFF`
/// at 50% would read as "half transparent, so leave it alone").
@Test("A pale translucent hex forces dark glyphs even in a dark window")
func aPaleTranslucentHexFlipsTheDarkAppearance() throws {
let translucent = try hex("#FFFFFF80")
let inDark = ContrastMath.inkChoice(
background: translucent, backdrop: try hex("#1E1E1E"), ink: labels, native: .dark
)
#expect(inDark.scheme == .light)
#expect(inDark.meetsAA)
}
/// The backdrop is *only* consulted where there is alpha to resolve: an opaque `#RRGGBB` value
/// covers the window background completely, so the two appearances see the same surface and
/// reach the same ink.
@Test("An opaque hex ignores its backdrop")
func anOpaqueHexIsBackdropIndependent() throws {
let value = try hex("#005152")
let overLight = ContrastMath.inkChoice(
background: value, backdrop: try hex("#ECECEC"), ink: labels, native: .light
)
let overDark = ContrastMath.inkChoice(
background: value, backdrop: try hex("#1E1E1E"), ink: labels, native: .light
)
#expect(overLight == overDark)
}
}
// MARK: - The board's application
@Suite("Contrast ▸ the board's painted background")
@MainActor
struct BoardTextInkTests {
/// **The predicate is "does the board paint anything", not "where did the value come from"** —
/// the same `if let` `BoardView.boardBackground` takes, routed through the same
/// `Palette.nsColor(for:)`. A palette name and a hex both name a surface the text has to be
/// legible on; two spellings of the question could disagree about a value, and one cannot.
@Test("Every value the board paints has a colour, and nothing else does")
func whatCountsAsAPaintedBackground() {
#expect(BoardTextInk.paintedColor(.valid("#1E1E1E")) != nil)
#expect(BoardTextInk.paintedColor(.valid("#1E1E1E80")) != nil)
#expect(BoardTextInk.paintedColor(.valid("#1e1e1e")) != nil)
#expect(BoardTextInk.paintedColor(.valid("smokey-ocean")) != nil)
#expect(BoardTextInk.paintedColor(.valid("chalk")) != nil)
// A foreground-table name in the `background` field resolves, because the two-table split is a
// picker split and not a namespace (`Palette`) — so it paints, so it decides an ink.
#expect(BoardTextInk.paintedColor(.valid("carnation")) != nil)
#expect(BoardTextInk.paintedColor(.valid("#12345")) == nil)
#expect(BoardTextInk.paintedColor(.valid("FF0000")) == nil)
#expect(BoardTextInk.paintedColor(.valid("chartreuse")) == nil)
#expect(BoardTextInk.paintedColor(FieldValue<String>.missing) == nil)
#expect(BoardTextInk.paintedColor(.malformed(raw: "[a, b]")) == nil)
}
/// Only a board that paints **nothing** keeps the window's own appearance — the empty key, the
/// malformed value, the typo. The surface is then the system's own, and the system's answer is
/// the right one.
@Test("A missing key, a malformed value and a typo leave the appearance alone")
func anUnpaintedBoardIsLeftAlone() {
for field: FieldValue<String> in [.missing, .malformed(raw: "[a, b]"), .valid("chartreuse")] {
#expect(BoardTextInk.scheme(forBoardBackground: field, appearance: .light) == .light)
#expect(BoardTextInk.scheme(forBoardBackground: field, appearance: .dark) == .dark)
#expect(BoardTextInk.choice(forBoardBackground: field, appearance: .light) == nil)
}
}
/// A **palette name** goes through the same door as a hex — which is the fix this card closed.
/// `smokey-ocean` is a near-black navy: under Aqua's own label it scored 1.58:1, and the board
/// now renders it in light glyphs instead.
@Test("A palette background decides an ink like any other painted colour")
func aPaletteNameRoutesThroughTheSameRule() throws {
let choice = try #require(
BoardTextInk.choice(forBoardBackground: .valid("smokey-ocean"), appearance: .light)
)
#expect(choice.scheme == .dark)
#expect(choice.meetsAA)
#expect(BoardTextInk.scheme(forBoardBackground: .valid("chalk"), appearance: .dark) == .light)
}
/// The end-to-end claim, against the **live** system colours rather than written-down ones: a
/// near-black board reads in light glyphs and a near-white one in dark glyphs, in either
/// appearance. Whatever alpha Apple ships `labelColor` at, these two must hold — they are the
/// reason the card exists.
@Test("The extremes resolve to the readable ink in both appearances")
func theExtremesResolveCorrectly() {
for appearance in [ColorScheme.light, .dark] {
#expect(BoardTextInk.scheme(forBoardBackground: .valid("#050505"), appearance: appearance) == .dark)
#expect(BoardTextInk.scheme(forBoardBackground: .valid("#FAFAFA"), appearance: appearance) == .light)
}
}
/// `#00000080` over the two window backgrounds — the design's own worked example, through the
/// live `NSColor.windowBackgroundColor` in each appearance. The value on disk is one string; the
/// surface it makes is not, and the ink follows the surface.
@Test("A translucent board colour resolves against the appearance's window background")
func theBackdropIsResolvedInTheActiveAppearance() {
let halfBlack = FieldValue<String>.valid("#00000080")
#expect(BoardTextInk.scheme(forBoardBackground: halfBlack, appearance: .light) == .light)
#expect(BoardTextInk.scheme(forBoardBackground: halfBlack, appearance: .dark) == .dark)
// The same value over the *pale* half of a light window is a mid-grey; over the dark half it
// is nearly black. Two surfaces, so two luminances.
let light = BoardTextInk.windowBackdrop(in: .light)
let dark = BoardTextInk.windowBackdrop(in: .dark)
#expect(ContrastMath.relativeLuminance(of: light) > ContrastMath.relativeLuminance(of: dark))
#expect(light.alpha == 1 && dark.alpha == 1, "the window background must be opaque to be a backdrop")
}
/// The live inks, as a sanity claim that survives any future tuning of the system palette: the
/// light appearance's label is the darker of the two, and each reads on its own appearance's
/// window background.
@Test("The live label inks are dark-on-light and light-on-dark")
func theLiveInksAreOrientedCorrectly() {
let ink = BoardTextInk.labelInk()
let light = ContrastMath.composite(ink.light, over: BoardTextInk.windowBackdrop(in: .light))
let dark = ContrastMath.composite(ink.dark, over: BoardTextInk.windowBackdrop(in: .dark))
#expect(ContrastMath.relativeLuminance(of: light) < ContrastMath.relativeLuminance(of: dark))
#expect(ContrastMath.ratio(of: ink.light, on: BoardTextInk.windowBackdrop(in: .light)) > 4.5)
#expect(ContrastMath.ratio(of: ink.dark, on: BoardTextInk.windowBackdrop(in: .dark)) > 4.5)
}
/// The fixture board's hand-written background (`#1E1E1E`, `PaletteHexTests`' own example) —
/// the value a real board on disk carries, decided end to end.
@Test("The rich fixture's hand-written background reads in light glyphs")
func theFixturesBackgroundResolves() throws {
let choice = try #require(
BoardTextInk.choice(forBoardBackground: .valid("#1E1E1E"), appearance: .light)
)
#expect(choice.scheme == .dark)
#expect(choice.meetsAA)
}
}
// MARK: - The palette's AA claim, made true
@Suite("Contrast ▸ the palette's AA claim")
@MainActor
struct PaletteContrastTests {
/// **03-board-ui.md ▸ Styling ▸ Controls' promise, computed — and this test *is* the promise**:
/// "the background grid offers the palette colors — every pair AA-verified at design time
/// (10-accessibility.md), the claim pinned by a computed-contrast unit test over all pairs so
/// palette drift can never silently break it."
///
/// **This is also the gate the palette grew through.** Four backgrounds were added on 2026-08-09;
/// the reason that was a safe thing to do is that a new well with no readable ink fails here
/// rather than shipping, which is exactly the first failure mode named below.
///
/// A **pair** is a background and the ink its text is drawn in, so the claim cannot be settled by
/// a table of colours alone — only by the code that chooses the ink. What is asserted here is
/// therefore end to end and in both appearances: for every well, the scheme `BoardTextInk`
/// *selects* clears 4.5:1 on the colour that well paints. Nothing weaker would be the design's
/// claim; nothing stronger is true, since no single ink reads on all of them.
///
/// Two ways to fail, both of them the point. A **new well** whose colour has no readable ink at
/// all — a mid-grey, the dead zone `InkChoiceTests.neitherPassingTakesTheHigherRatio` documents —
/// fails here instead of shipping. And a regression in the *selection* fails here too: with the
/// appearance-native label, which is what the board drew before this card, every one of the
/// wells failed in one appearance (every dark well under Aqua, `chalk` and `aluminum` under Dark
/// Aqua), so this test would have caught the m4 bug it now guards against returning.
@Test("The ink the board picks clears 4.5:1 on every palette background, in both appearances")
func everyPaletteBackgroundHasAReadableInk() throws {
let ink = BoardTextInk.labelInk()
for entry in Palette.backgrounds {
let color = try #require(
NSColor(paletteHex: entry.hex).flatMap(SRGBColor.init),
"palette background '\(entry.name)' did not parse"
)
for appearance in [ColorScheme.light, .dark] {
let choice = try #require(
BoardTextInk.choice(forBoardBackground: .valid(entry.name), appearance: appearance),
"palette background '\(entry.name)' decided no ink"
)
// The decision is taken on the very colour the well paints — a name that resolved to
// something else would make every ratio below a measurement of the wrong surface.
#expect(BoardTextInk.paintedColor(.valid(entry.name)) == color)
#expect(
choice.meetsAA,
"""
'\(entry.name)' (\(entry.hex)) in the \(appearance) appearance: the chosen \
\(choice.scheme) ink reaches only \(choice.ratio):1
"""
)
// And the ink that was chosen is the one that scores — not merely a passing ink that
// some other branch would have picked.
#expect(abs(ContrastMath.ratio(of: ink[choice.scheme], on: color) - choice.ratio) < 0.0001)
}
}
}
/// The other half of "verified at design time": **the appearance-native label is not enough**,
/// which is why the selection has to happen at all.
///
/// Every well is a colour the system's own label fails on in one of the two
/// appearances — the dark ones under Aqua, `chalk` and `aluminum` under Dark Aqua. Stating
/// it as a test keeps the reasoning from decaying into folklore: if a future palette were tame
/// enough that the native label always worked, this would fail and the seam's board-side wiring
/// could be reconsidered rather than carried on faith.
@Test("No palette background is readable under the appearance-native label in both appearances")
func theNativeLabelIsNeverEnough() throws {
for entry in Palette.backgrounds {
let color = try #require(NSColor(paletteHex: entry.hex).flatMap(SRGBColor.init))
let ink = BoardTextInk.labelInk()
let native = [ColorScheme.light, .dark].filter {
ContrastMath.ratio(of: ink[$0], on: color) >= ContrastMath.aaThreshold
}
#expect(
native.count == 1,
"'\(entry.name)' reads under \(native.count) native labels — the palette has changed shape"
)
}
}
/// Every well is opaque, which is why the design can verify them at all: a palette well with
/// alpha would make its own contrast a function of the appearance's window background, and
/// "verified at design time" would stop being a statement anyone could check.
@Test("No palette background carries alpha")
func thePaletteIsOpaque() throws {
for entry in Palette.backgrounds {
let color = try #require(NSColor(paletteHex: entry.hex).flatMap(SRGBColor.init))
#expect(color.alpha == 1, "'\(entry.name)' is translucent")
}
}
}