Files
lanework/KanbanTests/ContrastMathTests.swift
rzen 92a088fdd3 Runtime contrast for hand-written hex backgrounds
DESIGN/10's ≥ 4.5:1 rule gets its owner. ContrastMath is the pure seam:
WCAG relative luminance (piecewise sRGB linearization), symmetric
contrast ratio, source-over compositing (an #RRGGBBAA board colour
resolves over the window background of the active appearance), and
inkChoice — native label if it clears AA, else the other appearance's,
else the higher ratio with meetsAA false (a mid-grey hex can max out
below 4.5 against both 85%-alpha labels; the app paints the best
available rather than overriding the user's colour). BoardTextInk is
the board's application: the decision is a ColorScheme, not a Color —
the text on the board fill is a hierarchy (.primary/.secondary/
.quaternary), and overriding the subtree's scheme moves the whole
vocabulary coherently. Recomputed on appearance change by construction
(read in body); label/backdrop colours resolve inside the asked-for
appearance, Increase Contrast variants included.

Two render sites — the only board text that sits on the user's colour:
the lane header (lanes draw no plate; title, icon, badge, rename field
and the + button land directly on the board fill) and the trash header
(its wash is ~5% effective alpha). Menus, popovers, and drag replicas
deliberately stay native; card faces carry their own opaque plates.

The card's premise fell during implementation: 03's "palette pairs
AA-verified at design time, pinned by a computed-contrast unit test"
was false — no such test existed, and the m4 path drew the native label,
failing AA in one appearance for all twelve wells (obsidian in Light
Mode: 1.0:1). Palette names now route through the same ink selection
(paintedColor delegates to Palette.nsColor — one predicate with
BoardView's paint decision), and PaletteContrastTests pins that the
chosen ink clears AA for every well in both appearances — plus
theNativeLabelIsNeverEnough, which would have failed on the m4 code.
Filed on the Redesign board for ratification. 1579 unit tests green,
both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 10:20:16 -04:00

562 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 twelve
/// wells are "AA-verified at design time … pinned by a computed-contrast unit test over all 12
/// 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 12+12 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 12 palette colors — every pair AA-verified at design time
/// (10-accessibility.md), the claim pinned by a computed-contrast unit test over all 12 pairs so
/// palette drift can never silently break it."
///
/// 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 twelve.
///
/// 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
/// twelve failed in one appearance (ten dark wells 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 one of the twelve wells is a colour the system's own label fails on in one of the two
/// appearances — the ten 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"
)
}
}
/// The twelve are 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")
}
}
}