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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user