Runtime contrast for hand-written hex backgrounds
DESIGN/10's ≥ 4.5:1 rule gets its owner. ContrastMath is the pure seam: WCAG relative luminance (piecewise sRGB linearization), symmetric contrast ratio, source-over compositing (an #RRGGBBAA board colour resolves over the window background of the active appearance), and inkChoice — native label if it clears AA, else the other appearance's, else the higher ratio with meetsAA false (a mid-grey hex can max out below 4.5 against both 85%-alpha labels; the app paints the best available rather than overriding the user's colour). BoardTextInk is the board's application: the decision is a ColorScheme, not a Color — the text on the board fill is a hierarchy (.primary/.secondary/ .quaternary), and overriding the subtree's scheme moves the whole vocabulary coherently. Recomputed on appearance change by construction (read in body); label/backdrop colours resolve inside the asked-for appearance, Increase Contrast variants included. Two render sites — the only board text that sits on the user's colour: the lane header (lanes draw no plate; title, icon, badge, rename field and the + button land directly on the board fill) and the trash header (its wash is ~5% effective alpha). Menus, popovers, and drag replicas deliberately stay native; card faces carry their own opaque plates. The card's premise fell during implementation: 03's "palette pairs AA-verified at design time, pinned by a computed-contrast unit test" was false — no such test existed, and the m4 path drew the native label, failing AA in one appearance for all twelve wells (obsidian in Light Mode: 1.0:1). Palette names now route through the same ink selection (paintedColor delegates to Palette.nsColor — one predicate with BoardView's paint decision), and PaletteContrastTests pins that the chosen ink clears AA for every well in both appearances — plus theNativeLabelIsNeverEnough, which would have failed on the m4 code. Filed on the Redesign board for ratification. 1579 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user