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
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user