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