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
+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 {