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)
}
}