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:
2026-07-29 10:20:16 -04:00
parent 8564814754
commit 92a088fdd3
6 changed files with 1061 additions and 5 deletions
+15 -4
View File
@@ -380,10 +380,21 @@ struct BoardView: View {
///
/// Unlike the lane band and the card stripe this one is a **fill**, because at board level that
/// is what the design asks for and it is why the board is the level 10-accessibility.md binds
/// its 4.5:1 rule to: text does sit on it. That runtime contrast computation (a hex background's
/// text colour, recomputed against the composited backdrop on appearance change) is not this
/// card's what ships here is the palette path, whose twelve pairs are AA-verified at design
/// time.
/// its 4.5:1 rule to: text does sit on it.
///
/// The rule is enforced from the *text* side rather than here, because this view paints the
/// surface and draws none of the glyphs on it. Whatever colour lands below a palette name or a
/// hand-written hex, they reach the same place has its text colour computed against the
/// threshold by `BoardTextInk`, composited over the window background in the active appearance
/// and recomputed on an appearance flip; the two subtrees that sit on this fill
/// (`LaneView.header` and `TrashLaneView.header` every other surface on the board carries its
/// own opaque plate) take the answer as a `\.colorScheme` override.
///
/// **One path, two verification stories** (`ContrastMath`): the twelve palette pairs are checked
/// statically, by a test over the ink this seam chooses for each of them (03-board-ui.md §
/// Styling Controls' "AA-verified at design time", which is a claim about the *pair* and so
/// cannot be settled by a table of colours alone); an arbitrary hex is checked only as it
/// renders, because its value arrives from a file.
///
/// A value that resolves to nothing paints nothing, so the window keeps the standard background:
/// the same lenient degrade as the other two levels, and the bytes stay as written.
+48
View File
@@ -74,6 +74,14 @@ struct LaneView: View {
/// what "increased" does to a stroke.
@Environment(\.colorSchemeContrast) private var contrast
/// The window's appearance the input to 10-accessibility.md's runtime-contrast rule, and the
/// answer for every board whose background is not a hand-written hex (`headerInk`).
///
/// Read in `body` rather than resolved once, which is what makes "recomputed on appearance
/// change" free: a light/dark flip re-evaluates this view, and the decision below is taken again
/// against the colours of the appearance the window is now in.
@Environment(\.colorScheme) private var colorScheme
/// The live body metric every figure this lane lays out on is a multiple of it
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule).
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@@ -185,6 +193,24 @@ struct LaneView: View {
}
.onDisappear { drops.registry.removeHeader(lane.id) }
.overlay(alignment: .trailing) { newCardButton }
// **10-accessibility.md's 4.5:1 rule, at the one place on the board where text sits on
// a colour the user chose** (`BoardTextInk`) palette name and hand-written hex alike,
// since the ink they need is the same question and only the *verification* differs
// (`ContrastMath`).
//
// A lane draws *no plate*: its background is the selection wash, which is `.clear` at
// rest (`selectionBackground`), so the title, the icon, the count badge and the rename
// field all land directly on the board's `background` the surface the design binds the
// threshold to ("text does sit on it", `BoardView.boardBackground`). The card faces below
// are a different matter and deliberately untouched: they carry their own opaque plate
// (`CardFaceView`'s `.background.secondary`), so their titles never see the board colour.
//
// **Placed here, not at the end of the chain**, which is the modifier order doing real
// work: everything above including the new-card button in the overlay is text on the
// board background and takes the computed ink, while the context menu and the Style
// popover attached below stay in the window's own appearance, because a menu is system
// chrome drawn on its own surface and not on this board's colour.
.boardTextInk(headerInk)
.contextMenu { laneMenu }
// **The context menu's plain rows, additionally as custom actions** "where SwiftUI
// additionally surfaces menu items as custom accessibility actions, that's free
@@ -202,6 +228,21 @@ struct LaneView: View {
}
}
/// Which appearance's label vocabulary the header's text is drawn in the window's own, unless
/// the **board** paints a background whose composited surface fails 4.5:1 against it
/// (10-accessibility.md Text scaling & visual accommodations; `BoardTextInk`).
///
/// It is not only the exotic case: ten of the twelve palette wells are dark colours, and every
/// one of them needs the dark appearance's label in a *light* window. A board styled entirely
/// from the in-app grid reaches this line as often as a hand-written one does.
///
/// It reads the *board*'s background and never this lane's, which is 03-board-ui.md Styling's
/// C7 ruling showing up as arithmetic: a lane's colour is an edge band, not a fill, so it is
/// never behind this text and carries no contrast obligation (`accentBand`).
private var headerInk: ColorScheme {
BoardTextInk.scheme(forBoardBackground: store.snapshot.background, appearance: colorScheme)
}
/// The lane's colour as C7 "a lane's color paints a full-width band along its top edge; the
/// surfaces themselves keep the standard chrome, so colored title text never sits on a colored
/// fill" (03-board-ui.md § Styling Capabilities, settled in the pathfinder's treatment
@@ -498,6 +539,13 @@ struct LaneView: View {
}
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
// **Back to the window's own appearance**, undoing `header`'s runtime-contrast override for
// this one subtree (`boardTextInk`). The preview is attached inside that modifier and would
// otherwise inherit it but the replica is not text on the board background: it draws its
// own opaque plate (`replicaFace`) and floats over whatever the cursor is above, which during
// a cross-window drag is another board entirely. The rule's premise is a user-chosen surface
// behind the glyphs, and here there is none.
.boardTextInk(colorScheme)
}
private var draggedLaneCount: Int {
+23
View File
@@ -96,6 +96,10 @@ struct TrashLaneView: View {
/// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// The window's appearance 10-accessibility.md's runtime-contrast input, read in `body` so a
/// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason).
@Environment(\.colorScheme) private var colorScheme
/// The live body metric this column's geometry is `LaneView`'s, derived from the same font
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
/// sibling.
@@ -226,6 +230,25 @@ struct TrashLaneView: View {
))
}
}
// **The board's computed contrast ink** (10-accessibility.md; `BoardTextInk`) the trash
// header is the second and last place on the board where text sits on the user's colour,
// whether that colour came from the palette grid or from a hand-written hex.
//
// The wash between the text and the board background does not save it: `.quaternary` at 50%
// is a ~5%-alpha tint (`Accommodations.trashHeaderWash`), so the glyphs are effectively on
// the board colour, and the decision is deliberately taken against that colour rather than
// against a three-layer composite modelling a tint that shifts the surface by a couple of
// luminance points would add a whole layer of arithmetic to move no decision.
//
// **Outside the `.background`, unlike the lane's**, so the wash and the hatch flip with the
// text: both are drawn in `.quaternary` the same label vocabulary and a hatch left in the
// window's appearance over an inverted board is a dark texture on a dark surface, which
// would quietly cost the trash the *non-colour* distinction 10-accessibility.md's
// "state is never colour-alone" requires of it.
.boardTextInk(BoardTextInk.scheme(
forBoardBackground: store.snapshot.background,
appearance: colorScheme
))
// The container carries the label and the count (see `body`), so the header itself is
// decoration for VoiceOver rather than a second element saying the same thing.
.accessibilityHidden(true)
+413
View File
@@ -0,0 +1,413 @@
import AppKit
import SwiftUI
/// **WCAG contrast, as arithmetic** the seam behind 10-accessibility.md's one hard numeric
/// accessibility rule: "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)."
///
/// ### Why a separate type rather than a computed property on a view
///
/// `Accommodations`' reason, doubled. A contrast decision made inside `body` is a decision no test
/// can hold still: the inputs would be an environment and a `NSColor` catalog lookup, and the output
/// would be an opaque `AnyShapeStyle`. So the *arithmetic* lives here four plain numbers in, one
/// number or one `ColorScheme` out and `BoardTextInk` below is the thin layer that asks AppKit for
/// the two colours the arithmetic needs. Everything in this enum is a pure function of its
/// arguments; nothing in it renders, reads the environment, or touches the main actor.
///
/// ### One code path, two verification stories
///
/// 03-board-ui.md Styling Controls describes the palette and the hand-written hex as if they were
/// two mechanisms "every pair AA-verified at design time the claim pinned by a computed-contrast
/// unit test over all 12 pairs", against "custom hex is not pickable in-app but stays fully honored
/// from disk (runtime contrast)". They are not. **A pair is a background and the ink its text is
/// drawn in, and nothing can pair them but the code that renders them**: a design-time table of
/// twelve colours cannot choose an ink, and every one of the twelve needs a *different* ink in the
/// two appearances (`chalk` is unreadable under a dark label, `obsidian` under a light one).
///
/// So both paths run this arithmetic, and the difference is entirely in **who checks the answer**:
///
/// - **The palette is verified statically.** `PaletteContrastTests` asserts that the ink this seam
/// *chooses* clears 4.5:1 for all twelve backgrounds in both appearances. That test **is** the
/// design-time verification 03 promises a new well whose colour could not be read in one
/// appearance fails the suite instead of shipping.
/// - **Hand-written hex is verified at runtime, or not at all.** The value is whatever the author
/// typed into the file, so there is no table to check in advance; the same computation runs, and
/// `InkChoice.meetsAA` reports honestly when a colour has no readable ink at all (see `inkChoice`).
///
/// Which is why nothing below branches on provenance. `BoardTextInk` takes *any* resolved board
/// colour, palette or hex.
enum ContrastMath {
/// The threshold, from 10-accessibility.md: ** 4.5:1**, WCAG AA for body text.
///
/// Named rather than inlined because two different call sites compare against it the decision
/// below and the tests that pin it and a rule with two spellings is a rule that can drift.
static let aaThreshold: Double = 4.5
// MARK: - Relative luminance
/// A single sRGB channel, linearised WCAG 2.x's transfer function, which is *not* a plain
/// gamma curve: below the knee the encoding is linear (the `/ 12.92` leg), above it the usual
/// 2.4 power with the 0.055 offset.
///
/// Both legs matter for the board. Hand-written backgrounds cluster at the extremes a
/// near-black `#0A0A0A` is an ordinary thing to type and those land squarely on the linear leg,
/// where a naive `pow(c, 2.2)` is wrong by enough to flip a borderline decision.
private static func linearised(_ channel: Double) -> Double {
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
/// **Relative luminance** WCAG's L, in [0, 1]: black is 0, white is 1, and mid-grey `#808080`
/// is 0.2159 rather than 0.5, which is the whole reason this is computed rather than eyeballed.
///
/// Alpha is ignored, deliberately: luminance is only defined for an opaque colour. A translucent
/// one has no luminance until it is `composite`d over something, and asking for it directly is a
/// question with no answer every caller here composites first (see `inkChoice`).
static func relativeLuminance(of color: SRGBColor) -> Double {
0.2126 * linearised(color.red)
+ 0.7152 * linearised(color.green)
+ 0.0722 * linearised(color.blue)
}
/// The **contrast ratio** between two opaque colours: `(Lʟ + 0.05) / (L + 0.05)`, in [1, 21].
///
/// Symmetric by construction the lighter of the two always takes the numerator so a caller
/// never has to know which of "text" and "background" it is holding.
static func contrastRatio(_ one: SRGBColor, _ other: SRGBColor) -> Double {
let a = relativeLuminance(of: one)
let b = relativeLuminance(of: other)
return (max(a, b) + 0.05) / (min(a, b) + 0.05)
}
// MARK: - Alpha compositing
/// **Source-over compositing** with straight (non-premultiplied) alpha what
/// 10-accessibility.md means by "computes against the color composited over its effective
/// backdrop".
///
/// An `#RRGGBBAA` board background is not a colour until something is behind it, and *what* is
/// behind it is appearance-dependent (`NSColor.windowBackgroundColor` is near-white in Aqua and
/// near-black in Dark Aqua), which is why the design says "light and dark resolve differently"
/// and why the decision has to be recomputed when the appearance flips rather than cached.
///
/// The general form is written out rather than assuming an opaque backdrop, because the backdrop
/// is a caller's value: composing a wash over a wash has to stay meaningful if a second
/// translucent layer ever lands between the board colour and the window.
static func composite(_ source: SRGBColor, over backdrop: SRGBColor) -> SRGBColor {
let outputAlpha = source.alpha + backdrop.alpha * (1 - source.alpha)
// A fully transparent result has no colour to report the components would be a division by
// zero. Nothing on the board composites two invisible layers, but the function has to be
// total to be a seam.
guard outputAlpha > 0 else { return SRGBColor(red: 0, green: 0, blue: 0, alpha: 0) }
// The two weights are the same for every channel; hoisting them out keeps the three lines
// below reading as one formula rather than three.
let sourceWeight = source.alpha
let backdropWeight = backdrop.alpha * (1 - source.alpha)
return SRGBColor(
red: (source.red * sourceWeight + backdrop.red * backdropWeight) / outputAlpha,
green: (source.green * sourceWeight + backdrop.green * backdropWeight) / outputAlpha,
blue: (source.blue * sourceWeight + backdrop.blue * backdropWeight) / outputAlpha,
alpha: outputAlpha
)
}
// MARK: - The decision
/// The two inks a text surface can be drawn in the label colour as it resolves in each
/// appearance, which is exactly what overriding a subtree's `\.colorScheme` selects between.
///
/// They carry **alpha**, and that is not a detail to be normalised away: `NSColor.labelColor` is
/// 85% black in Aqua and 85% white in Dark Aqua, so the ink that actually lands on the board is
/// the label *composited over the board colour*, several points of contrast short of pure black
/// or pure white. Measuring the pure colour would let a background pass this check and still be
/// hard to read, which is the failure mode the rule exists to prevent.
struct Ink: Equatable, Sendable {
/// The ink of the **light** appearance dark glyphs.
var light: SRGBColor
/// The ink of the **dark** appearance light glyphs.
var dark: SRGBColor
subscript(scheme: ColorScheme) -> SRGBColor {
scheme == .dark ? dark : light
}
}
/// A decision, with the number it was made on.
///
/// The ratio rides along because `meetsAA` is not the whole answer: when neither ink clears the
/// threshold the app still has to draw *something*, and a caller (a test, a future diagnostic)
/// deserves to know it is looking at the best of two bad options rather than at a pass.
struct InkChoice: Equatable, Sendable {
/// The appearance whose label vocabulary the text should be drawn in.
var scheme: ColorScheme
/// The contrast ratio that choice achieves against the composited surface.
var ratio: Double
var meetsAA: Bool { ratio >= ContrastMath.aaThreshold }
}
/// **The rule**, in one function: given a board colour, the backdrop it is painted over, the two
/// candidate inks and the appearance the window is actually in, pick the ink the text should use.
///
/// Three cases, in order:
///
/// 1. **Both inks clear 4.5:1** the **appearance-native** one. The board should look like the
/// rest of the system wherever it can afford to; flipping to light glyphs in Aqua when dark
/// ones are already legible would be a gratuitous redesign of the user's board.
/// 2. **Exactly one clears it** that one, appearance notwithstanding. This is the case the card
/// exists for: a `background: #101010` board in the light appearance needs light glyphs, and
/// a `#F5F5DC` board in the dark appearance needs dark ones.
/// 3. **Neither clears it** the **higher-ratio** one, and `meetsAA` is `false`.
///
/// Case 3 is a real outcome, not a defensive branch. The inks are 85%-alpha labels, so a
/// hand-written mid-grey `#6E6E6E` and its neighbours tops out in the low fours against
/// *both* of them: there is no text colour in the system's vocabulary that meets AA on it. The
/// app's answer is the most legible thing available rather than a refusal to paint, because the
/// alternative rulings are worse: overriding the user's colour would break "the bytes on disk are
/// left exactly as written" (`Palette`), and inventing an off-vocabulary ink for one board would
/// make that board's text stop matching every other window in the app. **The value is honoured
/// and the text is as readable as it can be made** the same lenient-never-an-error posture
/// every other styling decision here takes.
///
/// Ties go to the native appearance, which keeps the function total and stable: two inks that
/// score identically are, by definition, equally legible.
static func inkChoice(
background: SRGBColor,
backdrop: SRGBColor,
ink: Ink,
native: ColorScheme
) -> InkChoice {
// The surface the glyphs actually land on. Opaque whenever the backdrop is, which the window
// background always is an `#RRGGBBAA` board colour is resolved here and nowhere else.
let surface = composite(background, over: backdrop)
let alternate: ColorScheme = native == .dark ? .light : .dark
let nativeChoice = InkChoice(scheme: native, ratio: ratio(of: ink[native], on: surface))
let alternateChoice = InkChoice(scheme: alternate, ratio: ratio(of: ink[alternate], on: surface))
if nativeChoice.meetsAA { return nativeChoice }
if alternateChoice.meetsAA { return alternateChoice }
return alternateChoice.ratio > nativeChoice.ratio ? alternateChoice : nativeChoice
}
/// One ink's ratio against a surface composited first, because the label colours are
/// translucent (see `Ink`).
static func ratio(of ink: SRGBColor, on surface: SRGBColor) -> Double {
contrastRatio(composite(ink, over: surface), surface)
}
}
// MARK: - A colour as four numbers
/// An sRGB colour with **straight alpha**, as four `Double`s the currency `ContrastMath` works in.
///
/// It exists so the arithmetic can be pure and `Sendable`: `NSColor` is a device-and-appearance
/// dependent catalog lookup, and a contrast rule expressed over it would be a rule that only holds
/// on the machine that ran it. sRGB is also the colour space `background:` hex is *written* in
/// (`NSColor.init(paletteHex:)` "the colour space the hex digits name, so a value hand-written
/// from a screenshot or a design tool renders as the same colour the author sampled"), so the
/// conversion at the boundary is a no-op for the values this file most cares about.
struct SRGBColor: Equatable, Sendable {
var red: Double
var green: Double
var blue: Double
var alpha: Double
init(red: Double, green: Double, blue: Double, alpha: Double = 1) {
self.red = red
self.green = green
self.blue = blue
self.alpha = alpha
}
/// `nil` when the colour cannot be expressed in sRGB at all a pattern or catalog colour with no
/// component representation. Every caller treats that the way `Palette` treats an unreadable
/// value: there is no colour, so fall back rather than guess.
init?(_ color: NSColor) {
guard let srgb = color.usingColorSpace(.sRGB) else { return nil }
self.init(
red: Double(srgb.redComponent),
green: Double(srgb.greenComponent),
blue: Double(srgb.blueComponent),
alpha: Double(srgb.alphaComponent)
)
}
}
// MARK: - The board's application of the rule
/// The board's side of 10-accessibility.md's contrast rule: **which label vocabulary the text sitting
/// on a user-chosen `background:` colour should be drawn in**, resolved against the live appearance.
///
/// It takes the board's `background` field and nothing else about where the value came from. A
/// palette name and a hand-written hex reach the same arithmetic, because the ink they need is the
/// same question see `ContrastMath`'s "one code path, two verification stories".
///
/// ### Why a `ColorScheme` and not a `Color`
///
/// The text on the board background is not one label. A lane header is a title, an icon, a count
/// badge on a `.quaternary` capsule and mid-rename a plain text field, and they are drawn in the
/// *hierarchy* (`.primary`, `.secondary`, `.quaternary`) rather than in named colours. Handing one
/// resolved `Color` to that subtree would flatten the hierarchy at every site that took it and leave
/// every site that didn't in the wrong ink.
///
/// Overriding `\.colorScheme` for the subtree moves the whole vocabulary at once, coherently, and
/// costs the call sites one modifier. It is also what the decision *is*: the two candidate inks are
/// the two appearances' label colours, so choosing an ink and choosing an appearance are the same
/// choice (`ContrastMath.Ink`).
///
/// ### Why it is recomputed rather than cached
///
/// Both inputs move. `NSColor.windowBackgroundColor` and `NSColor.labelColor` are catalog colours
/// that resolve differently per appearance *and* under Increase Contrast, and 10-accessibility.md
/// requires the answer to be "recomputed on appearance change". Reading `\.colorScheme` in `body` and
/// calling this is what makes that free: SwiftUI re-evaluates on the flip, and the resolution below
/// happens inside the appearance being asked about rather than whatever is current.
@MainActor
enum BoardTextInk {
/// The scheme a board's text should be drawn in, given its `background` field and the appearance
/// the window is in.
///
/// **Returns `appearance` unchanged exactly when the board paints no background** a missing
/// key, a malformed value, a name no table holds. Those paint nothing at all
/// (`BoardView.boardBackground`), so the surface behind the text is the system's own and the
/// system's own answer is the right one.
///
/// Everything the board *does* paint is decided here, palette name and hand-written hex alike.
/// That is not a widening of the hand-written case; it is the only way 03-board-ui.md's
/// "every pair AA-verified at design time" can be true of a running app. The pair is a background
/// and an ink, and with the appearance-native label which is what the board drew before this
/// **all twelve palette backgrounds fail 4.5:1 in one of the two appearances**: the ten dark
/// wells under Aqua's near-black label (`smokey-ocean` at 1.58:1, `obsidian` at 1.00:1), and
/// `chalk` and `aluminum` under Dark Aqua's near-white one (1.00:1 and 2.44:1). Selecting the ink
/// is what verifies the pair, and `PaletteContrastTests` is where the verification is kept.
static func scheme(forBoardBackground background: FieldValue<String>, appearance: ColorScheme) -> ColorScheme {
choice(forBoardBackground: background, appearance: appearance)?.scheme ?? appearance
}
/// `scheme(forBoardBackground:appearance:)` with its reasoning attached, or `nil` when the rule
/// does not apply. Split out so the decision including the ratio it was made on, and whether
/// that ratio actually cleared AA is inspectable from a test without a rendered view.
static func choice(
forBoardBackground background: FieldValue<String>,
appearance: ColorScheme
) -> ContrastMath.InkChoice? {
guard let color = paintedColor(background) else { return nil }
return ContrastMath.inkChoice(
background: color,
backdrop: windowBackdrop(in: appearance),
ink: labelInk(),
native: appearance
)
}
/// **The colour the board actually paints**, or `nil` when it paints none a palette name, a
/// `#RRGGBB[AA]` hex, or nothing at all.
///
/// Deliberately `Palette.nsColor(for:)` and not a second, narrower parser. That function is the
/// single source for what a `background` value means (`Palette` "the split is what each
/// *picker* offers, not a namespace"), so routing through it is what makes this predicate
/// identical to `BoardView.boardBackground`'s `if let`: **the ink is decided for exactly the
/// surfaces that get painted, and for no others.** Two spellings of "is there a colour here"
/// could disagree; one cannot.
///
/// The lenient degrade rides along for free a typo'd name and a malformed hex both answer
/// `nil`, which is a rendering instruction and never an error (`Palette`'s leading comment).
static func paintedColor(_ background: FieldValue<String>) -> SRGBColor? {
guard let value = background.value else { return nil }
return Palette.nsColor(for: value).flatMap(SRGBColor.init)
}
/// What an `#RRGGBBAA` board colour is painted over: the window's own background, in the
/// appearance asked for "the board's over the window background; light and dark resolve
/// differently" (10-accessibility.md).
///
/// It is the right backdrop because it is the literal one: `BoardView` paints its background on
/// the window's content, with nothing between (`BoardView.boardBackground`).
static func windowBackdrop(in appearance: ColorScheme) -> SRGBColor {
resolve(.windowBackgroundColor, in: appearance) ?? Self.fallbackBackdrop[appearance]
}
/// The two candidate inks `NSColor.labelColor` as it resolves in each appearance, which is what
/// `.primary` draws as on either side of the override.
///
/// Asked of AppKit rather than written down as constants so the app follows the system: under
/// Increase Contrast the label colours go to full strength, and a hardcoded 85% would then be
/// measuring an ink the app is no longer drawing.
static func labelInk() -> ContrastMath.Ink {
ContrastMath.Ink(
light: resolve(.labelColor, in: .light) ?? Self.fallbackInk.light,
dark: resolve(.labelColor, in: .dark) ?? Self.fallbackInk.dark
)
}
// MARK: - Appearance-scoped resolution
/// Resolves a dynamic colour **inside** an appearance rather than whatever happens to be current.
///
/// This is the whole reason the rule can be stated per-appearance at all: `NSColor.labelColor` is
/// a catalog lookup, and asking it for components outside a drawing appearance answers for the
/// app's current one which, for the light half of the decision made in a dark window, is the
/// wrong answer twice over.
///
/// **Increase Contrast is folded in** because it changes these very colours: the high-contrast
/// appearances take the label to full strength and the window background further from mid-grey,
/// and measuring the standard pair while drawing the accessible one would understate the app's
/// actual contrast (`Accommodations.prefersIncreasedContrast`, whose constituency this joins
/// there is no view environment here to read).
private static func resolve(_ color: NSColor, in scheme: ColorScheme) -> SRGBColor? {
guard let appearance = NSAppearance(named: appearanceName(for: scheme)) else { return nil }
var resolved: SRGBColor?
appearance.performAsCurrentDrawingAppearance { resolved = SRGBColor(color) }
return resolved
}
private static func appearanceName(for scheme: ColorScheme) -> NSAppearance.Name {
switch (scheme, Accommodations.prefersIncreasedContrast) {
case (.dark, true): .accessibilityHighContrastDarkAqua
case (.dark, false): .darkAqua
case (_, true): .accessibilityHighContrastAqua
case (_, false): .aqua
}
}
// MARK: - Fallbacks
/// Standing in for a resolution that failed which should not happen, and must not crash or
/// silently measure black-on-black if it does.
///
/// The numbers are the system's own at the time of writing (`labelColor` is 85% black / 85%
/// white; `windowBackgroundColor` is a light and a dark near-neutral), so a fallback decision is
/// the decision the live colours would have produced rather than an arbitrary one.
private static let fallbackInk = ContrastMath.Ink(
light: SRGBColor(red: 0, green: 0, blue: 0, alpha: 0.85),
dark: SRGBColor(red: 1, green: 1, blue: 1, alpha: 0.85)
)
private static let fallbackBackdrop = ContrastMath.Ink(
light: SRGBColor(red: 0.925, green: 0.925, blue: 0.925),
dark: SRGBColor(red: 0.196, green: 0.196, blue: 0.196)
)
}
// MARK: - The call sites' one modifier
extension View {
/// Draws this subtree's text in `scheme`'s label vocabulary the render-site half of
/// 10-accessibility.md's runtime-contrast rule (`BoardTextInk`).
///
/// A no-op when `scheme` is the appearance the window is already in which is the answer for
/// every board that paints no background at all, and for every coloured one whose native label is
/// already legible on it. So the modifier goes on unconditionally and the sites stay free of a
/// branch.
///
/// **Scoped by where it sits in the modifier chain**, deliberately: a subtree is only "text on
/// the board background" up to the first opaque plate. Put it *inside* the modifier that draws a
/// plate and the plate keeps the window's appearance; put it outside and the plate flips with the
/// text. Both are used on the board see `LaneView.header` and `TrashLaneView.header`.
func boardTextInk(_ scheme: ColorScheme) -> some View {
environment(\.colorScheme, scheme)
}
}
+561
View File
@@ -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")
}
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is left alone, and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption.
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent, and a card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent, and a card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled.
## Development