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.