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